pyrelay
Relay-spec GraphQL helpers for Python. Framework-agnostic, with zero dependencies.
Relay's server conventions (opaque global IDs, cursor connections, the node
refetch field, clientMutationId on mutations) are small, but they're easy to
get subtly wrong. Each Python GraphQL library also tends to ship its own
version. pyrelay implements them once as plain Python functions and
dataclasses. You can use it with Strawberry, Graphene, Ariadne, graphql-core,
or a hand-rolled resolver layer. The ID and cursor formats match the reference
graphql-relay-js implementation, so existing Relay clients and data keep working.
Features
- Global object IDs:
to_global_id/from_global_idencode and decodebase64("Type:id"), with strict validation. - Cursor connections:
connection_from_sequenceimplements the Relay Cursor Connections spec (first/after/last/before) and produces correctPageInfo. - Database-friendly slicing:
connection_from_sequence_slicepaginates when only anOFFSET/LIMITwindow is loaded. - Node registry:
NodeRegistrymaps type names to loaders for thenode(id:)field. It forwards context and works with async loaders. - Mutation helper: the
@relay_mutationdecorator stripsclientMutationIdfrom the input and echoes it back in the payload. It works for sync and async functions. - GraphQL-shaped output:
.to_dict()emits camelCaseedges/pageInfo/totalCount. - Fully type-hinted (
py.typed), standard library only, Python 3.9+.
Install
pip install pyrelay # once published
# or, from a checkout:
pip install -e .
Quickstart
from pyrelay import (
ConnectionArguments,
NodeRegistry,
connection_from_sequence,
from_global_id,
relay_mutation,
to_global_id,
)
# 1. Global object IDs
gid = to_global_id("User", 42)
assert gid == "VXNlcjo0Mg=="
assert from_global_id(gid) == ("User", "42")
# 2. Cursor pagination over any sequence
users = [{"id": i, "name": n} for i, n in enumerate(["Ada", "Grace", "Linus", "Guido"])]
page1 = connection_from_sequence(users, ConnectionArguments(first=2))
assert [u["name"] for u in page1.nodes] == ["Ada", "Grace"]
assert page1.page_info.has_next_page
page2 = connection_from_sequence(
users, ConnectionArguments(first=2, after=page1.page_info.end_cursor)
)
assert [u["name"] for u in page2.nodes] == ["Linus", "Guido"]
assert not page2.page_info.has_next_page
# GraphQL-shaped result, e.g. to return from a resolver
data = page2.to_dict(lambda u: {"id": to_global_id("User", u["id"]), "name": u["name"]})
assert set(data) == {"edges", "pageInfo"}
# 3. The `node(id:)` field
registry = NodeRegistry()
@registry.register("User")
def load_user(local_id: str, context=None):
return users[int(local_id)]
assert registry.resolve(registry.global_id("User", 1))["name"] == "Grace"
assert registry.resolve(to_global_id("Unknown", 1)) is None
# 4. Input-object mutations with clientMutationId
@relay_mutation
def rename_user(input, context=None):
user = registry.resolve(input["id"], context)
user["name"] = input["name"]
return {"user": user}
payload = rename_user(
{"id": to_global_id("User", 0), "name": "Countess Ada", "clientMutationId": "m-1"}
)
assert payload == {"user": {"id": 0, "name": "Countess Ada"}, "clientMutationId": "m-1"}
Paginating a database query
Load only the window you need, then tell pyrelay where that window sits:
from pyrelay import ConnectionArguments, connection_from_sequence_slice, cursor_to_offset
rows = list(range(100)) # pretend this is a table
args = ConnectionArguments(first=10, after="YXJyYXljb25uZWN0aW9uOjQ=") # offset 4
offset = cursor_to_offset(args.after) + 1 if args.after else 0
window = rows[offset : offset + args.first] # SELECT ... OFFSET :offset LIMIT :first
conn = connection_from_sequence_slice(
window, args, slice_start=offset, sequence_length=len(rows), include_total_count=True
)
assert conn.nodes == list(range(5, 15))
assert conn.page_info.has_next_page and conn.total_count == 100
API overview
Everything below can be imported from the top-level pyrelay package.
Global IDs (pyrelay.ids)
| Name | Description |
|---|---|
to_global_id(type_name, local_id) -> str |
Returns base64("Type:id"). Raises ValueError if type_name is empty or contains :. |
from_global_id(global_id) -> ResolvedGlobalId |
Decodes an ID. Raises InvalidGlobalIdError on malformed input. |
ResolvedGlobalId |
A NamedTuple with fields type: str and id: str. |
Cursors (pyrelay.cursors)
| Name | Description |
|---|---|
offset_to_cursor(offset) -> str |
Returns the opaque cursor base64("arrayconnection:N"). |
cursor_to_offset(cursor) -> int |
Inverse of offset_to_cursor. Raises InvalidCursorError. |
cursor_for_object_in_sequence(data, obj) -> str | None |
Returns the cursor of the first item equal to obj. |
Connections (pyrelay.connection)
| Name | Description |
|---|---|
ConnectionArguments(first=None, after=None, last=None, before=None) |
Frozen dataclass. Raises ConnectionArgumentError if first/last is negative or not an int. ConnectionArguments.from_mapping(kwargs) ignores unknown keys. |
connection_from_sequence(data, args=None, *, include_total_count=False) -> Connection |
Paginates an in-memory sequence. |
connection_from_sequence_slice(slice, args=None, *, slice_start, sequence_length, include_total_count=False) -> Connection |
Paginates a pre-fetched window of a larger sequence. |
Connection |
Frozen dataclass with edges: list[Edge], page_info: PageInfo and total_count: int | None. Also has a .nodes property and .to_dict(serialize_node=None). |
Edge |
Frozen dataclass with node and cursor: str, plus .to_dict(serialize_node=None). |
PageInfo |
Frozen dataclass with has_previous_page, has_next_page, start_cursor and end_cursor, plus .to_dict(). |
As in graphql-relay-js, has_previous_page is computed only when last is
given, and has_next_page only when first is given. Otherwise they are False.
Node registry (pyrelay.node)
| Name | Description |
|---|---|
NodeRegistry() |
Registry of type_name -> resolver(local_id, *args, **kwargs). |
.register(type_name, resolver=None) |
Registers a resolver. It can be used as a decorator. Raises ValueError on a duplicate or invalid name. |
.unregister(type_name) |
Removes a resolver. Raises KeyError if the type is not registered. |
.resolve(global_id, *args, **kwargs) |
Calls the resolver and returns its result. Returns None for an unregistered type. Raises InvalidGlobalIdError for a malformed ID. |
.global_id(type_name, local_id) -> str |
Like to_global_id, but raises KeyError for an unregistered type. |
.types, type_name in registry |
Introspection. |
Mutations (pyrelay.mutation)
| Name | Description |
|---|---|
relay_mutation(func) |
Decorator. The wrapped function is called as wrapped(input, *args, **kwargs). func receives input without clientMutationId and must return a mapping or None. The payload dict always includes clientMutationId. Coroutine functions stay awaitable. |
CLIENT_MUTATION_ID |
The constant "clientMutationId". |
Errors (pyrelay.errors)
| Name | Description |
|---|---|
RelayError |
Base class of all pyrelay exceptions. |
InvalidGlobalIdError |
Subclass of RelayError and ValueError. |
InvalidCursorError |
Subclass of RelayError and ValueError. |
ConnectionArgumentError |
Subclass of RelayError and ValueError. |
__version__ holds the package version string ("0.1.0").
Development
python3 -m venv .venv && . .venv/bin/activate
pip install -e '.[test]'
python -m pytest # or, without pytest:
PYTHONPATH=src python3 -m unittest discover -s tests -v
The test suite also runs every Python block in this README and checks that each public name is documented here, so the docs and code stay in sync.
License
MIT
Metadata
Release files for pyrelay 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| pyrelay-0.1.0.tar.gz | 18.1 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pyrelay-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 31.8 kB
Release files / pyrelay-0.1.0.tar.gz
| Download URL | pyrelay-0.1.0.tar.gz |
|---|---|
| Size | 18.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2a000f2a428ebf07e82f78ba77268328960cc0847a8f41f3b82b348be198a097
|
|
BLAKE2b-256 checksum How to use checksums |
af78dfee2ca24c4b676ce3315c9cc4fcea2a537f9e47efbb41a118c91c8f3194
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|
Release files / pyrelay-0.1.0-py3-none-any.whl
| Download URL | pyrelay-0.1.0-py3-none-any.whl |
|---|---|
| Size | 13.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
bfc68185048fcb1bfcc3f7bc6886c7ea7c733efaed67fee057c663b0b3dba750
|
|
BLAKE2b-256 checksum How to use checksums |
6fab0fdb1709844e91822e34246df07b1ad93815ef908dce9a089f527aa02f80
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|
Security for pyrelay 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.