Skip to content

Commit c006882

Browse files
committed
feat(migrate): flag MediaBuyStatus.pending_activation split in v3-to-v4 codemod
Adds a REMOVED_ENUM_VALUES dict and flag_enum_value finding kind to the v3→v4 codemod so adopters see an actionable hint rather than a runtime AttributeError after migration. The hint names both replacement values (pending_start / pending_creatives) and points to valid_actions as the runtime discriminator. https://claude.ai/code/session_01294Agd3ZTguC1drs5Beous
1 parent 863bb93 commit c006882

2 files changed

Lines changed: 80 additions & 2 deletions

File tree

src/adcp/migrate/v3_to_v4.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,17 @@
106106
}
107107

108108

109+
# Enum values removed or split between v3 and v4. Flagged (not rewritten)
110+
# because the correct replacement depends on call-site semantics.
111+
REMOVED_ENUM_VALUES: dict[str, str] = {
112+
"MediaBuyStatus.pending_activation": (
113+
"`pending_activation` split in v4: use `pending_start` if the buy hasn't reached "
114+
"its scheduled start date, or `pending_creatives` if creatives haven't been "
115+
"submitted. Check `valid_actions` on the MediaBuy response to confirm which applies."
116+
),
117+
}
118+
119+
109120
# Private-module imports that shouldn't appear in downstream code.
110121
PRIVATE_IMPORT_PATHS: dict[str, str] = {
111122
"adcp.types.generated_poc": (
@@ -168,7 +179,9 @@
168179
class Finding:
169180
"""One migration finding — either an applied rename or a manual TODO."""
170181

171-
kind: str # "rename" | "flag_removed" | "flag_private" | "flag_numbered" | "flag_attribute"
182+
# Valid kind values: "rename" | "flag_removed" | "flag_private" |
183+
# "flag_numbered" | "flag_attribute" | "flag_enum_value"
184+
kind: str
172185
path: str
173186
line: int
174187
column: int
@@ -253,6 +266,12 @@ def _iter_python_files(root: Path) -> list[Path]:
253266
attr: re.compile(rf"{re.escape(attr)}\b") for attr in REMOVED_ATTRIBUTE_ACCESSES
254267
}
255268

269+
# Enum value patterns — re.escape handles the dot so the pattern matches
270+
# the literal ``MediaBuyStatus.pending_activation``, not a regex wildcard.
271+
_REMOVED_ENUM_VALUE_PATTERNS = {
272+
val: re.compile(rf"{re.escape(val)}\b") for val in REMOVED_ENUM_VALUES
273+
}
274+
256275

257276
def scan_file(path: Path, *, apply_changes: bool) -> tuple[list[Finding], str | None]:
258277
"""Scan one file. Returns (findings, new_contents_or_None).
@@ -399,6 +418,23 @@ def scan_file(path: Path, *, apply_changes: bool) -> tuple[list[Finding], str |
399418
)
400419
)
401420

421+
# Removed enum values (e.g. MediaBuyStatus.pending_activation). The
422+
# class-qualified form is anchored tightly enough that false positives
423+
# are unlikely; trailing word boundary prevents suffix matches like
424+
# ``MediaBuyStatus.pending_activation_v2``.
425+
for enum_val, hint in REMOVED_ENUM_VALUES.items():
426+
for match in _REMOVED_ENUM_VALUE_PATTERNS[enum_val].finditer(line):
427+
findings.append(
428+
Finding(
429+
kind="flag_enum_value",
430+
path=str(path),
431+
line=lineno,
432+
column=match.start() + 1,
433+
before=enum_val,
434+
hint=hint,
435+
)
436+
)
437+
402438
if apply_changes and rename_hits:
403439
for old, new in ASSET_CONTENT_RENAMES.items():
404440
updated = _RENAME_PATTERNS[old].sub(new, updated)
@@ -513,7 +549,8 @@ def _format_text_report(report: Report, *, apply_changes: bool) -> str:
513549
"before": str, "after": str, "hint": null, "migration_anchor": null}
514550
],
515551
"flagged": [
516-
{"kind": "flag_removed" | "flag_numbered" | "flag_private" | "flag_attribute",
552+
{"kind": "flag_removed" | "flag_numbered" | "flag_private"
553+
| "flag_attribute" | "flag_enum_value",
517554
"path": str, "line": int, "column": int, "before": str,
518555
"after": null, "hint": str | null, "migration_anchor": str | null}
519556
]

tests/test_migrate_v3_to_v4.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,47 @@ def test_flags_removed_attribute_accesses(tmp_path: Path) -> None:
271271
assert attr[0].before == ".brand_manifest"
272272

273273

274+
def test_flags_removed_enum_values(tmp_path: Path) -> None:
275+
"""`MediaBuyStatus.pending_activation` references are flagged with
276+
a hint describing both replacement values and a runtime check."""
277+
_write(
278+
tmp_path,
279+
"code.py",
280+
"if status == MediaBuyStatus.pending_activation:\n"
281+
" handle_pending()\n"
282+
"# also in a comparison\n"
283+
"is_pending = mb.status is MediaBuyStatus.pending_activation\n",
284+
)
285+
286+
report = v3_to_v4.run(tmp_path, apply_changes=False)
287+
288+
enum_flags = [f for f in report.flagged if f.kind == "flag_enum_value"]
289+
assert len(enum_flags) == 2
290+
for finding in enum_flags:
291+
assert finding.before == "MediaBuyStatus.pending_activation"
292+
assert finding.hint is not None
293+
assert "pending_start" in finding.hint
294+
assert "pending_creatives" in finding.hint
295+
assert "valid_actions" in finding.hint
296+
297+
298+
def test_enum_value_word_boundary_no_false_positive(tmp_path: Path) -> None:
299+
"""`MediaBuyStatus.pending_activation_v2` must NOT be flagged —
300+
the trailing `_v2` is a word character so the word boundary fires
301+
before the suffix, not after."""
302+
_write(
303+
tmp_path,
304+
"code.py",
305+
"x = MediaBuyStatus.pending_activation_v2\n"
306+
"y = MediaBuyStatus.pending_activation_custom()\n",
307+
)
308+
309+
report = v3_to_v4.run(tmp_path, apply_changes=False)
310+
311+
enum_flags = [f for f in report.flagged if f.kind == "flag_enum_value"]
312+
assert enum_flags == [], f"false-positive on pending_activation_* suffixes: {enum_flags}"
313+
314+
274315
def test_brand_manifest_word_boundary_no_false_positive(tmp_path: Path) -> None:
275316
"""``.brand_manifest_v2`` / ``.brand_manifest_override`` are
276317
seller-specific extensions that happen to share a prefix. They

0 commit comments

Comments
 (0)