diff --git a/sdk-python/CHANGELOG.md b/sdk-python/CHANGELOG.md index cca3460..b75b656 100644 --- a/sdk-python/CHANGELOG.md +++ b/sdk-python/CHANGELOG.md @@ -1,4 +1,28 @@ -# wave-sdk Changelog +# wave-av-sdk Changelog + +> This directory builds the PyPI package **`wave-av-sdk`** (`[project] name` in +> `pyproject.toml`). This file was previously headed "wave-sdk Changelog", which is a +> *different* package published from a different repository (`wave-av/sdk-python`). The two +> are not the same distribution and their version numbers are unrelated. + +## 2.0.1 (2026-09-03) + +### Fixed + +- **License metadata now reaches the index.** `pyproject.toml` declares `Apache-2.0`, but the + only release on PyPI — `wave-av-sdk 2.0.0` — was published carrying `License: MIT` and the + MIT trove classifier. PyPI releases are immutable, so that correction could never reach a + user while the source still said `2.0.0`, and the next `sdk-python-v*` tag push would have + built `2.0.0` and failed on `400 File already exists`. Bumped to `2.0.1` so the Apache-2.0 + metadata can actually ship. The published `2.0.0` stays as published; it cannot be changed. + +### Added + +- `scripts/registry_license_truth.py` — compares this package's declared license and version + against what is actually on PyPI, and fails when the version is already taken or when a + release at the same version string declares a different license. Covered by + `tests/test_registry_license_truth.py`, which runs offline against a checked-in snapshot of + the real registry response. ## 2.0.0 (2026-04-05) diff --git a/sdk-python/pyproject.toml b/sdk-python/pyproject.toml index 0f4a789..d836b86 100644 --- a/sdk-python/pyproject.toml +++ b/sdk-python/pyproject.toml @@ -4,7 +4,10 @@ build-backend = "setuptools.build_meta" [project] name = "wave-av-sdk" -version = "2.0.0" +# 2.0.0 is already on PyPI, published as MIT, and PyPI releases are immutable. The Apache-2.0 +# licensing this file declares can therefore only reach a user at a NEW version — see +# scripts/registry_license_truth.py, which fails the build if this ever collides again. +version = "2.0.1" description = "Official WAVE SDK for Python - 33 API modules for streaming, production, analytics, and more" readme = "README.md" license = {text = "Apache-2.0"} diff --git a/sdk-python/scripts/registry_license_truth.py b/sdk-python/scripts/registry_license_truth.py new file mode 100644 index 0000000..9049222 --- /dev/null +++ b/sdk-python/scripts/registry_license_truth.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +""" +registry_license_truth — does the artifact on the registry carry the license the source declares? + +WHY THIS EXISTS +--------------- +Every license gate in this fleet compares one *declaration* to another declaration, or a +declaration to the LICENSE file sitting beside it. Neither can see the defect that actually +reached users, because that defect lives on the registry: + + $ curl -s https://pypi.org/pypi/wave-av-sdk/2.0.0/json | jq -r '.info.license' + MIT + $ grep '^license' sdk-python/pyproject.toml + license = {text = "Apache-2.0"} + +Both statements are true at the same time. `wave-av-sdk 2.0.0` is on PyPI carrying +`License: MIT`, and `sdk-python/pyproject.toml` declares `Apache-2.0` at that same version +string `2.0.0`. PyPI releases are immutable — a version can never be re-uploaded — so the +Apache-2.0 correction cannot reach a single user while the source still says `2.0.0`, and the +next `sdk-python-v*` tag push would build `2.0.0` and die on `400 File already exists`. + +So this gate reads the *published* metadata and compares it to the source, and it draws the +line where the immutability of a registry puts it: + + * **Blocking** — the version the source is about to publish is ALREADY published, and/or the + already-published artifact at that exact version string declares a different license. This + is unfixable-by-upload and must stop a release. + * **Reported, not blocking** — a strictly OLDER published version disagrees with what the + source declares today. That is immutable history. It is real and it is worth printing, but + failing on it forever would make the gate permanently red and therefore ignored. + +USAGE +----- + python3 scripts/registry_license_truth.py # offline, uses the checked-in snapshot + python3 scripts/registry_license_truth.py --json # machine-readable + python3 scripts/registry_license_truth.py --refresh # re-fetch the snapshot from PyPI + +Exit 0 when no blocking violation is found, 1 otherwise. + +Dependency-free and stdlib-only on purpose: this monorepo has no root package.json and the +python leg of CI runs a 3.10 matrix, where `tomllib` does not exist yet. +""" + +from __future__ import annotations + +import argparse +import datetime +import json +import re +import sys +from pathlib import Path + +PACKAGE_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_PYPROJECT = PACKAGE_ROOT / "pyproject.toml" +DEFAULT_SNAPSHOT = PACKAGE_ROOT / "tests" / "fixtures" / "pypi_wave_av_sdk.json" + +PYPI_JSON_URL = "https://pypi.org/pypi/{name}/json" + +# --------------------------------------------------------------------------- +# SPDX normalisation +# --------------------------------------------------------------------------- + +# PyPI carries the license in three different shapes depending on how old the release is and +# which build backend produced it: the free-text `License:` field, the modern +# `License-Expression:` field, and the `License :: OSI Approved :: ...` trove classifiers. +# All three have to be reduced to one SPDX-ish token before they can be compared. +_CLASSIFIER_TO_SPDX = { + "License :: OSI Approved :: MIT License": "MIT", + "License :: OSI Approved :: Apache Software License": "Apache-2.0", + "License :: OSI Approved :: BSD License": "BSD-3-Clause", + "License :: OSI Approved :: ISC License (ISCL)": "ISC", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)": "MPL-2.0", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)": "GPL-3.0-only", + "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)": "LGPL-3.0-only", + "License :: OSI Approved :: GNU Affero General Public License v3": "AGPL-3.0-only", +} + +_FREETEXT_TO_SPDX = ( + # Ordered: Apache is tested before MIT because the Apache-2.0 appendix contains the word + # "MIT" nowhere but its boilerplate is often pasted next to an MIT header in drifted repos. + (re.compile(r"apache", re.I), "Apache-2.0"), + (re.compile(r"\bmit\b", re.I), "MIT"), + (re.compile(r"mozilla|\bmpl\b", re.I), "MPL-2.0"), + (re.compile(r"\bisc\b", re.I), "ISC"), + (re.compile(r"\bbsd\b", re.I), "BSD-3-Clause"), + (re.compile(r"\bagpl", re.I), "AGPL-3.0-only"), + (re.compile(r"\blgpl", re.I), "LGPL-3.0-only"), + (re.compile(r"\bgpl", re.I), "GPL-3.0-only"), +) + + +def normalize_license(value: str | None) -> str: + """Reduce any of PyPI's three license shapes to one comparable token. + + Returns "UNKNOWN" for anything unrecognised — never guesses, because a wrong guess here + would silently mark a real contradiction as agreement. + """ + if not value or not str(value).strip(): + return "UNKNOWN" + text = str(value).strip() + if text in _CLASSIFIER_TO_SPDX: + return _CLASSIFIER_TO_SPDX[text] + # An exact SPDX id already (e.g. "Apache-2.0", "MIT"). + if re.fullmatch(r"[A-Za-z0-9.+-]+", text): + for canonical in set(_CLASSIFIER_TO_SPDX.values()): + if text.lower() == canonical.lower(): + return canonical + for pattern, spdx in _FREETEXT_TO_SPDX: + if pattern.search(text): + return spdx + return "UNKNOWN" + + +# --------------------------------------------------------------------------- +# Version comparison +# --------------------------------------------------------------------------- + + +def parse_version(version: str) -> tuple[int, ...]: + """Parse a dotted numeric version into a comparable tuple. + + Only the numeric release segment is compared; any pre/post/dev suffix is dropped. That is + sufficient here — this gate answers "is this exact string already taken" and "is that other + release older than mine", not full PEP 440 ordering. + """ + numbers = re.findall(r"\d+", str(version).split("+")[0]) + return tuple(int(n) for n in numbers) or (0,) + + +# --------------------------------------------------------------------------- +# Source metadata +# --------------------------------------------------------------------------- + + +def _parse_pyproject_with_regex(text: str) -> dict: + """Minimal `[project]`-table extractor for Python 3.10, which has no `tomllib`. + + Reads only the four scalar/array fields this gate needs. Deliberately not a TOML parser — + it is scoped to the exact keys, so it cannot silently mis-read the rest of the file. + """ + project = text.split("[project]", 1)[-1] + # Stop at the next top-level table so `[tool.*]` sections cannot leak in. + project = re.split(r"\n\[(?!project\.)", project, maxsplit=1)[0] + + def scalar(key: str) -> str | None: + m = re.search(rf'^\s*{key}\s*=\s*["\']([^"\']*)["\']', project, re.M) + return m.group(1) if m else None + + name = scalar("name") + version = scalar("version") + + # license = {text = "Apache-2.0"} or license = "Apache-2.0" + license_value = scalar("license") + if license_value is None: + m = re.search(r'^\s*license\s*=\s*\{[^}]*text\s*=\s*["\']([^"\']*)["\']', project, re.M) + license_value = m.group(1) if m else None + + classifiers: list[str] = [] + m = re.search(r"^\s*classifiers\s*=\s*\[(.*?)\]", project, re.M | re.S) + if m: + classifiers = re.findall(r'["\']([^"\']+)["\']', m.group(1)) + + return { + "name": name, + "version": version, + "license": license_value, + "classifiers": classifiers, + } + + +def read_source_metadata(pyproject_path: Path | str = DEFAULT_PYPROJECT) -> dict: + """Read name/version/license/license-classifiers out of a pyproject.toml. + + Uses `tomllib` where it exists (Python >= 3.11) and the scoped regex reader on 3.10, so the + gate behaves identically across the whole CI matrix without adding a dependency. + """ + text = Path(pyproject_path).read_text(encoding="utf-8") + try: + import tomllib + + project = tomllib.loads(text).get("project", {}) + raw_license = project.get("license") + if isinstance(raw_license, dict): + raw_license = raw_license.get("text") + parsed = { + "name": project.get("name"), + "version": project.get("version"), + "license": raw_license, + "classifiers": list(project.get("classifiers", [])), + } + except ModuleNotFoundError: # pragma: no cover - only taken on Python 3.10 + parsed = _parse_pyproject_with_regex(text) + + license_classifiers = [c for c in parsed["classifiers"] if c.startswith("License ::")] + return { + "name": parsed["name"], + "version": parsed["version"], + "license": parsed["license"], + "license_classifiers": license_classifiers, + "declared_spdx": normalize_license(parsed["license"]), + "classifier_spdx": [normalize_license(c) for c in license_classifiers], + } + + +# --------------------------------------------------------------------------- +# The check +# --------------------------------------------------------------------------- + + +def published_spdx(entry: dict) -> str: + """The license a published release actually declares, from whichever field carries it.""" + for key in ("license_expression", "license"): + spdx = normalize_license(entry.get(key)) + if spdx != "UNKNOWN": + return spdx + for classifier in entry.get("license_classifiers", []): + spdx = normalize_license(classifier) + if spdx != "UNKNOWN": + return spdx + return "UNKNOWN" + + +def check_release_readiness(source: dict, snapshot: dict) -> list[dict]: + """Compare source metadata against a registry snapshot. + + Returns a list of violations, each a dict with `code`, `blocking` and `message`. Blocking + violations are the ones a release must not proceed through; non-blocking ones are immutable + history that is reported so it stays visible. + """ + violations: list[dict] = [] + source_version = source.get("version") + source_spdx = source.get("declared_spdx", "UNKNOWN") + releases = list(snapshot.get("releases", [])) + per_version = snapshot.get("per_version", {}) + name = snapshot.get("name") or source.get("name") + + # 0. The source must agree with itself before it is worth comparing to anything else. + for classifier_spdx in source.get("classifier_spdx", []): + if classifier_spdx != source_spdx: + violations.append( + { + "code": "source-classifier-mismatch", + "blocking": True, + "message": ( + f"pyproject declares license {source_spdx!r} but carries a " + f"{classifier_spdx!r} trove classifier — the wheel would ship both" + ), + } + ) + + # 1. The immutability rule. A version already on the index can never be replaced. + if source_version in releases: + published = per_version.get(source_version, {}) + published_license = published_spdx(published) + violations.append( + { + "code": "version-already-published", + "blocking": True, + "message": ( + f"{name} {source_version} is already on the index (published as " + f"{published_license}); PyPI releases are immutable, so this build can " + f"never be uploaded — bump the version" + ), + } + ) + if published_license != source_spdx and "UNKNOWN" not in (published_license, source_spdx): + violations.append( + { + "code": "same-version-license-drift", + "blocking": True, + "message": ( + f"{name} {source_version} is published as {published_license} but the " + f"source declares {source_spdx} at that SAME version string — the " + f"license correction cannot reach a user without a version bump" + ), + } + ) + + # 2. Older releases that disagree. Immutable, so reported rather than failed. + source_tuple = parse_version(source_version or "0") + for version in releases: + if version == source_version: + continue + if parse_version(version) >= source_tuple: + continue + published_license = published_spdx(per_version.get(version, {})) + if published_license != source_spdx and "UNKNOWN" not in (published_license, source_spdx): + violations.append( + { + "code": "historical-license-drift", + "blocking": False, + "message": ( + f"{name} {version} is published as {published_license} while the source " + f"now declares {source_spdx}; that release is immutable and stays as " + f"published" + ), + } + ) + + return violations + + +# --------------------------------------------------------------------------- +# Snapshot I/O +# --------------------------------------------------------------------------- + + +def load_snapshot(path: Path | str = DEFAULT_SNAPSHOT) -> dict: + return json.loads(Path(path).read_text(encoding="utf-8")) + + +def fetch_snapshot(name: str) -> dict: + """Read-only, unauthenticated GET of a package's public PyPI metadata. + + Only used behind `--refresh`. Never called from the test suite, so CI stays deterministic + and offline. + """ + import urllib.request + + url = PYPI_JSON_URL.format(name=name) + with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310 - literal https URL + payload = json.loads(response.read().decode("utf-8")) + + releases = sorted(payload.get("releases", {}), key=parse_version) + per_version: dict[str, dict] = {} + for version in releases: + with urllib.request.urlopen( # noqa: S310 - literal https URL + f"https://pypi.org/pypi/{name}/{version}/json", timeout=30 + ) as response: + info = json.loads(response.read().decode("utf-8"))["info"] + per_version[version] = { + "license": info.get("license"), + "license_expression": info.get("license_expression"), + "license_classifiers": [ + c for c in info.get("classifiers", []) if c.startswith("License ::") + ], + "summary": info.get("summary"), + "repository": (info.get("project_urls") or {}).get("Repository"), + } + + return { + "name": payload["info"]["name"], + "source": url, + "latest_version": payload["info"]["version"], + "releases": releases, + "per_version": per_version, + } + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[1]) + parser.add_argument("--pyproject", default=str(DEFAULT_PYPROJECT)) + parser.add_argument("--snapshot", default=str(DEFAULT_SNAPSHOT)) + parser.add_argument( + "--refresh", + action="store_true", + help="re-fetch the registry snapshot from pypi.org before checking, and rewrite it", + ) + parser.add_argument("--json", action="store_true", help="emit machine-readable output") + args = parser.parse_args(argv) + + source = read_source_metadata(args.pyproject) + + if args.refresh: + snapshot = fetch_snapshot(source["name"]) + snapshot["captured_utc"] = datetime.datetime.now(datetime.timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + Path(args.snapshot).write_text( + json.dumps(snapshot, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + else: + snapshot = load_snapshot(args.snapshot) + + violations = check_release_readiness(source, snapshot) + blocking = [v for v in violations if v["blocking"]] + + if args.json: + print( + json.dumps( + { + "source": source, + "snapshot_captured_utc": snapshot.get("captured_utc"), + "violations": violations, + "ok": not blocking, + }, + indent=2, + sort_keys=True, + ) + ) + else: + label = f"{source['name']} {source['version']} declares {source['declared_spdx']}" + published = ", ".join( + f"{v}={published_spdx(snapshot.get('per_version', {}).get(v, {}))}" + for v in snapshot.get("releases", []) + ) + print(f"source: {label}") + print(f"published: {published or '(nothing on the index yet)'}") + if not violations: + print("OK — the source license agrees with every published artifact.") + for violation in violations: + mark = "FAIL" if violation["blocking"] else "note" + print(f" [{mark}] {violation['code']}: {violation['message']}") + if blocking: + print(f"\n{len(blocking)} blocking license/version violation(s).") + + return 1 if blocking else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sdk-python/tests/fixtures/pypi_wave_av_sdk.json b/sdk-python/tests/fixtures/pypi_wave_av_sdk.json new file mode 100644 index 0000000..f09a314 --- /dev/null +++ b/sdk-python/tests/fixtures/pypi_wave_av_sdk.json @@ -0,0 +1,20 @@ +{ + "captured_utc": "2026-09-04T02:00:00Z", + "latest_version": "2.0.0", + "name": "wave-av-sdk", + "per_version": { + "2.0.0": { + "license": "MIT", + "license_classifiers": [ + "License :: OSI Approved :: MIT License" + ], + "license_expression": null, + "repository": "https://github.com/wave-av/sdks", + "summary": "Official WAVE SDK for Python - 33 API modules for streaming, production, analytics, and more" + } + }, + "releases": [ + "2.0.0" + ], + "source": "https://pypi.org/pypi/wave-av-sdk/json" +} diff --git a/sdk-python/tests/test_registry_license_truth.py b/sdk-python/tests/test_registry_license_truth.py new file mode 100644 index 0000000..0d5b26b --- /dev/null +++ b/sdk-python/tests/test_registry_license_truth.py @@ -0,0 +1,245 @@ +""" +Registry license-truth tests. + +These guard the one license question no in-repo gate can answer: does the artifact that is +actually on PyPI carry the license this source tree declares? + +The defect they were written against, reproducible from the public index on 2026-09-03: + + $ curl -s https://pypi.org/pypi/wave-av-sdk/2.0.0/json | jq -r '.info.license' + MIT + $ grep '^license' sdk-python/pyproject.toml + license = {text = "Apache-2.0"} + +`wave-av-sdk 2.0.0` was published as MIT; the source declared Apache-2.0 at that same version +string. PyPI releases are immutable, so the correction could never reach a user and the next +tag push would have died on `400 File already exists`. The fix is a version bump; these tests +are what stop the collision from coming back. + +Offline by construction: the registry snapshot is a checked-in fixture captured from pypi.org, +so CI needs no network and cannot go red because an index is having a bad afternoon. +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +PACKAGE_ROOT = Path(__file__).resolve().parent.parent +SCRIPT = PACKAGE_ROOT / "scripts" / "registry_license_truth.py" +FIXTURE = PACKAGE_ROOT / "tests" / "fixtures" / "pypi_wave_av_sdk.json" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("registry_license_truth", SCRIPT) + module = importlib.util.module_from_spec(spec) + sys.modules["registry_license_truth"] = module + spec.loader.exec_module(module) + return module + + +rlt = _load_module() + + +@pytest.fixture +def snapshot() -> dict: + return json.loads(FIXTURE.read_text(encoding="utf-8")) + + +# --------------------------------------------------------------------------- +# The real defect +# --------------------------------------------------------------------------- + + +def test_the_2_0_0_collision_is_blocking(snapshot): + """Source at 2.0.0 against the real snapshot must fail, and say why, twice. + + This is the load-bearing test. If it ever stops producing both violations, the gate has + stopped seeing the defect that shipped. + """ + source = { + "name": "wave-av-sdk", + "version": "2.0.0", + "declared_spdx": "Apache-2.0", + "classifier_spdx": ["Apache-2.0"], + } + violations = rlt.check_release_readiness(source, snapshot) + codes = {v["code"] for v in violations if v["blocking"]} + + assert "version-already-published" in codes, ( + "2.0.0 is on the index; a build of 2.0.0 can never be uploaded" + ) + assert "same-version-license-drift" in codes, ( + "2.0.0 is published as MIT while the source declares Apache-2.0 at the same version" + ) + drift = next(v for v in violations if v["code"] == "same-version-license-drift") + assert "MIT" in drift["message"] and "Apache-2.0" in drift["message"] + + +def test_bumping_the_version_clears_the_block(snapshot): + """2.0.1 is uploadable, and the 2.0.0 drift survives as immutable history.""" + source = { + "name": "wave-av-sdk", + "version": "2.0.1", + "declared_spdx": "Apache-2.0", + "classifier_spdx": ["Apache-2.0"], + } + violations = rlt.check_release_readiness(source, snapshot) + + assert [v for v in violations if v["blocking"]] == [], ( + "nothing blocks a release at a version the index does not have" + ) + historical = [v for v in violations if v["code"] == "historical-license-drift"] + assert len(historical) == 1, "the published 2.0.0 MIT release must stay visible" + assert historical[0]["blocking"] is False, ( + "an immutable past release must not fail the gate forever" + ) + + +def test_a_self_contradicting_source_is_blocking(snapshot): + """A pyproject whose classifier disagrees with its own license field never ships.""" + source = { + "name": "wave-av-sdk", + "version": "9.9.9", + "declared_spdx": "Apache-2.0", + "classifier_spdx": ["MIT"], + } + violations = rlt.check_release_readiness(source, snapshot) + assert any( + v["code"] == "source-classifier-mismatch" and v["blocking"] for v in violations + ), "declaring Apache-2.0 beside an MIT trove classifier must fail" + + +# --------------------------------------------------------------------------- +# The live tree — this is the gate +# --------------------------------------------------------------------------- + + +def test_this_package_can_actually_be_published(): + """The real pyproject against the real snapshot: no blocking violation. + + This is the assertion that would have caught the release failure before the tag was pushed. + """ + source = rlt.read_source_metadata(PACKAGE_ROOT / "pyproject.toml") + snapshot = json.loads(FIXTURE.read_text(encoding="utf-8")) + blocking = [v for v in rlt.check_release_readiness(source, snapshot) if v["blocking"]] + assert blocking == [], "\n".join(f"{v['code']}: {v['message']}" for v in blocking) + + +def test_source_metadata_reads_the_real_pyproject(): + source = rlt.read_source_metadata(PACKAGE_ROOT / "pyproject.toml") + assert source["name"] == "wave-av-sdk" + assert source["declared_spdx"] == "Apache-2.0" + assert source["classifier_spdx"] == ["Apache-2.0"], ( + "exactly one license classifier, and it must agree with the license field" + ) + + +# --------------------------------------------------------------------------- +# Parsing +# --------------------------------------------------------------------------- + + +def test_regex_fallback_matches_tomllib(): + """The 3.10 path must read the same values as the 3.11+ path. + + CI runs a 3.10 leg where `tomllib` does not exist. A fallback that disagreed with tomllib + would make the gate's verdict depend on the interpreter. + """ + tomllib = pytest.importorskip("tomllib", reason="reference parser needs Python >= 3.11") + text = (PACKAGE_ROOT / "pyproject.toml").read_text(encoding="utf-8") + + reference = tomllib.loads(text)["project"] + fallback = rlt._parse_pyproject_with_regex(text) + + assert fallback["name"] == reference["name"] + assert fallback["version"] == reference["version"] + assert fallback["license"] == reference["license"]["text"] + assert fallback["classifiers"] == reference["classifiers"] + + +def test_regex_fallback_ignores_tool_tables(): + """`[tool.*]` sections must not leak into the `[project]` read.""" + text = ( + '[project]\n' + 'name = "pkg"\n' + 'version = "1.2.3"\n' + 'license = {text = "Apache-2.0"}\n' + 'classifiers = ["License :: OSI Approved :: Apache Software License"]\n' + '\n[tool.black]\n' + 'name = "not-the-project-name"\n' + 'version = "9.9.9"\n' + ) + parsed = rlt._parse_pyproject_with_regex(text) + assert parsed["name"] == "pkg" + assert parsed["version"] == "1.2.3" + assert parsed["license"] == "Apache-2.0" + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("MIT", "MIT"), + ("Apache-2.0", "Apache-2.0"), + ("Apache Software License", "Apache-2.0"), + ("License :: OSI Approved :: MIT License", "MIT"), + ("License :: OSI Approved :: Apache Software License", "Apache-2.0"), + ("", "UNKNOWN"), + (None, "UNKNOWN"), + ("Proprietary — all rights reserved", "UNKNOWN"), + ], +) +def test_normalize_license(raw, expected): + assert rlt.normalize_license(raw) == expected + + +def test_unknown_licenses_never_claim_agreement(snapshot): + """An unreadable license must not be reported as matching — silence is not agreement.""" + source = { + "name": "wave-av-sdk", + "version": "3.0.0", + "declared_spdx": "UNKNOWN", + "classifier_spdx": [], + } + violations = rlt.check_release_readiness(source, snapshot) + assert not any(v["code"] == "historical-license-drift" for v in violations), ( + "UNKNOWN must not be compared as if it were a real license" + ) + + +def test_published_spdx_prefers_expression_then_field_then_classifier(): + assert rlt.published_spdx({"license_expression": "Apache-2.0", "license": "MIT"}) == "Apache-2.0" + assert rlt.published_spdx({"license_expression": None, "license": "MIT"}) == "MIT" + assert ( + rlt.published_spdx( + {"license_classifiers": ["License :: OSI Approved :: Apache Software License"]} + ) + == "Apache-2.0" + ) + assert rlt.published_spdx({}) == "UNKNOWN" + + +@pytest.mark.parametrize( + "left,right", + [("2.0.0", "2.0.1"), ("2.0.9", "2.1.0"), ("1.9.9", "2.0.0")], +) +def test_parse_version_orders_releases(left, right): + assert rlt.parse_version(left) < rlt.parse_version(right) + + +# --------------------------------------------------------------------------- +# Fixture integrity +# --------------------------------------------------------------------------- + + +def test_snapshot_records_the_published_mit_release(snapshot): + """Guard the evidence itself: this fixture is the receipt for the defect.""" + assert snapshot["name"] == "wave-av-sdk" + assert "2.0.0" in snapshot["releases"] + assert snapshot["per_version"]["2.0.0"]["license"] == "MIT", ( + "captured from pypi.org — if this ever changes, PyPI stopped being immutable" + )