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,68 @@
|
|
|
1
|
+
"""Read a project's GraphQL: its documents' files, and its schema, whatever form it comes in."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import assert_never, cast
|
|
7
|
+
|
|
8
|
+
from graphql import IntrospectionQuery, Source, build_client_schema, print_schema
|
|
9
|
+
|
|
10
|
+
from graphql_codegen._cli._introspection import fetch_sdl
|
|
11
|
+
from graphql_codegen._cli._schema_pointer import (
|
|
12
|
+
Endpoint,
|
|
13
|
+
Globs,
|
|
14
|
+
IntrospectionResult,
|
|
15
|
+
SchemaPointer,
|
|
16
|
+
)
|
|
17
|
+
from graphql_codegen._note import error_note
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def read_sources(globs: Sequence[str], /, *, directory: Path) -> list[Source]:
|
|
21
|
+
paths: set[Path] = set()
|
|
22
|
+
|
|
23
|
+
for glob in globs:
|
|
24
|
+
matched = {path for path in directory.glob(glob) if path.is_file()}
|
|
25
|
+
|
|
26
|
+
if not matched:
|
|
27
|
+
# Also catches https://the-guild.dev/graphql/config's braces and negation, which `pathlib` reads literally: a negation would otherwise leave in what it means to leave out.
|
|
28
|
+
raise ValueError(f"Expected `{glob}` to match at least one file.")
|
|
29
|
+
|
|
30
|
+
paths |= matched
|
|
31
|
+
|
|
32
|
+
sources: list[Source] = []
|
|
33
|
+
|
|
34
|
+
# Sorted, so that the package does not depend on the file system's order.
|
|
35
|
+
for path in sorted(paths):
|
|
36
|
+
text = path.read_text(encoding="utf-8")
|
|
37
|
+
# Named by their POSIX paths, in which a module next to a document is placed.
|
|
38
|
+
sources.append(Source(text, path.as_posix()))
|
|
39
|
+
|
|
40
|
+
return sources
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_schema(pointer: SchemaPointer, /, *, directory: Path) -> list[Source]:
|
|
44
|
+
match pointer:
|
|
45
|
+
case Globs(patterns=patterns):
|
|
46
|
+
return read_sources(patterns, directory=directory)
|
|
47
|
+
case IntrospectionResult(path=path):
|
|
48
|
+
file = directory / path
|
|
49
|
+
|
|
50
|
+
with error_note(f"In `{file.as_posix()}`."):
|
|
51
|
+
text = file.read_text(encoding="utf-8")
|
|
52
|
+
return [Source(_introspection_sdl(json.loads(text)), file.as_posix())]
|
|
53
|
+
case Endpoint(url=url, headers=headers):
|
|
54
|
+
with error_note(f"In `{url}`."):
|
|
55
|
+
sdl = fetch_sdl(url, headers=headers)
|
|
56
|
+
return [Source(sdl, url)]
|
|
57
|
+
case _ as never:
|
|
58
|
+
assert_never(never)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _introspection_sdl(result: object, /) -> str:
|
|
62
|
+
if isinstance(result, dict) and "data" in result:
|
|
63
|
+
result = result["data"]
|
|
64
|
+
|
|
65
|
+
if not isinstance(result, dict) or "__schema" not in result:
|
|
66
|
+
raise ValueError("Expected an introspection result, holding `__schema`.")
|
|
67
|
+
|
|
68
|
+
return print_schema(build_client_schema(cast(IntrospectionQuery, result)))
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from pathlib import Path, PurePosixPath
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def write(directory: Path, files: Mapping[PurePosixPath, bytes], /) -> None:
|
|
6
|
+
"""Write *files*, keyed by their paths relative to *directory*.
|
|
7
|
+
|
|
8
|
+
- A file already holding its content is left untouched: file watchers do not see it change.
|
|
9
|
+
- Any other file at one of the paths is overwritten: version control can revert it if that was a mistake.
|
|
10
|
+
- No other file is touched: orphaned document modules are left behind, which is harmless if the output is gitignored.
|
|
11
|
+
|
|
12
|
+
"""
|
|
13
|
+
for relative_path, content in files.items():
|
|
14
|
+
path = directory / relative_path
|
|
15
|
+
|
|
16
|
+
existing = path.read_bytes() if path.is_file() else None
|
|
17
|
+
|
|
18
|
+
if existing == content:
|
|
19
|
+
continue
|
|
20
|
+
|
|
21
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
path.write_bytes(content)
|
|
File without changes
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from typing import Final, Literal, assert_never, final
|
|
4
|
+
|
|
5
|
+
from graphql import (
|
|
6
|
+
GraphQLBoolean,
|
|
7
|
+
GraphQLEnumType,
|
|
8
|
+
GraphQLFloat,
|
|
9
|
+
GraphQLID,
|
|
10
|
+
GraphQLInputObjectType,
|
|
11
|
+
GraphQLInt,
|
|
12
|
+
GraphQLList,
|
|
13
|
+
GraphQLNonNull,
|
|
14
|
+
GraphQLScalarType,
|
|
15
|
+
GraphQLString,
|
|
16
|
+
GraphQLType,
|
|
17
|
+
is_introspection_type,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from graphql_codegen._generator._ast_nodes import (
|
|
21
|
+
constant,
|
|
22
|
+
name,
|
|
23
|
+
optional,
|
|
24
|
+
qualified,
|
|
25
|
+
subscript,
|
|
26
|
+
)
|
|
27
|
+
from graphql_codegen._generator._naming import (
|
|
28
|
+
ABC,
|
|
29
|
+
BUILTINS,
|
|
30
|
+
ENUM,
|
|
31
|
+
INPUT,
|
|
32
|
+
SCALAR,
|
|
33
|
+
TYPING,
|
|
34
|
+
SchemaSpellings,
|
|
35
|
+
)
|
|
36
|
+
from graphql_codegen._generator.spelling import SettledSpelling
|
|
37
|
+
|
|
38
|
+
# A project may still configure them like custom ones.
|
|
39
|
+
BUILT_IN_SCALARS: Final[Mapping[str, str]] = {
|
|
40
|
+
GraphQLBoolean.name: "bool",
|
|
41
|
+
GraphQLFloat.name: "float",
|
|
42
|
+
GraphQLID.name: "str",
|
|
43
|
+
GraphQLInt.name: "int",
|
|
44
|
+
GraphQLString.name: "str",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
type Position = Literal["input", "data"]
|
|
49
|
+
"""Where a value of the type is.
|
|
50
|
+
|
|
51
|
+
- ``"input"``: sent to the server, as a variable or an input object's field, where a list is a :class:`collections.abc.Sequence`, so that a caller may pass a tuple.
|
|
52
|
+
- ``"data"``: received from the server, in a response's data, where a list is a :class:`list`, since that is what :func:`json.loads` produces.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def builtin(type_name: str, /) -> ast.expr:
|
|
57
|
+
return qualified(BUILTINS, type_name)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@final
|
|
61
|
+
class AnnotationBuilder:
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
scalar_spellings: Mapping[str, SettledSpelling],
|
|
66
|
+
schema_spellings: SchemaSpellings,
|
|
67
|
+
) -> None:
|
|
68
|
+
self._scalar_spellings: Final = scalar_spellings
|
|
69
|
+
self._type_spellings: Final = schema_spellings.type_spellings
|
|
70
|
+
|
|
71
|
+
def build(
|
|
72
|
+
self,
|
|
73
|
+
type_: GraphQLType,
|
|
74
|
+
/,
|
|
75
|
+
*,
|
|
76
|
+
position: Position,
|
|
77
|
+
object_annotation: ast.expr | None,
|
|
78
|
+
bare_inputs: bool,
|
|
79
|
+
) -> ast.expr:
|
|
80
|
+
"""*object_annotation* names a composite type, since it depends on the selection rather than on the schema."""
|
|
81
|
+
if isinstance(type_, GraphQLNonNull):
|
|
82
|
+
return self._build_non_null(
|
|
83
|
+
type_.of_type,
|
|
84
|
+
position=position,
|
|
85
|
+
object_annotation=object_annotation,
|
|
86
|
+
bare_inputs=bare_inputs,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return optional(
|
|
90
|
+
self._build_non_null(
|
|
91
|
+
type_,
|
|
92
|
+
position=position,
|
|
93
|
+
object_annotation=object_annotation,
|
|
94
|
+
bare_inputs=bare_inputs,
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def _build_non_null(
|
|
99
|
+
self,
|
|
100
|
+
type_: GraphQLType,
|
|
101
|
+
/,
|
|
102
|
+
*,
|
|
103
|
+
position: Position,
|
|
104
|
+
object_annotation: ast.expr | None,
|
|
105
|
+
bare_inputs: bool,
|
|
106
|
+
) -> ast.expr:
|
|
107
|
+
match type_:
|
|
108
|
+
case GraphQLList():
|
|
109
|
+
element = self.build(
|
|
110
|
+
type_.of_type,
|
|
111
|
+
position=position,
|
|
112
|
+
object_annotation=object_annotation,
|
|
113
|
+
bare_inputs=bare_inputs,
|
|
114
|
+
)
|
|
115
|
+
match position:
|
|
116
|
+
case "input":
|
|
117
|
+
container = qualified(ABC, "Sequence")
|
|
118
|
+
case "data":
|
|
119
|
+
container = builtin("list")
|
|
120
|
+
case _ as never:
|
|
121
|
+
assert_never(never)
|
|
122
|
+
|
|
123
|
+
return subscript(container, element)
|
|
124
|
+
case GraphQLScalarType():
|
|
125
|
+
return self._scalar_annotation(type_.name)
|
|
126
|
+
case GraphQLEnumType() if is_introspection_type(type_):
|
|
127
|
+
# Not a type of the schema, so not in its package: spelled where it is selected.
|
|
128
|
+
return subscript(
|
|
129
|
+
qualified(TYPING, "Literal"),
|
|
130
|
+
*(constant(value_name) for value_name in type_.values),
|
|
131
|
+
)
|
|
132
|
+
case GraphQLEnumType():
|
|
133
|
+
return qualified(ENUM, self._type_spellings[type_.name])
|
|
134
|
+
case GraphQLInputObjectType():
|
|
135
|
+
# Unquoted even though input types reference each other in cycles: generated modules postpone the evaluation of annotations.
|
|
136
|
+
spelling = self._type_spellings[type_.name]
|
|
137
|
+
return name(spelling) if bare_inputs else qualified(INPUT, spelling)
|
|
138
|
+
case _:
|
|
139
|
+
assert object_annotation is not None, (
|
|
140
|
+
f"Expected the caller to name the type for `{type_}`."
|
|
141
|
+
)
|
|
142
|
+
return object_annotation
|
|
143
|
+
|
|
144
|
+
def _scalar_annotation(self, scalar_name: str, /) -> ast.expr:
|
|
145
|
+
if scalar_name in self._scalar_spellings:
|
|
146
|
+
return qualified(SCALAR, self._scalar_spellings[scalar_name])
|
|
147
|
+
|
|
148
|
+
# An unconfigured custom scalar stays opaque rather than silently becoming `str`: the wire value is whatever JSON the server sent.
|
|
149
|
+
return builtin(BUILT_IN_SCALARS.get(scalar_name, "object"))
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from ast import literal_eval
|
|
3
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
4
|
+
from io import StringIO
|
|
5
|
+
from itertools import accumulate, pairwise
|
|
6
|
+
from textwrap import indent
|
|
7
|
+
from tokenize import NEWLINE, STRING, generate_tokens
|
|
8
|
+
from types import EllipsisType
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
from graphql_codegen._generator.spelling import Spelling, ast_str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def name(spelling: Spelling, /) -> ast.Name:
|
|
15
|
+
return ast.Name(id=ast_str(spelling), ctx=ast.Load())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def qualified(module_alias: Spelling, attribute: str, /) -> ast.Attribute:
|
|
19
|
+
"""`_alias.attribute`: how a generated module refers to anything it did not declare.
|
|
20
|
+
|
|
21
|
+
Qualifying every such reference, builtins included, is what keeps a generated name from ever shadowing one the module uses: an operation called `str` cannot hide `_builtins.str`.
|
|
22
|
+
"""
|
|
23
|
+
return ast.Attribute(value=name(module_alias), attr=attribute, ctx=ast.Load())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def import_as(module_name: str, alias: Spelling, /) -> ast.Import:
|
|
27
|
+
return ast.Import(names=[ast.alias(name=module_name, asname=ast_str(alias))])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def constant(value: str | int | EllipsisType | None, /) -> ast.Constant:
|
|
31
|
+
return ast.Constant(value=value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
FUTURE_ANNOTATIONS: Final = ast.ImportFrom(
|
|
35
|
+
module="__future__",
|
|
36
|
+
names=[ast.alias(name="annotations")],
|
|
37
|
+
level=0,
|
|
38
|
+
)
|
|
39
|
+
"""Emitted by the input module, whose types reference each other in cycles, such as a filter combining filters, so that annotations need no quoting.
|
|
40
|
+
|
|
41
|
+
The cost: under PEP 563, a TypedDict's :attr:`~typing.TypedDict.__required_keys__` is computed from unresolved strings and reports every key as required.
|
|
42
|
+
Nothing here reads it, since nothing is validated at runtime, and type checkers, which read the source, are unaffected.
|
|
43
|
+
Code introspecting these types at runtime must use :func:`typing.get_type_hints` rather than the class attributes.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def subscript(value: ast.expr, *items: ast.expr) -> ast.Subscript:
|
|
48
|
+
index: ast.expr = (
|
|
49
|
+
items[0] if len(items) == 1 else ast.Tuple(elts=list(items), ctx=ast.Load())
|
|
50
|
+
)
|
|
51
|
+
return ast.Subscript(value=value, slice=index, ctx=ast.Load())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def union(*options: ast.expr) -> ast.expr:
|
|
55
|
+
assert options, "A union needs at least one option."
|
|
56
|
+
result = options[0]
|
|
57
|
+
|
|
58
|
+
for option in options[1:]:
|
|
59
|
+
result = ast.BinOp(left=result, op=ast.BitOr(), right=option)
|
|
60
|
+
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def optional(annotation: ast.expr, /) -> ast.expr:
|
|
65
|
+
return union(annotation, constant(None))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def type_alias(target: Spelling, value: ast.expr, /) -> ast.TypeAlias:
|
|
69
|
+
"""Preferred over `X: TypeAlias = ...`, since a PEP 695 alias evaluates its value lazily, so it may name types declared after it."""
|
|
70
|
+
return ast.TypeAlias(
|
|
71
|
+
name=ast.Name(id=ast_str(target), ctx=ast.Store()),
|
|
72
|
+
type_params=[],
|
|
73
|
+
value=value,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def documented(statement: ast.stmt, /, *, text: str) -> list[ast.stmt]:
|
|
78
|
+
"""A `type` statement and an annotated attribute have no docstring slot in the grammar, but tooling widely understands a bare string literal directly underneath."""
|
|
79
|
+
return [statement, docstring(text)] if text else [statement]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def docstring(text: str, /) -> ast.Expr:
|
|
83
|
+
"""A docstring holding *text*: :func:`ast.unparse` drops comments, so whatever the generated code tells its reader must be a docstring, which is a real node."""
|
|
84
|
+
return ast.Expr(value=constant(text))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def class_definition(
|
|
88
|
+
class_spelling: Spelling,
|
|
89
|
+
/,
|
|
90
|
+
*,
|
|
91
|
+
bases: Sequence[ast.expr],
|
|
92
|
+
keywords: Mapping[str, ast.expr],
|
|
93
|
+
body: Sequence[ast.stmt],
|
|
94
|
+
docstring_text: str,
|
|
95
|
+
) -> ast.ClassDef:
|
|
96
|
+
statements: list[ast.stmt] = list(body)
|
|
97
|
+
|
|
98
|
+
if docstring_text:
|
|
99
|
+
statements.insert(0, docstring(docstring_text))
|
|
100
|
+
|
|
101
|
+
if not statements:
|
|
102
|
+
statements.append(ast.Expr(value=constant(Ellipsis)))
|
|
103
|
+
|
|
104
|
+
return ast.ClassDef(
|
|
105
|
+
name=ast_str(class_spelling),
|
|
106
|
+
bases=list(bases),
|
|
107
|
+
keywords=[
|
|
108
|
+
ast.keyword(arg=keyword, value=value) for keyword, value in keywords.items()
|
|
109
|
+
],
|
|
110
|
+
body=statements,
|
|
111
|
+
decorator_list=[],
|
|
112
|
+
type_params=[],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def relative_import_from(
|
|
117
|
+
module: str | None,
|
|
118
|
+
names: Iterable[str],
|
|
119
|
+
/,
|
|
120
|
+
*,
|
|
121
|
+
level: int,
|
|
122
|
+
re_export: bool,
|
|
123
|
+
) -> ast.ImportFrom:
|
|
124
|
+
"""When *re_export* is ``True``, each name is aliased to itself, as in `from .X import X as X`, which is how PEP 484 marks an explicit re-export.
|
|
125
|
+
|
|
126
|
+
Otherwise, a type checker treats the import as private to the module and rejects importing it from the package.
|
|
127
|
+
"""
|
|
128
|
+
return ast.ImportFrom(
|
|
129
|
+
module=module,
|
|
130
|
+
names=[
|
|
131
|
+
ast.alias(name=imported, asname=imported if re_export else None)
|
|
132
|
+
for imported in names
|
|
133
|
+
],
|
|
134
|
+
level=level,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def module(body: Sequence[ast.stmt], /) -> ast.Module:
|
|
139
|
+
return ast.Module(body=list(body), type_ignores=[])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _string_literal(value: str, /, *, quotes: Sequence[str]) -> str:
|
|
143
|
+
escaped = "".join(
|
|
144
|
+
character
|
|
145
|
+
if character in "\n\t" or (character.isprintable() and character != "\\")
|
|
146
|
+
else character.encode("unicode_escape").decode()
|
|
147
|
+
for character in value
|
|
148
|
+
)
|
|
149
|
+
usable = [quote for quote in quotes if quote not in escaped]
|
|
150
|
+
|
|
151
|
+
if not usable:
|
|
152
|
+
return repr(value)
|
|
153
|
+
|
|
154
|
+
# A quote ending like the value would need that last character escaped.
|
|
155
|
+
quote = min(usable, key=lambda quote: escaped.endswith(quote[0]))
|
|
156
|
+
|
|
157
|
+
if escaped.endswith(quote[0]):
|
|
158
|
+
escaped = f"{escaped[:-1]}\\{escaped[-1]}"
|
|
159
|
+
|
|
160
|
+
return f"{quote}{escaped}{quote}"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _triple_quote(source: str, /) -> str:
|
|
164
|
+
"""Respell with triple quotes the bare strings, which document the statement above them, and the strings spanning lines.
|
|
165
|
+
|
|
166
|
+
:func:`ast.unparse` only does this in docstring position; everywhere else a newline becomes a backslash-n inside a single line literal.
|
|
167
|
+
GraphQL documents are the only multi-line strings this library emits, and a triple quoted literal keeps them readable.
|
|
168
|
+
|
|
169
|
+
The continuation lines of a bare string are indented to match, as a hand written docstring would be.
|
|
170
|
+
That adds leading whitespace to the value, which is what :func:`inspect.cleandoc` exists to remove and what every tool rendering a docstring already does.
|
|
171
|
+
"""
|
|
172
|
+
# Split as the tokenizer reads it.
|
|
173
|
+
line_starts = list(accumulate(map(len, StringIO(source)), initial=0))
|
|
174
|
+
pieces: list[str] = []
|
|
175
|
+
end = 0
|
|
176
|
+
|
|
177
|
+
for token, following in pairwise(generate_tokens(StringIO(source).readline)):
|
|
178
|
+
if token.type != STRING:
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
value = literal_eval(token.string)
|
|
182
|
+
row, column = token.start
|
|
183
|
+
|
|
184
|
+
# A docstring too, which `ast.unparse()` quotes but leaves unindented.
|
|
185
|
+
if following.type == NEWLINE and not token.line[:column].strip():
|
|
186
|
+
# Every line but the first, which follows the opening quotes.
|
|
187
|
+
margin = " " * column
|
|
188
|
+
literal = _string_literal(
|
|
189
|
+
indent(value, margin).removeprefix(margin), quotes=['"""']
|
|
190
|
+
)
|
|
191
|
+
elif "\n" in value and not token.string.startswith(('"""', "'''")):
|
|
192
|
+
literal = _string_literal(value, quotes=['"""', "'''"])
|
|
193
|
+
else:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
start = line_starts[row - 1] + column
|
|
197
|
+
pieces += [source[end:start], literal]
|
|
198
|
+
end = line_starts[token.end[0] - 1] + token.end[1]
|
|
199
|
+
|
|
200
|
+
return "".join([*pieces, source[end:]])
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def unparse(tree: ast.Module, /) -> str:
|
|
204
|
+
"""Checked to compile, so that a bad node fails here rather than downstream.
|
|
205
|
+
|
|
206
|
+
Generated code is always built as a tree and unparsed, never assembled from strings, which can be syntactically wrong and need forward references quoted by hand.
|
|
207
|
+
:func:`ast.unparse` has no formatting options, such as line wrapping or blank lines, and the standard library has no formatter.
|
|
208
|
+
Unparsing each top-level statement separately and grouping them still gives imports as a block and one blank line between declarations.
|
|
209
|
+
Wrapping long lines is left to whatever formatter the consuming project runs; this library depends on none.
|
|
210
|
+
|
|
211
|
+
"""
|
|
212
|
+
ast.fix_missing_locations(tree)
|
|
213
|
+
|
|
214
|
+
chunks: list[str] = []
|
|
215
|
+
previous_was_import = False
|
|
216
|
+
|
|
217
|
+
for statement in tree.body:
|
|
218
|
+
is_import = isinstance(statement, ast.Import | ast.ImportFrom)
|
|
219
|
+
chunk = ast.unparse(statement)
|
|
220
|
+
|
|
221
|
+
# A bare string documents the statement above it, so it must stay glued to it.
|
|
222
|
+
is_docstring = isinstance(statement, ast.Expr) and isinstance(
|
|
223
|
+
statement.value,
|
|
224
|
+
ast.Constant,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if chunks and not (is_import and previous_was_import) and not is_docstring:
|
|
228
|
+
chunks.append("")
|
|
229
|
+
|
|
230
|
+
chunks.append(chunk)
|
|
231
|
+
previous_was_import = is_import
|
|
232
|
+
|
|
233
|
+
source = _triple_quote("\n".join(chunks))
|
|
234
|
+
compile(source, "<generated>", "exec")
|
|
235
|
+
return f"{source}\n"
|