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,246 @@
1
+ from collections.abc import Mapping, Sequence
2
+ from dataclasses import dataclass
3
+ from typing import Final, cast, final
4
+
5
+ from graphql import (
6
+ BooleanValueNode,
7
+ DirectiveNode,
8
+ FieldNode,
9
+ FragmentDefinitionNode,
10
+ FragmentSpreadNode,
11
+ GraphQLCompositeType,
12
+ GraphQLIncludeDirective,
13
+ GraphQLOutputType,
14
+ GraphQLSchema,
15
+ GraphQLSkipDirective,
16
+ InlineFragmentNode,
17
+ OperationDefinitionNode,
18
+ SelectionSetNode,
19
+ TypeInfo,
20
+ is_type_sub_type_of,
21
+ type_from_ast,
22
+ )
23
+
24
+
25
+ def inclusion(directives: Sequence[DirectiveNode], /) -> bool | None:
26
+ """Whether `@skip` and `@include` include a selection, or ``None`` when a variable decides.
27
+
28
+ graphql-core's :func:`~graphql.execution.collect_fields.should_include_node` needs the variables' values, which only exist at runtime.
29
+ A directive without its `if` argument, which validation reports, decides nothing either.
30
+ """
31
+ included: bool | None = True
32
+
33
+ for directive in directives:
34
+ name = directive.name.value
35
+
36
+ if name not in {GraphQLSkipDirective.name, GraphQLIncludeDirective.name}:
37
+ continue
38
+
39
+ value = next(
40
+ (
41
+ argument.value
42
+ for argument in directive.arguments or ()
43
+ if argument.name.value == "if"
44
+ ),
45
+ None,
46
+ )
47
+
48
+ if not isinstance(value, BooleanValueNode):
49
+ included = None
50
+ elif value.value == (name == GraphQLSkipDirective.name):
51
+ return False
52
+
53
+ return included
54
+
55
+
56
+ @final
57
+ @dataclass(frozen=True, kw_only=True)
58
+ class SelectedField:
59
+ name: str
60
+ alias: str | None
61
+ type: GraphQLOutputType
62
+ parent_type: GraphQLCompositeType
63
+ directive_names: frozenset[str]
64
+ description: str
65
+ selection_set: "SelectionSet | None"
66
+ skippable: bool
67
+
68
+ @property
69
+ def response_name(self) -> str:
70
+ return self.alias or self.name
71
+
72
+
73
+ @final
74
+ @dataclass(frozen=True, kw_only=True)
75
+ class TypeCondition:
76
+ type: GraphQLCompositeType
77
+ selection_set: "SelectionSet"
78
+ skippable: bool
79
+
80
+
81
+ @final
82
+ @dataclass(frozen=True, kw_only=True)
83
+ class SelectionSet:
84
+ """A selection set, resolved, with the selections under a type condition kept apart.
85
+
86
+ ``fields`` holds what is selected on every value, ``conditions`` what is selected only on some, and ``fragment_names`` the fragments spread here.
87
+ All three are in document order, which keeps emission deterministic.
88
+ Spreads are recorded rather than expanded, so that emission can reuse the fragment's own type as a base class.
89
+ """
90
+
91
+ type: GraphQLCompositeType
92
+ fields: tuple[SelectedField, ...]
93
+ conditions: tuple[TypeCondition, ...]
94
+ fragment_names: tuple[str, ...]
95
+ description: str
96
+
97
+
98
+ @final
99
+ class SelectionResolver:
100
+ """Resolve the type of every field an operation or a fragment selects.
101
+
102
+ This is the part a generator inferring types itself easily gets wrong, on `interface` chains notably, and graphql-core supplies all of it, so nothing here infers a type:
103
+
104
+ - :class:`~graphql.TypeInfo` tracks, during a recursive descent calling its :meth:`~graphql.TypeInfo.enter` and :meth:`~graphql.TypeInfo.leave`, the definition of each field, meta fields included, and the type each selection set selects on;
105
+ - :func:`~graphql.is_type_sub_type_of` tells whether a fragment's type condition holds for every value of a type.
106
+
107
+ What is left is what graphql-core leaves to its caller: following fragment spreads, and `@skip` and `@include`, whose variables only have values at runtime.
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ fragment_definitions: Mapping[str, FragmentDefinitionNode],
113
+ /,
114
+ *,
115
+ schema: GraphQLSchema,
116
+ ) -> None:
117
+ self._fragment_definitions: Final = fragment_definitions
118
+ self._schema: Final = schema
119
+
120
+ def resolve(
121
+ self, definition: OperationDefinitionNode | FragmentDefinitionNode, /
122
+ ) -> SelectionSet:
123
+ type_info = TypeInfo(self._schema)
124
+ # Entering the definition pushes the operation's root type, or the fragment's type condition.
125
+ type_info.enter(definition)
126
+ selection_set = self._resolve_selection_set(
127
+ definition.selection_set, type_info=type_info
128
+ )
129
+ type_info.leave(definition)
130
+ return selection_set
131
+
132
+ def _resolve_selection_set(
133
+ self, node: SelectionSetNode, /, *, type_info: TypeInfo
134
+ ) -> SelectionSet:
135
+ # Entering it makes the type it selects on the parent type of what it selects.
136
+ type_info.enter(node)
137
+ parent_type = type_info.get_parent_type()
138
+ assert parent_type is not None, (
139
+ "Validation guarantees that a selection set selects on a composite type."
140
+ )
141
+ fields: list[SelectedField] = []
142
+ conditions: list[TypeCondition] = []
143
+ fragment_names: list[str] = []
144
+
145
+ for selection in node.selections:
146
+ # `None` rather than empty when the selection has none.
147
+ included = inclusion(selection.directives or ())
148
+
149
+ # Never in the response, so not in the type either.
150
+ if included is False:
151
+ continue
152
+
153
+ skippable = included is None
154
+
155
+ match selection:
156
+ case FieldNode():
157
+ fields.append(
158
+ self._resolve_field(
159
+ selection, type_info=type_info, skippable=skippable
160
+ )
161
+ )
162
+ case InlineFragmentNode():
163
+ type_info.enter(selection)
164
+ # Its type condition, or, without one, the enclosing type.
165
+ selection_set = self._resolve_selection_set(
166
+ selection.selection_set, type_info=type_info
167
+ )
168
+ type_info.leave(selection)
169
+ conditions.append(
170
+ TypeCondition(
171
+ type=selection_set.type,
172
+ selection_set=selection_set,
173
+ skippable=skippable,
174
+ )
175
+ )
176
+ case FragmentSpreadNode():
177
+ # `TypeInfo` does not follow a spread: the fragment is resolved on its own, by name.
178
+ fragment_name = selection.name.value
179
+ fragment_type = cast(
180
+ "GraphQLCompositeType",
181
+ type_from_ast(
182
+ self._schema,
183
+ self._fragment_definitions[fragment_name].type_condition,
184
+ ),
185
+ )
186
+
187
+ if not skippable and is_type_sub_type_of(
188
+ self._schema, parent_type, fragment_type
189
+ ):
190
+ # Its type condition holds for every value selected here, so the fragment's type stands in, as a base class.
191
+ fragment_names.append(fragment_name)
192
+ else:
193
+ # One on a narrower type is a branch, as an inline fragment is, and so is a skippable one, since standing in would declare its keys present.
194
+ conditions.append(
195
+ TypeCondition(
196
+ type=fragment_type,
197
+ selection_set=SelectionSet(
198
+ type=fragment_type,
199
+ fragment_names=(fragment_name,),
200
+ fields=(),
201
+ conditions=(),
202
+ description="",
203
+ ),
204
+ skippable=skippable,
205
+ ),
206
+ )
207
+ case _: # pragma: no cover
208
+ raise TypeError(f"Unexpected selection: {selection}")
209
+
210
+ type_info.leave(node)
211
+ return SelectionSet(
212
+ type=parent_type,
213
+ fields=tuple(fields),
214
+ conditions=tuple(conditions),
215
+ fragment_names=tuple(fragment_names),
216
+ description=parent_type.description or "",
217
+ )
218
+
219
+ def _resolve_field(
220
+ self, node: FieldNode, /, *, type_info: TypeInfo, skippable: bool
221
+ ) -> SelectedField:
222
+ type_info.enter(node)
223
+ parent_type = type_info.get_parent_type()
224
+ # `__typename` included: `TypeInfo` knows the meta fields.
225
+ field_definition = type_info.get_field_def()
226
+ assert parent_type is not None and field_definition is not None, (
227
+ "Validation guarantees that a selected field exists."
228
+ )
229
+ selection_set = (
230
+ None
231
+ if node.selection_set is None
232
+ else self._resolve_selection_set(node.selection_set, type_info=type_info)
233
+ )
234
+ type_info.leave(node)
235
+ return SelectedField(
236
+ name=node.name.value,
237
+ alias=node.alias.value if node.alias else None,
238
+ type=field_definition.type,
239
+ parent_type=parent_type,
240
+ directive_names=frozenset(
241
+ directive.name.value for directive in node.directives or ()
242
+ ),
243
+ description=field_definition.description or "",
244
+ selection_set=selection_set,
245
+ skippable=skippable,
246
+ )
@@ -0,0 +1,87 @@
1
+ from collections.abc import Mapping
2
+ from dataclasses import dataclass
3
+ from typing import final
4
+
5
+ from graphql import (
6
+ GraphQLError,
7
+ GraphQLInputObjectType,
8
+ GraphQLInterfaceType,
9
+ GraphQLNamedType,
10
+ GraphQLScalarType,
11
+ GraphQLSchema,
12
+ get_named_type,
13
+ is_specified_scalar_type,
14
+ )
15
+
16
+
17
+ @final
18
+ @dataclass(frozen=True, kw_only=True)
19
+ class StructTypes:
20
+ payload_field_name: str
21
+
22
+ input_type_names: Mapping[str, str]
23
+ """The name of the input type each struct type's payload is shaped like, by the struct type's name."""
24
+
25
+
26
+ def parse_struct_types(
27
+ schema: GraphQLSchema, struct_interface_name: str, /
28
+ ) -> StructTypes:
29
+ """The interface has a single field, the payload, of a custom scalar type, since the payload is opaque on the wire.
30
+
31
+ Every object type implementing it is named after an input type followed by the interface's name: `BookFilterStruct` has the shape of `input BookFilter`, which lets one definition be both sent and received.
32
+ """
33
+ interface = schema.type_map.get(struct_interface_name)
34
+
35
+ if not isinstance(interface, GraphQLInterfaceType):
36
+ # A config value naming nothing in the schema, not a mistyped one.
37
+ raise ValueError(f"Found no interface named `{struct_interface_name}`.") # noqa: TRY004
38
+
39
+ match list(interface.fields.items()):
40
+ case [(payload_field_name, payload_field)] if isinstance(
41
+ payload_type := get_named_type(payload_field.type), GraphQLScalarType
42
+ ) and not is_specified_scalar_type(payload_type):
43
+ pass
44
+ case _:
45
+ raise GraphQLError(
46
+ f"Expected `{struct_interface_name}` to have a single field, the payload, of a custom scalar type.",
47
+ nodes=interface.ast_node,
48
+ )
49
+
50
+ input_type_names: dict[str, str] = {}
51
+
52
+ for object_type in schema.get_possible_types(interface):
53
+ input_name = object_type.name.removesuffix(struct_interface_name)
54
+
55
+ if input_name == object_type.name or not isinstance(
56
+ schema.type_map.get(input_name), GraphQLInputObjectType
57
+ ):
58
+ raise GraphQLError(
59
+ f"Expected `{object_type.name}`, which implements `{struct_interface_name}`, to be named after an input type followed by `{struct_interface_name}`.",
60
+ nodes=object_type.ast_node,
61
+ )
62
+
63
+ input_type_names[object_type.name] = input_name
64
+
65
+ return StructTypes(
66
+ payload_field_name=payload_field_name, input_type_names=input_type_names
67
+ )
68
+
69
+
70
+ def get_struct_input_name(
71
+ parent_type: GraphQLNamedType,
72
+ field_name: str,
73
+ /,
74
+ *,
75
+ struct_types: StructTypes | None,
76
+ ) -> str | None:
77
+ """The wire type of a struct's payload is an opaque scalar, so without this it would be :class:`object`.
78
+
79
+ The payload stays under its `value` key rather than replacing the struct, although a struct only ever has that one key.
80
+ Moving it up would make every response holding a struct pay for a conversion, even one whose payload otherwise needs none, and the data's type would stop mirroring the document, which selects `value`, all for little convenience.
81
+
82
+ Once the [Struct RFC](https://github.com/graphql/graphql-wg/blob/main/rfcs/Struct.md) lands, a struct field is selected without a selection set, so the wrapper disappears from the document itself.
83
+ """
84
+ if struct_types is None or field_name != struct_types.payload_field_name:
85
+ return None
86
+
87
+ return struct_types.input_type_names.get(parent_type.name)
@@ -0,0 +1,154 @@
1
+ import ast
2
+ from collections.abc import Sequence
3
+ from dataclasses import dataclass
4
+ from typing import final
5
+
6
+ from graphql_codegen._generator._ast_nodes import (
7
+ class_definition,
8
+ constant,
9
+ documented,
10
+ name,
11
+ qualified,
12
+ subscript,
13
+ )
14
+ from graphql_codegen._generator._naming import COMPAT, TYPING
15
+ from graphql_codegen._generator.spelling import Spelling, ast_str, is_bindable
16
+
17
+
18
+ @final
19
+ @dataclass(frozen=True, kw_only=True)
20
+ class Key:
21
+ name: str
22
+ annotation: ast.expr
23
+ required: bool
24
+ description: str
25
+
26
+
27
+ def _functional(
28
+ type_spelling: Spelling, keys: Sequence[Key], /, *, closed: bool
29
+ ) -> ast.Assign:
30
+ """The string passed as the first argument must match the variable it is assigned to, or `ty` reports `mismatched-type-name`."""
31
+ return ast.Assign(
32
+ targets=[ast.Name(id=ast_str(type_spelling), ctx=ast.Store())],
33
+ value=ast.Call(
34
+ func=qualified(COMPAT, "TypedDict"),
35
+ args=[
36
+ # Spelled like the target, placeholder included, so both get the same name.
37
+ constant(ast_str(type_spelling)),
38
+ ast.Dict(
39
+ keys=[constant(key.name) for key in keys],
40
+ values=[_annotation(key, closed=closed) for key in keys],
41
+ ),
42
+ ],
43
+ keywords=[ast.keyword(arg="closed", value=ast.Constant(value=True))]
44
+ if closed
45
+ else [],
46
+ ),
47
+ )
48
+
49
+
50
+ def _is_class_attribute_safe(key: str, /, *, bare_names: frozenset[str]) -> bool:
51
+ """A name Python cannot bind cannot be, a name starting with `__` would be mangled, and a public name the module references unqualified would be shadowed by the key in the class scope.
52
+
53
+ No name the generator adds can clash with a key, since :func:`~graphql_codegen._spelling.settle_module` settles none like any name the module holds.
54
+ """
55
+ return is_bindable(key) and not key.startswith("__") and key not in bare_names
56
+
57
+
58
+ def _annotation(key: Key, /, *, closed: bool) -> ast.expr:
59
+ """The key's annotation, saying whether it must be present: explicitly in a closed TypedDict, where reading what a caller must send matters, and only when it may be absent in an open one."""
60
+ if closed:
61
+ return subscript(
62
+ qualified(TYPING, "Required" if key.required else "NotRequired"),
63
+ key.annotation,
64
+ )
65
+
66
+ return (
67
+ key.annotation
68
+ if key.required
69
+ else subscript(qualified(TYPING, "NotRequired"), key.annotation)
70
+ )
71
+
72
+
73
+ def _declaration(key: Key, /, *, closed: bool) -> list[ast.stmt]:
74
+ return documented(
75
+ ast.AnnAssign(
76
+ target=ast.Name(id=key.name, ctx=ast.Store()),
77
+ annotation=_annotation(key, closed=closed),
78
+ simple=1,
79
+ ),
80
+ text=key.description,
81
+ )
82
+
83
+
84
+ def emit_closed(
85
+ type_spelling: Spelling,
86
+ keys: Sequence[Key],
87
+ /,
88
+ *,
89
+ description: str,
90
+ bare_names: frozenset[str],
91
+ ) -> list[ast.stmt]:
92
+ """Emit a closed TypedDict, as an input or variables type is, in class syntax unless a key cannot be declared in a class body, and then in functional syntax altogether, since a closed TypedDict cannot be extended with keys by a subclass."""
93
+ if all(_is_class_attribute_safe(key.name, bare_names=bare_names) for key in keys):
94
+ return [
95
+ class_definition(
96
+ type_spelling,
97
+ bases=[qualified(COMPAT, "TypedDict")],
98
+ keywords={"closed": ast.Constant(value=True)},
99
+ body=[
100
+ statement
101
+ for key in keys
102
+ for statement in _declaration(key, closed=True)
103
+ ],
104
+ docstring_text=description,
105
+ ),
106
+ ]
107
+
108
+ return documented(_functional(type_spelling, keys, closed=True), text=description)
109
+
110
+
111
+ def emit_data(
112
+ type_spelling: Spelling,
113
+ keys: Sequence[Key],
114
+ /,
115
+ *,
116
+ bases: Sequence[ast.expr],
117
+ closed: bool,
118
+ functional_base_spelling: Spelling,
119
+ description: str,
120
+ ) -> list[ast.stmt]:
121
+ """Emit a data type in class syntax, inheriting the keys a class body cannot declare from a functional base, so that it can still inherit fragments too.
122
+
123
+ The functional base is open, since the class extends it.
124
+ """
125
+ # A data module references no public name unqualified.
126
+ safe = [
127
+ key
128
+ for key in keys
129
+ if _is_class_attribute_safe(key.name, bare_names=frozenset())
130
+ ]
131
+ unsafe = [key for key in keys if key not in safe]
132
+ statements: list[ast.stmt] = []
133
+ all_bases = list(bases)
134
+
135
+ if unsafe:
136
+ statements.append(
137
+ _functional(functional_base_spelling, unsafe, closed=False),
138
+ )
139
+ all_bases.insert(0, name(functional_base_spelling))
140
+
141
+ statements.append(
142
+ class_definition(
143
+ type_spelling,
144
+ bases=all_bases or [qualified(COMPAT, "TypedDict")],
145
+ body=[
146
+ statement
147
+ for key in safe
148
+ for statement in _declaration(key, closed=False)
149
+ ],
150
+ docstring_text=description,
151
+ keywords={"closed": ast.Constant(value=True)} if closed else {},
152
+ ),
153
+ )
154
+ return statements
@@ -0,0 +1,48 @@
1
+ import builtins
2
+ from dataclasses import dataclass
3
+ from typing import final
4
+
5
+ from graphql_codegen._generator.spelling import is_bindable
6
+
7
+
8
+ @final
9
+ @dataclass(frozen=True, kw_only=True)
10
+ class Builtin:
11
+ name: str
12
+
13
+
14
+ @final
15
+ @dataclass(frozen=True, kw_only=True)
16
+ class Import:
17
+ level: int
18
+ """Like :attr:`ast.ImportFrom.level`: 0 for an absolute path, and 2 or more for one relative to the directory the package is written to."""
19
+
20
+ module: str | None
21
+ name: str
22
+
23
+
24
+ type Reference = Builtin | Import
25
+
26
+
27
+ def parse_dotted_name(dotted_name: str, /) -> Reference:
28
+ """A bare name, such as :class:`int`, is a builtin.
29
+
30
+ A dotted name is absolute, or starts with `..` to be relative to the directory the package is written to: `..scalars.decode` is `scalars.decode` next to it.
31
+ A dotted name starting with one dot would name a module of the generated package, which is not the project's to write in.
32
+ """
33
+ level = len(dotted_name) - len(dotted_name.lstrip("."))
34
+ *modules, last = dotted_name[level:].split(".")
35
+
36
+ if (
37
+ level == 1
38
+ or not all(is_bindable(part) for part in (*modules, last))
39
+ or (level == 0 and not modules and not hasattr(builtins, last))
40
+ ):
41
+ raise ValueError(
42
+ f"Expected an absolute dotted name, such as `datetime.datetime`, a dotted name relative to the package's directory, such as `..scalars.decode`, or a builtin, such as `int`, but got `{dotted_name}`."
43
+ )
44
+
45
+ if level == 0 and not modules:
46
+ return Builtin(name=last)
47
+
48
+ return Import(level=level, module=".".join(modules) or None, name=last)