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
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
@@ -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})