diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 011f2b8c6..75ab1c73f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,9 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +env: + ADCP_SDK_VERSION: "7.10.1" + jobs: test: name: Test Python ${{ matrix.python-version }} @@ -369,17 +372,13 @@ jobs: # Cache the npm tarball + extracted package directory so the # storyboard runner install isn't a cold network fetch every run. - # Key by OS only (not by version) so the cache survives across - # ``@adcp/sdk`` releases — npm install reuses tarballs that are - # already in the cache and only fetches the delta. ``@latest`` is - # intentional for drift detection (see "Run storyboard suite" - # below); the cache amortizes the 5-15 s of fetch+extract that - # would otherwise repeat on every CI run. + # Key by SDK version so storyboard CI executes against the same + # runner release locally and in GitHub Actions. - name: Cache ~/.npm uses: actions/cache@v4 with: path: ~/.npm - key: ${{ runner.os }}-npm-adcp-sdk + key: ${{ runner.os }}-npm-adcp-sdk-${{ env.ADCP_SDK_VERSION }} restore-keys: | ${{ runner.os }}-npm- @@ -387,10 +386,8 @@ jobs: # Single install step at the top of the job; subsequent runner # calls invoke the already-installed binary instead of paying # the ``npx -y -p ...`` per-invocation extract+link tax. - # ``@adcp/sdk@latest`` is intentionally unpinned: this is AdCP's - # own CI running AdCP's own canonical runner — tracking latest - # surfaces protocol drift as soon as it ships, which is the - # point of this job. + # Pin the runner release so CI remains reproducible while the + # examples catch up to new storyboard scenarios intentionally. # # Vendor missing fixtures into the SDK install: # ``@adcp/sdk`` does not ship two fixtures its storyboard runner @@ -403,7 +400,7 @@ jobs: # into the SDK's expected locations post-install; idempotent if # upstream later ships them in the npm tarball. run: | - npm install -g @adcp/sdk@latest + npm install -g @adcp/sdk@${ADCP_SDK_VERSION} adcp --version SDK_ROOT="$(npm root -g)/@adcp/sdk" mkdir -p "${SDK_ROOT}/test/lib/v2-projection-fixtures" @@ -541,15 +538,13 @@ jobs: with: node-version: "22" - # Cache ~/.npm by OS only so subsequent runs hit the tarball - # cache; npm install reuses what's there and only fetches the - # delta on a new ``@latest`` release. See the storyboard job - # above for the same pattern + rationale. + # Cache ~/.npm by SDK version so this storyboard job runs the + # same pinned runner release as the other example storyboards. - name: Cache ~/.npm uses: actions/cache@v4 with: path: ~/.npm - key: ${{ runner.os }}-npm-adcp-sdk + key: ${{ runner.os }}-npm-adcp-sdk-${{ env.ADCP_SDK_VERSION }} restore-keys: | ${{ runner.os }}-npm- @@ -567,7 +562,7 @@ jobs: # See the comment on the storyboard job's install step for the # AAO reference-formats fixture rationale (upstream adcp#3307). run: | - npm install -g @adcp/sdk@latest + npm install -g @adcp/sdk@${ADCP_SDK_VERSION} adcp --version SDK_ROOT="$(npm root -g)/@adcp/sdk" mkdir -p "${SDK_ROOT}/test/lib/v2-projection-fixtures" @@ -775,7 +770,7 @@ jobs: uses: actions/cache@v4 with: path: ~/.npm - key: ${{ runner.os }}-npm-adcp-sdk + key: ${{ runner.os }}-npm-adcp-sdk-${{ env.ADCP_SDK_VERSION }} restore-keys: | ${{ runner.os }}-npm- @@ -783,7 +778,7 @@ jobs: # See the comment on the storyboard job's install step for the # AAO reference-formats fixture rationale (upstream adcp#3307). run: | - npm install -g @adcp/sdk@latest + npm install -g @adcp/sdk@${ADCP_SDK_VERSION} adcp --version SDK_ROOT="$(npm root -g)/@adcp/sdk" mkdir -p "${SDK_ROOT}/test/lib/v2-projection-fixtures" @@ -883,7 +878,7 @@ jobs: uses: actions/cache@v4 with: path: ~/.npm - key: ${{ runner.os }}-npm-adcp-sdk + key: ${{ runner.os }}-npm-adcp-sdk-${{ env.ADCP_SDK_VERSION }} restore-keys: | ${{ runner.os }}-npm- @@ -891,7 +886,7 @@ jobs: # See the comment on the storyboard job's install step for the # AAO reference-formats fixture rationale (upstream adcp#3307). run: | - npm install -g @adcp/sdk@latest + npm install -g @adcp/sdk@${ADCP_SDK_VERSION} adcp --version SDK_ROOT="$(npm root -g)/@adcp/sdk" mkdir -p "${SDK_ROOT}/test/lib/v2-projection-fixtures" diff --git a/CHANGELOG.md b/CHANGELOG.md index 920577e68..2afb0e648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Bug Fixes + +* **examples:** keep storyboard examples compatible with `@adcp/sdk@7.10.1` ([#782](https://github.com/adcontextprotocol/adcp-client-python/issues/782)) + ## [5.6.0](https://github.com/adcontextprotocol/adcp-client-python/compare/v5.5.0...v5.6.0) (2026-05-19) diff --git a/examples/multi_platform_seller/src/account_store.py b/examples/multi_platform_seller/src/account_store.py index c085af009..553f56b1e 100644 --- a/examples/multi_platform_seller/src/account_store.py +++ b/examples/multi_platform_seller/src/account_store.py @@ -83,6 +83,35 @@ def resolve( auth_info=_auth_info_to_dict(auth_info), ) + def list( + self, + filter: dict[str, Any] | None = None, + ctx: Any | None = None, + ) -> list[Account[dict[str, Any]]]: + """Expose the demo tenant roster through ``list_accounts``. + + The multi-platform example uses explicit account resolution: + buyers route by subdomain or by ``tenant:account`` ids. The + storyboard runner still expects every sales seller to advertise + an account-discovery tool, so the store lists one stable demo + account per visible tenant. + """ + del ctx + if (filter or {}).get("sandbox") is True: + return [] + + tenant = self._tenant_from_subdomain() + tenants = [tenant] if tenant in self._tenants else sorted(self._tenants) + return [ + Account( + id=f"{tenant_id}:default", + name=f"{tenant_id} demo account", + status="active", + metadata={"tenant_id": tenant_id}, + ) + for tenant_id in tenants + ] + # ----- internals -------------------------------------------------- def _tenant_from_subdomain(self) -> str | None: diff --git a/examples/multi_platform_seller/src/mock_guaranteed.py b/examples/multi_platform_seller/src/mock_guaranteed.py index b37a988cc..149581c13 100644 --- a/examples/multi_platform_seller/src/mock_guaranteed.py +++ b/examples/multi_platform_seller/src/mock_guaranteed.py @@ -346,6 +346,17 @@ def update_media_buy( new_state = _read_target_state(patch) if new_state is not None: + if new_state == "canceled" and buy.status in ( + "completed", + "rejected", + "canceled", + ): + raise AdcpError( + "NOT_CANCELLABLE", + message=f"Cannot cancel a {buy.status} media buy", + recovery="correctable", + field="media_buy_id", + ) assert_media_buy_transition(buy.status, new_state, media_buy_id=media_buy_id) buy.status = new_state @@ -416,8 +427,16 @@ def list_creatives( ``query_summary`` block is required by ``schemas/3.0.6/creative/list-creatives-response.json``. """ + requested = _read_creative_ids(req) with self._lock: - creatives = list(self._creatives.values()) + if requested: + creatives = [ + self._creatives[creative_id] + for creative_id in requested + if creative_id in self._creatives + ] + else: + creatives = list(self._creatives.values()) total = len(creatives) return { "query_summary": {"total_matching": total, "returned": total}, @@ -584,6 +603,13 @@ def _parse_iso(value: str) -> datetime: return datetime.now(timezone.utc) +def _iso_z(value: datetime) -> str: + """Emit storyboard-compatible UTC datetimes without fractional seconds.""" + return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace( + "+00:00", "Z" + ) + + def _read_pkg_buyer_ref(pkg: Any, idx: int) -> str: return str(_attr(pkg, "buyer_ref", f"pkg-{idx}")) @@ -666,20 +692,8 @@ def _project_creative_to_wire(creative: Any, idx: int) -> dict[str, Any]: submitted creative comes back as ``approved``.""" creative_id = _creative_id(creative, idx) name = _attr(creative, "name", None) or creative_id - raw_format = _attr(creative, "format_id", None) - if isinstance(raw_format, dict): - format_id = raw_format - elif raw_format is not None: - format_id = { - "agent_url": "https://creative.adcontextprotocol.org/", - "id": str(raw_format), - } - else: - format_id = { - "agent_url": "https://creative.adcontextprotocol.org/", - "id": "display_300x250", - } - now_iso = datetime.now(timezone.utc).isoformat() + format_id = _format_id_to_wire(_attr(creative, "format_id", None)) + now_iso = _iso_z(datetime.now(timezone.utc)) return { "creative_id": creative_id, "name": str(name), @@ -712,16 +726,47 @@ def _project_media_buy_to_wire(buy: _MediaBuy) -> dict[str, Any]: "status": buy.status, "currency": "USD", "total_budget": buy.total_budget_usd, - "start_time": buy.start_time.isoformat(), - "end_time": buy.end_time.isoformat(), + "start_time": _iso_z(buy.start_time), + "end_time": _iso_z(buy.end_time), "packages": packages, } +def _format_id_to_wire(raw_format: Any) -> dict[str, Any]: + if raw_format is None: + return { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x250", + } + if hasattr(raw_format, "model_dump"): + raw_format = raw_format.model_dump(mode="json", exclude_none=True) + if isinstance(raw_format, dict): + format_id = raw_format.get("id", "display_300x250") + return { + "agent_url": raw_format.get( + "agent_url", "https://creative.adcontextprotocol.org/" + ), + "id": str(format_id), + **{k: v for k, v in raw_format.items() if k not in {"agent_url", "id"}}, + } + return { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": str(raw_format), + } + + def _read_target_state(patch: Any) -> str | None: """Project an ``UpdateMediaBuyRequest`` onto a target lifecycle state.""" if patch is None: return None + canceled = _attr(patch, "canceled", None) + if canceled is True: + return "canceled" + paused = _attr(patch, "paused", None) + if paused is True: + return "paused" + if paused is False: + return "active" active = _attr(patch, "active", None) if active is True: return "active" @@ -738,6 +783,12 @@ def _read_creatives(req: Any) -> list[Any]: return list(raw) +def _read_creative_ids(req: Any) -> list[str]: + filters = _attr(req, "filters", None) + raw = _attr(filters, "creative_ids", None) or _attr(req, "creative_ids", None) or [] + return [str(item) for item in raw if item is not None] + + def _read_media_buy_id(req: Any) -> str | None: raw = _attr(req, "media_buy_id", None) if raw is not None: diff --git a/examples/multi_platform_seller/src/mock_non_guaranteed.py b/examples/multi_platform_seller/src/mock_non_guaranteed.py index f672f91d7..9920831eb 100644 --- a/examples/multi_platform_seller/src/mock_non_guaranteed.py +++ b/examples/multi_platform_seller/src/mock_non_guaranteed.py @@ -308,6 +308,17 @@ def update_media_buy( new_state = _read_target_state(patch) if new_state is not None: + if new_state == "canceled" and buy.status in ( + "completed", + "rejected", + "canceled", + ): + raise AdcpError( + "NOT_CANCELLABLE", + message=f"Cannot cancel a {buy.status} media buy", + recovery="correctable", + field="media_buy_id", + ) assert_media_buy_transition(buy.status, new_state, media_buy_id=media_buy_id) buy.status = new_state @@ -385,8 +396,16 @@ def list_creatives( ``query_summary`` block is required by ``schemas/3.0.6/creative/list-creatives-response.json``. """ + requested = _read_creative_ids(req) with self._lock: - creatives = list(self._creatives.values()) + if requested: + creatives = [ + self._creatives[creative_id] + for creative_id in requested + if creative_id in self._creatives + ] + else: + creatives = list(self._creatives.values()) total = len(creatives) return { "query_summary": {"total_matching": total, "returned": total}, @@ -538,6 +557,13 @@ def _parse_iso(value: str) -> datetime: return datetime.now(timezone.utc) +def _iso_z(value: datetime) -> str: + """Emit storyboard-compatible UTC datetimes without fractional seconds.""" + return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace( + "+00:00", "Z" + ) + + def _read_pkg_buyer_ref(pkg: Any, idx: int) -> str: return str(_attr(pkg, "buyer_ref", f"pkg-{idx}")) @@ -545,6 +571,14 @@ def _read_pkg_buyer_ref(pkg: Any, idx: int) -> str: def _read_target_state(patch: Any) -> str | None: if patch is None: return None + canceled = _attr(patch, "canceled", None) + if canceled is True: + return "canceled" + paused = _attr(patch, "paused", None) + if paused is True: + return "paused" + if paused is False: + return "active" active = _attr(patch, "active", None) if active is True: return "active" @@ -561,6 +595,12 @@ def _read_creatives(req: Any) -> list[Any]: return list(raw) +def _read_creative_ids(req: Any) -> list[str]: + filters = _attr(req, "filters", None) + raw = _attr(filters, "creative_ids", None) or _attr(req, "creative_ids", None) or [] + return [str(item) for item in raw if item is not None] + + def _read_media_buy_id(req: Any) -> str | None: raw = _attr(req, "media_buy_id", None) if raw is not None: @@ -637,20 +677,8 @@ def _project_creative_to_wire(creative: Any, idx: int) -> dict[str, Any]: submitted creative comes back as ``approved``.""" creative_id = _creative_id(creative, idx) name = _attr(creative, "name", None) or creative_id - raw_format = _attr(creative, "format_id", None) - if isinstance(raw_format, dict): - format_id = raw_format - elif raw_format is not None: - format_id = { - "agent_url": "https://creative.adcontextprotocol.org/", - "id": str(raw_format), - } - else: - format_id = { - "agent_url": "https://creative.adcontextprotocol.org/", - "id": "display_300x250", - } - now_iso = datetime.now(timezone.utc).isoformat() + format_id = _format_id_to_wire(_attr(creative, "format_id", None)) + now_iso = _iso_z(datetime.now(timezone.utc)) return { "creative_id": creative_id, "name": str(name), @@ -680,12 +708,35 @@ def _project_media_buy_to_wire(buy: _MediaBuy) -> dict[str, Any]: "status": buy.status, "currency": "USD", "total_budget": buy.total_budget_usd, - "start_time": buy.start_time.isoformat(), - "end_time": buy.end_time.isoformat(), + "start_time": _iso_z(buy.start_time), + "end_time": _iso_z(buy.end_time), "packages": packages, } +def _format_id_to_wire(raw_format: Any) -> dict[str, Any]: + if raw_format is None: + return { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x250", + } + if hasattr(raw_format, "model_dump"): + raw_format = raw_format.model_dump(mode="json", exclude_none=True) + if isinstance(raw_format, dict): + format_id = raw_format.get("id", "display_300x250") + return { + "agent_url": raw_format.get( + "agent_url", "https://creative.adcontextprotocol.org/" + ), + "id": str(format_id), + **{k: v for k, v in raw_format.items() if k not in {"agent_url", "id"}}, + } + return { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": str(raw_format), + } + + def _creative_id(creative: Any, idx: int) -> str: raw = _attr(creative, "creative_id", None) if raw: diff --git a/tests/test_multi_platform_seller_example.py b/tests/test_multi_platform_seller_example.py new file mode 100644 index 000000000..352ba07f0 --- /dev/null +++ b/tests/test_multi_platform_seller_example.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from types import SimpleNamespace + +import pytest + +from adcp.decisioning import AdcpError, InMemoryTaskRegistry, RequestContext +from adcp.decisioning.handler import PlatformHandler +from adcp.types import FormatId +from adcp.types.generated_poc.creative.list_creatives_response import ListCreativesResponse +from examples.multi_platform_seller.src.app import build_router +from examples.multi_platform_seller.src.mock_guaranteed import MockGuaranteedPlatform + + +def _create_guaranteed_buy(platform: MockGuaranteedPlatform) -> str: + response = platform.create_media_buy( + SimpleNamespace( + buyer_ref="buyer-1", + start_time="2026-05-01T00:00:00Z", + end_time="2026-05-31T00:00:00Z", + packages=[ + SimpleNamespace( + buyer_ref="pkg-1", + product_id="guaranteed-homepage-takeover", + budget=1000, + ) + ], + ), + RequestContext(), + ) + return str(response["media_buy_id"]) + + +def test_multi_platform_router_advertises_account_discovery_tool() -> None: + router = build_router() + pool = ThreadPoolExecutor(max_workers=1) + try: + handler = PlatformHandler( + router, + executor=pool, + registry=InMemoryTaskRegistry(), + ) + advertised = handler.advertised_tools_for_instance() + finally: + pool.shutdown(wait=True) + + assert "list_accounts" in advertised + + +def test_guaranteed_get_media_buys_projects_storyboard_datetime_shape() -> None: + platform = MockGuaranteedPlatform() + media_buy_id = _create_guaranteed_buy(platform) + + response = platform.get_media_buys( + SimpleNamespace(media_buy_ids=[media_buy_id]), + RequestContext(), + ) + + buy = response["media_buys"][0] + assert buy["start_time"] == "2026-05-01T00:00:00Z" + assert buy["end_time"] == "2026-05-31T00:00:00Z" + + +def test_guaranteed_recancel_raises_not_cancellable() -> None: + platform = MockGuaranteedPlatform() + media_buy_id = _create_guaranteed_buy(platform) + + first = platform.update_media_buy( + media_buy_id, + SimpleNamespace(canceled=True), + RequestContext(), + ) + assert first["status"] == "canceled" + + with pytest.raises(AdcpError) as excinfo: + platform.update_media_buy( + media_buy_id, + SimpleNamespace(canceled=True), + RequestContext(), + ) + + assert excinfo.value.code == "NOT_CANCELLABLE" + + +def test_creative_format_projection_preserves_structured_format_id() -> None: + platform = MockGuaranteedPlatform() + platform.sync_creatives( + SimpleNamespace( + creatives=[ + SimpleNamespace( + creative_id="creative-0", + name="Creative 0", + format_id=FormatId( + agent_url="https://creative.adcontextprotocol.org/", + id="display_300x250", + ), + ), + SimpleNamespace( + creative_id="creative-1", + name="Creative 1", + format_id=FormatId( + agent_url="https://creative.adcontextprotocol.org/", + id="display_300x250", + ), + ) + ] + ), + RequestContext(), + ) + + response = platform.list_creatives( + SimpleNamespace(filters=SimpleNamespace(creative_ids=["creative-1"])), + RequestContext(), + ) + + ListCreativesResponse.model_validate(response) + assert [creative["creative_id"] for creative in response["creatives"]] == ["creative-1"] + assert response["creatives"][0]["format_id"] == { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x250", + }