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
snakeorm/connection.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""CENTRALISED connection config: the object that crowns any framework's connection config.
|
|
2
|
+
|
|
3
|
+
Django feeds it by translating its `DATABASES` (`contrib.django`); Flask, FastAPI and anything else
|
|
4
|
+
without a convention build THIS directly and open it with `contrib.open_session`. The `backend`
|
|
5
|
+
picks driver AND dialect PAIRED — the user cannot put a `SQLiteDriver` together with a
|
|
6
|
+
`PostgresDialect` because they never pick the two pieces separately. Each engine's heavy deps
|
|
7
|
+
(`psycopg2`, `pymysql`) stay OPTIONAL: every driver imports them inside its `connect()`, not when the
|
|
8
|
+
class is defined, so importing the driver here does not drag them in.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
from snakeorm.dialects.matrix import flavour_of
|
|
19
|
+
from snakeorm.dialects import MySQLDialect, PostgresDialect, SQLiteDialect
|
|
20
|
+
from snakeorm.registry import SnakeRegistry
|
|
21
|
+
from snakeorm.dialects.base import SnakeDialect
|
|
22
|
+
from snakeorm.drivers import PyMySQLDriver, SQLiteDriver
|
|
23
|
+
from snakeorm.drivers.asyncbase import AsyncDriver
|
|
24
|
+
from snakeorm.drivers.asyncpsycopg import AsyncPsycopgDriver
|
|
25
|
+
from snakeorm.drivers.asyncpymysql import AsyncPyMySQLDriver
|
|
26
|
+
from snakeorm.drivers.asyncsqlite import AsyncSQLiteDriver
|
|
27
|
+
from snakeorm.drivers.psycopg import PsycopgDriver, with_utc_timezone
|
|
28
|
+
from snakeorm.drivers.base import SnakeDriver
|
|
29
|
+
from snakeorm.session import AsyncSession, SnakeSession
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SnakeBackend(Enum):
|
|
33
|
+
"""The engine: it picks driver (how it EXECUTES) and dialect (how it WRITES SQL) at once."""
|
|
34
|
+
|
|
35
|
+
POSTGRES = "postgres"
|
|
36
|
+
SQLITE = "sqlite"
|
|
37
|
+
MYSQL = "mysql"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def db_system_name(self) -> str:
|
|
41
|
+
"""What OpenTelemetry calls this engine (`db.system.name`), for the `otel` debug channel.
|
|
42
|
+
|
|
43
|
+
It answers HERE because this enum is the one place engine identity is written down. The
|
|
44
|
+
alternatives are all second spellings of the same fact: the driver's class is hidden the
|
|
45
|
+
moment a pool or a timeout decorator wraps it, and the SQL's placeholders say nothing about
|
|
46
|
+
MariaDB.
|
|
47
|
+
|
|
48
|
+
`mariadb` IS a value of its own in the convention and not an alias of `mysql`. SnakeORM
|
|
49
|
+
reaches both through PyMySQL and cannot tell them apart without asking the server for its
|
|
50
|
+
banner, so it reports `mysql` and leaves the correction to whoever knows: the capture driver
|
|
51
|
+
takes the name as an argument.
|
|
52
|
+
"""
|
|
53
|
+
return _DB_SYSTEM_NAMES[self]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# The engine's OpenTelemetry name, per member. A dict and not a second enum value so the member's
|
|
57
|
+
# own value (`postgres`, what a user writes in `ENGINE`) stays the public spelling: they are two
|
|
58
|
+
# different vocabularies for the same engine and merging them would break one of the two.
|
|
59
|
+
_DB_SYSTEM_NAMES: dict[SnakeBackend, str] = {
|
|
60
|
+
SnakeBackend.POSTGRES: "postgresql",
|
|
61
|
+
SnakeBackend.SQLITE: "sqlite",
|
|
62
|
+
SnakeBackend.MYSQL: "mysql",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class SnakeConnectionConfig:
|
|
68
|
+
"""Everything defining ONE connection, in a typed object (frozen: not overwritten mid-request).
|
|
69
|
+
|
|
70
|
+
`name` is the database (Postgres/MySQL) or, for SQLite, whatever it calls a database: a file
|
|
71
|
+
path, `:memory:`, or a `file:` URI — the last being the only one that gives several connections
|
|
72
|
+
ONE in-memory database. The remaining pieces only apply to the networked engines; SQLite ignores
|
|
73
|
+
them.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
backend: SnakeBackend
|
|
77
|
+
name: str
|
|
78
|
+
host: str = "localhost"
|
|
79
|
+
port: str = ""
|
|
80
|
+
user: str = ""
|
|
81
|
+
password: str = ""
|
|
82
|
+
dsn: str | None = None
|
|
83
|
+
"""An already-written DSN, for when the connection comes as one rather than in pieces.
|
|
84
|
+
|
|
85
|
+
It exists so that ONE single piece pairs driver and dialect. `snake_session(name)` resolves the
|
|
86
|
+
connection through its DSN — multi-DB by environment variable — and used to assemble the pair by
|
|
87
|
+
hand, which left two places where somebody could join a driver to another engine's dialect. That
|
|
88
|
+
path now comes through here, and the pairing goes on living in a single method.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_dsn(cls, dsn: str, backend: SnakeBackend) -> SnakeConnectionConfig:
|
|
93
|
+
"""Takes a DSN apart into the pieces THAT engine's driver actually wants.
|
|
94
|
+
|
|
95
|
+
Each one asks for the connection in its own shape and there is no common denominator:
|
|
96
|
+
psycopg reads a DSN string, SQLite wants a filesystem PATH, and PyMySQL wants loose keyword
|
|
97
|
+
arguments. So a DSN has to be translated, and it is translated HERE — beside
|
|
98
|
+
`driver_and_dialect` — because this class exists so that pairing a driver with another
|
|
99
|
+
engine's dialect is not expressible. A second place doing the same translation would be a
|
|
100
|
+
second place to get it wrong.
|
|
101
|
+
|
|
102
|
+
It was `snake_session` that needed it, and needed it badly: it handed the connection's ALIAS
|
|
103
|
+
over as `name`, which Postgres ignores (its DSN wins) and SQLite reads as the path. So
|
|
104
|
+
`snake_session("reports")` created a file literally called `reports` in the working
|
|
105
|
+
directory instead of opening the one the DSN named — and a test that only checked the
|
|
106
|
+
dialect passed over it.
|
|
107
|
+
"""
|
|
108
|
+
if backend is SnakeBackend.POSTGRES:
|
|
109
|
+
# psycopg parses its own DSN, in either form (URL or libpq keywords), so it travels whole.
|
|
110
|
+
return cls(backend=backend, name="", dsn=dsn)
|
|
111
|
+
|
|
112
|
+
parsed = urlsplit(dsn)
|
|
113
|
+
if backend is SnakeBackend.SQLITE:
|
|
114
|
+
# The THIRD slash is the URL's separator, so exactly one comes off: `sqlite:///tmp/x.db`
|
|
115
|
+
# is the relative `tmp/x.db`, `sqlite:////tmp/x.db` is `/tmp/x.db`. SQLAlchemy's rule.
|
|
116
|
+
# A relative path STAYS relative — resolving it here would be guessing (bug #38).
|
|
117
|
+
# `:memory:` arrives in the authority, where no slash rule applies. The query string is
|
|
118
|
+
# kept only for a `file:` URI, which is the half of #37 that survived fixing the driver.
|
|
119
|
+
database = f"{parsed.netloc}{parsed.path}"
|
|
120
|
+
if database.startswith("/"):
|
|
121
|
+
database = database[1:]
|
|
122
|
+
if database.startswith("file:") and parsed.query:
|
|
123
|
+
database = f"{database}?{parsed.query}"
|
|
124
|
+
return cls(backend=backend, name=database or ":memory:")
|
|
125
|
+
return cls(
|
|
126
|
+
backend=backend,
|
|
127
|
+
name=parsed.path.lstrip("/"),
|
|
128
|
+
host=parsed.hostname or "localhost",
|
|
129
|
+
port=str(parsed.port) if parsed.port else "",
|
|
130
|
+
user=parsed.username or "",
|
|
131
|
+
password=parsed.password or "",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def driver_and_dialect(self) -> tuple[SnakeDriver, SnakeDialect]:
|
|
135
|
+
"""Builds driver and dialect PAIRED according to `backend` (impossible to unpair them)."""
|
|
136
|
+
if self.backend is SnakeBackend.SQLITE:
|
|
137
|
+
return SQLiteDriver.connect(self.name), SQLiteDialect()
|
|
138
|
+
if self.backend is SnakeBackend.POSTGRES:
|
|
139
|
+
return PsycopgDriver.connect(self.postgres_dsn()), PostgresDialect()
|
|
140
|
+
driver = PyMySQLDriver.connect(**self._mysql_kwargs())
|
|
141
|
+
return driver, MySQLDialect(flavour_of(driver.server_version()))
|
|
142
|
+
|
|
143
|
+
def open(
|
|
144
|
+
self,
|
|
145
|
+
wrap: Callable[[SnakeDriver], SnakeDriver] | None = None,
|
|
146
|
+
*,
|
|
147
|
+
model_registry: SnakeRegistry | None = None,
|
|
148
|
+
) -> SnakeSession:
|
|
149
|
+
"""Assembles the whole session (driver + dialect paired) in a single call.
|
|
150
|
+
|
|
151
|
+
`wrap` wraps the driver before the session is assembled: the seam for `CaptureDriver` (so the
|
|
152
|
+
debug panel sees the SQL), pooling, logging or timeout. It is passed FROM outside — this
|
|
153
|
+
module does not import it — so the central config stays uncoupled from the debug subsystem
|
|
154
|
+
and from the decorators.
|
|
155
|
+
"""
|
|
156
|
+
driver, dialect = self.driver_and_dialect()
|
|
157
|
+
if wrap is not None:
|
|
158
|
+
driver = wrap(driver)
|
|
159
|
+
return SnakeSession(driver, dialect, model_registry=model_registry)
|
|
160
|
+
|
|
161
|
+
async def async_driver_and_dialect(self) -> tuple[AsyncDriver, SnakeDialect]:
|
|
162
|
+
"""The async pair PAIRED according to `backend`, just like its synchronous sibling.
|
|
163
|
+
|
|
164
|
+
It is `async` because opening can wait: `AsyncPsycopgDriver.connect` is. Reusing the
|
|
165
|
+
synchronous method was no good, hence there are two: `driver_and_dialect` connects eagerly.
|
|
166
|
+
"""
|
|
167
|
+
if self.backend is SnakeBackend.SQLITE:
|
|
168
|
+
return await AsyncSQLiteDriver.connect(self.name), SQLiteDialect()
|
|
169
|
+
if self.backend is SnakeBackend.POSTGRES:
|
|
170
|
+
return await AsyncPsycopgDriver.connect(self.postgres_dsn()), (
|
|
171
|
+
PostgresDialect()
|
|
172
|
+
)
|
|
173
|
+
driver = await AsyncPyMySQLDriver.connect(**self._mysql_kwargs())
|
|
174
|
+
return driver, MySQLDialect(flavour_of(await driver.server_version()))
|
|
175
|
+
|
|
176
|
+
async def open_async(
|
|
177
|
+
self,
|
|
178
|
+
wrap: Callable[[AsyncDriver], AsyncDriver] | None = None,
|
|
179
|
+
*,
|
|
180
|
+
model_registry: SnakeRegistry | None = None,
|
|
181
|
+
) -> AsyncSession:
|
|
182
|
+
"""Assembles the whole ASYNCHRONOUS session (driver + dialect paired) in one call.
|
|
183
|
+
|
|
184
|
+
Without this, the async user picked driver and dialect separately — which is exactly what this
|
|
185
|
+
module exists to prevent: nobody can put a `SQLiteDriver` together with a `PostgresDialect`
|
|
186
|
+
because they never choose the two pieces.
|
|
187
|
+
|
|
188
|
+
`wrap` wraps the driver before the session is assembled, as on the synchronous path.
|
|
189
|
+
"""
|
|
190
|
+
driver, dialect = await self.async_driver_and_dialect()
|
|
191
|
+
if wrap is not None:
|
|
192
|
+
driver = wrap(driver)
|
|
193
|
+
return AsyncSession(driver, dialect, model_registry=model_registry)
|
|
194
|
+
|
|
195
|
+
def postgres_dsn(self) -> str:
|
|
196
|
+
"""psycopg's DSN: the declared one if there is one, or built from the pieces.
|
|
197
|
+
|
|
198
|
+
It only includes what was declared (no empty pieces). The UTC zone is pinned the same way on
|
|
199
|
+
both paths: that is what makes opening the database to look at a date show the instant that
|
|
200
|
+
was stored, and not the server's local time.
|
|
201
|
+
"""
|
|
202
|
+
if self.dsn is not None:
|
|
203
|
+
return with_utc_timezone(self.dsn)
|
|
204
|
+
parts = [f"host={self.host}", f"dbname={self.name}"]
|
|
205
|
+
if self.port:
|
|
206
|
+
parts.append(f"port={self.port}")
|
|
207
|
+
if self.user:
|
|
208
|
+
parts.append(f"user={self.user}")
|
|
209
|
+
if self.password:
|
|
210
|
+
parts.append(f"password={self.password}")
|
|
211
|
+
# The session's zone, pinned in the DSN itself: `TIMESTAMPTZ` stores the instant but SHOWS
|
|
212
|
+
# it in the session's zone, so without this, opening the database to check the dates shows
|
|
213
|
+
# the server's local time and the look tells you nothing.
|
|
214
|
+
return with_utc_timezone(" ".join(parts))
|
|
215
|
+
|
|
216
|
+
def _mysql_kwargs(self) -> dict[str, object]:
|
|
217
|
+
"""Kwargs for `PyMySQLDriver.connect` (its signature is not a DSN string, but loose args)."""
|
|
218
|
+
kwargs: dict[str, object] = {"host": self.host, "database": self.name}
|
|
219
|
+
if self.port:
|
|
220
|
+
kwargs["port"] = int(self.port)
|
|
221
|
+
if self.user:
|
|
222
|
+
kwargs["user"] = self.user
|
|
223
|
+
if self.password:
|
|
224
|
+
kwargs["password"] = self.password
|
|
225
|
+
# The MySQL equivalent, which has no libpq `options`. Same guarantee by another mechanism:
|
|
226
|
+
# what you see when querying is what was stored.
|
|
227
|
+
kwargs["init_command"] = "SET time_zone = '+00:00'"
|
|
228
|
+
return kwargs
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Optional web adapters: they CONSUME the `snakeorm.debug` kernel, they do not define it (a thin edge, hexagonal architecture).
|
|
2
|
+
|
|
3
|
+
None of them imports its framework at module level, so they import and test without FastAPI/Flask/Django installed.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from snakeorm.contrib.asgi import SnakeDebugASGI as SnakeDebugASGI
|
|
7
|
+
from snakeorm.contrib.config import SnakeOrmConfig as SnakeOrmConfig
|
|
8
|
+
from snakeorm.debug import SnakeDebugLanguage as SnakeDebugLanguage
|
|
9
|
+
from snakeorm.contrib.config import SnakeOrmSettings as SnakeOrmSettings
|
|
10
|
+
from snakeorm.contrib.config import (
|
|
11
|
+
debug_config_from_mapping as debug_config_from_mapping,
|
|
12
|
+
)
|
|
13
|
+
from snakeorm.contrib.config import open_session as open_session
|
|
14
|
+
from snakeorm.contrib.config import open_session_async as open_session_async
|
|
15
|
+
from snakeorm.contrib.django import SnakeDebugMiddleware as SnakeDebugMiddleware
|
|
16
|
+
from snakeorm.contrib.sidecar import SidecarBuffer as SidecarBuffer
|
|
17
|
+
from snakeorm.contrib.wsgi import SnakeDebugWSGI as SnakeDebugWSGI
|
snakeorm/contrib/asgi.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""ASGI adapter (FastAPI/Starlette): pure ASGI middleware that captures per request and delivers the debug by channel.
|
|
2
|
+
|
|
3
|
+
It does not import FastAPI, it serves the sidecar at `/__snake__/{token}` itself, and the SSR stays a no-op unless the app returns HTML.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Awaitable, Callable, MutableMapping
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from time import perf_counter
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from snakeorm.contrib.deliver import (
|
|
14
|
+
allowed_channels,
|
|
15
|
+
resolve_production,
|
|
16
|
+
index_advice,
|
|
17
|
+
plan_delivery,
|
|
18
|
+
serve_sidecar,
|
|
19
|
+
transform_body,
|
|
20
|
+
)
|
|
21
|
+
from snakeorm.contrib.sidecar import SidecarBuffer, new_token
|
|
22
|
+
from snakeorm.debug import (
|
|
23
|
+
SnakeDebugChannel,
|
|
24
|
+
SnakeDebugConfig,
|
|
25
|
+
SnakeWebFramework,
|
|
26
|
+
capture_queries,
|
|
27
|
+
channels_from_env,
|
|
28
|
+
export_report,
|
|
29
|
+
RequestInfo,
|
|
30
|
+
warn_unimplemented,
|
|
31
|
+
warn_unsupported,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
Scope = MutableMapping[str, Any]
|
|
35
|
+
Message = MutableMapping[str, Any]
|
|
36
|
+
Receive = Callable[[], Awaitable[Message]]
|
|
37
|
+
Send = Callable[[Message], Awaitable[None]]
|
|
38
|
+
ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
|
|
39
|
+
|
|
40
|
+
_SIDECAR_PREFIX = "/__snake__/"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SnakeDebugASGI:
|
|
44
|
+
"""ASGI middleware that captures the SQL and delivers the debug per `SNAKE_ORM_DEBUG`."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
app: ASGIApp,
|
|
49
|
+
*,
|
|
50
|
+
channels: frozenset[SnakeDebugChannel] | None = None,
|
|
51
|
+
production: bool | None = None,
|
|
52
|
+
buffer: SidecarBuffer | None = None,
|
|
53
|
+
config: SnakeDebugConfig | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
asked = channels_from_env() if channels is None else channels
|
|
56
|
+
self._app = app
|
|
57
|
+
self._config = config or SnakeDebugConfig.from_env()
|
|
58
|
+
# The config is resolved BEFORE the channels because it is one of the two places the
|
|
59
|
+
# environment can be declared, and `resolve_production` refuses rather than guessing when
|
|
60
|
+
# neither says anything and a risky channel is on.
|
|
61
|
+
self._channels = allowed_channels(
|
|
62
|
+
asked, production=resolve_production(production, self._config, asked)
|
|
63
|
+
)
|
|
64
|
+
self._buffer = buffer or SidecarBuffer()
|
|
65
|
+
warn_unsupported(self._channels, SnakeWebFramework.FASTAPI)
|
|
66
|
+
warn_unimplemented(self._channels)
|
|
67
|
+
|
|
68
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
69
|
+
"""ASGI entry point: it serves the sidecar, or captures and delivers the response's debug."""
|
|
70
|
+
if scope["type"] != "http" or not self._channels:
|
|
71
|
+
await self._app(scope, receive, send)
|
|
72
|
+
return
|
|
73
|
+
path: str = scope["path"]
|
|
74
|
+
if (
|
|
75
|
+
path.startswith(_SIDECAR_PREFIX)
|
|
76
|
+
and SnakeDebugChannel.SIDECAR in self._channels
|
|
77
|
+
):
|
|
78
|
+
await self._serve_sidecar(path, scope, send)
|
|
79
|
+
return
|
|
80
|
+
await self._capture_and_deliver(scope, receive, send)
|
|
81
|
+
|
|
82
|
+
async def _serve_sidecar(self, path: str, scope: Scope, send: Send) -> None:
|
|
83
|
+
"""Serve `/__snake__/{token}`: the panel page, or the report as JSON if `Accept` asks.
|
|
84
|
+
|
|
85
|
+
An ASGI scope keeps the headers as bytes pairs; WHAT to answer with them is decided in
|
|
86
|
+
`serve_sidecar`, shared with the other two adapters.
|
|
87
|
+
"""
|
|
88
|
+
page = serve_sidecar(
|
|
89
|
+
self._buffer.get(path[len(_SIDECAR_PREFIX) :]),
|
|
90
|
+
accept=_header(list(scope.get("headers", [])), b"accept"),
|
|
91
|
+
language=self._config.language,
|
|
92
|
+
)
|
|
93
|
+
await _send_response(send, page.status, page.content_type, page.body)
|
|
94
|
+
|
|
95
|
+
async def _capture_and_deliver(
|
|
96
|
+
self, scope: Scope, receive: Receive, send: Send
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Wrap the app in a scope, buffer its response and deliver it with the debug added."""
|
|
99
|
+
start: Message = {}
|
|
100
|
+
body = bytearray()
|
|
101
|
+
|
|
102
|
+
async def capture_send(message: Message) -> None:
|
|
103
|
+
if message["type"] == "http.response.start":
|
|
104
|
+
start.update(message)
|
|
105
|
+
elif message["type"] == "http.response.body":
|
|
106
|
+
body.extend(message.get("body", b""))
|
|
107
|
+
|
|
108
|
+
t0 = perf_counter()
|
|
109
|
+
at = datetime.now(UTC) # the instant, taken where the wall clock starts
|
|
110
|
+
with capture_queries() as collector:
|
|
111
|
+
await self._app(scope, receive, capture_send)
|
|
112
|
+
report = collector.report().with_wall_ms((perf_counter() - t0) * 1000)
|
|
113
|
+
report = report.with_index_hints(index_advice(report, self._config))
|
|
114
|
+
report = report.with_request(
|
|
115
|
+
RequestInfo(
|
|
116
|
+
method=scope.get("method", ""),
|
|
117
|
+
path=scope.get("path", ""),
|
|
118
|
+
status=int(start.get("status", 200)),
|
|
119
|
+
at=at,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
token = None
|
|
124
|
+
if SnakeDebugChannel.SIDECAR in self._channels:
|
|
125
|
+
token = new_token()
|
|
126
|
+
self._buffer.store(token, report)
|
|
127
|
+
# The `otel` channel: OUT of `plan_delivery`, which is a pure function that answers
|
|
128
|
+
# `(headers, envelope)` — two things that change the response, which a network send is not.
|
|
129
|
+
# Shared the same way `index_advice` is.
|
|
130
|
+
export_report(report, self._channels)
|
|
131
|
+
delivery = plan_delivery(report, self._channels, token=token)
|
|
132
|
+
|
|
133
|
+
headers: list[tuple[bytes, bytes]] = list(start.get("headers", []))
|
|
134
|
+
content_type = _header(headers, b"content-type")
|
|
135
|
+
new_body = transform_body(
|
|
136
|
+
bytes(body),
|
|
137
|
+
content_type,
|
|
138
|
+
delivery,
|
|
139
|
+
report,
|
|
140
|
+
self._channels,
|
|
141
|
+
self._config.language,
|
|
142
|
+
# ASGI has no per-request seam for this: no spec field and no framework convention
|
|
143
|
+
# holds a nonce, so the config's is the only one. FIXED for the process.
|
|
144
|
+
self._config.csp_nonce,
|
|
145
|
+
token=token,
|
|
146
|
+
)
|
|
147
|
+
for name, value in delivery.headers:
|
|
148
|
+
headers.append((name.encode("latin1"), value.encode("latin1")))
|
|
149
|
+
headers = _replace(headers, b"content-length", str(len(new_body)).encode())
|
|
150
|
+
|
|
151
|
+
await send(
|
|
152
|
+
{
|
|
153
|
+
"type": "http.response.start",
|
|
154
|
+
"status": start.get("status", 200),
|
|
155
|
+
"headers": headers,
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
await send({"type": "http.response.body", "body": new_body})
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _header(headers: list[tuple[bytes, bytes]], name: bytes) -> str:
|
|
162
|
+
"""The (decoded) value of a header by name, or an empty string if it is not there."""
|
|
163
|
+
for key, value in headers:
|
|
164
|
+
if key.lower() == name:
|
|
165
|
+
return value.decode("latin1")
|
|
166
|
+
return ""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _replace(
|
|
170
|
+
headers: list[tuple[bytes, bytes]], name: bytes, value: bytes
|
|
171
|
+
) -> list[tuple[bytes, bytes]]:
|
|
172
|
+
"""Return the headers with `name` set to `value` (removing any previous value)."""
|
|
173
|
+
kept = [(key, val) for key, val in headers if key.lower() != name]
|
|
174
|
+
kept.append((name, value))
|
|
175
|
+
return kept
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def _send_response(
|
|
179
|
+
send: Send, status: int, content_type: str, body: bytes
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Emit a complete ASGI response (start + body) in a single body."""
|
|
182
|
+
await send(
|
|
183
|
+
{
|
|
184
|
+
"type": "http.response.start",
|
|
185
|
+
"status": status,
|
|
186
|
+
"headers": [
|
|
187
|
+
(b"content-type", content_type.encode("latin1")),
|
|
188
|
+
(b"content-length", str(len(body)).encode()),
|
|
189
|
+
],
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
await send({"type": "http.response.body", "body": body})
|