Skip to main content

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_id encode and decode base64("Type:id"), with strict validation.
  • Cursor connections: connection_from_sequence implements the Relay Cursor Connections spec (first/after/last/before) and produces correct PageInfo.
  • Database-friendly slicing: connection_from_sequence_slice paginates when only an OFFSET/LIMIT window is loaded.
  • Node registry: NodeRegistry maps type names to loaders for the node(id:) field. It forwards context and works with async loaders.
  • Mutation helper: the @relay_mutation decorator strips clientMutationId from the input and echoes it back in the payload. It works for sync and async functions.
  • GraphQL-shaped output: .to_dict() emits camelCase edges / 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)

Source distribution for pyrelay 0.1.0
File Size Uploaded Provenance
pyrelay-0.1.0.tar.gz 18.1 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for pyrelay 0.1.0
File Interpreter ABI Platform
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.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files No provenance

0.0.1

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page