pydantic-rpc 0.8.0__py3-none-any.whl → 0.9.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 +12 -4
- pydantic_rpc/core.py +291 -178
- pydantic_rpc/decorators.py +138 -0
- pydantic_rpc/options.py +134 -0
- {pydantic_rpc-0.8.0.dist-info → pydantic_rpc-0.9.0.dist-info}/METADATA +81 -49
- pydantic_rpc-0.9.0.dist-info/RECORD +12 -0
- pydantic_rpc-0.8.0.dist-info/RECORD +0 -10
- {pydantic_rpc-0.8.0.dist-info → pydantic_rpc-0.9.0.dist-info}/WHEEL +0 -0
- {pydantic_rpc-0.8.0.dist-info → pydantic_rpc-0.9.0.dist-info}/entry_points.txt +0 -0
pydantic_rpc/__init__.py
CHANGED
|
@@ -5,9 +5,14 @@ from .core import (
|
|
|
5
5
|
AsyncIOServer,
|
|
6
6
|
WSGIApp,
|
|
7
7
|
ASGIApp,
|
|
8
|
-
ConnecpyASGIApp,
|
|
9
|
-
ConnecpyWSGIApp,
|
|
10
8
|
Message,
|
|
9
|
+
generate_proto,
|
|
10
|
+
)
|
|
11
|
+
from .decorators import (
|
|
12
|
+
http_option,
|
|
13
|
+
proto_option,
|
|
14
|
+
get_method_options,
|
|
15
|
+
has_http_option,
|
|
11
16
|
)
|
|
12
17
|
|
|
13
18
|
__all__ = [
|
|
@@ -15,9 +20,12 @@ __all__ = [
|
|
|
15
20
|
"AsyncIOServer",
|
|
16
21
|
"WSGIApp",
|
|
17
22
|
"ASGIApp",
|
|
18
|
-
"ConnecpyWSGIApp",
|
|
19
|
-
"ConnecpyASGIApp",
|
|
20
23
|
"Message",
|
|
24
|
+
"generate_proto",
|
|
25
|
+
"http_option",
|
|
26
|
+
"proto_option",
|
|
27
|
+
"get_method_options",
|
|
28
|
+
"has_http_option",
|
|
21
29
|
]
|
|
22
30
|
|
|
23
31
|
# Optional MCP support
|
pydantic_rpc/core.py
CHANGED
|
@@ -9,7 +9,7 @@ import sys
|
|
|
9
9
|
import time
|
|
10
10
|
import types
|
|
11
11
|
from typing import Union
|
|
12
|
-
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
12
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
|
|
13
13
|
from concurrent import futures
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from posixpath import basename
|
|
@@ -35,8 +35,7 @@ from grpc_health.v1.health import HealthServicer
|
|
|
35
35
|
from grpc_reflection.v1alpha import reflection
|
|
36
36
|
from grpc_tools import protoc
|
|
37
37
|
from pydantic import BaseModel, ValidationError
|
|
38
|
-
from
|
|
39
|
-
from sonora.wsgi import grpcWSGI
|
|
38
|
+
from .decorators import get_method_options, has_http_option
|
|
40
39
|
|
|
41
40
|
###############################################################################
|
|
42
41
|
# 1. Message definitions & converter extensions
|
|
@@ -838,7 +837,8 @@ def connect_obj_with_stub_connecpy(
|
|
|
838
837
|
if method.__name__.startswith("_"):
|
|
839
838
|
continue
|
|
840
839
|
a_method = implement_stub_method(method)
|
|
841
|
-
|
|
840
|
+
# Use the original snake_case method name for Connecpy v2.2.0 compatibility
|
|
841
|
+
setattr(ConcreteServiceClass, method.__name__, a_method)
|
|
842
842
|
|
|
843
843
|
return ConcreteServiceClass
|
|
844
844
|
|
|
@@ -847,7 +847,7 @@ def connect_obj_with_stub_async_connecpy(
|
|
|
847
847
|
connecpy_module: Any, pb2_module: Any, obj: object
|
|
848
848
|
) -> type:
|
|
849
849
|
"""
|
|
850
|
-
Connect a Python service object to a Connecpy stub for async methods.
|
|
850
|
+
Connect a Python service object to a Connecpy stub for async methods with streaming support.
|
|
851
851
|
"""
|
|
852
852
|
service_class = obj.__class__
|
|
853
853
|
stub_class_name = service_class.__name__
|
|
@@ -857,12 +857,13 @@ def connect_obj_with_stub_async_connecpy(
|
|
|
857
857
|
pass
|
|
858
858
|
|
|
859
859
|
def implement_stub_method(
|
|
860
|
-
method: Callable[...,
|
|
860
|
+
method: Callable[..., Any],
|
|
861
861
|
) -> Callable[[object, Any, Any], Any]:
|
|
862
862
|
sig = inspect.signature(method)
|
|
863
|
-
|
|
864
|
-
|
|
863
|
+
input_type = get_request_arg_type(sig)
|
|
864
|
+
is_input_stream = is_stream_type(input_type)
|
|
865
865
|
response_type = sig.return_annotation
|
|
866
|
+
is_output_stream = is_stream_type(response_type)
|
|
866
867
|
size_of_parameters = len(sig.parameters)
|
|
867
868
|
|
|
868
869
|
match size_of_parameters:
|
|
@@ -891,63 +892,215 @@ def connect_obj_with_stub_async_connecpy(
|
|
|
891
892
|
|
|
892
893
|
return stub_method0
|
|
893
894
|
|
|
894
|
-
case 1:
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
895
|
+
case 1 | 2:
|
|
896
|
+
if is_input_stream:
|
|
897
|
+
# Client streaming or bidirectional streaming
|
|
898
|
+
input_item_type = get_args(input_type)[0]
|
|
899
|
+
item_converter = generate_message_converter(input_item_type)
|
|
900
|
+
|
|
901
|
+
async def convert_iterator(
|
|
902
|
+
proto_iter: AsyncIterator[Any],
|
|
903
|
+
) -> AsyncIterator[Message]:
|
|
904
|
+
async for proto in proto_iter:
|
|
905
|
+
result = item_converter(proto)
|
|
906
|
+
if result is None:
|
|
907
|
+
raise TypeError(
|
|
908
|
+
f"Unexpected None result from converter for type {input_item_type}"
|
|
909
|
+
)
|
|
910
|
+
yield result
|
|
911
|
+
|
|
912
|
+
if is_output_stream:
|
|
913
|
+
# Bidirectional streaming
|
|
914
|
+
output_item_type = get_args(response_type)[0]
|
|
915
|
+
|
|
916
|
+
if size_of_parameters == 1:
|
|
917
|
+
|
|
918
|
+
async def stub_method(
|
|
919
|
+
self: object,
|
|
920
|
+
request_iterator: AsyncIterator[Any],
|
|
921
|
+
context: Any,
|
|
922
|
+
) -> AsyncIterator[Any]:
|
|
923
|
+
_ = self
|
|
924
|
+
try:
|
|
925
|
+
arg_iter = convert_iterator(request_iterator)
|
|
926
|
+
async for resp_obj in method(arg_iter):
|
|
927
|
+
yield convert_python_message_to_proto(
|
|
928
|
+
resp_obj, output_item_type, pb2_module
|
|
929
|
+
)
|
|
930
|
+
except ValidationError as e:
|
|
931
|
+
await context.abort(Errors.INVALID_ARGUMENT, str(e))
|
|
932
|
+
except Exception as e:
|
|
933
|
+
await context.abort(Errors.INTERNAL, str(e))
|
|
934
|
+
else: # size_of_parameters == 2
|
|
935
|
+
|
|
936
|
+
async def stub_method(
|
|
937
|
+
self: object,
|
|
938
|
+
request_iterator: AsyncIterator[Any],
|
|
939
|
+
context: Any,
|
|
940
|
+
) -> AsyncIterator[Any]:
|
|
941
|
+
_ = self
|
|
942
|
+
try:
|
|
943
|
+
arg_iter = convert_iterator(request_iterator)
|
|
944
|
+
async for resp_obj in method(arg_iter, context):
|
|
945
|
+
yield convert_python_message_to_proto(
|
|
946
|
+
resp_obj, output_item_type, pb2_module
|
|
947
|
+
)
|
|
948
|
+
except ValidationError as e:
|
|
949
|
+
await context.abort(Errors.INVALID_ARGUMENT, str(e))
|
|
950
|
+
except Exception as e:
|
|
951
|
+
await context.abort(Errors.INTERNAL, str(e))
|
|
952
|
+
|
|
953
|
+
return stub_method
|
|
954
|
+
else:
|
|
955
|
+
# Client streaming
|
|
956
|
+
if size_of_parameters == 1:
|
|
957
|
+
|
|
958
|
+
async def stub_method(
|
|
959
|
+
self: object,
|
|
960
|
+
request_iterator: AsyncIterator[Any],
|
|
961
|
+
context: Any,
|
|
962
|
+
) -> Any:
|
|
963
|
+
_ = self
|
|
964
|
+
try:
|
|
965
|
+
arg_iter = convert_iterator(request_iterator)
|
|
966
|
+
resp_obj = await method(arg_iter)
|
|
967
|
+
if is_none_type(response_type):
|
|
968
|
+
return empty_pb2.Empty() # type: ignore
|
|
969
|
+
return convert_python_message_to_proto(
|
|
970
|
+
resp_obj, response_type, pb2_module
|
|
971
|
+
)
|
|
972
|
+
except ValidationError as e:
|
|
973
|
+
await context.abort(Errors.INVALID_ARGUMENT, str(e))
|
|
974
|
+
except Exception as e:
|
|
975
|
+
await context.abort(Errors.INTERNAL, str(e))
|
|
976
|
+
else: # size_of_parameters == 2
|
|
977
|
+
|
|
978
|
+
async def stub_method(
|
|
979
|
+
self: object,
|
|
980
|
+
request_iterator: AsyncIterator[Any],
|
|
981
|
+
context: Any,
|
|
982
|
+
) -> Any:
|
|
983
|
+
_ = self
|
|
984
|
+
try:
|
|
985
|
+
arg_iter = convert_iterator(request_iterator)
|
|
986
|
+
resp_obj = await method(arg_iter, context)
|
|
987
|
+
if is_none_type(response_type):
|
|
988
|
+
return empty_pb2.Empty() # type: ignore
|
|
989
|
+
return convert_python_message_to_proto(
|
|
990
|
+
resp_obj, response_type, pb2_module
|
|
991
|
+
)
|
|
992
|
+
except ValidationError as e:
|
|
993
|
+
await context.abort(Errors.INVALID_ARGUMENT, str(e))
|
|
994
|
+
except Exception as e:
|
|
995
|
+
await context.abort(Errors.INTERNAL, str(e))
|
|
996
|
+
|
|
997
|
+
return stub_method
|
|
998
|
+
else:
|
|
999
|
+
# Unary request
|
|
1000
|
+
converter = generate_message_converter(input_type)
|
|
1001
|
+
|
|
1002
|
+
if is_output_stream:
|
|
1003
|
+
# Server streaming
|
|
1004
|
+
output_item_type = get_args(response_type)[0]
|
|
1005
|
+
|
|
1006
|
+
if size_of_parameters == 1:
|
|
1007
|
+
|
|
1008
|
+
async def stub_method(
|
|
1009
|
+
self: object,
|
|
1010
|
+
request: Any,
|
|
1011
|
+
context: Any,
|
|
1012
|
+
) -> AsyncIterator[Any]:
|
|
1013
|
+
_ = self
|
|
1014
|
+
try:
|
|
1015
|
+
if is_none_type(input_type):
|
|
1016
|
+
arg = None
|
|
1017
|
+
else:
|
|
1018
|
+
arg = converter(request)
|
|
1019
|
+
async for resp_obj in method(arg):
|
|
1020
|
+
yield convert_python_message_to_proto(
|
|
1021
|
+
resp_obj, output_item_type, pb2_module
|
|
1022
|
+
)
|
|
1023
|
+
except ValidationError as e:
|
|
1024
|
+
await context.abort(Errors.INVALID_ARGUMENT, str(e))
|
|
1025
|
+
except Exception as e:
|
|
1026
|
+
await context.abort(Errors.INTERNAL, str(e))
|
|
1027
|
+
else: # size_of_parameters == 2
|
|
1028
|
+
|
|
1029
|
+
async def stub_method(
|
|
1030
|
+
self: object,
|
|
1031
|
+
request: Any,
|
|
1032
|
+
context: Any,
|
|
1033
|
+
) -> AsyncIterator[Any]:
|
|
1034
|
+
_ = self
|
|
1035
|
+
try:
|
|
1036
|
+
if is_none_type(input_type):
|
|
1037
|
+
arg = None
|
|
1038
|
+
else:
|
|
1039
|
+
arg = converter(request)
|
|
1040
|
+
async for resp_obj in method(arg, context):
|
|
1041
|
+
yield convert_python_message_to_proto(
|
|
1042
|
+
resp_obj, output_item_type, pb2_module
|
|
1043
|
+
)
|
|
1044
|
+
except ValidationError as e:
|
|
1045
|
+
await context.abort(Errors.INVALID_ARGUMENT, str(e))
|
|
1046
|
+
except Exception as e:
|
|
1047
|
+
await context.abort(Errors.INTERNAL, str(e))
|
|
1048
|
+
|
|
1049
|
+
return stub_method
|
|
1050
|
+
else:
|
|
1051
|
+
# Unary RPC
|
|
1052
|
+
if size_of_parameters == 1:
|
|
1053
|
+
|
|
1054
|
+
async def stub_method(
|
|
1055
|
+
self: object,
|
|
1056
|
+
request: Any,
|
|
1057
|
+
context: Any,
|
|
1058
|
+
) -> Any:
|
|
1059
|
+
_ = self
|
|
1060
|
+
try:
|
|
1061
|
+
if is_none_type(input_type):
|
|
1062
|
+
resp_obj = await method(None)
|
|
1063
|
+
else:
|
|
1064
|
+
arg = converter(request)
|
|
1065
|
+
resp_obj = await method(arg)
|
|
1066
|
+
|
|
1067
|
+
if is_none_type(response_type):
|
|
1068
|
+
return empty_pb2.Empty() # type: ignore
|
|
1069
|
+
else:
|
|
1070
|
+
return convert_python_message_to_proto(
|
|
1071
|
+
resp_obj, response_type, pb2_module
|
|
1072
|
+
)
|
|
1073
|
+
except ValidationError as e:
|
|
1074
|
+
await context.abort(Errors.INVALID_ARGUMENT, str(e))
|
|
1075
|
+
except Exception as e:
|
|
1076
|
+
await context.abort(Errors.INTERNAL, str(e))
|
|
1077
|
+
else: # size_of_parameters == 2
|
|
1078
|
+
|
|
1079
|
+
async def stub_method(
|
|
1080
|
+
self: object,
|
|
1081
|
+
request: Any,
|
|
1082
|
+
context: Any,
|
|
1083
|
+
) -> Any:
|
|
1084
|
+
_ = self
|
|
1085
|
+
try:
|
|
1086
|
+
if is_none_type(input_type):
|
|
1087
|
+
resp_obj = await method(None, context)
|
|
1088
|
+
else:
|
|
1089
|
+
arg = converter(request)
|
|
1090
|
+
resp_obj = await method(arg, context)
|
|
1091
|
+
|
|
1092
|
+
if is_none_type(response_type):
|
|
1093
|
+
return empty_pb2.Empty() # type: ignore
|
|
1094
|
+
else:
|
|
1095
|
+
return convert_python_message_to_proto(
|
|
1096
|
+
resp_obj, response_type, pb2_module
|
|
1097
|
+
)
|
|
1098
|
+
except ValidationError as e:
|
|
1099
|
+
await context.abort(Errors.INVALID_ARGUMENT, str(e))
|
|
1100
|
+
except Exception as e:
|
|
1101
|
+
await context.abort(Errors.INTERNAL, str(e))
|
|
1102
|
+
|
|
1103
|
+
return stub_method
|
|
951
1104
|
|
|
952
1105
|
case _:
|
|
953
1106
|
raise Exception("Method must have 0, 1, or 2 parameters")
|
|
@@ -955,10 +1108,14 @@ def connect_obj_with_stub_async_connecpy(
|
|
|
955
1108
|
for method_name, method in get_rpc_methods(obj):
|
|
956
1109
|
if method.__name__.startswith("_"):
|
|
957
1110
|
continue
|
|
958
|
-
|
|
959
|
-
|
|
1111
|
+
# Check for async generator functions for streaming support
|
|
1112
|
+
if not (
|
|
1113
|
+
asyncio.iscoroutinefunction(method) or inspect.isasyncgenfunction(method)
|
|
1114
|
+
):
|
|
1115
|
+
raise Exception(f"Method {method_name} must be async or async generator")
|
|
960
1116
|
a_method = implement_stub_method(method)
|
|
961
|
-
|
|
1117
|
+
# Use the original snake_case method name for Connecpy v2.2.0 compatibility
|
|
1118
|
+
setattr(ConcreteServiceClass, method.__name__, a_method)
|
|
962
1119
|
|
|
963
1120
|
return ConcreteServiceClass
|
|
964
1121
|
|
|
@@ -1510,11 +1667,11 @@ def generate_message_definition(
|
|
|
1510
1667
|
fields.append("// length of " + str(metadata_item.len))
|
|
1511
1668
|
case annotated_types.MinLen:
|
|
1512
1669
|
fields.append(
|
|
1513
|
-
"// minimum length of " + str(metadata_item.
|
|
1670
|
+
"// minimum length of " + str(metadata_item.min_length)
|
|
1514
1671
|
)
|
|
1515
1672
|
case annotated_types.MaxLen:
|
|
1516
1673
|
fields.append(
|
|
1517
|
-
"// maximum length of " + str(metadata_item.
|
|
1674
|
+
"// maximum length of " + str(metadata_item.max_length)
|
|
1518
1675
|
)
|
|
1519
1676
|
case _:
|
|
1520
1677
|
fields.append("// " + str(metadata_item))
|
|
@@ -1550,6 +1707,39 @@ def is_generic_alias(annotation: Any) -> bool:
|
|
|
1550
1707
|
return get_origin(annotation) is not None
|
|
1551
1708
|
|
|
1552
1709
|
|
|
1710
|
+
def format_method_options(method: Any) -> list[str]:
|
|
1711
|
+
"""
|
|
1712
|
+
Format protobuf options for a method.
|
|
1713
|
+
|
|
1714
|
+
Args:
|
|
1715
|
+
method: The method to get options from
|
|
1716
|
+
|
|
1717
|
+
Returns:
|
|
1718
|
+
List of formatted option strings
|
|
1719
|
+
"""
|
|
1720
|
+
metadata = get_method_options(method)
|
|
1721
|
+
if metadata is None:
|
|
1722
|
+
return []
|
|
1723
|
+
|
|
1724
|
+
return metadata.to_proto_strings()
|
|
1725
|
+
|
|
1726
|
+
|
|
1727
|
+
def check_uses_http_options(obj: object) -> bool:
|
|
1728
|
+
"""
|
|
1729
|
+
Check if any method in the service uses HTTP options.
|
|
1730
|
+
|
|
1731
|
+
Args:
|
|
1732
|
+
obj: Service instance
|
|
1733
|
+
|
|
1734
|
+
Returns:
|
|
1735
|
+
True if any method has HTTP options
|
|
1736
|
+
"""
|
|
1737
|
+
for method_name, method in get_rpc_methods(obj):
|
|
1738
|
+
if has_http_option(method):
|
|
1739
|
+
return True
|
|
1740
|
+
return False
|
|
1741
|
+
|
|
1742
|
+
|
|
1553
1743
|
def generate_proto(obj: object, package_name: str = "") -> str:
|
|
1554
1744
|
"""
|
|
1555
1745
|
Generate a .proto definition from a service class.
|
|
@@ -1570,6 +1760,7 @@ def generate_proto(obj: object, package_name: str = "") -> str:
|
|
|
1570
1760
|
uses_timestamp = False
|
|
1571
1761
|
uses_duration = False
|
|
1572
1762
|
uses_empty = False
|
|
1763
|
+
uses_http_options = check_uses_http_options(obj)
|
|
1573
1764
|
|
|
1574
1765
|
def check_and_set_well_known_types_for_fields(py_type: Any):
|
|
1575
1766
|
"""Check well-known types for field annotations (excludes None/Empty)."""
|
|
@@ -1729,9 +1920,24 @@ def generate_proto(obj: object, package_name: str = "") -> str:
|
|
|
1729
1920
|
else output_msg_type.__name__
|
|
1730
1921
|
)
|
|
1731
1922
|
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1923
|
+
# Get method options
|
|
1924
|
+
method_options = format_method_options(method)
|
|
1925
|
+
|
|
1926
|
+
if method_options:
|
|
1927
|
+
# RPC with options - use block format
|
|
1928
|
+
rpc_definitions.append(
|
|
1929
|
+
f"rpc {method_name} ({input_str}) returns ({output_str}) {{"
|
|
1930
|
+
)
|
|
1931
|
+
for option_str in method_options:
|
|
1932
|
+
# Indent each option line
|
|
1933
|
+
for line in option_str.split("\n"):
|
|
1934
|
+
rpc_definitions.append(f" {line}")
|
|
1935
|
+
rpc_definitions.append("}")
|
|
1936
|
+
else:
|
|
1937
|
+
# RPC without options - use simple format
|
|
1938
|
+
rpc_definitions.append(
|
|
1939
|
+
f"rpc {method_name} ({input_str}) returns ({output_str});"
|
|
1940
|
+
)
|
|
1735
1941
|
|
|
1736
1942
|
if not package_name:
|
|
1737
1943
|
if service_name.endswith("Service"):
|
|
@@ -1741,6 +1947,8 @@ def generate_proto(obj: object, package_name: str = "") -> str:
|
|
|
1741
1947
|
package_name = package_name.lower() + ".v1"
|
|
1742
1948
|
|
|
1743
1949
|
imports: list[str] = []
|
|
1950
|
+
if uses_http_options:
|
|
1951
|
+
imports.append('import "google/api/annotations.proto";')
|
|
1744
1952
|
if uses_timestamp:
|
|
1745
1953
|
imports.append('import "google/protobuf/timestamp.proto";')
|
|
1746
1954
|
if uses_duration:
|
|
@@ -2548,116 +2756,19 @@ class AsyncIOServer:
|
|
|
2548
2756
|
print("gRPC server shutdown.")
|
|
2549
2757
|
|
|
2550
2758
|
|
|
2551
|
-
class WSGIApp:
|
|
2552
|
-
"""
|
|
2553
|
-
A WSGI-compatible application that can serve gRPC via sonora's grpcWSGI.
|
|
2554
|
-
Useful for embedding gRPC within an existing WSGI stack.
|
|
2555
|
-
"""
|
|
2556
|
-
|
|
2557
|
-
def __init__(self, app: Any):
|
|
2558
|
-
self._app: grpcWSGI = grpcWSGI(app)
|
|
2559
|
-
self._service_names: list[str] = []
|
|
2560
|
-
self._package_name: str = ""
|
|
2561
|
-
|
|
2562
|
-
def mount(self, obj: object, package_name: str = ""):
|
|
2563
|
-
"""Generate and compile proto files, then mount the service implementation."""
|
|
2564
|
-
pb2_grpc_module, pb2_module = generate_and_compile_proto(obj, package_name) or (
|
|
2565
|
-
None,
|
|
2566
|
-
None,
|
|
2567
|
-
)
|
|
2568
|
-
self.mount_using_pb2_modules(pb2_grpc_module, pb2_module, obj)
|
|
2569
|
-
|
|
2570
|
-
def mount_using_pb2_modules(
|
|
2571
|
-
self, pb2_grpc_module: Any, pb2_module: Any, obj: object
|
|
2572
|
-
):
|
|
2573
|
-
"""Connect the compiled gRPC modules with the service implementation."""
|
|
2574
|
-
concreteServiceClass = connect_obj_with_stub(pb2_grpc_module, pb2_module, obj)
|
|
2575
|
-
service_name = obj.__class__.__name__
|
|
2576
|
-
service_impl = concreteServiceClass()
|
|
2577
|
-
getattr(pb2_grpc_module, f"add_{service_name}Servicer_to_server")(
|
|
2578
|
-
service_impl, self._app
|
|
2579
|
-
)
|
|
2580
|
-
full_service_name = pb2_module.DESCRIPTOR.services_by_name[
|
|
2581
|
-
service_name
|
|
2582
|
-
].full_name
|
|
2583
|
-
self._service_names.append(full_service_name)
|
|
2584
|
-
|
|
2585
|
-
def mount_objs(self, *objs: object):
|
|
2586
|
-
"""Mount multiple service objects into this WSGI app."""
|
|
2587
|
-
for obj in objs:
|
|
2588
|
-
self.mount(obj, self._package_name)
|
|
2589
|
-
|
|
2590
|
-
def __call__(
|
|
2591
|
-
self,
|
|
2592
|
-
environ: dict[str, Any],
|
|
2593
|
-
start_response: Callable[[str, list[tuple[str, str]]], None],
|
|
2594
|
-
) -> Any:
|
|
2595
|
-
"""WSGI entry point."""
|
|
2596
|
-
return self._app(environ, start_response)
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
class ASGIApp:
|
|
2600
|
-
"""
|
|
2601
|
-
An ASGI-compatible application that can serve gRPC via sonora's grpcASGI.
|
|
2602
|
-
Useful for embedding gRPC within an existing ASGI stack.
|
|
2603
|
-
"""
|
|
2604
|
-
|
|
2605
|
-
def __init__(self, app: Any):
|
|
2606
|
-
self._app: grpcASGI = grpcASGI(app)
|
|
2607
|
-
self._service_names: list[str] = []
|
|
2608
|
-
self._package_name: str = ""
|
|
2609
|
-
|
|
2610
|
-
def mount(self, obj: object, package_name: str = ""):
|
|
2611
|
-
"""Generate and compile proto files, then mount the async service implementation."""
|
|
2612
|
-
pb2_grpc_module, pb2_module = generate_and_compile_proto(obj, package_name) or (
|
|
2613
|
-
None,
|
|
2614
|
-
None,
|
|
2615
|
-
)
|
|
2616
|
-
self.mount_using_pb2_modules(pb2_grpc_module, pb2_module, obj)
|
|
2617
|
-
|
|
2618
|
-
def mount_using_pb2_modules(
|
|
2619
|
-
self, pb2_grpc_module: Any, pb2_module: Any, obj: object
|
|
2620
|
-
):
|
|
2621
|
-
"""Connect the compiled gRPC modules with the async service implementation."""
|
|
2622
|
-
concreteServiceClass = connect_obj_with_stub_async(
|
|
2623
|
-
pb2_grpc_module, pb2_module, obj
|
|
2624
|
-
)
|
|
2625
|
-
service_name = obj.__class__.__name__
|
|
2626
|
-
service_impl = concreteServiceClass()
|
|
2627
|
-
getattr(pb2_grpc_module, f"add_{service_name}Servicer_to_server")(
|
|
2628
|
-
service_impl, self._app
|
|
2629
|
-
)
|
|
2630
|
-
full_service_name = pb2_module.DESCRIPTOR.services_by_name[
|
|
2631
|
-
service_name
|
|
2632
|
-
].full_name
|
|
2633
|
-
self._service_names.append(full_service_name)
|
|
2634
|
-
|
|
2635
|
-
def mount_objs(self, *objs: object):
|
|
2636
|
-
"""Mount multiple service objects into this ASGI app."""
|
|
2637
|
-
for obj in objs:
|
|
2638
|
-
self.mount(obj, self._package_name)
|
|
2639
|
-
|
|
2640
|
-
async def __call__(
|
|
2641
|
-
self,
|
|
2642
|
-
scope: dict[str, Any],
|
|
2643
|
-
receive: Callable[[], Any],
|
|
2644
|
-
send: Callable[[dict[str, Any]], Any],
|
|
2645
|
-
) -> Any:
|
|
2646
|
-
"""ASGI entry point."""
|
|
2647
|
-
_ = await self._app(scope, receive, send)
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
2759
|
def get_connecpy_asgi_app_class(connecpy_module: Any, service_name: str):
|
|
2760
|
+
"""Get the ASGI application class from connecpy module (Connecpy v2.x)."""
|
|
2651
2761
|
return getattr(connecpy_module, f"{service_name}ASGIApplication")
|
|
2652
2762
|
|
|
2653
2763
|
|
|
2654
2764
|
def get_connecpy_wsgi_app_class(connecpy_module: Any, service_name: str):
|
|
2765
|
+
"""Get the WSGI application class from connecpy module (Connecpy v2.x)."""
|
|
2655
2766
|
return getattr(connecpy_module, f"{service_name}WSGIApplication")
|
|
2656
2767
|
|
|
2657
2768
|
|
|
2658
|
-
class
|
|
2769
|
+
class ASGIApp:
|
|
2659
2770
|
"""
|
|
2660
|
-
An ASGI-compatible application that can serve Connect-RPC via Connecpy
|
|
2771
|
+
An ASGI-compatible application that can serve Connect-RPC via Connecpy.
|
|
2661
2772
|
"""
|
|
2662
2773
|
|
|
2663
2774
|
def __init__(self):
|
|
@@ -2727,9 +2838,9 @@ class ConnecpyASGIApp:
|
|
|
2727
2838
|
await send({"type": "http.response.body", "body": b"Not Found"})
|
|
2728
2839
|
|
|
2729
2840
|
|
|
2730
|
-
class
|
|
2841
|
+
class WSGIApp:
|
|
2731
2842
|
"""
|
|
2732
|
-
A WSGI-compatible application that can serve Connect-RPC via Connecpy
|
|
2843
|
+
A WSGI-compatible application that can serve Connect-RPC via Connecpy.
|
|
2733
2844
|
"""
|
|
2734
2845
|
|
|
2735
2846
|
def __init__(self):
|
|
@@ -2774,8 +2885,10 @@ class ConnecpyWSGIApp:
|
|
|
2774
2885
|
def __call__(
|
|
2775
2886
|
self,
|
|
2776
2887
|
environ: dict[str, Any],
|
|
2777
|
-
start_response: Callable[
|
|
2778
|
-
|
|
2888
|
+
start_response: Callable[
|
|
2889
|
+
[str, list[tuple[str, str]]], Callable[[bytes], object]
|
|
2890
|
+
],
|
|
2891
|
+
) -> Iterable[bytes]:
|
|
2779
2892
|
"""WSGI entry point with routing for multiple services."""
|
|
2780
2893
|
path = environ.get("PATH_INFO", "")
|
|
2781
2894
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Decorators for adding protobuf options to RPC methods."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Dict, List, Optional, TypeVar
|
|
4
|
+
from functools import wraps
|
|
5
|
+
|
|
6
|
+
from .options import OptionMetadata, OPTION_METADATA_ATTR
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def http_option(
|
|
13
|
+
method: str,
|
|
14
|
+
path: str,
|
|
15
|
+
body: Optional[str] = None,
|
|
16
|
+
response_body: Optional[str] = None,
|
|
17
|
+
additional_bindings: Optional[List[Dict[str, Any]]] = None,
|
|
18
|
+
) -> Callable[[F], F]:
|
|
19
|
+
"""
|
|
20
|
+
Decorator to add google.api.http option to an RPC method.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
method: HTTP method (GET, POST, PUT, DELETE, PATCH)
|
|
24
|
+
path: URL path template (e.g., "/v1/books/{id}")
|
|
25
|
+
body: Request body mapping (e.g., "*" for entire body)
|
|
26
|
+
response_body: Response body mapping (specific field to return)
|
|
27
|
+
additional_bindings: List of additional HTTP bindings
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
@http_option(method="GET", path="/v1/books/{id}")
|
|
31
|
+
async def get_book(self, request: GetBookRequest) -> Book:
|
|
32
|
+
...
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def decorator(func: F) -> F:
|
|
36
|
+
# Get or create option metadata
|
|
37
|
+
if not hasattr(func, OPTION_METADATA_ATTR):
|
|
38
|
+
setattr(func, OPTION_METADATA_ATTR, OptionMetadata())
|
|
39
|
+
|
|
40
|
+
metadata: OptionMetadata = getattr(func, OPTION_METADATA_ATTR)
|
|
41
|
+
|
|
42
|
+
# Set HTTP option
|
|
43
|
+
metadata.set_http_option(
|
|
44
|
+
method=method,
|
|
45
|
+
path=path,
|
|
46
|
+
body=body,
|
|
47
|
+
response_body=response_body,
|
|
48
|
+
additional_bindings=additional_bindings,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@wraps(func)
|
|
52
|
+
def wrapper(*args, **kwargs):
|
|
53
|
+
return func(*args, **kwargs)
|
|
54
|
+
|
|
55
|
+
# Preserve the metadata on the wrapper
|
|
56
|
+
setattr(wrapper, OPTION_METADATA_ATTR, metadata)
|
|
57
|
+
|
|
58
|
+
return wrapper # type: ignore
|
|
59
|
+
|
|
60
|
+
return decorator
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def proto_option(name: str, value: Any) -> Callable[[F], F]:
|
|
64
|
+
"""
|
|
65
|
+
Decorator to add a generic protobuf option to an RPC method.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
name: Option name (e.g., "deprecated", "idempotency_level")
|
|
69
|
+
value: Option value
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
@proto_option("deprecated", True)
|
|
73
|
+
@proto_option("idempotency_level", "IDEMPOTENT")
|
|
74
|
+
async def old_method(self, request: Request) -> Response:
|
|
75
|
+
...
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def decorator(func: F) -> F:
|
|
79
|
+
# Get or create option metadata
|
|
80
|
+
if not hasattr(func, OPTION_METADATA_ATTR):
|
|
81
|
+
setattr(func, OPTION_METADATA_ATTR, OptionMetadata())
|
|
82
|
+
|
|
83
|
+
metadata: OptionMetadata = getattr(func, OPTION_METADATA_ATTR)
|
|
84
|
+
|
|
85
|
+
# Add proto option
|
|
86
|
+
metadata.add_proto_option(name=name, value=value)
|
|
87
|
+
|
|
88
|
+
@wraps(func)
|
|
89
|
+
def wrapper(*args, **kwargs):
|
|
90
|
+
return func(*args, **kwargs)
|
|
91
|
+
|
|
92
|
+
# Preserve the metadata on the wrapper
|
|
93
|
+
setattr(wrapper, OPTION_METADATA_ATTR, metadata)
|
|
94
|
+
|
|
95
|
+
return wrapper # type: ignore
|
|
96
|
+
|
|
97
|
+
return decorator
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_method_options(method: Callable) -> Optional[OptionMetadata]:
|
|
101
|
+
"""
|
|
102
|
+
Get option metadata from a method.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
method: The method to get options from
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
OptionMetadata if present, None otherwise
|
|
109
|
+
"""
|
|
110
|
+
return getattr(method, OPTION_METADATA_ATTR, None)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def has_http_option(method: Callable) -> bool:
|
|
114
|
+
"""
|
|
115
|
+
Check if a method has an HTTP option.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
method: The method to check
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
True if the method has an HTTP option, False otherwise
|
|
122
|
+
"""
|
|
123
|
+
metadata = get_method_options(method)
|
|
124
|
+
return metadata is not None and metadata.http_option is not None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def has_proto_options(method: Callable) -> bool:
|
|
128
|
+
"""
|
|
129
|
+
Check if a method has any proto options.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
method: The method to check
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
True if the method has proto options, False otherwise
|
|
136
|
+
"""
|
|
137
|
+
metadata = get_method_options(method)
|
|
138
|
+
return metadata is not None and len(metadata.proto_options) > 0
|
pydantic_rpc/options.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Protocol Buffer options support for pydantic-rpc."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class HttpBinding(BaseModel):
|
|
8
|
+
"""HTTP binding configuration for a single HTTP method."""
|
|
9
|
+
|
|
10
|
+
method: str = Field(..., description="HTTP method (get, post, put, delete, patch)")
|
|
11
|
+
path: Optional[str] = Field(None, description="URL path template")
|
|
12
|
+
body: Optional[str] = Field(None, description="Request body mapping")
|
|
13
|
+
response_body: Optional[str] = Field(None, description="Response body mapping")
|
|
14
|
+
|
|
15
|
+
def to_proto_dict(self) -> Dict[str, Any]:
|
|
16
|
+
"""Convert to protobuf option format."""
|
|
17
|
+
result = {}
|
|
18
|
+
if self.path:
|
|
19
|
+
result[self.method.lower()] = self.path
|
|
20
|
+
if self.body:
|
|
21
|
+
result["body"] = self.body
|
|
22
|
+
if self.response_body:
|
|
23
|
+
result["response_body"] = self.response_body
|
|
24
|
+
return result
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HttpOption(BaseModel):
|
|
28
|
+
"""Google API HTTP option configuration."""
|
|
29
|
+
|
|
30
|
+
method: str = Field(..., description="Primary HTTP method")
|
|
31
|
+
path: str = Field(..., description="Primary URL path template")
|
|
32
|
+
body: Optional[str] = Field(None, description="Request body mapping")
|
|
33
|
+
response_body: Optional[str] = Field(None, description="Response body mapping")
|
|
34
|
+
additional_bindings: List[Dict[str, Any]] = Field(
|
|
35
|
+
default_factory=list, description="Additional HTTP bindings"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def to_proto_string(self) -> str:
|
|
39
|
+
"""Convert to protobuf option string format."""
|
|
40
|
+
lines = []
|
|
41
|
+
lines.append("option (google.api.http) = {")
|
|
42
|
+
|
|
43
|
+
# Primary binding
|
|
44
|
+
lines.append(f' {self.method.lower()}: "{self.path}"')
|
|
45
|
+
|
|
46
|
+
# Body mapping
|
|
47
|
+
if self.body:
|
|
48
|
+
lines.append(f' body: "{self.body}"')
|
|
49
|
+
|
|
50
|
+
# Response body mapping
|
|
51
|
+
if self.response_body:
|
|
52
|
+
lines.append(f' response_body: "{self.response_body}"')
|
|
53
|
+
|
|
54
|
+
# Additional bindings
|
|
55
|
+
for binding in self.additional_bindings:
|
|
56
|
+
lines.append(" additional_bindings {")
|
|
57
|
+
for key, value in binding.items():
|
|
58
|
+
if key == "body":
|
|
59
|
+
lines.append(f' {key}: "{value}"')
|
|
60
|
+
else:
|
|
61
|
+
lines.append(f' {key}: "{value}"')
|
|
62
|
+
lines.append(" }")
|
|
63
|
+
|
|
64
|
+
lines.append("};")
|
|
65
|
+
return "\n".join(lines)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ProtoOption(BaseModel):
|
|
69
|
+
"""Generic protocol buffer option."""
|
|
70
|
+
|
|
71
|
+
name: str = Field(..., description="Option name")
|
|
72
|
+
value: Any = Field(..., description="Option value")
|
|
73
|
+
|
|
74
|
+
def to_proto_string(self) -> str:
|
|
75
|
+
"""Convert to protobuf option string format."""
|
|
76
|
+
if isinstance(self.value, bool):
|
|
77
|
+
value_str = "true" if self.value else "false"
|
|
78
|
+
elif isinstance(self.value, str):
|
|
79
|
+
# Check if it's an enum value (no quotes) or string literal (quotes)
|
|
80
|
+
if self.value.isupper() or "_" in self.value:
|
|
81
|
+
# Likely an enum value
|
|
82
|
+
value_str = self.value
|
|
83
|
+
else:
|
|
84
|
+
value_str = f'"{self.value}"'
|
|
85
|
+
else:
|
|
86
|
+
value_str = str(self.value)
|
|
87
|
+
|
|
88
|
+
return f"option {self.name} = {value_str};"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class OptionMetadata(BaseModel):
|
|
92
|
+
"""Metadata container for method/service options."""
|
|
93
|
+
|
|
94
|
+
http_option: Optional[HttpOption] = None
|
|
95
|
+
proto_options: List[ProtoOption] = Field(default_factory=list)
|
|
96
|
+
|
|
97
|
+
def add_proto_option(self, name: str, value: Any) -> None:
|
|
98
|
+
"""Add a generic proto option."""
|
|
99
|
+
self.proto_options.append(ProtoOption(name=name, value=value))
|
|
100
|
+
|
|
101
|
+
def set_http_option(
|
|
102
|
+
self,
|
|
103
|
+
method: str,
|
|
104
|
+
path: str,
|
|
105
|
+
body: Optional[str] = None,
|
|
106
|
+
response_body: Optional[str] = None,
|
|
107
|
+
additional_bindings: Optional[List[Dict[str, Any]]] = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Set the HTTP option configuration."""
|
|
110
|
+
self.http_option = HttpOption(
|
|
111
|
+
method=method,
|
|
112
|
+
path=path,
|
|
113
|
+
body=body,
|
|
114
|
+
response_body=response_body,
|
|
115
|
+
additional_bindings=additional_bindings or [],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def to_proto_strings(self) -> List[str]:
|
|
119
|
+
"""Convert all options to protobuf strings."""
|
|
120
|
+
result = []
|
|
121
|
+
|
|
122
|
+
# Add HTTP option first if present
|
|
123
|
+
if self.http_option:
|
|
124
|
+
result.append(self.http_option.to_proto_string())
|
|
125
|
+
|
|
126
|
+
# Add other proto options
|
|
127
|
+
for option in self.proto_options:
|
|
128
|
+
result.append(option.to_proto_string())
|
|
129
|
+
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# Option metadata attribute name used on methods
|
|
134
|
+
OPTION_METADATA_ATTR = "__pydantic_rpc_options__"
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: pydantic-rpc
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: A Python library for building gRPC/ConnectRPC services with Pydantic models.
|
|
5
5
|
Author: Yasushi Itoh
|
|
6
|
-
Requires-Dist: annotated-types
|
|
6
|
+
Requires-Dist: annotated-types==0.7.0
|
|
7
7
|
Requires-Dist: pydantic>=2.1.1
|
|
8
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:
|
|
13
|
-
Requires-Dist: connecpy==2.0.0
|
|
12
|
+
Requires-Dist: connecpy>=2.2.0
|
|
14
13
|
Requires-Dist: mcp>=1.9.4
|
|
15
14
|
Requires-Dist: starlette>=0.27.0
|
|
16
15
|
Requires-Python: >=3.11
|
|
@@ -76,7 +75,7 @@ import asyncio
|
|
|
76
75
|
from openai import AsyncOpenAI
|
|
77
76
|
from pydantic_ai import Agent
|
|
78
77
|
from pydantic_ai.models.openai import OpenAIModel
|
|
79
|
-
from pydantic_rpc import
|
|
78
|
+
from pydantic_rpc import ASGIApp, Message
|
|
80
79
|
|
|
81
80
|
|
|
82
81
|
class CityLocation(Message):
|
|
@@ -107,7 +106,7 @@ class OlympicsLocationAgent:
|
|
|
107
106
|
result = await self._agent.run(req.prompt())
|
|
108
107
|
return result.data
|
|
109
108
|
|
|
110
|
-
app =
|
|
109
|
+
app = ASGIApp()
|
|
111
110
|
app.mount(OlympicsLocationAgent())
|
|
112
111
|
|
|
113
112
|
```
|
|
@@ -123,10 +122,10 @@ app.mount(OlympicsLocationAgent())
|
|
|
123
122
|
- 💚 **Health Checking:** Built-in support for gRPC health checks using `grpc_health.v1`.
|
|
124
123
|
- 🔎 **Server Reflection:** Built-in support for gRPC server reflection.
|
|
125
124
|
- ⚡ **Asynchronous Support:** Easily create asynchronous gRPC services with `AsyncIOServer`.
|
|
126
|
-
- **For gRPC-Web:**
|
|
127
|
-
- 🌐 **WSGI/ASGI Support:** Create gRPC-Web services that can run as WSGI or ASGI applications powered by `Sonora`.
|
|
128
125
|
- **For Connect-RPC:**
|
|
129
|
-
- 🌐 **
|
|
126
|
+
- 🌐 **Full Protocol Support:** Native Connect-RPC support via `Connecpy` v2.2.0+
|
|
127
|
+
- 🔄 **All Streaming Patterns:** Unary, server streaming, client streaming, and bidirectional streaming
|
|
128
|
+
- 🌐 **WSGI/ASGI Applications:** Run as standard WSGI or ASGI applications for easy deployment
|
|
130
129
|
- 🛠️ **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.
|
|
131
130
|
- 🤖 **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.
|
|
132
131
|
|
|
@@ -140,7 +139,11 @@ pip install pydantic-rpc
|
|
|
140
139
|
|
|
141
140
|
## 🚀 Getting Started
|
|
142
141
|
|
|
143
|
-
|
|
142
|
+
PydanticRPC supports two main protocols:
|
|
143
|
+
- **gRPC**: Traditional gRPC services with `Server` and `AsyncIOServer`
|
|
144
|
+
- **Connect-RPC**: Modern HTTP-based RPC with `ASGIApp` and `WSGIApp`
|
|
145
|
+
|
|
146
|
+
### 🔧 Synchronous gRPC Service Example
|
|
144
147
|
|
|
145
148
|
```python
|
|
146
149
|
from pydantic_rpc import Server, Message
|
|
@@ -161,7 +164,7 @@ if __name__ == "__main__":
|
|
|
161
164
|
server.run(Greeter())
|
|
162
165
|
```
|
|
163
166
|
|
|
164
|
-
### ⚙️ Asynchronous Service Example
|
|
167
|
+
### ⚙️ Asynchronous gRPC Service Example
|
|
165
168
|
|
|
166
169
|
```python
|
|
167
170
|
import asyncio
|
|
@@ -194,7 +197,7 @@ if __name__ == "__main__":
|
|
|
194
197
|
|
|
195
198
|
The AsyncIOServer automatically handles graceful shutdown on SIGTERM and SIGINT signals.
|
|
196
199
|
|
|
197
|
-
### 🌐 ASGI Application Example
|
|
200
|
+
### 🌐 Connect-RPC ASGI Application Example
|
|
198
201
|
|
|
199
202
|
```python
|
|
200
203
|
from pydantic_rpc import ASGIApp, Message
|
|
@@ -206,27 +209,17 @@ class HelloReply(Message):
|
|
|
206
209
|
message: str
|
|
207
210
|
|
|
208
211
|
class Greeter:
|
|
209
|
-
def say_hello(self, request: HelloRequest) -> HelloReply:
|
|
212
|
+
async def say_hello(self, request: HelloRequest) -> HelloReply:
|
|
210
213
|
return HelloReply(message=f"Hello, {request.name}!")
|
|
211
214
|
|
|
212
|
-
|
|
213
|
-
async def app(scope, receive, send):
|
|
214
|
-
"""ASGI application.
|
|
215
|
-
|
|
216
|
-
Args:
|
|
217
|
-
scope (dict): The ASGI scope.
|
|
218
|
-
receive (callable): The receive function.
|
|
219
|
-
send (callable): The send function.
|
|
220
|
-
"""
|
|
221
|
-
pass
|
|
222
|
-
|
|
223
|
-
# Please note that `app` is any ASGI application, such as FastAPI or Starlette.
|
|
224
|
-
|
|
225
|
-
app = ASGIApp(app)
|
|
215
|
+
app = ASGIApp()
|
|
226
216
|
app.mount(Greeter())
|
|
217
|
+
|
|
218
|
+
# Run with uvicorn:
|
|
219
|
+
# uvicorn script:app --host 0.0.0.0 --port 8000
|
|
227
220
|
```
|
|
228
221
|
|
|
229
|
-
### 🌐 WSGI Application Example
|
|
222
|
+
### 🌐 Connect-RPC WSGI Application Example
|
|
230
223
|
|
|
231
224
|
```python
|
|
232
225
|
from pydantic_rpc import WSGIApp, Message
|
|
@@ -241,31 +234,69 @@ class Greeter:
|
|
|
241
234
|
def say_hello(self, request: HelloRequest) -> HelloReply:
|
|
242
235
|
return HelloReply(message=f"Hello, {request.name}!")
|
|
243
236
|
|
|
244
|
-
|
|
245
|
-
"""WSGI application.
|
|
246
|
-
|
|
247
|
-
Args:
|
|
248
|
-
environ (dict): The WSGI environment.
|
|
249
|
-
start_response (callable): The start_response function.
|
|
250
|
-
"""
|
|
251
|
-
pass
|
|
252
|
-
|
|
253
|
-
# Please note that `app` is any WSGI application, such as Flask or Django.
|
|
254
|
-
|
|
255
|
-
app = WSGIApp(app)
|
|
237
|
+
app = WSGIApp()
|
|
256
238
|
app.mount(Greeter())
|
|
239
|
+
|
|
240
|
+
# Run with gunicorn:
|
|
241
|
+
# gunicorn script:app
|
|
257
242
|
```
|
|
258
243
|
|
|
259
|
-
### 🏆
|
|
244
|
+
### 🏆 Connect-RPC with Streaming Example
|
|
260
245
|
|
|
261
|
-
PydanticRPC
|
|
246
|
+
PydanticRPC provides native Connect-RPC support via Connecpy v2.2.0+, including full streaming capabilities and PEP 8 naming conventions. Check out our ASGI examples:
|
|
262
247
|
|
|
263
248
|
```bash
|
|
264
|
-
|
|
249
|
+
# Run with uvicorn
|
|
250
|
+
uv run uvicorn greeting_asgi:app --port 3000
|
|
251
|
+
|
|
252
|
+
# Or run streaming example
|
|
253
|
+
uv run python examples/streaming_connecpy.py
|
|
265
254
|
```
|
|
266
255
|
|
|
267
256
|
This will launch a Connecpy-based ASGI application that uses the same Pydantic models to serve Connect-RPC requests.
|
|
268
257
|
|
|
258
|
+
#### Streaming Support with Connecpy
|
|
259
|
+
|
|
260
|
+
Connecpy v2.2.0 provides full support for streaming RPCs with automatic PEP 8 naming (snake_case):
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
from typing import AsyncIterator
|
|
264
|
+
from pydantic_rpc import ASGIApp, Message
|
|
265
|
+
|
|
266
|
+
class StreamRequest(Message):
|
|
267
|
+
text: str
|
|
268
|
+
count: int
|
|
269
|
+
|
|
270
|
+
class StreamResponse(Message):
|
|
271
|
+
text: str
|
|
272
|
+
index: int
|
|
273
|
+
|
|
274
|
+
class StreamingService:
|
|
275
|
+
# Server streaming
|
|
276
|
+
async def server_stream(self, request: StreamRequest) -> AsyncIterator[StreamResponse]:
|
|
277
|
+
for i in range(request.count):
|
|
278
|
+
yield StreamResponse(text=f"{request.text}_{i}", index=i)
|
|
279
|
+
|
|
280
|
+
# Client streaming
|
|
281
|
+
async def client_stream(self, requests: AsyncIterator[StreamRequest]) -> StreamResponse:
|
|
282
|
+
texts = []
|
|
283
|
+
async for req in requests:
|
|
284
|
+
texts.append(req.text)
|
|
285
|
+
return StreamResponse(text=" ".join(texts), index=len(texts))
|
|
286
|
+
|
|
287
|
+
# Bidirectional streaming
|
|
288
|
+
async def bidi_stream(
|
|
289
|
+
self, requests: AsyncIterator[StreamRequest]
|
|
290
|
+
) -> AsyncIterator[StreamResponse]:
|
|
291
|
+
idx = 0
|
|
292
|
+
async for req in requests:
|
|
293
|
+
yield StreamResponse(text=f"Echo: {req.text}", index=idx)
|
|
294
|
+
idx += 1
|
|
295
|
+
|
|
296
|
+
app = ASGIApp()
|
|
297
|
+
app.mount(StreamingService())
|
|
298
|
+
```
|
|
299
|
+
|
|
269
300
|
> [!NOTE]
|
|
270
301
|
> Please install `protoc-gen-connecpy` to run the Connecpy example.
|
|
271
302
|
>
|
|
@@ -302,9 +333,9 @@ export PYDANTIC_RPC_RESERVED_FIELDS=1
|
|
|
302
333
|
|
|
303
334
|
## 💎 Advanced Features
|
|
304
335
|
|
|
305
|
-
### 🌊 Response Streaming
|
|
306
|
-
PydanticRPC supports streaming responses
|
|
307
|
-
If a service class method
|
|
336
|
+
### 🌊 Response Streaming (gRPC)
|
|
337
|
+
PydanticRPC supports streaming responses for both gRPC and Connect-RPC services.
|
|
338
|
+
If a service class method's return type is `typing.AsyncIterator[T]`, the method is considered a streaming method.
|
|
308
339
|
|
|
309
340
|
|
|
310
341
|
Please see the sample code below:
|
|
@@ -871,8 +902,9 @@ class GoodMessage(Message):
|
|
|
871
902
|
- Be aware that errors fail silently
|
|
872
903
|
|
|
873
904
|
### 🔗 Multiple Services with Custom Interceptors
|
|
905
|
+
>>>>>>> origin/main
|
|
874
906
|
|
|
875
|
-
PydanticRPC supports defining and running multiple services in a single server:
|
|
907
|
+
PydanticRPC supports defining and running multiple gRPC services in a single server:
|
|
876
908
|
|
|
877
909
|
```python
|
|
878
910
|
from datetime import datetime
|
|
@@ -1003,11 +1035,11 @@ Any MCP-compatible client can connect to your service. For example, to configure
|
|
|
1003
1035
|
MCP can also be mounted to existing ASGI applications:
|
|
1004
1036
|
|
|
1005
1037
|
```python
|
|
1006
|
-
from pydantic_rpc import
|
|
1038
|
+
from pydantic_rpc import ASGIApp
|
|
1007
1039
|
from pydantic_rpc.mcp import MCPExporter
|
|
1008
1040
|
|
|
1009
1041
|
# Create Connect-RPC ASGI app
|
|
1010
|
-
app =
|
|
1042
|
+
app = ASGIApp()
|
|
1011
1043
|
app.mount(MathService())
|
|
1012
1044
|
|
|
1013
1045
|
# Add MCP support via HTTP/SSE
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pydantic_rpc/__init__.py,sha256=cd21549eab234e0bc3b3744713c65486b00a63e9ca8d4696506b819099fc1f79,590
|
|
2
|
+
pydantic_rpc/core.py,sha256=ccc7a5f91dc471a0ecc253a5fd766a24878308be7905532ee7c6b9b51b9a1b8e,111436
|
|
3
|
+
pydantic_rpc/decorators.py,sha256=2f2dc3f642a6e40d05187901e75269415a70b8f18d555e6c7ac923dee149d34c,3852
|
|
4
|
+
pydantic_rpc/mcp/__init__.py,sha256=f05ad62cc38db5c5972e16870c484523c58c5ac650d8454706f1ce4539cc7a52,123
|
|
5
|
+
pydantic_rpc/mcp/converter.py,sha256=b60dcaf82e6bff6be4b2ab8b1e9f2d16e09cb98d8f00182a4f6f73d1a78848a4,4158
|
|
6
|
+
pydantic_rpc/mcp/exporter.py,sha256=20833662f9a973ed9e1a705b9b59aaa2533de6c514ebd87ef66664a430a0d04f,10239
|
|
7
|
+
pydantic_rpc/options.py,sha256=6094036184a500b92715fd91d31ecc501460a56de47dcf6024c6335ae8e5d6e3,4561
|
|
8
|
+
pydantic_rpc/py.typed,sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855,0
|
|
9
|
+
pydantic_rpc-0.9.0.dist-info/WHEEL,sha256=ab6157bc637547491fb4567cd7ddf26b04d63382916ca16c29a5c8e94c9c9ef7,79
|
|
10
|
+
pydantic_rpc-0.9.0.dist-info/entry_points.txt,sha256=72a47b1d2cae3abc045710fe0c2c2e6dfbb051fbf6960c22b62488004e9188ba,57
|
|
11
|
+
pydantic_rpc-0.9.0.dist-info/METADATA,sha256=7a022eb4a99c02b665ceb0dca3d48ad587ade87f9be0f08f7b6c378738bd043e,30976
|
|
12
|
+
pydantic_rpc-0.9.0.dist-info/RECORD,,
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
pydantic_rpc/__init__.py,sha256=7dbedaf58742b13e96954afb68ca91f9800a2faf0799f78da6070c9c88511065,440
|
|
2
|
-
pydantic_rpc/core.py,sha256=2e2bce8aaf677ae50fdcdde51507da605ed77efe5e1a522e52b406c416b18318,104265
|
|
3
|
-
pydantic_rpc/mcp/__init__.py,sha256=f05ad62cc38db5c5972e16870c484523c58c5ac650d8454706f1ce4539cc7a52,123
|
|
4
|
-
pydantic_rpc/mcp/converter.py,sha256=b60dcaf82e6bff6be4b2ab8b1e9f2d16e09cb98d8f00182a4f6f73d1a78848a4,4158
|
|
5
|
-
pydantic_rpc/mcp/exporter.py,sha256=20833662f9a973ed9e1a705b9b59aaa2533de6c514ebd87ef66664a430a0d04f,10239
|
|
6
|
-
pydantic_rpc/py.typed,sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855,0
|
|
7
|
-
pydantic_rpc-0.8.0.dist-info/WHEEL,sha256=ab6157bc637547491fb4567cd7ddf26b04d63382916ca16c29a5c8e94c9c9ef7,79
|
|
8
|
-
pydantic_rpc-0.8.0.dist-info/entry_points.txt,sha256=72a47b1d2cae3abc045710fe0c2c2e6dfbb051fbf6960c22b62488004e9188ba,57
|
|
9
|
-
pydantic_rpc-0.8.0.dist-info/METADATA,sha256=1cb4d588db71dd052e24c12c63d368c89520279bc9c9a8b10658fdd0d83ee0ec,29803
|
|
10
|
-
pydantic_rpc-0.8.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|