trolskgen 0.0.1__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Oliver Russell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: trolskgen
3
+ Version: 0.0.1
4
+ Summary: Cute Python codegen
5
+ Author-email: Oliver Russell <ojhrussell@gmail.com>
6
+ Maintainer-email: Oliver Russell <ojhrussell@gmail.com>
7
+ Project-URL: homepage, https://github.com/leontrolski/trolskgen
8
+ Keywords: codegen
9
+ Classifier: Programming Language :: Python
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENCE
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest==8.3.5; extra == "dev"
15
+ Requires-Dist: ruff==0.11.2; extra == "dev"
16
+ Requires-Dist: mypy==1.15.0; extra == "dev"
17
+ Requires-Dist: rich==14.0.0; extra == "dev"
18
+ Requires-Dist: pydantic==2.11.7; extra == "dev"
19
+ Dynamic: license-file
20
+
21
+ # Trolskgen
22
+
23
+ # Hello World
24
+
25
+ After `pip install trolskgen`, just run:
26
+
27
+ # TODO
28
+
29
+ - README
30
+ - Walk through each converter
31
+ - Plugins example
32
+ - Implement comments somehow? Monkeypatch ast?
33
+ - Notes on comments - use docstrings etc.
34
+
35
+ # Development
36
+
37
+ ```
38
+ uv pip install -e '.[dev]'
39
+ mypy .
40
+ pytest -vv
41
+ ```
@@ -0,0 +1,21 @@
1
+ # Trolskgen
2
+
3
+ # Hello World
4
+
5
+ After `pip install trolskgen`, just run:
6
+
7
+ # TODO
8
+
9
+ - README
10
+ - Walk through each converter
11
+ - Plugins example
12
+ - Implement comments somehow? Monkeypatch ast?
13
+ - Notes on comments - use docstrings etc.
14
+
15
+ # Development
16
+
17
+ ```
18
+ uv pip install -e '.[dev]'
19
+ mypy .
20
+ pytest -vv
21
+ ```
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "trolskgen"
3
+ version = "0.0.1"
4
+ description = "Cute Python codegen"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = {file = "LICENSE"}
8
+ keywords = ["codegen"]
9
+ authors = [
10
+ {name = "Oliver Russell", email = "ojhrussell@gmail.com"},
11
+ ]
12
+ maintainers = [
13
+ {name = "Oliver Russell", email = "ojhrussell@gmail.com"},
14
+ ]
15
+ classifiers = [
16
+ "Programming Language :: Python",
17
+ ]
18
+
19
+ dependencies = []
20
+
21
+ [project.optional-dependencies]
22
+ dev = [
23
+ "pytest==8.3.5",
24
+ "ruff==0.11.2",
25
+ "mypy==1.15.0",
26
+ "rich==14.0.0",
27
+ "pydantic==2.11.7",
28
+ ]
29
+
30
+ [project.urls]
31
+ homepage = "https://github.com/leontrolski/trolskgen"
32
+
33
+ [tool.ruff]
34
+ line-length = 120
35
+
36
+ [tool.mypy]
37
+ strict = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ from trolskgen.core import Config as Config
2
+ from trolskgen.core import F as F
3
+ from trolskgen.core import TrolskgenError as TrolskgenError
4
+ from trolskgen.core import to_ast as to_ast
5
+ from trolskgen.core import to_source as to_source
6
+ from trolskgen.templates import t as t
@@ -0,0 +1,205 @@
1
+ import ast
2
+ import sys
3
+ from types import NoneType
4
+ from typing import Any
5
+
6
+ FIELD_MAPS: dict[type[Any], dict[str, Any]] = {
7
+ NoneType: {},
8
+ str: {},
9
+ int: {},
10
+ object: {},
11
+ ast.AST: {},
12
+ ast.mod: {},
13
+ ast.Module: {"body": list[ast.stmt], "type_ignores": list[ast.type_ignore]},
14
+ ast.Interactive: {"body": list[ast.stmt]},
15
+ ast.Expression: {"body": ast.expr},
16
+ ast.FunctionType: {"argtypes": list[ast.expr], "returns": ast.expr},
17
+ ast.stmt: {},
18
+ ast.FunctionDef: {
19
+ "name": str,
20
+ "args": ast.arguments,
21
+ "body": list[ast.stmt],
22
+ "decorator_list": list[ast.expr],
23
+ "returns": ast.expr | NoneType,
24
+ "type_comment": str | NoneType,
25
+ "type_params": list[ast.type_param],
26
+ },
27
+ ast.AsyncFunctionDef: {
28
+ "name": str,
29
+ "args": ast.arguments,
30
+ "body": list[ast.stmt],
31
+ "decorator_list": list[ast.expr],
32
+ "returns": ast.expr | NoneType,
33
+ "type_comment": str | NoneType,
34
+ "type_params": list[ast.type_param],
35
+ },
36
+ ast.ClassDef: {
37
+ "name": str,
38
+ "bases": list[ast.expr],
39
+ "keywords": list[ast.keyword],
40
+ "body": list[ast.stmt],
41
+ "decorator_list": list[ast.expr],
42
+ "type_params": list[ast.type_param],
43
+ },
44
+ ast.Return: {"value": ast.expr | NoneType},
45
+ ast.Delete: {"targets": list[ast.expr]},
46
+ ast.Assign: {"targets": list[ast.expr], "value": ast.expr, "type_comment": str | NoneType},
47
+ ast.TypeAlias: {"name": ast.expr, "type_params": list[ast.type_param], "value": ast.expr},
48
+ ast.AugAssign: {"target": ast.expr, "op": ast.operator, "value": ast.expr},
49
+ ast.AnnAssign: {"target": ast.expr, "annotation": ast.expr, "value": ast.expr | NoneType, "simple": int},
50
+ ast.For: {
51
+ "target": ast.expr,
52
+ "iter": ast.expr,
53
+ "body": list[ast.stmt],
54
+ "orelse": list[ast.stmt],
55
+ "type_comment": str | NoneType,
56
+ },
57
+ ast.AsyncFor: {
58
+ "target": ast.expr,
59
+ "iter": ast.expr,
60
+ "body": list[ast.stmt],
61
+ "orelse": list[ast.stmt],
62
+ "type_comment": str | NoneType,
63
+ },
64
+ ast.While: {"test": ast.expr, "body": list[ast.stmt], "orelse": list[ast.stmt]},
65
+ ast.If: {"test": ast.expr, "body": list[ast.stmt], "orelse": list[ast.stmt]},
66
+ ast.With: {"items": list[ast.withitem], "body": list[ast.stmt], "type_comment": str | NoneType},
67
+ ast.AsyncWith: {"items": list[ast.withitem], "body": list[ast.stmt], "type_comment": str | NoneType},
68
+ ast.Match: {"subject": ast.expr, "cases": list[ast.match_case]},
69
+ ast.Raise: {"exc": ast.expr | NoneType, "cause": ast.expr | NoneType},
70
+ ast.Try: {
71
+ "body": list[ast.stmt],
72
+ "handlers": list[ast.excepthandler],
73
+ "orelse": list[ast.stmt],
74
+ "finalbody": list[ast.stmt],
75
+ },
76
+ ast.TryStar: {
77
+ "body": list[ast.stmt],
78
+ "handlers": list[ast.excepthandler],
79
+ "orelse": list[ast.stmt],
80
+ "finalbody": list[ast.stmt],
81
+ },
82
+ ast.Assert: {"test": ast.expr, "msg": ast.expr | NoneType},
83
+ ast.Import: {"names": list[ast.alias]},
84
+ ast.ImportFrom: {"module": str | NoneType, "names": list[ast.alias], "level": int | NoneType},
85
+ ast.Global: {"names": list[str]},
86
+ ast.Nonlocal: {"names": list[str]},
87
+ ast.Expr: {"value": ast.expr},
88
+ ast.Pass: {},
89
+ ast.Break: {},
90
+ ast.Continue: {},
91
+ ast.expr: {},
92
+ ast.BoolOp: {"op": ast.boolop, "values": list[ast.expr]},
93
+ ast.NamedExpr: {"target": ast.expr, "value": ast.expr},
94
+ ast.BinOp: {"left": ast.expr, "op": ast.operator, "right": ast.expr},
95
+ ast.UnaryOp: {"op": ast.unaryop, "operand": ast.expr},
96
+ ast.Lambda: {"args": ast.arguments, "body": ast.expr},
97
+ ast.IfExp: {"test": ast.expr, "body": ast.expr, "orelse": ast.expr},
98
+ ast.Dict: {"keys": list[ast.expr], "values": list[ast.expr]},
99
+ ast.Set: {"elts": list[ast.expr]},
100
+ ast.ListComp: {"elt": ast.expr, "generators": list[ast.comprehension]},
101
+ ast.SetComp: {"elt": ast.expr, "generators": list[ast.comprehension]},
102
+ ast.DictComp: {"key": ast.expr, "value": ast.expr, "generators": list[ast.comprehension]},
103
+ ast.GeneratorExp: {"elt": ast.expr, "generators": list[ast.comprehension]},
104
+ ast.Await: {"value": ast.expr},
105
+ ast.Yield: {"value": ast.expr | NoneType},
106
+ ast.YieldFrom: {"value": ast.expr},
107
+ ast.Compare: {"left": ast.expr, "ops": list[ast.cmpop], "comparators": list[ast.expr]},
108
+ ast.Call: {"func": ast.expr, "args": list[ast.expr], "keywords": list[ast.keyword]},
109
+ ast.FormattedValue: {"value": ast.expr, "conversion": int, "format_spec": ast.expr | NoneType},
110
+ ast.JoinedStr: {"values": list[ast.expr]},
111
+ ast.Constant: {"value": object, "kind": str | NoneType},
112
+ ast.Attribute: {"value": ast.expr, "attr": str},
113
+ ast.Subscript: {"value": ast.expr, "slice": ast.expr},
114
+ ast.Starred: {"value": ast.expr},
115
+ ast.Name: {"id": str},
116
+ ast.List: {"elts": list[ast.expr]},
117
+ ast.Tuple: {"elts": list[ast.expr]},
118
+ ast.Slice: {"lower": ast.expr | NoneType, "upper": ast.expr | NoneType, "step": ast.expr | NoneType},
119
+ ast.expr_context: {},
120
+ ast.Load: {},
121
+ ast.Store: {},
122
+ ast.Del: {},
123
+ ast.boolop: {},
124
+ ast.And: {},
125
+ ast.Or: {},
126
+ ast.operator: {},
127
+ ast.Add: {},
128
+ ast.Sub: {},
129
+ ast.Mult: {},
130
+ ast.MatMult: {},
131
+ ast.Div: {},
132
+ ast.Mod: {},
133
+ ast.Pow: {},
134
+ ast.LShift: {},
135
+ ast.RShift: {},
136
+ ast.BitOr: {},
137
+ ast.BitXor: {},
138
+ ast.BitAnd: {},
139
+ ast.FloorDiv: {},
140
+ ast.unaryop: {},
141
+ ast.Invert: {},
142
+ ast.Not: {},
143
+ ast.UAdd: {},
144
+ ast.USub: {},
145
+ ast.cmpop: {},
146
+ ast.Eq: {},
147
+ ast.NotEq: {},
148
+ ast.Lt: {},
149
+ ast.LtE: {},
150
+ ast.Gt: {},
151
+ ast.GtE: {},
152
+ ast.Is: {},
153
+ ast.IsNot: {},
154
+ ast.In: {},
155
+ ast.NotIn: {},
156
+ ast.comprehension: {"target": ast.expr, "iter": ast.expr, "ifs": list[ast.expr], "is_async": int},
157
+ ast.excepthandler: {},
158
+ ast.ExceptHandler: {"type": ast.expr | NoneType, "name": str | NoneType, "body": list[ast.stmt]},
159
+ ast.arguments: {
160
+ "posonlyargs": list[ast.arg],
161
+ "args": list[ast.arg],
162
+ "vararg": ast.arg | NoneType,
163
+ "kwonlyargs": list[ast.arg],
164
+ "kw_defaults": list[ast.expr],
165
+ "kwarg": ast.arg | NoneType,
166
+ "defaults": list[ast.expr],
167
+ },
168
+ ast.arg: {"arg": str, "annotation": ast.expr | NoneType, "type_comment": str | NoneType},
169
+ ast.keyword: {"arg": str | NoneType, "value": ast.expr},
170
+ ast.alias: {"name": str, "asname": str | NoneType},
171
+ ast.withitem: {"context_expr": ast.expr, "optional_vars": ast.expr | NoneType},
172
+ ast.match_case: {"pattern": ast.pattern, "guard": ast.expr | NoneType, "body": list[ast.stmt]},
173
+ ast.pattern: {},
174
+ ast.MatchValue: {"value": ast.expr},
175
+ ast.MatchSingleton: {"value": object},
176
+ ast.MatchSequence: {"patterns": list[ast.pattern]},
177
+ ast.MatchMapping: {"keys": list[ast.expr], "patterns": list[ast.pattern], "rest": str | NoneType},
178
+ ast.MatchClass: {
179
+ "cls": ast.expr,
180
+ "patterns": list[ast.pattern],
181
+ "kwd_attrs": list[str],
182
+ "kwd_patterns": list[ast.pattern],
183
+ },
184
+ ast.MatchStar: {"name": str | NoneType},
185
+ ast.MatchAs: {"pattern": ast.pattern | NoneType, "name": str | NoneType},
186
+ ast.MatchOr: {"patterns": list[ast.pattern]},
187
+ ast.type_ignore: {},
188
+ ast.TypeIgnore: {"lineno": int, "tag": str},
189
+ ast.type_param: {},
190
+ ast.TypeVar: {"name": str, "bound": ast.expr | NoneType, "default_value": ast.expr | NoneType},
191
+ ast.ParamSpec: {"name": str, "default_value": ast.expr | NoneType},
192
+ ast.TypeVarTuple: {"name": str, "default_value": ast.expr | NoneType},
193
+ ast.slice: {},
194
+ ast.Index: {},
195
+ ast.ExtSlice: {},
196
+ ast.Suite: {},
197
+ ast.AugLoad: {},
198
+ ast.AugStore: {},
199
+ ast.Param: {},
200
+ }
201
+ if sys.version_info >= (3, 14):
202
+ FIELD_MAPS |= {
203
+ ast.Interpolation: {"value": ast.expr, "str": object, "conversion": int, "format_spec": ast.expr | NoneType},
204
+ ast.TemplateStr: {"values": list[ast.expr]},
205
+ }
@@ -0,0 +1,294 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import datetime as dt
5
+ import enum
6
+ import inspect
7
+ import textwrap
8
+ import zoneinfo
9
+ from dataclasses import MISSING, dataclass, fields, is_dataclass
10
+ from types import NoneType, UnionType
11
+ from typing import Annotated, Any, TypeVar, Union, cast, get_args, get_origin
12
+
13
+ from typing_extensions import TypeIs
14
+
15
+ from trolskgen import ast_types, core, templates
16
+
17
+ ASTValue = ast.AST | list[ast.stmt] | str
18
+ T = TypeVar("T", bound=ASTValue)
19
+
20
+
21
+ def converter_ast(o: Any, f: core.F) -> ast.AST | None:
22
+ if not isinstance(o, ast.AST):
23
+ return None
24
+ return o
25
+
26
+
27
+ @core.upcast_expr
28
+ def converter_simple(o: Any, f: core.F) -> ast.AST | None:
29
+ if isinstance(o, int | float | str | bool | NoneType):
30
+ return ast.Constant(value=o)
31
+ if isinstance(o, list):
32
+ return ast.List(elts=[_downcast(ast.expr, f(e)) for e in o])
33
+ if isinstance(o, tuple):
34
+ return ast.Tuple(elts=[_downcast(ast.expr, f(e)) for e in o])
35
+ if isinstance(o, dict):
36
+ return ast.Dict(
37
+ keys=[_downcast(ast.expr, f(k)) for k in o.keys()],
38
+ values=[_downcast(ast.expr, f(v)) for v in o.values()],
39
+ )
40
+ if isinstance(o, set):
41
+ return ast.Set(elts=[_downcast(ast.expr, f(e)) for e in o])
42
+ return None
43
+
44
+
45
+ DONT_PREFIX_WITH_MODULE = {"builtins", "typing"}
46
+
47
+
48
+ @core.upcast_expr
49
+ def converter_types_and_functions(o: Any, f: core.F) -> ast.AST | None:
50
+ if isinstance(o, type) or inspect.isfunction(o):
51
+ module_name = o.__module__ + "."
52
+ if o.__module__ in DONT_PREFIX_WITH_MODULE:
53
+ module_name = ""
54
+ return f(templates.t(module_name + o.__qualname__))
55
+ return None
56
+
57
+
58
+ class FieldMissing: ...
59
+
60
+
61
+ FIELD_MISSING = FieldMissing()
62
+
63
+
64
+ @core.upcast_expr
65
+ def converter_common(o: Any, f: core.F) -> ast.AST | None:
66
+ if o is dt.UTC:
67
+ return f(templates.t("dt.UTC"))
68
+ if isinstance(o, zoneinfo.ZoneInfo):
69
+ return f(templates.t("zoneinfo.ZoneInfo({key})", key=o.key))
70
+ if isinstance(o, dt.datetime):
71
+ parts: list[int | ast.AST] = [o.year, o.month, o.day]
72
+ if o.hour or o.minute or o.second or o.microsecond:
73
+ parts.append(o.hour)
74
+ parts.append(o.minute)
75
+ parts.append(o.second)
76
+ if o.microsecond:
77
+ parts.append(o.microsecond)
78
+ if o.tzinfo is not None:
79
+ parts.append(f(templates.t("tzinfo={tzinfo}", tzinfo=o.tzinfo)))
80
+ return f(templates.t("dt.datetime({parts:*})", parts=parts))
81
+ if isinstance(o, dt.date):
82
+ return f(templates.t("dt.date({year}, {month}, {day})", year=o.year, month=o.month, day=o.day))
83
+ if isinstance(o, enum.Enum):
84
+ return f(templates.t("{enum}.{name}", enum=type(o), name=templates.t(o.name)))
85
+ if is_dataclass(o):
86
+ args = list[ast.AST]()
87
+ for field in fields(o):
88
+ value = getattr(o, field.name, FIELD_MISSING)
89
+ if value == field.default:
90
+ continue
91
+ if field.default_factory != MISSING and value == field.default_factory():
92
+ continue
93
+ args.append(f(templates.t("{key}={value}", key=templates.t(field.name), value=value)))
94
+ return f(templates.t("{c}({args:*})", c=type(o), args=args))
95
+ return None
96
+
97
+
98
+ @core.upcast_expr
99
+ def converter_pydantic(o: Any, f: core.F) -> ast.AST | None:
100
+ import pydantic
101
+
102
+ if not isinstance(o, pydantic.BaseModel):
103
+ return None
104
+
105
+ args = list[ast.AST]()
106
+ for name, field in o.__class__.model_fields.items():
107
+ value = getattr(o, name, FIELD_MISSING)
108
+ if value == field.default:
109
+ continue
110
+ if field.default_factory and value == field.default_factory(): # type: ignore[call-arg]
111
+ continue
112
+ args.append(f(templates.t("{key}={value}", key=templates.t(name), value=value)))
113
+ return f(templates.t("{c}({args:*})", c=type(o), args=args))
114
+
115
+
116
+ @core.upcast_expr
117
+ def converter_typeform(o: Any, f: core.F) -> ast.AST | None:
118
+ if o is Annotated:
119
+ return f(templates.t("Annotated"))
120
+ if (origin := get_origin(o)) is not None:
121
+ if origin is Union or origin is UnionType:
122
+ a, b, *rest = get_args(o)
123
+ union = f(templates.t("{a} | {b}", a=a, b=b))
124
+ while rest:
125
+ b, *rest = rest
126
+ union = f(templates.t("{a} | {b}", a=union, b=f(b)))
127
+ return union
128
+ slice = [f(arg) for arg in get_args(o)]
129
+ return f(templates.t("{a}[{b:*}]", a=origin, b=slice))
130
+
131
+ return None
132
+
133
+
134
+ def converter_template(o: Any, f: core.F) -> ast.AST | None:
135
+ if not isinstance(o, templates.TemplateLike):
136
+ return None
137
+
138
+ o = templates.Template.from_templatelike(o)
139
+ parts = list[str]()
140
+ map = NameNodeMap()
141
+ separator: str = ", "
142
+ for part in o.parts:
143
+ if isinstance(part, str):
144
+ parts.append(part)
145
+ separator = _trailing_indent(part)
146
+ else:
147
+ assert isinstance(part, templates.Interpolation)
148
+ if part.format_spec == "*":
149
+ if not isinstance(part.value, list):
150
+ raise core.TrolskgenError(f"Can only splat lists of values, not: {part.value!r}")
151
+ v = [f(n) for n in part.value]
152
+ else:
153
+ v = [f(part.value)]
154
+
155
+ for i, u in enumerate(v):
156
+ if (named := _named(u)) is None:
157
+ name = map.insert(u)
158
+ parts.append(name)
159
+ else:
160
+ parts.append(named.name)
161
+ if named.annotation is not None:
162
+ name = map.insert(named.annotation)
163
+ parts.append(f": {name}")
164
+ if named.value is not None:
165
+ name = map.insert(named.value)
166
+ parts.append(f" = {name}")
167
+ if i != len(v) - 1:
168
+ parts.append(separator)
169
+
170
+ node_template = textwrap.dedent("".join(parts)).strip()
171
+ try:
172
+ node = ast.parse(node_template)
173
+ except SyntaxError as e:
174
+ raise core.TrolskgenError(f"SyntaxError with template:\n\n{node_template}") from e
175
+
176
+ _ast_replace(node, map)
177
+ assert not map.map
178
+ return node
179
+
180
+
181
+ # `converter_template` replacement helpers
182
+
183
+
184
+ class NameNodeMap:
185
+ def __init__(self) -> None:
186
+ self.i = -1
187
+ self.map = dict[str, ast.AST]()
188
+
189
+ def insert(self, v: ast.AST) -> str:
190
+ self.i += 1
191
+ name = f"__tg_{self.i}"
192
+ self.map[name] = v
193
+ return name
194
+
195
+ def pop(self, v: ast.AST | str) -> ast.AST | None:
196
+ if isinstance(v, ast.Expr):
197
+ v = v.value
198
+ if isinstance(v, ast.Name):
199
+ v = v.id
200
+ if isinstance(v, str) and v in self.map:
201
+ return self.map.pop(v)
202
+ return None
203
+
204
+
205
+ def _is_instance(v: ASTValue, t: type[T]) -> TypeIs[T]:
206
+ """`isinstance`, but handles `t = list[T], t = A | B`"""
207
+ if get_origin(t) is list:
208
+ [t_inner] = get_args(t)
209
+ return isinstance(v, list) and all(_is_instance(u, t_inner) for u in v)
210
+ if get_origin(t) is Union:
211
+ return any(_is_instance(v, t_inner) for t_inner in get_args(t))
212
+
213
+ return isinstance(v, t)
214
+
215
+
216
+ def _downcast(t: type[T], v: ASTValue) -> T:
217
+ """Keep removing layers of AST until hopefully, `_is_instance(v, t)`."""
218
+ v_original = v
219
+ while not _is_instance(v, t):
220
+ if isinstance(v, list) and get_origin(t) is list:
221
+ [r] = get_args(t)
222
+ return cast(T, [_downcast(r, u) for u in v])
223
+ if isinstance(v, ast.Module):
224
+ v = v.body
225
+ elif isinstance(v, list) and len(v) == 1:
226
+ v = v[0]
227
+ elif isinstance(v, ast.Expr):
228
+ v = v.value
229
+ elif isinstance(v, ast.Name):
230
+ v = v.id
231
+ else:
232
+ raise core.TrolskgenError(f"Cannot convert {v_original!r} to {t!r}, got as far as {v!r}")
233
+ return v
234
+
235
+
236
+ @dataclass
237
+ class _Named:
238
+ name: str
239
+ annotation: ast.expr | None
240
+ value: ast.expr | None
241
+
242
+
243
+ def _named(v: ast.AST) -> _Named | None:
244
+ """Convert eg. `a: int = 1` to `Named("a", int, 1)`."""
245
+ if isinstance(v, ast.Module) and len(v.body) == 1:
246
+ v = v.body[0]
247
+ if isinstance(v, ast.Assign) and len(v.targets) == 1 and isinstance(v.targets[0], ast.Name):
248
+ return _Named(v.targets[0].id, None, v.value)
249
+ if isinstance(v, ast.AnnAssign) and isinstance(v.target, ast.Name):
250
+ return _Named(v.target.id, v.annotation, v.value)
251
+ return None
252
+
253
+
254
+ def _trailing_indent(part: str) -> str:
255
+ """Get the trailing indent of a part.
256
+
257
+ If previous to the newline, there was a comma, assume we're in a
258
+ comma separated list.
259
+ """
260
+ lines = part.splitlines()
261
+ try:
262
+ trailing_comma = lines[-2][-1] == ","
263
+ except IndexError:
264
+ trailing_comma = False
265
+ if all(c == " " for c in lines[-1]) and not trailing_comma:
266
+ return "\n" + lines[-1]
267
+ return ", "
268
+
269
+
270
+ def _ast_replace(node: ast.AST, map: NameNodeMap) -> None:
271
+ """Traverse the AST, replacing nodes with items from the `map`."""
272
+ type_map = ast_types.FIELD_MAPS.get(type(node))
273
+ if not type_map:
274
+ return
275
+
276
+ for name, t in type_map.items():
277
+ v = getattr(node, name)
278
+
279
+ if (interpolation := map.pop(v)) is not None:
280
+ v = interpolation
281
+ elif isinstance(v, list):
282
+ for i, u in enumerate(v):
283
+ if (interpolation := map.pop(u)) is not None:
284
+ v[i] = interpolation
285
+
286
+ v = _downcast(t, v)
287
+ setattr(node, name, v)
288
+
289
+ # recurse
290
+ if isinstance(v, list):
291
+ for u in v:
292
+ _ast_replace(u, map)
293
+ else:
294
+ _ast_replace(v, map)