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/__init__.py +12 -4
- pydantic_rpc/core.py +686 -203
- pydantic_rpc/decorators.py +138 -0
- pydantic_rpc/mcp/__init__.py +0 -0
- pydantic_rpc/mcp/converter.py +0 -0
- pydantic_rpc/mcp/exporter.py +0 -0
- pydantic_rpc/options.py +134 -0
- pydantic_rpc/py.typed +0 -0
- {pydantic_rpc-0.7.0.dist-info → pydantic_rpc-0.9.0.dist-info}/METADATA +286 -58
- pydantic_rpc-0.9.0.dist-info/RECORD +12 -0
- pydantic_rpc-0.9.0.dist-info/WHEEL +4 -0
- {pydantic_rpc-0.7.0.dist-info → pydantic_rpc-0.9.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/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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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[...,
|
|
860
|
+
method: Callable[..., Any],
|
|
738
861
|
) -> Callable[[object, Any, Any], Any]:
|
|
739
862
|
sig = inspect.signature(method)
|
|
740
|
-
|
|
741
|
-
|
|
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
|
|
747
|
-
|
|
748
|
-
async def
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
811
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
920
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1352
|
-
|
|
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
|
|
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
|
-
|
|
1406
|
-
|
|
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
|
|
1607
|
-
|
|
1608
|
-
|
|
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
|
-
|
|
1878
|
-
|
|
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}{
|
|
1966
|
-
{
|
|
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
|
|
2769
|
+
class ASGIApp:
|
|
2289
2770
|
"""
|
|
2290
|
-
An ASGI-compatible application that can serve Connect-RPC via Connecpy
|
|
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
|
|
2841
|
+
class WSGIApp:
|
|
2361
2842
|
"""
|
|
2362
|
-
A WSGI-compatible application that can serve Connect-RPC via Connecpy
|
|
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[
|
|
2408
|
-
|
|
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
|
|