snake-orm 0.1.0b1__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.
- snake_orm-0.1.0b1.dist-info/METADATA +366 -0
- snake_orm-0.1.0b1.dist-info/RECORD +198 -0
- snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
- snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
- snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
- snakeorm/__init__.py +214 -0
- snakeorm/advisor.py +109 -0
- snakeorm/cli/__init__.py +3 -0
- snakeorm/cli/app.py +992 -0
- snakeorm/cli/discovery.py +179 -0
- snakeorm/cli/hooks.py +86 -0
- snakeorm/compiler/__init__.py +3 -0
- snakeorm/compiler/compiler.py +330 -0
- snakeorm/connection.py +228 -0
- snakeorm/contrib/__init__.py +17 -0
- snakeorm/contrib/asgi.py +192 -0
- snakeorm/contrib/config.py +264 -0
- snakeorm/contrib/deliver.py +256 -0
- snakeorm/contrib/django.py +245 -0
- snakeorm/contrib/sidecar.py +44 -0
- snakeorm/contrib/wsgi.py +176 -0
- snakeorm/core/__init__.py +1 -0
- snakeorm/core/config.py +173 -0
- snakeorm/core/converters.py +172 -0
- snakeorm/core/exceptions.py +175 -0
- snakeorm/core/placement.py +15 -0
- snakeorm/core/sentinels.py +37 -0
- snakeorm/core/signals.py +114 -0
- snakeorm/debug/__init__.py +42 -0
- snakeorm/debug/assets/css/panel.css +616 -0
- snakeorm/debug/assets/css/snake_orm_app.css +293 -0
- snakeorm/debug/assets/img/icon.svg +5 -0
- snakeorm/debug/assets/js/history.js +425 -0
- snakeorm/debug/assets/js/language.js +283 -0
- snakeorm/debug/assets/js/panel.js +241 -0
- snakeorm/debug/assets/js/snake_orm_app.js +82 -0
- snakeorm/debug/assets/pages/cli.html +28 -0
- snakeorm/debug/assets/pages/config.html +34 -0
- snakeorm/debug/assets/pages/database_first.html +29 -0
- snakeorm/debug/assets/pages/help.html +21 -0
- snakeorm/debug/assets/pages/history.html +27 -0
- snakeorm/debug/capture.py +234 -0
- snakeorm/debug/channel.py +131 -0
- snakeorm/debug/collector.py +144 -0
- snakeorm/debug/config.py +149 -0
- snakeorm/debug/html.py +578 -0
- snakeorm/debug/matrix.py +60 -0
- snakeorm/debug/origin.py +42 -0
- snakeorm/debug/otel/__init__.py +68 -0
- snakeorm/debug/otel/context.py +43 -0
- snakeorm/debug/otel/exporter.py +277 -0
- snakeorm/debug/otel/payload.py +97 -0
- snakeorm/debug/otel/report.py +29 -0
- snakeorm/debug/otel/spans.py +393 -0
- snakeorm/debug/otel/summary.py +59 -0
- snakeorm/debug/record.py +54 -0
- snakeorm/debug/report.py +311 -0
- snakeorm/debug/testing.py +23 -0
- snakeorm/decorators/__init__.py +13 -0
- snakeorm/decorators/abstract.py +26 -0
- snakeorm/decorators/db_first.py +104 -0
- snakeorm/decorators/function.py +28 -0
- snakeorm/decorators/model.py +268 -0
- snakeorm/decorators/polymorphic.py +140 -0
- snakeorm/decorators/result.py +167 -0
- snakeorm/decorators/row.py +90 -0
- snakeorm/decorators/trigger.py +47 -0
- snakeorm/decorators/view.py +149 -0
- snakeorm/dialects/__init__.py +6 -0
- snakeorm/dialects/base.py +373 -0
- snakeorm/dialects/capabilities.py +571 -0
- snakeorm/dialects/literals.py +46 -0
- snakeorm/dialects/matrix.py +384 -0
- snakeorm/dialects/mysql.py +519 -0
- snakeorm/dialects/postgres.py +504 -0
- snakeorm/dialects/sqlite.py +417 -0
- snakeorm/drivers/__init__.py +18 -0
- snakeorm/drivers/asyncbase.py +73 -0
- snakeorm/drivers/asyncdecorators.py +244 -0
- snakeorm/drivers/asyncpool.py +228 -0
- snakeorm/drivers/asyncpsycopg.py +124 -0
- snakeorm/drivers/asyncpymysql.py +50 -0
- snakeorm/drivers/asyncsqlite.py +30 -0
- snakeorm/drivers/base.py +76 -0
- snakeorm/drivers/failures.py +181 -0
- snakeorm/drivers/logging.py +167 -0
- snakeorm/drivers/pool.py +352 -0
- snakeorm/drivers/psycopg.py +188 -0
- snakeorm/drivers/pymysql.py +176 -0
- snakeorm/drivers/savepoints.py +46 -0
- snakeorm/drivers/sqlite.py +170 -0
- snakeorm/drivers/threaded.py +143 -0
- snakeorm/drivers/timeout.py +87 -0
- snakeorm/dto/__init__.py +55 -0
- snakeorm/dto/read.py +231 -0
- snakeorm/dto/region.py +326 -0
- snakeorm/dto/resolve.py +354 -0
- snakeorm/dto/spec.py +201 -0
- snakeorm/expressions/__init__.py +88 -0
- snakeorm/expressions/conditional.py +178 -0
- snakeorm/expressions/expression.py +810 -0
- snakeorm/expressions/functions.py +71 -0
- snakeorm/expressions/keys.py +299 -0
- snakeorm/expressions/paths.py +43 -0
- snakeorm/expressions/scalar.py +293 -0
- snakeorm/expressions/window.py +248 -0
- snakeorm/fields/__init__.py +68 -0
- snakeorm/fields/check.py +79 -0
- snakeorm/fields/column.py +316 -0
- snakeorm/fields/enum.py +64 -0
- snakeorm/fields/index.py +46 -0
- snakeorm/fields/relationship.py +738 -0
- snakeorm/fields/typed.py +736 -0
- snakeorm/helpers/__init__.py +1 -0
- snakeorm/helpers/annotations.py +21 -0
- snakeorm/helpers/inheritance.py +41 -0
- snakeorm/helpers/pyliteral.py +52 -0
- snakeorm/helpers/pytype.py +50 -0
- snakeorm/introspection/__init__.py +13 -0
- snakeorm/introspection/base.py +30 -0
- snakeorm/introspection/drift.py +78 -0
- snakeorm/introspection/models.py +633 -0
- snakeorm/introspection/mysql.py +367 -0
- snakeorm/introspection/postgres.py +397 -0
- snakeorm/introspection/sqlite.py +217 -0
- snakeorm/introspection/unsupported.py +94 -0
- snakeorm/linker/__init__.py +3 -0
- snakeorm/linker/linker.py +521 -0
- snakeorm/metadata/__init__.py +41 -0
- snakeorm/metadata/check.py +37 -0
- snakeorm/metadata/column.py +201 -0
- snakeorm/metadata/enum_storage.py +33 -0
- snakeorm/metadata/fk_action.py +19 -0
- snakeorm/metadata/foreign_key.py +26 -0
- snakeorm/metadata/index.py +43 -0
- snakeorm/metadata/index_method.py +19 -0
- snakeorm/metadata/int_size.py +20 -0
- snakeorm/metadata/json_storage.py +21 -0
- snakeorm/metadata/polymorphic.py +30 -0
- snakeorm/metadata/primary_key.py +23 -0
- snakeorm/metadata/relationship.py +60 -0
- snakeorm/metadata/relationship_kind.py +43 -0
- snakeorm/metadata/routine.py +26 -0
- snakeorm/metadata/server_default.py +20 -0
- snakeorm/metadata/table.py +97 -0
- snakeorm/metadata/table_kind.py +23 -0
- snakeorm/metadata/trigger.py +62 -0
- snakeorm/metadata/type_params.py +348 -0
- snakeorm/migration/__init__.py +88 -0
- snakeorm/migration/asyncrunner.py +168 -0
- snakeorm/migration/autodetect.py +96 -0
- snakeorm/migration/ddl.py +1172 -0
- snakeorm/migration/diff.py +701 -0
- snakeorm/migration/loader.py +77 -0
- snakeorm/migration/operations.py +952 -0
- snakeorm/migration/planner.py +78 -0
- snakeorm/migration/realize.py +420 -0
- snakeorm/migration/renames.py +153 -0
- snakeorm/migration/render.py +1095 -0
- snakeorm/migration/runner.py +314 -0
- snakeorm/migration/squash.py +60 -0
- snakeorm/migration/state.py +107 -0
- snakeorm/model.py +162 -0
- snakeorm/py.typed +0 -0
- snakeorm/query/__init__.py +9 -0
- snakeorm/query/compound.py +288 -0
- snakeorm/query/join_kind.py +19 -0
- snakeorm/query/joined.py +143 -0
- snakeorm/query/query.py +1001 -0
- snakeorm/query/recursive.py +244 -0
- snakeorm/registry/__init__.py +5 -0
- snakeorm/registry/by_module.py +163 -0
- snakeorm/registry/registry.py +166 -0
- snakeorm/session/__init__.py +8 -0
- snakeorm/session/asyncsession.py +638 -0
- snakeorm/session/coercion.py +306 -0
- snakeorm/session/factory.py +29 -0
- snakeorm/session/guards.py +269 -0
- snakeorm/session/isolation.py +23 -0
- snakeorm/session/mapper.py +213 -0
- snakeorm/session/planning.py +815 -0
- snakeorm/session/retry.py +96 -0
- snakeorm/session/session.py +871 -0
- snakeorm/session/shared.py +355 -0
- snakeorm/sql/__init__.py +18 -0
- snakeorm/sql/adapt.py +104 -0
- snakeorm/sql/aggregate.py +232 -0
- snakeorm/sql/condition.py +359 -0
- snakeorm/sql/delete.py +46 -0
- snakeorm/sql/insert.py +149 -0
- snakeorm/sql/joins.py +158 -0
- snakeorm/sql/pk_subquery.py +54 -0
- snakeorm/sql/refs.py +19 -0
- snakeorm/sql/resolver.py +28 -0
- snakeorm/sql/select.py +175 -0
- snakeorm/sql/update.py +82 -0
- snakeorm/sql/value.py +552 -0
- snakeorm/times.py +276 -0
snakeorm/dto/read.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Reading the declarations out of a DTO file with `ast`, without importing or running it.
|
|
2
|
+
|
|
3
|
+
The declarations live under `if TYPE_CHECKING:`, so there is nothing to run: the interpreter never
|
|
4
|
+
reaches them and only a checker looks. That is not an obstacle this module works around, it is the
|
|
5
|
+
arrangement that makes the whole thing safe — a tool that rewrites your file does not execute your
|
|
6
|
+
file, and the check mode in particular touches nothing of yours at all.
|
|
7
|
+
|
|
8
|
+
The ONLY thing imported is the models module the file names, because the compiled metadata is where
|
|
9
|
+
the types, the nullability and the relationships live. The AST gives names; `SnakeTableInfo` gives
|
|
10
|
+
the truth.
|
|
11
|
+
|
|
12
|
+
Reading also sees something evaluating cannot. The source says which name a path is ROOTED at, and
|
|
13
|
+
the evaluated descriptor has forgotten: `Post.author.username` and `Author.username` both come out
|
|
14
|
+
as a `SnakeExpr` that knows its own steps and not whose they are. So a spec over `Post` that selects
|
|
15
|
+
`Author.id` — which type-checks, because `Author.id` is a perfectly good expression — is a refusal
|
|
16
|
+
here and was invisible before.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
import importlib
|
|
23
|
+
|
|
24
|
+
from snakeorm.core.exceptions import SnakeDtoError
|
|
25
|
+
from snakeorm.dto.spec import SnakeDtoPick, SnakeDtoSpec, build_spec
|
|
26
|
+
|
|
27
|
+
_SNAKE_DTO = "snakeorm.dto.snake_dto"
|
|
28
|
+
"""The dotted path the declaration function is imported from.
|
|
29
|
+
|
|
30
|
+
Matched through the file's IMPORTS rather than by the literal text `snake_dto`, so an alias reads
|
|
31
|
+
and somebody else's function of the same name does not.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def specs_in_source(source: str, *, path: str = "<source>") -> tuple[SnakeDtoSpec, ...]:
|
|
36
|
+
"""Every `snake_dto(...)` declaration in this source, in the order it was written.
|
|
37
|
+
|
|
38
|
+
Model modules are imported as they are named — that is what compiles the metadata — but the file
|
|
39
|
+
being read never is.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
tree = ast.parse(source, filename=path)
|
|
43
|
+
except SyntaxError as error:
|
|
44
|
+
raise SnakeDtoError(
|
|
45
|
+
f"{path} is not valid Python, so there are no declarations to read: {error}"
|
|
46
|
+
) from error
|
|
47
|
+
statements = _reachable(tree)
|
|
48
|
+
imports = _imported_paths(statements)
|
|
49
|
+
callers = {local for local, dotted in imports.items() if dotted == _SNAKE_DTO}
|
|
50
|
+
return tuple(
|
|
51
|
+
_spec_of(call, imports, path)
|
|
52
|
+
for statement in statements
|
|
53
|
+
if isinstance(statement, ast.Expr)
|
|
54
|
+
and isinstance(statement.value, ast.Call)
|
|
55
|
+
and _called_name(statement.value) in callers
|
|
56
|
+
for call in [statement.value]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _reachable(tree: ast.Module) -> list[ast.stmt]:
|
|
61
|
+
"""The module's own statements, plus the bodies of its `if TYPE_CHECKING:` blocks.
|
|
62
|
+
|
|
63
|
+
Both spellings of the guard count, the bare name and the attribute. Recognising only one would
|
|
64
|
+
reject a file that is correct, which is the same fail-in-the-open shape as identifying a
|
|
65
|
+
TypedDict by the text of its base class.
|
|
66
|
+
"""
|
|
67
|
+
statements: list[ast.stmt] = []
|
|
68
|
+
for statement in tree.body:
|
|
69
|
+
if isinstance(statement, ast.If) and _is_type_checking(statement.test):
|
|
70
|
+
statements.extend(statement.body)
|
|
71
|
+
else:
|
|
72
|
+
statements.append(statement)
|
|
73
|
+
return statements
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_type_checking(test: ast.expr) -> bool:
|
|
77
|
+
"""Whether an `if` guards a type-checking-only block, written either way round."""
|
|
78
|
+
if isinstance(test, ast.Name):
|
|
79
|
+
return test.id == "TYPE_CHECKING"
|
|
80
|
+
return isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _imported_paths(statements: list[ast.stmt]) -> dict[str, str]:
|
|
84
|
+
"""Every name the file binds by importing, mapped to the dotted path it came from.
|
|
85
|
+
|
|
86
|
+
Only `from x import Y`, because that is what binds a CLASS. A plain `import x.y` binds a module,
|
|
87
|
+
and a module is neither a model nor the declaration function. A relative import is left out: it
|
|
88
|
+
gives no absolute path to import back, and guessing the package from the file would be guessing.
|
|
89
|
+
"""
|
|
90
|
+
paths: dict[str, str] = {}
|
|
91
|
+
for statement in statements:
|
|
92
|
+
if not isinstance(statement, ast.ImportFrom) or statement.module is None:
|
|
93
|
+
continue
|
|
94
|
+
if statement.level:
|
|
95
|
+
continue
|
|
96
|
+
for alias in statement.names:
|
|
97
|
+
paths[alias.asname or alias.name] = f"{statement.module}.{alias.name}"
|
|
98
|
+
return paths
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _called_name(call: ast.Call) -> str:
|
|
102
|
+
"""The bare name a call is made through, or `""` for anything more complicated."""
|
|
103
|
+
return call.func.id if isinstance(call.func, ast.Name) else ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _spec_of(call: ast.Call, imports: dict[str, str], path: str) -> SnakeDtoSpec:
|
|
107
|
+
"""One `snake_dto(...)` call turned into a spec, with its model imported for real."""
|
|
108
|
+
where = f"{path}:{call.lineno}"
|
|
109
|
+
if len(call.args) != 1 or not isinstance(call.args[0], ast.Name):
|
|
110
|
+
raise SnakeDtoError(
|
|
111
|
+
f"{where}: snake_dto() takes the MODEL CLASS as its only positional argument, written "
|
|
112
|
+
f"as the name the file imports it under."
|
|
113
|
+
)
|
|
114
|
+
root = call.args[0].id
|
|
115
|
+
model = _model(root, imports, where)
|
|
116
|
+
name = _name(call, where)
|
|
117
|
+
fields = _list(call, "fields")
|
|
118
|
+
excluded = _list(call, "exclude")
|
|
119
|
+
return build_spec(
|
|
120
|
+
model=model,
|
|
121
|
+
name=name,
|
|
122
|
+
fields=None
|
|
123
|
+
if fields is None
|
|
124
|
+
else tuple(_pick(item, root, name, where) for item in fields),
|
|
125
|
+
exclude=None
|
|
126
|
+
if excluded is None
|
|
127
|
+
else tuple(_pick(item, root, name, where).path for item in excluded),
|
|
128
|
+
where=where,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _model(root: str, imports: dict[str, str], where: str) -> type:
|
|
133
|
+
"""The model class, resolved through the file's own import of it and then imported for real.
|
|
134
|
+
|
|
135
|
+
Through the import and never through a registry index keyed by class name: two applications can
|
|
136
|
+
each declare a `Customer`, and that index is kept by whichever registered last. The dotted path
|
|
137
|
+
the user already wrote IS an identity, so the module is imported and the attribute read off it.
|
|
138
|
+
"""
|
|
139
|
+
dotted = imports.get(root)
|
|
140
|
+
if dotted is None:
|
|
141
|
+
raise SnakeDtoError(
|
|
142
|
+
f"{where}: {root!r} is not imported in this file, so there is no telling which model "
|
|
143
|
+
f"it is. Add it under `if TYPE_CHECKING:` next to the declaration."
|
|
144
|
+
)
|
|
145
|
+
module_name, _, attribute = dotted.rpartition(".")
|
|
146
|
+
try:
|
|
147
|
+
module = importlib.import_module(module_name)
|
|
148
|
+
except ImportError as error:
|
|
149
|
+
raise SnakeDtoError(
|
|
150
|
+
f"{where}: {root!r} comes from {module_name!r}, which cannot be imported: {error}."
|
|
151
|
+
) from error
|
|
152
|
+
found = getattr(module, attribute, None)
|
|
153
|
+
if not isinstance(found, type):
|
|
154
|
+
raise SnakeDtoError(
|
|
155
|
+
f"{where}: {module_name!r} has no class called {attribute!r}."
|
|
156
|
+
)
|
|
157
|
+
return found
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _name(call: ast.Call, where: str) -> str:
|
|
161
|
+
"""The `name=` keyword, which has to be a plain string literal to be readable without running."""
|
|
162
|
+
for keyword in call.keywords:
|
|
163
|
+
if keyword.arg == "name":
|
|
164
|
+
value = keyword.value
|
|
165
|
+
if isinstance(value, ast.Constant) and isinstance(value.value, str):
|
|
166
|
+
return value.value
|
|
167
|
+
raise SnakeDtoError(
|
|
168
|
+
f"{where}: name= has to be a plain string literal. It is read out of the source "
|
|
169
|
+
f"without running it, so an expression cannot be resolved."
|
|
170
|
+
)
|
|
171
|
+
raise SnakeDtoError(
|
|
172
|
+
f"{where}: this declaration has no name=, and that is what the generated class is called."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _list(call: ast.Call, keyword: str) -> list[ast.expr] | None:
|
|
177
|
+
"""One of the two switches as a list of expressions, or `None` when it was not given."""
|
|
178
|
+
for item in call.keywords:
|
|
179
|
+
if item.arg == keyword:
|
|
180
|
+
if not isinstance(item.value, ast.List):
|
|
181
|
+
raise SnakeDtoError(
|
|
182
|
+
f"{keyword}= has to be written as a list literal, so it can be read without "
|
|
183
|
+
f"running the file."
|
|
184
|
+
)
|
|
185
|
+
return list(item.value.elts)
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _pick(entry: ast.expr, root: str, dto: str, where: str) -> SnakeDtoPick:
|
|
190
|
+
"""One selected entry: the path it names, and the DTO it was disambiguated with if any."""
|
|
191
|
+
if isinstance(entry, ast.Tuple):
|
|
192
|
+
if len(entry.elts) != 2 or not isinstance(entry.elts[1], ast.Constant):
|
|
193
|
+
raise SnakeDtoError(
|
|
194
|
+
f'{where}: a paired entry of {dto} is written `(Model.relation, "DtoName")`, '
|
|
195
|
+
f"with the name as a plain string."
|
|
196
|
+
)
|
|
197
|
+
named = entry.elts[1].value
|
|
198
|
+
if not isinstance(named, str):
|
|
199
|
+
raise SnakeDtoError(
|
|
200
|
+
f"{where}: the DTO name paired with an entry of {dto} has to be a string."
|
|
201
|
+
)
|
|
202
|
+
return SnakeDtoPick(path=_chain(entry.elts[0], root, dto, where), dto=named)
|
|
203
|
+
return SnakeDtoPick(path=_chain(entry, root, dto, where))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _chain(entry: ast.expr, root: str, dto: str, where: str) -> tuple[str, ...]:
|
|
207
|
+
"""The attribute chain an entry is written as, checked to start at the model being described.
|
|
208
|
+
|
|
209
|
+
The ROOT check is the one that only reading can do. `snake_dto(Post, fields=[Author.id])`
|
|
210
|
+
type-checks — `Author.id` is a valid expression — and the value it produces has forgotten it
|
|
211
|
+
came from `Author`. `Post` has an `id` too, so resolving the evaluated path lands on the wrong
|
|
212
|
+
column and says nothing.
|
|
213
|
+
"""
|
|
214
|
+
steps: list[str] = []
|
|
215
|
+
node = entry
|
|
216
|
+
while isinstance(node, ast.Attribute):
|
|
217
|
+
steps.append(node.attr)
|
|
218
|
+
node = node.value
|
|
219
|
+
if not isinstance(node, ast.Name) or not steps:
|
|
220
|
+
raise SnakeDtoError(
|
|
221
|
+
f"{where}: {dto} selects something that is not class access on a model. An entry is "
|
|
222
|
+
f"written `Model.column`, `Model.relation.column` or `Model.relation`, so that the "
|
|
223
|
+
f"type checker validates it where you wrote it."
|
|
224
|
+
)
|
|
225
|
+
if node.id != root:
|
|
226
|
+
written = ".".join([node.id, *reversed(steps)])
|
|
227
|
+
raise SnakeDtoError(
|
|
228
|
+
f"{where}: {dto} describes {root} and selects {written}, which starts somewhere else. "
|
|
229
|
+
f"Every entry has to be rooted at the model the declaration names."
|
|
230
|
+
)
|
|
231
|
+
return tuple(reversed(steps))
|
snakeorm/dto/region.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""The marked region: the only part of a DTO file this generator owns, and the only part it writes.
|
|
2
|
+
|
|
3
|
+
A DTO file has two owners with a visible line between them. Above, the specs, which are the user's
|
|
4
|
+
and are never edited. Between the markers, the classes, which are the generator's and are rewritten
|
|
5
|
+
whole on every run. Nothing outside the two marker lines is ever touched — that is a property of the
|
|
6
|
+
splice, not of care taken while writing it.
|
|
7
|
+
|
|
8
|
+
A marker EARNS its place here in a way it would not have in somebody's own class body. These classes
|
|
9
|
+
were never typed by a person: without the boundary there is no way to tell a generated class from a
|
|
10
|
+
hand-written one, and a tool that cannot tell would eventually overwrite the wrong thing.
|
|
11
|
+
|
|
12
|
+
The region is regenerated rather than patched, which is what makes idempotence structural: the same
|
|
13
|
+
specs and the same models render the same bytes, so a second run has nothing to write. What is
|
|
14
|
+
DIFFED, field by field, is the old region against the new one — and because the region is this
|
|
15
|
+
module's own output, it can be read strictly instead of being guessed at.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from enum import Enum
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from snakeorm.core.exceptions import SnakeDtoError
|
|
27
|
+
from snakeorm.dto.read import specs_in_source
|
|
28
|
+
from snakeorm.dto.resolve import SnakeDtoShape, resolve_all
|
|
29
|
+
from snakeorm.helpers.pytype import FROM_SNAKEORM
|
|
30
|
+
|
|
31
|
+
BEGIN = "# snakeorm-dto: begin generated block"
|
|
32
|
+
"""First line of the region. Matched as a PREFIX so the explanation after it can be reworded."""
|
|
33
|
+
|
|
34
|
+
END = "# snakeorm-dto: end generated block"
|
|
35
|
+
"""Last line of the region."""
|
|
36
|
+
|
|
37
|
+
_HEADER = f"{BEGIN} — written by `snakeorm dto --sync`, edit the specs above"
|
|
38
|
+
_FOOTER = END
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SnakeDtoChangeKind(Enum):
|
|
42
|
+
"""What happened to one field. Three kinds, because they are three different risks."""
|
|
43
|
+
|
|
44
|
+
ADDED = "added"
|
|
45
|
+
REMOVED = "removed"
|
|
46
|
+
RETYPED = "retyped"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class SnakeDtoChange:
|
|
51
|
+
"""One field this run added, dropped or retyped, with the text on both sides."""
|
|
52
|
+
|
|
53
|
+
dto: str
|
|
54
|
+
field: str
|
|
55
|
+
kind: SnakeDtoChangeKind
|
|
56
|
+
before: str = ""
|
|
57
|
+
after: str = ""
|
|
58
|
+
|
|
59
|
+
def describe(self) -> str:
|
|
60
|
+
"""The change as ONE line, which is what the command prints.
|
|
61
|
+
|
|
62
|
+
Every change is printed, always. A generator that writes into your file and reports only
|
|
63
|
+
failures is a generator you have to `git diff` to trust.
|
|
64
|
+
"""
|
|
65
|
+
if self.kind is SnakeDtoChangeKind.ADDED:
|
|
66
|
+
return f"{self.dto}: added `{self.after}`"
|
|
67
|
+
if self.kind is SnakeDtoChangeKind.REMOVED:
|
|
68
|
+
return f"{self.dto}: removed `{self.before}`"
|
|
69
|
+
return f"{self.dto}: retyped `{self.before}` -> `{self.after}`"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class SnakeDtoSyncResult:
|
|
74
|
+
"""The source as it should be, and every field that moved to get there."""
|
|
75
|
+
|
|
76
|
+
source: str
|
|
77
|
+
changes: tuple[SnakeDtoChange, ...]
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def changed(self) -> bool:
|
|
81
|
+
"""Whether anything moved at all. `False` is what a second run has to say."""
|
|
82
|
+
return bool(self.changes)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def sync_source(source: str, *, path: str = "<source>") -> SnakeDtoSyncResult:
|
|
86
|
+
"""The file with its generated region brought in line with its specs, and what that took.
|
|
87
|
+
|
|
88
|
+
Nothing is written here — this returns text — so a refusal halfway through cannot leave a file
|
|
89
|
+
half rewritten. And nothing of the user's is ever EXECUTED: the specs are read out of the source
|
|
90
|
+
with `ast`, and the only thing imported is the models module they name.
|
|
91
|
+
"""
|
|
92
|
+
specs = specs_in_source(source, path=path)
|
|
93
|
+
lines = source.splitlines(keepends=True)
|
|
94
|
+
start, stop = _locate(lines, path)
|
|
95
|
+
if not specs:
|
|
96
|
+
return SnakeDtoSyncResult(source=source, changes=())
|
|
97
|
+
shapes = resolve_all(specs)
|
|
98
|
+
_check_scope(shapes, source, path)
|
|
99
|
+
newline = _newline_of(lines)
|
|
100
|
+
rendered = _render(shapes, newline)
|
|
101
|
+
existing = lines[start:stop] if start is not None and stop is not None else []
|
|
102
|
+
changes = _diff(_parse_region(existing), shapes)
|
|
103
|
+
if existing == rendered:
|
|
104
|
+
return SnakeDtoSyncResult(source=source, changes=())
|
|
105
|
+
if start is None or stop is None:
|
|
106
|
+
# No region yet: it goes at the END of the file, always, so where it lands is a rule and
|
|
107
|
+
# not a judgement about somebody's layout.
|
|
108
|
+
head = (
|
|
109
|
+
lines
|
|
110
|
+
if not lines or lines[-1].endswith(("\n", "\r"))
|
|
111
|
+
else [*lines[:-1], lines[-1] + newline]
|
|
112
|
+
)
|
|
113
|
+
return SnakeDtoSyncResult(
|
|
114
|
+
source="".join([*head, newline, newline, *rendered]), changes=changes
|
|
115
|
+
)
|
|
116
|
+
return SnakeDtoSyncResult(
|
|
117
|
+
source="".join([*lines[:start], *rendered, *lines[stop:]]), changes=changes
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def sync_file(path: Path, *, write: bool) -> SnakeDtoSyncResult:
|
|
122
|
+
"""Same over a file on disk. With `write=False` it reports and touches nothing.
|
|
123
|
+
|
|
124
|
+
Written only when something actually moved, so a clean run does not restamp the modification
|
|
125
|
+
time and set half the build tooling off.
|
|
126
|
+
"""
|
|
127
|
+
result = sync_source(path.read_text(encoding="utf-8"), path=str(path))
|
|
128
|
+
if write and result.changed:
|
|
129
|
+
path.write_text(result.source, encoding="utf-8")
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _locate(lines: list[str], path: str) -> tuple[int | None, int | None]:
|
|
134
|
+
"""Where the region starts and ends, as line indices, or `(None, None)` when there is none.
|
|
135
|
+
|
|
136
|
+
Every way of being malformed is an error rather than a repair: an unterminated region has no end
|
|
137
|
+
to write up to, and two regions have no rule saying which is the real one. Guessing either way
|
|
138
|
+
is how a generator eats the half of the file it did not understand.
|
|
139
|
+
"""
|
|
140
|
+
starts = [index for index, line in enumerate(lines) if line.startswith(BEGIN)]
|
|
141
|
+
ends = [index for index, line in enumerate(lines) if line.startswith(END)]
|
|
142
|
+
if not starts and not ends:
|
|
143
|
+
return None, None
|
|
144
|
+
if len(starts) > 1 or len(ends) > 1:
|
|
145
|
+
raise SnakeDtoError(
|
|
146
|
+
f"{path} holds more than one generated region, and there is no rule saying which is the "
|
|
147
|
+
f"real one. Nothing was written: delete the extra `{BEGIN}` block."
|
|
148
|
+
)
|
|
149
|
+
if not starts:
|
|
150
|
+
raise SnakeDtoError(
|
|
151
|
+
f"{path} has a `{END}` with no beginning. Nothing was written."
|
|
152
|
+
)
|
|
153
|
+
if not ends:
|
|
154
|
+
raise SnakeDtoError(
|
|
155
|
+
f"{path} opens a generated region at line {starts[0] + 1} and never closes it with "
|
|
156
|
+
f"`{END}`. There is no end to write up to, so nothing was written."
|
|
157
|
+
)
|
|
158
|
+
if ends[0] < starts[0]:
|
|
159
|
+
raise SnakeDtoError(
|
|
160
|
+
f"{path} closes its generated region before it opens it. Nothing was written."
|
|
161
|
+
)
|
|
162
|
+
return starts[0], ends[0] + 1
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _render(shapes: Sequence[SnakeDtoShape], newline: str) -> list[str]:
|
|
166
|
+
"""The whole region as lines, markers included. Deterministic, which is where idempotence lives."""
|
|
167
|
+
out = [_HEADER + newline]
|
|
168
|
+
for index, shape in enumerate(shapes):
|
|
169
|
+
if index:
|
|
170
|
+
out.extend([newline, newline])
|
|
171
|
+
out.append(f"class {shape.name}(TypedDict):{newline}")
|
|
172
|
+
for field in shape.fields:
|
|
173
|
+
out.append(f" {field.name}: {field.annotation}{newline}")
|
|
174
|
+
if not shape.fields:
|
|
175
|
+
# A class body cannot be empty, and a spec CAN legitimately select nothing.
|
|
176
|
+
out.append(f" pass{newline}")
|
|
177
|
+
out.append(_FOOTER + newline)
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _parse_region(lines: list[str]) -> dict[str, dict[str, str]]:
|
|
182
|
+
"""`{class: {field: annotation}}` for the region as it stands on disk.
|
|
183
|
+
|
|
184
|
+
Read with `ast` and not with a regular expression, because this is the generator's own output
|
|
185
|
+
and the shape of it is known exactly. Anything unparseable comes back empty, which reports every
|
|
186
|
+
field as added — loud, and correct: a region that is not what this tool writes IS out of date.
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
tree = ast.parse("".join(line for line in lines if not line.startswith("#")))
|
|
190
|
+
except SyntaxError:
|
|
191
|
+
return {}
|
|
192
|
+
found: dict[str, dict[str, str]] = {}
|
|
193
|
+
for node in tree.body:
|
|
194
|
+
if not isinstance(node, ast.ClassDef):
|
|
195
|
+
continue
|
|
196
|
+
found[node.name] = {
|
|
197
|
+
statement.target.id: ast.unparse(statement.annotation)
|
|
198
|
+
for statement in node.body
|
|
199
|
+
if isinstance(statement, ast.AnnAssign)
|
|
200
|
+
and isinstance(statement.target, ast.Name)
|
|
201
|
+
}
|
|
202
|
+
return found
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _diff(
|
|
206
|
+
before: dict[str, dict[str, str]], shapes: Sequence[SnakeDtoShape]
|
|
207
|
+
) -> tuple[SnakeDtoChange, ...]:
|
|
208
|
+
"""Every field that moved between the region on disk and the one about to be written.
|
|
209
|
+
|
|
210
|
+
A whole class appearing reports as its fields being added, and a class disappearing as its
|
|
211
|
+
fields being removed. One vocabulary instead of two: what a reader wants to know is which keys
|
|
212
|
+
the response gained or lost, and a class is only how they are grouped.
|
|
213
|
+
"""
|
|
214
|
+
changes: list[SnakeDtoChange] = []
|
|
215
|
+
wanted = {shape.name: shape for shape in shapes}
|
|
216
|
+
for shape in shapes:
|
|
217
|
+
old = before.get(shape.name, {})
|
|
218
|
+
for field in shape.fields:
|
|
219
|
+
written = f"{field.name}: {field.annotation}"
|
|
220
|
+
if field.name not in old:
|
|
221
|
+
changes.append(
|
|
222
|
+
SnakeDtoChange(
|
|
223
|
+
dto=shape.name,
|
|
224
|
+
field=field.name,
|
|
225
|
+
kind=SnakeDtoChangeKind.ADDED,
|
|
226
|
+
after=written,
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
elif old[field.name] != field.annotation:
|
|
230
|
+
changes.append(
|
|
231
|
+
SnakeDtoChange(
|
|
232
|
+
dto=shape.name,
|
|
233
|
+
field=field.name,
|
|
234
|
+
kind=SnakeDtoChangeKind.RETYPED,
|
|
235
|
+
before=f"{field.name}: {old[field.name]}",
|
|
236
|
+
after=written,
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
current = {field.name for field in shape.fields}
|
|
240
|
+
for name, annotation in old.items():
|
|
241
|
+
if name not in current:
|
|
242
|
+
changes.append(
|
|
243
|
+
SnakeDtoChange(
|
|
244
|
+
dto=shape.name,
|
|
245
|
+
field=name,
|
|
246
|
+
kind=SnakeDtoChangeKind.REMOVED,
|
|
247
|
+
before=f"{name}: {annotation}",
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
for name, old in before.items():
|
|
251
|
+
if name in wanted:
|
|
252
|
+
continue
|
|
253
|
+
for field_name, annotation in old.items():
|
|
254
|
+
changes.append(
|
|
255
|
+
SnakeDtoChange(
|
|
256
|
+
dto=name,
|
|
257
|
+
field=field_name,
|
|
258
|
+
kind=SnakeDtoChangeKind.REMOVED,
|
|
259
|
+
before=f"{field_name}: {annotation}",
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
return tuple(changes)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _check_scope(shapes: Sequence[SnakeDtoShape], source: str, path: str) -> None:
|
|
266
|
+
"""Refuses anything whose name the file does not already have in scope, and writes no imports.
|
|
267
|
+
|
|
268
|
+
`TypedDict` is included, and it is the one people forget: the generator writes
|
|
269
|
+
`class X(TypedDict)` and cannot make that resolve on its own. Editing somebody's import block on
|
|
270
|
+
a guess is a much bigger claim on their file than filling a region they marked out; getting it
|
|
271
|
+
wrong means the file stops importing, so the generator would have broken the build to fix a
|
|
272
|
+
type. Saying which line to add costs the reader one line and cannot go wrong.
|
|
273
|
+
"""
|
|
274
|
+
in_scope = _names_in_scope(source, path)
|
|
275
|
+
if "TypedDict" not in in_scope:
|
|
276
|
+
raise SnakeDtoError(
|
|
277
|
+
f"{path} does not have `TypedDict` in scope, and every generated class is written "
|
|
278
|
+
f"`class X(TypedDict)`. Add `from typing import TypedDict` yourself — or from "
|
|
279
|
+
f"`typing_extensions` if these shapes are read by pydantic or FastAPI on Python 3.11, "
|
|
280
|
+
f"where the one in `typing` does not carry what pydantic needs and it refuses. This "
|
|
281
|
+
f"command writes classes, never imports."
|
|
282
|
+
)
|
|
283
|
+
for shape in shapes:
|
|
284
|
+
for field in shape.fields:
|
|
285
|
+
if field.requires is None:
|
|
286
|
+
continue
|
|
287
|
+
if field.requires == FROM_SNAKEORM:
|
|
288
|
+
symbol = field.annotation.split(" ")[0]
|
|
289
|
+
needed, line = symbol, f"from snakeorm import {symbol}"
|
|
290
|
+
else:
|
|
291
|
+
needed = field.requires.split(".")[0]
|
|
292
|
+
line = f"import {field.requires}"
|
|
293
|
+
if needed not in in_scope:
|
|
294
|
+
raise SnakeDtoError(
|
|
295
|
+
f"{shape.name} needs `{field.name}: {field.annotation}` and {path} does not "
|
|
296
|
+
f"have `{needed}` in scope. Add `{line}` yourself and run this again: this "
|
|
297
|
+
f"command writes classes, never imports."
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _names_in_scope(source: str, path: str) -> set[str]:
|
|
302
|
+
"""Every name the file's imports bind, wherever in the file they are written.
|
|
303
|
+
|
|
304
|
+
The whole tree, so an import parked under `if TYPE_CHECKING:` counts — which is exactly where a
|
|
305
|
+
name used only in annotations belongs, and where these specs live. Being generous here can only
|
|
306
|
+
let a class THROUGH; the checker reads the same file and has the last word.
|
|
307
|
+
"""
|
|
308
|
+
bound: set[str] = set()
|
|
309
|
+
for node in ast.walk(ast.parse(source, filename=path)):
|
|
310
|
+
if isinstance(node, ast.Import):
|
|
311
|
+
for alias in node.names:
|
|
312
|
+
bound.add(alias.asname or alias.name.split(".")[0])
|
|
313
|
+
elif isinstance(node, ast.ImportFrom):
|
|
314
|
+
for alias in node.names:
|
|
315
|
+
bound.add(alias.asname or alias.name)
|
|
316
|
+
return bound
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _newline_of(lines: list[str]) -> str:
|
|
320
|
+
"""The file's line ending, so what gets inserted matches what is already there."""
|
|
321
|
+
for line in lines:
|
|
322
|
+
if line.endswith("\r\n"):
|
|
323
|
+
return "\r\n"
|
|
324
|
+
if line.endswith("\n"):
|
|
325
|
+
return "\n"
|
|
326
|
+
return "\n"
|