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,34 @@
1
+ <!-- Vista "Config": cómo se configura el panel. Estático (informativo, no interactivo: el panel es SSR
2
+ por request). Textos traducibles con `data-t`; env-vars y código NO se traducen. -->
3
+ <div class="snk-help">
4
+ <div class="snk-help-card">
5
+ <h4 data-t="cfg_channels">Canales de debug</h4>
6
+ <p data-t="cfg_channels_d">Qué entrega el panel. Se combinan por coma:</p>
7
+ <p><code>SNAKE_ORM_DEBUG=ssr,envelope,timing</code></p>
8
+ <p><code>ssr</code> · <span data-t="cfg_ch_ssr">panel HTML inyectado en la respuesta</span></p>
9
+ <p><code>envelope</code> · <span data-t="cfg_ch_envelope">el debug en el JSON, bajo la clave</span> <code>snakeorm</code></p>
10
+ <p><code>timing</code> · <span data-t="cfg_ch_timing">cabecera Server-Timing</span></p>
11
+ <p><code>sidecar</code> · <span data-t="cfg_ch_sidecar">un token y su propia página en</span> <code>/__snake__/{token}</code></p>
12
+ <p><code>otel</code> · <span data-t="cfg_ch_otel">DECLARADO y sin implementar: encenderlo no entrega nada y avisa</span></p>
13
+ </div>
14
+ <div class="snk-help-card">
15
+ <h4 data-t="cfg_advise">Umbral del asesor</h4>
16
+ <p data-t="cfg_advise_d">Solo sugiere índices para queries más lentas que X ms (por debajo, un índice no cambia nada):</p>
17
+ <p><code>SNAKE_ORM_ADVISE_MS=10</code></p>
18
+ <p><span data-t="cfg_advise_code">O tipado en código:</span> <code>SnakeDebugConfig(advise_min_ms=25)</code></p>
19
+ </div>
20
+ <div class="snk-help-card">
21
+ <h4 data-t="cfg_where">Qué va en config y qué en el comando</h4>
22
+ <p data-t="cfg_where_d">La regla: si la aplicación lo necesita para funcionar, va en config; si solo lo necesita esa invocación, va en flag.</p>
23
+ <p><code>SnakeOrmConfig</code> · <span data-t="cfg_where_cfg">databases, debug, advise_ms, migrations_dir, language</span></p>
24
+ <p><span data-t="cfg_where_cli">Flags del comando:</span> <code>--out</code>, <code>--schema</code>, <code>--keep-underscores</code></p>
25
+ <p data-t="cfg_where_db">--database no sobrescribe nada: ELIGE una de las conexiones que declara la config. Un nombre que no esté no se puede crear desde el comando.</p>
26
+ <p data-t="cfg_where_django">En Django no se declara aparte: el CLI sube hasta manage.py, lee su DJANGO_SETTINGS_MODULE y traduce settings.DATABASES + settings.SNAKEORM al mismo objeto.</p>
27
+ </div>
28
+ <div class="snk-help-card">
29
+ <h4 data-t="cfg_prod">Producción</h4>
30
+ <p data-t="cfg_prod_d">Los que entregan el SQL a quien pide —ssr, envelope y sidecar— se caen con production=True, aunque los configures. timing se queda: mide, no cuenta.</p>
31
+ <p><code>SNAKE_ORM_PRODUCTION=true</code></p>
32
+ <p data-t="cfg_prod_env">Del entorno no se adivina nada: si hay un canal de riesgo encendido y nadie lo ha declarado —ni esta variable, ni production= en el middleware, ni SnakeDebugConfig— el arranque se niega en vez de elegir un defecto.</p>
33
+ </div>
34
+ </div>
@@ -0,0 +1,29 @@
1
+ <!-- Vista "DB-first": adoptar una BD existente (scaffolding + modelo espejo + deriva). Estático. Textos
2
+ traducibles con `data-t`; comandos y decoradores NO se traducen. Se inlinea como una vista más. -->
3
+ <div class="snk-help">
4
+ <div class="snk-help-card">
5
+ <h4 data-t="dbf_scaffold">Generar modelos</h4>
6
+ <p data-t="dbf_scaffold_d">Espeja una BD existente a modelos Python:</p>
7
+ <p><code>snakeorm scaffold create --out models.py</code></p>
8
+ <p><span data-t="dbf_scaffold_u">Re-sincronizar (sobrescribe entero):</span> <code>scaffold update</code></p>
9
+ <p><span data-t="dbf_scaffold_out">Sin</span> <code>--out</code> <span data-t="dbf_scaffold_out2">no genera: no hay ruta por defecto y falla diciéndolo.</span></p>
10
+ </div>
11
+ <div class="snk-help-card">
12
+ <h4 data-t="dbf_mirror">Modelo espejo</h4>
13
+ <p><code>@snake_db_first</code> · <span data-t="dbf_mirror_d">marca un modelo como espejo: las migraciones lo IGNORAN. El esquema es del sysadmin, no nuestro.</span></p>
14
+ <p data-t="dbf_mirror_adopt">NO hay adopción in situ: cambiarlo a @snake_model no le entrega los mandos a las migraciones, porque el histórico no conoce la tabla y el autogen solo emitiría un CreateTable, que contra la tabla existente muere. Sirve para LLEVARSE el esquema a otra base gobernada desde cero.</p>
15
+ </div>
16
+ <div class="snk-help-card">
17
+ <h4 data-t="dbf_names">Nombres de las clases</h4>
18
+ <p data-t="dbf_names_d">La clase se llama como la tabla, en CapWords y con el esquema delante:</p>
19
+ <p><code>public.project_requests</code> → <code>PublicProjectRequests</code></p>
20
+ <p><code>--keep-underscores</code> · <span data-t="dbf_names_u">deja los guiones bajos</span> <code>Public_project_requests</code></p>
21
+ <p><code>--no-schema-prefix</code> · <span data-t="dbf_names_s">quita el esquema; dos esquemas con la misma tabla COLISIONAN y se avisa</span></p>
22
+ <p data-t="dbf_names_why">No se quita el plural: eso es adivinar en inglés y rompe status, analysis o direcciones. Lo que no se puede nombrar se avisa y se deja fuera, nunca se inventa.</p>
23
+ </div>
24
+ <div class="snk-help-card">
25
+ <h4 data-t="dbf_check">Deriva</h4>
26
+ <p data-t="dbf_check_d">Compara el código con la BD real y avisa si no cuadran:</p>
27
+ <p><code>snakeorm check --models miapp.models</code></p>
28
+ </div>
29
+ </div>
@@ -0,0 +1,21 @@
1
+ <!-- Contenido ESTÁTICO de la vista "Ayuda" del panel: cómo usar la herramienta. Se inlinea dentro del
2
+ shadow root como una vista más. Los textos traducibles llevan `data-t` (el JS los swapea); el
3
+ server pinta el español por defecto. Los fragmentos de código/env-var no se traducen. -->
4
+ <div class="snk-help">
5
+ <div class="snk-help-card">
6
+ <h4 data-t="help_enable">Activar el panel</h4>
7
+ <p><code>SNAKE_ORM_DEBUG=ssr,envelope,timing</code></p>
8
+ </div>
9
+ <div class="snk-help-card">
10
+ <h4 data-t="help_json">JSON de la API</h4>
11
+ <p><span data-t="help_json_d">Con el canal envelope, en cada respuesta JSON bajo la clave</span> <code>snakeorm</code></p>
12
+ </div>
13
+ <div class="snk-help-card">
14
+ <h4 data-t="help_shortcuts">Atajos</h4>
15
+ <p data-t="help_shortcuts_d">Esc cierra · arrastra el botón para moverlo · ◐ tema · 🌐 idioma</p>
16
+ </div>
17
+ <div class="snk-help-card">
18
+ <h4 data-t="help_read">Leer las consultas</h4>
19
+ <p data-t="help_read_d">×N = misma consulta repetida · ↳ fichero:línea = de dónde salió · los ? ya llevan su valor real · pliega los avisos y elige cuántas queries ves por página para ganar sitio</p>
20
+ </div>
21
+ </div>
@@ -0,0 +1,27 @@
1
+ <!-- Vista "Historial": las llamadas que la página hace DESPUÉS del render (fetch, XHR, HTMX, Alpine).
2
+ Es la única vista DINÁMICA: el servidor pinta el esqueleto y el cliente añade sus `<li>` a
3
+ `.snk-history-list`. Las tarjetas de arriba son aritmética del cliente sobre esa misma lista:
4
+ el servidor las pinta vacías (con sus textos traducibles) y el JS solo escribe los números.
5
+ Textos traducibles con `data-t`; el código y las env-vars no se traducen.
6
+ `.snk-history-off` solo se ve cuando la vista lleva `data-envelope="off"`. -->
7
+ <div class="snk-help">
8
+ <div class="snk-help-card snk-history-live">
9
+ <div class="snk-metrics snk-hmetrics">
10
+ <div class="snk-m" title="Llamadas que la página ha hecho desde que cargó." data-tt="hcalls_tip"><b data-hm="calls">0</b><span data-t="hcalls">llamadas</span></div>
11
+ <div class="snk-m" title="Suma de las consultas de esas llamadas." data-tt="hqueries_tip"><b data-hm="queries">0</b><span data-t="queries">queries</span><em class="snk-m-part" data-hm-part="queries" title="Calculado solo sobre las llamadas que trajeron el dato; las demás no cuentan como cero." data-tt="hpart_tip"></em></div>
12
+ <div class="snk-m" title="Suma de lo que esas llamadas esperaron a la BD." data-tt="hdb_tip"><b data-hm="db">0.00<small>ms</small></b><span data-t="db">en BD</span><em class="snk-m-part" data-hm-part="db" title="Calculado solo sobre las llamadas que trajeron el dato; las demás no cuentan como cero." data-tt="hpart_tip"></em></div>
13
+ <div class="snk-m" title="Suma del mapeo de esas llamadas: filas convertidas en objetos." data-tt="hmap_tip"><b data-hm="map">0.00<small>ms</small></b><span data-t="map">mapeo</span><em class="snk-m-part" data-hm-part="map" title="Calculado solo sobre las llamadas que trajeron el dato; las demás no cuentan como cero." data-tt="hpart_tip"></em></div>
14
+ <div class="snk-m" title="La llamada que más esperó a la BD de toda la lista." data-tt="hslowest_tip"><b data-hm="slowest">0.00<small>ms</small></b><span data-t="slowest">más lenta</span><em class="snk-m-part" data-hm-part="slowest" title="Calculado solo sobre las llamadas que trajeron el dato; las demás no cuentan como cero." data-tt="hpart_tip"></em></div>
15
+ </div>
16
+ <h4 data-t="hist_title">Llamadas posteriores</h4>
17
+ <p data-t="hist_intro">El informe de arriba es el de la petición que pintó esta página. Aquí se apilan las que vienen después, sin recargar.</p>
18
+ <ul class="snk-history-list"></ul>
19
+ <p class="snk-history-empty" data-t="hist_empty">Aún no hay llamadas.</p>
20
+ </div>
21
+ <div class="snk-help-card snk-history-off">
22
+ <h4 data-t="hist_off">Falta el canal envelope</h4>
23
+ <p data-t="hist_off_d">El histórico lee el informe que el ORM cuelga de las respuestas JSON. Sin ese canal no llega ninguno, así que aquí no habría nada que apilar:</p>
24
+ <p><code>SNAKE_ORM_DEBUG=ssr,envelope</code></p>
25
+ <p data-t="hist_off_why">El panel lo dice y no lo enciende: ssr y envelope son canales independientes y quien los elige eres tú.</p>
26
+ </div>
27
+ </div>
@@ -0,0 +1,234 @@
1
+ """`CaptureDriver` / `AsyncCaptureDriver`: driver decorators that record each statement into the scope's collector.
2
+
3
+ With no active scope they delegate without timing or recording: with the debug off, the cost is one `ContextVar` read.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import AsyncIterator, Iterator, Sequence
9
+ from time import perf_counter
10
+
11
+ from snakeorm.debug.collector import current_collector
12
+ from snakeorm.debug.record import QueryKind
13
+ from snakeorm.drivers.asyncbase import AsyncDriver
14
+ from snakeorm.drivers.base import SnakeDriver
15
+
16
+
17
+ class CaptureDriver:
18
+ """Wrap a `SnakeDriver` and record each statement into the active collector (if there is one)."""
19
+
20
+ __slots__ = ("_inner", "_system")
21
+
22
+ def __init__(self, inner: SnakeDriver, *, system: str = "") -> None:
23
+ """`system` is the engine's OpenTelemetry name (`db.system.name`), DECLARED by the caller.
24
+
25
+ It is read once here and copied onto every record, so the per-query cost is an attribute
26
+ read. It is not derived from the driver's class: a decorator chain (pool, timeout, logging)
27
+ would hide it, and guessing from the SQL cannot tell MySQL from MariaDB.
28
+ """
29
+ self._inner = inner
30
+ self._system = system
31
+
32
+ def fetch_all(self, sql: str, params: Sequence[object]) -> list[tuple[object, ...]]:
33
+ """Run the read; if there is a scope, record SQL, time and rows returned."""
34
+ collector = current_collector()
35
+ if collector is None:
36
+ return self._inner.fetch_all(sql, params)
37
+ start = perf_counter()
38
+ rows = self._inner.fetch_all(sql, params)
39
+ collector.add(
40
+ sql=sql,
41
+ params=params,
42
+ duration_ms=(perf_counter() - start) * 1000,
43
+ rows=len(rows),
44
+ kind=QueryKind.SELECT,
45
+ started_at=start,
46
+ system=self._system,
47
+ )
48
+ return rows
49
+
50
+ def fetch_iter(
51
+ self, sql: str, params: Sequence[object], *, chunk: int = 1000
52
+ ) -> Iterator[tuple[object, ...]]:
53
+ """Yield the rows in streaming; if there is a scope, record SQL, time and rows CONSUMED.
54
+
55
+ It gets noted at the end, not at the start, and for two reasons. The first is that counting
56
+ beforehand would demand materialising the result, which is exactly what this path avoids.
57
+ The second is that in streaming the interesting figure IS what was consumed: a `break` at
58
+ the tenth row out of a million counts ten, and that is what the panel must show.
59
+
60
+ The measured time includes whatever the consumer takes between rows. It is unavoidable —the
61
+ cursor stays open that whole time— and it is also honest: that is how long the query lasts
62
+ from the point of view of the connection, which is the one being held.
63
+ """
64
+ collector = current_collector()
65
+ if collector is None:
66
+ yield from self._inner.fetch_iter(sql, params, chunk=chunk)
67
+ return
68
+ start = perf_counter()
69
+ consumed = 0
70
+ try:
71
+ for row in self._inner.fetch_iter(sql, params, chunk=chunk):
72
+ consumed += 1
73
+ yield row
74
+ finally:
75
+ collector.add(
76
+ sql=sql,
77
+ params=params,
78
+ duration_ms=(perf_counter() - start) * 1000,
79
+ rows=consumed,
80
+ kind=QueryKind.SELECT,
81
+ started_at=start,
82
+ system=self._system,
83
+ )
84
+
85
+ def execute(self, sql: str, params: Sequence[object]) -> int:
86
+ """Run the write; if there is a scope, record SQL, time and rows affected."""
87
+ collector = current_collector()
88
+ if collector is None:
89
+ return self._inner.execute(sql, params)
90
+ start = perf_counter()
91
+ affected = self._inner.execute(sql, params)
92
+ collector.add(
93
+ sql=sql,
94
+ params=params,
95
+ duration_ms=(perf_counter() - start) * 1000,
96
+ rows=affected,
97
+ kind=QueryKind.WRITE,
98
+ started_at=start,
99
+ system=self._system,
100
+ )
101
+ return affected
102
+
103
+ @property
104
+ def last_insert_id(self) -> int:
105
+ """Forward to the wrapped driver (see the `SnakeDriver` Protocol)."""
106
+ return self._inner.last_insert_id
107
+
108
+ def commit(self) -> None:
109
+ """Delegate the commit."""
110
+ self._inner.commit()
111
+
112
+ def rollback(self) -> None:
113
+ """Delegate the rollback."""
114
+ self._inner.rollback()
115
+
116
+ def savepoint(self, name: str) -> None:
117
+ """Delegate the savepoint."""
118
+ self._inner.savepoint(name)
119
+
120
+ def release_savepoint(self, name: str) -> None:
121
+ """Delegate the savepoint release."""
122
+ self._inner.release_savepoint(name)
123
+
124
+ def rollback_to_savepoint(self, name: str) -> None:
125
+ """Delegate the rollback to the savepoint."""
126
+ self._inner.rollback_to_savepoint(name)
127
+
128
+ def close(self) -> None:
129
+ """Close the inner driver."""
130
+ self._inner.close()
131
+
132
+
133
+ class AsyncCaptureDriver:
134
+ """Asynchronous mirror of `CaptureDriver`, over the `AsyncDriver` Protocol."""
135
+
136
+ __slots__ = ("_inner", "_system")
137
+
138
+ def __init__(self, inner: AsyncDriver, *, system: str = "") -> None:
139
+ """Same declaration as the synchronous one: the engine's `db.system.name`, given not guessed."""
140
+ self._inner = inner
141
+ self._system = system
142
+
143
+ async def fetch_all(
144
+ self, sql: str, params: Sequence[object]
145
+ ) -> list[tuple[object, ...]]:
146
+ """Run the async read; if there is a scope, record SQL, time and rows returned."""
147
+ collector = current_collector()
148
+ if collector is None:
149
+ return await self._inner.fetch_all(sql, params)
150
+ start = perf_counter()
151
+ rows = await self._inner.fetch_all(sql, params)
152
+ collector.add(
153
+ sql=sql,
154
+ params=params,
155
+ duration_ms=(perf_counter() - start) * 1000,
156
+ rows=len(rows),
157
+ kind=QueryKind.SELECT,
158
+ started_at=start,
159
+ system=self._system,
160
+ )
161
+ return rows
162
+
163
+ async def fetch_iter(
164
+ self, sql: str, params: Sequence[object], *, chunk: int = 1000
165
+ ) -> AsyncIterator[tuple[object, ...]]:
166
+ """Async mirror of `CaptureDriver.fetch_iter`: it records the rows CONSUMED, at the end."""
167
+ collector = current_collector()
168
+ if collector is None:
169
+ async for row in self._inner.fetch_iter(sql, params, chunk=chunk):
170
+ yield row
171
+ return
172
+ start = perf_counter()
173
+ consumed = 0
174
+ try:
175
+ async for row in self._inner.fetch_iter(sql, params, chunk=chunk):
176
+ consumed += 1
177
+ yield row
178
+ finally:
179
+ collector.add(
180
+ sql=sql,
181
+ params=params,
182
+ duration_ms=(perf_counter() - start) * 1000,
183
+ rows=consumed,
184
+ kind=QueryKind.SELECT,
185
+ started_at=start,
186
+ system=self._system,
187
+ )
188
+
189
+ async def execute(self, sql: str, params: Sequence[object]) -> int:
190
+ """Run the async write; if there is a scope, record SQL, time and rows affected."""
191
+ collector = current_collector()
192
+ if collector is None:
193
+ return await self._inner.execute(sql, params)
194
+ start = perf_counter()
195
+ affected = await self._inner.execute(sql, params)
196
+ collector.add(
197
+ sql=sql,
198
+ params=params,
199
+ duration_ms=(perf_counter() - start) * 1000,
200
+ rows=affected,
201
+ kind=QueryKind.WRITE,
202
+ started_at=start,
203
+ system=self._system,
204
+ )
205
+ return affected
206
+
207
+ @property
208
+ def last_insert_id(self) -> int:
209
+ """Delegate the id of the last INSERT: capturing the SQL does not change what the engine wrote."""
210
+ return self._inner.last_insert_id
211
+
212
+ async def commit(self) -> None:
213
+ """Delegate the commit."""
214
+ await self._inner.commit()
215
+
216
+ async def rollback(self) -> None:
217
+ """Delegate the rollback."""
218
+ await self._inner.rollback()
219
+
220
+ async def savepoint(self, name: str) -> None:
221
+ """Delegate the savepoint."""
222
+ await self._inner.savepoint(name)
223
+
224
+ async def release_savepoint(self, name: str) -> None:
225
+ """Delegate the savepoint release."""
226
+ await self._inner.release_savepoint(name)
227
+
228
+ async def rollback_to_savepoint(self, name: str) -> None:
229
+ """Delegate the rollback to the savepoint."""
230
+ await self._inner.rollback_to_savepoint(name)
231
+
232
+ async def close(self) -> None:
233
+ """Close the inner driver."""
234
+ await self._inner.close()
@@ -0,0 +1,131 @@
1
+ """Debug delivery channels and the parsing of `SNAKE_ORM_DEBUG`.
2
+
3
+ A channel is a DELIVERY mode of the same report; they compose as a `frozenset[SnakeDebugChannel]`. The environment string is only their serialisation.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from collections.abc import Mapping
10
+ from enum import Enum, StrEnum
11
+
12
+ from snakeorm.core.exceptions import SnakeConfigError
13
+
14
+ DEBUG_ENV_KEY = "SNAKE_ORM_DEBUG"
15
+ """Environment variable that turns the channels on, as a comma-separated list."""
16
+
17
+
18
+ class SnakeDebugChannel(StrEnum):
19
+ """How the report is DELIVERED. Independent channels that combine freely; each contrib applies only the ones that make sense in its framework."""
20
+
21
+ SSR = "ssr" # an HTML panel injected into the response (Django/Flask)
22
+ ENVELOPE = "envelope" # a `snakeorm` block in the JSON + a text table — NO tooling (Postman)
23
+ TIMING = "timing" # a Server-Timing header — WITH tooling (the browser devtools)
24
+ SIDECAR = "sidecar" # a token + its own page at /__snake__/{token}
25
+ OTEL = "otel" # OpenTelemetry spans over OTLP — WITH tooling (Jaeger/Grafana)
26
+
27
+
28
+ class ChannelAudience(Enum):
29
+ """WHO ends up holding what a channel delivers. It is the axis that decides risk.
30
+
31
+ The set of risky channels used to be written out by hand as `{envelope, sidecar}`, under the
32
+ heading "the ones that come back to the client". That heading is not the rule it was standing
33
+ in for: `ssr` comes back to the client too, and `timing` also does while being harmless. `ssr`
34
+ was missing, and what is missing from a hand-written set reads as harmless without anyone
35
+ deciding so — which is how a production deploy came to hand its SQL to anonymous visitors.
36
+
37
+ The real question is two-part, and this enum asks both halves: who receives it, and does what
38
+ they receive contain the SQL.
39
+ """
40
+
41
+ CLIENT_SQL = "client_sql"
42
+ """The requester gets the statements themselves. Never in production."""
43
+
44
+ CLIENT_METRICS = "client_metrics"
45
+ """The requester gets timings and counts, no SQL. Safe to leave on."""
46
+
47
+ OPERATOR = "operator"
48
+ """It goes out SIDEWAYS, to infrastructure the operator already runs — never on the response."""
49
+
50
+
51
+ CHANNEL_AUDIENCE: Mapping[SnakeDebugChannel, ChannelAudience] = {
52
+ # The panel paints every statement with the placeholders ALREADY REPLACED by their values, plus
53
+ # the `file:line` of the application code that fired them. Strictly MORE than the envelope.
54
+ SnakeDebugChannel.SSR: ChannelAudience.CLIENT_SQL,
55
+ SnakeDebugChannel.ENVELOPE: ChannelAudience.CLIENT_SQL,
56
+ SnakeDebugChannel.SIDECAR: ChannelAudience.CLIENT_SQL,
57
+ # A `Server-Timing` header: how long, not what. It is the one delivery worth leaving on.
58
+ SnakeDebugChannel.TIMING: ChannelAudience.CLIENT_METRICS,
59
+ # Spans over OTLP, to the operator's own collector. Production is the ONLY place a tracing
60
+ # channel justifies existing, so filing it as risky would be declaring it dead.
61
+ SnakeDebugChannel.OTEL: ChannelAudience.OPERATOR,
62
+ }
63
+ """Who receives each channel. Exhaustive by construction — see the guard right below."""
64
+
65
+
66
+ def demand_every_channel_classified(
67
+ audience: Mapping[SnakeDebugChannel, ChannelAudience],
68
+ ) -> None:
69
+ """Blows up if a channel of the enum has no audience declared. Runs AT IMPORT.
70
+
71
+ Same mechanism the capability catalogue uses (`SnakeCapabilities.__post_init__`) and for the
72
+ same reason: a channel that nobody classified would simply not be risky, which is precisely the
73
+ silence this module exists to prevent. Declaring the audience is not paperwork — it is the only
74
+ moment somebody is forced to think about who ends up with the SQL.
75
+ """
76
+ missing = [
77
+ channel.value for channel in SnakeDebugChannel if channel not in audience
78
+ ]
79
+ if missing:
80
+ raise SnakeConfigError(
81
+ f"These debug channels do not declare who receives them: {', '.join(missing)}. "
82
+ f"Every channel declares an audience: an unclassified one would read as harmless "
83
+ f"and would survive the production filter without anyone having decided so."
84
+ )
85
+
86
+
87
+ demand_every_channel_classified(CHANNEL_AUDIENCE)
88
+
89
+ RISKY_CHANNELS: frozenset[SnakeDebugChannel] = frozenset(
90
+ channel
91
+ for channel, audience in CHANNEL_AUDIENCE.items()
92
+ if audience is ChannelAudience.CLIENT_SQL
93
+ )
94
+ """Channels that hand the SQL to whoever asked: dropped in production even if configured.
95
+
96
+ DERIVED, never written out. The literal it replaces was missing `ssr` and nothing could tell."""
97
+
98
+ # Channels that are DECLARED but not built yet. Enabling one delivers nothing, so it WARNS instead
99
+ # of failing silently. It is EMPTY today: `otel` was the last one in it and now exports for real
100
+ # (`debug/otel/`). The mechanism stays for the next channel declared ahead of its exporter; what
101
+ # does not stay is a warning telling users that a working channel delivers nothing.
102
+ UNIMPLEMENTED_CHANNELS: frozenset[SnakeDebugChannel] = frozenset()
103
+
104
+
105
+ def parse_channels(raw: str) -> frozenset[SnakeDebugChannel]:
106
+ """Parse `"ssr,envelope"` into a `frozenset`. Empty → off; spaces/uppercase are forgiven.
107
+
108
+ An UNKNOWN name blows up naming it: a typo that silently left you without debug is precisely the failure being fought.
109
+ """
110
+ channels: set[SnakeDebugChannel] = set()
111
+ for token in raw.split(","):
112
+ name = token.strip().lower()
113
+ if not name:
114
+ continue
115
+ try:
116
+ channels.add(SnakeDebugChannel(name))
117
+ except ValueError:
118
+ valid = ", ".join(channel.value for channel in SnakeDebugChannel)
119
+ raise SnakeConfigError(
120
+ f"Unknown debug channel: '{name}'. "
121
+ f"{DEBUG_ENV_KEY} accepts these channels: {valid}."
122
+ ) from None
123
+ return frozenset(channels)
124
+
125
+
126
+ def channels_from_env(
127
+ environ: Mapping[str, str] | None = None,
128
+ ) -> frozenset[SnakeDebugChannel]:
129
+ """Read `SNAKE_ORM_DEBUG` from the environment (or the given map, for tests) and parse it into channels."""
130
+ env = os.environ if environ is None else environ
131
+ return parse_channels(env.get(DEBUG_ENV_KEY, ""))
@@ -0,0 +1,144 @@
1
+ """Per-scope collector where the `QueryRecord`s of a unit of work land.
2
+
3
+ Built on `ContextVar` (neither global nor thread-local): it works in sync and async and does not get contaminated between concurrent requests that share a thread.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Callable, Iterator, Sequence
9
+ from contextlib import contextmanager
10
+ from contextvars import ContextVar
11
+ from functools import wraps
12
+ from time import perf_counter
13
+ from typing import ParamSpec, TypeVar
14
+
15
+ from snakeorm.debug.origin import capture_origin
16
+ from snakeorm.debug.record import QueryKind, QueryRecord
17
+ from snakeorm.debug.report import DebugReport
18
+
19
+ P = ParamSpec("P")
20
+ R = TypeVar("R")
21
+
22
+
23
+ class DebugCollector:
24
+ """Accumulate the records of a scope and freeze them into a `DebugReport` on demand.
25
+
26
+ It also holds the scope's MAPPING total. It lives here and not on the report because the
27
+ mapping happens INSIDE the scope, exactly like the queries do — the wall clock is the odd one
28
+ out, measured by the middleware around the scope and stamped afterwards.
29
+ """
30
+
31
+ __slots__ = ("_mapping_ms", "_records")
32
+
33
+ def __init__(self) -> None:
34
+ self._records: list[QueryRecord] = []
35
+ self._mapping_ms = 0.0
36
+
37
+ def add(
38
+ self,
39
+ *,
40
+ sql: str,
41
+ params: Sequence[object],
42
+ duration_ms: float,
43
+ rows: int,
44
+ kind: QueryKind,
45
+ started_at: float = 0.0,
46
+ system: str = "",
47
+ ) -> None:
48
+ """Record a statement, numbering it by arrival order (1-based) and noting its ORIGIN.
49
+
50
+ The origin (file/line/function of the user code that fired the query) is resolved HERE,
51
+ while the caller's stack is still alive: one frame up is the capture driver and, above
52
+ that, the app's code.
53
+
54
+ `started_at` is the monotonic instant the driver measured FROM; `system` the engine it
55
+ declared. Both are passed through untouched: this collector computes neither.
56
+ """
57
+ self._records.append(
58
+ QueryRecord(
59
+ n=len(self._records) + 1,
60
+ sql=sql,
61
+ params=tuple(params),
62
+ duration_ms=duration_ms,
63
+ rows=rows,
64
+ kind=kind,
65
+ origin=capture_origin(),
66
+ started_at=started_at,
67
+ system=system,
68
+ )
69
+ )
70
+
71
+ def add_mapping(self, duration_ms: float) -> None:
72
+ """Add a BATCH of hydration to the scope's mapping total (see `timed_mapping`)."""
73
+ self._mapping_ms += duration_ms
74
+
75
+ @property
76
+ def mapping_ms(self) -> float:
77
+ """Time spent turning rows into objects in this scope. Zero is MEASURED, not unknown."""
78
+ return self._mapping_ms
79
+
80
+ def report(self) -> DebugReport:
81
+ """The snapshot of what has been captured so far."""
82
+ return DebugReport.from_records(self._records, mapping_ms=self._mapping_ms)
83
+
84
+
85
+ # The active collector of the current scope. `None` = nothing is being captured (passthrough).
86
+ _active: ContextVar[DebugCollector | None] = ContextVar(
87
+ "snakeorm_debug_collector", default=None
88
+ )
89
+
90
+
91
+ @contextmanager
92
+ def capture_queries() -> Iterator[DebugCollector]:
93
+ """Open a capture scope and return its collector; on the way out it restores the previous one.
94
+
95
+ A synchronous context manager on purpose and still valid for async: setting/restoring a `ContextVar` needs no `await` and the value travels with the task.
96
+ """
97
+ collector = DebugCollector()
98
+ token = _active.set(collector)
99
+ try:
100
+ yield collector
101
+ finally:
102
+ _active.reset(token)
103
+
104
+
105
+ def current_collector() -> DebugCollector | None:
106
+ """The collector of the active scope, or `None` if nothing is being captured."""
107
+ return _active.get()
108
+
109
+
110
+ def timed_mapping(function: Callable[P, R]) -> Callable[P, R]:
111
+ """Mark a callable that turns an ALREADY FETCHED result set into objects, and time it.
112
+
113
+ Decorate only a body that has its rows in hand and does nothing but build objects out of them.
114
+ Two things must stay outside or the number lies: I/O of any kind, and resolving a relationship
115
+ that fires its own query. Get that wrong and `DB + MAPPING + APP` stops adding up to the
116
+ request, which is the invariant the panel's cards promise.
117
+
118
+ ONE lookup per result set, never per row — the same argument `_hydrate_with_plan` makes about
119
+ the plan. Measured on the hydration loop with the debug OFF: a `ContextVar` read inside the row
120
+ loop costs +52 ns/row (+4.3% on a five-column row), while this gate costs ~130 ns per RESULT
121
+ SET however many rows it holds. A decorator and not a `with` block for the same reason: an
122
+ equivalent `@contextmanager` measured ~1000 ns per result set, which is +80% on the one-row
123
+ read `get()` and `first()` do.
124
+
125
+ Do NOT nest two of these: the inner one would be counted twice.
126
+
127
+ STREAMING (`iterate`) is deliberately NOT marked: the capture driver's stopwatch around
128
+ `fetch_iter` spans whatever the consumer does BETWEEN rows, so hydration during a stream is
129
+ already inside `db_ms` and timing it again would count it twice. A streaming read reports a
130
+ MAPPING that leaves that share out; the sum stays true.
131
+ """
132
+
133
+ @wraps(function)
134
+ def timed(*args: P.args, **kwargs: P.kwargs) -> R:
135
+ collector = current_collector()
136
+ if collector is None:
137
+ return function(*args, **kwargs)
138
+ start = perf_counter()
139
+ try:
140
+ return function(*args, **kwargs)
141
+ finally:
142
+ collector.add_mapping((perf_counter() - start) * 1000)
143
+
144
+ return timed