pydantic-rpc 0.9.0__tar.gz → 0.11.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pydantic-rpc
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: A Python library for building gRPC/ConnectRPC services with Pydantic models.
5
5
  Author: Yasushi Itoh
6
6
  Requires-Dist: annotated-types==0.7.0
@@ -62,9 +62,10 @@ class OlympicsLocationAgent:
62
62
 
63
63
 
64
64
  if __name__ == "__main__":
65
- s = AsyncIOServer()
65
+ # New enhanced initialization API (optional - backward compatible)
66
+ s = AsyncIOServer(service=OlympicsLocationAgent(), port=50051)
66
67
  loop = asyncio.get_event_loop()
67
- loop.run_until_complete(s.run(OlympicsLocationAgent()))
68
+ loop.run_until_complete(s.run())
68
69
  ```
69
70
 
70
71
  And here is an example of a simple Connect RPC service that exposes the same agent as an ASGI application:
@@ -106,8 +107,8 @@ class OlympicsLocationAgent:
106
107
  result = await self._agent.run(req.prompt())
107
108
  return result.data
108
109
 
109
- app = ASGIApp()
110
- app.mount(OlympicsLocationAgent())
110
+ # New enhanced initialization API (optional - backward compatible)
111
+ app = ASGIApp(service=OlympicsLocationAgent())
111
112
 
112
113
  ```
113
114
 
@@ -129,6 +130,17 @@ app.mount(OlympicsLocationAgent())
129
130
  - 🛠️ **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.
130
131
  - 🤖 **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.
131
132
 
133
+ ## ⚠️ Important Notes for Connect-RPC
134
+
135
+ When using Connect-RPC with ASGIApp:
136
+
137
+ - **Endpoint Path Format**: Connect-RPC endpoints use CamelCase method names in the path: `/<package>.<service>/<Method>` (e.g., `/chat.v1.ChatService/SendMessage`)
138
+ - **Content-Type**: Set `Content-Type: application/json` or `application/connect+json` for requests
139
+ - **HTTP/2 Requirement**: Bidirectional streaming requires HTTP/2. Use Hypercorn instead of uvicorn for HTTP/2 support
140
+ - **Testing**: Use [buf curl](https://buf.build/docs/ecosystem/cli/curl) for testing Connect-RPC endpoints with proper streaming support
141
+
142
+ For detailed examples and testing instructions, see the [examples directory](examples/).
143
+
132
144
  ## 📦 Installation
133
145
 
134
146
  Install PydanticRPC via pip:
@@ -137,6 +149,54 @@ Install PydanticRPC via pip:
137
149
  pip install pydantic-rpc
138
150
  ```
139
151
 
152
+ For CLI support with built-in server runners:
153
+
154
+ ```bash
155
+ pip install pydantic-rpc-cli # Includes hypercorn and gunicorn
156
+ ```
157
+
158
+ ## 🆕 Enhanced Features (v0.10.0+)
159
+
160
+ **Note: All new features are fully backward compatible. Existing code continues to work without modification.**
161
+
162
+ ### Enhanced Initialization API
163
+ All server classes now support optional initialization with services:
164
+
165
+ ```python
166
+ # Traditional API (still works)
167
+ server = AsyncIOServer()
168
+ server.set_port(50051)
169
+ await server.run(MyService())
170
+
171
+ # New enhanced API (optional)
172
+ server = AsyncIOServer(
173
+ service=MyService(),
174
+ port=50051,
175
+ package_name="my.package"
176
+ )
177
+ await server.run()
178
+
179
+ # Same for ASGI/WSGI apps
180
+ app = ASGIApp(service=MyService(), package_name="my.package")
181
+ ```
182
+
183
+ ### Error Handling with Decorators
184
+ Automatically map exceptions to gRPC/Connect status codes:
185
+
186
+ ```python
187
+ from pydantic_rpc import error_handler
188
+ import grpc
189
+
190
+ class MyService:
191
+ @error_handler(ValidationError, status_code=grpc.StatusCode.INVALID_ARGUMENT)
192
+ @error_handler(KeyError, status_code=grpc.StatusCode.NOT_FOUND)
193
+ async def get_user(self, request: GetUserRequest) -> User:
194
+ # Exceptions are automatically converted to proper status codes
195
+ if request.id not in users_db:
196
+ raise KeyError(f"User {request.id} not found")
197
+ return users_db[request.id]
198
+ ```
199
+
140
200
  ## 🚀 Getting Started
141
201
 
142
202
  PydanticRPC supports two main protocols:
@@ -901,8 +961,43 @@ class GoodMessage(Message):
901
961
  - Test error cases thoroughly
902
962
  - Be aware that errors fail silently
903
963
 
964
+ ### 🔒 TLS/mTLS Support
965
+
966
+ PydanticRPC provides built-in support for TLS (Transport Layer Security) and mTLS (mutual TLS) for secure gRPC communication.
967
+
968
+ ```python
969
+ from pydantic_rpc import AsyncIOServer, GrpcTLSConfig, extract_peer_identity
970
+ import grpc
971
+
972
+ # Basic TLS (server authentication only)
973
+ tls_config = GrpcTLSConfig(
974
+ cert_chain=server_cert_bytes,
975
+ private_key=server_key_bytes,
976
+ require_client_cert=False
977
+ )
978
+
979
+ # mTLS (mutual authentication)
980
+ tls_config = GrpcTLSConfig(
981
+ cert_chain=server_cert_bytes,
982
+ private_key=server_key_bytes,
983
+ root_certs=ca_cert_bytes, # CA to verify client certificates
984
+ require_client_cert=True
985
+ )
986
+
987
+ # Create server with TLS
988
+ server = AsyncIOServer(tls=tls_config)
989
+
990
+ # Extract client identity in service methods
991
+ class SecureService:
992
+ async def secure_method(self, request, context: grpc.ServicerContext):
993
+ client_identity = extract_peer_identity(context)
994
+ if client_identity:
995
+ print(f"Authenticated client: {client_identity}")
996
+ ```
997
+
998
+ For a complete example, see [examples/tls_server.py](examples/tls_server.py) and [examples/tls_client.py](examples/tls_client.py).
999
+
904
1000
  ### 🔗 Multiple Services with Custom Interceptors
905
- >>>>>>> origin/main
906
1001
 
907
1002
  PydanticRPC supports defining and running multiple gRPC services in a single server:
908
1003
 
@@ -1055,15 +1150,40 @@ MCP endpoints will be available at:
1055
1150
  - SSE: `GET http://localhost:8000/mcp/sse`
1056
1151
  - Messages: `POST http://localhost:8000/mcp/messages/`
1057
1152
 
1058
- ### 🗄️ Protobuf file and code (Python files) generation using CLI
1153
+ ### 🗄️ CLI Tool (pydantic-rpc-cli)
1059
1154
 
1060
- You can genereate protobuf files and code for a given module and a specified class using `pydantic-rpc` CLI command:
1155
+ The CLI tool provides powerful features for generating protobuf files and running servers. Install it separately:
1061
1156
 
1062
1157
  ```bash
1063
- pydantic-rpc a_module.py aClassName
1158
+ pip install pydantic-rpc-cli
1159
+ ```
1160
+
1161
+ #### Generate Protobuf Files
1162
+
1163
+ ```bash
1164
+ # Generate .proto file from a service class
1165
+ pydantic-rpc generate myapp.services.UserService --output ./proto/
1166
+
1167
+ # Also compile to Python code
1168
+ pydantic-rpc generate myapp.services.UserService --compile
1169
+ ```
1170
+
1171
+ #### Run Servers Directly
1172
+
1173
+ The CLI can run any type of server:
1174
+
1175
+ ```bash
1176
+ # Run as gRPC server (auto-detects async/sync)
1177
+ pydantic-rpc serve myapp.services.UserService --port 50051
1178
+
1179
+ # Run as Connect-RPC with ASGI (HTTP/2, uses Hypercorn)
1180
+ pydantic-rpc serve myapp.services.UserService --asgi --port 8000
1181
+
1182
+ # Run as Connect-RPC with WSGI (HTTP/1.1, uses Gunicorn)
1183
+ pydantic-rpc serve myapp.services.UserService --wsgi --port 8000 --workers 4
1064
1184
  ```
1065
1185
 
1066
- Using this generated proto file and tools as `protoc`, `buf` and `BSR`, you could generate code for any desired language other than Python.
1186
+ Using the generated proto files with tools like `protoc`, `buf` and `BSR`, you can generate code for any desired language.
1067
1187
 
1068
1188
 
1069
1189
  ## 📖 Data Type Mapping
@@ -45,9 +45,10 @@ class OlympicsLocationAgent:
45
45
 
46
46
 
47
47
  if __name__ == "__main__":
48
- s = AsyncIOServer()
48
+ # New enhanced initialization API (optional - backward compatible)
49
+ s = AsyncIOServer(service=OlympicsLocationAgent(), port=50051)
49
50
  loop = asyncio.get_event_loop()
50
- loop.run_until_complete(s.run(OlympicsLocationAgent()))
51
+ loop.run_until_complete(s.run())
51
52
  ```
52
53
 
53
54
  And here is an example of a simple Connect RPC service that exposes the same agent as an ASGI application:
@@ -89,8 +90,8 @@ class OlympicsLocationAgent:
89
90
  result = await self._agent.run(req.prompt())
90
91
  return result.data
91
92
 
92
- app = ASGIApp()
93
- app.mount(OlympicsLocationAgent())
93
+ # New enhanced initialization API (optional - backward compatible)
94
+ app = ASGIApp(service=OlympicsLocationAgent())
94
95
 
95
96
  ```
96
97
 
@@ -112,6 +113,17 @@ app.mount(OlympicsLocationAgent())
112
113
  - 🛠️ **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.
113
114
  - 🤖 **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.
114
115
 
116
+ ## ⚠️ Important Notes for Connect-RPC
117
+
118
+ When using Connect-RPC with ASGIApp:
119
+
120
+ - **Endpoint Path Format**: Connect-RPC endpoints use CamelCase method names in the path: `/<package>.<service>/<Method>` (e.g., `/chat.v1.ChatService/SendMessage`)
121
+ - **Content-Type**: Set `Content-Type: application/json` or `application/connect+json` for requests
122
+ - **HTTP/2 Requirement**: Bidirectional streaming requires HTTP/2. Use Hypercorn instead of uvicorn for HTTP/2 support
123
+ - **Testing**: Use [buf curl](https://buf.build/docs/ecosystem/cli/curl) for testing Connect-RPC endpoints with proper streaming support
124
+
125
+ For detailed examples and testing instructions, see the [examples directory](examples/).
126
+
115
127
  ## 📦 Installation
116
128
 
117
129
  Install PydanticRPC via pip:
@@ -120,6 +132,54 @@ Install PydanticRPC via pip:
120
132
  pip install pydantic-rpc
121
133
  ```
122
134
 
135
+ For CLI support with built-in server runners:
136
+
137
+ ```bash
138
+ pip install pydantic-rpc-cli # Includes hypercorn and gunicorn
139
+ ```
140
+
141
+ ## 🆕 Enhanced Features (v0.10.0+)
142
+
143
+ **Note: All new features are fully backward compatible. Existing code continues to work without modification.**
144
+
145
+ ### Enhanced Initialization API
146
+ All server classes now support optional initialization with services:
147
+
148
+ ```python
149
+ # Traditional API (still works)
150
+ server = AsyncIOServer()
151
+ server.set_port(50051)
152
+ await server.run(MyService())
153
+
154
+ # New enhanced API (optional)
155
+ server = AsyncIOServer(
156
+ service=MyService(),
157
+ port=50051,
158
+ package_name="my.package"
159
+ )
160
+ await server.run()
161
+
162
+ # Same for ASGI/WSGI apps
163
+ app = ASGIApp(service=MyService(), package_name="my.package")
164
+ ```
165
+
166
+ ### Error Handling with Decorators
167
+ Automatically map exceptions to gRPC/Connect status codes:
168
+
169
+ ```python
170
+ from pydantic_rpc import error_handler
171
+ import grpc
172
+
173
+ class MyService:
174
+ @error_handler(ValidationError, status_code=grpc.StatusCode.INVALID_ARGUMENT)
175
+ @error_handler(KeyError, status_code=grpc.StatusCode.NOT_FOUND)
176
+ async def get_user(self, request: GetUserRequest) -> User:
177
+ # Exceptions are automatically converted to proper status codes
178
+ if request.id not in users_db:
179
+ raise KeyError(f"User {request.id} not found")
180
+ return users_db[request.id]
181
+ ```
182
+
123
183
  ## 🚀 Getting Started
124
184
 
125
185
  PydanticRPC supports two main protocols:
@@ -884,8 +944,43 @@ class GoodMessage(Message):
884
944
  - Test error cases thoroughly
885
945
  - Be aware that errors fail silently
886
946
 
947
+ ### 🔒 TLS/mTLS Support
948
+
949
+ PydanticRPC provides built-in support for TLS (Transport Layer Security) and mTLS (mutual TLS) for secure gRPC communication.
950
+
951
+ ```python
952
+ from pydantic_rpc import AsyncIOServer, GrpcTLSConfig, extract_peer_identity
953
+ import grpc
954
+
955
+ # Basic TLS (server authentication only)
956
+ tls_config = GrpcTLSConfig(
957
+ cert_chain=server_cert_bytes,
958
+ private_key=server_key_bytes,
959
+ require_client_cert=False
960
+ )
961
+
962
+ # mTLS (mutual authentication)
963
+ tls_config = GrpcTLSConfig(
964
+ cert_chain=server_cert_bytes,
965
+ private_key=server_key_bytes,
966
+ root_certs=ca_cert_bytes, # CA to verify client certificates
967
+ require_client_cert=True
968
+ )
969
+
970
+ # Create server with TLS
971
+ server = AsyncIOServer(tls=tls_config)
972
+
973
+ # Extract client identity in service methods
974
+ class SecureService:
975
+ async def secure_method(self, request, context: grpc.ServicerContext):
976
+ client_identity = extract_peer_identity(context)
977
+ if client_identity:
978
+ print(f"Authenticated client: {client_identity}")
979
+ ```
980
+
981
+ For a complete example, see [examples/tls_server.py](examples/tls_server.py) and [examples/tls_client.py](examples/tls_client.py).
982
+
887
983
  ### 🔗 Multiple Services with Custom Interceptors
888
- >>>>>>> origin/main
889
984
 
890
985
  PydanticRPC supports defining and running multiple gRPC services in a single server:
891
986
 
@@ -1038,15 +1133,40 @@ MCP endpoints will be available at:
1038
1133
  - SSE: `GET http://localhost:8000/mcp/sse`
1039
1134
  - Messages: `POST http://localhost:8000/mcp/messages/`
1040
1135
 
1041
- ### 🗄️ Protobuf file and code (Python files) generation using CLI
1136
+ ### 🗄️ CLI Tool (pydantic-rpc-cli)
1042
1137
 
1043
- You can genereate protobuf files and code for a given module and a specified class using `pydantic-rpc` CLI command:
1138
+ The CLI tool provides powerful features for generating protobuf files and running servers. Install it separately:
1044
1139
 
1045
1140
  ```bash
1046
- pydantic-rpc a_module.py aClassName
1141
+ pip install pydantic-rpc-cli
1142
+ ```
1143
+
1144
+ #### Generate Protobuf Files
1145
+
1146
+ ```bash
1147
+ # Generate .proto file from a service class
1148
+ pydantic-rpc generate myapp.services.UserService --output ./proto/
1149
+
1150
+ # Also compile to Python code
1151
+ pydantic-rpc generate myapp.services.UserService --compile
1152
+ ```
1153
+
1154
+ #### Run Servers Directly
1155
+
1156
+ The CLI can run any type of server:
1157
+
1158
+ ```bash
1159
+ # Run as gRPC server (auto-detects async/sync)
1160
+ pydantic-rpc serve myapp.services.UserService --port 50051
1161
+
1162
+ # Run as Connect-RPC with ASGI (HTTP/2, uses Hypercorn)
1163
+ pydantic-rpc serve myapp.services.UserService --asgi --port 8000
1164
+
1165
+ # Run as Connect-RPC with WSGI (HTTP/1.1, uses Gunicorn)
1166
+ pydantic-rpc serve myapp.services.UserService --wsgi --port 8000 --workers 4
1047
1167
  ```
1048
1168
 
1049
- Using this generated proto file and tools as `protoc`, `buf` and `BSR`, you could generate code for any desired language other than Python.
1169
+ Using the generated proto files with tools like `protoc`, `buf` and `BSR`, you can generate code for any desired language.
1050
1170
 
1051
1171
 
1052
1172
  ## 📖 Data Type Mapping
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pydantic-rpc"
3
- version = "0.9.0"
3
+ version = "0.11.0"
4
4
  description = "A Python library for building gRPC/ConnectRPC services with Pydantic models."
5
5
  authors = [
6
6
  { name = "Yasushi Itoh" }
@@ -19,8 +19,6 @@ dependencies = [
19
19
  readme = "README.md"
20
20
  requires-python = ">= 3.11"
21
21
 
22
- [project.scripts]
23
- pydantic-rpc = "pydantic_rpc.core:main"
24
22
 
25
23
  [build-system]
26
24
  requires = ["uv_build>=0.7.21,<0.8.0"]
@@ -13,6 +13,13 @@ from .decorators import (
13
13
  proto_option,
14
14
  get_method_options,
15
15
  has_http_option,
16
+ error_handler,
17
+ get_error_handlers,
18
+ )
19
+ from .tls import (
20
+ GrpcTLSConfig,
21
+ extract_peer_identity,
22
+ extract_peer_certificate_chain,
16
23
  )
17
24
 
18
25
  __all__ = [
@@ -26,6 +33,11 @@ __all__ = [
26
33
  "proto_option",
27
34
  "get_method_options",
28
35
  "has_http_option",
36
+ "error_handler",
37
+ "get_error_handlers",
38
+ "GrpcTLSConfig",
39
+ "extract_peer_identity",
40
+ "extract_peer_certificate_chain",
29
41
  ]
30
42
 
31
43
  # Optional MCP support
@@ -15,6 +15,7 @@ from pathlib import Path
15
15
  from posixpath import basename
16
16
  from typing import (
17
17
  Any,
18
+ Optional,
18
19
  TypeAlias,
19
20
  get_args,
20
21
  get_origin,
@@ -36,6 +37,7 @@ from grpc_reflection.v1alpha import reflection
36
37
  from grpc_tools import protoc
37
38
  from pydantic import BaseModel, ValidationError
38
39
  from .decorators import get_method_options, has_http_option
40
+ from .tls import GrpcTLSConfig
39
41
 
40
42
  ###############################################################################
41
43
  # 1. Message definitions & converter extensions
@@ -2602,13 +2604,23 @@ def generate_combined_descriptor_set(
2602
2604
  class Server:
2603
2605
  """A simple gRPC server that uses ThreadPoolExecutor for concurrency."""
2604
2606
 
2605
- def __init__(self, max_workers: int = 8, *interceptors: Any) -> None:
2607
+ def __init__(
2608
+ self,
2609
+ service: Optional[object] = None,
2610
+ port: int = 50051,
2611
+ package_name: str = "",
2612
+ max_workers: int = 8,
2613
+ *interceptors: Any,
2614
+ tls: Optional["GrpcTLSConfig"] = None,
2615
+ ) -> None:
2606
2616
  self._server: grpc.Server = grpc.server(
2607
2617
  futures.ThreadPoolExecutor(max_workers), interceptors=interceptors
2608
2618
  )
2609
2619
  self._service_names: list[str] = []
2610
- self._package_name: str = ""
2611
- self._port: int = 50051
2620
+ self._package_name: str = package_name
2621
+ self._port: int = port
2622
+ self._tls_config = tls
2623
+ self._initial_service = service
2612
2624
 
2613
2625
  def set_package_name(self, package_name: str):
2614
2626
  """Set the package name for .proto generation."""
@@ -2646,6 +2658,11 @@ class Server:
2646
2658
  Mount multiple services and run the gRPC server with reflection and health check.
2647
2659
  Press Ctrl+C or send SIGTERM to stop.
2648
2660
  """
2661
+ # Mount initial service if provided
2662
+ if self._initial_service:
2663
+ self.mount(self._initial_service, self._package_name)
2664
+
2665
+ # Mount additional services
2649
2666
  for obj in objs:
2650
2667
  self.mount(obj, self._package_name)
2651
2668
 
@@ -2658,7 +2675,16 @@ class Server:
2658
2675
  health_pb2_grpc.add_HealthServicer_to_server(health_servicer, self._server)
2659
2676
  reflection.enable_server_reflection(SERVICE_NAMES, self._server)
2660
2677
 
2661
- self._server.add_insecure_port(f"[::]:{self._port}")
2678
+ if self._tls_config:
2679
+ # Use secure port with TLS
2680
+ credentials = self._tls_config.to_server_credentials()
2681
+ self._server.add_secure_port(f"[::]:{self._port}", credentials)
2682
+ print(f"gRPC server starting with TLS on port {self._port}...")
2683
+ else:
2684
+ # Use insecure port
2685
+ self._server.add_insecure_port(f"[::]:{self._port}")
2686
+ print(f"gRPC server starting on port {self._port}...")
2687
+
2662
2688
  self._server.start()
2663
2689
 
2664
2690
  def handle_signal(signum: signal.Signals, frame: Any):
@@ -2680,11 +2706,20 @@ class Server:
2680
2706
  class AsyncIOServer:
2681
2707
  """An async gRPC server using asyncio."""
2682
2708
 
2683
- def __init__(self, *interceptors: grpc.ServerInterceptor) -> None:
2709
+ def __init__(
2710
+ self,
2711
+ service: Optional[object] = None,
2712
+ port: int = 50051,
2713
+ package_name: str = "",
2714
+ *interceptors: grpc.ServerInterceptor,
2715
+ tls: Optional["GrpcTLSConfig"] = None,
2716
+ ) -> None:
2684
2717
  self._server: grpc.aio.Server = grpc.aio.server(interceptors=interceptors)
2685
2718
  self._service_names: list[str] = []
2686
- self._package_name: str = ""
2687
- self._port: int = 50051
2719
+ self._package_name: str = package_name
2720
+ self._port: int = port
2721
+ self._tls_config = tls
2722
+ self._initial_service = service
2688
2723
 
2689
2724
  def set_package_name(self, package_name: str):
2690
2725
  """Set the package name for .proto generation."""
@@ -2724,6 +2759,11 @@ class AsyncIOServer:
2724
2759
  Mount multiple async services and run the gRPC server with reflection and health check.
2725
2760
  Press Ctrl+C or send SIGTERM to stop.
2726
2761
  """
2762
+ # Mount initial service if provided
2763
+ if self._initial_service:
2764
+ self.mount(self._initial_service, self._package_name)
2765
+
2766
+ # Mount additional services
2727
2767
  for obj in objs:
2728
2768
  self.mount(obj, self._package_name)
2729
2769
 
@@ -2736,7 +2776,16 @@ class AsyncIOServer:
2736
2776
  health_pb2_grpc.add_HealthServicer_to_server(health_servicer, self._server)
2737
2777
  reflection.enable_server_reflection(SERVICE_NAMES, self._server)
2738
2778
 
2739
- _ = self._server.add_insecure_port(f"[::]:{self._port}")
2779
+ if self._tls_config:
2780
+ # Use secure port with TLS
2781
+ credentials = self._tls_config.to_server_credentials()
2782
+ _ = self._server.add_secure_port(f"[::]:{self._port}", credentials)
2783
+ print(f"gRPC server starting with TLS on port {self._port}...")
2784
+ else:
2785
+ # Use insecure port
2786
+ _ = self._server.add_insecure_port(f"[::]:{self._port}")
2787
+ print(f"gRPC server starting on port {self._port}...")
2788
+
2740
2789
  await self._server.start()
2741
2790
 
2742
2791
  shutdown_event = asyncio.Event()
@@ -2771,10 +2820,11 @@ class ASGIApp:
2771
2820
  An ASGI-compatible application that can serve Connect-RPC via Connecpy.
2772
2821
  """
2773
2822
 
2774
- def __init__(self):
2823
+ def __init__(self, service: Optional[object] = None, package_name: str = ""):
2775
2824
  self._services: list[tuple[Any, str]] = [] # List of (app, path) tuples
2776
2825
  self._service_names: list[str] = []
2777
- self._package_name: str = ""
2826
+ self._package_name: str = package_name
2827
+ self._initial_service = service
2778
2828
 
2779
2829
  def mount(self, obj: object, package_name: str = ""):
2780
2830
  """Generate and compile proto files, then mount the async service implementation."""
@@ -2807,6 +2857,11 @@ class ASGIApp:
2807
2857
 
2808
2858
  def mount_objs(self, *objs: object):
2809
2859
  """Mount multiple service objects into this ASGI app."""
2860
+ # Mount initial service if provided
2861
+ if self._initial_service:
2862
+ self.mount(self._initial_service, self._package_name)
2863
+
2864
+ # Mount additional services
2810
2865
  for obj in objs:
2811
2866
  self.mount(obj, self._package_name)
2812
2867
 
@@ -2817,6 +2872,10 @@ class ASGIApp:
2817
2872
  send: Callable[[dict[str, Any]], Any],
2818
2873
  ):
2819
2874
  """ASGI entry point with routing for multiple services."""
2875
+ # Mount initial service on first call if not already mounted
2876
+ if self._initial_service and not self._services:
2877
+ self.mount(self._initial_service, self._package_name)
2878
+
2820
2879
  if scope["type"] != "http":
2821
2880
  await send({"type": "http.response.start", "status": 404})
2822
2881
  await send({"type": "http.response.body", "body": b"Not Found"})
@@ -2843,10 +2902,11 @@ class WSGIApp:
2843
2902
  A WSGI-compatible application that can serve Connect-RPC via Connecpy.
2844
2903
  """
2845
2904
 
2846
- def __init__(self):
2905
+ def __init__(self, service: Optional[object] = None, package_name: str = ""):
2847
2906
  self._services: list[tuple[Any, str]] = [] # List of (app, path) tuples
2848
2907
  self._service_names: list[str] = []
2849
- self._package_name: str = ""
2908
+ self._package_name: str = package_name
2909
+ self._initial_service = service
2850
2910
 
2851
2911
  def mount(self, obj: object, package_name: str = ""):
2852
2912
  """Generate and compile proto files, then mount the sync service implementation."""
@@ -2879,6 +2939,11 @@ class WSGIApp:
2879
2939
 
2880
2940
  def mount_objs(self, *objs: object):
2881
2941
  """Mount multiple service objects into this WSGI app."""
2942
+ # Mount initial service if provided
2943
+ if self._initial_service:
2944
+ self.mount(self._initial_service, self._package_name)
2945
+
2946
+ # Mount additional services
2882
2947
  for obj in objs:
2883
2948
  self.mount(obj, self._package_name)
2884
2949
 
@@ -2890,6 +2955,10 @@ class WSGIApp:
2890
2955
  ],
2891
2956
  ) -> Iterable[bytes]:
2892
2957
  """WSGI entry point with routing for multiple services."""
2958
+ # Mount initial service on first call if not already mounted
2959
+ if self._initial_service and not self._services:
2960
+ self.mount(self._initial_service, self._package_name)
2961
+
2893
2962
  path = environ.get("PATH_INFO", "")
2894
2963
 
2895
2964
  # Route to the appropriate service based on path
@@ -1,12 +1,15 @@
1
1
  """Decorators for adding protobuf options to RPC methods."""
2
2
 
3
- from typing import Any, Callable, Dict, List, Optional, TypeVar
3
+ from typing import Any, Callable, Dict, List, Optional, TypeVar, Type
4
4
  from functools import wraps
5
+ import grpc
6
+ from connecpy.code import Code as ConnectErrors
5
7
 
6
8
  from .options import OptionMetadata, OPTION_METADATA_ATTR
7
9
 
8
10
 
9
11
  F = TypeVar("F", bound=Callable[..., Any])
12
+ ERROR_HANDLER_ATTR = "__pydantic_rpc_error_handlers__"
10
13
 
11
14
 
12
15
  def http_option(
@@ -136,3 +139,71 @@ def has_proto_options(method: Callable) -> bool:
136
139
  """
137
140
  metadata = get_method_options(method)
138
141
  return metadata is not None and len(metadata.proto_options) > 0
142
+
143
+
144
+ def error_handler(
145
+ exception_type: Type[Exception],
146
+ status_code: Optional[grpc.StatusCode] = None,
147
+ connect_code: Optional[ConnectErrors] = None,
148
+ handler: Optional[Callable[[Exception], tuple[str, Any]]] = None,
149
+ ) -> Callable[[F], F]:
150
+ """
151
+ Decorator to add automatic error handling to an RPC method.
152
+
153
+ Args:
154
+ exception_type: The type of exception to handle
155
+ status_code: The gRPC status code to return (for gRPC services)
156
+ connect_code: The Connect error code to return (for Connect services)
157
+ handler: Optional custom handler function that returns (message, details)
158
+
159
+ Example:
160
+ @error_handler(ValidationError, status_code=grpc.StatusCode.INVALID_ARGUMENT)
161
+ @error_handler(KeyError, status_code=grpc.StatusCode.NOT_FOUND)
162
+ async def get_user(self, request: GetUserRequest) -> User:
163
+ ...
164
+ """
165
+
166
+ def decorator(func: F) -> F:
167
+ # Get or create error handlers list
168
+ if not hasattr(func, ERROR_HANDLER_ATTR):
169
+ setattr(func, ERROR_HANDLER_ATTR, [])
170
+
171
+ handlers = getattr(func, ERROR_HANDLER_ATTR)
172
+
173
+ # Add this handler to the list
174
+ handlers.append(
175
+ {
176
+ "exception_type": exception_type,
177
+ "status_code": status_code or grpc.StatusCode.INTERNAL,
178
+ "connect_code": connect_code or ConnectErrors.INTERNAL,
179
+ "handler": handler,
180
+ }
181
+ )
182
+
183
+ @wraps(func)
184
+ def wrapper(*args, **kwargs):
185
+ return func(*args, **kwargs)
186
+
187
+ # Preserve the error handlers on the wrapper
188
+ setattr(wrapper, ERROR_HANDLER_ATTR, handlers)
189
+
190
+ # Preserve any existing option metadata
191
+ if hasattr(func, OPTION_METADATA_ATTR):
192
+ setattr(wrapper, OPTION_METADATA_ATTR, getattr(func, OPTION_METADATA_ATTR))
193
+
194
+ return wrapper # type: ignore
195
+
196
+ return decorator
197
+
198
+
199
+ def get_error_handlers(method: Callable) -> Optional[List[Dict[str, Any]]]:
200
+ """
201
+ Get error handlers from a method.
202
+
203
+ Args:
204
+ method: The method to get error handlers from
205
+
206
+ Returns:
207
+ List of error handler configurations if present, None otherwise
208
+ """
209
+ return getattr(method, ERROR_HANDLER_ATTR, None)
@@ -0,0 +1,96 @@
1
+ """TLS configuration for gRPC servers."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional, Any
5
+ import grpc
6
+
7
+
8
+ @dataclass
9
+ class GrpcTLSConfig:
10
+ """Configuration for gRPC server TLS.
11
+
12
+ Args:
13
+ cert_chain: The PEM-encoded server certificate chain.
14
+ private_key: The PEM-encoded private key for the server certificate.
15
+ root_certs: Optional PEM-encoded root certificates for verifying client certificates.
16
+ If provided with require_client_cert=True, enables mTLS.
17
+ require_client_cert: If True, requires and verifies client certificates (mTLS).
18
+ """
19
+
20
+ cert_chain: bytes
21
+ private_key: bytes
22
+ root_certs: Optional[bytes] = None
23
+ require_client_cert: bool = False
24
+
25
+ def to_server_credentials(self) -> grpc.ServerCredentials:
26
+ """Convert to gRPC ServerCredentials."""
27
+ private_key_certificate_chain_pairs = [(self.private_key, self.cert_chain)]
28
+
29
+ if self.require_client_cert and self.root_certs:
30
+ # mTLS: require and verify client certificates
31
+ return grpc.ssl_server_credentials(
32
+ private_key_certificate_chain_pairs,
33
+ root_certificates=self.root_certs,
34
+ require_client_auth=True,
35
+ )
36
+ elif self.root_certs:
37
+ # Optional client certificates
38
+ return grpc.ssl_server_credentials(
39
+ private_key_certificate_chain_pairs,
40
+ root_certificates=self.root_certs,
41
+ require_client_auth=False,
42
+ )
43
+ else:
44
+ # Server TLS only, no client certificate validation
45
+ return grpc.ssl_server_credentials(private_key_certificate_chain_pairs)
46
+
47
+
48
+ def extract_peer_identity(context: grpc.ServicerContext) -> Optional[str]:
49
+ """Extract the peer identity from the ServicerContext.
50
+
51
+ For mTLS connections, this returns the client's certificate subject.
52
+
53
+ Args:
54
+ context: The gRPC ServicerContext from a request handler.
55
+
56
+ Returns:
57
+ The peer identity string if available, None otherwise.
58
+ """
59
+ auth_context: Any = context.auth_context()
60
+ if auth_context:
61
+ # The peer identity is typically stored under the 'x509_common_name' key
62
+ # or 'x509_subject_alternative_name' for SANs
63
+ identities = auth_context.get("x509_common_name")
64
+ if identities and len(identities) > 0:
65
+ # Return the first identity (there's usually only one CN)
66
+ # gRPC returns bytes, so we decode to string
67
+ identity_bytes: bytes = identities[0]
68
+ return identity_bytes.decode("utf-8")
69
+
70
+ # Fallback to SAN if CN is not available
71
+ san_identities = auth_context.get("x509_subject_alternative_name")
72
+ if san_identities and len(san_identities) > 0:
73
+ # gRPC returns bytes, so we decode to string
74
+ san_bytes: bytes = san_identities[0]
75
+ return san_bytes.decode("utf-8")
76
+
77
+ return None
78
+
79
+
80
+ def extract_peer_certificate_chain(context: grpc.ServicerContext) -> Optional[bytes]:
81
+ """Extract the peer's certificate chain from the ServicerContext.
82
+
83
+ Args:
84
+ context: The gRPC ServicerContext from a request handler.
85
+
86
+ Returns:
87
+ The peer's certificate chain in PEM format if available, None otherwise.
88
+ """
89
+ auth_context: Any = context.auth_context()
90
+ if auth_context:
91
+ cert_chain = auth_context.get("x509_peer_certificate")
92
+ if cert_chain and len(cert_chain) > 0:
93
+ cert_bytes: bytes = cert_chain[0]
94
+ return cert_bytes
95
+
96
+ return None