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,244 @@
1
+ """`WITH RECURSIVE`: walking an entire hierarchy with ONE query.
2
+
3
+ It brings a NEW capability: "every descendant at any depth" cannot be written with subqueries
4
+ (either you go down level by level with an N+1, or you use recursion). Emitted shape:
5
+
6
+ WITH RECURSIVE snake_rec AS (
7
+ (<anchor>) UNION [ALL]
8
+ SELECT t.<cols> FROM <table> AS t JOIN snake_rec ON t.<child> = snake_rec.<accumulated>
9
+ )
10
+ SELECT <cols> FROM snake_rec [ORDER BY ...] [LIMIT ...]
11
+
12
+ `UNION ALL` by default: deduplicating every step would cost hashing everything accumulated so far,
13
+ and a well-formed tree has no duplicates to drop. Over data with CYCLES that walk never ends —each
14
+ lap yields rows the engine counts as new— so the operator is the CALLER's to pick: `distinct=True`
15
+ emits a plain `UNION`, the lap that repeats contributes nothing, and the recursion stops on its own.
16
+
17
+ A `limit()` is not a substitute. Measured against a real Postgres over a three-row cycle: `UNION
18
+ ALL` with an `order_by()` and a `LIMIT 3` never returns, because the sort has to consume every row
19
+ before it can emit one. The limit bounds what comes BACK, not how far the engine goes.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, replace
25
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
26
+
27
+ from snakeorm.dialects import SnakeDialect
28
+ from snakeorm.core.exceptions import SnakeEmitError
29
+ from snakeorm.expressions import SnakeExpr, SnakeOrder
30
+ from snakeorm.metadata import SnakeTableInfo
31
+ from snakeorm.sql.refs import qualified
32
+ from snakeorm.sql.value import emit_order_key, guard_untabled_order_keys
33
+
34
+ if TYPE_CHECKING:
35
+ from snakeorm.query.query import SnakeQuery
36
+
37
+ T = TypeVar("T")
38
+
39
+ CTE_NAME = "snake_rec"
40
+ """Name of the CTE. Fixed on purpose: it does not nest (a recursion admits no other inside it)."""
41
+
42
+ SOURCE_ALIAS = "snake_src"
43
+ """Alias of the table inside the recursive step, mandatory: the CTE has the same column names, so
44
+ without an alias they are ambiguous and Postgres rejects the query (the test against the engine caught it)."""
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class SnakeRecursive(Generic[T]):
49
+ """An ANCHOR query that expands itself by following a hop onto itself.
50
+
51
+ `child_column` points upwards (`parent_id`) and `parent_column` identifies the accumulated row
52
+ (`id`): the direction is fixed by which one goes first (swapping them walks the ancestors, a
53
+ perfectly legitimate query).
54
+
55
+ `distinct` picks the set operator that joins each step to what has already been accumulated:
56
+ `False` (the default) emits `UNION ALL`, `True` emits `UNION`. It is the difference between a
57
+ walk that ends over cyclic data and one that does not.
58
+ """
59
+
60
+ anchor: SnakeQuery[T]
61
+ table: SnakeTableInfo
62
+ child_column: str
63
+ parent_column: str
64
+ distinct: bool = False
65
+ order_by_keys: tuple[SnakeOrder, ...] = ()
66
+ limit_value: int | None = None
67
+ offset_value: int | None = None
68
+
69
+ @property
70
+ def model(self) -> type[T]:
71
+ """The model of the rows: the same as the anchor's. The session uses it to instantiate them."""
72
+ return self.anchor.model
73
+
74
+ @property
75
+ def projected_columns(self) -> frozenset[str] | None:
76
+ """Never narrowed: the CTE's columns are the TABLE's, and a narrowed anchor is refused."""
77
+ return None
78
+
79
+ @property
80
+ def has_includes(self) -> bool:
81
+ """Never: an anchor with includes is rejected at build time, not here."""
82
+ return False
83
+
84
+ @property
85
+ def has_lock(self) -> bool:
86
+ """Never: a recursive CTE does not lock rows. It is rejected at build time."""
87
+ return False
88
+
89
+ @property
90
+ def has_bounds(self) -> bool:
91
+ """Tells whether it carries `limit`/`offset` of its own. The compound looks at this."""
92
+ return self.limit_value is not None or self.offset_value is not None
93
+
94
+ @property
95
+ def has_order(self) -> bool:
96
+ """Tells whether it carries `order_by` of its own. The compound looks at this."""
97
+ return bool(self.order_by_keys)
98
+
99
+ @property
100
+ def has_cte(self) -> bool:
101
+ """Always: this is the `WITH RECURSIVE`. The compound asks so it can refuse where a CTE
102
+ cannot be a branch, which is two of the three engines."""
103
+ return True
104
+
105
+ def order_by(self, *keys: SnakeExpr[Any] | SnakeOrder) -> SnakeRecursive[T]:
106
+ """Orders the RESULT (not the anchor nor the step). A bare column orders ascending.
107
+
108
+ The same guard the compound uses, and for the same reason: the `SELECT ... FROM cte` this
109
+ ordering hangs off has only the CTE's columns, so a key that navigates a relationship would
110
+ lose the hop and be written as another column's bare name.
111
+ """
112
+ normalized = tuple(
113
+ key if isinstance(key, SnakeOrder) else key.asc() for key in keys
114
+ )
115
+ guard_untabled_order_keys(
116
+ normalized,
117
+ self.projected_columns,
118
+ "A WITH RECURSIVE",
119
+ "Order the rows in Python after the traversal, or join the far table in a separate "
120
+ "query.",
121
+ )
122
+ return replace(self, order_by_keys=self.order_by_keys + normalized)
123
+
124
+ def limit(self, limit: int) -> SnakeRecursive[T]:
125
+ """Bounds the RESULT, and only the result: it does NOT bound the traversal.
126
+
127
+ It read as the safety net against a cycle until somebody measured it. Put an `order_by()`
128
+ in front of it —which is the normal way to ask for a hierarchy— and the engine has to
129
+ produce every row before it can sort them, so the bound never gets its turn and the query
130
+ hangs all the same. What ends a cyclic walk is `recursive(..., distinct=True)`.
131
+ """
132
+ return replace(self, limit_value=limit)
133
+
134
+ def offset(self, offset: int) -> SnakeRecursive[T]:
135
+ """Skips rows of the result (it replaces the previous one)."""
136
+ return replace(self, offset_value=offset)
137
+
138
+ def to_sql(self, dialect: SnakeDialect) -> tuple[str, tuple[object, ...]]:
139
+ """Compiles to `(sql, params)`, with the ANCHOR's params first.
140
+
141
+ That order is mandatory: the anchor comes earlier in the string and Postgres' `%s` is positional.
142
+ """
143
+ quote = dialect.quote_ident
144
+ columns = ", ".join(quote(column.name) for column in self.table.columns)
145
+ cte = quote(CTE_NAME)
146
+ source = quote(SOURCE_ALIAS)
147
+ # The step's columns go QUALIFIED by the alias: the CTE has the same names.
148
+ step_columns = ", ".join(
149
+ f"{source}.{quote(column.name)}" for column in self.table.columns
150
+ )
151
+
152
+ params: list[object] = []
153
+ anchor_sql, anchor_params = self.anchor.to_sql(dialect)
154
+ params.extend(anchor_params)
155
+
156
+ # The anchor is parenthesised only if the engine admits it (SQLite rejects `((SELECT ...)
157
+ # UNION ALL ...)`). Same flag as the UNION emitter. Without parentheses, an anchor with
158
+ # limit()/offset() of its own is inexpressible (the bounding would read off the whole UNION),
159
+ # so it is rejected in plain words.
160
+ if not dialect.supports_parenthesised_compound and self.anchor.has_bounds:
161
+ raise SnakeEmitError(
162
+ "This engine does not accept parentheses around the anchor of a WITH RECURSIVE, so "
163
+ "an anchor with its own limit()/offset() is inexpressible: the bound would read "
164
+ "off the whole traversal. Drop them from the anchor and bound the RESULT with "
165
+ ".limit() on the recursion."
166
+ )
167
+ anchor = (
168
+ f"({anchor_sql})" if dialect.supports_parenthesised_compound else anchor_sql
169
+ )
170
+
171
+ table = qualified(self.table.schema, self.table.name, dialect)
172
+ step = (
173
+ f"SELECT {step_columns} FROM {table} AS {source} "
174
+ f"JOIN {cte} ON {source}.{quote(self.child_column)} "
175
+ f"= {cte}.{quote(self.parent_column)}"
176
+ )
177
+ # The operator the CALLER chose. `UNION` is `UNION DISTINCT`, so the step drops what has
178
+ # already been accumulated and a cyclic walk runs out of new rows instead of running for ever.
179
+ operator = "UNION" if self.distinct else "UNION ALL"
180
+ sql = (
181
+ f"WITH RECURSIVE {cte} AS ({anchor} {operator} {step}) "
182
+ f"SELECT {columns} FROM {cte}"
183
+ )
184
+
185
+ if self.order_by_keys:
186
+ keys = ", ".join(
187
+ emit_order_key(key, dialect, params, None) for key in self.order_by_keys
188
+ )
189
+ sql = f"{sql} ORDER BY {keys}"
190
+
191
+ clause = dialect.limit_offset(self.limit_value, self.offset_value, params)
192
+ if clause:
193
+ sql = f"{sql} {clause}"
194
+ return sql, tuple(params)
195
+
196
+
197
+ def build_recursive(
198
+ anchor: SnakeQuery[T],
199
+ table: SnakeTableInfo,
200
+ child: SnakeExpr[Any],
201
+ parent: SnakeExpr[Any],
202
+ distinct: bool = False,
203
+ ) -> SnakeRecursive[T]:
204
+ """Builds the recursion, rejecting whatever does not survive a CTE.
205
+
206
+ The hop's two columns must belong to the table that recurses (it joins ONTO ITSELF): a column
207
+ from another model would be emitted all the same and would produce an absurd JOIN, or worse,
208
+ wrong results with no error.
209
+
210
+ `distinct` travels straight through to the emission: it is a choice, not something to validate.
211
+ """
212
+ if anchor.has_includes:
213
+ raise SnakeEmitError(
214
+ "A query with include(...) cannot be the anchor of a recursion: the columns of the "
215
+ "CTE are those of the table, so the loaded relationships would be lost. Recurse "
216
+ "without include and load the relationships afterwards."
217
+ )
218
+ if anchor.has_lock:
219
+ raise SnakeEmitError(
220
+ "A query with for_update() cannot be the anchor of a recursion: Postgres does not "
221
+ "accept locks inside a recursive CTE. Lock the rows in a separate query."
222
+ )
223
+ if anchor.projected_columns is not None:
224
+ raise SnakeEmitError(
225
+ "A query narrowed with only()/defer() cannot be the anchor of a recursion: the CTE's "
226
+ "columns are the TABLE's, so the step selects every one of them and the anchor would "
227
+ "select a few, and the UNION inside the CTE would not line up. Recurse whole rows and "
228
+ "project afterwards."
229
+ )
230
+
231
+ names = {column.name for column in table.columns}
232
+ for expr in (child, parent):
233
+ if expr.path[-1] not in names:
234
+ raise SnakeEmitError(
235
+ f"Column '{expr.path[-1]}' does not belong to '{table.name}', and a recursion joins "
236
+ f"that table WITH ITSELF. Both columns of the hop have to be its own."
237
+ )
238
+ return SnakeRecursive(
239
+ anchor=anchor,
240
+ table=table,
241
+ child_column=child.path[-1],
242
+ parent_column=parent.path[-1],
243
+ distinct=distinct,
244
+ )
@@ -0,0 +1,5 @@
1
+ """SnakeORM's registry of compiled models."""
2
+
3
+ from snakeorm.registry.registry import SnakeRegistry as SnakeRegistry
4
+ from snakeorm.registry.registry import registry_of as registry_of
5
+ from snakeorm.registry.registry import registry as registry
@@ -0,0 +1,163 @@
1
+ """Resolving a model NAME the way the module that wrote it means it.
2
+
3
+ A class name does not identify a model: two apps can each declare a `Customer`, and the registry's
4
+ by-name index keeps whichever registered LAST. Asking it is bug #14, and it does not fail — it
5
+ returns a valid answer about the wrong table.
6
+
7
+ Annotations never had the problem: `get_type_hints` evaluates against the MODULE's globals, so each
8
+ file resolves its own `Post`. This does the same for names that arrive as ARGUMENTS
9
+ (`through="Tagging"`), so one declaration stops having two rules on adjacent lines.
10
+
11
+ Two callers, one reader. `resolve_in_module` answers about ONE name; `type_checking_names` answers
12
+ about the whole block, for `linker.hints_of`, which resolves a model's annotations all at once. They
13
+ share `_type_checking_imports` so a spelling one understands is a spelling the other does too.
14
+
15
+ Two layers, in this order:
16
+
17
+ 1. The module's globals. Covers the forward declaration — the class comes later in the same file —
18
+ because `snake_link()` runs at the end. Costs nothing.
19
+ 2. The `if TYPE_CHECKING:` block, read from source. Covers the real circular import, where the name
20
+ is never in globals and `get_type_hints` raises `NameError`.
21
+
22
+ If neither has it, it REFUSES naming both ways out. Falling back to the global index is what this
23
+ module exists to stop.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import ast
29
+ import importlib.util
30
+ import inspect
31
+ import sys
32
+ from typing import TYPE_CHECKING
33
+
34
+ if TYPE_CHECKING:
35
+ from types import ModuleType
36
+
37
+
38
+ def resolve_in_module(owner: type, name: str) -> type | None:
39
+ """The class `name` refers to, as seen from the module that declared `owner`.
40
+
41
+ `None` when the module cannot see it at all — the caller decides how to complain, because it is
42
+ the one that knows what the name was for.
43
+ """
44
+ module = sys.modules.get(owner.__module__)
45
+ if module is None: # pragma: no cover - a class whose module was unloaded
46
+ return None
47
+ found = getattr(module, name, None)
48
+ if isinstance(found, type):
49
+ return found
50
+ return _from_type_checking(module, name)
51
+
52
+
53
+ def type_checking_names(owner: type) -> dict[str, type]:
54
+ """Every class the OWNER's module imports under `if TYPE_CHECKING:`, imported for real.
55
+
56
+ The same reading as `resolve_in_module`'s second layer, answering for the whole block instead of
57
+ one name — because the caller that needs it, `get_type_hints`, resolves a model's annotations
58
+ ALL AT ONCE and cannot be asked about them one at a time.
59
+
60
+ Empty when there is no block, no source, or nothing importable in it. That is not a failure: the
61
+ caller passes this as extra scope, and extra scope that adds nothing simply changes nothing.
62
+ """
63
+ module = sys.modules.get(owner.__module__)
64
+ if module is None: # pragma: no cover - a class whose module was unloaded
65
+ return {}
66
+ found: dict[str, type] = {}
67
+ for module_name, local, attribute in _type_checking_imports(module):
68
+ imported = _import(module_name, attribute)
69
+ if imported is not None:
70
+ found[local] = imported
71
+ return found
72
+
73
+
74
+ def _from_type_checking(module: ModuleType, name: str) -> type | None:
75
+ """The class a `if TYPE_CHECKING:` import names, imported for real, or `None`.
76
+
77
+ This block is invisible at runtime by design, so the only way to read it is from the SOURCE. It
78
+ is the one case layer 1 cannot cover: a genuine circular import between two modules, where the
79
+ runtime import never happens.
80
+
81
+ Importing the named module HERE is safe in a way it would not have been at declaration time: by
82
+ the time anything asks, the cycle that made the author write `TYPE_CHECKING` is over.
83
+ """
84
+ for module_name, local, attribute in _type_checking_imports(module):
85
+ if local != name:
86
+ continue
87
+ imported = _import(module_name, attribute)
88
+ if imported is not None:
89
+ return imported
90
+ return None
91
+
92
+
93
+ def _type_checking_imports(module: ModuleType) -> list[tuple[str, str, str]]:
94
+ """Every `from X import Y [as Z]` inside the module's type-checking blocks.
95
+
96
+ `(module, bound name, attribute)`, in source order. It is one reader for the two questions asked
97
+ of the block —one name, or all of them— so a spelling either understands is a spelling both do.
98
+ Splitting it was how `through=` and the annotations came to disagree in the first place.
99
+ """
100
+ try:
101
+ source = inspect.getsource(module)
102
+ except (OSError, TypeError): # pragma: no cover - a module with no source on disk
103
+ return []
104
+ found: list[tuple[str, str, str]] = []
105
+ for statement in ast.parse(source).body:
106
+ if not isinstance(statement, ast.If) or not _is_type_checking(statement.test):
107
+ continue
108
+ for node in statement.body:
109
+ if not isinstance(node, ast.ImportFrom):
110
+ continue
111
+ absolute = _absolute(node, module)
112
+ if absolute is None:
113
+ continue
114
+ for alias in node.names:
115
+ found.append((absolute, alias.asname or alias.name, alias.name))
116
+ return found
117
+
118
+
119
+ def _absolute(node: ast.ImportFrom, module: ModuleType) -> str | None:
120
+ """The module an import names, with `from .sibling import X` resolved against its package.
121
+
122
+ Relative imports are the ordinary way to write one inside a package, so skipping them would drop
123
+ the pair back onto the bare `NameError` this fallback exists to stop. Resolving them is not a
124
+ guess: the module carries its own `__package__` and `importlib.util.resolve_name` is what the
125
+ import system itself uses. `None` only when there is no package to resolve against.
126
+ """
127
+ if not node.level:
128
+ return node.module
129
+ package = getattr(module, "__package__", None)
130
+ if (
131
+ not package
132
+ ): # pragma: no cover - a relative import outside any package cannot be executed
133
+ return None
134
+ try:
135
+ return importlib.util.resolve_name(
136
+ f"{'.' * node.level}{node.module or ''}", package
137
+ )
138
+ except (
139
+ ImportError,
140
+ ValueError,
141
+ ): # pragma: no cover - more dots than the package is deep
142
+ return None
143
+
144
+
145
+ def _is_type_checking(test: ast.expr) -> bool:
146
+ """Whether an `if` guards on `TYPE_CHECKING`, spelled bare or as `typing.TYPE_CHECKING`."""
147
+ if isinstance(test, ast.Name):
148
+ return test.id == "TYPE_CHECKING"
149
+ return isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING"
150
+
151
+
152
+ def _import(module_name: str, attribute: str) -> type | None:
153
+ """Imports `module_name` and returns its `attribute`, or `None` if either step fails.
154
+
155
+ Failures come back as `None` rather than raising: this is one of two layers, and a module that
156
+ cannot be imported is a name this layer cannot answer for — not an error in itself. The caller
157
+ refuses once, with both ways out named.
158
+ """
159
+ try:
160
+ found = getattr(importlib.import_module(module_name), attribute, None)
161
+ except ImportError:
162
+ return None
163
+ return found if isinstance(found, type) else None
@@ -0,0 +1,166 @@
1
+ """The Registry: the store of compiled models (class → SnakeTableInfo)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from snakeorm.core.exceptions import SnakeRegistryError
6
+ from snakeorm.metadata import (
7
+ SnakeRelationshipInfo,
8
+ SnakeRoutineInfo,
9
+ SnakeTableInfo,
10
+ SnakeTriggerInfo,
11
+ )
12
+
13
+
14
+ class SnakeRegistry:
15
+ """A class → SnakeTableInfo store (and name → SnakeRoutineInfo alongside it).
16
+
17
+ @snake_model populates it (Phase 1); the linker reads it and replaces the already-linked tables
18
+ (Phase 2). The routines from `snake_function(...)` live apart (`_routines`): they are not
19
+ models, just a DESIRED function the diff compares against.
20
+ """
21
+
22
+ def __init__(self) -> None:
23
+ self._tables: dict[type, SnakeTableInfo] = {}
24
+ self._by_name: dict[str, SnakeTableInfo] = {}
25
+ self._model_by_name: dict[str, type] = {}
26
+ self._table_owner: dict[str, type] = {}
27
+ self._routines: dict[str, SnakeRoutineInfo] = {}
28
+ # Key (table, name): in Postgres a trigger's name is NOT unique on its own.
29
+ self._triggers: dict[tuple[str, str], SnakeTriggerInfo] = {}
30
+ # qualified table -> {discriminator value: class}. Nested (not a composite key) because the
31
+ # mapper asks for the WHOLE map ONCE and resolves each row with `dict.get`.
32
+ self._polymorphic: dict[str, dict[str, type]] = {}
33
+
34
+ def register(self, model: type, table: SnakeTableInfo) -> None:
35
+ """Registers (or replaces) a model's compiled table.
36
+
37
+ Guard: two DIFFERENT models cannot map to the same qualified name (re-registering the SAME
38
+ model is fine, e.g. after snake_link). Exception: a polymorphic hierarchy shares a table on
39
+ purpose; it is recognised from the metadata (`is_polymorphic_child`), not from a flag, so
40
+ nobody gets to skip the guard "just this once".
41
+ """
42
+ qualified = f"{table.schema}.{table.name}"
43
+ owner = self._table_owner.get(qualified)
44
+ if table.is_polymorphic_child:
45
+ assert table.polymorphic is not None
46
+ self._tables[model] = table
47
+ self._by_name[model.__name__] = table
48
+ self._model_by_name[model.__name__] = model
49
+ self._polymorphic.setdefault(qualified, {})[
50
+ table.polymorphic.value or ""
51
+ ] = model
52
+ return
53
+ if owner is not None and owner is not model:
54
+ raise SnakeRegistryError(
55
+ f"Table collision: '{qualified}' is already registered by "
56
+ f"{owner.__qualname__}. Disambiguate with prefix= or table= in @snake_model."
57
+ )
58
+ self._table_owner[qualified] = model
59
+ self._tables[model] = table
60
+ self._by_name[model.__name__] = table
61
+ self._model_by_name[model.__name__] = model
62
+
63
+ def polymorphic_map(self, table: SnakeTableInfo) -> dict[str, type]:
64
+ """Every subclass of that table, indexed by its discriminator value.
65
+
66
+ The WHOLE map is returned (not one class per value) because the mapper asks for it ONCE and
67
+ resolves each row with `dict.get`; asking per row was 55% slower on the hottest path. A
68
+ value with no subclass simply is not in the map, and that is NOT an error: it is hydrated as
69
+ the base class (you lose the subclass's fields, not the row).
70
+ """
71
+ return self._polymorphic.get(f"{table.schema}.{table.name}", {})
72
+
73
+ def table_of(self, model: type) -> SnakeTableInfo | None:
74
+ """Returns a model's compiled table, or None if it is not registered."""
75
+ return self._tables.get(model)
76
+
77
+ def table_by_name(self, name: str) -> SnakeTableInfo | None:
78
+ """Returns the table by the model's name (to resolve relation targets)."""
79
+ return self._by_name.get(name)
80
+
81
+ def model_by_name(self, name: str) -> type | None:
82
+ """Returns the model's CLASS by its name (to instantiate related objects in .include())."""
83
+ return self._model_by_name.get(name)
84
+
85
+ def table_by_qualified(self, qualified: str) -> SnakeTableInfo | None:
86
+ """Returns the table by its QUALIFIED name (`schema.table`), which IS unique.
87
+
88
+ The class name is not (two apps can each have their own `Customer`, and the index keyed by
89
+ `__name__` is kept by whichever comes last). The qualified one is protected by the collision
90
+ guard in `register()`.
91
+ """
92
+ return (
93
+ self._tables.get(self._table_owner[qualified])
94
+ if qualified in self._table_owner
95
+ else None
96
+ )
97
+
98
+ def model_by_qualified(self, qualified: str) -> type | None:
99
+ """Returns the CLASS that owns a qualified table. Counterpart of `table_by_qualified`."""
100
+ return self._table_owner.get(qualified)
101
+
102
+ def resolve_relationship(
103
+ self, relationship: SnakeRelationshipInfo
104
+ ) -> tuple[SnakeTableInfo | None, type | None]:
105
+ """Resolves a relation's target to `(table, class)`, unambiguously when ambiguity exists.
106
+
107
+ It prefers the linker's qualified `target_table`; it falls back to the class name only when
108
+ there is none (a relation rebuilt from a migration, which does not carry it). Centralised
109
+ here so that fixing the wrong target is ONE change and not twelve copies.
110
+ """
111
+ if relationship.target_table:
112
+ table = self.table_by_qualified(relationship.target_table)
113
+ if table is not None:
114
+ return table, self.model_by_qualified(relationship.target_table)
115
+ return self.table_by_name(relationship.target), self.model_by_name(
116
+ relationship.target
117
+ )
118
+
119
+ def models(self) -> tuple[type, ...]:
120
+ """Lists the registered models."""
121
+ return tuple(self._tables)
122
+
123
+ def register_routine(self, routine: SnakeRoutineInfo) -> None:
124
+ """Registers (or replaces) a DESIRED routine declared with `snake_function(...)`.
125
+
126
+ Keyed by `name`: redeclaring the same name replaces the `body`, like a `CREATE OR REPLACE`.
127
+ """
128
+ self._routines[routine.name] = routine
129
+
130
+ def routine_by_name(self, name: str) -> SnakeRoutineInfo | None:
131
+ """Returns the desired routine by name, or None if it is not declared."""
132
+ return self._routines.get(name)
133
+
134
+ def routines(self) -> tuple[SnakeRoutineInfo, ...]:
135
+ """Lists the registered desired routines (autodetect's code-first source of truth)."""
136
+ return tuple(self._routines.values())
137
+
138
+ def register_trigger(self, trigger: SnakeTriggerInfo) -> None:
139
+ """Registers (or replaces) a DESIRED trigger declared with `snake_trigger(...)`.
140
+
141
+ Keyed by (table, name): by name alone, a trigger of the same name on another table would
142
+ stomp on it.
143
+ """
144
+ self._triggers[(trigger.table, trigger.name)] = trigger
145
+
146
+ def triggers(self) -> tuple[SnakeTriggerInfo, ...]:
147
+ """Lists the registered desired triggers."""
148
+ return tuple(self._triggers.values())
149
+
150
+
151
+ registry = SnakeRegistry()
152
+ """The default global registry that @snake_model populates."""
153
+
154
+
155
+ def registry_of(model: type) -> SnakeRegistry:
156
+ """The registry where the model lives (the decorator put it there), or the global one.
157
+
158
+ It is what makes `@snake_model(registry=reg)` work: everything downstream —typed navigation,
159
+ the query, the session— has to resolve against THAT registry and not the global one, or a model
160
+ in its own registry is registered and unreachable.
161
+
162
+ It lives here rather than in `fields/` because it answers a REGISTRY question, and because two
163
+ other packages were already importing the private `_registry_of` across a package boundary,
164
+ which is the shape a helper takes just before it becomes public by accident.
165
+ """
166
+ return getattr(model, "__snake_registry__", registry)
@@ -0,0 +1,8 @@
1
+ """SnakeORM's session layer: synchronous execution of queries."""
2
+
3
+ from snakeorm.session.asyncsession import AsyncSession as AsyncSession
4
+ from snakeorm.session.factory import snake_session as snake_session
5
+ from snakeorm.session.isolation import SnakeIsolation as SnakeIsolation
6
+ from snakeorm.session.retry import is_transient as is_transient
7
+ from snakeorm.session.retry import with_retry as with_retry
8
+ from snakeorm.session.session import SnakeSession as SnakeSession