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,245 @@
1
+ """Django adapter: middleware that branches by `Content-Type` (the panel in HTML, `snakeorm` in JSON) for the hybrid SSR + `/api/` case.
2
+
3
+ The production gate is tied to `settings.DEBUG`. Django is imported LAZILY (only when serving the sidecar), so the module imports and tests without Django installed.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Mapping
9
+ from datetime import UTC, datetime
10
+ from time import perf_counter
11
+ from typing import TYPE_CHECKING, Any, cast
12
+
13
+ from snakeorm.advisor import DEFAULT_MIN_MS
14
+
15
+ # `X as X`: an EXPLICIT re-export — the user's settings.py imports them from here (Django's world).
16
+ from snakeorm.contrib.config import SnakeOrmDatabase as SnakeOrmDatabase
17
+ from snakeorm.contrib.config import SnakeOrmSettings as SnakeOrmSettings
18
+ from snakeorm.contrib.config import (
19
+ SnakeOrmConfig,
20
+ connection_from_mapping,
21
+ debug_config_from_mapping,
22
+ )
23
+ from snakeorm.contrib.deliver import (
24
+ allowed_channels,
25
+ index_advice,
26
+ plan_delivery,
27
+ serve_sidecar,
28
+ transform_body,
29
+ )
30
+ from snakeorm.contrib.sidecar import SidecarBuffer, new_token
31
+ from snakeorm.debug import (
32
+ SnakeDebugChannel,
33
+ SnakeDebugConfig,
34
+ SnakeDebugLanguage,
35
+ SnakeWebFramework,
36
+ capture_queries,
37
+ channels_from_env,
38
+ export_report,
39
+ RequestInfo,
40
+ warn_unimplemented,
41
+ warn_unsupported,
42
+ )
43
+
44
+ if TYPE_CHECKING:
45
+ from snakeorm.session import SnakeSession
46
+
47
+ _SIDECAR_PREFIX = "/__snake__/"
48
+
49
+
50
+ def _django_is_production() -> bool:
51
+ """`True` if Django says it is NOT in debug; `False` if Django is absent (falls back to dev).
52
+
53
+ It only resolves the `settings.DEBUG` signal; the real gate is exercised by the pure layer
54
+ `allowed_channels`.
55
+ """
56
+ try:
57
+ from django.conf import settings # pyright: ignore[reportMissingImports]
58
+
59
+ return not settings.DEBUG
60
+ except Exception:
61
+ return False
62
+
63
+
64
+ def _csp_nonce_for(request: Any, config: SnakeDebugConfig) -> str | None:
65
+ """The REQUEST's nonce (`request.csp_nonce`) if there is one; otherwise the config's.
66
+
67
+ django-csp mints one per response and hangs it there, which is what CSP asks for; a nonce fixed
68
+ in the config is the same for every response. Django is the only one of the three adapters with
69
+ a request object to ask. Absent django-csp the attribute is not there and the config wins.
70
+ `str()` because the value is a lazy object, not a `str`.
71
+ """
72
+ nonce = getattr(request, "csp_nonce", None)
73
+ return config.csp_nonce if nonce is None else str(nonce)
74
+
75
+
76
+ def _accept_of(request: Any) -> str:
77
+ """The request's `Accept` header, or an empty string when there is none to read.
78
+
79
+ `request.headers` is Django's own case-insensitive mapping. It is read defensively because this
80
+ method also runs against request doubles that carry nothing but a path.
81
+ """
82
+ headers = getattr(request, "headers", None)
83
+ if headers is None:
84
+ return ""
85
+ return str(headers.get("Accept", ""))
86
+
87
+
88
+ class SnakeDebugMiddleware:
89
+ """Django middleware that captures the SQL and delivers the debug per `SNAKE_ORM_DEBUG`."""
90
+
91
+ def __init__(self, get_response: Any) -> None:
92
+ self._get_response = get_response
93
+ # The config comes out of the `SNAKEORM` constant of settings.py (Django's idiomatic way).
94
+ # If it is not declared (or Django is not configured, in the tests), it falls back to the
95
+ # environment as before.
96
+ snakeorm = _django_setting("SNAKEORM")
97
+ if snakeorm:
98
+ channels, self._config = debug_config_from_mapping(
99
+ cast("SnakeOrmSettings", snakeorm)
100
+ )
101
+ else:
102
+ channels, self._config = channels_from_env(), SnakeDebugConfig.from_env()
103
+ self._channels = allowed_channels(channels, production=_django_is_production())
104
+ self._buffer = SidecarBuffer()
105
+ warn_unsupported(self._channels, SnakeWebFramework.DJANGO)
106
+ warn_unimplemented(self._channels)
107
+
108
+ def __call__(self, request: Any) -> Any:
109
+ """Serve the sidecar, or capture and add the debug to the view's response."""
110
+ path: str = getattr(request, "path", "")
111
+ if (
112
+ path.startswith(_SIDECAR_PREFIX)
113
+ and SnakeDebugChannel.SIDECAR in self._channels
114
+ ):
115
+ return self._serve_sidecar(path, request)
116
+ if not self._channels:
117
+ return self._get_response(request)
118
+
119
+ start = perf_counter()
120
+ at = datetime.now(UTC) # the instant, taken where the wall clock starts
121
+ with capture_queries() as collector:
122
+ response = self._get_response(request)
123
+ report = collector.report().with_wall_ms((perf_counter() - start) * 1000)
124
+ report = report.with_index_hints(index_advice(report, self._config))
125
+ report = report.with_request(
126
+ RequestInfo(
127
+ method=getattr(request, "method", ""),
128
+ path=path,
129
+ status=int(getattr(response, "status_code", 0)),
130
+ at=at,
131
+ )
132
+ )
133
+
134
+ token = None
135
+ if SnakeDebugChannel.SIDECAR in self._channels:
136
+ token = new_token()
137
+ self._buffer.store(token, report)
138
+ # The `otel` channel: OUT of `plan_delivery`, which is a pure function that answers
139
+ # `(headers, envelope)` — two things that change the response, which a network send is not.
140
+ # Shared the same way `index_advice` is, and OUTSIDE the streaming `if` below: a
141
+ # `StreamingHttpResponse` has no body to inject a panel into, but it ran the same
142
+ # queries — dropping its trace would hide exactly the endpoints that stream because
143
+ # they are big.
144
+ export_report(report, self._channels)
145
+ delivery = plan_delivery(report, self._channels, token=token)
146
+
147
+ # A StreamingHttpResponse has no `.content` (accessing it raises): headers get added to it
148
+ # but the body is not touched — a streaming response is no place for a panel.
149
+ if not getattr(response, "streaming", False):
150
+ content_type = response.get("Content-Type", "")
151
+ new_body = transform_body(
152
+ response.content,
153
+ content_type,
154
+ delivery,
155
+ report,
156
+ self._channels,
157
+ self._config.language,
158
+ _csp_nonce_for(request, self._config),
159
+ token=token,
160
+ )
161
+ if new_body != response.content:
162
+ response.content = new_body
163
+ # Re-seal Content-Length with the REAL length of the already transformed body. If
164
+ # an inner middleware set it earlier (with the HTML WITHOUT the panel), it would be
165
+ # short and the server would truncate the body on the way out: the panel would be
166
+ # lost. Django's test client reads `.content` and does not catch it; a real server
167
+ # (runserver/gunicorn) does.
168
+ response["Content-Length"] = str(len(response.content))
169
+ for name, value in delivery.headers:
170
+ response[name] = value
171
+ return response
172
+
173
+ def _serve_sidecar(self, path: str, request: Any) -> Any:
174
+ """Serve `/__snake__/{token}`: the panel page, or the report as JSON if `Accept` asks.
175
+
176
+ Django is imported here, lazily. WHAT to answer is decided in `serve_sidecar`, shared with
177
+ the other two adapters; the 404 keeps its own class because that is the status Django reads
178
+ off the response type, not off an argument.
179
+ """
180
+ from django.http import ( # pyright: ignore[reportMissingImports]
181
+ HttpResponse,
182
+ HttpResponseNotFound,
183
+ )
184
+
185
+ page = serve_sidecar(
186
+ self._buffer.get(path[len(_SIDECAR_PREFIX) :]),
187
+ accept=_accept_of(request),
188
+ language=self._config.language,
189
+ )
190
+ if page.status == 404:
191
+ return HttpResponseNotFound(page.body)
192
+ return HttpResponse(page.body, content_type=page.content_type)
193
+
194
+
195
+ # --- Django linker: it reads the NATIVE config (settings.DATABASES / settings.SNAKEORM) -------------
196
+ # The user works AS ALWAYS (their lifelong `DATABASES`); the translation to `SnakeConnectionConfig`
197
+ # is generic and lives in `contrib.config`. Here there is only what is specific to Django: reading
198
+ # from `settings`.
199
+
200
+
201
+ def _django_setting(name: str) -> dict[str, Any]:
202
+ """Read a constant off Django's `settings.py`, or `{}` if Django is not configured (tests)."""
203
+ try:
204
+ from django.conf import settings # pyright: ignore[reportMissingImports]
205
+
206
+ return getattr(settings, name, {})
207
+ except Exception:
208
+ return {}
209
+
210
+
211
+ def config_from_django(
212
+ *,
213
+ databases: Mapping[str, SnakeOrmDatabase] | None = None,
214
+ snakeorm: SnakeOrmSettings | None = None,
215
+ ) -> SnakeOrmConfig:
216
+ """Translate Django's NATIVE config (`settings.DATABASES` + `settings.SNAKEORM`) into the ROOT
217
+ config.
218
+
219
+ That way Django CONVERGES on the SAME `SnakeOrmConfig` that Flask/FastAPI build by hand: a
220
+ single normalised shape out of which sessions and the panel config come. The args are injected
221
+ in the tests; in a real app they fall back to `settings` (`{}` if Django is not configured).
222
+ """
223
+ raw_dbs = _django_setting("DATABASES") if databases is None else databases
224
+ raw_settings = _django_setting("SNAKEORM") if snakeorm is None else snakeorm
225
+ connections = {alias: connection_from_mapping(db) for alias, db in raw_dbs.items()}
226
+ return SnakeOrmConfig(
227
+ databases=connections,
228
+ debug=raw_settings.get("DEBUG", ""),
229
+ advise_ms=float(raw_settings.get("ADVISE_MS", DEFAULT_MIN_MS)),
230
+ language=SnakeDebugLanguage.coerce(raw_settings.get("LANG")),
231
+ )
232
+
233
+
234
+ def django_session(
235
+ alias: str = "default",
236
+ *,
237
+ databases: Mapping[str, SnakeOrmDatabase] | None = None,
238
+ ) -> SnakeSession:
239
+ """Open a session reading Django's `DATABASES` (the `alias`, `"default"` by default).
240
+
241
+ A shortcut over `config_from_django(...).open(alias)`. `databases` is injected in the tests (the
242
+ project pattern) so global Django does not have to be configured; in a real app it falls back to
243
+ `settings.DATABASES`.
244
+ """
245
+ return config_from_django(databases=databases).open(alias)
@@ -0,0 +1,44 @@
1
+ """The sidecar buffer: recent reports by token, served at `/__snake__/{token}`.
2
+
3
+ A bounded ring buffer over `OrderedDict`: in a long-lived process it does not grow without limit (the footgun of Django's global `connection.queries`).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import secrets
9
+ import threading
10
+ from collections import OrderedDict
11
+
12
+ from snakeorm.debug import DebugReport
13
+
14
+
15
+ def new_token() -> str:
16
+ """A short, unguessable token to reference a report from the response."""
17
+ return secrets.token_urlsafe(9)
18
+
19
+
20
+ class SidecarBuffer:
21
+ """Keep the last N reports by token, evicting the oldest one when it fills up.
22
+
23
+ With a lock: a WSGI server is multithreaded and `store()` does three non-atomic operations together on the `OrderedDict`.
24
+ """
25
+
26
+ __slots__ = ("_store", "_capacity", "_lock")
27
+
28
+ def __init__(self, capacity: int = 50) -> None:
29
+ self._store: OrderedDict[str, DebugReport] = OrderedDict()
30
+ self._capacity = capacity
31
+ self._lock = threading.Lock()
32
+
33
+ def store(self, token: str, report: DebugReport) -> None:
34
+ """Store a report under its token, evicting the oldest one if the capacity is exceeded."""
35
+ with self._lock:
36
+ self._store[token] = report
37
+ self._store.move_to_end(token)
38
+ while len(self._store) > self._capacity:
39
+ self._store.popitem(last=False)
40
+
41
+ def get(self, token: str) -> DebugReport | None:
42
+ """The report of that token, or `None` if it is gone (expired) or never existed."""
43
+ with self._lock:
44
+ return self._store.get(token)
@@ -0,0 +1,176 @@
1
+ """WSGI adapter (Flask and any WSGI app): pure middleware that branches by `Content-Type` (the panel in HTML, `snakeorm` in JSON).
2
+
3
+ It does not import Flask; it also serves the sidecar at `/__snake__/{token}`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Callable, Iterable
9
+ from datetime import UTC, datetime
10
+ from time import perf_counter
11
+
12
+ from snakeorm.contrib.deliver import (
13
+ allowed_channels,
14
+ resolve_production,
15
+ index_advice,
16
+ plan_delivery,
17
+ serve_sidecar,
18
+ transform_body,
19
+ )
20
+ from snakeorm.contrib.sidecar import SidecarBuffer, new_token
21
+ from snakeorm.debug import (
22
+ SnakeDebugChannel,
23
+ SnakeDebugConfig,
24
+ SnakeWebFramework,
25
+ capture_queries,
26
+ channels_from_env,
27
+ export_report,
28
+ RequestInfo,
29
+ warn_unimplemented,
30
+ warn_unsupported,
31
+ )
32
+
33
+ StartResponse = Callable[[str, list[tuple[str, str]]], object]
34
+ WSGIApp = Callable[[dict[str, str], StartResponse], Iterable[bytes]]
35
+
36
+ _SIDECAR_PREFIX = "/__snake__/"
37
+
38
+ # WSGI writes the status as a LINE, so the two the sidecar can answer are spelled here.
39
+ _STATUS_LINE = {200: "200 OK", 404: "404 Not Found"}
40
+
41
+
42
+ class SnakeDebugWSGI:
43
+ """WSGI middleware that captures the SQL and delivers the debug per `SNAKE_ORM_DEBUG`."""
44
+
45
+ def __init__(
46
+ self,
47
+ app: WSGIApp,
48
+ *,
49
+ channels: frozenset[SnakeDebugChannel] | None = None,
50
+ production: bool | None = None,
51
+ buffer: SidecarBuffer | None = None,
52
+ config: SnakeDebugConfig | None = None,
53
+ ) -> None:
54
+ asked = channels_from_env() if channels is None else channels
55
+ self._app = app
56
+ self._config = config or SnakeDebugConfig.from_env()
57
+ # The config is resolved BEFORE the channels because it is one of the two places the
58
+ # environment can be declared, and `resolve_production` refuses rather than guessing when
59
+ # neither says anything and a risky channel is on.
60
+ self._channels = allowed_channels(
61
+ asked, production=resolve_production(production, self._config, asked)
62
+ )
63
+ self._buffer = buffer or SidecarBuffer()
64
+ warn_unsupported(self._channels, SnakeWebFramework.FLASK)
65
+ warn_unimplemented(self._channels)
66
+
67
+ def __call__(
68
+ self, environ: dict[str, str], start_response: StartResponse
69
+ ) -> Iterable[bytes]:
70
+ """WSGI entry point: it serves the sidecar, or captures and delivers the response's debug."""
71
+ path = environ.get("PATH_INFO", "")
72
+ if (
73
+ path.startswith(_SIDECAR_PREFIX)
74
+ and SnakeDebugChannel.SIDECAR in self._channels
75
+ ):
76
+ return self._serve_sidecar(path, environ, start_response)
77
+ if not self._channels:
78
+ return self._app(environ, start_response)
79
+ return self._capture_and_deliver(environ, start_response)
80
+
81
+ def _serve_sidecar(
82
+ self, path: str, environ: dict[str, str], start_response: StartResponse
83
+ ) -> Iterable[bytes]:
84
+ """Serve `/__snake__/{token}`: the panel page, or the report as JSON if `Accept` asks.
85
+
86
+ `HTTP_ACCEPT` is where a WSGI environ keeps the header; WHAT to answer with it is decided in
87
+ `serve_sidecar`, shared with the other two adapters.
88
+ """
89
+ page = serve_sidecar(
90
+ self._buffer.get(path[len(_SIDECAR_PREFIX) :]),
91
+ accept=environ.get("HTTP_ACCEPT", ""),
92
+ language=self._config.language,
93
+ )
94
+ start_response(
95
+ _STATUS_LINE[page.status],
96
+ [
97
+ ("Content-Type", page.content_type),
98
+ ("Content-Length", str(len(page.body))),
99
+ ],
100
+ )
101
+ return [page.body]
102
+
103
+ def _capture_and_deliver(
104
+ self, environ: dict[str, str], start_response: StartResponse
105
+ ) -> Iterable[bytes]:
106
+ """Wrap the app, buffer its response and deliver it with the debug added."""
107
+ captured_status = "200 OK"
108
+ captured_headers: list[tuple[str, str]] = []
109
+
110
+ def capture_start(status: str, headers: list[tuple[str, str]]) -> object:
111
+ nonlocal captured_status, captured_headers
112
+ captured_status = status
113
+ captured_headers = headers
114
+ return None
115
+
116
+ # Wall clock of the WHOLE request (app + DB + template): out of here comes the panel's
117
+ # "en app" (wall - DB). It is measured around the app, not around the panel injection
118
+ # (which is not part of the request).
119
+ start = perf_counter()
120
+ at = datetime.now(UTC) # the instant, taken where the wall clock starts
121
+ with capture_queries() as collector:
122
+ body = b"".join(self._app(environ, capture_start))
123
+ report = collector.report().with_wall_ms((perf_counter() - start) * 1000)
124
+ report = report.with_index_hints(index_advice(report, self._config))
125
+ report = report.with_request(
126
+ RequestInfo(
127
+ method=environ.get("REQUEST_METHOD", ""),
128
+ path=environ.get("PATH_INFO", ""),
129
+ status=_status_code(captured_status),
130
+ at=at,
131
+ )
132
+ )
133
+
134
+ token = None
135
+ if SnakeDebugChannel.SIDECAR in self._channels:
136
+ token = new_token()
137
+ self._buffer.store(token, report)
138
+ # The `otel` channel: OUT of `plan_delivery`, which is a pure function that answers
139
+ # `(headers, envelope)` — two things that change the response, which a network send is not.
140
+ # Shared the same way `index_advice` is.
141
+ export_report(report, self._channels)
142
+ delivery = plan_delivery(report, self._channels, token=token)
143
+
144
+ content_type = next(
145
+ (value for key, value in captured_headers if key.lower() == "content-type"),
146
+ "",
147
+ )
148
+ new_body = transform_body(
149
+ body,
150
+ content_type,
151
+ delivery,
152
+ report,
153
+ self._channels,
154
+ self._config.language,
155
+ # WSGI has no per-request seam for this: `environ` carries no nonce convention, so the
156
+ # config's is the only one. It is FIXED for the process; a strict CSP wants one per
157
+ # response, which here means the app must repeat it in its own header.
158
+ self._config.csp_nonce,
159
+ token=token,
160
+ )
161
+ headers = [
162
+ (key, value)
163
+ for key, value in captured_headers
164
+ if key.lower() != "content-length"
165
+ ]
166
+ headers.extend(delivery.headers)
167
+ headers.append(("Content-Length", str(len(new_body))))
168
+
169
+ start_response(captured_status, headers)
170
+ return [new_body]
171
+
172
+
173
+ def _status_code(status: str) -> int:
174
+ """The number out of a WSGI status line (`"404 Not Found"` -> `404`); 0 if it is not one."""
175
+ code, _, _ = status.partition(" ")
176
+ return int(code) if code.isdigit() else 0
@@ -0,0 +1 @@
1
+ """The ORM's core: the cross-cutting identity (base model, exceptions, signals, config)."""
@@ -0,0 +1,173 @@
1
+ """The one place that turns the environment (`.env` included) into a psycopg2 DSN; shared by CLI
2
+ and tests.
3
+
4
+ `find_dotenv(usecwd=True)`: a bare `load_dotenv()` searches from the calling file, which with the
5
+ package installed would point at `site-packages`; anchoring it to the cwd is what the user expects.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+
13
+ from dotenv import find_dotenv, load_dotenv
14
+
15
+ from snakeorm.core.exceptions import SnakeConfigError
16
+
17
+ # Each piece of the DSN: (environment variable, key in the DSN, devcontainer default).
18
+ # The order fixes the order of the emitted DSN; it is the ONLY source of the names and defaults.
19
+ _PARTS: tuple[tuple[str, str, str], ...] = (
20
+ ("DB_HOST", "host", "127.0.0.1"),
21
+ ("DB_PORT", "port", "5432"),
22
+ ("DB_USER", "user", "postgres"),
23
+ ("DB_PASSWORD", "password", "snakeorm_pass"),
24
+ ("DB_NAME", "dbname", "snakeorm_db"),
25
+ )
26
+
27
+ # Environment variables that describe the connection (derived from _PARTS, no duplication).
28
+ DB_ENV_KEYS: tuple[str, ...] = tuple(env_key for env_key, _, _ in _PARTS)
29
+
30
+
31
+ _SCHEME = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*)://")
32
+ """The RFC 3986 shape of a scheme, and a regex rather than `split("://", 1)`.
33
+
34
+ A password containing `://` would otherwise be read as the scheme — the same failure this guard is
35
+ about, arriving through a value nobody thinks of as configuration."""
36
+
37
+
38
+ def load_env() -> None:
39
+ """Loads the cwd's `.env` (or an ancestor's), without stomping on variables already present
40
+ (the environment wins)."""
41
+ path = find_dotenv(
42
+ usecwd=True
43
+ ) # empty string if there is no .env in the cwd's tree
44
+ if path:
45
+ load_dotenv(path)
46
+
47
+
48
+ def dsn_from_env(*, connect_timeout: int | None = None) -> str:
49
+ """Builds the psycopg2 DSN from `.env`/environment, with the devcontainer defaults.
50
+
51
+ `connect_timeout` adds the parameter of the same name when given (the tests lower it so they do
52
+ not hang).
53
+ """
54
+ load_env()
55
+ parts = [
56
+ f"{dsn_key}={os.environ.get(env_key, default)}"
57
+ for env_key, dsn_key, default in _PARTS
58
+ ]
59
+ if connect_timeout is not None:
60
+ parts.append(f"connect_timeout={connect_timeout}")
61
+ return " ".join(parts)
62
+
63
+
64
+ # -- NAMED connections (multi-DB) ------------------------------------------------------------
65
+ # Here we only resolve WHICH DSN belongs to each name; `snake_session(...)` assembles the driver.
66
+ # `default` resolves as always (a single-DB project changes nothing); the rest through
67
+ # `SNAKEORM_DSN_<NAME>` in uppercase.
68
+
69
+ DEFAULT_DATABASE = "default"
70
+
71
+
72
+ def _env_key(database: str) -> str:
73
+ """The environment variable that declares a named connection's DSN."""
74
+ return f"SNAKEORM_DSN_{database.upper()}"
75
+
76
+
77
+ def dsn_for(database: str = DEFAULT_DATABASE) -> str:
78
+ """DSN of a connection BY NAME.
79
+
80
+ `default` falls into the usual resolution; any other name is looked up in
81
+ `SNAKEORM_DSN_<NAME>`, and if it is missing we say EXACTLY which one: resolving blindly ends up
82
+ connecting to the wrong DB.
83
+ """
84
+ if database == DEFAULT_DATABASE:
85
+ return dsn_from_env()
86
+ load_env()
87
+ value = os.environ.get(_env_key(database))
88
+ if not value:
89
+ raise SnakeConfigError(
90
+ f"There is no DSN for connection '{database}': set the environment variable "
91
+ f"{_env_key(database)} (or put it in the .env). Connection '{DEFAULT_DATABASE}' is the "
92
+ f"only one resolved from the DB_* pieces."
93
+ )
94
+ return value
95
+
96
+
97
+ _BACKEND_DEFAULT_KEY = "DB_BACKEND"
98
+ """The engine of the DEFAULT connection, beside the `DB_*` pieces that build its DSN.
99
+
100
+ The name is not invented here: `frameworks/shared/config.py` already reads it, because the demos
101
+ needed to pick an engine and the ORM offered nowhere to say so. That is the asymmetry this closes —
102
+ the convention existed, it just lived in the showcase instead of the library.
103
+ """
104
+
105
+ _BACKEND_NAMES: dict[str, str] = {
106
+ # DSN scheme -> engine. It is a DECLARATION, not a guess: whoever writes `mysql://` said which
107
+ # engine they meant, and reading it is not divination.
108
+ "postgresql": "postgres",
109
+ "postgres": "postgres",
110
+ "mysql": "mysql",
111
+ "mariadb": "mysql",
112
+ "sqlite": "sqlite",
113
+ }
114
+
115
+ _KNOWN_BACKENDS = ("postgres", "mysql", "sqlite")
116
+
117
+
118
+ def _backend_key(database: str) -> str:
119
+ """The environment variable that declares a named connection's ENGINE."""
120
+ return f"SNAKEORM_BACKEND_{database.upper()}"
121
+
122
+
123
+ def backend_name_for(database: str = DEFAULT_DATABASE) -> str:
124
+ """The engine of a connection BY NAME, as a plain string. It is READ, never guessed.
125
+
126
+ 1. `SNAKEORM_BACKEND_<NAME>` — or `DB_BACKEND` for the default connection — which always wins:
127
+ saying it out loud beats inferring it.
128
+ 2. The DSN's own scheme (`postgresql://`, `mysql://`, `sqlite://`).
129
+ 3. Postgres, when the DSN carries NO scheme. That is a derivation and not a fallback: a DSN
130
+ shaped `host=x dbname=y` is libpq keyword syntax, which no other engine speaks.
131
+
132
+ A value that is not one of the three is REFUSED naming what it read. The demos already paid for
133
+ the alternative and wrote it down: a `postgress` with one extra `s` brought the app up on SQLite,
134
+ talking to the wrong database without a word.
135
+
136
+ It returns a `str` and not a `SnakeBackend` on purpose: `connection.py` — where that enum lives —
137
+ sits ABOVE this module and pulls in every driver and dialect. Importing it from here would close
138
+ a circle between the layers, and the acyclicity net says so.
139
+ """
140
+ load_env()
141
+ key = (
142
+ _BACKEND_DEFAULT_KEY if database == DEFAULT_DATABASE else _backend_key(database)
143
+ )
144
+ declared = os.environ.get(key, "").strip().lower()
145
+ if declared:
146
+ if declared not in _KNOWN_BACKENDS:
147
+ raise SnakeConfigError(
148
+ f"{key}='{declared}' is not a known engine. The three are: "
149
+ f"{', '.join(_KNOWN_BACKENDS)}. It is refused instead of falling back, because "
150
+ f"falling back means talking to another database without saying so."
151
+ )
152
+ return declared
153
+ dsn = dsn_for(database)
154
+ match = _SCHEME.match(dsn)
155
+ if match is None:
156
+ # NO scheme is the libpq keyword/value form (`host=x dbname=y`), which is Postgres and is
157
+ # not an error. This is the case the fallback existed for.
158
+ return "postgres"
159
+ scheme = match.group(1).lower()
160
+ if scheme not in _BACKEND_NAMES:
161
+ # And an UNKNOWN scheme is refused, for the reason written fifteen lines above about
162
+ # `DB_BACKEND`: falling back means talking to another database without saying so. The old
163
+ # `.get(scheme, "postgres")` could not tell the two cases apart, so `sqlite3://` —the name
164
+ # of the Python module, and an alias `contrib/config.py` accepts elsewhere— came out as
165
+ # Postgres. Same class of typo, two opposite treatments, in the one function that decides
166
+ # which engine you are talking to.
167
+ raise SnakeConfigError(
168
+ f"The DSN of '{database}' starts with '{scheme}://', which is not an engine this ORM "
169
+ f"knows. The schemes it reads are: {', '.join(sorted(_BACKEND_NAMES))}. A DSN with no "
170
+ f"scheme at all is the libpq keyword/value form and resolves to postgres; a scheme it "
171
+ f"does not recognise is refused rather than guessed at."
172
+ )
173
+ return _BACKEND_NAMES[scheme]