gRPC in Python made EZ.
Python community puts a lot of effort to make developing REST APIs as good as it currently is. If you get used to using FastAPI and Pydantic for your APIs, and then suddenly decide that you want to use gRPC for whatever reason, that is going to be a disappointment.
The goal of grpez
is to bring gRPC development in Python on similar modern level
as what you can get with REST.
# Note: you must install hypercorn from my fork,
# it includes a critical fix for grpc to work at all
# see this PR for more details: https://github.com/pgjones/hypercorn/pull/255
# pypi for grpez is coming soon
pip install 'git+https://github.com/adambudziak/hypercorn' \
'git+https://github.com/adambudziak/grpez'
# server.py
import asyncio
import hypercorn.asyncio
import pathlib
import grpez
svc = grpez.Service("HelloWorld")
class GreetingRequest(grpez.RequestModel):
name: str
class GreetingResponse(grpez.ResponseModel):
greeting: str
@svc.rpc()
async def greeting(r: GreetingRequest) -> GreetingResponse:
return GreetingResponse(greeting=f'hello, {r.name}')
app = grpez.Grpez(
services=[svc],
reflection=True,
gen_path=pathlib.Path("grpez_gen"), # where the proto will land
)
asyncio.run(hypercorn.asyncio.serve(app, hypercorn.Config()))
If you run the script above, grpez will do a few things:
It's contents will look as follows, it's stored in the grpez_gen
directory.
syntax = "proto3";
service HelloWorld {
rpc Greeting(GreetingRequest) returns (GreetingResponse) {}
}
message GreetingRequest {
string name = 1;
}
message GreetingResponse {
string greeting = 1;
}
They're stored right besides the proto file.
Most of the heavy lifting is done eagerly, so handling requests is as lightweight as possible.
The library is in a very early stage, so most of those features are in-progress at the moment.
The library has a lot of opinions, but one of its design rules is to not force any one of them on you. If you dislike any of the features, there are ways to just use old-style grpc/proto approaches.
You can mix grpez with any other ASGI application and run them together. You can even mix it with FastAPI.
One caveat: gRPC needs HTTP/2 and currently hypercorn is the only ASGI implementation that supports that. Granian might also be used once it supports HTTP/2 trailers.
import pathlib
import grpez
import prometheus_client
from hypercorn.middleware import DispatcherMiddleware
from hypercorn.asyncio import serve
from hypercorn import Config
svc = grpez.Service("ServiceWithMetrics")
class Thing(grpez.RequestModel, grpez.ResponseModel):
value: int
@svc.rpc()
async def do_thing(r: Thing) -> Thing:
return Thing(value=r.value * 3)
async def main():
app = grpez.Grpez(
services=[svc],
reflection=True,
gen_path=pathlib.Path(__file__).parent / "grpez_gen"
)
dispatcher_app = DispatcherMiddleware({
"/metrics": prometheus_client.make_asgi_app(),
"": app
})
await serve(dispatcher_app, Config())
Classes generated from protobuf are terrible in all kinds of ways. They have
ambiguous __repr__
, it's hard to see their internals, they don't play nicely with
types, it's impossible to add an extra parsing stage. With grpez, you just define a Pydantic
model that will be your interface when reading request contents.
The generated proto class is still there, but you never need to see it (if you do for whatever reason, there are ways).
Instead of writing a .proto file first, then generating code from it, and then writing Pydantic models to wrap it all into something usable, you can first define endpoints using Pydantic and grpez, and then generate .proto from it.
The reason for that is that .proto is way more limited than what Pydantic gives you. For example,
there is no way to explain that an email
field in a message must pass some kind of validation, besides writing
a comment.
With grpez's approach, you can define the models, with as rich validation as you want, and generate a proto file from it, that will include comments extracted from validators.
import asyncio
import pathlib
import grpez
from hypercorn.asyncio import serve
from hypercorn import Config
hello_svc = grpez.Service("HelloService")
class HelloRequest(grpez.RequestModel):
name: str
class HelloResponse(grpez.ResponseModel):
greeting: str
@hello_svc.rpc()
async def get_greeting(hello: HelloRequest) -> HelloResponse:
return HelloResponse(greeting=f"hello {hello.name}")
async def main():
app = grpez.Grpez(
services=[hello_svc],
reflection=True,
gen_path=pathlib.Path(__file__).parent / "grpez_gen"
)
await serve(app, Config())
asyncio.run(main())
When you run the server, it'll first generate proto files from your code, then generate
Python code using protoc
, and finally hook it all together. It's possible to turn off generating
on startup, which should be the default for production environments.
If you want to stick to the "proto-first, code later" approach, that's still possible.
It would be possible to support both sync and async endpoints, but it's a lot of work. Currently only async endpoints are supported.
It's somewhat limited at the moment, but you can define any callable that will be passed to your endpoints at runtime
import grpez
from typing import Annotated
svc = grpez.Service("Dependency")
class App:
def __init__(self):
self.counter = 0
async def get_app():
return App()
class Message(grpez.RequestModel, grpez.ResponseModel):
value: int
@svc.rpc()
async def foo(msg: Message, app: Annotated[App, grpez.D(get_app, scope="server")]) -> Message:
app.counter += msg.value
return Message(value=app.counter)
@svc.rpc()
async def bar(msg: Message, app: Annotated[App, grpez.D(get_app, scope="server")]) -> Message:
app.counter += msg.value * 2
return Message(value=app.counter)
@svc.rpc()
async def baz(msg: Message, app: Annotated[App, grpez.D(get_app)]) -> Message:
app.counter += msg.value * 3
return Message(value=app.counter)
In the example above, foo
and bar
will share the same app
instance, and it will be created
at startup and once only. On the other hand, baz
will get a fresh instance of app
on every call.
It's currently impossible to define sub-dependencies, file an issue if you want this.
You want your endpoint to return a unary response? Just return a pydantic model. You want it to stream results? Just make it an async generator. You want it to receive a stream? Make the stream an async generator.
import grpez
from collections.abc import AsyncGenerator
streaming_svc = grpez.Service("StreamingExample")
class GetRangeRequest(grpez.RequestModel):
min_value: int
max_value: int
class GetRangeResponse(grpez.ResponseModel):
value: int
class SumNumbersRequest(grpez.RequestModel):
number: int
class SumNumbersResponse(grpez.ResponseModel):
total: int
class DoubleNumber(grpez.RequestModel, grpez.ResponseModel):
number: int
@streaming_svc.rpc()
async def get_range(r: GetRangeRequest) -> AsyncGenerator[GetRangeResponse, None]:
# example of streaming response (unary_stream)
for i in range(r.min_value, r.max_value + 1):
yield GetRangeResponse(value=i)
@streaming_svc.rpc()
async def sum_numbers(rs: AsyncGenerator[SumNumbersRequest, None]) -> SumNumbersResponse:
# example of streaming request (stream_unary)
total = 0
async for r in rs:
total += r.number
return SumNumbersResponse(total=total)
@streaming_svc.rpc()
async def double_numbers(rs: AsyncGenerator[DoubleNumber, None]) -> AsyncGenerator[DoubleNumber, None]:
# example of bidirectional streaming (stream_stream)
async for r in rs:
yield DoubleNumber(number=r.number * 2)
Interceptors don't have access to the request or response objects. grpez middlewares are a thin wrapper around interceptors that gives you the full context.
TODO examples
Fully supporting interceptors is difficult as they can access the RPC handler, and grpez uses a different implementation than plain gRPC. In general, you should prefer using grpez middlewares instead of interceptors, but for the migration process, it's probably useful to be able to just use what you already have.
Reading handler_call_details
is fully supported (see example), and
you don't need any changes to your interceptor to do it. If your interceptor changes the rpc_handler
used for the call, or accesses some properties of it, then it's currently not supported by the lib, so file an issue.
If you have a servicer class that you want to include in grpez (for the migration period only, right?) then you can do it exactly the same way, like you'd do it with a normal grpc server:
app = grpez.Grpez(...)
add_YourServicer_to_server(servicer, app)
That way you gain ASGI support, and you can start the slow migration towards native-grpez.