From b7c0cc28fabdf7b4f160e1a35d69dd93808bcf2e Mon Sep 17 00:00:00 2001 From: croc100 Date: Wed, 17 Jun 2026 05:26:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20v1.6.0=20=E2=80=94=20step=20control,=20?= =?UTF-8?q?Django=20mode=20guards,=20timeout=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 ++ README.md | 30 +++-- ROADMAP.md | 46 +++++--- docs/api.md | 214 +++++++++++++++++++++++++++--------- docs/cli.md | 4 +- docs/index.md | 4 +- docs/migration-guide.md | 80 ++++++++++---- docs/roadmap.md | 78 +++++++++---- pyproject.toml | 2 +- pytest_mrt/core/verifier.py | 13 +++ pytest_mrt/plugin.py | 22 +++- tests/test_plugin.py | 39 +++++++ tests/test_verifier_unit.py | 22 ++++ 13 files changed, 437 insertions(+), 129 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb536a1..783b31e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) --- +## [1.6.0] — 2026-06-17 + +### Added +- **Fine-grained migration step control** — `MRTFixture` gains `upgrade_to(revision)`, `upgrade_one()`, `downgrade_one()`, `downgrade_to(revision)`, and `current_revision()` for testing data migration logic at any intermediate point in the chain. Achieves full API parity with pytest-alembic. (#86, #92) + +### Fixed +- **Django mode step methods raised `AttributeError` instead of `RuntimeError`** — `upgrade()`, `upgrade_to()`, `upgrade_one()`, `downgrade()`, `downgrade_one()`, `downgrade_to()`, and `current_revision()` all called `self._runner.*` without checking `_django_mode`. In Django mode `_runner` is `None`, so these methods crashed with an uninformative `AttributeError`. They now raise `RuntimeError` with a clear message, consistent with `check_revision()` and `assert_reversible()`. +- **`check_static()` raised `AttributeError` in Django mode** — `check_static()` called `self._runner.get_versions_dir()` without a Django mode guard. Now raises `RuntimeError` with a message directing users to `check_migration()` / `check_all()`. +- **Timeout path did not attempt DB state recovery** — when a migration exceeded `MRTConfig.migration_timeout`, the `FuturesTimeout` was caught but no recovery was attempted. The DB could be left in an upgraded state, corrupting all subsequent revisions in `check_all()`. Recovery now mirrors the `except Exception` path added in v0.7. + +--- + ## [1.5.0] — 2026-06-11 ### Removed diff --git a/README.md b/README.md index 4f83997..25979a3 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ CI Coverage Production/Stable - Python 3.10-3.13 + Python 3.10-3.14 MIT License Contributors

@@ -167,14 +167,14 @@ Add to `.pre-commit-config.yaml` to run `mrt check` automatically before every p ```yaml # Alembic - repo: https://github.com/croc100/pytest-mrt - rev: v1.4.0 + rev: v1.5.0 hooks: - id: mrt-check args: [alembic/versions/] # Django - repo: https://github.com/croc100/pytest-mrt - rev: v1.4.0 + rev: v1.5.0 hooks: - id: mrt-check args: [myapp/migrations/] @@ -263,13 +263,25 @@ Legacy syntax `# mrt: ignore` is still supported for backward compatibility. The key difference from pytest-alembic: pytest-mrt seeds actual rows before each rollback and verifies they survive. A migration that reverses the schema cleanly but silently destroys data will pass pytest-alembic and fail pytest-mrt. -## What's new in v1.4.0 +## What's new in v1.6.0 -- **`mrt check --format json/html`** — structured JSON output for CI tooling; self-contained HTML safety report -- **`mrt check --watch`** — re-runs automatically whenever a migration file changes -- **`mrt check --min-revision`** — skip revisions older than a configured floor (mirrors `MRTConfig.minimum_downgrade_revision`) -- **Django squashmigrations detection** — MRT601/MRT602 catch unsafe `RunPython` in squashed migrations -- **`minimum_downgrade_revision` in dynamic tests** — floor now respected by the `mrt` fixture `check_all()`, not just static analysis +- **Fine-grained migration step control** — `upgrade_to()`, `upgrade_one()`, `downgrade_one()`, `downgrade_to()`, `current_revision()` let you test data migration logic at any point in the chain: + +```python +def test_data_migration(mrt): + mrt.upgrade_to("abc123") # upgrade to a specific revision + mrt.seed("users", [...]) # seed data at that checkpoint + mrt.upgrade_one() # apply exactly one more step + assert mrt.current_revision() == "def456" + mrt.downgrade_one() # roll back one step + mrt.downgrade_to("base") # roll all the way back +``` + +## Migrating from v1.4.x + +**v1.5.0 removed `mrt fix` and `mrt clean-backups`** — migration code generation is a *transform* operation, not a *verify* operation, and was out of scope. Projects relying on these commands should pin `pytest-mrt<1.5.0`. + +The `fixable` field in `mrt check --format json` output was also removed in v1.5.0. ## Changelog diff --git a/ROADMAP.md b/ROADMAP.md index f384d5c..c396ae8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -## Current status: Production/Stable (v1.3.0) +## Current status: Production/Stable (v1.5.0 on PyPI — v1.6.0 on main) pytest-mrt is production-ready. The core API (`MRTConfig`, `mrt` fixture, `mrt check`) is stable and breaking changes will be versioned. See [`docs/api.md`](docs/api.md) for the stability guarantee. @@ -64,45 +64,57 @@ breaking changes will be versioned. See [`docs/api.md`](docs/api.md) for the sta - Test coverage: `default_tests.py` and `drift.py` at 100% - Documentation fully updated (pattern counts, version refs, suppression docs) -## v1.3.0 — Incremental CI + pre-commit + Django fix (shipped) +## v1.3.0 — Incremental CI + pre-commit (shipped) - **`mrt check --since `** — check only migrations added since a given revision; eliminates re-scanning full history on every PR - **pre-commit hook** — `.pre-commit-hooks.yaml` ships with the package; two-line setup -- **Django-aware `mrt fix`** — auto-generates reverse operations for `RunSQL`, `RunPython`, `RemoveField`, `DeleteModel` with transactional DB backup/restore scaffolding -- **`mrt clean-backups`** — CLI command to remove `_mrt_backups` data after deployment --- -## v1.4.0 — Under consideration +## v1.4.0 — CI integration + rolling deploy (shipped) -- **`mrt check --watch`** — re-run on file change during development -- **Django: `squashmigrations` detection** — squashed migrations with unresolved refs -- **Per-pattern confidence scores** in JSON output -- **HTML report: source line links** — click a finding to jump to the migration file -- **Sentry integration** — report migration failures as Sentry events -- **GitHub App** — automated PR comments with migration risk summary -- **VS Code extension** — inline warnings in migration files +- **`mrt check --format json/html`** — structured JSON output for CI tooling; self-contained HTML safety report (`--output` to write file) +- **`mrt check --watch`** — re-run automatically when migration files change; Ctrl-C to stop +- **`mrt check --min-revision`** — skip revisions at or before a floor; mirrors `MRTConfig.minimum_downgrade_revision` +- **`mrt check --check-compat`** — rolling-deploy compatibility checks (MRT701–MRT705): DROP COLUMN, RENAME COLUMN, DROP TABLE, ADD NOT NULL without default, incompatible type changes +- **Django squashmigrations detection** — MRT601/MRT602: catches `RunPython` without `reverse_code` in squashed migrations and suspicious squash filenames +- **`MRTConfig.minimum_downgrade_revision`** — permanent rollback floor respected by `check_all()` in the `mrt` fixture +- **`croc100/pytest-mrt-action` v1.0.0** — GitHub Actions action that posts findings as a job summary + +--- + +## v1.5.0 — Scope reduction (breaking, shipped) + +- **Removed `mrt fix` and `mrt clean-backups`** — migration code generation is a *transform* operation, not a *verify* operation. Out of scope. Projects that rely on these must pin `pytest-mrt<1.5.0`. +- **Removed `fixable` field from `mrt check --format json`** — advertised the removed auto-fix capability. Breaking for downstream tooling that read this field. +- **Fixed** `RollbackVerifier` false positive on failed custom seeds — rows whose `INSERT` fails are no longer tracked, eliminating "lost after rollback" false positives. + +--- + +## v1.6.0 — Fine-grained step control (shipped on main, pending release) + +- **`upgrade_to(revision)`**, **`upgrade_one()`**, **`downgrade_one()`**, **`downgrade_to(revision)`**, **`current_revision()`** — call any migration step from a test, enabling mid-chain data seeding and assertion --- -## Next / Under consideration +## Under consideration -These are not committed to a specific version yet: +These are not committed to a version yet: -- **`mrt check --watch`** — re-run on file change during development -- **Django: `squashmigrations` detection** — squashed migrations with unresolved refs +- **Django squashmigrations: full rollback plan** — detect and test rollback paths through squashed migration graphs dynamically (static detection is already in v1.4.0) - **Per-pattern confidence scores** in JSON output - **HTML report: source line links** — click a finding to jump to the migration file - **Sentry integration** — report migration failures as Sentry events - **GitHub App** — automated PR comments with migration risk summary - **VS Code extension** — inline warnings in migration files +- **`mrt check --check-compat` Django support** — currently Alembic only --- ## What won't be in scope - Executing migrations in production (this is a *testing* tool only) -- **Migration code generation / auto-fix** — pytest-mrt verifies migrations; it does not rewrite them. The former `mrt fix` / `mrt clean-backups` commands were removed in v1.5.0 (out of scope: "transform", not "verify") +- **Migration code generation / auto-fix** — removed in v1.5.0 (out of scope: "transform", not "verify") - Schema diff tools (use `alembic check` or `django-migration-linter`) - ORM-agnostic support (focused on Alembic and Django) diff --git a/docs/api.md b/docs/api.md index 1ceba6b..f802548 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,16 +16,22 @@ Configuration object passed to `pytest_configure` to set up migration rollback t | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `alembic_ini` | `str` | `"alembic.ini"` | Path to your `alembic.ini` file | +| `alembic_ini` | `str` | `"alembic.ini"` | Path to your `alembic.ini` file (ignored in Django mode) | | `db_url` | `str` | `""` | SQLAlchemy database URL for the test database | | `seed_rows` | `int` | `3` | Number of rows to seed per table during rollback verification | | `skip` | `dict[str, str]` | `{}` | Revisions to skip, with documented reasons | | `severity_overrides` | `dict[str, str]` | `{}` | Override severity of specific risk patterns | | `custom_seeds` | `dict[str, Callable]` | `{}` | Custom seed functions per table | -| `custom_checks` | `list[Callable]` | `[]` | Additional static analysis check functions | -| `migration_timeout` | `int \| None` | `None` | Per-migration timeout in seconds (`None` = no limit) | +| `custom_checks` | `list[Callable]` | `[]` | Additional static analysis check functions (Alembic only) | +| `migration_timeout` | `int \| None` | `60` | Per-migration timeout in seconds (`None` = no limit) | +| `minimum_downgrade_revision` | `str \| None` | `None` | Skip revisions at or before this floor in `check_all()` | +| `target_metadata` | `str \| None` | `None` | Import path for SQLAlchemy `Base`/`MetaData` used by `assert_schema_matches()` | +| `django_settings` | `str \| None` | `None` | Django settings module — enables Django mode | +| `django_apps` | `list[str] \| None` | `None` | Restrict dynamic testing to specific Django app labels | +| `django_project_dir` | `str \| None` | `None` | Path added to `sys.path` before Django import | +| `explain_model` | `str` | `"claude-opus-4-5"` | Claude model used by `mrt explain` | -### Example +### Example (Alembic) ```python # conftest.py @@ -46,6 +52,23 @@ def pytest_configure(config): custom_seeds={ "users": lambda: [{"id": 1, "name": "Alice", "email": "alice@example.com"}], }, + minimum_downgrade_revision="a1b2c3d4", + target_metadata="myapp.models:Base", + ) +``` + +### Example (Django) + +```python +# conftest.py +import os +from pytest_mrt import MRTConfig + +def pytest_configure(config): + config._mrt_config = MRTConfig( + db_url=os.environ["TEST_DATABASE_URL"], + django_settings="myproject.settings_test", + django_apps=["users", "orders"], ) ``` @@ -62,18 +85,18 @@ skip={ ### `severity_overrides` -Promotes warnings to errors (or demotes errors to warnings) for specific risk pattern names. Pattern names match the `pattern` field in the JSON output of `mrt check`. +Promotes warnings to errors (or demotes errors to warnings) for specific risk pattern names. Pattern names match the `pattern` field in `mrt check --format json` output. ```python severity_overrides={ - "INDEX without CONCURRENTLY": "error", # treat as error in your org - "noop downgrade": "warning", # already handled by your deploy process + "INDEX without CONCURRENTLY": "error", + "noop downgrade": "warning", } ``` ### `custom_checks` -Each function receives a `MigrationAST` and returns a list of `RiskWarning` objects. Custom checks run in addition to the built-in checks. +Each function receives a `MigrationAST` and returns a list of `RiskWarning` objects. Custom checks run in addition to the built-in checks. Alembic mode only. ```python from pytest_mrt.core.ast_analyzer import MigrationAST @@ -106,28 +129,74 @@ def test_migrations(mrt): ### Migration control +> **Django mode**: `upgrade_*`, `downgrade_*`, and `current_revision()` are Alembic-only and raise `RuntimeError` in Django mode. Use `check_migration()` / `check_all()` for Django projects. + #### `mrt.upgrade(revision="head")` Run `alembic upgrade` to the given revision. ```python -mrt.upgrade("head") # upgrade to latest -mrt.upgrade("001abc") # upgrade to a specific revision +mrt.upgrade("head") +mrt.upgrade("001abc") +``` + +#### `mrt.upgrade_to(revision)` + +Upgrade to a specific revision. Equivalent to `upgrade(revision)`. + +```python +mrt.upgrade_to("abc123") +``` + +#### `mrt.upgrade_one()` + +Upgrade exactly one step from the current revision. + +```python +mrt.upgrade_one() ``` #### `mrt.downgrade(revision="-1")` -Run `alembic downgrade` by one step (or to a specific revision). +Run `alembic downgrade` by one step or to a specific revision. ```python mrt.downgrade() # roll back one step mrt.downgrade("base") # roll back to empty schema ``` +#### `mrt.downgrade_one()` + +Downgrade exactly one step from the current revision. + +```python +mrt.downgrade_one() +``` + +#### `mrt.downgrade_to(revision)` + +Downgrade to a specific revision. + +```python +mrt.downgrade_to("abc123") +mrt.downgrade_to("base") +``` + +#### `mrt.current_revision() → str | None` + +Return the current Alembic revision ID, or `None` if at base. + +```python +rev = mrt.current_revision() +assert rev == "abc123" +``` + --- ### Static analysis +> Not available in Django mode — raises `RuntimeError`. Use `check_migration()` / `check_all()` for Django rollback testing. + #### `mrt.check_static(versions_dir=None) → list[RiskWarning]` Run static analysis on migration files. Returns all detected risk warnings. @@ -156,9 +225,7 @@ def test_no_unsafe_migrations(mrt): #### `mrt.check_revision(revision) → RevisionResult` -Test a single revision for safe reversibility. The database must be at the state just _before_ this revision when called. - -Returns a `RevisionResult` (see below). +Test a single Alembic revision for safe reversibility. The database must be at the state just _before_ this revision when called. Alembic mode only. ```python mrt.upgrade("001abc") @@ -166,9 +233,18 @@ result = mrt.check_revision("002def") assert result.passed ``` -#### `mrt.check_all() → list[RevisionResult]` +#### `mrt.check_migration(app_label, migration_name) → RevisionResult` -Test every migration in the chain. Internally runs in O(n) upgrade operations. +Test a single Django migration by app label and migration name. Django mode only. + +```python +result = mrt.check_migration("users", "0003_add_phone") +assert result.passed +``` + +#### `mrt.check_all(apps=None) → list[RevisionResult]` + +Test every migration in the chain. Runs in O(n) upgrade operations. In Django mode, pass `apps` to restrict to specific app labels (overrides `MRTConfig.django_apps` for this call). ```python results = mrt.check_all() @@ -177,16 +253,16 @@ failed = [r for r in results if not r.passed] #### `mrt.assert_reversible(revision="head")` -Assert that a single revision is safely reversible. Fails the test if not. +Assert that a single Alembic revision is safely reversible. Fails the test if not. Alembic mode only. ```python def test_latest_migration(mrt): mrt.assert_reversible() ``` -#### `mrt.assert_all_reversible()` +#### `mrt.assert_all_reversible(apps=None)` -Assert every migration in the chain is safely reversible. Prints a summary table and fails if any migration fails. +Assert every migration in the chain is safely reversible. Prints a summary table and fails if any migration fails. Works for both Alembic and Django. ```python def test_all_migrations(mrt): @@ -199,36 +275,52 @@ def test_all_migrations(mrt): #### `mrt.seed(table, rows, pk_col="id")` -Manually seed rows into a table (currently open schema). +Manually seed rows into a table at the current schema state. Combine with step control methods for mid-chain data assertions. ```python -mrt.upgrade("001abc") -mrt.seed("users", [{"id": 1, "name": "Alice"}]) +def test_data_migration(mrt): + mrt.upgrade_to("abc123") + mrt.seed("users", [{"id": 1, "name": "Alice"}]) + mrt.upgrade_one() + mrt.downgrade_one() + mrt.assert_data_intact() ``` #### `mrt.assert_data_intact()` Assert that all previously seeded rows still exist and have their original values. -```python -mrt.upgrade("head") -mrt.downgrade() -mrt.assert_data_intact() -``` - #### `mrt.reset()` Clear the internal seed state. Called automatically at fixture teardown. --- +### Schema drift + +#### `mrt.assert_schema_matches(target_metadata=None, metadata_path=None)` + +Fail if the DB schema does not match the SQLAlchemy model definitions. In Django mode, delegates to `manage.py makemigrations --check`. + +```python +from myapp.models import Base + +def test_no_drift(mrt): + mrt.upgrade("head") + mrt.assert_schema_matches(Base) +``` + +Or configure once via `MRTConfig(target_metadata="myapp.models:Base")` and rely on the built-in `test_mrt_schema_matches_models`. + +--- + ## `RevisionResult` -Return type of `mrt.check_revision()` and elements of `mrt.check_all()`. +Return type of `mrt.check_revision()`, `mrt.check_migration()`, and elements of `mrt.check_all()`. | Attribute | Type | Description | |-----------|------|-------------| -| `revision` | `str` | The Alembic revision ID | +| `revision` | `str` | The revision ID (Alembic) or `app/name` (Django) | | `passed` | `bool` | `True` if rollback was safe | | `skipped` | `bool` | `True` if this revision is in `MRTConfig.skip` | | `skip_reason` | `str` | The documented reason for skipping | @@ -255,10 +347,11 @@ Returned by `mrt.check_static()` and by `custom_checks` functions. |-----------|------|-------------| | `revision` | `str` | Revision ID or filename stem | | `file` | `str` | Migration filename | -| `pattern` | `str` | Short pattern name (e.g. `"DROP COLUMN"`) | +| `pattern` | `str` | Short pattern name (e.g. `"DROP COLUMN in upgrade"`) | | `message` | `str` | Human-readable explanation | | `severity` | `str` | `"error"` or `"warning"` | | `line` | `int \| None` | Line number in the migration file | +| `code` | `str` | Rule code (e.g. `"MRT201"`) | --- @@ -266,50 +359,61 @@ Returned by `mrt.check_static()` and by `custom_checks` functions. ### `mrt check ` -Statically analyze migration files for rollback risks. +Statically analyze migration files for rollback risk patterns. Auto-detects Django migrations. ``` mrt check alembic/versions/ -mrt check alembic/versions/ --strict -mrt check alembic/versions/ --format json +mrt check myapp/migrations/ --strict +mrt check alembic/versions/ --format json --output report.json +mrt check alembic/versions/ --format html --output report.html +mrt check alembic/versions/ --watch +mrt check alembic/versions/ --since a1b2c3d4 +mrt check alembic/versions/ --check-compat ``` | Option | Default | Description | |--------|---------|-------------| -| `--strict` | `False` | Exit 1 on warnings as well as errors | -| `--format` / `-f` | `table` | Output format: `table` or `json` | +| `--strict` | `False` | Treat warnings as errors (exit 2) | +| `--format` / `-f` | `table` | Output format: `table`, `json`, or `html` | +| `--output` / `-o` | `None` | Write output to file. For `--format html` defaults to `mrt-report.html`. | +| `--since` | `None` | Only check migrations added after this revision. Alembic: revision ID. Django: `app_label.migration_name`. Graph checks (orphan, data-hole detection) are skipped when `--since` is active. | +| `--min-revision` | `None` | Skip revisions at or older than this point. Alembic: revision ID. Django: `app_label.migration_name`. Mirrors `MRTConfig.minimum_downgrade_revision`. | +| `--watch` / `-w` | `False` | Re-run automatically when migration files change. `--format table` only. Ctrl-C to stop. | +| `--check-compat` | `False` | Also run rolling-deploy compatibility checks (MRT701–MRT705). Alembic only. | -**Exit codes:** `0` = safe, `1` = errors found (or warnings with `--strict`) +**Exit codes:** `0` = no findings, `1` = warnings only, `2` = one or more errors (or warnings with `--strict`) **JSON output schema:** ```json -[ - { - "revision": "001abc", - "file": "001_create_users.py", - "pattern": "DROP COLUMN", - "severity": "error", - "message": "op.drop_column('users', 'email') — data permanently lost...", - "line": 12 - } -] +{ + "version": "1.6.0", + "checked_at": "2026-06-17T12:00:00Z", + "summary": { "total_issues": 1, "errors": 1, "warnings": 0 }, + "findings": [ + { + "file": "001_create_users.py", + "line": 12, + "rule": "MRT201", + "severity": "error", + "pattern": "DROP COLUMN in upgrade", + "message": "op.drop_column('users', 'email') — data permanently lost on rollback" + } + ] +} ``` --- -### `mrt report ` +### `mrt drift ` -Generate an HTML safety report for all migrations. +Compare the live DB schema against SQLAlchemy model definitions and print a diff. ``` -mrt report alembic/versions/ -mrt report alembic/versions/ --output report.html +mrt drift myapp.models:Base --config alembic.ini --db-url sqlite:///test.db ``` -| Option | Default | Description | -|--------|---------|-------------| -| `--output` / `-o` | `migration_report.html` | Output file path | +Exits `1` if drift is detected, `0` if schema matches. --- @@ -333,6 +437,8 @@ mrt explain alembic/versions/001_create_users.py Requires: `pip install pytest-mrt[ai]` and `ANTHROPIC_API_KEY` environment variable. +Override the model with `MRTConfig(explain_model="claude-haiku-4-5-20251001")`. + --- ### `mrt version` diff --git a/docs/cli.md b/docs/cli.md index cb16cf2..0832c63 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -145,7 +145,7 @@ Output schema: ```json { - "version": "1.4.0", + "version": "1.6.0", "checked_at": "2026-06-10T11:00:00Z", "summary": { "total_issues": 2, "errors": 1, "warnings": 1 }, "findings": [ @@ -232,7 +232,7 @@ When there are no problems: ```bash mrt version -# pytest-mrt 1.4.0 +# pytest-mrt 1.6.0 ``` --- diff --git a/docs/index.md b/docs/index.md index 97f0a81..8fc9c44 100644 --- a/docs/index.md +++ b/docs/index.md @@ -112,7 +112,9 @@ pytest tests/test_migrations.py -s |---|---|---| | PostgreSQL | Yes | Yes | | SQLite | Yes | Yes | -| MySQL / MariaDB | Yes | planned | +| MySQL / MariaDB | Yes | Yes | +| Oracle | Yes | Yes | +| SQL Server | Yes | Yes | --- diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 8583c29..f1e9f45 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -4,29 +4,79 @@ This document covers breaking changes and required actions when upgrading pytest --- -## v1.x → v1.2 +## v1.5.x → v1.6 -No breaking changes. Drop-in upgrade. +No breaking changes. Drop-in upgrade. -**New in v1.2:** +**New in v1.6:** -- Every pattern now has a unique rule code (`MRT101`–`MRT902`). -- `# noqa: MRTxxx` suppression syntax (ruff/flake8-compatible). -- Legacy `# mrt: ignore` syntax is retained and still works. +- `upgrade_to(revision)`, `upgrade_one()`, `downgrade_one()`, `downgrade_to(revision)`, `current_revision()` — fine-grained step control in the `mrt` fixture. + +**Action required:** None. + +--- + +## v1.4.x → v1.5 + +**Breaking changes:** + +| Area | Change | +|------|--------| +| `mrt fix` command | Removed entirely | +| `mrt clean-backups` command | Removed entirely | +| `mrt check --format json` | `fixable` field removed from each finding | + +**Migration steps:** + +1. Remove any `mrt fix` or `mrt clean-backups` calls from your scripts and CI pipelines. +2. If downstream tooling reads the `fixable` field from `mrt check --format json`, update it to ignore or omit that field. +3. If you have `_mrt_backups` tables left over from a previous `mrt fix` run, drop them manually. + +Pin to `pytest-mrt<1.5.0` if you need to keep using `mrt fix`. + +--- + +## v1.3.x → v1.4 -**Action required:** None. Existing suppression comments continue to work. +No breaking changes. Drop-in upgrade. + +**New in v1.4:** + +- `mrt check --format json/html` — structured output and self-contained HTML reports. +- `mrt check --watch` — re-run on file change during development. +- `mrt check --min-revision` / `MRTConfig.minimum_downgrade_revision` — rollback testing floor. +- `mrt check --check-compat` — rolling-deploy compatibility checks (MRT701–MRT705). +- Django squashmigrations detection (MRT601/MRT602). + +**Action required:** None. --- -## v1.x → v1.3 (upcoming) +## v1.x → v1.3 -No breaking changes planned. +No breaking changes. Drop-in upgrade. **New in v1.3:** - `mrt check --since ` — incremental analysis for CI. - pre-commit hook support via `.pre-commit-hooks.yaml`. +**Action required:** None. + +--- + +## v1.x → v1.2 + +No breaking changes. Drop-in upgrade. + +**New in v1.2:** + +- Every pattern now has a unique rule code (`MRT101`–`MRT902`). +- `# noqa: MRTxxx` suppression syntax (ruff/flake8-compatible). +- Legacy `# mrt: ignore` syntax is retained and still works. + +**Action required:** None. Existing suppression comments continue to work. + --- ## v0.x → v1.0 @@ -36,8 +86,8 @@ No breaking changes planned. | Area | Change | |------|--------| | `MRTConfig` | `engine_url` renamed to `db_url` | -| Default tests | 6 built-in tests are now auto-injected when `mrt` fixture is configured. Opt out per test with `skip_default_tests={"test_mrt_upgrade"}`. | -| Exit codes | `mrt check` now exits `0` (clean) / `1` (warnings) / `2` (errors). Previously always `0` or `1`. | +| Default tests | 6 built-in tests are now auto-injected when `mrt` fixture is configured. Opt out per test with `skip_default_tests={...}`. | +| Exit codes | `mrt check` now exits `0` (clean) / `1` (warnings) / `2` (errors). Previously always `0` or `1`. | **Migration steps:** @@ -47,14 +97,6 @@ No breaking changes planned. --- -## v0.8 → v0.9 - -No breaking changes. - -**New:** Django dynamic rollback support via `DjangoMigrationRunner` and `DjangoRollbackVerifier`. No action needed for Alembic-only projects. - ---- - ## Checking your installed version ```bash diff --git a/docs/roadmap.md b/docs/roadmap.md index 151f4e8..83d6617 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,49 +4,79 @@ Items are tracked as GitHub issues. This page is a high-level overview. --- -## Near-term +## Shipped -### Single-head revision check ([#72](https://github.com/croc100/pytest-mrt/issues/72)) +### v1.6.0 — Fine-grained step control (main, pending release) -Add a built-in default test that fails when the migration chain has more than one head. A diverged head is almost always an unresolved merge conflict and will break deployments silently. pytest-alembic has this; pytest-mrt should too. +- `upgrade_to()`, `upgrade_one()`, `downgrade_one()`, `downgrade_to()`, `current_revision()` — test data migration logic at any intermediate point in the migration chain -### GitHub Actions action ([#74](https://github.com/croc100/pytest-mrt/issues/74)) +### v1.5.0 — Scope reduction -A dedicated action (`croc100/pytest-mrt-action`) that wraps `mrt check`, posts findings as a job summary, and optionally annotates changed migration files inline. The `--format json` output already exists — this is the CI integration layer on top. +- Removed `mrt fix` and `mrt clean-backups` — out of scope for a *testing* tool +- Fixed `RollbackVerifier` false positive on failed custom seeds -```yaml -- uses: croc100/pytest-mrt-action@v1 - with: - migrations-dir: alembic/versions/ -``` +### v1.4.0 — CI integration + rolling deploy safety -### r/Python showcase post ([#75](https://github.com/croc100/pytest-mrt/issues/75)) +- `mrt check --format json/html` — structured output for CI pipelines and HTML safety reports +- `mrt check --watch` — re-run on file change during development +- `mrt check --min-revision` — skip revisions older than a configured floor +- `mrt check --check-compat` — rolling-deploy compatibility checks (MRT701–MRT705) +- Django squashmigrations detection — MRT601/MRT602 +- `croc100/pytest-mrt-action` v1.0.0 — GitHub Actions integration -After rolling deploy compat checks ship, write a showcase covering what pytest-mrt does, how it differs from pytest-alembic and django-migration-linter, and a concrete example. Goal: grow star count toward 50+ for awesome-django eligibility. +### v1.3.0 — Incremental CI + pre-commit + +- `mrt check --since` — scan only new migrations in PRs +- pre-commit hook (`.pre-commit-hooks.yaml`) + +### v1.1.0–v1.2.0 — Default tests + rule codes + +- Six built-in default tests auto-injected on fixture configuration +- Schema drift detection (`mrt drift`, `assert_schema_matches()`) +- MRT rule codes (MRT101–MRT902) and `# noqa: MRTxxx` suppression + +### v1.0.0 — Production/Stable + +- Full database coverage: PostgreSQL, SQLite, MySQL/MariaDB, Oracle, SQL Server +- Full migration framework coverage: Alembic (static + dynamic) and Django (static + dynamic) +- 44+ static analysis patterns, public accuracy report --- -## Medium-term +## Under consideration + +### Django squashmigrations: dynamic rollback testing -### Rolling deploy compatibility checks ([#73](https://github.com/croc100/pytest-mrt/issues/73)) +Static detection of squashmigrations is already in v1.4.0 (MRT601/MRT602). The next step is dynamic verification: run the rollback plan through the squashed migration graph and verify it succeeds. Currently skipped in `check_all()`. -Static analysis pass that checks whether a migration is safe during a rolling deploy — i.e., whether the old app version can still run against the new schema while the deploy is in progress. +### `--check-compat` Django support -This is a different axis from rollback safety: +Rolling-deploy compatibility checks (`--check-compat`, MRT7xx) are currently Alembic-only. Extending to Django migrations requires mapping Django operation types to the same compat patterns. -- **Rollback safety** (what pytest-mrt tests today): can the migration be undone? -- **Rolling deploy safety** (new): can the old app survive the new schema long enough to be replaced? +### Per-pattern confidence scores -Patterns to detect: DROP COLUMN, RENAME COLUMN, ADD NOT NULL without default, DROP TABLE, incompatible type changes. +Add a `confidence` field to `mrt check --format json` output. Lets downstream tooling suppress known false-positive-prone patterns without using `# noqa`. -New flag: `mrt check --check-compat`. Alembic and Django both. +### HTML report: source line links + +Click a finding in the HTML report to jump to the exact line in the migration file. Requires either embedding file contents or linking to the file on disk/GitHub. + +### Sentry integration + +Report dynamic rollback failures as Sentry events, enabling production-side alerting when a migration that was not tested fails in the field. + +--- -### pytest-dev contribution ([#76](https://github.com/croc100/pytest-mrt/issues/76)) +## What won't be in scope -PR [#14576](https://github.com/pytest-dev/pytest/pull/14576) to pytest-dev: show which items are missing in `dict.items()` / `dict.keys()` set comparisons (`>=`, `<=`, `>`, `<`), the same way set comparisons already work. Waiting for maintainer review. +- Executing migrations in production (this is a *testing* tool only) +- Migration code generation / auto-fix (removed in v1.5.0 — out of scope) +- Schema diff tools (use `alembic check` or `django-migration-linter`) +- ORM-agnostic support (focused on Alembic and Django) --- -## Why this order +## How to influence the roadmap -The single-head check and GitHub Action are both small, self-contained, and directly address gaps vs. competing tools. Rolling deploy compat is bigger and becomes the centerpiece of the showcase post — do it second so the post has something new to announce. +Open an issue tagged `roadmap` with your use case. +Sponsorship fast-tracks specific items — see [GitHub Sponsors](https://github.com/sponsors/croc100). diff --git a/pyproject.toml b/pyproject.toml index 095ccf0..45d328f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pytest-mrt" -version = "1.5.0" +version = "1.6.0" description = "Catch database migration rollback failures before they reach production" readme = "README.md" license = { text = "MIT" } diff --git a/pytest_mrt/core/verifier.py b/pytest_mrt/core/verifier.py index dd1baea..2ff7e61 100644 --- a/pytest_mrt/core/verifier.py +++ b/pytest_mrt/core/verifier.py @@ -150,6 +150,19 @@ def check_revision(self, revision: str) -> RevisionResult: "The migration may be deadlocked or running a long data operation. " "Increase MRTConfig.migration_timeout or split the migration." ) + # Best-effort state recovery: the timed-out thread may have + # left the DB in a partial upgrade/downgrade state. + try: + current = self.runner.current_revision() + if current != start_revision: + self.runner.downgrade_base() + if start_revision is not None: + self.runner.upgrade(start_revision) + except Exception as recovery_exc: + failures.append( + f"State recovery failed after timeout — DB may be in unknown state: " + f"{recovery_exc}" + ) else: failures = self._run_migration_check(revision, schema_before, seeder) diff --git a/pytest_mrt/plugin.py b/pytest_mrt/plugin.py index c0127c8..38ee99c 100644 --- a/pytest_mrt/plugin.py +++ b/pytest_mrt/plugin.py @@ -103,32 +103,44 @@ def __init__(self, config: MRTConfig): # ── migration control ────────────────────────────────────────────── + def _require_alembic(self, method: str) -> None: + if self._django_mode: + raise RuntimeError( + f"{method}() is not available in Django mode. " + "Use check_migration() / check_all() for Django projects." + ) + def upgrade(self, revision: str = "head") -> None: + self._require_alembic("upgrade") self._runner.upgrade(revision) def upgrade_to(self, revision: str) -> None: """Upgrade to a specific revision. Equivalent to upgrade(revision).""" + self._require_alembic("upgrade_to") self._runner.upgrade(revision) def upgrade_one(self) -> None: """Upgrade exactly one step from the current revision.""" + self._require_alembic("upgrade_one") self._runner.upgrade("+1") def downgrade(self, revision: str = "-1") -> None: + self._require_alembic("downgrade") self._runner.downgrade(revision) def downgrade_one(self) -> None: """Downgrade exactly one step from the current revision.""" + self._require_alembic("downgrade_one") self._runner.downgrade("-1") def downgrade_to(self, revision: str) -> None: """Downgrade to a specific revision.""" + self._require_alembic("downgrade_to") self._runner.downgrade(revision) def current_revision(self) -> str | None: """Return the current Alembic revision, or None if at base.""" - if self._django_mode: - raise RuntimeError("current_revision() is not available in Django mode.") + self._require_alembic("current_revision") return self._runner.current_revision() # ── manual seeding ──────────────────────────────────────────────── @@ -147,6 +159,12 @@ def check_static(self, versions_dir: str | None = None) -> list[RiskWarning]: Includes built-in checks + any custom_checks registered in MRTConfig. severity_overrides from config are applied to the results. """ + if self._django_mode: + raise RuntimeError( + "check_static() is not available in Django mode — " + "static analysis targets Alembic migration files. " + "Use mrt check_migration() / check_all() for Django rollback testing." + ) if versions_dir is None: versions_dir = self._runner.get_versions_dir() diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 48d1363..42de034 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -970,6 +970,45 @@ def test_assert_reversible_raises_in_django_mode(alembic_env): fixture.assert_reversible("001") +@pytest.mark.parametrize("method,args", [ + ("upgrade", ("head",)), + ("upgrade_to", ("abc123",)), + ("upgrade_one", ()), + ("downgrade", ()), + ("downgrade_one", ()), + ("downgrade_to", ("abc123",)), + ("current_revision", ()), +]) +def test_alembic_step_methods_raise_in_django_mode(alembic_env, method, args): + """upgrade/downgrade step methods raise RuntimeError in Django mode, not AttributeError.""" + cfg = MRTConfig(alembic_ini=alembic_env["ini"], db_url=alembic_env["db_url"]) + fixture = MRTFixture.__new__(MRTFixture) + fixture._config = cfg + fixture._django_mode = True + fixture._runner = None + fixture._verifier = None + fixture._django_verifier = None + fixture._seeder = None + + with pytest.raises(RuntimeError, match="not available in Django mode"): + getattr(fixture, method)(*args) + + +def test_check_static_raises_in_django_mode(alembic_env): + """check_static() raises RuntimeError in Django mode, not AttributeError.""" + cfg = MRTConfig(alembic_ini=alembic_env["ini"], db_url=alembic_env["db_url"]) + fixture = MRTFixture.__new__(MRTFixture) + fixture._config = cfg + fixture._django_mode = True + fixture._runner = None + fixture._verifier = None + fixture._django_verifier = None + fixture._seeder = None + + with pytest.raises(RuntimeError, match="not available in Django mode"): + fixture.check_static() + + # ── _auto_detect_django ImportError branch ──────────────────────────────────── diff --git a/tests/test_verifier_unit.py b/tests/test_verifier_unit.py index 7a21dee..e376b48 100644 --- a/tests/test_verifier_unit.py +++ b/tests/test_verifier_unit.py @@ -72,6 +72,28 @@ def slow_check(*args, **kwargs): assert any("timed out" in f for f in result.failures) +def test_check_revision_timeout_triggers_recovery(): + """After a timeout, check_revision attempts DB state recovery.""" + runner = _make_runner_mock() + # After timeout: current_revision returns "rev1" (DB is in upgraded state) + runner.current_revision.side_effect = [None, "rev1"] + + def slow_check(*args, **kwargs): + import time + + time.sleep(0.5) + + verifier = _make_verifier(runner, timeout=0.05) + + with patch.object(verifier, "_run_migration_check", side_effect=slow_check): + result = verifier.check_revision("rev1") + + assert not result.passed + assert any("timed out" in f for f in result.failures) + # current != start_revision → recovery should have been attempted + runner.downgrade_base.assert_called() + + # ── error recovery ────────────────────────────────────────────────────────────