pydantic-rpc 0.7.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/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
@@ -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)
@@ -37,8 +35,7 @@ from grpc_health.v1.health import HealthServicer
37
35
  from grpc_reflection.v1alpha import reflection
38
36
  from grpc_tools import protoc
39
37
  from pydantic import BaseModel, ValidationError
40
- from sonora.asgi import grpcASGI
41
- from sonora.wsgi import grpcWSGI
38
+ from .decorators import get_method_options, has_http_option
42
39
 
43
40
  ###############################################################################
44
41
  # 1. Message definitions & converter extensions
@@ -49,6 +46,50 @@ from sonora.wsgi import grpcWSGI
49
46
 
50
47
  Message: TypeAlias = BaseModel
51
48
 
49
+ # Cache for serializer checks
50
+ _has_serializers_cache: dict[type[Message], bool] = {}
51
+ _has_field_serializers_cache: dict[type[Message], set[str]] = {}
52
+
53
+
54
+ # Serializer strategy configuration
55
+ class SerializerStrategy(enum.Enum):
56
+ """Strategy for applying Pydantic serializers."""
57
+
58
+ NONE = "none" # No serializers applied
59
+ SHALLOW = "shallow" # Only top-level serializers
60
+ DEEP = "deep" # Nested serializers too (default)
61
+
62
+
63
+ # Get strategy from environment variable
64
+ _SERIALIZER_STRATEGY = SerializerStrategy(
65
+ os.getenv("PYDANTIC_RPC_SERIALIZER_STRATEGY", "deep").lower()
66
+ )
67
+
68
+
69
+ def has_serializers(msg_type: type[Message]) -> bool:
70
+ """Check if a Message type has any serializers (cached for performance)."""
71
+ if msg_type not in _has_serializers_cache:
72
+ # Check for both field and model serializers
73
+ has_field = bool(getattr(msg_type, "__pydantic_serializer__", None))
74
+ has_model = bool(
75
+ getattr(msg_type, "model_dump", None) and msg_type != BaseModel
76
+ )
77
+ _has_serializers_cache[msg_type] = has_field or has_model
78
+ return _has_serializers_cache[msg_type]
79
+
80
+
81
+ def get_field_serializers(msg_type: type[Message]) -> set[str]:
82
+ """Get the set of fields that have serializers (cached for performance)."""
83
+ if msg_type not in _has_field_serializers_cache:
84
+ fields_with_serializers = set()
85
+ # Check model_fields_set or similar for fields with serializers
86
+ if hasattr(msg_type, "__pydantic_decorators__"):
87
+ decorators = msg_type.__pydantic_decorators__
88
+ if hasattr(decorators, "field_serializers"):
89
+ fields_with_serializers = set(decorators.field_serializers.keys())
90
+ _has_field_serializers_cache[msg_type] = fields_with_serializers
91
+ return _has_field_serializers_cache[msg_type]
92
+
52
93
 
53
94
  def is_none_type(annotation: Any) -> TypeGuard[type[None] | None]:
54
95
  """Check if annotation represents None/NoneType (handles both None and type(None))."""
@@ -149,6 +190,14 @@ def generate_converter(annotation: type[Any] | None) -> Callable[[Any], Any]:
149
190
 
150
191
  # For Message classes
151
192
  if inspect.isclass(annotation) and issubclass(annotation, Message):
193
+ # Check if it's an empty message class
194
+ if not annotation.model_fields:
195
+
196
+ def empty_message_converter(value: empty_pb2.Empty): # type: ignore
197
+ _ = value
198
+ return annotation() # Return instance of the empty message class
199
+
200
+ return empty_message_converter
152
201
  return generate_message_converter(annotation)
153
202
 
154
203
  # For union types or other unsupported cases, just return the value as-is.
@@ -171,6 +220,15 @@ def generate_message_converter(
171
220
 
172
221
  arg_type = cast("type[Message]", arg_type)
173
222
  fields = arg_type.model_fields
223
+
224
+ # Handle empty message classes (no fields)
225
+ if not fields:
226
+
227
+ def empty_message_converter(request: Any) -> Message:
228
+ _ = request # The incoming request will be google.protobuf.Empty
229
+ return arg_type() # Return an instance of the empty message class
230
+
231
+ return empty_message_converter
174
232
  converters = {
175
233
  field: generate_converter(field_type.annotation) # type: ignore
176
234
  for field, field_type in fields.items()
@@ -238,7 +296,12 @@ def generate_message_converter(
238
296
  # For non-union fields, convert normally
239
297
  rdict[field_name] = converters[field_name](getattr(request, field_name))
240
298
 
241
- return arg_type(**rdict)
299
+ # Use model_validate to support @model_validator
300
+ try:
301
+ return arg_type.model_validate(rdict)
302
+ except AttributeError:
303
+ # Fallback for older Pydantic versions or if model_validate doesn't exist
304
+ return arg_type(**rdict)
242
305
 
243
306
  return converter
244
307
 
@@ -294,7 +357,7 @@ def connect_obj_with_stub(
294
357
  param_count = len(sig.parameters)
295
358
  converter = generate_message_converter(arg_type)
296
359
 
297
- if param_count == 1:
360
+ if param_count == 0 or param_count == 1:
298
361
 
299
362
  def stub_method(
300
363
  self: object,
@@ -305,7 +368,10 @@ def connect_obj_with_stub(
305
368
  ) -> Any:
306
369
  _ = self
307
370
  try:
308
- if is_none_type(arg_type):
371
+ if param_count == 0:
372
+ # Method takes no parameters
373
+ resp_obj = original()
374
+ elif is_none_type(arg_type):
309
375
  resp_obj = original(None) # Fixed: pass None instead of no args
310
376
  else:
311
377
  arg = converter(request)
@@ -313,6 +379,13 @@ def connect_obj_with_stub(
313
379
 
314
380
  if is_none_type(response_type):
315
381
  return empty_pb2.Empty() # type: ignore
382
+ elif (
383
+ inspect.isclass(response_type)
384
+ and issubclass(response_type, Message)
385
+ and not response_type.model_fields
386
+ ):
387
+ # Empty message class
388
+ return empty_pb2.Empty() # type: ignore
316
389
  else:
317
390
  return convert_python_message_to_proto(
318
391
  resp_obj, response_type, pb2_module
@@ -343,6 +416,13 @@ def connect_obj_with_stub(
343
416
 
344
417
  if is_none_type(response_type):
345
418
  return empty_pb2.Empty() # type: ignore
419
+ elif (
420
+ inspect.isclass(response_type)
421
+ and issubclass(response_type, Message)
422
+ and not response_type.model_fields
423
+ ):
424
+ # Empty message class
425
+ return empty_pb2.Empty() # type: ignore
346
426
  else:
347
427
  return convert_python_message_to_proto(
348
428
  resp_obj, response_type, pb2_module
@@ -391,9 +471,9 @@ def connect_obj_with_stub_async(
391
471
  is_output_stream = is_stream_type(response_type)
392
472
  size_of_parameters = len(sig.parameters)
393
473
 
394
- if size_of_parameters not in (1, 2):
474
+ if size_of_parameters not in (0, 1, 2):
395
475
  raise TypeError(
396
- f"Method '{method.__name__}' must have 1 or 2 parameters, got {size_of_parameters}"
476
+ f"Method '{method.__name__}' must have 0, 1 or 2 parameters, got {size_of_parameters}"
397
477
  )
398
478
 
399
479
  if is_input_stream:
@@ -559,7 +639,7 @@ def connect_obj_with_stub_async(
559
639
 
560
640
  else:
561
641
  # unary-unary
562
- if size_of_parameters == 1:
642
+ if size_of_parameters == 0 or size_of_parameters == 1:
563
643
 
564
644
  async def stub_method(
565
645
  self: object,
@@ -568,7 +648,10 @@ def connect_obj_with_stub_async(
568
648
  ) -> Any:
569
649
  _ = self
570
650
  try:
571
- if is_none_type(input_type):
651
+ if size_of_parameters == 0:
652
+ # Method takes no parameters
653
+ resp_obj = await method()
654
+ elif is_none_type(input_type):
572
655
  resp_obj = await method(None)
573
656
  else:
574
657
  arg = converter(request)
@@ -576,6 +659,13 @@ def connect_obj_with_stub_async(
576
659
 
577
660
  if is_none_type(response_type):
578
661
  return empty_pb2.Empty() # type: ignore
662
+ elif (
663
+ inspect.isclass(response_type)
664
+ and issubclass(response_type, Message)
665
+ and not response_type.model_fields
666
+ ):
667
+ # Empty message class
668
+ return empty_pb2.Empty() # type: ignore
579
669
  else:
580
670
  return convert_python_message_to_proto(
581
671
  resp_obj, response_type, pb2_module
@@ -604,6 +694,13 @@ def connect_obj_with_stub_async(
604
694
 
605
695
  if is_none_type(response_type):
606
696
  return empty_pb2.Empty() # type: ignore
697
+ elif (
698
+ inspect.isclass(response_type)
699
+ and issubclass(response_type, Message)
700
+ and not response_type.model_fields
701
+ ):
702
+ # Empty message class
703
+ return empty_pb2.Empty() # type: ignore
607
704
  else:
608
705
  return convert_python_message_to_proto(
609
706
  resp_obj, response_type, pb2_module
@@ -650,6 +747,31 @@ def connect_obj_with_stub_connecpy(
650
747
  size_of_parameters = len(sig.parameters)
651
748
 
652
749
  match size_of_parameters:
750
+ case 0:
751
+ # Method with no parameters (empty request)
752
+ def stub_method0(
753
+ self: object,
754
+ request: Any,
755
+ context: Any,
756
+ method: Callable[..., Message] = method,
757
+ ) -> Any:
758
+ _ = self
759
+ try:
760
+ resp_obj = method()
761
+
762
+ if is_none_type(response_type):
763
+ return empty_pb2.Empty() # type: ignore
764
+ else:
765
+ return convert_python_message_to_proto(
766
+ resp_obj, response_type, pb2_module
767
+ )
768
+ except ValidationError as e:
769
+ return context.abort(Errors.INVALID_ARGUMENT, str(e))
770
+ except Exception as e:
771
+ return context.abort(Errors.INTERNAL, str(e))
772
+
773
+ return stub_method0
774
+
653
775
  case 1:
654
776
 
655
777
  def stub_method1(
@@ -709,13 +831,14 @@ def connect_obj_with_stub_connecpy(
709
831
  return stub_method2
710
832
 
711
833
  case _:
712
- raise Exception("Method must have exactly one or two parameters")
834
+ raise Exception("Method must have 0, 1, or 2 parameters")
713
835
 
714
836
  for method_name, method in get_rpc_methods(obj):
715
837
  if method.__name__.startswith("_"):
716
838
  continue
717
839
  a_method = implement_stub_method(method)
718
- setattr(ConcreteServiceClass, method_name, a_method)
840
+ # Use the original snake_case method name for Connecpy v2.2.0 compatibility
841
+ setattr(ConcreteServiceClass, method.__name__, a_method)
719
842
 
720
843
  return ConcreteServiceClass
721
844
 
@@ -724,7 +847,7 @@ def connect_obj_with_stub_async_connecpy(
724
847
  connecpy_module: Any, pb2_module: Any, obj: object
725
848
  ) -> type:
726
849
  """
727
- 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.
728
851
  """
729
852
  service_class = obj.__class__
730
853
  stub_class_name = service_class.__name__
@@ -734,47 +857,19 @@ def connect_obj_with_stub_async_connecpy(
734
857
  pass
735
858
 
736
859
  def implement_stub_method(
737
- method: Callable[..., Awaitable[Message]],
860
+ method: Callable[..., Any],
738
861
  ) -> Callable[[object, Any, Any], Any]:
739
862
  sig = inspect.signature(method)
740
- arg_type = get_request_arg_type(sig)
741
- converter = generate_message_converter(arg_type)
863
+ input_type = get_request_arg_type(sig)
864
+ is_input_stream = is_stream_type(input_type)
742
865
  response_type = sig.return_annotation
866
+ is_output_stream = is_stream_type(response_type)
743
867
  size_of_parameters = len(sig.parameters)
744
868
 
745
869
  match size_of_parameters:
746
- case 1:
747
-
748
- async def stub_method1(
749
- self: object,
750
- request: Any,
751
- context: Any,
752
- method: Callable[..., Awaitable[Message]] = method,
753
- ) -> Any:
754
- _ = self
755
- try:
756
- if is_none_type(arg_type):
757
- resp_obj = await method(None)
758
- else:
759
- arg = converter(request)
760
- resp_obj = await method(arg)
761
-
762
- if is_none_type(response_type):
763
- return empty_pb2.Empty() # type: ignore
764
- else:
765
- return convert_python_message_to_proto(
766
- resp_obj, response_type, pb2_module
767
- )
768
- except ValidationError as e:
769
- await context.abort(Errors.INVALID_ARGUMENT, str(e))
770
- except Exception as e:
771
- await context.abort(Errors.INTERNAL, str(e))
772
-
773
- return stub_method1
774
-
775
- case 2:
776
-
777
- async def stub_method2(
870
+ case 0:
871
+ # Method with no parameters (empty request)
872
+ async def stub_method0(
778
873
  self: object,
779
874
  request: Any,
780
875
  context: Any,
@@ -782,11 +877,7 @@ def connect_obj_with_stub_async_connecpy(
782
877
  ) -> Any:
783
878
  _ = self
784
879
  try:
785
- if is_none_type(arg_type):
786
- resp_obj = await method(None, context)
787
- else:
788
- arg = converter(request)
789
- resp_obj = await method(arg, context)
880
+ resp_obj = await method()
790
881
 
791
882
  if is_none_type(response_type):
792
883
  return empty_pb2.Empty() # type: ignore
@@ -799,24 +890,243 @@ def connect_obj_with_stub_async_connecpy(
799
890
  except Exception as e:
800
891
  await context.abort(Errors.INTERNAL, str(e))
801
892
 
802
- return stub_method2
893
+ return stub_method0
894
+
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
803
1104
 
804
1105
  case _:
805
- raise Exception("Method must have exactly one or two parameters")
1106
+ raise Exception("Method must have 0, 1, or 2 parameters")
806
1107
 
807
1108
  for method_name, method in get_rpc_methods(obj):
808
1109
  if method.__name__.startswith("_"):
809
1110
  continue
810
- if not asyncio.iscoroutinefunction(method):
811
- raise Exception("Method must be async", method_name)
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")
812
1116
  a_method = implement_stub_method(method)
813
- setattr(ConcreteServiceClass, method_name, a_method)
1117
+ # Use the original snake_case method name for Connecpy v2.2.0 compatibility
1118
+ setattr(ConcreteServiceClass, method.__name__, a_method)
814
1119
 
815
1120
  return ConcreteServiceClass
816
1121
 
817
1122
 
818
1123
  def python_value_to_proto_oneof(
819
- field_name: str, field_type: type[Any], value: Any, pb2_module: Any
1124
+ field_name: str,
1125
+ field_type: type[Any],
1126
+ value: Any,
1127
+ pb2_module: Any,
1128
+ _visited: set[int] | None = None,
1129
+ _depth: int = 0,
820
1130
  ) -> tuple[str, Any]:
821
1131
  """
822
1132
  Converts a Python value from a Union type to a protobuf oneof field.
@@ -847,20 +1157,120 @@ def python_value_to_proto_oneof(
847
1157
  raise TypeError(f"Unsupported type in oneof: {actual_type}")
848
1158
 
849
1159
  oneof_field_name = f"{field_name}_{proto_typename.replace('.', '_')}"
850
- converted_value = python_value_to_proto(actual_type, value, pb2_module)
1160
+ converted_value = python_value_to_proto(
1161
+ actual_type, value, pb2_module, _visited, _depth
1162
+ )
851
1163
  return oneof_field_name, converted_value
852
1164
 
853
1165
 
854
1166
  def convert_python_message_to_proto(
855
- py_msg: Message, msg_type: type[Message], pb2_module: Any
1167
+ py_msg: Message,
1168
+ msg_type: type[Message],
1169
+ pb2_module: Any,
1170
+ _visited: set[int] | None = None,
1171
+ _depth: int = 0,
856
1172
  ) -> object:
857
- """Convert a Python Pydantic Message instance to a protobuf message instance. Used for constructing a response."""
1173
+ """Convert a Python Pydantic Message instance to a protobuf message instance. Used for constructing a response.
1174
+
1175
+ Args:
1176
+ py_msg: The Pydantic Message instance to convert
1177
+ msg_type: The type of the Message
1178
+ pb2_module: The protobuf module
1179
+ _visited: Internal set to track visited objects for circular reference detection
1180
+ _depth: Current recursion depth for strategy control
1181
+ """
1182
+ # Handle empty message classes
1183
+ if not msg_type.model_fields:
1184
+ # Return google.protobuf.Empty instance
1185
+ return empty_pb2.Empty()
1186
+
1187
+ # Initialize visited set for circular reference detection
1188
+ if _visited is None:
1189
+ _visited = set()
1190
+
1191
+ # Check for circular references
1192
+ obj_id = id(py_msg)
1193
+ if obj_id in _visited:
1194
+ # Return empty proto to avoid infinite recursion
1195
+ proto_class = getattr(pb2_module, msg_type.__name__)
1196
+ return proto_class()
1197
+ _visited.add(obj_id)
1198
+
1199
+ # Determine if we should apply serializers based on strategy
1200
+ apply_serializers = False
1201
+ if _SERIALIZER_STRATEGY == SerializerStrategy.DEEP:
1202
+ apply_serializers = True # Always apply at any depth
1203
+ elif _SERIALIZER_STRATEGY == SerializerStrategy.SHALLOW:
1204
+ apply_serializers = _depth == 0 # Only at top level
1205
+ # SerializerStrategy.NONE: never apply
1206
+
1207
+ # Only use model_dump if there are serializers and strategy allows
1208
+ serialized_data = None
1209
+ if apply_serializers and has_serializers(msg_type):
1210
+ try:
1211
+ serialized_data = py_msg.model_dump(mode="python")
1212
+ except Exception:
1213
+ # Fallback to the old approach if model_dump fails
1214
+ serialized_data = None
1215
+
858
1216
  field_dict = {}
859
1217
  for name, field_info in msg_type.model_fields.items():
1218
+ field_type = field_info.annotation
1219
+
1220
+ # Check if this field type contains Message types
1221
+ contains_message = False
1222
+ if field_type:
1223
+ if inspect.isclass(field_type) and issubclass(field_type, Message):
1224
+ contains_message = True
1225
+ elif is_union_type(field_type):
1226
+ # Check if any of the union args are Messages
1227
+ for arg in flatten_union(field_type):
1228
+ if arg and inspect.isclass(arg) and issubclass(arg, Message):
1229
+ contains_message = True
1230
+ break
1231
+ else:
1232
+ # Check for list/dict of Messages
1233
+ origin = get_origin(field_type)
1234
+ if origin in (list, tuple):
1235
+ inner_type = (
1236
+ get_args(field_type)[0] if get_args(field_type) else None
1237
+ )
1238
+ if (
1239
+ inner_type
1240
+ and inspect.isclass(inner_type)
1241
+ and issubclass(inner_type, Message)
1242
+ ):
1243
+ contains_message = True
1244
+ elif origin is dict:
1245
+ args = get_args(field_type)
1246
+ if len(args) >= 2:
1247
+ val_type = args[1]
1248
+ if inspect.isclass(val_type) and issubclass(val_type, Message):
1249
+ contains_message = True
1250
+
1251
+ # Get the value
860
1252
  value = getattr(py_msg, name)
861
1253
  if value is None:
862
1254
  continue
863
1255
 
1256
+ # For Message types, recursively apply serialization
1257
+ if contains_message and not is_union_type(field_type):
1258
+ # Direct Message field
1259
+ if inspect.isclass(field_type) and issubclass(field_type, Message):
1260
+ # Recursively convert nested Message with serializers
1261
+ field_dict[name] = convert_python_message_to_proto(
1262
+ value, field_type, pb2_module, _visited, _depth + 1
1263
+ )
1264
+ continue
1265
+
1266
+ # Use serialized data for non-Message types to respect custom serializers
1267
+ if (
1268
+ serialized_data is not None
1269
+ and name in serialized_data
1270
+ and not contains_message
1271
+ ):
1272
+ value = serialized_data[name]
1273
+
864
1274
  field_type = field_info.annotation
865
1275
 
866
1276
  # Handle oneof fields, which are represented as Unions.
@@ -874,20 +1284,33 @@ def convert_python_message_to_proto(
874
1284
  (
875
1285
  oneof_field_name,
876
1286
  converted_value,
877
- ) = python_value_to_proto_oneof(name, field_type, value, pb2_module)
1287
+ ) = python_value_to_proto_oneof(
1288
+ name, field_type, value, pb2_module, _visited, _depth
1289
+ )
878
1290
  field_dict[oneof_field_name] = converted_value
879
1291
  continue
880
1292
 
881
1293
  # For regular and Optional fields that have a value.
882
1294
  if field_type is not None:
883
- field_dict[name] = python_value_to_proto(field_type, value, pb2_module)
1295
+ field_dict[name] = python_value_to_proto(
1296
+ field_type, value, pb2_module, _visited, _depth
1297
+ )
1298
+
1299
+ # Remove from visited set when done
1300
+ _visited.discard(obj_id)
884
1301
 
885
1302
  # Retrieve the appropriate protobuf class dynamically
886
1303
  proto_class = getattr(pb2_module, msg_type.__name__)
887
1304
  return proto_class(**field_dict)
888
1305
 
889
1306
 
890
- def python_value_to_proto(field_type: type[Any], value: Any, pb2_module: Any) -> Any:
1307
+ def python_value_to_proto(
1308
+ field_type: type[Any],
1309
+ value: Any,
1310
+ pb2_module: Any,
1311
+ _visited: set[int] | None = None,
1312
+ _depth: int = 0,
1313
+ ) -> Any:
891
1314
  """
892
1315
  Perform Python->protobuf type conversion for each field value.
893
1316
  """
@@ -910,15 +1333,36 @@ def python_value_to_proto(field_type: type[Any], value: Any, pb2_module: Any) ->
910
1333
  # If seq
911
1334
  if origin in (list, tuple):
912
1335
  inner_type = get_args(field_type)[0]
913
- return [python_value_to_proto(inner_type, v, pb2_module) for v in value]
1336
+ # Handle list of Messages
1337
+ if inspect.isclass(inner_type) and issubclass(inner_type, Message):
1338
+ return [
1339
+ convert_python_message_to_proto(
1340
+ v, inner_type, pb2_module, _visited, _depth + 1
1341
+ )
1342
+ for v in value
1343
+ ]
1344
+ return [
1345
+ python_value_to_proto(inner_type, v, pb2_module, _visited, _depth)
1346
+ for v in value
1347
+ ]
914
1348
 
915
1349
  # If dict
916
1350
  if origin is dict:
917
1351
  key_type, val_type = get_args(field_type)
1352
+ # Handle dict with Message values
1353
+ if inspect.isclass(val_type) and issubclass(val_type, Message):
1354
+ return {
1355
+ python_value_to_proto(
1356
+ key_type, k, pb2_module, _visited, _depth
1357
+ ): convert_python_message_to_proto(
1358
+ v, val_type, pb2_module, _visited, _depth + 1
1359
+ )
1360
+ for k, v in value.items()
1361
+ }
918
1362
  return {
919
- python_value_to_proto(key_type, k, pb2_module): python_value_to_proto(
920
- val_type, v, pb2_module
921
- )
1363
+ python_value_to_proto(
1364
+ key_type, k, pb2_module, _visited, _depth
1365
+ ): python_value_to_proto(val_type, v, pb2_module, _visited, _depth)
922
1366
  for k, v in value.items()
923
1367
  }
924
1368
 
@@ -930,12 +1374,16 @@ def python_value_to_proto(field_type: type[Any], value: Any, pb2_module: Any) ->
930
1374
  ]
931
1375
  if non_none_args:
932
1376
  # 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)
1377
+ return python_value_to_proto(
1378
+ non_none_args[0], value, pb2_module, _visited, _depth
1379
+ )
934
1380
  return None # Should not be reached if value is not None
935
1381
 
936
1382
  # If Message
937
1383
  if inspect.isclass(field_type) and issubclass(field_type, Message):
938
- return convert_python_message_to_proto(value, field_type, pb2_module)
1384
+ return convert_python_message_to_proto(
1385
+ value, field_type, pb2_module, _visited, _depth + 1
1386
+ )
939
1387
 
940
1388
  # If primitive
941
1389
  return value
@@ -1023,6 +1471,9 @@ def protobuf_type_mapping(python_type: Any) -> str | None:
1023
1471
  return f"map<{key_proto_type}, {value_proto_type}>"
1024
1472
 
1025
1473
  if inspect.isclass(python_type) and issubclass(python_type, Message):
1474
+ # Check if it's an empty message
1475
+ if not python_type.model_fields:
1476
+ return "google.protobuf.Empty"
1026
1477
  return python_type.__name__
1027
1478
 
1028
1479
  return mapping.get(python_type)
@@ -1123,6 +1574,12 @@ def generate_message_definition(
1123
1574
  fields: list[str] = []
1124
1575
  refs: list[Any] = []
1125
1576
  pydantic_fields = message_type.model_fields
1577
+
1578
+ # Check if this is an empty message and should use google.protobuf.Empty
1579
+ if not pydantic_fields:
1580
+ # Return a special marker that indicates this should use google.protobuf.Empty
1581
+ return "__EMPTY__", []
1582
+
1126
1583
  index = 1
1127
1584
 
1128
1585
  for field_name, field_info in pydantic_fields.items():
@@ -1210,11 +1667,11 @@ def generate_message_definition(
1210
1667
  fields.append("// length of " + str(metadata_item.len))
1211
1668
  case annotated_types.MinLen:
1212
1669
  fields.append(
1213
- "// minimum length of " + str(metadata_item.min_len)
1670
+ "// minimum length of " + str(metadata_item.min_length)
1214
1671
  )
1215
1672
  case annotated_types.MaxLen:
1216
1673
  fields.append(
1217
- "// maximum length of " + str(metadata_item.max_len)
1674
+ "// maximum length of " + str(metadata_item.max_length)
1218
1675
  )
1219
1676
  case _:
1220
1677
  fields.append("// " + str(metadata_item))
@@ -1250,6 +1707,39 @@ def is_generic_alias(annotation: Any) -> bool:
1250
1707
  return get_origin(annotation) is not None
1251
1708
 
1252
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
+
1253
1743
  def generate_proto(obj: object, package_name: str = "") -> str:
1254
1744
  """
1255
1745
  Generate a .proto definition from a service class.
@@ -1270,6 +1760,7 @@ def generate_proto(obj: object, package_name: str = "") -> str:
1270
1760
  uses_timestamp = False
1271
1761
  uses_duration = False
1272
1762
  uses_empty = False
1763
+ uses_http_options = check_uses_http_options(obj)
1273
1764
 
1274
1765
  def check_and_set_well_known_types_for_fields(py_type: Any):
1275
1766
  """Check well-known types for field annotations (excludes None/Empty)."""
@@ -1328,6 +1819,11 @@ def generate_proto(obj: object, package_name: str = "") -> str:
1328
1819
  message_types.append(item_type)
1329
1820
  continue
1330
1821
 
1822
+ # Check if mt is actually a Message type
1823
+ if not (inspect.isclass(mt) and issubclass(mt, Message)):
1824
+ # Not a Message type, skip processing
1825
+ continue
1826
+
1331
1827
  mt = cast(type[Message], mt)
1332
1828
 
1333
1829
  for _, field_info in mt.model_fields.items():
@@ -1343,13 +1839,19 @@ def generate_proto(obj: object, package_name: str = "") -> str:
1343
1839
  ) # Use the field-specific version
1344
1840
 
1345
1841
  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
1842
 
1351
- all_type_definitions.append(msg_def)
1352
- all_type_definitions.append("")
1843
+ # Skip adding definition if it's an empty message (will use google.protobuf.Empty)
1844
+ if msg_def != "__EMPTY__":
1845
+ mt_doc = inspect.getdoc(mt)
1846
+ if mt_doc:
1847
+ for comment_line in comment_out(mt_doc):
1848
+ all_type_definitions.append(comment_line)
1849
+
1850
+ all_type_definitions.append(msg_def)
1851
+ all_type_definitions.append("")
1852
+ else:
1853
+ # Mark that we need google.protobuf.Empty import
1854
+ uses_empty = True
1353
1855
 
1354
1856
  for r in refs:
1355
1857
  if is_enum_type(r) and r not in done_enums:
@@ -1379,11 +1881,19 @@ def generate_proto(obj: object, package_name: str = "") -> str:
1379
1881
  else:
1380
1882
  output_msg_type = response_type
1381
1883
 
1382
- # Handle NoneType by using Empty (but we've already validated no streaming of Empty above)
1884
+ # Handle NoneType and empty messages by using Empty
1383
1885
  if input_msg_type is None or input_msg_type is ServicerContext:
1384
1886
  input_str = "google.protobuf.Empty" # No need to check for stream since we validated above
1385
1887
  if input_msg_type is ServicerContext:
1386
1888
  uses_empty = True
1889
+ elif (
1890
+ inspect.isclass(input_msg_type)
1891
+ and issubclass(input_msg_type, Message)
1892
+ and not input_msg_type.model_fields
1893
+ ):
1894
+ # Empty message class
1895
+ input_str = "google.protobuf.Empty"
1896
+ uses_empty = True
1387
1897
  else:
1388
1898
  input_str = (
1389
1899
  f"stream {input_msg_type.__name__}"
@@ -1395,6 +1905,14 @@ def generate_proto(obj: object, package_name: str = "") -> str:
1395
1905
  output_str = "google.protobuf.Empty" # No need to check for stream since we validated above
1396
1906
  if output_msg_type is ServicerContext:
1397
1907
  uses_empty = True
1908
+ elif (
1909
+ inspect.isclass(output_msg_type)
1910
+ and issubclass(output_msg_type, Message)
1911
+ and not output_msg_type.model_fields
1912
+ ):
1913
+ # Empty message class
1914
+ output_str = "google.protobuf.Empty"
1915
+ uses_empty = True
1398
1916
  else:
1399
1917
  output_str = (
1400
1918
  f"stream {output_msg_type.__name__}"
@@ -1402,9 +1920,24 @@ def generate_proto(obj: object, package_name: str = "") -> str:
1402
1920
  else output_msg_type.__name__
1403
1921
  )
1404
1922
 
1405
- rpc_definitions.append(
1406
- f"rpc {method_name} ({input_str}) returns ({output_str});"
1407
- )
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
+ )
1408
1941
 
1409
1942
  if not package_name:
1410
1943
  if service_name.endswith("Service"):
@@ -1414,6 +1947,8 @@ def generate_proto(obj: object, package_name: str = "") -> str:
1414
1947
  package_name = package_name.lower() + ".v1"
1415
1948
 
1416
1949
  imports: list[str] = []
1950
+ if uses_http_options:
1951
+ imports.append('import "google/api/annotations.proto";')
1417
1952
  if uses_timestamp:
1418
1953
  imports.append('import "google/protobuf/timestamp.proto";')
1419
1954
  if uses_duration:
@@ -1601,11 +2136,18 @@ def generate_pb_code(proto_path: Path) -> types.ModuleType | None:
1601
2136
 
1602
2137
 
1603
2138
  def get_request_arg_type(sig: inspect.Signature) -> Any:
1604
- """Return the type annotation of the first parameter (request) of a method."""
2139
+ """Return the type annotation of the first parameter (request) of a method.
2140
+
2141
+ If the method has no parameters, return None (implying an empty request).
2142
+ """
1605
2143
  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
2144
+ if num_of_params == 0:
2145
+ # No parameters means empty request
2146
+ return None
2147
+ elif num_of_params == 1 or num_of_params == 2:
2148
+ return tuple(sig.parameters.values())[0].annotation
2149
+ else:
2150
+ raise Exception("Method must have 0, 1, or 2 parameters")
1609
2151
 
1610
2152
 
1611
2153
  def get_rpc_methods(obj: object) -> list[tuple[str, Callable[..., Any]]]:
@@ -1836,6 +2378,18 @@ def generate_combined_proto(
1836
2378
  if is_none_type(response_type):
1837
2379
  uses_empty = True
1838
2380
 
2381
+ # Validate that users aren't using protobuf messages directly
2382
+ if hasattr(request_type, "DESCRIPTOR") and not is_none_type(request_type):
2383
+ raise TypeError(
2384
+ f"Method '{method_name}' uses protobuf message '{request_type.__name__}' directly. "
2385
+ f"Please use Pydantic Message classes instead, or None/empty Message for empty requests."
2386
+ )
2387
+ if hasattr(response_type, "DESCRIPTOR") and not is_none_type(response_type):
2388
+ raise TypeError(
2389
+ f"Method '{method_name}' uses protobuf message '{response_type.__name__}' directly. "
2390
+ f"Please use Pydantic Message classes instead, or None/empty Message for empty responses."
2391
+ )
2392
+
1839
2393
  # Collect message types for processing
1840
2394
  message_types = []
1841
2395
  if not is_none_type(request_type):
@@ -1869,13 +2423,19 @@ def generate_combined_proto(
1869
2423
  msg_def, refs = generate_message_definition(
1870
2424
  mt, done_enums, done_messages
1871
2425
  )
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
2426
 
1877
- all_type_definitions.append(msg_def)
1878
- all_type_definitions.append("")
2427
+ # Skip adding definition if it's an empty message (will use google.protobuf.Empty)
2428
+ if msg_def != "__EMPTY__":
2429
+ mt_doc = inspect.getdoc(mt)
2430
+ if mt_doc:
2431
+ for comment_line in comment_out(mt_doc):
2432
+ all_type_definitions.append(comment_line)
2433
+
2434
+ all_type_definitions.append(msg_def)
2435
+ all_type_definitions.append("")
2436
+ else:
2437
+ # Mark that we need google.protobuf.Empty import
2438
+ uses_empty = True
1879
2439
 
1880
2440
  for r in refs:
1881
2441
  if is_enum_type(r) and r not in done_enums:
@@ -1906,11 +2466,19 @@ def generate_combined_proto(
1906
2466
  else:
1907
2467
  output_msg_type = response_type
1908
2468
 
1909
- # Handle NoneType by using Empty
2469
+ # Handle NoneType and empty messages by using Empty
1910
2470
  if input_msg_type is None or input_msg_type is ServicerContext:
1911
2471
  input_str = "google.protobuf.Empty" # No need to check for stream since we validated above
1912
2472
  if input_msg_type is ServicerContext:
1913
2473
  uses_empty = True
2474
+ elif (
2475
+ inspect.isclass(input_msg_type)
2476
+ and issubclass(input_msg_type, Message)
2477
+ and not input_msg_type.model_fields
2478
+ ):
2479
+ # Empty message class
2480
+ input_str = "google.protobuf.Empty"
2481
+ uses_empty = True
1914
2482
  else:
1915
2483
  input_str = (
1916
2484
  f"stream {input_msg_type.__name__}"
@@ -1922,6 +2490,14 @@ def generate_combined_proto(
1922
2490
  output_str = "google.protobuf.Empty" # No need to check for stream since we validated above
1923
2491
  if output_msg_type is ServicerContext:
1924
2492
  uses_empty = True
2493
+ elif (
2494
+ inspect.isclass(output_msg_type)
2495
+ and issubclass(output_msg_type, Message)
2496
+ and not output_msg_type.model_fields
2497
+ ):
2498
+ # Empty message class
2499
+ output_str = "google.protobuf.Empty"
2500
+ uses_empty = True
1925
2501
  else:
1926
2502
  output_str = (
1927
2503
  f"stream {output_msg_type.__name__}"
@@ -1958,12 +2534,14 @@ def generate_combined_proto(
1958
2534
  import_block += "\n"
1959
2535
 
1960
2536
  # Combine everything
2537
+ service_defs = "\n".join(all_service_definitions)
2538
+ type_defs = "\n".join(all_type_definitions)
1961
2539
  proto_definition = f"""syntax = "proto3";
1962
2540
 
1963
2541
  package {package_name};
1964
2542
 
1965
- {import_block}{"".join(all_service_definitions)}
1966
- {indent_lines(all_type_definitions, "")}
2543
+ {import_block}{service_defs}
2544
+ {type_defs}
1967
2545
  """
1968
2546
  return proto_definition
1969
2547
 
@@ -2178,116 +2756,19 @@ class AsyncIOServer:
2178
2756
  print("gRPC server shutdown.")
2179
2757
 
2180
2758
 
2181
- class WSGIApp:
2182
- """
2183
- A WSGI-compatible application that can serve gRPC via sonora's grpcWSGI.
2184
- Useful for embedding gRPC within an existing WSGI stack.
2185
- """
2186
-
2187
- def __init__(self, app: Any):
2188
- self._app: grpcWSGI = grpcWSGI(app)
2189
- self._service_names: list[str] = []
2190
- self._package_name: str = ""
2191
-
2192
- def mount(self, obj: object, package_name: str = ""):
2193
- """Generate and compile proto files, then mount the service implementation."""
2194
- pb2_grpc_module, pb2_module = generate_and_compile_proto(obj, package_name) or (
2195
- None,
2196
- None,
2197
- )
2198
- self.mount_using_pb2_modules(pb2_grpc_module, pb2_module, obj)
2199
-
2200
- def mount_using_pb2_modules(
2201
- self, pb2_grpc_module: Any, pb2_module: Any, obj: object
2202
- ):
2203
- """Connect the compiled gRPC modules with the service implementation."""
2204
- concreteServiceClass = connect_obj_with_stub(pb2_grpc_module, pb2_module, obj)
2205
- service_name = obj.__class__.__name__
2206
- service_impl = concreteServiceClass()
2207
- getattr(pb2_grpc_module, f"add_{service_name}Servicer_to_server")(
2208
- service_impl, self._app
2209
- )
2210
- full_service_name = pb2_module.DESCRIPTOR.services_by_name[
2211
- service_name
2212
- ].full_name
2213
- self._service_names.append(full_service_name)
2214
-
2215
- def mount_objs(self, *objs: object):
2216
- """Mount multiple service objects into this WSGI app."""
2217
- for obj in objs:
2218
- self.mount(obj, self._package_name)
2219
-
2220
- def __call__(
2221
- self,
2222
- environ: dict[str, Any],
2223
- start_response: Callable[[str, list[tuple[str, str]]], None],
2224
- ) -> Any:
2225
- """WSGI entry point."""
2226
- return self._app(environ, start_response)
2227
-
2228
-
2229
- class ASGIApp:
2230
- """
2231
- An ASGI-compatible application that can serve gRPC via sonora's grpcASGI.
2232
- Useful for embedding gRPC within an existing ASGI stack.
2233
- """
2234
-
2235
- def __init__(self, app: Any):
2236
- self._app: grpcASGI = grpcASGI(app)
2237
- self._service_names: list[str] = []
2238
- self._package_name: str = ""
2239
-
2240
- def mount(self, obj: object, package_name: str = ""):
2241
- """Generate and compile proto files, then mount the async service implementation."""
2242
- pb2_grpc_module, pb2_module = generate_and_compile_proto(obj, package_name) or (
2243
- None,
2244
- None,
2245
- )
2246
- self.mount_using_pb2_modules(pb2_grpc_module, pb2_module, obj)
2247
-
2248
- def mount_using_pb2_modules(
2249
- self, pb2_grpc_module: Any, pb2_module: Any, obj: object
2250
- ):
2251
- """Connect the compiled gRPC modules with the async service implementation."""
2252
- concreteServiceClass = connect_obj_with_stub_async(
2253
- pb2_grpc_module, pb2_module, obj
2254
- )
2255
- service_name = obj.__class__.__name__
2256
- service_impl = concreteServiceClass()
2257
- getattr(pb2_grpc_module, f"add_{service_name}Servicer_to_server")(
2258
- service_impl, self._app
2259
- )
2260
- full_service_name = pb2_module.DESCRIPTOR.services_by_name[
2261
- service_name
2262
- ].full_name
2263
- self._service_names.append(full_service_name)
2264
-
2265
- def mount_objs(self, *objs: object):
2266
- """Mount multiple service objects into this ASGI app."""
2267
- for obj in objs:
2268
- self.mount(obj, self._package_name)
2269
-
2270
- async def __call__(
2271
- self,
2272
- scope: dict[str, Any],
2273
- receive: Callable[[], Any],
2274
- send: Callable[[dict[str, Any]], Any],
2275
- ) -> Any:
2276
- """ASGI entry point."""
2277
- _ = await self._app(scope, receive, send)
2278
-
2279
-
2280
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)."""
2281
2761
  return getattr(connecpy_module, f"{service_name}ASGIApplication")
2282
2762
 
2283
2763
 
2284
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)."""
2285
2766
  return getattr(connecpy_module, f"{service_name}WSGIApplication")
2286
2767
 
2287
2768
 
2288
- class ConnecpyASGIApp:
2769
+ class ASGIApp:
2289
2770
  """
2290
- An ASGI-compatible application that can serve Connect-RPC via Connecpy's ConnecpyASGIApp.
2771
+ An ASGI-compatible application that can serve Connect-RPC via Connecpy.
2291
2772
  """
2292
2773
 
2293
2774
  def __init__(self):
@@ -2357,9 +2838,9 @@ class ConnecpyASGIApp:
2357
2838
  await send({"type": "http.response.body", "body": b"Not Found"})
2358
2839
 
2359
2840
 
2360
- class ConnecpyWSGIApp:
2841
+ class WSGIApp:
2361
2842
  """
2362
- A WSGI-compatible application that can serve Connect-RPC via Connecpy's ConnecpyWSGIApp.
2843
+ A WSGI-compatible application that can serve Connect-RPC via Connecpy.
2363
2844
  """
2364
2845
 
2365
2846
  def __init__(self):
@@ -2404,8 +2885,10 @@ class ConnecpyWSGIApp:
2404
2885
  def __call__(
2405
2886
  self,
2406
2887
  environ: dict[str, Any],
2407
- start_response: Callable[[str, list[tuple[str, str]]], None],
2408
- ) -> Any:
2888
+ start_response: Callable[
2889
+ [str, list[tuple[str, str]]], Callable[[bytes], object]
2890
+ ],
2891
+ ) -> Iterable[bytes]:
2409
2892
  """WSGI entry point with routing for multiple services."""
2410
2893
  path = environ.get("PATH_INFO", "")
2411
2894