pytest-gpu-proof 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from pytest_gpu_proof.cli import main
2
+
3
+ main()
@@ -0,0 +1,95 @@
1
+ import argparse
2
+ import sys
3
+
4
+
5
+ def main():
6
+ parser = argparse.ArgumentParser(
7
+ prog="gpu-proof",
8
+ description="pytest-gpu-proof: verify signed GPU test receipts",
9
+ )
10
+ subparsers = parser.add_subparsers(dest="command", required=True)
11
+
12
+ # verify
13
+ vp = subparsers.add_parser(
14
+ "verify",
15
+ help="Verify a signed receipt against GitHub public keys",
16
+ )
17
+ vp.add_argument("--receipt", required=True, metavar="PATH", help="Path to gpu-proof.json")
18
+ vp.add_argument(
19
+ "--policy",
20
+ default=None,
21
+ metavar="PATH",
22
+ help="Path to policy YAML/JSON (optional)",
23
+ )
24
+ vp.add_argument(
25
+ "--repo",
26
+ default=".",
27
+ metavar="PATH",
28
+ help="Repository root for fingerprint recomputation (default: .)",
29
+ )
30
+ vp.add_argument(
31
+ "--github-user",
32
+ default=None,
33
+ metavar="USERNAME",
34
+ help="Override GitHub username (default: read from receipt)",
35
+ )
36
+ vp.add_argument(
37
+ "--max-age-days",
38
+ type=int,
39
+ default=None,
40
+ metavar="N",
41
+ help="Override max receipt age in days",
42
+ )
43
+ vp.add_argument(
44
+ "--allow-unsigned",
45
+ action="store_true",
46
+ default=False,
47
+ help="Accept UNSIGNED receipts (signature: null). Unsigned receipts prove "
48
+ "nothing about who ran the tests — use only if you accept that.",
49
+ )
50
+ vp.add_argument(
51
+ "--allow-skipped",
52
+ action="store_true",
53
+ default=False,
54
+ help="Accept receipts that contain skipped marked tests (default: reject)",
55
+ )
56
+ vp.add_argument(
57
+ "--expected-skips",
58
+ default=None,
59
+ metavar="PATH",
60
+ help="Baseline file of node IDs (one per line, '#' comments) that the "
61
+ "receipt's skipped tests must match EXACTLY — new skips fail, and a "
62
+ "baselined test that now runs flags the baseline as stale. Stricter "
63
+ "than (and mutually exclusive with) --allow-skipped. Can also be set "
64
+ "as an inline list via expected_skips in [tool.gpu_proof].",
65
+ )
66
+ vp.add_argument(
67
+ "--require-gpu",
68
+ action="store_true",
69
+ default=None,
70
+ help="Fail verification if the receipt's environment.gpu_info is null/absent "
71
+ "(modest hardening, not proof of GPU execution). Can also be set via "
72
+ "require_gpu = true in [tool.gpu_proof].",
73
+ )
74
+
75
+ args = parser.parse_args()
76
+
77
+ if args.command == "verify":
78
+ from .verify import verify_receipt
79
+
80
+ ok = verify_receipt(
81
+ receipt_path=args.receipt,
82
+ policy_path=args.policy,
83
+ repo_root=args.repo,
84
+ github_user_override=args.github_user,
85
+ max_age_days=args.max_age_days,
86
+ allow_unsigned=args.allow_unsigned,
87
+ allow_skipped=args.allow_skipped,
88
+ require_gpu=args.require_gpu,
89
+ expected_skips_path=args.expected_skips,
90
+ )
91
+ sys.exit(0 if ok else 1)
92
+
93
+
94
+ if __name__ == "__main__":
95
+ main()
@@ -0,0 +1,49 @@
1
+ from typing import Any, Callable, Optional, Tuple
2
+
3
+
4
+ def default_compare(ref: Any, cand: Any) -> None:
5
+ """numpy.allclose for float arrays, exact equality otherwise. Raises AssertionError on mismatch."""
6
+ try:
7
+ import numpy as np
8
+
9
+ ref_arr = np.asarray(ref)
10
+ cand_arr = np.asarray(cand)
11
+ if ref_arr.dtype.kind in ("f", "c"):
12
+ if not np.allclose(ref_arr, cand_arr):
13
+ max_diff = float(np.max(np.abs(ref_arr - cand_arr)))
14
+ raise AssertionError(f"Arrays not close: max difference = {max_diff:.6e}")
15
+ return
16
+ except (ImportError, TypeError, ValueError):
17
+ pass
18
+
19
+ if ref != cand:
20
+ raise AssertionError(f"Values not equal: {ref!r} != {cand!r}")
21
+
22
+
23
+ def run_comparison(
24
+ reference: Callable,
25
+ candidate: Callable,
26
+ args: tuple,
27
+ kwargs: dict,
28
+ compare_fn: Optional[Callable] = None,
29
+ ) -> Tuple[str, Any, Any, Optional[str]]:
30
+ """
31
+ Call reference and candidate with the same args, then compare outputs.
32
+ Returns (outcome, ref_result, cand_result, error_message).
33
+ """
34
+ try:
35
+ ref_result = reference(*args, **kwargs)
36
+ except Exception as e:
37
+ return "error", None, None, f"Reference raised: {e}"
38
+
39
+ try:
40
+ cand_result = candidate(*args, **kwargs)
41
+ except Exception as e:
42
+ return "error", ref_result, None, f"Candidate raised: {e}"
43
+
44
+ compare = compare_fn if compare_fn is not None else default_compare
45
+ try:
46
+ compare(ref_result, cand_result)
47
+ return "passed", ref_result, cand_result, None
48
+ except AssertionError as e:
49
+ return "failed", ref_result, cand_result, str(e)
@@ -0,0 +1,82 @@
1
+ import tomllib
2
+ from dataclasses import dataclass, field
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List, Optional, Union
5
+
6
+
7
+ @dataclass
8
+ class GpuProofConfig:
9
+ enabled: bool = False
10
+ mode: str = "local"
11
+ output: str = "gpu-proof.json"
12
+ key_path: Optional[str] = None
13
+ signing_backend: str = "ed25519"
14
+ policy_path: Optional[str] = None
15
+ required_marker: str = "gpu_proof"
16
+ fail_on_skip: bool = False
17
+ fingerprint_paths: List[str] = field(default_factory=lambda: ["src", "tests"])
18
+ github_username: Optional[str] = None
19
+ max_age_days: int = 30
20
+ require_gpu: bool = False
21
+
22
+
23
+ def load_toml_defaults(root: Union[str, Path]) -> Dict[str, Any]:
24
+ """Read the ``[tool.gpu_proof]`` table from ``<root>/pyproject.toml``.
25
+
26
+ Returns an empty dict when the file or table is absent or unparseable.
27
+ """
28
+ pyproject = Path(root) / "pyproject.toml"
29
+ if not pyproject.is_file():
30
+ return {}
31
+ try:
32
+ with open(pyproject, "rb") as f:
33
+ data = tomllib.load(f)
34
+ except (tomllib.TOMLDecodeError, OSError):
35
+ return {}
36
+ table = data.get("tool", {}).get("gpu_proof", {})
37
+ return table if isinstance(table, dict) else {}
38
+
39
+
40
+ def load_config(pytest_config) -> GpuProofConfig:
41
+ toml_cfg = load_toml_defaults(pytest_config.rootpath)
42
+
43
+ def opt(name, default=None):
44
+ try:
45
+ return pytest_config.getoption(name)
46
+ except ValueError:
47
+ return default
48
+
49
+ def resolve(opt_name, toml_key, default):
50
+ """Precedence: CLI flag (when it differs from its built-in default),
51
+ then [tool.gpu_proof] in pyproject.toml, then the built-in default."""
52
+ cli = opt(opt_name, default)
53
+ if cli != default:
54
+ return cli
55
+ toml_val = toml_cfg.get(toml_key)
56
+ if toml_val is not None:
57
+ return toml_val
58
+ return default
59
+
60
+ raw_paths = resolve("--gpu-proof-fingerprint-paths", "fingerprint_paths", "src,tests")
61
+ if isinstance(raw_paths, str):
62
+ paths = [p.strip() for p in raw_paths.split(",") if p.strip()]
63
+ else:
64
+ paths = [str(p) for p in raw_paths]
65
+
66
+ max_age = toml_cfg.get("max_age_days")
67
+ max_age_days = int(max_age) if max_age is not None else 30
68
+
69
+ return GpuProofConfig(
70
+ enabled=bool(opt("--gpu-proof-enable", False)),
71
+ mode=resolve("--gpu-proof-mode", "mode", "local"),
72
+ output=resolve("--gpu-proof-out", "output", "gpu-proof.json"),
73
+ key_path=resolve("--gpu-proof-key", "key_path", None),
74
+ signing_backend=resolve("--gpu-proof-signing-backend", "signing_backend", "ed25519"),
75
+ policy_path=resolve("--gpu-proof-policy", "policy_path", None),
76
+ required_marker=resolve("--gpu-proof-required-marker", "required_marker", "gpu_proof"),
77
+ fail_on_skip=bool(resolve("--gpu-proof-fail-on-skip", "fail_on_skip", False)),
78
+ fingerprint_paths=paths,
79
+ github_username=resolve("--gpu-proof-github-user", "github_username", None),
80
+ max_age_days=max_age_days,
81
+ require_gpu=bool(toml_cfg.get("require_gpu", False)),
82
+ )
@@ -0,0 +1,38 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ from typing import List
5
+
6
+ from .gitutils import get_tracked_files
7
+
8
+
9
+ def _hash_file(path: str, root: str) -> str:
10
+ h = hashlib.sha256()
11
+ with open(os.path.join(root, path), "rb") as f:
12
+ for chunk in iter(lambda: f.read(65536), b""):
13
+ h.update(chunk)
14
+ return h.hexdigest()
15
+
16
+
17
+ def compute_fingerprint(paths: List[str], root: str = ".") -> dict:
18
+ tracked = get_tracked_files(paths, root)
19
+ file_hashes = {}
20
+ for p in tracked:
21
+ try:
22
+ file_hashes[p] = _hash_file(p, root)
23
+ except OSError:
24
+ pass
25
+
26
+ canonical = json.dumps(
27
+ {"files": {k: file_hashes[k] for k in sorted(file_hashes)}},
28
+ sort_keys=True,
29
+ separators=(",", ":"),
30
+ )
31
+ digest = hashlib.sha256(canonical.encode()).hexdigest()
32
+
33
+ return {
34
+ "algorithm": "sha256",
35
+ "included_paths": sorted(paths),
36
+ "file_count": len(file_hashes),
37
+ "digest": digest,
38
+ }
@@ -0,0 +1,112 @@
1
+ import re
2
+ import subprocess
3
+ from typing import Optional
4
+
5
+
6
+ def _git(*args) -> Optional[str]:
7
+ try:
8
+ result = subprocess.run(
9
+ ["git"] + list(args),
10
+ capture_output=True,
11
+ text=True,
12
+ check=True,
13
+ )
14
+ return result.stdout.strip() or None
15
+ except (subprocess.CalledProcessError, FileNotFoundError):
16
+ return None
17
+
18
+
19
+ def get_commit_sha() -> Optional[str]:
20
+ return _git("rev-parse", "HEAD")
21
+
22
+
23
+ def get_branch() -> Optional[str]:
24
+ return _git("rev-parse", "--abbrev-ref", "HEAD")
25
+
26
+
27
+ def is_dirty() -> bool:
28
+ # --ignore-submodules=untracked: untracked files INSIDE a submodule (build
29
+ # deps, caches) cannot change the code a receipt attests — the submodule's
30
+ # content is pinned by the parent's gitlink, and a *pin* change (a different
31
+ # or new commit checked out in the submodule) still reports as modified
32
+ # under this flag. Without it, a consumer whose submodule carries build
33
+ # artifacts can never produce a clean receipt (first hit: GATO's sqpcpu
34
+ # baseline submodule).
35
+ try:
36
+ result = subprocess.run(
37
+ ["git", "status", "--porcelain", "--ignore-submodules=untracked"],
38
+ capture_output=True,
39
+ text=True,
40
+ check=True,
41
+ )
42
+ return bool(result.stdout.strip())
43
+ except (subprocess.CalledProcessError, FileNotFoundError):
44
+ return False
45
+
46
+
47
+ def get_remote_url() -> Optional[str]:
48
+ return _git("remote", "get-url", "origin")
49
+
50
+
51
+ def extract_github_username(remote_url: str) -> Optional[str]:
52
+ match = re.search(r"github\.com[:/]([^/]+)/", remote_url)
53
+ return match.group(1) if match else None
54
+
55
+
56
+ def get_github_username() -> Optional[str]:
57
+ url = get_remote_url()
58
+ return extract_github_username(url) if url else None
59
+
60
+
61
+ def get_gh_cli_login() -> Optional[str]:
62
+ """The authenticated GitHub CLI user, if `gh` is installed and logged in.
63
+
64
+ This is the KEYHOLDER — the account whose github.com/<user>.keys will
65
+ verify the receipt — unlike the origin-remote owner, which for org-owned
66
+ repos is an org with no SSH keys.
67
+ """
68
+ try:
69
+ result = subprocess.run(
70
+ ["gh", "api", "user", "--jq", ".login"],
71
+ capture_output=True,
72
+ text=True,
73
+ check=True,
74
+ timeout=10,
75
+ )
76
+ return result.stdout.strip() or None
77
+ except (subprocess.CalledProcessError, FileNotFoundError,
78
+ subprocess.TimeoutExpired):
79
+ return None
80
+
81
+
82
+ def get_git_signing_key() -> Optional[str]:
83
+ val = _git("config", "--get", "user.signingKey")
84
+ if not val:
85
+ return None
86
+ import os
87
+ return os.path.expanduser(val)
88
+
89
+
90
+ def get_tracked_files(paths, root="."):
91
+ try:
92
+ result = subprocess.run(
93
+ ["git", "ls-files"] + list(paths),
94
+ capture_output=True,
95
+ text=True,
96
+ check=True,
97
+ cwd=root,
98
+ )
99
+ return sorted(line for line in result.stdout.splitlines() if line)
100
+ except (subprocess.CalledProcessError, FileNotFoundError):
101
+ import os
102
+ files = []
103
+ for path in paths:
104
+ full = os.path.join(root, path)
105
+ if os.path.isfile(full):
106
+ files.append(os.path.relpath(full, root))
107
+ elif os.path.isdir(full):
108
+ for dirpath, _, filenames in os.walk(full):
109
+ for fn in filenames:
110
+ fp = os.path.join(dirpath, fn)
111
+ files.append(os.path.relpath(fp, root))
112
+ return sorted(files)