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 +57 -0
- pyrelay/connection.py +212 -0
- pyrelay/cursors.py +63 -0
- pyrelay/errors.py +26 -0
- pyrelay/ids.py +66 -0
- pyrelay/mutation.py +75 -0
- pyrelay/node.py +92 -0
- pyrelay/py.typed +0 -0
- pyrelay-0.1.0.dist-info/METADATA +211 -0
- pyrelay-0.1.0.dist-info/RECORD +13 -0
- pyrelay-0.1.0.dist-info/WHEEL +5 -0
- pyrelay-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyrelay-0.1.0.dist-info/top_level.txt +1 -0
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,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
|