sciproof 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.
sciproof/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """SciProof: record computational experiments and create verifiable evidence for them.
2
+
3
+ SciProof checks *integrity* and *execution reproducibility* of recorded runs. It does not,
4
+ and cannot, establish that a scientific result is correct. See docs/CONCEPTS.md.
5
+ """
6
+
7
+ __version__ = "0.1.0"
sciproof/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from sciproof.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
sciproof/capture.py ADDED
@@ -0,0 +1,263 @@
1
+ """Capture an experiment: run it once and write an evidence manifest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ import shutil
8
+ from dataclasses import dataclass, field
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from sciproof import __version__
14
+ from sciproof.environment import interpreter_info, platform_info, selected_env_vars
15
+ from sciproof.errors import CaptureError, SciProofError, UnsafePathError
16
+ from sciproof.gitinfo import git_info
17
+ from sciproof.hashing import sha256_file
18
+ from sciproof.manifest import (
19
+ MANIFEST_SUFFIX,
20
+ NAME_RE,
21
+ SCHEMA,
22
+ SCHEMA_VERSION,
23
+ snapshot_dir_name,
24
+ write_manifest,
25
+ )
26
+ from sciproof.numeric import NumericFormatError, load_numeric
27
+ from sciproof.paths import to_manifest_path
28
+ from sciproof.runner import (
29
+ CommandInterrupted,
30
+ CommandNotRunnable,
31
+ CommandTimeout,
32
+ run_command,
33
+ summarize_stream,
34
+ )
35
+
36
+ DEFAULT_MAX_LOG_BYTES = 64 * 1024
37
+ DEFAULT_RTOL = 1e-9
38
+ DEFAULT_ATOL = 0.0
39
+
40
+
41
+ @dataclass
42
+ class CaptureResult:
43
+ manifest_path: Path
44
+ manifest: dict[str, Any]
45
+ warnings: list[str] = field(default_factory=list)
46
+
47
+ @property
48
+ def exit_code(self) -> int:
49
+ return int(self.manifest["execution"]["exit_code"])
50
+
51
+
52
+ def _hash_inputs(base: Path, inputs: list[str]) -> list[dict[str, Any]]:
53
+ records = []
54
+ for relative in inputs:
55
+ path = base / relative
56
+ if path.is_dir():
57
+ raise CaptureError(
58
+ f"input {relative!r} is a directory; only files can be declared "
59
+ "(directory support is tracked in issue #5)"
60
+ )
61
+ if not path.is_file():
62
+ raise CaptureError(f"input file not found: {relative}")
63
+ digest, size = sha256_file(path)
64
+ records.append({"path": relative, "sha256": digest, "size": size})
65
+ return records
66
+
67
+
68
+ def _unique(items: list[str], what: str) -> None:
69
+ if len(set(items)) != len(items):
70
+ raise CaptureError(f"{what} contains the same path more than once")
71
+
72
+
73
+ def capture(
74
+ *,
75
+ name: str,
76
+ argv: list[str],
77
+ base: Path,
78
+ inputs: list[str] | None = None,
79
+ outputs: list[str] | None = None,
80
+ numeric_outputs: list[str] | None = None,
81
+ rtol: float = DEFAULT_RTOL,
82
+ atol: float = DEFAULT_ATOL,
83
+ timeout: float | None = None,
84
+ max_log_bytes: int = DEFAULT_MAX_LOG_BYTES,
85
+ force: bool = False,
86
+ ) -> CaptureResult:
87
+ """Run ``argv`` once in ``base`` and write ``<name>.sciproof.json`` there.
88
+
89
+ Paths in ``inputs``/``outputs``/``numeric_outputs`` are user paths relative to ``base``
90
+ (or absolute paths inside it). The manifest is only written if everything succeeded;
91
+ a command that exits with a nonzero status is still recorded.
92
+ """
93
+ if not NAME_RE.fullmatch(name):
94
+ raise CaptureError(
95
+ f"invalid name {name!r}: use letters, digits, '.', '_' or '-' (max 100 characters)"
96
+ )
97
+ if not argv or not argv[0]:
98
+ raise CaptureError("no command given")
99
+ if not (math.isfinite(rtol) and math.isfinite(atol)) or rtol < 0 or atol < 0:
100
+ raise CaptureError("rtol and atol must be finite numbers that are not negative")
101
+ if timeout is not None and not (math.isfinite(timeout) and timeout > 0):
102
+ raise CaptureError("timeout must be a positive number of seconds")
103
+ if max_log_bytes < 0:
104
+ raise CaptureError("max-log-bytes must not be negative")
105
+ base = base.resolve()
106
+
107
+ try:
108
+ input_paths = [to_manifest_path(base, p) for p in (inputs or [])]
109
+ exact_paths = [to_manifest_path(base, p) for p in (outputs or [])]
110
+ numeric_paths = [to_manifest_path(base, p) for p in (numeric_outputs or [])]
111
+ except UnsafePathError as exc:
112
+ raise CaptureError(str(exc)) from exc
113
+ _unique(input_paths, "--input")
114
+ _unique(exact_paths + numeric_paths, "--output/--output-numeric")
115
+ overlap = set(input_paths) & set(exact_paths + numeric_paths)
116
+ if overlap:
117
+ raise CaptureError(f"a file cannot be both input and output: {sorted(overlap)[0]}")
118
+ if not (input_paths or exact_paths or numeric_paths):
119
+ raise CaptureError(
120
+ "declare at least one --input, --output or --output-numeric file; "
121
+ "SciProof only verifies files that were declared"
122
+ )
123
+
124
+ manifest_path = base / f"{name}{MANIFEST_SUFFIX}"
125
+ if manifest_path.exists() and not force:
126
+ raise CaptureError(f"{manifest_path.name} already exists (use --force to overwrite it)")
127
+
128
+ warnings: list[str] = []
129
+ input_records = _hash_inputs(base, input_paths)
130
+ before = {}
131
+ for relative in exact_paths + numeric_paths:
132
+ target = base / relative
133
+ if target.is_dir():
134
+ raise CaptureError(
135
+ f"output {relative!r} is a directory; only files can be declared "
136
+ "(directory support is tracked in issue #5)"
137
+ )
138
+ before[relative] = sha256_file(target)[0] if target.is_file() else None
139
+
140
+ git = git_info(base)
141
+ if git and git["dirty"]:
142
+ warnings.append(
143
+ "the Git working tree has uncommitted changes, so the commit alone does not "
144
+ "identify the code that was run"
145
+ )
146
+ interpreter, interpreter_source = interpreter_info(argv[0], base)
147
+
148
+ try:
149
+ result = run_command(argv, base, timeout)
150
+ except CommandInterrupted as exc:
151
+ raise CaptureError("interrupted; no evidence was written") from exc
152
+ except CommandTimeout as exc:
153
+ raise CaptureError(f"{exc}; no evidence was written") from exc
154
+ except CommandNotRunnable as exc:
155
+ raise CaptureError(str(exc)) from exc
156
+
157
+ # The command must treat declared inputs as read-only, otherwise the evidence is meaningless.
158
+ for record in input_records:
159
+ digest, _ = (
160
+ sha256_file(base / record["path"]) if (base / record["path"]).is_file() else (None, 0)
161
+ )
162
+ if digest != record["sha256"]:
163
+ raise CaptureError(
164
+ f"the command modified or removed the declared input {record['path']!r}; "
165
+ "inputs must be read-only. No evidence was written."
166
+ )
167
+
168
+ # Snapshots are written to a staging directory and only replace the old ones once the new
169
+ # manifest has been written, so a failed capture never damages earlier evidence and
170
+ # --force never leaves snapshots that the new manifest does not reference.
171
+ snapshot_dir = base / snapshot_dir_name(name)
172
+ staging = base / f"{snapshot_dir_name(name)}.tmp"
173
+ shutil.rmtree(staging, ignore_errors=True)
174
+ try:
175
+ output_records: list[dict[str, Any]] = []
176
+ for relative in exact_paths + numeric_paths:
177
+ numeric = relative in numeric_paths
178
+ target = base / relative
179
+ record: dict[str, Any] = {"path": relative}
180
+ if target.is_file():
181
+ digest, size = sha256_file(target)
182
+ record.update(present=True, sha256=digest, size=size)
183
+ if before[relative] == digest:
184
+ warnings.append(
185
+ f"output {relative!r} existed before the run and was not changed by it"
186
+ )
187
+ elif result.exit_code == 0:
188
+ raise CaptureError(
189
+ f"the command succeeded but the declared output {relative!r} does not exist. "
190
+ "No evidence was written."
191
+ )
192
+ else:
193
+ record.update(present=False, sha256=None, size=0)
194
+ if numeric:
195
+ raise CaptureError(
196
+ f"the command failed and the numeric output {relative!r} does not exist"
197
+ )
198
+
199
+ if numeric:
200
+ try:
201
+ load_numeric(target)
202
+ except NumericFormatError as exc:
203
+ raise CaptureError(str(exc)) from exc
204
+ staging.mkdir(exist_ok=True)
205
+ snapshot_name = f"{len(output_records)}-{target.name}"
206
+ shutil.copyfile(target, staging / snapshot_name)
207
+ snapshot_digest, _ = sha256_file(staging / snapshot_name)
208
+ record["compare"] = {
209
+ "mode": "numeric",
210
+ "rtol": rtol,
211
+ "atol": atol,
212
+ "snapshot": {
213
+ "path": f"{snapshot_dir_name(name)}/{snapshot_name}",
214
+ "sha256": snapshot_digest,
215
+ },
216
+ }
217
+ else:
218
+ record["compare"] = {"mode": "exact"}
219
+ output_records.append(record)
220
+
221
+ manifest: dict[str, Any] = {
222
+ "schema": SCHEMA,
223
+ "schema_version": SCHEMA_VERSION,
224
+ "name": name,
225
+ "created_at": datetime.now(UTC).replace(microsecond=0).isoformat(),
226
+ "tool": {"name": "sciproof", "version": __version__},
227
+ "command": {"argv": list(argv), "cwd": "."},
228
+ "environment": {
229
+ "source": interpreter_source,
230
+ "python": {
231
+ "version": interpreter["version"],
232
+ "implementation": interpreter["implementation"],
233
+ "byteorder": interpreter.get("byteorder", ""),
234
+ },
235
+ "platform": platform_info(),
236
+ "env_vars": selected_env_vars(),
237
+ },
238
+ "dependencies": [
239
+ {"name": pkg_name, "version": pkg_version}
240
+ for pkg_name, pkg_version in interpreter["packages"]
241
+ ],
242
+ "git": git,
243
+ "inputs": input_records,
244
+ "execution": {
245
+ "exit_code": result.exit_code,
246
+ "duration_seconds": round(result.duration_seconds, 3),
247
+ "stdout": summarize_stream(result.stdout, max_log_bytes),
248
+ "stderr": summarize_stream(result.stderr, max_log_bytes),
249
+ },
250
+ "outputs": output_records,
251
+ }
252
+ try:
253
+ write_manifest(manifest_path, manifest)
254
+ except OSError as exc:
255
+ raise CaptureError(f"cannot write {manifest_path}: {exc}") from exc
256
+ except SciProofError as exc: # a bug in SciProof itself; never write an invalid manifest
257
+ raise CaptureError(f"internal error: generated an invalid manifest: {exc}") from exc
258
+ shutil.rmtree(snapshot_dir, ignore_errors=True)
259
+ if staging.exists():
260
+ os.replace(staging, snapshot_dir)
261
+ finally:
262
+ shutil.rmtree(staging, ignore_errors=True)
263
+ return CaptureResult(manifest_path=manifest_path, manifest=manifest, warnings=warnings)
sciproof/cli.py ADDED
@@ -0,0 +1,249 @@
1
+ """Command line interface.
2
+
3
+ Exit codes
4
+ ----------
5
+ 0 success / verification PASS
6
+ 1 verification FAIL, or the captured command failed (evidence is still written)
7
+ 2 verification INCONCLUSIVE
8
+ 3 usage error, invalid manifest, or capture could not be completed
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from sciproof import __version__
18
+ from sciproof.capture import DEFAULT_ATOL, DEFAULT_MAX_LOG_BYTES, DEFAULT_RTOL, capture
19
+ from sciproof.errors import ConfirmationRequired, SciProofError
20
+ from sciproof.inspect_manifest import inspect_file
21
+ from sciproof.textutil import safe_text
22
+ from sciproof.verify import (
23
+ DEFAULT_RERUN_TIMEOUT_SECONDS,
24
+ Status,
25
+ format_report,
26
+ format_report_json,
27
+ verify,
28
+ )
29
+
30
+ EXIT_OK = 0
31
+ EXIT_FAIL = 1
32
+ EXIT_INCONCLUSIVE = 2
33
+ EXIT_ERROR = 3
34
+
35
+ _STATUS_EXIT = {
36
+ Status.PASS: EXIT_OK,
37
+ Status.FAIL: EXIT_FAIL,
38
+ Status.INCONCLUSIVE: EXIT_INCONCLUSIVE,
39
+ }
40
+
41
+
42
+ def _build_parser() -> argparse.ArgumentParser:
43
+ parser = argparse.ArgumentParser(
44
+ prog="sciproof",
45
+ description="Record computational experiments and create verifiable evidence for them.",
46
+ )
47
+ parser.add_argument("--version", action="version", version=f"sciproof {__version__}")
48
+ sub = parser.add_subparsers(dest="command_name", required=True)
49
+
50
+ cap = sub.add_parser(
51
+ "capture",
52
+ help="run a command once and record evidence",
53
+ description=(
54
+ "Run COMMAND once in the current directory and write NAME.sciproof.json. "
55
+ "Everything after '--' is the command (it is not run through a shell)."
56
+ ),
57
+ )
58
+ cap.add_argument("--name", required=True, help="experiment name (also names the manifest)")
59
+ cap.add_argument(
60
+ "--input",
61
+ action="append",
62
+ default=[],
63
+ metavar="PATH",
64
+ help="declare an input file (hashed before and after the run); repeatable",
65
+ )
66
+ cap.add_argument(
67
+ "--output",
68
+ action="append",
69
+ default=[],
70
+ metavar="PATH",
71
+ help="declare an output file compared by exact hash; repeatable",
72
+ )
73
+ cap.add_argument(
74
+ "--output-numeric",
75
+ action="append",
76
+ default=[],
77
+ metavar="PATH",
78
+ help="declare a .json/.csv output compared with a numeric tolerance; repeatable",
79
+ )
80
+ cap.add_argument(
81
+ "--rtol",
82
+ type=float,
83
+ default=DEFAULT_RTOL,
84
+ help=f"relative tolerance for numeric outputs (default {DEFAULT_RTOL})",
85
+ )
86
+ cap.add_argument(
87
+ "--atol",
88
+ type=float,
89
+ default=DEFAULT_ATOL,
90
+ help=f"absolute tolerance for numeric outputs (default {DEFAULT_ATOL})",
91
+ )
92
+ cap.add_argument(
93
+ "--timeout",
94
+ type=float,
95
+ default=None,
96
+ metavar="SECONDS",
97
+ help="stop the command after this many seconds (no evidence is written)",
98
+ )
99
+ cap.add_argument(
100
+ "--max-log-bytes",
101
+ type=int,
102
+ default=DEFAULT_MAX_LOG_BYTES,
103
+ help="keep at most this many bytes of stdout and of stderr in the manifest",
104
+ )
105
+ cap.add_argument("--force", action="store_true", help="overwrite an existing manifest")
106
+ cap.add_argument(
107
+ "--allow-nonzero",
108
+ action="store_true",
109
+ help="exit with status 0 even if the command failed (evidence is written either way)",
110
+ )
111
+ cap.add_argument(
112
+ "cmd", nargs=argparse.REMAINDER, metavar="-- COMMAND [ARGS...]", help="the command to run"
113
+ )
114
+
115
+ ver = sub.add_parser(
116
+ "verify",
117
+ help="check a manifest (integrity by default, re-execution only with --rerun)",
118
+ )
119
+ ver.add_argument("manifest", type=Path)
120
+ ver.add_argument(
121
+ "--rerun",
122
+ action="store_true",
123
+ help="EXECUTE the recorded command again and compare results "
124
+ "(runs arbitrary code with your permissions)",
125
+ )
126
+ ver.add_argument(
127
+ "--yes", action="store_true", help="do not ask for confirmation before --rerun"
128
+ )
129
+ ver.add_argument(
130
+ "--strict-env",
131
+ action="store_true",
132
+ help="with --rerun: report INCONCLUSIVE if the environment differs",
133
+ )
134
+ ver.add_argument(
135
+ "--timeout",
136
+ type=float,
137
+ default=DEFAULT_RERUN_TIMEOUT_SECONDS,
138
+ metavar="SECONDS",
139
+ help="stop the rerun after this many seconds (default %(default)s)",
140
+ )
141
+
142
+ ver.add_argument(
143
+ "--json",
144
+ action="store_true",
145
+ help="print the report as one JSON object (exit codes are unchanged)",
146
+ )
147
+
148
+ ins = sub.add_parser("inspect", help="show a readable summary; never executes anything")
149
+ ins.add_argument("manifest", type=Path)
150
+ return parser
151
+
152
+
153
+ def _confirm(prompt: str) -> bool:
154
+ if not sys.stdin.isatty():
155
+ raise ConfirmationRequired(
156
+ "refusing to execute the recorded command: no terminal to ask for confirmation; "
157
+ "re-run with --yes after reading the command (see 'sciproof inspect')"
158
+ )
159
+ print(prompt, file=sys.stderr) # stderr keeps stdout clean for --json
160
+ answer = input("\nExecute this command? Type 'yes' to continue: ")
161
+ return answer.strip().lower() == "yes"
162
+
163
+
164
+ def _run_capture(args: argparse.Namespace) -> int:
165
+ command = list(args.cmd)
166
+ if command and command[0] == "--":
167
+ command = command[1:]
168
+ if not command:
169
+ raise SciProofError(
170
+ "no command given; usage: sciproof capture --name NAME [options] -- COMMAND"
171
+ )
172
+ result = capture(
173
+ name=args.name,
174
+ argv=command,
175
+ base=Path.cwd(),
176
+ inputs=args.input,
177
+ outputs=args.output,
178
+ numeric_outputs=args.output_numeric,
179
+ rtol=args.rtol,
180
+ atol=args.atol,
181
+ timeout=args.timeout,
182
+ max_log_bytes=args.max_log_bytes,
183
+ force=args.force,
184
+ )
185
+ for warning in result.warnings:
186
+ print(f"warning: {warning}", file=sys.stderr)
187
+ manifest = result.manifest
188
+ print(f"Evidence written to {result.manifest_path.name}")
189
+ print(f" command exit code: {result.exit_code}")
190
+ print(f" inputs recorded: {len(manifest['inputs'])}")
191
+ print(f" outputs recorded: {len(manifest['outputs'])}")
192
+ print(
193
+ "Note: stdout/stderr are stored in the manifest; check them for secrets before sharing it."
194
+ )
195
+ if result.exit_code != 0:
196
+ print(
197
+ f"The command failed (exit code {result.exit_code}); the failure was recorded.",
198
+ file=sys.stderr,
199
+ )
200
+ return EXIT_OK if args.allow_nonzero else EXIT_FAIL
201
+ return EXIT_OK
202
+
203
+
204
+ def _run_verify(args: argparse.Namespace) -> int:
205
+ report = verify(
206
+ args.manifest,
207
+ rerun=args.rerun,
208
+ assume_yes=args.yes,
209
+ strict_env=args.strict_env,
210
+ timeout=args.timeout,
211
+ confirm=_confirm if args.rerun and not args.yes else None,
212
+ )
213
+ print(format_report_json(report) if args.json else format_report(report))
214
+ return _STATUS_EXIT[report.status]
215
+
216
+
217
+ def _make_output_robust() -> None:
218
+ """Never crash because the terminal cannot encode a character from a manifest or a log.
219
+
220
+ Captured stdout/stderr and file names can contain any character, while a console may use
221
+ a narrow encoding (for example cp1252 on Windows). Unencodable characters are printed as
222
+ escapes (``\u2713``) instead of raising ``UnicodeEncodeError``.
223
+ """
224
+ for stream in (sys.stdout, sys.stderr):
225
+ reconfigure = getattr(stream, "reconfigure", None)
226
+ if reconfigure is not None:
227
+ reconfigure(errors="backslashreplace")
228
+
229
+
230
+ def main(argv: list[str] | None = None) -> int:
231
+ _make_output_robust()
232
+ parser = _build_parser()
233
+ args = parser.parse_args(argv)
234
+ try:
235
+ if args.command_name == "capture":
236
+ return _run_capture(args)
237
+ if args.command_name == "verify":
238
+ return _run_verify(args)
239
+ print(inspect_file(args.manifest))
240
+ return EXIT_OK
241
+ except SciProofError as exc:
242
+ print(f"error: {safe_text(str(exc), 2000)}", file=sys.stderr)
243
+ return EXIT_ERROR
244
+ except KeyboardInterrupt:
245
+ print("interrupted", file=sys.stderr)
246
+ return EXIT_ERROR
247
+ except EOFError:
248
+ print("error: no confirmation received", file=sys.stderr)
249
+ return EXIT_ERROR
@@ -0,0 +1,123 @@
1
+ """Collect environment metadata without leaking secrets.
2
+
3
+ Only an explicit allowlist of environment variables is ever recorded. Absolute paths
4
+ (interpreter location, home directory, user name) are deliberately left out.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import platform
12
+ import re
13
+ import shutil
14
+ import subprocess # noqa: S404 (used to ask the recorded interpreter for its version)
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ # Variables that influence numerical or locale behaviour and are not secret.
20
+ ENV_ALLOWLIST = (
21
+ "PYTHONHASHSEED",
22
+ "TZ",
23
+ "LANG",
24
+ "LC_ALL",
25
+ "OMP_NUM_THREADS",
26
+ "MKL_NUM_THREADS",
27
+ "OPENBLAS_NUM_THREADS",
28
+ "NUMEXPR_NUM_THREADS",
29
+ "CUDA_VISIBLE_DEVICES",
30
+ )
31
+
32
+ PROBE_TIMEOUT_SECONDS = 30
33
+
34
+ # Executed by the interpreter that runs the experiment. It prints one JSON document.
35
+ _PROBE_SCRIPT = r"""
36
+ import importlib.metadata as md, json, platform, sys
37
+ seen = {}
38
+ for dist in md.distributions():
39
+ name = (dist.metadata["Name"] or "").strip()
40
+ if name:
41
+ seen[name.lower().replace("_", "-")] = [name, dist.version]
42
+ print(json.dumps({
43
+ "version": platform.python_version(),
44
+ "implementation": platform.python_implementation(),
45
+ "byteorder": sys.byteorder,
46
+ "packages": sorted(seen.values(), key=lambda item: item[0].lower()),
47
+ }))
48
+ """
49
+
50
+
51
+ def platform_info() -> dict[str, str]:
52
+ return {
53
+ "system": platform.system(),
54
+ "release": platform.release(),
55
+ "machine": platform.machine(),
56
+ "platform": platform.platform(),
57
+ }
58
+
59
+
60
+ def selected_env_vars(environ: dict[str, str] | None = None) -> dict[str, str]:
61
+ environ = os.environ if environ is None else environ
62
+ return {name: environ[name] for name in ENV_ALLOWLIST if name in environ}
63
+
64
+
65
+ def _own_interpreter_info() -> dict[str, Any]:
66
+ import importlib.metadata as md
67
+
68
+ seen: dict[str, list[str]] = {}
69
+ for dist in md.distributions():
70
+ name = (dist.metadata["Name"] or "").strip()
71
+ if name:
72
+ seen[name.lower().replace("_", "-")] = [name, dist.version]
73
+ return {
74
+ "version": platform.python_version(),
75
+ "implementation": platform.python_implementation(),
76
+ "byteorder": sys.byteorder,
77
+ "packages": sorted(seen.values(), key=lambda item: item[0].lower()),
78
+ }
79
+
80
+
81
+ _INTERPRETER_NAME = re.compile(r"^(py|pythonw?(\d+(\.\d+)*[dt]*)?|pypy(\d+(\.\d+)*)?)$")
82
+
83
+
84
+ def _looks_like_python(executable: str) -> bool:
85
+ """True only for names of real interpreters (``python``, ``python3.13``, ``py``, ``pypy3``).
86
+
87
+ The probe runs ``<executable> -c <snippet>``; running it on ``python_train.sh`` or
88
+ ``python-config`` would execute the user's own program with unexpected arguments.
89
+ """
90
+ name = Path(executable).name.lower()
91
+ if name.endswith(".exe"):
92
+ name = name[:-4]
93
+ return bool(_INTERPRETER_NAME.fullmatch(name))
94
+
95
+
96
+ def interpreter_info(argv0: str, cwd: Path) -> tuple[dict[str, Any], str]:
97
+ """Describe the Python interpreter that runs the experiment.
98
+
99
+ If ``argv0`` is a Python interpreter, it is asked for its own version and installed
100
+ packages (this runs a fixed, built-in snippet and not the experiment). Otherwise the
101
+ interpreter that runs SciProof is described and the source is reported as such.
102
+
103
+ Returns ``(info, source)`` where source is ``"command-interpreter"`` or
104
+ ``"sciproof-process"``.
105
+ """
106
+ resolved = shutil.which(argv0, path=None) if _looks_like_python(argv0) else None
107
+ if resolved is None and _looks_like_python(argv0) and Path(argv0).exists():
108
+ resolved = argv0
109
+ if resolved is not None:
110
+ try:
111
+ completed = subprocess.run( # noqa: S603
112
+ [resolved, "-c", _PROBE_SCRIPT],
113
+ cwd=cwd,
114
+ capture_output=True,
115
+ text=True,
116
+ timeout=PROBE_TIMEOUT_SECONDS,
117
+ check=True,
118
+ )
119
+ info = json.loads(completed.stdout)
120
+ return info, "command-interpreter"
121
+ except (OSError, subprocess.SubprocessError, ValueError):
122
+ pass
123
+ return _own_interpreter_info(), "sciproof-process"
sciproof/errors.py ADDED
@@ -0,0 +1,25 @@
1
+ """Exception types used by SciProof."""
2
+
3
+
4
+ class SciProofError(Exception):
5
+ """Base class for all expected SciProof errors (shown to the user without a traceback)."""
6
+
7
+
8
+ class ManifestError(SciProofError):
9
+ """The manifest is missing, malformed, unsupported or contains unsafe values."""
10
+
11
+
12
+ class UnsafePathError(SciProofError):
13
+ """A path is absolute, escapes the base directory, or otherwise cannot be trusted."""
14
+
15
+
16
+ class CaptureError(SciProofError):
17
+ """The experiment could not be captured."""
18
+
19
+
20
+ class UsageError(SciProofError):
21
+ """An option has an invalid value."""
22
+
23
+
24
+ class ConfirmationRequired(SciProofError):
25
+ """Re-execution was requested but not confirmed by the user."""