Module adcp.migrate.v3_to_v4
v3 → v4 migration for the AdCP SDK.
The spec redesign in 4.0 renamed the 9 <Type>Asset payload
classes to <Type>Content and removed several legacy types
(BrandManifest, DeliverTo, Pricing, PromotedProducts,
PromotedOfferings, FormatCategory, PackageStatus). This
module does the mechanical rewrites and prints a structured report of
everything that still needs human attention.
Two kinds of findings:
- Applied: direct name rewrites (
AudioAsset→AudioContentetc). The 9 rename targets are distinctive enough that word-boundary regex is safe; sellers should still review the diff. - Flagged: removed types, numbered
Assets<N>imports,adcp.types.generated_pocimports. These don't rewrite — the seller has to choose the replacement (e.g.BrandManifest→BrandReference(domain=...)depends on call-site context).
Invocation::
python -m adcp.migrate v3-to-v4 ./src # dry run, report only
python -m adcp.migrate v3-to-v4 ./src --apply # rewrite files in place
python -m adcp.migrate v3-to-v4 ./src --auto-apply # also rewrite safe imports
python -m adcp.migrate v3-to-v4 ./src --json # structured report
The dry run is the default — you always see what would change before
anything moves. --apply rewrites the 9 <Type>Asset renames in
place.
--auto-apply implies --apply and additionally rewrites
flag_private findings whose target symbol is a known public alias in
adcp.types, and flag_numbered findings with a documented semantic
alias (Assets81 → VideoFormatAsset, etc.).
flag_removed
findings always require human review and remain flagged even with
--auto-apply.
Commit your tree before running either write mode so
git diff is your review view.
Important
The codemod matches identifiers textually (word-boundary regex, not
AST). That's deliberate — attribute accesses, imports, type
annotations, and f-string-interpolated type names all need the
rename, and a text-match catches every context a caller cares
about. The tradeoff: a string literal like
ERROR_MSG = "AudioAsset deprecated" or a comment mentioning
AudioAsset will rewrite. Review the git diff for these
cases (usually trivially reverted) — they are the one class of
false positive the regex approach produces.
Global variables
var REPORT_SCHEMA_VERSION-
Version of the JSON report shape. CI scripts / editors parsing the migrate output key on this so a future shape change (adding a summary block, renaming fields) doesn't silently break them.
Bump the minor SDK version AND this constant when changing the JSON shape in a non-additive way. Additive changes (new optional keys) stay at the same version.
v1 shape:
.. code-block:: json
{ "schema_version": 1, "scanned_files": int, "rewritten_files": int, "applied": [ {"kind": "rename", "path": str, "line": int, "column": int, "before": str, "after": str, "hint": null, "migration_anchor": null} ], "auto_applied": [ {"kind": "auto_applied", "path": str, "line": int, "column": int, "before": str, "after": str, "hint": str | null, "migration_anchor": null} ], "flagged": [ {"kind": "flag_removed" | "flag_numbered" | "flag_private" | "flag_attribute" | "flag_enum_value", "path": str, "line": int, "column": int, "before": str, "after": str | null, "hint": str | null, "migration_anchor": str | null} ] }auto_appliedis an additive field (v1, no version bump needed). Parsers that don't know about it receive an empty array in non---auto-applyruns and can safely ignore it. Entries inflaggedalways require human attention regardless of whatauto_appliedcontains.
Functions
def main(argv: list[str] | None = None) ‑> int-
Expand source code
def main(argv: list[str] | None = None) -> int: """CLI entry point for ``python -m adcp.migrate v3-to-v4``.""" parser = argparse.ArgumentParser( prog="adcp.migrate v3-to-v4", description=( "Rewrite adcp 3.x → 4.0 ``<Type>Asset`` → ``<Type>Content`` renames " "and flag usages of removed types. " "Exits 0 when all findings are mechanical (or none); " "exits 1 when flag_removed findings remain for human review; " "exits 2 on usage errors." ), ) parser.add_argument( "path", type=Path, help="File or directory to scan (source tree root in typical use).", ) parser.add_argument( "--apply", action="store_true", help=( "Rewrite files in place. Default is dry-run (report only). " "Commit your tree first so `git diff` is your review view. " "See also --auto-apply to also mechanically fix flag_private " "and flag_numbered findings." ), ) parser.add_argument( "--auto-apply", action="store_true", dest="auto_apply", help=( "Rewrite files in place (implies --apply) and additionally " "auto-apply safe import rewrites: flag_private findings " "whose target symbol exists on adcp.types, and flag_numbered " "findings with a documented semantic alias (Assets81 → " "VideoFormatAsset, etc.). flag_removed findings always " "require human review and remain flagged; exit code 1 when " "any remain." ), ) parser.add_argument( "--allow-dirty", action="store_true", help=( "Allow --apply / --auto-apply even when the git working tree " "has uncommitted changes. Default is to refuse so `git diff` " "after the migration shows only the codemod's rewrites, " "not a mix of the seller's in-progress work and the " "codemod. Pass --allow-dirty when you know what you're " "doing (e.g. applying to a staged change deliberately)." ), ) parser.add_argument( "--json", action="store_true", help="Emit structured JSON report instead of the human-readable text.", ) args = parser.parse_args(argv) # --auto-apply implies --apply; treat them uniformly downstream. if args.auto_apply: args.apply = True if not args.path.exists(): print(f"error: path does not exist: {args.path}", file=sys.stderr) return 2 if args.apply and not args.allow_dirty and _is_dirty_tree(args.path): flag_used = "--auto-apply" if args.auto_apply else "--apply" print( f"error: {flag_used} refused on a dirty git working tree.\n" " Commit your changes first so `git diff` after the\n" " migration shows only the codemod's rewrites. Pass\n" " --allow-dirty to override (e.g. you're deliberately\n" " applying on top of staged changes).", file=sys.stderr, ) return 2 report = run(args.path, apply_changes=args.apply, auto_apply=args.auto_apply) if args.json: print(_format_json_report(report)) else: print(_format_text_report(report, apply_changes=args.apply, auto_apply=args.auto_apply)) # Return non-zero when there are manual-review findings so CI can # gate on a clean report. Applied/auto-applied rewrites alone don't # trip the gate — they're mechanical and apply cleanly. return 1 if report.flagged else 0CLI entry point for
python -m adcp.migrate v3-to-v4. def run(root: Path, *, apply_changes: bool = False, auto_apply: bool = False) ‑> Report-
Expand source code
def run(root: Path, *, apply_changes: bool = False, auto_apply: bool = False) -> Report: """Execute the migration across ``root``. Returns a :class:`Report`.""" report = Report() for path in _iter_python_files(root): report.scanned_files += 1 findings, new_contents = scan_file(path, apply_changes=apply_changes, auto_apply=auto_apply) for f in findings: report.add(f) if new_contents is not None: # newline="" preserves whatever line endings were read # (including mixed — unusual but possible). Pair with the # ``open(..., newline="")`` read in ``scan_file``. with open(path, "w", encoding="utf-8", newline="") as fh: fh.write(new_contents) report.rewritten_files += 1 return reportExecute the migration across
root. Returns a :class:Report. def scan_file(path: Path, *, apply_changes: bool, auto_apply: bool = False) ‑> tuple[list[Finding], str | None]-
Expand source code
def scan_file( path: Path, *, apply_changes: bool, auto_apply: bool = False ) -> tuple[list[Finding], str | None]: """Scan one file. Returns (findings, new_contents_or_None). new_contents_or_None is None when apply_changes=False or when no renames fired; the caller uses it as the signal to rewrite. ``auto_apply=True`` promotes safe findings to ``kind="auto_applied"`` in the returned list, but file rewrites only happen when ``apply_changes=True`` as well — ``auto_apply`` alone never writes. Reads with ``utf-8-sig`` so UTF-8-BOM-prefixed source files (legal Python, common on Windows) migrate correctly. Uses ``newline=""`` on read and write so CRLF line endings are preserved verbatim — Windows sellers otherwise get a giant noise diff where every line flips to LF. """ findings: list[Finding] = [] try: # Use ``open(..., newline="")`` over ``Path.read_text(newline=)`` # — the latter was added in 3.13 but the SDK supports 3.10+. with open(path, encoding="utf-8-sig", newline="") as fh: original = fh.read() except (UnicodeDecodeError, OSError): # Skip unreadable or non-UTF8 files; migration targets Python source. return findings, None # Detect renames per-line so the report carries column info and the # same pattern that matched detection also drives the rewrite. updated = original rename_hits = False auto_apply_hits = False # any numbered or private-import rewrites queued for lineno, line in enumerate(original.splitlines(), start=1): # Pre-pass: when this line is a single-line ``generated_poc`` # import, decide whether the line as a whole is auto-apply-safe. # An import is *unsafe* when at least one of its symbols (after # the hypothetical numbered substitution) isn't in # ``_AUTO_APPLY_PUBLIC_SYMBOLS``; rewriting one symbol while # leaving another behind would leave the line importing a # public name from a private module — guaranteed ImportError. # The rewrite block (`updated.splitlines()` later) skips # unsafe-mixed lines; the per-symbol Finding emission below # also treats numbered references on those lines as # ``flag_numbered`` rather than ``auto_applied`` so the report # matches the file content. line_is_mixed_unsafe_import = False if "adcp.types.generated_poc" in line: from_match = _GENERATED_POC_FROM_IMPORT.search(line) if from_match: raw_syms = [s.strip() for s in from_match.group(1).split(",")] pre_syms = [r.split(" as ")[0].strip() for r in raw_syms if r.strip()] post_syms = [NUMBERED_ASSETS_RENAMES.get(s, s) for s in pre_syms] if pre_syms and not all(s in _AUTO_APPLY_PUBLIC_SYMBOLS for s in post_syms): line_is_mixed_unsafe_import = True for old, new in ASSET_CONTENT_RENAMES.items(): for match in _RENAME_PATTERNS[old].finditer(line): findings.append( Finding( kind="rename", path=str(path), line=lineno, column=match.start() + 1, before=old, after=new, ) ) rename_hits = True # Removed types — flagged, not rewritten. for name, (hint, anchor) in REMOVED_TYPES.items(): for match in _REMOVED_PATTERNS[name].finditer(line): findings.append( Finding( kind="flag_removed", path=str(path), line=lineno, column=match.start() + 1, before=name, hint=hint, migration_anchor=anchor, ) ) # Numbered Assets imports / references. for match in NUMBERED_ASSETS_PATTERN.finditer(line): symbol = match.group(0) alias = NUMBERED_ASSETS_RENAMES.get(symbol) if auto_apply and alias is not None and not line_is_mixed_unsafe_import: findings.append( Finding( kind="auto_applied", path=str(path), line=lineno, column=match.start() + 1, before=symbol, after=alias, ) ) auto_apply_hits = True else: findings.append( Finding( kind="flag_numbered", path=str(path), line=lineno, column=match.start() + 1, before=symbol, after=alias, # hint toward the public alias even in flag mode hint=( "numbered Assets classes are unstable across spec revisions; " "import the semantic alias from adcp.types instead" ), migration_anchor="numbered-discriminated-union-classes-shifted", ) ) # adcp.types.generated_poc imports. # # When the line is a single-line # ``from adcp.types.generated_poc.<path> import <symbols>`` # emit one per-symbol Finding. For symbols in # GENERATED_POC_SYMBOL_MAP the Finding carries the public alias; # unknown symbols get the generic "private module" flag so they # still surface (fixing the prior silent-drop on mixed lines). # # Under ``--auto-apply`` the per-symbol findings are promoted to # ``auto_applied`` only when ALL symbols on the line are in the # map — a mixed line cannot be safely rewritten without splitting # the import statement, so it remains flagged. # # Numbered-Assets imports (e.g. ``import Assets81``) appear here # too. Those are handled by the numbered pass above and will be # fixed by the post-scan import-path rewrite; suppress the extra # generic flag so the report doesn't double-count them. for private_path, hint in PRIVATE_IMPORT_PATHS.items(): if private_path not in line: continue col = line.index(private_path) + 1 from_match = _GENERATED_POC_FROM_IMPORT.search(line) if from_match: raw_symbols = [s.strip() for s in from_match.group(1).split(",")] parsed: list[tuple[str, str | None]] = [] for raw in raw_symbols: raw = raw.strip() if not raw: continue symbol = raw.split(" as ")[0].strip() if not symbol: continue parsed.append((symbol, GENERATED_POC_SYMBOL_MAP.get(symbol))) if not parsed: findings.append( Finding( kind="flag_private", path=str(path), line=lineno, column=col, before=private_path, hint=hint, ) ) continue all_known = all( repl is not None or (auto_apply and symbol in NUMBERED_ASSETS_RENAMES) for symbol, repl in parsed ) for symbol, replacement in parsed: sym_col = line.find(symbol, from_match.start(1)) + 1 if sym_col <= 0: sym_col = col if replacement is not None: kind = "auto_applied" if (auto_apply and all_known) else "flag_private" if kind == "auto_applied": auto_apply_hits = True findings.append( Finding( kind=kind, path=str(path), line=lineno, column=sym_col, before=symbol, after=replacement, hint=( f"private module — import {symbol} from " "adcp.types (stable public API) instead" ), ) ) else: # Unknown symbol. Suppress the generic flag when # auto_apply is active and the symbol is a numbered # asset that will be renamed by the other pass — # the import-path fix covers it. if auto_apply and symbol in NUMBERED_ASSETS_RENAMES: continue findings.append( Finding( kind="flag_private", path=str(path), line=lineno, column=sym_col, before=private_path, hint=hint, ) ) else: # Multiline import, star import, or regex mismatch. findings.append( Finding( kind="flag_private", path=str(path), line=lineno, column=col, before=private_path, hint=hint, ) ) # Removed attribute accesses (.brand_manifest etc.). Regex with # trailing word boundary prevents false-positives on # ``.brand_manifest_v2``, ``.brand_manifest_override``, etc. for attr, hint in REMOVED_ATTRIBUTE_ACCESSES.items(): for match in _REMOVED_ATTRIBUTE_PATTERNS[attr].finditer(line): findings.append( Finding( kind="flag_attribute", path=str(path), line=lineno, column=match.start() + 1, before=attr, hint=hint, ) ) # Removed enum values (e.g. MediaBuyStatus.pending_activation). The # class-qualified form is anchored tightly enough that false positives # are unlikely; trailing word boundary prevents suffix matches like # ``MediaBuyStatus.pending_activation_v2``. for enum_val, (enum_hint, enum_anchor) in REMOVED_ENUM_VALUES.items(): for match in _REMOVED_ENUM_VALUE_PATTERNS[enum_val].finditer(line): findings.append( Finding( kind="flag_enum_value", path=str(path), line=lineno, column=match.start() + 1, before=enum_val, hint=enum_hint, migration_anchor=enum_anchor, ) ) needs_write = False if apply_changes and rename_hits: for old, new in ASSET_CONTENT_RENAMES.items(): updated = _RENAME_PATTERNS[old].sub(new, updated) needs_write = True if apply_changes and auto_apply and auto_apply_hits: # Process the file line-by-line so generated_poc imports get a # safety check against the post-numbered-substitution symbol set # before any rewrite happens. The earlier "Step 1: substitute # Assets<N> file-wide; Step 2: fix import paths only when safe" # ordering corrupted mixed lines like # ``from generated_poc.core.format import Assets81, Assets149`` # — Assets81 became VideoFormatAsset while Assets149 stayed, # leaving VideoFormatAsset imported from a private module. new_lines: list[str] = [] for text_line in updated.splitlines(keepends=True): is_generated_poc_import = ( "adcp.types.generated_poc" in text_line and _GENERATED_POC_FROM_IMPORT.search(text_line) is not None ) if is_generated_poc_import: m = _GENERATED_POC_FROM_IMPORT.search(text_line) assert m is not None # narrowed above raw_syms = [s.strip() for s in m.group(1).split(",")] pre_syms = [r.split(" as ")[0].strip() for r in raw_syms if r.strip()] # Apply the hypothetical numbered rename to each symbol # so we can check if the *post-rename* symbol set is # safe. post_syms = [NUMBERED_ASSETS_RENAMES.get(s, s) for s in pre_syms] if post_syms and all(s in _AUTO_APPLY_PUBLIC_SYMBOLS for s in post_syms): # Whole import is safe — substitute numbered names # AND fix the module path. for old, new in NUMBERED_ASSETS_RENAMES.items(): text_line = _NUMBERED_RENAME_PATTERNS[old].sub(new, text_line) text_line = _GENERATED_POC_MODULE_RE.sub("from adcp.types import", text_line) # Mixed line — leave it alone. The findings list still # carries the per-symbol flag_private and flag_numbered # entries so the adopter sees the work to do. new_lines.append(text_line) continue # Non-import lines: substitute numbered names freely (the # semantic alias is already importable via adcp.types and # any local reference the line carries is a usage site). for old, new in NUMBERED_ASSETS_RENAMES.items(): text_line = _NUMBERED_RENAME_PATTERNS[old].sub(new, text_line) new_lines.append(text_line) updated = "".join(new_lines) needs_write = True if needs_write: return findings, updated return findings, NoneScan one file. Returns (findings, new_contents_or_None).
new_contents_or_None is None when apply_changes=False or when no renames fired; the caller uses it as the signal to rewrite.
auto_apply=Truepromotes safe findings tokind="auto_applied"in the returned list, but file rewrites only happen whenapply_changes=Trueas well —auto_applyalone never writes.Reads with
utf-8-sigso UTF-8-BOM-prefixed source files (legal Python, common on Windows) migrate correctly. Usesnewline=""on read and write so CRLF line endings are preserved verbatim — Windows sellers otherwise get a giant noise diff where every line flips to LF.
Classes
class Finding (kind: str,
path: str,
line: int,
column: int,
before: str,
after: str | None = None,
hint: str | None = None,
migration_anchor: str | None = None)-
Expand source code
@dataclass class Finding: """One migration finding — either an applied rename or a manual TODO.""" # Valid kind values: "rename" | "auto_applied" | "flag_removed" | # "flag_private" | "flag_numbered" | "flag_attribute" | # "flag_enum_value" kind: str path: str line: int column: int before: str after: str | None = None # None for flag-only items hint: str | None = None migration_anchor: str | None = NoneOne migration finding — either an applied rename or a manual TODO.
Instance variables
var after : str | Nonevar before : strvar column : intvar hint : str | Nonevar kind : strvar line : intvar migration_anchor : str | Nonevar path : str
class Report (applied: list[Finding] = <factory>,
auto_applied: list[Finding] = <factory>,
flagged: list[Finding] = <factory>,
scanned_files: int = 0,
rewritten_files: int = 0)-
Expand source code
@dataclass class Report: """Structured migration report.""" applied: list[Finding] = field(default_factory=list) auto_applied: list[Finding] = field(default_factory=list) flagged: list[Finding] = field(default_factory=list) scanned_files: int = 0 rewritten_files: int = 0 def add(self, finding: Finding) -> None: if finding.kind == "rename": self.applied.append(finding) elif finding.kind == "auto_applied": self.auto_applied.append(finding) else: self.flagged.append(finding)Structured migration report.
Instance variables
var applied : list[Finding]var auto_applied : list[Finding]var flagged : list[Finding]var rewritten_files : intvar scanned_files : int
Methods
def add(self,
finding: Finding) ‑> None-
Expand source code
def add(self, finding: Finding) -> None: if finding.kind == "rename": self.applied.append(finding) elif finding.kind == "auto_applied": self.auto_applied.append(finding) else: self.flagged.append(finding)