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 +7 -0
- sciproof/__main__.py +6 -0
- sciproof/capture.py +263 -0
- sciproof/cli.py +249 -0
- sciproof/environment.py +123 -0
- sciproof/errors.py +25 -0
- sciproof/gitinfo.py +42 -0
- sciproof/hashing.py +23 -0
- sciproof/inspect_manifest.py +98 -0
- sciproof/manifest.py +216 -0
- sciproof/numeric.py +189 -0
- sciproof/paths.py +61 -0
- sciproof/runner.py +72 -0
- sciproof/textutil.py +21 -0
- sciproof/verify.py +549 -0
- sciproof-0.1.0.dist-info/METADATA +218 -0
- sciproof-0.1.0.dist-info/RECORD +21 -0
- sciproof-0.1.0.dist-info/WHEEL +5 -0
- sciproof-0.1.0.dist-info/entry_points.txt +2 -0
- sciproof-0.1.0.dist-info/licenses/LICENSE +21 -0
- sciproof-0.1.0.dist-info/top_level.txt +1 -0
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
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
|
sciproof/environment.py
ADDED
|
@@ -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."""
|