diff --git a/README.md b/README.md index 027412f..2b95523 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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: @@ -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 `** — 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 `** — 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 diff --git a/pytest_mrt/commands/init.py b/pytest_mrt/commands/init.py index 0434e12..4e04ce2 100644 --- a/pytest_mrt/commands/init.py +++ b/pytest_mrt/commands/init.py @@ -17,20 +17,39 @@ 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" @@ -38,7 +57,7 @@ def _append_conftest(path: Path, alembic_ini: str, db_url: str) -> None: 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) @@ -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" @@ -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: @@ -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 "." @@ -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]")