Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 17 additions & 22 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -369,28 +372,22 @@ 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-

- name: Pre-install @adcp/sdk (once, then call binary directly)
# 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
Expand All @@ -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"
Expand Down Expand Up @@ -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-

Expand All @@ -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"
Expand Down Expand Up @@ -775,15 +770,15 @@ 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-

- name: Pre-install @adcp/sdk
# 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"
Expand Down Expand Up @@ -883,15 +878,15 @@ 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-

- name: Pre-install @adcp/sdk
# 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"
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)


Expand Down
29 changes: 29 additions & 0 deletions examples/multi_platform_seller/src/account_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
85 changes: 68 additions & 17 deletions examples/multi_platform_seller/src/mock_guaranteed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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}"))

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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"
Expand All @@ -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:
Expand Down
Loading
Loading