pydantic-rpc 0.8.0__py3-none-any.whl → 0.9.0__py3-none-any.whl

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