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.
- snake_orm-0.1.0b1.dist-info/METADATA +366 -0
- snake_orm-0.1.0b1.dist-info/RECORD +198 -0
- snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
- snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
- snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
- snakeorm/__init__.py +214 -0
- snakeorm/advisor.py +109 -0
- snakeorm/cli/__init__.py +3 -0
- snakeorm/cli/app.py +992 -0
- snakeorm/cli/discovery.py +179 -0
- snakeorm/cli/hooks.py +86 -0
- snakeorm/compiler/__init__.py +3 -0
- snakeorm/compiler/compiler.py +330 -0
- snakeorm/connection.py +228 -0
- snakeorm/contrib/__init__.py +17 -0
- snakeorm/contrib/asgi.py +192 -0
- snakeorm/contrib/config.py +264 -0
- snakeorm/contrib/deliver.py +256 -0
- snakeorm/contrib/django.py +245 -0
- snakeorm/contrib/sidecar.py +44 -0
- snakeorm/contrib/wsgi.py +176 -0
- snakeorm/core/__init__.py +1 -0
- snakeorm/core/config.py +173 -0
- snakeorm/core/converters.py +172 -0
- snakeorm/core/exceptions.py +175 -0
- snakeorm/core/placement.py +15 -0
- snakeorm/core/sentinels.py +37 -0
- snakeorm/core/signals.py +114 -0
- snakeorm/debug/__init__.py +42 -0
- snakeorm/debug/assets/css/panel.css +616 -0
- snakeorm/debug/assets/css/snake_orm_app.css +293 -0
- snakeorm/debug/assets/img/icon.svg +5 -0
- snakeorm/debug/assets/js/history.js +425 -0
- snakeorm/debug/assets/js/language.js +283 -0
- snakeorm/debug/assets/js/panel.js +241 -0
- snakeorm/debug/assets/js/snake_orm_app.js +82 -0
- snakeorm/debug/assets/pages/cli.html +28 -0
- snakeorm/debug/assets/pages/config.html +34 -0
- snakeorm/debug/assets/pages/database_first.html +29 -0
- snakeorm/debug/assets/pages/help.html +21 -0
- snakeorm/debug/assets/pages/history.html +27 -0
- snakeorm/debug/capture.py +234 -0
- snakeorm/debug/channel.py +131 -0
- snakeorm/debug/collector.py +144 -0
- snakeorm/debug/config.py +149 -0
- snakeorm/debug/html.py +578 -0
- snakeorm/debug/matrix.py +60 -0
- snakeorm/debug/origin.py +42 -0
- snakeorm/debug/otel/__init__.py +68 -0
- snakeorm/debug/otel/context.py +43 -0
- snakeorm/debug/otel/exporter.py +277 -0
- snakeorm/debug/otel/payload.py +97 -0
- snakeorm/debug/otel/report.py +29 -0
- snakeorm/debug/otel/spans.py +393 -0
- snakeorm/debug/otel/summary.py +59 -0
- snakeorm/debug/record.py +54 -0
- snakeorm/debug/report.py +311 -0
- snakeorm/debug/testing.py +23 -0
- snakeorm/decorators/__init__.py +13 -0
- snakeorm/decorators/abstract.py +26 -0
- snakeorm/decorators/db_first.py +104 -0
- snakeorm/decorators/function.py +28 -0
- snakeorm/decorators/model.py +268 -0
- snakeorm/decorators/polymorphic.py +140 -0
- snakeorm/decorators/result.py +167 -0
- snakeorm/decorators/row.py +90 -0
- snakeorm/decorators/trigger.py +47 -0
- snakeorm/decorators/view.py +149 -0
- snakeorm/dialects/__init__.py +6 -0
- snakeorm/dialects/base.py +373 -0
- snakeorm/dialects/capabilities.py +571 -0
- snakeorm/dialects/literals.py +46 -0
- snakeorm/dialects/matrix.py +384 -0
- snakeorm/dialects/mysql.py +519 -0
- snakeorm/dialects/postgres.py +504 -0
- snakeorm/dialects/sqlite.py +417 -0
- snakeorm/drivers/__init__.py +18 -0
- snakeorm/drivers/asyncbase.py +73 -0
- snakeorm/drivers/asyncdecorators.py +244 -0
- snakeorm/drivers/asyncpool.py +228 -0
- snakeorm/drivers/asyncpsycopg.py +124 -0
- snakeorm/drivers/asyncpymysql.py +50 -0
- snakeorm/drivers/asyncsqlite.py +30 -0
- snakeorm/drivers/base.py +76 -0
- snakeorm/drivers/failures.py +181 -0
- snakeorm/drivers/logging.py +167 -0
- snakeorm/drivers/pool.py +352 -0
- snakeorm/drivers/psycopg.py +188 -0
- snakeorm/drivers/pymysql.py +176 -0
- snakeorm/drivers/savepoints.py +46 -0
- snakeorm/drivers/sqlite.py +170 -0
- snakeorm/drivers/threaded.py +143 -0
- snakeorm/drivers/timeout.py +87 -0
- snakeorm/dto/__init__.py +55 -0
- snakeorm/dto/read.py +231 -0
- snakeorm/dto/region.py +326 -0
- snakeorm/dto/resolve.py +354 -0
- snakeorm/dto/spec.py +201 -0
- snakeorm/expressions/__init__.py +88 -0
- snakeorm/expressions/conditional.py +178 -0
- snakeorm/expressions/expression.py +810 -0
- snakeorm/expressions/functions.py +71 -0
- snakeorm/expressions/keys.py +299 -0
- snakeorm/expressions/paths.py +43 -0
- snakeorm/expressions/scalar.py +293 -0
- snakeorm/expressions/window.py +248 -0
- snakeorm/fields/__init__.py +68 -0
- snakeorm/fields/check.py +79 -0
- snakeorm/fields/column.py +316 -0
- snakeorm/fields/enum.py +64 -0
- snakeorm/fields/index.py +46 -0
- snakeorm/fields/relationship.py +738 -0
- snakeorm/fields/typed.py +736 -0
- snakeorm/helpers/__init__.py +1 -0
- snakeorm/helpers/annotations.py +21 -0
- snakeorm/helpers/inheritance.py +41 -0
- snakeorm/helpers/pyliteral.py +52 -0
- snakeorm/helpers/pytype.py +50 -0
- snakeorm/introspection/__init__.py +13 -0
- snakeorm/introspection/base.py +30 -0
- snakeorm/introspection/drift.py +78 -0
- snakeorm/introspection/models.py +633 -0
- snakeorm/introspection/mysql.py +367 -0
- snakeorm/introspection/postgres.py +397 -0
- snakeorm/introspection/sqlite.py +217 -0
- snakeorm/introspection/unsupported.py +94 -0
- snakeorm/linker/__init__.py +3 -0
- snakeorm/linker/linker.py +521 -0
- snakeorm/metadata/__init__.py +41 -0
- snakeorm/metadata/check.py +37 -0
- snakeorm/metadata/column.py +201 -0
- snakeorm/metadata/enum_storage.py +33 -0
- snakeorm/metadata/fk_action.py +19 -0
- snakeorm/metadata/foreign_key.py +26 -0
- snakeorm/metadata/index.py +43 -0
- snakeorm/metadata/index_method.py +19 -0
- snakeorm/metadata/int_size.py +20 -0
- snakeorm/metadata/json_storage.py +21 -0
- snakeorm/metadata/polymorphic.py +30 -0
- snakeorm/metadata/primary_key.py +23 -0
- snakeorm/metadata/relationship.py +60 -0
- snakeorm/metadata/relationship_kind.py +43 -0
- snakeorm/metadata/routine.py +26 -0
- snakeorm/metadata/server_default.py +20 -0
- snakeorm/metadata/table.py +97 -0
- snakeorm/metadata/table_kind.py +23 -0
- snakeorm/metadata/trigger.py +62 -0
- snakeorm/metadata/type_params.py +348 -0
- snakeorm/migration/__init__.py +88 -0
- snakeorm/migration/asyncrunner.py +168 -0
- snakeorm/migration/autodetect.py +96 -0
- snakeorm/migration/ddl.py +1172 -0
- snakeorm/migration/diff.py +701 -0
- snakeorm/migration/loader.py +77 -0
- snakeorm/migration/operations.py +952 -0
- snakeorm/migration/planner.py +78 -0
- snakeorm/migration/realize.py +420 -0
- snakeorm/migration/renames.py +153 -0
- snakeorm/migration/render.py +1095 -0
- snakeorm/migration/runner.py +314 -0
- snakeorm/migration/squash.py +60 -0
- snakeorm/migration/state.py +107 -0
- snakeorm/model.py +162 -0
- snakeorm/py.typed +0 -0
- snakeorm/query/__init__.py +9 -0
- snakeorm/query/compound.py +288 -0
- snakeorm/query/join_kind.py +19 -0
- snakeorm/query/joined.py +143 -0
- snakeorm/query/query.py +1001 -0
- snakeorm/query/recursive.py +244 -0
- snakeorm/registry/__init__.py +5 -0
- snakeorm/registry/by_module.py +163 -0
- snakeorm/registry/registry.py +166 -0
- snakeorm/session/__init__.py +8 -0
- snakeorm/session/asyncsession.py +638 -0
- snakeorm/session/coercion.py +306 -0
- snakeorm/session/factory.py +29 -0
- snakeorm/session/guards.py +269 -0
- snakeorm/session/isolation.py +23 -0
- snakeorm/session/mapper.py +213 -0
- snakeorm/session/planning.py +815 -0
- snakeorm/session/retry.py +96 -0
- snakeorm/session/session.py +871 -0
- snakeorm/session/shared.py +355 -0
- snakeorm/sql/__init__.py +18 -0
- snakeorm/sql/adapt.py +104 -0
- snakeorm/sql/aggregate.py +232 -0
- snakeorm/sql/condition.py +359 -0
- snakeorm/sql/delete.py +46 -0
- snakeorm/sql/insert.py +149 -0
- snakeorm/sql/joins.py +158 -0
- snakeorm/sql/pk_subquery.py +54 -0
- snakeorm/sql/refs.py +19 -0
- snakeorm/sql/resolver.py +28 -0
- snakeorm/sql/select.py +175 -0
- snakeorm/sql/update.py +82 -0
- snakeorm/sql/value.py +552 -0
- snakeorm/times.py +276 -0
snakeorm/drivers/pool.py
ADDED
|
@@ -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()
|