diff --git a/.env.example b/.env.example index 607beec..4892be0 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,10 @@ # Copy this file to .env and add your actual values # Your OilPriceAPI key (required) -# Get your free API key at: https://oilpriceapi.com +# Get your API key at: https://oilpriceapi.com OILPRICEAPI_KEY=your_api_key_here # API Base URL (optional) # Default: https://api.oilpriceapi.com # For local development: http://localhost:5000 -OILPRICEAPI_BASE_URL=https://api.oilpriceapi.com \ No newline at end of file +OILPRICEAPI_BASE_URL=https://api.oilpriceapi.com diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d2e25ff..e687ee8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,7 +30,7 @@ jobs: - name: Install verification dependencies run: | python -m pip install --upgrade pip - python -m pip install -e '.[dev]' pip-audit build 'jsonschema>=4.17,<4.24' + python -m pip install -e '.[dev]' pip-audit 'build==1.5.0' 'jsonschema>=4.17,<4.24' - name: Verify release tag matches package version and protected main env: @@ -69,6 +69,17 @@ jobs: - name: Build package run: python -m build + - name: Validate exact built source distribution + run: | + set -euo pipefail + PACKAGE_VERSION="$(python scripts/package_version.py)" + SDIST="dist/oilpriceapi-${PACKAGE_VERSION}.tar.gz" + if [ ! -f "$SDIST" ]; then + echo "::error::Exact source distribution not found: $SDIST" + exit 1 + fi + python scripts/validate_storefront_claims.py --sdist "$SDIST" + - name: Install and import the exact built wheel run: ./scripts/clean-wheel-smoke.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f90020a..7c4d148 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,6 +43,20 @@ jobs: - name: Validate public storefront claims run: python scripts/validate_storefront_claims.py + - name: Build and validate exact source distribution + if: matrix.python-version == '3.12' + run: | + set -euo pipefail + python -m pip install 'build==1.5.0' + python -m build --sdist + PACKAGE_VERSION="$(python scripts/package_version.py)" + SDIST="dist/oilpriceapi-${PACKAGE_VERSION}.tar.gz" + if [ ! -f "$SDIST" ]; then + echo "::error::Exact source distribution not found: $SDIST" + exit 1 + fi + python scripts/validate_storefront_claims.py --sdist "$SDIST" + - name: Run unit tests run: pytest tests/ --ignore=tests/integration --ignore=tests/contract -m 'not slow' --cov=oilpriceapi --cov-report=xml -v diff --git a/CHANGELOG.md b/CHANGELOG.md index 69f4fb8..db8764d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to the OilPriceAPI Python SDK will be documented in this file. +## [1.12.8] - 2026-08-12 + +### Fixed + +- Validate every customer-readable member in the exact built source + distribution, including root release/configuration files and future nested + package data, while explicitly excluding intentional test/tooling fixtures. +- Remove the unsupported universal-entitlement wording from the packaged + environment example and reject never-existent promises that attribution + headers change entitlements in authored and distributed release notes. + ## [1.12.7] - 2026-08-12 ### Fixed diff --git a/oilpriceapi/version.py b/oilpriceapi/version.py index 3daaed7..3e3d79c 100644 --- a/oilpriceapi/version.py +++ b/oilpriceapi/version.py @@ -5,6 +5,6 @@ Used in __init__.py, client.py, and async_client.py. """ -__version__ = "1.12.7" +__version__ = "1.12.8" SDK_VERSION = __version__ SDK_NAME = "oilpriceapi-python" diff --git a/pyproject.toml b/pyproject.toml index f823ff5..5e2b118 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "oilpriceapi" -version = "1.12.7" +version = "1.12.8" description = "Official Python SDK for source-timestamped OilPriceAPI energy data" authors = [ {name = "OilPriceAPI", email = "support@oilpriceapi.com"} diff --git a/scripts/validate_storefront_claims.py b/scripts/validate_storefront_claims.py index 8c2025f..7951ebd 100644 --- a/scripts/validate_storefront_claims.py +++ b/scripts/validate_storefront_claims.py @@ -4,8 +4,9 @@ import argparse import csv import re -from pathlib import Path -from typing import Iterable, Iterator, List, Match, Pattern, Sequence, Set, Tuple +import tarfile +from pathlib import Path, PurePosixPath +from typing import Dict, Iterable, Iterator, List, Match, Pattern, Sequence, Set, Tuple ROOT = Path(__file__).resolve().parents[1] CONTRACT = "https://api.oilpriceapi.com/product-facts.json" @@ -19,7 +20,19 @@ ".pyc", ".pyo", ".so", + ".wasm", } +SDIST_DEVELOPMENT_ROOTS = {".github", ".pytest_cache", ".tox", "scripts", "test", "tests"} +ACTIVE_ROOT_SURFACES = ( + ".env.example", + "CONTRIBUTING.md", + "EXAMPLES.md", + "MANIFEST.in", + "README.md", + "SECURITY.md", + "pyproject.toml", +) +MAX_SDIST_TEXT_BYTES = 5_000_000 _RATE_COUNT = r"\d[\d,]*" _RATE_ACTION = r"(?:(?:api[- ]+)?(?:requests?|calls?|queries?|hits?|credits?)|reqs?\.?)" _RATE_UNIT_SINGULAR = r"(?:second|sec|minute|min|hour|hr|day|week|month|year)" @@ -141,7 +154,7 @@ def discover_public_surfaces(root: Path = ROOT) -> List[Path]: - surfaces = [root / "README.md", root / "EXAMPLES.md", root / "pyproject.toml"] + surfaces = [root / name for name in ACTIVE_ROOT_SURFACES if (root / name).is_file()] for directory in (root / "docs", root / "oilpriceapi"): surfaces.extend(path for path in directory.rglob("*") if _is_public_text(path)) return sorted(set(surfaces)) @@ -309,29 +322,170 @@ def _telemetry_reward_claims(text: str) -> List[str]: return claims +def _text_claim_failures(surface: str, text: str) -> List[str]: + failures: List[str] = [] + for label, pattern in BLOCKED: + for match in pattern.finditer(text): + failures.append(f"{surface}: {label} matched {match.group(0)!r}") + for claim in _fixed_rate_claims(text): + failures.append(f"{surface}: fixed demo rate matched {claim!r}") + failures.extend(_telemetry_claim_failures(surface, text)) + return failures + + +def _telemetry_claim_failures(surface: str, text: str) -> List[str]: + return [ + f"{surface}: telemetry quota reward matched {claim!r}" + for claim in _telemetry_reward_claims(text) + ] + + def _claim_failures(root: Path, surfaces: Iterable[Path]) -> List[str]: failures: List[str] = [] for path in surfaces: - text = path.read_text(encoding="utf-8") - for label, pattern in BLOCKED: - for match in pattern.finditer(text): + failures.extend( + _text_claim_failures( + path.relative_to(root).as_posix(), + path.read_text(encoding="utf-8"), + ) + ) + return failures + + +def _safe_sdist_member_path(name: str) -> PurePosixPath: + path = PurePosixPath(name) + if path.is_absolute() or ".." in path.parts or "\\" in name: + raise ValueError(f"unsafe source-distribution member path: {name!r}") + if not path.parts: + raise ValueError(f"source-distribution member has an empty path: {name!r}") + return path + + +def validate_sdist(sdist: Path) -> List[str]: + """Validate every customer-readable surface in the exact built sdist.""" + sdist = sdist.resolve() + archive_suffix = ".tar.gz" + if not sdist.name.endswith(archive_suffix): + return ["source distribution filename must end in .tar.gz"] + failures: List[str] = [] + text_members: Dict[str, str] = {} + package_roots: Set[str] = set() + seen_members: Set[str] = set() + + try: + archive = tarfile.open(sdist, mode="r:gz") + except (OSError, tarfile.TarError) as error: + return [f"source distribution could not be opened: {error}"] + + with archive: + for member in archive.getmembers(): + try: + path = _safe_sdist_member_path(member.name) + except ValueError as error: + failures.append(str(error)) + continue + + normalized = path.as_posix() + if normalized in seen_members: + failures.append(f"source distribution contains duplicate member: {normalized}") + continue + seen_members.add(normalized) + package_roots.add(path.parts[0]) + + if member.issym() or member.islnk(): + failures.append(f"source distribution contains a link: {normalized}") + continue + if member.isdir(): + continue + if len(path.parts) < 2: failures.append( - f"{path.relative_to(root)}: {label} matched {match.group(0)!r}" + f"source-distribution member is outside its package root: {normalized!r}" ) - for claim in _fixed_rate_claims(text): - failures.append( - f"{path.relative_to(root)}: fixed demo rate matched {claim!r}" - ) - for claim in _telemetry_reward_claims(text): - failures.append( - f"{path.relative_to(root)}: telemetry quota reward matched {claim!r}" - ) + continue + if not member.isfile(): + failures.append(f"source distribution contains a special member: {normalized}") + continue + + relative = PurePosixPath(*path.parts[1:]) + if ( + relative.parts[0] in SDIST_DEVELOPMENT_ROOTS + or "__pycache__" in relative.parts + ): + continue + if relative.suffix.lower() in BINARY_SUFFIXES: + continue + if member.size > MAX_SDIST_TEXT_BYTES: + failures.append(f"source distribution text candidate is too large: {relative}") + continue + + extracted = archive.extractfile(member) + if extracted is None: + failures.append(f"source distribution member could not be read: {relative}") + continue + contents = extracted.read(MAX_SDIST_TEXT_BYTES + 1) + if len(contents) != member.size: + failures.append(f"source distribution member size changed while reading: {relative}") + continue + if b"\x00" in contents: + continue + try: + text_members[relative.as_posix()] = contents.decode("utf-8") + except UnicodeDecodeError: + continue + + expected_root = sdist.name[: -len(archive_suffix)] + if package_roots != {expected_root}: + failures.append( + "source distribution package root differs from its filename: " + f"expected {expected_root!r}, found {sorted(package_roots)!r}" + ) + + for surface, text in sorted(text_members.items()): + if surface == "CHANGELOG.md": + # Historical release notes can truthfully describe retired plans. + # A telemetry quota reward never existed and is forbidden in history too. + failures.extend(_telemetry_claim_failures(surface, text)) + else: + failures.extend(_text_claim_failures(surface, text)) + + metadata = text_members.get("PKG-INFO") + version_source = text_members.get("oilpriceapi/version.py") + if metadata is None: + failures.append("source distribution must contain readable PKG-INFO") + elif CONTRACT not in metadata: + failures.append("source distribution PKG-INFO: reviewed product-facts contract is not linked") + if version_source is None: + failures.append("source distribution must contain readable oilpriceapi/version.py") + if metadata is not None and version_source is not None: + metadata_match = re.search(r"^Version: ([^\s]+)$", metadata, re.MULTILINE) + module_match = re.search(r'^__version__ = "([^"]+)"', version_source, re.MULTILINE) + package_prefix = "oilpriceapi-" + expected_version = ( + expected_root[len(package_prefix) :] + if expected_root.startswith(package_prefix) + else "" + ) + versions = { + metadata_match.group(1) if metadata_match else None, + module_match.group(1) if module_match else None, + expected_version, + } + if None in versions or len(versions) != 1: + failures.append("source distribution filename, metadata, and module versions differ") return failures def validate(root: Path = ROOT) -> List[str]: failures = _claim_failures(root, discover_public_surfaces(root)) + changelog = root / "CHANGELOG.md" + if changelog.is_file(): + failures.extend( + _telemetry_claim_failures( + "CHANGELOG.md", changelog.read_text(encoding="utf-8") + ) + ) + readme = (root / "README.md").read_text() if CONTRACT not in readme: failures.append("README.md: reviewed product-facts contract is not linked") @@ -374,14 +528,23 @@ def validate_package(package_root: Path) -> List[str]: def main() -> None: parser = argparse.ArgumentParser() - parser.add_argument("--package-root", type=Path) + inputs = parser.add_mutually_exclusive_group() + inputs.add_argument("--package-root", type=Path) + inputs.add_argument("--sdist", type=Path) args = parser.parse_args() - failures = validate_package(args.package_root) if args.package_root else validate() + if args.package_root: + failures = validate_package(args.package_root) + elif args.sdist: + failures = validate_sdist(args.sdist) + else: + failures = validate() if failures: raise SystemExit("\n".join(failures)) if args.package_root: print("validated exact installed Python artifact claims") + elif args.sdist: + print("validated exact Python sdist claims") else: print(f"validated {len(discover_public_surfaces())} public surfaces") diff --git a/tests/test_release_readiness.py b/tests/test_release_readiness.py index 9139c94..dd12ef9 100644 --- a/tests/test_release_readiness.py +++ b/tests/test_release_readiness.py @@ -106,6 +106,7 @@ def test_packaged_futures_examples_prefer_instrument_generic_slugs() -> None: def test_publish_gate_audits_and_installs_the_built_wheel() -> None: workflow = (ROOT / ".github" / "workflows" / "publish.yml").read_text() + test_workflow = (ROOT / ".github" / "workflows" / "test.yml").read_text() smoke = (ROOT / "scripts" / "clean-wheel-smoke.sh").read_text() assert "pip-audit" in workflow @@ -115,6 +116,16 @@ def test_publish_gate_audits_and_installs_the_built_wheel() -> None: assert "--package-root" in smoke assert 'oilpriceapi-${expected_version}-py3-none-any.whl' in smoke assert "-name '*.whl' -print -quit" not in smoke + assert "--sdist" in workflow + assert 'oilpriceapi-${PACKAGE_VERSION}.tar.gz' in workflow + assert workflow.index("Build package") < workflow.index( + "Validate exact built source distribution" + ) < workflow.index("Prepare checksummed release artifact") + assert "Build and validate exact source distribution" in test_workflow + assert "python scripts/validate_storefront_claims.py --sdist" in test_workflow + assert "matrix.python-version == '3.12'" in test_workflow + assert "'build==1.5.0'" in workflow + assert "'build==1.5.0'" in test_workflow def test_oidc_publisher_consumes_only_the_verified_artifact() -> None: @@ -143,7 +154,7 @@ def test_oidc_publisher_consumes_only_the_verified_artifact() -> None: assert action_refs assert all(re.fullmatch(r"[0-9a-f]{40}", ref) for ref in action_refs) assert "Verify exact public PyPI hashes" in workflow - assert workflow.count("python scripts/package_version.py") == 2 + assert workflow.count("python scripts/package_version.py") == 3 assert "seq 1 24" in workflow assert "sleep_seconds" in workflow @@ -159,7 +170,7 @@ def test_package_version_helper_reads_the_project_version() -> None: capture_output=True, text=True, ) - assert result.stdout.strip() == "1.12.7" + assert result.stdout.strip() == "1.12.8" def test_every_workflow_pins_actions_and_hardens_each_checkout_step() -> None: diff --git a/tests/test_storefront_claims.py b/tests/test_storefront_claims.py index d2fda17..3ca043b 100644 --- a/tests/test_storefront_claims.py +++ b/tests/test_storefront_claims.py @@ -1,9 +1,14 @@ +import io +import subprocess +import sys +import tarfile from pathlib import Path -from typing import List +from typing import Dict, List import pytest from scripts.validate_storefront_claims import ( + MAX_SDIST_TEXT_BYTES, discover_installed_surfaces, discover_public_surfaces, validate, @@ -13,6 +18,48 @@ ROOT = Path(__file__).resolve().parents[1] +def _write_sdist(tmp_path: Path, overrides: Dict[str, bytes]) -> Path: + source = tmp_path / "source" / "oilpriceapi-9.9.9" + files = { + "README.md": ( + "https://api.oilpriceapi.com/product-facts.json\n" + ).encode(), + "CHANGELOG.md": b"Reviewed historical release notes.\n", + "PKG-INFO": ( + "Metadata-Version: 2.1\n" + "Name: oilpriceapi\n" + "Version: 9.9.9\n\n" + "https://api.oilpriceapi.com/product-facts.json\n" + ).encode(), + "oilpriceapi/version.py": b'__version__ = "9.9.9"\n', + } + files.update(overrides) + for relative, content in files.items(): + path = source / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + + archive = tmp_path / "oilpriceapi-9.9.9.tar.gz" + with tarfile.open(archive, "w:gz") as package: + package.add(source, arcname=source.name) + return archive + + +def _validate_sdist(archive: Path) -> subprocess.CompletedProcess: + return subprocess.run( + [ + sys.executable, + str(ROOT / "scripts" / "validate_storefront_claims.py"), + "--sdist", + str(archive), + ], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + + def _installed_text_failures(tmp_path: Path, text: str) -> List[str]: package = tmp_path / "oilpriceapi" dist_info = tmp_path / "oilpriceapi-9.9.9.dist-info" @@ -64,6 +111,24 @@ def test_discovers_docs_examples_and_nested_package_source() -> None: assert "docs/index.md" in surfaces assert "docs/index.html" in surfaces assert "oilpriceapi/streaming/client.py" in surfaces + assert ".env.example" in surfaces + assert "CONTRIBUTING.md" in surfaces + assert "SECURITY.md" in surfaces + + +def test_rejects_active_claim_in_manifest_published_root_surface( + tmp_path: Path, +) -> None: + _authored_text_failures(tmp_path, "Reviewed package source.\n") + (tmp_path / ".env.example").write_text( + "Add optional telemetry headers (10% bonus for app_url!).\n" + ) + + failures = validate(tmp_path) + + assert any( + ".env.example: telemetry quota reward" in failure for failure in failures + ), failures def test_rejects_claim_introduced_only_in_installed_wheel(tmp_path: Path) -> None: @@ -160,6 +225,133 @@ def test_rejects_claim_in_future_installed_package_data(tmp_path: Path) -> None: ) +def test_sdist_guard_rejects_never_existent_reward_in_changelog( + tmp_path: Path, +) -> None: + archive = _write_sdist( + tmp_path, + { + "CHANGELOG.md": ( + b"Add optional telemetry headers (10% bonus for app_url!).\n" + ) + }, + ) + + result = _validate_sdist(archive) + + assert result.returncode != 0 + assert "CHANGELOG.md: telemetry quota reward" in result.stderr + + +def test_authored_guard_rejects_never_existent_reward_in_changelog( + tmp_path: Path, +) -> None: + _authored_text_failures(tmp_path, "Reviewed package source.\n") + (tmp_path / "CHANGELOG.md").write_text( + "Add optional telemetry headers (10% bonus for app_url!).\n" + ) + + failures = validate(tmp_path) + + assert any( + "CHANGELOG.md: telemetry quota reward" in failure for failure in failures + ), failures + + +def test_sdist_guard_recursively_rejects_future_customer_package_data( + tmp_path: Path, +) -> None: + archive = _write_sdist( + tmp_path, + { + "oilpriceapi/future/guides/claim.txt": ( + b"Application metadata unlocks additional API calls.\n" + ), + "oilpriceapi/scripts/claim.md": ( + b"X-App-URL earns extra request credits.\n" + ), + }, + ) + + result = _validate_sdist(archive) + + assert result.returncode != 0 + assert "oilpriceapi/future/guides/claim.txt: telemetry quota reward" in result.stderr + assert "oilpriceapi/scripts/claim.md: telemetry quota reward" in result.stderr + + +def test_sdist_guard_excludes_test_fixtures_and_binary_data(tmp_path: Path) -> None: + stale_claim = b"Add optional telemetry headers (10% bonus for app_url!).\n" + archive = _write_sdist( + tmp_path, + { + "tests/test_claim_fixtures.py": stale_claim, + "scripts/claim_fixture.py": stale_claim, + "oilpriceapi/future/fixture.wasm": b"\x00asm\xff\x00", + "oilpriceapi/future/public.txt": b"Optional usage-attribution metadata.\n", + }, + ) + + result = _validate_sdist(archive) + + assert result.returncode == 0, result.stderr + assert "validated exact Python sdist claims" in result.stdout + + +def test_sdist_guard_excludes_large_known_binary_package_data(tmp_path: Path) -> None: + archive = _write_sdist( + tmp_path, + { + "oilpriceapi/future/runtime.wasm": ( + b"\x00asm" + b"\xff" * (MAX_SDIST_TEXT_BYTES + 1) + ) + }, + ) + + result = _validate_sdist(archive) + + assert result.returncode == 0, result.stderr + assert "validated exact Python sdist claims" in result.stdout + + +def test_sdist_guard_rejects_version_drift(tmp_path: Path) -> None: + archive = _write_sdist( + tmp_path, + {"oilpriceapi/version.py": b'__version__ = "9.9.8"\n'}, + ) + + result = _validate_sdist(archive) + + assert result.returncode != 0 + assert "filename, metadata, and module versions differ" in result.stderr + + +def test_sdist_guard_rejects_links_duplicates_and_traversal(tmp_path: Path) -> None: + _write_sdist(tmp_path, {}) + source = tmp_path / "source" / "oilpriceapi-9.9.9" + unsafe = tmp_path / "oilpriceapi-9.9.9-unsafe.tar.gz" + with tarfile.open(unsafe, "w:gz") as package: + package.add(source, arcname="oilpriceapi-9.9.9") + package.add( + source / "README.md", + arcname="oilpriceapi-9.9.9/README.md", + ) + link = tarfile.TarInfo("oilpriceapi-9.9.9/oilpriceapi/linked.py") + link.type = tarfile.SYMTYPE + link.linkname = "version.py" + package.addfile(link) + traversal = tarfile.TarInfo("oilpriceapi-9.9.9/../outside.txt") + traversal.size = 4 + package.addfile(traversal, io.BytesIO(b"text")) + + result = _validate_sdist(unsafe) + + assert result.returncode != 0 + assert "duplicate member" in result.stderr + assert "contains a link" in result.stderr + assert "unsafe source-distribution member path" in result.stderr + + def test_rejects_telemetry_quota_reward_in_future_nested_authored_source( tmp_path: Path, ) -> None: