pydantic-rpc 0.8.0__py3-none-any.whl → 0.10.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 +20 -4
- pydantic_rpc/core.py +326 -182
- pydantic_rpc/decorators.py +138 -0
- pydantic_rpc/options.py +134 -0
- pydantic_rpc/tls.py +96 -0
- {pydantic_rpc-0.8.0.dist-info → pydantic_rpc-0.10.0.dist-info}/METADATA +116 -49
- pydantic_rpc-0.10.0.dist-info/RECORD +13 -0
- pydantic_rpc-0.8.0.dist-info/RECORD +0 -10
- {pydantic_rpc-0.8.0.dist-info → pydantic_rpc-0.10.0.dist-info}/WHEEL +0 -0
- {pydantic_rpc-0.8.0.dist-info → pydantic_rpc-0.10.0.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
pydantic_rpc/options.py
ADDED
|
@@ -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__"
|
pydantic_rpc/tls.py
ADDED
|
@@ -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
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: pydantic-rpc
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.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
|
|
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:
|
|
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
|
|
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 =
|
|
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
|
-
- 🌐 **
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
### 🏆
|
|
244
|
+
### 🏆 Connect-RPC with Streaming Example
|
|
260
245
|
|
|
261
|
-
PydanticRPC
|
|
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
|
-
|
|
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
|
|
307
|
-
If a service class 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:
|
|
@@ -870,9 +901,45 @@ class GoodMessage(Message):
|
|
|
870
901
|
- Test error cases thoroughly
|
|
871
902
|
- Be aware that errors fail silently
|
|
872
903
|
|
|
904
|
+
### 🔒 TLS/mTLS Support
|
|
905
|
+
|
|
906
|
+
PydanticRPC provides built-in support for TLS (Transport Layer Security) and mTLS (mutual TLS) for secure gRPC communication.
|
|
907
|
+
|
|
908
|
+
```python
|
|
909
|
+
from pydantic_rpc import AsyncIOServer, GrpcTLSConfig, extract_peer_identity
|
|
910
|
+
import grpc
|
|
911
|
+
|
|
912
|
+
# Basic TLS (server authentication only)
|
|
913
|
+
tls_config = GrpcTLSConfig(
|
|
914
|
+
cert_chain=server_cert_bytes,
|
|
915
|
+
private_key=server_key_bytes,
|
|
916
|
+
require_client_cert=False
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
# mTLS (mutual authentication)
|
|
920
|
+
tls_config = GrpcTLSConfig(
|
|
921
|
+
cert_chain=server_cert_bytes,
|
|
922
|
+
private_key=server_key_bytes,
|
|
923
|
+
root_certs=ca_cert_bytes, # CA to verify client certificates
|
|
924
|
+
require_client_cert=True
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
# Create server with TLS
|
|
928
|
+
server = AsyncIOServer(tls=tls_config)
|
|
929
|
+
|
|
930
|
+
# Extract client identity in service methods
|
|
931
|
+
class SecureService:
|
|
932
|
+
async def secure_method(self, request, context: grpc.ServicerContext):
|
|
933
|
+
client_identity = extract_peer_identity(context)
|
|
934
|
+
if client_identity:
|
|
935
|
+
print(f"Authenticated client: {client_identity}")
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
For a complete example, see [examples/tls_server.py](examples/tls_server.py) and [examples/tls_client.py](examples/tls_client.py).
|
|
939
|
+
|
|
873
940
|
### 🔗 Multiple Services with Custom Interceptors
|
|
874
941
|
|
|
875
|
-
PydanticRPC supports defining and running multiple services in a single server:
|
|
942
|
+
PydanticRPC supports defining and running multiple gRPC services in a single server:
|
|
876
943
|
|
|
877
944
|
```python
|
|
878
945
|
from datetime import datetime
|
|
@@ -1003,11 +1070,11 @@ Any MCP-compatible client can connect to your service. For example, to configure
|
|
|
1003
1070
|
MCP can also be mounted to existing ASGI applications:
|
|
1004
1071
|
|
|
1005
1072
|
```python
|
|
1006
|
-
from pydantic_rpc import
|
|
1073
|
+
from pydantic_rpc import ASGIApp
|
|
1007
1074
|
from pydantic_rpc.mcp import MCPExporter
|
|
1008
1075
|
|
|
1009
1076
|
# Create Connect-RPC ASGI app
|
|
1010
|
-
app =
|
|
1077
|
+
app = ASGIApp()
|
|
1011
1078
|
app.mount(MathService())
|
|
1012
1079
|
|
|
1013
1080
|
# Add MCP support via HTTP/SSE
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
pydantic_rpc/__init__.py,sha256=a8c4ed1a731b7f601006cbbd4aff16d43bbca0b7ffb02c698f1c79dccefe2f8e,781
|
|
2
|
+
pydantic_rpc/core.py,sha256=b2867838460a23b1ce302c9d7e6ef2a2e711c6c84887a50e3ad40b94ff5dc53c,112505
|
|
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/tls.py,sha256=0eb290cc09c8ebedc71e7a97c01736d9675a314f70ebd80a2ce73e3f1689d359,3567
|
|
10
|
+
pydantic_rpc-0.10.0.dist-info/WHEEL,sha256=ab6157bc637547491fb4567cd7ddf26b04d63382916ca16c29a5c8e94c9c9ef7,79
|
|
11
|
+
pydantic_rpc-0.10.0.dist-info/entry_points.txt,sha256=72a47b1d2cae3abc045710fe0c2c2e6dfbb051fbf6960c22b62488004e9188ba,57
|
|
12
|
+
pydantic_rpc-0.10.0.dist-info/METADATA,sha256=8819d19940dcb5a2718e9455725e976babebeeee32ba2b016135f29ba3dda67c,32096
|
|
13
|
+
pydantic_rpc-0.10.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,,
|
|
File without changes
|
|
File without changes
|