pyrelay 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyrelay/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """pyrelay: Relay-spec GraphQL server helpers, independent of any GraphQL library.
2
+
3
+ Provides global object IDs, cursor-based connection pagination, a ``node``
4
+ resolver registry, and ``clientMutationId`` handling for input-object mutations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .connection import (
10
+ Connection,
11
+ ConnectionArguments,
12
+ Edge,
13
+ PageInfo,
14
+ connection_from_sequence,
15
+ connection_from_sequence_slice,
16
+ )
17
+ from .cursors import cursor_for_object_in_sequence, cursor_to_offset, offset_to_cursor
18
+ from .errors import (
19
+ ConnectionArgumentError,
20
+ InvalidCursorError,
21
+ InvalidGlobalIdError,
22
+ RelayError,
23
+ )
24
+ from .ids import ResolvedGlobalId, from_global_id, to_global_id
25
+ from .mutation import CLIENT_MUTATION_ID, relay_mutation
26
+ from .node import NodeRegistry
27
+
28
+ __version__ = "0.1.0"
29
+
30
+ __all__ = [
31
+ "__version__",
32
+ # ids
33
+ "to_global_id",
34
+ "from_global_id",
35
+ "ResolvedGlobalId",
36
+ # cursors
37
+ "offset_to_cursor",
38
+ "cursor_to_offset",
39
+ "cursor_for_object_in_sequence",
40
+ # connections
41
+ "PageInfo",
42
+ "Edge",
43
+ "Connection",
44
+ "ConnectionArguments",
45
+ "connection_from_sequence",
46
+ "connection_from_sequence_slice",
47
+ # node
48
+ "NodeRegistry",
49
+ # mutations
50
+ "CLIENT_MUTATION_ID",
51
+ "relay_mutation",
52
+ # errors
53
+ "RelayError",
54
+ "InvalidGlobalIdError",
55
+ "InvalidCursorError",
56
+ "ConnectionArgumentError",
57
+ ]
pyrelay/connection.py ADDED
@@ -0,0 +1,212 @@
1
+ """Cursor-based connection pagination per the Relay Cursor Connections spec.
2
+
3
+ The algorithm mirrors ``connectionFromArraySlice`` from ``graphql-relay-js``:
4
+ ``after``/``before`` narrow the window, then ``first`` keeps the head of it and
5
+ ``last`` keeps the tail. ``hasPreviousPage`` is only computed when ``last`` is
6
+ given and ``hasNextPage`` only when ``first`` is given, as the spec permits.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Callable, Dict, Generic, List, Mapping, Optional, Sequence, TypeVar
13
+
14
+ from .cursors import cursor_to_offset, offset_to_cursor
15
+ from .errors import ConnectionArgumentError
16
+
17
+ __all__ = [
18
+ "PageInfo",
19
+ "Edge",
20
+ "Connection",
21
+ "ConnectionArguments",
22
+ "connection_from_sequence",
23
+ "connection_from_sequence_slice",
24
+ ]
25
+
26
+ T = TypeVar("T")
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class PageInfo:
31
+ """Pagination metadata for a :class:`Connection`."""
32
+
33
+ has_previous_page: bool
34
+ has_next_page: bool
35
+ start_cursor: Optional[str] = None
36
+ end_cursor: Optional[str] = None
37
+
38
+ def to_dict(self) -> Dict[str, Any]:
39
+ """Return the GraphQL-shaped (camelCase) representation."""
40
+ return {
41
+ "hasPreviousPage": self.has_previous_page,
42
+ "hasNextPage": self.has_next_page,
43
+ "startCursor": self.start_cursor,
44
+ "endCursor": self.end_cursor,
45
+ }
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Edge(Generic[T]):
50
+ """A node together with the cursor that locates it."""
51
+
52
+ node: T
53
+ cursor: str
54
+
55
+ def to_dict(self, serialize_node: Optional[Callable[[T], Any]] = None) -> Dict[str, Any]:
56
+ """Return ``{"node": ..., "cursor": ...}``, optionally transforming the node."""
57
+ node = serialize_node(self.node) if serialize_node else self.node
58
+ return {"node": node, "cursor": self.cursor}
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Connection(Generic[T]):
63
+ """A page of edges plus :class:`PageInfo`."""
64
+
65
+ edges: List[Edge[T]]
66
+ page_info: PageInfo
67
+ total_count: Optional[int] = field(default=None)
68
+ """Total length of the underlying sequence, when known."""
69
+
70
+ @property
71
+ def nodes(self) -> List[T]:
72
+ """The nodes of :attr:`edges`, in order."""
73
+ return [edge.node for edge in self.edges]
74
+
75
+ def to_dict(self, serialize_node: Optional[Callable[[T], Any]] = None) -> Dict[str, Any]:
76
+ """Return the GraphQL-shaped representation.
77
+
78
+ ``totalCount`` is included only when :attr:`total_count` is known.
79
+ """
80
+ result: Dict[str, Any] = {
81
+ "edges": [edge.to_dict(serialize_node) for edge in self.edges],
82
+ "pageInfo": self.page_info.to_dict(),
83
+ }
84
+ if self.total_count is not None:
85
+ result["totalCount"] = self.total_count
86
+ return result
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class ConnectionArguments:
91
+ """The four standard connection arguments.
92
+
93
+ Use :meth:`from_mapping` to build one from raw GraphQL arguments.
94
+ """
95
+
96
+ first: Optional[int] = None
97
+ after: Optional[str] = None
98
+ last: Optional[int] = None
99
+ before: Optional[str] = None
100
+
101
+ def __post_init__(self) -> None:
102
+ for name in ("first", "last"):
103
+ value = getattr(self, name)
104
+ if value is None:
105
+ continue
106
+ if isinstance(value, bool) or not isinstance(value, int):
107
+ raise ConnectionArgumentError(f"Argument '{name}' must be an int")
108
+ if value < 0:
109
+ raise ConnectionArgumentError(
110
+ f"Argument '{name}' must be a non-negative integer"
111
+ )
112
+
113
+ @classmethod
114
+ def from_mapping(cls, args: Mapping[str, Any]) -> "ConnectionArguments":
115
+ """Build from a mapping such as GraphQL resolver ``**kwargs``.
116
+
117
+ Unknown keys are ignored so the full field arguments can be passed.
118
+ """
119
+ return cls(
120
+ first=args.get("first"),
121
+ after=args.get("after"),
122
+ last=args.get("last"),
123
+ before=args.get("before"),
124
+ )
125
+
126
+
127
+ def connection_from_sequence(
128
+ data: Sequence[T],
129
+ args: Optional[ConnectionArguments] = None,
130
+ *,
131
+ include_total_count: bool = False,
132
+ ) -> Connection[T]:
133
+ """Paginate an in-memory sequence.
134
+
135
+ >>> conn = connection_from_sequence("ABCDE", ConnectionArguments(first=2))
136
+ >>> conn.nodes, conn.page_info.has_next_page
137
+ (['A', 'B'], True)
138
+
139
+ :raises ConnectionArgumentError: if ``first``/``last`` are negative.
140
+ :raises InvalidCursorError: if ``after``/``before`` are malformed.
141
+ """
142
+ return connection_from_sequence_slice(
143
+ data,
144
+ args,
145
+ slice_start=0,
146
+ sequence_length=len(data),
147
+ include_total_count=include_total_count,
148
+ )
149
+
150
+
151
+ def connection_from_sequence_slice(
152
+ sequence_slice: Sequence[T],
153
+ args: Optional[ConnectionArguments] = None,
154
+ *,
155
+ slice_start: int,
156
+ sequence_length: int,
157
+ include_total_count: bool = False,
158
+ ) -> Connection[T]:
159
+ """Paginate when only a window of the full sequence is in memory.
160
+
161
+ This is the building block for database-backed connections: fetch rows
162
+ ``[slice_start, slice_start + len(sequence_slice))`` (e.g. with
163
+ ``OFFSET``/``LIMIT``), report the full ``sequence_length``, and the
164
+ resulting cursors stay consistent with :func:`connection_from_sequence`.
165
+
166
+ :raises ConnectionArgumentError: on negative ``first``/``last``, a
167
+ negative ``slice_start``, or a ``sequence_length`` smaller than the slice end.
168
+ :raises InvalidCursorError: if ``after``/``before`` are malformed.
169
+ """
170
+ args = args or ConnectionArguments()
171
+ if slice_start < 0:
172
+ raise ConnectionArgumentError("slice_start must be non-negative")
173
+ slice_end = slice_start + len(sequence_slice)
174
+ if sequence_length < slice_end:
175
+ raise ConnectionArgumentError(
176
+ "sequence_length must be >= slice_start + len(sequence_slice)"
177
+ )
178
+
179
+ before_offset = cursor_to_offset(args.before) if args.before is not None else sequence_length
180
+ after_offset = cursor_to_offset(args.after) if args.after is not None else -1
181
+
182
+ start_offset = max(slice_start - 1, after_offset, -1) + 1
183
+ end_offset = min(slice_end, before_offset, sequence_length)
184
+
185
+ if args.first is not None:
186
+ end_offset = min(end_offset, start_offset + args.first)
187
+ if args.last is not None:
188
+ start_offset = max(start_offset, end_offset - args.last)
189
+
190
+ lo = max(start_offset - slice_start, 0)
191
+ hi = max(len(sequence_slice) - (slice_end - end_offset), 0)
192
+ window = list(sequence_slice[lo:hi]) if hi > lo else []
193
+
194
+ edges = [
195
+ Edge(node=node, cursor=offset_to_cursor(start_offset + index))
196
+ for index, node in enumerate(window)
197
+ ]
198
+
199
+ lower_bound = after_offset + 1 if args.after is not None else 0
200
+ upper_bound = before_offset if args.before is not None else sequence_length
201
+
202
+ page_info = PageInfo(
203
+ has_previous_page=start_offset > lower_bound if args.last is not None else False,
204
+ has_next_page=end_offset < upper_bound if args.first is not None else False,
205
+ start_cursor=edges[0].cursor if edges else None,
206
+ end_cursor=edges[-1].cursor if edges else None,
207
+ )
208
+ return Connection(
209
+ edges=edges,
210
+ page_info=page_info,
211
+ total_count=sequence_length if include_total_count else None,
212
+ )
pyrelay/cursors.py ADDED
@@ -0,0 +1,63 @@
1
+ """Opaque offset-based cursors for connection pagination.
2
+
3
+ Cursors are ``base64("arrayconnection:<offset>")`` -- the same format used by
4
+ ``graphql-relay-js`` -- so clients never need to understand their contents.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import binascii
11
+ from typing import Any, Optional, Sequence
12
+
13
+ from .errors import InvalidCursorError
14
+
15
+ __all__ = [
16
+ "CURSOR_PREFIX",
17
+ "offset_to_cursor",
18
+ "cursor_to_offset",
19
+ "cursor_for_object_in_sequence",
20
+ ]
21
+
22
+ CURSOR_PREFIX = "arrayconnection:"
23
+
24
+
25
+ def offset_to_cursor(offset: int) -> str:
26
+ """Return the opaque cursor for a zero-based ``offset``.
27
+
28
+ :raises ValueError: if ``offset`` is negative or not an integer.
29
+ """
30
+ if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
31
+ raise ValueError(f"offset must be a non-negative int (got {offset!r})")
32
+ return base64.b64encode(f"{CURSOR_PREFIX}{offset}".encode("ascii")).decode("ascii")
33
+
34
+
35
+ def cursor_to_offset(cursor: str) -> int:
36
+ """Return the zero-based offset encoded in ``cursor``.
37
+
38
+ :raises InvalidCursorError: if the cursor was not produced by
39
+ :func:`offset_to_cursor`.
40
+ """
41
+ if not isinstance(cursor, str) or not cursor:
42
+ raise InvalidCursorError(f"Invalid cursor: {cursor!r}")
43
+ try:
44
+ decoded = base64.b64decode(cursor, validate=True).decode("ascii")
45
+ except (binascii.Error, UnicodeDecodeError, ValueError) as exc:
46
+ raise InvalidCursorError(f"Invalid cursor: {cursor!r}") from exc
47
+ if not decoded.startswith(CURSOR_PREFIX):
48
+ raise InvalidCursorError(f"Invalid cursor: {cursor!r}")
49
+ digits = decoded[len(CURSOR_PREFIX):]
50
+ if not digits.isdigit():
51
+ raise InvalidCursorError(f"Invalid cursor: {cursor!r}")
52
+ return int(digits)
53
+
54
+
55
+ def cursor_for_object_in_sequence(data: Sequence[Any], obj: Any) -> Optional[str]:
56
+ """Return the cursor of the first item in ``data`` equal to ``obj``.
57
+
58
+ Returns ``None`` when ``obj`` is not present.
59
+ """
60
+ for index, item in enumerate(data):
61
+ if item == obj:
62
+ return offset_to_cursor(index)
63
+ return None
pyrelay/errors.py ADDED
@@ -0,0 +1,26 @@
1
+ """Exception hierarchy for :mod:`pyrelay`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "RelayError",
7
+ "InvalidGlobalIdError",
8
+ "InvalidCursorError",
9
+ "ConnectionArgumentError",
10
+ ]
11
+
12
+
13
+ class RelayError(Exception):
14
+ """Base class for every error raised by pyrelay."""
15
+
16
+
17
+ class InvalidGlobalIdError(RelayError, ValueError):
18
+ """Raised when a string cannot be decoded as a Relay global object ID."""
19
+
20
+
21
+ class InvalidCursorError(RelayError, ValueError):
22
+ """Raised when a pagination cursor is malformed or was not issued by pyrelay."""
23
+
24
+
25
+ class ConnectionArgumentError(RelayError, ValueError):
26
+ """Raised when ``first``/``last``/``after``/``before`` arguments are invalid."""
pyrelay/ids.py ADDED
@@ -0,0 +1,66 @@
1
+ """Relay global object identification.
2
+
3
+ A *global ID* is an opaque string that uniquely identifies an object across
4
+ every type in a schema. Following the reference implementation
5
+ (``graphql-relay-js``), pyrelay encodes it as ``base64("<TypeName>:<local id>")``,
6
+ so IDs produced here interoperate with other Relay servers and clients.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import binascii
13
+ from typing import NamedTuple, Union
14
+
15
+ from .errors import InvalidGlobalIdError
16
+
17
+ __all__ = ["ResolvedGlobalId", "to_global_id", "from_global_id"]
18
+
19
+ LocalId = Union[str, int]
20
+
21
+
22
+ class ResolvedGlobalId(NamedTuple):
23
+ """The decoded parts of a global ID."""
24
+
25
+ type: str
26
+ """The GraphQL type name, e.g. ``"User"``."""
27
+
28
+ id: str
29
+ """The type-local identifier, always returned as a string."""
30
+
31
+
32
+ def to_global_id(type_name: str, local_id: LocalId) -> str:
33
+ """Encode ``type_name`` and ``local_id`` into an opaque global ID.
34
+
35
+ >>> to_global_id("User", 42)
36
+ 'VXNlcjo0Mg=='
37
+
38
+ :raises ValueError: if ``type_name`` is empty or contains ``":"``.
39
+ """
40
+ if not type_name:
41
+ raise ValueError("type_name must be a non-empty string")
42
+ if ":" in type_name:
43
+ raise ValueError(f"type_name must not contain ':' (got {type_name!r})")
44
+ raw = f"{type_name}:{local_id}".encode("utf-8")
45
+ return base64.b64encode(raw).decode("ascii")
46
+
47
+
48
+ def from_global_id(global_id: str) -> ResolvedGlobalId:
49
+ """Decode a global ID produced by :func:`to_global_id`.
50
+
51
+ >>> from_global_id("VXNlcjo0Mg==")
52
+ ResolvedGlobalId(type='User', id='42')
53
+
54
+ :raises InvalidGlobalIdError: if the value is not valid base64, not UTF-8,
55
+ or lacks a ``"Type:id"`` structure with a non-empty type and id.
56
+ """
57
+ if not isinstance(global_id, str) or not global_id:
58
+ raise InvalidGlobalIdError(f"Invalid global ID: {global_id!r}")
59
+ try:
60
+ decoded = base64.b64decode(global_id, validate=True).decode("utf-8")
61
+ except (binascii.Error, UnicodeDecodeError, ValueError) as exc:
62
+ raise InvalidGlobalIdError(f"Invalid global ID: {global_id!r}") from exc
63
+ type_name, sep, local_id = decoded.partition(":")
64
+ if not sep or not type_name or not local_id:
65
+ raise InvalidGlobalIdError(f"Invalid global ID: {global_id!r}")
66
+ return ResolvedGlobalId(type_name, local_id)
pyrelay/mutation.py ADDED
@@ -0,0 +1,75 @@
1
+ """Relay input-object mutation helpers.
2
+
3
+ Relay mutations take a single ``input`` argument and return a payload object.
4
+ Clients may send ``clientMutationId``, which the server must echo back
5
+ unchanged in the payload. :func:`relay_mutation` handles that bookkeeping so
6
+ the wrapped function deals only with business fields.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import inspect
13
+ from typing import Any, Callable, Dict, Mapping, TypeVar, cast
14
+
15
+ __all__ = ["CLIENT_MUTATION_ID", "relay_mutation"]
16
+
17
+ CLIENT_MUTATION_ID = "clientMutationId"
18
+
19
+ F = TypeVar("F", bound=Callable[..., Any])
20
+
21
+
22
+ def _split_input(input: Mapping[str, Any]) -> "tuple[Any, Dict[str, Any]]":
23
+ if not isinstance(input, Mapping):
24
+ raise TypeError(f"Mutation input must be a mapping (got {type(input).__name__})")
25
+ client_id = input.get(CLIENT_MUTATION_ID)
26
+ fields = {k: v for k, v in input.items() if k != CLIENT_MUTATION_ID}
27
+ return client_id, fields
28
+
29
+
30
+ def _build_payload(result: Any, client_id: Any) -> Dict[str, Any]:
31
+ if result is None:
32
+ payload: Dict[str, Any] = {}
33
+ elif isinstance(result, Mapping):
34
+ payload = dict(result)
35
+ else:
36
+ raise TypeError(
37
+ "Relay mutation must return a mapping or None "
38
+ f"(got {type(result).__name__})"
39
+ )
40
+ payload[CLIENT_MUTATION_ID] = client_id
41
+ return payload
42
+
43
+
44
+ def relay_mutation(func: F) -> F:
45
+ """Decorate a mutation function to handle ``clientMutationId``.
46
+
47
+ The decorated callable is invoked as ``wrapped(input, *args, **kwargs)``.
48
+ ``clientMutationId`` is removed from ``input`` before calling ``func``, and
49
+ added (``None`` if absent) to the returned payload dict. ``func`` must
50
+ return a mapping or ``None``. Coroutine functions are supported and stay
51
+ awaitable.
52
+
53
+ >>> @relay_mutation
54
+ ... def rename(input):
55
+ ... return {"name": input["name"].title()}
56
+ >>> rename({"name": "ada", "clientMutationId": "m1"})
57
+ {'name': 'Ada', 'clientMutationId': 'm1'}
58
+ """
59
+ if inspect.iscoroutinefunction(func):
60
+
61
+ @functools.wraps(func)
62
+ async def async_wrapper(input: Mapping[str, Any], *args: Any, **kwargs: Any) -> Dict[str, Any]:
63
+ client_id, fields = _split_input(input)
64
+ result = await func(fields, *args, **kwargs)
65
+ return _build_payload(result, client_id)
66
+
67
+ return cast(F, async_wrapper)
68
+
69
+ @functools.wraps(func)
70
+ def wrapper(input: Mapping[str, Any], *args: Any, **kwargs: Any) -> Dict[str, Any]:
71
+ client_id, fields = _split_input(input)
72
+ result = func(fields, *args, **kwargs)
73
+ return _build_payload(result, client_id)
74
+
75
+ return cast(F, wrapper)
pyrelay/node.py ADDED
@@ -0,0 +1,92 @@
1
+ """A registry implementing the Relay ``node(id: ID!)`` refetch field."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Dict, Optional, Tuple, overload
6
+
7
+ from .ids import LocalId, from_global_id, to_global_id
8
+
9
+ __all__ = ["NodeResolver", "NodeRegistry"]
10
+
11
+ NodeResolver = Callable[..., Any]
12
+ """``resolver(local_id: str, *args, **kwargs) -> object | None``."""
13
+
14
+
15
+ class NodeRegistry:
16
+ """Maps GraphQL type names to functions that load objects by local ID.
17
+
18
+ Register a resolver per type, then call :meth:`resolve` from your schema's
19
+ ``node`` field. Extra positional/keyword arguments (e.g. a request context)
20
+ are forwarded to the resolver unchanged, so async resolvers work too: the
21
+ caller simply awaits the returned value.
22
+
23
+ >>> registry = NodeRegistry()
24
+ >>> @registry.register("User")
25
+ ... def load_user(local_id):
26
+ ... return {"id": local_id}
27
+ >>> registry.resolve(registry.global_id("User", 7))
28
+ {'id': '7'}
29
+ """
30
+
31
+ def __init__(self) -> None:
32
+ self._resolvers: Dict[str, NodeResolver] = {}
33
+
34
+ @overload
35
+ def register(self, type_name: str) -> Callable[[NodeResolver], NodeResolver]: ...
36
+
37
+ @overload
38
+ def register(self, type_name: str, resolver: NodeResolver) -> NodeResolver: ...
39
+
40
+ def register(self, type_name: str, resolver: Optional[NodeResolver] = None) -> Any:
41
+ """Register ``resolver`` for ``type_name``; usable as a decorator.
42
+
43
+ :raises ValueError: if ``type_name`` is already registered or invalid.
44
+ """
45
+ if not type_name or ":" in type_name:
46
+ raise ValueError(f"Invalid type name: {type_name!r}")
47
+
48
+ def decorator(fn: NodeResolver) -> NodeResolver:
49
+ if type_name in self._resolvers:
50
+ raise ValueError(f"Type {type_name!r} is already registered")
51
+ self._resolvers[type_name] = fn
52
+ return fn
53
+
54
+ return decorator(resolver) if resolver is not None else decorator
55
+
56
+ def unregister(self, type_name: str) -> None:
57
+ """Remove the resolver for ``type_name``.
58
+
59
+ :raises KeyError: if it is not registered.
60
+ """
61
+ del self._resolvers[type_name]
62
+
63
+ @property
64
+ def types(self) -> Tuple[str, ...]:
65
+ """Registered type names, in registration order."""
66
+ return tuple(self._resolvers)
67
+
68
+ def __contains__(self, type_name: object) -> bool:
69
+ return type_name in self._resolvers
70
+
71
+ def global_id(self, type_name: str, local_id: LocalId) -> str:
72
+ """Encode a global ID for a *registered* type.
73
+
74
+ :raises KeyError: if ``type_name`` is not registered (catches typos).
75
+ """
76
+ if type_name not in self._resolvers:
77
+ raise KeyError(f"Type {type_name!r} is not registered")
78
+ return to_global_id(type_name, local_id)
79
+
80
+ def resolve(self, global_id: str, *args: Any, **kwargs: Any) -> Any:
81
+ """Load the object identified by ``global_id``.
82
+
83
+ Returns ``None`` when the type is not registered, matching the Relay
84
+ convention that ``node`` yields ``null`` for unknown IDs.
85
+
86
+ :raises InvalidGlobalIdError: if ``global_id`` is malformed.
87
+ """
88
+ type_name, local_id = from_global_id(global_id)
89
+ resolver = self._resolvers.get(type_name)
90
+ if resolver is None:
91
+ return None
92
+ return resolver(local_id, *args, **kwargs)
pyrelay/py.typed ADDED
File without changes
@@ -0,0 +1,211 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyrelay
3
+ Version: 0.1.0
4
+ Summary: Relay-spec GraphQL helpers for Python: global object IDs, cursor connections, node registry, and mutation payloads. Framework-agnostic, zero dependencies.
5
+ Author: nehz
6
+ License-Expression: MIT
7
+ Keywords: graphql,relay,pagination,cursor,connection,global-id
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Provides-Extra: test
25
+ Requires-Dist: pytest>=7; extra == "test"
26
+ Dynamic: license-file
27
+
28
+ # pyrelay
29
+
30
+ **Relay-spec GraphQL helpers for Python. Framework-agnostic, with zero dependencies.**
31
+
32
+ Relay's server conventions (opaque global IDs, cursor connections, the `node`
33
+ refetch field, `clientMutationId` on mutations) are small, but they're easy to
34
+ get subtly wrong. Each Python GraphQL library also tends to ship its own
35
+ version. `pyrelay` implements them once as plain Python functions and
36
+ dataclasses. You can use it with Strawberry, Graphene, Ariadne, `graphql-core`,
37
+ or a hand-rolled resolver layer. The ID and cursor formats match the reference
38
+ `graphql-relay-js` implementation, so existing Relay clients and data keep working.
39
+
40
+ ## Features
41
+
42
+ - **Global object IDs**: `to_global_id` / `from_global_id` encode and decode `base64("Type:id")`, with strict validation.
43
+ - **Cursor connections**: `connection_from_sequence` implements the Relay Cursor Connections spec (`first`/`after`/`last`/`before`) and produces correct `PageInfo`.
44
+ - **Database-friendly slicing**: `connection_from_sequence_slice` paginates when only an `OFFSET`/`LIMIT` window is loaded.
45
+ - **Node registry**: `NodeRegistry` maps type names to loaders for the `node(id:)` field. It forwards context and works with async loaders.
46
+ - **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.
47
+ - **GraphQL-shaped output**: `.to_dict()` emits camelCase `edges` / `pageInfo` / `totalCount`.
48
+ - Fully type-hinted (`py.typed`), standard library only, Python 3.9+.
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ pip install pyrelay # once published
54
+ # or, from a checkout:
55
+ pip install -e .
56
+ ```
57
+
58
+ ## Quickstart
59
+
60
+ ```python
61
+ from pyrelay import (
62
+ ConnectionArguments,
63
+ NodeRegistry,
64
+ connection_from_sequence,
65
+ from_global_id,
66
+ relay_mutation,
67
+ to_global_id,
68
+ )
69
+
70
+ # 1. Global object IDs
71
+ gid = to_global_id("User", 42)
72
+ assert gid == "VXNlcjo0Mg=="
73
+ assert from_global_id(gid) == ("User", "42")
74
+
75
+ # 2. Cursor pagination over any sequence
76
+ users = [{"id": i, "name": n} for i, n in enumerate(["Ada", "Grace", "Linus", "Guido"])]
77
+ page1 = connection_from_sequence(users, ConnectionArguments(first=2))
78
+ assert [u["name"] for u in page1.nodes] == ["Ada", "Grace"]
79
+ assert page1.page_info.has_next_page
80
+
81
+ page2 = connection_from_sequence(
82
+ users, ConnectionArguments(first=2, after=page1.page_info.end_cursor)
83
+ )
84
+ assert [u["name"] for u in page2.nodes] == ["Linus", "Guido"]
85
+ assert not page2.page_info.has_next_page
86
+
87
+ # GraphQL-shaped result, e.g. to return from a resolver
88
+ data = page2.to_dict(lambda u: {"id": to_global_id("User", u["id"]), "name": u["name"]})
89
+ assert set(data) == {"edges", "pageInfo"}
90
+
91
+ # 3. The `node(id:)` field
92
+ registry = NodeRegistry()
93
+
94
+ @registry.register("User")
95
+ def load_user(local_id: str, context=None):
96
+ return users[int(local_id)]
97
+
98
+ assert registry.resolve(registry.global_id("User", 1))["name"] == "Grace"
99
+ assert registry.resolve(to_global_id("Unknown", 1)) is None
100
+
101
+ # 4. Input-object mutations with clientMutationId
102
+ @relay_mutation
103
+ def rename_user(input, context=None):
104
+ user = registry.resolve(input["id"], context)
105
+ user["name"] = input["name"]
106
+ return {"user": user}
107
+
108
+ payload = rename_user(
109
+ {"id": to_global_id("User", 0), "name": "Countess Ada", "clientMutationId": "m-1"}
110
+ )
111
+ assert payload == {"user": {"id": 0, "name": "Countess Ada"}, "clientMutationId": "m-1"}
112
+ ```
113
+
114
+ ### Paginating a database query
115
+
116
+ Load only the window you need, then tell pyrelay where that window sits:
117
+
118
+ ```python
119
+ from pyrelay import ConnectionArguments, connection_from_sequence_slice, cursor_to_offset
120
+
121
+ rows = list(range(100)) # pretend this is a table
122
+ args = ConnectionArguments(first=10, after="YXJyYXljb25uZWN0aW9uOjQ=") # offset 4
123
+
124
+ offset = cursor_to_offset(args.after) + 1 if args.after else 0
125
+ window = rows[offset : offset + args.first] # SELECT ... OFFSET :offset LIMIT :first
126
+
127
+ conn = connection_from_sequence_slice(
128
+ window, args, slice_start=offset, sequence_length=len(rows), include_total_count=True
129
+ )
130
+ assert conn.nodes == list(range(5, 15))
131
+ assert conn.page_info.has_next_page and conn.total_count == 100
132
+ ```
133
+
134
+ ## API overview
135
+
136
+ Everything below can be imported from the top-level `pyrelay` package.
137
+
138
+ ### Global IDs (`pyrelay.ids`)
139
+
140
+ | Name | Description |
141
+ | --- | --- |
142
+ | `to_global_id(type_name, local_id) -> str` | Returns `base64("Type:id")`. Raises `ValueError` if `type_name` is empty or contains `:`. |
143
+ | `from_global_id(global_id) -> ResolvedGlobalId` | Decodes an ID. Raises `InvalidGlobalIdError` on malformed input. |
144
+ | `ResolvedGlobalId` | A `NamedTuple` with fields `type: str` and `id: str`. |
145
+
146
+ ### Cursors (`pyrelay.cursors`)
147
+
148
+ | Name | Description |
149
+ | --- | --- |
150
+ | `offset_to_cursor(offset) -> str` | Returns the opaque cursor `base64("arrayconnection:N")`. |
151
+ | `cursor_to_offset(cursor) -> int` | Inverse of `offset_to_cursor`. Raises `InvalidCursorError`. |
152
+ | `cursor_for_object_in_sequence(data, obj) -> str \| None` | Returns the cursor of the first item equal to `obj`. |
153
+
154
+ ### Connections (`pyrelay.connection`)
155
+
156
+ | Name | Description |
157
+ | --- | --- |
158
+ | `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. |
159
+ | `connection_from_sequence(data, args=None, *, include_total_count=False) -> Connection` | Paginates an in-memory sequence. |
160
+ | `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. |
161
+ | `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)`. |
162
+ | `Edge` | Frozen dataclass with `node` and `cursor: str`, plus `.to_dict(serialize_node=None)`. |
163
+ | `PageInfo` | Frozen dataclass with `has_previous_page`, `has_next_page`, `start_cursor` and `end_cursor`, plus `.to_dict()`. |
164
+
165
+ As in `graphql-relay-js`, `has_previous_page` is computed only when `last` is
166
+ given, and `has_next_page` only when `first` is given. Otherwise they are `False`.
167
+
168
+ ### Node registry (`pyrelay.node`)
169
+
170
+ | Name | Description |
171
+ | --- | --- |
172
+ | `NodeRegistry()` | Registry of `type_name -> resolver(local_id, *args, **kwargs)`. |
173
+ | `.register(type_name, resolver=None)` | Registers a resolver. It can be used as a decorator. Raises `ValueError` on a duplicate or invalid name. |
174
+ | `.unregister(type_name)` | Removes a resolver. Raises `KeyError` if the type is not registered. |
175
+ | `.resolve(global_id, *args, **kwargs)` | Calls the resolver and returns its result. Returns `None` for an unregistered type. Raises `InvalidGlobalIdError` for a malformed ID. |
176
+ | `.global_id(type_name, local_id) -> str` | Like `to_global_id`, but raises `KeyError` for an unregistered type. |
177
+ | `.types`, `type_name in registry` | Introspection. |
178
+
179
+ ### Mutations (`pyrelay.mutation`)
180
+
181
+ | Name | Description |
182
+ | --- | --- |
183
+ | `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. |
184
+ | `CLIENT_MUTATION_ID` | The constant `"clientMutationId"`. |
185
+
186
+ ### Errors (`pyrelay.errors`)
187
+
188
+ | Name | Description |
189
+ | --- | --- |
190
+ | `RelayError` | Base class of all pyrelay exceptions. |
191
+ | `InvalidGlobalIdError` | Subclass of `RelayError` and `ValueError`. |
192
+ | `InvalidCursorError` | Subclass of `RelayError` and `ValueError`. |
193
+ | `ConnectionArgumentError` | Subclass of `RelayError` and `ValueError`. |
194
+
195
+ `__version__` holds the package version string (`"0.1.0"`).
196
+
197
+ ## Development
198
+
199
+ ```bash
200
+ python3 -m venv .venv && . .venv/bin/activate
201
+ pip install -e '.[test]'
202
+ python -m pytest # or, without pytest:
203
+ PYTHONPATH=src python3 -m unittest discover -s tests -v
204
+ ```
205
+
206
+ The test suite also runs every Python block in this README and checks that
207
+ each public name is documented here, so the docs and code stay in sync.
208
+
209
+ ## License
210
+
211
+ MIT
@@ -0,0 +1,13 @@
1
+ pyrelay/__init__.py,sha256=mi_EpHrEn6fWPrV10Sfe9BE3SEkrFWAM8Kro2sulCfM,1402
2
+ pyrelay/connection.py,sha256=tAjXLzKOcybkbXAg_f2qBP996Zv-Y8A70647zDaBtCU,7323
3
+ pyrelay/cursors.py,sha256=WmMuAyGbnhJpu4j2u9DtBlSFQ-5Zapq-mu49h5WDOYo,2111
4
+ pyrelay/errors.py,sha256=mYmmQ3YWzNfxidWNRcfU-PeB9lPA4IuoriADQ6JGtUY,698
5
+ pyrelay/ids.py,sha256=EwJMPwLCF--7nc7xkLAy2ySLAyAXxtruZRZBl6lYQpI,2311
6
+ pyrelay/mutation.py,sha256=2yCoDnN2yWXMnLHAMB5jXGZ_i6zoCjq0Mlcra-HeHfU,2673
7
+ pyrelay/node.py,sha256=72AqkeFHab4fw4yBDLKv7LZWhMRi_csbSi76Lz4_c28,3355
8
+ pyrelay/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ pyrelay-0.1.0.dist-info/licenses/LICENSE,sha256=pAfYREEW9GAy7cnK20OXjDn7ofJahYny9GCuIZQTDAA,1061
10
+ pyrelay-0.1.0.dist-info/METADATA,sha256=63yOGL7ruhmkSzDjy1_YKikRn-Q3gKb33F3tCr1ndGE,9308
11
+ pyrelay-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
12
+ pyrelay-0.1.0.dist-info/top_level.txt,sha256=8HaSBt3fHoKosrbC0-WDSG9L2dJD3CTFNrusjhly9rs,8
13
+ pyrelay-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nehz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pyrelay