graphql-codegen 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.
- graphql_codegen/__init__.py +7 -0
- graphql_codegen/__main__.py +5 -0
- graphql_codegen/_cli/__init__.py +41 -0
- graphql_codegen/_cli/_graphql_config.py +291 -0
- graphql_codegen/_cli/_introspection.py +87 -0
- graphql_codegen/_cli/_introspection_graphql.py +53 -0
- graphql_codegen/_cli/_parsing.py +67 -0
- graphql_codegen/_cli/_schema_pointer.py +108 -0
- graphql_codegen/_cli/_source.py +68 -0
- graphql_codegen/_cli/_write.py +22 -0
- graphql_codegen/_generator/__init__.py +0 -0
- graphql_codegen/_generator/_annotation.py +149 -0
- graphql_codegen/_generator/_ast_nodes.py +235 -0
- graphql_codegen/_generator/_data_type.py +532 -0
- graphql_codegen/_generator/_document.py +108 -0
- graphql_codegen/_generator/_document_module.py +54 -0
- graphql_codegen/_generator/_imports.py +166 -0
- graphql_codegen/_generator/_injector.py +267 -0
- graphql_codegen/_generator/_merge.py +149 -0
- graphql_codegen/_generator/_naming.py +80 -0
- graphql_codegen/_generator/_operation.py +223 -0
- graphql_codegen/_generator/_scalar.py +76 -0
- graphql_codegen/_generator/_schema.py +46 -0
- graphql_codegen/_generator/_schema_type.py +334 -0
- graphql_codegen/_generator/_selection.py +246 -0
- graphql_codegen/_generator/_structs.py +87 -0
- graphql_codegen/_generator/_typed_dict.py +154 -0
- graphql_codegen/_generator/dotted_name.py +48 -0
- graphql_codegen/_generator/package.py +695 -0
- graphql_codegen/_generator/spelling.py +168 -0
- graphql_codegen/_metadata.py +11 -0
- graphql_codegen/_note.py +12 -0
- graphql_codegen/config.py +42 -0
- graphql_codegen/document_sibling_module.py +58 -0
- graphql_codegen/generate.py +19 -0
- graphql_codegen/package_location.py +41 -0
- graphql_codegen/py.typed +0 -0
- graphql_codegen/runtime/__init__.py +20 -0
- graphql_codegen/runtime/_compat.py +27 -0
- graphql_codegen/runtime/_literal.py +18 -0
- graphql_codegen/runtime/_merge.py +153 -0
- graphql_codegen/runtime/_prepare.py +250 -0
- graphql_codegen/runtime/_reflection.py +376 -0
- graphql_codegen/runtime/_sigil.py +11 -0
- graphql_codegen/runtime/_transport.py +21 -0
- graphql_codegen/runtime/client.py +384 -0
- graphql_codegen/runtime/error.py +135 -0
- graphql_codegen/runtime/injection.py +107 -0
- graphql_codegen/runtime/operation.py +135 -0
- graphql_codegen/scalar.py +59 -0
- graphql_codegen-0.1.0.dist-info/METADATA +871 -0
- graphql_codegen-0.1.0.dist-info/RECORD +55 -0
- graphql_codegen-0.1.0.dist-info/WHEEL +4 -0
- graphql_codegen-0.1.0.dist-info/entry_points.txt +3 -0
- graphql_codegen-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from collections.abc import Collection, Iterator, Mapping, Sequence
|
|
3
|
+
from functools import cache
|
|
4
|
+
from itertools import count
|
|
5
|
+
from typing import Final, Self, assert_never, final
|
|
6
|
+
|
|
7
|
+
_MARK: Final = "\x00"
|
|
8
|
+
"""What starts a placeholder, and separates its creation counter from its preferred name: NUL, which no identifier holds and no text starts with, and which Python refuses in source, so that a placeholder left in a name fails to compile rather than go unnoticed."""
|
|
9
|
+
|
|
10
|
+
_counter: Final = count()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@cache
|
|
14
|
+
def is_bindable(name: str, /) -> bool:
|
|
15
|
+
if not name.isidentifier():
|
|
16
|
+
return False
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
# Asking Python itself instead of checking against a list of exceptions that could go stale.
|
|
20
|
+
compile(f"{name} = None", "<name>", "exec")
|
|
21
|
+
except SyntaxError:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@final
|
|
28
|
+
class PendingSpelling:
|
|
29
|
+
"""Nothing prevents a schema or a document from using any name the generator could pick in advance, so none is safe.
|
|
30
|
+
|
|
31
|
+
An emitter therefore never writes a name it adds: it makes a pending spelling of the name it would prefer, and uses it wherever the name goes, its placeholder holding the place.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, preferred: str, /) -> None:
|
|
35
|
+
assert preferred.isidentifier(), f"Expected `{preferred}` to be an identifier."
|
|
36
|
+
# A class body would mangle a name starting with two underscores, unless it also ends with two, as `_GetData`'s nested `__GetData_x` would be when an operation is named `_Get`.
|
|
37
|
+
unmangled = (
|
|
38
|
+
f"_{preferred.lstrip('_')}"
|
|
39
|
+
if preferred.startswith("__") and not preferred.endswith("__")
|
|
40
|
+
else preferred
|
|
41
|
+
)
|
|
42
|
+
self._placeholder: Final = f"{_MARK}{next(_counter)}{_MARK}{unmangled}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@final
|
|
46
|
+
class SettledSpelling(str):
|
|
47
|
+
__slots__ = ()
|
|
48
|
+
|
|
49
|
+
def __new__(cls, value: str, /) -> Self:
|
|
50
|
+
assert is_bindable(value), f"Expected Python to be able to bind `{value}`."
|
|
51
|
+
return super().__new__(cls, value)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
type Spelling = PendingSpelling | SettledSpelling
|
|
55
|
+
"""How generated code writes a name.
|
|
56
|
+
|
|
57
|
+
- :class:`PendingSpelling`: added by the generator, so decided by :func:`settle_module` once its module is complete.
|
|
58
|
+
- :class:`SettledSpelling`: known up front, such as a public one, so decided at once by :func:`settle_names`.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def ast_str(spelling: Spelling, /) -> str:
|
|
63
|
+
match spelling:
|
|
64
|
+
case SettledSpelling():
|
|
65
|
+
return spelling
|
|
66
|
+
case PendingSpelling():
|
|
67
|
+
return spelling._placeholder
|
|
68
|
+
case _ as never:
|
|
69
|
+
assert_never(never)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_placeholder(value: str, /) -> bool:
|
|
73
|
+
return value.startswith(_MARK)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _preferred(placeholder: str, /) -> str:
|
|
77
|
+
return placeholder.rsplit(_MARK, 1)[1]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _strings(node: ast.AST, /) -> Iterator[str]:
|
|
81
|
+
for _, value in ast.iter_fields(node):
|
|
82
|
+
values = value if isinstance(value, list) else [value]
|
|
83
|
+
|
|
84
|
+
for item in values:
|
|
85
|
+
if isinstance(item, str):
|
|
86
|
+
yield item
|
|
87
|
+
elif isinstance(item, ast.AST):
|
|
88
|
+
if isinstance(item, ast.Constant) and isinstance(item.value, str):
|
|
89
|
+
yield item.value
|
|
90
|
+
else:
|
|
91
|
+
yield from _strings(item)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _identifiers(module: ast.Module, /) -> Iterator[str]:
|
|
95
|
+
for child in ast.walk(module):
|
|
96
|
+
match child:
|
|
97
|
+
case ast.Name(id=bound):
|
|
98
|
+
pass
|
|
99
|
+
case ast.alias(name=imported, asname=alias):
|
|
100
|
+
bound = alias or imported.split(".")[0]
|
|
101
|
+
case ast.ClassDef(name=bound) | ast.FunctionDef(name=bound):
|
|
102
|
+
pass
|
|
103
|
+
case _:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
if not _is_placeholder(bound):
|
|
107
|
+
yield bound
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _rewrite(module: ast.Module, spellings: Mapping[str, SettledSpelling], /) -> None:
|
|
111
|
+
# Lists of identifiers, as `global`, `nonlocal`, and keyword class patterns hold, are left: generated code writes none.
|
|
112
|
+
for node in ast.walk(module):
|
|
113
|
+
for field, value in ast.iter_fields(node):
|
|
114
|
+
if isinstance(value, str) and value in spellings:
|
|
115
|
+
setattr(node, field, spellings[value])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def settle_module(module: ast.Module, /) -> None:
|
|
119
|
+
"""The other names are fixed, since they come from GraphQL, so a clash is impossible rather than detected.
|
|
120
|
+
|
|
121
|
+
Collecting them widely, every name loaded, stored, or imported anywhere in the module, class body keys included, is what makes that hold without reasoning about scopes.
|
|
122
|
+
"""
|
|
123
|
+
fixed = set(_identifiers(module))
|
|
124
|
+
# In creation order, which the counter after the first mark records.
|
|
125
|
+
placeholders = sorted(
|
|
126
|
+
{value for value in _strings(module) if _is_placeholder(value)},
|
|
127
|
+
key=lambda placeholder: int(placeholder.split(_MARK)[1]),
|
|
128
|
+
)
|
|
129
|
+
spellings: dict[str, SettledSpelling] = {}
|
|
130
|
+
|
|
131
|
+
for placeholder in placeholders:
|
|
132
|
+
spelling = next(
|
|
133
|
+
candidate
|
|
134
|
+
for candidate in candidates(_preferred(placeholder))
|
|
135
|
+
if candidate not in fixed
|
|
136
|
+
)
|
|
137
|
+
fixed.add(spelling)
|
|
138
|
+
spellings[placeholder] = spelling
|
|
139
|
+
|
|
140
|
+
_rewrite(module, spellings)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def candidates(name: str, /) -> Iterator[SettledSpelling]:
|
|
144
|
+
yield SettledSpelling(name if is_bindable(name) else f"{name}_")
|
|
145
|
+
yield from (SettledSpelling(f"{name}_{suffix}") for suffix in count(1))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def settle_names(
|
|
149
|
+
identifiers: Sequence[str], /, *, taken: Collection[SettledSpelling]
|
|
150
|
+
) -> dict[str, SettledSpelling]:
|
|
151
|
+
assert all(identifier.isidentifier() for identifier in identifiers), (
|
|
152
|
+
f"Expected only identifiers, but got {identifiers}."
|
|
153
|
+
)
|
|
154
|
+
used = set(taken)
|
|
155
|
+
spellings: dict[str, SettledSpelling] = {}
|
|
156
|
+
|
|
157
|
+
for name in sorted(
|
|
158
|
+
identifiers,
|
|
159
|
+
# Names that can be bound as they are come first, so a renamed one never takes the name of a definition spelled that way.
|
|
160
|
+
key=lambda name: not is_bindable(name),
|
|
161
|
+
):
|
|
162
|
+
spelling = next(
|
|
163
|
+
candidate for candidate in candidates(name) if candidate not in used
|
|
164
|
+
)
|
|
165
|
+
used.add(spelling)
|
|
166
|
+
spellings[name] = spelling
|
|
167
|
+
|
|
168
|
+
return {name: spellings[name] for name in identifiers}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from importlib.metadata import metadata
|
|
2
|
+
from typing import Final
|
|
3
|
+
|
|
4
|
+
METADATA: Final = metadata(__name__.partition(".")[0])
|
|
5
|
+
"""This library's metadata, which `pyproject.toml` declares."""
|
|
6
|
+
|
|
7
|
+
DISTRIBUTION_NAME: Final = METADATA["Name"]
|
|
8
|
+
"""This library's name, which only `pyproject.toml` spells."""
|
|
9
|
+
|
|
10
|
+
# Inspired by https://github.com/github-linguist/linguist/blob/5fbdfcb8133be2bed88bf3ce62b2335f50474525/lib/linguist/generated.rb#L345.
|
|
11
|
+
GENERATED_FILE_COMMENT_PREFIX: Final = f"# Code generated by {DISTRIBUTION_NAME}"
|
graphql_codegen/_note.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@contextmanager
|
|
6
|
+
def error_note(note: str, /) -> Iterator[None]:
|
|
7
|
+
"""Add *note* to an error raised inside, so that a function need not know where its caller found what it checks."""
|
|
8
|
+
try:
|
|
9
|
+
yield
|
|
10
|
+
except Exception as error:
|
|
11
|
+
error.add_note(note)
|
|
12
|
+
raise
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from collections.abc import Collection, Mapping
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import final
|
|
4
|
+
|
|
5
|
+
from graphql import GraphQLError, assert_name
|
|
6
|
+
|
|
7
|
+
from graphql_codegen.document_sibling_module import DocumentSiblingModule
|
|
8
|
+
from graphql_codegen.scalar import Scalar
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@final
|
|
12
|
+
@dataclass(frozen=True, kw_only=True)
|
|
13
|
+
class Config:
|
|
14
|
+
document_sibling_module: DocumentSiblingModule | None = None
|
|
15
|
+
"""When ``None``, each document's module is named after the document, in the generated package's `document` subpackage, which re-exports every operation and fragment."""
|
|
16
|
+
|
|
17
|
+
injector_names: Collection[str] = frozenset()
|
|
18
|
+
"""The names of the variables and input fields an injector supplies, left out of the generated types and injected wherever an operation holds them.
|
|
19
|
+
|
|
20
|
+
Every variable and input field of a same name must have the same type, nullability aside.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
non_null_directive_name: str | None = None
|
|
24
|
+
"""The name of the client directive asserting that a nullable field is not null for this operation, stripped before a document goes on the wire."""
|
|
25
|
+
|
|
26
|
+
scalars: Mapping[str, Scalar] = field(default_factory=dict)
|
|
27
|
+
"""The Python type of each scalar by GraphQL name."""
|
|
28
|
+
|
|
29
|
+
struct_interface_name: str | None = None
|
|
30
|
+
"""The name of the interface every struct implements: an object type whose single field, of a custom scalar type, carries a JSON payload.
|
|
31
|
+
|
|
32
|
+
The payload is shaped like the input type named as the struct without the interface's name, such as ``"BookFilter"`` for ``f"BookFilter{struct_interface_name}"``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
if self.non_null_directive_name is not None:
|
|
37
|
+
try:
|
|
38
|
+
assert_name(self.non_null_directive_name)
|
|
39
|
+
except GraphQLError as error:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"Cannot name the client directive `{self.non_null_directive_name}`: {error.message}"
|
|
42
|
+
) from error
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from functools import cached_property
|
|
3
|
+
from pathlib import PurePosixPath
|
|
4
|
+
from typing import final
|
|
5
|
+
|
|
6
|
+
from graphql_codegen.package_location import PackageLocation
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@final
|
|
10
|
+
@dataclass(frozen=True, kw_only=True)
|
|
11
|
+
class DocumentSiblingModule:
|
|
12
|
+
"""The module holding a document's operations and fragments, in the document's directory.
|
|
13
|
+
|
|
14
|
+
The schema types they reference deliberately stay in the generated package, so that their import paths never depend on which documents use them.
|
|
15
|
+
A fragment spread from another document is imported from its own document's module.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
name: str
|
|
19
|
+
"""Its name, such as ``_{document}_gql``, in which ``{document}`` is the document's file stem, the whole then settled as a module name."""
|
|
20
|
+
|
|
21
|
+
package_location: PackageLocation
|
|
22
|
+
"""Where the package lives, which the module imports it from."""
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
# Parsed eagerly, so that no sibling module holds a name it cannot use.
|
|
26
|
+
_ = self._affixes
|
|
27
|
+
|
|
28
|
+
@cached_property
|
|
29
|
+
def _affixes(self) -> tuple[str, str]:
|
|
30
|
+
"""What the name puts ``(before, after)`` ``{document}``."""
|
|
31
|
+
before, placeholder, after = self.name.partition("{document}")
|
|
32
|
+
|
|
33
|
+
if (
|
|
34
|
+
not placeholder
|
|
35
|
+
or "{document}" in after
|
|
36
|
+
or not f"{before}x{after}".isidentifier()
|
|
37
|
+
):
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"Expected the module's name to be an identifier holding `{{document}}` once, such as `_{{document}}_gql`, but got `{self.name}`."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return before, after
|
|
43
|
+
|
|
44
|
+
def _module_name(self, document: PurePosixPath, /) -> str:
|
|
45
|
+
"""The name of *document*'s module, before it is settled as a module name.
|
|
46
|
+
|
|
47
|
+
An underscore the name puts next to ``{document}`` merges with the stem's own.
|
|
48
|
+
"""
|
|
49
|
+
before, after = self._affixes
|
|
50
|
+
stem = document.stem
|
|
51
|
+
|
|
52
|
+
# `_{document}_gql` with the stem `_cube_` makes `_cube_gql`, not `__cube__gql`.
|
|
53
|
+
if before.endswith("_"):
|
|
54
|
+
stem = stem.lstrip("_")
|
|
55
|
+
if after.startswith("_"):
|
|
56
|
+
stem = stem.rstrip("_")
|
|
57
|
+
|
|
58
|
+
return f"{before}{stem}{after}"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from pathlib import PurePosixPath
|
|
2
|
+
from typing import Unpack
|
|
3
|
+
|
|
4
|
+
from graphql import DocumentNode, GraphQLSchema
|
|
5
|
+
|
|
6
|
+
from graphql_codegen._generator.package import PackageEmitter
|
|
7
|
+
from graphql_codegen.config import Config
|
|
8
|
+
from graphql_codegen.runtime._compat import TypedDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _Params(TypedDict, closed=True):
|
|
12
|
+
document: DocumentNode
|
|
13
|
+
schema: GraphQLSchema
|
|
14
|
+
config: Config
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def generate(**args: Unpack[_Params]) -> dict[PurePosixPath, bytes]:
|
|
18
|
+
"""Pure function returning the content of each file of the generated package by its path."""
|
|
19
|
+
return PackageEmitter(**args).emit()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import posixpath
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from graphql_codegen._generator.spelling import is_bindable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@final
|
|
11
|
+
@dataclass(frozen=True, kw_only=True)
|
|
12
|
+
class PackageLocation:
|
|
13
|
+
"""Where the generated package lives."""
|
|
14
|
+
|
|
15
|
+
module_root: PurePosixPath
|
|
16
|
+
"""Where the package's top-level package starts."""
|
|
17
|
+
|
|
18
|
+
package: str
|
|
19
|
+
"""The package's dotted name, which a module outside it imports it by."""
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
# Parsed eagerly, so that no location holds a package it cannot use.
|
|
23
|
+
_ = self._parts
|
|
24
|
+
|
|
25
|
+
@cached_property
|
|
26
|
+
def _parts(self) -> tuple[str, ...]:
|
|
27
|
+
"""The names the package's dotted name holds."""
|
|
28
|
+
parts = tuple(self.package.split("."))
|
|
29
|
+
|
|
30
|
+
if not all(is_bindable(part) for part in parts):
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"Expected the package to be a dotted name, such as `my_app._graphql`, but got `{self.package}`."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return parts
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def _package_directory(self) -> PurePosixPath:
|
|
39
|
+
return PurePosixPath(
|
|
40
|
+
posixpath.normpath(self.module_root.joinpath(*self._parts))
|
|
41
|
+
)
|
graphql_codegen/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Copied into each generated package, where this line says not to edit it.
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
AsyncClient as AsyncClient,
|
|
5
|
+
AsyncSubscriptionClient as AsyncSubscriptionClient,
|
|
6
|
+
Client as Client,
|
|
7
|
+
SubscriptionClient as SubscriptionClient,
|
|
8
|
+
)
|
|
9
|
+
from .error import (
|
|
10
|
+
ClientError as ClientError,
|
|
11
|
+
Error as Error,
|
|
12
|
+
ExecutionError as ExecutionError,
|
|
13
|
+
Location as Location,
|
|
14
|
+
ProtocolError as ProtocolError,
|
|
15
|
+
RequestError as RequestError,
|
|
16
|
+
ResponseError as ResponseError,
|
|
17
|
+
UnexpectedNullError as UnexpectedNullError,
|
|
18
|
+
)
|
|
19
|
+
from .injection import OMITTED as OMITTED
|
|
20
|
+
from .operation import Operation as Operation, Request as Request
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Copied into each generated package, where this line says not to edit it.
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
# Closed TypedDicts (PEP 728) and sentinels (PEP 661) are the last to reach the standard library.
|
|
6
|
+
if sys.version_info >= (3, 15):
|
|
7
|
+
from typing import (
|
|
8
|
+
ReadOnly as ReadOnly,
|
|
9
|
+
TypedDict as TypedDict,
|
|
10
|
+
TypeVar as TypeVar,
|
|
11
|
+
get_type_hints as get_type_hints,
|
|
12
|
+
is_typeddict as is_typeddict,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# The class of what the builtin `sentinel()` returns.
|
|
16
|
+
SentinelType = sentinel # noqa: F821
|
|
17
|
+
else:
|
|
18
|
+
from typing_extensions import (
|
|
19
|
+
ReadOnly as ReadOnly,
|
|
20
|
+
Sentinel,
|
|
21
|
+
TypedDict as TypedDict,
|
|
22
|
+
TypeVar as TypeVar,
|
|
23
|
+
get_type_hints as get_type_hints,
|
|
24
|
+
is_typeddict as is_typeddict,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
SentinelType = Sentinel
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Copied into each generated package, where this line says not to edit it.
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
type OperationType = Literal["query", "mutation", "subscription"]
|
|
6
|
+
"""The spec's `OperationType`.
|
|
7
|
+
|
|
8
|
+
- ``"query"`` reads, and a client may run several at once.
|
|
9
|
+
- ``"mutation"`` writes, and the server runs the root fields of several in order.
|
|
10
|
+
- ``"subscription"`` answers with a stream of responses rather than one, which only a subscription client runs.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
type ExecutionErrorHandling = Literal["raise", "return"]
|
|
14
|
+
"""What a client does with the :class:`~.error.ExecutionError` of the response to a request.
|
|
15
|
+
|
|
16
|
+
- ``"raise"`` raises it, so that what a call returns is always complete data.
|
|
17
|
+
- ``"return"`` returns it in place of the request's data, typed by the request, for a caller who can use partial data.
|
|
18
|
+
"""
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Copied into each generated package, where this line says not to edit it.
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from functools import cache
|
|
5
|
+
from json import dumps
|
|
6
|
+
from typing import Any, Final
|
|
7
|
+
|
|
8
|
+
from ._literal import OperationType
|
|
9
|
+
from ._sigil import SIGIL
|
|
10
|
+
from .error import Error
|
|
11
|
+
from .operation import Operation
|
|
12
|
+
|
|
13
|
+
MERGED_OPERATION_NAME: Final = "MergedOperation"
|
|
14
|
+
|
|
15
|
+
_INDEX_SEPARATOR: Final = "_"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@cache
|
|
19
|
+
def _template_parts(document: str, /) -> tuple[str, str, Mapping[str, str]]:
|
|
20
|
+
"""The generator prints a template like any document: the operation first, its header on one line, then its root fields, then each fragment after a blank line.
|
|
21
|
+
|
|
22
|
+
Cached: the documents of a generated package are few and fixed.
|
|
23
|
+
"""
|
|
24
|
+
operation, *fragment_definitions = document.split("\n\n")
|
|
25
|
+
header, *root_selections, _ = operation.split("\n")
|
|
26
|
+
# An operation holding the sigil has no directives, so its header ends with its variable definitions, if any.
|
|
27
|
+
variable_definitions = (
|
|
28
|
+
header[header.index("(") + 1 : header.rindex(")")] if "(" in header else ""
|
|
29
|
+
)
|
|
30
|
+
fragments = {
|
|
31
|
+
definition.split(" ", 2)[1]: definition for definition in fragment_definitions
|
|
32
|
+
}
|
|
33
|
+
return variable_definitions, "\n".join(root_selections), fragments
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def encode_body(
|
|
37
|
+
parts: Sequence[tuple[Operation[OperationType, Any, object], Mapping[str, object]]],
|
|
38
|
+
/,
|
|
39
|
+
) -> bytes:
|
|
40
|
+
"""Merging lets a server see several operations at once, such as mutations it can apply in one step, and saves round trips.
|
|
41
|
+
|
|
42
|
+
Each operation's variables and root fields are suffixed with its index in the merge, so that the operations cannot collide.
|
|
43
|
+
The generator writes a sigil wherever the index goes, so suffixing is plain string replacement.
|
|
44
|
+
|
|
45
|
+
A lone operation is sent as is, so that the server knows it by its own name.
|
|
46
|
+
"""
|
|
47
|
+
if not parts:
|
|
48
|
+
raise ValueError("Cannot merge no operation.")
|
|
49
|
+
|
|
50
|
+
if len(parts) == 1:
|
|
51
|
+
((operation, operation_variables),) = parts
|
|
52
|
+
return operation._request_body(operation_variables)
|
|
53
|
+
|
|
54
|
+
operation_types = {operation._operation_type for operation, _ in parts}
|
|
55
|
+
|
|
56
|
+
if len(operation_types) != 1:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"Cannot merge operations of different types: {', '.join(sorted(operation_types))}.",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
(operation_type,) = operation_types
|
|
62
|
+
variable_definitions: list[str] = []
|
|
63
|
+
selections: list[str] = []
|
|
64
|
+
fragments: dict[str, str] = {}
|
|
65
|
+
variables: dict[str, object] = {}
|
|
66
|
+
|
|
67
|
+
for index, (operation, operation_variables) in enumerate(parts):
|
|
68
|
+
if SIGIL not in operation._document:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"Cannot merge `{operation._name}`: its document has no `{SIGIL}` where its index would go."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
definitions, root_selections, operation_fragments = _template_parts(
|
|
74
|
+
operation._document
|
|
75
|
+
)
|
|
76
|
+
suffix = f"{_INDEX_SEPARATOR}{index}"
|
|
77
|
+
|
|
78
|
+
if definitions:
|
|
79
|
+
variable_definitions.append(definitions.replace(SIGIL, suffix))
|
|
80
|
+
|
|
81
|
+
selections.append(root_selections.replace(SIGIL, suffix))
|
|
82
|
+
fragments.update(operation_fragments)
|
|
83
|
+
variables.update(
|
|
84
|
+
{f"{name}{suffix}": value for name, value in operation_variables.items()}
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
signature = f"({', '.join(variable_definitions)})" if variable_definitions else ""
|
|
88
|
+
document = "\n\n".join(
|
|
89
|
+
[
|
|
90
|
+
"\n".join(
|
|
91
|
+
[
|
|
92
|
+
f"{operation_type} {MERGED_OPERATION_NAME}{signature} {{",
|
|
93
|
+
*selections,
|
|
94
|
+
"}",
|
|
95
|
+
]
|
|
96
|
+
),
|
|
97
|
+
*fragments.values(),
|
|
98
|
+
],
|
|
99
|
+
)
|
|
100
|
+
return dumps(
|
|
101
|
+
{
|
|
102
|
+
"operationName": MERGED_OPERATION_NAME,
|
|
103
|
+
"query": document,
|
|
104
|
+
"variables": variables,
|
|
105
|
+
},
|
|
106
|
+
).encode()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _split_response_name(key: str, /) -> tuple[str, int]:
|
|
110
|
+
response_name, index = key.rsplit(_INDEX_SEPARATOR, 1)
|
|
111
|
+
return response_name, int(index)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def split_merged_data(
|
|
115
|
+
data: Mapping[str, object], /, *, count: int
|
|
116
|
+
) -> Sequence[Mapping[str, object]]:
|
|
117
|
+
"""Preallocated, so that an operation whose every root field `@skip` left out still gets its own, empty, data rather than shifting the others'."""
|
|
118
|
+
if count == 1:
|
|
119
|
+
return [data]
|
|
120
|
+
|
|
121
|
+
split: list[dict[str, object]] = [{} for _ in range(count)]
|
|
122
|
+
|
|
123
|
+
for key, value in data.items():
|
|
124
|
+
response_name, index = _split_response_name(key)
|
|
125
|
+
split[index][response_name] = value
|
|
126
|
+
|
|
127
|
+
return split
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def split_merged_errors(
|
|
131
|
+
errors: Sequence[Error], /, *, count: int
|
|
132
|
+
) -> Sequence[Sequence[Error]]:
|
|
133
|
+
"""An error's path starts with the suffixed response name of the root field it concerns, which tells its operation.
|
|
134
|
+
|
|
135
|
+
An error without a path concerns them all.
|
|
136
|
+
"""
|
|
137
|
+
if count == 1:
|
|
138
|
+
return [errors]
|
|
139
|
+
|
|
140
|
+
split: list[list[Error]] = [[] for _ in range(count)]
|
|
141
|
+
|
|
142
|
+
for error in errors:
|
|
143
|
+
path = error.get("path")
|
|
144
|
+
|
|
145
|
+
if not path or not isinstance(path[0], str):
|
|
146
|
+
for operation_errors in split:
|
|
147
|
+
operation_errors.append(error)
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
response_name, index = _split_response_name(path[0])
|
|
151
|
+
split[index].append({**error, "path": [response_name, *path[1:]]})
|
|
152
|
+
|
|
153
|
+
return split
|