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,172 @@
1
+ """The registry for the SECOND axis of the type vocabulary: how a value TRAVELS there and back.
2
+
3
+ The type vocabulary has two axes, and until now only one of them was open:
4
+
5
+ - **How the column is WRITTEN** — `dialect.register_type(type, sql)`. It goes per dialect, because
6
+ the same Python type is written `INET` in Postgres and `TEXT` in SQLite.
7
+ - **How the value TRAVELS** — this. It goes global, and it can go global because `from_db` is
8
+ IDEMPOTENT.
9
+
10
+ That idempotence is the piece the whole design rests on. The eleven internal converters already
11
+ honour it: `_to_decimal` swallows the `Decimal` Postgres returns and the `str` SQLite returns with
12
+ the same code, so nobody has to ask which engine they are on. Without it we would have to register
13
+ one converter per engine, and the value axis would stop being agnostic.
14
+
15
+ It lives in `core/` because both ends of the journey consult it: `sql/adapt.py` on write and
16
+ `session/coercion.py` on read. Anywhere else would create a cycle between `sql/` and `session/`.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Callable, Iterable
22
+ from typing import TypeVar, cast
23
+
24
+ from snakeorm.core.exceptions import SnakeConfigError
25
+
26
+ T = TypeVar("T")
27
+
28
+ ToDb = Callable[[object], object]
29
+ """From the domain type to something a DBAPI driver knows how to send, as it is STORED in the
30
+ registry.
31
+
32
+ Converters come in typed —`register_converter` is generic in the type being declared, so whoever
33
+ writes the `to_db` receives their own type and not `object`— and are stored untyped because the
34
+ registry is heterogeneous: a single table with a different type per key.
35
+ """
36
+
37
+ FromDb = Callable[[object], object]
38
+ """From what the driver returns to the domain type. It MUST be idempotent. See `ToDb`."""
39
+
40
+ _TO_DB: dict[type, ToDb] = {}
41
+ _FROM_DB: dict[type, FromDb] = {}
42
+
43
+ _BUILTIN: set[type] = set()
44
+ """The types whose journey the ORM already defines. `session/coercion.py` DECLARES them on import.
45
+
46
+ The direction matters: `core/` cannot import `session/` —`test_layering` checks it, and rightly so:
47
+ a cycle between layers turns import order into part of the behaviour—. So the upper layer registers
48
+ itself when it loads, instead of this one going off to ask it.
49
+ """
50
+
51
+ _PROBES: tuple[object, ...] = ("", 0, b"")
52
+ """The values idempotence is tested with at registration time.
53
+
54
+ They do not aim to cover the type's domain: all it takes is ONE round the converter knows how to
55
+ make, so we can make it again and compare. If it can make none of them, the check keeps quiet —
56
+ testing with what the converter does not accept would claim it is not idempotent when something else
57
+ is going on.
58
+ """
59
+
60
+
61
+ def register_converter(
62
+ python_type: type[T],
63
+ *,
64
+ to_db: Callable[[T], object],
65
+ from_db: Callable[[object], T],
66
+ ) -> None:
67
+ """Declares how a domain type travels: from Python to the driver and back.
68
+
69
+ Generic in the declared type, so whoever writes the converters works with THEIR type and not
70
+ with `object`: `to_db` receives an `Inet` and `from_db` returns an `Inet`, and the checker
71
+ verifies it. An ORM whose selling point is typing cannot ask for untyped lambdas in its own API.
72
+
73
+ `from_db` MUST be idempotent —`from_db(from_db(x)) == from_db(x)`— because the same converter
74
+ serves all three engines and each returns the column in one shape: Postgres may hand over the
75
+ object already and SQLite the text. It is checked here, at registration time, and not on the
76
+ first read in production.
77
+
78
+ It does not rewrite the types the ORM already handles. A global registry is an invitation for a
79
+ third-party library to change how a `Decimal` travels for the entire process just by being
80
+ imported.
81
+ """
82
+ if python_type in _BUILTIN:
83
+ raise SnakeConfigError(
84
+ f"{python_type!r} is a type the ORM already handles, so its converter is not rewritten. "
85
+ f"A global registry is shared by the whole process: changing here how a core type "
86
+ f"travels would change it too for code that asked for nothing."
87
+ )
88
+ _demand_idempotent(python_type, from_db)
89
+ # The registry is heterogeneous (one key per type, each with its own signature), so they are
90
+ # stored untyped. The `cast` loses nothing: the generic signature already checked the pair at
91
+ # the call site.
92
+ _TO_DB[python_type] = cast("ToDb", to_db)
93
+ _FROM_DB[python_type] = cast("FromDb", from_db)
94
+
95
+
96
+ def _demand_idempotent(python_type: type, from_db: Callable[[object], object]) -> None:
97
+ """Checks that applying `from_db` twice gives the same as applying it once.
98
+
99
+ It is tested with probe values, ignoring the ones the converter cannot swallow: what we are
100
+ after is a round it CAN make, so we can make it again. If no probe works, nothing is claimed —
101
+ a false positive here would block a perfectly correct type.
102
+
103
+ The two calls are attempted SEPARATELY, and that separation is the whole guard rather than a
104
+ tidying-up. Under a single `try` both failures looked the same and both were read as "this probe
105
+ does not fit the type, try the next one" — but they are opposite facts. The FIRST call failing
106
+ says nothing about the converter: the probe simply is not of its shape. The SECOND call failing
107
+ says everything: the converter swallowed a value and then choked on the value it had just
108
+ produced, which is exactly what happens the day the engine underneath hands back the object
109
+ already built instead of its text. So the more dangerous of the two ways of not being idempotent
110
+ was the one that registered without a word, and it surfaces on a READ, in production, over rows
111
+ that are already written.
112
+ """
113
+ for probe in _PROBES:
114
+ try:
115
+ once = from_db(probe)
116
+ except Exception: # noqa: BLE001 - the probe does not fit this type; try the next one
117
+ continue
118
+ try:
119
+ twice = from_db(once)
120
+ except Exception as error:
121
+ raise SnakeConfigError(
122
+ f"The `from_db` of {python_type!r} is not idempotent: it accepted {probe!r} and "
123
+ f"then failed on the value it had just returned ({error!r}). It has to accept its "
124
+ f"own output, because the same converter reads from the three engines and each one "
125
+ f"returns the column in a different shape: one may hand over the object already and "
126
+ f"another the text. Make it return the value as it is when it already is the "
127
+ f"expected type."
128
+ ) from error
129
+ if once != twice:
130
+ raise SnakeConfigError(
131
+ f"The `from_db` of {python_type!r} is not idempotent: converting twice gives "
132
+ f"something different from converting once ({once!r} and then {twice!r}). It has to be, "
133
+ f"because the same converter reads from the three engines, and each one returns the "
134
+ f"column in a different shape: one may hand over the object already and another the "
135
+ f"text. Make it return the value as it is when it already is the expected type."
136
+ )
137
+ return
138
+
139
+
140
+ def mark_builtin(types: Iterable[object]) -> None:
141
+ """Declares which types the ORM already handles, so `register_converter` will not rewrite them.
142
+
143
+ `session/coercion.py` calls it on import, with the keys of its own registry: that way the list
144
+ is not duplicated —nor can it drift— and `core/` still imports nobody from above.
145
+ """
146
+ _BUILTIN.update(klass for klass in types if isinstance(klass, type))
147
+
148
+
149
+ def to_db_for(value: object) -> ToDb | None:
150
+ """The adapter for a VALUE, looked up by its type and its bases', or `None` if there is none.
151
+
152
+ By MRO and not by exact `type(value)` so a subclass of the registered type inherits the journey,
153
+ which is what anyone declaring `class IPv4(Inet)` expects.
154
+ """
155
+ if not _TO_DB:
156
+ return None
157
+ for klass in type(value).__mro__:
158
+ adapter = _TO_DB.get(klass)
159
+ if adapter is not None:
160
+ return adapter
161
+ return None
162
+
163
+
164
+ def from_db_for(python_type: object) -> FromDb | None:
165
+ """The read converter of a DECLARED type, or `None` if none was registered."""
166
+ if not isinstance(python_type, type):
167
+ return None
168
+ for klass in python_type.__mro__:
169
+ converter = _FROM_DB.get(klass)
170
+ if converter is not None:
171
+ return converter
172
+ return None
@@ -0,0 +1,175 @@
1
+ """Public exception hierarchy: every ORM error hangs off `SnakeError`.
2
+
3
+ Each one ALSO inherits from the builtin it replaces (ValueError/TypeError/...) for compatibility:
4
+ `except SnakeError` gains precision and `except ValueError` keeps working. Multiple inheritance,
5
+ deliberately. The `TypeError`s from the generated `__init__` (calling a callable wrong) do NOT live
6
+ here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class SnakeError(Exception):
13
+ """Root of every SnakeORM error."""
14
+
15
+
16
+ class SnakeModelError(SnakeError, TypeError):
17
+ """Something without `@snake_model` was passed as a model: the type is inappropriate."""
18
+
19
+
20
+ class SnakeModelDefinitionError(SnakeError, ValueError):
21
+ """The model carries `@snake_model` but is declared wrong (with no PK, for instance).
22
+
23
+ It is a valid class with invalid content: hence `ValueError` and not `TypeError`.
24
+ """
25
+
26
+
27
+ class SnakeRegistryError(SnakeError, ValueError):
28
+ """Trouble in the registry: unregistered model or table collision."""
29
+
30
+
31
+ class SnakeUnknownRelationship(SnakeError, ValueError):
32
+ """A relation the table does not declare was named."""
33
+
34
+
35
+ class SnakeUnlinkedRelationship(SnakeError, RuntimeError):
36
+ """A relation was used before calling `snake_link()`."""
37
+
38
+
39
+ class SnakeRelationshipNotLoaded(SnakeError, AttributeError):
40
+ """A relation the query did not load was accessed (the anti-N+1 lock)."""
41
+
42
+
43
+ class SnakeAggregateNotLoaded(SnakeError, AttributeError):
44
+ """An aggregate asked for through the escape hatch (`obj.aggregate.x`) was not loaded (a missing
45
+ `annotate`, or a missing name)."""
46
+
47
+
48
+ class SnakeColumnNotLoaded(SnakeError, AttributeError):
49
+ """A column left out by `only()`/`defer()` was read on the instance.
50
+
51
+ The sibling of `SnakeRelationshipNotLoaded`, and it exists for the same reason: the alternative
52
+ is the descriptor falling through to the column's DEFAULT, which hands back `None` or `0` for a
53
+ value nobody loaded. A wrong answer with no error is the one outcome this ORM does not produce.
54
+ """
55
+
56
+
57
+ class SnakeUnknownColumn(SnakeError, ValueError):
58
+ """A column the table does not declare was named."""
59
+
60
+
61
+ class SnakeConfigError(SnakeError, ValueError):
62
+ """Configuration is missing to operate (e.g. there is no way to determine the DSN)."""
63
+
64
+
65
+ class SnakePoolTimeout(SnakeError, TimeoutError):
66
+ """The pool could not hand over a healthy connection within the deadline.
67
+
68
+ It has a name of its own because the engine's raw error ("connection pool exhausted") does not
69
+ tell apart two situations that call for opposite actions: "there is no slot right now" (wait, or
70
+ raise the pool size) and "I have spent thirty seconds failing to get a live one" (the database
71
+ is down). It inherits from `TimeoutError` so a generic retry treats it as what it is.
72
+ """
73
+
74
+
75
+ class SnakeIntegrityError(SnakeError):
76
+ """A constraint refused the write. ONE exception where the three drivers raised three.
77
+
78
+ A duplicate key came back as `psycopg2.errors.UniqueViolation`, `pymysql.err.IntegrityError` and
79
+ `sqlite3.IntegrityError`, so the `except` handling it was the one part of an application that
80
+ could not be moved between engines.
81
+
82
+ The driver's exception is CHAINED, never swallowed: it stays in `__cause__` and the traceback
83
+ prints both. Whoever catches gets a portable name, whoever debugs still gets the server's own
84
+ words.
85
+
86
+ Catch this one to be coarse, or a subtype below to be precise. Every engine says which
87
+ constraint broke, so the subtype is read off a CODE and never off the message: reading text is
88
+ how a detector fails open.
89
+
90
+ It KEEPS what it was built from, in `driver_error` and `code`, so the classification is testable
91
+ without an engine — checking that MariaDB's 4025 lands in `SnakeCheckViolation` needs no
92
+ MariaDB, while `__cause__` proves the chaining and says nothing about the DECISION. The engine
93
+ tests then check the other half, that the engine really does send that code.
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ message: str,
99
+ *,
100
+ driver_error: BaseException | None = None,
101
+ code: str | int | None = None,
102
+ ) -> None:
103
+ super().__init__(message)
104
+ self.driver_error = driver_error
105
+ """The exception the DBAPI raised, kept whole. Also reachable as `__cause__`."""
106
+ self.code = code
107
+ """The engine's own code that decided the subtype: a SQLSTATE, a MySQL errno or an SQLite
108
+ extended-result name. What was READ, not what was guessed — so a wrong classification can be
109
+ argued with rather than re-derived."""
110
+
111
+
112
+ class SnakeUniqueViolation(SnakeIntegrityError):
113
+ """A UNIQUE constraint or a primary key already holds that value."""
114
+
115
+
116
+ class SnakeForeignKeyViolation(SnakeIntegrityError):
117
+ """A foreign key points at a row that is not there, or a referenced row was removed."""
118
+
119
+
120
+ class SnakeNotNullViolation(SnakeIntegrityError):
121
+ """A NOT NULL column was given nothing.
122
+
123
+ Rare from a typed model —`SnakeColumn[int]` refuses a `None` long before any driver sees it— and
124
+ that is exactly why it is here: it arrives from what the type system does not cover, such as raw
125
+ SQL or a column a migration added.
126
+ """
127
+
128
+
129
+ class SnakeCheckViolation(SnakeIntegrityError):
130
+ """A CHECK constraint said no.
131
+
132
+ The one that proves the classification cannot key on the driver's exception CLASS: MySQL raises
133
+ `OperationalError` for this and `IntegrityError` for the other three.
134
+ """
135
+
136
+
137
+ class SnakeUnsupportedFeature(SnakeError, ValueError):
138
+ """The combination asked for exists in the design but is not built yet."""
139
+
140
+
141
+ class SnakeDialectError(SnakeError, ValueError):
142
+ """The dialect does not know how to translate something (a Python type, a SQL literal)."""
143
+
144
+
145
+ class SnakeMigrationError(SnakeError, ValueError):
146
+ """Trouble with the migration history on disk: duplicated numbering, gaps, a file that does not
147
+ expose `migration`, or a migration that fails while being applied without transactional DDL
148
+ (leaving the DB half done)."""
149
+
150
+
151
+ class SnakeEmitError(SnakeError, ValueError):
152
+ """Valid SQL cannot be emitted from what was given (an INSERT with no columns...)."""
153
+
154
+
155
+ class SnakeNodeError(SnakeError, TypeError):
156
+ """The emitter received an AST node it does not know how to translate into SQL."""
157
+
158
+
159
+ class SnakeValueError(SnakeError, ValueError):
160
+ """A value does not fit its declared column (e.g. a `Decimal` with more decimals than the
161
+ `scale`). Raised on WRITE, before touching the DB, so the engine does not round it or store it
162
+ half-way silently."""
163
+
164
+
165
+ class SnakeDtoError(SnakeError, ValueError):
166
+ """A generated DTO cannot be honoured: its declaration is contradictory, it names a column the
167
+ model does not have, or the file it has to be written into holds something the generator does
168
+ not manage. It is raised at DECLARATION time whenever it can be, so the mistake dies where it
169
+ was written and never becomes a body that quietly lost a column."""
170
+
171
+
172
+ class SnakeWarning(UserWarning):
173
+ """An ORM warning that does not stop you, but is worth seeing: a bulk write that fires no
174
+ signals, or an engine with less fidelity than another. It inherits from `UserWarning` so that
175
+ `filterwarnings("ignore", category=SnakeWarning)` silences ONLY the ORM's."""
@@ -0,0 +1,15 @@
1
+ """The two axes that place a table: in WHICH database and in WHICH schema (their defaults).
2
+
3
+ No imports on purpose: half the codebase reads it (metadata, compiler, decorators, migrations,
4
+ CLI), so any import of its own would be a cycle — and putting it in `config.py` would drag `dotenv`
5
+ into everyone who touches a table.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ DEFAULT_SCHEMA = "public"
11
+ """A table's default schema. Postgres's; SQLite ignores it (it has no schemas)."""
12
+
13
+ DEFAULT_DATABASE = "default"
14
+ """The default connection. Resolved through `DATABASE_URL`/`SNAKEORM_DSN`/`DB_*`; every other one
15
+ through `SNAKEORM_DSN_<NAME>`."""
@@ -0,0 +1,37 @@
1
+ """Sentinels the whole package shares. One module, so no two of them mean the same thing.
2
+
3
+ A sentinel is worth a module of its own for a reason `None` explains: `None` is a VALUE a column may
4
+ legitimately hold, so it cannot also mean "there is nothing here". These do not collide with any
5
+ value a database can return.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Final
11
+
12
+
13
+ class _NotLoaded:
14
+ """The type of `NOT_LOADED`. A class rather than `object()` so the `repr` says what it is."""
15
+
16
+ __slots__ = ()
17
+
18
+ def __repr__(self) -> str:
19
+ return "<not loaded>"
20
+
21
+ def __bool__(self) -> bool:
22
+ """Falsy, and deliberately so: `if row.column` on an unloaded one must not read as a value.
23
+
24
+ It never reaches user code through the descriptor —that raises— but it does sit in the
25
+ instance's `__dict__`, where a `vars(row)` or a debugger will meet it.
26
+ """
27
+ return False
28
+
29
+
30
+ NOT_LOADED: Final[Any] = _NotLoaded()
31
+ """Written by hydration into every column a query left out (`only()` / `defer()`).
32
+
33
+ Typed `Any` on purpose: it is assigned into slots declared as `str`, `int` or whatever the column is,
34
+ and the checker has no way to express "this is that type or the sentinel" without infecting every
35
+ column's type with a union the user would then have to narrow. The guarantee is upheld at RUNTIME by
36
+ the descriptor, which raises rather than ever handing this out.
37
+ """
@@ -0,0 +1,114 @@
1
+ """CODE signals: `PRE_SAVE` / `POST_SAVE` / `PRE_DELETE` / `POST_DELETE`.
2
+
3
+ The difference from `snake_trigger` (one of GUARANTEE, not of implementation): a trigger lives in
4
+ the schema and always holds; a signal lives in the app and only fires if the write goes through the
5
+ session —which is why it NEVER guarantees integrity; if it must always hold, it is a trigger. The
6
+ handlers run INSIDE the transaction: if one raises, the write is undone.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import warnings
12
+ from collections.abc import Callable
13
+ from enum import Enum
14
+ from typing import TypeVar
15
+
16
+ from snakeorm.core.exceptions import SnakeWarning
17
+
18
+ T = TypeVar("T")
19
+
20
+ Handler = Callable[[object], None]
21
+ """A handler receives the instance and returns nothing."""
22
+
23
+
24
+ class SnakeSignal(Enum):
25
+ """The moment a handler fires relative to the write."""
26
+
27
+ PRE_SAVE = "pre_save"
28
+ POST_SAVE = "post_save"
29
+ PRE_DELETE = "pre_delete"
30
+ POST_DELETE = "post_delete"
31
+
32
+
33
+ _HANDLERS: dict[tuple[type, SnakeSignal], list[Handler]] = {}
34
+ """Connected handlers, by (model, signal). Module-global, like the registry."""
35
+
36
+
37
+ def snake_on(
38
+ model: type[T], signal: SnakeSignal
39
+ ) -> Callable[[Callable[[T], None]], Callable[[T], None]]:
40
+ """Connects a handler to a model's signal. Returns the function untouched.
41
+
42
+ @snake_on(Order, SnakeSignal.POST_SAVE)
43
+ def notify(order: Order) -> None:
44
+ ...
45
+
46
+ The handler receives the instance with the model's TYPE (to the checker it is a `Order`, not
47
+ `Any`).
48
+ """
49
+
50
+ def decorator(handler: Callable[[T], None]) -> Callable[[T], None]:
51
+ connect(model, signal, handler)
52
+ return handler
53
+
54
+ return decorator
55
+
56
+
57
+ def connect(model: type[T], signal: SnakeSignal, handler: Callable[[T], None]) -> None:
58
+ """Connects a handler without the decorator (handy for connecting at runtime)."""
59
+ _HANDLERS.setdefault((model, signal), []).append(handler) # type: ignore[arg-type]
60
+
61
+
62
+ def disconnect_all(model: type | None = None) -> None:
63
+ """Disconnects a model's handlers, or ALL of them if none is given (mostly for tests: the
64
+ dictionary is global and a handler left in place contaminates the ones that follow)."""
65
+ if model is None:
66
+ _HANDLERS.clear()
67
+ return
68
+ for key in [key for key in _HANDLERS if key[0] is model]:
69
+ del _HANDLERS[key]
70
+
71
+
72
+ def signals_of(model: type) -> tuple[SnakeSignal, ...]:
73
+ """The signals that model has connected. The bulk-write warning uses it."""
74
+ return tuple(
75
+ sorted(
76
+ {key[1] for key in _HANDLERS if key[0] is model and _HANDLERS[key]},
77
+ key=lambda signal: signal.value,
78
+ )
79
+ )
80
+
81
+
82
+ def models_with_signals() -> tuple[type, ...]:
83
+ """The models that have any signal connected. The `check` command lists them."""
84
+ return tuple({key[0] for key in _HANDLERS if _HANDLERS[key]})
85
+
86
+
87
+ def emit(model: type, signal: SnakeSignal, instance: object) -> None:
88
+ """Fires the handlers of `(model, signal)` in connection order.
89
+
90
+ Exceptions are deliberately not caught: if a handler fails, the write must be undone with it.
91
+ """
92
+ for handler in _HANDLERS.get((model, signal), ()):
93
+ handler(instance)
94
+
95
+
96
+ def warn_bulk_skips_signals(model: type, operation: str) -> None:
97
+ """Warns that a BULK write is not going to fire the model's signals.
98
+
99
+ Neither an error (bulk writing is legitimate) nor silence (the `queryset.update()` trap in
100
+ Django): the bulk path resolves the UPDATE in the engine without fetching the rows, and firing
101
+ them would force a SELECT of N rows — the very N+1 the rest of the ORM avoids. So we warn.
102
+ """
103
+ signals = signals_of(model)
104
+ if not signals:
105
+ return
106
+ names = ", ".join(signal.value for signal in signals)
107
+ warnings.warn(
108
+ f"{operation}() over '{model.__name__}' does NOT fire its signals ({names}): a bulk write "
109
+ f"is resolved by the engine without fetching the rows. If that logic has to run here, walk "
110
+ f"the rows and use update()/delete() on each one; if it has to hold ALWAYS — outside the ORM "
111
+ f"too — move it into a trigger.",
112
+ SnakeWarning,
113
+ stacklevel=3,
114
+ )
@@ -0,0 +1,42 @@
1
+ """DEBUG subsystem: it captures the SQL that runs and delivers it readable. A core that is AGNOSTIC of the web framework.
2
+
3
+ It captures the queries (a driver decorator + a per-scope collector), normalises them into a `DebugReport` and offers several renderers of that same report. The web adapters live in `snakeorm.contrib.*` and only CONSUME this.
4
+ """
5
+
6
+ from snakeorm.debug.channel import DEBUG_ENV_KEY as DEBUG_ENV_KEY
7
+ from snakeorm.debug.config import PRODUCTION_ENV_KEY as PRODUCTION_ENV_KEY
8
+ from snakeorm.debug.channel import CHANNEL_AUDIENCE as CHANNEL_AUDIENCE
9
+ from snakeorm.debug.channel import ChannelAudience as ChannelAudience
10
+ from snakeorm.debug.channel import RISKY_CHANNELS as RISKY_CHANNELS
11
+ from snakeorm.debug.channel import (
12
+ demand_every_channel_classified as demand_every_channel_classified,
13
+ )
14
+ from snakeorm.debug.channel import (
15
+ UNIMPLEMENTED_CHANNELS as UNIMPLEMENTED_CHANNELS,
16
+ )
17
+ from snakeorm.debug.channel import SnakeDebugChannel as SnakeDebugChannel
18
+ from snakeorm.debug.channel import channels_from_env as channels_from_env
19
+ from snakeorm.debug.channel import parse_channels as parse_channels
20
+ from snakeorm.debug.config import ADVISE_MS_ENV_KEY as ADVISE_MS_ENV_KEY
21
+ from snakeorm.debug.config import LANG_ENV_KEY as LANG_ENV_KEY
22
+ from snakeorm.debug.config import SnakeDebugConfig as SnakeDebugConfig
23
+ from snakeorm.debug.config import SnakeDebugLanguage as SnakeDebugLanguage
24
+ from snakeorm.debug.matrix import SnakeWebFramework as SnakeWebFramework
25
+ from snakeorm.debug.matrix import channels_without_effect as channels_without_effect
26
+ from snakeorm.debug.matrix import warn_unimplemented as warn_unimplemented
27
+ from snakeorm.debug.matrix import warn_unsupported as warn_unsupported
28
+ from snakeorm.debug.testing import assert_queries as assert_queries
29
+ from snakeorm.debug.capture import AsyncCaptureDriver as AsyncCaptureDriver
30
+ from snakeorm.debug.capture import CaptureDriver as CaptureDriver
31
+ from snakeorm.debug.collector import DebugCollector as DebugCollector
32
+ from snakeorm.debug.collector import capture_queries as capture_queries
33
+ from snakeorm.debug.collector import current_collector as current_collector
34
+ from snakeorm.debug.html import render_report_html as render_report_html
35
+ from snakeorm.debug.html import render_report_page as render_report_page
36
+ from snakeorm.debug.record import QueryKind as QueryKind
37
+ from snakeorm.debug.record import QueryOrigin as QueryOrigin
38
+ from snakeorm.debug.record import QueryRecord as QueryRecord
39
+ from snakeorm.debug.report import DebugReport as DebugReport
40
+ from snakeorm.debug.report import DuplicateGroup as DuplicateGroup
41
+ from snakeorm.debug.report import RequestInfo as RequestInfo
42
+ from snakeorm.debug.otel import export_report as export_report