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,352 @@
1
+ """Connection pool: lend one CONNECTION per session, not one per statement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterator, Sequence
6
+ from contextlib import contextmanager
7
+ from time import monotonic, sleep
8
+
9
+ from snakeorm.core.exceptions import SnakePoolTimeout
10
+ from snakeorm.drivers.base import SnakeDriver
11
+
12
+ Borrow = Callable[[], SnakeDriver]
13
+ GiveBack = Callable[[SnakeDriver], None]
14
+ CloseAll = Callable[[], None]
15
+ Discard = Callable[[SnakeDriver], None]
16
+ """How a connection is THROWN AWAY, as opposed to returned to the pool. `pre_ping` and `recycle` need it."""
17
+ Clock = Callable[[], float]
18
+ """Where the time comes from. Injectable so recycling can be tested without really waiting."""
19
+
20
+ _MAX_DISCARDS = 3
21
+ """Dead connections in a row before giving up. Three are not bad luck: it is the database, not the connection."""
22
+
23
+
24
+ class _PooledDriver:
25
+ """A lent driver: identical to the real one except that `close()` RETURNS it to the pool.
26
+ The connection is lent for the whole session, not per statement: a driver HOLDS a transaction
27
+ (if INSERT and COMMIT went through different connections, the transaction would be lost).
28
+ """
29
+
30
+ __slots__ = ("_inner", "_give_back", "_returned")
31
+
32
+ def __init__(self, inner: SnakeDriver, give_back: GiveBack) -> None:
33
+ self._inner = inner
34
+ self._give_back = give_back
35
+ self._returned = False
36
+
37
+ def fetch_all(self, sql: str, params: Sequence[object]) -> list[tuple[object, ...]]:
38
+ """Delegates the query to the lent connection."""
39
+ return self._inner.fetch_all(sql, params)
40
+
41
+ def fetch_iter(
42
+ self, sql: str, params: Sequence[object], *, chunk: int = 1000
43
+ ) -> Iterator[tuple[object, ...]]:
44
+ """Delegates the streaming to the lent connection.
45
+
46
+ The connection does NOT go back to the pool once the iteration runs out: it stays lent to
47
+ the session, like any other query. Returning it here would rip it away from a live
48
+ transaction.
49
+ """
50
+ return self._inner.fetch_iter(sql, params, chunk=chunk)
51
+
52
+ def execute(self, sql: str, params: Sequence[object]) -> int:
53
+ """Delegates the statement to the lent connection."""
54
+ return self._inner.execute(sql, params)
55
+
56
+ @property
57
+ def last_insert_id(self) -> int:
58
+ """The id of the last INSERT (see the Protocol). Forwarded to the wrapped driver."""
59
+ return self._inner.last_insert_id
60
+
61
+ def commit(self) -> None:
62
+ """Commits the lent connection's transaction."""
63
+ self._inner.commit()
64
+
65
+ def rollback(self) -> None:
66
+ """Rolls back the lent connection's transaction."""
67
+ self._inner.rollback()
68
+
69
+ def savepoint(self, name: str) -> None:
70
+ """Marks a savepoint on the lent connection."""
71
+ self._inner.savepoint(name)
72
+
73
+ def release_savepoint(self, name: str) -> None:
74
+ """Releases a savepoint on the lent connection."""
75
+ self._inner.release_savepoint(name)
76
+
77
+ def rollback_to_savepoint(self, name: str) -> None:
78
+ """Rolls back to a savepoint on the lent connection."""
79
+ self._inner.rollback_to_savepoint(name)
80
+
81
+ def close(self) -> None:
82
+ """RETURNS the connection to the pool instead of closing it, ROLLED BACK. Idempotent.
83
+
84
+ The rollback is the pool's contract and not a courtesy. Whatever the borrower left open —
85
+ an uncommitted write, a savepoint it never released — belongs to a unit of work that is
86
+ over, and the next borrower must not be able to see it, commit it by accident, or roll its
87
+ own work back into it. A savepoint is the sharpest case: the name still resolves, so
88
+ `rollback_to_savepoint` on a stranger's mark raises nothing and quietly undoes work.
89
+
90
+ It used to be `give_back` alone, and the promise held only because the one pool this project
91
+ ships is psycopg2's, whose `putconn` rolls back on its own. `SnakePool` takes `give_back`
92
+ from whoever builds it, so a pool written for SQLite or MySQL got no cleaning at all — the
93
+ guarantee was BORROWED from a library rather than kept by the seam.
94
+
95
+ Note this does not undo committed work: a commit ended its transaction, and the rollback
96
+ finds nothing to discard. `test_a_committed_loan_is_not_undone_by_the_cleanup` is the net
97
+ under that, because a cure that threw away commits would pass every other test here.
98
+ """
99
+ if self._returned:
100
+ return # returning it twice would hand it to two users at once
101
+ self._returned = True
102
+ try:
103
+ self._inner.rollback()
104
+ finally:
105
+ # The RETURN is not optional; the CLEAN-UP is what may fail. Without this `finally` a
106
+ # rollback that raised —a restarted database, a cut socket, a failover— skipped
107
+ # `give_back` entirely, and the idempotency guard above stopped any retry from putting
108
+ # it right: the backing pool went on counting that connection as lent, for ever. Ten of
109
+ # those leave a `maximum=10` pool permanently dry, with the process alive and the
110
+ # database recovered. It is the very failure `_MAX_DISCARDS` exists to prevent
111
+ # —"process alive, no errors and no progress"— coming in through the next door along.
112
+ self._give_back(self._inner)
113
+
114
+
115
+ class SnakePool:
116
+ """Hands out connections and takes them back. Engine-agnostic: it receives the three operations
117
+ and delegates the real pooling (`psycopg2.pool` today). Only the rule lives here: one connection
118
+ per session, returned when it is done.
119
+ """
120
+
121
+ __slots__ = (
122
+ "_borrow",
123
+ "_give_back",
124
+ "_close_all",
125
+ "_discard",
126
+ "_pre_ping",
127
+ "_recycle",
128
+ "_timeout",
129
+ "_retry_interval",
130
+ "_clock",
131
+ "_born",
132
+ )
133
+
134
+ def __init__(
135
+ self,
136
+ borrow: Borrow,
137
+ give_back: GiveBack,
138
+ close_all: CloseAll,
139
+ *,
140
+ discard: Discard | None = None,
141
+ pre_ping: bool = False,
142
+ recycle_seconds: float | None = None,
143
+ timeout_seconds: float | None = None,
144
+ retry_interval: float = 0.05,
145
+ clock: Clock = monotonic,
146
+ ) -> None:
147
+ self._borrow = borrow
148
+ self._give_back = give_back
149
+ self._close_all = close_all
150
+ # How a connection is THROWN AWAY (as opposed to returned). Without this, `pre_ping` and
151
+ # `recycle` would have nowhere to send the bad connection: returning it would put it back
152
+ # in the queue.
153
+ self._discard = discard
154
+ self._pre_ping = pre_ping
155
+ self._recycle = recycle_seconds
156
+ self._timeout = timeout_seconds
157
+ self._retry_interval = retry_interval
158
+ self._clock = clock
159
+ # When the pool first saw each connection, for `recycle`. The DRIVER lives in the value,
160
+ # and that is what makes the key mean anything: while the entry exists the object cannot
161
+ # die, so CPython cannot hand its address to the next connection. Keyed by `id()` alone, a
162
+ # brand-new connection inherits a dead one's birthday and gets thrown away for being too
163
+ # old, and `_born` is only ever cleaned in `_throw_away`, so the stale entry outlives
164
+ # everything.
165
+ #
166
+ # Not a `WeakKeyDictionary`, and it is measured: `LoggingDriver` and `TimeoutDriver`
167
+ # declare `__slots__` without `__weakref__`, so it raises `TypeError` on exactly the
168
+ # wrapping the production guide recommends.
169
+ #
170
+ # Nothing is recorded unless `recycle_seconds` is set: holding a reference to every
171
+ # connection the pool has ever seen would trade a bookkeeping bug for a socket leak.
172
+ self._born: dict[int, tuple[SnakeDriver, float]] = {}
173
+
174
+ def acquire(self) -> SnakeDriver:
175
+ """Lends a HEALTHY connection, wrapped: closing it sends it back to the pool.
176
+
177
+ With `timeout_seconds`, it retries until the deadline if the pool is drained instead of
178
+ giving up on the first attempt (which is what `psycopg2` does: it does not block, it
179
+ raises `PoolError` instantly). With `pre_ping` or `recycle_seconds`, it throws away
180
+ whatever is no good and keeps looking.
181
+ """
182
+ deadline = None if self._timeout is None else self._clock() + self._timeout
183
+ discarded = 0
184
+ while True:
185
+ try:
186
+ inner = self._borrow()
187
+ except Exception:
188
+ if deadline is None or self._clock() >= deadline:
189
+ if deadline is None:
190
+ raise
191
+ raise SnakePoolTimeout(
192
+ f"No connection came free in {self._timeout} s. The pool is "
193
+ f"exhausted for longer than you are willing to wait: either there is too "
194
+ f"much load or too little pool (or somebody is not giving their "
195
+ f"connections back)."
196
+ ) from None
197
+ sleep(self._retry_interval)
198
+ continue
199
+ if self._usable(inner):
200
+ if self._recycle is not None:
201
+ self._born.setdefault(id(inner), (inner, self._clock()))
202
+ return _PooledDriver(inner, self._give_back)
203
+ self._throw_away(inner)
204
+ discarded += 1
205
+ # A cap on the discarding. Without it, a downed database would turn this into an
206
+ # infinite loop throwing connections away and asking for another: the process alive,
207
+ # with no errors and making no progress, which is the worst possible failure. Three
208
+ # dead in a row are not bad luck: it is the DATABASE, not the connection, and the
209
+ # caller has to hear that.
210
+ if discarded >= _MAX_DISCARDS:
211
+ raise SnakePoolTimeout(
212
+ f"The last {discarded} connections in the pool were dead. This is not one "
213
+ f"connection being unlucky: the database is not answering (restart, failover, "
214
+ f"network?). The pool has already thrown them away; retry when it comes back."
215
+ )
216
+ if deadline is not None and self._clock() >= deadline:
217
+ raise SnakePoolTimeout(
218
+ f"No connection in the pool answered in {self._timeout} s."
219
+ )
220
+
221
+ def _usable(self, inner: SnakeDriver) -> bool:
222
+ """Is this connection any good? It checks the AGE first (free) and then the pulse (costs a round trip)."""
223
+ if self._recycle is not None:
224
+ recorded = self._born.get(id(inner))
225
+ # `recorded[0] is inner` is the whole guard: an entry left by a dead connection that
226
+ # happened to hold this address answers about somebody else, and the age it carries is
227
+ # not this connection's.
228
+ if (
229
+ recorded is not None
230
+ and recorded[0] is inner
231
+ and self._clock() - recorded[1] >= self._recycle
232
+ ):
233
+ return False
234
+ if not self._pre_ping:
235
+ return True
236
+ try:
237
+ inner.execute("SELECT 1", ())
238
+ except Exception:
239
+ # Any exception: the connection is broken and it does not matter why. Telling the
240
+ # reasons apart here would be putting one engine's jargon into the very piece that
241
+ # exists so as not to have it.
242
+ return False
243
+ return True
244
+
245
+ def _throw_away(self, inner: SnakeDriver) -> None:
246
+ """Takes the connection out of the pool for good. Without `discard`, it at least closes it."""
247
+ self._born.pop(id(inner), None)
248
+ if self._discard is not None:
249
+ self._discard(inner)
250
+ else:
251
+ inner.close()
252
+
253
+ @contextmanager
254
+ def connection(self) -> Iterator[SnakeDriver]:
255
+ """Lends a connection and ALWAYS returns it, even if the block blows up (or it drains the pool bit by bit)."""
256
+ driver = self.acquire()
257
+ try:
258
+ yield driver
259
+ except BaseException as error:
260
+ # The caller's exception WINS. Closing in a plain `finally` meant a clean-up failure
261
+ # came out instead of the business error that ended the block, and that second one is
262
+ # what somebody is trying to read at three in the morning. The clean-up still has to
263
+ # travel — losing it would hide a dying pool — so it rides as a note (PEP 678).
264
+ try:
265
+ driver.close()
266
+ except BaseException as cleanup: # noqa: BLE001 - it is re-attached, never swallowed
267
+ error.add_note(
268
+ f"and the pool failed to clean up on the way out: {cleanup!r}"
269
+ )
270
+ raise
271
+ else:
272
+ driver.close()
273
+
274
+ def close(self) -> None:
275
+ """Closes every connection in the pool."""
276
+ self._close_all()
277
+
278
+
279
+ def psycopg_pool(
280
+ dsn: str,
281
+ *,
282
+ minimum: int = 1,
283
+ maximum: int = 10,
284
+ pre_ping: bool = False,
285
+ recycle_seconds: float | None = None,
286
+ timeout_seconds: float | None = None,
287
+ ) -> SnakePool:
288
+ """Builds a `SnakePool` on top of psycopg2's threaded pool.
289
+
290
+ `pre_ping` checks the pulse before lending (it costs a round trip, and it saves the deployment
291
+ where the database restarts and the pool carries on handing out dead connections).
292
+ `recycle_seconds` throws away connections older than that without asking. `timeout_seconds`
293
+ waits for one to be freed instead of giving up instantly, which is what psycopg2 does on its
294
+ own.
295
+
296
+ All three are OFF by default: turning them on costs round trips or throws away healthy
297
+ connections, and that is decided by whoever knows their deployment, not by the library.
298
+
299
+ Lazy import: do not force psycopg2 on whoever only generates SQL or migrations without
300
+ connecting.
301
+ """
302
+ from psycopg2.pool import ThreadedConnectionPool
303
+
304
+ from snakeorm.drivers.psycopg import PsycopgDriver, with_utc_timezone
305
+
306
+ # Here too: the pool opens its own connections and without this they would speak in a
307
+ # different time zone than the standalone driver's, depending on which way you came in.
308
+ backend = ThreadedConnectionPool(minimum, maximum, with_utc_timezone(dsn))
309
+
310
+ # One driver per CONNECTION, kept across loans. `SnakePool` tracks a connection's age by the
311
+ # object `borrow` hands it, so building a fresh wrapper every time made every loan look newborn
312
+ # and `recycle_seconds` discarded nothing — with the lifetime set to a hundred thousand seconds,
313
+ # zero discards, in the ONLY pool this project ships. Adopting once and reusing gives the pool
314
+ # the stable identity its bookkeeping needs, and costs one dict.
315
+ adopted: dict[object, SnakeDriver] = {}
316
+
317
+ def borrow() -> SnakeDriver:
318
+ # `adopt` is the customs post: it adapts psycopg2's concrete type to our minimal DBAPI at an edge.
319
+ connection = backend.getconn()
320
+ driver = adopted.get(connection)
321
+ if driver is None:
322
+ driver = PsycopgDriver.adopt(connection)
323
+ adopted[connection] = driver
324
+ return driver
325
+
326
+ def give_back(driver: SnakeDriver) -> None:
327
+ # The connection is recovered from the driver itself, not from a map keyed by `id()`: that
328
+ # one falls out of sync as soon as the driver is wrapped, and returning the wrong
329
+ # connection to the pool is fiendishly hard to track down.
330
+ if isinstance(driver, PsycopgDriver):
331
+ # The `type: ignore` is a STRUCTURAL vs NOMINAL clash, not a hole: it is the SAME object
332
+ # that came out of `getconn()`, only each type system looks at it its own way.
333
+ backend.putconn(driver._connection) # type: ignore[arg-type] # noqa: SLF001
334
+
335
+ def discard(driver: SnakeDriver) -> None:
336
+ # `close=True` is the difference between returning and THROWING AWAY: without it, the bad
337
+ # connection goes back in the queue and the next one to ask for it gets the same problem.
338
+ if isinstance(driver, PsycopgDriver):
339
+ # Out of the map too, or a connection thrown away would keep its old wrapper alive and
340
+ # the next `getconn()` reusing that slot would inherit somebody else's age.
341
+ adopted.pop(driver._connection, None) # noqa: SLF001
342
+ backend.putconn(driver._connection, close=True) # type: ignore[arg-type] # noqa: SLF001
343
+
344
+ return SnakePool(
345
+ borrow,
346
+ give_back,
347
+ backend.closeall,
348
+ discard=discard,
349
+ pre_ping=pre_ping,
350
+ recycle_seconds=recycle_seconds,
351
+ timeout_seconds=timeout_seconds,
352
+ )
@@ -0,0 +1,188 @@
1
+ """PsycopgDriver: runs SQL against PostgreSQL with psycopg2 (synchronous). A THIN adapter that
2
+ translates the SnakeDriver Protocol into cursor calls; it only runs the already-compiled
3
+ `(sql, params)`. The connection is typed with a minimal DBAPI Protocol (`_Connection`), not the
4
+ concrete type: that decouples and makes it testable.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterator, Sequence
10
+ from typing import Protocol, cast
11
+
12
+ from snakeorm.drivers.failures import translating
13
+ from snakeorm.drivers.savepoints import quote_savepoint
14
+
15
+ from snakeorm.sql.adapt import adapt_params
16
+
17
+ _QUOTE = '"'
18
+ """The character THIS engine quotes an identifier with.
19
+
20
+ MySQL and MariaDB reject a double-quoted identifier unless `ANSI_QUOTES` is on, so a shared helper
21
+ that always wrote a double quote turned every `SAVEPOINT` into `ERROR 1064` here. The RULE about
22
+ what a valid name is, is shared; the quoting character is the engine's own grammar.
23
+ """
24
+
25
+
26
+ class _Cursor(Protocol):
27
+ """The minimal DBAPI cursor the driver needs."""
28
+
29
+ @property
30
+ def rowcount(self) -> int:
31
+ """Rows affected by the last statement. A read-only property, like psycopg2's real `rowcount`."""
32
+ ...
33
+
34
+ itersize: int
35
+ """Rows per round trip of a NAMED cursor. On an unnamed one it does nothing."""
36
+
37
+ @translating
38
+ # `| None` is how these two libraries spell NO PARAMETERS, and it is not the same as an
39
+ # empty sequence: given `()` they re-read the SQL as a format template, so a literal `%` —
40
+ # every DDL statement the migration runner emits — dies before reaching the server. The
41
+ # Protocol has to admit it or the drivers cannot say it.
42
+ def execute(self, sql: str, params: Sequence[object] | None) -> object: ...
43
+ def fetchall(self) -> list[tuple[object, ...]]: ...
44
+ def fetchmany(self, size: int) -> list[tuple[object, ...]]: ...
45
+ def close(self) -> None: ...
46
+
47
+
48
+ class _Connection(Protocol):
49
+ """The minimal DBAPI connection the driver needs."""
50
+
51
+ def cursor(self, name: str = ...) -> _Cursor: ...
52
+ @translating
53
+ def commit(self) -> None: ...
54
+ def rollback(self) -> None: ...
55
+ def close(self) -> None: ...
56
+
57
+
58
+ _UTC_OPTION = "options='-c timezone=UTC'"
59
+ """Pins the SESSION's time zone as the connection starts, without running a single statement.
60
+
61
+ `TIMESTAMPTZ` stores the instant but DISPLAYS it in the session's zone: without this, opening the
62
+ database to check that the dates are right shows the server's local time and the glance tells you
63
+ nothing. It goes in the DSN and not in a `SET TIME ZONE` because a statement would open a
64
+ transaction, and `set_isolation()` demands being the first one in its own.
65
+ """
66
+
67
+
68
+ def with_utc_timezone(dsn: str) -> str:
69
+ """Adds the UTC time zone request to the DSN, unless it already carries its own `options`.
70
+
71
+ Respecting somebody else's `options=` is the escape hatch: whoever writes the startup options
72
+ knows what they are doing, and stomping on them would be deciding for them.
73
+ """
74
+ return dsn if "options=" in dsn else f"{dsn} {_UTC_OPTION}"
75
+
76
+
77
+ class PsycopgDriver:
78
+ """Synchronous driver over psycopg2. Implements the SnakeDriver Protocol."""
79
+
80
+ def __init__(self, connection: _Connection) -> None:
81
+ self._connection = connection
82
+ # Named-cursor counter: two alive at once with the SAME name on one connection would
83
+ # collide, and the streaming path opens one per query.
84
+ self._cursor_seq = 0
85
+
86
+ @classmethod
87
+ def adopt(cls, connection: object) -> PsycopgDriver:
88
+ """Wraps a RAW psycopg2 connection, adapting it to our minimal DBAPI.
89
+
90
+ This is THE edge where psycopg2's concrete type enters our world (`connect()` and the pool
91
+ both use it). The `cast` is legitimate: pyright does not narrow psycopg2's `cursor()` to
92
+ `_Connection`; the parameter is `object` because the incoming type is foreign and cannot be
93
+ narrowed.
94
+ """
95
+ return cls(cast("_Connection", connection))
96
+
97
+ @classmethod
98
+ def connect(cls, dsn: str) -> PsycopgDriver:
99
+ """Opens a psycopg2 connection with the given DSN and wraps it (lazy import)."""
100
+ import psycopg2
101
+
102
+ return cls.adopt(psycopg2.connect(with_utc_timezone(dsn)))
103
+
104
+ @translating
105
+ def fetch_all(self, sql: str, params: Sequence[object]) -> list[tuple[object, ...]]:
106
+ """Runs a query and returns all of its rows, closing the cursor."""
107
+ cursor = self._connection.cursor()
108
+ try:
109
+ cursor.execute(
110
+ sql, adapt_params(params, native_arrays=True, percent_formatting=True)
111
+ )
112
+ return cursor.fetchall()
113
+ finally:
114
+ cursor.close()
115
+
116
+ def fetch_iter(
117
+ self, sql: str, params: Sequence[object], *, chunk: int = 1000
118
+ ) -> Iterator[tuple[object, ...]]:
119
+ """Yields the rows in chunks using a NAMED cursor (server-side).
120
+
121
+ The name is not cosmetic: it is what turns psycopg2's cursor into a SERVER cursor. Without
122
+ it, psycopg2 pulls the whole result into the client's memory even if you call `fetchmany`,
123
+ and the streaming would be an illusion. With it, the result stays on Postgres and
124
+ `itersize` decides how many rows travel per round trip.
125
+
126
+ The name carries a counter because two named cursors alive at once on the same connection
127
+ would collide.
128
+ """
129
+ self._cursor_seq += 1
130
+ cursor = self._connection.cursor(name=f"snake_stream_{self._cursor_seq}")
131
+ try:
132
+ cursor.itersize = chunk
133
+ cursor.execute(
134
+ sql, adapt_params(params, native_arrays=True, percent_formatting=True)
135
+ )
136
+ while True:
137
+ rows = cursor.fetchmany(chunk)
138
+ if not rows:
139
+ return
140
+ yield from rows
141
+ finally:
142
+ cursor.close()
143
+
144
+ @translating
145
+ def execute(self, sql: str, params: Sequence[object]) -> int:
146
+ """Runs a statement fetching no rows and returns the rowcount, closing the cursor.
147
+
148
+ `cursor.rowcount` is how many rows the statement affected (bulk writes read it). It is
149
+ captured BEFORE closing the cursor: after `close()` it is no longer available.
150
+ """
151
+ cursor = self._connection.cursor()
152
+ try:
153
+ cursor.execute(
154
+ sql, adapt_params(params, native_arrays=True, percent_formatting=True)
155
+ )
156
+ return cursor.rowcount
157
+ finally:
158
+ cursor.close()
159
+
160
+ @property
161
+ def last_insert_id(self) -> int:
162
+ """The id of the last INSERT (see the Protocol). Unused: Postgres returns the PK through RETURNING."""
163
+ return 0
164
+
165
+ @translating
166
+ def commit(self) -> None:
167
+ """Commits the transaction in progress."""
168
+ self._connection.commit()
169
+
170
+ def rollback(self) -> None:
171
+ """Rolls back the transaction in progress."""
172
+ self._connection.rollback()
173
+
174
+ def savepoint(self, name: str) -> None:
175
+ """Emits `SAVEPOINT "n"`. The name is internal, but it is quoted for safety."""
176
+ self.execute(f"SAVEPOINT {quote_savepoint(name, quote=_QUOTE)}", ())
177
+
178
+ def release_savepoint(self, name: str) -> None:
179
+ """Emits `RELEASE SAVEPOINT "n"`: folds the savepoint into the transaction."""
180
+ self.execute(f"RELEASE SAVEPOINT {quote_savepoint(name, quote=_QUOTE)}", ())
181
+
182
+ def rollback_to_savepoint(self, name: str) -> None:
183
+ """Emits `ROLLBACK TO SAVEPOINT "n"`: discards what was done since the savepoint."""
184
+ self.execute(f"ROLLBACK TO SAVEPOINT {quote_savepoint(name, quote=_QUOTE)}", ())
185
+
186
+ def close(self) -> None:
187
+ """Closes the connection."""
188
+ self._connection.close()