pydantic-rpc 0.10.0__py3-none-any.whl → 0.12.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pydantic_rpc/__init__.py +4 -0
- pydantic_rpc/core.py +103 -57
- pydantic_rpc/decorators.py +72 -1
- {pydantic_rpc-0.10.0.dist-info → pydantic_rpc-0.12.0.dist-info}/METADATA +154 -10
- {pydantic_rpc-0.10.0.dist-info → pydantic_rpc-0.12.0.dist-info}/RECORD +6 -7
- pydantic_rpc-0.10.0.dist-info/entry_points.txt +0 -3
- {pydantic_rpc-0.10.0.dist-info → pydantic_rpc-0.12.0.dist-info}/WHEEL +0 -0
pydantic_rpc/__init__.py
CHANGED
|
@@ -13,6 +13,8 @@ from .decorators import (
|
|
|
13
13
|
proto_option,
|
|
14
14
|
get_method_options,
|
|
15
15
|
has_http_option,
|
|
16
|
+
error_handler,
|
|
17
|
+
get_error_handlers,
|
|
16
18
|
)
|
|
17
19
|
from .tls import (
|
|
18
20
|
GrpcTLSConfig,
|
|
@@ -31,6 +33,8 @@ __all__ = [
|
|
|
31
33
|
"proto_option",
|
|
32
34
|
"get_method_options",
|
|
33
35
|
"has_http_option",
|
|
36
|
+
"error_handler",
|
|
37
|
+
"get_error_handlers",
|
|
34
38
|
"GrpcTLSConfig",
|
|
35
39
|
"extract_peer_identity",
|
|
36
40
|
"extract_peer_certificate_chain",
|
pydantic_rpc/core.py
CHANGED
|
@@ -27,7 +27,7 @@ import annotated_types
|
|
|
27
27
|
import grpc
|
|
28
28
|
from grpc import ServicerContext
|
|
29
29
|
import grpc_tools
|
|
30
|
-
from
|
|
30
|
+
from connectrpc.code import Code as Errors
|
|
31
31
|
|
|
32
32
|
# Protobuf Python modules for Timestamp, Duration (requires protobuf / grpcio)
|
|
33
33
|
from google.protobuf import duration_pb2, timestamp_pb2, empty_pb2
|
|
@@ -726,15 +726,15 @@ def connect_obj_with_stub_async(
|
|
|
726
726
|
return ConcreteServiceClass
|
|
727
727
|
|
|
728
728
|
|
|
729
|
-
def
|
|
730
|
-
|
|
729
|
+
def connect_obj_with_stub_connect_python(
|
|
730
|
+
connect_python_module: Any, pb2_module: Any, obj: object
|
|
731
731
|
) -> type:
|
|
732
732
|
"""
|
|
733
|
-
Connect a Python service object to a
|
|
733
|
+
Connect a Python service object to a Connect Python stub.
|
|
734
734
|
"""
|
|
735
735
|
service_class = obj.__class__
|
|
736
736
|
stub_class_name = service_class.__name__
|
|
737
|
-
stub_class = getattr(
|
|
737
|
+
stub_class = getattr(connect_python_module, stub_class_name)
|
|
738
738
|
|
|
739
739
|
class ConcreteServiceClass(stub_class):
|
|
740
740
|
pass
|
|
@@ -845,15 +845,15 @@ def connect_obj_with_stub_connecpy(
|
|
|
845
845
|
return ConcreteServiceClass
|
|
846
846
|
|
|
847
847
|
|
|
848
|
-
def
|
|
849
|
-
|
|
848
|
+
def connect_obj_with_stub_async_connect_python(
|
|
849
|
+
connect_python_module: Any, pb2_module: Any, obj: object
|
|
850
850
|
) -> type:
|
|
851
851
|
"""
|
|
852
|
-
Connect a Python service object to a
|
|
852
|
+
Connect a Python service object to a Connect Python stub for async methods with streaming support.
|
|
853
853
|
"""
|
|
854
854
|
service_class = obj.__class__
|
|
855
855
|
stub_class_name = service_class.__name__
|
|
856
|
-
stub_class = getattr(
|
|
856
|
+
stub_class = getattr(connect_python_module, stub_class_name)
|
|
857
857
|
|
|
858
858
|
class ConcreteServiceClass(stub_class):
|
|
859
859
|
pass
|
|
@@ -2030,10 +2030,10 @@ def generate_grpc_code(proto_path: Path) -> types.ModuleType | None:
|
|
|
2030
2030
|
return module
|
|
2031
2031
|
|
|
2032
2032
|
|
|
2033
|
-
def
|
|
2033
|
+
def generate_connect_python_code(proto_path: Path) -> types.ModuleType | None:
|
|
2034
2034
|
"""
|
|
2035
|
-
Run protoc with the
|
|
2036
|
-
Writes
|
|
2035
|
+
Run protoc with the Connect Python plugin to generate Python Connect code from proto_path.
|
|
2036
|
+
Writes foo_connect_python.py next to proto_path, then imports and returns that module.
|
|
2037
2037
|
"""
|
|
2038
2038
|
# 1) Ensure the .proto exists
|
|
2039
2039
|
if not proto_path.is_file():
|
|
@@ -2051,7 +2051,7 @@ def generate_connecpy_code(proto_path: Path) -> types.ModuleType | None:
|
|
|
2051
2051
|
"protoc", # Dummy program name (required for protoc.main)
|
|
2052
2052
|
"-I.",
|
|
2053
2053
|
f"-I{well_known_path}",
|
|
2054
|
-
f"--
|
|
2054
|
+
f"--connect-python_out={out_str}",
|
|
2055
2055
|
proto_path.name,
|
|
2056
2056
|
]
|
|
2057
2057
|
|
|
@@ -2065,7 +2065,7 @@ def generate_connecpy_code(proto_path: Path) -> types.ModuleType | None:
|
|
|
2065
2065
|
|
|
2066
2066
|
# 4) Locate the generated file
|
|
2067
2067
|
base_name = proto_path.stem # "foo"
|
|
2068
|
-
generated_filename = f"{base_name}
|
|
2068
|
+
generated_filename = f"{base_name}_connect.py" # "foo_connect.py"
|
|
2069
2069
|
generated_filepath = out_dir / generated_filename
|
|
2070
2070
|
|
|
2071
2071
|
# 5) Add out_dir to sys.path so we can import by filename
|
|
@@ -2074,7 +2074,7 @@ def generate_connecpy_code(proto_path: Path) -> types.ModuleType | None:
|
|
|
2074
2074
|
|
|
2075
2075
|
# 6) Load and return the module
|
|
2076
2076
|
spec = importlib.util.spec_from_file_location(
|
|
2077
|
-
base_name + "
|
|
2077
|
+
base_name + "_connect", str(generated_filepath)
|
|
2078
2078
|
)
|
|
2079
2079
|
if spec is None or spec.loader is None:
|
|
2080
2080
|
return None
|
|
@@ -2259,7 +2259,7 @@ def get_proto_path(proto_filename: str) -> Path:
|
|
|
2259
2259
|
return base / proto_filename
|
|
2260
2260
|
|
|
2261
2261
|
|
|
2262
|
-
def
|
|
2262
|
+
def generate_and_compile_proto_using_connect_python(
|
|
2263
2263
|
obj: object,
|
|
2264
2264
|
package_name: str = "",
|
|
2265
2265
|
existing_proto_path: Path | None = None,
|
|
@@ -2268,7 +2268,7 @@ def generate_and_compile_proto_using_connecpy(
|
|
|
2268
2268
|
import importlib
|
|
2269
2269
|
|
|
2270
2270
|
pb2_module = None
|
|
2271
|
-
|
|
2271
|
+
connect_python_module = None
|
|
2272
2272
|
|
|
2273
2273
|
try:
|
|
2274
2274
|
pb2_module = importlib.import_module(
|
|
@@ -2278,14 +2278,14 @@ def generate_and_compile_proto_using_connecpy(
|
|
|
2278
2278
|
pass
|
|
2279
2279
|
|
|
2280
2280
|
try:
|
|
2281
|
-
|
|
2282
|
-
f"{obj.__class__.__name__.lower()}
|
|
2281
|
+
connect_python_module = importlib.import_module(
|
|
2282
|
+
f"{obj.__class__.__name__.lower()}_connect"
|
|
2283
2283
|
)
|
|
2284
2284
|
except ImportError:
|
|
2285
2285
|
pass
|
|
2286
2286
|
|
|
2287
|
-
if
|
|
2288
|
-
return
|
|
2287
|
+
if connect_python_module is not None and pb2_module is not None:
|
|
2288
|
+
return connect_python_module, pb2_module
|
|
2289
2289
|
|
|
2290
2290
|
# If the modules are not found, generate and compile the proto files.
|
|
2291
2291
|
|
|
@@ -2306,10 +2306,10 @@ def generate_and_compile_proto_using_connecpy(
|
|
|
2306
2306
|
if gen_pb is None:
|
|
2307
2307
|
raise Exception("Generating pb code")
|
|
2308
2308
|
|
|
2309
|
-
|
|
2310
|
-
if
|
|
2311
|
-
raise Exception("Generating
|
|
2312
|
-
return
|
|
2309
|
+
gen_connect_python = generate_connect_python_code(proto_file_path)
|
|
2310
|
+
if gen_connect_python is None:
|
|
2311
|
+
raise Exception("Generating Connect Python code")
|
|
2312
|
+
return gen_connect_python, gen_pb
|
|
2313
2313
|
|
|
2314
2314
|
|
|
2315
2315
|
def is_combined_proto_enabled() -> bool:
|
|
@@ -2606,6 +2606,9 @@ class Server:
|
|
|
2606
2606
|
|
|
2607
2607
|
def __init__(
|
|
2608
2608
|
self,
|
|
2609
|
+
service: Optional[object] = None,
|
|
2610
|
+
port: int = 50051,
|
|
2611
|
+
package_name: str = "",
|
|
2609
2612
|
max_workers: int = 8,
|
|
2610
2613
|
*interceptors: Any,
|
|
2611
2614
|
tls: Optional["GrpcTLSConfig"] = None,
|
|
@@ -2614,9 +2617,10 @@ class Server:
|
|
|
2614
2617
|
futures.ThreadPoolExecutor(max_workers), interceptors=interceptors
|
|
2615
2618
|
)
|
|
2616
2619
|
self._service_names: list[str] = []
|
|
2617
|
-
self._package_name: str =
|
|
2618
|
-
self._port: int =
|
|
2620
|
+
self._package_name: str = package_name
|
|
2621
|
+
self._port: int = port
|
|
2619
2622
|
self._tls_config = tls
|
|
2623
|
+
self._initial_service = service
|
|
2620
2624
|
|
|
2621
2625
|
def set_package_name(self, package_name: str):
|
|
2622
2626
|
"""Set the package name for .proto generation."""
|
|
@@ -2654,6 +2658,11 @@ class Server:
|
|
|
2654
2658
|
Mount multiple services and run the gRPC server with reflection and health check.
|
|
2655
2659
|
Press Ctrl+C or send SIGTERM to stop.
|
|
2656
2660
|
"""
|
|
2661
|
+
# Mount initial service if provided
|
|
2662
|
+
if self._initial_service:
|
|
2663
|
+
self.mount(self._initial_service, self._package_name)
|
|
2664
|
+
|
|
2665
|
+
# Mount additional services
|
|
2657
2666
|
for obj in objs:
|
|
2658
2667
|
self.mount(obj, self._package_name)
|
|
2659
2668
|
|
|
@@ -2699,14 +2708,18 @@ class AsyncIOServer:
|
|
|
2699
2708
|
|
|
2700
2709
|
def __init__(
|
|
2701
2710
|
self,
|
|
2711
|
+
service: Optional[object] = None,
|
|
2712
|
+
port: int = 50051,
|
|
2713
|
+
package_name: str = "",
|
|
2702
2714
|
*interceptors: grpc.ServerInterceptor,
|
|
2703
2715
|
tls: Optional["GrpcTLSConfig"] = None,
|
|
2704
2716
|
) -> None:
|
|
2705
2717
|
self._server: grpc.aio.Server = grpc.aio.server(interceptors=interceptors)
|
|
2706
2718
|
self._service_names: list[str] = []
|
|
2707
|
-
self._package_name: str =
|
|
2708
|
-
self._port: int =
|
|
2719
|
+
self._package_name: str = package_name
|
|
2720
|
+
self._port: int = port
|
|
2709
2721
|
self._tls_config = tls
|
|
2722
|
+
self._initial_service = service
|
|
2710
2723
|
|
|
2711
2724
|
def set_package_name(self, package_name: str):
|
|
2712
2725
|
"""Set the package name for .proto generation."""
|
|
@@ -2746,6 +2759,11 @@ class AsyncIOServer:
|
|
|
2746
2759
|
Mount multiple async services and run the gRPC server with reflection and health check.
|
|
2747
2760
|
Press Ctrl+C or send SIGTERM to stop.
|
|
2748
2761
|
"""
|
|
2762
|
+
# Mount initial service if provided
|
|
2763
|
+
if self._initial_service:
|
|
2764
|
+
self.mount(self._initial_service, self._package_name)
|
|
2765
|
+
|
|
2766
|
+
# Mount additional services
|
|
2749
2767
|
for obj in objs:
|
|
2750
2768
|
self.mount(obj, self._package_name)
|
|
2751
2769
|
|
|
@@ -2786,46 +2804,53 @@ class AsyncIOServer:
|
|
|
2786
2804
|
await self._server.stop(10)
|
|
2787
2805
|
print("gRPC server shutdown.")
|
|
2788
2806
|
|
|
2807
|
+
async def stop(self, grace: float = 10.0):
|
|
2808
|
+
"""Stop the gRPC server gracefully."""
|
|
2809
|
+
await self._server.stop(grace)
|
|
2810
|
+
|
|
2789
2811
|
|
|
2790
|
-
def
|
|
2791
|
-
"""Get the ASGI application class from
|
|
2792
|
-
return getattr(
|
|
2812
|
+
def get_connect_python_asgi_app_class(connect_python_module: Any, service_name: str):
|
|
2813
|
+
"""Get the ASGI application class from connect-python module."""
|
|
2814
|
+
return getattr(connect_python_module, f"{service_name}ASGIApplication")
|
|
2793
2815
|
|
|
2794
2816
|
|
|
2795
|
-
def
|
|
2796
|
-
"""Get the WSGI application class from
|
|
2797
|
-
return getattr(
|
|
2817
|
+
def get_connect_python_wsgi_app_class(connect_python_module: Any, service_name: str):
|
|
2818
|
+
"""Get the WSGI application class from connect-python module."""
|
|
2819
|
+
return getattr(connect_python_module, f"{service_name}WSGIApplication")
|
|
2798
2820
|
|
|
2799
2821
|
|
|
2800
2822
|
class ASGIApp:
|
|
2801
2823
|
"""
|
|
2802
|
-
An ASGI-compatible application that can serve Connect-RPC via
|
|
2824
|
+
An ASGI-compatible application that can serve Connect-RPC via connect-python.
|
|
2803
2825
|
"""
|
|
2804
2826
|
|
|
2805
|
-
def __init__(self):
|
|
2827
|
+
def __init__(self, service: Optional[object] = None, package_name: str = ""):
|
|
2806
2828
|
self._services: list[tuple[Any, str]] = [] # List of (app, path) tuples
|
|
2807
2829
|
self._service_names: list[str] = []
|
|
2808
|
-
self._package_name: str =
|
|
2830
|
+
self._package_name: str = package_name
|
|
2831
|
+
self._initial_service = service
|
|
2809
2832
|
|
|
2810
2833
|
def mount(self, obj: object, package_name: str = ""):
|
|
2811
2834
|
"""Generate and compile proto files, then mount the async service implementation."""
|
|
2812
|
-
|
|
2813
|
-
obj, package_name
|
|
2835
|
+
connect_python_module, pb2_module = (
|
|
2836
|
+
generate_and_compile_proto_using_connect_python(obj, package_name)
|
|
2814
2837
|
)
|
|
2815
|
-
self.mount_using_pb2_modules(
|
|
2838
|
+
self.mount_using_pb2_modules(connect_python_module, pb2_module, obj)
|
|
2816
2839
|
|
|
2817
2840
|
def mount_using_pb2_modules(
|
|
2818
|
-
self,
|
|
2841
|
+
self, connect_python_module: Any, pb2_module: Any, obj: object
|
|
2819
2842
|
):
|
|
2820
|
-
"""Connect the compiled
|
|
2821
|
-
concreteServiceClass =
|
|
2822
|
-
|
|
2843
|
+
"""Connect the compiled connect-python and pb2 modules with the async service implementation."""
|
|
2844
|
+
concreteServiceClass = connect_obj_with_stub_async_connect_python(
|
|
2845
|
+
connect_python_module, pb2_module, obj
|
|
2823
2846
|
)
|
|
2824
2847
|
service_name = obj.__class__.__name__
|
|
2825
2848
|
service_impl = concreteServiceClass()
|
|
2826
2849
|
|
|
2827
2850
|
# Get the service-specific ASGI application class
|
|
2828
|
-
app_class =
|
|
2851
|
+
app_class = get_connect_python_asgi_app_class(
|
|
2852
|
+
connect_python_module, service_name
|
|
2853
|
+
)
|
|
2829
2854
|
app = app_class(service=service_impl)
|
|
2830
2855
|
|
|
2831
2856
|
# Store the app and its path for routing
|
|
@@ -2838,6 +2863,11 @@ class ASGIApp:
|
|
|
2838
2863
|
|
|
2839
2864
|
def mount_objs(self, *objs: object):
|
|
2840
2865
|
"""Mount multiple service objects into this ASGI app."""
|
|
2866
|
+
# Mount initial service if provided
|
|
2867
|
+
if self._initial_service:
|
|
2868
|
+
self.mount(self._initial_service, self._package_name)
|
|
2869
|
+
|
|
2870
|
+
# Mount additional services
|
|
2841
2871
|
for obj in objs:
|
|
2842
2872
|
self.mount(obj, self._package_name)
|
|
2843
2873
|
|
|
@@ -2848,6 +2878,10 @@ class ASGIApp:
|
|
|
2848
2878
|
send: Callable[[dict[str, Any]], Any],
|
|
2849
2879
|
):
|
|
2850
2880
|
"""ASGI entry point with routing for multiple services."""
|
|
2881
|
+
# Mount initial service on first call if not already mounted
|
|
2882
|
+
if self._initial_service and not self._services:
|
|
2883
|
+
self.mount(self._initial_service, self._package_name)
|
|
2884
|
+
|
|
2851
2885
|
if scope["type"] != "http":
|
|
2852
2886
|
await send({"type": "http.response.start", "status": 404})
|
|
2853
2887
|
await send({"type": "http.response.body", "body": b"Not Found"})
|
|
@@ -2871,33 +2905,36 @@ class ASGIApp:
|
|
|
2871
2905
|
|
|
2872
2906
|
class WSGIApp:
|
|
2873
2907
|
"""
|
|
2874
|
-
A WSGI-compatible application that can serve Connect-RPC via
|
|
2908
|
+
A WSGI-compatible application that can serve Connect-RPC via connect-python.
|
|
2875
2909
|
"""
|
|
2876
2910
|
|
|
2877
|
-
def __init__(self):
|
|
2911
|
+
def __init__(self, service: Optional[object] = None, package_name: str = ""):
|
|
2878
2912
|
self._services: list[tuple[Any, str]] = [] # List of (app, path) tuples
|
|
2879
2913
|
self._service_names: list[str] = []
|
|
2880
|
-
self._package_name: str =
|
|
2914
|
+
self._package_name: str = package_name
|
|
2915
|
+
self._initial_service = service
|
|
2881
2916
|
|
|
2882
2917
|
def mount(self, obj: object, package_name: str = ""):
|
|
2883
2918
|
"""Generate and compile proto files, then mount the sync service implementation."""
|
|
2884
|
-
|
|
2885
|
-
obj, package_name
|
|
2919
|
+
connect_python_module, pb2_module = (
|
|
2920
|
+
generate_and_compile_proto_using_connect_python(obj, package_name)
|
|
2886
2921
|
)
|
|
2887
|
-
self.mount_using_pb2_modules(
|
|
2922
|
+
self.mount_using_pb2_modules(connect_python_module, pb2_module, obj)
|
|
2888
2923
|
|
|
2889
2924
|
def mount_using_pb2_modules(
|
|
2890
|
-
self,
|
|
2925
|
+
self, connect_python_module: Any, pb2_module: Any, obj: object
|
|
2891
2926
|
):
|
|
2892
|
-
"""Connect the compiled
|
|
2893
|
-
concreteServiceClass =
|
|
2894
|
-
|
|
2927
|
+
"""Connect the compiled connect-python and pb2 modules with the sync service implementation."""
|
|
2928
|
+
concreteServiceClass = connect_obj_with_stub_connect_python(
|
|
2929
|
+
connect_python_module, pb2_module, obj
|
|
2895
2930
|
)
|
|
2896
2931
|
service_name = obj.__class__.__name__
|
|
2897
2932
|
service_impl = concreteServiceClass()
|
|
2898
2933
|
|
|
2899
2934
|
# Get the service-specific WSGI application class
|
|
2900
|
-
app_class =
|
|
2935
|
+
app_class = get_connect_python_wsgi_app_class(
|
|
2936
|
+
connect_python_module, service_name
|
|
2937
|
+
)
|
|
2901
2938
|
app = app_class(service=service_impl)
|
|
2902
2939
|
|
|
2903
2940
|
# Store the app and its path for routing
|
|
@@ -2910,6 +2947,11 @@ class WSGIApp:
|
|
|
2910
2947
|
|
|
2911
2948
|
def mount_objs(self, *objs: object):
|
|
2912
2949
|
"""Mount multiple service objects into this WSGI app."""
|
|
2950
|
+
# Mount initial service if provided
|
|
2951
|
+
if self._initial_service:
|
|
2952
|
+
self.mount(self._initial_service, self._package_name)
|
|
2953
|
+
|
|
2954
|
+
# Mount additional services
|
|
2913
2955
|
for obj in objs:
|
|
2914
2956
|
self.mount(obj, self._package_name)
|
|
2915
2957
|
|
|
@@ -2921,6 +2963,10 @@ class WSGIApp:
|
|
|
2921
2963
|
],
|
|
2922
2964
|
) -> Iterable[bytes]:
|
|
2923
2965
|
"""WSGI entry point with routing for multiple services."""
|
|
2966
|
+
# Mount initial service on first call if not already mounted
|
|
2967
|
+
if self._initial_service and not self._services:
|
|
2968
|
+
self.mount(self._initial_service, self._package_name)
|
|
2969
|
+
|
|
2924
2970
|
path = environ.get("PATH_INFO", "")
|
|
2925
2971
|
|
|
2926
2972
|
# Route to the appropriate service based on path
|
pydantic_rpc/decorators.py
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
"""Decorators for adding protobuf options to RPC methods."""
|
|
2
2
|
|
|
3
|
-
from typing import Any, Callable, Dict, List, Optional, TypeVar
|
|
3
|
+
from typing import Any, Callable, Dict, List, Optional, TypeVar, Type
|
|
4
4
|
from functools import wraps
|
|
5
|
+
import grpc
|
|
6
|
+
from connectrpc.code import Code as ConnectErrors
|
|
5
7
|
|
|
6
8
|
from .options import OptionMetadata, OPTION_METADATA_ATTR
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
F = TypeVar("F", bound=Callable[..., Any])
|
|
12
|
+
ERROR_HANDLER_ATTR = "__pydantic_rpc_error_handlers__"
|
|
10
13
|
|
|
11
14
|
|
|
12
15
|
def http_option(
|
|
@@ -136,3 +139,71 @@ def has_proto_options(method: Callable) -> bool:
|
|
|
136
139
|
"""
|
|
137
140
|
metadata = get_method_options(method)
|
|
138
141
|
return metadata is not None and len(metadata.proto_options) > 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def error_handler(
|
|
145
|
+
exception_type: Type[Exception],
|
|
146
|
+
status_code: Optional[grpc.StatusCode] = None,
|
|
147
|
+
connect_code: Optional[ConnectErrors] = None,
|
|
148
|
+
handler: Optional[Callable[[Exception], tuple[str, Any]]] = None,
|
|
149
|
+
) -> Callable[[F], F]:
|
|
150
|
+
"""
|
|
151
|
+
Decorator to add automatic error handling to an RPC method.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
exception_type: The type of exception to handle
|
|
155
|
+
status_code: The gRPC status code to return (for gRPC services)
|
|
156
|
+
connect_code: The Connect error code to return (for Connect services)
|
|
157
|
+
handler: Optional custom handler function that returns (message, details)
|
|
158
|
+
|
|
159
|
+
Example:
|
|
160
|
+
@error_handler(ValidationError, status_code=grpc.StatusCode.INVALID_ARGUMENT)
|
|
161
|
+
@error_handler(KeyError, status_code=grpc.StatusCode.NOT_FOUND)
|
|
162
|
+
async def get_user(self, request: GetUserRequest) -> User:
|
|
163
|
+
...
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def decorator(func: F) -> F:
|
|
167
|
+
# Get or create error handlers list
|
|
168
|
+
if not hasattr(func, ERROR_HANDLER_ATTR):
|
|
169
|
+
setattr(func, ERROR_HANDLER_ATTR, [])
|
|
170
|
+
|
|
171
|
+
handlers = getattr(func, ERROR_HANDLER_ATTR)
|
|
172
|
+
|
|
173
|
+
# Add this handler to the list
|
|
174
|
+
handlers.append(
|
|
175
|
+
{
|
|
176
|
+
"exception_type": exception_type,
|
|
177
|
+
"status_code": status_code or grpc.StatusCode.INTERNAL,
|
|
178
|
+
"connect_code": connect_code or ConnectErrors.INTERNAL,
|
|
179
|
+
"handler": handler,
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
@wraps(func)
|
|
184
|
+
def wrapper(*args, **kwargs):
|
|
185
|
+
return func(*args, **kwargs)
|
|
186
|
+
|
|
187
|
+
# Preserve the error handlers on the wrapper
|
|
188
|
+
setattr(wrapper, ERROR_HANDLER_ATTR, handlers)
|
|
189
|
+
|
|
190
|
+
# Preserve any existing option metadata
|
|
191
|
+
if hasattr(func, OPTION_METADATA_ATTR):
|
|
192
|
+
setattr(wrapper, OPTION_METADATA_ATTR, getattr(func, OPTION_METADATA_ATTR))
|
|
193
|
+
|
|
194
|
+
return wrapper # type: ignore
|
|
195
|
+
|
|
196
|
+
return decorator
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def get_error_handlers(method: Callable) -> Optional[List[Dict[str, Any]]]:
|
|
200
|
+
"""
|
|
201
|
+
Get error handlers from a method.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
method: The method to get error handlers from
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
List of error handler configurations if present, None otherwise
|
|
208
|
+
"""
|
|
209
|
+
return getattr(method, ERROR_HANDLER_ATTR, None)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: pydantic-rpc
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: A Python library for building gRPC/ConnectRPC services with Pydantic models.
|
|
5
5
|
Author: Yasushi Itoh
|
|
6
6
|
Requires-Dist: annotated-types==0.7.0
|
|
@@ -9,7 +9,8 @@ Requires-Dist: grpcio>=1.56.2
|
|
|
9
9
|
Requires-Dist: grpcio-tools>=1.56.2
|
|
10
10
|
Requires-Dist: grpcio-reflection>=1.56.2
|
|
11
11
|
Requires-Dist: grpcio-health-checking>=1.56.2
|
|
12
|
-
Requires-Dist:
|
|
12
|
+
Requires-Dist: connect-python>=0.5.0
|
|
13
|
+
Requires-Dist: protoc-gen-connect-python>=0.1.0
|
|
13
14
|
Requires-Dist: mcp>=1.9.4
|
|
14
15
|
Requires-Dist: starlette>=0.27.0
|
|
15
16
|
Requires-Python: >=3.11
|
|
@@ -62,9 +63,10 @@ class OlympicsLocationAgent:
|
|
|
62
63
|
|
|
63
64
|
|
|
64
65
|
if __name__ == "__main__":
|
|
65
|
-
|
|
66
|
+
# New enhanced initialization API (optional - backward compatible)
|
|
67
|
+
s = AsyncIOServer(service=OlympicsLocationAgent(), port=50051)
|
|
66
68
|
loop = asyncio.get_event_loop()
|
|
67
|
-
loop.run_until_complete(s.run(
|
|
69
|
+
loop.run_until_complete(s.run())
|
|
68
70
|
```
|
|
69
71
|
|
|
70
72
|
And here is an example of a simple Connect RPC service that exposes the same agent as an ASGI application:
|
|
@@ -106,8 +108,8 @@ class OlympicsLocationAgent:
|
|
|
106
108
|
result = await self._agent.run(req.prompt())
|
|
107
109
|
return result.data
|
|
108
110
|
|
|
109
|
-
|
|
110
|
-
app
|
|
111
|
+
# New enhanced initialization API (optional - backward compatible)
|
|
112
|
+
app = ASGIApp(service=OlympicsLocationAgent())
|
|
111
113
|
|
|
112
114
|
```
|
|
113
115
|
|
|
@@ -129,6 +131,17 @@ app.mount(OlympicsLocationAgent())
|
|
|
129
131
|
- 🛠️ **Pre-generated Protobuf Files and Code:** Pre-generate proto files and corresponding code via the CLI. By setting the environment variable (PYDANTIC_RPC_SKIP_GENERATION), you can skip runtime generation.
|
|
130
132
|
- 🤖 **MCP (Model Context Protocol) Support:** Expose your services as tools for AI assistants using the official MCP SDK, supporting both stdio and HTTP/SSE transports.
|
|
131
133
|
|
|
134
|
+
## ⚠️ Important Notes for Connect-RPC
|
|
135
|
+
|
|
136
|
+
When using Connect-RPC with ASGIApp:
|
|
137
|
+
|
|
138
|
+
- **Endpoint Path Format**: Connect-RPC endpoints use CamelCase method names in the path: `/<package>.<service>/<Method>` (e.g., `/chat.v1.ChatService/SendMessage`)
|
|
139
|
+
- **Content-Type**: Set `Content-Type: application/json` or `application/connect+json` for requests
|
|
140
|
+
- **HTTP/2 Requirement**: Bidirectional streaming requires HTTP/2. Use Hypercorn instead of uvicorn for HTTP/2 support
|
|
141
|
+
- **Testing**: Use [buf curl](https://buf.build/docs/ecosystem/cli/curl) for testing Connect-RPC endpoints with proper streaming support
|
|
142
|
+
|
|
143
|
+
For detailed examples and testing instructions, see the [examples directory](examples/).
|
|
144
|
+
|
|
132
145
|
## 📦 Installation
|
|
133
146
|
|
|
134
147
|
Install PydanticRPC via pip:
|
|
@@ -137,6 +150,54 @@ Install PydanticRPC via pip:
|
|
|
137
150
|
pip install pydantic-rpc
|
|
138
151
|
```
|
|
139
152
|
|
|
153
|
+
For CLI support with built-in server runners:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
pip install pydantic-rpc-cli # Includes hypercorn and gunicorn
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## 🆕 Enhanced Features (v0.10.0+)
|
|
160
|
+
|
|
161
|
+
**Note: All new features are fully backward compatible. Existing code continues to work without modification.**
|
|
162
|
+
|
|
163
|
+
### Enhanced Initialization API
|
|
164
|
+
All server classes now support optional initialization with services:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
# Traditional API (still works)
|
|
168
|
+
server = AsyncIOServer()
|
|
169
|
+
server.set_port(50051)
|
|
170
|
+
await server.run(MyService())
|
|
171
|
+
|
|
172
|
+
# New enhanced API (optional)
|
|
173
|
+
server = AsyncIOServer(
|
|
174
|
+
service=MyService(),
|
|
175
|
+
port=50051,
|
|
176
|
+
package_name="my.package"
|
|
177
|
+
)
|
|
178
|
+
await server.run()
|
|
179
|
+
|
|
180
|
+
# Same for ASGI/WSGI apps
|
|
181
|
+
app = ASGIApp(service=MyService(), package_name="my.package")
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Error Handling with Decorators
|
|
185
|
+
Automatically map exceptions to gRPC/Connect status codes:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from pydantic_rpc import error_handler
|
|
189
|
+
import grpc
|
|
190
|
+
|
|
191
|
+
class MyService:
|
|
192
|
+
@error_handler(ValidationError, status_code=grpc.StatusCode.INVALID_ARGUMENT)
|
|
193
|
+
@error_handler(KeyError, status_code=grpc.StatusCode.NOT_FOUND)
|
|
194
|
+
async def get_user(self, request: GetUserRequest) -> User:
|
|
195
|
+
# Exceptions are automatically converted to proper status codes
|
|
196
|
+
if request.id not in users_db:
|
|
197
|
+
raise KeyError(f"User {request.id} not found")
|
|
198
|
+
return users_db[request.id]
|
|
199
|
+
```
|
|
200
|
+
|
|
140
201
|
## 🚀 Getting Started
|
|
141
202
|
|
|
142
203
|
PydanticRPC supports two main protocols:
|
|
@@ -1090,15 +1151,40 @@ MCP endpoints will be available at:
|
|
|
1090
1151
|
- SSE: `GET http://localhost:8000/mcp/sse`
|
|
1091
1152
|
- Messages: `POST http://localhost:8000/mcp/messages/`
|
|
1092
1153
|
|
|
1093
|
-
### 🗄️
|
|
1154
|
+
### 🗄️ CLI Tool (pydantic-rpc-cli)
|
|
1155
|
+
|
|
1156
|
+
The CLI tool provides powerful features for generating protobuf files and running servers. Install it separately:
|
|
1157
|
+
|
|
1158
|
+
```bash
|
|
1159
|
+
pip install pydantic-rpc-cli
|
|
1160
|
+
```
|
|
1161
|
+
|
|
1162
|
+
#### Generate Protobuf Files
|
|
1094
1163
|
|
|
1095
|
-
|
|
1164
|
+
```bash
|
|
1165
|
+
# Generate .proto file from a service class
|
|
1166
|
+
pydantic-rpc generate myapp.services.UserService --output ./proto/
|
|
1167
|
+
|
|
1168
|
+
# Also compile to Python code
|
|
1169
|
+
pydantic-rpc generate myapp.services.UserService --compile
|
|
1170
|
+
```
|
|
1171
|
+
|
|
1172
|
+
#### Run Servers Directly
|
|
1173
|
+
|
|
1174
|
+
The CLI can run any type of server:
|
|
1096
1175
|
|
|
1097
1176
|
```bash
|
|
1098
|
-
|
|
1177
|
+
# Run as gRPC server (auto-detects async/sync)
|
|
1178
|
+
pydantic-rpc serve myapp.services.UserService --port 50051
|
|
1179
|
+
|
|
1180
|
+
# Run as Connect-RPC with ASGI (HTTP/2, uses Hypercorn)
|
|
1181
|
+
pydantic-rpc serve myapp.services.UserService --asgi --port 8000
|
|
1182
|
+
|
|
1183
|
+
# Run as Connect-RPC with WSGI (HTTP/1.1, uses Gunicorn)
|
|
1184
|
+
pydantic-rpc serve myapp.services.UserService --wsgi --port 8000 --workers 4
|
|
1099
1185
|
```
|
|
1100
1186
|
|
|
1101
|
-
Using
|
|
1187
|
+
Using the generated proto files with tools like `protoc`, `buf` and `BSR`, you can generate code for any desired language.
|
|
1102
1188
|
|
|
1103
1189
|
|
|
1104
1190
|
## 📖 Data Type Mapping
|
|
@@ -1162,6 +1248,64 @@ class MyMessage(Message):
|
|
|
1162
1248
|
|
|
1163
1249
|
This approach works because protobuf allows message types within `oneof` fields, and the collections are contained within those messages.
|
|
1164
1250
|
|
|
1251
|
+
## 🔧 Development
|
|
1252
|
+
|
|
1253
|
+
This project uses [`just`](https://github.com/casey/just) as a command runner for development tasks.
|
|
1254
|
+
|
|
1255
|
+
### Installing just
|
|
1256
|
+
|
|
1257
|
+
**macOS:**
|
|
1258
|
+
```bash
|
|
1259
|
+
brew install just
|
|
1260
|
+
```
|
|
1261
|
+
|
|
1262
|
+
**Linux:**
|
|
1263
|
+
```bash
|
|
1264
|
+
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to ~/bin
|
|
1265
|
+
```
|
|
1266
|
+
|
|
1267
|
+
**Windows:**
|
|
1268
|
+
Download from [GitHub releases](https://github.com/casey/just/releases)
|
|
1269
|
+
|
|
1270
|
+
### Quick Start
|
|
1271
|
+
|
|
1272
|
+
```bash
|
|
1273
|
+
# Install dependencies
|
|
1274
|
+
just install
|
|
1275
|
+
|
|
1276
|
+
# Run tests
|
|
1277
|
+
just test # or just t
|
|
1278
|
+
|
|
1279
|
+
# Format and lint code
|
|
1280
|
+
just format # or just f
|
|
1281
|
+
just lint # or just l
|
|
1282
|
+
|
|
1283
|
+
# Run all checks (lint + tests)
|
|
1284
|
+
just check # or just c
|
|
1285
|
+
|
|
1286
|
+
# See all available commands
|
|
1287
|
+
just --list
|
|
1288
|
+
```
|
|
1289
|
+
|
|
1290
|
+
### Running Examples
|
|
1291
|
+
|
|
1292
|
+
```bash
|
|
1293
|
+
# Start servers
|
|
1294
|
+
just greeting-server # gRPC server on port 50051
|
|
1295
|
+
just greeting-asgi # Connect RPC ASGI on port 8000
|
|
1296
|
+
just greeting-wsgi # Connect RPC WSGI on port 3000
|
|
1297
|
+
|
|
1298
|
+
# Test with buf curl (in another terminal)
|
|
1299
|
+
just greet # gRPC request
|
|
1300
|
+
just connect-greet # Connect RPC request
|
|
1301
|
+
just wsgi-greet # WSGI request
|
|
1302
|
+
|
|
1303
|
+
# Custom names
|
|
1304
|
+
just greet-name Alice
|
|
1305
|
+
just connect-greet-name Bob
|
|
1306
|
+
```
|
|
1307
|
+
|
|
1308
|
+
For more development commands and options, see the [Justfile](Justfile) or run `just --list`.
|
|
1165
1309
|
|
|
1166
1310
|
## TODO
|
|
1167
1311
|
- [x] Streaming Support
|
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
pydantic_rpc/__init__.py,sha256=
|
|
2
|
-
pydantic_rpc/core.py,sha256=
|
|
3
|
-
pydantic_rpc/decorators.py,sha256=
|
|
1
|
+
pydantic_rpc/__init__.py,sha256=c5e0b5d4ed6de3e3c7c1ada5389e704ce149ea56ca0930392db6abdf222b4e86,871
|
|
2
|
+
pydantic_rpc/core.py,sha256=450c03111224052a3e2b85567ffd16a0ff6dd4d6f02bcfa23c09f6d39cf30861,114578
|
|
3
|
+
pydantic_rpc/decorators.py,sha256=94d87825e034b6303df46dd74e6c8c7ed5c88133aa504d61249a83dbf82f1de3,6219
|
|
4
4
|
pydantic_rpc/mcp/__init__.py,sha256=f05ad62cc38db5c5972e16870c484523c58c5ac650d8454706f1ce4539cc7a52,123
|
|
5
5
|
pydantic_rpc/mcp/converter.py,sha256=b60dcaf82e6bff6be4b2ab8b1e9f2d16e09cb98d8f00182a4f6f73d1a78848a4,4158
|
|
6
6
|
pydantic_rpc/mcp/exporter.py,sha256=20833662f9a973ed9e1a705b9b59aaa2533de6c514ebd87ef66664a430a0d04f,10239
|
|
7
7
|
pydantic_rpc/options.py,sha256=6094036184a500b92715fd91d31ecc501460a56de47dcf6024c6335ae8e5d6e3,4561
|
|
8
8
|
pydantic_rpc/py.typed,sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855,0
|
|
9
9
|
pydantic_rpc/tls.py,sha256=0eb290cc09c8ebedc71e7a97c01736d9675a314f70ebd80a2ce73e3f1689d359,3567
|
|
10
|
-
pydantic_rpc-0.
|
|
11
|
-
pydantic_rpc-0.
|
|
12
|
-
pydantic_rpc-0.
|
|
13
|
-
pydantic_rpc-0.10.0.dist-info/RECORD,,
|
|
10
|
+
pydantic_rpc-0.12.0.dist-info/WHEEL,sha256=ab6157bc637547491fb4567cd7ddf26b04d63382916ca16c29a5c8e94c9c9ef7,79
|
|
11
|
+
pydantic_rpc-0.12.0.dist-info/METADATA,sha256=daa91c220e04adae53cb3e654d249f2feaca8e461f84e9d03f70fad2b48e403b,36156
|
|
12
|
+
pydantic_rpc-0.12.0.dist-info/RECORD,,
|
|
File without changes
|