weightsdb 0.2.0__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.
- weightsdb/__about__.py +1 -0
- weightsdb/__init__.py +71 -0
- weightsdb/backup.py +610 -0
- weightsdb/engine.py +229 -0
- weightsdb/errors.py +83 -0
- weightsdb/health.py +318 -0
- weightsdb/migrations.py +328 -0
- weightsdb/py.typed +0 -0
- weightsdb/redaction.py +26 -0
- weightsdb/session.py +115 -0
- weightsdb/testing.py +131 -0
- weightsdb/types.py +187 -0
- weightsdb-0.2.0.dist-info/METADATA +96 -0
- weightsdb-0.2.0.dist-info/RECORD +16 -0
- weightsdb-0.2.0.dist-info/WHEEL +4 -0
- weightsdb-0.2.0.dist-info/licenses/LICENSE +201 -0
weightsdb/migrations.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""weightsdb.migrations — a programmatic Alembic wrapper.
|
|
2
|
+
|
|
3
|
+
One linear history per application, from the first release (database standards §5). This module
|
|
4
|
+
never shells out to the ``alembic`` CLI or reads an ``alembic.ini`` — it builds an in-memory
|
|
5
|
+
:class:`alembic.config.Config` and drives :mod:`alembic.command` directly, always against the
|
|
6
|
+
already-configured :class:`~sqlalchemy.Engine` the caller built with
|
|
7
|
+
:func:`~weightsdb.engine.create_engine_for`, so every migration runs with the same pragmas and
|
|
8
|
+
transaction semantics as the rest of the application — never a second, differently configured
|
|
9
|
+
connection built from the bare URL.
|
|
10
|
+
|
|
11
|
+
Moved from FreeWeight's ``infrastructure.db.migration`` (ADR-0011), renamed to the plural
|
|
12
|
+
``migrations`` to match this package's own module-naming choice; behaviour is unchanged except that
|
|
13
|
+
backup and restore now go through :mod:`weightsdb.backup` instead of an application-local module.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
from alembic import command
|
|
24
|
+
from alembic.autogenerate import compare_metadata
|
|
25
|
+
from alembic.config import Config
|
|
26
|
+
from alembic.migration import MigrationContext
|
|
27
|
+
from alembic.script import ScriptDirectory
|
|
28
|
+
|
|
29
|
+
from weightsdb.backup import backup as take_backup
|
|
30
|
+
from weightsdb.backup import restore as restore_backup
|
|
31
|
+
from weightsdb.backup import sqlite_path
|
|
32
|
+
from weightsdb.errors import MigrationFailed
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from sqlalchemy import Connection, Engine, MetaData
|
|
36
|
+
|
|
37
|
+
__all__ = ["MigrationOutcome", "MigrationRunner", "ParityResult"]
|
|
38
|
+
|
|
39
|
+
# The filename family `prune_backups` rotates. An operator-named backup never starts with
|
|
40
|
+
# this, and is therefore never a rotation candidate.
|
|
41
|
+
_PRE_MIGRATION_PREFIX = "pre-migration-"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class MigrationOutcome:
|
|
46
|
+
"""The result of :meth:`MigrationRunner.upgrade` or :meth:`MigrationRunner.downgrade`.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
from_revision: The revision before this operation ran, or ``None`` for a fresh database.
|
|
50
|
+
to_revision: The revision after this operation ran, or ``None`` when a downgrade reached
|
|
51
|
+
``base`` (no revision at all — an unmigrated database).
|
|
52
|
+
backed_up: Whether a backup was taken first.
|
|
53
|
+
backup_path: Where the backup was written, or ``None`` when ``backed_up`` is ``False``.
|
|
54
|
+
pruned_backups: Older automatic backups rotated out by this call, oldest first.
|
|
55
|
+
restore_on_failure_available: Whether a failure of *this* migration would have been rolled
|
|
56
|
+
back automatically. ``True`` only on SQLite with a backup in hand; ``False`` on
|
|
57
|
+
PostgreSQL, where the guarantee does not exist (spec §11.4), and ``False`` for a fresh
|
|
58
|
+
database, where there is nothing to roll back to. This is the field that states the
|
|
59
|
+
dialect difference rather than papering over it.
|
|
60
|
+
dialect: ``"sqlite"`` or ``"postgresql"``.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from_revision: str | None
|
|
64
|
+
to_revision: str | None
|
|
65
|
+
backed_up: bool
|
|
66
|
+
backup_path: Path | None
|
|
67
|
+
pruned_backups: tuple[Path, ...]
|
|
68
|
+
restore_on_failure_available: bool
|
|
69
|
+
dialect: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class ParityResult:
|
|
74
|
+
"""The result of :meth:`MigrationRunner.check_parity`.
|
|
75
|
+
|
|
76
|
+
Attributes:
|
|
77
|
+
matches: ``True`` when autogenerate finds no difference between the live schema and the
|
|
78
|
+
given metadata.
|
|
79
|
+
diff: A human-readable rendering of the drift found, empty when ``matches`` is ``True``.
|
|
80
|
+
Alembic's autogenerate comparator does not reliably detect a changed ``CheckConstraint``
|
|
81
|
+
or a partial (``WHERE``-qualified) index — a known upstream limitation, not something
|
|
82
|
+
this wrapper works around; a migration that changes only one of those needs its own
|
|
83
|
+
explicit test, not a mechanical parity check.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
matches: bool
|
|
87
|
+
diff: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class MigrationRunner:
|
|
91
|
+
"""Runs one application's own migration history against one engine.
|
|
92
|
+
|
|
93
|
+
Stateless beyond its constructor arguments: every method opens its own connection (or is
|
|
94
|
+
handed one internally) and closes it, so one instance is safely reused across calls but is not
|
|
95
|
+
itself a resource that needs closing.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
engine: Engine,
|
|
101
|
+
*,
|
|
102
|
+
script_location: str,
|
|
103
|
+
version_table: str = "alembic_version",
|
|
104
|
+
backup_retention: int = 5,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Build a runner for ``engine``'s migration history under ``script_location``.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
engine: The engine migrations run against. Reused as-is — this class never builds its
|
|
110
|
+
own engine from a bare URL, so the caller's pragmas and transaction semantics
|
|
111
|
+
(``BEGIN IMMEDIATE`` on SQLite) apply to every migration exactly as they apply to
|
|
112
|
+
the rest of the application.
|
|
113
|
+
script_location: Filesystem path to the ``migrations/`` directory containing ``env.py``
|
|
114
|
+
and ``versions/``.
|
|
115
|
+
version_table: The table Alembic records the current revision in. Defaults to Alembic's
|
|
116
|
+
own default; overridable so two applications sharing infrastructure-but-not-schema
|
|
117
|
+
could coexist without colliding on table name (spec §11.6 — nothing here refers to
|
|
118
|
+
another application's tables, and a distinct version table per consumer is part of
|
|
119
|
+
keeping that true even when, as in this package's own test suite, two schemas share
|
|
120
|
+
one physical database).
|
|
121
|
+
backup_retention: How many automatic pre-migration backups to keep (database standards
|
|
122
|
+
§7, default 5). Rotation applies only to this runner's own ``pre-migration-*``
|
|
123
|
+
files; a backup an operator asked for by name is never rotated.
|
|
124
|
+
"""
|
|
125
|
+
self._engine = engine
|
|
126
|
+
self._script_location = script_location
|
|
127
|
+
self._version_table = version_table
|
|
128
|
+
self._backup_retention = backup_retention
|
|
129
|
+
self._script_config = Config()
|
|
130
|
+
self._script_config.set_main_option("script_location", script_location)
|
|
131
|
+
|
|
132
|
+
def current(self) -> str | None:
|
|
133
|
+
"""Return the database's current revision, or ``None`` for an unmigrated database."""
|
|
134
|
+
with self._engine.connect() as connection:
|
|
135
|
+
context = MigrationContext.configure(
|
|
136
|
+
connection, opts={"version_table": self._version_table}
|
|
137
|
+
)
|
|
138
|
+
return context.get_current_revision()
|
|
139
|
+
|
|
140
|
+
def heads(self) -> tuple[str, ...]:
|
|
141
|
+
"""Return this script directory's head revision(s) — normally exactly one."""
|
|
142
|
+
script = ScriptDirectory.from_config(self._script_config)
|
|
143
|
+
return tuple(script.get_heads())
|
|
144
|
+
|
|
145
|
+
def known_revisions(self) -> frozenset[str]:
|
|
146
|
+
"""Return every revision ID this script directory's history contains.
|
|
147
|
+
|
|
148
|
+
Used to detect :class:`~weightsdb.errors.SchemaAhead`: a database whose current revision is
|
|
149
|
+
not in this set was written by a build whose migrations this one does not have.
|
|
150
|
+
"""
|
|
151
|
+
script = ScriptDirectory.from_config(self._script_config)
|
|
152
|
+
return frozenset(revision.revision for revision in script.walk_revisions())
|
|
153
|
+
|
|
154
|
+
def is_at_head(self) -> bool:
|
|
155
|
+
"""Return whether the database's current revision is a head revision."""
|
|
156
|
+
current = self.current()
|
|
157
|
+
return current is not None and current in self.heads()
|
|
158
|
+
|
|
159
|
+
def upgrade(self, revision: str = "head", *, backup: bool = True) -> MigrationOutcome:
|
|
160
|
+
"""Migrate to ``revision``, taking a backup first (database standards §5.1, §7).
|
|
161
|
+
|
|
162
|
+
A no-op when already at ``revision`` — ``command.upgrade`` is itself idempotent, and this
|
|
163
|
+
method takes no backup and performs no write in that case (CLI standards §11).
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
revision: The target revision, or ``"head"``.
|
|
167
|
+
backup: Take a backup before migrating. Ignored when the database is unmigrated (there
|
|
168
|
+
is nothing to back up) or when the dialect is PostgreSQL, where the automatic
|
|
169
|
+
restore-on-failure guarantee does not apply (spec §11.4) and a caller that wants a
|
|
170
|
+
PostgreSQL backup takes one explicitly.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
The :class:`MigrationOutcome`.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
MigrationFailed: The migration raised. On SQLite, the pre-migration backup has already
|
|
177
|
+
been restored by the time this is raised (``details["restored"] is True``) and the
|
|
178
|
+
original database is byte-identical. On PostgreSQL, no restore is attempted;
|
|
179
|
+
``details`` names the revision actually reached and, if one was taken, the backup to
|
|
180
|
+
restore from manually.
|
|
181
|
+
"""
|
|
182
|
+
return self._run(command.upgrade, revision, backup=backup)
|
|
183
|
+
|
|
184
|
+
def downgrade(self, revision: str) -> MigrationOutcome:
|
|
185
|
+
"""Migrate down to ``revision``. Same backup and failure semantics as :meth:`upgrade`."""
|
|
186
|
+
return self._run(command.downgrade, revision, backup=True)
|
|
187
|
+
|
|
188
|
+
def _run(self, alembic_command: object, revision: str, *, backup: bool) -> MigrationOutcome:
|
|
189
|
+
from_revision = self.current()
|
|
190
|
+
dialect = self._engine.dialect.name
|
|
191
|
+
|
|
192
|
+
script = ScriptDirectory.from_config(self._script_config)
|
|
193
|
+
target_revision = script.as_revision_number(revision)
|
|
194
|
+
if target_revision == from_revision:
|
|
195
|
+
# Already there: a genuine no-op (CLI standards §11), not merely "alembic ran and
|
|
196
|
+
# changed nothing" — no backup is taken and alembic is never invoked, so this is also
|
|
197
|
+
# the case that keeps `upgrade()` cheap when called opportunistically at every startup.
|
|
198
|
+
return MigrationOutcome(
|
|
199
|
+
from_revision=from_revision,
|
|
200
|
+
to_revision=from_revision,
|
|
201
|
+
backed_up=False,
|
|
202
|
+
backup_path=None,
|
|
203
|
+
pruned_backups=(),
|
|
204
|
+
restore_on_failure_available=False,
|
|
205
|
+
dialect=dialect,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
backed_up = False
|
|
209
|
+
backup_path: Path | None = None
|
|
210
|
+
pruned: tuple[Path, ...] = ()
|
|
211
|
+
if backup and from_revision is not None and dialect == "sqlite":
|
|
212
|
+
result = take_backup(
|
|
213
|
+
self._engine,
|
|
214
|
+
self._pre_migration_backup_path(from_revision),
|
|
215
|
+
keep=self._backup_retention,
|
|
216
|
+
prefix=_PRE_MIGRATION_PREFIX,
|
|
217
|
+
)
|
|
218
|
+
backed_up = True
|
|
219
|
+
backup_path = result.path
|
|
220
|
+
pruned = result.pruned
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
with self._engine.connect() as connection:
|
|
224
|
+
config = self._connection_config(connection)
|
|
225
|
+
alembic_command(config, revision) # type: ignore[operator]
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
restored = False
|
|
228
|
+
if backed_up and backup_path is not None and dialect == "sqlite":
|
|
229
|
+
restore_backup(
|
|
230
|
+
self._engine,
|
|
231
|
+
backup_path,
|
|
232
|
+
confirm=True,
|
|
233
|
+
known_revisions=self.known_revisions(),
|
|
234
|
+
)
|
|
235
|
+
restored = True
|
|
236
|
+
raise MigrationFailed(
|
|
237
|
+
f"Migration to {revision!r} failed: {exc}",
|
|
238
|
+
details={
|
|
239
|
+
"restored": restored,
|
|
240
|
+
"backup_path": str(backup_path) if backup_path else None,
|
|
241
|
+
"reached_revision": self.current(),
|
|
242
|
+
"restore_command": None
|
|
243
|
+
if restored
|
|
244
|
+
else f"restore(engine, {backup_path!r}, confirm=True)"
|
|
245
|
+
if backup_path
|
|
246
|
+
else None,
|
|
247
|
+
},
|
|
248
|
+
) from exc
|
|
249
|
+
|
|
250
|
+
# `to_revision` legitimately ends up `None` here for a downgrade all the way to `base` —
|
|
251
|
+
# an unmigrated database is not a broken outcome, so nothing above raises for it; a
|
|
252
|
+
# migration that fails to reach any real revision would have raised inside the `try`
|
|
253
|
+
# block above instead of returning normally.
|
|
254
|
+
to_revision = self.current()
|
|
255
|
+
return MigrationOutcome(
|
|
256
|
+
from_revision=from_revision,
|
|
257
|
+
to_revision=to_revision,
|
|
258
|
+
backed_up=backed_up,
|
|
259
|
+
backup_path=backup_path,
|
|
260
|
+
pruned_backups=pruned,
|
|
261
|
+
restore_on_failure_available=backed_up and dialect == "sqlite",
|
|
262
|
+
dialect=dialect,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def stamp(self, revision: str = "head") -> None:
|
|
266
|
+
"""Mark the database as being at ``revision`` without running any migration.
|
|
267
|
+
|
|
268
|
+
For recovering a database whose schema is known to already match a revision — never for
|
|
269
|
+
routine use, and never called automatically by anything in this module.
|
|
270
|
+
"""
|
|
271
|
+
with self._engine.connect() as connection:
|
|
272
|
+
config = self._connection_config(connection)
|
|
273
|
+
command.stamp(config, revision)
|
|
274
|
+
|
|
275
|
+
def check_parity(self, metadata: MetaData) -> ParityResult:
|
|
276
|
+
"""Diff ``metadata`` against the database's live schema (database standards §5.2).
|
|
277
|
+
|
|
278
|
+
Independent of Alembic's own revision bookkeeping — this reflects the actual tables,
|
|
279
|
+
columns and indexes in the database and compares them to ``metadata`` directly, so it
|
|
280
|
+
catches a model changed without a matching migration even if the ``alembic_version`` table
|
|
281
|
+
is technically at head.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
metadata: The consumer's own declarative ``MetaData`` — never this package's, which is
|
|
285
|
+
always empty (spec §7, §20).
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
The :class:`ParityResult`.
|
|
289
|
+
"""
|
|
290
|
+
with self._engine.connect() as connection:
|
|
291
|
+
context = MigrationContext.configure(
|
|
292
|
+
connection, opts={"version_table": self._version_table}
|
|
293
|
+
)
|
|
294
|
+
diff = compare_metadata(context, metadata)
|
|
295
|
+
if not diff:
|
|
296
|
+
return ParityResult(matches=True, diff="")
|
|
297
|
+
return ParityResult(matches=False, diff="\n".join(repr(item) for item in diff))
|
|
298
|
+
|
|
299
|
+
def _connection_config(self, connection: Connection) -> Config:
|
|
300
|
+
"""Build a per-call :class:`Config` bound to an already-open connection.
|
|
301
|
+
|
|
302
|
+
A fresh object every call rather than a cached one: ``config.attributes["connection"]``
|
|
303
|
+
must point at *this* call's connection, and reusing one :class:`Config` across calls would
|
|
304
|
+
leak the previous, already-closed connection into the next migration's ``env.py``.
|
|
305
|
+
"""
|
|
306
|
+
config = Config()
|
|
307
|
+
config.set_main_option("script_location", self._script_location)
|
|
308
|
+
config.set_main_option(
|
|
309
|
+
"sqlalchemy.url", self._engine.url.render_as_string(hide_password=True)
|
|
310
|
+
)
|
|
311
|
+
config.attributes["connection"] = connection
|
|
312
|
+
config.attributes["version_table"] = self._version_table
|
|
313
|
+
return config
|
|
314
|
+
|
|
315
|
+
def _pre_migration_backup_path(self, from_revision: str) -> Path:
|
|
316
|
+
"""Choose ``<db directory>/backups/pre-migration-<revision>-<UTC timestamp>.sqlite3``.
|
|
317
|
+
|
|
318
|
+
The revision is part of the name, not only the timestamp (database standards §7): an
|
|
319
|
+
operator picking a file out of a backups directory needs to know which schema it holds
|
|
320
|
+
without opening it, and rotation keeps several generations side by side.
|
|
321
|
+
"""
|
|
322
|
+
source = sqlite_path(self._engine)
|
|
323
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
|
|
324
|
+
return (
|
|
325
|
+
source.parent
|
|
326
|
+
/ "backups"
|
|
327
|
+
/ f"{_PRE_MIGRATION_PREFIX}{from_revision}-{stamp}{source.suffix}"
|
|
328
|
+
)
|
weightsdb/py.typed
ADDED
|
File without changes
|
weightsdb/redaction.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""weightsdb.redaction — strip credentials from a database URL before it is logged or raised.
|
|
2
|
+
|
|
3
|
+
Used by every error and log path in this package (spec §14): a connection URL reaches
|
|
4
|
+
:class:`~weightsdb.errors.DatabaseUnavailable`, a health payload or a DEBUG log line only after
|
|
5
|
+
going through :func:`redact_url`, never in its raw form.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from sqlalchemy.engine import make_url
|
|
11
|
+
|
|
12
|
+
__all__ = ["redact_url"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def redact_url(url: str) -> str:
|
|
16
|
+
"""Return ``url`` with any password replaced by ``***``.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
url: A SQLAlchemy database URL, e.g. ``postgresql://user:secret@host/db``.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
The same URL with its password component masked (``postgresql://user:***@host/db``), or
|
|
23
|
+
unchanged if it carried no password. Every other component (user, host, database name) is
|
|
24
|
+
preserved, since those are needed to identify *which* database a message is about.
|
|
25
|
+
"""
|
|
26
|
+
return make_url(url).render_as_string(hide_password=True)
|
weightsdb/session.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""weightsdb.session — session factory, unit-of-work scope, and explicit transaction control.
|
|
2
|
+
|
|
3
|
+
SQLite's transaction-start behaviour (``BEGIN IMMEDIATE``, so lock contention fails fast rather than
|
|
4
|
+
at commit) is dialect-specific plumbing configured once on the engine by :mod:`weightsdb.engine`;
|
|
5
|
+
nothing here branches on dialect.
|
|
6
|
+
|
|
7
|
+
Generalized from FreeWeight's ``infrastructure.db.session`` (ADR-0011): the application version
|
|
8
|
+
folded read-only declaration into ``session_scope`` itself via a ``read_only`` flag. Two consumers
|
|
9
|
+
made that the wrong shape to keep — a caller wants to declare read-only-ness for one sub-block of a
|
|
10
|
+
unit of work, not the whole session — so it is split here into :func:`session_scope` (commit,
|
|
11
|
+
rollback, close: the session's lifecycle) and :func:`transaction` (declares how the transaction
|
|
12
|
+
that lifecycle wraps begins).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Iterator
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
|
|
20
|
+
from sqlalchemy import Engine
|
|
21
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
22
|
+
|
|
23
|
+
from weightsdb.engine import READ_ONLY_EXECUTION_OPTION
|
|
24
|
+
from weightsdb.errors import DatabaseError
|
|
25
|
+
|
|
26
|
+
__all__ = ["session_factory", "session_scope", "transaction"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def session_factory(engine: Engine) -> sessionmaker[Session]:
|
|
30
|
+
"""Build a session factory bound to ``engine``.
|
|
31
|
+
|
|
32
|
+
``expire_on_commit=False``: a repository's return value is a detached, plain-data snapshot
|
|
33
|
+
(coding standards §4 — ORM objects never leave the repository layer), and a caller reading an
|
|
34
|
+
attribute off it after commit must not trigger a lazy load on a session that may already be
|
|
35
|
+
closed.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
engine: The engine to bind every session this factory produces to.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A ``sessionmaker`` producing sessions against ``engine``.
|
|
42
|
+
"""
|
|
43
|
+
return sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@contextmanager
|
|
47
|
+
def session_scope(factory: sessionmaker[Session]) -> Iterator[Session]:
|
|
48
|
+
"""Run one unit of work: commit on success, roll back on any exception, always close.
|
|
49
|
+
|
|
50
|
+
"Any exception" includes ``KeyboardInterrupt`` and ``SystemExit`` — a ``Ctrl-C`` mid-write must
|
|
51
|
+
leave the database in its pre-write state, not a half-committed one, so the ``except`` below is
|
|
52
|
+
deliberately ``BaseException`` rather than ``Exception``.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
factory: A session factory from :func:`session_factory`.
|
|
56
|
+
|
|
57
|
+
Yields:
|
|
58
|
+
A session open for exactly this unit of work. Wrap it in :func:`transaction` to declare
|
|
59
|
+
read-only intent for SQLite's benefit; a plain write is the default and needs no wrapping.
|
|
60
|
+
"""
|
|
61
|
+
session = factory()
|
|
62
|
+
try:
|
|
63
|
+
yield session
|
|
64
|
+
session.commit()
|
|
65
|
+
except BaseException:
|
|
66
|
+
session.rollback()
|
|
67
|
+
raise
|
|
68
|
+
finally:
|
|
69
|
+
session.close()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@contextmanager
|
|
73
|
+
def transaction(session: Session, *, immediate: bool = True) -> Iterator[Session]:
|
|
74
|
+
"""Declare how ``session``'s transaction begins.
|
|
75
|
+
|
|
76
|
+
On SQLite, ``immediate=True`` (the default) uses ``BEGIN IMMEDIATE`` so lock contention fails
|
|
77
|
+
fast, at the start of the transaction, rather than at commit time (:mod:`weightsdb.engine`'s own
|
|
78
|
+
docstring explains why that matters). ``immediate=False`` uses a deferred ``BEGIN`` and enforces
|
|
79
|
+
it: an attempted write inside raises rather than silently taking the write lock. Inert on
|
|
80
|
+
PostgreSQL, where ordinary MVCC already lets readers and writers proceed without blocking each
|
|
81
|
+
other.
|
|
82
|
+
|
|
83
|
+
This only *declares* the transaction's start semantics — commit, rollback and close remain
|
|
84
|
+
whichever enclosing :func:`session_scope` is responsible for them. It does not itself commit or
|
|
85
|
+
roll back, so it composes as a sub-block of one:
|
|
86
|
+
|
|
87
|
+
.. code-block:: python
|
|
88
|
+
|
|
89
|
+
with session_scope(factory) as session, transaction(session, immediate=False):
|
|
90
|
+
... # read-only work
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
session: An open session whose connection has not yet begun a transaction — normally one
|
|
94
|
+
freshly obtained from :func:`session_scope`, before any query has run on it.
|
|
95
|
+
immediate: ``True`` for ``BEGIN IMMEDIATE`` (a writer); ``False`` for a deferred ``BEGIN``
|
|
96
|
+
(a reader).
|
|
97
|
+
|
|
98
|
+
Yields:
|
|
99
|
+
The same session, with its transaction-start semantics declared.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
DatabaseError: ``session`` already has an active transaction — database standards §6
|
|
103
|
+
forbids nested transactions, and entering this twice on one session is exactly that.
|
|
104
|
+
"""
|
|
105
|
+
if session.in_transaction():
|
|
106
|
+
raise DatabaseError(
|
|
107
|
+
"transaction() cannot nest: this session already has an active transaction "
|
|
108
|
+
"(database standards §6 — use a savepoint deliberately if partial rollback is needed)."
|
|
109
|
+
)
|
|
110
|
+
if immediate:
|
|
111
|
+
session.connection()
|
|
112
|
+
else:
|
|
113
|
+
read_only_options: dict[str, bool] = {READ_ONLY_EXECUTION_OPTION: True}
|
|
114
|
+
session.connection(execution_options=read_only_options)
|
|
115
|
+
yield session
|
weightsdb/testing.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""weightsdb.testing — fixtures for a consumer's own test suite, shipped as supported API.
|
|
2
|
+
|
|
3
|
+
Not a test module itself: importable by any application's tests, and part of this package's public
|
|
4
|
+
contract (spec §7) rather than an internal helper that happens to be reachable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
from sqlalchemy import text
|
|
19
|
+
|
|
20
|
+
from weightsdb.engine import create_engine_for
|
|
21
|
+
from weightsdb.migrations import MigrationRunner
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from sqlalchemy import Engine, MetaData
|
|
25
|
+
|
|
26
|
+
__all__ = ["MigrationHarness", "migration_harness", "temporary_postgres", "temporary_sqlite"]
|
|
27
|
+
|
|
28
|
+
_DEFAULT_POSTGRES_URL = "postgresql+psycopg://weightsdb:weightsdb@localhost:5432/weightsdb_test"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@contextmanager
|
|
32
|
+
def temporary_sqlite() -> Iterator[Engine]:
|
|
33
|
+
"""Yield an engine on a fresh, empty SQLite database file, disposed and deleted on exit.
|
|
34
|
+
|
|
35
|
+
A real file rather than ``:memory:``: backup, restore and the WAL checkpoint all operate on a
|
|
36
|
+
file on disk, and an in-memory database would silently exempt every one of those from a test
|
|
37
|
+
that believes it is exercising them.
|
|
38
|
+
"""
|
|
39
|
+
with tempfile.TemporaryDirectory(prefix="weightsdb-") as directory:
|
|
40
|
+
url = f"sqlite:///{Path(directory) / 'test.sqlite3'}"
|
|
41
|
+
engine = create_engine_for(url)
|
|
42
|
+
try:
|
|
43
|
+
yield engine
|
|
44
|
+
finally:
|
|
45
|
+
engine.dispose()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@contextmanager
|
|
49
|
+
def temporary_postgres() -> Iterator[Engine]:
|
|
50
|
+
"""Yield an engine on a freshly reset PostgreSQL schema, disposed on exit.
|
|
51
|
+
|
|
52
|
+
Skips (``pytest.skip``) when no server is reachable at ``WEIGHTSDB_POSTGRES_URL`` (default
|
|
53
|
+
``postgresql+psycopg://weightsdb:weightsdb@localhost:5432/weightsdb_test``), **except** when
|
|
54
|
+
``WEIGHTSDB_REQUIRE_POSTGRES=1``, which turns the skip into a failure: a silently skipped
|
|
55
|
+
dialect is an untested dialect, and the both-dialects promise (spec §7) is only as good as its
|
|
56
|
+
enforcement.
|
|
57
|
+
|
|
58
|
+
The server is reused across calls, so this resets to empty by dropping and recreating the
|
|
59
|
+
``public`` schema — including any ``alembic_version`` table a previous test left behind —
|
|
60
|
+
rather than assuming a pristine database.
|
|
61
|
+
"""
|
|
62
|
+
url = os.environ.get("WEIGHTSDB_POSTGRES_URL", _DEFAULT_POSTGRES_URL)
|
|
63
|
+
require = os.environ.get("WEIGHTSDB_REQUIRE_POSTGRES") == "1"
|
|
64
|
+
try:
|
|
65
|
+
probe = create_engine_for(url)
|
|
66
|
+
try:
|
|
67
|
+
with probe.connect() as connection:
|
|
68
|
+
connection.execute(text("SELECT 1"))
|
|
69
|
+
finally:
|
|
70
|
+
probe.dispose()
|
|
71
|
+
except Exception as exc: # noqa: BLE001 — any failure means "no usable server", by design
|
|
72
|
+
if require:
|
|
73
|
+
pytest.fail(f"WEIGHTSDB_REQUIRE_POSTGRES=1 but {url} is unreachable: {exc}")
|
|
74
|
+
pytest.skip(f"no PostgreSQL server available at {url}: {exc}")
|
|
75
|
+
|
|
76
|
+
reset_engine = create_engine_for(url)
|
|
77
|
+
try:
|
|
78
|
+
with reset_engine.begin() as connection:
|
|
79
|
+
connection.execute(text("DROP SCHEMA public CASCADE"))
|
|
80
|
+
connection.execute(text("CREATE SCHEMA public"))
|
|
81
|
+
finally:
|
|
82
|
+
reset_engine.dispose()
|
|
83
|
+
|
|
84
|
+
engine = create_engine_for(url)
|
|
85
|
+
try:
|
|
86
|
+
yield engine
|
|
87
|
+
finally:
|
|
88
|
+
engine.dispose()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(slots=True)
|
|
92
|
+
class MigrationHarness:
|
|
93
|
+
"""One consumer's migration script location and metadata, ready for its own tests.
|
|
94
|
+
|
|
95
|
+
Bundles the two pieces of information every migration test needs so an application's test
|
|
96
|
+
module does not repeat ``MigrationRunner(engine, script_location=...)`` boilerplate in every
|
|
97
|
+
test function; :meth:`sqlite` and :meth:`postgres` each combine a fresh :func:`temporary_sqlite`
|
|
98
|
+
or :func:`temporary_postgres` engine with a :class:`~weightsdb.migrations.MigrationRunner` bound
|
|
99
|
+
to it.
|
|
100
|
+
|
|
101
|
+
Attributes:
|
|
102
|
+
script_location: Filesystem path to the consumer's ``migrations/`` directory.
|
|
103
|
+
metadata: The consumer's own declarative ``MetaData``, for ``check_parity``.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
script_location: str
|
|
107
|
+
metadata: MetaData
|
|
108
|
+
|
|
109
|
+
@contextmanager
|
|
110
|
+
def sqlite(self) -> Iterator[MigrationRunner]:
|
|
111
|
+
"""A :class:`~weightsdb.migrations.MigrationRunner` on a fresh temporary SQLite database."""
|
|
112
|
+
with temporary_sqlite() as engine:
|
|
113
|
+
yield MigrationRunner(engine, script_location=self.script_location)
|
|
114
|
+
|
|
115
|
+
@contextmanager
|
|
116
|
+
def postgres(self) -> Iterator[MigrationRunner]:
|
|
117
|
+
"""A :class:`~weightsdb.migrations.MigrationRunner` on a freshly reset PostgreSQL DB."""
|
|
118
|
+
with temporary_postgres() as engine:
|
|
119
|
+
yield MigrationRunner(engine, script_location=self.script_location)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def migration_harness(script_location: str, metadata: MetaData) -> MigrationHarness:
|
|
123
|
+
"""Build a :class:`MigrationHarness` for one consumer's migration history.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
script_location: Filesystem path to the consumer's ``migrations/`` directory (containing
|
|
127
|
+
``env.py`` and ``versions/``).
|
|
128
|
+
metadata: The consumer's own declarative ``MetaData`` — never this package's, which is
|
|
129
|
+
always empty (spec §7, §20).
|
|
130
|
+
"""
|
|
131
|
+
return MigrationHarness(script_location=script_location, metadata=metadata)
|