Skip to content

Commit cad9840

Browse files
authored
feat(types): lazy, typed public surface with curated partial modules (#963)
Make `import adcp` and `import adcp.types` lightweight (PEP 562 lazy surface), add curated partial type modules (adcp.types.media_buy/creative/signals/ protocol/buyer/seller), and keep full mypy/IDE typing — with zero wire-shape change and back-compat preserved (from adcp.types import Product still works). - import adcp ~3.25s -> ~2ms; generated schema graph + client/server/a2a load lazily, on first access to a type symbol; importlib.metadata version lookup deferred too. - Adopter typing preserved and improved: typos like `from adcp import Prodct` are flagged (runtime __getattr__ under `if not TYPE_CHECKING`); unknown names fail fast without building the graph. - Fixed a latent webhooks <-> webhook_sender import cycle the lazy facade exposed; docs/examples no longer instruct importing the internal generated layer; README/llms.txt/CONTRIBUTING updated. 5839 tests, ruff, and mypy --strict (incl. adopter fixtures) all green.
1 parent 20d2369 commit cad9840

29 files changed

Lines changed: 5473 additions & 1659 deletions

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,11 @@ from adcp import ADCPClient, ADCPMultiAgentClient, AgentConfig
219219
# Request/response types
220220
from adcp.types import GetProductsRequest, CreateMediaBuyRequest, Product, Package
221221

222+
# Or import from a curated partial module for a narrower surface:
223+
# adcp.types.media_buy / creative / signals / protocol / buyer / seller
224+
from adcp.types.media_buy import CreateMediaBuyRequest
225+
# Never import from adcp.types.generated_poc.* or adcp.types._generated (internal)
226+
222227
# Response variant types (discriminated unions)
223228
from adcp.types.aliases import CreateMediaBuySuccessResponse, CreateMediaBuyErrorResponse
224229

CLAUDE.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,27 @@ _generated.py (internal consolidation)
2727
2828
aliases.py + capabilities.py + _ergonomic.py + _forward_compat.py
2929
30-
__init__.py (user-facing exports)
30+
_eager.py (binds the full public surface; runs import-time patching)
31+
32+
__init__.py (thin lazy facade; user-facing exports via PEP 562 __getattr__)
3133
```
3234

35+
`adcp.types/__init__.py` is a lazy facade (PEP 562): `import adcp.types` is
36+
cheap, and the generated Pydantic graph is built on first access to a type
37+
symbol, by importing `_eager.py` (the former eager `__init__` body). The
38+
runtime `__getattr__`/`__dir__` live under `if not TYPE_CHECKING:` so type
39+
checkers see the surface only via the explicit `TYPE_CHECKING` re-export block
40+
— a typo'd import is flagged, not silently typed as `object`.
41+
3342
Only these modules may import from `generated_poc/` or `_generated.py`
3443
(enforced by `tests/test_import_layering.py`):
3544
- `_generated.py`: Consolidates exports from `generated_poc/` into a flat namespace
45+
- `_eager.py`: Eager realization of the public surface — binds every exported name and runs the import-time patchers (`_ergonomic`, `_forward_compat`)
3646
- `aliases.py`: Creates semantic aliases for numbered discriminated union types
3747
- `capabilities.py`: Re-exports `get_adcp_capabilities_response` sub-models with disambiguated names
3848
- `_ergonomic.py`: Applies BeforeValidator coercion for type ergonomics
3949
- `_forward_compat.py`: Patches `Format.assets` / `RepeatableAssetGroup.assets` with open union types at import time
40-
- `__init__.py`: Public API surface
50+
- `__init__.py`: Public API surface (thin lazy facade)
4151

4252
All other source code should import from `adcp.types` (the public API).
4353

CONTRIBUTING.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,58 @@ src/adcp/
8585
- `tests/type_checks/` is the adopter-facing type contract suite. Fixtures must
8686
pass `mypy --strict` without `# type: ignore` suppressions.
8787

88+
### Adding a public type/export
89+
90+
`adcp` and `adcp.types` are lazy (PEP 562): `import adcp` is ~2ms and does not
91+
build the generated Pydantic graph or import the client. The first access to any
92+
AdCP type builds the full graph once (~1s per process). The runtime resolution
93+
lives in a `__getattr__` under `if not TYPE_CHECKING:`; type checkers see the
94+
surface only through an explicit `TYPE_CHECKING` re-export block. Because of that
95+
split, a new public export must be added in **both** places — the lazy runtime
96+
map and the `TYPE_CHECKING` block — or it silently breaks lazy resolution or
97+
type-checker visibility. See the "Import Architecture for Generated Types" section
98+
in `CLAUDE.md` for the layering this protects.
99+
100+
Pick the surface you are adding to:
101+
102+
- **Top-level `adcp` export** (`from adcp import Foo`): add the name to the owning
103+
module's tuple in `_LAZY_MODULES`, to the matching `from <module> import (...)`
104+
block under `TYPE_CHECKING`, and to `__all__` — all three in
105+
`src/adcp/__init__.py`.
106+
107+
- **`adcp.types` export** (`from adcp.types import Foo`): the name is bound in
108+
`src/adcp/types/_eager.py` (the eager body that realizes the graph). In
109+
`src/adcp/types/__init__.py`, add it to `__all__` and to the `from
110+
adcp.types._eager import (...)` block under `TYPE_CHECKING`. If it is an internal
111+
re-export helper that is intentionally *not* in `__all__`, add it to
112+
`_EAGER_ONLY_EXTRAS` instead (this constant must match `_eager`'s namespace
113+
exactly).
114+
115+
- **Curated partial module** (`adcp.types.media_buy`, `creative`, `signals`,
116+
`protocol`, `buyer`, `seller`): add the name to that module's `__all__` and its
117+
`from adcp.types import (...)` block under `TYPE_CHECKING`. The name must already
118+
be exported from `adcp.types` — partial modules only re-curate that surface; they
119+
never import the generated layer.
120+
121+
Never import from `adcp.types.generated_poc.*` or `adcp.types._generated` outside
122+
the allowlisted layering modules (`_generated.py`, `aliases.py`, `_ergonomic.py`,
123+
`_forward_compat.py`, `capabilities.py`, `canonical_decl.py`, `_eager.py`, and
124+
`types/__init__.py`). The generated class names are unstable across schema regen.
125+
126+
After an intentional change to `adcp.__all__` or `adcp.types.__all__`, regenerate
127+
the public-API snapshot:
128+
129+
```bash
130+
python scripts/regenerate_public_api_snapshot.py
131+
```
132+
133+
These guards enforce the contract and run in `make ci-local`:
134+
135+
- `tests/test_import_layering.py` — no new module may import the generated layer.
136+
- `tests/test_lazy_types.py` — lazy/eager parity, fast-fail on unknown names, and
137+
`_EAGER_ONLY_EXTRAS` matching `_eager`.
138+
- `tests/test_public_api.py` — the public-API snapshot.
139+
88140
### Documentation
89141
- Add docstrings to all public functions
90142
- Use Google-style docstrings

MIGRATION_v3_to_v4.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,14 @@ from adcp.types import ContextObject, TargetingOverlay
482482
top-level surface, check `from adcp.types import X` first — most generated
483483
types are re-exported there.
484484

485+
For a narrower import surface, six curated partial modules group types by
486+
domain: `adcp.types.media_buy`, `adcp.types.creative`, `adcp.types.signals`,
487+
`adcp.types.protocol`, `adcp.types.buyer`, and `adcp.types.seller`. Each is
488+
lazy and re-exports only the types relevant to that area
489+
(`from adcp.types.media_buy import CreateMediaBuyRequest`). These and
490+
`adcp.types` are the supported surfaces — never import from
491+
`adcp.types.generated_poc.*` or `adcp.types._generated`.
492+
485493
## `__version__` now reflects the installed distribution
486494

487495
`adcp.__version__` now reads from `importlib.metadata.version("adcp")`

README.md

Lines changed: 149 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,73 @@
66

77
Official Python SDK for the **Ad Context Protocol (AdCP)**. Build and connect to advertising agents that work synchronously OR asynchronously with the same code.
88

9+
## Choose your path
10+
11+
This README serves both sides of an AdCP integration. Jump to what you're doing:
12+
13+
- **Connect as a buyer**[Quick Start: Test Helpers](#quick-start-test-helpers) and [Quick Start: Distributed Operations](#quick-start-distributed-operations). Entry point: `from adcp import ADCPClient, AgentConfig`; start with the `client.simple.*` API.
14+
- **Build a seller / agent**[Building an AdCP Agent](#building-an-adcp-agent). Entry point: `from adcp.server import ADCPHandler, serve`.
15+
- **Understand the type system & imports**[Type Safety](#type-safety) (import surface, partial modules, cold-start note).
16+
- **Test against reference agents**[Quick Start: Test Helpers](#quick-start-test-helpers) and [Test Helpers](#test-helpers). Entry point: `from adcp.testing import test_agent, creative_agent`.
17+
18+
## Table of Contents
19+
20+
- [Building an AdCP Agent](#building-an-adcp-agent)
21+
- [Multi-agent discovery manifest](#multi-agent-discovery-manifest)
22+
- [Connecting to AdCP Agents](#connecting-to-adcp-agents)
23+
- [The Core Concept](#the-core-concept)
24+
- [Installation](#installation)
25+
- [Quick Start: Test Helpers](#quick-start-test-helpers)
26+
- [Simple vs. Standard API](#simple-vs-standard-api)
27+
- [Available Test Helpers](#available-test-helpers)
28+
- [Quick Start: Distributed Operations](#quick-start-distributed-operations)
29+
- [AdCP version support](#adcp-version-support)
30+
- [Documentation](#documentation)
31+
- [Features](#features)
32+
- [Test Helpers](#test-helpers)
33+
- [Full Protocol Support](#full-protocol-support)
34+
- [Type Safety](#type-safety)
35+
- [Multi-Agent Operations](#multi-agent-operations)
36+
- [Webhook Handling](#webhook-handling)
37+
- [Security](#security)
38+
- [Signed webhooks (AdCP 3.0): receiver quickstart](#signed-webhooks-adcp-30-receiver-quickstart)
39+
- [Signed webhooks: sender quickstart](#signed-webhooks-sender-quickstart)
40+
- [Debug Mode](#debug-mode)
41+
- [Resource Management](#resource-management)
42+
- [Error Handling](#error-handling)
43+
- [Idempotency and retries](#idempotency-and-retries)
44+
- [Building a seller: idempotency middleware](#building-a-seller-idempotency-middleware)
45+
- [AdCP 3.0.0-rc.4 migration](#adcp-300-rc4-migration)
46+
- [Available Tools](#available-tools)
47+
- [Workflow Examples](#workflow-examples)
48+
- [Complete Media Buy Workflow](#complete-media-buy-workflow)
49+
- [Complete Creative Workflow](#complete-creative-workflow)
50+
- [Integrated Workflow: Media Buy + Creatives](#integrated-workflow-media-buy--creatives)
51+
- [Property Discovery (AdCP v2.2.0)](#property-discovery-adcp-v220)
52+
- [Publisher Authorization Validation](#publisher-authorization-validation)
53+
- [Authorization Discovery](#authorization-discovery)
54+
- [Request Signing (AdCP 3.0 optional, 4.0 required)](#request-signing-adcp-30-optional-40-required)
55+
- [Generate a keypair](#generate-a-keypair)
56+
- [Sign an outgoing request](#sign-an-outgoing-request)
57+
- [Auto-sign on `ADCPClient`](#auto-sign-on-adcpclient)
58+
- [Auto-sign on raw httpx (no ADCPClient)](#auto-sign-on-raw-httpx-no-adcpclient)
59+
- [Verify incoming requests (FastAPI)](#verify-incoming-requests-fastapi)
60+
- [Migration & rollout](#migration--rollout)
61+
- [Conformance](#conformance)
62+
- [CLI Tool](#cli-tool)
63+
- [Installation](#installation-1)
64+
- [Quick Start](#quick-start)
65+
- [Using Test Agents from CLI](#using-test-agents-from-cli)
66+
- [Configuration Management](#configuration-management)
67+
- [Direct URL Access](#direct-url-access)
68+
- [Examples](#examples)
69+
- [Configuration File](#configuration-file)
70+
- [Environment Configuration](#environment-configuration)
71+
- [Development](#development)
72+
- [Contributing](#contributing)
73+
- [License](#license)
74+
- [Support](#support)
75+
976
## Building an AdCP Agent
1077

1178
The fastest path to a working agent: subclass `ADCPHandler`, use response builders, call `serve()`.
@@ -154,7 +221,7 @@ Pre-configured agents (all include `.simple` accessor):
154221
155222
See [examples/simple_api_demo.py](examples/simple_api_demo.py) for a complete comparison.
156223

157-
> **Tip**: Import types from the main `adcp` package (e.g., `from adcp import GetProductsRequest`) rather than `adcp.types.generated` for better API stability.
224+
> **Tip**: Import types from the main `adcp` package (e.g., `from adcp import GetProductsRequest`), from `adcp.types`, or from a curated partial module (`adcp.types.media_buy`, `.creative`, `.signals`, `.protocol`, `.buyer`, `.seller`) — never from the internal `adcp.types.generated_poc.*` layer. `import adcp` is lightweight; the generated type graph is built only when you import a type.
158225
159226
## Quick Start: Distributed Operations
160227

@@ -209,13 +276,20 @@ async with ADCPMultiAgentClient(
209276

210277
## AdCP version support
211278

212-
The 5.x line targets AdCP 3.0 stable. v3.1 support lands in SDK 6.0 against
213-
the 3.1 stable spec — there is no opt-in preview surface in 5.x. If you talk
214-
to a v3.1+ agent from 5.x, the SDK parses the response through v3.0 types
215-
(unknown fields are preserved on the model but not surfaced as typed
216-
attributes) and schema validation is skipped for that version. Track
217-
[#741](https://github.com/adcontextprotocol/adcp-client-python/issues/741)
218-
for 6.0 progress.
279+
The 6.x line is built against **AdCP 3.1.0 stable** and natively validates
280+
both AdCP 3.0 and 3.1 wire shapes. Check the versions at runtime:
281+
282+
```python
283+
import adcp
284+
285+
adcp.get_adcp_sdk_version() # SDK package version, e.g. "6.4.1"
286+
adcp.get_adcp_spec_version() # AdCP spec this build targets, e.g. "3.1.0"
287+
```
288+
289+
If you talk to an agent on a newer spec than this SDK validates, the response
290+
still parses — unknown fields are preserved on the model (but not surfaced as
291+
typed attributes) and schema validation is skipped for that version, so
292+
forward traffic degrades gracefully rather than failing.
219293

220294
## Documentation
221295

@@ -341,7 +415,31 @@ if media_buy.status == MediaBuyStatus.active:
341415
- **All 9 pricing options**: `CpcPricingOption`, `CpmFixedRatePricingOption`, `VcpmAuctionPricingOption`, etc.
342416
- **Request/Response types**: All 16 operations with full request/response types
343417

344-
For types not on the top-level surface, import from `adcp.types` (e.g., `from adcp.types import AssetStatus`). If a type you need isn't in `adcp.types`, open an issue — we'll add an alias. The `adcp.types.generated_poc.*` modules are internal; class names and module paths shift on every schema regeneration and are not a supported API.
418+
For types not on the top-level surface, import from `adcp.types` (e.g., `from adcp.types import AssetStatus`), or from one of the curated partial modules that group the types by domain:
419+
420+
```python
421+
from adcp.types.media_buy import CreateMediaBuyRequest, MediaBuyStatus
422+
from adcp.types.creative import Format, SyncCreativesRequest
423+
from adcp.types.signals import GetSignalsRequest, SignalTargeting
424+
from adcp.types.protocol import Error, Pagination, GetTaskStatusRequest
425+
from adcp.types.buyer import GetProductsRequest, CpmPricingOption
426+
from adcp.types.seller import Offering, PropertyList, ContentStandards
427+
```
428+
429+
If a type you need isn't in `adcp.types`, open an issue — we'll add an alias. The `adcp.types.generated_poc.*` modules are internal; class names and module paths shift on every schema regeneration and are not a supported API.
430+
431+
The six partial modules (`media_buy`, `creative`, `signals`, `protocol`, `buyer`, `seller`) are for curation and discoverability — they group types by domain and give you a smaller import surface. They are **not** a per-domain performance tier: the first AdCP type you touch through any of them builds the same single Pydantic graph.
432+
433+
#### Cold start / import performance
434+
435+
`import adcp` is lightweight (~2ms) and builds nothing — it does not import pydantic, the A2A SDK, or the client, and it does not construct the type graph. The first time you access *any* AdCP type — through `adcp`, `adcp.types`, or a partial module — the full generated Pydantic graph builds once per process (~1s). There is only one graph; subsequent type access is free.
436+
437+
For latency-sensitive cold starts (AWS Lambda, agent tool invocations), warm the graph at startup so the cost lands before your first request:
438+
439+
```python
440+
import adcp.types
441+
adcp.types.Product # forces the one-time graph build now, not on the hot path
442+
```
345443

346444
#### Semantic Type Aliases
347445

@@ -364,7 +462,6 @@ def handle_response(
364462

365463
**Available semantic aliases:**
366464
- Response types: `*SuccessResponse` / `*ErrorResponse` (e.g., `CreateMediaBuySuccessResponse`)
367-
- Request variants: `*FormatRequest` / `*ManifestRequest` (e.g., `PreviewCreativeFormatRequest`)
368465
- Preview renders: `PreviewRenderImage` / `PreviewRenderHtml` / `PreviewRenderIframe`
369466
- Activation keys: `PropertyIdActivationKey` / `PropertyTagActivationKey`
370467

@@ -952,8 +1049,8 @@ Build and deliver production-ready creatives:
9521049

9531050
```python
9541051
from adcp import ADCPClient, AgentConfig
955-
from adcp import PreviewCreativeFormatRequest, BuildCreativeRequest
956-
from adcp import CreativeManifest, PlatformDeployment
1052+
from adcp import PreviewCreativeRequest, BuildCreativeRequest
1053+
from adcp import CreativeManifest
9571054

9581055
# 1. Connect to creative agent
9591056
config = AgentConfig(id="creative_agent", agent_uri="https://...", protocol="mcp")
@@ -963,18 +1060,25 @@ async with ADCPClient(config) as client:
9631060
formats_result = await client.list_creative_formats()
9641061

9651062
if formats_result.success:
1063+
# format_id is a FormatReferenceStructuredObject; reuse it directly
9661064
format_id = formats_result.data.formats[0].format_id
9671065
print(f"Using format: {format_id.id}")
9681066

9691067
# 3. Preview creative (test before building)
9701068
preview_result = await client.preview_creative(
971-
PreviewCreativeFormatRequest(
972-
target_format_id=format_id.id,
973-
inputs={
974-
"headline": "Fresh Coffee Daily",
975-
"cta": "Order Now"
976-
},
977-
output_format="url" # Get preview URL
1069+
PreviewCreativeRequest(
1070+
request_type="single",
1071+
format_id=format_id,
1072+
inputs=[
1073+
{
1074+
"name": "Coffee promo",
1075+
"macros": {
1076+
"headline": "Fresh Coffee Daily",
1077+
"cta": "Order Now",
1078+
},
1079+
}
1080+
],
1081+
output_format="url", # Get preview URL
9781082
)
9791083
)
9801084

@@ -985,16 +1089,19 @@ async with ADCPClient(config) as client:
9851089
# 4. Build production creative
9861090
build_result = await client.build_creative(
9871091
BuildCreativeRequest(
988-
manifest=CreativeManifest(
1092+
idempotency_key="build-coffee-001",
1093+
creative_manifest=CreativeManifest(
9891094
format_id=format_id,
990-
brand_url="https://coffeeco.com",
991-
# ... creative content
1095+
assets={
1096+
"banner_image": {
1097+
"asset_type": "image",
1098+
"url": "https://cdn.coffeeco.com/banner_300x250.png",
1099+
"width": 300,
1100+
"height": 250,
1101+
}
1102+
},
9921103
),
993-
target_format_id=format_id.id,
994-
deployment=PlatformDeployment(
995-
type="platform",
996-
platform_id="google_admanager"
997-
)
1104+
target_format_id=format_id,
9981105
)
9991106
)
10001107

@@ -1009,7 +1116,7 @@ Combine both workflows for a complete campaign setup:
10091116

10101117
```python
10111118
from adcp import ADCPMultiAgentClient, AgentConfig, BrandReference, PublisherPropertiesAll
1012-
from adcp import BuildCreativeRequest, CreateMediaBuyRequest
1119+
from adcp import BuildCreativeRequest, CreateMediaBuyRequest, CreativeManifest
10131120

10141121
# Connect to both sales and creative agents
10151122
async with ADCPMultiAgentClient(
@@ -1029,12 +1136,24 @@ async with ADCPMultiAgentClient(
10291136
# 2. Get creative formats from creative agent
10301137
creative_agent = client.agent("creative")
10311138
formats = await creative_agent.simple.list_creative_formats()
1139+
format_id = formats.formats[0].format_id
10321140

10331141
# 3. Build creative asset
10341142
creative_result = await creative_agent.build_creative(
10351143
BuildCreativeRequest(
1036-
manifest=creative_manifest,
1037-
target_format_id=formats.formats[0].format_id.id,
1144+
idempotency_key="build-campaign-001",
1145+
creative_manifest=CreativeManifest(
1146+
format_id=format_id,
1147+
assets={
1148+
"banner_image": {
1149+
"asset_type": "image",
1150+
"url": "https://cdn.coffeeco.com/banner_300x250.png",
1151+
"width": 300,
1152+
"height": 250,
1153+
}
1154+
},
1155+
),
1156+
target_format_id=format_id,
10381157
)
10391158
)
10401159

0 commit comments

Comments
 (0)