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,76 @@
1
+ """The Protocol that defines a driver: how the SQL is EXECUTED (synchronous)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator, Sequence
6
+ from typing import Protocol, runtime_checkable
7
+
8
+
9
+ @runtime_checkable
10
+ class SnakeDriver(Protocol):
11
+ """How the SQL is executed: wraps the DBAPI (connection, cursor, transaction).
12
+
13
+ SYNCHRONOUS. The SQL arrives already compiled (colourless) from `sql/`; the driver only
14
+ runs it. The day async is needed, an AsyncDriver is added WITHOUT touching the compiler,
15
+ the dialect or the AST. Adding a new engine = one implementation of this Protocol, with
16
+ no refactor.
17
+ """
18
+
19
+ def fetch_all(self, sql: str, params: Sequence[object]) -> list[tuple[object, ...]]:
20
+ """Runs a query and returns every row (for SELECT / RETURNING)."""
21
+ ...
22
+
23
+ def fetch_iter(
24
+ self, sql: str, params: Sequence[object], *, chunk: int = 1000
25
+ ) -> Iterator[tuple[object, ...]]:
26
+ """Runs a query and yields the rows WITHOUT materialising them all.
27
+
28
+ This is the streaming seam, and that is why it lives in the Protocol and not in some
29
+ loose method on one driver: with only `fetch_all`, a ten-million-row query built a
30
+ Python list of ten million tuples before returning the first one, and there was no way
31
+ to fix that without touching this contract.
32
+
33
+ `chunk` is how many rows the engine brings back per round trip: the knob that decides
34
+ the memory. Whoever can, uses a SERVER-SIDE CURSOR (the result stays over there);
35
+ whoever cannot, a `fetchmany`, which at least bounds the peak.
36
+
37
+ The cursor lives for as long as the iteration does, so the consumer must exhaust it or
38
+ close it.
39
+ """
40
+ ...
41
+
42
+ def execute(self, sql: str, params: Sequence[object]) -> int:
43
+ """Runs a statement fetching no rows and returns the rowcount (used by bulk writes)."""
44
+ ...
45
+
46
+ @property
47
+ def last_insert_id(self) -> int:
48
+ """The autoincrement id of the last INSERT. The session only uses it on engines WITHOUT
49
+ `RETURNING` (MySQL); on Postgres/SQLite the PK comes back through RETURNING and this is
50
+ irrelevant (it may be 0).
51
+ """
52
+ ...
53
+
54
+ def commit(self) -> None:
55
+ """Commits the transaction in progress."""
56
+ ...
57
+
58
+ def rollback(self) -> None:
59
+ """Rolls back the transaction in progress."""
60
+ ...
61
+
62
+ def savepoint(self, name: str) -> None:
63
+ """Marks a SAVEPOINT: lets you roll back ONLY a part (via `rollback_to_savepoint`) without aborting the transaction."""
64
+ ...
65
+
66
+ def release_savepoint(self, name: str) -> None:
67
+ """Releases (RELEASE) a savepoint: its work is folded into the transaction in progress."""
68
+ ...
69
+
70
+ def rollback_to_savepoint(self, name: str) -> None:
71
+ """Rolls back to a savepoint: discards what was done since it, without aborting the transaction."""
72
+ ...
73
+
74
+ def close(self) -> None:
75
+ """Closes the connection."""
76
+ ...
@@ -0,0 +1,181 @@
1
+ """Turning a driver's exception into the ORM's, by CODE and never by message.
2
+
3
+ A violated constraint came back as `psycopg2.errors.UniqueViolation`, `pymysql.err.IntegrityError`
4
+ and `sqlite3.IntegrityError`: three types for one condition, so the `except` that handled it was the
5
+ one part of an application this ORM could not carry between engines.
6
+
7
+ WHY BY CODE. Reading the message is how a detector fails open — it finds only what somebody already
8
+ thought to look for. It is also not needed: all three engines say precisely which constraint broke,
9
+ each in its own place.
10
+
11
+ postgres SQLSTATE 23505 / 23503 / 23502 / 23514
12
+ mysql errno 1062 / 1452 / 1048 / 4025
13
+ (its SQLSTATE is 23000 for all four and says nothing)
14
+ sqlite sqlite_errorname SQLITE_CONSTRAINT_UNIQUE / _FOREIGNKEY / _NOTNULL / _CHECK
15
+ (Python 3.11+, which is this project's declared floor)
16
+
17
+ AND NEVER BY THE DRIVER'S CLASS, which is the trap this module exists to keep shut: on MySQL a CHECK
18
+ violation arrives as `OperationalError` while the other three arrive as `IntegrityError`. Keyed on
19
+ the class, that one lands in the wrong bucket on one engine only.
20
+
21
+ One function for the three engines, the same way `session/retry.py::is_transient` reads them: it
22
+ looks for each spelling in turn and does not need to know who raised.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import functools
28
+ from collections.abc import Awaitable, Callable, Iterator
29
+ from contextlib import contextmanager
30
+ from typing import ParamSpec, TypeVar, cast
31
+
32
+ from snakeorm.core.exceptions import (
33
+ SnakeCheckViolation,
34
+ SnakeForeignKeyViolation,
35
+ SnakeIntegrityError,
36
+ SnakeNotNullViolation,
37
+ SnakeUniqueViolation,
38
+ )
39
+
40
+ P = ParamSpec("P")
41
+ T = TypeVar("T")
42
+ AsyncMethod = TypeVar("AsyncMethod", bound=Callable[..., Awaitable[object]])
43
+ """An asynchronous driver method, PRESERVED. Bound and not `ParamSpec` + return, because what
44
+ has to survive the decorator is the exact signature the Protocol declares, `CoroutineType`
45
+ included."""
46
+
47
+ _BY_SQLSTATE: dict[str, type[SnakeIntegrityError]] = {
48
+ "23505": SnakeUniqueViolation,
49
+ "23503": SnakeForeignKeyViolation,
50
+ "23502": SnakeNotNullViolation,
51
+ "23514": SnakeCheckViolation,
52
+ }
53
+ """PostgreSQL, which is the only one whose SQLSTATE distinguishes the four."""
54
+
55
+ _BY_MYSQL_ERRNO: dict[int, type[SnakeIntegrityError]] = {
56
+ 1062: SnakeUniqueViolation, # ER_DUP_ENTRY
57
+ 1586: SnakeUniqueViolation, # ER_DUP_ENTRY_WITH_KEY_NAME
58
+ 1452: SnakeForeignKeyViolation, # ER_NO_REFERENCED_ROW_2
59
+ 1451: SnakeForeignKeyViolation, # ER_ROW_IS_REFERENCED_2 (the delete side)
60
+ 1048: SnakeNotNullViolation, # ER_BAD_NULL_ERROR
61
+ 3819: SnakeCheckViolation, # ER_CHECK_CONSTRAINT_VIOLATED (MySQL 8)
62
+ 4025: SnakeCheckViolation, # ER_CONSTRAINT_FAILED (MariaDB)
63
+ }
64
+ """MySQL and MariaDB. Two errnos per family where the engines disagree, and both are listed rather
65
+ than one guessed: MariaDB reports a CHECK as 4025 and MySQL 8 as 3819, and a foreign key breaks from
66
+ either side."""
67
+
68
+ _BY_SQLITE_NAME: dict[str, type[SnakeIntegrityError]] = {
69
+ "SQLITE_CONSTRAINT_UNIQUE": SnakeUniqueViolation,
70
+ "SQLITE_CONSTRAINT_PRIMARYKEY": SnakeUniqueViolation,
71
+ "SQLITE_CONSTRAINT_FOREIGNKEY": SnakeForeignKeyViolation,
72
+ "SQLITE_CONSTRAINT_NOTNULL": SnakeNotNullViolation,
73
+ "SQLITE_CONSTRAINT_CHECK": SnakeCheckViolation,
74
+ }
75
+ """SQLite's EXTENDED result codes. `PRIMARYKEY` is its own name for what the other two call a unique
76
+ violation, so it maps to the same exception rather than falling through to the generic family."""
77
+
78
+
79
+ _SAYS: dict[type[SnakeIntegrityError], str] = {
80
+ SnakeUniqueViolation: "a UNIQUE constraint refused the write: that value is already there",
81
+ SnakeForeignKeyViolation: (
82
+ "a FOREIGN KEY refused the write: the row it points at is not there, or it is still "
83
+ "pointed at"
84
+ ),
85
+ SnakeNotNullViolation: "a NOT NULL column was given nothing",
86
+ SnakeCheckViolation: "a CHECK constraint refused the write",
87
+ }
88
+ """What the ORM says, in its OWN words, before quoting the engine.
89
+
90
+ Passing the driver's text through unchanged was half a translation: the exception was portable and
91
+ the sentence it carried still was not, so anything reading the message was back to three engines.
92
+ In this ORM the message is the product.
93
+
94
+ The engine's own words follow, because they carry what these cannot — WHICH constraint, and the
95
+ value that collided. Two texts for one fact, in the right order: what happened, then who said so.
96
+ """
97
+
98
+
99
+ def translate(error: BaseException) -> SnakeIntegrityError | None:
100
+ """The ORM exception this driver error corresponds to, or `None` if it is not a constraint.
101
+
102
+ `None` is the answer for everything else — a syntax error, a dropped connection, a timeout — and
103
+ those keep flying untouched. Wrapping what is not understood would put an ORM name on a problem
104
+ the ORM did not diagnose, which is worse than the raw exception it replaced.
105
+ """
106
+ for attribute in ("sqlstate", "pgcode"):
107
+ sqlstate = getattr(error, attribute, None)
108
+ if isinstance(sqlstate, str) and sqlstate in _BY_SQLSTATE:
109
+ return _built(_BY_SQLSTATE[sqlstate], error, sqlstate)
110
+ name = getattr(error, "sqlite_errorname", None)
111
+ if isinstance(name, str) and name in _BY_SQLITE_NAME:
112
+ return _built(_BY_SQLITE_NAME[name], error, name)
113
+ errno = error.args[0] if error.args else None
114
+ if isinstance(errno, int) and errno in _BY_MYSQL_ERRNO:
115
+ return _built(_BY_MYSQL_ERRNO[errno], error, errno)
116
+ return None
117
+
118
+
119
+ def _built(
120
+ kind: type[SnakeIntegrityError], error: BaseException, code: str | int
121
+ ) -> SnakeIntegrityError:
122
+ """The exception, its message and what it was built from, in one place."""
123
+ return kind(
124
+ f"{_SAYS[kind]}. The engine said: {error}", driver_error=error, code=code
125
+ )
126
+
127
+
128
+ @contextmanager
129
+ def translating_failures() -> Iterator[None]:
130
+ """Runs a driver call and re-raises a constraint failure as the ORM's own, CHAINED.
131
+
132
+ `from error` is the whole difference between wrapping and hiding: the original stays in
133
+ `__cause__`, the traceback prints both, and whoever debugs still gets the server's own words
134
+ about what it refused.
135
+ """
136
+ try:
137
+ yield
138
+ except Exception as error:
139
+ translated = translate(error)
140
+ if translated is None:
141
+ raise
142
+ raise translated from error
143
+
144
+
145
+ def translating(method: Callable[P, T]) -> Callable[P, T]:
146
+ """Applies the translation to one synchronous driver method.
147
+
148
+ A decorator and not a `with` inside each body, because it is the same line twenty times and a
149
+ decorator is the version somebody can see is MISSING from a method.
150
+
151
+ Not for `fetch_iter`: it is a generator, so this wrapper would return it without running a
152
+ statement and the exception would escape untranslated during iteration. It is also not needed —
153
+ it walks a SELECT, and a SELECT breaks no constraint.
154
+ """
155
+
156
+ @functools.wraps(method)
157
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
158
+ with translating_failures():
159
+ return method(*args, **kwargs)
160
+
161
+ return wrapper
162
+
163
+
164
+ def async_translating(method: AsyncMethod) -> AsyncMethod:
165
+ """The same for an asynchronous driver method. The colour changes; the classification does not.
166
+
167
+ It gives BACK the type it was handed, instead of describing the wrapper: a decorator that
168
+ translates exceptions must not change what the method IS. Spelling the return as
169
+ `Callable[P, Coroutine[object, object, T]]` looked equivalent and was not — an `async def`
170
+ declared in a Protocol is a `CoroutineType`, which a plain `Coroutine` does not satisfy, so
171
+ every driver wrapped by this stopped being an `AsyncDriver` for pyright while mypy said
172
+ nothing. The one function that hands the pair out (`connection.py::driver_and_dialect`) was
173
+ the visible casualty.
174
+ """
175
+
176
+ @functools.wraps(method)
177
+ async def wrapper(*args: object, **kwargs: object) -> object:
178
+ with translating_failures():
179
+ return await method(*args, **kwargs)
180
+
181
+ return cast("AsyncMethod", wrapper)
@@ -0,0 +1,167 @@
1
+ """A driver that LOGS the SQL passing through it, by wrapping another one."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterator, Sequence
6
+ from contextlib import contextmanager
7
+
8
+ from snakeorm.drivers.base import SnakeDriver
9
+
10
+ # Where each line gets written. By default it writes nothing (leave it wired up without making a mess); inject `print`/a logger.
11
+ Writer = Callable[[str], None]
12
+
13
+
14
+ def _silent(line: str) -> None:
15
+ """Throws the line away (the default behaviour)."""
16
+
17
+
18
+ def render_params(params: Sequence[object], keys: frozenset[str]) -> str:
19
+ """The named parameters, the rest hidden, and the count always.
20
+
21
+ The guide wires these drivers with `write=print`, whose output is the container's stdout and
22
+ from there the log aggregator. So `params=('ana@x.com', '$2b$12$...')` was an email and a
23
+ password hash sitting in a searchable index, with no flag to turn it off.
24
+
25
+ Written ONCE for both colours on purpose: the asynchronous driver went on printing raw
26
+ values after the synchronous one stopped, which is the drift this seam keeps suffering.
27
+ """
28
+ if not keys:
29
+ return f"<{len(params)} hidden>"
30
+ shown = [
31
+ repr(value) if str(index) in keys else "<hidden>"
32
+ for index, value in enumerate(params)
33
+ ]
34
+ return f"({', '.join(shown)})"
35
+
36
+
37
+ @contextmanager
38
+ def _logged(write: Writer, head: str) -> Iterator[list[str]]:
39
+ """Writes ONE line for the statement, whether it worked or not.
40
+
41
+ Every write point used to sit AFTER the inner call, so a statement that raised left no line
42
+ at all: you opened the log at 3:14, found the ten statements before it and a ROLLBACK —that
43
+ one wrote, because `rollback()` follows a call that does not fail— and no trace of what
44
+ caused the rollback. The log showed you everything except the thing you came for.
45
+
46
+ And the exception does not carry it either: `drivers/failures.py` builds its message from
47
+ the engine's words, with no SQL and no params in it. The log WAS the only place the failed
48
+ statement existed.
49
+
50
+ The caller appends its outcome to the yielded list; if it never gets there, the failure is
51
+ written instead. `except BaseException` and re-raise: a timeout or a cancellation is exactly
52
+ the kind of ending worth having in the log.
53
+
54
+ `fetch_iter` does NOT use this, deliberately — see its own docstring.
55
+ """
56
+ outcome: list[str] = []
57
+ try:
58
+ yield outcome
59
+ except BaseException as error:
60
+ write(f"{head} -> FAILED: {type(error).__name__}: {error}")
61
+ raise
62
+ # A boundary counts no rows: `COMMIT` stays `COMMIT`, byte for byte.
63
+ write(f"{head} -> {outcome[0]}" if outcome else head)
64
+
65
+
66
+ class LoggingDriver:
67
+ """Wraps a `SnakeDriver` and logs SQL, params and how much it affected. A pure decorator: it changes nothing.
68
+ It also logs the transaction boundaries (commit/rollback/savepoints); without them the log lies by omission.
69
+ """
70
+
71
+ __slots__ = ("_inner", "_parameter_keys", "_write")
72
+
73
+ def __init__(
74
+ self,
75
+ inner: SnakeDriver,
76
+ write: Writer = _silent,
77
+ *,
78
+ parameter_keys: frozenset[str] = frozenset(),
79
+ ) -> None:
80
+ """`parameter_keys` names the parameter positions to WRITE OUT, and there is no environment
81
+ variable for it.
82
+
83
+ That omission is the decision, and it is the same one `debug/otel/exporter.py` already made
84
+ for the same data: an environment variable is precisely the switch somebody flips in
85
+ production by accident, and this one would put user values into the log aggregator. It takes
86
+ an explicit line of code, key by key — the key of a positional parameter is its 0-based
87
+ index, as the OpenTelemetry convention spells it.
88
+
89
+ By default the values are hidden and the COUNT is written instead. The count leaks nothing
90
+ and it is half of what makes a log line readable.
91
+ """
92
+ self._inner = inner
93
+ self._write = write
94
+ self._parameter_keys = parameter_keys
95
+
96
+ def _params(self, params: Sequence[object]) -> str:
97
+ """The parameters as this driver writes them. Delegates so both colours render alike."""
98
+ return render_params(params, self._parameter_keys)
99
+
100
+ def fetch_all(self, sql: str, params: Sequence[object]) -> list[tuple[object, ...]]:
101
+ """Runs the query, logs the SQL and how many rows it returned."""
102
+ with _logged(self._write, f"{sql} -- params={self._params(params)}") as outcome:
103
+ rows = self._inner.fetch_all(sql, params)
104
+ outcome.append(f"{len(rows)} row(s)")
105
+ return rows
106
+
107
+ def fetch_iter(
108
+ self, sql: str, params: Sequence[object], *, chunk: int = 1000
109
+ ) -> Iterator[tuple[object, ...]]:
110
+ """Logs the SQL and yields the rows, counting them WHEN IT IS DONE.
111
+
112
+ The counter goes at the end and not at the start on purpose: writing "-> N rows" before
113
+ walking them would require materialising them, which is exactly what this path avoids.
114
+ What actually got consumed is what gets logged, and that is also the interesting figure
115
+ when somebody cuts out with a `break`.
116
+ """
117
+ counter = 0
118
+ try:
119
+ for row in self._inner.fetch_iter(sql, params, chunk=chunk):
120
+ counter += 1
121
+ yield row
122
+ finally:
123
+ self._write(
124
+ f"{sql} -- params={self._params(params)} -> {counter} row(s) streamed"
125
+ )
126
+
127
+ def execute(self, sql: str, params: Sequence[object]) -> int:
128
+ """Runs the statement, logs the SQL and how many rows it affected."""
129
+ with _logged(self._write, f"{sql} -- params={self._params(params)}") as outcome:
130
+ affected = self._inner.execute(sql, params)
131
+ outcome.append(f"{affected} row(s)")
132
+ return affected
133
+
134
+ @property
135
+ def last_insert_id(self) -> int:
136
+ """The id of the last INSERT (see the Protocol). Forwarded to the wrapped driver."""
137
+ return self._inner.last_insert_id
138
+
139
+ def commit(self) -> None:
140
+ """Commits the transaction and writes it down."""
141
+ with _logged(self._write, "COMMIT"):
142
+ self._inner.commit()
143
+
144
+ def rollback(self) -> None:
145
+ """Rolls the transaction back and writes it down."""
146
+ with _logged(self._write, "ROLLBACK"):
147
+ self._inner.rollback()
148
+
149
+ def savepoint(self, name: str) -> None:
150
+ """Marks a savepoint and writes it down."""
151
+ with _logged(self._write, f"SAVEPOINT {name}"):
152
+ self._inner.savepoint(name)
153
+
154
+ def release_savepoint(self, name: str) -> None:
155
+ """Releases a savepoint and writes it down."""
156
+ with _logged(self._write, f"RELEASE SAVEPOINT {name}"):
157
+ self._inner.release_savepoint(name)
158
+
159
+ def rollback_to_savepoint(self, name: str) -> None:
160
+ """Rolls back to a savepoint and writes it down."""
161
+ with _logged(self._write, f"ROLLBACK TO SAVEPOINT {name}"):
162
+ self._inner.rollback_to_savepoint(name)
163
+
164
+ def close(self) -> None:
165
+ """Closes the INNER driver: the decorator does not keep the connection."""
166
+ with _logged(self._write, "CLOSE"):
167
+ self._inner.close()