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]")
Loading