diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81049b2..192b4d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e232ae..428c44b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to pytest-gpu-proof are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/) (pre-1.0: minor bumps may break). +## [0.5.0] — 2026-10-28 + +### Added + +- Python 3.10 support with a conditional `tomli` dependency and a CI test lane. +- Signed `environment.nvcc_version` records the stripped output of + `nvcc --version` from PATH, or null when unavailable. Fresh shard merges + require matching compiler metadata; existing receipts without the field + remain supported. + ## [0.4.1] — 2026-10-12 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d04dd43..6146764 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ python -m build python -m twine check --strict dist/* ``` -CI runs the test suite on Python 3.11, 3.12, and 3.13, independently enforces +CI runs the test suite on Python 3.10, 3.11, 3.12, and 3.13, independently enforces 100% line and branch coverage, builds docs strictly, and smoke-tests the wheel. ## Change expectations diff --git a/README.md b/README.md index 9007f63..3e71426 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ cd pytest-gpu-proof python -m pip install -e ".[dev]" ``` -Requirements: Python 3.11+, pytest 7+, and `cryptography` 41+. +Requirements: Python 3.10+, pytest 7+, and `cryptography` 41+. ## Quick start @@ -306,7 +306,7 @@ python -m build python -m twine check --strict dist/* ``` -CI tests Python 3.11–3.13 and enforces 100% line and branch coverage. See +CI tests Python 3.10–3.13 and enforces 100% line and branch coverage. See [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), and [RELEASING.md](RELEASING.md). diff --git a/docs/architecture.md b/docs/architecture.md index a0da45c..7cc4b62 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,6 +69,15 @@ canonical = json.dumps( Putting signer metadata inside this payload prevents identity or algorithm substitution after signing. +The optional `environment.nvcc_version` field records the stripped stdout of +`nvcc --version` resolved through PATH. It is null if the executable is missing, +cannot run, times out after ten seconds, fails, or returns empty/undecodable output. +This is self-reported compiler availability, not evidence that this compiler built +the tested code. It is independent of GPU detection and requires no GPU. +Existing schema-3 receipts without the field still verify. Fresh shard merges +require matching values (missing and null are equivalent); carried shards retain +their original environment metadata. + ## Fingerprint manifest `sha256-manifest-v2` enumerates stage-0 Git index entries. Regular files bind diff --git a/docs/index.md b/docs/index.md index eb54c89..1218d8a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -36,5 +36,5 @@ or exact SSH key fingerprints. - [Architecture](architecture.md): schema and data flow. - [Security model](security_model.md): exact guarantees and limits. -Requirements: Python 3.11+, pytest 7+, and an SSH private-key file whose public +Requirements: Python 3.10+, pytest 7+, and an SSH private-key file whose public key is registered on GitHub. diff --git a/pyproject.toml b/pyproject.toml index 252b10d..7625922 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "hatchling.build" [project] name = "pytest-gpu-proof" -version = "0.4.1" +version = "0.5.0" description = "pytest plugin for GPU equivalence testing with signed receipts verified via GitHub SSH keys" readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.10" license = { text = "MIT" } keywords = ["pytest", "gpu", "cuda", "testing", "signing", "attestation"] classifiers = [ @@ -17,6 +17,7 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -26,6 +27,7 @@ classifiers = [ dependencies = [ "pytest>=7.0", "cryptography>=41.0", + "tomli>=2.0; python_version < '3.11'", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 80e71c1..0c81c27 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pytest>=7.0 cryptography>=41.0 +tomli>=2.0; python_version < '3.11' diff --git a/src/pytest_gpu_proof/__init__.py b/src/pytest_gpu_proof/__init__.py index 3d26edf..3d18726 100644 --- a/src/pytest_gpu_proof/__init__.py +++ b/src/pytest_gpu_proof/__init__.py @@ -1 +1 @@ -__version__ = "0.4.1" +__version__ = "0.5.0" diff --git a/src/pytest_gpu_proof/config.py b/src/pytest_gpu_proof/config.py index d9e96df..905a14b 100644 --- a/src/pytest_gpu_proof/config.py +++ b/src/pytest_gpu_proof/config.py @@ -1,4 +1,7 @@ -import tomllib +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional, Union diff --git a/src/pytest_gpu_proof/merge.py b/src/pytest_gpu_proof/merge.py index fa10ff9..fc88fbc 100644 --- a/src/pytest_gpu_proof/merge.py +++ b/src/pytest_gpu_proof/merge.py @@ -67,7 +67,7 @@ def merge_payloads(receipts: List[dict], sources: List[str]) -> dict: "repo.commit_sha") _require_identical(receipts, sources, lambda r: r.get("fingerprint"), "fingerprint") _require_identical(receipts, sources, lambda r: r.get("mode"), "mode") - for key in ("python_version", "platform", "pytest_version", "plugin_version"): + for key in ("python_version", "platform", "pytest_version", "plugin_version", "nvcc_version"): _require_identical(receipts, sources, lambda r, k=key: r.get("environment", {}).get(k), f"environment.{key}") diff --git a/src/pytest_gpu_proof/plugin.py b/src/pytest_gpu_proof/plugin.py index f89e6b4..6352632 100644 --- a/src/pytest_gpu_proof/plugin.py +++ b/src/pytest_gpu_proof/plugin.py @@ -17,7 +17,7 @@ def _utcnow() -> str: - return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def _has_gpu() -> bool: diff --git a/src/pytest_gpu_proof/receipt.py b/src/pytest_gpu_proof/receipt.py index fbc4012..b01af4c 100644 --- a/src/pytest_gpu_proof/receipt.py +++ b/src/pytest_gpu_proof/receipt.py @@ -25,7 +25,7 @@ def _utcnow() -> str: - return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def _gpu_info_from_torch() -> Optional[Dict[str, Any]]: @@ -92,6 +92,19 @@ def _gpu_info(include_uuid: bool = True) -> Optional[Dict[str, Any]]: } +def _nvcc_version() -> Optional[str]: + """Record the compiler on PATH, independently of GPU availability.""" + try: + result = subprocess.run( + ["nvcc", "--version"], capture_output=True, text=True, timeout=10, + ) + except (OSError, subprocess.TimeoutExpired, UnicodeError): + return None + if result.returncode != 0: + return None + return result.stdout.strip() or None + + def _env_info(record_gpu_uuid: bool = True) -> Dict[str, Any]: import pytest @@ -102,6 +115,7 @@ def _env_info(record_gpu_uuid: bool = True) -> Dict[str, Any]: "platform": platform.platform(), "pytest_version": pytest.__version__, "plugin_version": __version__, + "nvcc_version": _nvcc_version(), "gpu_info": _gpu_info(include_uuid=record_gpu_uuid), } diff --git a/src/pytest_gpu_proof/verify.py b/src/pytest_gpu_proof/verify.py index a9b1611..2f580ef 100644 --- a/src/pytest_gpu_proof/verify.py +++ b/src/pytest_gpu_proof/verify.py @@ -179,7 +179,7 @@ def _parse_time(value, field: str) -> datetime.datetime: raise VerificationError(f"{field} is missing or is not a UTC timestamp") try: return datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace( - tzinfo=datetime.UTC + tzinfo=datetime.timezone.utc ) except ValueError as exc: raise VerificationError(f"{field} is not a valid UTC timestamp: {value!r}") from exc @@ -498,7 +498,7 @@ def _verify( raise VerificationError("repository policy requires recorded GPU information") ended = _parse_time(session.get("ended_at"), "session.ended_at") - age = datetime.datetime.now(datetime.UTC) - ended + age = datetime.datetime.now(datetime.timezone.utc) - ended if age < datetime.timedelta(minutes=-5): raise VerificationError("receipt timestamp is in the future") if age > datetime.timedelta(days=max_days): @@ -508,7 +508,7 @@ def _verify( committed = get_commit_time(stored_sha, root) if committed is not None: started = _parse_time(session.get("started_at"), "session.started_at") - commit_dt = datetime.datetime.fromtimestamp(committed, datetime.UTC) + commit_dt = datetime.datetime.fromtimestamp(committed, datetime.timezone.utc) if started < commit_dt - datetime.timedelta(minutes=5): raise VerificationError( "receipt session started before its commit existed " @@ -577,7 +577,7 @@ def _verify_shards(receipt: dict, root: str, policy: dict, max_days: int = 30) - raise VerificationError( f"carried shard {name!r} was recorded with a dirty working tree") original = _parse_time(carried.get("original_ended_at"), f"shard {name}.original_ended_at") - age = datetime.datetime.now(datetime.UTC) - original + age = datetime.datetime.now(datetime.timezone.utc) - original # A carried shard's original run is never allowed to be older than # the receipt itself may be, unless policy says so explicitly. limit = policy.get("carried_max_age_days", min(30, max_days)) diff --git a/tests/conftest.py b/tests/conftest.py index 9a5c427..010c927 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -89,3 +89,16 @@ def tmp_git_repo(tmp_path): "*.json\nid_*\nexpected_skips.txt\npyproject.toml\n" ) return tmp_path + + +@pytest.fixture(autouse=True) +def _no_host_nvcc(monkeypatch): + """Compiler metadata tests override this deterministic default explicitly.""" + run = subprocess.run + + def without_host_nvcc(args, *positional, **kwargs): + if args == ["nvcc", "--version"]: + return subprocess.CompletedProcess(args, 0, stdout="test nvcc version\n", stderr="") + return run(args, *positional, **kwargs) + + monkeypatch.setattr(subprocess, "run", without_host_nvcc) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8f22468..e7cf2c2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,6 @@ -import runpy +import subprocess +from importlib.util import spec_from_file_location +from pathlib import Path import sys from types import SimpleNamespace @@ -69,14 +71,31 @@ def fail(*args, **kwargs): def test_module_entrypoint(monkeypatch): monkeypatch.setattr("pytest_gpu_proof.cli.main", lambda: (_ for _ in ()).throw(SystemExit(7))) with pytest.raises(SystemExit) as exc: - runpy.run_module("pytest_gpu_proof.__main__", run_name="__main__") + path = Path(cli.__file__).with_name("__main__.py") + exec(compile(path.read_text(), str(path), "exec"), + {"__name__": "__main__", "__package__": "pytest_gpu_proof", + "__file__": str(path), + "__spec__": spec_from_file_location(f"pytest_gpu_proof.{path.stem}", path)}) assert exc.value.code == 7 -@pytest.mark.filterwarnings("ignore:.*found in sys.modules.*:RuntimeWarning") def test_cli_file_entrypoint(monkeypatch): monkeypatch.setattr("pytest_gpu_proof.verify.verify_receipt", lambda **kwargs: True) monkeypatch.setattr(sys, "argv", ["gpu-proof", "verify", "--receipt", "r.json"]) with pytest.raises(SystemExit) as exc: - runpy.run_module("pytest_gpu_proof.cli", run_name="__main__") + path = Path(cli.__file__) + exec(compile(path.read_text(), str(path), "exec"), + {"__name__": "__main__", "__package__": "pytest_gpu_proof", + "__file__": str(path), + "__spec__": spec_from_file_location(f"pytest_gpu_proof.{path.stem}", path)}) assert exc.value.code == 0 + + +@pytest.mark.parametrize("module", ["pytest_gpu_proof", "pytest_gpu_proof.cli"]) +def test_module_cli_in_fresh_interpreter(module): + # Real -m invocation has no pytest assertion-rewriting loader. The in-process + # source-execution tests above retain coverage and mocks for exit/dispatch behavior. + result = subprocess.run([sys.executable, "-m", module, "--help"], + capture_output=True, text=True, timeout=10) + assert result.returncode == 0, result.stderr + assert "verify signed GPU test receipts" in result.stdout diff --git a/tests/test_config.py b/tests/test_config.py index b17f9b6..d3bc30e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -121,3 +121,23 @@ def test_record_gpu_uuid_from_toml(tmp_path): ) ) assert config.record_gpu_uuid is False + + +def test_tomli_fallback(monkeypatch, tmp_path): + import builtins + import runpy + import pytest_gpu_proof.config as config + + original_import = builtins.__import__ + + def without_tomllib(name, *args, **kwargs): + if name == "tomllib": + raise ModuleNotFoundError("No module named 'tomllib'") + if name == "tomli": + return config.tomllib + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", without_tomllib) + namespace = runpy.run_path(config.__file__) + (tmp_path / "pyproject.toml").write_text('[tool.gpu_proof]\nmode = "local"\n') + assert namespace["load_toml_defaults"](tmp_path) == {"mode": "local"} diff --git a/tests/test_merge.py b/tests/test_merge.py index 31ba751..bc09751 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -30,7 +30,7 @@ def signer_with_key(tmp_path, ed25519_keypair): def _utcstamp(minutes_ago: int = 0) -> str: - ts = datetime.datetime.now(datetime.UTC) - datetime.timedelta(minutes=minutes_ago) + ts = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=minutes_ago) return ts.strftime("%Y-%m-%dT%H:%M:%SZ") @@ -241,3 +241,24 @@ def test_schema1_merge_has_no_schema3_session_fields(): merged = merge_payloads([base], ["one.json"]) assert "outcome" not in merged["session"] assert "shards" not in merged + + +@pytest.mark.parametrize("first, second", [("V13.2", "V12.8"), ("V13.2", None)]) +def test_refuses_nvcc_mismatch(tmp_path, tmp_git_repo, signer_with_key, first, second): + signer, _, _ = signer_with_key + a = _shard(tmp_path, tmp_git_repo, signer, "a.json", [_result("t::a")], + mutate=lambda p: p["environment"].__setitem__("nvcc_version", first)) + b = _shard(tmp_path, tmp_git_repo, signer, "b.json", [_result("t::b")], + mutate=lambda p: p["environment"].__setitem__("nvcc_version", second)) + with pytest.raises(MergeError, match="environment.nvcc_version"): + merge_payloads([json.loads(a.read_text()), json.loads(b.read_text())], ["a", "b"]) + + +def test_merge_old_receipt_without_nvcc(tmp_path, tmp_git_repo, signer_with_key): + signer, _, _ = signer_with_key + a = _shard(tmp_path, tmp_git_repo, signer, "a.json", [_result("t::a")], + mutate=lambda p: p["environment"].pop("nvcc_version", None)) + b = _shard(tmp_path, tmp_git_repo, signer, "b.json", [_result("t::b")], + mutate=lambda p: p["environment"].__setitem__("nvcc_version", None)) + merged = merge_payloads([json.loads(a.read_text()), json.loads(b.read_text())], ["a", "b"]) + assert len(merged["tests"]) == 2 diff --git a/tests/test_receipt.py b/tests/test_receipt.py index 0a1f89a..f64bba1 100644 --- a/tests/test_receipt.py +++ b/tests/test_receipt.py @@ -339,3 +339,38 @@ def test_gpu_info_falls_back_to_torch_on_nvidia_smi_failure(monkeypatch): lambda *a, **k: types.SimpleNamespace(returncode=1, stdout="")) monkeypatch.setitem(sys.modules, "torch", None) assert receipt_mod._gpu_info() is None + + +@pytest.mark.parametrize("code, output, expected", [ + (0, "nvcc: NVIDIA (R) Cuda compiler driver\nCuda compilation tools, release 13.2, V13.2.51\n", + "nvcc: NVIDIA (R) Cuda compiler driver\nCuda compilation tools, release 13.2, V13.2.51"), + (0, " \n", None), + (1, "partial output", None), +]) +def test_nvcc_version(monkeypatch, code, output, expected): + import subprocess + from pytest_gpu_proof.receipt import _nvcc_version + + def run(args, **kwargs): + assert args == ["nvcc", "--version"] + assert kwargs == {"capture_output": True, "text": True, "timeout": 10} + return subprocess.CompletedProcess(args, code, stdout=output, stderr="ignored") + + monkeypatch.setattr("pytest_gpu_proof.receipt.subprocess.run", run) + assert _nvcc_version() == expected + monkeypatch.setattr("pytest_gpu_proof.receipt._gpu_info", lambda **kw: None) + assert _env_info()["nvcc_version"] == expected + + +@pytest.mark.parametrize("error", [ + FileNotFoundError(), PermissionError(), + __import__("subprocess").TimeoutExpired("nvcc", 10), UnicodeError(), +]) +def test_nvcc_unavailable(monkeypatch, error): + from pytest_gpu_proof.receipt import _nvcc_version + + def run(*args, **kwargs): + raise error + + monkeypatch.setattr("pytest_gpu_proof.receipt.subprocess.run", run) + assert _nvcc_version() is None diff --git a/tests/test_sharding.py b/tests/test_sharding.py index 4e0cb69..a1f064c 100644 --- a/tests/test_sharding.py +++ b/tests/test_sharding.py @@ -37,7 +37,7 @@ def signer_with_key(tmp_path, ed25519_keypair): def _utcstamp(days_ago: float = 0) -> str: - ts = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=days_ago) + ts = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=days_ago) return ts.strftime("%Y-%m-%dT%H:%M:%SZ") diff --git a/tests/test_verifier.py b/tests/test_verifier.py index b5a519e..8281b4a 100644 --- a/tests/test_verifier.py +++ b/tests/test_verifier.py @@ -38,7 +38,7 @@ def signer_with_key(tmp_path, keypair): def _utcstamp(days_ago: int = 0) -> str: - ts = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=days_ago) + ts = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=days_ago) return ts.strftime("%Y-%m-%dT%H:%M:%SZ") @@ -273,7 +273,7 @@ def test_verify_fails_on_failed_test(tmp_path, tmp_git_repo, signer_with_key): } ] import datetime - now = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") payload = build_receipt_payload(config, results, now, now) receipt = finalize_receipt(payload, signer) path = tmp_path / "gpu-proof.json" @@ -571,7 +571,7 @@ def test_verifier_wrapper_reports_failure(tmp_path, tmp_git_repo, capsys): def test_future_and_invalid_age_policy(tmp_path, tmp_git_repo, signer_with_key): signer, public_key = signer_with_key - future = (datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + future = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ") path = _make_receipt(tmp_path, tmp_git_repo, signer, ended_at=future) with _mock_github_keys(public_key), pytest.raises(VerificationError, match="future"): _verify(str(path), None, str(tmp_git_repo), "testuser", None)