Status: draft
Source: bpp/src/django_pg_baseline/ (already cleanly isolated as a
local Django app)
Target: standalone OSS package on PyPI as django-pg-baseline
Package #3 of 3 in the testcontainers-for-Django family:
pytest-testcontainers— generic pytest plugin, session-scoped Docker container lifecycle. Framework-agnostic.pytest-testcontainers-django— Django bridge on top of #1. Injects env vars before Django imports settings; supports init-script mounts andDATABASES['default']['TEST']['TEMPLATE']for fast test-db clone.django-pg-baseline(this package) — Django app for managing abaseline.sqlartifact (apg_dumpof seed-data + post-migrate schema). Two consumption modes:- (a) standalone: monkey-patch Django's test DB creation to load the
baseline via
psqlafterCREATE DATABASE, - (b) testcontainer-bridged: provide the baseline path so package
#2 mounts it as a PG init script and clones via
WITH TEMPLATE.
- (a) standalone: monkey-patch Django's test DB creation to load the
baseline via
This spec describes the extraction. Code-wise the package is already clean — the spec is mostly about packaging, integration contracts, and the public-API surface we commit to before publishing.
A Django suite with hundreds of migrations and/or non-trivial seed data
spends many minutes per test run on migrate. The fix is well-known
in principle:
- Apply migrations once against a clean PG.
pg_dumpthe result.- On every subsequent test run,
psql -f(orCREATE DATABASE … WITH TEMPLATE) the dump into the test DB, then letmigrateapply only the small delta of migrations added since the dump was taken.
django-pg-baseline packages this pattern as a reusable Django app with
all the operational bits people forget the first time:
- a deterministic, version-controlled
baseline.sql(with timestamp freezing for diff stability), - a sidecar
baseline.meta.jsonrecording the highest migration name per app + git SHA + PG version, - automatic loading on test DB creation (monkey patch) when no faster template-clone path is available,
- compatibility with the testcontainer/template-clone path for consumers using package #2.
When to rebuild the baseline is the project's decision, not the
package's — we provide baseline_info so the user can see how stale
the dump is, but no exit-1 gate enforcing freshness.
In real BPP runs this turns a ~6-minute migrate into a ~3-second
psql import (or a sub-second CREATE DATABASE WITH TEMPLATE clone).
BaselineConfig+settings.PG_BASELINEtyped loader.loader.load_baseline()— stdlib-only (no Django at import time) shell-out topsqlto load a dump into a DSN.loader.baseline_needed()— probeto_regclass('public.django_migrations').patches.install_test_db_patch()— idempotent monkey patch onBaseDatabaseCreation._create_test_dbthat:- kicks other sessions off the template DB before
CREATE DATABASE WITH TEMPLATE(Postgres requires zero connections on the source), - on the non-template path, post-
CREATE DATABASErunspsql -f baseline.sqlonly when the new DB is empty.
- kicks other sessions off the template DB before
freshness.check_freshness()— compares disk migrations (viaMigrationLoader.graph) vs. the per-applast_migrationrecorded inbaseline.meta.jsonand produces a typed report. Used bybaseline_infofor human-readable output. Informational only — does not enforce any threshold. The project decides when to rebuild.writer.write_meta()— emitsbaseline.meta.jsonwith ameta_versionfield (currently1), git SHA, PG version, per-app last migration. Themeta_versionfield lets future package releases detect and migrate older formats; readers must checkmeta_version <= MAX_SUPPORTEDand fail with a clear message otherwise.rebuild.rebuild_baseline()— orchestrates a full regeneration: spins an isolated PG viatestcontainers, redirects thedefaultDjango connection at it, runsmigrate, freezes configurable timestamp columns, runspg_dumpinside the container (to guarantee client/server major-version match), scrubs PG17→PG16-incompatible lines, writes the dump and meta.- Management commands:
baseline_load,baseline_info,baseline_rebuild. - Auto-install of the monkey patch via
AppConfig.ready()whendjango_pg_baselineis inINSTALLED_APPS. - Optional pytest plugin (
pytest_plugin.py) for projects that prefer not to add the app toINSTALLED_APPS.
- The actual
baseline.sqlandbaseline.meta.jsonartifacts — consumers ship their own undersettings.PG_BASELINE['BASELINE_DIR']. This package ships the machinery, never the data. - Non-Postgres backends.
psql/pg_dump/template-clone are PG-specific. We do not pretend to be portable. - Any "build a testcontainer for the test DB" logic — that lives in
package #2 (
pytest-testcontainers-django). - Image-distribution of pre-loaded PG (e.g.
iplweb/bpp_dbserverwith the dump baked into/docker-entrypoint-initdb.d/). That's a consumer concern, not ours.
The single Django settings entry point. Schema (already in conf.py):
PG_BASELINE = {
# REQUIRED. Directory holding baseline.sql + baseline.meta.json.
"BASELINE_DIR": "/path/to/your/baseline-sql",
# Optional, defaults shown.
"SQL_FILENAME": "baseline.sql",
"META_FILENAME": "baseline.meta.json",
# Which Django connection to load into / dump from.
"DATABASE_ALIAS": "default",
# Auto-install the _create_test_db monkey patch in AppConfig.ready().
# Set to False if you want to control patch installation manually
# (e.g. only under pytest, only on certain CI hosts).
"AUTO_LOAD_ON_TEST_DB": True,
# Image used by `baseline_rebuild`. Override if you need
# plpython3u, custom locales, extensions, etc.
"REBUILD_IMAGE": "postgres:16",
# Extra args appended to the built-in pg_dump invocation. The
# default invocation already includes --no-owner --no-acl
# --no-privileges --no-comments and --exclude-table-data=django_session
# — use this for project-specific additions. Always additive; there
# is no "replace defaults" option (fork the package if you need it).
"PG_DUMP_EXTRA_ARGS": ["--exclude-table-data=audit_log"],
# Convenience: stacks ON TOP of the default exclusions. Each entry
# becomes --exclude-table-data=<pattern>. Equivalent to listing
# --exclude-table-data=... in PG_DUMP_EXTRA_ARGS, but cleaner.
"PG_DUMP_EXTRA_EXCLUDE_TABLE_DATA": ["django_cache*", "easy_thumbnails_*"],
# Tables/columns whose timestamps should be set to a fixed value
# before pg_dump, for deterministic diffs across rebuilds.
"FREEZE_TIMESTAMPS": [("django_migrations", ["applied"])],
# Stack on top of the default freeze list:
"FREEZE_TIMESTAMPS_EXTRA": [("django_template", ["creation_date"])],
"FREEZE_TIMESTAMP_VALUE": "2000-01-01 00:00:00+00",
}Required: BASELINE_DIR. Everything else has a default. Missing
or empty PG_BASELINE raises ImproperlyConfigured with a clear
message.
get_config() returns a @dataclass(frozen=True) BaselineConfig.
CLI overrides (e.g. --image for baseline_rebuild) construct a
new instance via dataclasses.replace(cfg, rebuild_image=...)
rather than mutating in place — this avoids time-of-check /
time-of-use bugs when multiple call sites read the same config.
| Command | What it does |
|---|---|
baseline_load |
Load baseline.sql into the configured DB. Skips when django_migrations already exists, unless --force. Flags: --database <alias>, --force. |
baseline_info |
Human summary: git SHA, PG version, sql/meta paths, plus per-app deltas (computed via MigrationLoader.graph, so replaces= squashes are counted correctly). Always exits 0 — informational, no gating. |
baseline_rebuild |
Regenerate baseline.sql + baseline.meta.json. Spins a testcontainers PG, runs migrate, freezes timestamps, runs in-container pg_dump, scrubs, writes meta. Flags: --image <ref>, --baseline-dir <path>. |
All commands instantiate via get_config() and respect overrides
on the loaded BaselineConfig.
Adding "django_pg_baseline" to INSTALLED_APPS is enough for the
common case. DjangoPgBaselineConfig.ready():
- Calls
get_config(). Ifsettings.PG_BASELINEis completely unset, returns silently (app is inINSTALLED_APPSbut the user hasn't configured it yet — fine). - If
auto_load_on_test_dbis False, returns silently. The user explicitly opted out of the auto-patch, so configuration problems below are not our concern. - If
sql_pathdoesn't exist, raisesImproperlyConfiguredwith a message of the form:Rationale: a typo indjango-pg-baseline: BASELINE_DIR is configured ({path}) but {sql_filename} is missing. Run `manage.py baseline_rebuild` to generate it, or remove PG_BASELINE from settings to disable.BASELINE_DIRshould fail loudly, not produce silent slowness in CI (everyone migrates from scratch and nobody knows why). - Installs the
_create_test_dbmonkey patch.
The patch itself is idempotent (_already_patched flag) — safe under
double-import / autoreload.
Stale baseline is fine. If the dump on disk is older than HEAD
(some app's last migration is past what meta.json recorded), the
patch still loads it — Django's migrate then applies the delta on
top. Staleness surfaces via baseline_info (informational only); the
loading path itself is permissive by design. When to rebuild is the
project's call, not the package's.
The package exposes a pytest entry point pointing at
django_pg_baseline.pytest_plugin. The plugin's pytest_configure:
- If
DJANGO_SETTINGS_MODULEis unset, no-op (pytest may be running in a non-Django project that picked us up transitively). - Calls
django.setup(). - Calls
get_config(). IfPG_BASELINEis unset, no-op. - If
auto_load_on_test_dbis False, no-op (user opted out of the auto-patch). - If
sql_pathdoesn't exist, raisespytest.UsageErrorwith the same message asAppConfig.ready()(see §3.3). Same rationale: loud failure beats silent slowness. - Calls
install_test_db_patch(cfg).
Same effect as the INSTALLED_APPS route, but available to users who
prefer not to list the app. Mutually exclusive in practice with the
INSTALLED_APPS path — both are idempotent so double-install is
fine, but documenting "use one or the other" prevents confusion.
See §10 for the decision on whether this plugin stays in #3 or moves to #2.
Stable from v0.1 (commit-to-not-break, contract surface for package #2):
from django_pg_baseline import get_baseline_path # Path | NoneAvailable but not yet stable in 0.x (subject to change before v1.0; import from submodules at your own risk):
from django_pg_baseline.conf import get_config, BaselineConfig
from django_pg_baseline.patches import install_test_db_patch
from django_pg_baseline.loader import load_baseline, baseline_needed
from django_pg_baseline.freshness import check_freshness, FreshnessReportThe full set is re-exported from the top-level module starting at
v1.0.0, when the public API is locked under semver. Keeping
BaselineConfig out of the top-level re-exports during 0.x leaves
room to refactor the dataclass shape (e.g. for multi-DB, see §3.6)
without breaking downstreams.
get_baseline_path() is a thin wrapper around get_config().sql_path
that returns Path | None — None when settings.PG_BASELINE is unset
or the file is missing, instead of raising. This is the documented
contract surface for package #2.
v1 supports a single database alias (DATABASE_ALIAS, default
"default"). To leave room for v2 multi-DB without breaking
changes, the internals are already shaped for it:
- All internal helpers accept an explicit
aliasparameter (default =cfg.database_alias):def load_baseline(cfg: BaselineConfig, alias: str | None = None) -> None: ... def baseline_needed(cfg: BaselineConfig, alias: str | None = None) -> bool: ... def get_baseline_path(alias: str = "default") -> Path | None: ...
- Internal config resolution returns a list, even if one-element:
def _get_configs() -> list[BaselineConfig]: return [get_config()] # v1: single-element list
- The
_create_test_dbpatch dispatches byself.connection.alias— in v1 the lookup always hits the same single config, in v2 it picks per-alias.
The user-facing settings.PG_BASELINE shape in v1 is the flat dict
documented in §3.1. v2 may introduce a dict-of-dicts form
(PG_BASELINE = {"default": {...}, "analytics": {...}}); the
detector for "old vs. new shape" is "are the top-level keys
connection aliases?" — heuristic but unambiguous in practice.
| Variable | Effect |
|---|---|
DJANGO_PG_BASELINE_SQL_PATH |
Overrides get_baseline_path() resolution. When set, points at the dump file directly, bypassing settings.PG_BASELINE['BASELINE_DIR'] + SQL_FILENAME. Missing-file behavior is the same as a configured-but-missing baseline (loud error in AppConfig.ready() / pytest plugin). |
The override exists so CI can pin a specific baseline without editing
settings. It is the rename target for the legacy BPP-flavored
BPP_BASELINE_SQL_PATH from testcontainers_bpp (§4.3, §11); the
old name is not honored by this package.
The two packages must agree on who loads the baseline and how, so there is no double-load and no silent mismatch.
django-pg-baseline exposes get_baseline_path() -> Path | None.
pytest-testcontainers-django calls it (best-effort import — no hard
dependency on us) and:
- if it returns a path: mounts the file into the PG container as
/docker-entrypoint-initdb.d/01-baseline.sql, and writesDATABASES['default']['TEST']['TEMPLATE']to the seed DB name so Django runsCREATE DATABASE … WITH TEMPLATE …instead of running migrate from scratch. - if it returns
None: falls back to an empty PG; tests run a normalmigrate. Nothing in #3 fires either, because the monkey patch short-circuits whensql_pathdoesn't exist.
When the bridge is active, package #2 sets a private marker on the DB settings:
DATABASES['default']['TEST']['TEMPLATE'] = '<seed-db-name>'
DATABASES['default']['TEST']['_django_pg_baseline_seeded'] = TrueOur patch reads _django_pg_baseline_seeded (not TEMPLATE
directly) to decide whether to skip the psql reload. This avoids
confusing the patch when a user has set TEMPLATE for unrelated
reasons — e.g. a manually configured template DB with their own
seed. The marker is the explicit coordination flag between #2 and
#3; TEMPLATE alone is not.
The patch's other job — kicking other sessions off the template DB
before CREATE DATABASE WITH TEMPLATE (via pg_terminate_backend)
— runs whenever TEMPLATE is set, marker or no marker. That part
is generic Postgres hygiene and benefits any consumer that sets
TEMPLATE. It requires the pg_signal_backend role; in Mode B
(testcontainers) the test user is superuser, so this is satisfied
automatically. Mode A users running against a shared PG must grant
the role to the test DB user — documented in the README.
- Why not let #2 read
settings.PG_BASELINEdirectly? It would couple #2 to our settings schema. Withget_baseline_path()we own the resolution rule (env override, fallback paths, file existence check) — and #2 stays focused on container lifecycle. - Why a function and not just a setting? Because the resolution
may grow. The vendor-neutral env override
DJANGO_PG_BASELINE_SQL_PATH(replacing the legacy BPP-flavoredBPP_BASELINE_SQL_PATHfromtestcontainers_bpp) is centralized here behind the same call. A function is forward-compatible with future resolution rules. - Why optional / soft? #2 must work without #3 installed. If
import django_pg_baselinefails, #2 logs and continues with an empty PG.
bpp/src/testcontainers_bpp/containers.py::find_baseline_sql()
currently hardcodes the convention path
<src>/baseline-sql/baseline.sql and respects
$BPP_BASELINE_SQL_PATH. After extraction, that function is
replaced by django_pg_baseline.get_baseline_path(), and the env
override is renamed to DJANGO_PG_BASELINE_SQL_PATH (clean cut,
no deprecation period — the old name leaves the codebase with the
extraction). This is part of the migration path for BPP (§11) and
the integration contract for #2.
The simplest case. Useful when:
- the consumer runs tests against a long-lived dev PG (e.g. a host PG,
a
docker composeservice, a CI service container), psqlis onPATHon the host running pytest.
Setup:
INSTALLED_APPS = [..., "django_pg_baseline"]
PG_BASELINE = {"BASELINE_DIR": BASE_DIR / "baseline-sql"}What happens at test time:
AppConfig.ready()installs the_create_test_dbpatch.- Django's runner calls
_create_test_db→CREATE DATABASE test_<name>. - Patch sees
django_migrationsis missing in the new DB →psql -f baseline.sql --single-transaction --quiet -v ON_ERROR_STOP=1. - Django's
migratethen applies only the post-baseline delta.
Faster. Useful when:
- the consumer accepts a Docker daemon as a test dependency,
- they want the test DB to be a clone of a populated template
rather than a
psqlreload (sub-second vs. seconds).
Setup:
INSTALLED_APPS = [..., "django_pg_baseline"]
PG_BASELINE = {"BASELINE_DIR": BASE_DIR / "baseline-sql"}pytest-testcontainers-django is auto-loaded as a pytest plugin. It:
- Reads
get_baseline_path()from #3. - Starts a PG container with the baseline mounted as init script.
- Sets
DATABASES['default']['TEST']['TEMPLATE'] = '<seed-db>'.
What happens at test time:
- PG comes up; init script loads
baseline.sqlinto the seed DB. - Django runs
_create_test_db. Our patch sees the_django_pg_baseline_seededmarker onTEST(set by #2 alongsideTEMPLATE), closes the default connection, kicks other backends off the template, then delegates to Django's original_create_test_dbwhich issuesCREATE DATABASE test_… WITH TEMPLATE seed. - The new DB is already populated —
migrateapplies only the delta.
In Mode B the psql shell-out from #3's loader.py is never
invoked. We still own:
- the patch's "kick sessions off template" prelude (runs whenever
TEMPLATEis set, with or without our marker — generic PG hygiene), - the marker-driven "skip psql reload" decision,
settings.PG_BASELINEandget_baseline_path().
Run when the user decides the baseline needs refreshing (typically after adding migrations):
uv run python manage.py baseline_rebuild
git add path/to/baseline-sql/baseline.sql baseline.meta.json
git commit -m "chore(baseline): refresh after migrations …"rebuild_baseline():
- Spins a fresh
PostgresContainer(image=cfg.rebuild_image). - Validates that
pg_dumpis present in the image by runningpg_dump --versioninside the container. If absent (e.g. a cut-down/distroless image), aborts with a clear error before any migrations run, instead of failing cryptically mid-rebuild. - Swaps
connections.databases[cfg.database_alias]to point at the testcontainer (and evicts the cachedDatabaseWrapperso subsequent access reconnects). This is necessary because data migrations commonly grabdjango.db.connectiondirectly, ignoring thedatabase=arg passed tocall_command("migrate", database=…). migrate(interactive=False)._freeze_timestamps()against the configured tables/columns.pg_dumpinside the container (sopg_dumpmajor version matches the server, avoiding e.g. PG17 emittingSET transaction_timeout = 0;for a PG16 target)._scrub_dump()removes\restrict/\unrestrict(random tokens, nondeterministic) andSET transaction_timeout = …(PG17→<17 incompatibility). This is ongoing maintenance: every new PG major release may emit new directives that need scrubbing for forward/backward compatibility. The scrubbing pass is treated as a list-of-known-incompatibilities, kept current as new PG ships.write_meta()writes the JSON sidecar (withmeta_version).- Restores the original
defaultconnection infinally.
Security note. The dump captures all data present in the
testcontainer after migrate(). If your data migrations seed users,
fixtures, or other content that ends up in the dump, that data
lands in version control. Review the dump before committing,
especially on the first rebuild. Use PG_DUMP_EXTRA_EXCLUDE_TABLE_DATA
to skip tables whose row data should not ship (e.g. auth_user
when you have real test passwords). The package does not exclude
auth_user by default — projects that intentionally seed admin
fixtures rely on that data being in the baseline.
Recommended downstream wiring: a GitHub Action that runs
baseline_rebuild whenever **/migrations/** changes on the main
branch and opens a PR with the refreshed dump. The package itself
does not enforce any "freshness" policy — when to rebuild is the
project's decision; we just provide the tooling.
django-pg-baseline/
├── pyproject.toml # src layout, [test] extra
├── README.md # quickstart, three modes, troubleshooting
├── CHANGELOG.md # keep-a-changelog format, towncrier optional
├── LICENSE # MIT (matches BPP's reusable-app convention)
├── .pre-commit-config.yaml # ruff format + ruff check (changed-files-only)
├── .github/
│ └── workflows/
│ ├── tests.yml # pytest matrix (see §8)
│ └── release.yml # tag → build sdist+wheel → twine upload
├── src/
│ └── django_pg_baseline/
│ ├── __init__.py # re-exports public API (see §3.5)
│ ├── apps.py
│ ├── conf.py
│ ├── loader.py
│ ├── patches.py
│ ├── writer.py
│ ├── freshness.py
│ ├── rebuild.py # gated behind [rebuild] extra
│ ├── pytest_plugin.py
│ └── management/
│ ├── __init__.py
│ └── commands/
│ ├── __init__.py
│ ├── baseline_load.py
│ ├── baseline_info.py
│ └── baseline_rebuild.py
└── tests/
├── conftest.py
├── test_apps.py
├── test_conf.py
├── test_loader.py
├── test_patches.py
├── test_freshness.py
├── test_writer.py
├── test_rebuild.py
├── test_management_commands.py
└── test_pytest_plugin.py
pyproject.toml extras:
[project.optional-dependencies]
test = ["pytest", "pytest-django", "django>=5.0"]testcontainers[postgres] is a regular runtime dependency, not
an optional extra — baseline_rebuild is core functionality, not
opt-in. The cost (one extra Python dep) is far smaller than the
cost of users discovering at runtime that their pip install left
out the rebuild path.
pyproject.toml entry point for the pytest plugin:
[project.entry-points.pytest11]
django_pg_baseline = "django_pg_baseline.pytest_plugin"(See §10 for the open question on whether to register the pytest11
entry point at all, or only ship the module and require the user to opt
in via -p django_pg_baseline.pytest_plugin.)
Runtime dependencies:
Django>=5.0testcontainers[postgres]>=4.14.2
No psycopg runtime dep. This is a Django app — by definition
the host project already has either psycopg2, psycopg2-binary,
or psycopg[binary]>=3 installed (Django's Postgres backend
requires one). Forcing a flavor would conflict with the host's
choice and is wrong for a library.
Internally, a small _backend.py module hides the differences
between v2 and v3 behind the narrow API we actually use (connect,
cursor.execute, commit, close). The public surface is one
function:
# _backend.py
def connect(dsn: str):
"""Open a Postgres connection using whichever psycopg is installed.
Prefers psycopg (v3); falls back to psycopg2. Returned connection
supports the v2-style cursor protocol our callers rely on.
"""
try:
import psycopg
return psycopg.connect(dsn)
except ImportError:
import psycopg2
return psycopg2.connect(dsn)The naive aliasing trick import psycopg2 as psycopg is
deliberately avoided — psycopg2 and psycopg3 are not
API-compatible (connection-string parsing, cursor methods,
sql.SQL, and the async story all differ). _backend.py documents
the subset we rely on so future callers don't accidentally use a
v3-only feature and break v2 users.
loader.py only shells out to psql and doesn't need a backend at
all. patches.py (the only consumer of _backend) imports it
lazily at patch-execution time, after Django has already loaded its
own DB driver — so by then we know one of the two is available.
Both flavors are exercised in CI (see §8).
grep -rn "from bpp\|import bpp\|from django_bpp\|import django_bpp" src/django_pg_baseline/ returns zero matches. Tests don't import
BPP fixtures (the local tests/conftest.py defines its own
tmp_baseline_dir, fake_sql_file, fake_meta_dict).
What I did find that mentions BPP, and what to do:
| Location | Mention | Action |
|---|---|---|
loader.py docstring |
"Postgres testcontainer started by testcontainers_bpp" |
Reword to refer to "any external mechanism that pre-populates the test DB via PG init scripts and TEST.TEMPLATE" — and mention package #2 by its public name. |
rebuild.py::PostgresContainer(...) |
hardcoded username="bpp", password="password", dbname="bpp_baseline" |
Replace with vendor-neutral defaults (postgres/postgres/baseline) or read from config. These never escape the throwaway testcontainer so the values don't matter functionally — but the BPP names are confusing in OSS docs. |
tests/conftest.py::fake_meta_dict |
"bpp": "0500_something" in fake migration name |
Cosmetic — change to "myapp": "0050_initial" or similar. Doesn't affect logic. |
src/baseline-sql/README.md |
BPP-specific troubleshooting (plpython3u, pl_PL.UTF-8) |
Move into the new package's README.md as examples of REBUILD_IMAGE customization. Keep wording vendor-neutral. |
pytest_plugin.py docstring |
"in bpp we install the monkey patch via INSTALLED_APPS" | Reword. |
None of these are coupling — they're cosmetic strings/docstrings. The package can be lifted as-is and these can be cleaned up in a single "vendor-neutral docstrings" commit before first release.
Verdict: zero-coupling. This is by far the cleanest of the three extractions.
Test matrix (GitHub Actions):
- Python: 3.10, 3.11, 3.12, 3.13.
- Django: 5.0, 5.1, 5.2 (current LTS). Django 4.2 is out of scope — the project targets current Django, and 4.2 LTS goes EOL in April 2026 anyway. Excluded combo: Django 5.0 + Python 3.13 (Django 5.0 supports Python 3.10–3.12 only).
- Postgres: 16, 17 — as a service container in CI. Older PG is
out of scope: the project targets current Postgres, and supporting
EOL versions (14/15) is dead weight that complicates
_scrub_dumpand thepg_signal_backendstory. - psycopg driver:
psycopg2-binaryandpsycopg[binary]>=3— separate cells to validate the lazy-import contract from §6. - OS: Linux only. Windows is not part of the matrix — the
package shells out to
psql/pg_dump, uses POSIX path conventions, and the testcontainer story assumes a Docker daemon reachable the way it is on Linux runners. macOS works in practice for local development but is not CI-tested.
Total cells = (4 × 3 − 1 excluded) × 2 × 2 = 44. Trim to a sensible diagonal:
- Full matrix only on push to
main. - PRs run a smaller set:
{3.10, 3.13} × {5.1, 5.2} × {16, 17} × {psycopg2, psycopg3}= 16 cells (5.1 and 5.2 both support the full Python range, so no exclusions needed). Still under 5 minutes wall time.
Test layers:
- Unit tests (no DB, no Docker). The vast majority of the existing
suite is already at this layer — fakes for
psycopg/psycopg2,subprocess,connections,MigrationLoader. Runs in seconds across the full matrix. Keep this property. - Integration test for
rebuild_baseline— needs Docker. Gate behind a marker (@pytest.mark.requires_docker) and run on every push and every PR (GitHub Actions Linux runners ship with Docker — no reason to skip on forks). Catching rebuild regressions pre-merge is the whole point. - End-to-end "fake project" — a tiny throwaway Django project
under
tests/fake_project/with a couple of migrations. Test thatbaseline_rebuildproduces a working dump and thatbaseline_loadreloads it cleanly. Run on the integration job. - Django
_create_test_dbsignature contract test — for each Django version in the matrix, inspectBaseDatabaseCreation._create_test_db.__signature__(viainspect.signature) and assert positional/keyword arguments match what the patch expects. Fails loudly when a new Django release changes the private signature, so we catch breakage at our CI rather than at a downstream user'smanage.py test.
Linting/formatting:
ruff check+ruff format(mirror BPP's setup, line length 88).pre-commitwith hooks scoped to changed files (perfeedback_no_mass_reformat.md).
Release pipeline:
- Tag
vX.Y.Z→ workflow builds sdist + wheel viapython -m build, validates withtwine check, uploads via OIDC to PyPI (no API tokens). Match the pattern from BPP's other extracted packages.
All questions raised during spec review have been folded into the relevant sections as decisions. This log is the single index:
| Topic | Decision | Section |
|---|---|---|
| psycopg2 vs psycopg3 | No psycopg runtime dep; lazy import of psycopg (v3) with fallback to psycopg2; CI tests both |
§6 |
HOST fallback to localhost |
Pass-through, no fallback (preserves Django's unix-socket case) | loader.py impl note |
| Freshness on squashed migrations | Use MigrationLoader.graph (knows replaces=), not max(names) |
§3.2 |
_create_test_db signature drift |
Dedicated CI contract test per Django version | §8 layer 4 |
Silent no-op on missing BASELINE_DIR |
Hard ImproperlyConfigured / pytest.UsageError with rebuild instructions when PG_BASELINE is set but sql is missing |
§3.3, §3.4, §10 |
meta.json schema evolution |
meta_version field from v0.1 |
§3.1, §5 Mode C |
| Postgres version range | Drop PG 14/15; matrix is PG 16 + PG 17 | §8 |
| Multi-DB | Internal helpers already accept alias; settings shape stays flat in v1; v2 may introduce dict-of-dicts |
§3.6 |
| Coordination flag with #2 | Explicit _django_pg_baseline_seeded marker on TEST dict, not TEMPLATE itself |
§4.1 |
BaselineConfig mutability |
frozen=True + dataclasses.replace() for CLI overrides |
§3.1 |
| 0.x stable subset | Only get_baseline_path re-exported from top-level; rest accessed via submodules until v1.0 |
§3.5, §13 |
| Integration tests on PR forks | Run on every push and PR (Linux GHA runners have Docker) | §8 layer 2 |
_scrub_dump maintenance |
Treated as a living list; new PG majors will add directives we'll keep up with | §5 Mode C |
[rebuild] extra |
Dropped — testcontainers[postgres] is a regular runtime dep, rebuild is core not opt-in |
§6 |
Freshness gate / FRESHNESS_MAX_DELTA |
Removed — when to rebuild is the project's call, not the package's. baseline_check command dropped; baseline_info still shows per-app deltas, informational only |
§1, §3.2 |
pg_terminate_backend privileges |
Satisfied automatically in Mode B (testcontainers superuser); Mode A users must grant pg_signal_backend role to the test DB user |
§4.1 |
BPP_BASELINE_SQL_PATH rename |
Clean cut to DJANGO_PG_BASELINE_SQL_PATH; no deprecation shim — the old name leaves the codebase together with the extraction |
§4.2, §4.3, §11 |
Decision: stays in #3.
Re-reading pytest_plugin.py:
def pytest_configure(config):
import django
django.setup()
from .conf import get_config
from .patches import install_test_db_patch
cfg = get_config()
if cfg.auto_load_on_test_db and cfg.sql_path.exists():
install_test_db_patch(cfg)It is purely an alternative way to install the same monkey patch as
AppConfig.ready(). It depends on:
get_config()(this package),install_test_db_patch()(this package),django.setup()and a validDJANGO_SETTINGS_MODULE(the user's project, not us).
It does not depend on testcontainers, container lifecycle, or
anything package #2 owns. Moving it to #2 would force users who don't
use testcontainers (Mode A) to install #2 just to get the
no-INSTALLED_APPS registration path. That's a bad bargain.
The plugin's existence is also useful for the consumer's "I have a
weird stack and I don't want to add django_pg_baseline to
INSTALLED_APPS" case — totally unrelated to whether #2 is involved.
However, there is a related question: should #3 register the
pytest11 entry point at all, or just ship pytest_plugin.py as a
module the user opts into via -p django_pg_baseline.pytest_plugin?
- Pro entry-point registration: easier for users —
pip install django-pg-baselineand pytest auto-loads the plugin. - Con: the plugin calls
django.setup()unconditionally onpytest_configure. If a user installs us as a transitive dep through some other library and runs pytest in a project that doesn't haveDJANGO_SETTINGS_MODULE, the plugin will crash.
Recommendation: do register the entry point. The plugin
no-ops gracefully when there is no Django context, but fails loudly
when there is a Django context with PG_BASELINE configured but
the dump is missing — matching the AppConfig.ready() policy:
def pytest_configure(config):
import os
if not os.environ.get("DJANGO_SETTINGS_MODULE"):
return # not a Django project — leave it alone
import django
django.setup()
from django.core.exceptions import ImproperlyConfigured
import pytest
try:
cfg = get_config()
except ImproperlyConfigured:
return # PG_BASELINE not set — fine
if not cfg.auto_load_on_test_db:
return # user opted out of auto-patch — don't validate further
if not cfg.sql_path.exists():
raise pytest.UsageError(
f"django-pg-baseline: BASELINE_DIR is configured "
f"({cfg.baseline_dir}) but {cfg.sql_filename} is missing. "
f"Run `manage.py baseline_rebuild` to generate it, or "
f"remove PG_BASELINE from settings to disable."
)
install_test_db_patch(cfg)That mirrors the defensiveness already in AppConfig.ready() and
keeps the plugin friendly to non-Django-aware test runs while still
catching configuration mistakes loudly.
After django-pg-baseline is on PyPI, the BPP repo becomes a
consumer. Concrete steps, in order:
-
Add a runtime dep to
bpp/pyproject.toml:dependencies = [ ..., "django-pg-baseline>=0.1", ]
testcontainers[postgres]is now transitive viadjango-pg-baseline— drop the local line. No optional-extras plumbing in BPP for the rebuild path; everyone who installs BPP getsbaseline_rebuildavailable. -
Delete
bpp/src/django_pg_baseline/. TheINSTALLED_APPSentry stays ("django_pg_baseline"); it now resolves to the PyPI package. -
settings/base.py:PG_BASELINEstays unchanged. BPP's custom values (REBUILD_IMAGE = "iplweb/bpp_dbserver:psql-16.13",PG_DUMP_EXTRA_EXCLUDE_TABLE_DATA,FREEZE_TIMESTAMPS_EXTRA) are already expressed as the published settings keys and need no edit. -
bpp/src/testcontainers_bpp/containers.py: replacefind_baseline_sql()with a call todjango_pg_baseline.get_baseline_path(). TheBPP_BASELINE_SQL_PATHenv override is dropped; users who relied on it setDJANGO_PG_BASELINE_SQL_PATHinstead (clean cut — the old name leaves the codebase with the extraction, no deprecation shim in either #2 or #3). This is also part of the package #2 extraction work — the two extractions need to land in the right order:- publish #3 (this package),
- publish #1 + #2,
- update BPP to consume both.
-
Tests in
bpp/src/django_pg_baseline/tests/: move to the new package's repo (already covered in §6). BPP's repo loses them — and that's fine, they tested package internals, not BPP. -
src/baseline-sql/stays in BPP. It's data, not code. BPP'sPG_BASELINE['BASELINE_DIR']continues to point at it. -
CI: BPP's
.github/workflows/refresh-baseline.ymlstays — it just callsmanage.py baseline_rebuild, which now resolves to the published package's command. No edits needed. -
Docs: update
docs/CODEBASE_MAP.mdand thesrc/baseline-sql/README to reference the new package and link to its repo.
Net BPP diff: a few lines of dep changes + one file rename in
testcontainers_bpp/. No settings change. No test changes (other
than removing the package-internal tests). No production behavior
change.
PyPI availability check (via https://pypi.org/pypi/<name>/json,
HTTP 404 = available):
| Name | Status |
|---|---|
django-pg-baseline |
404 — available |
django-pg-dump |
404 — available |
django-baseline-sql |
404 — available |
django-postgres-baseline |
404 — available |
Recommended name: django-pg-baseline. Reasons:
- mirrors the existing module name (
django_pg_baseline), so the import path stays identical and the BPP migration is a one-line dep change; - reads naturally ("Django Postgres baseline");
- shorter than
django-postgres-baseline; - the keyword "baseline" is the unique signal — "pg-dump" is too generic, "baseline-sql" inverts the natural word order.
GitHub repo: iplweb/django-pg-baseline (matches BPP's other
extracted-package repos).
- v0.1.0 — minimum viable extraction. Ship as-is from BPP,
minus the cosmetic BPP names from §7. Document Modes A and C.
get_baseline_path()is the only stable top-level export and is the contract surface for package #2 (even before #2 ships). Everything else is reachable via submodules but explicitly marked as 0.x-unstable in §3.5. - v0.2.0 — once package #2 is published, document Mode B and
the explicit
_django_pg_baseline_seededmarker contract (§4.1). May coincide with widening the test matrix. - v1.0.0 — once we've used it in BPP for a release cycle and
one external consumer has adopted it. Re-export the full helper
set from the top-level module (§3.5) and lock the API under
semver. Schema-bump rules for
meta.jsonand breaking changes from this point follow strict semver.
Towncrier fragments for changelog (matches BPP's convention):
changes/<issue>.{added,changed,fixed,removed}.md.
The pattern (baseline pg_dump + load on test-DB creation) is rediscovered
independently by every Django shop with a heavy migration history.
The off-the-shelf alternatives are all partial:
pytest-django --create-db+--reuse-db— speeds up subsequent runs but not the first one in CI; doesn't solve the "100s of migrations" boot cost.pytest-djangodjango_db_setupfixture override — DIY territory, every project rolls its own.pgcopy/pgloader— wrong layer, those are bulk-data tools, not test-DB bootstrap.- The standard advice "squash your migrations" — works once, decays
again, and squashing has its own correctness pitfalls
(data migrations, custom
RunPython, etc.).
django-pg-baseline is the missing rung: a small, focused Django app
whose entire job is "manage a baseline.sql artifact and use it
automatically." Pairs with package #2 for the testcontainer crowd,
works standalone for everyone else.