Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ pip install pytest-mrt[oracle] # python-oracledb
pip install pytest-mrt[mssql] # pymssql
```

## Auto-fix missing reverse operations (v1.3.0)
## Auto-fix missing reverse operations

`mrt fix` generates missing reverse operations for both Alembic and Django migrations.

Expand All @@ -158,18 +158,29 @@ mrt clean-backups --db $DATABASE_URL
mrt clean-backups --db $DATABASE_URL --label 0042_remove_user_phone --yes
```

## pre-commit integration (v1.3.0)
## pre-commit integration

Add to `.pre-commit-config.yaml` to run `mrt check` automatically before every push:

```yaml
# Alembic
- repo: https://github.com/croc100/pytest-mrt
rev: v1.3.0
rev: v1.2.0
hooks:
- id: mrt-check
args: [alembic/versions/]

# Django
- repo: https://github.com/croc100/pytest-mrt
rev: v1.2.0
hooks:
- id: mrt-check
args: [myapp/migrations/]
```

## Incremental CI — `--since` (v1.3.0)
Update `rev` to the latest release tag. Run `pre-commit autoupdate` to keep it current.

## Incremental CI — `--since`

Check only migrations added since a given revision. Keeps CI fast on large codebases:

Expand Down Expand Up @@ -247,12 +258,13 @@ To suppress all MRT warnings on a line:

Legacy syntax `# mrt: ignore` is still supported for backward compatibility.

## What's new in v1.3.0
## What's new in v1.2.0

- **Django-aware `mrt fix`** — auto-generates reverse operations and data-safe backup/restore code for `RemoveField` and `DeleteModel`
- **`mrt clean-backups`** — removes `_mrt_backups` rows after a deployment is confirmed stable
- **`mrt check --since <revision>`** — incremental scan; only checks migrations added since a given git ref
- **pre-commit hook** — add two lines to `.pre-commit-config.yaml` and `mrt check` runs automatically before every push
- **MRT rule codes** (MRT101-MRT902) on all 44 patterns
- **`# noqa: MRTxxx` suppression** — ruff/flake8-compatible per-line suppression syntax
- **CLI refactored** into `commands/` subpackage
- **`mrt check --since <revision>`** — incremental scan; only checks migrations added since a given revision
- **pre-commit hook** — add to `.pre-commit-config.yaml` and `mrt check` runs automatically before every push

## Changelog

Expand Down
78 changes: 62 additions & 16 deletions pytest_mrt/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,47 @@ def _detect_django_project() -> tuple[bool, str | None]:
return False, None


def _db_url_expr(db_url: str) -> str:
"""Return db_url as a valid Python expression for conftest.py.

If the value looks like a Python expression (e.g. os.environ.get(...))
write it as-is. Otherwise wrap it in quotes so it becomes a string literal.
"""
stripped = db_url.strip()
if stripped.startswith("os.") or stripped.startswith('"') or stripped.startswith("'"):
return stripped
return f'"{stripped}"'


def _write_conftest(path: Path, alembic_ini: str, db_url: str) -> None:
url_expr = _db_url_expr(db_url)
needs_os = "os." in url_expr
imports = (
"import os\nfrom pytest_mrt import MRTConfig\n"
if needs_os
else "from pytest_mrt import MRTConfig\n"
)
path.write_text(
f"import os\n"
f"from pytest_mrt import MRTConfig\n\n\n"
f"{imports}\n\n"
f"def pytest_configure(config):\n"
f" config._mrt_config = MRTConfig(\n"
f' alembic_ini="{alembic_ini}",\n'
f" db_url={db_url},\n"
f' # skip={{"revision_id": "Reason this is a known issue"}},\n'
f" db_url={url_expr},\n"
f' # skip={{"revision_id": "Reason this migration is intentionally irreversible"}},\n'
f" )\n"
)


def _append_conftest(path: Path, alembic_ini: str, db_url: str) -> None:
url_expr = _db_url_expr(db_url)
existing = path.read_text()
addition = (
f"\n\n# Added by mrt init\n"
f"from pytest_mrt import MRTConfig\n\n\n"
f"def pytest_configure(config):\n"
f" config._mrt_config = MRTConfig(\n"
f' alembic_ini="{alembic_ini}",\n'
f" db_url={db_url},\n"
f" db_url={url_expr},\n"
f" )\n"
)
path.write_text(existing + addition)
Expand All @@ -53,20 +72,30 @@ def _init_django(detected_settings: str | None) -> None:
console.print("[yellow]Tip:[/yellow] Set DJANGO_SETTINGS_MODULE or provide it below.")
settings = typer.prompt("Django settings module (e.g. myproject.settings_test)")

console.print(
"[dim]Tip: use sqlite:///test.db for local testing, "
"or set TEST_DATABASE_URL env var for CI.[/dim]"
)
db_url = typer.prompt(
"Test database URL",
default='os.environ.get("TEST_DATABASE_URL", "sqlite:///test.db")',
default="sqlite:///test.db",
)

test_dir = "tests" if Path("tests").exists() else "."
conftest_path = Path(test_dir) / "conftest.py"

url_expr = _db_url_expr(db_url)
needs_os = "os." in url_expr
_imports = (
"import os\nfrom pytest_mrt import MRTConfig\n"
if needs_os
else "from pytest_mrt import MRTConfig\n"
)
django_conftest = (
f"import os\n"
f"from pytest_mrt import MRTConfig\n\n\n"
f"{_imports}\n\n"
f"def pytest_configure(config):\n"
f" config._mrt_config = MRTConfig(\n"
f" db_url={db_url},\n"
f" db_url={url_expr},\n"
f' django_settings="{settings}",\n'
f' # django_apps=["myapp", "otherapp"], # restrict to specific apps\n'
f" )\n"
Expand Down Expand Up @@ -97,11 +126,16 @@ def _init_django(detected_settings: str | None) -> None:
console.print(f"[green]✓[/green] Created [bold]{test_path}[/bold]")

console.print()
console.print("[bold]Next steps:[/bold]")
console.print(f" [cyan]pytest {test_dir}/test_migrations.py -s[/cyan]")
console.print("[bold]Setup complete. Next steps:[/bold]")
console.print()
console.print(" 1. Run dynamic rollback tests (requires a test DB):")
console.print(f" [cyan]pytest {test_dir}/test_migrations.py -s[/cyan]")
console.print()
console.print("[dim]Static analysis (no DB needed):[/dim]")
console.print(" [cyan]mrt check yourapp/migrations/[/cyan]")
console.print(" 2. Run static analysis (no DB needed):")
console.print(" [cyan]mrt check yourapp/migrations/[/cyan]")
console.print()
console.print(" [dim]If tests fail, run with -v for details or check docs:[/dim]")
console.print(" [dim]https://croc100.github.io/pytest-mrt[/dim]")


def init() -> None:
Expand All @@ -124,9 +158,13 @@ def init() -> None:
else:
found_ini = typer.prompt("Path to alembic.ini", default="alembic.ini")

console.print(
"[dim]Tip: use sqlite:///test.db for local testing, "
"or set TEST_DATABASE_URL env var for CI.[/dim]"
)
db_url = typer.prompt(
"Test database URL",
default='os.environ.get("TEST_DATABASE_URL", "sqlite:///test.db")',
default="sqlite:///test.db",
)

test_dir = "tests" if Path("tests").exists() else "."
Expand All @@ -153,5 +191,13 @@ def init() -> None:
console.print(f"[green]✓[/green] Created [bold]{test_path}[/bold]")

console.print()
console.print("[bold]Next steps:[/bold]")
console.print(f" [cyan]pytest {test_dir}/test_migrations.py -s[/cyan]")
console.print("[bold]Setup complete. Next steps:[/bold]")
console.print()
console.print(" 1. Run dynamic rollback tests (requires a test DB):")
console.print(f" [cyan]pytest {test_dir}/test_migrations.py -s[/cyan]")
console.print()
console.print(" 2. Run static analysis (no DB needed):")
console.print(f" [cyan]mrt check {found_ini.replace('alembic.ini', 'versions/')}[/cyan]")
console.print()
console.print(" [dim]If tests fail, run with -v for details or check docs:[/dim]")
console.print(" [dim]https://croc100.github.io/pytest-mrt[/dim]")
51 changes: 43 additions & 8 deletions pytest_mrt/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,9 @@ def __init__(self, config: MRTConfig):

if not _Path(config.alembic_ini).exists():
raise MRTConfigError(
f"\n\n alembic.ini not found: '{config.alembic_ini}'\n\n"
" If you are using Django migrations (not Alembic), use:\n\n"
" config._mrt_config = MRTConfig(\n"
" db_url=os.environ['TEST_DATABASE_URL'],\n"
" django_settings='myproject.settings_test',\n"
" )\n\n"
" See: https://croc100.github.io/pytest-mrt/quickstart/#django"
f"alembic.ini not found: '{config.alembic_ini}'\n\n"
"Check the path and update MRTConfig(alembic_ini=...) in your conftest.py.\n"
"See: https://croc100.github.io/pytest-mrt/quickstart/"
)

self._runner = MigrationRunner(config.alembic_ini, config.db_url)
Expand Down Expand Up @@ -301,6 +297,39 @@ def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line("markers", "mrt: migration rollback test")


def pytest_sessionstart(session: pytest.Session) -> None:
"""Validate MRTConfig once at session start.

Catches obvious setup errors (missing alembic.ini, etc.) before any test
runs, so the user sees one clear error instead of the same message repeated
for every collected test.
"""
cfg: MRTConfig | None = getattr(session.config, "_mrt_config", None)
if cfg is None:
return

cfg = _auto_detect_django(cfg)

# Django mode — no alembic.ini needed
if cfg.django_settings is not None:
return

from pathlib import Path

if not Path(cfg.alembic_ini).exists():
msg = (
f"alembic.ini not found: '{cfg.alembic_ini}'\n\n"
"Check the path and update MRTConfig(alembic_ini=...) in your conftest.py.\n\n"
"If you are using Django migrations (not Alembic), set django_settings instead:\n\n"
" config._mrt_config = MRTConfig(\n"
" db_url=os.environ['TEST_DATABASE_URL'],\n"
" django_settings='myproject.settings_test',\n"
" )\n\n"
"See: https://croc100.github.io/pytest-mrt/quickstart/"
)
pytest.exit(msg, returncode=4)


def pytest_collection_modifyitems(
session: pytest.Session,
config: pytest.Config,
Expand Down Expand Up @@ -336,9 +365,15 @@ def pytest_collection_modifyitems(
@pytest.fixture
def mrt(request: pytest.FixtureRequest) -> Iterator[MRTFixture]:
cfg: MRTConfig = getattr(request.config, "_mrt_config", None) or MRTConfig()
# Capture error outside the except block to avoid "During handling of the
# above exception, another exception occurred" in the traceback output.
config_error: str | None = None
try:
fixture = MRTFixture(cfg)
except MRTConfigError as e:
pytest.fail(str(e), pytrace=False)
config_error = str(e)
if config_error is not None:
pytest.fail(config_error, pytrace=False)
return # unreachable; keeps type checkers happy
yield fixture
fixture.reset()
4 changes: 2 additions & 2 deletions tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -665,13 +665,13 @@ def test_auto_detect_django_no_switch_when_explicit_django_settings(tmp_path, mo


def test_mrt_fixture_raises_on_missing_alembic_ini(tmp_path, monkeypatch):
"""MRTFixture raises MRTConfigError with a Django hint when alembic.ini is missing."""
"""MRTFixture raises MRTConfigError when alembic.ini is missing."""
from pytest_mrt.exceptions import MRTConfigError

monkeypatch.delenv("DJANGO_SETTINGS_MODULE", raising=False)

cfg = MRTConfig(alembic_ini=str(tmp_path / "nonexistent.ini"), db_url="sqlite:///test.db")
with pytest.raises(MRTConfigError, match="django_settings"):
with pytest.raises(MRTConfigError, match="alembic.ini not found"):
MRTFixture(cfg)


Expand Down
Loading