Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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).

Expand Down
9 changes: 9 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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",
Expand All @@ -26,6 +27,7 @@ classifiers = [
dependencies = [
"pytest>=7.0",
"cryptography>=41.0",
"tomli>=2.0; python_version < '3.11'",
]

[project.urls]
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pytest>=7.0
cryptography>=41.0
tomli>=2.0; python_version < '3.11'
2 changes: 1 addition & 1 deletion src/pytest_gpu_proof/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.4.1"
__version__ = "0.5.0"
5 changes: 4 additions & 1 deletion src/pytest_gpu_proof/config.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/pytest_gpu_proof/merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
2 changes: 1 addition & 1 deletion src/pytest_gpu_proof/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 15 additions & 1 deletion src/pytest_gpu_proof/receipt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Expand Down Expand Up @@ -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

Expand All @@ -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),
}

Expand Down
8 changes: 4 additions & 4 deletions src/pytest_gpu_proof/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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 "
Expand Down Expand Up @@ -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))
Expand Down
13 changes: 13 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
27 changes: 23 additions & 4 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
20 changes: 20 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
23 changes: 22 additions & 1 deletion tests/test_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down Expand Up @@ -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
35 changes: 35 additions & 0 deletions tests/test_receipt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion tests/test_sharding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
Loading
Loading