Skip to content

feat(types): lazy, typed public surface + curated partial modules#963

Merged
bokelley merged 5 commits into
mainfrom
lazy-types-submodules
Jun 28, 2026
Merged

feat(types): lazy, typed public surface + curated partial modules#963
bokelley merged 5 commits into
mainfrom
lazy-types-submodules

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Summary

Makes the adcp public surface world-class for adopters: import adcp is now ~2ms (was ~3.25s), from adcp.types import Product stays 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 adcp and adcp.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 of adcp.types moved verbatim to adcp/types/_eager.py (which still runs the import-time _ergonomic/_forward_compat patching); real submodules pass through the import system to avoid a circular import.

before after
import adcp ~3250 ms ~2 ms
import adcp.types ~3600 ms ~2 ms
from adcp import ADCPError builds full graph no graph, no a2a
from adcp import Product builds graph on demand

Also defers importlib.metadata (the version lookup) so import adcp doesn't pay its ~15ms cost; __version__ resolves on first access.

2. Curated partial modulesadcp.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 under if not TYPE_CHECKING, so mypy sees the surface only via an explicit TYPE_CHECKING re-export block: valid names type correctly and typos are now flagged (from adcp import Prodct"maybe Product?") — no .pyi stubs needed. Missing/typo'd attributes also fail fast at runtime without building the graph.

4. generated_poc is internal-only for consumers. Removed two dead __all__ entries that never resolved (PreviewCreative{Format,Manifest}Request), so from adcp.types import * works. Rewrote the docs/examples that instructed generated_poc imports to use the public surface.

Docs

  • README: "Choose your path" router + TOC, cold-start/import-performance note, fixed stale version section (6.x / spec 3.1.0) and the stale Complete Creative Workflow example.
  • 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.
  • 5828 tests pass (41 skipped, 1 xfailed); 24 new guards in tests/test_lazy_types.py (laziness in fresh subprocesses, lazy/eager surface parity, fast-fail, version-collision, importlib.metadata deferral, 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).
  • Two adversarial code reviews (correctness/re-entrancy + backward-compat) — no blockers.

Notes for reviewers

  • Behavior-preserving except laziness. Verified identity (from adcp import Product is from adcp.types import Product is from adcp.types.buyer import Product), removed-v4 ImportErrors, GeoPostalArea deprecation, from adcp import *, dir()/hasattr all unchanged.
  • Adding a public export now requires a dual _LAZY_MODULES/TYPE_CHECKING (or __all__/_eager) update — documented in CONTRIBUTING + guarded by test_lazy_types.py / test_import_layering.py / the public-API snapshot.

🤖 Generated with Claude Code

bokelley and others added 2 commits June 28, 2026 08:59
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>
Comment thread src/adcp/types/_eager.py Fixed
Comment thread src/adcp/types/__init__.py Fixed
aao-ipr-bot[bot]
aao-ipr-bot Bot previously approved these changes Jun 28, 2026

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 / PreviewCreativeManifestRequest appear only as string literals at src/adcp/types/__init__.py:1590-1591 on main with no import binding anywhere — from adcp.types import * raised AttributeError on main the moment it hit them. Removal fixes star-import; it removes nothing that ever resolved. ad-tech-protocol-expert: sound — preview_creative collapsed to a single discriminated PreviewCreativeRequest, these names are not on the 3.x wire surface.
  • generated resolves both ways. find_spec(\"adcp.types.generated\") is None (it's a TYPE_CHECKING alias for _generated), so it falls through _RESOLVABLE to _eager.generated, which _eager binds via from adcp.types import _generated as generated. adcp.generated and adcp.types.generated both land.
  • _eager.py is a verbatim move, patch ordering intact. src/adcp/types/_eager.py:9-16 keeps _ergonomic / _forward_compat / aliases imported before any type — so the Format.assets / RepeatableAssetGroup.assets open-union patch and the BeforeValidator coercion still fire exactly once before first model use. None of aliases.py / _ergonomic.py / _forward_compat.py is touched.
  • Re-entrancy / deadlock reasoned about, not accidental. src/adcp/types/__init__.py:905-948 routes real submodules (partials, aliases, generated layer) straight to the import system rather than through _eager, avoiding the _eager → _forward_compat → aliases → _generated circular re-entry. Fail-fast on name not in _RESOLVABLE keeps typos and hasattr probes from building the graph.
  • Version cache named _sdk_version, not _version (src/adcp/__init__.py:26-29) — avoids the collision with the adcp._version submodule that would clobber __version__ into a module object. Guarded by test_version_not_clobbered_by_version_submodule.
  • Layering allowlist updated. tests/test_import_layering.py adds _eager.py to ALLOWED_FILES; partials import only from adcp.types, never the generated layer (AST-guarded by test_partial_modules_never_import_generated_layer).
  • Partial-module parity is enforced, not asserted. test_partial_module_names_all_resolve_via_adcp_types does getattr(adcp.types, name) is getattr(module, name) for all 265 partial names — any unresolvable entry fails the suite. _EAGER_ONLY_EXTRAS drift fails test_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)

  1. protocol.py re-exports GeneratedTaskStatus, not a clean TaskStatus. Faithful to the existing adcp.types surface (the master __init__ avoids binding TaskStatus to 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>
aao-ipr-bot[bot]
aao-ipr-bot Bot previously approved these changes Jun 28, 2026

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, _LAZY re-export, submodule passthrough, AttributeError) is safe. find_spec(f"adcp.{name}") at :671 cannot raise — parent adcp is already imported — so a typo like from adcp import Prodct fails fast without building the graph. _LAZY covers 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 the adcp._version submodule collision. Deliberate, and test-guarded.
  • src/adcp/types/__init__.py:940-955 — the re-entrancy claim holds: aliases is a real submodule so find_spec routes it straight through the import system (never through _eager, avoiding the _forward_compat-to-aliases cycle), while generated has no real submodule and correctly falls to getattr(_eager, "generated"), which _eager.py:31 binds and _eager.__all__ lists.
  • src/adcp/types/__init__.py:924-933GeoPostalArea caches into globals after first access, so the DeprecationWarning fires once per process, not per access. PostalArea5 (legacy arm) still validates against the PostalArea union — back-compat intact.
  • Every name in all six partial __all__ lists resolves through adcp.types._RESOLVABLE — verified buyer.py 12 non-obvious entries (GetProductsResponseUnion, ProductCard*, RefinementApplied, Rights*) against adcp.types.__all__; code-reviewer confirmed the other five. test_partial_module_names_all_resolve_via_adcp_types enforces it in CI, so a drifted partial __all__ fails loudly rather than landing a broken from adcp.types.buyer import * on an adopter.
  • src/adcp/webhook_sender.py bottom-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 in webhooks.py. test_submodule_imports_standalone parametrizes both import orders.
  • tests/test_import_layering.py adds _eager.py to the generated-layer allowlist — correct, it holds the former __init__.py body and inherits that role. No layering breach.
  • ad-tech-protocol-expert: sound — PreviewCreativeFormatRequest / PreviewCreativeManifestRequest resolve to no class anywhere at head SHA (grep across generated_poc/_generated/_eager/aliases confirms PreviewCreativeRequest collapsed to one class with a request_type discriminator). They were dead __all__ entries that made from 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 the adcp.types equivalents 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>
@bokelley bokelley merged commit cad9840 into main Jun 28, 2026
25 of 26 checks passed
@bokelley bokelley deleted the lazy-types-submodules branch June 28, 2026 15:33
@aao-ipr-bot aao-ipr-bot Bot mentioned this pull request Jun 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant