snake-orm 0.1.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. snake_orm-0.1.0b1.dist-info/METADATA +366 -0
  2. snake_orm-0.1.0b1.dist-info/RECORD +198 -0
  3. snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
  4. snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
  5. snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
  6. snakeorm/__init__.py +214 -0
  7. snakeorm/advisor.py +109 -0
  8. snakeorm/cli/__init__.py +3 -0
  9. snakeorm/cli/app.py +992 -0
  10. snakeorm/cli/discovery.py +179 -0
  11. snakeorm/cli/hooks.py +86 -0
  12. snakeorm/compiler/__init__.py +3 -0
  13. snakeorm/compiler/compiler.py +330 -0
  14. snakeorm/connection.py +228 -0
  15. snakeorm/contrib/__init__.py +17 -0
  16. snakeorm/contrib/asgi.py +192 -0
  17. snakeorm/contrib/config.py +264 -0
  18. snakeorm/contrib/deliver.py +256 -0
  19. snakeorm/contrib/django.py +245 -0
  20. snakeorm/contrib/sidecar.py +44 -0
  21. snakeorm/contrib/wsgi.py +176 -0
  22. snakeorm/core/__init__.py +1 -0
  23. snakeorm/core/config.py +173 -0
  24. snakeorm/core/converters.py +172 -0
  25. snakeorm/core/exceptions.py +175 -0
  26. snakeorm/core/placement.py +15 -0
  27. snakeorm/core/sentinels.py +37 -0
  28. snakeorm/core/signals.py +114 -0
  29. snakeorm/debug/__init__.py +42 -0
  30. snakeorm/debug/assets/css/panel.css +616 -0
  31. snakeorm/debug/assets/css/snake_orm_app.css +293 -0
  32. snakeorm/debug/assets/img/icon.svg +5 -0
  33. snakeorm/debug/assets/js/history.js +425 -0
  34. snakeorm/debug/assets/js/language.js +283 -0
  35. snakeorm/debug/assets/js/panel.js +241 -0
  36. snakeorm/debug/assets/js/snake_orm_app.js +82 -0
  37. snakeorm/debug/assets/pages/cli.html +28 -0
  38. snakeorm/debug/assets/pages/config.html +34 -0
  39. snakeorm/debug/assets/pages/database_first.html +29 -0
  40. snakeorm/debug/assets/pages/help.html +21 -0
  41. snakeorm/debug/assets/pages/history.html +27 -0
  42. snakeorm/debug/capture.py +234 -0
  43. snakeorm/debug/channel.py +131 -0
  44. snakeorm/debug/collector.py +144 -0
  45. snakeorm/debug/config.py +149 -0
  46. snakeorm/debug/html.py +578 -0
  47. snakeorm/debug/matrix.py +60 -0
  48. snakeorm/debug/origin.py +42 -0
  49. snakeorm/debug/otel/__init__.py +68 -0
  50. snakeorm/debug/otel/context.py +43 -0
  51. snakeorm/debug/otel/exporter.py +277 -0
  52. snakeorm/debug/otel/payload.py +97 -0
  53. snakeorm/debug/otel/report.py +29 -0
  54. snakeorm/debug/otel/spans.py +393 -0
  55. snakeorm/debug/otel/summary.py +59 -0
  56. snakeorm/debug/record.py +54 -0
  57. snakeorm/debug/report.py +311 -0
  58. snakeorm/debug/testing.py +23 -0
  59. snakeorm/decorators/__init__.py +13 -0
  60. snakeorm/decorators/abstract.py +26 -0
  61. snakeorm/decorators/db_first.py +104 -0
  62. snakeorm/decorators/function.py +28 -0
  63. snakeorm/decorators/model.py +268 -0
  64. snakeorm/decorators/polymorphic.py +140 -0
  65. snakeorm/decorators/result.py +167 -0
  66. snakeorm/decorators/row.py +90 -0
  67. snakeorm/decorators/trigger.py +47 -0
  68. snakeorm/decorators/view.py +149 -0
  69. snakeorm/dialects/__init__.py +6 -0
  70. snakeorm/dialects/base.py +373 -0
  71. snakeorm/dialects/capabilities.py +571 -0
  72. snakeorm/dialects/literals.py +46 -0
  73. snakeorm/dialects/matrix.py +384 -0
  74. snakeorm/dialects/mysql.py +519 -0
  75. snakeorm/dialects/postgres.py +504 -0
  76. snakeorm/dialects/sqlite.py +417 -0
  77. snakeorm/drivers/__init__.py +18 -0
  78. snakeorm/drivers/asyncbase.py +73 -0
  79. snakeorm/drivers/asyncdecorators.py +244 -0
  80. snakeorm/drivers/asyncpool.py +228 -0
  81. snakeorm/drivers/asyncpsycopg.py +124 -0
  82. snakeorm/drivers/asyncpymysql.py +50 -0
  83. snakeorm/drivers/asyncsqlite.py +30 -0
  84. snakeorm/drivers/base.py +76 -0
  85. snakeorm/drivers/failures.py +181 -0
  86. snakeorm/drivers/logging.py +167 -0
  87. snakeorm/drivers/pool.py +352 -0
  88. snakeorm/drivers/psycopg.py +188 -0
  89. snakeorm/drivers/pymysql.py +176 -0
  90. snakeorm/drivers/savepoints.py +46 -0
  91. snakeorm/drivers/sqlite.py +170 -0
  92. snakeorm/drivers/threaded.py +143 -0
  93. snakeorm/drivers/timeout.py +87 -0
  94. snakeorm/dto/__init__.py +55 -0
  95. snakeorm/dto/read.py +231 -0
  96. snakeorm/dto/region.py +326 -0
  97. snakeorm/dto/resolve.py +354 -0
  98. snakeorm/dto/spec.py +201 -0
  99. snakeorm/expressions/__init__.py +88 -0
  100. snakeorm/expressions/conditional.py +178 -0
  101. snakeorm/expressions/expression.py +810 -0
  102. snakeorm/expressions/functions.py +71 -0
  103. snakeorm/expressions/keys.py +299 -0
  104. snakeorm/expressions/paths.py +43 -0
  105. snakeorm/expressions/scalar.py +293 -0
  106. snakeorm/expressions/window.py +248 -0
  107. snakeorm/fields/__init__.py +68 -0
  108. snakeorm/fields/check.py +79 -0
  109. snakeorm/fields/column.py +316 -0
  110. snakeorm/fields/enum.py +64 -0
  111. snakeorm/fields/index.py +46 -0
  112. snakeorm/fields/relationship.py +738 -0
  113. snakeorm/fields/typed.py +736 -0
  114. snakeorm/helpers/__init__.py +1 -0
  115. snakeorm/helpers/annotations.py +21 -0
  116. snakeorm/helpers/inheritance.py +41 -0
  117. snakeorm/helpers/pyliteral.py +52 -0
  118. snakeorm/helpers/pytype.py +50 -0
  119. snakeorm/introspection/__init__.py +13 -0
  120. snakeorm/introspection/base.py +30 -0
  121. snakeorm/introspection/drift.py +78 -0
  122. snakeorm/introspection/models.py +633 -0
  123. snakeorm/introspection/mysql.py +367 -0
  124. snakeorm/introspection/postgres.py +397 -0
  125. snakeorm/introspection/sqlite.py +217 -0
  126. snakeorm/introspection/unsupported.py +94 -0
  127. snakeorm/linker/__init__.py +3 -0
  128. snakeorm/linker/linker.py +521 -0
  129. snakeorm/metadata/__init__.py +41 -0
  130. snakeorm/metadata/check.py +37 -0
  131. snakeorm/metadata/column.py +201 -0
  132. snakeorm/metadata/enum_storage.py +33 -0
  133. snakeorm/metadata/fk_action.py +19 -0
  134. snakeorm/metadata/foreign_key.py +26 -0
  135. snakeorm/metadata/index.py +43 -0
  136. snakeorm/metadata/index_method.py +19 -0
  137. snakeorm/metadata/int_size.py +20 -0
  138. snakeorm/metadata/json_storage.py +21 -0
  139. snakeorm/metadata/polymorphic.py +30 -0
  140. snakeorm/metadata/primary_key.py +23 -0
  141. snakeorm/metadata/relationship.py +60 -0
  142. snakeorm/metadata/relationship_kind.py +43 -0
  143. snakeorm/metadata/routine.py +26 -0
  144. snakeorm/metadata/server_default.py +20 -0
  145. snakeorm/metadata/table.py +97 -0
  146. snakeorm/metadata/table_kind.py +23 -0
  147. snakeorm/metadata/trigger.py +62 -0
  148. snakeorm/metadata/type_params.py +348 -0
  149. snakeorm/migration/__init__.py +88 -0
  150. snakeorm/migration/asyncrunner.py +168 -0
  151. snakeorm/migration/autodetect.py +96 -0
  152. snakeorm/migration/ddl.py +1172 -0
  153. snakeorm/migration/diff.py +701 -0
  154. snakeorm/migration/loader.py +77 -0
  155. snakeorm/migration/operations.py +952 -0
  156. snakeorm/migration/planner.py +78 -0
  157. snakeorm/migration/realize.py +420 -0
  158. snakeorm/migration/renames.py +153 -0
  159. snakeorm/migration/render.py +1095 -0
  160. snakeorm/migration/runner.py +314 -0
  161. snakeorm/migration/squash.py +60 -0
  162. snakeorm/migration/state.py +107 -0
  163. snakeorm/model.py +162 -0
  164. snakeorm/py.typed +0 -0
  165. snakeorm/query/__init__.py +9 -0
  166. snakeorm/query/compound.py +288 -0
  167. snakeorm/query/join_kind.py +19 -0
  168. snakeorm/query/joined.py +143 -0
  169. snakeorm/query/query.py +1001 -0
  170. snakeorm/query/recursive.py +244 -0
  171. snakeorm/registry/__init__.py +5 -0
  172. snakeorm/registry/by_module.py +163 -0
  173. snakeorm/registry/registry.py +166 -0
  174. snakeorm/session/__init__.py +8 -0
  175. snakeorm/session/asyncsession.py +638 -0
  176. snakeorm/session/coercion.py +306 -0
  177. snakeorm/session/factory.py +29 -0
  178. snakeorm/session/guards.py +269 -0
  179. snakeorm/session/isolation.py +23 -0
  180. snakeorm/session/mapper.py +213 -0
  181. snakeorm/session/planning.py +815 -0
  182. snakeorm/session/retry.py +96 -0
  183. snakeorm/session/session.py +871 -0
  184. snakeorm/session/shared.py +355 -0
  185. snakeorm/sql/__init__.py +18 -0
  186. snakeorm/sql/adapt.py +104 -0
  187. snakeorm/sql/aggregate.py +232 -0
  188. snakeorm/sql/condition.py +359 -0
  189. snakeorm/sql/delete.py +46 -0
  190. snakeorm/sql/insert.py +149 -0
  191. snakeorm/sql/joins.py +158 -0
  192. snakeorm/sql/pk_subquery.py +54 -0
  193. snakeorm/sql/refs.py +19 -0
  194. snakeorm/sql/resolver.py +28 -0
  195. snakeorm/sql/select.py +175 -0
  196. snakeorm/sql/update.py +82 -0
  197. snakeorm/sql/value.py +552 -0
  198. snakeorm/times.py +276 -0
@@ -0,0 +1,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