Skip to content
Merged
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
14 changes: 9 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ env:
ADCP_SDK_VERSION: "14.0.0"

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
# A tagged main commit needs its own full CI even when main advances.
# PR updates may still cancel superseded runs for the same PR ref.
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
changes:
Expand Down Expand Up @@ -100,7 +102,9 @@ jobs:
# 3.12 always runs the canonical suite and coverage. Ordinary PRs use
# compatibility checks on the other interpreters; main/manual runs and
# shared build/schema/type changes retain exhaustive interpreter coverage.
timeout-minutes: ${{ matrix.python-version == '3.12' && 70 || needs.changes.outputs.full_matrix == 'true' && 60 || 15 }}
# Recent coverage runs take 53 minutes before setup/type checks. Keep
# 15 minutes beyond each full-suite step for those other required checks.
timeout-minutes: ${{ matrix.python-version == '3.12' && 85 || needs.changes.outputs.full_matrix == 'true' && 70 || 15 }}
strategy:
# Complete exhaustive lanes independently so late interpreter failures
# preserve coverage results. Fast PR compatibility checks cancel early.
Expand Down Expand Up @@ -161,7 +165,7 @@ jobs:

- name: Run full native suite
if: matrix.python-version != '3.12' && needs.changes.outputs.full_matrix == 'true'
timeout-minutes: 45
timeout-minutes: 55
run: python scripts/ci/run_native_tests.py --mode full --unit-workers 2

- name: Run interpreter compatibility checks
Expand All @@ -171,7 +175,7 @@ jobs:

- name: Run canonical suite with coverage
if: matrix.python-version == '3.12' && needs.changes.outputs.release_metadata != 'true'
timeout-minutes: 55
timeout-minutes: 70
run: python scripts/ci/run_native_tests.py --mode full --coverage --unit-workers 2

- name: Validate release metadata and built distributions
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/release-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ jobs:
build:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 75
# Allow the 85-minute main CI lane plus queueing and distribution builds.
timeout-minutes: 120
permissions:
contents: read
actions: read
Expand All @@ -42,7 +43,7 @@ jobs:
[[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]]
test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$RELEASE_SHA"
git merge-base --is-ancestor "$RELEASE_SHA" HEAD
for attempt in {1..130}; do
for attempt in {1..220}; do
if gh run list --workflow CI --event push --commit "$RELEASE_SHA" --json headSha,headBranch,status,conclusion --limit 20 |
jq -e --arg sha "$RELEASE_SHA" 'any(.[]; .headSha == $sha and .headBranch == "main" and .status == "completed" and .conclusion == "success")'; then
git checkout --detach "$RELEASE_SHA"
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,22 @@ pip install adcp

> **Note**: This client requires Python 3.10 or later and supports both synchronous and asynchronous workflows.

### SDK 9 beta

SDK 9 is available as a prerelease. Ordinary installation selects the stable
SDK 8 release; a stable `adcp==9.0.0` pin becomes available at GA. The release
tag `9.0.0-beta.3` corresponds to the Python package version `9.0.0b3`.

```bash
pip install 'adcp==9.0.0b3' # exact beta
pip install --pre 'adcp>=9.0.0b3,<10' # latest SDK 9 prerelease
uv add --prerelease allow 'adcp>=9.0.0b3,<10' # uv prerelease opt-in
```

Read [the SDK 9 migration guide](MIGRATION_v8_to_v9.md) before upgrading.
The package's Production/Stable classifier describes the stable channel;
SDK 9 betas remain prereleases regardless of that classifier.

## Quick Start: Test Helpers

The fastest way to get started is using pre-configured test agents with the **`.simple` API**:
Expand Down Expand Up @@ -533,6 +549,26 @@ import adcp.types
adcp.types.Product # forces the one-time graph build now, not on the hot path
```

Request serialization also discovers nullable paths separately for each task
and wire version. Warming the generated type graph does not warm that cache.
Allow startup time before imposing a short first-call deadline. Applications
that need to warm known task/version pairs can use the internal schema helper
at startup; its import path is not a public API stability guarantee:

```python
from adcp._null_clear import nullable_request_paths

nullable_request_paths("get_products", "3.2")
nullable_request_paths("create_media_buy", "3.2")
```

Cold discovery has taken about 127–204 ms on a loaded machine, compared with
about 0.001 ms from the cache; these are measurements, not latency guarantees.
A deadline that expires before transport dispatch returns `recovery=None`
because the request was never sent. A timeout after dispatch may need recovery
because the remote outcome is uncertain. Warming changes startup latency, not
that distinction.

#### Semantic Type Aliases

For discriminated union types (success/error responses), use semantic aliases for clearer code:
Expand Down
10 changes: 6 additions & 4 deletions docs/types-9-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,10 +333,12 @@ a declaration for a kind this pin does not know.

This is a transfer of responsibility, not a new helper. Before 9.0 a closed
enum refused an unknown `format_kind` inside the model, so a seller got that
refusal without asking for it. Now the SDK accepts any string on the way out
and on the way back, and the seller owns the refusal. **A seller that adds no
check has silently stopped validating something the library used to validate
for it** — no error appears, and nothing in a passing test suite says so.
refusal without asking for it. Open `Format` and other consumer models now
accept any string on the way out and on the way back, so a seller using those
models owns the refusal. **A seller using an open model without an explicit
check has stopped validating something the library used to validate for it**.
The strict `ProductFormatDeclaration` authoring class still enforces its
schema's closed set, as described above; using that class supplies the check.

If you emit `format_kind`, the producer-side rule that you MUST NOT mint
ad-hoc values is now yours to enforce, and `is_canonical_format_kind` is how:
Expand Down
3 changes: 3 additions & 0 deletions src/adcp/signing/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
WEBHOOK_TAG = "adcp/webhook-signing/v1"
ADCP_USE_REQUEST = "request-signing"
ADCP_USE_WEBHOOK = "webhook-signing"
#: Current request keys and deprecated webhook keys are both valid for webhooks.
WEBHOOK_ACCEPTED_ADCP_USES: frozenset[str] = frozenset({ADCP_USE_REQUEST, ADCP_USE_WEBHOOK})
MAX_WINDOW_SECONDS = 300
DEFAULT_EXPIRES_IN_SECONDS = 300
DEFAULT_SKEW_SECONDS = 60
Expand All @@ -21,5 +23,6 @@
"MAX_WINDOW_SECONDS",
"NONCE_BYTES",
"SIG_LABEL_DEFAULT",
"WEBHOOK_ACCEPTED_ADCP_USES",
"WEBHOOK_TAG",
]
5 changes: 2 additions & 3 deletions src/adcp/signing/webhook_verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,10 @@

from adcp.signing.canonical import _lookup, parse_signature_input_header, split_structured_field
from adcp.signing.constants import (
ADCP_USE_REQUEST,
ADCP_USE_WEBHOOK,
DEFAULT_SKEW_SECONDS,
MAX_WINDOW_SECONDS,
SIG_LABEL_DEFAULT,
WEBHOOK_ACCEPTED_ADCP_USES,
WEBHOOK_TAG,
)
from adcp.signing.crypto import ALLOWED_ALGS
Expand Down Expand Up @@ -176,7 +175,7 @@ def verify_webhook_signature(
max_window_seconds=options.max_window_seconds,
label=options.label,
expected_tag=WEBHOOK_TAG,
accepted_adcp_uses=frozenset({ADCP_USE_REQUEST, ADCP_USE_WEBHOOK}),
accepted_adcp_uses=WEBHOOK_ACCEPTED_ADCP_USES,
allowed_algs=options.allowed_algs,
agent_url=options.sender_url,
expected_key_origins=(
Expand Down
4 changes: 4 additions & 0 deletions src/adcp/types/_geo_place_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ def _https_system_adapter() -> TypeAdapter[AnyUrl]:
def _validate_geo_system_key(value: str) -> str:
"""Validate the namespace and preserve its exact opaque wire spelling."""
if value not in _REGISTERED_SYSTEMS:
# URL parsing normalizes schemes, but the schema's ^https:// pattern
# applies to the original opaque identifier, before any normalization.
if not value.startswith("https://"):
raise ValueError("geographic place system URLs must start with https://")
_https_system_adapter().validate_python(value)
return value

Expand Down
25 changes: 25 additions & 0 deletions tests/conformance/signing/test_webhook_rc4_vectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import pytest

from adcp.signing import InMemoryReplayStore, SignatureVerificationError
from adcp.signing.constants import WEBHOOK_ACCEPTED_ADCP_USES
from adcp.signing.revocation import RevocationList
from adcp.webhooks import WebhookVerifyOptions, verify_webhook_signature

Expand Down Expand Up @@ -83,6 +84,30 @@ def test_protocol_owned_webhook_vector(name):
assert raised.value.step == expected["failed_step"]


@pytest.mark.parametrize("purpose", ["request-signing", "webhook-signing", "response-signing"])
def test_named_webhook_key_purposes_match_actual_verification(purpose):
assert WEBHOOK_ACCEPTED_ADCP_USES == frozenset({"request-signing", "webhook-signing"})
vector = json.loads(VECTORS.joinpath("positive/001-basic-post.json").read_text())
options = vector_options(vector)
resolve = options.jwks_resolver
options = replace(options, jwks_resolver=lambda kid: {**resolve(kid), "adcp_use": purpose})
request = vector["request"]
arguments = {
"method": request["method"],
"url": request["url"],
"headers": request["headers"],
"body": request["body"].encode(),
"options": options,
}
if purpose in WEBHOOK_ACCEPTED_ADCP_USES:
assert verify_webhook_signature(**arguments).label == "sig1"
else:
with pytest.raises(SignatureVerificationError) as raised:
verify_webhook_signature(**arguments)
assert raised.value.code == "webhook_signature_key_purpose_invalid"
assert raised.value.step == 8


@pytest.mark.parametrize(
"signature",
[
Expand Down
13 changes: 12 additions & 1 deletion tests/test_geo_place_system_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,18 @@ def test_registered_enum_keys_become_plain_strings(

@pytest.mark.parametrize("model,field,value", _MODELS)
@pytest.mark.parametrize(
"system", ["http://seller.example/places", "ftp://seller.example", "unknown", "", "https://"]
"system",
[
"http://seller.example/places",
"ftp://seller.example",
"unknown",
"",
"https://",
"HTTPS://seller.example/places",
"Https://seller.example/places",
" https://seller.example/places",
"https:\\seller.example/places",
],
)
@pytest.mark.parametrize("from_json", [False, True], ids=["python", "json"])
def test_invalid_system_keys_raise_validation_errors(
Expand Down
41 changes: 41 additions & 0 deletions tests/test_main_release_policies.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import base64
import copy
import json
import re
import subprocess
import urllib.error
from pathlib import Path
Expand Down Expand Up @@ -447,3 +448,43 @@ def test_native_policy_jobs_are_read_only_pinned_and_run_on_main() -> None:
}
assert job["steps"][1]["run"] == f"python3 -m scripts.check_main_policies {command}"
assert "secrets." not in json.dumps(job)


def test_main_ci_cannot_be_superseded_by_a_different_release_commit() -> None:
root = Path(__file__).resolve().parent.parent
ci = yaml.load((root / ".github/workflows/ci.yml").read_text(), Loader=yaml.BaseLoader)
concurrency = ci["concurrency"]
assert concurrency["group"] == (
"ci-${{ github.event_name == 'pull_request' && github.ref || github.sha }}"
)
assert concurrency["cancel-in-progress"] == "${{ github.event_name == 'pull_request' }}"
publisher = yaml.load(
(root / ".github/workflows/release-publish.yml").read_text(), Loader=yaml.BaseLoader
)
gate = publisher["jobs"]["build"]["steps"][1]["run"]
assert '--commit "$RELEASE_SHA"' in gate
assert '.headSha == $sha and .headBranch == "main"' in gate
assert 'git checkout --detach "$RELEASE_SHA"' in gate


def test_release_wait_and_job_budgets_cover_full_test_steps() -> None:
root = Path(__file__).resolve().parent.parent
ci = yaml.load((root / ".github/workflows/ci.yml").read_text(), Loader=yaml.BaseLoader)
job = ci["jobs"]["test"]
budgets = [int(value) for value in re.findall(r"&& (\d+)", job["timeout-minutes"])]
steps = {step["name"]: step for step in job["steps"] if "name" in step}
coverage_minutes = int(steps["Run canonical suite with coverage"]["timeout-minutes"])
full_minutes = int(steps["Run full native suite"]["timeout-minutes"])
assert coverage_minutes >= 70
assert full_minutes >= 55
assert budgets[0] >= coverage_minutes + 15
assert budgets[1] >= full_minutes + 15
publisher = yaml.load(
(root / ".github/workflows/release-publish.yml").read_text(), Loader=yaml.BaseLoader
)["jobs"]["build"]
gate = publisher["steps"][1]["run"]
attempts = int(re.search(r"for attempt in \{1\.\.(\d+)\}", gate)[1])
delay = int(re.search(r"sleep (\d+)", gate)[1])
wait_minutes = attempts * delay / 60
assert wait_minutes >= max(budgets) + 20
assert int(publisher["timeout-minutes"]) >= wait_minutes + 10
Loading