diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3c13d2bf..a84af502a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -993,6 +993,7 @@ jobs: # The default build path creates the sdist first, then builds the # wheel from that sdist. This covers both VCS and sdist consumers. python -m build --outdir dist/ + python scripts/ci/check_distribution_size.py dist/ pip install dist/*.whl - name: Verify VCS-built wheel schemas @@ -1019,9 +1020,9 @@ jobs: for child in schema_root.iterdir() if child.is_dir() } - # Preserve historical beta.6, rc.3 and rc.6 schemas offline. + # Ship released 3.0/3.1 and only the current 3.2 candidate offline. assert packaged_versions == { - "2.5", "3.0", "3.1", "3.2.0-beta.6", "3.2.0-rc.3", "3.2.0-rc.6", current_bundle + "3.0", "3.1", current_bundle } error_schema = json.loads( (schema_root / current_bundle / "enums/error-code.json").read_text(encoding="utf-8") diff --git a/MANIFEST.in b/MANIFEST.in index 55df6fad0..2851eff04 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,28 +4,29 @@ include LICENSE include MIGRATION*.md recursive-include src/adcp py.typed recursive-include src/adcp/reporting/materializer/assets *.json -recursive-include src/adcp/_compliance/3.2.0-rc.6 *.json *.jsonl *.yaml *.md recursive-include src/adcp/_compliance/3.2.0-rc.7 *.json *.jsonl *.yaml *.md # Bundled AdCP JSON schemas. ``scripts/bundle_schemas.py`` mirrors # ``schemas/cache/`` into ``src/adcp/_schemas/`` before ``python -m # build`` so the validator ships with the wheel. Keep distributions on -# supported bundles, including historical v32's beta.6 and rc.3 continuations. -recursive-include src/adcp/_schemas/2.5 *.json +# supported released lines and the current 3.2 release candidate. recursive-include src/adcp/_schemas/3.0 *.json recursive-include src/adcp/_schemas/3.1 *.json -recursive-include src/adcp/_schemas/3.2.0-beta.6 *.json -recursive-include src/adcp/_schemas/3.2.0-rc.3 *.json -recursive-include src/adcp/_schemas/3.2.0-rc.6 *.json recursive-include src/adcp/_schemas/3.2.0-rc.7 *.json +prune src/adcp/_schemas/2.5 +prune src/adcp/_schemas/3.2.0-beta.6 +prune src/adcp/_schemas/3.2.0-rc.3 +prune src/adcp/_schemas/3.2.0-rc.6 +prune src/adcp/_compliance/3.2.0-rc.6 prune src/adcp/_schemas/3.1.0-* # A clean VCS source tree has no ignored ``src/adcp/_schemas`` directory. # Keep the tracked inputs needed by the build_py hook in source distributions # so wheels built from an sdist have the same schema set as direct VCS wheels. -recursive-include schemas/cache/2.5 *.json recursive-include schemas/cache/3.0 *.json recursive-include schemas/cache/3.1 *.json -recursive-include schemas/cache/3.2.0-beta.6 *.json -recursive-include schemas/cache/3.2.0-rc.3 *.json -recursive-include schemas/cache/3.2.0-rc.6 *.json recursive-include schemas/cache/3.2.0-rc.7 *.json -recursive-include schemas/releases *.json +prune schemas/cache/2.5 +prune schemas/cache/3.2.0-beta.6 +prune schemas/cache/3.2.0-rc.3 +prune schemas/cache/3.2.0-rc.6 +prune schemas/releases +include schemas/releases/3.2.0-rc.7.json diff --git a/pyproject.toml b/pyproject.toml index 033525b27..e68875087 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -211,18 +211,10 @@ adcp = [ # AdCP JSON schemas, mirrored from ``schemas/cache/`` by # ``scripts/bundle_schemas.py`` so the wheel ships them for # ``adcp.validation.schema_loader``. - "_schemas/2.5/**/*.json", "_schemas/3.0/**/*.json", "_schemas/3.1/**/*.json", - "_schemas/3.2.0-beta.6/**/*.json", - "_schemas/3.2.0-rc.3/**/*.json", - "_schemas/3.2.0-rc.6/**/*.json", "_schemas/3.2.0-rc.7/**/*.json", # Exact signed compliance inputs plus their immutable release provenance. - "_compliance/3.2.0-rc.6/**/*.json", - "_compliance/3.2.0-rc.6/**/*.jsonl", - "_compliance/3.2.0-rc.6/**/*.yaml", - "_compliance/3.2.0-rc.6/**/*.md", "_compliance/3.2.0-rc.7/**/*.json", "_compliance/3.2.0-rc.7/**/*.jsonl", "_compliance/3.2.0-rc.7/**/*.yaml", diff --git a/scripts/ci/check_distribution_size.py b/scripts/ci/check_distribution_size.py new file mode 100644 index 000000000..79460e98a --- /dev/null +++ b/scripts/ci/check_distribution_size.py @@ -0,0 +1,53 @@ +"""Fail a release build before upload if either distribution approaches PyPI's limit.""" + +from __future__ import annotations + +import sys +import tarfile +import zipfile +from pathlib import Path + +MAX_BYTES = 90_000_000 +RETIRED_BUNDLES = {"2.5", "3.2.0-beta.6", "3.2.0-rc.3", "3.2.0-rc.6"} + + +def check_distribution_size(directory: Path) -> tuple[Path, Path]: + """Require one wheel and one sdist, each below the release budget.""" + artifacts = [] + for pattern in ("*.whl", "*.tar.gz"): + found = sorted(directory.glob(pattern)) + if len(found) != 1: + raise ValueError(f"expected one {pattern} in {directory}, found {len(found)}") + artifacts.append(found[0]) + for artifact in artifacts: + size = artifact.stat().st_size + if size > MAX_BYTES: + raise ValueError(f"{artifact.name} is {size} bytes; limit is {MAX_BYTES} bytes") + print(f"{artifact.name}: {size} bytes (limit {MAX_BYTES})") + return artifacts[0], artifacts[1] + + +def check_retired_schema_bundles(wheel: Path, source: Path) -> None: + """Reject superseded schema directories in either release artifact.""" + with zipfile.ZipFile(wheel) as archive: + wheel_members = archive.namelist() + with tarfile.open(source) as archive: + source_members = archive.getnames() + for artifact, members in ((wheel, wheel_members), (source, source_members)): + for name in members: + path = Path(name) + retired = RETIRED_BUNDLES.intersection((*path.parts, path.stem)) + if retired: + raise ValueError( + f"{artifact.name} contains retired schema bundle {sorted(retired)[0]}" + ) + + +if __name__ == "__main__": + try: + wheel, source = check_distribution_size( + Path(sys.argv[1]) if len(sys.argv) == 2 else Path("dist") + ) + check_retired_schema_bundles(wheel, source) + except ValueError as exc: + raise SystemExit(str(exc)) from exc diff --git a/setup.py b/setup.py index e70d6aa8c..2adad79d2 100644 --- a/setup.py +++ b/setup.py @@ -15,11 +15,7 @@ if "-" in _PINNED_ADCP_VERSION else ".".join(_PINNED_ADCP_VERSION.split(".")[:2]) ) -_BUNDLED_SCHEMA_VERSIONS = tuple( - dict.fromkeys( - ("2.5", "3.0", "3.1", "3.2.0-beta.6", "3.2.0-rc.3", "3.2.0-rc.6", _CURRENT_SCHEMA_BUNDLE) - ) -) +_BUNDLED_SCHEMA_VERSIONS = tuple(dict.fromkeys(("3.0", "3.1", _CURRENT_SCHEMA_BUNDLE))) class BuildPy(_build_py): diff --git a/src/adcp/compat/purchase_continuation.py b/src/adcp/compat/purchase_continuation.py index 43ca73ea7..241dfc8cd 100644 --- a/src/adcp/compat/purchase_continuation.py +++ b/src/adcp/compat/purchase_continuation.py @@ -34,6 +34,7 @@ from adcp.types import AccountReference, CompatibilityPurchaseCoordinatorInput from adcp.types.core import TaskResult, TaskStatus from adcp.validation import ( + ValidationOutcome, get_bundle_adcp_version, validate_request, validate_response, @@ -57,6 +58,9 @@ _SOURCE_VERSION_RE = re.compile( r"^(?:2\.5|3\.[01])\.\d+(?:-[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)?$" ) +# This exact historical release was accepted while its bundle was shipped. +# Keep continuation support after removing that bundle from distributions. +_UNBUNDLED_SOURCE_VERSIONS = frozenset({"2.5.3"}) _REQUIRED_LOSSES = frozenset({"feed_version_not_atomic", "pricing_version_not_atomic"}) _MUTATION_LOSS = "mutation_idempotency_not_guaranteed" _ALLOWED_LOSSES = _REQUIRED_LOSSES | {_MUTATION_LOSS} @@ -1128,7 +1132,7 @@ def _validate_bindings( request = payload["legacy_create_request"] outcome = validate_request("create_media_buy", request, version=record.source_adcp_version) - if not outcome.valid or outcome.variant == "skipped": + if not _source_schema_valid(outcome, record.source_adcp_version): raise _error( CompatibilityContinuationErrorCode.INVALID_LEGACY_REQUEST, "legacy_create_request does not validate against its exact source version", @@ -1285,6 +1289,8 @@ def _validate_source_version(version: str) -> None: "source_adcp_version must be an exact 2.5.x, 3.0.x, or 3.1.x stable/prerelease bundle" ) bundled_version = get_bundle_adcp_version(version=version) + if bundled_version is None and version in _UNBUNDLED_SOURCE_VERSIONS: + return if bundled_version != version: raise _invalid( "source_adcp_version must exactly match the bundled source schema release " @@ -1292,6 +1298,16 @@ def _validate_source_version(version: str) -> None: ) +def _source_schema_valid(outcome: ValidationOutcome, version: str) -> bool: + """Allow a missing schema only for an exact supported cross-major release. + + The continuation's account, product, pricing, target, loss and idempotency + bindings still run when the retired 2.5 bundle is unavailable. + """ + + return outcome.valid and (outcome.variant != "skipped" or version in _UNBUNDLED_SOURCE_VERSIONS) + + def _validate_loss_set( values: Any, *, @@ -1488,7 +1504,7 @@ def _validate_source_discovery( request_outcome = validate_request("get_products", request, version=source_adcp_version) response_outcome = validate_response("get_products", response, version=source_adcp_version) for side, outcome in (("request", request_outcome), ("response", response_outcome)): - if not outcome.valid or outcome.variant == "skipped": + if not _source_schema_valid(outcome, source_adcp_version): raise _error( CompatibilityContinuationErrorCode.INVALID_INPUT, f"observed get_products {side} does not validate against its source version", @@ -1729,7 +1745,7 @@ def _validated_result( except (TypeError, ValueError, CompatibilityContinuationError) as exc: raise _invalid_legacy_response(source_adcp_version, []) from exc outcome = validate_response("create_media_buy", payload, version=source_adcp_version) - if not outcome.valid or outcome.variant == "skipped": + if not _source_schema_valid(outcome, source_adcp_version): raise _invalid_legacy_response( source_adcp_version, [ @@ -1741,6 +1757,8 @@ def _validated_result( for issue in outcome.issues ], ) + if outcome.variant == "skipped": + _validate_unbundled_v25_result(payload, source_adcp_version) if outcome.variant in {"submitted", "working", "input-required"}: state = CompatibilityOperationState.PENDING elif "errors" in payload: @@ -1764,6 +1782,43 @@ def _validated_result( return payload, state +def _validate_unbundled_v25_result(payload: JsonObject, version: str) -> None: + """Retain the 2.5 success/error boundary without shipping its full schema.""" + + if "errors" in payload: + errors = payload["errors"] + valid_errors = ( + isinstance(errors, list) + and bool(errors) + and all( + isinstance(error, Mapping) + and isinstance(error.get("code"), str) + and isinstance(error.get("message"), str) + for error in errors + ) + ) + if ( + not valid_errors + or any(key in payload for key in ("media_buy_id", "buyer_ref", "packages")) + or payload.get("status") not in (None, "failed") + ): + raise _invalid_legacy_response(version, []) + return + + packages = payload.get("packages") + if ( + not isinstance(payload.get("media_buy_id"), str) + or not isinstance(payload.get("buyer_ref"), str) + or not isinstance(packages, list) + or not all( + isinstance(package, Mapping) and isinstance(package.get("package_id"), str) + for package in packages + ) + or payload.get("status") not in (None, "completed") + ): + raise _invalid_legacy_response(version, []) + + def _aware_utc(value: datetime, *, field: str) -> datetime: if not isinstance(value, datetime) or value.tzinfo is None: raise _invalid(f"{field} must be a timezone-aware datetime") diff --git a/tests/conformance/reporting/_production_installed.py b/tests/conformance/reporting/_production_installed.py index 6f3ff5618..7e6bbc3e3 100644 --- a/tests/conformance/reporting/_production_installed.py +++ b/tests/conformance/reporting/_production_installed.py @@ -17,7 +17,7 @@ def assert_installed_schema_keys(schemas, pinned): - assert set(schemas) == {"3.2.0-rc.6", pinned} + assert set(schemas) == {pinned} def main(settings): @@ -70,33 +70,33 @@ def enter_phase(name): for name, expected in schemas.items(): assert hashlib.sha256((schema_root / name).read_bytes()).hexdigest() == expected enter_phase("historical_reference_schemas") - reference = settings["historical_reference_schema"] - reference_root = Path(reference["root"]) - assert not reference_root.is_relative_to(Path(sys.prefix)) - assert not reference_root.is_relative_to(workspace) - assert reference["version"] not in settings["schemas"] - historical_resolved = schema_loader._resolve_schema_root(reference["version"]) - assert historical_resolved is not None - historical_root = historical_resolved.root - assert historical_root.is_relative_to(Path(sys.prefix)) - assert not historical_root.is_relative_to(workspace) - assert historical_root != reference_root + references = settings["historical_reference_schemas"] + installed_historical = [] - def historical_manifest(): + def schema_manifest(path): return { - str(p.relative_to(historical_root)): hashlib.sha256(p.read_bytes()).hexdigest() - for p in sorted(historical_root.rglob("*.json")) + str(p.relative_to(path)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(path.rglob("*.json")) } - assert historical_manifest() == reference["files"] - - def reference_manifest(): - return { - str(p.relative_to(reference_root)): hashlib.sha256(p.read_bytes()).hexdigest() - for p in sorted(reference_root.rglob("*.json")) - } - - assert reference_manifest() == reference["files"] + for reference in references: + version = reference["version"] + reference_root = Path(reference["root"]) + assert not reference_root.is_relative_to(Path(sys.prefix)) + assert not reference_root.is_relative_to(workspace) + assert version not in settings["schemas"] + # The wheel excludes superseded bundles. The immutable test input + # remains available through the existing source-layout fallback. + assert not (files("adcp") / "_schemas" / version / "bundled").is_dir() + historical_resolved = schema_loader._resolve_schema_root(version) + assert historical_resolved is not None + historical_root = historical_resolved.root + assert historical_root == reference_root + actual = schema_manifest(historical_root) + assert actual == reference["files"] + installed_historical.append( + {"version": version, "root": str(historical_root), "files": len(actual)} + ) enter_phase("optional_driver_boundary") if settings["driver_absent"]: assert importlib.util.find_spec("psycopg") is None @@ -105,7 +105,7 @@ def reference_manifest(): else: assert os.environ.get("ADCP_PG_TEST_URL") reference_inputs = evidence / (settings["label"] + "-historical-schema-inputs.json") - reference_bytes = (json.dumps(reference, indent=2, sort_keys=True) + "\n").encode() + reference_bytes = (json.dumps(references, indent=2, sort_keys=True) + "\n").encode() reference_inputs.write_bytes(reference_bytes) identity = { "python": sys.version, @@ -118,22 +118,11 @@ def reference_manifest(): "wheel_sha256": settings["wheel_sha256"], "assets": settings["assets"], "schemas": settings["schemas"], - "historical_reference_schema": { - "version": reference["version"], - "root": str(reference_root), - "origin": ( - "copied immutable test reference; independently compared with " - "the packaged historical bundle" - ), - "files": len(reference["files"]), + "historical_reference_schemas": { + "origin": "copied immutable test fixtures outside the wheel", "inputs": str(reference_inputs), "inputs_sha256": hashlib.sha256(reference_bytes).hexdigest(), - }, - "installed_historical_schema": { - "version": reference["version"], - "root": str(historical_root), - "origin": "installed distribution", - "files": len(reference["files"]), + "versions": installed_historical, }, "driver_absent": settings["driver_absent"], } @@ -210,8 +199,11 @@ def reference_manifest(): for name, module in tuple(sys.modules.items()): if (name == "adcp" or name.startswith("adcp.")) and getattr(module, "__file__", None): assert Path(module.__file__).resolve().is_relative_to(Path(sys.prefix)) - assert reference_manifest() == reference["files"] - assert historical_manifest() == reference["files"] + for reference in references: + assert schema_manifest(Path(reference["root"])) == reference["files"] + resolved = schema_loader._resolve_schema_root(reference["version"]) + assert resolved is not None + assert schema_manifest(resolved.root) == reference["files"] progress.start("complete") progress.close() journal = progress.path.with_suffix(".jsonl") diff --git a/tests/conformance/reporting/_production_packaging.py b/tests/conformance/reporting/_production_packaging.py index 67f46a985..c4a6a6f35 100644 --- a/tests/conformance/reporting/_production_packaging.py +++ b/tests/conformance/reporting/_production_packaging.py @@ -37,7 +37,7 @@ "protocol/get-adcp-capabilities-response.json", "bundled/protocol/get-adcp-capabilities-response.json", ) -RETAINED_SCHEMA_VERSIONS = ("3.2.0-rc.6", "3.2.0-rc.7") +RETAINED_SCHEMA_VERSIONS = ("3.2.0-rc.7",) def production_schema_hashes(): @@ -226,31 +226,33 @@ def git(*args): return basis -def historical_schema_fixture(root): - """Copy immutable rc.3 reference inputs, never claim them as wheel contents. +def historical_schema_fixtures(root): + """Copy superseded reference inputs separately from wheel contents. The installed suite retains historical rejection and correction controls. - rc.3 is no longer a shipped bundle. The existing source-layout fallback - can read these explicit test inputs without changing installed SDK code, - its current bundle, or the public protocol-version allowlist. + These versions are no longer shipped bundles. The source-layout fallback + can read explicit test inputs without changing installed SDK code or the + public protocol-version allowlist. """ - version = "3.2.0-rc.3" - source = ROOT / "schemas/cache" / version - destination = root / "schemas/cache" / version assert not root.resolve().is_relative_to(ROOT.resolve()) - expected = { - str(p.relative_to(source)): hashlib.sha256(p.read_bytes()).hexdigest() - for p in sorted(source.rglob("*.json")) - } - assert expected - if not destination.exists(): - shutil.copytree(source, destination) - actual = { - str(p.relative_to(destination)): hashlib.sha256(p.read_bytes()).hexdigest() - for p in sorted(destination.rglob("*.json")) - } - assert actual == expected - return {"version": version, "root": str(destination), "files": expected} + references = [] + for version in ("3.2.0-beta.6", "3.2.0-rc.3", "3.2.0-rc.6"): + source = ROOT / "schemas/cache" / version + destination = root / "schemas/cache" / version + expected = { + str(p.relative_to(source)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(source.rglob("*.json")) + } + assert expected + if not destination.exists(): + shutil.copytree(source, destination) + actual = { + str(p.relative_to(destination)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(destination.rglob("*.json")) + } + assert actual == expected + references.append({"version": version, "root": str(destination), "files": expected}) + return references def installed_production(root, python, wheel, source, *, label, driver_absent): @@ -314,8 +316,8 @@ def installed_production(root, python, wheel, source, *, label, driver_absent): "tests/conformance/signing/test_revocation_checker_boundary.py", "tests/test_reporting_production_public.py", "tests/test_schema_datetime_formats.py", - "tests/test_rc6_adoption.py", "tests/test_mcp_schema_materialization.py", + "tests/test_purchase_continuation_unbundled.py", ) ] evidence = Path(os.environ.get("ADCP_PRODUCTION_EVIDENCE", str(root / "production-evidence"))) @@ -327,7 +329,7 @@ def installed_production(root, python, wheel, source, *, label, driver_absent): "modules": modules, "assets": assets, "schemas": production_schema_hashes(), - "historical_reference_schema": historical_schema_fixture(root), + "historical_reference_schemas": historical_schema_fixtures(root), "tests": [str(p.relative_to(ROOT)) for p in tests], "driver_absent": driver_absent, "python": [3, 10], diff --git a/tests/conformance/reporting/test_reporting_notification_packaging.py b/tests/conformance/reporting/test_reporting_notification_packaging.py index 55bd12920..4f35c0d8f 100644 --- a/tests/conformance/reporting/test_reporting_notification_packaging.py +++ b/tests/conformance/reporting/test_reporting_notification_packaging.py @@ -242,9 +242,7 @@ def built_distribution(tmp_path_factory, request): ) pin = (ROOT / "src/adcp/ADCP_VERSION").read_text().strip() current = pin if "-" in pin else ".".join(pin.split(".")[:2]) - for version in dict.fromkeys( - ("2.5", "3.0", "3.1", "3.2.0-beta.6", "3.2.0-rc.3", "3.2.0-rc.6", current) - ): + for version in dict.fromkeys(("3.0", "3.1", current)): shutil.copytree( ROOT / "schemas" / "cache" / version, project / "schemas" / "cache" / version ) diff --git a/tests/conformance/reporting/test_reporting_production_packaging.py b/tests/conformance/reporting/test_reporting_production_packaging.py index 50365394a..65712b64d 100644 --- a/tests/conformance/reporting/test_reporting_production_packaging.py +++ b/tests/conformance/reporting/test_reporting_production_packaging.py @@ -15,7 +15,7 @@ __all__ = ["b1_wheels", "built_distribution"] -def test_installed_schema_input_retains_rc6_and_current_rc7(): +def test_installed_schema_input_retains_only_current_rc7(): from adcp.validation import schema_loader schemas = production_schema_hashes() @@ -23,9 +23,9 @@ def test_installed_schema_input_retains_rc6_and_current_rc7(): assert pinned == "3.2.0-rc.7" assert_installed_schema_keys(schemas, pinned) with pytest.raises(AssertionError): - assert_installed_schema_keys({pinned: schemas[pinned]}, pinned) + assert_installed_schema_keys({}, pinned) with pytest.raises(AssertionError): - assert_installed_schema_keys({**schemas, "3.2.0-rc.3": {}}, pinned) + assert_installed_schema_keys({**schemas, "3.2.0-rc.6": {}}, pinned) @pytest.mark.parametrize("kind", ["vcs", "sdist"]) diff --git a/tests/conformance/signing/_webhook_http_server.py b/tests/conformance/signing/_webhook_http_server.py index 035e5eb40..d183bcc38 100644 --- a/tests/conformance/signing/_webhook_http_server.py +++ b/tests/conformance/signing/_webhook_http_server.py @@ -21,7 +21,7 @@ async def run(root, socket_fd): key_rows = json.loads( files("adcp") - .joinpath("_compliance/3.2.0-rc.6/test-vectors/webhook-signing/keys.json") + .joinpath("_compliance/3.2.0-rc.7/test-vectors/webhook-signing/keys.json") .read_text() )["keys"] keys = {row["kid"]: {k: v for k, v in row.items() if not k.startswith("_")} for row in key_rows} diff --git a/tests/conformance/signing/test_webhook_rc4_vectors.py b/tests/conformance/signing/test_webhook_rc4_vectors.py index 460707c77..cb976414b 100644 --- a/tests/conformance/signing/test_webhook_rc4_vectors.py +++ b/tests/conformance/signing/test_webhook_rc4_vectors.py @@ -14,7 +14,7 @@ from adcp.signing.revocation import RevocationList from adcp.webhooks import WebhookVerifyOptions, verify_webhook_signature -VECTORS = files("adcp").joinpath("_compliance/3.2.0-rc.6/test-vectors/webhook-signing") +VECTORS = files("adcp").joinpath("_compliance/3.2.0-rc.7/test-vectors/webhook-signing") KEYS = { row["kid"]: {name: value for name, value in row.items() if not name.startswith("_")} for row in json.loads(VECTORS.joinpath("keys.json").read_text())["keys"] diff --git a/tests/conformance/signing/test_webhook_signature_emission.py b/tests/conformance/signing/test_webhook_signature_emission.py index 8f049c378..32fff06dc 100644 --- a/tests/conformance/signing/test_webhook_signature_emission.py +++ b/tests/conformance/signing/test_webhook_signature_emission.py @@ -13,7 +13,7 @@ KEYS = json.loads( files("adcp") - .joinpath("_compliance/3.2.0-rc.6/test-vectors/webhook-signing/keys.json") + .joinpath("_compliance/3.2.0-rc.7/test-vectors/webhook-signing/keys.json") .read_text() )["keys"] ALGORITHMS = [ diff --git a/tests/conformance/signing/test_webhook_signature_http.py b/tests/conformance/signing/test_webhook_signature_http.py index 8558fed1e..0e7f5ca7c 100644 --- a/tests/conformance/signing/test_webhook_signature_http.py +++ b/tests/conformance/signing/test_webhook_signature_http.py @@ -75,7 +75,7 @@ async def receiver(tmp_path): def keys(): return json.loads( files("adcp") - .joinpath("_compliance/3.2.0-rc.6/test-vectors/webhook-signing/keys.json") + .joinpath("_compliance/3.2.0-rc.7/test-vectors/webhook-signing/keys.json") .read_text() )["keys"] diff --git a/tests/test_distribution_size.py b/tests/test_distribution_size.py new file mode 100644 index 000000000..c515b6d99 --- /dev/null +++ b/tests/test_distribution_size.py @@ -0,0 +1,79 @@ +"""Release artifact size checks fail before a PyPI upload is attempted.""" + +from __future__ import annotations + +import io +import tarfile +import zipfile +from pathlib import Path + +import pytest + +from scripts.ci.check_distribution_size import ( + MAX_BYTES, + check_distribution_size, + check_retired_schema_bundles, +) + + +def test_distribution_size_requires_both_artifacts(tmp_path: Path) -> None: + (tmp_path / "adcp-1-py3-none-any.whl").write_bytes(b"wheel") + with pytest.raises(ValueError, match=r"expected one \*\.tar\.gz"): + check_distribution_size(tmp_path) + + +@pytest.mark.parametrize("suffix", ["py3-none-any.whl", "tar.gz"]) +def test_distribution_size_rejects_oversized_artifact(tmp_path: Path, suffix: str) -> None: + wheel = tmp_path / "adcp-1-py3-none-any.whl" + source = tmp_path / "adcp-1.tar.gz" + wheel.write_bytes(b"wheel") + source.write_bytes(b"source") + target = wheel if suffix.endswith(".whl") else source + with target.open("r+b") as stream: + stream.truncate(MAX_BYTES + 1) + with pytest.raises(ValueError, match="limit is 90000000 bytes"): + check_distribution_size(tmp_path) + + +def test_distribution_size_accepts_both_artifacts(tmp_path: Path) -> None: + wheel = tmp_path / "adcp-1-py3-none-any.whl" + source = tmp_path / "adcp-1.tar.gz" + wheel.write_bytes(b"wheel") + source.write_bytes(b"source") + assert check_distribution_size(tmp_path) == (wheel, source) + + +@pytest.mark.parametrize("artifact", ["wheel", "sdist"]) +@pytest.mark.parametrize("version", ["2.5", "3.2.0-beta.6", "3.2.0-rc.3", "3.2.0-rc.6"]) +def test_distribution_rejects_retired_schema_members( + tmp_path: Path, artifact: str, version: str +) -> None: + wheel = tmp_path / "adcp-1-py3-none-any.whl" + source = tmp_path / "adcp-1.tar.gz" + wheel_name = f"adcp/_schemas/{version}/index.json" if artifact == "wheel" else "adcp/x.py" + source_name = ( + f"adcp-1/schemas/cache/{version}/index.json" if artifact == "sdist" else "adcp-1/x.py" + ) + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr(wheel_name, "{}") + with tarfile.open(source, "w:gz") as archive: + body = b"{}" + member = tarfile.TarInfo(source_name) + member.size = len(body) + archive.addfile(member, io.BytesIO(body)) + with pytest.raises(ValueError, match="retired schema bundle"): + check_retired_schema_bundles(wheel, source) + + +def test_distribution_rejects_retired_release_metadata(tmp_path: Path) -> None: + wheel = tmp_path / "adcp-1-py3-none-any.whl" + source = tmp_path / "adcp-1.tar.gz" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr("adcp/x.py", "") + with tarfile.open(source, "w:gz") as archive: + body = b"{}" + member = tarfile.TarInfo("adcp-1/schemas/releases/3.2.0-rc.6.json") + member.size = len(body) + archive.addfile(member, io.BytesIO(body)) + with pytest.raises(ValueError, match="retired schema bundle 3.2.0-rc.6"): + check_retired_schema_bundles(wheel, source) diff --git a/tests/test_mcp_schema_materialization.py b/tests/test_mcp_schema_materialization.py index 02e0a818c..7367bc65f 100644 --- a/tests/test_mcp_schema_materialization.py +++ b/tests/test_mcp_schema_materialization.py @@ -298,19 +298,35 @@ async def test_mutation_cannot_change_mounted_discovery_registration_or_validati assert hashlib.sha256(canonical(repeated)).hexdigest() == (EXPECTED_PUBLIC_SHA256[version]) -def test_cached_rc6_schema_retains_all_signed_summary_and_period_controls(): - from tests.test_rc6_adoption import patched - +def test_cached_rc7_schema_retains_all_signed_summary_and_period_controls(): + def patched(value, operations): + value = deepcopy(value) + for operation in operations: + parts = [ + part.replace("~1", "/").replace("~0", "~") + for part in operation["path"].split("/")[1:] + ] + parent = value + for part in parts[:-1]: + parent = parent[part] + if operation["op"] == "remove": + del parent[parts[-1]] + else: + assert operation["op"] in {"add", "replace"} + parent[parts[-1]] = deepcopy(operation["value"]) + return value + + version = "3.2.0-rc.7" fixture = json.loads( files("adcp") - .joinpath("_compliance", PINS[1], "test-vectors/reporting-summary/complete-summary.json") + .joinpath("_compliance", version, "test-vectors/reporting-summary/complete-summary.json") .read_bytes() ) - cold = loader.get_mcp_schema("get_reporting_status", "sync", version=PINS[1]) + cold = loader.get_mcp_schema("get_reporting_status", "sync", version=version) saved = canonical(cold) assert cold is not None cold.clear() - warm = loader.get_mcp_schema("get_reporting_status", "sync", version=PINS[1]) + warm = loader.get_mcp_schema("get_reporting_status", "sync", version=version) assert canonical(warm) == saved checker = FormatChecker() checker.checks("date-time")(loader._is_rfc3339_date_time) diff --git a/tests/test_purchase_continuation.py b/tests/test_purchase_continuation.py index 0ca31a0d9..1b3b2b552 100644 --- a/tests/test_purchase_continuation.py +++ b/tests/test_purchase_continuation.py @@ -47,12 +47,12 @@ def _cases() -> list[dict[str, Any]]: return json.loads(_VECTORS.read_text())["cases"] -def test_all_signed_compact_projection_vectors_validate_against_beta4() -> None: +def test_all_signed_compact_projection_vectors_validate_against_current_schema() -> None: vectors = json.loads(_VECTORS.read_text()) projections = [case["compact_projection"] for case in vectors["cases"]] projections += [case["compact_projection"] for case in vectors["listed_purchase_cases"]] for projection in projections: - outcome = validate_response("request_proposals", projection, version="3.2-beta.4") + outcome = validate_response("request_proposals", projection, version="3.2.0-rc.7") assert outcome.valid, outcome.issues @@ -2277,7 +2277,8 @@ async def test_sqlite_migrates_pre_release_tombstone_schema_before_purge( for trigger_name in trigger_names: conn.execute(f'DROP TRIGGER "{trigger_name}"') conn.execute("DROP TABLE adcp_compat_issuance_tombstones") - conn.execute(""" + conn.execute( + """ CREATE TABLE adcp_compat_issuance_tombstones ( token_hash TEXT PRIMARY KEY, principal_id TEXT NOT NULL, @@ -2286,7 +2287,8 @@ async def test_sqlite_migrates_pre_release_tombstone_schema_before_purge( legacy_equivalence_hash TEXT NOT NULL, retired_at TEXT NOT NULL ) - """) + """ + ) conn.execute( "CREATE UNIQUE INDEX adcp_compat_issuance_tombstones_issuance_idx " "ON adcp_compat_issuance_tombstones (principal_id, issuance_fingerprint) " diff --git a/tests/test_purchase_continuation_unbundled.py b/tests/test_purchase_continuation_unbundled.py new file mode 100644 index 000000000..d0e775ad8 --- /dev/null +++ b/tests/test_purchase_continuation_unbundled.py @@ -0,0 +1,129 @@ +"""Legacy purchase continuation when its historical schema is not installed.""" + +from __future__ import annotations + +import copy +from pathlib import Path +from typing import Any + +import pytest + +import adcp +from adcp.compat import ( + CompatibilityContinuationError, + CompatibilityContinuationErrorCode, + InMemoryCompatibilityContinuationStore, +) +from adcp.validation import ( + get_bundle_adcp_version, + schema_loader, + validate_request, + validate_response, +) +from tests.test_purchase_continuation import ( + _cases, + _coordinator, + _issue, + _success_result, +) + + +@pytest.fixture +def unavailable_v25_schema(monkeypatch: pytest.MonkeyPatch) -> None: + case = next(case for case in _cases() if case["source_version"] == "2.5.3") + package_root = Path(adcp.__file__).resolve() + checkout_root = Path(__file__).resolve().parents[1] + if package_root.is_relative_to(checkout_root): + # Editable/source tests have the historical cache. Exercise the same + # unavailable-schema path that an installed distribution takes. + original = schema_loader._ensure_state + + def without_v25(version: str | None = None) -> Any: + if version is not None and schema_loader.resolve_bundle_key(version) == "2.5": + return None + return original(version) + + monkeypatch.setattr(schema_loader, "_ensure_state", without_v25) + else: + # The installed production gate executes this test from a wheel and + # verifies that no source-layout fallback supplies the retired bundle. + assert schema_loader._resolve_schema_root("2.5") is None + + assert get_bundle_adcp_version(version="2.5.3") is None + assert ( + validate_request("get_products", case["legacy_request"], version="2.5.3").variant + == "skipped" + ) + assert ( + validate_response("get_products", case["legacy_response"], version="2.5.3").variant + == "skipped" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("result_kind", ["completed", "failed"]) +async def test_v25_continuation_without_bundled_schema( + unavailable_v25_schema: None, result_kind: str +) -> None: + case = copy.deepcopy(next(case for case in _cases() if case["source_version"] == "2.5.3")) + + calls = 0 + + def execute(_ctx: Any) -> dict[str, Any]: + nonlocal calls + calls += 1 + if result_kind == "failed": + return {"errors": [{"code": "INVALID_REQUEST", "message": "Rejected"}]} + return _success_result("2.5.3", "legacy-buy-25") + + coordinator = _coordinator(InMemoryCompatibilityContinuationStore(), execute) + await _issue(coordinator, case) + first = await coordinator.continue_legacy_purchase( + case["continuation_input"], + principal_id="principal-acme", + target_binding="seller-session-acme", + ) + replay = await coordinator.continue_legacy_purchase( + copy.deepcopy(case["continuation_input"]), + principal_id="principal-acme", + target_binding="seller-session-acme", + ) + assert first == replay + assert calls == 1 + assert first == ( + {"errors": [{"code": "INVALID_REQUEST", "message": "Rejected"}]} + if result_kind == "failed" + else _success_result("2.5.3", "legacy-buy-25") + ) + operation = await coordinator.get_legacy_purchase_operation_by_idempotency_key( + case["continuation_input"]["idempotency_key"], principal_id="principal-acme" + ) + assert operation.state.value == result_kind.replace("completed", "succeeded") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "invalid", + [ + {"status": "failed", "message": "Rejected"}, + {"status": "working", "task_id": "legacy-task-25"}, + {"status": "failed", "media_buy_id": "false-success", "buyer_ref": "b", "packages": []}, + ], +) +async def test_unbundled_v25_never_records_unvalidated_success( + unavailable_v25_schema: None, invalid: dict[str, Any] +) -> None: + case = copy.deepcopy(next(case for case in _cases() if case["source_version"] == "2.5.3")) + coordinator = _coordinator(InMemoryCompatibilityContinuationStore(), lambda _ctx: invalid) + await _issue(coordinator, case) + with pytest.raises(CompatibilityContinuationError) as caught: + await coordinator.continue_legacy_purchase( + case["continuation_input"], + principal_id="principal-acme", + target_binding="seller-session-acme", + ) + assert caught.value.code == CompatibilityContinuationErrorCode.INVALID_LEGACY_RESPONSE + operation = await coordinator.get_legacy_purchase_operation_by_idempotency_key( + case["continuation_input"]["idempotency_key"], principal_id="principal-acme" + ) + assert operation.state.value == "ambiguous"