Skip to content

Commit f0f1eec

Browse files
authored
Merge pull request #3 from HyreAS/recx/backward-compat-ignore-reason
Make IgnoreMigration without reason backcompat
2 parents 1e5d4cd + 64322d7 commit f0f1eec

File tree

4 files changed

+97
-40
lines changed

4 files changed

+97
-40
lines changed

src/django_migration_linter/migration_linter.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,12 +164,42 @@ def lint_migration(self, migration: Migration) -> None:
164164

165165
md5hash = self.get_migration_hash(app_label, migration_name)
166166

167+
# Check if migration has IgnoreMigration operation without a reason
168+
has_ignore_op = any(isinstance(o, IgnoreMigration) for o in operations)
169+
if has_ignore_op:
170+
reason = self.get_ignore_migration_reason(operations)
171+
if not reason:
172+
# IgnoreMigration without reason - show error and continue linting
173+
error = Issue(
174+
code="IGNORE_MIGRATION_MISSING_REASON",
175+
message="IgnoreMigration operation must include a reason. "
176+
"The migration will be linted because no reason was provided. "
177+
"Please add a reason: IgnoreMigration('your reason here')",
178+
table=None,
179+
column=None,
180+
)
181+
self.print_linting_msg(
182+
app_label,
183+
migration_name,
184+
f"({error.code}) {error.message}",
185+
MessageType.ERROR,
186+
)
187+
# Don't ignore - continue to lint this migration
188+
else:
189+
# IgnoreMigration with reason - ignore the migration
190+
msg = f"IGNORE - REASON: {reason}"
191+
self.print_linting_msg(
192+
app_label, migration_name, msg, MessageType.IGNORE
193+
)
194+
self.nb_ignored += 1
195+
return
196+
167197
if self.should_ignore_migration(
168-
app_label, migration_name, operations, is_initial=migration.initial
198+
app_label, migration_name, is_initial=migration.initial
169199
):
170-
reason = self.get_ignore_migration_reason(operations)
171-
msg = f"IGNORE - REASON: {reason}" if reason else "IGNORE"
172-
self.print_linting_msg(app_label, migration_name, msg, MessageType.IGNORE)
200+
self.print_linting_msg(
201+
app_label, migration_name, "IGNORE", MessageType.IGNORE
202+
)
173203
self.nb_ignored += 1
174204
return
175205

@@ -479,13 +509,13 @@ def should_ignore_migration(
479509
self,
480510
app_label: str,
481511
migration_name: str,
482-
operations: Iterable[Operation] = (),
483512
is_initial: bool = False,
484513
) -> bool:
514+
# Note: IgnoreMigration is handled separately in lint_migration()
515+
# to check for missing reason
485516
return (
486517
(self.include_apps and app_label not in self.include_apps)
487518
or (self.exclude_apps and app_label in self.exclude_apps)
488-
or any(isinstance(o, IgnoreMigration) for o in operations)
489519
or (
490520
self.ignore_name_contains
491521
and self.ignore_name_contains in migration_name

src/django_migration_linter/operations.py

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,23 @@ class IgnoreMigration(Operation):
77
"""
88
No-op migration operation that will enable the Django Migration Linter
99
to detect if the entire migration should be ignored (through code).
10-
11-
A reason must be provided to document why this migration is being ignored.
1210
"""
1311

1412
reversible = True
1513
reduces_to_sql = False
1614
elidable = True
1715

18-
def __init__(self, reason: str):
16+
def __init__(self, reason: str | None = None):
1917
"""
20-
Initialize IgnoreMigration with a required reason.
18+
Initialize IgnoreMigration with an optional reason.
2119
2220
Args:
23-
reason: A non-empty string explaining why this migration is being ignored.
24-
25-
Raises:
26-
ValueError: If reason is empty or contains only whitespace.
21+
reason: Optional string explaining why this migration is being ignored.
22+
For backward compatibility, this parameter is optional, but providing
23+
a reason is strongly encouraged and will be enforced during linting
24+
for new migrations.
2725
"""
28-
if not reason or not reason.strip():
29-
raise ValueError("IgnoreMigration requires a non-empty reason")
30-
self.reason = reason.strip()
26+
self.reason = reason.strip() if reason and reason.strip() else None
3127

3228
def state_forwards(self, app_label, state):
3329
pass
@@ -39,7 +35,9 @@ def database_backwards(self, app_label, schema_editor, from_state, to_state):
3935
pass
4036

4137
def describe(self):
42-
return (
43-
f"The Django migration linter will ignore this migration - "
44-
f"Reason: {self.reason}"
45-
)
38+
if self.reason:
39+
return (
40+
f"The Django migration linter will ignore this migration - "
41+
f"Reason: {self.reason}"
42+
)
43+
return "The Django migration linter will ignore this migration"

tests/unit/test_linter.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,24 @@ def test_get_ignore_migration_reason(self):
193193
extracted_reason = linter.get_ignore_migration_reason(operations)
194194
self.assertEqual(extracted_reason, reason)
195195

196-
# Test without IgnoreMigration operation
197-
from django.db.migrations.operations import AddField
198-
199196
operations_without_ignore = []
200197
extracted_reason = linter.get_ignore_migration_reason(operations_without_ignore)
201198
self.assertIsNone(extracted_reason)
199+
200+
def test_should_ignore_migration_without_ignore_operation(self):
201+
"""Test that should_ignore_migration works for non-IgnoreMigration conditions."""
202+
# IgnoreMigration operations are handled separately in lint_migration()
203+
# so should_ignore_migration doesn't check for them
204+
205+
# Test with app exclusion
206+
linter = MigrationLinter(exclude_apps=["app_excluded"])
207+
self.assertTrue(
208+
linter.should_ignore_migration(
209+
"app_excluded", "0001_test", is_initial=False
210+
)
211+
)
212+
self.assertFalse(
213+
linter.should_ignore_migration(
214+
"app_included", "0001_test", is_initial=False
215+
)
216+
)

tests/unit/test_operations.py

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,33 @@ def test_ignore_migration_strips_whitespace(self):
1818
operation = IgnoreMigration(reason)
1919
self.assertEqual(operation.reason, "Valid reason with whitespace")
2020

21-
def test_ignore_migration_without_reason_raises_error(self):
22-
"""Test that IgnoreMigration raises ValueError when no reason is provided."""
23-
with self.assertRaises(TypeError) as _:
24-
IgnoreMigration()
25-
26-
def test_ignore_migration_with_empty_string_raises_error(self):
27-
"""Test that IgnoreMigration raises ValueError for empty string."""
28-
with self.assertRaises(ValueError) as context:
29-
IgnoreMigration("")
30-
self.assertIn("non-empty reason", str(context.exception))
31-
32-
def test_ignore_migration_with_whitespace_only_raises_error(self):
33-
"""Test that IgnoreMigration raises ValueError for whitespace-only string."""
34-
with self.assertRaises(ValueError) as context:
35-
IgnoreMigration(" ")
36-
self.assertIn("non-empty reason", str(context.exception))
21+
def test_ignore_migration_without_reason_allowed(self):
22+
"""Test that IgnoreMigration can be instantiated without a reason for backward compatibility."""
23+
operation = IgnoreMigration()
24+
self.assertIsNone(operation.reason)
25+
26+
def test_ignore_migration_with_empty_string(self):
27+
"""Test that IgnoreMigration with empty string sets reason to None."""
28+
operation = IgnoreMigration("")
29+
self.assertIsNone(operation.reason)
30+
31+
def test_ignore_migration_with_whitespace_only(self):
32+
"""Test that IgnoreMigration with whitespace-only string sets reason to None."""
33+
operation = IgnoreMigration(" ")
34+
self.assertIsNone(operation.reason)
35+
36+
def test_ignore_migration_describe_with_reason(self):
37+
"""Test that describe() includes reason when provided."""
38+
reason = "Test reason"
39+
operation = IgnoreMigration(reason)
40+
description = operation.describe()
41+
self.assertIn(reason, description)
42+
self.assertIn("Reason:", description)
43+
44+
def test_ignore_migration_describe_without_reason(self):
45+
"""Test that describe() handles missing reason gracefully."""
46+
operation = IgnoreMigration()
47+
description = operation.describe()
48+
self.assertEqual(
49+
description, "The Django migration linter will ignore this migration"
50+
)

0 commit comments

Comments
 (0)