DEV Community

TOKUJI
TOKUJI

Posted on

gRPC Without grpcio: How BlackBull Serves All Four RPC Shapes in Pure Python

If you have ever wanted to serve a gRPC endpoint from Python without compiling grpcio's C++ core — or wondered what gRPC actually looks like on the wire — this post is for you. It introduces BlackBull, a pure-Python ASGI framework I've been building, and walks through how its gRPC support works, what it's good for, and — just as importantly — what it's not for. By the end you'll have a server that a stock grpcurl can call: pip install, one file, no certificates, no protoc, no config.

(This continues my earlier series: BlackBull goes multi-protocol and How a from-scratch HTTP/2 server actually works. You don't need either to follow along.)

What is BlackBull?

BlackBull is a pure-Python ASGI 3.0 web framework that implements HTTP/1.1, HTTP/2, and WebSocket at the protocol level — its own frame parsing, its own HPACK, its own flow control. No C extensions, no wrapping a foreign HTTP library. A few things distinguish it:

  • Zero-ceremony deploy. app.run() is the whole story. There is no separate ASGI runner to pick, no gunicorn -k some.worker.Class, no YAML. For a static site, blackbull serve ./public gives you ETag handling and HTTP/2 in one command.
  • Every byte is debuggable. Because there are no C extensions, you can set a breakpoint anywhere — inside HPACK header decoding, inside the flow-control window accounting — and step through it with pdb. If you've ever tried to figure out why a server sent RST_STREAM, you know why this matters.
  • Multi-protocol, one process. HTTP/1.1, HTTP/2, WebSocket, gRPC, and MQTT 5.0 are all served from a single python app.py. Not five servers behind a reverse proxy — one process, and for the HTTP/2-based protocols, one port.
  • Actor-model internals. Each connection is owned by a ConnectionActor that spawns per-connection protocol actors, each with its own inbox loop. Isolation between connections comes from the actor structure, not from shared locks.
  • RFC-grade conformance. The HTTP/2 stack passes h2spec, the WebSocket stack passes the Autobahn test suite, and HTTP behaviour is checked by a differential oracle against nginx.
  • Typed throughout (PEP 561), and — unusually — programmable fault injection: the same protocol code can be told to misbehave on command, so you can use BlackBull in CI to test how your HTTP clients handle a server that sends malformed frames or stalls mid-response.

One honest disclosure up front: BlackBull is a personal learning project. Correctness over the wire matters more than API stability, and the versioning is ZeroVer (0.MINOR.PATCH — v0.53.3 as of this writing). If you need a stability guarantee, that's a real consideration. If you want to understand or test protocols, it's arguably a feature.

gRPC is just HTTP/2 (mostly)

Here is the observation that makes BlackBull's gRPC support possible: gRPC is not a separate transport. It is HTTP/2 with a few extra rules:

  1. Requests are POST /package.Service/Method with content-type: application/grpc.
  2. Each message on the wire is length-prefixed: 1 byte of compression flag, 4 bytes of big-endian length, then the payload.
  3. The status code (grpc-status) is delivered in HTTP/2 trailing headers after the last message.

That's most of it. If you already have a complete HTTP/2 implementation — frames, HPACK, flow control, trailers — then gRPC is a thin layer on top. And BlackBull does, so its entire gRPC layer is about six files in blackbull/grpc/: the registry, the codec (that 5-byte prefix), compression, status codes, and the bridge to the framework. No new protocol actor, no second listening socket. A gRPC call is just another HTTP/2 stream to the connection layer.

The simplest possible gRPC server

from blackbull import BlackBull
from blackbull.grpc import GrpcServiceRegistry, GrpcStatus

app = BlackBull()
grpc = GrpcServiceRegistry()


@grpc.method('/echo.Echo/Echo')
async def echo(request: bytes, context) -> bytes:
    if not request:
        context.abort(GrpcStatus.INVALID_ARGUMENT, 'empty request message')
    return request


# Server-streaming: an async generator that yields response messages.
@grpc.method('/echo.Echo/Split')
async def split(request: bytes, context):
    for token in request.split():
        yield token


app.enable_grpc(grpc)


@app.route(path='/')
async def index():
    return 'REST here; gRPC service echo.Echo on the same port.'


app.run(port=50051)   # cleartext HTTP/2 — add certfile=/keyfile= for TLS
Enter fullscreen mode Exit fullscreen mode

Install with pip install 'blackbull[grpc]' and run the file. That's it — note what's missing: no certificate step, no protoc, no config. gRPC normally rides on TLS + ALPN, but BlackBull also accepts cleartext HTTP/2 (h2c, prior knowledge), so any stock gRPC client in plaintext mode — grpcurl -plaintext, ghz --insecure, a grpcio insecure_channel — can call /echo.Echo/Echo on localhost:50051 immediately. (For a production-like setup, pass certfile=/keyfile= and the same code serves TLS + ALPN.) The / route in the same file is not decoration: REST, WebSocket, and gRPC genuinely share one port. BlackBull dispatches on the content-type, so there is no separate gRPC server process to run or proxy to.

All four RPC shapes, detected from the signature

gRPC defines four call shapes, and BlackBull supports all of them. You never declare which one a handler is — the registry infers it:

  • Unary: async def handler(request: bytes, context) -> bytes — one message in, one out.
  • Server-streaming: write the handler as an async generator and yield each response message.
  • Client-streaming: take a request_iter parameter instead of request — an async iterator of incoming messages — and return one response.
  • Bidirectional: request_iter in, async generator out.

Error handling goes through the context: context.abort(GrpcStatus.NOT_FOUND, 'no such thing') ends the call with a proper status in the trailers, and context.metadata(name) reads call metadata (request headers). An unexpected exception in a handler is isolated and reported as INTERNAL — a handler bug never takes down the connection or its sibling streams.

Bring your own protobuf

Notice what the example above doesn't import: protobuf. BlackBull's gRPC handlers exchange raw bytes. The framework handles the HTTP/2 and gRPC framing; message serialisation is entirely yours. Use grpc_tools.protoc-generated classes, the protobuf package directly, or hand-roll the encoding — the handler contract is the same:

@grpc.method('/helloworld.Greeter/SayHello')
async def say_hello(request: bytes, context) -> bytes:
    req = helloworld_pb2.HelloRequest.FromString(request)
    reply = helloworld_pb2.HelloReply(message=f'Hello, {req.name}!')
    return reply.SerializeToString()
Enter fullscreen mode Exit fullscreen mode

This is a deliberate layering decision, not a missing feature. gRPC-the-protocol and protobuf-the-format are separable, and keeping them separate means the core has no codegen step and no protobuf dependency.

When you do want the batteries, pip install 'blackbull[protobuf]' adds a separate integration layer: servicer classes (register a whole service from generated stubs), server reflection (grpc.reflection.v1alpha — so grpcurl and Postman can discover and call your service with no local .proto file), health checking (grpc.health.v1, the protocol Kubernetes probes and load balancers expect), and rich error details delivered in grpc-status-details-bin trailers.

The repo's examples/grpc_greeter.py sits nicely in the middle: it implements the canonical helloworld.Greeter service with real protobuf on the wire, but hand-rolls the (trivial) encoding of its two single-field messages in ~20 lines — so it needs no protoc step, yet a stock grpcurl or grpcio client using the standard helloworld.proto talks to it unmodified.

The parts you'd only notice if they were missing

A few implementation details are worth calling out, because they're the difference between "speaks the protocol" and "usable":

  • Write-coalescing for server-streaming. A naive implementation sends one DATA frame per yielded message, paying a full trip through the event loop's sender each time. BlackBull batches consecutive small messages into a single DATA frame — valid on the wire, since gRPC clients frame on the 5-byte length prefix, not on DATA frame boundaries. For streams of thousands of small messages this removes almost all of the per-message overhead.
  • Message size cap. Incoming messages are capped at 4 MiB by default — the same default as grpcio — so a misbehaving client can't buffer you into the ground.
  • Deadline enforcement. The grpc-timeout request header is honoured; an expired call is terminated with DEADLINE_EXCEEDED, including mid-stream.
  • Compression. gzip message compression is supported, with a minimum-size threshold so that compressing tiny messages doesn't make them larger (gzip has fixed overhead that swamps small payloads).

And it's not just self-certified: the CI pipeline includes a grpc-interop job that runs real grpcio clients against BlackBull over h2c sockets — success paths, every error status, streaming, and concurrent multiplexed calls. Interop with the reference implementation is tested on every push, not assumed.

Who is this for?

Honest positioning time. BlackBull + gRPC is a good fit if you are:

  • Avoiding the native stack. grpcio ships a large C++ core. If you're on an unusual platform or ARM board where wheels are scarce, or you just don't want a compiled dependency in the way, a pure-Python server that stock gRPC clients can talk to is genuinely useful.
  • Building IoT or edge gateways. One Python process serving HTTP for a dashboard, gRPC for device RPCs, and MQTT 5.0 for telemetry — on constrained hardware, collapsing three servers into one process is the whole value proposition.
  • Building protocol-level test tooling. Because the fault-injection hooks reach the protocol layer, you can stand up a gRPC server in CI that misbehaves on command — truncated messages, stalled streams, hostile frames — and verify your client's error handling actually works. That's very hard to do with a production-grade server that's engineered never to misbehave.
  • Learning how gRPC actually works. You can put a breakpoint in the HPACK decoder and step through an entire gRPC call — header decompression, length-prefixed message framing, trailer delivery — in pdb. No other stack I know of lets you do that in pure Python end to end.

And it is not for you if you need the full gRPC ecosystem today: client-side load balancing, xDS, gRPC-Web proxying. BlackBull is a server-side framework that speaks the gRPC wire protocol well; it is not a gRPC platform, and it isn't trying to be one.

Try it (60 seconds, no certificates)

The library comes from PyPI; the runnable examples live in the repo:

pip install 'blackbull[grpc]'       # core gRPC (raw bytes)
git clone https://github.com/TOKUJI/BlackBull && cd BlackBull

python examples/grpc_greeter.py --port 50051   # the canonical helloworld.Greeter
Enter fullscreen mode Exit fullscreen mode

No cert step — this serves gRPC over cleartext HTTP/2 on localhost:50051. To call it with grpcurl, save the canonical proto as helloworld.proto so it knows how to encode the request (the core install deliberately has no reflection — that ships in the [protobuf] extra):

syntax = "proto3";
package helloworld;
service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply);
  rpc SayManyHellos (HelloRequest) returns (stream HelloReply);
}
message HelloRequest { string name = 1; }
message HelloReply   { string message = 1; }
Enter fullscreen mode Exit fullscreen mode
grpcurl -plaintext -proto helloworld.proto \
  -d '{"name": "BlackBull"}' localhost:50051 helloworld.Greeter/SayHello
Enter fullscreen mode Exit fullscreen mode
{ "message": "Hello, BlackBull!" }
Enter fullscreen mode Exit fullscreen mode

That is a stock gRPC client talking to a pure-Python server, end to end. Call SayManyHellos instead to watch server-streaming deliver three messages; curl http://localhost:50051/ hits the REST route on the same port. The raw-bytes echo server from earlier in this post is examples/grpc_server.py, and pip install 'blackbull[protobuf]' adds the servicer/reflection/health layer.

For a TLS setup (what you'd deploy), generate a dev cert and add --cert/--key:

openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \
  -days 365 -nodes -subj '/CN=localhost'
python examples/grpc_greeter.py --port 8443 --cert cert.pem --key key.pem
grpcurl -insecure -proto helloworld.proto \
  -d '{"name": "BlackBull"}' localhost:8443 helloworld.Greeter/SayHello
Enter fullscreen mode Exit fullscreen mode

Source, examples, and docs are at github.com/TOKUJI/BlackBull — and if the idea of one debuggable Python process speaking five protocols appeals to you, the earlier series digs into how the HTTP/2 and multi-protocol layers underneath all this actually work.

BlackBull v0.53.3, 2026-07-14.

Top comments (0)