Skip to content

Commit 100680b

Browse files
bokelleyclaude
andauthored
fix(migrate): per-symbol replacement for generated_poc reach-ins (#329)
Round-5 adopter feedback (salesagent v3→v4 experiment, 270 files / 161 collection failures): 82 of 156 codemod findings were ``adcp.types.generated_poc`` flag-only output that forced adopters to hand-grep the public-API module to find each replacement. Fixes: - ``GENERATED_POC_SYMBOL_MAP`` covers the 8 unambiguous reach-ins with explicit "Symbol → adcp.types.Symbol" replacements: AccountReference, BrandReference, ContextObject, CreativeAsset, Error, MediaBuyStatus, ProductFilters, ReportingWebhook. The ``test_generated_poc_symbol_map_covers_publicly_exported_names`` test guards drift between the codemod and the SDK's public surface. - Multi-symbol single-line imports (``from … import A, B, C``) emit one Finding per known symbol; unknown symbols and multiline imports fall back to the generic "private module" flag. - Text report shows per-symbol replacement on the header line so adopters fix without leaving the codemod output. - ``import X as Y`` aliases are handled — the codemod keys off the canonical LHS name. Intentionally NOT mapped: ``CreditLimit``, ``Setup``, ``GovernanceAgent``. These names appear in 8+ generated files (per-schema variants like ``core/account.Setup`` vs. ``account/sync_accounts_response.Setup``); a blanket ``adcp.types.Setup`` hint would be ambiguous. Adopters reaching for these get the generic flag; landing them in the public API is a separate design decision. Migration guide additions: - "Fix removed-type imports first — they cascade" section documents salesagent's pattern of centralizing SDK imports in a barrel module (``_base.py`` re-export) where one broken import crashes ~140 tests during pytest collect-only. Calls out the priority order: barrel modules first, then generated_poc reach-ins, then numbered Assets, then per-call-site shape changes. - "a2a-sdk transitive bump" section flags the ``a2a-sdk>=1.0.0`` surprise migration for adopters with direct ``a2a.utils.errors`` / ``a2a.types`` imports. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3539359 commit 100680b

3 files changed

Lines changed: 234 additions & 8 deletions

File tree

MIGRATION_v3_to_v4.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,58 @@ dependencies = [
4646
]
4747
```
4848

49+
## Fix removed-type imports first — they cascade
50+
51+
Real-adopter feedback (salesagent v3→v4 experiment, 270 files scanned, 161
52+
test-collection failures): consumers tend to centralize SDK imports in one
53+
schema module, so a single broken import there crashes test collection across
54+
the whole codebase. salesagent re-exported through `src/core/schemas/_base.py`
55+
**one missing `FormatCategory` import there cascaded into ~140 test failures
56+
during pytest collect-only**, and stubbing it revealed the next ~140-test
57+
cascade from `BrandManifest`, then the next from the `generated_poc` reach-ins.
58+
59+
The codemod's static finding count understates this by 100x+. To minimize the
60+
felt blast radius, work in this order:
61+
62+
1. **Removed types in central re-export modules first.** Find every
63+
`BrandManifest`, `FormatCategory`, `DeliverTo`, `PromotedProducts`,
64+
`PromotedOfferings`, `Pricing`, `PackageStatus` import in modules that
65+
re-export to the rest of your codebase (e.g. a `_base.py` schema barrel).
66+
Fix those before anything else — most of your test-collection failures
67+
disappear.
68+
2. **`adcp.types.generated_poc` reach-ins.** The codemod's per-symbol mapping
69+
tells you the public alias for each (`ContextObject`
70+
`adcp.types.ContextObject`, etc.). Mechanical lookup once you know the
71+
pattern.
72+
3. **Numbered `Assets<N>` imports.** Switch to the semantic alias from
73+
`adcp.types`. See [Numbered discriminated-union classes
74+
shifted](#numbered-discriminated-union-classes-shifted) below.
75+
4. **Per-call-site shape changes** (e.g. `BrandManifest(name=..., logo_url=...)`
76+
`BrandReference(domain=...)`). The codemod can't auto-rewrite these —
77+
the data is different.
78+
79+
## a2a-sdk transitive bump (only matters if you have direct `a2a` imports)
80+
81+
v4 of this SDK requires `a2a-sdk>=1.0.0`. If your codebase has any direct
82+
imports from the `a2a` package — typically `a2a.utils.errors.ServerError`,
83+
`a2a.types.DataPart`, or other surface — those are a separate migration that
84+
this SDK forces on you transitively.
85+
86+
Symptoms during pytest collection after upgrading:
87+
88+
- `cannot import name 'ServerError' from 'a2a.utils.errors'`
89+
- `cannot import name 'DataPart' from 'a2a.types'`
90+
91+
If you don't import from `a2a` directly (only via `adcp.server` /
92+
`adcp.protocols.a2a`), this section doesn't apply — the SDK's wrapper layer
93+
absorbs the change.
94+
95+
If you do, see the [a2a-sdk
96+
1.0 release notes](https://github.com/a2aproject/a2a-python/releases) for the
97+
import-path moves. The most common pattern is `a2a.utils.errors.ServerError`
98+
`a2a.types.A2AError` and `a2a.types.DataPart``a2a.types.MessagePart`,
99+
but verify against your specific usage.
100+
49101
## Removed types
50102

51103
The following types were removed from the AdCP spec and have no replacement

src/adcp/migrate/v3_to_v4.py

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,50 @@
114114
}
115115

116116

117+
# Per-symbol mapping for the most common ``generated_poc`` reach-ins
118+
# salesagent surfaced during their v3→v4 experiment (and any other
119+
# adopter would hit). The codemod scans for ``from
120+
# adcp.types.generated_poc.<path> import <Symbol>`` lines and emits an
121+
# explicit "before → after" hint per symbol so adopters don't have to
122+
# hand-grep the public-API module to find the canonical alias.
123+
#
124+
# Mapping shape: ``<symbol-name> → adcp.types.<symbol-name>``. Every
125+
# symbol listed here is already exported from ``adcp.types``; the
126+
# ``test_generated_poc_symbol_map_covers_publicly_exported_names`` test
127+
# guards drift between this map and the SDK's public surface.
128+
#
129+
# Intentionally NOT in the map (yet): ``CreditLimit``, ``Setup``,
130+
# ``GovernanceAgent``. These names appear in 8+ generated files
131+
# (``core/account.py``, ``account/sync_accounts_response.py``,
132+
# ``media_buy/sync_event_sources_response.py``, ``bundled/...``) — the
133+
# codegen emits one independent class per containing schema, so a
134+
# blanket "import from adcp.types" hint would be ambiguous about
135+
# which variant. Adopters reaching for these get the generic
136+
# private-module flag; landing them in the public API is a separate
137+
# design decision (which canonical variant to expose / whether to
138+
# expose schema-namespaced aliases like ``AccountSetup``).
139+
GENERATED_POC_SYMBOL_MAP: dict[str, str] = {
140+
"AccountReference": "adcp.types.AccountReference",
141+
"BrandReference": "adcp.types.BrandReference",
142+
"ContextObject": "adcp.types.ContextObject",
143+
"CreativeAsset": "adcp.types.CreativeAsset",
144+
"Error": "adcp.types.Error",
145+
"MediaBuyStatus": "adcp.types.MediaBuyStatus",
146+
"ProductFilters": "adcp.types.ProductFilters",
147+
"ReportingWebhook": "adcp.types.ReportingWebhook",
148+
}
149+
150+
151+
# ``from adcp.types.generated_poc.<...> import <Symbol[, ...]>`` —
152+
# captures the symbol list so we can emit per-symbol replacement hints.
153+
# Multiline imports (parenthesized) aren't covered by this regex; they
154+
# fall through to the generic "private module" flag, which still
155+
# surfaces the issue and prints the migration anchor.
156+
_GENERATED_POC_FROM_IMPORT = re.compile(
157+
r"from\s+adcp\.types\.generated_poc(?:\.[\w.]+)?\s+import\s+([\w\s,]+)"
158+
)
159+
160+
117161
# Regex for numbered Assets direct imports (``Assets5``, ``Assets14``, etc).
118162
# Bare ``Assets`` (no digits) is a legitimate base class alias; the
119163
# regex requires at least one digit to avoid false positives.
@@ -283,10 +327,51 @@ def scan_file(path: Path, *, apply_changes: bool) -> tuple[list[Finding], str |
283327
)
284328
)
285329

286-
# adcp.types.generated_poc imports.
330+
# adcp.types.generated_poc imports. When the line is a
331+
# single-line ``from adcp.types.generated_poc.<path> import
332+
# <symbols>`` and any of the imported symbols are in
333+
# GENERATED_POC_SYMBOL_MAP, emit one per-symbol Finding with the
334+
# public-API replacement (e.g. "ContextObject → adcp.types.ContextObject").
335+
# Otherwise fall back to the generic "private module" flag so
336+
# multiline / star imports still surface.
287337
for private_path, hint in PRIVATE_IMPORT_PATHS.items():
288-
if private_path in line:
289-
col = line.index(private_path) + 1
338+
if private_path not in line:
339+
continue
340+
col = line.index(private_path) + 1
341+
from_match = _GENERATED_POC_FROM_IMPORT.search(line)
342+
mapped_any = False
343+
if from_match:
344+
# Symbols list — handles ``A``, ``A, B``, ``A as X``.
345+
# ``as`` aliases are rare in practice for these reach-ins
346+
# but treat the LHS as the canonical symbol when present.
347+
raw_symbols = [s.strip() for s in from_match.group(1).split(",")]
348+
for raw in raw_symbols:
349+
if not raw:
350+
continue
351+
symbol = raw.split(" as ")[0].strip()
352+
replacement = GENERATED_POC_SYMBOL_MAP.get(symbol)
353+
if replacement is None:
354+
continue
355+
sym_col = line.find(symbol, from_match.start(1)) + 1
356+
findings.append(
357+
Finding(
358+
kind="flag_private",
359+
path=str(path),
360+
line=lineno,
361+
column=sym_col,
362+
before=symbol,
363+
after=replacement,
364+
hint=(
365+
f"private module — import {symbol} from "
366+
"adcp.types (stable public API) instead"
367+
),
368+
)
369+
)
370+
mapped_any = True
371+
if not mapped_any:
372+
# Generic flag — multiline imports, star imports, or
373+
# symbols without a known public alias. Adopter does the
374+
# lookup; codemod still surfaces the issue.
290375
findings.append(
291376
Finding(
292377
kind="flag_private",
@@ -373,7 +458,17 @@ def _format_text_report(report: Report, *, apply_changes: bool) -> str:
373458
for f in report.flagged:
374459
by_name.setdefault(f.before, []).append(f)
375460
for name, hits in sorted(by_name.items()):
376-
lines.append(f" {name} ({len(hits)} hit{'s' if len(hits) != 1 else ''})")
461+
# Per-symbol mapping ("ContextObject → adcp.types.ContextObject")
462+
# — print the explicit replacement on the header line so
463+
# adopters fix without leaving the report. Falls back to
464+
# bare name when no replacement is mapped.
465+
replacement = hits[0].after
466+
header = (
467+
f" {name}{replacement} ({len(hits)} hit{'s' if len(hits) != 1 else ''})"
468+
if replacement
469+
else f" {name} ({len(hits)} hit{'s' if len(hits) != 1 else ''})"
470+
)
471+
lines.append(header)
377472
hint = hits[0].hint
378473
if hint:
379474
lines.append(f" → {hint}")

tests/test_migrate_v3_to_v4.py

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,13 +160,16 @@ def test_bare_assets_is_not_flagged_as_numbered(tmp_path: Path) -> None:
160160
assert numbered == []
161161

162162

163-
def test_flags_generated_poc_imports(tmp_path: Path) -> None:
164-
"""``adcp.types.generated_poc`` is a private module — flag imports
165-
from it and point at the public alias path."""
163+
def test_flags_generated_poc_imports_unknown_symbol_falls_back_to_generic_hint(
164+
tmp_path: Path,
165+
) -> None:
166+
"""A ``generated_poc`` import for a symbol not in the per-symbol map
167+
falls back to the generic 'private module' flag — still surfaces the
168+
issue, adopter does the lookup."""
166169
_write(
167170
tmp_path,
168171
"code.py",
169-
"from adcp.types.generated_poc.core.account import Account\n",
172+
"from adcp.types.generated_poc.core.something import Unknown\n",
170173
)
171174

172175
report = v3_to_v4.run(tmp_path, apply_changes=False)
@@ -176,6 +179,82 @@ def test_flags_generated_poc_imports(tmp_path: Path) -> None:
176179
assert private[0].before == "adcp.types.generated_poc"
177180

178181

182+
def test_flags_generated_poc_imports_per_symbol_mapping(tmp_path: Path) -> None:
183+
"""Round-5 adopter feedback (salesagent v3→v4 experiment): the
184+
``generated_poc`` flag-only output forced 82 of 156 findings into
185+
hand-grep territory. Each known reach-in now emits an explicit
186+
"Symbol → adcp.types.Symbol" replacement so adopters apply the fix
187+
without leaving the codemod report."""
188+
_write(
189+
tmp_path,
190+
"code.py",
191+
"from adcp.types.generated_poc.core.context import ContextObject\n"
192+
"from adcp.types.generated_poc.core.brand_ref import BrandReference\n"
193+
"from adcp.types.generated_poc.enums.media_buy_status import MediaBuyStatus\n"
194+
"from adcp.types.generated_poc.core.error import Error as AdCPResponseError\n",
195+
)
196+
197+
report = v3_to_v4.run(tmp_path, apply_changes=False)
198+
199+
private = [f for f in report.flagged if f.kind == "flag_private"]
200+
by_symbol = {f.before: f for f in private}
201+
202+
# Each known symbol gets a per-symbol replacement, NOT the generic
203+
# "adcp.types.generated_poc" flag.
204+
assert by_symbol["ContextObject"].after == "adcp.types.ContextObject"
205+
assert by_symbol["BrandReference"].after == "adcp.types.BrandReference"
206+
assert by_symbol["MediaBuyStatus"].after == "adcp.types.MediaBuyStatus"
207+
# ``import Error as AdCPResponseError`` — codemod keys off the LHS
208+
# canonical name, ignoring the local alias.
209+
assert by_symbol["Error"].after == "adcp.types.Error"
210+
# The generic private-module flag MUST NOT also fire when the
211+
# per-symbol mapping handled the line — would double-count and
212+
# confuse the report.
213+
assert "adcp.types.generated_poc" not in by_symbol
214+
215+
216+
def test_flags_generated_poc_multiple_symbols_one_line(tmp_path: Path) -> None:
217+
"""``from adcp.types.generated_poc.core.x import A, B, C`` emits
218+
one Finding per symbol so the report surfaces every replacement."""
219+
_write(
220+
tmp_path,
221+
"code.py",
222+
"from adcp.types.generated_poc.core.x import "
223+
"BrandReference, ContextObject, MediaBuyStatus\n",
224+
)
225+
226+
report = v3_to_v4.run(tmp_path, apply_changes=False)
227+
228+
private = [f for f in report.flagged if f.kind == "flag_private"]
229+
by_symbol = {f.before: f.after for f in private}
230+
assert by_symbol == {
231+
"BrandReference": "adcp.types.BrandReference",
232+
"ContextObject": "adcp.types.ContextObject",
233+
"MediaBuyStatus": "adcp.types.MediaBuyStatus",
234+
}
235+
236+
237+
def test_generated_poc_symbol_map_covers_publicly_exported_names() -> None:
238+
"""Every entry in ``GENERATED_POC_SYMBOL_MAP`` MUST point at a real
239+
public-API symbol on ``adcp.types`` — otherwise the hint sends
240+
adopters to a NameError. Guards against drift between the codemod's
241+
map and the SDK's __all__."""
242+
import importlib
243+
244+
types_module = importlib.import_module("adcp.types")
245+
for symbol, replacement in v3_to_v4.GENERATED_POC_SYMBOL_MAP.items():
246+
# Every replacement is exactly ``adcp.types.<symbol>``.
247+
assert replacement == f"adcp.types.{symbol}", (
248+
f"GENERATED_POC_SYMBOL_MAP[{symbol!r}] = {replacement!r} but "
249+
f"the convention is adcp.types.{symbol}"
250+
)
251+
assert hasattr(types_module, symbol), (
252+
f"GENERATED_POC_SYMBOL_MAP claims adcp.types.{symbol} exists "
253+
"but it's not on the public types module — drop the entry "
254+
"or add the public alias."
255+
)
256+
257+
179258
def test_flags_removed_attribute_accesses(tmp_path: Path) -> None:
180259
"""``.brand_manifest`` on ResolvedBrand was removed — flag the
181260
attribute access with a hint."""

0 commit comments

Comments
 (0)