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,7 @@
1
+ from graphql_codegen.config import Config as Config
2
+ from graphql_codegen.document_sibling_module import (
3
+ DocumentSiblingModule as DocumentSiblingModule,
4
+ )
5
+ from graphql_codegen.generate import generate as generate
6
+ from graphql_codegen.package_location import PackageLocation as PackageLocation
7
+ from graphql_codegen.scalar import Codec as Codec, Scalar as Scalar
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from graphql_codegen._cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,41 @@
1
+ import sys
2
+ from argparse import ArgumentParser, Namespace
3
+ from collections.abc import Sequence
4
+ from pathlib import Path
5
+ from typing import final
6
+
7
+ from graphql_codegen._cli._graphql_config import read_graphql_config
8
+ from graphql_codegen._cli._write import write
9
+ from graphql_codegen._metadata import DISTRIBUTION_NAME
10
+ from graphql_codegen.generate import generate
11
+
12
+
13
+ @final
14
+ class _Args(Namespace):
15
+ config_path: Path
16
+
17
+
18
+ def _parse_args(argv: Sequence[str] | None, /) -> _Args:
19
+ parser = ArgumentParser(
20
+ prog=DISTRIBUTION_NAME,
21
+ description="Generate the package of every project of a graphql-config file.",
22
+ )
23
+ parser.add_argument(
24
+ "config_path",
25
+ metavar="config",
26
+ type=Path,
27
+ help="the path to the graphql-config file, in YAML, JSON, or TOML",
28
+ )
29
+ return parser.parse_args(argv, namespace=_Args())
30
+
31
+
32
+ def main(argv: Sequence[str] | None = None, /) -> None:
33
+ cli_args = _parse_args(argv)
34
+ generate_args_by_directory = read_graphql_config(cli_args.config_path)
35
+ packages = {
36
+ directory: generate(**args)
37
+ for directory, args in generate_args_by_directory.items()
38
+ }
39
+ for directory, files in packages.items():
40
+ write(directory, files)
41
+ print(f"Wrote {len(files)} files to `{directory}`.", file=sys.stderr)
@@ -0,0 +1,291 @@
1
+ import json
2
+ import os
3
+ import posixpath
4
+ import re
5
+ import tomllib
6
+ from collections.abc import Callable, Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from functools import cache
9
+ from pathlib import Path, PurePosixPath
10
+ from typing import Final, final
11
+
12
+ from graphql import (
13
+ DocumentNode,
14
+ GraphQLError,
15
+ Source,
16
+ build_ast_schema,
17
+ parse,
18
+ )
19
+
20
+ from graphql_codegen._cli._parsing import (
21
+ check_all_read,
22
+ optional,
23
+ parse_at,
24
+ parse_dict,
25
+ parse_string,
26
+ parse_strings,
27
+ required,
28
+ )
29
+ from graphql_codegen._cli._schema_pointer import SchemaPointer, parse_schema_pointer
30
+ from graphql_codegen._cli._source import read_schema, read_sources
31
+ from graphql_codegen._metadata import DISTRIBUTION_NAME, METADATA
32
+ from graphql_codegen._note import error_note
33
+ from graphql_codegen.config import Config
34
+ from graphql_codegen.document_sibling_module import DocumentSiblingModule
35
+ from graphql_codegen.generate import _Params
36
+ from graphql_codegen.package_location import PackageLocation
37
+ from graphql_codegen.scalar import Codec, Scalar
38
+
39
+ _EXTENSION_NAME: Final = "pythonCodegen"
40
+
41
+ # As https://github.com/kamilkisiela/string-env-interpolation/blob/94c441088f2ba62c5f0a2a31b8b9728242184360/src/index.ts#L16, which graphql-config calls at https://github.com/graphql-hive/graphql-config/blob/3091fcc7259c2feeb9866994a6c3ca4fc6ddb7a0/src/helpers/cosmiconfig.ts#L24.
42
+ _ENVIRONMENT_VARIABLE: Final = re.compile(
43
+ r"\$\{(?P<name>[A-Z0-9_]+)(?::(?P<default>[^}]+))?\}", re.IGNORECASE
44
+ )
45
+ _QUOTED: Final = re.compile(r"\"([^\"]+)\"|'([^']+)'")
46
+
47
+
48
+ def read_graphql_config(path: Path, /) -> dict[Path, _Params]:
49
+ """Return the generation arguments for each project of the graphql-config file at *path* by the directory its package goes to."""
50
+ load = _get_loader(path)
51
+ text = path.read_text(encoding="utf-8")
52
+ config = load(_interpolate(text, environment=os.environ))
53
+
54
+ return {
55
+ directory: _read_project(project, directory=path.parent)
56
+ for directory, project in _parse_graphql_config(
57
+ config, directory=PurePosixPath(path.parent.as_posix())
58
+ ).items()
59
+ }
60
+
61
+
62
+ def _interpolate(text: str, /, *, environment: Mapping[str, str]) -> str:
63
+ def replacement(match: re.Match[str], /) -> str:
64
+ name = match["name"]
65
+
66
+ # An empty value falls back to the default too.
67
+ if value := environment.get(name):
68
+ return value
69
+
70
+ if isinstance(default := match["default"], str):
71
+ stripped = default.strip()
72
+ quoted = _QUOTED.fullmatch(stripped)
73
+ # One holding a colon may be quoted, in single or double quotes.
74
+ return (
75
+ stripped
76
+ if quoted is None
77
+ else next(group for group in quoted.groups() if isinstance(group, str))
78
+ )
79
+
80
+ raise ValueError(
81
+ f"Expected the environment variable `{name}` to have a value, since `{match[0]}` has no default."
82
+ )
83
+
84
+ return _ENVIRONMENT_VARIABLE.sub(replacement, text)
85
+
86
+
87
+ def _get_loader(config_path: Path, /) -> Callable[[str], object]:
88
+ match config_path.suffix:
89
+ case ".json":
90
+ return json.loads
91
+ case ".toml":
92
+ return tomllib.loads
93
+ case ".yaml" | ".yml" | "":
94
+ # `.graphqlrc` may be YAML or JSON, which YAML reads too.
95
+ try:
96
+ import yaml # noqa: PLC0415
97
+ except ImportError as error:
98
+ raise ImportError(
99
+ f"`{config_path}` is YAML: install `{DISTRIBUTION_NAME}[{_yaml_extra_name()}]` to read it."
100
+ ) from error
101
+
102
+ return yaml.safe_load
103
+ case ".js" | ".cjs" | ".mjs" | ".ts" | ".cts" | ".mts":
104
+ raise ValueError(
105
+ f"`{config_path}` is JavaScript or TypeScript, which only JavaScript can evaluate: write it in YAML, JSON, or TOML."
106
+ )
107
+ case suffix:
108
+ raise ValueError(
109
+ f"Cannot tell the format of `{config_path}` from `{suffix}`: expected YAML, JSON, or TOML."
110
+ )
111
+
112
+
113
+ @cache
114
+ def _yaml_extra_name() -> str:
115
+ name = "yaml"
116
+ assert name in METADATA.get_all("Provides-Extra", ()), (
117
+ f"Expected `pyproject.toml` to declare the `{name}` extra."
118
+ )
119
+ return name
120
+
121
+
122
+ @final
123
+ @dataclass(frozen=True, kw_only=True)
124
+ class _Project:
125
+ name: str
126
+ schema: SchemaPointer
127
+ documents: Sequence[str]
128
+ package_location: PackageLocation
129
+ config: Config
130
+
131
+
132
+ def _read_project(project: _Project, /, *, directory: Path) -> _Params:
133
+ with error_note(f"In project `{project.name}`."):
134
+ return _parse_project(
135
+ project,
136
+ schema=read_schema(project.schema, directory=directory),
137
+ documents=read_sources(project.documents, directory=directory),
138
+ )
139
+
140
+
141
+ def _parse_project(
142
+ project: _Project,
143
+ /,
144
+ *,
145
+ schema: Sequence[Source],
146
+ documents: Sequence[Source],
147
+ ) -> _Params:
148
+ try:
149
+ built_schema = build_ast_schema(_parse_sources(schema))
150
+ except (GraphQLError, TypeError) as error:
151
+ # `build_ast_schema()` raises a `TypeError` for an invalid schema.
152
+ raise ValueError(f"Cannot build the schema: {error}") from error
153
+
154
+ return _Params(
155
+ document=_parse_sources(documents),
156
+ schema=built_schema,
157
+ config=project.config,
158
+ )
159
+
160
+
161
+ def _parse_sources(sources: Sequence[Source], /) -> DocumentNode:
162
+ """Parse *sources* into one document, since a fragment defined in one file may be spread in another, and a schema's types spread over several files."""
163
+ return DocumentNode(
164
+ definitions=tuple(
165
+ definition for source in sources for definition in parse(source).definitions
166
+ )
167
+ )
168
+
169
+
170
+ def _parse_graphql_config(
171
+ config: object, /, *, directory: PurePosixPath
172
+ ) -> dict[Path, _Project]:
173
+ """The projects of *config*, the graphql-config file in *directory*, by the directory each package goes to."""
174
+ root = parse_dict(config)
175
+ entries = (
176
+ required(root, "projects", parse_dict)
177
+ if "projects" in root
178
+ else {"default": root}
179
+ )
180
+ projects: dict[Path, _Project] = {}
181
+
182
+ for name, entry in entries.items():
183
+ with error_note(f"In project `{name}`."):
184
+ project = _project(entry, name=name, directory=directory)
185
+
186
+ if project is None:
187
+ continue
188
+
189
+ # Derived from both, which needs no IO, unlike finding where packages start from a directory.
190
+
191
+ package_directory = Path(project.package_location._package_directory)
192
+
193
+ if (other := projects.get(package_directory)) is not None:
194
+ raise ValueError(
195
+ f"Expected `{other.name}` and `{name}` to generate into directories of their own, but both generate into `{package_directory}`."
196
+ )
197
+
198
+ projects[package_directory] = project
199
+
200
+ if not projects:
201
+ raise ValueError(f"Expected a project with a `{_EXTENSION_NAME}` extension.")
202
+
203
+ return projects
204
+
205
+
206
+ def _project(
207
+ value: object, /, *, name: str, directory: PurePosixPath
208
+ ) -> _Project | None:
209
+ entry = parse_dict(value)
210
+ extensions = optional(entry, "extensions", parse_dict, default={})
211
+
212
+ if (extension := extensions.get(_EXTENSION_NAME)) is None:
213
+ return None
214
+
215
+ schema = required(entry, "schema", parse_schema_pointer)
216
+ documents = required(entry, "documents", parse_strings)
217
+ package_location, config = parse_at(
218
+ extension,
219
+ key=f"extensions.{_EXTENSION_NAME}",
220
+ parse=lambda value: _extension(value, directory=directory),
221
+ )
222
+ return _Project(
223
+ name=name,
224
+ schema=schema,
225
+ documents=documents,
226
+ package_location=package_location,
227
+ config=config,
228
+ )
229
+
230
+
231
+ def _extension(
232
+ value: object, /, *, directory: PurePosixPath
233
+ ) -> tuple[PackageLocation, Config]:
234
+ unread = parse_dict(value)
235
+
236
+ module_root = required(unread, "moduleRoot", parse_string)
237
+ package_location = PackageLocation(
238
+ module_root=PurePosixPath(posixpath.normpath(directory / module_root)),
239
+ package=required(unread, "package", parse_string),
240
+ )
241
+ document_sibling_module = optional(
242
+ unread,
243
+ "documentSiblingModule",
244
+ lambda value: DocumentSiblingModule(
245
+ name=parse_string(value), package_location=package_location
246
+ ),
247
+ default=None,
248
+ )
249
+ injector_names = optional(
250
+ unread, "injectorNames", parse_strings, default=frozenset()
251
+ )
252
+ non_null_directive_name = optional(
253
+ unread, "nonNullDirectiveName", parse_string, default=None
254
+ )
255
+ scalars = optional(unread, "scalars", _scalars, default={})
256
+ struct_interface_name = optional(
257
+ unread, "structInterfaceName", parse_string, default=None
258
+ )
259
+
260
+ check_all_read(unread)
261
+ return package_location, Config(
262
+ document_sibling_module=document_sibling_module,
263
+ injector_names=injector_names,
264
+ non_null_directive_name=non_null_directive_name,
265
+ scalars=scalars,
266
+ struct_interface_name=struct_interface_name,
267
+ )
268
+
269
+
270
+ def _scalars(value: object, /) -> dict[str, Scalar]:
271
+ scalars = parse_dict(value)
272
+ return {
273
+ scalar_name: required(scalars, scalar_name, _scalar)
274
+ for scalar_name in list(scalars)
275
+ }
276
+
277
+
278
+ def _scalar(value: object, /) -> Scalar:
279
+ unread = parse_dict(value)
280
+ type_ = required(unread, "type", parse_string)
281
+ codec = optional(unread, "codec", _codec, default=None)
282
+ check_all_read(unread)
283
+ return Scalar(type=type_, codec=codec)
284
+
285
+
286
+ def _codec(value: object, /) -> Codec:
287
+ unread = parse_dict(value)
288
+ decode = required(unread, "decode", parse_string)
289
+ encode = required(unread, "encode", parse_string)
290
+ check_all_read(unread)
291
+ return Codec(decode=decode, encode=encode)
@@ -0,0 +1,87 @@
1
+ from collections.abc import Callable, Mapping
2
+ from http.client import HTTPResponse
3
+ from mimetypes import types_map
4
+ from typing import Final, Literal, TypeAlias
5
+ from urllib.request import Request, urlopen
6
+
7
+ from graphql import (
8
+ IntrospectionQuery,
9
+ build_client_schema,
10
+ get_introspection_query,
11
+ print_schema,
12
+ )
13
+
14
+ from graphql_codegen._cli._introspection_graphql import (
15
+ FieldNames,
16
+ IntrospectionProbe,
17
+ IntrospectionProbeData,
18
+ )
19
+ from graphql_codegen._metadata import DISTRIBUTION_NAME, METADATA
20
+ from graphql_codegen.runtime import Client, Operation
21
+
22
+ _JSON: Final = types_map[".json"]
23
+ _GRAPHQL_RESPONSE: Final = "application/graphql-response+json"
24
+
25
+ _TIMEOUT: Final = 30.0 # graphql-config has no option for it.
26
+ """Seconds to wait for each response."""
27
+
28
+
29
+ def _field_names(type_: FieldNames, /) -> frozenset[str]:
30
+ return frozenset(field["name"] for field in type_["fields"])
31
+
32
+
33
+ # Not a `type` statement, which could not be called to build the operation.
34
+ _IntrospectionOperation: TypeAlias = Operation[ # noqa: UP040
35
+ Literal["query"], dict[str, object], IntrospectionQuery
36
+ ]
37
+
38
+
39
+ def _introspection_operation(
40
+ probe: IntrospectionProbeData, /
41
+ ) -> _IntrospectionOperation:
42
+ type_fields = _field_names(probe["type"])
43
+ return _IntrospectionOperation(
44
+ operation_type="query",
45
+ name="IntrospectionQuery",
46
+ document=get_introspection_query(
47
+ specified_by_url="specifiedByURL" in type_fields,
48
+ directive_is_repeatable="isRepeatable" in _field_names(probe["directive"]),
49
+ schema_description="description" in _field_names(probe["schema"]),
50
+ input_value_deprecation="isDeprecated" in _field_names(probe["inputValue"]),
51
+ one_of="isOneOf" in type_fields,
52
+ ),
53
+ variables_type=dict[str, object],
54
+ # graphql-core's `IntrospectionQuery` uses `DirectiveLocation`, which it imports only `if TYPE_CHECKING:`.
55
+ data_type=dict[str, object], # ty: ignore[invalid-argument-type]
56
+ )
57
+
58
+
59
+ def fetch_sdl(url: str, /, *, headers: Mapping[str, str]) -> str:
60
+ client = Client(_transport(url, headers=headers))
61
+ probe = client(IntrospectionProbe({}))
62
+ introspection_operation = _introspection_operation(probe)
63
+ introspection_data = client(introspection_operation({}))
64
+ return print_schema(build_client_schema(introspection=introspection_data))
65
+
66
+
67
+ def _transport(url: str, /, *, headers: Mapping[str, str]) -> Callable[[bytes], bytes]:
68
+ def transport(body: bytes, /) -> bytes:
69
+ request = Request( # noqa: S310
70
+ url,
71
+ data=body,
72
+ headers={
73
+ "Accept": f"{_GRAPHQL_RESPONSE}, {_JSON}",
74
+ "Content-Type": _JSON,
75
+ "User-Agent": f"{DISTRIBUTION_NAME}/{METADATA['Version']}",
76
+ **headers,
77
+ },
78
+ )
79
+
80
+ response = urlopen(request, timeout=_TIMEOUT) # noqa: S310
81
+ # The only response an `http:` or `https:` URL gives.
82
+ assert isinstance(response, HTTPResponse)
83
+
84
+ with response:
85
+ return response.read()
86
+
87
+ return transport
@@ -0,0 +1,53 @@
1
+ # Code generated by graphql-codegen from `_introspection.graphql`. DO NOT EDIT.
2
+
3
+ import builtins as _builtins
4
+ import typing as _typing
5
+ from graphql_codegen.runtime import _compat
6
+ from graphql_codegen.runtime import _reflection
7
+ from graphql_codegen import runtime as _runtime
8
+
9
+ class _FieldNames_fields(_compat.TypedDict, closed=True):
10
+ """Object and Interface types are described by a list of Fields, each of which has a name, potentially a list of arguments, and a return type."""
11
+ name: _builtins.str
12
+
13
+ class FieldNames(_compat.TypedDict):
14
+ """The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum.
15
+
16
+ Depending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name, description and optional `specifiedByURL`, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types."""
17
+ fields: _typing.Annotated[_builtins.list[_FieldNames_fields], _reflection.NON_NULL]
18
+
19
+ class IntrospectionProbeVariables(_compat.TypedDict, closed=True):
20
+ ...
21
+
22
+ class IntrospectionProbeData(_compat.TypedDict, closed=True):
23
+ schema: _typing.Annotated[FieldNames, _reflection.NON_NULL]
24
+ """Request the type information of a single type."""
25
+ type: _typing.Annotated[FieldNames, _reflection.NON_NULL]
26
+ """Request the type information of a single type."""
27
+ inputValue: _typing.Annotated[FieldNames, _reflection.NON_NULL]
28
+ """Request the type information of a single type."""
29
+ directive: _typing.Annotated[FieldNames, _reflection.NON_NULL]
30
+ """Request the type information of a single type."""
31
+
32
+ IntrospectionProbe: _runtime.Operation[_typing.Literal['query'], IntrospectionProbeVariables, IntrospectionProbeData] = _runtime.Operation(operation_type='query', name='IntrospectionProbe', document="""query IntrospectionProbe {
33
+ schema§: __type(name: "__Schema") {
34
+ ...FieldNames
35
+ }
36
+ type§: __type(name: "__Type") {
37
+ ...FieldNames
38
+ }
39
+ inputValue§: __type(name: "__InputValue") {
40
+ ...FieldNames
41
+ }
42
+ directive§: __type(name: "__Directive") {
43
+ ...FieldNames
44
+ }
45
+ }
46
+
47
+ fragment FieldNames on __Type {
48
+ fields {
49
+ name
50
+ }
51
+ }""", variables_type=IntrospectionProbeVariables, data_type=IntrospectionProbeData)
52
+ """Which fields the server's introspection types have.
53
+ The full introspection query then asks only for those, since an older server rejects a field it does not know."""
@@ -0,0 +1,67 @@
1
+ """Parse the values of a config file, noting where each error is."""
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from typing import cast
5
+
6
+ from graphql_codegen._note import error_note
7
+
8
+
9
+ def check_all_read(unread: dict[str, object], /) -> None:
10
+ if unread:
11
+ raise ValueError(
12
+ f"Expected no other key, but got {', '.join(f'`{key}`' for key in sorted(unread))}."
13
+ )
14
+
15
+
16
+ def parse_dict(value: object, /) -> dict[str, object]:
17
+ """Return a shallow-copy of *value*."""
18
+ if not isinstance(value, Mapping) or not all(isinstance(key, str) for key in value):
19
+ raise TypeError(f"Expected a mapping with string keys, but got `{value!r}`.")
20
+
21
+ return dict(cast(Mapping[str, object], value))
22
+
23
+
24
+ def required[T](
25
+ unread: dict[str, object], key: str, parse: Callable[[object], T], /
26
+ ) -> T:
27
+ value = optional(unread, key, parse, default=None)
28
+
29
+ if value is None:
30
+ raise ValueError(f"Expected `{key}` to be set.")
31
+
32
+ return value
33
+
34
+
35
+ def optional[T](
36
+ unread: dict[str, object], key: str, parse: Callable[[object], T], /, *, default: T
37
+ ) -> T:
38
+ value = unread.pop(key, None)
39
+ return default if value is None else parse_at(value, key=key, parse=parse)
40
+
41
+
42
+ def parse_at[T](value: object, /, *, key: str, parse: Callable[[object], T]) -> T:
43
+ with error_note(f"In `{key}`."):
44
+ return parse(value)
45
+
46
+
47
+ def parse_strings(value: object, /) -> list[str]:
48
+ if isinstance(value, str):
49
+ return [value]
50
+
51
+ if not isinstance(value, list):
52
+ raise TypeError(f"Expected a string or a list of strings, but got `{value!r}`.")
53
+
54
+ strings: list[str] = []
55
+
56
+ for index, item in enumerate(value):
57
+ with error_note(f"At index {index}."):
58
+ strings.append(parse_string(item))
59
+
60
+ return strings
61
+
62
+
63
+ def parse_string(value: object, /) -> str:
64
+ if not isinstance(value, str):
65
+ raise TypeError(f"Expected a string, but got `{value!r}`.")
66
+
67
+ return value
@@ -0,0 +1,108 @@
1
+ from collections.abc import Mapping
2
+ from dataclasses import dataclass
3
+ from pathlib import PurePosixPath
4
+ from typing import cast, final
5
+ from urllib.parse import urlsplit
6
+
7
+ from graphql_codegen._cli._parsing import (
8
+ check_all_read,
9
+ optional,
10
+ parse_at,
11
+ parse_dict,
12
+ parse_string,
13
+ required,
14
+ )
15
+ from graphql_codegen._note import error_note
16
+
17
+
18
+ @final
19
+ @dataclass(frozen=True, kw_only=True)
20
+ class Globs:
21
+ patterns: tuple[str, ...]
22
+
23
+
24
+ @final
25
+ @dataclass(frozen=True, kw_only=True)
26
+ class IntrospectionResult:
27
+ path: PurePosixPath
28
+
29
+
30
+ @final
31
+ @dataclass(frozen=True, kw_only=True)
32
+ class Endpoint:
33
+ url: str
34
+ headers: Mapping[str, str]
35
+
36
+
37
+ type SchemaPointer = Globs | IntrospectionResult | Endpoint
38
+ """Where a project's schema comes from, in one of the forms https://the-guild.dev/graphql/config/docs/user/schema lists."""
39
+
40
+
41
+ def parse_schema_pointer(value: object, /) -> SchemaPointer:
42
+ match value:
43
+ case [item]:
44
+ with error_note("At index 0."):
45
+ return _parse_single_pointer(item)
46
+ case list():
47
+ patterns: list[str] = []
48
+
49
+ for index, item in enumerate(value):
50
+ with error_note(f"At index {index}."):
51
+ pointer = _parse_single_pointer(item)
52
+
53
+ if not isinstance(pointer, Globs):
54
+ raise TypeError(
55
+ f"Expected a glob of SDL files, since the list holds more than one item, but got `{item}`."
56
+ )
57
+
58
+ # A single item is one glob.
59
+ (pattern,) = pointer.patterns
60
+ patterns.append(pattern)
61
+
62
+ return Globs(patterns=tuple(patterns))
63
+ case _:
64
+ return _parse_single_pointer(value)
65
+
66
+
67
+ def _parse_single_pointer(value: object, /) -> SchemaPointer:
68
+ if isinstance(value, str):
69
+ if (url := _parse_url(value)) is not None:
70
+ return Endpoint(url=url, headers={})
71
+
72
+ if (path := PurePosixPath(value)).suffix == ".json":
73
+ return IntrospectionResult(path=path)
74
+
75
+ return Globs(patterns=(value,))
76
+
77
+ if isinstance(value, Mapping) and len(value) == 1:
78
+ ((key, options),) = cast(Mapping[object, object], value).items()
79
+
80
+ if isinstance(key, str) and (url := _parse_url(key)) is not None:
81
+ return Endpoint(
82
+ url=url,
83
+ headers={}
84
+ if options is None
85
+ else parse_at(options, key=key, parse=_parse_endpoint_headers),
86
+ )
87
+
88
+ raise TypeError(
89
+ f"Expected a glob, a `.json` path, or a URL (possibly mapped to its options), but got `{value!r}`."
90
+ )
91
+
92
+
93
+ def _parse_url(value: str, /) -> str | None:
94
+ split_result = urlsplit(value)
95
+
96
+ return (
97
+ split_result.geturl()
98
+ # `urlsplit()` lowercases the scheme, which `geturl()` keeps.
99
+ if split_result.scheme in {"http", "https"}
100
+ else None
101
+ )
102
+
103
+
104
+ def _parse_endpoint_headers(value: object, /) -> dict[str, str]:
105
+ unread = parse_dict(value)
106
+ headers = optional(unread, "headers", parse_dict, default={})
107
+ check_all_read(unread)
108
+ return {name: required(headers, name, parse_string) for name in list(headers)}