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,264 @@
|
|
|
1
|
+
"""SHARED translators: from a framework's native config (dicts) to the centralised config.
|
|
2
|
+
|
|
3
|
+
The logic has NOTHING of any concrete framework in it —it is dict → `SnakeConnectionConfig` /
|
|
4
|
+
(channels, panel config)—, so it lives here and `contrib.django`, the only framework linker, uses
|
|
5
|
+
it. The TypedDicts type the SHAPE of the dict the user writes, for IntelliSense. Everything fails
|
|
6
|
+
LOUD: an unknown `ENGINE`/channel or a non-numeric threshold blows up naming them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING, NotRequired, TypedDict
|
|
14
|
+
|
|
15
|
+
from snakeorm.advisor import DEFAULT_MIN_MS
|
|
16
|
+
from snakeorm.connection import SnakeBackend, SnakeConnectionConfig
|
|
17
|
+
from snakeorm.core.exceptions import SnakeConfigError
|
|
18
|
+
from snakeorm.debug import (
|
|
19
|
+
AsyncCaptureDriver,
|
|
20
|
+
CaptureDriver,
|
|
21
|
+
SnakeDebugChannel,
|
|
22
|
+
SnakeDebugConfig,
|
|
23
|
+
SnakeDebugLanguage,
|
|
24
|
+
parse_channels,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from snakeorm.session import AsyncSession, SnakeSession
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def open_session(config: SnakeConnectionConfig) -> SnakeSession:
|
|
32
|
+
"""Open a session from the central config, with the captured driver (the panel sees the SQL).
|
|
33
|
+
|
|
34
|
+
The UNIVERSAL path: whoever builds a `SnakeConnectionConfig` directly (Flask, FastAPI, or
|
|
35
|
+
wherever) injects it into their app and opens with this. A framework WITHOUT a connection
|
|
36
|
+
convention needs no bespoke linker —only Django has one, because its `DATABASES` IS a convention
|
|
37
|
+
to translate—.
|
|
38
|
+
|
|
39
|
+
The `backend` also DECLARES the engine to the capture driver, so every record knows which engine
|
|
40
|
+
ran it — `db.system.name`, which the `otel` channel puts on every query span. It is declared and
|
|
41
|
+
not guessed: a pooled or timed-out driver hides its class, and no SQL tells MySQL from MariaDB.
|
|
42
|
+
"""
|
|
43
|
+
system = config.backend.db_system_name
|
|
44
|
+
return config.open(wrap=lambda driver: CaptureDriver(driver, system=system))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def open_session_async(config: SnakeConnectionConfig) -> AsyncSession:
|
|
48
|
+
"""The same path, awaited: an ASYNCHRONOUS session from the central config with the SQL captured.
|
|
49
|
+
|
|
50
|
+
It exists for the same reason as its sibling: an async framework building a
|
|
51
|
+
`SnakeConnectionConfig` had to assemble driver and dialect by hand, and lost the debug panel
|
|
52
|
+
along the way. That the facade is a single line is precisely the sign that the seam was in the
|
|
53
|
+
right place.
|
|
54
|
+
|
|
55
|
+
The engine is declared here too: one seam, the same two colours.
|
|
56
|
+
"""
|
|
57
|
+
system = config.backend.db_system_name
|
|
58
|
+
return await config.open_async(
|
|
59
|
+
wrap=lambda driver: AsyncCaptureDriver(driver, system=system)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class SnakeOrmConfig:
|
|
65
|
+
"""SnakeORM's ROOT config in ONE object: the connections (`databases`) and the panel settings.
|
|
66
|
+
|
|
67
|
+
The user builds ONE, injects it into their app (Flask `app.config`, FastAPI `app.state`, ...)
|
|
68
|
+
and out of it come the sessions (`open`), the panel config (`channels` / `debug_config`) and the
|
|
69
|
+
migrations (`migrate`). Multi-DB by name in `databases`; `debug` are the channels
|
|
70
|
+
(`"ssr,envelope,timing"`) and `advise_ms` the advisor threshold. `migrations_dir` decides WHERE
|
|
71
|
+
the migrations live: a SINGLE directory (centralised) if declared, or `None` for the per-domain
|
|
72
|
+
mode (one `migrations/` per domain). `language` fixes the language the panel OPENS in (default
|
|
73
|
+
English; the user changes it with the 🌐 selector). The debug envelope
|
|
74
|
+
(`snakeorm` in the JSON) is turned on by the `envelope` CHANNEL of `debug`, not by a separate
|
|
75
|
+
flag. It is the FRIENDLY face: one object, everything inside, typed. SnakeORM reads THIS object,
|
|
76
|
+
never the `.env`: each framework reads its environment its own way and fills this in.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
databases: Mapping[str, SnakeConnectionConfig]
|
|
80
|
+
debug: str = ""
|
|
81
|
+
advise_ms: float = DEFAULT_MIN_MS
|
|
82
|
+
migrations_dir: str | None = None
|
|
83
|
+
language: SnakeDebugLanguage = SnakeDebugLanguage.EN
|
|
84
|
+
production: bool | None = None
|
|
85
|
+
"""WHERE this app runs. Declaring it is what lets the risky channels be dropped.
|
|
86
|
+
|
|
87
|
+
`None` means NOBODY SAID, which is a third state and not a synonym for development: while
|
|
88
|
+
`debug` names no channel that hands out SQL it costs nothing, and the moment one is named the
|
|
89
|
+
middleware refuses to start rather than guessing. This is the root config, so it is the one
|
|
90
|
+
place a whole app answers the question once — and the reason it is a typed field rather than a
|
|
91
|
+
key in a settings dict is the one `SnakeDebugConfig` gives: a dict returns `object` on every
|
|
92
|
+
access and reintroduces the `Any` the thesis forbids.
|
|
93
|
+
|
|
94
|
+
A Django app does not need it: that adapter reads `settings.DEBUG`, which is the framework's own
|
|
95
|
+
answer to the same question.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def _connection(self, alias: str) -> SnakeConnectionConfig:
|
|
99
|
+
"""The connection that alias names, or a `SnakeError` saying which ones exist.
|
|
100
|
+
|
|
101
|
+
It used to be `self.databases[alias]` bare, in two places. A `KeyError: 'analitycs'` does
|
|
102
|
+
not say what aliases there are, does not say where they are declared, and is not even a
|
|
103
|
+
`SnakeError` — so an application catching `SnakeError` around its start-up did not catch it.
|
|
104
|
+
|
|
105
|
+
The answer already existed twelve lines below, in `_backend_for`, with "fail loud: a typo
|
|
106
|
+
never falls back to a default" written on it. Three places, one question; now one answer.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
return self.databases[alias]
|
|
110
|
+
except KeyError:
|
|
111
|
+
declared = ", ".join(sorted(self.databases)) or "(none)"
|
|
112
|
+
raise SnakeConfigError(
|
|
113
|
+
f"'{alias}' is not a connection of this config. The ones it declares are: "
|
|
114
|
+
f"{declared}. A connection cannot be created from the command line: it has to be "
|
|
115
|
+
f"in `databases`."
|
|
116
|
+
) from None
|
|
117
|
+
|
|
118
|
+
def open(self, alias: str = "default") -> SnakeSession:
|
|
119
|
+
"""Open a session of the `alias` connection, with the captured driver (the panel sees the SQL)."""
|
|
120
|
+
return open_session(self._connection(alias))
|
|
121
|
+
|
|
122
|
+
def migrate(self, alias: str = "default") -> list[str]:
|
|
123
|
+
"""Apply the migrations to the `alias` connection. It returns the freshly applied ones.
|
|
124
|
+
|
|
125
|
+
MULTI-ENGINE: driver and dialect come out of the `backend`, so it runs on
|
|
126
|
+
SQLite/Postgres/MySQL alike (the CLI pairs them the same way). Idempotent. Two modes
|
|
127
|
+
depending on `migrations_dir`:
|
|
128
|
+
|
|
129
|
+
- A SINGLE directory (centralised): applied in its linear order.
|
|
130
|
+
- `None` (PER DOMAIN): it discovers the `apps/*/migrations`, loads them ALL and orders them
|
|
131
|
+
by FK DEPENDENCY (`accounts` creates `users` → it goes before `blog`, which references
|
|
132
|
+
it). The order is DERIVED from the relations, not declared by hand.
|
|
133
|
+
"""
|
|
134
|
+
from snakeorm.migration import ( # lazy: do not drag migration in if you do not migrate
|
|
135
|
+
MigrationRunner,
|
|
136
|
+
dependency_order,
|
|
137
|
+
load,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# The alias is resolved FIRST. It used to be indexed at the end, so a typo in an argument
|
|
141
|
+
# cost a full directory walk and a topological sort of every migration before failing —
|
|
142
|
+
# and the failure was still a bare `KeyError`.
|
|
143
|
+
connection = self._connection(alias)
|
|
144
|
+
|
|
145
|
+
if self.migrations_dir is not None:
|
|
146
|
+
pending = load(self.migrations_dir)
|
|
147
|
+
else:
|
|
148
|
+
import glob
|
|
149
|
+
|
|
150
|
+
dirs = sorted(glob.glob("apps/*/migrations"))
|
|
151
|
+
if not dirs:
|
|
152
|
+
raise SnakeConfigError(
|
|
153
|
+
"Per-domain mode (`migrations_dir=None`): there are no migrations in `apps/*/migrations`. "
|
|
154
|
+
"Generate the ones for each domain (`makemigrations --only`) or declare a single directory."
|
|
155
|
+
)
|
|
156
|
+
pending = dependency_order([mig for d in dirs for mig in load(d)])
|
|
157
|
+
|
|
158
|
+
driver, dialect = connection.driver_and_dialect()
|
|
159
|
+
try:
|
|
160
|
+
return MigrationRunner(driver, dialect).apply(pending)
|
|
161
|
+
finally:
|
|
162
|
+
driver.close()
|
|
163
|
+
|
|
164
|
+
def channels(self) -> frozenset[SnakeDebugChannel]:
|
|
165
|
+
"""The panel channels (it parses `debug`; fail loud if a channel has a typo)."""
|
|
166
|
+
return parse_channels(self.debug)
|
|
167
|
+
|
|
168
|
+
def debug_config(self) -> SnakeDebugConfig:
|
|
169
|
+
"""The panel config: the advisor threshold, the language it opens in, and the environment."""
|
|
170
|
+
return SnakeDebugConfig(
|
|
171
|
+
advise_min_ms=self.advise_ms,
|
|
172
|
+
language=self.language,
|
|
173
|
+
production=self.production,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class SnakeOrmDatabase(TypedDict):
|
|
178
|
+
"""The SHAPE of a `DATABASES["<alias>"]` SnakeORM understands (TypedDict = it types a dict literal).
|
|
179
|
+
|
|
180
|
+
`ENGINE` accepts the short name (`"postgres"`) or Django's
|
|
181
|
+
(`"django.db.backends.postgresql"`). The network pieces are optional (SQLite only uses `NAME`,
|
|
182
|
+
which is the path of the file).
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
ENGINE: str
|
|
186
|
+
NAME: str
|
|
187
|
+
HOST: NotRequired[str]
|
|
188
|
+
PORT: NotRequired[str]
|
|
189
|
+
USER: NotRequired[str]
|
|
190
|
+
PASSWORD: NotRequired[str]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class SnakeOrmSettings(TypedDict):
|
|
194
|
+
"""The SHAPE of the PANEL config constant (not the connection).
|
|
195
|
+
|
|
196
|
+
`DEBUG` are the channels (`"ssr,envelope,timing"`; the `envelope` channel puts the debug in the
|
|
197
|
+
JSON); `ADVISE_MS`, the index advisor threshold; `LANG` (`"es"`/`"en"`), the language the panel
|
|
198
|
+
OPENS in. All optional: without `DEBUG` the panel stays off; without `ADVISE_MS`, the default
|
|
199
|
+
threshold; without `LANG`, English.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
DEBUG: NotRequired[str]
|
|
203
|
+
ADVISE_MS: NotRequired[float]
|
|
204
|
+
LANG: NotRequired[str]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# Accepted engine names → backend. It includes Django's so that its `DATABASES` changes nothing.
|
|
208
|
+
_ENGINES: dict[str, SnakeBackend] = {
|
|
209
|
+
"postgres": SnakeBackend.POSTGRES,
|
|
210
|
+
"postgresql": SnakeBackend.POSTGRES,
|
|
211
|
+
"django.db.backends.postgresql": SnakeBackend.POSTGRES,
|
|
212
|
+
"sqlite": SnakeBackend.SQLITE,
|
|
213
|
+
"sqlite3": SnakeBackend.SQLITE,
|
|
214
|
+
"django.db.backends.sqlite3": SnakeBackend.SQLITE,
|
|
215
|
+
"mysql": SnakeBackend.MYSQL,
|
|
216
|
+
"django.db.backends.mysql": SnakeBackend.MYSQL,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _backend_for(engine: str) -> SnakeBackend:
|
|
221
|
+
"""Resolve the `ENGINE` to the backend, or BLOW UP naming it (fail loud: a typo never falls back to a default)."""
|
|
222
|
+
key = engine.strip().lower()
|
|
223
|
+
try:
|
|
224
|
+
return _ENGINES[key]
|
|
225
|
+
except KeyError:
|
|
226
|
+
valid = ", ".join(sorted({b.value for b in SnakeBackend}))
|
|
227
|
+
raise SnakeConfigError(
|
|
228
|
+
f"Unknown ENGINE: '{engine}'. SnakeORM accepts: {valid} "
|
|
229
|
+
f"(or the 'django.db.backends.*' equivalent)."
|
|
230
|
+
) from None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def connection_from_mapping(db: SnakeOrmDatabase) -> SnakeConnectionConfig:
|
|
234
|
+
"""Translate a connection dict (`DATABASES` format) into the centralised config."""
|
|
235
|
+
return SnakeConnectionConfig(
|
|
236
|
+
backend=_backend_for(db["ENGINE"]),
|
|
237
|
+
name=str(db["NAME"]),
|
|
238
|
+
host=db.get("HOST", "localhost"),
|
|
239
|
+
port=db.get("PORT", ""),
|
|
240
|
+
user=db.get("USER", ""),
|
|
241
|
+
password=db.get("PASSWORD", ""),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def debug_config_from_mapping(
|
|
246
|
+
settings: SnakeOrmSettings,
|
|
247
|
+
) -> tuple[frozenset[SnakeDebugChannel], SnakeDebugConfig]:
|
|
248
|
+
"""Translate the panel config dict into (channels, config). Fail loud if anything is off.
|
|
249
|
+
|
|
250
|
+
A channel with a typo or a non-numeric `ADVISE_MS` BLOW UP naming them: a panel that does not
|
|
251
|
+
start —or a threshold that gets ignored— in silence is precisely the failure being fought.
|
|
252
|
+
"""
|
|
253
|
+
channels = parse_channels(
|
|
254
|
+
settings.get("DEBUG", "")
|
|
255
|
+
) # an unknown channel → SnakeConfigError
|
|
256
|
+
raw = settings.get("ADVISE_MS", DEFAULT_MIN_MS)
|
|
257
|
+
try:
|
|
258
|
+
advise_min_ms = float(raw)
|
|
259
|
+
except (TypeError, ValueError):
|
|
260
|
+
raise SnakeConfigError(f"ADVISE_MS has to be a number, got {raw!r}.") from None
|
|
261
|
+
return channels, SnakeDebugConfig(
|
|
262
|
+
advise_min_ms=advise_min_ms,
|
|
263
|
+
language=SnakeDebugLanguage.coerce(settings.get("LANG")),
|
|
264
|
+
)
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""PURE core of the debug delivery: headers, envelope and the production gate, in pure functions.
|
|
2
|
+
|
|
3
|
+
The adapters (ASGI/WSGI/Django) are thin plumbing that calls in here: the logic gets tested and is not duplicated; only the irreducible difference (ASGI ≠ WSGI) stays at the edge.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from snakeorm.advisor import index_hints_from_records
|
|
12
|
+
from snakeorm.core.exceptions import SnakeConfigError
|
|
13
|
+
from snakeorm.debug import (
|
|
14
|
+
PRODUCTION_ENV_KEY,
|
|
15
|
+
RISKY_CHANNELS,
|
|
16
|
+
DebugReport,
|
|
17
|
+
SnakeDebugChannel,
|
|
18
|
+
SnakeDebugConfig,
|
|
19
|
+
SnakeDebugLanguage,
|
|
20
|
+
render_report_html,
|
|
21
|
+
render_report_page,
|
|
22
|
+
)
|
|
23
|
+
from snakeorm.migration import current_schema
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
SIDECAR_UNKNOWN_TOKEN_BODY = (
|
|
27
|
+
b"Unknown debug token: this report is no longer buffered. The sidecar keeps only the most "
|
|
28
|
+
b"recent reports, so reload the page that produced it to get a fresh token."
|
|
29
|
+
)
|
|
30
|
+
"""The 404 body for `/__snake__/{token}` when the report is gone, for the THREE adapters.
|
|
31
|
+
|
|
32
|
+
It lives here and not in each adapter for the same reason everything else in this module does: three
|
|
33
|
+
copies of one sentence drift, and this one had already drifted into Spanish (`token desconocido`) in
|
|
34
|
+
all three at once — a body a person reads in their browser, in `contrib/`, outside the single
|
|
35
|
+
bilingual exemption, which covers the debug PANEL. These adapters serve no `SnakeDebugLanguage`
|
|
36
|
+
selector, which is precisely the reasoning that took `debug/channel.py` out of that exemption.
|
|
37
|
+
|
|
38
|
+
And it says what happened, not just that something did: whoever lands here is holding a stale tab,
|
|
39
|
+
and the two words it used to carry never told them to reload.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class SidecarPage:
|
|
45
|
+
"""What `/__snake__/{token}` answers: the status, the content type and the body.
|
|
46
|
+
|
|
47
|
+
One shape for the three adapters, so the decision of WHAT to answer is taken once and each of
|
|
48
|
+
them is left with the only thing that really differs: how its protocol writes a response.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
status: int
|
|
52
|
+
content_type: str
|
|
53
|
+
body: bytes
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def serve_sidecar(
|
|
57
|
+
report: DebugReport | None,
|
|
58
|
+
*,
|
|
59
|
+
accept: str = "",
|
|
60
|
+
language: SnakeDebugLanguage = SnakeDebugLanguage.EN,
|
|
61
|
+
) -> SidecarPage:
|
|
62
|
+
"""Answer a sidecar token: the panel page to a browser, the report itself to `Accept: application/json`.
|
|
63
|
+
|
|
64
|
+
The URL has TWO readers. A person opens it and wants the panel. The history tab holds a token
|
|
65
|
+
off a response whose body it never sees —an HTML fragment carries its report in the headers— and
|
|
66
|
+
wants the report; handing it a document that embeds the whole panel would make it parse markup
|
|
67
|
+
to recover data the server already has as a dictionary.
|
|
68
|
+
|
|
69
|
+
The JSON body is `DebugReport.to_dict()`, the very dictionary the `envelope` channel hangs off
|
|
70
|
+
JSON responses, so the tab reads ONE shape whichever channel brought it.
|
|
71
|
+
|
|
72
|
+
The 404 does NOT negotiate: there is one sentence for an evicted report, and a second
|
|
73
|
+
serialisation of it would be a second wording to keep in step.
|
|
74
|
+
"""
|
|
75
|
+
if report is None:
|
|
76
|
+
return SidecarPage(404, "text/plain; charset=utf-8", SIDECAR_UNKNOWN_TOKEN_BODY)
|
|
77
|
+
if "application/json" in accept:
|
|
78
|
+
body = json.dumps(report.to_dict(), ensure_ascii=False).encode()
|
|
79
|
+
return SidecarPage(200, "application/json; charset=utf-8", body)
|
|
80
|
+
page = render_report_page(report, language=language).encode()
|
|
81
|
+
return SidecarPage(200, "text/html; charset=utf-8", page)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve_production(
|
|
85
|
+
explicit: bool | None,
|
|
86
|
+
config: SnakeDebugConfig,
|
|
87
|
+
channels: frozenset[SnakeDebugChannel],
|
|
88
|
+
) -> bool:
|
|
89
|
+
"""WHERE this process runs: the explicit argument, then the config, then a REFUSAL.
|
|
90
|
+
|
|
91
|
+
The order is deliberate. Somebody already passing `production=` keeps the last word; otherwise
|
|
92
|
+
the config answers (from code or from `SNAKE_ORM_PRODUCTION`); and if nobody said anything, the
|
|
93
|
+
answer depends on what is at stake. With no risky channel on there is no SQL to leak, so silence
|
|
94
|
+
resolves to development and costs nothing. With one on, the middleware refuses to start.
|
|
95
|
+
|
|
96
|
+
What is NOT here is a default of `False`, and that absence is the whole point. The channels read
|
|
97
|
+
themselves out of the environment while this one sat next to them defaulting to the permissive
|
|
98
|
+
answer — two switches for one decision, and the manual one wrong-way-round. The project's own
|
|
99
|
+
rule is that a typo never falls back to a default; this is that rule applied to the switch that
|
|
100
|
+
decides whether the SQL goes out on the response.
|
|
101
|
+
"""
|
|
102
|
+
if explicit is not None:
|
|
103
|
+
return explicit
|
|
104
|
+
if config.production is not None:
|
|
105
|
+
return config.production
|
|
106
|
+
risky = channels & RISKY_CHANNELS
|
|
107
|
+
if risky:
|
|
108
|
+
names = ", ".join(sorted(channel.value for channel in risky))
|
|
109
|
+
raise SnakeConfigError(
|
|
110
|
+
f"These debug channels hand the SQL to whoever asked ({names}) and nothing declares "
|
|
111
|
+
f"whether this is production. Set {PRODUCTION_ENV_KEY}=true|false, or pass "
|
|
112
|
+
f"production=True/False (or SnakeDebugConfig(production=...)). There is no default: "
|
|
113
|
+
f"guessing 'development' here is how a deploy serves its statements, with the values "
|
|
114
|
+
f"already substituted, to anyone who asks for a page."
|
|
115
|
+
)
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def allowed_channels(
|
|
120
|
+
channels: frozenset[SnakeDebugChannel],
|
|
121
|
+
*,
|
|
122
|
+
production: bool,
|
|
123
|
+
) -> frozenset[SnakeDebugChannel]:
|
|
124
|
+
"""Filter the channels by environment: in production the ones exposing SQL are dropped, even if requested (attack surface)."""
|
|
125
|
+
if not production:
|
|
126
|
+
return channels
|
|
127
|
+
return channels - RISKY_CHANNELS
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def index_advice(
|
|
131
|
+
report: DebugReport, config: SnakeDebugConfig
|
|
132
|
+
) -> tuple[tuple[str, str, float], ...]:
|
|
133
|
+
"""Index suggestions for the SLOW queries of the report: the advisor over the SQL + the metadata.
|
|
134
|
+
|
|
135
|
+
It only looks at queries above `config.advise_min_ms` (the user's threshold, or 10 ms by
|
|
136
|
+
default): a fast query over an unindexed column does not deserve an index. Empty if nothing slow
|
|
137
|
+
filtered.
|
|
138
|
+
"""
|
|
139
|
+
if not report.records:
|
|
140
|
+
return ()
|
|
141
|
+
rows = [(record.sql, record.duration_ms) for record in report.records]
|
|
142
|
+
return tuple(
|
|
143
|
+
index_hints_from_records(rows, current_schema(), min_ms=config.advise_min_ms)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True, slots=True)
|
|
148
|
+
class Delivery:
|
|
149
|
+
"""What to do with the response: headers to add and, if applicable, the `snakeorm` block to inject."""
|
|
150
|
+
|
|
151
|
+
headers: tuple[tuple[str, str], ...]
|
|
152
|
+
envelope: dict[str, object] | None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def plan_delivery(
|
|
156
|
+
report: DebugReport,
|
|
157
|
+
channels: frozenset[SnakeDebugChannel],
|
|
158
|
+
*,
|
|
159
|
+
token: str | None = None,
|
|
160
|
+
) -> Delivery:
|
|
161
|
+
"""Decide headers and envelope from the channels that are on (framework-agnostic).
|
|
162
|
+
|
|
163
|
+
The envelope comes out IF the `envelope` channel was requested: the channel itself is the
|
|
164
|
+
switch (if you declare it in `DEBUG`, you want it). In production `allowed_channels` already
|
|
165
|
+
removes it, so nothing leaks.
|
|
166
|
+
"""
|
|
167
|
+
headers: list[tuple[str, str]] = []
|
|
168
|
+
if SnakeDebugChannel.TIMING in channels:
|
|
169
|
+
headers.append(("Server-Timing", report.to_server_timing()))
|
|
170
|
+
if SnakeDebugChannel.SIDECAR in channels and token is not None:
|
|
171
|
+
headers.append(("X-Debug-Token", token))
|
|
172
|
+
envelope = report.to_dict() if SnakeDebugChannel.ENVELOPE in channels else None
|
|
173
|
+
return Delivery(tuple(headers), envelope)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def inject_envelope(body: bytes, envelope: dict[str, object]) -> bytes:
|
|
177
|
+
"""Hang the debug (`snakeorm`) off the JSON body, without corrupting it or over-nesting it.
|
|
178
|
+
|
|
179
|
+
If the body is already an OBJECT, it is enough to ADD the `snakeorm` key as a sibling (it keeps
|
|
180
|
+
its shape, with no extra wrapping). If it is an ARRAY or a scalar —where you cannot put a key—
|
|
181
|
+
it gets wrapped in `{"data": <response>, "snakeorm": <debug>}`. A non-JSON body is left intact.
|
|
182
|
+
"""
|
|
183
|
+
try:
|
|
184
|
+
data = json.loads(body)
|
|
185
|
+
except (ValueError, UnicodeDecodeError):
|
|
186
|
+
return body
|
|
187
|
+
if isinstance(data, dict):
|
|
188
|
+
data["snakeorm"] = (
|
|
189
|
+
envelope # already an object: the key fits as a sibling, no wrapping
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
data = {"data": data, "snakeorm": envelope} # array/scalar: must be wrapped
|
|
193
|
+
# `ensure_ascii=False` so the accents do not get re-escaped to `\uXXXX`: it preserves the UTF-8
|
|
194
|
+
# as it is (the way Starlette does) and does not double the size of a response with non-ASCII
|
|
195
|
+
# text.
|
|
196
|
+
return json.dumps(data, ensure_ascii=False).encode()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def inject_panel(
|
|
200
|
+
body: bytes,
|
|
201
|
+
report: DebugReport,
|
|
202
|
+
language: SnakeDebugLanguage = SnakeDebugLanguage.EN,
|
|
203
|
+
csp_nonce: str | None = None,
|
|
204
|
+
*,
|
|
205
|
+
token: str | None = None,
|
|
206
|
+
channels: frozenset[SnakeDebugChannel] | None = None,
|
|
207
|
+
) -> bytes:
|
|
208
|
+
"""Insert the HTML panel right before `</body>`; a body with no such tag comes back INTACT.
|
|
209
|
+
|
|
210
|
+
No `</body>` means a FRAGMENT (an HTMX/Alpine swap), and a fragment gets nothing in its body.
|
|
211
|
+
Appending anything returns two top-level nodes where the application asked for one, which
|
|
212
|
+
changes `:last-child` under `innerHTML`, adds a child under `outerHTML` and can be dropped by
|
|
213
|
+
`hx-select`. The fragment's debug travels in the headers instead. `</body>` is the
|
|
214
|
+
discriminator on purpose: `HX-Request` would tie this to one library.
|
|
215
|
+
|
|
216
|
+
`csp_nonce` travels through to the mounting script. An application with a strict
|
|
217
|
+
Content-Security-Policy generates one per response and hands it in; without it the panel is
|
|
218
|
+
injected exactly as before, and under `script-src 'self'` the browser drops it in silence.
|
|
219
|
+
"""
|
|
220
|
+
marker = b"</body>"
|
|
221
|
+
if marker not in body:
|
|
222
|
+
return body
|
|
223
|
+
panel = render_report_html(
|
|
224
|
+
report,
|
|
225
|
+
language=language,
|
|
226
|
+
csp_nonce=csp_nonce,
|
|
227
|
+
token=token,
|
|
228
|
+
channels=channels,
|
|
229
|
+
).encode()
|
|
230
|
+
return body.replace(marker, panel + marker, 1)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def transform_body(
|
|
234
|
+
body: bytes,
|
|
235
|
+
content_type: str,
|
|
236
|
+
delivery: Delivery,
|
|
237
|
+
report: DebugReport,
|
|
238
|
+
channels: frozenset[SnakeDebugChannel],
|
|
239
|
+
language: SnakeDebugLanguage = SnakeDebugLanguage.EN,
|
|
240
|
+
csp_nonce: str | None = None,
|
|
241
|
+
*,
|
|
242
|
+
token: str | None = None,
|
|
243
|
+
) -> bytes:
|
|
244
|
+
"""Rewrite the body by `Content-Type` (envelope in JSON, panel in HTML): one adapter serves a hybrid app without wiring two things.
|
|
245
|
+
|
|
246
|
+
`csp_nonce` and `token` ride alongside `language`: this is the ONLY path the three adapters take
|
|
247
|
+
to render the panel, so anything that does not travel here never reaches a middleware-delivered
|
|
248
|
+
page. The channels travel too — the history tab reads them to say `envelope` is missing.
|
|
249
|
+
"""
|
|
250
|
+
if delivery.envelope is not None and "application/json" in content_type:
|
|
251
|
+
return inject_envelope(body, delivery.envelope)
|
|
252
|
+
if SnakeDebugChannel.SSR in channels and "text/html" in content_type:
|
|
253
|
+
return inject_panel(
|
|
254
|
+
body, report, language, csp_nonce, token=token, channels=channels
|
|
255
|
+
)
|
|
256
|
+
return body
|