feat(types): lazy, typed public surface + curated partial modules#963
Conversation
Make ``import adcp`` and ``import adcp.types`` lightweight, add curated
partial type modules, and keep the full typed surface for adopters.
- ``import adcp`` ~3.25s -> ~2ms: the generated Pydantic schema graph and the
client/server/a2a stack load lazily (PEP 562 __getattr__). Schemas build only
when a type symbol is actually accessed. The former eager body of
``adcp.types`` moved verbatim to ``adcp/types/_eager.py`` (which runs the
import-time _ergonomic/_forward_compat patching); real submodules pass through
the import system to avoid a circular import.
- Defer ``importlib.metadata`` (the version lookup) so ``import adcp`` no longer
pays its ~15ms cost; ``__version__`` resolves on first access.
- New curated, lazy partial modules: ``adcp.types.media_buy`` / ``creative`` /
``signals`` / ``protocol`` / ``buyer`` / ``seller`` — a narrow, domain-grouped
surface that never touches the internal generated layer.
- Adopter type-checking preserved: the runtime __getattr__ lives under
``if not TYPE_CHECKING`` so mypy sees the surface only via the explicit
TYPE_CHECKING re-export block — valid names type correctly and typos are
flagged (no .pyi stubs needed).
- Missing/typo'd attributes fail fast (AttributeError) without building the
graph; remove two dead ``__all__`` entries (PreviewCreative{Format,Manifest}
Request) that never resolved, so ``from adcp.types import *`` works.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…TOC, contributor guide - Stop instructing imports from the internal ``adcp.types.generated_poc.*`` / ``_generated`` layer: extending-types, request-signing-migration, handler-authoring, AGENTS, MIGRATION, llms.txt, and the example scripts now use ``adcp`` / ``adcp.types`` / the curated partial modules. Remaining generated-layer mentions are "do not import" warnings only. - README: add a "Choose your path" router + table of contents, a cold-start / import-performance note, and accurate framing of the partial modules (curation/discoverability, not a per-domain perf tier). Fix the stale "AdCP version support" section (6.x targets spec 3.1.0; validates 3.0 + 3.1) and the stale "Complete Creative Workflow" example (correct PreviewCreativeRequest / BuildCreativeRequest / CreativeManifest fields). - examples/README: replace the single-feature page with a categorized index of every example; fix the broken ``fetch_previews.py`` reference. - CONTRIBUTING + CLAUDE.md: document the lazy import architecture and the contract for adding a public export (the _LAZY/TYPE_CHECKING dual-update, _EAGER_ONLY_EXTRAS, snapshot regen, layering allowlist). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Clean lazy-surface conversion — import adcp drops from ~3.25s to ~2ms with zero wire-shape change. Right architecture: PEP 562 __getattr__ under if not TYPE_CHECKING, with the type surface declared in an explicit TYPE_CHECKING block so mypy still flags from adcp import Prodct. No .pyi stubs, no object-typed escape hatch.
Things I checked
- No breaking public-API change.
adcp.__all__is byte-identical to the snapshot;adcp.types.__all__only adds the six partial-module names and removes two dead entries.feat(types):is the correct semver signal — no!needed. - The two removed
__all__entries were dead.PreviewCreativeFormatRequest/PreviewCreativeManifestRequestappear only as string literals atsrc/adcp/types/__init__.py:1590-1591on main with no import binding anywhere —from adcp.types import *raisedAttributeErroron main the moment it hit them. Removal fixes star-import; it removes nothing that ever resolved.ad-tech-protocol-expert: sound —preview_creativecollapsed to a single discriminatedPreviewCreativeRequest, these names are not on the 3.x wire surface. generatedresolves both ways.find_spec(\"adcp.types.generated\")isNone(it's aTYPE_CHECKINGalias for_generated), so it falls through_RESOLVABLEto_eager.generated, which_eagerbinds viafrom adcp.types import _generated as generated.adcp.generatedandadcp.types.generatedboth land._eager.pyis a verbatim move, patch ordering intact.src/adcp/types/_eager.py:9-16keeps_ergonomic/_forward_compat/aliasesimported before any type — so theFormat.assets/RepeatableAssetGroup.assetsopen-union patch and theBeforeValidatorcoercion still fire exactly once before first model use. None ofaliases.py/_ergonomic.py/_forward_compat.pyis touched.- Re-entrancy / deadlock reasoned about, not accidental.
src/adcp/types/__init__.py:905-948routes real submodules (partials,aliases, generated layer) straight to the import system rather than through_eager, avoiding the_eager → _forward_compat → aliases → _generatedcircular re-entry. Fail-fast onname not in _RESOLVABLEkeeps typos andhasattrprobes from building the graph. - Version cache named
_sdk_version, not_version(src/adcp/__init__.py:26-29) — avoids the collision with theadcp._versionsubmodule that would clobber__version__into a module object. Guarded bytest_version_not_clobbered_by_version_submodule. - Layering allowlist updated.
tests/test_import_layering.pyadds_eager.pytoALLOWED_FILES; partials import onlyfrom adcp.types, never the generated layer (AST-guarded bytest_partial_modules_never_import_generated_layer). - Partial-module parity is enforced, not asserted.
test_partial_module_names_all_resolve_via_adcp_typesdoesgetattr(adcp.types, name) is getattr(module, name)for all 265 partial names — any unresolvable entry fails the suite._EAGER_ONLY_EXTRASdrift failstest_eager_only_extras_matches_eager_namespace. 24 new guards, run in fresh subprocesses so a prior import can't mask laziness. - Test plan is honest — Verification section claims 5828 pass / ruff / mypy(926 files) / mypy --strict, no unchecked manual box for the path being changed.
Minor nits (non-blocking)
protocol.pyre-exportsGeneratedTaskStatus, not a cleanTaskStatus. Faithful to the existingadcp.typessurface (the master__init__avoids bindingTaskStatusto not shadow the generated enum), so it's consistent with prior art — but slightly awkward for an adopter reading the curated surface. Pre-existing; worth a follow-up if the partials are meant to read cleaner than the flat namespace.
The same name set is now typed out four times — _LAZY_MODULES, __all__, the TYPE_CHECKING block, and the partials — which is exactly the kind of thing that drifts silently; the byte-identical snapshot plus the parity guards are what make that load-bearing duplication safe to ship.
Approving on the strength of the parity guards plus the verbatim _eager move preserving the forward-compat patch ordering.
… by lazy adcp The eager ``import adcp`` used to load ``adcp.webhooks`` fully before anything imported ``adcp.webhook_sender`` directly, masking a latent circular import. With the lazy ``adcp`` facade, ``from adcp.webhook_sender import WebhookSender`` (as the v3 reference seller's app.py does) now imports webhook_sender first: its top-level ``from adcp.webhooks import (...)`` triggers webhooks, whose bottom ``from adcp.webhook_sender import (...)`` then hits a partially initialized webhook_sender -> ImportError. Move webhook_sender's import of the payload-builder functions to the bottom of the module (after WebhookSender / WebhookDeliveryResult are defined; they're only used at call time), mirroring the existing bottom-import in webhooks.py. Now the cycle resolves regardless of which module is imported first. Add a parametrized guard (test_submodule_imports_standalone) that imports each cross-importing adcp submodule first in a fresh interpreter, so this class of import-order cycle can't silently regress. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cades, silence unused-import flags Review follow-ups on the lazy type surface: - GeoPostalArea now caches into the module namespace after the first access, so the DeprecationWarning fires once per process instead of on every access. test_postal_area_compat gets an autouse fixture that resets the cache so the warning assertions stay order-independent; the lazy guard now asserts the warn-once contract. - Extract the six partial modules' identical lazy ``__getattr__`` / ``__dir__`` bodies into ``adcp.types._partial.lazy_partial_surface`` (single source of truth; the per-module ``__all__`` + TYPE_CHECKING re-export blocks stay). - Make ``_PARTIAL_MODULES`` load-bearing again (fast path in the submodule passthrough) — github-code-quality flagged it as unused after the fast-fail refactor. - Import ``_ergonomic`` / ``_forward_compat`` in ``_eager`` via ``importlib.import_module`` (a call, not a name binding) so the intentional side-effect imports don't read as unused to static analysers. No behavior change beyond warn-once; full suite + adopter mypy --strict green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Clean lazy-surface refactor — the PEP 562 conversion is coherent end to end and the public surface is preserved name-for-name. Right shape: the runtime __getattr__ lives under if not TYPE_CHECKING while the static surface comes from an explicit TYPE_CHECKING re-export block, so laziness on the wire costs adopters nothing and typos still get flagged.
Things I checked
src/adcp/__init__.py:643-676—__getattr__resolution order (version, removed-in-v4,_LAZYre-export, submodule passthrough,AttributeError) is safe.find_spec(f"adcp.{name}")at :671 cannot raise — parentadcpis already imported — so a typo likefrom adcp import Prodctfails fast without building the graph._LAZYcovers every name from the old eager import blocks; head__all__is name-for-name identical to base (adcp.__all__snapshot unchanged).src/adcp/__init__.py:32-44,652-655— version lookup deferred out of import; cache named_sdk_version(not_version) to dodge theadcp._versionsubmodule collision. Deliberate, and test-guarded.src/adcp/types/__init__.py:940-955— the re-entrancy claim holds:aliasesis a real submodule sofind_specroutes it straight through the import system (never through_eager, avoiding the_forward_compat-to-aliasescycle), whilegeneratedhas no real submodule and correctly falls togetattr(_eager, "generated"), which_eager.py:31binds and_eager.__all__lists.src/adcp/types/__init__.py:924-933—GeoPostalAreacaches into globals after first access, so theDeprecationWarningfires once per process, not per access.PostalArea5(legacy arm) still validates against thePostalAreaunion — back-compat intact.- Every name in all six partial
__all__lists resolves throughadcp.types._RESOLVABLE— verifiedbuyer.py12 non-obvious entries (GetProductsResponseUnion,ProductCard*,RefinementApplied,Rights*) againstadcp.types.__all__;code-reviewerconfirmed the other five.test_partial_module_names_all_resolve_via_adcp_typesenforces it in CI, so a drifted partial__all__fails loudly rather than landing a brokenfrom adcp.types.buyer import *on an adopter. src/adcp/webhook_sender.pybottom-import cycle fix — all four payload-builders are used only inside method bodies (call time), bound at module end after the classes, symmetric with the pre-existing bottom import inwebhooks.py.test_submodule_imports_standaloneparametrizes both import orders.tests/test_import_layering.pyadds_eager.pyto the generated-layer allowlist — correct, it holds the former__init__.pybody and inherits that role. No layering breach.ad-tech-protocol-expert: sound —PreviewCreativeFormatRequest/PreviewCreativeManifestRequestresolve to no class anywhere at head SHA (grep acrossgenerated_poc/_generated/_eager/aliasesconfirmsPreviewCreativeRequestcollapsed to one class with arequest_typediscriminator). They were dead__all__entries that madefrom adcp.types import *raise; removing them is a fix, not a wire break.
Follow-ups (non-blocking — file as issues)
- The curated partial surfaces list codegen-numbered names (
CreateMediaBuyResponse1,ActivateSignalResponse1,GetProductsResponseUnion). Per CLAUDE.md the trailing-digit names renumber on schema regen — a future regen could shift them. The CI guard catches the break loudly, but prefer the semantic aliases in these discoverability lists so a regen does not churn them. (ad-tech-protocol-expert) adcp.__getattr__/__dir__and theadcp.typesequivalents are near-duplicate machinery. Small and well-commented; a shared helper would tighten it. (code-reviewer)
Verification section is fully checked — 5828 tests, 24 new lazy guards, adopter mypy --strict green; no unchecked manual box against the path this PR changes.
Turning import adcp from 3.25s into 2ms, and the 3.25s turns out to have been load-bearing for nobody. Approving on the strength of the byte-identical public surface plus the CI guard on partial-module resolution.
Approving.
…ules CreateMediaBuyResponse1 (media_buy) and ActivateSignalResponse1 (signals) are datamodel-code-generator names that churn on schema regen. Both are the same class object as the semantic alias already exported alongside them (CreateMediaBuySuccessResponse / ActivateSignalSuccessResponse), so drop the numbered names from the curated surface — they remain on the full adcp.types surface for back-compat. Add test_partial_modules_avoid_numbered_codegen_names so a future categorization or regen can't reintroduce a ``*Response1``-style name into a partial module. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Makes the
adcppublic surface world-class for adopters:import adcpis now ~2ms (was ~3.25s),from adcp.types import Productstays a stable lazy compatibility path, and there are new curated partial type modules — all while keeping full mypy/IDE typing and zero behavioral change beyond laziness.What changed
1. Lazy
adcpandadcp.types(PEP 562). The generated Pydantic schema graph and the client/server/a2a stack now load lazily, the first time a name that needs them is accessed. The former eager body ofadcp.typesmoved verbatim toadcp/types/_eager.py(which still runs the import-time_ergonomic/_forward_compatpatching); real submodules pass through the import system to avoid a circular import.import adcpimport adcp.typesfrom adcp import ADCPErrorfrom adcp import ProductAlso defers
importlib.metadata(the version lookup) soimport adcpdoesn't pay its ~15ms cost;__version__resolves on first access.2. Curated partial modules —
adcp.types.media_buy,.creative,.signals,.protocol,.buyer,.seller. A narrow, domain-grouped surface that never touches the internal generated layer. (Curation/discoverability — not a per-domain perf tier; the first type access builds the single shared graph.)3. Adopter typing preserved — and improved. The runtime
__getattr__lives underif not TYPE_CHECKING, so mypy sees the surface only via an explicitTYPE_CHECKINGre-export block: valid names type correctly and typos are now flagged (from adcp import Prodct→ "maybe Product?") — no.pyistubs needed. Missing/typo'd attributes also fail fast at runtime without building the graph.4.
generated_pocis internal-only for consumers. Removed two dead__all__entries that never resolved (PreviewCreative{Format,Manifest}Request), sofrom adcp.types import *works. Rewrote the docs/examples that instructedgenerated_pocimports to use the public surface.Docs
docs/extending-types.md,request-signing-migration.md,handler-authoring.md,AGENTS.md,MIGRATION_v3_to_v4.md,llms.txt: public-surface imports + partial-module guidance.examples/README.md: categorized index of every example (fixed a broken file reference).CONTRIBUTING.md+CLAUDE.md: the lazy-import architecture and the contract for adding a public export.Verification
ruff check src/,mypy src/adcp/(926 files),mypy --strict tests/type_checks/, type-ignore contract — all pass.tests/test_lazy_types.py(laziness in fresh subprocesses, lazy/eager surface parity, fast-fail, version-collision,importlib.metadatadeferral, partial-module identity, no-generated-layer AST check).adcp.__all__byte-identical to the snapshot;adcp.types.__all__adds only the 6 partial-module names (snapshot regenerated).Notes for reviewers
from adcp import Product is from adcp.types import Product is from adcp.types.buyer import Product), removed-v4ImportErrors,GeoPostalAreadeprecation,from adcp import *,dir()/hasattrall unchanged._LAZY_MODULES/TYPE_CHECKING(or__all__/_eager) update — documented in CONTRIBUTING + guarded bytest_lazy_types.py/test_import_layering.py/ the public-API snapshot.🤖 Generated with Claude Code