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.
@@ -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)