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 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
- # For list types
109
- if get_origin(annotation) is list:
110
- item_converter = generate_converter(get_args(annotation)[0])
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
- def list_converter(value):
113
- return [item_converter(v) for v in value]
116
+ def seq_converter(value):
117
+ return [item_converter(v) for v in value]
114
118
 
115
- return list_converter
119
+ return seq_converter
116
120
 
117
- # For dict types
118
- if get_origin(annotation) is dict:
119
- key_converter = generate_converter(get_args(annotation)[0])
120
- value_converter = generate_converter(get_args(annotation)[1])
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
- def dict_converter(value):
123
- return {key_converter(k): value_converter(v) for k, v in value.items()}
126
+ def dict_converter(value):
127
+ return {key_converter(k): value_converter(v) for k, v in value.items()}
124
128
 
125
- return dict_converter
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
- # If list
422
- if get_origin(field_type) is list:
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 get_origin(field_type) is dict:
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__ is list:
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
- rpc_definitions.append(
736
- f"rpc {method_name} ({request_type.__name__}) returns ({response_type.__name__});"
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.2.2
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/)/[ConnectRPC](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.
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 ConnectRPC service that exposes the same agent as an ASGI application:
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,,