pyrelay 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- pyrelay-0.1.0/PKG-INFO +211 -0
- pyrelay-0.1.0/README.md +184 -0
- pyrelay-0.1.0/pyproject.toml +43 -0
- pyrelay-0.1.0/setup.cfg +4 -0
- pyrelay-0.1.0/src/pyrelay/__init__.py +57 -0
- pyrelay-0.1.0/src/pyrelay/connection.py +212 -0
- pyrelay-0.1.0/src/pyrelay/cursors.py +63 -0
- pyrelay-0.1.0/src/pyrelay/errors.py +26 -0
- pyrelay-0.1.0/src/pyrelay/ids.py +66 -0
- pyrelay-0.1.0/src/pyrelay/mutation.py +75 -0
- pyrelay-0.1.0/src/pyrelay/node.py +92 -0
- pyrelay-0.1.0/src/pyrelay/py.typed +0 -0
- pyrelay-0.1.0/src/pyrelay.egg-info/PKG-INFO +211 -0
- pyrelay-0.1.0/src/pyrelay.egg-info/SOURCES.txt +21 -0
- pyrelay-0.1.0/src/pyrelay.egg-info/dependency_links.txt +1 -0
- pyrelay-0.1.0/src/pyrelay.egg-info/requires.txt +3 -0
- pyrelay-0.1.0/src/pyrelay.egg-info/top_level.txt +1 -0
- pyrelay-0.1.0/tests/test_connection.py +186 -0
- pyrelay-0.1.0/tests/test_cursors.py +49 -0
- pyrelay-0.1.0/tests/test_docs.py +36 -0
- pyrelay-0.1.0/tests/test_ids.py +53 -0
- pyrelay-0.1.0/tests/test_node_and_mutation.py +145 -0
pyrelay-0.1.0/LICENSE
ADDED
|
@@ -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.
|
pyrelay-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
pyrelay-0.1.0/README.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# pyrelay
|
|
2
|
+
|
|
3
|
+
**Relay-spec GraphQL helpers for Python. Framework-agnostic, with zero dependencies.**
|
|
4
|
+
|
|
5
|
+
Relay's server conventions (opaque global IDs, cursor connections, the `node`
|
|
6
|
+
refetch field, `clientMutationId` on mutations) are small, but they're easy to
|
|
7
|
+
get subtly wrong. Each Python GraphQL library also tends to ship its own
|
|
8
|
+
version. `pyrelay` implements them once as plain Python functions and
|
|
9
|
+
dataclasses. You can use it with Strawberry, Graphene, Ariadne, `graphql-core`,
|
|
10
|
+
or a hand-rolled resolver layer. The ID and cursor formats match the reference
|
|
11
|
+
`graphql-relay-js` implementation, so existing Relay clients and data keep working.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **Global object IDs**: `to_global_id` / `from_global_id` encode and decode `base64("Type:id")`, with strict validation.
|
|
16
|
+
- **Cursor connections**: `connection_from_sequence` implements the Relay Cursor Connections spec (`first`/`after`/`last`/`before`) and produces correct `PageInfo`.
|
|
17
|
+
- **Database-friendly slicing**: `connection_from_sequence_slice` paginates when only an `OFFSET`/`LIMIT` window is loaded.
|
|
18
|
+
- **Node registry**: `NodeRegistry` maps type names to loaders for the `node(id:)` field. It forwards context and works with async loaders.
|
|
19
|
+
- **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.
|
|
20
|
+
- **GraphQL-shaped output**: `.to_dict()` emits camelCase `edges` / `pageInfo` / `totalCount`.
|
|
21
|
+
- Fully type-hinted (`py.typed`), standard library only, Python 3.9+.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install pyrelay # once published
|
|
27
|
+
# or, from a checkout:
|
|
28
|
+
pip install -e .
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from pyrelay import (
|
|
35
|
+
ConnectionArguments,
|
|
36
|
+
NodeRegistry,
|
|
37
|
+
connection_from_sequence,
|
|
38
|
+
from_global_id,
|
|
39
|
+
relay_mutation,
|
|
40
|
+
to_global_id,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# 1. Global object IDs
|
|
44
|
+
gid = to_global_id("User", 42)
|
|
45
|
+
assert gid == "VXNlcjo0Mg=="
|
|
46
|
+
assert from_global_id(gid) == ("User", "42")
|
|
47
|
+
|
|
48
|
+
# 2. Cursor pagination over any sequence
|
|
49
|
+
users = [{"id": i, "name": n} for i, n in enumerate(["Ada", "Grace", "Linus", "Guido"])]
|
|
50
|
+
page1 = connection_from_sequence(users, ConnectionArguments(first=2))
|
|
51
|
+
assert [u["name"] for u in page1.nodes] == ["Ada", "Grace"]
|
|
52
|
+
assert page1.page_info.has_next_page
|
|
53
|
+
|
|
54
|
+
page2 = connection_from_sequence(
|
|
55
|
+
users, ConnectionArguments(first=2, after=page1.page_info.end_cursor)
|
|
56
|
+
)
|
|
57
|
+
assert [u["name"] for u in page2.nodes] == ["Linus", "Guido"]
|
|
58
|
+
assert not page2.page_info.has_next_page
|
|
59
|
+
|
|
60
|
+
# GraphQL-shaped result, e.g. to return from a resolver
|
|
61
|
+
data = page2.to_dict(lambda u: {"id": to_global_id("User", u["id"]), "name": u["name"]})
|
|
62
|
+
assert set(data) == {"edges", "pageInfo"}
|
|
63
|
+
|
|
64
|
+
# 3. The `node(id:)` field
|
|
65
|
+
registry = NodeRegistry()
|
|
66
|
+
|
|
67
|
+
@registry.register("User")
|
|
68
|
+
def load_user(local_id: str, context=None):
|
|
69
|
+
return users[int(local_id)]
|
|
70
|
+
|
|
71
|
+
assert registry.resolve(registry.global_id("User", 1))["name"] == "Grace"
|
|
72
|
+
assert registry.resolve(to_global_id("Unknown", 1)) is None
|
|
73
|
+
|
|
74
|
+
# 4. Input-object mutations with clientMutationId
|
|
75
|
+
@relay_mutation
|
|
76
|
+
def rename_user(input, context=None):
|
|
77
|
+
user = registry.resolve(input["id"], context)
|
|
78
|
+
user["name"] = input["name"]
|
|
79
|
+
return {"user": user}
|
|
80
|
+
|
|
81
|
+
payload = rename_user(
|
|
82
|
+
{"id": to_global_id("User", 0), "name": "Countess Ada", "clientMutationId": "m-1"}
|
|
83
|
+
)
|
|
84
|
+
assert payload == {"user": {"id": 0, "name": "Countess Ada"}, "clientMutationId": "m-1"}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Paginating a database query
|
|
88
|
+
|
|
89
|
+
Load only the window you need, then tell pyrelay where that window sits:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from pyrelay import ConnectionArguments, connection_from_sequence_slice, cursor_to_offset
|
|
93
|
+
|
|
94
|
+
rows = list(range(100)) # pretend this is a table
|
|
95
|
+
args = ConnectionArguments(first=10, after="YXJyYXljb25uZWN0aW9uOjQ=") # offset 4
|
|
96
|
+
|
|
97
|
+
offset = cursor_to_offset(args.after) + 1 if args.after else 0
|
|
98
|
+
window = rows[offset : offset + args.first] # SELECT ... OFFSET :offset LIMIT :first
|
|
99
|
+
|
|
100
|
+
conn = connection_from_sequence_slice(
|
|
101
|
+
window, args, slice_start=offset, sequence_length=len(rows), include_total_count=True
|
|
102
|
+
)
|
|
103
|
+
assert conn.nodes == list(range(5, 15))
|
|
104
|
+
assert conn.page_info.has_next_page and conn.total_count == 100
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## API overview
|
|
108
|
+
|
|
109
|
+
Everything below can be imported from the top-level `pyrelay` package.
|
|
110
|
+
|
|
111
|
+
### Global IDs (`pyrelay.ids`)
|
|
112
|
+
|
|
113
|
+
| Name | Description |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| `to_global_id(type_name, local_id) -> str` | Returns `base64("Type:id")`. Raises `ValueError` if `type_name` is empty or contains `:`. |
|
|
116
|
+
| `from_global_id(global_id) -> ResolvedGlobalId` | Decodes an ID. Raises `InvalidGlobalIdError` on malformed input. |
|
|
117
|
+
| `ResolvedGlobalId` | A `NamedTuple` with fields `type: str` and `id: str`. |
|
|
118
|
+
|
|
119
|
+
### Cursors (`pyrelay.cursors`)
|
|
120
|
+
|
|
121
|
+
| Name | Description |
|
|
122
|
+
| --- | --- |
|
|
123
|
+
| `offset_to_cursor(offset) -> str` | Returns the opaque cursor `base64("arrayconnection:N")`. |
|
|
124
|
+
| `cursor_to_offset(cursor) -> int` | Inverse of `offset_to_cursor`. Raises `InvalidCursorError`. |
|
|
125
|
+
| `cursor_for_object_in_sequence(data, obj) -> str \| None` | Returns the cursor of the first item equal to `obj`. |
|
|
126
|
+
|
|
127
|
+
### Connections (`pyrelay.connection`)
|
|
128
|
+
|
|
129
|
+
| Name | Description |
|
|
130
|
+
| --- | --- |
|
|
131
|
+
| `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. |
|
|
132
|
+
| `connection_from_sequence(data, args=None, *, include_total_count=False) -> Connection` | Paginates an in-memory sequence. |
|
|
133
|
+
| `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. |
|
|
134
|
+
| `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)`. |
|
|
135
|
+
| `Edge` | Frozen dataclass with `node` and `cursor: str`, plus `.to_dict(serialize_node=None)`. |
|
|
136
|
+
| `PageInfo` | Frozen dataclass with `has_previous_page`, `has_next_page`, `start_cursor` and `end_cursor`, plus `.to_dict()`. |
|
|
137
|
+
|
|
138
|
+
As in `graphql-relay-js`, `has_previous_page` is computed only when `last` is
|
|
139
|
+
given, and `has_next_page` only when `first` is given. Otherwise they are `False`.
|
|
140
|
+
|
|
141
|
+
### Node registry (`pyrelay.node`)
|
|
142
|
+
|
|
143
|
+
| Name | Description |
|
|
144
|
+
| --- | --- |
|
|
145
|
+
| `NodeRegistry()` | Registry of `type_name -> resolver(local_id, *args, **kwargs)`. |
|
|
146
|
+
| `.register(type_name, resolver=None)` | Registers a resolver. It can be used as a decorator. Raises `ValueError` on a duplicate or invalid name. |
|
|
147
|
+
| `.unregister(type_name)` | Removes a resolver. Raises `KeyError` if the type is not registered. |
|
|
148
|
+
| `.resolve(global_id, *args, **kwargs)` | Calls the resolver and returns its result. Returns `None` for an unregistered type. Raises `InvalidGlobalIdError` for a malformed ID. |
|
|
149
|
+
| `.global_id(type_name, local_id) -> str` | Like `to_global_id`, but raises `KeyError` for an unregistered type. |
|
|
150
|
+
| `.types`, `type_name in registry` | Introspection. |
|
|
151
|
+
|
|
152
|
+
### Mutations (`pyrelay.mutation`)
|
|
153
|
+
|
|
154
|
+
| Name | Description |
|
|
155
|
+
| --- | --- |
|
|
156
|
+
| `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. |
|
|
157
|
+
| `CLIENT_MUTATION_ID` | The constant `"clientMutationId"`. |
|
|
158
|
+
|
|
159
|
+
### Errors (`pyrelay.errors`)
|
|
160
|
+
|
|
161
|
+
| Name | Description |
|
|
162
|
+
| --- | --- |
|
|
163
|
+
| `RelayError` | Base class of all pyrelay exceptions. |
|
|
164
|
+
| `InvalidGlobalIdError` | Subclass of `RelayError` and `ValueError`. |
|
|
165
|
+
| `InvalidCursorError` | Subclass of `RelayError` and `ValueError`. |
|
|
166
|
+
| `ConnectionArgumentError` | Subclass of `RelayError` and `ValueError`. |
|
|
167
|
+
|
|
168
|
+
`__version__` holds the package version string (`"0.1.0"`).
|
|
169
|
+
|
|
170
|
+
## Development
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
python3 -m venv .venv && . .venv/bin/activate
|
|
174
|
+
pip install -e '.[test]'
|
|
175
|
+
python -m pytest # or, without pytest:
|
|
176
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The test suite also runs every Python block in this README and checks that
|
|
180
|
+
each public name is documented here, so the docs and code stay in sync.
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyrelay"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Relay-spec GraphQL helpers for Python: global object IDs, cursor connections, node registry, and mutation payloads. Framework-agnostic, zero dependencies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "nehz" }]
|
|
14
|
+
keywords = ["graphql", "relay", "pagination", "cursor", "connection", "global-id"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
dependencies = []
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
test = ["pytest>=7"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.package-data]
|
|
39
|
+
pyrelay = ["py.typed"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
pythonpath = ["src"]
|
pyrelay-0.1.0/setup.cfg
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
|
+
]
|