Building Pyrseia I: The Idea

Over at Highrise, we're looking to replace our internal Python RPC library. The in-house solution we're using now isn't particularly bad, but it doesn't integrate well with Mypy, which is a Python typechecker that might be useful to us. It's also somewhat boilerplate-y, and very coupled to our particular set of design choices. This series of articles follows my attempts to do better, in the form of a modern, open source, fully customizable RPC library. I've decided to call the library Pyrseia, which is an ancient greek method of long distance communication. I've created the initial repository over at GitHub.

This is the first article in the Pyrseia series. The others are:

Requirements

Ideally, for Pyrseia to be useful for the use-case I'm creating it for and for it to meaningfully contribute to the broader Python ecosystem, it should have the following properties.

It should be type-safe with regards to Mypy, within reason. It should be type safe at the point of use - for users of the library, not necessarily the implementation innards.

It should be async-first, since I believe network services should primarily be written in the async style. Sync support should be possible down the road.

It should be fairly minimal and understandable. It should be a component and not a framework, something that accomodates your needs and architecture instead of making you accomodate it. That said, it should still provide a few opinionated solutions out of the box so users can get started quickly.

I'm not interested in supporting RPC schemas like gRPC, since I believe those types of systems should be higher-level than what we're building here. For example, given an gRPC schema, it should be possible to generate a matching Pyrseia configuration from it, but it shouldn't be supported directly in Pyrseia.

The included batteries should be based on what I consider best-of-breed, modern Python building blocks. Libraries such as attrs, structlog and aiohttp come to mind.

That said, the ultimate point of building this definitely isn't to create something useful to everyone (i.e. take over the world), just to push the ecosystem slightly into a direction not fully explored previously.

Imagining an API

Since Pyrseia isn't even built yet, it's hard to say what the end result might look like. We still need to pick a starting point, though.

The core duty of an RPC library is, essentially, transferring requests from the point of generation (which we'll call the client, or the stub) to a place where the request is actually implemented and fulfilled (which we'll call the server), and then taking the result back over the wire to the client.

We therefore have 3 major components:

  • The API definition.
  • The client.
  • The server.

We choose to expose the server using a collection of functions, to make it convenient to use from both the client and the server sides.

To start, let's create an imaginary RPC calculator. Our calculator can add two integers together. We combine the API definition with the client for now, and the server will depend on the client.

calculator.py:

from pyrseia import client, rpc

@client
class CalculatorRpc:
    @rpc
    async def add(self, a: int, b: int) -> int:
        ...

server.py:

from pyrseia import server

from .calculator import CalculatorRpc

serv = server(CalculatorRpc)

@serv.implement(CalculatorRpc.add)
async def add(a: int, b: int) -> int:
    return a + b

The First Draft

The first draft is available at 0873eb991c7410723cd689b3d4c5b5457c8b9eee.

This is a very rough proof of concept. There is basically zero customization available, and calling the client methods will create a new aiohttp session every call, which is an anti-pattern.

I've managed to get Mypy working, though. If you try implementing a non-existent client method, Mypy will yell at you:

@serv.implement(CalculatorRpc.subtract) # "Type[CalculatorRpc]" has no attribute "subtract"
async def sub(a: int, b: int) -> int:
    return a - b

This is not a huge feat though, since that'd break in runtime anyway, and probably be caught by linters. What is more interesting is that your handlers need to match the API signature. If you mess up add:

# Argument 1 has incompatible type "Callable[[str, str], Coroutine[Any, Any, str]]"; expected "Callable[[int, int], Coroutine[Any, Any, int]]"

@serv.implement(CalculatorRpc.add)
async def add(a: str, b: str) -> str:
    return a + b

A limitation of the Python type system (or my ability to use it) is that only calls with up to 5 arguments can currently be typechecked. Each arity requires a little boilerplate in the Pyrseia codebase.

If you forget annotating an RPC function with @rpc, you won't be able to implement it:

@client
class CalculatorRpc:
    # No decorator.
    async def add(self, a: int, b: int) -> int:
        ...

server.py:

from .calculator import CalculatorRpc

serv = server(CalculatorRpc)

# No overload variant of "implement" of "Server" matches argument type "Callable[[CalculatorRpc, int, int], Coroutine[Any, Any, int]]"

@serv.implement(CalculatorRpc.add)
async def add(a: int, b: int) -> int:
    return a + b

If you try implementing a matching function but from the wrong client, Mypy will complain. Small stuff like that.

The server part has the simplest MVP API:

class Server(Generic[CT]):
    ...
    async def process(self, payload: bytes) -> bytes:
        <implementation omitted>

In other words, it takes in bytes (a serialized representation of a call), and spits out bytes (a serialized representation of a result).

Next Steps

The client stub should have async initialization, so we should probably decouple the API definition from the client.

The client and server parts require a component-based design, so their behavior can be actually customized. For example, users should be able to provide their own network adapter (so they can set timeouts and other parameters, and use other protocols). The server part requires a middleware-esque system for things like logging, metrics, etc.

So stay tuned!