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.
Files changed (55) hide show
  1. graphql_codegen/__init__.py +7 -0
  2. graphql_codegen/__main__.py +5 -0
  3. graphql_codegen/_cli/__init__.py +41 -0
  4. graphql_codegen/_cli/_graphql_config.py +291 -0
  5. graphql_codegen/_cli/_introspection.py +87 -0
  6. graphql_codegen/_cli/_introspection_graphql.py +53 -0
  7. graphql_codegen/_cli/_parsing.py +67 -0
  8. graphql_codegen/_cli/_schema_pointer.py +108 -0
  9. graphql_codegen/_cli/_source.py +68 -0
  10. graphql_codegen/_cli/_write.py +22 -0
  11. graphql_codegen/_generator/__init__.py +0 -0
  12. graphql_codegen/_generator/_annotation.py +149 -0
  13. graphql_codegen/_generator/_ast_nodes.py +235 -0
  14. graphql_codegen/_generator/_data_type.py +532 -0
  15. graphql_codegen/_generator/_document.py +108 -0
  16. graphql_codegen/_generator/_document_module.py +54 -0
  17. graphql_codegen/_generator/_imports.py +166 -0
  18. graphql_codegen/_generator/_injector.py +267 -0
  19. graphql_codegen/_generator/_merge.py +149 -0
  20. graphql_codegen/_generator/_naming.py +80 -0
  21. graphql_codegen/_generator/_operation.py +223 -0
  22. graphql_codegen/_generator/_scalar.py +76 -0
  23. graphql_codegen/_generator/_schema.py +46 -0
  24. graphql_codegen/_generator/_schema_type.py +334 -0
  25. graphql_codegen/_generator/_selection.py +246 -0
  26. graphql_codegen/_generator/_structs.py +87 -0
  27. graphql_codegen/_generator/_typed_dict.py +154 -0
  28. graphql_codegen/_generator/dotted_name.py +48 -0
  29. graphql_codegen/_generator/package.py +695 -0
  30. graphql_codegen/_generator/spelling.py +168 -0
  31. graphql_codegen/_metadata.py +11 -0
  32. graphql_codegen/_note.py +12 -0
  33. graphql_codegen/config.py +42 -0
  34. graphql_codegen/document_sibling_module.py +58 -0
  35. graphql_codegen/generate.py +19 -0
  36. graphql_codegen/package_location.py +41 -0
  37. graphql_codegen/py.typed +0 -0
  38. graphql_codegen/runtime/__init__.py +20 -0
  39. graphql_codegen/runtime/_compat.py +27 -0
  40. graphql_codegen/runtime/_literal.py +18 -0
  41. graphql_codegen/runtime/_merge.py +153 -0
  42. graphql_codegen/runtime/_prepare.py +250 -0
  43. graphql_codegen/runtime/_reflection.py +376 -0
  44. graphql_codegen/runtime/_sigil.py +11 -0
  45. graphql_codegen/runtime/_transport.py +21 -0
  46. graphql_codegen/runtime/client.py +384 -0
  47. graphql_codegen/runtime/error.py +135 -0
  48. graphql_codegen/runtime/injection.py +107 -0
  49. graphql_codegen/runtime/operation.py +135 -0
  50. graphql_codegen/scalar.py +59 -0
  51. graphql_codegen-0.1.0.dist-info/METADATA +871 -0
  52. graphql_codegen-0.1.0.dist-info/RECORD +55 -0
  53. graphql_codegen-0.1.0.dist-info/WHEEL +4 -0
  54. graphql_codegen-0.1.0.dist-info/entry_points.txt +3 -0
  55. 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}"
@@ -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
+ )
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