pydantic-rpc 0.7.0__py3-none-any.whl → 0.8.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 +0 -0
- pydantic_rpc/core.py +414 -44
- pydantic_rpc/mcp/__init__.py +0 -0
- pydantic_rpc/mcp/converter.py +0 -0
- pydantic_rpc/mcp/exporter.py +0 -0
- pydantic_rpc/py.typed +0 -0
- {pydantic_rpc-0.7.0.dist-info → pydantic_rpc-0.8.0.dist-info}/METADATA +208 -12
- pydantic_rpc-0.8.0.dist-info/RECORD +10 -0
- pydantic_rpc-0.8.0.dist-info/WHEEL +4 -0
- {pydantic_rpc-0.7.0.dist-info → pydantic_rpc-0.8.0.dist-info}/entry_points.txt +1 -0
- pydantic_rpc-0.7.0.dist-info/RECORD +0 -11
- pydantic_rpc-0.7.0.dist-info/WHEEL +0 -4
- pydantic_rpc-0.7.0.dist-info/licenses/LICENSE +0 -21
pydantic_rpc/__init__.py
CHANGED
|
File without changes
|
pydantic_rpc/core.py
CHANGED
|
@@ -26,8 +26,6 @@ import annotated_types
|
|
|
26
26
|
import grpc
|
|
27
27
|
from grpc import ServicerContext
|
|
28
28
|
import grpc_tools
|
|
29
|
-
from connecpy.server import ConnecpyASGIApplication as ConnecpyASGI
|
|
30
|
-
from connecpy.server import ConnecpyWSGIApplication as ConnecpyWSGI
|
|
31
29
|
from connecpy.code import Code as Errors
|
|
32
30
|
|
|
33
31
|
# Protobuf Python modules for Timestamp, Duration (requires protobuf / grpcio)
|
|
@@ -49,6 +47,50 @@ from sonora.wsgi import grpcWSGI
|
|
|
49
47
|
|
|
50
48
|
Message: TypeAlias = BaseModel
|
|
51
49
|
|
|
50
|
+
# Cache for serializer checks
|
|
51
|
+
_has_serializers_cache: dict[type[Message], bool] = {}
|
|
52
|
+
_has_field_serializers_cache: dict[type[Message], set[str]] = {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Serializer strategy configuration
|
|
56
|
+
class SerializerStrategy(enum.Enum):
|
|
57
|
+
"""Strategy for applying Pydantic serializers."""
|
|
58
|
+
|
|
59
|
+
NONE = "none" # No serializers applied
|
|
60
|
+
SHALLOW = "shallow" # Only top-level serializers
|
|
61
|
+
DEEP = "deep" # Nested serializers too (default)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Get strategy from environment variable
|
|
65
|
+
_SERIALIZER_STRATEGY = SerializerStrategy(
|
|
66
|
+
os.getenv("PYDANTIC_RPC_SERIALIZER_STRATEGY", "deep").lower()
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def has_serializers(msg_type: type[Message]) -> bool:
|
|
71
|
+
"""Check if a Message type has any serializers (cached for performance)."""
|
|
72
|
+
if msg_type not in _has_serializers_cache:
|
|
73
|
+
# Check for both field and model serializers
|
|
74
|
+
has_field = bool(getattr(msg_type, "__pydantic_serializer__", None))
|
|
75
|
+
has_model = bool(
|
|
76
|
+
getattr(msg_type, "model_dump", None) and msg_type != BaseModel
|
|
77
|
+
)
|
|
78
|
+
_has_serializers_cache[msg_type] = has_field or has_model
|
|
79
|
+
return _has_serializers_cache[msg_type]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_field_serializers(msg_type: type[Message]) -> set[str]:
|
|
83
|
+
"""Get the set of fields that have serializers (cached for performance)."""
|
|
84
|
+
if msg_type not in _has_field_serializers_cache:
|
|
85
|
+
fields_with_serializers = set()
|
|
86
|
+
# Check model_fields_set or similar for fields with serializers
|
|
87
|
+
if hasattr(msg_type, "__pydantic_decorators__"):
|
|
88
|
+
decorators = msg_type.__pydantic_decorators__
|
|
89
|
+
if hasattr(decorators, "field_serializers"):
|
|
90
|
+
fields_with_serializers = set(decorators.field_serializers.keys())
|
|
91
|
+
_has_field_serializers_cache[msg_type] = fields_with_serializers
|
|
92
|
+
return _has_field_serializers_cache[msg_type]
|
|
93
|
+
|
|
52
94
|
|
|
53
95
|
def is_none_type(annotation: Any) -> TypeGuard[type[None] | None]:
|
|
54
96
|
"""Check if annotation represents None/NoneType (handles both None and type(None))."""
|
|
@@ -149,6 +191,14 @@ def generate_converter(annotation: type[Any] | None) -> Callable[[Any], Any]:
|
|
|
149
191
|
|
|
150
192
|
# For Message classes
|
|
151
193
|
if inspect.isclass(annotation) and issubclass(annotation, Message):
|
|
194
|
+
# Check if it's an empty message class
|
|
195
|
+
if not annotation.model_fields:
|
|
196
|
+
|
|
197
|
+
def empty_message_converter(value: empty_pb2.Empty): # type: ignore
|
|
198
|
+
_ = value
|
|
199
|
+
return annotation() # Return instance of the empty message class
|
|
200
|
+
|
|
201
|
+
return empty_message_converter
|
|
152
202
|
return generate_message_converter(annotation)
|
|
153
203
|
|
|
154
204
|
# For union types or other unsupported cases, just return the value as-is.
|
|
@@ -171,6 +221,15 @@ def generate_message_converter(
|
|
|
171
221
|
|
|
172
222
|
arg_type = cast("type[Message]", arg_type)
|
|
173
223
|
fields = arg_type.model_fields
|
|
224
|
+
|
|
225
|
+
# Handle empty message classes (no fields)
|
|
226
|
+
if not fields:
|
|
227
|
+
|
|
228
|
+
def empty_message_converter(request: Any) -> Message:
|
|
229
|
+
_ = request # The incoming request will be google.protobuf.Empty
|
|
230
|
+
return arg_type() # Return an instance of the empty message class
|
|
231
|
+
|
|
232
|
+
return empty_message_converter
|
|
174
233
|
converters = {
|
|
175
234
|
field: generate_converter(field_type.annotation) # type: ignore
|
|
176
235
|
for field, field_type in fields.items()
|
|
@@ -238,7 +297,12 @@ def generate_message_converter(
|
|
|
238
297
|
# For non-union fields, convert normally
|
|
239
298
|
rdict[field_name] = converters[field_name](getattr(request, field_name))
|
|
240
299
|
|
|
241
|
-
|
|
300
|
+
# Use model_validate to support @model_validator
|
|
301
|
+
try:
|
|
302
|
+
return arg_type.model_validate(rdict)
|
|
303
|
+
except AttributeError:
|
|
304
|
+
# Fallback for older Pydantic versions or if model_validate doesn't exist
|
|
305
|
+
return arg_type(**rdict)
|
|
242
306
|
|
|
243
307
|
return converter
|
|
244
308
|
|
|
@@ -294,7 +358,7 @@ def connect_obj_with_stub(
|
|
|
294
358
|
param_count = len(sig.parameters)
|
|
295
359
|
converter = generate_message_converter(arg_type)
|
|
296
360
|
|
|
297
|
-
if param_count == 1:
|
|
361
|
+
if param_count == 0 or param_count == 1:
|
|
298
362
|
|
|
299
363
|
def stub_method(
|
|
300
364
|
self: object,
|
|
@@ -305,7 +369,10 @@ def connect_obj_with_stub(
|
|
|
305
369
|
) -> Any:
|
|
306
370
|
_ = self
|
|
307
371
|
try:
|
|
308
|
-
if
|
|
372
|
+
if param_count == 0:
|
|
373
|
+
# Method takes no parameters
|
|
374
|
+
resp_obj = original()
|
|
375
|
+
elif is_none_type(arg_type):
|
|
309
376
|
resp_obj = original(None) # Fixed: pass None instead of no args
|
|
310
377
|
else:
|
|
311
378
|
arg = converter(request)
|
|
@@ -313,6 +380,13 @@ def connect_obj_with_stub(
|
|
|
313
380
|
|
|
314
381
|
if is_none_type(response_type):
|
|
315
382
|
return empty_pb2.Empty() # type: ignore
|
|
383
|
+
elif (
|
|
384
|
+
inspect.isclass(response_type)
|
|
385
|
+
and issubclass(response_type, Message)
|
|
386
|
+
and not response_type.model_fields
|
|
387
|
+
):
|
|
388
|
+
# Empty message class
|
|
389
|
+
return empty_pb2.Empty() # type: ignore
|
|
316
390
|
else:
|
|
317
391
|
return convert_python_message_to_proto(
|
|
318
392
|
resp_obj, response_type, pb2_module
|
|
@@ -343,6 +417,13 @@ def connect_obj_with_stub(
|
|
|
343
417
|
|
|
344
418
|
if is_none_type(response_type):
|
|
345
419
|
return empty_pb2.Empty() # type: ignore
|
|
420
|
+
elif (
|
|
421
|
+
inspect.isclass(response_type)
|
|
422
|
+
and issubclass(response_type, Message)
|
|
423
|
+
and not response_type.model_fields
|
|
424
|
+
):
|
|
425
|
+
# Empty message class
|
|
426
|
+
return empty_pb2.Empty() # type: ignore
|
|
346
427
|
else:
|
|
347
428
|
return convert_python_message_to_proto(
|
|
348
429
|
resp_obj, response_type, pb2_module
|
|
@@ -391,9 +472,9 @@ def connect_obj_with_stub_async(
|
|
|
391
472
|
is_output_stream = is_stream_type(response_type)
|
|
392
473
|
size_of_parameters = len(sig.parameters)
|
|
393
474
|
|
|
394
|
-
if size_of_parameters not in (1, 2):
|
|
475
|
+
if size_of_parameters not in (0, 1, 2):
|
|
395
476
|
raise TypeError(
|
|
396
|
-
f"Method '{method.__name__}' must have 1 or 2 parameters, got {size_of_parameters}"
|
|
477
|
+
f"Method '{method.__name__}' must have 0, 1 or 2 parameters, got {size_of_parameters}"
|
|
397
478
|
)
|
|
398
479
|
|
|
399
480
|
if is_input_stream:
|
|
@@ -559,7 +640,7 @@ def connect_obj_with_stub_async(
|
|
|
559
640
|
|
|
560
641
|
else:
|
|
561
642
|
# unary-unary
|
|
562
|
-
if size_of_parameters == 1:
|
|
643
|
+
if size_of_parameters == 0 or size_of_parameters == 1:
|
|
563
644
|
|
|
564
645
|
async def stub_method(
|
|
565
646
|
self: object,
|
|
@@ -568,7 +649,10 @@ def connect_obj_with_stub_async(
|
|
|
568
649
|
) -> Any:
|
|
569
650
|
_ = self
|
|
570
651
|
try:
|
|
571
|
-
if
|
|
652
|
+
if size_of_parameters == 0:
|
|
653
|
+
# Method takes no parameters
|
|
654
|
+
resp_obj = await method()
|
|
655
|
+
elif is_none_type(input_type):
|
|
572
656
|
resp_obj = await method(None)
|
|
573
657
|
else:
|
|
574
658
|
arg = converter(request)
|
|
@@ -576,6 +660,13 @@ def connect_obj_with_stub_async(
|
|
|
576
660
|
|
|
577
661
|
if is_none_type(response_type):
|
|
578
662
|
return empty_pb2.Empty() # type: ignore
|
|
663
|
+
elif (
|
|
664
|
+
inspect.isclass(response_type)
|
|
665
|
+
and issubclass(response_type, Message)
|
|
666
|
+
and not response_type.model_fields
|
|
667
|
+
):
|
|
668
|
+
# Empty message class
|
|
669
|
+
return empty_pb2.Empty() # type: ignore
|
|
579
670
|
else:
|
|
580
671
|
return convert_python_message_to_proto(
|
|
581
672
|
resp_obj, response_type, pb2_module
|
|
@@ -604,6 +695,13 @@ def connect_obj_with_stub_async(
|
|
|
604
695
|
|
|
605
696
|
if is_none_type(response_type):
|
|
606
697
|
return empty_pb2.Empty() # type: ignore
|
|
698
|
+
elif (
|
|
699
|
+
inspect.isclass(response_type)
|
|
700
|
+
and issubclass(response_type, Message)
|
|
701
|
+
and not response_type.model_fields
|
|
702
|
+
):
|
|
703
|
+
# Empty message class
|
|
704
|
+
return empty_pb2.Empty() # type: ignore
|
|
607
705
|
else:
|
|
608
706
|
return convert_python_message_to_proto(
|
|
609
707
|
resp_obj, response_type, pb2_module
|
|
@@ -650,6 +748,31 @@ def connect_obj_with_stub_connecpy(
|
|
|
650
748
|
size_of_parameters = len(sig.parameters)
|
|
651
749
|
|
|
652
750
|
match size_of_parameters:
|
|
751
|
+
case 0:
|
|
752
|
+
# Method with no parameters (empty request)
|
|
753
|
+
def stub_method0(
|
|
754
|
+
self: object,
|
|
755
|
+
request: Any,
|
|
756
|
+
context: Any,
|
|
757
|
+
method: Callable[..., Message] = method,
|
|
758
|
+
) -> Any:
|
|
759
|
+
_ = self
|
|
760
|
+
try:
|
|
761
|
+
resp_obj = method()
|
|
762
|
+
|
|
763
|
+
if is_none_type(response_type):
|
|
764
|
+
return empty_pb2.Empty() # type: ignore
|
|
765
|
+
else:
|
|
766
|
+
return convert_python_message_to_proto(
|
|
767
|
+
resp_obj, response_type, pb2_module
|
|
768
|
+
)
|
|
769
|
+
except ValidationError as e:
|
|
770
|
+
return context.abort(Errors.INVALID_ARGUMENT, str(e))
|
|
771
|
+
except Exception as e:
|
|
772
|
+
return context.abort(Errors.INTERNAL, str(e))
|
|
773
|
+
|
|
774
|
+
return stub_method0
|
|
775
|
+
|
|
653
776
|
case 1:
|
|
654
777
|
|
|
655
778
|
def stub_method1(
|
|
@@ -709,7 +832,7 @@ def connect_obj_with_stub_connecpy(
|
|
|
709
832
|
return stub_method2
|
|
710
833
|
|
|
711
834
|
case _:
|
|
712
|
-
raise Exception("Method must have
|
|
835
|
+
raise Exception("Method must have 0, 1, or 2 parameters")
|
|
713
836
|
|
|
714
837
|
for method_name, method in get_rpc_methods(obj):
|
|
715
838
|
if method.__name__.startswith("_"):
|
|
@@ -743,6 +866,31 @@ def connect_obj_with_stub_async_connecpy(
|
|
|
743
866
|
size_of_parameters = len(sig.parameters)
|
|
744
867
|
|
|
745
868
|
match size_of_parameters:
|
|
869
|
+
case 0:
|
|
870
|
+
# Method with no parameters (empty request)
|
|
871
|
+
async def stub_method0(
|
|
872
|
+
self: object,
|
|
873
|
+
request: Any,
|
|
874
|
+
context: Any,
|
|
875
|
+
method: Callable[..., Awaitable[Message]] = method,
|
|
876
|
+
) -> Any:
|
|
877
|
+
_ = self
|
|
878
|
+
try:
|
|
879
|
+
resp_obj = await method()
|
|
880
|
+
|
|
881
|
+
if is_none_type(response_type):
|
|
882
|
+
return empty_pb2.Empty() # type: ignore
|
|
883
|
+
else:
|
|
884
|
+
return convert_python_message_to_proto(
|
|
885
|
+
resp_obj, response_type, pb2_module
|
|
886
|
+
)
|
|
887
|
+
except ValidationError as e:
|
|
888
|
+
await context.abort(Errors.INVALID_ARGUMENT, str(e))
|
|
889
|
+
except Exception as e:
|
|
890
|
+
await context.abort(Errors.INTERNAL, str(e))
|
|
891
|
+
|
|
892
|
+
return stub_method0
|
|
893
|
+
|
|
746
894
|
case 1:
|
|
747
895
|
|
|
748
896
|
async def stub_method1(
|
|
@@ -802,7 +950,7 @@ def connect_obj_with_stub_async_connecpy(
|
|
|
802
950
|
return stub_method2
|
|
803
951
|
|
|
804
952
|
case _:
|
|
805
|
-
raise Exception("Method must have
|
|
953
|
+
raise Exception("Method must have 0, 1, or 2 parameters")
|
|
806
954
|
|
|
807
955
|
for method_name, method in get_rpc_methods(obj):
|
|
808
956
|
if method.__name__.startswith("_"):
|
|
@@ -816,7 +964,12 @@ def connect_obj_with_stub_async_connecpy(
|
|
|
816
964
|
|
|
817
965
|
|
|
818
966
|
def python_value_to_proto_oneof(
|
|
819
|
-
field_name: str,
|
|
967
|
+
field_name: str,
|
|
968
|
+
field_type: type[Any],
|
|
969
|
+
value: Any,
|
|
970
|
+
pb2_module: Any,
|
|
971
|
+
_visited: set[int] | None = None,
|
|
972
|
+
_depth: int = 0,
|
|
820
973
|
) -> tuple[str, Any]:
|
|
821
974
|
"""
|
|
822
975
|
Converts a Python value from a Union type to a protobuf oneof field.
|
|
@@ -847,20 +1000,120 @@ def python_value_to_proto_oneof(
|
|
|
847
1000
|
raise TypeError(f"Unsupported type in oneof: {actual_type}")
|
|
848
1001
|
|
|
849
1002
|
oneof_field_name = f"{field_name}_{proto_typename.replace('.', '_')}"
|
|
850
|
-
converted_value = python_value_to_proto(
|
|
1003
|
+
converted_value = python_value_to_proto(
|
|
1004
|
+
actual_type, value, pb2_module, _visited, _depth
|
|
1005
|
+
)
|
|
851
1006
|
return oneof_field_name, converted_value
|
|
852
1007
|
|
|
853
1008
|
|
|
854
1009
|
def convert_python_message_to_proto(
|
|
855
|
-
py_msg: Message,
|
|
1010
|
+
py_msg: Message,
|
|
1011
|
+
msg_type: type[Message],
|
|
1012
|
+
pb2_module: Any,
|
|
1013
|
+
_visited: set[int] | None = None,
|
|
1014
|
+
_depth: int = 0,
|
|
856
1015
|
) -> object:
|
|
857
|
-
"""Convert a Python Pydantic Message instance to a protobuf message instance. Used for constructing a response.
|
|
1016
|
+
"""Convert a Python Pydantic Message instance to a protobuf message instance. Used for constructing a response.
|
|
1017
|
+
|
|
1018
|
+
Args:
|
|
1019
|
+
py_msg: The Pydantic Message instance to convert
|
|
1020
|
+
msg_type: The type of the Message
|
|
1021
|
+
pb2_module: The protobuf module
|
|
1022
|
+
_visited: Internal set to track visited objects for circular reference detection
|
|
1023
|
+
_depth: Current recursion depth for strategy control
|
|
1024
|
+
"""
|
|
1025
|
+
# Handle empty message classes
|
|
1026
|
+
if not msg_type.model_fields:
|
|
1027
|
+
# Return google.protobuf.Empty instance
|
|
1028
|
+
return empty_pb2.Empty()
|
|
1029
|
+
|
|
1030
|
+
# Initialize visited set for circular reference detection
|
|
1031
|
+
if _visited is None:
|
|
1032
|
+
_visited = set()
|
|
1033
|
+
|
|
1034
|
+
# Check for circular references
|
|
1035
|
+
obj_id = id(py_msg)
|
|
1036
|
+
if obj_id in _visited:
|
|
1037
|
+
# Return empty proto to avoid infinite recursion
|
|
1038
|
+
proto_class = getattr(pb2_module, msg_type.__name__)
|
|
1039
|
+
return proto_class()
|
|
1040
|
+
_visited.add(obj_id)
|
|
1041
|
+
|
|
1042
|
+
# Determine if we should apply serializers based on strategy
|
|
1043
|
+
apply_serializers = False
|
|
1044
|
+
if _SERIALIZER_STRATEGY == SerializerStrategy.DEEP:
|
|
1045
|
+
apply_serializers = True # Always apply at any depth
|
|
1046
|
+
elif _SERIALIZER_STRATEGY == SerializerStrategy.SHALLOW:
|
|
1047
|
+
apply_serializers = _depth == 0 # Only at top level
|
|
1048
|
+
# SerializerStrategy.NONE: never apply
|
|
1049
|
+
|
|
1050
|
+
# Only use model_dump if there are serializers and strategy allows
|
|
1051
|
+
serialized_data = None
|
|
1052
|
+
if apply_serializers and has_serializers(msg_type):
|
|
1053
|
+
try:
|
|
1054
|
+
serialized_data = py_msg.model_dump(mode="python")
|
|
1055
|
+
except Exception:
|
|
1056
|
+
# Fallback to the old approach if model_dump fails
|
|
1057
|
+
serialized_data = None
|
|
1058
|
+
|
|
858
1059
|
field_dict = {}
|
|
859
1060
|
for name, field_info in msg_type.model_fields.items():
|
|
1061
|
+
field_type = field_info.annotation
|
|
1062
|
+
|
|
1063
|
+
# Check if this field type contains Message types
|
|
1064
|
+
contains_message = False
|
|
1065
|
+
if field_type:
|
|
1066
|
+
if inspect.isclass(field_type) and issubclass(field_type, Message):
|
|
1067
|
+
contains_message = True
|
|
1068
|
+
elif is_union_type(field_type):
|
|
1069
|
+
# Check if any of the union args are Messages
|
|
1070
|
+
for arg in flatten_union(field_type):
|
|
1071
|
+
if arg and inspect.isclass(arg) and issubclass(arg, Message):
|
|
1072
|
+
contains_message = True
|
|
1073
|
+
break
|
|
1074
|
+
else:
|
|
1075
|
+
# Check for list/dict of Messages
|
|
1076
|
+
origin = get_origin(field_type)
|
|
1077
|
+
if origin in (list, tuple):
|
|
1078
|
+
inner_type = (
|
|
1079
|
+
get_args(field_type)[0] if get_args(field_type) else None
|
|
1080
|
+
)
|
|
1081
|
+
if (
|
|
1082
|
+
inner_type
|
|
1083
|
+
and inspect.isclass(inner_type)
|
|
1084
|
+
and issubclass(inner_type, Message)
|
|
1085
|
+
):
|
|
1086
|
+
contains_message = True
|
|
1087
|
+
elif origin is dict:
|
|
1088
|
+
args = get_args(field_type)
|
|
1089
|
+
if len(args) >= 2:
|
|
1090
|
+
val_type = args[1]
|
|
1091
|
+
if inspect.isclass(val_type) and issubclass(val_type, Message):
|
|
1092
|
+
contains_message = True
|
|
1093
|
+
|
|
1094
|
+
# Get the value
|
|
860
1095
|
value = getattr(py_msg, name)
|
|
861
1096
|
if value is None:
|
|
862
1097
|
continue
|
|
863
1098
|
|
|
1099
|
+
# For Message types, recursively apply serialization
|
|
1100
|
+
if contains_message and not is_union_type(field_type):
|
|
1101
|
+
# Direct Message field
|
|
1102
|
+
if inspect.isclass(field_type) and issubclass(field_type, Message):
|
|
1103
|
+
# Recursively convert nested Message with serializers
|
|
1104
|
+
field_dict[name] = convert_python_message_to_proto(
|
|
1105
|
+
value, field_type, pb2_module, _visited, _depth + 1
|
|
1106
|
+
)
|
|
1107
|
+
continue
|
|
1108
|
+
|
|
1109
|
+
# Use serialized data for non-Message types to respect custom serializers
|
|
1110
|
+
if (
|
|
1111
|
+
serialized_data is not None
|
|
1112
|
+
and name in serialized_data
|
|
1113
|
+
and not contains_message
|
|
1114
|
+
):
|
|
1115
|
+
value = serialized_data[name]
|
|
1116
|
+
|
|
864
1117
|
field_type = field_info.annotation
|
|
865
1118
|
|
|
866
1119
|
# Handle oneof fields, which are represented as Unions.
|
|
@@ -874,20 +1127,33 @@ def convert_python_message_to_proto(
|
|
|
874
1127
|
(
|
|
875
1128
|
oneof_field_name,
|
|
876
1129
|
converted_value,
|
|
877
|
-
) = python_value_to_proto_oneof(
|
|
1130
|
+
) = python_value_to_proto_oneof(
|
|
1131
|
+
name, field_type, value, pb2_module, _visited, _depth
|
|
1132
|
+
)
|
|
878
1133
|
field_dict[oneof_field_name] = converted_value
|
|
879
1134
|
continue
|
|
880
1135
|
|
|
881
1136
|
# For regular and Optional fields that have a value.
|
|
882
1137
|
if field_type is not None:
|
|
883
|
-
field_dict[name] = python_value_to_proto(
|
|
1138
|
+
field_dict[name] = python_value_to_proto(
|
|
1139
|
+
field_type, value, pb2_module, _visited, _depth
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1142
|
+
# Remove from visited set when done
|
|
1143
|
+
_visited.discard(obj_id)
|
|
884
1144
|
|
|
885
1145
|
# Retrieve the appropriate protobuf class dynamically
|
|
886
1146
|
proto_class = getattr(pb2_module, msg_type.__name__)
|
|
887
1147
|
return proto_class(**field_dict)
|
|
888
1148
|
|
|
889
1149
|
|
|
890
|
-
def python_value_to_proto(
|
|
1150
|
+
def python_value_to_proto(
|
|
1151
|
+
field_type: type[Any],
|
|
1152
|
+
value: Any,
|
|
1153
|
+
pb2_module: Any,
|
|
1154
|
+
_visited: set[int] | None = None,
|
|
1155
|
+
_depth: int = 0,
|
|
1156
|
+
) -> Any:
|
|
891
1157
|
"""
|
|
892
1158
|
Perform Python->protobuf type conversion for each field value.
|
|
893
1159
|
"""
|
|
@@ -910,15 +1176,36 @@ def python_value_to_proto(field_type: type[Any], value: Any, pb2_module: Any) ->
|
|
|
910
1176
|
# If seq
|
|
911
1177
|
if origin in (list, tuple):
|
|
912
1178
|
inner_type = get_args(field_type)[0]
|
|
913
|
-
|
|
1179
|
+
# Handle list of Messages
|
|
1180
|
+
if inspect.isclass(inner_type) and issubclass(inner_type, Message):
|
|
1181
|
+
return [
|
|
1182
|
+
convert_python_message_to_proto(
|
|
1183
|
+
v, inner_type, pb2_module, _visited, _depth + 1
|
|
1184
|
+
)
|
|
1185
|
+
for v in value
|
|
1186
|
+
]
|
|
1187
|
+
return [
|
|
1188
|
+
python_value_to_proto(inner_type, v, pb2_module, _visited, _depth)
|
|
1189
|
+
for v in value
|
|
1190
|
+
]
|
|
914
1191
|
|
|
915
1192
|
# If dict
|
|
916
1193
|
if origin is dict:
|
|
917
1194
|
key_type, val_type = get_args(field_type)
|
|
1195
|
+
# Handle dict with Message values
|
|
1196
|
+
if inspect.isclass(val_type) and issubclass(val_type, Message):
|
|
1197
|
+
return {
|
|
1198
|
+
python_value_to_proto(
|
|
1199
|
+
key_type, k, pb2_module, _visited, _depth
|
|
1200
|
+
): convert_python_message_to_proto(
|
|
1201
|
+
v, val_type, pb2_module, _visited, _depth + 1
|
|
1202
|
+
)
|
|
1203
|
+
for k, v in value.items()
|
|
1204
|
+
}
|
|
918
1205
|
return {
|
|
919
|
-
python_value_to_proto(
|
|
920
|
-
|
|
921
|
-
)
|
|
1206
|
+
python_value_to_proto(
|
|
1207
|
+
key_type, k, pb2_module, _visited, _depth
|
|
1208
|
+
): python_value_to_proto(val_type, v, pb2_module, _visited, _depth)
|
|
922
1209
|
for k, v in value.items()
|
|
923
1210
|
}
|
|
924
1211
|
|
|
@@ -930,12 +1217,16 @@ def python_value_to_proto(field_type: type[Any], value: Any, pb2_module: Any) ->
|
|
|
930
1217
|
]
|
|
931
1218
|
if non_none_args:
|
|
932
1219
|
# Assuming it's an Optional[T], so there's one type left.
|
|
933
|
-
return python_value_to_proto(
|
|
1220
|
+
return python_value_to_proto(
|
|
1221
|
+
non_none_args[0], value, pb2_module, _visited, _depth
|
|
1222
|
+
)
|
|
934
1223
|
return None # Should not be reached if value is not None
|
|
935
1224
|
|
|
936
1225
|
# If Message
|
|
937
1226
|
if inspect.isclass(field_type) and issubclass(field_type, Message):
|
|
938
|
-
return convert_python_message_to_proto(
|
|
1227
|
+
return convert_python_message_to_proto(
|
|
1228
|
+
value, field_type, pb2_module, _visited, _depth + 1
|
|
1229
|
+
)
|
|
939
1230
|
|
|
940
1231
|
# If primitive
|
|
941
1232
|
return value
|
|
@@ -1023,6 +1314,9 @@ def protobuf_type_mapping(python_type: Any) -> str | None:
|
|
|
1023
1314
|
return f"map<{key_proto_type}, {value_proto_type}>"
|
|
1024
1315
|
|
|
1025
1316
|
if inspect.isclass(python_type) and issubclass(python_type, Message):
|
|
1317
|
+
# Check if it's an empty message
|
|
1318
|
+
if not python_type.model_fields:
|
|
1319
|
+
return "google.protobuf.Empty"
|
|
1026
1320
|
return python_type.__name__
|
|
1027
1321
|
|
|
1028
1322
|
return mapping.get(python_type)
|
|
@@ -1123,6 +1417,12 @@ def generate_message_definition(
|
|
|
1123
1417
|
fields: list[str] = []
|
|
1124
1418
|
refs: list[Any] = []
|
|
1125
1419
|
pydantic_fields = message_type.model_fields
|
|
1420
|
+
|
|
1421
|
+
# Check if this is an empty message and should use google.protobuf.Empty
|
|
1422
|
+
if not pydantic_fields:
|
|
1423
|
+
# Return a special marker that indicates this should use google.protobuf.Empty
|
|
1424
|
+
return "__EMPTY__", []
|
|
1425
|
+
|
|
1126
1426
|
index = 1
|
|
1127
1427
|
|
|
1128
1428
|
for field_name, field_info in pydantic_fields.items():
|
|
@@ -1328,6 +1628,11 @@ def generate_proto(obj: object, package_name: str = "") -> str:
|
|
|
1328
1628
|
message_types.append(item_type)
|
|
1329
1629
|
continue
|
|
1330
1630
|
|
|
1631
|
+
# Check if mt is actually a Message type
|
|
1632
|
+
if not (inspect.isclass(mt) and issubclass(mt, Message)):
|
|
1633
|
+
# Not a Message type, skip processing
|
|
1634
|
+
continue
|
|
1635
|
+
|
|
1331
1636
|
mt = cast(type[Message], mt)
|
|
1332
1637
|
|
|
1333
1638
|
for _, field_info in mt.model_fields.items():
|
|
@@ -1343,13 +1648,19 @@ def generate_proto(obj: object, package_name: str = "") -> str:
|
|
|
1343
1648
|
) # Use the field-specific version
|
|
1344
1649
|
|
|
1345
1650
|
msg_def, refs = generate_message_definition(mt, done_enums, done_messages)
|
|
1346
|
-
mt_doc = inspect.getdoc(mt)
|
|
1347
|
-
if mt_doc:
|
|
1348
|
-
for comment_line in comment_out(mt_doc):
|
|
1349
|
-
all_type_definitions.append(comment_line)
|
|
1350
1651
|
|
|
1351
|
-
|
|
1352
|
-
|
|
1652
|
+
# Skip adding definition if it's an empty message (will use google.protobuf.Empty)
|
|
1653
|
+
if msg_def != "__EMPTY__":
|
|
1654
|
+
mt_doc = inspect.getdoc(mt)
|
|
1655
|
+
if mt_doc:
|
|
1656
|
+
for comment_line in comment_out(mt_doc):
|
|
1657
|
+
all_type_definitions.append(comment_line)
|
|
1658
|
+
|
|
1659
|
+
all_type_definitions.append(msg_def)
|
|
1660
|
+
all_type_definitions.append("")
|
|
1661
|
+
else:
|
|
1662
|
+
# Mark that we need google.protobuf.Empty import
|
|
1663
|
+
uses_empty = True
|
|
1353
1664
|
|
|
1354
1665
|
for r in refs:
|
|
1355
1666
|
if is_enum_type(r) and r not in done_enums:
|
|
@@ -1379,11 +1690,19 @@ def generate_proto(obj: object, package_name: str = "") -> str:
|
|
|
1379
1690
|
else:
|
|
1380
1691
|
output_msg_type = response_type
|
|
1381
1692
|
|
|
1382
|
-
# Handle NoneType
|
|
1693
|
+
# Handle NoneType and empty messages by using Empty
|
|
1383
1694
|
if input_msg_type is None or input_msg_type is ServicerContext:
|
|
1384
1695
|
input_str = "google.protobuf.Empty" # No need to check for stream since we validated above
|
|
1385
1696
|
if input_msg_type is ServicerContext:
|
|
1386
1697
|
uses_empty = True
|
|
1698
|
+
elif (
|
|
1699
|
+
inspect.isclass(input_msg_type)
|
|
1700
|
+
and issubclass(input_msg_type, Message)
|
|
1701
|
+
and not input_msg_type.model_fields
|
|
1702
|
+
):
|
|
1703
|
+
# Empty message class
|
|
1704
|
+
input_str = "google.protobuf.Empty"
|
|
1705
|
+
uses_empty = True
|
|
1387
1706
|
else:
|
|
1388
1707
|
input_str = (
|
|
1389
1708
|
f"stream {input_msg_type.__name__}"
|
|
@@ -1395,6 +1714,14 @@ def generate_proto(obj: object, package_name: str = "") -> str:
|
|
|
1395
1714
|
output_str = "google.protobuf.Empty" # No need to check for stream since we validated above
|
|
1396
1715
|
if output_msg_type is ServicerContext:
|
|
1397
1716
|
uses_empty = True
|
|
1717
|
+
elif (
|
|
1718
|
+
inspect.isclass(output_msg_type)
|
|
1719
|
+
and issubclass(output_msg_type, Message)
|
|
1720
|
+
and not output_msg_type.model_fields
|
|
1721
|
+
):
|
|
1722
|
+
# Empty message class
|
|
1723
|
+
output_str = "google.protobuf.Empty"
|
|
1724
|
+
uses_empty = True
|
|
1398
1725
|
else:
|
|
1399
1726
|
output_str = (
|
|
1400
1727
|
f"stream {output_msg_type.__name__}"
|
|
@@ -1601,11 +1928,18 @@ def generate_pb_code(proto_path: Path) -> types.ModuleType | None:
|
|
|
1601
1928
|
|
|
1602
1929
|
|
|
1603
1930
|
def get_request_arg_type(sig: inspect.Signature) -> Any:
|
|
1604
|
-
"""Return the type annotation of the first parameter (request) of a method.
|
|
1931
|
+
"""Return the type annotation of the first parameter (request) of a method.
|
|
1932
|
+
|
|
1933
|
+
If the method has no parameters, return None (implying an empty request).
|
|
1934
|
+
"""
|
|
1605
1935
|
num_of_params = len(sig.parameters)
|
|
1606
|
-
if
|
|
1607
|
-
|
|
1608
|
-
|
|
1936
|
+
if num_of_params == 0:
|
|
1937
|
+
# No parameters means empty request
|
|
1938
|
+
return None
|
|
1939
|
+
elif num_of_params == 1 or num_of_params == 2:
|
|
1940
|
+
return tuple(sig.parameters.values())[0].annotation
|
|
1941
|
+
else:
|
|
1942
|
+
raise Exception("Method must have 0, 1, or 2 parameters")
|
|
1609
1943
|
|
|
1610
1944
|
|
|
1611
1945
|
def get_rpc_methods(obj: object) -> list[tuple[str, Callable[..., Any]]]:
|
|
@@ -1836,6 +2170,18 @@ def generate_combined_proto(
|
|
|
1836
2170
|
if is_none_type(response_type):
|
|
1837
2171
|
uses_empty = True
|
|
1838
2172
|
|
|
2173
|
+
# Validate that users aren't using protobuf messages directly
|
|
2174
|
+
if hasattr(request_type, "DESCRIPTOR") and not is_none_type(request_type):
|
|
2175
|
+
raise TypeError(
|
|
2176
|
+
f"Method '{method_name}' uses protobuf message '{request_type.__name__}' directly. "
|
|
2177
|
+
f"Please use Pydantic Message classes instead, or None/empty Message for empty requests."
|
|
2178
|
+
)
|
|
2179
|
+
if hasattr(response_type, "DESCRIPTOR") and not is_none_type(response_type):
|
|
2180
|
+
raise TypeError(
|
|
2181
|
+
f"Method '{method_name}' uses protobuf message '{response_type.__name__}' directly. "
|
|
2182
|
+
f"Please use Pydantic Message classes instead, or None/empty Message for empty responses."
|
|
2183
|
+
)
|
|
2184
|
+
|
|
1839
2185
|
# Collect message types for processing
|
|
1840
2186
|
message_types = []
|
|
1841
2187
|
if not is_none_type(request_type):
|
|
@@ -1869,13 +2215,19 @@ def generate_combined_proto(
|
|
|
1869
2215
|
msg_def, refs = generate_message_definition(
|
|
1870
2216
|
mt, done_enums, done_messages
|
|
1871
2217
|
)
|
|
1872
|
-
mt_doc = inspect.getdoc(mt)
|
|
1873
|
-
if mt_doc:
|
|
1874
|
-
for comment_line in comment_out(mt_doc):
|
|
1875
|
-
all_type_definitions.append(comment_line)
|
|
1876
2218
|
|
|
1877
|
-
|
|
1878
|
-
|
|
2219
|
+
# Skip adding definition if it's an empty message (will use google.protobuf.Empty)
|
|
2220
|
+
if msg_def != "__EMPTY__":
|
|
2221
|
+
mt_doc = inspect.getdoc(mt)
|
|
2222
|
+
if mt_doc:
|
|
2223
|
+
for comment_line in comment_out(mt_doc):
|
|
2224
|
+
all_type_definitions.append(comment_line)
|
|
2225
|
+
|
|
2226
|
+
all_type_definitions.append(msg_def)
|
|
2227
|
+
all_type_definitions.append("")
|
|
2228
|
+
else:
|
|
2229
|
+
# Mark that we need google.protobuf.Empty import
|
|
2230
|
+
uses_empty = True
|
|
1879
2231
|
|
|
1880
2232
|
for r in refs:
|
|
1881
2233
|
if is_enum_type(r) and r not in done_enums:
|
|
@@ -1906,11 +2258,19 @@ def generate_combined_proto(
|
|
|
1906
2258
|
else:
|
|
1907
2259
|
output_msg_type = response_type
|
|
1908
2260
|
|
|
1909
|
-
# Handle NoneType by using Empty
|
|
2261
|
+
# Handle NoneType and empty messages by using Empty
|
|
1910
2262
|
if input_msg_type is None or input_msg_type is ServicerContext:
|
|
1911
2263
|
input_str = "google.protobuf.Empty" # No need to check for stream since we validated above
|
|
1912
2264
|
if input_msg_type is ServicerContext:
|
|
1913
2265
|
uses_empty = True
|
|
2266
|
+
elif (
|
|
2267
|
+
inspect.isclass(input_msg_type)
|
|
2268
|
+
and issubclass(input_msg_type, Message)
|
|
2269
|
+
and not input_msg_type.model_fields
|
|
2270
|
+
):
|
|
2271
|
+
# Empty message class
|
|
2272
|
+
input_str = "google.protobuf.Empty"
|
|
2273
|
+
uses_empty = True
|
|
1914
2274
|
else:
|
|
1915
2275
|
input_str = (
|
|
1916
2276
|
f"stream {input_msg_type.__name__}"
|
|
@@ -1922,6 +2282,14 @@ def generate_combined_proto(
|
|
|
1922
2282
|
output_str = "google.protobuf.Empty" # No need to check for stream since we validated above
|
|
1923
2283
|
if output_msg_type is ServicerContext:
|
|
1924
2284
|
uses_empty = True
|
|
2285
|
+
elif (
|
|
2286
|
+
inspect.isclass(output_msg_type)
|
|
2287
|
+
and issubclass(output_msg_type, Message)
|
|
2288
|
+
and not output_msg_type.model_fields
|
|
2289
|
+
):
|
|
2290
|
+
# Empty message class
|
|
2291
|
+
output_str = "google.protobuf.Empty"
|
|
2292
|
+
uses_empty = True
|
|
1925
2293
|
else:
|
|
1926
2294
|
output_str = (
|
|
1927
2295
|
f"stream {output_msg_type.__name__}"
|
|
@@ -1958,12 +2326,14 @@ def generate_combined_proto(
|
|
|
1958
2326
|
import_block += "\n"
|
|
1959
2327
|
|
|
1960
2328
|
# Combine everything
|
|
2329
|
+
service_defs = "\n".join(all_service_definitions)
|
|
2330
|
+
type_defs = "\n".join(all_type_definitions)
|
|
1961
2331
|
proto_definition = f"""syntax = "proto3";
|
|
1962
2332
|
|
|
1963
2333
|
package {package_name};
|
|
1964
2334
|
|
|
1965
|
-
{import_block}{
|
|
1966
|
-
{
|
|
2335
|
+
{import_block}{service_defs}
|
|
2336
|
+
{type_defs}
|
|
1967
2337
|
"""
|
|
1968
2338
|
return proto_definition
|
|
1969
2339
|
|
pydantic_rpc/mcp/__init__.py
CHANGED
|
File without changes
|
pydantic_rpc/mcp/converter.py
CHANGED
|
File without changes
|
pydantic_rpc/mcp/exporter.py
CHANGED
|
File without changes
|
pydantic_rpc/py.typed
CHANGED
|
File without changes
|
|
@@ -1,20 +1,19 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: pydantic-rpc
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: A Python library for building gRPC/ConnectRPC services with Pydantic models.
|
|
5
5
|
Author: Yasushi Itoh
|
|
6
|
-
License-File: LICENSE
|
|
7
|
-
Requires-Python: >=3.11
|
|
8
6
|
Requires-Dist: annotated-types>=0.5.0
|
|
9
|
-
Requires-Dist: connecpy==2.0.0
|
|
10
|
-
Requires-Dist: grpcio-health-checking>=1.56.2
|
|
11
|
-
Requires-Dist: grpcio-reflection>=1.56.2
|
|
12
|
-
Requires-Dist: grpcio-tools>=1.56.2
|
|
13
|
-
Requires-Dist: grpcio>=1.56.2
|
|
14
|
-
Requires-Dist: mcp>=1.9.4
|
|
15
7
|
Requires-Dist: pydantic>=2.1.1
|
|
8
|
+
Requires-Dist: grpcio>=1.56.2
|
|
9
|
+
Requires-Dist: grpcio-tools>=1.56.2
|
|
10
|
+
Requires-Dist: grpcio-reflection>=1.56.2
|
|
11
|
+
Requires-Dist: grpcio-health-checking>=1.56.2
|
|
16
12
|
Requires-Dist: sonora>=0.2.3
|
|
13
|
+
Requires-Dist: connecpy==2.0.0
|
|
14
|
+
Requires-Dist: mcp>=1.9.4
|
|
17
15
|
Requires-Dist: starlette>=0.27.0
|
|
16
|
+
Requires-Python: >=3.11
|
|
18
17
|
Description-Content-Type: text/markdown
|
|
19
18
|
|
|
20
19
|
# 🚀 PydanticRPC
|
|
@@ -674,6 +673,203 @@ buf: * (#2) Call complete
|
|
|
674
673
|
%
|
|
675
674
|
```
|
|
676
675
|
|
|
676
|
+
### 🪶 Empty Messages
|
|
677
|
+
|
|
678
|
+
Empty request/response messages are automatically mapped to `google.protobuf.Empty`:
|
|
679
|
+
|
|
680
|
+
```python
|
|
681
|
+
from pydantic_rpc import AsyncIOServer, Message
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
class EmptyRequest(Message):
|
|
685
|
+
pass # Automatically uses google.protobuf.Empty
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
class GreetingResponse(Message):
|
|
689
|
+
message: str
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
class GreetingService:
|
|
693
|
+
async def say_hello(self, request: EmptyRequest) -> GreetingResponse:
|
|
694
|
+
return GreetingResponse(message="Hello!")
|
|
695
|
+
|
|
696
|
+
async def get_default_greeting(self) -> GreetingResponse:
|
|
697
|
+
# Method with no request parameter (implicitly empty)
|
|
698
|
+
return GreetingResponse(message="Hello, World!")
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
### 🎨 Custom Serialization
|
|
702
|
+
|
|
703
|
+
Pydantic's serialization decorators are fully supported:
|
|
704
|
+
|
|
705
|
+
```python
|
|
706
|
+
from typing import Any
|
|
707
|
+
from pydantic import field_serializer, model_serializer
|
|
708
|
+
from pydantic_rpc import Message
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
class UserMessage(Message):
|
|
712
|
+
name: str
|
|
713
|
+
age: int
|
|
714
|
+
|
|
715
|
+
@field_serializer('name')
|
|
716
|
+
def serialize_name(self, name: str) -> str:
|
|
717
|
+
"""Always uppercase the name when serializing."""
|
|
718
|
+
return name.upper()
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
class ComplexMessage(Message):
|
|
722
|
+
value: int
|
|
723
|
+
multiplier: int
|
|
724
|
+
|
|
725
|
+
@model_serializer
|
|
726
|
+
def serialize_model(self) -> dict[str, Any]:
|
|
727
|
+
"""Custom serialization with computed fields."""
|
|
728
|
+
return {
|
|
729
|
+
'value': self.value,
|
|
730
|
+
'multiplier': self.multiplier,
|
|
731
|
+
'result': self.value * self.multiplier # Computed field
|
|
732
|
+
}
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
The serializers are automatically applied when converting between Pydantic models and protobuf messages.
|
|
736
|
+
|
|
737
|
+
#### ⚠️ Limitations and Considerations
|
|
738
|
+
|
|
739
|
+
**1. Nested Message serializers are now supported (v0.8.0+)**
|
|
740
|
+
```python
|
|
741
|
+
class Address(Message):
|
|
742
|
+
city: str
|
|
743
|
+
|
|
744
|
+
@field_serializer("city")
|
|
745
|
+
def serialize_city(self, city: str) -> str:
|
|
746
|
+
return city.upper()
|
|
747
|
+
|
|
748
|
+
class User(Message):
|
|
749
|
+
name: str
|
|
750
|
+
address: Address # ← Address's serializers ARE applied with DEEP strategy
|
|
751
|
+
|
|
752
|
+
@field_serializer("name")
|
|
753
|
+
def serialize_name(self, name: str) -> str:
|
|
754
|
+
return name.upper() # ← This IS applied
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
**Serializer Strategy Control:**
|
|
758
|
+
You can control how nested serializers are applied via environment variable:
|
|
759
|
+
```bash
|
|
760
|
+
# Apply serializers at all nesting levels (default)
|
|
761
|
+
export PYDANTIC_RPC_SERIALIZER_STRATEGY=deep
|
|
762
|
+
|
|
763
|
+
# Apply only top-level serializers
|
|
764
|
+
export PYDANTIC_RPC_SERIALIZER_STRATEGY=shallow
|
|
765
|
+
|
|
766
|
+
# Disable all serializers
|
|
767
|
+
export PYDANTIC_RPC_SERIALIZER_STRATEGY=none
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
**Performance Impact:**
|
|
771
|
+
- DEEP strategy: ~4% overhead for simple nested structures
|
|
772
|
+
- SHALLOW strategy: ~2% overhead (only top-level)
|
|
773
|
+
- NONE strategy: No overhead (serializers disabled)
|
|
774
|
+
|
|
775
|
+
**2. New fields added by serializers are ignored**
|
|
776
|
+
```python
|
|
777
|
+
class ComplexMessage(Message):
|
|
778
|
+
value: int
|
|
779
|
+
multiplier: int
|
|
780
|
+
|
|
781
|
+
@model_serializer
|
|
782
|
+
def serialize_model(self) -> dict[str, Any]:
|
|
783
|
+
return {
|
|
784
|
+
"value": self.value,
|
|
785
|
+
"multiplier": self.multiplier,
|
|
786
|
+
"result": self.value * self.multiplier # ← Won't appear in protobuf
|
|
787
|
+
}
|
|
788
|
+
```
|
|
789
|
+
**Problem**: The `result` field doesn't exist in the Message definition, so it's not in the protobuf schema.
|
|
790
|
+
|
|
791
|
+
**3. Type must remain consistent**
|
|
792
|
+
```python
|
|
793
|
+
class BadExample(Message):
|
|
794
|
+
number: int
|
|
795
|
+
|
|
796
|
+
@field_serializer("number")
|
|
797
|
+
def serialize_number(self, number: int) -> str: # ❌ int → str
|
|
798
|
+
return str(number) # This will cause issues
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
**4. Union/Optional fields have limited support**
|
|
802
|
+
```python
|
|
803
|
+
class UnionExample(Message):
|
|
804
|
+
data: str | int | None # Union type
|
|
805
|
+
|
|
806
|
+
@field_serializer("data")
|
|
807
|
+
def serialize_data(self, data: str | int | None) -> str | int | None:
|
|
808
|
+
# Serializer may not be applied to Union types
|
|
809
|
+
return data
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
**5. Errors fail silently with fallback**
|
|
813
|
+
```python
|
|
814
|
+
class RiskyMessage(Message):
|
|
815
|
+
value: int
|
|
816
|
+
|
|
817
|
+
@field_serializer("value")
|
|
818
|
+
def serialize_value(self, value: int) -> int:
|
|
819
|
+
if value == 0:
|
|
820
|
+
raise ValueError("Cannot serialize zero")
|
|
821
|
+
return value * 2
|
|
822
|
+
|
|
823
|
+
# If error occurs, original value is used (silent fallback)
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
**6. Circular references are handled gracefully**
|
|
827
|
+
```python
|
|
828
|
+
class Node(Message):
|
|
829
|
+
value: str
|
|
830
|
+
child: "Node | None" = None
|
|
831
|
+
|
|
832
|
+
@field_serializer("value")
|
|
833
|
+
def serialize_value(self, v: str) -> str:
|
|
834
|
+
return v.upper()
|
|
835
|
+
|
|
836
|
+
# Circular references are detected and prevented
|
|
837
|
+
node1 = Node(value="first")
|
|
838
|
+
node2 = Node(value="second")
|
|
839
|
+
node1.child = node2
|
|
840
|
+
node2.child = node1 # Circular reference
|
|
841
|
+
|
|
842
|
+
# When converting to protobuf:
|
|
843
|
+
# - Circular references are detected
|
|
844
|
+
# - Empty proto is returned for repeated objects
|
|
845
|
+
# - No infinite recursion occurs
|
|
846
|
+
# Note: Pydantic's model_dump() will fail on circular refs,
|
|
847
|
+
# so serializers won't be applied in this case
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
**✅ Recommended Usage:**
|
|
851
|
+
```python
|
|
852
|
+
class GoodMessage(Message):
|
|
853
|
+
# Use with primitive types
|
|
854
|
+
name: str
|
|
855
|
+
age: int
|
|
856
|
+
|
|
857
|
+
@field_serializer("name")
|
|
858
|
+
def normalize_name(self, name: str) -> str:
|
|
859
|
+
return name.strip().title() # Normalization
|
|
860
|
+
|
|
861
|
+
@field_serializer("age")
|
|
862
|
+
def clamp_age(self, age: int) -> int:
|
|
863
|
+
return max(0, min(age, 150)) # Range limiting
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
**Best Practices:**
|
|
867
|
+
- Use serializers primarily for primitive types (str, int, float, bool)
|
|
868
|
+
- Keep type consistency (int → int, str → str)
|
|
869
|
+
- Avoid complex transformations or side effects
|
|
870
|
+
- Test error cases thoroughly
|
|
871
|
+
- Be aware that errors fail silently
|
|
872
|
+
|
|
677
873
|
### 🔗 Multiple Services with Custom Interceptors
|
|
678
874
|
|
|
679
875
|
PydanticRPC supports defining and running multiple services in a single server:
|
|
@@ -905,8 +1101,8 @@ This approach works because protobuf allows message types within `oneof` fields,
|
|
|
905
1101
|
- [x] unary-stream
|
|
906
1102
|
- [x] stream-unary
|
|
907
1103
|
- [x] stream-stream
|
|
908
|
-
- [
|
|
909
|
-
- [
|
|
1104
|
+
- [x] Empty Message Support (automatic google.protobuf.Empty)
|
|
1105
|
+
- [x] Pydantic Serializer Support (@model_serializer, @field_serializer)
|
|
910
1106
|
- [ ] Custom Health Check Support
|
|
911
1107
|
- [x] MCP (Model Context Protocol) Support via official MCP SDK
|
|
912
1108
|
- [ ] Add more examples
|
|
@@ -0,0 +1,10 @@
|
|
|
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,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
pydantic_rpc/__init__.py,sha256=fb7a9YdCsT6WlUr7aMqR-YAKL68HmfeNpgcMnIhREGU,440
|
|
2
|
-
pydantic_rpc/core.py,sha256=eDOEvjrIdg8iN_RagvZ5yHvYIOzIhPHNORp-GzbRFTc,89401
|
|
3
|
-
pydantic_rpc/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
-
pydantic_rpc/mcp/__init__.py,sha256=8FrWLMONtcWXLhaHDEhFI8WMWsZQ2EVHBvHORTnMelI,123
|
|
5
|
-
pydantic_rpc/mcp/converter.py,sha256=tg3K-C5r_2vksquLHp8tFuCcuY2PABgqT29z0aeISKQ,4158
|
|
6
|
-
pydantic_rpc/mcp/exporter.py,sha256=IIM2Yvmpc-2eGnBbm1mqolM95sUU69h-9mZkpDCg0E8,10239
|
|
7
|
-
pydantic_rpc-0.7.0.dist-info/METADATA,sha256=y6076ec28-4XiQYkEtvbyw8Duk4n-izw2Mp4Yy2LGP4,24343
|
|
8
|
-
pydantic_rpc-0.7.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
9
|
-
pydantic_rpc-0.7.0.dist-info/entry_points.txt,sha256=LeZJ6UN-fhjKrEGkcmsAAKuA-fIe7MpvzKMPSZfi0NE,56
|
|
10
|
-
pydantic_rpc-0.7.0.dist-info/licenses/LICENSE,sha256=Y6jkAm2VqPqoGIGQ-mEQCecNfteQ2LwdpYhC5XiH_cA,1069
|
|
11
|
-
pydantic_rpc-0.7.0.dist-info/RECORD,,
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2023 Yasushi Itoh
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|