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.
Files changed (198) hide show
  1. snake_orm-0.1.0b1.dist-info/METADATA +366 -0
  2. snake_orm-0.1.0b1.dist-info/RECORD +198 -0
  3. snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
  4. snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
  5. snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
  6. snakeorm/__init__.py +214 -0
  7. snakeorm/advisor.py +109 -0
  8. snakeorm/cli/__init__.py +3 -0
  9. snakeorm/cli/app.py +992 -0
  10. snakeorm/cli/discovery.py +179 -0
  11. snakeorm/cli/hooks.py +86 -0
  12. snakeorm/compiler/__init__.py +3 -0
  13. snakeorm/compiler/compiler.py +330 -0
  14. snakeorm/connection.py +228 -0
  15. snakeorm/contrib/__init__.py +17 -0
  16. snakeorm/contrib/asgi.py +192 -0
  17. snakeorm/contrib/config.py +264 -0
  18. snakeorm/contrib/deliver.py +256 -0
  19. snakeorm/contrib/django.py +245 -0
  20. snakeorm/contrib/sidecar.py +44 -0
  21. snakeorm/contrib/wsgi.py +176 -0
  22. snakeorm/core/__init__.py +1 -0
  23. snakeorm/core/config.py +173 -0
  24. snakeorm/core/converters.py +172 -0
  25. snakeorm/core/exceptions.py +175 -0
  26. snakeorm/core/placement.py +15 -0
  27. snakeorm/core/sentinels.py +37 -0
  28. snakeorm/core/signals.py +114 -0
  29. snakeorm/debug/__init__.py +42 -0
  30. snakeorm/debug/assets/css/panel.css +616 -0
  31. snakeorm/debug/assets/css/snake_orm_app.css +293 -0
  32. snakeorm/debug/assets/img/icon.svg +5 -0
  33. snakeorm/debug/assets/js/history.js +425 -0
  34. snakeorm/debug/assets/js/language.js +283 -0
  35. snakeorm/debug/assets/js/panel.js +241 -0
  36. snakeorm/debug/assets/js/snake_orm_app.js +82 -0
  37. snakeorm/debug/assets/pages/cli.html +28 -0
  38. snakeorm/debug/assets/pages/config.html +34 -0
  39. snakeorm/debug/assets/pages/database_first.html +29 -0
  40. snakeorm/debug/assets/pages/help.html +21 -0
  41. snakeorm/debug/assets/pages/history.html +27 -0
  42. snakeorm/debug/capture.py +234 -0
  43. snakeorm/debug/channel.py +131 -0
  44. snakeorm/debug/collector.py +144 -0
  45. snakeorm/debug/config.py +149 -0
  46. snakeorm/debug/html.py +578 -0
  47. snakeorm/debug/matrix.py +60 -0
  48. snakeorm/debug/origin.py +42 -0
  49. snakeorm/debug/otel/__init__.py +68 -0
  50. snakeorm/debug/otel/context.py +43 -0
  51. snakeorm/debug/otel/exporter.py +277 -0
  52. snakeorm/debug/otel/payload.py +97 -0
  53. snakeorm/debug/otel/report.py +29 -0
  54. snakeorm/debug/otel/spans.py +393 -0
  55. snakeorm/debug/otel/summary.py +59 -0
  56. snakeorm/debug/record.py +54 -0
  57. snakeorm/debug/report.py +311 -0
  58. snakeorm/debug/testing.py +23 -0
  59. snakeorm/decorators/__init__.py +13 -0
  60. snakeorm/decorators/abstract.py +26 -0
  61. snakeorm/decorators/db_first.py +104 -0
  62. snakeorm/decorators/function.py +28 -0
  63. snakeorm/decorators/model.py +268 -0
  64. snakeorm/decorators/polymorphic.py +140 -0
  65. snakeorm/decorators/result.py +167 -0
  66. snakeorm/decorators/row.py +90 -0
  67. snakeorm/decorators/trigger.py +47 -0
  68. snakeorm/decorators/view.py +149 -0
  69. snakeorm/dialects/__init__.py +6 -0
  70. snakeorm/dialects/base.py +373 -0
  71. snakeorm/dialects/capabilities.py +571 -0
  72. snakeorm/dialects/literals.py +46 -0
  73. snakeorm/dialects/matrix.py +384 -0
  74. snakeorm/dialects/mysql.py +519 -0
  75. snakeorm/dialects/postgres.py +504 -0
  76. snakeorm/dialects/sqlite.py +417 -0
  77. snakeorm/drivers/__init__.py +18 -0
  78. snakeorm/drivers/asyncbase.py +73 -0
  79. snakeorm/drivers/asyncdecorators.py +244 -0
  80. snakeorm/drivers/asyncpool.py +228 -0
  81. snakeorm/drivers/asyncpsycopg.py +124 -0
  82. snakeorm/drivers/asyncpymysql.py +50 -0
  83. snakeorm/drivers/asyncsqlite.py +30 -0
  84. snakeorm/drivers/base.py +76 -0
  85. snakeorm/drivers/failures.py +181 -0
  86. snakeorm/drivers/logging.py +167 -0
  87. snakeorm/drivers/pool.py +352 -0
  88. snakeorm/drivers/psycopg.py +188 -0
  89. snakeorm/drivers/pymysql.py +176 -0
  90. snakeorm/drivers/savepoints.py +46 -0
  91. snakeorm/drivers/sqlite.py +170 -0
  92. snakeorm/drivers/threaded.py +143 -0
  93. snakeorm/drivers/timeout.py +87 -0
  94. snakeorm/dto/__init__.py +55 -0
  95. snakeorm/dto/read.py +231 -0
  96. snakeorm/dto/region.py +326 -0
  97. snakeorm/dto/resolve.py +354 -0
  98. snakeorm/dto/spec.py +201 -0
  99. snakeorm/expressions/__init__.py +88 -0
  100. snakeorm/expressions/conditional.py +178 -0
  101. snakeorm/expressions/expression.py +810 -0
  102. snakeorm/expressions/functions.py +71 -0
  103. snakeorm/expressions/keys.py +299 -0
  104. snakeorm/expressions/paths.py +43 -0
  105. snakeorm/expressions/scalar.py +293 -0
  106. snakeorm/expressions/window.py +248 -0
  107. snakeorm/fields/__init__.py +68 -0
  108. snakeorm/fields/check.py +79 -0
  109. snakeorm/fields/column.py +316 -0
  110. snakeorm/fields/enum.py +64 -0
  111. snakeorm/fields/index.py +46 -0
  112. snakeorm/fields/relationship.py +738 -0
  113. snakeorm/fields/typed.py +736 -0
  114. snakeorm/helpers/__init__.py +1 -0
  115. snakeorm/helpers/annotations.py +21 -0
  116. snakeorm/helpers/inheritance.py +41 -0
  117. snakeorm/helpers/pyliteral.py +52 -0
  118. snakeorm/helpers/pytype.py +50 -0
  119. snakeorm/introspection/__init__.py +13 -0
  120. snakeorm/introspection/base.py +30 -0
  121. snakeorm/introspection/drift.py +78 -0
  122. snakeorm/introspection/models.py +633 -0
  123. snakeorm/introspection/mysql.py +367 -0
  124. snakeorm/introspection/postgres.py +397 -0
  125. snakeorm/introspection/sqlite.py +217 -0
  126. snakeorm/introspection/unsupported.py +94 -0
  127. snakeorm/linker/__init__.py +3 -0
  128. snakeorm/linker/linker.py +521 -0
  129. snakeorm/metadata/__init__.py +41 -0
  130. snakeorm/metadata/check.py +37 -0
  131. snakeorm/metadata/column.py +201 -0
  132. snakeorm/metadata/enum_storage.py +33 -0
  133. snakeorm/metadata/fk_action.py +19 -0
  134. snakeorm/metadata/foreign_key.py +26 -0
  135. snakeorm/metadata/index.py +43 -0
  136. snakeorm/metadata/index_method.py +19 -0
  137. snakeorm/metadata/int_size.py +20 -0
  138. snakeorm/metadata/json_storage.py +21 -0
  139. snakeorm/metadata/polymorphic.py +30 -0
  140. snakeorm/metadata/primary_key.py +23 -0
  141. snakeorm/metadata/relationship.py +60 -0
  142. snakeorm/metadata/relationship_kind.py +43 -0
  143. snakeorm/metadata/routine.py +26 -0
  144. snakeorm/metadata/server_default.py +20 -0
  145. snakeorm/metadata/table.py +97 -0
  146. snakeorm/metadata/table_kind.py +23 -0
  147. snakeorm/metadata/trigger.py +62 -0
  148. snakeorm/metadata/type_params.py +348 -0
  149. snakeorm/migration/__init__.py +88 -0
  150. snakeorm/migration/asyncrunner.py +168 -0
  151. snakeorm/migration/autodetect.py +96 -0
  152. snakeorm/migration/ddl.py +1172 -0
  153. snakeorm/migration/diff.py +701 -0
  154. snakeorm/migration/loader.py +77 -0
  155. snakeorm/migration/operations.py +952 -0
  156. snakeorm/migration/planner.py +78 -0
  157. snakeorm/migration/realize.py +420 -0
  158. snakeorm/migration/renames.py +153 -0
  159. snakeorm/migration/render.py +1095 -0
  160. snakeorm/migration/runner.py +314 -0
  161. snakeorm/migration/squash.py +60 -0
  162. snakeorm/migration/state.py +107 -0
  163. snakeorm/model.py +162 -0
  164. snakeorm/py.typed +0 -0
  165. snakeorm/query/__init__.py +9 -0
  166. snakeorm/query/compound.py +288 -0
  167. snakeorm/query/join_kind.py +19 -0
  168. snakeorm/query/joined.py +143 -0
  169. snakeorm/query/query.py +1001 -0
  170. snakeorm/query/recursive.py +244 -0
  171. snakeorm/registry/__init__.py +5 -0
  172. snakeorm/registry/by_module.py +163 -0
  173. snakeorm/registry/registry.py +166 -0
  174. snakeorm/session/__init__.py +8 -0
  175. snakeorm/session/asyncsession.py +638 -0
  176. snakeorm/session/coercion.py +306 -0
  177. snakeorm/session/factory.py +29 -0
  178. snakeorm/session/guards.py +269 -0
  179. snakeorm/session/isolation.py +23 -0
  180. snakeorm/session/mapper.py +213 -0
  181. snakeorm/session/planning.py +815 -0
  182. snakeorm/session/retry.py +96 -0
  183. snakeorm/session/session.py +871 -0
  184. snakeorm/session/shared.py +355 -0
  185. snakeorm/sql/__init__.py +18 -0
  186. snakeorm/sql/adapt.py +104 -0
  187. snakeorm/sql/aggregate.py +232 -0
  188. snakeorm/sql/condition.py +359 -0
  189. snakeorm/sql/delete.py +46 -0
  190. snakeorm/sql/insert.py +149 -0
  191. snakeorm/sql/joins.py +158 -0
  192. snakeorm/sql/pk_subquery.py +54 -0
  193. snakeorm/sql/refs.py +19 -0
  194. snakeorm/sql/resolver.py +28 -0
  195. snakeorm/sql/select.py +175 -0
  196. snakeorm/sql/update.py +82 -0
  197. snakeorm/sql/value.py +552 -0
  198. snakeorm/times.py +276 -0
@@ -0,0 +1,354 @@
1
+ """Turning declared specs into the SHAPES that get written: annotations, nesting, and write order.
2
+
3
+ The metadata graph has the last word here. A spec carries paths, which are names; what a name MEANS
4
+ — a column of this type, a to-one that may find nothing, a collection — is a question only the
5
+ compiled `SnakeTableInfo` can answer, and asking it is this module's whole job.
6
+
7
+ Three rules, each of which has a way of being quietly wrong:
8
+
9
+ NULLABILITY is accumulated over the WHOLE path and never read off the last step. One nullable key
10
+ anywhere along the way means the value can arrive as `None`, however NOT NULL the column at the end
11
+ is. `editor.username` is `str | None`; so is `author.country.name`, where the only nullable hop is
12
+ in the middle and both ends are required.
13
+
14
+ NESTING resolves to another SPEC, never to the far model's columns. Zero specs for that model is an
15
+ error; two is an error naming both. Picking whichever is bug #14 in another costume — it does not
16
+ fail, it publishes the wrong shape.
17
+
18
+ ORDER is by DEPENDENCY. Python reads a file top to bottom, so a class that mentions another has to
19
+ come after it, whichever order the two declarations were written in. A cycle has no such order, so
20
+ it is named and refused rather than resolved into a file that does not import.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Sequence
26
+ from dataclasses import dataclass
27
+
28
+ from snakeorm.core.exceptions import SnakeDtoError
29
+ from snakeorm.dto.spec import SnakeDtoPick, SnakeDtoSpec
30
+ from snakeorm.helpers.pytype import type_annotation
31
+ from snakeorm.metadata import (
32
+ SnakeColumnInfo,
33
+ SnakeRelationshipInfo,
34
+ SnakeRelationshipKind,
35
+ SnakeTableInfo,
36
+ )
37
+ from snakeorm.registry import registry_of
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class SnakeDtoField:
42
+ """One key of a generated class: its name, its annotation as SOURCE, and the import it needs."""
43
+
44
+ name: str
45
+ annotation: str
46
+ requires: str | None = None
47
+ """The module that has to be in scope for `annotation` to resolve, or `None` for a builtin.
48
+
49
+ It travels with the field because the generator writes into somebody's file and never writes
50
+ their imports: without this the writer could not refuse a field it cannot spell there.
51
+ """
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class SnakeDtoShape:
56
+ """One class ready to be written: its name and its keys, in the order they go in."""
57
+
58
+ name: str
59
+ fields: tuple[SnakeDtoField, ...]
60
+
61
+
62
+ def resolve_all(specs: Sequence[SnakeDtoSpec]) -> tuple[SnakeDtoShape, ...]:
63
+ """Every spec of one file resolved together, in the order they have to be WRITTEN in.
64
+
65
+ Together and not one at a time, because a nested field points at another spec: a DTO cannot be
66
+ resolved without the set it belongs to.
67
+ """
68
+ _guard_unique_names(specs)
69
+ by_name = {spec.name: spec for spec in specs}
70
+ shapes: dict[str, SnakeDtoShape] = {}
71
+ needs: dict[str, set[str]] = {}
72
+ for spec in specs:
73
+ fields, depends = _shape_of(spec, specs, by_name)
74
+ shapes[spec.name] = SnakeDtoShape(name=spec.name, fields=fields)
75
+ needs[spec.name] = depends
76
+ return tuple(shapes[name] for name in _write_order(specs, needs))
77
+
78
+
79
+ def _guard_unique_names(specs: Sequence[SnakeDtoSpec]) -> None:
80
+ """Two specs of one name in one file would be two classes of one name: the second wins, silently."""
81
+ seen: set[str] = set()
82
+ for spec in specs:
83
+ if spec.name in seen:
84
+ raise SnakeDtoError(
85
+ f"{spec.name!r} is declared twice in this file. Two classes of one name means the "
86
+ f"second silently replaces the first: give one of them a name of its own."
87
+ )
88
+ seen.add(spec.name)
89
+
90
+
91
+ def _write_order(
92
+ specs: Sequence[SnakeDtoSpec], needs: dict[str, set[str]]
93
+ ) -> list[str]:
94
+ """Names sorted so that everything a class nests is defined above it. Declaration order breaks ties.
95
+
96
+ A plain depth-first walk with a colour mark, which is what makes the CYCLE reportable: the grey
97
+ set IS the stack, so the names in it when an edge points back are exactly the loop, in order.
98
+ Ties keep the declaration order, so two classes that depend on nothing do not get reshuffled —
99
+ a sort that churned independent classes would rewrite the region for no reason, and the diff is
100
+ the product here.
101
+ """
102
+ order: list[str] = []
103
+ done: set[str] = set()
104
+ stack: list[str] = []
105
+
106
+ def visit(name: str) -> None:
107
+ if name in done:
108
+ return
109
+ if name in stack:
110
+ loop = [*stack[stack.index(name) :], name]
111
+ raise SnakeDtoError(
112
+ f"these DTOs nest each other in a cycle: {' -> '.join(loop)}. There is no order "
113
+ f"that defines each one before it is mentioned, so nothing was written: break the "
114
+ f"loop by dropping one of the nested fields."
115
+ )
116
+ stack.append(name)
117
+ for nested in sorted(needs.get(name, set())):
118
+ visit(nested)
119
+ stack.pop()
120
+ done.add(name)
121
+ order.append(name)
122
+
123
+ for spec in specs:
124
+ visit(spec.name)
125
+ return order
126
+
127
+
128
+ def _shape_of(
129
+ spec: SnakeDtoSpec,
130
+ specs: Sequence[SnakeDtoSpec],
131
+ by_name: dict[str, SnakeDtoSpec],
132
+ ) -> tuple[tuple[SnakeDtoField, ...], set[str]]:
133
+ """One spec's fields, and the names of the DTOs it nests."""
134
+ table = _table_of(spec.model)
135
+ fields: list[SnakeDtoField] = []
136
+ depends: set[str] = set()
137
+ taken: set[str] = set()
138
+ for pick in _picks(spec, table):
139
+ field, nested = _field_of(pick, spec, table, specs, by_name)
140
+ if field.name in taken:
141
+ raise SnakeDtoError(
142
+ f"{spec.name} would carry the key {field.name!r} twice: two of its fields resolve "
143
+ f"to the same name. A generated key is named after its whole path, so rename the "
144
+ f"column or drop one of the two."
145
+ )
146
+ taken.add(field.name)
147
+ fields.append(field)
148
+ if nested is not None:
149
+ depends.add(nested)
150
+ return tuple(fields), depends
151
+
152
+
153
+ def _picks(spec: SnakeDtoSpec, table: SnakeTableInfo) -> list[SnakeDtoPick]:
154
+ """What the spec selects: what it named, or every column when it named nothing.
155
+
156
+ Neither switch is every COLUMN and no relationship. Nesting by default would drag an object
157
+ graph into a response nobody asked for, and could not be written at all without a spec for every
158
+ model it reached.
159
+ """
160
+ if spec.fields is not None:
161
+ return list(spec.fields)
162
+ dropped = {path[0] for path in spec.exclude}
163
+ return [
164
+ SnakeDtoPick(path=(_attribute(column.attr_name, column.name),))
165
+ for column in table.columns
166
+ if _attribute(column.attr_name, column.name) not in dropped
167
+ ]
168
+
169
+
170
+ def _field_of(
171
+ pick: SnakeDtoPick,
172
+ spec: SnakeDtoSpec,
173
+ table: SnakeTableInfo,
174
+ specs: Sequence[SnakeDtoSpec],
175
+ by_name: dict[str, SnakeDtoSpec],
176
+ ) -> tuple[SnakeDtoField, str | None]:
177
+ """One selected path resolved into a field, plus the DTO it nests if it nests one."""
178
+ landing, optional = _walk(table, spec.model, pick.path, spec.name)
179
+ name = "_".join(pick.path)
180
+ if isinstance(landing, SnakeRelationshipInfo):
181
+ nested = _nested_name(landing, pick, spec, specs, by_name)
182
+ many = landing.kind is not SnakeRelationshipKind.TO_ONE
183
+ annotation = f"list[{nested}]" if many else nested
184
+ # A collection is never `None`: a parent with no children gets `[]`. What CAN vanish is the
185
+ # collection's owner, when the path crossed a nullable key to reach it.
186
+ if optional:
187
+ annotation = f"{annotation} | None"
188
+ return SnakeDtoField(name=name, annotation=annotation), nested
189
+ spelled, module = type_annotation(landing.python_type)
190
+ nullable = landing.nullable or optional
191
+ return (
192
+ SnakeDtoField(
193
+ name=name,
194
+ annotation=f"{spelled} | None" if nullable else spelled,
195
+ requires=module,
196
+ ),
197
+ None,
198
+ )
199
+
200
+
201
+ def _nested_name(
202
+ relationship: SnakeRelationshipInfo,
203
+ pick: SnakeDtoPick,
204
+ spec: SnakeDtoSpec,
205
+ specs: Sequence[SnakeDtoSpec],
206
+ by_name: dict[str, SnakeDtoSpec],
207
+ ) -> str:
208
+ """Which DTO a relationship field nests: the one the user named, or the only candidate.
209
+
210
+ "The only" is load-bearing. Two specs over one model and no rule to choose between them is the
211
+ same defect as resolving a model by class name — it produces a valid-looking class describing
212
+ the wrong shape, and nothing anywhere goes red.
213
+ """
214
+ reg = registry_of(spec.model)
215
+ target = reg.resolve_relationship(relationship)[1]
216
+ if target is None:
217
+ raise SnakeDtoError(
218
+ f"{spec.name} nests {relationship.name!r}, whose target {relationship.target!r} is not "
219
+ f"registered. Import the module that declares it, and call `snake_link()`."
220
+ )
221
+ candidates = [item.name for item in specs if item.model is target]
222
+ if pick.dto is not None:
223
+ named = by_name.get(pick.dto)
224
+ if named is None:
225
+ raise SnakeDtoError(
226
+ f"{spec.name} nests {relationship.name!r} as {pick.dto!r}, and no spec of that "
227
+ f"name is declared in this file. Over {target.__name__} there is: "
228
+ f"{', '.join(candidates) or '(none)'}."
229
+ )
230
+ if named.model is not target:
231
+ raise SnakeDtoError(
232
+ f"{spec.name} nests {relationship.name!r} as {pick.dto!r}, which describes "
233
+ f"{named.model.__name__} and not {target.__name__}. It would type-check and it "
234
+ f"would put another table's fields in this response."
235
+ )
236
+ return named.name
237
+ if not candidates:
238
+ raise SnakeDtoError(
239
+ f"{spec.name} nests {relationship.name!r}, and no spec describes {target.__name__}. A "
240
+ f"relationship is written as the DTO of its model, so declare one: "
241
+ f"`snake_dto({target.__name__}, fields=[...], name=...)`."
242
+ )
243
+ if len(candidates) > 1:
244
+ listed = ", ".join(sorted(candidates))
245
+ raise SnakeDtoError(
246
+ f"{spec.name} nests {relationship.name!r} and {target.__name__} has more than one "
247
+ f"spec ({listed}), so there is no telling which shape this field is. Nothing is "
248
+ f"picked: say which, with "
249
+ f"`({spec.model.__name__}.{relationship.name}, {sorted(candidates)[0]!r})`."
250
+ )
251
+ return candidates[0]
252
+
253
+
254
+ def _walk(
255
+ table: SnakeTableInfo, model: type, path: tuple[str, ...], dto: str
256
+ ) -> tuple[SnakeColumnInfo | SnakeRelationshipInfo, bool]:
257
+ """Follows a path across to-ones and returns what it LANDS on, and whether it can find nothing.
258
+
259
+ The landing is a `SnakeColumnInfo` or a `SnakeRelationshipInfo` — a column or a relationship —
260
+ and the caller decides what to do with each. `optional` accumulates over every hop and is never
261
+ reset: that is the correctness of this function in one line.
262
+ """
263
+ reg = registry_of(model)
264
+ optional = False
265
+ current, owner = table, model
266
+ for index, step in enumerate(path):
267
+ column = current.get_column_by_attr(step)
268
+ relationship = _relationship_named(current, step)
269
+ last = index == len(path) - 1
270
+ if column is not None and last:
271
+ return column, optional
272
+ if relationship is None:
273
+ known = ", ".join(
274
+ [_attribute(item.attr_name, item.name) for item in current.columns]
275
+ + [item.name for item in current.relationships]
276
+ )
277
+ raise SnakeDtoError(
278
+ f"{dto} selects {'.'.join(path)!r}, and {owner.__name__} has no {step!r}. It has: "
279
+ f"{known}."
280
+ )
281
+ if last:
282
+ # The LAST hop counts too, and forgetting it is the bug this line exists for: a path
283
+ # that ends ON a to-one crosses that key, so `Post.editor` is `EditorDto | None` for
284
+ # exactly the reason `Post.editor.username` is `str | None`. A to-many is never
285
+ # optional by its own key — that key lives on the child, and no children is `[]`.
286
+ if relationship.kind is SnakeRelationshipKind.TO_ONE:
287
+ optional = optional or _to_one_is_optional(current, relationship)
288
+ return relationship, optional
289
+ if relationship.kind is not SnakeRelationshipKind.TO_ONE:
290
+ raise SnakeDtoError(
291
+ f"step {step!r} of {'.'.join(path)!r} in {dto} is a {relationship.kind.value}, and "
292
+ f"a path can only be continued across a to-one. A collection has no single row to "
293
+ f"read the next step off: nest it whole instead."
294
+ )
295
+ optional = optional or _to_one_is_optional(current, relationship)
296
+ target, target_model = reg.resolve_relationship(relationship)
297
+ if target is None or target_model is None:
298
+ raise SnakeDtoError(
299
+ f"step {step!r} of {'.'.join(path)!r} in {dto} points at "
300
+ f"{relationship.target!r}, which is not registered. Import the module that "
301
+ f"declares it, and call `snake_link()`."
302
+ )
303
+ current, owner = target, target_model
304
+ raise SnakeDtoError(f"{dto} selects an empty path, which names nothing.")
305
+
306
+
307
+ def _to_one_is_optional(
308
+ table: SnakeTableInfo, relationship: SnakeRelationshipInfo
309
+ ) -> bool:
310
+ """Whether crossing this to-one can find NOTHING. One nullable member of the key is enough.
311
+
312
+ A pair whose local column is not on the table RAISES rather than being skipped. Skipping it and
313
+ letting `any()` fall through to `False` would answer NOT NULL for a key it could not read, and
314
+ that is the one direction this function must never fail in: the DTO promises a value that
315
+ arrives as `None`, the checker believes it, and nothing anywhere goes red.
316
+
317
+ The pair is `(source_column, target_column)` and the source side belongs to the declaring table,
318
+ so this cannot happen while the linker is right. That is precisely why it is worth saying out
319
+ loud — an invariant nobody checks is a defect waiting to be plausible.
320
+ """
321
+ by_name = {column.name: column for column in table.columns}
322
+ missing = [
323
+ pair[0] for pair in relationship.foreign_key.pairs if pair[0] not in by_name
324
+ ]
325
+ if missing:
326
+ raise SnakeDtoError(
327
+ f"the key of {relationship.name!r} reads {', '.join(missing)} off {table.name}, which "
328
+ f"has no such column, so there is no telling whether crossing it can find nothing. "
329
+ f"Nothing was written: this is the graph being inconsistent, not the declaration."
330
+ )
331
+ return any(by_name[pair[0]].nullable for pair in relationship.foreign_key.pairs)
332
+
333
+
334
+ def _relationship_named(
335
+ table: SnakeTableInfo, name: str
336
+ ) -> SnakeRelationshipInfo | None:
337
+ """The relationship with that attribute name, or `None`."""
338
+ return next((item for item in table.relationships if item.name == name), None)
339
+
340
+
341
+ def _table_of(model: type) -> SnakeTableInfo:
342
+ """The model's compiled table, asked of the registry the model itself lives in."""
343
+ table = registry_of(model).table_of(model)
344
+ if table is None:
345
+ raise SnakeDtoError(
346
+ f"{getattr(model, '__name__', model)!r} is not a compiled model, so there is no column "
347
+ f"list to read."
348
+ )
349
+ return table
350
+
351
+
352
+ def _attribute(attr_name: str, sql_name: str) -> str:
353
+ """The column's PYTHON name, which `snake_column(name=...)` can split from its SQL one."""
354
+ return attr_name or sql_name
snakeorm/dto/spec.py ADDED
@@ -0,0 +1,201 @@
1
+ """`snake_dto(...)`: the DECLARATION of a typed DTO. The class it describes is written by the CLI.
2
+
3
+ This module does one job and stops: it turns a line somebody wrote into a frozen `SnakeDtoSpec`,
4
+ refusing anything it can already tell is wrong. It never reads the graph beyond checking that the
5
+ model was compiled — resolving what a path MEANS is `resolve.py`'s job, and it needs every spec in
6
+ the file at once because a nested field points at another spec.
7
+
8
+ WHY THE FIELDS ARE DESCRIPTORS. `Post.tilte` does not compile; `"tilte"` does. That is the whole
9
+ argument, and it is measured in this package's tests against both mypy and pyright.
10
+
11
+ WHERE THE DECLARATION LIVES, and it is the decision the rest of this package hangs off: inside the
12
+ DTO file's `if TYPE_CHECKING:` block, together with the import of the models. Three properties at
13
+ once, all measured:
14
+
15
+ mypy/pyright over `Post.tilte` inside the block -> error: "type[Post]" has no attribute
16
+ importing the module at runtime -> clean; nothing in the block ever runs
17
+ importing the module at runtime -> does NOT drag in `blog.models`
18
+
19
+ So the checker validates every path, the file costs nothing to import, and the import cycle between
20
+ `models.py` and `dto.py` cannot form. The generator never executes any of it: it READS the block
21
+ with `ast`. The function still works when called, but its runtime behaviour is not the product —
22
+ the type signature is.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import keyword
28
+ from dataclasses import dataclass
29
+ from typing import Protocol, runtime_checkable
30
+
31
+ from snakeorm.core.exceptions import SnakeDtoError
32
+ from snakeorm.fields.relationship import SnakePathProxy, path_of
33
+ from snakeorm.registry import registry_of
34
+
35
+
36
+ @runtime_checkable
37
+ class SnakeDtoPath(Protocol):
38
+ """Anything that knows the path it names: a column expression or a collection.
39
+
40
+ Structural on purpose. `SnakeExpr` and `SnakeCollection` have nothing else in common and there
41
+ is no shared base to name — what this function needs from both is the path, so that is what the
42
+ type asks for. A to-one proxy deliberately does NOT match: its namespace is the target model's,
43
+ so it cannot carry a `path` attribute without shadowing a column of that name.
44
+ """
45
+
46
+ @property
47
+ def path(self) -> tuple[str, ...]: ...
48
+
49
+
50
+ SnakeDtoEntry = SnakeDtoPath | type[object]
51
+ """One thing that can appear in `fields=`.
52
+
53
+ `type[object]` is how a to-one reads to the CHECKER: class access on `SnakeToOne[M]` is typed
54
+ `type[M]`, which is the same overload that makes `Post.author.username` resolve. It lets any class
55
+ through the type check, and that is the price of the navigation being typed at all; a class that
56
+ names no relationship is refused here, by name, at the line that wrote it.
57
+ """
58
+
59
+ SnakeDtoSelection = SnakeDtoEntry | tuple[SnakeDtoEntry, str]
60
+ """An entry, or an entry paired with the NAME of the DTO to nest for it.
61
+
62
+ The pair exists for one case: a model with more than one spec. `Post.author` alone cannot say
63
+ whether it means `AuthorDto` or `AuthorCard`, and picking either would be bug #14 wearing a
64
+ different hat. The disambiguation sits on the line that is ambiguous rather than in a parameter of
65
+ its own, and it stays typed.
66
+ """
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class SnakeDtoPick:
71
+ """One selected field: the path it names, and the DTO to nest if the user named one."""
72
+
73
+ path: tuple[str, ...]
74
+ dto: str | None = None
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class SnakeDtoSpec:
79
+ """A declared DTO: which model, what it is called, and which of its fields are in.
80
+
81
+ `fields` is `None` when neither switch was given, which means every column. Frozen and hashable
82
+ like every other node this project compiles, so a spec can be a key in the dependency graph the
83
+ renderer sorts.
84
+ """
85
+
86
+ model: type
87
+ name: str
88
+ fields: tuple[SnakeDtoPick, ...] | None = None
89
+ exclude: tuple[tuple[str, ...], ...] = ()
90
+
91
+
92
+ def snake_dto(
93
+ model: type,
94
+ *,
95
+ name: str,
96
+ fields: list[SnakeDtoSelection] | None = None,
97
+ exclude: list[SnakeDtoSelection] | None = None,
98
+ ) -> SnakeDtoSpec:
99
+ """Declares a DTO over `model`. Returns the spec, and records it for the generator.
100
+
101
+ Three combinations and one refusal: neither switch means every column, `fields` means exactly
102
+ those, `exclude` means all but those, and both together is an error because they are two answers
103
+ to one question.
104
+
105
+ A relationship in `fields` NESTS — `Post.author` becomes `author: AuthorDto` — and does not
106
+ expand into the author's columns. Expanding would mean a declaration you cannot read without
107
+ opening another model, and the point of writing the shape down is that it is a complete picture
108
+ of what goes out.
109
+
110
+ The spec is returned rather than recorded anywhere: in the file it is written in this line is
111
+ never reached, so a process-wide list of declarations would be empty in production and full in
112
+ tests.
113
+ """
114
+ return build_spec(
115
+ model=model,
116
+ name=name,
117
+ fields=None if fields is None else tuple(_pick(item, name) for item in fields),
118
+ exclude=None
119
+ if exclude is None
120
+ else tuple(_excluded(item, name) for item in exclude),
121
+ )
122
+
123
+
124
+ def build_spec(
125
+ *,
126
+ model: type,
127
+ name: str,
128
+ fields: tuple[SnakeDtoPick, ...] | None,
129
+ exclude: tuple[tuple[str, ...], ...] | None,
130
+ where: str = "",
131
+ ) -> SnakeDtoSpec:
132
+ """Builds a spec out of already-normalised parts, applying the checks BOTH routes need.
133
+
134
+ Two things produce a spec — this module's typed call, and the AST reader — and they have to
135
+ agree about what a declaration may say. The checks live here so there is ONE answer: two copies
136
+ would eventually differ, and the difference would be a file the generator accepts and the
137
+ checker does not, or the other way round.
138
+
139
+ `where` prefixes the messages with `file:line` when the caller has one. The typed call does not,
140
+ because it fails on the line it was written on and the traceback already says which.
141
+ """
142
+ prefix = f"{where}: " if where else ""
143
+ if fields is not None and exclude is not None:
144
+ raise SnakeDtoError(
145
+ f"{prefix}{name!r} declares both fields= and exclude=. They are two ways of answering "
146
+ f"the same question and they can disagree: name the fields you want, or the columns "
147
+ f"you do not, never both."
148
+ )
149
+ if not name.isidentifier() or keyword.iskeyword(name):
150
+ raise SnakeDtoError(
151
+ f"{prefix}{name!r} is not a valid class name, and this spec is written out as "
152
+ f"`class {name}(TypedDict)`."
153
+ )
154
+ if registry_of(model).table_of(model) is None:
155
+ raise SnakeDtoError(
156
+ f"{prefix}{getattr(model, '__name__', model)!r} is not a compiled model: nothing "
157
+ f"declared it with @snake_model or @snake_db_first, so there is no column list to read."
158
+ )
159
+ for path in exclude or ():
160
+ if len(path) != 1:
161
+ raise SnakeDtoError(
162
+ f"{prefix}{name!r} excludes {'.'.join(path)!r}, which is not one column of the "
163
+ f"model it describes. `exclude=` prunes this model's own columns; the fields of a "
164
+ f"nested DTO come from ITS spec, so there is nothing to prune from here."
165
+ )
166
+ return SnakeDtoSpec(model=model, name=name, fields=fields, exclude=exclude or ())
167
+
168
+
169
+ def _path_of_entry(entry: SnakeDtoEntry, dto: str) -> tuple[str, ...]:
170
+ """The path an entry names, whichever of the three kinds of navigation node it is."""
171
+ if isinstance(entry, SnakePathProxy):
172
+ return path_of(entry)
173
+ if isinstance(entry, SnakeDtoPath):
174
+ return entry.path
175
+ raise SnakeDtoError(
176
+ f"{dto} was given {type(entry).__name__} as a field, which names no column or relationship. "
177
+ f"An entry is class access on a model — `Post.title`, `Post.author.username`, "
178
+ f"`Post.author`, `Post.comments` — and nothing else."
179
+ )
180
+
181
+
182
+ def _pick(item: SnakeDtoSelection, dto: str) -> SnakeDtoPick:
183
+ """One `fields=` entry, with the nested DTO name if it was paired with one."""
184
+ if isinstance(item, tuple):
185
+ entry, named = item
186
+ return SnakeDtoPick(path=_path_of_entry(entry, dto), dto=named)
187
+ return SnakeDtoPick(path=_path_of_entry(item, dto))
188
+
189
+
190
+ def _excluded(item: SnakeDtoSelection, dto: str) -> tuple[str, ...]:
191
+ """One `exclude=` entry, which has to be a plain column of the model being described.
192
+
193
+ A path across a relationship reads as if it removed something and removes nothing: the far
194
+ model's columns were never in this DTO to begin with, because a relationship NESTS.
195
+ """
196
+ if isinstance(item, tuple):
197
+ raise SnakeDtoError(
198
+ f"{dto} pairs an exclusion with a DTO name, and there is nothing to name: "
199
+ f"`exclude=` removes columns, it does not nest anything."
200
+ )
201
+ return _path_of_entry(item, dto)
@@ -0,0 +1,88 @@
1
+ """SnakeORM's typed expression AST (columns and conditions)."""
2
+
3
+ from snakeorm.expressions.expression import SnakeCast as SnakeCast
4
+ from snakeorm.expressions.scalar import snake_cast as snake_cast
5
+ from snakeorm.expressions.scalar import snake_substring as snake_substring
6
+ from snakeorm.expressions.scalar import snake_replace as snake_replace
7
+ from snakeorm.expressions.scalar import snake_ceil as snake_ceil
8
+ from snakeorm.expressions.scalar import snake_floor as snake_floor
9
+ from snakeorm.expressions.scalar import snake_sqrt as snake_sqrt
10
+ from snakeorm.expressions.scalar import snake_power as snake_power
11
+ from snakeorm.expressions.conditional import SnakeCase as SnakeCase
12
+ from snakeorm.expressions.conditional import SnakeCaseBranch as SnakeCaseBranch
13
+ from snakeorm.expressions.conditional import SnakeCoalesce as SnakeCoalesce
14
+ from snakeorm.expressions.conditional import SnakeNullIf as SnakeNullIf
15
+ from snakeorm.expressions.conditional import snake_case as snake_case
16
+ from snakeorm.expressions.conditional import snake_coalesce as snake_coalesce
17
+ from snakeorm.expressions.conditional import snake_nullif as snake_nullif
18
+ from snakeorm.expressions.expression import SnakeAggFunc as SnakeAggFunc
19
+ from snakeorm.expressions.expression import SnakeAggregate as SnakeAggregate
20
+ from snakeorm.expressions.expression import SnakeAnd as SnakeAnd
21
+ from snakeorm.expressions.expression import SnakeArith as SnakeArith
22
+ from snakeorm.expressions.expression import SnakeJsonGet as SnakeJsonGet
23
+ from snakeorm.expressions.expression import SnakeArithOp as SnakeArithOp
24
+ from snakeorm.expressions.expression import static_type as static_type
25
+ from snakeorm.expressions.expression import SnakeStringAgg as SnakeStringAgg
26
+ from snakeorm.expressions.functions import string_agg as string_agg
27
+ from snakeorm.expressions.window import SNAKE_CURRENT_ROW as SNAKE_CURRENT_ROW
28
+ from snakeorm.expressions.window import SnakeFrame as SnakeFrame
29
+ from snakeorm.expressions.window import SnakeFrameBound as SnakeFrameBound
30
+ from snakeorm.expressions.window import SnakeFrameMode as SnakeFrameMode
31
+ from snakeorm.expressions.window import snake_following as snake_following
32
+ from snakeorm.expressions.window import snake_preceding as snake_preceding
33
+ from snakeorm.expressions.window import snake_range as snake_range
34
+ from snakeorm.expressions.window import snake_rows as snake_rows
35
+ from snakeorm.expressions.scalar import SnakeDateShift as SnakeDateShift
36
+ from snakeorm.expressions.scalar import snake_date_add as snake_date_add
37
+ from snakeorm.expressions.scalar import snake_date_sub as snake_date_sub
38
+ from snakeorm.expressions.expression import SnakeComparison as SnakeComparison
39
+ from snakeorm.expressions.expression import SnakeCondition as SnakeCondition
40
+ from snakeorm.expressions.expression import SnakeExists as SnakeExists
41
+ from snakeorm.expressions.expression import SnakeExistsJoin as SnakeExistsJoin
42
+ from snakeorm.expressions.expression import SnakeExpr as SnakeExpr
43
+ from snakeorm.expressions.expression import SnakeInList as SnakeInList
44
+ from snakeorm.expressions.expression import SnakeInSubquery as SnakeInSubquery
45
+ from snakeorm.expressions.expression import SnakeIsNotNull as SnakeIsNotNull
46
+ from snakeorm.expressions.expression import SnakeIsNull as SnakeIsNull
47
+ from snakeorm.expressions.expression import SnakeLike as SnakeLike
48
+ from snakeorm.expressions.expression import SnakeLock as SnakeLock
49
+ from snakeorm.expressions.expression import SnakeNot as SnakeNot
50
+ from snakeorm.expressions.expression import SnakeNulls as SnakeNulls
51
+ from snakeorm.expressions.expression import SnakeOp as SnakeOp
52
+ from snakeorm.expressions.expression import SnakeOr as SnakeOr
53
+ from snakeorm.expressions.expression import SnakeOrder as SnakeOrder
54
+ from snakeorm.expressions.expression import SnakeSubquery as SnakeSubquery
55
+ from snakeorm.expressions.expression import (
56
+ SnakeSubqueryAggregate as SnakeSubqueryAggregate,
57
+ )
58
+ from snakeorm.expressions.expression import SnakeTupleIn as SnakeTupleIn
59
+ from snakeorm.expressions.expression import SnakeValue as SnakeValue
60
+ from snakeorm.expressions.functions import avg as avg
61
+ from snakeorm.expressions.functions import count as count
62
+ from snakeorm.expressions.functions import max_ as max_
63
+ from snakeorm.expressions.functions import min_ as min_
64
+ from snakeorm.expressions.functions import sum_ as sum_
65
+ from snakeorm.expressions.paths import condition_paths as condition_paths
66
+ from snakeorm.expressions.scalar import SnakeDatePart as SnakeDatePart
67
+ from snakeorm.expressions.scalar import SnakeFunc as SnakeFunc
68
+ from snakeorm.expressions.scalar import SnakeFuncCall as SnakeFuncCall
69
+ from snakeorm.expressions.scalar import snake_abs as snake_abs
70
+ from snakeorm.expressions.scalar import snake_concat as snake_concat
71
+ from snakeorm.expressions.scalar import snake_date_trunc as snake_date_trunc
72
+ from snakeorm.expressions.scalar import snake_extract as snake_extract
73
+ from snakeorm.expressions.scalar import snake_length as snake_length
74
+ from snakeorm.expressions.scalar import snake_lower as snake_lower
75
+ from snakeorm.expressions.scalar import snake_round as snake_round
76
+ from snakeorm.expressions.scalar import snake_trim as snake_trim
77
+ from snakeorm.expressions.scalar import snake_upper as snake_upper
78
+ from snakeorm.expressions.window import SnakeWindow as SnakeWindow
79
+ from snakeorm.expressions.window import dense_rank as dense_rank
80
+ from snakeorm.expressions.window import lag as lag
81
+ from snakeorm.expressions.window import lead as lead
82
+ from snakeorm.expressions.window import rank as rank
83
+ from snakeorm.expressions.window import row_number as row_number
84
+ from snakeorm.expressions.keys import SnakeKey as SnakeKey
85
+ from snakeorm.expressions.keys import SnakeKeys as SnakeKeys
86
+ from snakeorm.expressions.keys import SnakeScalar as SnakeScalar
87
+ from snakeorm.expressions.keys import snake_key as snake_key
88
+ from snakeorm.expressions.keys import snake_keys as snake_keys