pydantic-rpc 0.2.2__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pydantic_rpc/core.py +93 -20
- {pydantic_rpc-0.2.2.dist-info → pydantic_rpc-0.3.1.dist-info}/METADATA +96 -3
- pydantic_rpc-0.3.1.dist-info/RECORD +7 -0
- pydantic_rpc-0.2.2.dist-info/RECORD +0 -7
- {pydantic_rpc-0.2.2.dist-info → pydantic_rpc-0.3.1.dist-info}/WHEEL +0 -0
- {pydantic_rpc-0.2.2.dist-info → pydantic_rpc-0.3.1.dist-info}/licenses/LICENSE +0 -0
pydantic_rpc/core.py
CHANGED
|
@@ -17,7 +17,9 @@ from typing import (
|
|
|
17
17
|
get_origin,
|
|
18
18
|
Union,
|
|
19
19
|
TypeAlias,
|
|
20
|
+
# AsyncIterator, # Add if not already present
|
|
20
21
|
)
|
|
22
|
+
from collections.abc import AsyncIterator
|
|
21
23
|
|
|
22
24
|
import grpc
|
|
23
25
|
from grpc_health.v1 import health_pb2, health_pb2_grpc
|
|
@@ -105,24 +107,26 @@ def generate_converter(annotation: Type) -> Callable:
|
|
|
105
107
|
|
|
106
108
|
return dur_converter
|
|
107
109
|
|
|
108
|
-
|
|
109
|
-
if
|
|
110
|
-
|
|
110
|
+
origin = get_origin(annotation)
|
|
111
|
+
if origin is not None:
|
|
112
|
+
# For seq types
|
|
113
|
+
if origin in (list, tuple):
|
|
114
|
+
item_converter = generate_converter(get_args(annotation)[0])
|
|
111
115
|
|
|
112
|
-
|
|
113
|
-
|
|
116
|
+
def seq_converter(value):
|
|
117
|
+
return [item_converter(v) for v in value]
|
|
114
118
|
|
|
115
|
-
|
|
119
|
+
return seq_converter
|
|
116
120
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
# For dict types
|
|
122
|
+
if origin is dict:
|
|
123
|
+
key_converter = generate_converter(get_args(annotation)[0])
|
|
124
|
+
value_converter = generate_converter(get_args(annotation)[1])
|
|
121
125
|
|
|
122
|
-
|
|
123
|
-
|
|
126
|
+
def dict_converter(value):
|
|
127
|
+
return {key_converter(k): value_converter(v) for k, v in value.items()}
|
|
124
128
|
|
|
125
|
-
|
|
129
|
+
return dict_converter
|
|
126
130
|
|
|
127
131
|
# For Message classes
|
|
128
132
|
if inspect.isclass(annotation) and issubclass(annotation, Message):
|
|
@@ -262,6 +266,50 @@ def connect_obj_with_stub_async(pb2_grpc_module, pb2_module, obj: object) -> typ
|
|
|
262
266
|
response_type = sig.return_annotation
|
|
263
267
|
size_of_parameters = len(sig.parameters)
|
|
264
268
|
|
|
269
|
+
if is_stream_type(response_type):
|
|
270
|
+
item_type = get_args(response_type)[0]
|
|
271
|
+
match size_of_parameters:
|
|
272
|
+
case 1:
|
|
273
|
+
|
|
274
|
+
async def stub_method_stream1(
|
|
275
|
+
self, request, context, method=method
|
|
276
|
+
):
|
|
277
|
+
try:
|
|
278
|
+
arg = converter(request)
|
|
279
|
+
async for resp_obj in method(arg):
|
|
280
|
+
yield convert_python_message_to_proto(
|
|
281
|
+
resp_obj, item_type, pb2_module
|
|
282
|
+
)
|
|
283
|
+
except ValidationError as e:
|
|
284
|
+
await context.abort(
|
|
285
|
+
grpc.StatusCode.INVALID_ARGUMENT, str(e)
|
|
286
|
+
)
|
|
287
|
+
except Exception as e:
|
|
288
|
+
await context.abort(grpc.StatusCode.INTERNAL, str(e))
|
|
289
|
+
|
|
290
|
+
return stub_method_stream1
|
|
291
|
+
case 2:
|
|
292
|
+
|
|
293
|
+
async def stub_method_stream2(
|
|
294
|
+
self, request, context, method=method
|
|
295
|
+
):
|
|
296
|
+
try:
|
|
297
|
+
arg = converter(request)
|
|
298
|
+
async for resp_obj in method(arg, context):
|
|
299
|
+
yield convert_python_message_to_proto(
|
|
300
|
+
resp_obj, item_type, pb2_module
|
|
301
|
+
)
|
|
302
|
+
except ValidationError as e:
|
|
303
|
+
await context.abort(
|
|
304
|
+
grpc.StatusCode.INVALID_ARGUMENT, str(e)
|
|
305
|
+
)
|
|
306
|
+
except Exception as e:
|
|
307
|
+
await context.abort(grpc.StatusCode.INTERNAL, str(e))
|
|
308
|
+
|
|
309
|
+
return stub_method_stream2
|
|
310
|
+
case _:
|
|
311
|
+
raise Exception("Method must have exactly one or two parameters")
|
|
312
|
+
|
|
265
313
|
match size_of_parameters:
|
|
266
314
|
case 1:
|
|
267
315
|
|
|
@@ -418,13 +466,14 @@ def python_value_to_proto(field_type: Type, value, pb2_module):
|
|
|
418
466
|
if inspect.isclass(field_type) and issubclass(field_type, enum.Enum):
|
|
419
467
|
return value.value # proto3 enum is an int
|
|
420
468
|
|
|
421
|
-
|
|
422
|
-
|
|
469
|
+
origin = get_origin(field_type)
|
|
470
|
+
# If seq
|
|
471
|
+
if origin in (list, tuple):
|
|
423
472
|
inner_type = get_args(field_type)[0] # type: ignore
|
|
424
473
|
return [python_value_to_proto(inner_type, v, pb2_module) for v in value]
|
|
425
474
|
|
|
426
475
|
# If dict
|
|
427
|
-
if
|
|
476
|
+
if origin is dict:
|
|
428
477
|
key_type, val_type = get_args(field_type) # type: ignore
|
|
429
478
|
return {
|
|
430
479
|
python_value_to_proto(key_type, k, pb2_module): python_value_to_proto(
|
|
@@ -530,7 +579,7 @@ def protobuf_type_mapping(python_type: Type) -> str | type | None:
|
|
|
530
579
|
return None # Handled separately as oneof
|
|
531
580
|
|
|
532
581
|
if hasattr(python_type, "__origin__"):
|
|
533
|
-
if python_type.__origin__
|
|
582
|
+
if python_type.__origin__ in (list, tuple):
|
|
534
583
|
inner_type = python_type.__args__[0]
|
|
535
584
|
inner_proto_type = protobuf_type_mapping(inner_type)
|
|
536
585
|
if inner_proto_type:
|
|
@@ -553,6 +602,10 @@ def comment_out(docstr: str) -> tuple[str, ...]:
|
|
|
553
602
|
"""Convert docstrings into commented-out lines in a .proto file."""
|
|
554
603
|
if docstr is None:
|
|
555
604
|
return tuple()
|
|
605
|
+
|
|
606
|
+
if docstr.startswith("Usage docs: https://docs.pydantic.dev/2.10/concepts/models/"):
|
|
607
|
+
return tuple()
|
|
608
|
+
|
|
556
609
|
return tuple(f"//" if line == "" else f"// {line}" for line in docstr.split("\n"))
|
|
557
610
|
|
|
558
611
|
|
|
@@ -659,6 +712,14 @@ def generate_message_definition(
|
|
|
659
712
|
return msg_def, refs
|
|
660
713
|
|
|
661
714
|
|
|
715
|
+
def is_stream_type(annotation: Type) -> bool:
|
|
716
|
+
return get_origin(annotation) is AsyncIterator
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def is_generic_alias(annotation: Type) -> bool:
|
|
720
|
+
return get_origin(annotation) is not None
|
|
721
|
+
|
|
722
|
+
|
|
662
723
|
def generate_proto(obj: object, package_name: str = "") -> str:
|
|
663
724
|
"""
|
|
664
725
|
Generate a .proto definition from a service class.
|
|
@@ -702,6 +763,11 @@ def generate_proto(obj: object, package_name: str = "") -> str:
|
|
|
702
763
|
continue
|
|
703
764
|
done_messages.add(mt)
|
|
704
765
|
|
|
766
|
+
if is_stream_type(mt):
|
|
767
|
+
item_type = get_args(mt)[0]
|
|
768
|
+
message_types.append(item_type)
|
|
769
|
+
continue
|
|
770
|
+
|
|
705
771
|
for _, field_info in mt.model_fields.items():
|
|
706
772
|
t = field_info.annotation
|
|
707
773
|
if is_union_type(t):
|
|
@@ -732,9 +798,16 @@ def generate_proto(obj: object, package_name: str = "") -> str:
|
|
|
732
798
|
if method_docstr:
|
|
733
799
|
for comment_line in comment_out(method_docstr):
|
|
734
800
|
rpc_definitions.append(comment_line)
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
801
|
+
|
|
802
|
+
if is_stream_type(response_type):
|
|
803
|
+
item_type = get_args(response_type)[0]
|
|
804
|
+
rpc_definitions.append(
|
|
805
|
+
f"rpc {method_name} ({request_type.__name__}) returns (stream {item_type.__name__});"
|
|
806
|
+
)
|
|
807
|
+
else:
|
|
808
|
+
rpc_definitions.append(
|
|
809
|
+
f"rpc {method_name} ({request_type.__name__}) returns ({response_type.__name__});"
|
|
810
|
+
)
|
|
738
811
|
|
|
739
812
|
if not package_name:
|
|
740
813
|
if service_name.endswith("Service"):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: pydantic-rpc
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A Python library for building gRPC/ConnectRPC services with Pydantic models.
|
|
5
5
|
Author: Yasushi Itoh
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -14,7 +14,7 @@ Description-Content-Type: text/markdown
|
|
|
14
14
|
|
|
15
15
|
# 🚀 PydanticRPC
|
|
16
16
|
|
|
17
|
-
**PydanticRPC** is a Python library that enables you to rapidly expose [Pydantic](https://docs.pydantic.dev/) models via [gRPC](https://grpc.io/)/[
|
|
17
|
+
**PydanticRPC** is a Python library that enables you to rapidly expose [Pydantic](https://docs.pydantic.dev/) models via [gRPC](https://grpc.io/)/[Connect RPC](https://connectrpc.com/docs/protocol/) services without writing any protobuf files. Instead, it automatically generates protobuf files on the fly from the method signatures of your Python objects and the type signatures of your Pydantic models.
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
Below is an example of a simple gRPC service that exposes a [PydanticAI](https://ai.pydantic.dev/) agent:
|
|
@@ -54,7 +54,7 @@ if __name__ == "__main__":
|
|
|
54
54
|
loop.run_until_complete(s.run(OlympicsLocationAgent()))
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
And here is an example of a simple
|
|
57
|
+
And here is an example of a simple Connect RPC service that exposes the same agent as an ASGI application:
|
|
58
58
|
|
|
59
59
|
```python
|
|
60
60
|
import asyncio
|
|
@@ -254,6 +254,88 @@ When this variable is set to "true", PydanticRPC will load existing pre-generate
|
|
|
254
254
|
|
|
255
255
|
## 💎 Advanced Features
|
|
256
256
|
|
|
257
|
+
### 🌊 Response Streaming
|
|
258
|
+
PydanticRPC supports streaming for responses in asynchronous gRPC and gRPC-Web services only.
|
|
259
|
+
|
|
260
|
+
Please see the sample code below:
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
import asyncio
|
|
264
|
+
from typing import AsyncIterator
|
|
265
|
+
|
|
266
|
+
from pydantic import field_validator
|
|
267
|
+
from pydantic_ai import Agent
|
|
268
|
+
from pydantic_rpc import AsyncIOServer, Message
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# `Message` is just a pydantic BaseModel alias
|
|
272
|
+
class CityLocation(Message):
|
|
273
|
+
city: str
|
|
274
|
+
country: str
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class OlympicsQuery(Message):
|
|
278
|
+
year: int
|
|
279
|
+
|
|
280
|
+
def prompt(self):
|
|
281
|
+
return f"Where were the Olympics held in {self.year}?"
|
|
282
|
+
|
|
283
|
+
@field_validator("year")
|
|
284
|
+
def validate_year(cls, value):
|
|
285
|
+
if value < 1896:
|
|
286
|
+
raise ValueError("The first modern Olympics was held in 1896.")
|
|
287
|
+
|
|
288
|
+
return value
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class OlympicsDurationQuery(Message):
|
|
292
|
+
start: int
|
|
293
|
+
end: int
|
|
294
|
+
|
|
295
|
+
def prompt(self):
|
|
296
|
+
return f"From {self.start} to {self.end}, how many Olympics were held? Please provide the list of countries and cities."
|
|
297
|
+
|
|
298
|
+
@field_validator("start")
|
|
299
|
+
def validate_start(cls, value):
|
|
300
|
+
if value < 1896:
|
|
301
|
+
raise ValueError("The first modern Olympics was held in 1896.")
|
|
302
|
+
|
|
303
|
+
return value
|
|
304
|
+
|
|
305
|
+
@field_validator("end")
|
|
306
|
+
def validate_end(cls, value):
|
|
307
|
+
if value < 1896:
|
|
308
|
+
raise ValueError("The first modern Olympics was held in 1896.")
|
|
309
|
+
|
|
310
|
+
return value
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class StreamingResult(Message):
|
|
314
|
+
answer: str
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class OlympicsAgent:
|
|
318
|
+
def __init__(self):
|
|
319
|
+
self._agent = Agent("ollama:llama3.2")
|
|
320
|
+
|
|
321
|
+
async def ask(self, req: OlympicsQuery) -> CityLocation:
|
|
322
|
+
result = await self._agent.run(req.prompt(), result_type=CityLocation)
|
|
323
|
+
return result.data
|
|
324
|
+
|
|
325
|
+
async def ask_stream(
|
|
326
|
+
self, req: OlympicsDurationQuery
|
|
327
|
+
) -> AsyncIterator[StreamingResult]:
|
|
328
|
+
async with self._agent.run_stream(req.prompt(), result_type=str) as result:
|
|
329
|
+
async for data in result.stream_text(delta=True):
|
|
330
|
+
yield StreamingResult(answer=data)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
if __name__ == "__main__":
|
|
334
|
+
s = AsyncIOServer()
|
|
335
|
+
loop = asyncio.get_event_loop()
|
|
336
|
+
loop.run_until_complete(s.run(OlympicsAgent()))
|
|
337
|
+
```
|
|
338
|
+
|
|
257
339
|
### 🔗 Multiple Services with Custom Interceptors
|
|
258
340
|
|
|
259
341
|
PydanticRPC supports defining and running multiple services in a single server:
|
|
@@ -349,6 +431,17 @@ python core.py a_module.py aClass
|
|
|
349
431
|
|
|
350
432
|
Using this generated proto file and tools as `protoc`, `buf` and `BSR`, you could generate code for any desired language other than Python.
|
|
351
433
|
|
|
434
|
+
## 📖 Data Type Mapping
|
|
435
|
+
|
|
436
|
+
| Python Type | Protobuf Type |
|
|
437
|
+
|--------------------|-----------------|
|
|
438
|
+
| str | string |
|
|
439
|
+
| bool | bool |
|
|
440
|
+
| int | int32, int64 |
|
|
441
|
+
| float | float, double |
|
|
442
|
+
| list[T], tuple[T] | repeated T |
|
|
443
|
+
| dict[K, V] | map<K, V> |
|
|
444
|
+
|
|
352
445
|
|
|
353
446
|
## TODO
|
|
354
447
|
- [ ] Streaming Support
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
pydantic_rpc/__init__.py,sha256=AWYjSmYQcMqsqGmGK4k-pQQhX6RBBgkTvNcQtCtsctU,113
|
|
2
|
+
pydantic_rpc/core.py,sha256=FbSEiSnm9oOh0vFr4jcv7_5ZWyMvRDnMwzlFN5M9g4U,47417
|
|
3
|
+
pydantic_rpc/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
pydantic_rpc-0.3.1.dist-info/METADATA,sha256=xfYTVEKisOvkxozu9nbdJ_nPR3rcaufARQfqZubWFDs,11412
|
|
5
|
+
pydantic_rpc-0.3.1.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
|
|
6
|
+
pydantic_rpc-0.3.1.dist-info/licenses/LICENSE,sha256=Y6jkAm2VqPqoGIGQ-mEQCecNfteQ2LwdpYhC5XiH_cA,1069
|
|
7
|
+
pydantic_rpc-0.3.1.dist-info/RECORD,,
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
pydantic_rpc/__init__.py,sha256=AWYjSmYQcMqsqGmGK4k-pQQhX6RBBgkTvNcQtCtsctU,113
|
|
2
|
-
pydantic_rpc/core.py,sha256=awYIYiWOtvRsgom6xeC5Ipaa1DoBjT2IJSPwQ9h2Sv4,44434
|
|
3
|
-
pydantic_rpc/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
-
pydantic_rpc-0.2.2.dist-info/METADATA,sha256=-Rx8bKZe9Grea0Qk6KDY3yIL6s1UlRbgjdYFO23XNOE,8933
|
|
5
|
-
pydantic_rpc-0.2.2.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
|
|
6
|
-
pydantic_rpc-0.2.2.dist-info/licenses/LICENSE,sha256=Y6jkAm2VqPqoGIGQ-mEQCecNfteQ2LwdpYhC5XiH_cA,1069
|
|
7
|
-
pydantic_rpc-0.2.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|