pydantic-rpc 0.2.1__py3-none-any.whl → 0.3.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/core.py CHANGED
@@ -17,17 +17,20 @@ 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
24
26
  from grpc_health.v1.health import HealthServicer
25
27
  from grpc_reflection.v1alpha import reflection
26
28
  from grpc_tools import protoc
27
- from pydantic import BaseModel
29
+ from pydantic import BaseModel, ValidationError
28
30
  from sonora.wsgi import grpcWSGI
29
31
  from sonora.asgi import grpcASGI
30
32
  from connecpy.asgi import ConnecpyASGIApp as ConnecpyASGI
33
+ from connecpy.errors import Errors
31
34
 
32
35
  # Protobuf Python modules for Timestamp, Duration (requires protobuf / grpcio)
33
36
  from google.protobuf import timestamp_pb2, duration_pb2
@@ -104,24 +107,26 @@ def generate_converter(annotation: Type) -> Callable:
104
107
 
105
108
  return dur_converter
106
109
 
107
- # For list types
108
- if get_origin(annotation) is list:
109
- 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])
110
115
 
111
- def list_converter(value):
112
- return [item_converter(v) for v in value]
116
+ def seq_converter(value):
117
+ return [item_converter(v) for v in value]
113
118
 
114
- return list_converter
119
+ return seq_converter
115
120
 
116
- # For dict types
117
- if get_origin(annotation) is dict:
118
- key_converter = generate_converter(get_args(annotation)[0])
119
- 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])
120
125
 
121
- def dict_converter(value):
122
- 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()}
123
128
 
124
- return dict_converter
129
+ return dict_converter
125
130
 
126
131
  # For Message classes
127
132
  if inspect.isclass(annotation) and issubclass(annotation, Message):
@@ -198,25 +203,35 @@ def connect_obj_with_stub(pb2_grpc_module, pb2_module, service_obj: object) -> t
198
203
  case 1:
199
204
 
200
205
  def stub_method1(self, request, context, method=method):
201
- # Convert request to Python object
202
- arg = converter(request)
203
- # Invoke the actual method
204
- resp_obj = method(arg)
205
- # Convert the returned Python Message to a protobuf message
206
- return convert_python_message_to_proto(
207
- resp_obj, response_type, pb2_module
208
- )
206
+ try:
207
+ # Convert request to Python object
208
+ arg = converter(request)
209
+ # Invoke the actual method
210
+ resp_obj = method(arg)
211
+ # Convert the returned Python Message to a protobuf message
212
+ return convert_python_message_to_proto(
213
+ resp_obj, response_type, pb2_module
214
+ )
215
+ except ValidationError as e:
216
+ return context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(e))
217
+ except Exception as e:
218
+ return context.abort(grpc.StatusCode.INTERNAL, str(e))
209
219
 
210
220
  return stub_method1
211
221
 
212
222
  case 2:
213
223
 
214
224
  def stub_method2(self, request, context, method=method):
215
- arg = converter(request)
216
- resp_obj = method(arg, context)
217
- return convert_python_message_to_proto(
218
- resp_obj, response_type, pb2_module
219
- )
225
+ try:
226
+ arg = converter(request)
227
+ resp_obj = method(arg, context)
228
+ return convert_python_message_to_proto(
229
+ resp_obj, response_type, pb2_module
230
+ )
231
+ except ValidationError as e:
232
+ return context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(e))
233
+ except Exception as e:
234
+ return context.abort(grpc.StatusCode.INTERNAL, str(e))
220
235
 
221
236
  return stub_method2
222
237
 
@@ -251,26 +266,80 @@ def connect_obj_with_stub_async(pb2_grpc_module, pb2_module, obj: object) -> typ
251
266
  response_type = sig.return_annotation
252
267
  size_of_parameters = len(sig.parameters)
253
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
+
254
313
  match size_of_parameters:
255
314
  case 1:
256
315
 
257
316
  async def stub_method1(self, request, context, method=method):
258
- arg = converter(request)
259
- resp_obj = await method(arg)
260
- return convert_python_message_to_proto(
261
- resp_obj, response_type, pb2_module
262
- )
317
+ try:
318
+ arg = converter(request)
319
+ resp_obj = await method(arg)
320
+ return convert_python_message_to_proto(
321
+ resp_obj, response_type, pb2_module
322
+ )
323
+ except ValidationError as e:
324
+ await context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(e))
325
+ except Exception as e:
326
+ await context.abort(grpc.StatusCode.INTERNAL, str(e))
263
327
 
264
328
  return stub_method1
265
329
 
266
330
  case 2:
267
331
 
268
332
  async def stub_method2(self, request, context, method=method):
269
- arg = converter(request)
270
- resp_obj = await method(arg, context)
271
- return convert_python_message_to_proto(
272
- resp_obj, response_type, pb2_module
273
- )
333
+ try:
334
+ arg = converter(request)
335
+ resp_obj = await method(arg, context)
336
+ return convert_python_message_to_proto(
337
+ resp_obj, response_type, pb2_module
338
+ )
339
+ except ValidationError as e:
340
+ await context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(e))
341
+ except Exception as e:
342
+ await context.abort(grpc.StatusCode.INTERNAL, str(e))
274
343
 
275
344
  return stub_method2
276
345
 
@@ -302,7 +371,6 @@ def connect_obj_with_stub_async_connecpy(
302
371
 
303
372
  def implement_stub_method(method):
304
373
  sig = inspect.signature(method)
305
- print(type(method))
306
374
  arg_type = get_request_arg_type(sig)
307
375
  converter = generate_message_converter(arg_type)
308
376
  response_type = sig.return_annotation
@@ -312,22 +380,32 @@ def connect_obj_with_stub_async_connecpy(
312
380
  case 1:
313
381
 
314
382
  async def stub_method1(self, request, context, method=method):
315
- arg = converter(request)
316
- resp_obj = await method(arg)
317
- return convert_python_message_to_proto(
318
- resp_obj, response_type, pb2_module
319
- )
383
+ try:
384
+ arg = converter(request)
385
+ resp_obj = await method(arg)
386
+ return convert_python_message_to_proto(
387
+ resp_obj, response_type, pb2_module
388
+ )
389
+ except ValidationError as e:
390
+ await context.abort(Errors.InvalidArgument, str(e))
391
+ except Exception as e:
392
+ await context.abort(Errors.Internal, str(e))
320
393
 
321
394
  return stub_method1
322
395
 
323
396
  case 2:
324
397
 
325
398
  async def stub_method2(self, request, context, method=method):
326
- arg = converter(request)
327
- resp_obj = await method(arg, context)
328
- return convert_python_message_to_proto(
329
- resp_obj, response_type, pb2_module
330
- )
399
+ try:
400
+ arg = converter(request)
401
+ resp_obj = await method(arg, context)
402
+ return convert_python_message_to_proto(
403
+ resp_obj, response_type, pb2_module
404
+ )
405
+ except ValidationError as e:
406
+ await context.abort(Errors.InvalidArgument, str(e))
407
+ except Exception as e:
408
+ await context.abort(Errors.Internal, str(e))
331
409
 
332
410
  return stub_method2
333
411
 
@@ -388,13 +466,14 @@ def python_value_to_proto(field_type: Type, value, pb2_module):
388
466
  if inspect.isclass(field_type) and issubclass(field_type, enum.Enum):
389
467
  return value.value # proto3 enum is an int
390
468
 
391
- # If list
392
- if get_origin(field_type) is list:
469
+ origin = get_origin(field_type)
470
+ # If seq
471
+ if origin in (list, tuple):
393
472
  inner_type = get_args(field_type)[0] # type: ignore
394
473
  return [python_value_to_proto(inner_type, v, pb2_module) for v in value]
395
474
 
396
475
  # If dict
397
- if get_origin(field_type) is dict:
476
+ if origin is dict:
398
477
  key_type, val_type = get_args(field_type) # type: ignore
399
478
  return {
400
479
  python_value_to_proto(key_type, k, pb2_module): python_value_to_proto(
@@ -500,7 +579,7 @@ def protobuf_type_mapping(python_type: Type) -> str | type | None:
500
579
  return None # Handled separately as oneof
501
580
 
502
581
  if hasattr(python_type, "__origin__"):
503
- if python_type.__origin__ is list:
582
+ if python_type.__origin__ in (list, tuple):
504
583
  inner_type = python_type.__args__[0]
505
584
  inner_proto_type = protobuf_type_mapping(inner_type)
506
585
  if inner_proto_type:
@@ -629,6 +708,14 @@ def generate_message_definition(
629
708
  return msg_def, refs
630
709
 
631
710
 
711
+ def is_stream_type(annotation: Type) -> bool:
712
+ return get_origin(annotation) is AsyncIterator
713
+
714
+
715
+ def is_generic_alias(annotation: Type) -> bool:
716
+ return get_origin(annotation) is not None
717
+
718
+
632
719
  def generate_proto(obj: object, package_name: str = "") -> str:
633
720
  """
634
721
  Generate a .proto definition from a service class.
@@ -672,6 +759,11 @@ def generate_proto(obj: object, package_name: str = "") -> str:
672
759
  continue
673
760
  done_messages.add(mt)
674
761
 
762
+ if is_stream_type(mt):
763
+ item_type = get_args(mt)[0]
764
+ message_types.append(item_type)
765
+ continue
766
+
675
767
  for _, field_info in mt.model_fields.items():
676
768
  t = field_info.annotation
677
769
  if is_union_type(t):
@@ -702,9 +794,16 @@ def generate_proto(obj: object, package_name: str = "") -> str:
702
794
  if method_docstr:
703
795
  for comment_line in comment_out(method_docstr):
704
796
  rpc_definitions.append(comment_line)
705
- rpc_definitions.append(
706
- f"rpc {method_name} ({request_type.__name__}) returns ({response_type.__name__});"
707
- )
797
+
798
+ if is_stream_type(response_type):
799
+ item_type = get_args(response_type)[0]
800
+ rpc_definitions.append(
801
+ f"rpc {method_name} ({request_type.__name__}) returns (stream {item_type.__name__});"
802
+ )
803
+ else:
804
+ rpc_definitions.append(
805
+ f"rpc {method_name} ({request_type.__name__}) returns ({response_type.__name__});"
806
+ )
708
807
 
709
808
  if not package_name:
710
809
  if service_name.endswith("Service"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pydantic-rpc
3
- Version: 0.2.1
3
+ Version: 0.3.0
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=AA2LISRvwNqqVByFzG_7tToZTnchfLIUhD3jlYcm0S4,47299
3
+ pydantic_rpc/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ pydantic_rpc-0.3.0.dist-info/METADATA,sha256=kobbXfMf1l64C4yMLiyXBDwxdgLHYFeVp-o0eqYvxno,11412
5
+ pydantic_rpc-0.3.0.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
6
+ pydantic_rpc-0.3.0.dist-info/licenses/LICENSE,sha256=Y6jkAm2VqPqoGIGQ-mEQCecNfteQ2LwdpYhC5XiH_cA,1069
7
+ pydantic_rpc-0.3.0.dist-info/RECORD,,
@@ -1,7 +0,0 @@
1
- pydantic_rpc/__init__.py,sha256=AWYjSmYQcMqsqGmGK4k-pQQhX6RBBgkTvNcQtCtsctU,113
2
- pydantic_rpc/core.py,sha256=CUoiQpARAAejnxNv_6foerWvc_vUvXpZOU1LxoR-EiQ,42596
3
- pydantic_rpc/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- pydantic_rpc-0.2.1.dist-info/METADATA,sha256=Td5HvDvxy521IB9rClJcsmtxPHAdPRfxcJHgQ3WSEmU,8933
5
- pydantic_rpc-0.2.1.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
6
- pydantic_rpc-0.2.1.dist-info/licenses/LICENSE,sha256=Y6jkAm2VqPqoGIGQ-mEQCecNfteQ2LwdpYhC5XiH_cA,1069
7
- pydantic_rpc-0.2.1.dist-info/RECORD,,