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 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
- return arg_type(**rdict)
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 is_none_type(arg_type):
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 is_none_type(input_type):
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 exactly one or two parameters")
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 exactly one or two parameters")
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, field_type: type[Any], value: Any, pb2_module: Any
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(actual_type, value, pb2_module)
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, msg_type: type[Message], pb2_module: Any
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(name, field_type, value, pb2_module)
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(field_type, value, pb2_module)
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(field_type: type[Any], value: Any, pb2_module: Any) -> Any:
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
- return [python_value_to_proto(inner_type, v, pb2_module) for v in value]
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(key_type, k, pb2_module): python_value_to_proto(
920
- val_type, v, pb2_module
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(non_none_args[0], value, pb2_module)
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(value, field_type, pb2_module)
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
- all_type_definitions.append(msg_def)
1352
- all_type_definitions.append("")
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 by using Empty (but we've already validated no streaming of Empty above)
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 not (num_of_params == 1 or num_of_params == 2):
1607
- raise Exception("Method must have exactly one or two parameters")
1608
- return tuple(sig.parameters.values())[0].annotation
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
- all_type_definitions.append(msg_def)
1878
- all_type_definitions.append("")
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}{"".join(all_service_definitions)}
1966
- {indent_lines(all_type_definitions, "")}
2335
+ {import_block}{service_defs}
2336
+ {type_defs}
1967
2337
  """
1968
2338
  return proto_definition
1969
2339
 
File without changes
File without changes
File without changes
pydantic_rpc/py.typed CHANGED
File without changes
@@ -1,20 +1,19 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.3
2
2
  Name: pydantic-rpc
3
- Version: 0.7.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
- - [ ] Betterproto Support
909
- - [ ] Sonora-connect Support
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.7.22
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
2
  pydantic-rpc = pydantic_rpc.core:main
3
+
@@ -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,4 +0,0 @@
1
- Wheel-Version: 1.0
2
- Generator: hatchling 1.27.0
3
- Root-Is-Purelib: true
4
- Tag: py3-none-any
@@ -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.