diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03666aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*/**/__pycache__/* +__pycache__/ + +.*/* +.*/ \ No newline at end of file diff --git a/README.md b/README.md index eba29ae..f457b83 100644 --- a/README.md +++ b/README.md @@ -1 +1,99 @@ -# SimpleRPC \ No newline at end of file +# SimpleRPC + +Simple framework to create your own gRPC server with grpcio. +### It's early version so api isn't stable! + +### Todo + +- [x] Exceptions handling +- [ ] Autoconfigure from pydantic model +- [ ] Simplify usage +- [ ] Tests and docs + +## Quick Start + +server.py +```python +from SimpleRPC import GrpcServer +from pydantic import BaseModel +import asyncio + +class RequestModel(BaseModel): + num1: int + +class ResponceModel(BaseModel): + num2: int + num4: int + +app = GrpcServer( + proto_filename = "proto.proto" +) + +class Server: + @app.grpc_method(inp_model=RequestModel, out_model=ResponceModel, out_proto_name="ResponceMsg") + async def Method(self, num1) -> ResponceModel: + return ResponceModel( + num2 = num1*2, + num4 = 99 + ) + +app.configure_service( + proto_service_name="Example", + cls = Server(), + port=50055 +) +app.run() +``` + +client.py +```python +from pydantic import BaseModel +from SimpleRPC import GrpcClient +import asyncio + +class ResponceModel(BaseModel): + num2: int + num4: int + +cli = GrpcClient( + port=50055 +) +command = cli.configure_command( + struct_name="RequestMsg", + func_name="Method", + service_name="Example", + responce_validation_model=ResponceModel +) + +async def run(): + print( + await command(num1 = 1) + ) + +if __name__ == "__main__": + asyncio.run( + run() + ) +``` + +proto.proto +```protobuf +syntax = "proto3"; + +service Example { + rpc Method(RequestMsg) returns (ResponceMsg) {} +} + +message RequestMsg { + int32 num1 = 1; +} + +message ResponceMsg { + int32 num2 = 1; + int32 num4 = 2; +} +``` + +## TODO + +1. Add documentation \ No newline at end of file diff --git a/SimpleRPC/__init__.py b/SimpleRPC/__init__.py new file mode 100644 index 0000000..895833e --- /dev/null +++ b/SimpleRPC/__init__.py @@ -0,0 +1,2 @@ +from .grpc_server_reduced import GrpcServer +from .grpc_client_reduced import GrpcClient \ No newline at end of file diff --git a/SimpleRPC/exceptions.py b/SimpleRPC/exceptions.py new file mode 100644 index 0000000..a164f08 --- /dev/null +++ b/SimpleRPC/exceptions.py @@ -0,0 +1,5 @@ +from dataclasses import dataclass + +@dataclass +class GRPCException(Exception): + details: str | None \ No newline at end of file diff --git a/SimpleRPC/grpc_client_reduced.py b/SimpleRPC/grpc_client_reduced.py new file mode 100644 index 0000000..cb3daf2 --- /dev/null +++ b/SimpleRPC/grpc_client_reduced.py @@ -0,0 +1,78 @@ +from pydantic import BaseModel +import grpc +from .exceptions import GRPCException + +class Command(): + def __init__( + self, + proto_pb2: object, + proto_pb2_grpc: object, + ip: str, + port: int, + struct_name: str, + func_name: str, + service_name: str, + responce_validation_model: type[BaseModel] | None = None + ): + self.proto_pb2 = proto_pb2 + self.proto_pb2_grpc = proto_pb2_grpc + self.ip = ip + self.port = port + self.struct_name = struct_name + self.func_name = func_name + self.service_name = service_name + self.responce_validation_model = responce_validation_model + + async def __call__(self, *args, **kwargs): + async with grpc.aio.insecure_channel(f"{self.ip}:{self.port}") as channel: + stub = getattr( + self.proto_pb2_grpc, f"{self.service_name}Stub" + )(channel=channel) + try: + _ = getattr( + self.proto_pb2, self.struct_name + )(*args, **kwargs) # request structure setter + + response = await getattr( + stub, self.func_name + )(_) # responce getter + + if self.responce_validation_model is not None: + return self.responce_validation_model.model_validate( + response, + from_attributes=True + ) + + return response + except grpc.aio.AioRpcError as rpc_error: + print(rpc_error.details()) + raise GRPCException(rpc_error.details()) + +class GrpcClient(): + def __init__( + self, + proto_filename: str = "proto.proto", + ip: str = "0.0.0.0", + port: int = 50051 + ) -> None: + self.proto_pb2, self.proto_pb2_grpc = grpc.protos_and_services(proto_filename) # noqa + self.ip = ip + self.port = port + + def configure_command( + self, + struct_name: str, + func_name: str, + service_name: str, + responce_validation_model: type[BaseModel] | None = None + ) -> Command: + return Command( + proto_pb2 = self.proto_pb2, + proto_pb2_grpc = self.proto_pb2_grpc, + ip = self.ip, + port = self.port, + struct_name = struct_name, + func_name = func_name, + service_name = service_name, + responce_validation_model = responce_validation_model + ) diff --git a/SimpleRPC/grpc_server_reduced.py b/SimpleRPC/grpc_server_reduced.py new file mode 100644 index 0000000..662d862 --- /dev/null +++ b/SimpleRPC/grpc_server_reduced.py @@ -0,0 +1,124 @@ +from pydantic import BaseModel +import grpc +from typing_extensions import Callable +import asyncio + +class GrpcServer(): + + # --- CONFIGURATION AND STARTUP PART --- + + def __init__(self, proto_filename: str = "proto.proto") -> None: + self.proto_pb2, self.proto_pb2_grpc = grpc.protos_and_services(proto_filename) # noqa + + def _register_servicer(self, proto_service_name) -> Callable: + service_name = f"{proto_service_name}Servicer" # Service grpc pseudonim + register_class = getattr( + self.proto_pb2_grpc, service_name + ) # Grpc service class + super(register_class) # init grpc service class + + return getattr( + self.proto_pb2_grpc, f"add_{service_name}_to_server" + ) # function, that adds class to grpc service class + + def _enable_service(self) -> type[grpc.aio.Server]: + server = grpc.aio.server() # async serveer init + server.add_insecure_port(self.adress) # INSECURE connection info + + self._register_servicer( + self.proto_service_name + )(self.cls, server) # proto service registration + return server # noqa + + def configure_service( + self, + cls, + proto_service_name: str, + ip: str = "0.0.0.0", + port: int = 50051 + ) -> None: + + self.adress = f"{ip}:{port}" + self.proto_service_name = proto_service_name + self.cls = cls + + def run(self): + loop = asyncio.get_event_loop() + try: + loop.run_until_complete(self.run_async()) + finally: + loop.close() + + async def run_async(self): + server = self._enable_service() + await server.start() + await server.wait_for_termination() + + # --- MAIN PART --- + + def grpc_method( + grpc_cls_self, # self analog + inp_model: type[BaseModel], # input pydantic model + out_model: type[BaseModel], # output pydantic model + out_proto_name: str # name of ouptut proto message + ) -> Callable: # megawrapper + ''' + gRPC decodator initializer + + Args: + inp_model (`Base Model`): + input pydantic model + out_model (`Base Model`): + output pydantic model + out_proto_name (str): + name of ouptut proto message + Returns: + Callable: + wrapped function + ''' + def wrap_grpc_serv(func) -> Callable: # wrapper + ''' + gRPC decorator + + Args: + func (`Callable`): + function + Returns: + Callable: + wrapped function + ''' + async def wrapper(self, request: object, context: grpc.aio.ServicerContext): + ''' + gRPC wrapper + + Args: + request: + gRPC parsed message + context (grpc.aio.ServicerContext): + gRPC context + Returns: + object: + gRPC parsed message + ''' + input_data = inp_model.model_validate( + request, + from_attributes=True + ) + try: + _ = await func(self, **input_data.model_dump()) + + # TODO: test this + if not isinstance(_, out_model): + raise ValueError("resp mismatch") + + out_proto = getattr( + grpc_cls_self.proto_pb2, out_proto_name + ) + return out_proto(**_.model_dump()) + except Exception as exc: + await context.abort( + code=grpc.StatusCode.INTERNAL, + details=f"{exc.__class__.__name__}: {exc.__str__()}" + ) + return wrapper + return wrap_grpc_serv diff --git a/examples/client.py b/examples/client.py new file mode 100644 index 0000000..e7a935b --- /dev/null +++ b/examples/client.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel +from SimpleRPC import GrpcClient +import asyncio + +class ResponceModel(BaseModel): + num2: int + num4: int + +cli = GrpcClient( + port=50055 +) +command = cli.configure_command( + struct_name="RequestMsg", + func_name="Method", + service_name="Example", + responce_validation_model=ResponceModel +) + +async def run(): + print( + await command(num1 = 1) + ) + +if __name__ == "__main__": + asyncio.run( + run() + ) \ No newline at end of file diff --git a/examples/proto.proto b/examples/proto.proto new file mode 100644 index 0000000..c2c76a0 --- /dev/null +++ b/examples/proto.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +service Example { + rpc Method(RequestMsg) returns (ResponceMsg) {} +} + +message RequestMsg { + int32 num1 = 1; +} + +message ResponceMsg { + int32 num2 = 1; + int32 num4 = 2; +} \ No newline at end of file diff --git a/examples/server.py b/examples/server.py new file mode 100644 index 0000000..a03981c --- /dev/null +++ b/examples/server.py @@ -0,0 +1,29 @@ +from SimpleRPC import GrpcServer +from pydantic import BaseModel +import asyncio + +class RequestModel(BaseModel): + num1: int + +class ResponceModel(BaseModel): + num2: int + num4: int + +app = GrpcServer( + proto_filename = "proto.proto" +) + +class Server: + @app.grpc_method(inp_model=RequestModel, out_model=ResponceModel, out_proto_name="ResponceMsg") + async def Method(self, num1) -> ResponceModel: + return ResponceModel( + num2 = num1*2, + num4 = 99 + ) + +app.configure_service( + proto_service_name="Example", + cls = Server(), + port=50055 +) +app.run() \ No newline at end of file