pytest-mrt 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pytest_mrt/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .config import MRTConfig
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["MRTConfig"]
File without changes
pytest_mrt/cli.py ADDED
@@ -0,0 +1,63 @@
1
+ import typer
2
+ from rich.console import Console
3
+ from rich.table import Table
4
+ from rich import box
5
+
6
+ from . import __version__
7
+ from .core.detector import analyze_migrations
8
+
9
+ app = typer.Typer(
10
+ name="mrt",
11
+ help="MRT — Migration Rollback Tester",
12
+ no_args_is_help=True,
13
+ )
14
+ console = Console()
15
+
16
+
17
+ @app.command("version")
18
+ def version_cmd() -> None:
19
+ """Show version."""
20
+ console.print(f"pytest-mrt {__version__}")
21
+
22
+
23
+ def _severity_color(s: str) -> str:
24
+ return "red" if s == "error" else "yellow"
25
+
26
+
27
+ @app.command("check")
28
+ def check(
29
+ versions_dir: str = typer.Argument(help="Path to Alembic versions directory"),
30
+ strict: bool = typer.Option(False, "--strict", help="Exit 1 on warnings too"),
31
+ ) -> None:
32
+ """Statically analyze migrations for rollback risk patterns."""
33
+ warnings = analyze_migrations(versions_dir)
34
+
35
+ if not warnings:
36
+ console.print("[green]✓ No rollback risks detected.[/green]")
37
+ raise typer.Exit(0)
38
+
39
+ errors = [w for w in warnings if w.severity == "error"]
40
+ warns = [w for w in warnings if w.severity == "warning"]
41
+
42
+ table = Table(box=box.ROUNDED, title="Rollback Risk Analysis", show_lines=True)
43
+ table.add_column("Revision", style="cyan", no_wrap=True)
44
+ table.add_column("Pattern", no_wrap=True)
45
+ table.add_column("Sev", no_wrap=True)
46
+ table.add_column("Message")
47
+
48
+ for w in warnings:
49
+ c = _severity_color(w.severity)
50
+ table.add_row(w.revision, w.pattern, f"[{c}]{w.severity}[/{c}]", w.message)
51
+
52
+ console.print(table)
53
+ console.print()
54
+
55
+ if errors:
56
+ console.print(f"[red]{len(errors)} error(s)[/red], [yellow]{len(warns)} warning(s)[/yellow]")
57
+ raise typer.Exit(1)
58
+ elif warns and strict:
59
+ console.print(f"[yellow]{len(warns)} warning(s)[/yellow] (--strict mode)")
60
+ raise typer.Exit(1)
61
+ else:
62
+ console.print(f"[yellow]{len(warns)} warning(s)[/yellow] — review before deploying")
63
+ raise typer.Exit(0)
pytest_mrt/config.py ADDED
@@ -0,0 +1,8 @@
1
+ from dataclasses import dataclass, field
2
+
3
+
4
+ @dataclass
5
+ class MRTConfig:
6
+ alembic_ini: str = "alembic.ini"
7
+ db_url: str = ""
8
+ seed_rows: int = 10
File without changes
@@ -0,0 +1,259 @@
1
+ from __future__ import annotations
2
+ import re
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass
8
+ class RiskWarning:
9
+ revision: str
10
+ file: str
11
+ pattern: str
12
+ message: str
13
+ severity: str # "error" | "warning"
14
+
15
+
16
+ # ──────────────────────────────────────────────
17
+ # helpers
18
+ # ──────────────────────────────────────────────
19
+
20
+ def _fn_body(source: str, fn_name: str) -> str:
21
+ """Extract the body of a top-level function."""
22
+ m = re.search(
23
+ rf"def {fn_name}\s*\([^)]*\)\s*(?:->.*?)?\s*:\s*\n((?:[ \t]+[^\n]*\n?)*)",
24
+ source,
25
+ )
26
+ return m.group(1) if m else ""
27
+
28
+
29
+ def _upgrade_body(source: str) -> str:
30
+ return _fn_body(source, "upgrade")
31
+
32
+
33
+ def _downgrade_body(source: str) -> str:
34
+ return _fn_body(source, "downgrade")
35
+
36
+
37
+ def _is_noop(body: str) -> bool:
38
+ stripped = body.strip()
39
+ return stripped in ("", "pass") or stripped.startswith("pass\n")
40
+
41
+
42
+ # ──────────────────────────────────────────────
43
+ # individual checks
44
+ # ──────────────────────────────────────────────
45
+
46
+ def _check_downgrade_exists(source: str, rev: str, fname: str) -> list[RiskWarning]:
47
+ if not re.search(r"def downgrade\s*\(", source):
48
+ return [RiskWarning(rev, fname, "Missing downgrade",
49
+ "No downgrade() function — migration is permanently irreversible", "error")]
50
+ return []
51
+
52
+
53
+ def _check_noop_downgrade(source: str, rev: str, fname: str) -> list[RiskWarning]:
54
+ body = _downgrade_body(source)
55
+ if body and _is_noop(body):
56
+ return [RiskWarning(rev, fname, "No-op downgrade",
57
+ "downgrade() body is `pass` — migration is irreversible", "error")]
58
+ return []
59
+
60
+
61
+ def _check_drop_column_in_upgrade(source: str, rev: str, fname: str) -> list[RiskWarning]:
62
+ if re.search(r"op\.drop_column\s*\(", _upgrade_body(source)):
63
+ return [RiskWarning(rev, fname, "DROP COLUMN in upgrade",
64
+ "Column dropped in upgrade — data is permanently lost on rollback", "error")]
65
+ return []
66
+
67
+
68
+ def _check_drop_table_in_upgrade(source: str, rev: str, fname: str) -> list[RiskWarning]:
69
+ if re.search(r"op\.drop_table\s*\(", _upgrade_body(source)):
70
+ return [RiskWarning(rev, fname, "DROP TABLE in upgrade",
71
+ "Table dropped in upgrade — all data is permanently lost on rollback", "error")]
72
+ return []
73
+
74
+
75
+ def _check_truncate(source: str, rev: str, fname: str) -> list[RiskWarning]:
76
+ body = _upgrade_body(source)
77
+ if re.search(r"TRUNCATE\s+", body, re.IGNORECASE):
78
+ return [RiskWarning(rev, fname, "TRUNCATE",
79
+ "TRUNCATE in upgrade destroys data — cannot be rolled back", "error")]
80
+ return []
81
+
82
+
83
+ def _check_run_python_no_reverse(source: str, rev: str, fname: str) -> list[RiskWarning]:
84
+ warnings = []
85
+ for m in re.finditer(r"op\.execute\s*\(.*?run_module|op\.run_async\s*\([^)]+\)", source, re.DOTALL):
86
+ if "reverse_func" not in m.group(0):
87
+ warnings.append(RiskWarning(rev, fname, "RunPython without reverse",
88
+ "Data transformation has no reverse_func — cannot undo", "error"))
89
+ break
90
+ # Also check bulk execute_if patterns
91
+ return warnings
92
+
93
+
94
+ def _check_not_null_no_default(source: str, rev: str, fname: str) -> list[RiskWarning]:
95
+ warnings = []
96
+ body = _upgrade_body(source)
97
+ for m in re.finditer(r"op\.add_column\s*\(([^)]+)\)", body, re.DOTALL):
98
+ col_expr = m.group(1)
99
+ has_nullable_false = re.search(r"nullable\s*=\s*False", col_expr)
100
+ has_server_default = re.search(r"server_default\s*=", col_expr)
101
+ if has_nullable_false and not has_server_default:
102
+ warnings.append(RiskWarning(
103
+ rev, fname, "NOT NULL without default",
104
+ "ADD NOT NULL column without server_default — will fail on non-empty tables "
105
+ "and rollback may leave column in invalid state",
106
+ "warning",
107
+ ))
108
+ return warnings
109
+
110
+
111
+ def _check_column_type_change(source: str, rev: str, fname: str) -> list[RiskWarning]:
112
+ warnings = []
113
+ body = _upgrade_body(source)
114
+ for m in re.finditer(r"op\.alter_column\s*\([^)]+type_\s*=", body, re.DOTALL):
115
+ warnings.append(RiskWarning(
116
+ rev, fname, "Column type change",
117
+ "Type change may be destructive — verify downgrade restores original type "
118
+ "and existing data survives conversion",
119
+ "warning",
120
+ ))
121
+ break
122
+ return warnings
123
+
124
+
125
+ def _check_column_size_shrink(source: str, rev: str, fname: str) -> list[RiskWarning]:
126
+ body = _upgrade_body(source)
127
+ # Look for VARCHAR/String with smaller length
128
+ for m in re.finditer(
129
+ r"op\.alter_column\s*\([^)]+(?:VARCHAR|String)\s*\(\s*(\d+)\s*\)", body, re.DOTALL | re.IGNORECASE
130
+ ):
131
+ return [RiskWarning(
132
+ rev, fname, "Column size change",
133
+ f"Column resized to {m.group(1)} chars — data exceeding new limit will be truncated on rollback",
134
+ "warning",
135
+ )]
136
+ return []
137
+
138
+
139
+ def _check_raw_execute(source: str, rev: str, fname: str) -> list[RiskWarning]:
140
+ body = _upgrade_body(source)
141
+ if re.search(r"op\.execute\s*\(", body):
142
+ return [RiskWarning(
143
+ rev, fname, "Raw SQL (op.execute)",
144
+ "op.execute() with raw SQL — manually verify downgrade correctly reverses this",
145
+ "warning",
146
+ )]
147
+ return []
148
+
149
+
150
+ def _check_data_migration_no_reverse(source: str, rev: str, fname: str) -> list[RiskWarning]:
151
+ """Detect bulk data transforms (UPDATE ... SET) in upgrade without reverse."""
152
+ body = _upgrade_body(source)
153
+ dg_body = _downgrade_body(source)
154
+ if re.search(r"UPDATE\s+\w+\s+SET", body, re.IGNORECASE):
155
+ if not re.search(r"UPDATE\s+\w+\s+SET", dg_body, re.IGNORECASE):
156
+ return [RiskWarning(
157
+ rev, fname, "Data transform without reverse",
158
+ "Bulk UPDATE in upgrade but not in downgrade — data transformation is one-way",
159
+ "warning",
160
+ )]
161
+ return []
162
+
163
+
164
+ def _check_cascade_delete(source: str, rev: str, fname: str) -> list[RiskWarning]:
165
+ if re.search(r"ondelete\s*=\s*['\"]CASCADE['\"]|ON DELETE CASCADE", _upgrade_body(source), re.IGNORECASE):
166
+ return [RiskWarning(
167
+ rev, fname, "CASCADE DELETE",
168
+ "FK with ON DELETE CASCADE added — child rows will be silently deleted if parent is deleted",
169
+ "warning",
170
+ )]
171
+ return []
172
+
173
+
174
+ def _check_index_without_concurrently(source: str, rev: str, fname: str) -> list[RiskWarning]:
175
+ """PostgreSQL: CREATE INDEX without CONCURRENTLY locks the table."""
176
+ body = _upgrade_body(source)
177
+ if re.search(r"op\.create_index\s*\(", body):
178
+ if not re.search(r"postgresql_concurrently\s*=\s*True", body):
179
+ return [RiskWarning(
180
+ rev, fname, "INDEX without CONCURRENTLY",
181
+ "CREATE INDEX without postgresql_concurrently=True — locks table during index build",
182
+ "warning",
183
+ )]
184
+ return []
185
+
186
+
187
+ def _check_add_column_with_default_on_large_table(source: str, rev: str, fname: str) -> list[RiskWarning]:
188
+ """PostgreSQL < 11: ADD COLUMN with DEFAULT rewrites entire table."""
189
+ body = _upgrade_body(source)
190
+ for m in re.finditer(r"op\.add_column\s*\([^)]+\)", body, re.DOTALL):
191
+ col_expr = m.group(0)
192
+ if re.search(r"server_default\s*=", col_expr) or re.search(r"default\s*=", col_expr):
193
+ return [RiskWarning(
194
+ rev, fname, "ADD COLUMN with DEFAULT",
195
+ "Adding column with DEFAULT may rewrite the entire table on PostgreSQL < 11 — "
196
+ "causes long lock on large tables",
197
+ "warning",
198
+ )]
199
+ return []
200
+
201
+
202
+ def _check_unique_constraint_on_existing(source: str, rev: str, fname: str) -> list[RiskWarning]:
203
+ body = _upgrade_body(source)
204
+ if re.search(r"op\.create_unique_constraint\s*\(", body):
205
+ return [RiskWarning(
206
+ rev, fname, "UNIQUE constraint on existing data",
207
+ "Adding UNIQUE constraint — will fail if existing rows have duplicate values",
208
+ "warning",
209
+ )]
210
+ return []
211
+
212
+
213
+ def _check_drop_not_null(source: str, rev: str, fname: str) -> list[RiskWarning]:
214
+ """Making column NOT NULL when downgrade re-adds NULL values."""
215
+ up = _upgrade_body(source)
216
+ if re.search(r"op\.alter_column\s*\([^)]+nullable\s*=\s*False", up, re.DOTALL):
217
+ down = _downgrade_body(source)
218
+ if not re.search(r"op\.alter_column\s*\([^)]+nullable\s*=\s*True", down, re.DOTALL):
219
+ return [RiskWarning(
220
+ rev, fname, "NOT NULL without reverting nullable",
221
+ "Column set to NOT NULL but downgrade does not restore nullable=True",
222
+ "warning",
223
+ )]
224
+ return []
225
+
226
+
227
+ # ──────────────────────────────────────────────
228
+ # public API
229
+ # ──────────────────────────────────────────────
230
+
231
+ _CHECKS = [
232
+ _check_downgrade_exists,
233
+ _check_noop_downgrade,
234
+ _check_drop_column_in_upgrade,
235
+ _check_drop_table_in_upgrade,
236
+ _check_truncate,
237
+ _check_run_python_no_reverse,
238
+ _check_not_null_no_default,
239
+ _check_column_type_change,
240
+ _check_column_size_shrink,
241
+ _check_raw_execute,
242
+ _check_data_migration_no_reverse,
243
+ _check_cascade_delete,
244
+ _check_index_without_concurrently,
245
+ _check_add_column_with_default_on_large_table,
246
+ _check_unique_constraint_on_existing,
247
+ _check_drop_not_null,
248
+ ]
249
+
250
+
251
+ def analyze_migrations(versions_dir: str) -> list[RiskWarning]:
252
+ warnings: list[RiskWarning] = []
253
+ for path in sorted(Path(versions_dir).glob("*.py")):
254
+ source = path.read_text()
255
+ m = re.search(r'revision\s*=\s*["\']([^"\']+)["\']', source)
256
+ revision = m.group(1) if m else path.stem
257
+ for check in _CHECKS:
258
+ warnings.extend(check(source, revision, path.name))
259
+ return warnings
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from alembic import command
4
+ from alembic.config import Config as AlembicConfig
5
+ from alembic.script import ScriptDirectory
6
+ from sqlalchemy import create_engine
7
+ from sqlalchemy.engine import Engine
8
+
9
+
10
+ class MigrationRunner:
11
+ def __init__(self, alembic_ini: str, db_url: str):
12
+ self.db_url = db_url
13
+ self.alembic_cfg = AlembicConfig(alembic_ini)
14
+ self.alembic_cfg.set_main_option("sqlalchemy.url", db_url)
15
+ self.engine: Engine = create_engine(db_url)
16
+
17
+ def upgrade(self, revision: str = "head") -> None:
18
+ command.upgrade(self.alembic_cfg, revision)
19
+
20
+ def downgrade(self, revision: str = "-1") -> None:
21
+ command.downgrade(self.alembic_cfg, revision)
22
+
23
+ def downgrade_base(self) -> None:
24
+ command.downgrade(self.alembic_cfg, "base")
25
+
26
+ def current_revision(self) -> str | None:
27
+ from alembic.runtime.migration import MigrationContext
28
+ with self.engine.connect() as conn:
29
+ ctx = MigrationContext.configure(conn)
30
+ return ctx.get_current_revision()
31
+
32
+ def get_revisions(self) -> list:
33
+ """All revisions in upgrade order (oldest → newest)."""
34
+ script = ScriptDirectory.from_config(self.alembic_cfg)
35
+ return list(reversed(list(script.walk_revisions("base", "heads"))))
36
+
37
+ def get_versions_dir(self) -> str:
38
+ script = ScriptDirectory.from_config(self.alembic_cfg)
39
+ return script.dir
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from sqlalchemy import inspect
4
+ from sqlalchemy.engine import Engine
5
+
6
+ _INTERNAL_TABLES = {"alembic_version"}
7
+
8
+
9
+ @dataclass
10
+ class ColumnInfo:
11
+ name: str
12
+ type_str: str
13
+ nullable: bool
14
+ default: str | None = None
15
+ primary_key: bool = False
16
+
17
+
18
+ @dataclass
19
+ class TableInfo:
20
+ name: str
21
+ columns: dict[str, ColumnInfo] = field(default_factory=dict)
22
+ pk_cols: list[str] = field(default_factory=list)
23
+ fk_tables: list[str] = field(default_factory=list)
24
+
25
+
26
+ @dataclass
27
+ class SchemaSnapshot:
28
+ tables: dict[str, TableInfo] = field(default_factory=dict)
29
+
30
+ @classmethod
31
+ def capture(cls, engine: Engine) -> SchemaSnapshot:
32
+ snap = cls()
33
+ with engine.connect() as conn:
34
+ insp = inspect(conn)
35
+ for tname in insp.get_table_names():
36
+ if tname in _INTERNAL_TABLES:
37
+ continue
38
+ ti = TableInfo(name=tname)
39
+ pk_info = insp.get_pk_constraint(tname)
40
+ ti.pk_cols = pk_info.get("constrained_columns", [])
41
+ ti.fk_tables = list({
42
+ fk["referred_table"]
43
+ for fk in insp.get_foreign_keys(tname)
44
+ })
45
+ for col in insp.get_columns(tname):
46
+ ti.columns[col["name"]] = ColumnInfo(
47
+ name=col["name"],
48
+ type_str=str(col["type"]),
49
+ nullable=col.get("nullable", True),
50
+ default=str(col["default"]) if col.get("default") is not None else None,
51
+ primary_key=col["name"] in ti.pk_cols,
52
+ )
53
+ snap.tables[tname] = ti
54
+ return snap
55
+
56
+
57
+ @dataclass
58
+ class SchemaIssue:
59
+ table: str
60
+ message: str
61
+ severity: str # "error" | "warning"
62
+
63
+
64
+ @dataclass
65
+ class SchemaDiff:
66
+ dropped_tables: list[str] = field(default_factory=list)
67
+ added_tables: list[str] = field(default_factory=list)
68
+ dropped_columns: dict[str, list[str]] = field(default_factory=dict)
69
+ added_columns: dict[str, list[str]] = field(default_factory=dict)
70
+ type_changed: dict[str, list[tuple[str, str, str]]] = field(default_factory=dict)
71
+
72
+ @classmethod
73
+ def compute(cls, before: SchemaSnapshot, after: SchemaSnapshot) -> SchemaDiff:
74
+ diff = cls()
75
+ before_t = set(before.tables)
76
+ after_t = set(after.tables)
77
+ diff.dropped_tables = sorted(before_t - after_t)
78
+ diff.added_tables = sorted(after_t - before_t)
79
+ for t in before_t & after_t:
80
+ before_c = set(before.tables[t].columns)
81
+ after_c = set(after.tables[t].columns)
82
+ dropped = sorted(before_c - after_c)
83
+ added = sorted(after_c - before_c)
84
+ if dropped:
85
+ diff.dropped_columns[t] = dropped
86
+ if added:
87
+ diff.added_columns[t] = added
88
+ for col in before_c & after_c:
89
+ bt = before.tables[t].columns[col].type_str
90
+ at = after.tables[t].columns[col].type_str
91
+ if bt != at:
92
+ diff.type_changed.setdefault(t, []).append((col, bt, at))
93
+ return diff
94
+
95
+ def verify_restored(
96
+ self, before: SchemaSnapshot, after_rollback: SchemaSnapshot
97
+ ) -> list[SchemaIssue]:
98
+ issues = []
99
+ before_t = set(before.tables)
100
+ restored_t = set(after_rollback.tables)
101
+
102
+ for t in before_t - restored_t:
103
+ issues.append(SchemaIssue(t, f"Table '{t}' missing after rollback", "error"))
104
+
105
+ for t in restored_t - before_t:
106
+ issues.append(
107
+ SchemaIssue(t, f"Table '{t}' still exists after rollback — downgrade is incomplete", "error")
108
+ )
109
+
110
+ for t in before_t & restored_t:
111
+ before_c = set(before.tables[t].columns)
112
+ restored_c = set(after_rollback.tables[t].columns)
113
+ for col in before_c - restored_c:
114
+ issues.append(SchemaIssue(t, f"Column '{t}.{col}' missing after rollback", "error"))
115
+ for col in restored_c - before_c:
116
+ issues.append(
117
+ SchemaIssue(t, f"Column '{t}.{col}' still present after rollback — downgrade is incomplete", "warning")
118
+ )
119
+
120
+ return issues
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+ import re
3
+ import uuid
4
+ from datetime import date, datetime, time
5
+ from typing import Any
6
+
7
+ from sqlalchemy import inspect, text
8
+ from sqlalchemy.engine import Engine
9
+
10
+ from .schema import ColumnInfo, TableInfo
11
+
12
+
13
+ def _generate_value(col: ColumnInfo, index: int) -> Any:
14
+ t = col.type_str.upper()
15
+ if any(x in t for x in ("INT", "SERIAL", "BIGINT", "SMALLINT")):
16
+ return index * 1000 + 1
17
+ if any(x in t for x in ("FLOAT", "DOUBLE", "REAL", "NUMERIC", "DECIMAL")):
18
+ return float(index * 1000 + 1)
19
+ if "BOOL" in t:
20
+ return True
21
+ if "UUID" in t:
22
+ return str(uuid.UUID(int=index + 10**12))
23
+ if "JSON" in t:
24
+ return '{"mrt": true}'
25
+ if any(x in t for x in ("BYTEA", "BLOB", "BINARY")):
26
+ return b"mrt_seed"
27
+ if "TIMESTAMP" in t or "DATETIME" in t:
28
+ return datetime(2024, 1, 1, index % 24, 0, 0)
29
+ if "DATE" in t:
30
+ return date(2024, 1, index % 28 + 1)
31
+ if "TIME" in t:
32
+ return time(index % 24, 0, 0)
33
+ if any(x in t for x in ("VARCHAR", "TEXT", "CHAR", "STRING", "CLOB")):
34
+ m = re.search(r"\((\d+)\)", t)
35
+ limit = int(m.group(1)) if m else 255
36
+ val = f"mrt_seed_{index:05d}"
37
+ return val[:limit]
38
+ return f"mrt_{index}"
39
+
40
+
41
+ def _topological_order(tables: dict[str, TableInfo]) -> list[str]:
42
+ """Return table names ordered so FK parents come before children."""
43
+ order: list[str] = []
44
+ visited: set[str] = set()
45
+
46
+ def visit(name: str) -> None:
47
+ if name in visited:
48
+ return
49
+ visited.add(name)
50
+ for parent in tables.get(name, TableInfo(name)).fk_tables:
51
+ if parent in tables:
52
+ visit(parent)
53
+ order.append(name)
54
+
55
+ for name in tables:
56
+ visit(name)
57
+ return order
58
+
59
+
60
+ class SmartSeeder:
61
+ def __init__(self, engine: Engine):
62
+ self.engine = engine
63
+ # table_name -> list of (pk_col, pk_val)
64
+ self._seeded: dict[str, list[Any]] = {}
65
+
66
+ def seed_all(self, tables: dict[str, TableInfo], count: int = 3) -> None:
67
+ for tname in _topological_order(tables):
68
+ self.seed_table(tables[tname], count)
69
+
70
+ def seed_table(self, table: TableInfo, count: int = 3) -> None:
71
+ if not table.pk_cols:
72
+ return
73
+
74
+ pk_col = table.pk_cols[0]
75
+ inserted_pks: list[Any] = []
76
+
77
+ for i in range(count):
78
+ row: dict[str, Any] = {}
79
+ for col_name, col_info in table.columns.items():
80
+ if col_info.primary_key and any(
81
+ x in col_info.type_str.upper() for x in ("SERIAL", "AUTOINCREMENT")
82
+ ):
83
+ continue
84
+ if not col_info.nullable and col_info.default is None:
85
+ row[col_name] = _generate_value(col_info, i + len(self._seeded) * 100)
86
+ elif not col_info.nullable:
87
+ row[col_name] = _generate_value(col_info, i + len(self._seeded) * 100)
88
+
89
+ if not row:
90
+ continue
91
+
92
+ cols = ", ".join(f'"{c}"' for c in row)
93
+ placeholders = ", ".join(f":{c}" for c in row)
94
+ stmt = text(f'INSERT INTO "{table.name}" ({cols}) VALUES ({placeholders})')
95
+
96
+ try:
97
+ with self.engine.begin() as conn:
98
+ conn.execute(stmt, row)
99
+ if pk_col in row:
100
+ inserted_pks.append(row[pk_col])
101
+ else:
102
+ # Auto-generated PK — fetch last inserted
103
+ result = conn.execute(
104
+ text(f'SELECT "{pk_col}" FROM "{table.name}" ORDER BY "{pk_col}" DESC LIMIT 1')
105
+ )
106
+ val = result.scalar()
107
+ if val is not None:
108
+ inserted_pks.append(val)
109
+ except Exception:
110
+ # FK or constraint we can't satisfy — skip this table
111
+ pass
112
+
113
+ if inserted_pks:
114
+ self._seeded.setdefault(table.name, []).extend(inserted_pks)
115
+
116
+ def verify(self) -> list[str]:
117
+ failures: list[str] = []
118
+ with self.engine.connect() as conn:
119
+ insp = inspect(conn)
120
+ existing = set(insp.get_table_names())
121
+
122
+ for tname, pk_vals in self._seeded.items():
123
+ if tname not in existing:
124
+ failures.append(f"Table '{tname}' no longer exists after rollback — all data lost")
125
+ continue
126
+
127
+ with self.engine.connect() as conn:
128
+ insp = inspect(conn)
129
+ pk_info = insp.get_pk_constraint(tname)
130
+ pk_cols = pk_info.get("constrained_columns", [])
131
+ if not pk_cols:
132
+ continue
133
+ pk_col = pk_cols[0]
134
+
135
+ placeholders = ", ".join(f":v{i}" for i in range(len(pk_vals)))
136
+ params = {f"v{i}": v for i, v in enumerate(pk_vals)}
137
+ result = conn.execute(
138
+ text(f'SELECT COUNT(*) FROM "{tname}" WHERE "{pk_col}" IN ({placeholders})'),
139
+ params,
140
+ )
141
+ found = result.scalar() or 0
142
+
143
+ if found < len(pk_vals):
144
+ lost = len(pk_vals) - found
145
+ failures.append(
146
+ f"Table '{tname}': {lost}/{len(pk_vals)} row(s) lost after rollback"
147
+ )
148
+
149
+ return failures
150
+
151
+ def reset(self) -> None:
152
+ self._seeded.clear()
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+
4
+ from .runner import MigrationRunner
5
+ from .schema import SchemaSnapshot, SchemaDiff
6
+ from .seeder import SmartSeeder
7
+
8
+
9
+ @dataclass
10
+ class RevisionResult:
11
+ revision: str
12
+ passed: bool
13
+ failures: list[str] = field(default_factory=list)
14
+
15
+ def failure_summary(self) -> str:
16
+ return "\n".join(f" - {f}" for f in self.failures)
17
+
18
+
19
+ class RollbackVerifier:
20
+ """
21
+ For each revision under test:
22
+ 1. Seed data into tables that exist BEFORE this migration (pre-upgrade state)
23
+ 2. Upgrade to the revision
24
+ 3. Downgrade one step
25
+ 4. Verify schema is exactly restored + seeded data survived
26
+ """
27
+
28
+ def __init__(self, runner: MigrationRunner):
29
+ self.runner = runner
30
+
31
+ def check_revision(self, revision: str) -> RevisionResult:
32
+ seeder = SmartSeeder(self.runner.engine)
33
+ failures: list[str] = []
34
+
35
+ # Capture state before this migration
36
+ schema_before = SchemaSnapshot.capture(self.runner.engine)
37
+
38
+ # Seed into pre-existing tables — these rows must survive rollback
39
+ seeder.seed_all(schema_before.tables)
40
+
41
+ # Apply and then revert the migration
42
+ self.runner.upgrade(revision)
43
+ self.runner.downgrade()
44
+
45
+ schema_restored = SchemaSnapshot.capture(self.runner.engine)
46
+
47
+ # Schema must be exactly as before (no missing tables, no leftover tables)
48
+ diff = SchemaDiff()
49
+ for issue in diff.verify_restored(schema_before, schema_restored):
50
+ failures.append(issue.message)
51
+
52
+ # Data seeded before upgrade must survive the round-trip
53
+ failures.extend(seeder.verify())
54
+
55
+ return RevisionResult(
56
+ revision=revision,
57
+ passed=len(failures) == 0,
58
+ failures=failures,
59
+ )
60
+
61
+ def check_all(self) -> list[RevisionResult]:
62
+ """Test every revision independently in sequence."""
63
+ results: list[RevisionResult] = []
64
+ self.runner.downgrade_base()
65
+
66
+ for rev in self.runner.get_revisions():
67
+ result = self.check_revision(rev.revision)
68
+ results.append(result)
69
+ # Advance to this revision so the next check starts from correct state
70
+ self.runner.upgrade(rev.revision)
71
+
72
+ return results
pytest_mrt/plugin.py ADDED
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+ import pytest
3
+
4
+ from .config import MRTConfig
5
+ from .core.runner import MigrationRunner
6
+ from .core.seeder import SmartSeeder
7
+ from .core.verifier import RevisionResult, RollbackVerifier
8
+
9
+
10
+ class MRTFixture:
11
+ def __init__(self, config: MRTConfig):
12
+ self._runner = MigrationRunner(config.alembic_ini, config.db_url)
13
+ self._seeder = SmartSeeder(self._runner.engine)
14
+ self._verifier = RollbackVerifier(self._runner)
15
+
16
+ # ── migration control ──────────────────────────────────────────────
17
+
18
+ def upgrade(self, revision: str = "head") -> None:
19
+ self._runner.upgrade(revision)
20
+
21
+ def downgrade(self, revision: str = "-1") -> None:
22
+ self._runner.downgrade(revision)
23
+
24
+ # ── manual seeding ────────────────────────────────────────────────
25
+
26
+ def seed(self, table: str, rows: list[dict], pk_col: str = "id") -> None:
27
+ from .core.schema import SchemaSnapshot
28
+ snap = SchemaSnapshot.capture(self._runner.engine)
29
+ if table in snap.tables:
30
+ self._seeder.seed_table(snap.tables[table])
31
+ else:
32
+ raise ValueError(f"Table '{table}' not found in current schema")
33
+
34
+ # ── assertions ────────────────────────────────────────────────────
35
+
36
+ def assert_data_intact(self) -> None:
37
+ failures = self._seeder.verify()
38
+ if failures:
39
+ pytest.fail("Rollback caused data loss:\n" + "\n".join(f" - {f}" for f in failures))
40
+
41
+ def check_revision(self, revision: str) -> RevisionResult:
42
+ return self._verifier.check_revision(revision)
43
+
44
+ def check_all(self) -> list[RevisionResult]:
45
+ return self._verifier.check_all()
46
+
47
+ def assert_reversible(self, revision: str = "head") -> None:
48
+ result = self._verifier.check_revision(revision)
49
+ if not result.passed:
50
+ pytest.fail(f"Migration {revision} is not safely reversible:\n{result.failure_summary()}")
51
+
52
+ def assert_all_reversible(self) -> None:
53
+ from .reporter import print_check_all_summary
54
+ results = self._verifier.check_all()
55
+ print_check_all_summary(results)
56
+ failed = [r for r in results if not r.passed]
57
+ if failed:
58
+ lines = []
59
+ for r in failed:
60
+ lines.append(f" revision {r.revision}:")
61
+ lines.append(r.failure_summary())
62
+ pytest.fail("Some migrations are not safely reversible:\n" + "\n".join(lines))
63
+
64
+ def reset(self) -> None:
65
+ self._seeder.reset()
66
+
67
+
68
+ def pytest_configure(config: pytest.Config) -> None:
69
+ config.addinivalue_line("markers", "mrt: migration rollback test")
70
+
71
+
72
+ @pytest.fixture
73
+ def mrt(request: pytest.FixtureRequest) -> MRTFixture:
74
+ cfg: MRTConfig = getattr(request.config, "_mrt_config", None) or MRTConfig()
75
+ fixture = MRTFixture(cfg)
76
+ yield fixture
77
+ fixture.reset()
pytest_mrt/reporter.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+ from rich.console import Console
3
+ from rich.panel import Panel
4
+ from rich.table import Table
5
+ from rich.text import Text
6
+ from rich import box
7
+
8
+ from .core.verifier import RevisionResult
9
+
10
+ console = Console()
11
+
12
+
13
+ def print_revision_result(result: RevisionResult) -> None:
14
+ if result.passed:
15
+ console.print(f" [green]✓[/green] [bold]{result.revision}[/bold] [dim]reversible[/dim]")
16
+ else:
17
+ console.print(f" [red]✗[/red] [bold]{result.revision}[/bold] [red]data loss detected[/red]")
18
+ for f in result.failures:
19
+ console.print(f" [dim]└─[/dim] [red]{f}[/red]")
20
+
21
+
22
+ def print_check_all_summary(results: list[RevisionResult]) -> None:
23
+ passed = [r for r in results if r.passed]
24
+ failed = [r for r in results if not r.passed]
25
+
26
+ console.print()
27
+ console.rule("[bold]MRT — Migration Rollback Test[/bold]", style="dim")
28
+ console.print()
29
+
30
+ for result in results:
31
+ print_revision_result(result)
32
+
33
+ console.print()
34
+
35
+ if not failed:
36
+ console.print(Panel(
37
+ f"[green]All {len(results)} migration(s) are safely reversible.[/green]",
38
+ border_style="green",
39
+ ))
40
+ else:
41
+ lines = Text()
42
+ lines.append(f"{len(failed)} migration(s) will cause data loss on rollback.\n\n", style="red bold")
43
+ for r in failed:
44
+ lines.append(f" {r.revision}\n", style="red")
45
+ for f in r.failures:
46
+ lines.append(f" └─ {f}\n", style="dim")
47
+ console.print(Panel(lines, border_style="red", title="[red]Rollback Unsafe[/red]"))
48
+
49
+ console.print()
50
+
51
+
52
+ def print_static_check_header(versions_dir: str) -> None:
53
+ console.print()
54
+ console.rule(f"[bold]MRT static analysis[/bold] [dim]{versions_dir}[/dim]", style="dim")
55
+ console.print()
@@ -0,0 +1,335 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-mrt
3
+ Version: 0.1.0
4
+ Summary: Catch database migration rollback failures before they reach production
5
+ Project-URL: Homepage, https://github.com/croc100/pytest-mrt
6
+ Project-URL: Repository, https://github.com/croc100/pytest-mrt
7
+ Project-URL: Issues, https://github.com/croc100/pytest-mrt/issues
8
+ Project-URL: Changelog, https://github.com/croc100/pytest-mrt/releases
9
+ Author: croc100
10
+ License: MIT License
11
+
12
+ Copyright (c) 2026 croc100
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: alembic,database,migrations,pytest,rollback,sqlalchemy,testing
33
+ Classifier: Development Status :: 4 - Beta
34
+ Classifier: Framework :: Pytest
35
+ Classifier: Intended Audience :: Developers
36
+ Classifier: License :: OSI Approved :: Apache Software License
37
+ Classifier: Operating System :: OS Independent
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3.10
40
+ Classifier: Programming Language :: Python :: 3.11
41
+ Classifier: Programming Language :: Python :: 3.12
42
+ Classifier: Topic :: Database
43
+ Classifier: Topic :: Software Development :: Testing
44
+ Requires-Python: >=3.10
45
+ Requires-Dist: alembic>=1.9
46
+ Requires-Dist: pytest>=7.0
47
+ Requires-Dist: rich>=13.0
48
+ Requires-Dist: sqlalchemy>=2.0
49
+ Requires-Dist: typer>=0.9
50
+ Provides-Extra: asyncpg
51
+ Requires-Dist: asyncpg; extra == 'asyncpg'
52
+ Provides-Extra: dev
53
+ Requires-Dist: hatch; extra == 'dev'
54
+ Requires-Dist: psycopg2-binary; extra == 'dev'
55
+ Requires-Dist: pytest; extra == 'dev'
56
+ Provides-Extra: postgres
57
+ Requires-Dist: psycopg2-binary; extra == 'postgres'
58
+ Description-Content-Type: text/markdown
59
+
60
+ # pytest-mrt
61
+
62
+ <p align="center">
63
+ <strong>Migration Rollback Tester</strong><br>
64
+ Catch database migration disasters before they reach production.
65
+ </p>
66
+
67
+ <p align="center">
68
+ <a href="https://pypi.org/project/pytest-mrt"><img src="https://img.shields.io/pypi/v/pytest-mrt?color=blue" alt="PyPI"></a>
69
+ <a href="https://github.com/croc100/pytest-mrt/actions"><img src="https://img.shields.io/github/actions/workflow/status/croc100/pytest-mrt/ci.yml?branch=main" alt="CI"></a>
70
+ <a href="https://pypi.org/project/pytest-mrt"><img src="https://img.shields.io/pypi/pyversions/pytest-mrt" alt="Python"></a>
71
+ <a href="https://github.com/croc100/pytest-mrt/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License"></a>
72
+ </p>
73
+
74
+ ---
75
+
76
+ ## The problem
77
+
78
+ It's 2am. Your new feature is deployed. Something is wrong. You run `alembic downgrade -1`.
79
+
80
+ The command succeeds. But the data is gone.
81
+
82
+ The column came back. The rows didn't.
83
+
84
+ ---
85
+
86
+ This happens because **most tools only check if your migration runs without errors** — not whether your data survives the round-trip. `alembic downgrade` can succeed while silently destroying everything it was supposed to restore.
87
+
88
+ **pytest-mrt** tests the full cycle: seed real data → upgrade → downgrade → verify nothing was lost.
89
+
90
+ ---
91
+
92
+ ## Install
93
+
94
+ ```bash
95
+ pip install pytest-mrt
96
+ ```
97
+
98
+ ---
99
+
100
+ ## Quickstart
101
+
102
+ ```python
103
+ # conftest.py
104
+ from pytest_mrt import MRTConfig
105
+
106
+ def pytest_configure(config):
107
+ config._mrt_config = MRTConfig(
108
+ alembic_ini="alembic.ini",
109
+ db_url="postgresql://localhost/myapp_test",
110
+ )
111
+ ```
112
+
113
+ ```python
114
+ # test_migrations.py
115
+ def test_all_migrations_are_reversible(mrt):
116
+ mrt.assert_all_reversible()
117
+ ```
118
+
119
+ ```
120
+ $ pytest test_migrations.py -s
121
+
122
+ ──────────── MRT — Migration Rollback Test ────────────
123
+
124
+ ✓ 001 reversible
125
+ ✓ 002 reversible
126
+ ✓ 003 reversible
127
+ ✗ 004 data loss detected
128
+ └─ Table 'users': 3/3 rows lost after rollback
129
+ ✗ 005 data loss detected
130
+ └─ Table 'users' still exists after rollback — downgrade is incomplete
131
+
132
+ ╭─────────────────────────────────────────────────────╮
133
+ │ 2 migration(s) will cause data loss on rollback. │
134
+ │ 004 │
135
+ │ └─ Table 'users': 3/3 rows lost after rollback │
136
+ │ 005 │
137
+ │ └─ Table 'users' still exists after rollback │
138
+ ╰─────────────────────────────────────────────────────╯
139
+ ```
140
+
141
+ ---
142
+
143
+ ## What it catches
144
+
145
+ ### Static analysis — before you even run
146
+
147
+ | Pattern | Severity | Why it's dangerous |
148
+ |---|---|---|
149
+ | `op.drop_column()` in upgrade | 🔴 error | Column data is permanently gone |
150
+ | `op.drop_table()` in upgrade | 🔴 error | All table data is permanently gone |
151
+ | `TRUNCATE` in migration | 🔴 error | Destroys data with no undo |
152
+ | `def downgrade(): pass` | 🔴 error | Rollback silently does nothing |
153
+ | No `downgrade()` function | 🔴 error | Migration is completely irreversible |
154
+ | `RunPython` without `reverse_func` | 🔴 error | Data transformation cannot be undone |
155
+ | `NOT NULL` without `server_default` | 🟡 warning | Will fail on non-empty tables |
156
+ | `ALTER COLUMN type_=...` | 🟡 warning | Type conversion may lose data |
157
+ | `op.execute()` with raw SQL | 🟡 warning | Cannot verify reversibility |
158
+ | Bulk `UPDATE` without reverse | 🟡 warning | One-way data transformation |
159
+ | `ON DELETE CASCADE` added | 🟡 warning | Child rows silently deleted |
160
+ | `CREATE INDEX` without `CONCURRENTLY` | 🟡 warning | Locks table during index build |
161
+ | `ADD COLUMN` with `DEFAULT` | 🟡 warning | Full table rewrite on PostgreSQL < 11 |
162
+ | `CREATE UNIQUE CONSTRAINT` | 🟡 warning | Will fail if duplicates exist |
163
+ | `NOT NULL` without restoring `nullable` | 🟡 warning | Downgrade leaves column in wrong state |
164
+
165
+ Run static analysis without a database:
166
+
167
+ ```bash
168
+ mrt check migrations/versions/
169
+ ```
170
+
171
+ ```
172
+ ╭──────────────────────────────────────────────────────────────────────────────╮
173
+ │ Rollback Risk Analysis │
174
+ ├──────────┬──────────────────────┬─────────────┬─────────────────────────── │
175
+ │ Revision │ Pattern │ Sev │ Message │
176
+ ├──────────┼──────────────────────┼─────────────┼─────────────────────────── │
177
+ │ 004 │ DROP COLUMN │ error │ Data loss on rollback │
178
+ │ 005 │ No-op downgrade │ error │ downgrade() does nothing │
179
+ │ 006 │ INDEX without CONC. │ warning │ Locks table during build │
180
+ ╰──────────────────────────────────────────────────────────────────────────────╯
181
+ 2 error(s), 1 warning(s)
182
+ ```
183
+
184
+ ### Dynamic verification — with real data
185
+
186
+ pytest-mrt seeds actual rows before each migration, then checks they survive the downgrade:
187
+
188
+ ```python
189
+ def test_specific_revision(mrt):
190
+ result = mrt.check_revision("abc123")
191
+ assert result.passed, result.failure_summary()
192
+ ```
193
+
194
+ Or test everything at once:
195
+
196
+ ```python
197
+ def test_all_migrations(mrt):
198
+ mrt.assert_all_reversible()
199
+ ```
200
+
201
+ ---
202
+
203
+ ## How it works
204
+
205
+ For each migration revision, pytest-mrt:
206
+
207
+ ```
208
+ 1. Capture schema at current state
209
+ 2. Seed real data into all existing tables
210
+ 3. Run upgrade to this revision
211
+ 4. Run downgrade (one step back)
212
+ 5. Verify schema is exactly restored
213
+ 6. Verify every seeded row survived
214
+ ```
215
+
216
+ This catches failures that syntax checks miss:
217
+ - Schema comes back, but seeded rows are gone → **data loss**
218
+ - Downgrade is a no-op, table still exists → **rollback did nothing**
219
+ - Column returns but with wrong type → **schema drift**
220
+
221
+ ---
222
+
223
+ ## Supported databases
224
+
225
+ | Database | Status |
226
+ |---|---|
227
+ | PostgreSQL | ✅ Full support |
228
+ | SQLite | ✅ Full support (great for CI) |
229
+ | MySQL / MariaDB | 🔜 Planned |
230
+
231
+ ---
232
+
233
+ ## CI integration
234
+
235
+ Add to your GitHub Actions workflow:
236
+
237
+ ```yaml
238
+ - name: Test migration rollbacks
239
+ run: pytest tests/test_migrations.py -v -s
240
+ ```
241
+
242
+ Or use the static check as a fast pre-flight:
243
+
244
+ ```yaml
245
+ - name: Static migration analysis
246
+ run: mrt check migrations/versions/ --strict
247
+ ```
248
+
249
+ `--strict` makes warnings fail the build, not just errors.
250
+
251
+ ---
252
+
253
+ ## Configuration
254
+
255
+ ```python
256
+ # conftest.py
257
+ from pytest_mrt import MRTConfig
258
+
259
+ def pytest_configure(config):
260
+ config._mrt_config = MRTConfig(
261
+ alembic_ini="alembic.ini", # path to alembic.ini
262
+ db_url="postgresql://...", # test database URL
263
+ seed_rows=5, # rows to seed per table (default: 3)
264
+ )
265
+ ```
266
+
267
+ Use environment variables for CI:
268
+
269
+ ```python
270
+ import os
271
+ from pytest_mrt import MRTConfig
272
+
273
+ def pytest_configure(config):
274
+ config._mrt_config = MRTConfig(
275
+ alembic_ini="alembic.ini",
276
+ db_url=os.environ["TEST_DATABASE_URL"],
277
+ )
278
+ ```
279
+
280
+ ---
281
+
282
+ ## Examples
283
+
284
+ See [`examples/blog/`](examples/blog/) for a complete working example with:
285
+ - Safe migrations (add nullable column, create table)
286
+ - Dangerous migrations (drop column with data, no-op downgrade)
287
+ - How pytest-mrt catches each failure
288
+
289
+ ```bash
290
+ cd examples/blog
291
+ pip install pytest-mrt
292
+ pytest test_migrations.py -v -s
293
+ ```
294
+
295
+ ---
296
+
297
+ ## FAQ
298
+
299
+ **Does it modify my production database?**
300
+ No. pytest-mrt only runs against the database URL you provide in `MRTConfig`. Always use a test database.
301
+
302
+ **Does it work with Django migrations?**
303
+ Django support is on the roadmap. Currently only Alembic is supported.
304
+
305
+ **How is this different from pytest-alembic?**
306
+ `pytest-alembic` checks that migrations run without errors and that your schema matches your models. It does **not** verify that data survives a rollback. pytest-mrt focuses specifically on that gap.
307
+
308
+ **My migration intentionally drops a column. Will this always fail?**
309
+ Yes — dropping a column destroys data. That's exactly what pytest-mrt warns you about. If you want to proceed, you can exclude specific revisions or mark the test as expected-to-fail.
310
+
311
+ ---
312
+
313
+ ## Roadmap
314
+
315
+ - [x] Alembic support
316
+ - [x] Static risk analysis CLI (`mrt check`)
317
+ - [x] Dynamic data integrity verification
318
+ - [x] GitHub Actions CI
319
+ - [ ] Django Migrations support
320
+ - [ ] MySQL / MariaDB support
321
+ - [ ] HTML report output
322
+ - [ ] Per-revision exclusions (`@mrt.skip("004", reason="...")`)
323
+ - [ ] PyPI release
324
+
325
+ ---
326
+
327
+ ## Contributing
328
+
329
+ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
330
+
331
+ ---
332
+
333
+ ## License
334
+
335
+ Apache 2.0
@@ -0,0 +1,17 @@
1
+ pytest_mrt/__init__.py,sha256=Z9a-gTCwskapo7A8bauDfG77l447IrcD1bnozyZOL7I,77
2
+ pytest_mrt/cli.py,sha256=pkXGYwbZf6Ki-RwICBlP2iMmqOEdBsf4d0TpYLxchNY,1957
3
+ pytest_mrt/config.py,sha256=50-JZLptavXqNQLwWFZMvs36s_7V5aM-xEyKalT0gBs,153
4
+ pytest_mrt/plugin.py,sha256=0hhCbex9GX_iIdLIHsnyrh5ngwtHW9RlLfSiJwmrEbw,3139
5
+ pytest_mrt/reporter.py,sha256=MBJYLw_0cZCwF_32RN0iO5XGKbiP6a4q62OPFVE84s4,1821
6
+ pytest_mrt/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ pytest_mrt/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ pytest_mrt/core/detector.py,sha256=8xhvqJUK2TW-oKtPTM7weeQSldPpxyMKsf5FiyxKVZM,10485
9
+ pytest_mrt/core/runner.py,sha256=INmvcT6rhPudxOlcuKyq2j7ewOcyj49_c_4Enab78RQ,1446
10
+ pytest_mrt/core/schema.py,sha256=_pTMqqUFicU8Wi7TcZOKXpPLkDsoqn4c96MK9mLBYyE,4322
11
+ pytest_mrt/core/seeder.py,sha256=1n_-VmMAbEr924IjxfLiJYqMZKseJNeW7A5vLuRcAUM,5456
12
+ pytest_mrt/core/verifier.py,sha256=pI3NGSxDcXd28xfbFH-K0XVLWDqUdy38jK7h81GxyV8,2315
13
+ pytest_mrt-0.1.0.dist-info/METADATA,sha256=Jxb0M37GqTy2iIXJM-j_3_ztorxsKMDoPBTLy3wzYKk,12029
14
+ pytest_mrt-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
15
+ pytest_mrt-0.1.0.dist-info/entry_points.txt,sha256=sXUH1xz6G48-QRXBUpvQnTr_LpQwXQMKsLEBP60XCiI,79
16
+ pytest_mrt-0.1.0.dist-info/licenses/LICENSE,sha256=y1u42SEfipXW9_1zN2r6M__JguUyOd7i5L6B1mHyDMc,1064
17
+ pytest_mrt-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ mrt = pytest_mrt.cli:app
3
+
4
+ [pytest11]
5
+ mrt = pytest_mrt.plugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 croc100
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.