snake-orm 0.1.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- snake_orm-0.1.0b1.dist-info/METADATA +366 -0
- snake_orm-0.1.0b1.dist-info/RECORD +198 -0
- snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
- snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
- snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
- snakeorm/__init__.py +214 -0
- snakeorm/advisor.py +109 -0
- snakeorm/cli/__init__.py +3 -0
- snakeorm/cli/app.py +992 -0
- snakeorm/cli/discovery.py +179 -0
- snakeorm/cli/hooks.py +86 -0
- snakeorm/compiler/__init__.py +3 -0
- snakeorm/compiler/compiler.py +330 -0
- snakeorm/connection.py +228 -0
- snakeorm/contrib/__init__.py +17 -0
- snakeorm/contrib/asgi.py +192 -0
- snakeorm/contrib/config.py +264 -0
- snakeorm/contrib/deliver.py +256 -0
- snakeorm/contrib/django.py +245 -0
- snakeorm/contrib/sidecar.py +44 -0
- snakeorm/contrib/wsgi.py +176 -0
- snakeorm/core/__init__.py +1 -0
- snakeorm/core/config.py +173 -0
- snakeorm/core/converters.py +172 -0
- snakeorm/core/exceptions.py +175 -0
- snakeorm/core/placement.py +15 -0
- snakeorm/core/sentinels.py +37 -0
- snakeorm/core/signals.py +114 -0
- snakeorm/debug/__init__.py +42 -0
- snakeorm/debug/assets/css/panel.css +616 -0
- snakeorm/debug/assets/css/snake_orm_app.css +293 -0
- snakeorm/debug/assets/img/icon.svg +5 -0
- snakeorm/debug/assets/js/history.js +425 -0
- snakeorm/debug/assets/js/language.js +283 -0
- snakeorm/debug/assets/js/panel.js +241 -0
- snakeorm/debug/assets/js/snake_orm_app.js +82 -0
- snakeorm/debug/assets/pages/cli.html +28 -0
- snakeorm/debug/assets/pages/config.html +34 -0
- snakeorm/debug/assets/pages/database_first.html +29 -0
- snakeorm/debug/assets/pages/help.html +21 -0
- snakeorm/debug/assets/pages/history.html +27 -0
- snakeorm/debug/capture.py +234 -0
- snakeorm/debug/channel.py +131 -0
- snakeorm/debug/collector.py +144 -0
- snakeorm/debug/config.py +149 -0
- snakeorm/debug/html.py +578 -0
- snakeorm/debug/matrix.py +60 -0
- snakeorm/debug/origin.py +42 -0
- snakeorm/debug/otel/__init__.py +68 -0
- snakeorm/debug/otel/context.py +43 -0
- snakeorm/debug/otel/exporter.py +277 -0
- snakeorm/debug/otel/payload.py +97 -0
- snakeorm/debug/otel/report.py +29 -0
- snakeorm/debug/otel/spans.py +393 -0
- snakeorm/debug/otel/summary.py +59 -0
- snakeorm/debug/record.py +54 -0
- snakeorm/debug/report.py +311 -0
- snakeorm/debug/testing.py +23 -0
- snakeorm/decorators/__init__.py +13 -0
- snakeorm/decorators/abstract.py +26 -0
- snakeorm/decorators/db_first.py +104 -0
- snakeorm/decorators/function.py +28 -0
- snakeorm/decorators/model.py +268 -0
- snakeorm/decorators/polymorphic.py +140 -0
- snakeorm/decorators/result.py +167 -0
- snakeorm/decorators/row.py +90 -0
- snakeorm/decorators/trigger.py +47 -0
- snakeorm/decorators/view.py +149 -0
- snakeorm/dialects/__init__.py +6 -0
- snakeorm/dialects/base.py +373 -0
- snakeorm/dialects/capabilities.py +571 -0
- snakeorm/dialects/literals.py +46 -0
- snakeorm/dialects/matrix.py +384 -0
- snakeorm/dialects/mysql.py +519 -0
- snakeorm/dialects/postgres.py +504 -0
- snakeorm/dialects/sqlite.py +417 -0
- snakeorm/drivers/__init__.py +18 -0
- snakeorm/drivers/asyncbase.py +73 -0
- snakeorm/drivers/asyncdecorators.py +244 -0
- snakeorm/drivers/asyncpool.py +228 -0
- snakeorm/drivers/asyncpsycopg.py +124 -0
- snakeorm/drivers/asyncpymysql.py +50 -0
- snakeorm/drivers/asyncsqlite.py +30 -0
- snakeorm/drivers/base.py +76 -0
- snakeorm/drivers/failures.py +181 -0
- snakeorm/drivers/logging.py +167 -0
- snakeorm/drivers/pool.py +352 -0
- snakeorm/drivers/psycopg.py +188 -0
- snakeorm/drivers/pymysql.py +176 -0
- snakeorm/drivers/savepoints.py +46 -0
- snakeorm/drivers/sqlite.py +170 -0
- snakeorm/drivers/threaded.py +143 -0
- snakeorm/drivers/timeout.py +87 -0
- snakeorm/dto/__init__.py +55 -0
- snakeorm/dto/read.py +231 -0
- snakeorm/dto/region.py +326 -0
- snakeorm/dto/resolve.py +354 -0
- snakeorm/dto/spec.py +201 -0
- snakeorm/expressions/__init__.py +88 -0
- snakeorm/expressions/conditional.py +178 -0
- snakeorm/expressions/expression.py +810 -0
- snakeorm/expressions/functions.py +71 -0
- snakeorm/expressions/keys.py +299 -0
- snakeorm/expressions/paths.py +43 -0
- snakeorm/expressions/scalar.py +293 -0
- snakeorm/expressions/window.py +248 -0
- snakeorm/fields/__init__.py +68 -0
- snakeorm/fields/check.py +79 -0
- snakeorm/fields/column.py +316 -0
- snakeorm/fields/enum.py +64 -0
- snakeorm/fields/index.py +46 -0
- snakeorm/fields/relationship.py +738 -0
- snakeorm/fields/typed.py +736 -0
- snakeorm/helpers/__init__.py +1 -0
- snakeorm/helpers/annotations.py +21 -0
- snakeorm/helpers/inheritance.py +41 -0
- snakeorm/helpers/pyliteral.py +52 -0
- snakeorm/helpers/pytype.py +50 -0
- snakeorm/introspection/__init__.py +13 -0
- snakeorm/introspection/base.py +30 -0
- snakeorm/introspection/drift.py +78 -0
- snakeorm/introspection/models.py +633 -0
- snakeorm/introspection/mysql.py +367 -0
- snakeorm/introspection/postgres.py +397 -0
- snakeorm/introspection/sqlite.py +217 -0
- snakeorm/introspection/unsupported.py +94 -0
- snakeorm/linker/__init__.py +3 -0
- snakeorm/linker/linker.py +521 -0
- snakeorm/metadata/__init__.py +41 -0
- snakeorm/metadata/check.py +37 -0
- snakeorm/metadata/column.py +201 -0
- snakeorm/metadata/enum_storage.py +33 -0
- snakeorm/metadata/fk_action.py +19 -0
- snakeorm/metadata/foreign_key.py +26 -0
- snakeorm/metadata/index.py +43 -0
- snakeorm/metadata/index_method.py +19 -0
- snakeorm/metadata/int_size.py +20 -0
- snakeorm/metadata/json_storage.py +21 -0
- snakeorm/metadata/polymorphic.py +30 -0
- snakeorm/metadata/primary_key.py +23 -0
- snakeorm/metadata/relationship.py +60 -0
- snakeorm/metadata/relationship_kind.py +43 -0
- snakeorm/metadata/routine.py +26 -0
- snakeorm/metadata/server_default.py +20 -0
- snakeorm/metadata/table.py +97 -0
- snakeorm/metadata/table_kind.py +23 -0
- snakeorm/metadata/trigger.py +62 -0
- snakeorm/metadata/type_params.py +348 -0
- snakeorm/migration/__init__.py +88 -0
- snakeorm/migration/asyncrunner.py +168 -0
- snakeorm/migration/autodetect.py +96 -0
- snakeorm/migration/ddl.py +1172 -0
- snakeorm/migration/diff.py +701 -0
- snakeorm/migration/loader.py +77 -0
- snakeorm/migration/operations.py +952 -0
- snakeorm/migration/planner.py +78 -0
- snakeorm/migration/realize.py +420 -0
- snakeorm/migration/renames.py +153 -0
- snakeorm/migration/render.py +1095 -0
- snakeorm/migration/runner.py +314 -0
- snakeorm/migration/squash.py +60 -0
- snakeorm/migration/state.py +107 -0
- snakeorm/model.py +162 -0
- snakeorm/py.typed +0 -0
- snakeorm/query/__init__.py +9 -0
- snakeorm/query/compound.py +288 -0
- snakeorm/query/join_kind.py +19 -0
- snakeorm/query/joined.py +143 -0
- snakeorm/query/query.py +1001 -0
- snakeorm/query/recursive.py +244 -0
- snakeorm/registry/__init__.py +5 -0
- snakeorm/registry/by_module.py +163 -0
- snakeorm/registry/registry.py +166 -0
- snakeorm/session/__init__.py +8 -0
- snakeorm/session/asyncsession.py +638 -0
- snakeorm/session/coercion.py +306 -0
- snakeorm/session/factory.py +29 -0
- snakeorm/session/guards.py +269 -0
- snakeorm/session/isolation.py +23 -0
- snakeorm/session/mapper.py +213 -0
- snakeorm/session/planning.py +815 -0
- snakeorm/session/retry.py +96 -0
- snakeorm/session/session.py +871 -0
- snakeorm/session/shared.py +355 -0
- snakeorm/sql/__init__.py +18 -0
- snakeorm/sql/adapt.py +104 -0
- snakeorm/sql/aggregate.py +232 -0
- snakeorm/sql/condition.py +359 -0
- snakeorm/sql/delete.py +46 -0
- snakeorm/sql/insert.py +149 -0
- snakeorm/sql/joins.py +158 -0
- snakeorm/sql/pk_subquery.py +54 -0
- snakeorm/sql/refs.py +19 -0
- snakeorm/sql/resolver.py +28 -0
- snakeorm/sql/select.py +175 -0
- snakeorm/sql/update.py +82 -0
- snakeorm/sql/value.py +552 -0
- snakeorm/times.py +276 -0
|
@@ -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
|
+
"""
|
snakeorm/core/signals.py
ADDED
|
@@ -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
|