Metadata-Version: 2.4
Name: pyrelay
Version: 0.1.0
Summary: Relay-spec GraphQL helpers for Python: global object IDs, cursor connections, node registry, and mutation payloads. Framework-agnostic, zero dependencies.
Author: nehz
License-Expression: MIT
Keywords: graphql,relay,pagination,cursor,connection,global-id
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: test
Requires-Dist: pytest>=7; extra == "test"
Dynamic: license-file

# 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

```bash
pip install pyrelay            # once published
# or, from a checkout:
pip install -e .
```

## Quickstart

```python
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:

```python
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

```bash
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
