From c35d300f76faec869524f1988189a7868cfebf62 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 27 May 2026 05:30:01 -0400 Subject: [PATCH 1/4] chore(dx): align local typecheck workflow --- .github/workflows/ci.yml | 4 +++ .pre-commit-config.yaml | 15 ++++++++- CLAUDE.md | 16 ++++++---- CONTRIBUTING.md | 45 ++++++++++++++++---------- Makefile | 32 ++++++++++++++++--- README.md | 14 ++++---- pyproject.toml | 5 +-- scripts/check_type_ignore_contract.py | 46 +++++++++++++++++++++++++++ 8 files changed, 138 insertions(+), 39 deletions(-) create mode 100644 scripts/check_type_ignore_contract.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62093025b..393457a81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,10 @@ jobs: run: | mypy --strict tests/type_checks/ + - name: Enforce adopter type-check fixture contract + run: | + python scripts/check_type_ignore_contract.py + - name: Run tests run: | pytest tests/ -v --cov=src/adcp --cov-report=term-missing diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 040af81e1..a3867c497 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ # Pre-commit hooks for AdCP Python client # See https://pre-commit.com for more information -# Installation: uv add --dev pre-commit && uv run pre-commit install +# Installation: make bootstrap repos: # Black code formatting @@ -30,6 +30,19 @@ repos: types: [python] pass_filenames: false args: [src/adcp] + - id: adopter-type-checks + name: adopter type-check fixtures + entry: uv run --extra dev mypy + language: system + types: [python] + pass_filenames: false + args: [--strict, tests/type_checks/] + - id: adopter-type-ignore-contract + name: "adopter type-check fixtures contain no type: ignore suppressions" + entry: uv run python scripts/check_type_ignore_contract.py + language: system + types: [python] + pass_filenames: false - id: check-commit-msg name: release-please commit subject check entry: scripts/check-commit-msg.sh diff --git a/CLAUDE.md b/CLAUDE.md index b56f65d28..7ff4a6a08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -184,15 +184,18 @@ Wrong order creates invalid escape sequences! ## Pre-Commit Checks -Run these three checks locally before every commit — they mirror CI exactly: +Run these checks locally before every commit: ```bash -ruff check src/ # Linter -mypy src/adcp/ # Type checker -pytest tests/ -v # Tests +make lint +make typecheck-all +make test ``` -All three must pass. CI runs them across Python 3.10–3.13; locally running on your current version catches most issues. +All must pass. `make ci-local` runs the core local CI target; some specialized +CI jobs such as Postgres conformance still have their own prerequisites. CI runs +the core matrix across Python 3.10–3.13; locally running on your current version +catches most issues. ## Parallel Agent Isolation (git worktrees) @@ -216,8 +219,7 @@ git worktree add /tmp/claude-issue-- -b claude/issue-- main ```bash cd /tmp/claude-issue-- cp "$(git rev-parse --git-common-dir)/../.env" .env # .env is not inherited -pre-commit install # hooks are not inherited from parent worktree -pip install -e .[dev] # install in this worktree's context +make bootstrap # requires uv; installs deps and hooks here ``` **Teardown (after branch is merged):** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff1e3051e..b6eede188 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,10 @@ Thank you for your interest in contributing to the AdCP Python client! ## Development Setup +This repository expects `uv` on your `PATH` for the local contributor +environment because the pre-commit hooks run through `uv run` to match CI +dependencies. + 1. Clone the repository: ```bash git clone https://github.com/adcontextprotocol/adcp-client-python.git @@ -12,25 +16,29 @@ cd adcp-client-python 2. Install dependencies and pre-commit hooks: ```bash -pip install -e ".[dev]" -pre-commit install -pre-commit install --hook-type commit-msg +make bootstrap ``` 3. Run tests: ```bash -pytest +make test ``` 4. Format code: ```bash -black src/ tests/ -ruff check src/ tests/ --fix +make format +make lint ``` 5. Type check: ```bash -mypy src/ +make typecheck-all +``` + +For the core local CI-style pass before opening a PR, run: + +```bash +make ci-local ``` ## Project Structure @@ -39,15 +47,16 @@ mypy src/ src/adcp/ ├── __init__.py # Main exports ├── client.py # ADCPClient & ADCPMultiAgentClient -├── protocols/ -│ ├── base.py # Protocol interface -│ ├── a2a.py # A2A adapter -│ └── mcp.py # MCP adapter -├── types/ -│ ├── core.py # Core types -│ └── tools.py # Generated from AdCP schema -└── utils/ - └── operation_id.py # Utilities +├── canonical_formats/ # Canonical format fixtures and adapters +├── compat/ # Legacy protocol compatibility adapters +├── decisioning/ # DecisioningPlatform framework +├── protocols/ # A2A and MCP client adapters +├── server/ # Server framework, auth, routing, middleware +├── signing/ # Request signing, verification, JWKS, replay stores +├── testing/ # In-process test helpers and test agents +├── types/ # Public types, generated models, mypy plugin +├── utils/ # Shared helpers +└── validation/ # Schema validation hooks and loaders ``` ## Guidelines @@ -68,7 +77,9 @@ src/adcp/ ### Type Safety - All functions must have type hints - Use Pydantic for data validation -- Run `mypy` before committing +- Run `make typecheck-all` before committing +- `tests/type_checks/` is the adopter-facing type contract suite. Fixtures must + pass `mypy --strict` without `# type: ignore` suppressions. ### Documentation - Add docstrings to all public functions diff --git a/Makefile b/Makefile index f5693cb39..602760663 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help format lint typecheck test test-type-checks regenerate-schemas pre-push ci-local clean install-dev check-schema-drift +.PHONY: help check-uv bootstrap format lint lint-all typecheck typecheck-all test test-type-checks check-type-ignore-contract regenerate-schemas pre-push ci-local clean install-dev check-schema-drift # Detect Python and use venv if available PYTHON := $(shell if [ -f .venv/bin/python ]; then echo .venv/bin/python; else echo python3; fi) @@ -7,6 +7,7 @@ PYTEST := $(shell if [ -f .venv/bin/pytest ]; then echo .venv/bin/pytest; else e BLACK := $(shell if [ -f .venv/bin/black ]; then echo .venv/bin/black; else echo black; fi) RUFF := $(shell if [ -f .venv/bin/ruff ]; then echo .venv/bin/ruff; else echo ruff; fi) MYPY := $(shell if [ -f .venv/bin/mypy ]; then echo .venv/bin/mypy; else echo mypy; fi) +UV := uv help: ## Show this help message @echo 'Usage: make [target]' @@ -14,21 +15,38 @@ help: ## Show this help message @echo 'Available targets:' @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' -install-dev: ## Install package in development mode with dev dependencies +install-dev: ## Install with pip dev extra only; bootstrap is preferred for contributors $(PIP) install -e ".[dev]" +check-uv: + @command -v $(UV) >/dev/null || { \ + echo "uv is required for bootstrap and pre-commit hooks. Install it from https://docs.astral.sh/uv/"; \ + exit 1; \ + } + +bootstrap: check-uv ## Install uv-managed dev deps and pre-commit hooks + $(UV) run --extra dev --group dev pre-commit install + $(UV) run --extra dev --group dev pre-commit install --hook-type commit-msg + format: ## Format code with black (excludes generated files) $(BLACK) src/ tests/ scripts/ @echo "✓ Code formatted successfully (_generated.py excluded via pyproject.toml)" lint: ## Run linter (ruff) on source code - $(RUFF) check src/ tests/ + $(RUFF) check src/ @echo "✓ Linting passed" +lint-all: ## Run linter (ruff) on source and tests + $(RUFF) check src/ tests/ + @echo "✓ Source and test linting passed" + typecheck: ## Run type checker (mypy) on source code $(MYPY) src/adcp/ @echo "✓ Type checking passed" +typecheck-all: typecheck test-type-checks check-type-ignore-contract ## Run all type-check contracts + @echo "✓ All type-check contracts passed" + test: ## Run test suite with coverage $(PYTEST) tests/ -v --cov=src/adcp --cov-report=term-missing @echo "✓ All tests passed" @@ -41,6 +59,10 @@ test-type-checks: ## Run adopter-pattern type-check suite (mypy --strict, zero t $(MYPY) --strict tests/type_checks/ @echo "✓ Adopter type-checks passed" +check-type-ignore-contract: ## Fail if adopter type-check fixtures use type: ignore suppressions + $(PYTHON) scripts/check_type_ignore_contract.py + @echo "✓ Adopter type-check fixtures contain no type: ignore suppressions" + test-generation: ## Run only code generation tests $(PYTEST) tests/test_code_generation.py -v @echo "✓ Code generation tests passed" @@ -70,7 +92,7 @@ validate-generated: ## Validate generated code (syntax and imports) @$(PYTHON) -m py_compile src/adcp/types/_generated.py @echo "✓ Generated code validation passed" -pre-push: format lint typecheck test validate-generated ## Run all checks before pushing (format, lint, typecheck, test, validate) +pre-push: format lint typecheck-all test validate-generated ## Run all checks before pushing (format, lint, typecheck, test, validate) @echo "" @echo "================================" @echo "✓ All pre-push checks passed!" @@ -78,7 +100,7 @@ pre-push: format lint typecheck test validate-generated ## Run all checks before @echo "" @echo "Safe to push to remote." -ci-local: lint typecheck test validate-generated ## Run CI checks locally (without formatting) +ci-local: lint typecheck-all test validate-generated ## Run core CI checks locally (without formatting) @echo "" @echo "================================" @echo "✓ All CI checks passed!" diff --git a/README.md b/README.md index 0bdd8eff3..4e1418039 100644 --- a/README.md +++ b/README.md @@ -1452,18 +1452,18 @@ client = ADCPMultiAgentClient.from_env() ## Development ```bash -# Install with dev dependencies -pip install -e ".[dev]" +# Install dev dependencies and git hooks (requires uv) +make bootstrap # Run tests -pytest +make test -# Type checking -mypy src/ +# Type checking: source package plus adopter-facing type fixtures +make typecheck-all # Format code -black src/ tests/ -ruff check src/ tests/ +make format +make lint ``` ## Contributing diff --git a/pyproject.toml b/pyproject.toml index bb258c797..e621178a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,7 +123,7 @@ dev = [ "pytest>=7.0.0", "pytest-asyncio>=0.21.0", "pytest-cov>=4.0.0", - "mypy>=1.20.0,<1.21", + "mypy==1.20.2", "black>=23.0.0", "ruff>=0.1.0", # Pin to exact version: codegen's variant numbering (e.g. CreateMediaBuyResponse1 vs @@ -148,6 +148,7 @@ dev = [ # propagate. Required at runtime by examples/v3_reference_seller/ # src/app.py and seed.py. "alembic>=1.13.0", + "pre-commit>=4.4.0", ] docs = [ "pdoc3>=0.10.0", @@ -321,5 +322,5 @@ dev = [ # from CI's, producing a different error count than CI on the same # source — and a dead-weight hook that everyone bypasses with # ``SKIP=mypy``. - "mypy>=1.20.0", + "mypy==1.20.2", ] diff --git a/scripts/check_type_ignore_contract.py b/scripts/check_type_ignore_contract.py new file mode 100644 index 000000000..e2920cca6 --- /dev/null +++ b/scripts/check_type_ignore_contract.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Fail if adopter type-check fixtures rely on type-ignore suppressions.""" + +from __future__ import annotations + +import sys +import tokenize +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +TYPE_CHECK_DIR = ROOT / "tests" / "type_checks" + + +def type_ignore_comments(path: Path) -> list[tuple[int, str]]: + findings: list[tuple[int, str]] = [] + with path.open("rb") as handle: + for token in tokenize.tokenize(handle.readline): + if token.type != tokenize.COMMENT: + continue + comment = token.string.lstrip("#").strip() + if comment.startswith("type: ignore"): + findings.append((token.start[0], token.string.strip())) + return findings + + +def main() -> int: + failures: list[str] = [] + for path in sorted(TYPE_CHECK_DIR.rglob("*.py")): + for line_no, comment in type_ignore_comments(path): + rel_path = path.relative_to(ROOT) + failures.append(f"{rel_path}:{line_no}: {comment}") + + if not failures: + return 0 + + print( + "tests/type_checks/ fixtures must pass mypy --strict without type-ignore suppressions.", + file=sys.stderr, + ) + for failure in failures: + print(failure, file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From a27114aa02c3652706fc662c46578fe6fa91ff83 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 27 May 2026 05:37:38 -0400 Subject: [PATCH 2/4] chore(types): enable unreachable mypy checks --- pyproject.toml | 2 ++ src/adcp/adagents.py | 4 ++-- src/adcp/capabilities.py | 2 -- .../legacy/v2_5/list_creative_formats.py | 2 +- src/adcp/decisioning/account_mode.py | 2 +- src/adcp/decisioning/handler.py | 3 +-- .../decisioning/pg/buyer_agent_registry.py | 3 ++- src/adcp/decisioning/pg/proposal_store.py | 2 +- src/adcp/decisioning/platform_router.py | 5 ++++- src/adcp/decisioning/registry.py | 20 ++++++------------ src/adcp/decisioning/registry_cache.py | 15 +++++-------- src/adcp/protocols/a2a.py | 4 +--- src/adcp/protocols/mcp.py | 10 ++++----- src/adcp/server/a2a_server.py | 15 ++++++------- src/adcp/server/responses.py | 2 +- src/adcp/server/serve.py | 6 ++++-- src/adcp/server/translate.py | 21 +++++++++++-------- src/adcp/signing/brand_jwks.py | 2 +- src/adcp/validation/oneof_hints.py | 4 ---- src/adcp/webhook_supervisor.py | 2 +- src/adcp/webhook_supervisor_pg.py | 2 +- src/adcp/webhooks.py | 6 ++---- 22 files changed, 61 insertions(+), 73 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e621178a7..d7cfa4c81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -226,6 +226,8 @@ python_version = "3.10" strict = true warn_return_any = true warn_unused_configs = true +warn_unreachable = true +strict_equality_for_none = true # adcp.types.SchemaVariant — see adcp.types.mypy_plugin for the marker # semantics. Adopters that want the same override-compat behavior on # their own cross-class field overrides should add this plugin to their diff --git a/src/adcp/adagents.py b/src/adcp/adagents.py index d3bcd7430..cdbcfac82 100644 --- a/src/adcp/adagents.py +++ b/src/adcp/adagents.py @@ -1310,7 +1310,7 @@ def _is_bare_authorized_agent_entry(agent: dict[str, Any]) -> bool: def _build_domain_index( - properties: list[dict[str, Any]], + properties: list[Any], ) -> dict[str, list[dict[str, Any]]]: """Build a ``publisher_domain → [property, ...]`` index. @@ -1933,7 +1933,7 @@ class AuthorizationContext: raw_properties: Raw property data from adagents.json """ - def __init__(self, properties: list[dict[str, Any]]): + def __init__(self, properties: list[Any]): """Initialize from list of properties. Args: diff --git a/src/adcp/capabilities.py b/src/adcp/capabilities.py index a05b55d2c..5d0920ac5 100644 --- a/src/adcp/capabilities.py +++ b/src/adcp/capabilities.py @@ -218,8 +218,6 @@ def supports_v3(self) -> bool: Returns: True if major_versions includes 3. """ - if self._caps.adcp is None: - return False for v in self._caps.adcp.major_versions: if (v.root if hasattr(v, "root") else v) == 3: return True diff --git a/src/adcp/compat/legacy/v2_5/list_creative_formats.py b/src/adcp/compat/legacy/v2_5/list_creative_formats.py index b871a864f..5df1dc89c 100644 --- a/src/adcp/compat/legacy/v2_5/list_creative_formats.py +++ b/src/adcp/compat/legacy/v2_5/list_creative_formats.py @@ -58,7 +58,7 @@ def _normalize_format_renders(format_obj: dict[str, Any]) -> dict[str, Any]: return new_format -def normalize_response(response: dict[str, Any]) -> dict[str, Any]: +def normalize_response(response: dict[str, Any] | list[Any]) -> dict[str, Any]: """Normalize a v2.5 ``list_creative_formats`` response into v3 shape.""" # Some agents omit the ``{formats: [...]}`` wrapper; tolerate that # by treating a top-level array as the formats list. diff --git a/src/adcp/decisioning/account_mode.py b/src/adcp/decisioning/account_mode.py index 8fc48e1b7..ee3467332 100644 --- a/src/adcp/decisioning/account_mode.py +++ b/src/adcp/decisioning/account_mode.py @@ -132,7 +132,7 @@ def assert_sandbox_account( ) -def get_mock_upstream_url(account: Account[Any]) -> str | None: +def get_mock_upstream_url(account: Account[Any] | None) -> str | None: """Read ``account.metadata['mock_upstream_url']`` safely. Adopters populate this in :meth:`AccountStore.resolve` for diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index 8e5f1a590..88dfb0282 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -95,7 +95,6 @@ # crashes inside the shim with ``'dict' object has no attribute # 'account'`` (Emma sales-direct backend test, verdict 2/10). from adcp.types import ( - AccountReference, AcquireRightsRequest, AcquireRightsResponse, ActivateSignalRequest, @@ -1333,7 +1332,7 @@ async def _prime_auth_context(self, ctx: ToolContext) -> None: async def _resolve_account( self, - ref: AccountReference | None, + ref: object | None, ctx: ToolContext, ) -> Account[Any]: """Resolve a wire :class:`AccountReference` to a typed diff --git a/src/adcp/decisioning/pg/buyer_agent_registry.py b/src/adcp/decisioning/pg/buyer_agent_registry.py index c77f3f78f..c36724909 100644 --- a/src/adcp/decisioning/pg/buyer_agent_registry.py +++ b/src/adcp/decisioning/pg/buyer_agent_registry.py @@ -91,6 +91,7 @@ BuyerAgent, BuyerAgentDefaultTerms, BuyerAgentStatus, + Credential, OAuthCredential, ) @@ -265,7 +266,7 @@ async def resolve_by_agent_url(self, agent_url: str) -> BuyerAgent | None: async def resolve_by_credential( self, - credential: ApiKeyCredential | OAuthCredential, + credential: Credential, ) -> BuyerAgent | None: """Resolve a bearer / API-key / OAuth credential. diff --git a/src/adcp/decisioning/pg/proposal_store.py b/src/adcp/decisioning/pg/proposal_store.py index bc2d13325..45d279ff6 100644 --- a/src/adcp/decisioning/pg/proposal_store.py +++ b/src/adcp/decisioning/pg/proposal_store.py @@ -189,7 +189,7 @@ def _default_recipe_decoder(payload: Mapping[str, Any]) -> Recipe: return Recipe.model_validate(payload_dict) -def _encode_recipes(recipes: Mapping[str, Recipe]) -> str: +def _encode_recipes(recipes: Mapping[str, Recipe | Mapping[str, Any]]) -> str: """Serialize the recipes mapping to a JSONB-compatible JSON string.""" out: dict[str, dict[str, Any]] = {} for product_id, recipe in recipes.items(): diff --git a/src/adcp/decisioning/platform_router.py b/src/adcp/decisioning/platform_router.py index 805ac4d8f..5619e68b5 100644 --- a/src/adcp/decisioning/platform_router.py +++ b/src/adcp/decisioning/platform_router.py @@ -613,7 +613,10 @@ def proposal_store_for_tenant(self, tenant_id: str) -> ProposalStore | None: #: async def factory(tenant_id: str) -> DecisioningPlatform: #: cfg = await load_tenant_config(tenant_id) #: return GamPlatform(cfg) -PlatformFactory = Callable[[str], DecisioningPlatform | Awaitable[DecisioningPlatform]] +PlatformFactory = Callable[ + [str], + DecisioningPlatform | None | Awaitable[DecisioningPlatform | None], +] # Sentinel for cache miss — distinct from any value the cache might diff --git a/src/adcp/decisioning/registry.py b/src/adcp/decisioning/registry.py index ff76278f2..ec6e664eb 100644 --- a/src/adcp/decisioning/registry.py +++ b/src/adcp/decisioning/registry.py @@ -155,7 +155,7 @@ class BuyerAgent: agent_url: str display_name: str - status: BuyerAgentStatus + status: BuyerAgentStatus | str #: Set of legal ``billing`` values for accounts under this agent. #: Pre-trust beta default: ``frozenset({"operator"})`` (passthrough @@ -216,9 +216,7 @@ async def resolve_by_agent_url(self, agent_url: str) -> BuyerAgent | None: method just looks up the counterparty row in the adopter's commercial registry.""" - async def resolve_by_credential( - self, credential: ApiKeyCredential | OAuthCredential - ) -> BuyerAgent | None: + async def resolve_by_credential(self, credential: Credential) -> BuyerAgent | None: """Resolve a bearer / API-key / OAuth credential. For pre-trust beta sellers, this IS the existing key table just exposed through a typed surface.""" @@ -227,7 +225,7 @@ async def resolve_by_credential( # Factory call signatures — adopters provide the inner async lookup # functions; the factory returns a registry with the right posture. _SignedResolver = Callable[[str], Awaitable[BuyerAgent | None]] -_CredentialResolver = Callable[[ApiKeyCredential | OAuthCredential], Awaitable[BuyerAgent | None]] +_CredentialResolver = Callable[[Credential], Awaitable[BuyerAgent | None]] @dataclass(frozen=True) @@ -240,9 +238,7 @@ class _SigningOnlyRegistry: async def resolve_by_agent_url(self, agent_url: str) -> BuyerAgent | None: return await self._resolve_by_agent_url(agent_url) - async def resolve_by_credential( - self, credential: ApiKeyCredential | OAuthCredential - ) -> BuyerAgent | None: + async def resolve_by_credential(self, credential: Credential) -> BuyerAgent | None: # Intentional reject — adopter chose signing-only. return None @@ -260,9 +256,7 @@ async def resolve_by_agent_url(self, agent_url: str) -> BuyerAgent | None: # accept signed traffic yet. return None - async def resolve_by_credential( - self, credential: ApiKeyCredential | OAuthCredential - ) -> BuyerAgent | None: + async def resolve_by_credential(self, credential: Credential) -> BuyerAgent | None: return await self._resolve_by_credential(credential) @@ -277,9 +271,7 @@ class _MixedRegistry: async def resolve_by_agent_url(self, agent_url: str) -> BuyerAgent | None: return await self._resolve_by_agent_url(agent_url) - async def resolve_by_credential( - self, credential: ApiKeyCredential | OAuthCredential - ) -> BuyerAgent | None: + async def resolve_by_credential(self, credential: Credential) -> BuyerAgent | None: return await self._resolve_by_credential(credential) diff --git a/src/adcp/decisioning/registry_cache.py b/src/adcp/decisioning/registry_cache.py index 1eb87b1bd..76b1b7e3a 100644 --- a/src/adcp/decisioning/registry_cache.py +++ b/src/adcp/decisioning/registry_cache.py @@ -86,6 +86,7 @@ ApiKeyCredential, BuyerAgent, BuyerAgentRegistry, + Credential, OAuthCredential, ) from adcp.decisioning.types import AdcpError @@ -149,7 +150,7 @@ def _current_tenant_id() -> str | None: return tenant.id if tenant is not None else None -def _credential_key(credential: ApiKeyCredential | OAuthCredential) -> str: +def _credential_key(credential: Credential) -> str: """Project a credential to a stable cache / rate-limit key. The key is namespaced (``"api_key:..."`` / ``"oauth:..."``) so @@ -331,9 +332,7 @@ async def resolve_by_agent_url(self, agent_url: str) -> BuyerAgent | None: await self._store(key, result) return result - async def resolve_by_credential( - self, credential: ApiKeyCredential | OAuthCredential - ) -> BuyerAgent | None: + async def resolve_by_credential(self, credential: Credential) -> BuyerAgent | None: """Resolve via cache, falling through to ``inner`` on miss.""" tenant_id = _current_tenant_id() lookup_key = _credential_key(credential) @@ -531,9 +530,7 @@ async def resolve_by_agent_url(self, agent_url: str) -> BuyerAgent | None: ) return await self._inner.resolve_by_agent_url(agent_url) - async def resolve_by_credential( - self, credential: ApiKeyCredential | OAuthCredential - ) -> BuyerAgent | None: + async def resolve_by_credential(self, credential: Credential) -> BuyerAgent | None: tenant_id = _current_tenant_id() lookup_key = _credential_key(credential) await self._charge( @@ -635,9 +632,7 @@ async def resolve_by_agent_url(self, agent_url: str) -> BuyerAgent | None: ) return result - async def resolve_by_credential( - self, credential: ApiKeyCredential | OAuthCredential - ) -> BuyerAgent | None: + async def resolve_by_credential(self, credential: Credential) -> BuyerAgent | None: tenant_id = _current_tenant_id() result = await self._inner.resolve_by_credential(credential) await _emit_audit( diff --git a/src/adcp/protocols/a2a.py b/src/adcp/protocols/a2a.py index 080880fe8..d7e77feda 100644 --- a/src/adcp/protocols/a2a.py +++ b/src/adcp/protocols/a2a.py @@ -39,9 +39,7 @@ def _part_data_dict(part: pb.Part) -> dict[str, Any] | None: if part.WhichOneof("content") != "data": return None value = MessageToDict(part.data) - if isinstance(value, dict): - return value - return None + return value def _part_text(part: pb.Part) -> str | None: diff --git a/src/adcp/protocols/mcp.py b/src/adcp/protocols/mcp.py index 3168e65ac..3ae0668f9 100644 --- a/src/adcp/protocols/mcp.py +++ b/src/adcp/protocols/mcp.py @@ -40,9 +40,10 @@ from httpx import HTTPStatusError HTTPX_AVAILABLE = True + _HTTP_STATUS_ERROR_TYPES: tuple[type[BaseException], ...] = (HTTPStatusError,) except ImportError: HTTPX_AVAILABLE = False - HTTPStatusError = None # type: ignore[assignment, misc] + _HTTP_STATUS_ERROR_TYPES = () _httpx = None # type: ignore[assignment] import json @@ -292,8 +293,7 @@ def _log_cleanup_error(self, exc: BaseException, context: str) -> None: ) or ( # HTTP errors during cleanup (if httpx is available) HTTPX_AVAILABLE - and HTTPStatusError is not None - and isinstance(exc, HTTPStatusError) + and isinstance(exc, _HTTP_STATUS_ERROR_TYPES) ) if is_known_cleanup_error: @@ -814,8 +814,8 @@ async def get_agent_info(self) -> dict[str, Any]: # Try to extract AdCP extension metadata from server capabilities # MCP servers may expose this in their initialization response - if hasattr(session, "_server_capabilities"): - capabilities = session._server_capabilities + capabilities = getattr(session, "_server_capabilities", None) + if capabilities is not None: if isinstance(capabilities, dict): extensions = capabilities.get("extensions", {}) adcp_ext = extensions.get("adcp", {}) diff --git a/src/adcp/server/a2a_server.py b/src/adcp/server/a2a_server.py index 7172d8792..8c56c3200 100644 --- a/src/adcp/server/a2a_server.py +++ b/src/adcp/server/a2a_server.py @@ -50,7 +50,9 @@ try: from adcp.decisioning.types import AdcpError as _DecisioningAdcpError except Exception: # pragma: no cover - decisioning is an optional dep surface - _DecisioningAdcpError = None # type: ignore[assignment,misc] + _DECISIONING_ADCP_ERROR_TYPES: tuple[type[BaseException], ...] = () +else: + _DECISIONING_ADCP_ERROR_TYPES = (_DecisioningAdcpError,) if TYPE_CHECKING: from collections.abc import Sequence @@ -137,9 +139,7 @@ def _part_data_dict(part: pb.Part) -> dict[str, Any] | None: if part.WhichOneof("content") != "data": return None value = MessageToDict(part.data) - if isinstance(value, dict): - return value - return None + return value def _part_text(part: pb.Part) -> str | None: @@ -283,9 +283,10 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non # adopters write against the decisioning graph). They are # disjoint hierarchies; both project onto the same structured # ``adcp_error`` envelope per transport-errors.mdx §A2A Binding. - structured_error_types: tuple[type[BaseException], ...] = (ADCPError,) - if _DecisioningAdcpError is not None: - structured_error_types = (ADCPError, _DecisioningAdcpError) + structured_error_types: tuple[type[BaseException], ...] = ( + ADCPError, + *_DECISIONING_ADCP_ERROR_TYPES, + ) try: result = await self._dispatch_with_middleware(skill_name, params, tool_context) # ``params`` carries the parsed wire request including any diff --git a/src/adcp/server/responses.py b/src/adcp/server/responses.py index 529776cb5..374a046cc 100644 --- a/src/adcp/server/responses.py +++ b/src/adcp/server/responses.py @@ -82,7 +82,7 @@ def _normalize_capabilities_adcp_version( "when the helper cannot infer exact SDK-supported versions. Pass " "supported_versions with exact release values, or omit adcp_version." ) - if supported_versions is not None and normalized not in supported_versions: + if normalized not in supported_versions: raise ConfigurationError( f"adcp_version={adcp_version!r} is not included in supported_versions " f"({supported_versions}). Pass an adcp_version from supported_versions, " diff --git a/src/adcp/server/serve.py b/src/adcp/server/serve.py index e77e7b015..0c7ad35ad 100644 --- a/src/adcp/server/serve.py +++ b/src/adcp/server/serve.py @@ -2371,7 +2371,9 @@ def _register_tool( try: from adcp.decisioning.types import AdcpError as DecisioningAdcpError # noqa: N813 except Exception: - DecisioningAdcpError = None # type: ignore[assignment,misc] # noqa: N806 + decisioning_error_types: tuple[type[BaseException], ...] = () + else: + decisioning_error_types = (DecisioningAdcpError,) async def fn(**kwargs: Any) -> dict[str, Any]: # Caller identity: FastMCP does not expose an authenticated principal @@ -2442,7 +2444,7 @@ async def _call_handler() -> Any: # ``adcp.exceptions.ADCPError`` (different class hierarchy # — ``adcp.decisioning.types.AdcpError``). Catch it explicitly # and project the same structured envelope. - if DecisioningAdcpError is not None and isinstance(exc, DecisioningAdcpError): + if isinstance(exc, decisioning_error_types): return build_mcp_error_result(exc, params=kwargs) # type: ignore[return-value] raise # Pre-built CallToolResult (error envelope from build_mcp_error_result) diff --git a/src/adcp/server/translate.py b/src/adcp/server/translate.py index 0280e2a01..240b0879d 100644 --- a/src/adcp/server/translate.py +++ b/src/adcp/server/translate.py @@ -26,7 +26,7 @@ from __future__ import annotations import json -from typing import Any, Literal +from typing import Any, Literal, cast from urllib.parse import urlparse from a2a.utils.errors import A2AError, InternalError, InvalidParamsError @@ -119,7 +119,9 @@ def _extract_structured_fields( try: from adcp.decisioning.types import AdcpError as DecisioningAdcpError # noqa: N813 except Exception: - DecisioningAdcpError = None # type: ignore[assignment,misc] # noqa: N806 + decisioning_error_types: tuple[type[BaseException], ...] = () + else: + decisioning_error_types = (DecisioningAdcpError,) field: str | None = None if isinstance(exc, Error): @@ -139,14 +141,15 @@ def _extract_structured_fields( recovery = str(recovery_val) errors = None field = exc.field - elif DecisioningAdcpError is not None and isinstance(exc, DecisioningAdcpError): - code = exc.code - message = exc.args[0] if exc.args else "" - suggestion = exc.suggestion - recovery = exc.recovery - details = exc.details or None + elif isinstance(exc, decisioning_error_types): + decisioning_exc = cast(Any, exc) + code = decisioning_exc.code + message = decisioning_exc.args[0] if decisioning_exc.args else "" + suggestion = decisioning_exc.suggestion + recovery = decisioning_exc.recovery + details = decisioning_exc.details or None errors = None - field = exc.field + field = decisioning_exc.field elif isinstance(exc, ADCPError): code = _error_code_for_exception(exc) message = exc.message diff --git a/src/adcp/signing/brand_jwks.py b/src/adcp/signing/brand_jwks.py index 58ed84ff4..18af2b7a5 100644 --- a/src/adcp/signing/brand_jwks.py +++ b/src/adcp/signing/brand_jwks.py @@ -433,7 +433,7 @@ async def resolve(self, kid: str) -> dict[str, Any] | None: await self._refresh() except BrandJsonResolverError: return None - return await self._inner(kid) if self._inner is not None else None + return await self._inner(kid) return None @property diff --git a/src/adcp/validation/oneof_hints.py b/src/adcp/validation/oneof_hints.py index 333b8db79..b8ddcd7d1 100644 --- a/src/adcp/validation/oneof_hints.py +++ b/src/adcp/validation/oneof_hints.py @@ -82,8 +82,6 @@ def _detect_discriminator(variants: list[dict[str, Any]]) -> str | None: """ counts: dict[str, int] = {} for variant in variants: - if not isinstance(variant, dict): - continue props = variant.get("properties") if not isinstance(props, dict): continue @@ -181,8 +179,6 @@ def _fallback_seen_key( """ declared: set[str] = set() for variant in variants: - if not isinstance(variant, dict): - continue props = variant.get("properties") if isinstance(props, dict): declared.update(props.keys()) diff --git a/src/adcp/webhook_supervisor.py b/src/adcp/webhook_supervisor.py index a30ef3315..c54eaa515 100644 --- a/src/adcp/webhook_supervisor.py +++ b/src/adcp/webhook_supervisor.py @@ -321,7 +321,7 @@ class InMemoryWebhookDeliverySupervisor: def __init__( self, - sender: WebhookSender, + sender: WebhookSender | None, *, retry: RetryPolicy | None = None, circuit: CircuitBreakerPolicy | None = None, diff --git a/src/adcp/webhook_supervisor_pg.py b/src/adcp/webhook_supervisor_pg.py index d7d81d12c..a792907a8 100644 --- a/src/adcp/webhook_supervisor_pg.py +++ b/src/adcp/webhook_supervisor_pg.py @@ -210,7 +210,7 @@ class PgWebhookDeliverySupervisor: def __init__( self, pool: Any, # psycopg_pool.AsyncConnectionPool; Any avoids import at runtime - sender: WebhookSender, + sender: WebhookSender | None, *, retry: RetryPolicy | None = None, circuit: CircuitBreakerPolicy | None = None, diff --git a/src/adcp/webhooks.py b/src/adcp/webhooks.py index cd997808a..109548932 100644 --- a/src/adcp/webhooks.py +++ b/src/adcp/webhooks.py @@ -1073,12 +1073,10 @@ def validate_webhook_destination_url( if isinstance(url, str): url_text = url - elif isinstance(url, AnyUrl): - url_text = str(url) else: - url_text = None + url_text = str(url) - if not isinstance(url_text, str) or not url_text: + if not url_text: _raise_webhook_destination_error( "webhook destination URL must be a non-empty string", reason="missing_url", From e63b384a775290e4fb8f3b1784837ac0a3002574 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 27 May 2026 05:52:15 -0400 Subject: [PATCH 3/4] fix(storyboards): align examples with 3.1 runner --- examples/seller_agent.py | 19 +++-- src/adcp/decisioning/handler.py | 3 + src/adcp/decisioning/proposal_dispatch.py | 52 ++++++++++++- src/adcp/decisioning/proposal_lifecycle.py | 5 +- tests/test_proposal_lifecycle.py | 4 +- tests/test_proposal_lifecycle_e2e.py | 87 ++++++++++++++++++++++ 6 files changed, 159 insertions(+), 11 deletions(-) diff --git a/examples/seller_agent.py b/examples/seller_agent.py index 5e0d8a593..3cdb7e474 100644 --- a/examples/seller_agent.py +++ b/examples/seller_agent.py @@ -228,7 +228,7 @@ def _image_format_options( { "pricing_option_id": "po-cpm-homepage", "pricing_model": "cpm", - "floor_price": 15.00, + "fixed_price": 15.00, "currency": "USD", } ], @@ -260,7 +260,7 @@ def _image_format_options( { "pricing_option_id": "po-cpm-ros", "pricing_model": "cpm", - "floor_price": 5.00, + "fixed_price": 5.00, "currency": "USD", } ], @@ -295,7 +295,7 @@ def _image_format_options( { "pricing_option_id": "cpm_standard", "pricing_model": "cpm", - "floor_price": 5.00, + "fixed_price": 5.00, "currency": "USD", } ], @@ -327,7 +327,7 @@ def _image_format_options( { "pricing_option_id": "cpm_standard", "pricing_model": "cpm", - "floor_price": 8.00, + "fixed_price": 8.00, "currency": "USD", } ], @@ -359,7 +359,7 @@ def _image_format_options( { "pricing_option_id": "cpm_guaranteed", "pricing_model": "cpm", - "floor_price": 25.00, + "fixed_price": 25.00, "currency": "USD", } ], @@ -391,7 +391,7 @@ def _image_format_options( { "pricing_option_id": "cpm_standard", "pricing_model": "cpm", - "floor_price": 6.00, + "fixed_price": 6.00, "currency": "USD", } ], @@ -826,6 +826,13 @@ async def get_media_buy_delivery( "impressions": 45000, "clicks": 680, "spend": 540.00, + "viewability": { + "measurable_impressions": 43000, + "viewable_impressions": 36550, + "viewable_rate": 0.85, + "viewed_seconds": 8.4, + "standard": "mrc", + }, }, "by_package": [], } diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index 88dfb0282..88c922fef 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -63,6 +63,7 @@ maybe_hydrate_recipes_for_media_buy_id, maybe_intercept_finalize, maybe_persist_draft_after_get_products, + maybe_validate_refine_proposal_refs, release_proposal_reservation, ) from adcp.decisioning.refine import ( @@ -1800,6 +1801,8 @@ async def get_products( # type: ignore[override] if finalize_response is not None: return cast("GetProductsResponse", finalize_response) + await maybe_validate_refine_proposal_refs(self._platform, params, ctx) + if not has_refine_support(self._platform): raise AdcpError( "INVALID_REQUEST", diff --git a/src/adcp/decisioning/proposal_dispatch.py b/src/adcp/decisioning/proposal_dispatch.py index eb6e96adf..e989cc7db 100644 --- a/src/adcp/decisioning/proposal_dispatch.py +++ b/src/adcp/decisioning/proposal_dispatch.py @@ -204,7 +204,7 @@ async def maybe_intercept_finalize( "get_products with buying_mode='brief' or 'refine' to " "obtain a draft proposal_id before finalizing it." ), - recovery="terminal", + recovery="correctable", field=field_path, ) # Finalize requires a draft; finalizing an already-committed proposal @@ -337,6 +337,55 @@ async def _commit_on_handoff_completion(success: Any) -> None: ) +async def maybe_validate_refine_proposal_refs( + platform: Any, + params: Any, + ctx: RequestContext[Any], +) -> None: + """Reject proposal-scoped refine entries that reference no draft. + + ``refine_products`` adopters should not need to re-implement proposal + store lookup just to return the canonical ``PROPOSAL_NOT_FOUND`` shape. + Finalize is handled by :func:`maybe_intercept_finalize`; this guard covers + ordinary refine actions before the manager can accidentally emit and + persist an unrelated proposal payload. + """ + _manager, store = _resolve_manager_and_store(platform, ctx) + if store is None: + return + + for refine_index, entry in enumerate(list(getattr(params, "refine", None) or [])): + inner = getattr(entry, "root", entry) + scope = _read(inner, "scope") + scope_str = str(getattr(scope, "value", scope)) if scope is not None else None + if scope_str != "proposal": + continue + + action = _read(inner, "action") + action_str = str(getattr(action, "value", action)) if action is not None else None + if action_str == "finalize": + continue + + proposal_id = _read(inner, "proposal_id") + if proposal_id is None: + continue + + account_id = ctx.account.id + raw = await _await_maybe(store.get(str(proposal_id), expected_account_id=account_id)) + record = cast("ProposalRecord | None", raw) + if record is None: + raise AdcpError( + "PROPOSAL_NOT_FOUND", + message=( + f"Proposal {proposal_id!r} not found. The buyer must call " + "get_products with buying_mode='brief' to obtain a draft " + "proposal_id before refining it." + ), + recovery="correctable", + field=f"refine[{refine_index}].proposal_id", + ) + + def _project_finalize_response( *, params: Any, @@ -937,5 +986,6 @@ def _to_dict(obj: Any) -> dict[str, Any]: "maybe_hydrate_recipes_for_media_buy_id", "maybe_intercept_finalize", "maybe_persist_draft_after_get_products", + "maybe_validate_refine_proposal_refs", "release_proposal_reservation", ] diff --git a/src/adcp/decisioning/proposal_lifecycle.py b/src/adcp/decisioning/proposal_lifecycle.py index 6c0c7a1af..7dfe44810 100644 --- a/src/adcp/decisioning/proposal_lifecycle.py +++ b/src/adcp/decisioning/proposal_lifecycle.py @@ -68,7 +68,8 @@ async def enforce_proposal_expiry( Three failure modes mapped to spec error codes: * Record not found OR cross-tenant → ``PROPOSAL_NOT_FOUND`` - (recovery=terminal). The dispatch path supplies + (recovery=correctable). The buyer can request and finalize a fresh + proposal_id. The dispatch path supplies ``expected_account_id`` from the authenticated principal so cross-tenant probes return the same error as missing IDs (no principal-enumeration via id probing). @@ -110,7 +111,7 @@ async def enforce_proposal_expiry( "refine=[{action:'finalize',...}] to obtain a committed " "proposal_id before referencing it on create_media_buy." ), - recovery="terminal", + recovery="correctable", field="proposal_id", ) if record.state != ProposalState.COMMITTED: diff --git a/tests/test_proposal_lifecycle.py b/tests/test_proposal_lifecycle.py index f66e86f0a..63f66cd64 100644 --- a/tests/test_proposal_lifecycle.py +++ b/tests/test_proposal_lifecycle.py @@ -3,7 +3,7 @@ Covers: * enforce_proposal_expiry (D7) — three failure modes: - - missing record → PROPOSAL_NOT_FOUND, recovery=terminal + - missing record → PROPOSAL_NOT_FOUND, recovery=correctable - cross-tenant probe → PROPOSAL_NOT_FOUND (not the raw record) - state != COMMITTED → PROPOSAL_NOT_COMMITTED, recovery=correctable - now > expires_at + grace → PROPOSAL_EXPIRED, recovery=terminal @@ -68,7 +68,7 @@ async def test_expiry_unknown_proposal_raises_not_found() -> None: expected_account_id="acct_a", ) assert exc.value.code == "PROPOSAL_NOT_FOUND" - assert exc.value.recovery == "terminal" + assert exc.value.recovery == "correctable" assert exc.value.field == "proposal_id" diff --git a/tests/test_proposal_lifecycle_e2e.py b/tests/test_proposal_lifecycle_e2e.py index 10b3973e7..277fde80f 100644 --- a/tests/test_proposal_lifecycle_e2e.py +++ b/tests/test_proposal_lifecycle_e2e.py @@ -194,6 +194,35 @@ async def test_refine_overwrites_draft( assert record.state == ProposalState.DRAFT +@pytest.mark.asyncio +async def test_refine_unknown_proposal_is_correctable( + handler: PlatformHandler, +) -> None: + """Proposal-scoped refine should fail before the manager returns an + unrelated proposal payload when the referenced draft does not exist.""" + from adcp.types import GetProductsRequest + + refine_req = GetProductsRequest.model_validate( + { + "buying_mode": "refine", + "refine": [ + { + "scope": "proposal", + "proposal_id": "unknown_proposal", + "ask": "Shift budget to CTV.", + }, + ], + } + ) + + with pytest.raises(AdcpError) as exc_info: + await handler.get_products(refine_req, ToolContext()) + + assert exc_info.value.code == "PROPOSAL_NOT_FOUND" + assert exc_info.value.recovery == "correctable" + assert exc_info.value.field == "refine[0].proposal_id" + + # --------------------------------------------------------------------------- # Phase 3: finalize → framework intercepts; manager.finalize_proposal commits # --------------------------------------------------------------------------- @@ -247,6 +276,35 @@ async def test_finalize_commits_proposal( assert record.expires_at is not None +@pytest.mark.asyncio +async def test_finalize_unknown_proposal_is_correctable( + handler: PlatformHandler, +) -> None: + """Finalize can recover from an unknown proposal by asking for a fresh + draft proposal_id, so it should not be classified as terminal.""" + from adcp.types import GetProductsRequest + + finalize_req = GetProductsRequest.model_validate( + { + "buying_mode": "refine", + "refine": [ + { + "scope": "proposal", + "proposal_id": "unknown_proposal", + "action": "finalize", + }, + ], + } + ) + + with pytest.raises(AdcpError) as exc_info: + await handler.get_products(finalize_req, ToolContext()) + + assert exc_info.value.code == "PROPOSAL_NOT_FOUND" + assert exc_info.value.recovery == "correctable" + assert exc_info.value.field == "refine[0].proposal_id" + + # --------------------------------------------------------------------------- # Phase 4: create_media_buy(proposal_id) → recipes hydrated; consume marked # --------------------------------------------------------------------------- @@ -313,6 +371,35 @@ async def test_create_media_buy_hydrates_and_consumes( assert reverse_record.proposal_id == PROPOSAL_ID +@pytest.mark.asyncio +async def test_create_media_buy_unknown_proposal_is_correctable( + handler: PlatformHandler, +) -> None: + """Accepting an unknown proposal can recover by obtaining and finalizing + a fresh proposal_id.""" + from adcp.types import CreateMediaBuyRequest + + cmb_req = CreateMediaBuyRequest.model_validate( + { + "proposal_id": "unknown_proposal", + "total_budget": {"amount": 50000, "currency": "USD"}, + "start_time": "2026-04-01T00:00:00Z", + "end_time": "2026-06-30T23:59:59Z", + "buyer_ref": "test-buyer-unknown", + "idempotency_key": _CMB_IDEM + "unknown", + "brand": _BRAND, + "account": _ACCOUNT, + } + ) + + with pytest.raises(AdcpError) as exc_info: + await handler.create_media_buy(cmb_req, ToolContext()) + + assert exc_info.value.code == "PROPOSAL_NOT_FOUND" + assert exc_info.value.recovery == "correctable" + assert exc_info.value.field == "proposal_id" + + # --------------------------------------------------------------------------- # Phase 5+6: update_media_buy / get_media_buy_delivery hydrate from reverse-index # --------------------------------------------------------------------------- From 266b9dae310a31984be15fe61d1a444cc57794c8 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 27 May 2026 05:53:29 -0400 Subject: [PATCH 4/4] docs(dx): clarify local setup path --- CLAUDE.md | 9 +++++---- CONTRIBUTING.md | 4 ++++ Makefile | 4 ++-- README.md | 8 ++++++++ 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7ff4a6a08..7092f68e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -192,10 +192,11 @@ make typecheck-all make test ``` -All must pass. `make ci-local` runs the core local CI target; some specialized -CI jobs such as Postgres conformance still have their own prerequisites. CI runs -the core matrix across Python 3.10–3.13; locally running on your current version -catches most issues. +All must pass. `make ci-local` runs the core local gate: lint, all type-check +contracts, tests, and generated-code validation. Specialized CI jobs such as +storyboard runners, Postgres conformance, and conventional-commit validation +still run separately in GitHub Actions. CI runs the core matrix across Python +3.10–3.13; locally running on your current version catches most issues. ## Parallel Agent Isolation (git worktrees) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b6eede188..0261bbda0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,10 @@ For the core local CI-style pass before opening a PR, run: make ci-local ``` +This covers lint, all type-check contracts, tests, and generated-code +validation. GitHub Actions still runs specialized jobs such as storyboard +runners, Postgres conformance, and conventional-commit validation. + ## Project Structure ``` diff --git a/Makefile b/Makefile index 602760663..c68d27ca0 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ help: ## Show this help message @echo 'Available targets:' @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' -install-dev: ## Install with pip dev extra only; bootstrap is preferred for contributors +install-dev: ## Legacy pip-only install; bootstrap is preferred for contributors $(PIP) install -e ".[dev]" check-uv: @@ -24,7 +24,7 @@ check-uv: exit 1; \ } -bootstrap: check-uv ## Install uv-managed dev deps and pre-commit hooks +bootstrap: check-uv ## Recommended contributor setup: uv-managed deps and git hooks $(UV) run --extra dev --group dev pre-commit install $(UV) run --extra dev --group dev pre-commit install --hook-type commit-msg diff --git a/README.md b/README.md index 4e1418039..076c525e6 100644 --- a/README.md +++ b/README.md @@ -1451,6 +1451,9 @@ client = ADCPMultiAgentClient.from_env() ## Development +This repository uses `uv` for the contributor environment and pre-commit hooks. +Install `uv` first if it is not already on your `PATH`. + ```bash # Install dev dependencies and git hooks (requires uv) make bootstrap @@ -1466,6 +1469,11 @@ make format make lint ``` +`make ci-local` runs the core local gate: lint, all type-check contracts, tests, +and generated-code validation. Specialized CI jobs such as storyboard runners, +Postgres conformance, and conventional-commit validation still run separately in +GitHub Actions. + ## Contributing Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. All contributors must agree to the [AgenticAdvertising.Org IPR Policy](https://github.com/adcontextprotocol/adcp/blob/main/IPR_POLICY.md) — the bot prompts new contributors on their first PR and a single signature covers all AAO repositories.