Skip to content

Commit d0659c7

Browse files
bokelleyclaude
andauthored
feat(migrate): flag MediaBuyStatus.pending_activation split in v3-to-v4 codemod (#523)
* 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 * fix(migrate): wire migration anchor + add doc section for pending_activation Per review on #523: - REMOVED_ENUM_VALUES gains the (hint, anchor) tuple shape so findings carry a migration_anchor like REMOVED_TYPES do. - New MIGRATION_v3_to_v4.md § ``MediaBuyStatus.pending_activation`` → split with the cause-to-replacement mapping and a code example, matching the anchor the codemod now emits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 57cb4e5 commit d0659c7

3 files changed

Lines changed: 116 additions & 2 deletions

File tree

MIGRATION_v3_to_v4.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,40 @@ if package.status == PackageStatus.active: ...
256256
if media_buy.status == MediaBuyStatus.active: ...
257257
```
258258

259+
### `MediaBuyStatus.pending_activation` → split
260+
261+
The single `pending_activation` enum value was split into two distinct
262+
states based on cause. The codemod flags every reference; the correct
263+
replacement is per-call-site.
264+
265+
| Cause | Replacement |
266+
| --- | --- |
267+
| Buy is scheduled and waiting for its start time | `MediaBuyStatus.pending_start` |
268+
| Buy is waiting on creative approval / asset processing | `MediaBuyStatus.pending_creatives` |
269+
270+
**Before (v3.x):**
271+
```python
272+
if media_buy.status == MediaBuyStatus.pending_activation:
273+
notify_trafficker(media_buy)
274+
```
275+
276+
**After (v4.0):**
277+
```python
278+
if media_buy.status in (
279+
MediaBuyStatus.pending_start,
280+
MediaBuyStatus.pending_creatives,
281+
):
282+
notify_trafficker(media_buy)
283+
```
284+
285+
When the original branch only fired for one cause, narrow to the right
286+
state (e.g. only `pending_creatives` for creative-review notifications).
287+
A blanket replacement to either single value is almost always wrong —
288+
the spec split was driven by adopters needing distinct behaviour for
289+
the two cases. The wire enum still accepts `pending` as a legacy alias
290+
for `pending_start`, so existing buyer clients reading older payloads
291+
keep working without code changes.
292+
259293
### `ResolvedBrand.brand_manifest` field removed
260294

261295
`RegistryClient.lookup_brand()` returns a `ResolvedBrand` whose

src/adcp/migrate/v3_to_v4.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,18 @@
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, tuple[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+
"mediabuystatuspending_activation--split",
117+
),
118+
}
119+
120+
109121
# Private-module imports that shouldn't appear in downstream code.
110122
PRIVATE_IMPORT_PATHS: dict[str, str] = {
111123
"adcp.types.generated_poc": (
@@ -168,7 +180,9 @@
168180
class Finding:
169181
"""One migration finding — either an applied rename or a manual TODO."""
170182

171-
kind: str # "rename" | "flag_removed" | "flag_private" | "flag_numbered" | "flag_attribute"
183+
# Valid kind values: "rename" | "flag_removed" | "flag_private" |
184+
# "flag_numbered" | "flag_attribute" | "flag_enum_value"
185+
kind: str
172186
path: str
173187
line: int
174188
column: int
@@ -253,6 +267,12 @@ def _iter_python_files(root: Path) -> list[Path]:
253267
attr: re.compile(rf"{re.escape(attr)}\b") for attr in REMOVED_ATTRIBUTE_ACCESSES
254268
}
255269

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

257277
def scan_file(path: Path, *, apply_changes: bool) -> tuple[list[Finding], str | None]:
258278
"""Scan one file. Returns (findings, new_contents_or_None).
@@ -399,6 +419,24 @@ def scan_file(path: Path, *, apply_changes: bool) -> tuple[list[Finding], str |
399419
)
400420
)
401421

422+
# Removed enum values (e.g. MediaBuyStatus.pending_activation). The
423+
# class-qualified form is anchored tightly enough that false positives
424+
# are unlikely; trailing word boundary prevents suffix matches like
425+
# ``MediaBuyStatus.pending_activation_v2``.
426+
for enum_val, (enum_hint, enum_anchor) in REMOVED_ENUM_VALUES.items():
427+
for match in _REMOVED_ENUM_VALUE_PATTERNS[enum_val].finditer(line):
428+
findings.append(
429+
Finding(
430+
kind="flag_enum_value",
431+
path=str(path),
432+
line=lineno,
433+
column=match.start() + 1,
434+
before=enum_val,
435+
hint=enum_hint,
436+
migration_anchor=enum_anchor,
437+
)
438+
)
439+
402440
if apply_changes and rename_hits:
403441
for old, new in ASSET_CONTENT_RENAMES.items():
404442
updated = _RENAME_PATTERNS[old].sub(new, updated)
@@ -513,7 +551,8 @@ def _format_text_report(report: Report, *, apply_changes: bool) -> str:
513551
"before": str, "after": str, "hint": null, "migration_anchor": null}
514552
],
515553
"flagged": [
516-
{"kind": "flag_removed" | "flag_numbered" | "flag_private" | "flag_attribute",
554+
{"kind": "flag_removed" | "flag_numbered" | "flag_private"
555+
| "flag_attribute" | "flag_enum_value",
517556
"path": str, "line": int, "column": int, "before": str,
518557
"after": null, "hint": str | null, "migration_anchor": str | null}
519558
]

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)