runproof-engine 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.
- runproof_engine/__init__.py +33 -0
- runproof_engine/cli.py +69 -0
- runproof_engine/core.py +557 -0
- runproof_engine/diff.py +197 -0
- runproof_engine/policy.py +29 -0
- runproof_engine/py.typed +0 -0
- runproof_engine/replay.py +131 -0
- runproof_engine/utils.py +197 -0
- runproof_engine-0.1.0.dist-info/LICENSE +151 -0
- runproof_engine-0.1.0.dist-info/METADATA +97 -0
- runproof_engine-0.1.0.dist-info/RECORD +14 -0
- runproof_engine-0.1.0.dist-info/WHEEL +5 -0
- runproof_engine-0.1.0.dist-info/entry_points.txt +2 -0
- runproof_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from .core import (
|
|
2
|
+
CheckRecord,
|
|
3
|
+
ReplayUnavailable,
|
|
4
|
+
RunContext,
|
|
5
|
+
RunProofError,
|
|
6
|
+
RunResult,
|
|
7
|
+
StepRecord,
|
|
8
|
+
verified,
|
|
9
|
+
)
|
|
10
|
+
from .diff import Difference, RunDiff, compare_manifests
|
|
11
|
+
from .policy import Policy, PolicyDenied, safe_default_policy
|
|
12
|
+
from .replay import LoadedRun, ReplayReport, load_run
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CheckRecord",
|
|
16
|
+
"Difference",
|
|
17
|
+
"LoadedRun",
|
|
18
|
+
"Policy",
|
|
19
|
+
"PolicyDenied",
|
|
20
|
+
"ReplayReport",
|
|
21
|
+
"ReplayUnavailable",
|
|
22
|
+
"RunContext",
|
|
23
|
+
"RunDiff",
|
|
24
|
+
"RunProofError",
|
|
25
|
+
"RunResult",
|
|
26
|
+
"StepRecord",
|
|
27
|
+
"compare_manifests",
|
|
28
|
+
"load_run",
|
|
29
|
+
"safe_default_policy",
|
|
30
|
+
"verified",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
runproof_engine/cli.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .replay import LoadedRun, load_run
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
12
|
+
parser = argparse.ArgumentParser(prog="runproof", description="Inspect and compare RunProof artifacts")
|
|
13
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
14
|
+
|
|
15
|
+
inspect_parser = subparsers.add_parser("inspect", help="inspect a run artifact")
|
|
16
|
+
inspect_parser.add_argument("path", type=Path)
|
|
17
|
+
inspect_parser.add_argument("--json", action="store_true", dest="as_json")
|
|
18
|
+
|
|
19
|
+
verify_parser = subparsers.add_parser("verify", help="verify input and output integrity")
|
|
20
|
+
verify_parser.add_argument("path", type=Path)
|
|
21
|
+
verify_parser.add_argument("--json", action="store_true", dest="as_json")
|
|
22
|
+
|
|
23
|
+
diff_parser = subparsers.add_parser("diff", help="compare two run artifacts")
|
|
24
|
+
diff_parser.add_argument("left", type=Path)
|
|
25
|
+
diff_parser.add_argument("right", type=Path)
|
|
26
|
+
diff_parser.add_argument("--json", action="store_true", dest="as_json")
|
|
27
|
+
return parser
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main(argv: list[str] | None = None) -> int:
|
|
31
|
+
parser = build_parser()
|
|
32
|
+
args = parser.parse_args(argv)
|
|
33
|
+
try:
|
|
34
|
+
if args.command == "inspect":
|
|
35
|
+
run = load_run(args.path)
|
|
36
|
+
payload = run.manifest
|
|
37
|
+
text = json.dumps(payload, indent=2, ensure_ascii=False) if args.as_json else _inspect_text(run)
|
|
38
|
+
elif args.command == "verify":
|
|
39
|
+
run = load_run(args.path)
|
|
40
|
+
report = run.verify_integrity()
|
|
41
|
+
payload = report.to_dict()
|
|
42
|
+
text = json.dumps(payload, indent=2, ensure_ascii=False) if args.as_json else str(report)
|
|
43
|
+
print(text)
|
|
44
|
+
return 0 if report.status == "verified" else 2
|
|
45
|
+
else:
|
|
46
|
+
left = load_run(args.left)
|
|
47
|
+
right = load_run(args.right)
|
|
48
|
+
comparison = left.diff(right)
|
|
49
|
+
payload = comparison.to_dict()
|
|
50
|
+
text = json.dumps(payload, indent=2, ensure_ascii=False) if args.as_json else comparison.render()
|
|
51
|
+
print(text)
|
|
52
|
+
return 0
|
|
53
|
+
except (OSError, ValueError, KeyError) as error:
|
|
54
|
+
print(f"runproof: {error}", file=sys.stderr)
|
|
55
|
+
return 2
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _inspect_text(run: LoadedRun) -> str:
|
|
59
|
+
manifest = run.manifest
|
|
60
|
+
run_info = manifest.get("run", {})
|
|
61
|
+
return "\n".join([
|
|
62
|
+
f"name: {run_info.get('name')}",
|
|
63
|
+
f"run_id: {run_info.get('run_id')}",
|
|
64
|
+
f"status: {run_info.get('status')}",
|
|
65
|
+
f"inputs: {len(manifest.get('inputs', []))}",
|
|
66
|
+
f"steps: {len(manifest.get('steps', []))}",
|
|
67
|
+
f"outputs: {len(manifest.get('outputs', []))}",
|
|
68
|
+
f"checks: {len(manifest.get('checks', []))}",
|
|
69
|
+
])
|
runproof_engine/core.py
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import traceback
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from time import perf_counter
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Any, Self
|
|
13
|
+
from urllib.error import HTTPError, URLError
|
|
14
|
+
from urllib.request import Request, urlopen
|
|
15
|
+
|
|
16
|
+
from .policy import Policy, PolicyDenied, safe_default_policy
|
|
17
|
+
from .utils import (
|
|
18
|
+
environment_snapshot,
|
|
19
|
+
file_metadata,
|
|
20
|
+
fingerprint,
|
|
21
|
+
function_descriptor,
|
|
22
|
+
safe_value,
|
|
23
|
+
sha256_bytes,
|
|
24
|
+
sha256_file,
|
|
25
|
+
summarize,
|
|
26
|
+
utc_now,
|
|
27
|
+
write_json,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RunProofError(RuntimeError):
|
|
32
|
+
"""Base exception for RunProof failures."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ReplayUnavailable(RunProofError):
|
|
36
|
+
"""Raised when an artifact does not contain enough information for replay."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class CheckRecord:
|
|
41
|
+
name: str
|
|
42
|
+
passed: bool
|
|
43
|
+
message: str
|
|
44
|
+
created_at: str = field(default_factory=utc_now)
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"name": self.name,
|
|
49
|
+
"passed": self.passed,
|
|
50
|
+
"message": self.message,
|
|
51
|
+
"created_at": self.created_at,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class StepRecord:
|
|
57
|
+
name: str
|
|
58
|
+
status: str
|
|
59
|
+
started_at: str
|
|
60
|
+
finished_at: str
|
|
61
|
+
duration_ms: float
|
|
62
|
+
function: dict[str, Any]
|
|
63
|
+
input_summaries: list[dict[str, Any]]
|
|
64
|
+
output_summary: dict[str, Any] | None = None
|
|
65
|
+
error: dict[str, Any] | None = None
|
|
66
|
+
replayable: bool = False
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict[str, Any]:
|
|
69
|
+
return {
|
|
70
|
+
"name": self.name,
|
|
71
|
+
"status": self.status,
|
|
72
|
+
"started_at": self.started_at,
|
|
73
|
+
"finished_at": self.finished_at,
|
|
74
|
+
"duration_ms": round(self.duration_ms, 3),
|
|
75
|
+
"function": self.function,
|
|
76
|
+
"inputs": self.input_summaries,
|
|
77
|
+
"output": self.output_summary,
|
|
78
|
+
"error": self.error,
|
|
79
|
+
"replayable": self.replayable,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class RunResult:
|
|
85
|
+
run_id: str
|
|
86
|
+
name: str
|
|
87
|
+
status: str
|
|
88
|
+
artifact_dir: Path
|
|
89
|
+
started_at: str
|
|
90
|
+
finished_at: str | None = None
|
|
91
|
+
error: dict[str, Any] | None = None
|
|
92
|
+
|
|
93
|
+
def to_dict(self) -> dict[str, Any]:
|
|
94
|
+
return {
|
|
95
|
+
"run_id": self.run_id,
|
|
96
|
+
"name": self.name,
|
|
97
|
+
"status": self.status,
|
|
98
|
+
"artifact_dir": str(self.artifact_dir),
|
|
99
|
+
"started_at": self.started_at,
|
|
100
|
+
"finished_at": self.finished_at,
|
|
101
|
+
"error": self.error,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def summary(self) -> str:
|
|
105
|
+
return (
|
|
106
|
+
f"status={self.status} name={self.name} run_id={self.run_id} "
|
|
107
|
+
f"artifact_dir={self.artifact_dir}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class RunContext:
|
|
112
|
+
"""Record a declared Python workflow as a real, inspectable run artifact."""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
name: str,
|
|
117
|
+
*,
|
|
118
|
+
root: str | Path = "runs",
|
|
119
|
+
copy_inputs: bool = False,
|
|
120
|
+
capture_environment: bool = True,
|
|
121
|
+
fail_on_check: bool = False,
|
|
122
|
+
policy: Policy | None = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
self.name = name
|
|
125
|
+
self.root = Path(root)
|
|
126
|
+
self.copy_inputs = copy_inputs
|
|
127
|
+
self.capture_environment = capture_environment
|
|
128
|
+
self.fail_on_check = fail_on_check
|
|
129
|
+
self.policy = policy or safe_default_policy()
|
|
130
|
+
self.run_id = f"{utc_now().replace(':', '').replace('-', '')}-{uuid.uuid4().hex[:10]}"
|
|
131
|
+
self.artifact_dir = self.root / self._safe_name(name) / self.run_id
|
|
132
|
+
self._started_at = utc_now()
|
|
133
|
+
self._started_clock = 0.0
|
|
134
|
+
self._finished_at: str | None = None
|
|
135
|
+
self._steps: list[StepRecord] = []
|
|
136
|
+
self._checks: list[CheckRecord] = []
|
|
137
|
+
self._inputs: list[dict[str, Any]] = []
|
|
138
|
+
self._outputs: list[dict[str, Any]] = []
|
|
139
|
+
self._observations: list[dict[str, Any]] = []
|
|
140
|
+
self._events: list[dict[str, Any]] = []
|
|
141
|
+
self._environment: dict[str, Any] | None = None
|
|
142
|
+
self._status = "running"
|
|
143
|
+
self._error: dict[str, Any] | None = None
|
|
144
|
+
self.result: RunResult = RunResult(
|
|
145
|
+
run_id=self.run_id,
|
|
146
|
+
name=self.name,
|
|
147
|
+
status=self._status,
|
|
148
|
+
artifact_dir=self.artifact_dir,
|
|
149
|
+
started_at=self._started_at,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _safe_name(value: str) -> str:
|
|
154
|
+
safe = "".join(character if character.isalnum() or character in "-_" else "_" for character in value)
|
|
155
|
+
return safe.strip("_") or "run"
|
|
156
|
+
|
|
157
|
+
def __enter__(self) -> Self:
|
|
158
|
+
self.artifact_dir.mkdir(parents=True, exist_ok=False)
|
|
159
|
+
for directory in ("inputs", "outputs", "checks", "execution", "environment"):
|
|
160
|
+
(self.artifact_dir / directory).mkdir()
|
|
161
|
+
self._started_clock = perf_counter()
|
|
162
|
+
self._event("run_started", {"name": self.name})
|
|
163
|
+
if self.capture_environment:
|
|
164
|
+
self._environment = environment_snapshot()
|
|
165
|
+
write_json(self.artifact_dir / "environment" / "snapshot.json", self._environment)
|
|
166
|
+
return self
|
|
167
|
+
|
|
168
|
+
def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> bool:
|
|
169
|
+
if isinstance(exc, PolicyDenied):
|
|
170
|
+
self._status = "blocked"
|
|
171
|
+
self._error = {
|
|
172
|
+
"type": f"{type(exc).__module__}.{type(exc).__qualname__}",
|
|
173
|
+
"message": str(exc),
|
|
174
|
+
}
|
|
175
|
+
self._event("action_blocked", self._error)
|
|
176
|
+
elif exc is not None:
|
|
177
|
+
self._status = "failed"
|
|
178
|
+
self._error = {
|
|
179
|
+
"type": f"{type(exc).__module__}.{type(exc).__qualname__}",
|
|
180
|
+
"message": str(exc),
|
|
181
|
+
"traceback": "".join(traceback.format_exception(type(exc), exc, tb)),
|
|
182
|
+
}
|
|
183
|
+
self._event("run_failed", self._error)
|
|
184
|
+
elif any(not check.passed for check in self._checks):
|
|
185
|
+
self._status = "failed" if self.fail_on_check else "verified_with_warnings"
|
|
186
|
+
self._event("checks_completed", {"failed": True})
|
|
187
|
+
else:
|
|
188
|
+
self._status = "verified"
|
|
189
|
+
self._event("checks_completed", {"failed": False})
|
|
190
|
+
self._finalize()
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
def _event(self, event_type: str, payload: dict[str, Any]) -> None:
|
|
194
|
+
self._events.append({
|
|
195
|
+
"event": event_type,
|
|
196
|
+
"at": utc_now(),
|
|
197
|
+
"payload": safe_value(payload),
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
def authorize(self, action: str, *, approved: bool = False, target: str | None = None) -> dict[str, Any]:
|
|
201
|
+
"""Authorize a sensitive action and record the decision in the trace."""
|
|
202
|
+
try:
|
|
203
|
+
decision = self.policy.authorize(action, approved=approved, target=target)
|
|
204
|
+
except PolicyDenied as error:
|
|
205
|
+
self._event("action_blocked", {"action": action, "target": target, "reason": str(error)})
|
|
206
|
+
raise
|
|
207
|
+
self._event("action_authorized", decision)
|
|
208
|
+
return decision
|
|
209
|
+
|
|
210
|
+
def input(self, path: str | Path, *, name: str | None = None, copy: bool | None = None) -> Path:
|
|
211
|
+
"""Register a real file input and return its resolved path."""
|
|
212
|
+
should_copy = self.copy_inputs if copy is None else copy
|
|
213
|
+
record_name = name or Path(path).stem or "input"
|
|
214
|
+
metadata = file_metadata(path)
|
|
215
|
+
destination: str | None = None
|
|
216
|
+
if should_copy:
|
|
217
|
+
target = self.artifact_dir / "inputs" / Path(path).name
|
|
218
|
+
if target.exists():
|
|
219
|
+
target = self.artifact_dir / "inputs" / f"{fingerprint(str(path))[:8]}-{Path(path).name}"
|
|
220
|
+
shutil.copy2(path, target)
|
|
221
|
+
destination = str(target.relative_to(self.artifact_dir))
|
|
222
|
+
metadata["captured_copy"] = destination
|
|
223
|
+
metadata["name"] = record_name
|
|
224
|
+
self._inputs.append(metadata)
|
|
225
|
+
self._event("input_registered", metadata)
|
|
226
|
+
write_json(self.artifact_dir / "inputs" / f"{self._safe_name(record_name)}.json", metadata)
|
|
227
|
+
return Path(metadata["path"])
|
|
228
|
+
|
|
229
|
+
def step(self, name: str, function: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
|
230
|
+
"""Execute a callable and record a bounded, privacy-safe step trace."""
|
|
231
|
+
started_at = utc_now()
|
|
232
|
+
clock = perf_counter()
|
|
233
|
+
descriptor = function_descriptor(function)
|
|
234
|
+
self._event("step_started", {"name": name, "function": descriptor})
|
|
235
|
+
try:
|
|
236
|
+
output = function(*args, **kwargs)
|
|
237
|
+
finished_at = utc_now()
|
|
238
|
+
record = StepRecord(
|
|
239
|
+
name=name,
|
|
240
|
+
status="completed",
|
|
241
|
+
started_at=started_at,
|
|
242
|
+
finished_at=finished_at,
|
|
243
|
+
duration_ms=(perf_counter() - clock) * 1000,
|
|
244
|
+
function=descriptor,
|
|
245
|
+
input_summaries=[summarize(value) for value in args],
|
|
246
|
+
output_summary=summarize(output),
|
|
247
|
+
replayable=self._is_json_replayable(args, kwargs, output),
|
|
248
|
+
)
|
|
249
|
+
self._steps.append(record)
|
|
250
|
+
self._event("step_completed", {"name": name, "output": record.output_summary})
|
|
251
|
+
return output
|
|
252
|
+
except Exception as error:
|
|
253
|
+
finished_at = utc_now()
|
|
254
|
+
record = StepRecord(
|
|
255
|
+
name=name,
|
|
256
|
+
status="failed",
|
|
257
|
+
started_at=started_at,
|
|
258
|
+
finished_at=finished_at,
|
|
259
|
+
duration_ms=(perf_counter() - clock) * 1000,
|
|
260
|
+
function=descriptor,
|
|
261
|
+
input_summaries=[summarize(value) for value in args],
|
|
262
|
+
error={
|
|
263
|
+
"type": f"{type(error).__module__}.{type(error).__qualname__}",
|
|
264
|
+
"message": str(error),
|
|
265
|
+
},
|
|
266
|
+
)
|
|
267
|
+
self._steps.append(record)
|
|
268
|
+
self._event("step_failed", {"name": name, "error": record.error or {}})
|
|
269
|
+
raise
|
|
270
|
+
|
|
271
|
+
def output(self, path: str | Path, value: Any, *, name: str | None = None) -> Path:
|
|
272
|
+
"""Write a JSON-safe output inside the artifact and record its fingerprint."""
|
|
273
|
+
target = self._output_target(path)
|
|
274
|
+
if target.suffix.lower() not in {".json", ".jsonl"}:
|
|
275
|
+
raise RunProofError("RunContext.output currently supports .json or .jsonl; use save_file for binary/text artifacts")
|
|
276
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
277
|
+
target.write_text(json.dumps(value, indent=2, ensure_ascii=False, default=str) + "\n", encoding="utf-8")
|
|
278
|
+
record = {
|
|
279
|
+
"name": name or target.stem,
|
|
280
|
+
"path": str(target.relative_to(self.artifact_dir)),
|
|
281
|
+
"size_bytes": target.stat().st_size,
|
|
282
|
+
"sha256": file_metadata(target)["sha256"],
|
|
283
|
+
"value_summary": summarize(value),
|
|
284
|
+
}
|
|
285
|
+
self._outputs.append(record)
|
|
286
|
+
self._event("output_saved", record)
|
|
287
|
+
return target
|
|
288
|
+
|
|
289
|
+
def save_file(self, path: str | Path, *, source: str | Path | None = None, content: str | bytes | None = None) -> Path:
|
|
290
|
+
"""Save a real text or binary artifact and register its hash."""
|
|
291
|
+
target = self._output_target(path)
|
|
292
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
293
|
+
if source is not None and content is not None:
|
|
294
|
+
raise ValueError("provide source or content, not both")
|
|
295
|
+
if source is not None:
|
|
296
|
+
shutil.copy2(source, target)
|
|
297
|
+
elif isinstance(content, bytes):
|
|
298
|
+
target.write_bytes(content)
|
|
299
|
+
elif isinstance(content, str):
|
|
300
|
+
target.write_text(content, encoding="utf-8")
|
|
301
|
+
else:
|
|
302
|
+
raise ValueError("source or content is required")
|
|
303
|
+
record = {
|
|
304
|
+
"name": target.stem,
|
|
305
|
+
"path": str(target.relative_to(self.artifact_dir)),
|
|
306
|
+
"size_bytes": target.stat().st_size,
|
|
307
|
+
"sha256": file_metadata(target)["sha256"],
|
|
308
|
+
}
|
|
309
|
+
self._outputs.append(record)
|
|
310
|
+
self._event("file_saved", record)
|
|
311
|
+
return target
|
|
312
|
+
|
|
313
|
+
def observe(self, value: Any, *, name: str) -> Any:
|
|
314
|
+
"""Record a bounded summary of an in-memory value without copying its full contents."""
|
|
315
|
+
record = {"name": name, "summary": summarize(value)}
|
|
316
|
+
self._observations.append(record)
|
|
317
|
+
self._event("value_observed", record)
|
|
318
|
+
return value
|
|
319
|
+
|
|
320
|
+
def external_call(
|
|
321
|
+
self,
|
|
322
|
+
name: str,
|
|
323
|
+
*,
|
|
324
|
+
provider: str,
|
|
325
|
+
request: Any,
|
|
326
|
+
response: Any,
|
|
327
|
+
status_code: int | None = None,
|
|
328
|
+
approved: bool = False,
|
|
329
|
+
_authorized: bool = False,
|
|
330
|
+
) -> Any:
|
|
331
|
+
"""Record a completed real external call after policy authorization.
|
|
332
|
+
|
|
333
|
+
The core package never performs the network request and never stores
|
|
334
|
+
credentials. The caller performs the request, then gives RunProof a
|
|
335
|
+
privacy-safe request and the response to fingerprint and summarize.
|
|
336
|
+
"""
|
|
337
|
+
if not _authorized:
|
|
338
|
+
self.authorize("network", approved=approved, target=provider)
|
|
339
|
+
response_summary = summarize(response)
|
|
340
|
+
response_summary.pop("value", None)
|
|
341
|
+
record = {
|
|
342
|
+
"name": name,
|
|
343
|
+
"provider": provider,
|
|
344
|
+
"request": safe_value(request),
|
|
345
|
+
"response": response_summary,
|
|
346
|
+
"response_fingerprint": fingerprint(response),
|
|
347
|
+
"status_code": status_code,
|
|
348
|
+
}
|
|
349
|
+
self._event("external_call", record)
|
|
350
|
+
return response
|
|
351
|
+
|
|
352
|
+
def request(
|
|
353
|
+
self,
|
|
354
|
+
name: str,
|
|
355
|
+
url: str,
|
|
356
|
+
*,
|
|
357
|
+
method: str = "GET",
|
|
358
|
+
headers: dict[str, str] | None = None,
|
|
359
|
+
body: bytes | str | dict[str, Any] | None = None,
|
|
360
|
+
timeout: float = 30.0,
|
|
361
|
+
approved: bool = False,
|
|
362
|
+
) -> Any:
|
|
363
|
+
"""Perform and record a real HTTP request after explicit authorization."""
|
|
364
|
+
self.authorize("network", approved=approved, target=url)
|
|
365
|
+
request_headers = dict(headers or {})
|
|
366
|
+
safe_headers = {
|
|
367
|
+
key: "[REDACTED]" if key.lower() in {"authorization", "proxy-authorization", "cookie", "set-cookie"} else value
|
|
368
|
+
for key, value in request_headers.items()
|
|
369
|
+
}
|
|
370
|
+
if isinstance(body, dict):
|
|
371
|
+
request_body = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
372
|
+
request_headers.setdefault("Content-Type", "application/json")
|
|
373
|
+
elif isinstance(body, str):
|
|
374
|
+
request_body = body.encode("utf-8")
|
|
375
|
+
else:
|
|
376
|
+
request_body = body
|
|
377
|
+
request = Request(url, data=request_body, headers=request_headers, method=method.upper())
|
|
378
|
+
request_meta = {
|
|
379
|
+
"method": method.upper(),
|
|
380
|
+
"url": url,
|
|
381
|
+
"headers": safe_headers,
|
|
382
|
+
"body_sha256": sha256_bytes(request_body) if request_body else None,
|
|
383
|
+
}
|
|
384
|
+
try:
|
|
385
|
+
with urlopen(request, timeout=timeout) as response:
|
|
386
|
+
raw = response.read(10 * 1024 * 1024 + 1)
|
|
387
|
+
truncated = len(raw) > 10 * 1024 * 1024
|
|
388
|
+
raw = raw[:10 * 1024 * 1024]
|
|
389
|
+
content_type = response.headers.get("Content-Type", "")
|
|
390
|
+
text = raw.decode("utf-8", errors="replace")
|
|
391
|
+
parsed: Any = text
|
|
392
|
+
if "json" in content_type.lower():
|
|
393
|
+
try:
|
|
394
|
+
parsed = json.loads(text)
|
|
395
|
+
except json.JSONDecodeError:
|
|
396
|
+
parsed = text
|
|
397
|
+
self.external_call(
|
|
398
|
+
name,
|
|
399
|
+
provider=url,
|
|
400
|
+
request=request_meta,
|
|
401
|
+
response=parsed,
|
|
402
|
+
status_code=getattr(response, "status", None),
|
|
403
|
+
approved=True,
|
|
404
|
+
_authorized=True,
|
|
405
|
+
)
|
|
406
|
+
self._event("external_response", {"name": name, "content_type": content_type, "truncated": truncated, "bytes": len(raw)})
|
|
407
|
+
return parsed
|
|
408
|
+
except (HTTPError, URLError, TimeoutError) as error:
|
|
409
|
+
self._event("external_call_failed", {
|
|
410
|
+
"name": name,
|
|
411
|
+
"provider": url,
|
|
412
|
+
"error_type": type(error).__name__,
|
|
413
|
+
"message": str(error),
|
|
414
|
+
})
|
|
415
|
+
raise
|
|
416
|
+
|
|
417
|
+
def check_schema(self, value: Any, *, required_columns: list[str], types: dict[str, str] | None = None) -> bool:
|
|
418
|
+
columns = getattr(value, "columns", None)
|
|
419
|
+
actual = [str(column) for column in list(columns)] if columns is not None else []
|
|
420
|
+
missing = [column for column in required_columns if column not in actual]
|
|
421
|
+
passed = not missing
|
|
422
|
+
message = "schema contains required columns" if passed else f"missing columns: {missing}"
|
|
423
|
+
if passed and types:
|
|
424
|
+
dtypes = getattr(value, "dtypes", {})
|
|
425
|
+
mismatches = []
|
|
426
|
+
for column, expected in types.items():
|
|
427
|
+
if column in dtypes and not self._type_matches(dtypes[column], expected):
|
|
428
|
+
mismatches.append(f"{column}: expected {expected}, got {dtypes[column]}")
|
|
429
|
+
if mismatches:
|
|
430
|
+
passed = False
|
|
431
|
+
message = "; ".join(mismatches)
|
|
432
|
+
return self._record_check("schema", passed, message)
|
|
433
|
+
|
|
434
|
+
@staticmethod
|
|
435
|
+
def _type_matches(actual: Any, expected: str) -> bool:
|
|
436
|
+
actual_text = str(actual).lower()
|
|
437
|
+
expected_text = expected.lower()
|
|
438
|
+
aliases = {
|
|
439
|
+
"number": ("int", "float", "decimal", "double", "number"),
|
|
440
|
+
"integer": ("int", "integer"),
|
|
441
|
+
"float": ("float", "double"),
|
|
442
|
+
"string": ("object", "string", "str", "unicode"),
|
|
443
|
+
"datetime": ("datetime", "date", "time"),
|
|
444
|
+
"boolean": ("bool", "boolean"),
|
|
445
|
+
}
|
|
446
|
+
tokens = aliases.get(expected_text, (expected_text,))
|
|
447
|
+
return any(token in actual_text for token in tokens)
|
|
448
|
+
|
|
449
|
+
def assert_true(self, condition: bool, message: str = "assertion passed", *, name: str | None = None) -> bool:
|
|
450
|
+
return self._record_check(name or "assert_true", bool(condition), message if condition else f"FAILED: {message}")
|
|
451
|
+
|
|
452
|
+
def assert_columns(self, value: Any, columns: list[str]) -> bool:
|
|
453
|
+
actual = [str(column) for column in list(getattr(value, "columns", []))]
|
|
454
|
+
missing = [column for column in columns if column not in actual]
|
|
455
|
+
return self._record_check("columns", not missing, "columns present" if not missing else f"missing columns: {missing}")
|
|
456
|
+
|
|
457
|
+
def assert_non_negative(self, value: Any, *, name: str = "non_negative") -> bool:
|
|
458
|
+
try:
|
|
459
|
+
passed = bool((value >= 0).all()) if hasattr(value, "all") else all(item >= 0 for item in value)
|
|
460
|
+
except (AttributeError, TypeError, ValueError) as error:
|
|
461
|
+
return self._record_check(name, False, f"unable to evaluate: {error}")
|
|
462
|
+
return self._record_check(name, passed, "all values are non-negative" if passed else "negative value found")
|
|
463
|
+
|
|
464
|
+
def assert_file(self, path: str | Path) -> bool:
|
|
465
|
+
target = self._artifact_target(path)
|
|
466
|
+
passed = target.is_file()
|
|
467
|
+
return self._record_check("file_exists", passed, f"file exists: {target.name}" if passed else f"missing file: {target}")
|
|
468
|
+
|
|
469
|
+
def _record_check(self, name: str, passed: bool, message: str) -> bool:
|
|
470
|
+
check = CheckRecord(name=name, passed=passed, message=message)
|
|
471
|
+
self._checks.append(check)
|
|
472
|
+
self._event("check", check.to_dict())
|
|
473
|
+
return passed
|
|
474
|
+
|
|
475
|
+
def _output_target(self, path: str | Path) -> Path:
|
|
476
|
+
relative = Path(path)
|
|
477
|
+
if relative.is_absolute():
|
|
478
|
+
raise RunProofError("output paths must be relative")
|
|
479
|
+
if relative.parts and relative.parts[0] == "outputs":
|
|
480
|
+
relative = Path(*relative.parts[1:])
|
|
481
|
+
output_root = (self.artifact_dir / "outputs").resolve()
|
|
482
|
+
candidate = (output_root / relative).resolve()
|
|
483
|
+
if candidate != output_root and output_root not in candidate.parents:
|
|
484
|
+
raise RunProofError("output path escapes the outputs directory")
|
|
485
|
+
return candidate
|
|
486
|
+
|
|
487
|
+
def _artifact_target(self, path: str | Path) -> Path:
|
|
488
|
+
target = Path(path)
|
|
489
|
+
if target.is_absolute():
|
|
490
|
+
raise RunProofError("artifact paths must be relative to the run artifact directory")
|
|
491
|
+
candidate = (self.artifact_dir / target).resolve()
|
|
492
|
+
if self.artifact_dir.resolve() not in candidate.parents and candidate != self.artifact_dir.resolve():
|
|
493
|
+
raise RunProofError("artifact path escapes the run directory")
|
|
494
|
+
return candidate
|
|
495
|
+
|
|
496
|
+
@staticmethod
|
|
497
|
+
def _is_json_replayable(args: tuple[Any, ...], kwargs: dict[str, Any], output: Any) -> bool:
|
|
498
|
+
try:
|
|
499
|
+
json.dumps({"args": args, "kwargs": kwargs, "output": output}, default=lambda value: safe_value(value, max_items=20, max_text=100))
|
|
500
|
+
return all(isinstance(value, (str, int, float, bool, type(None), list, tuple, dict)) for value in args)
|
|
501
|
+
except (TypeError, ValueError, OverflowError):
|
|
502
|
+
return False
|
|
503
|
+
|
|
504
|
+
def _finalize(self) -> None:
|
|
505
|
+
self._finished_at = utc_now()
|
|
506
|
+
self._event("run_finished", {"status": self._status})
|
|
507
|
+
self.result = RunResult(
|
|
508
|
+
run_id=self.run_id,
|
|
509
|
+
name=self.name,
|
|
510
|
+
status=self._status,
|
|
511
|
+
artifact_dir=self.artifact_dir,
|
|
512
|
+
started_at=self._started_at,
|
|
513
|
+
finished_at=self._finished_at,
|
|
514
|
+
error=self._error,
|
|
515
|
+
)
|
|
516
|
+
manifest = {
|
|
517
|
+
"schema_version": "0.1",
|
|
518
|
+
"run": self.result.to_dict(),
|
|
519
|
+
"inputs": self._inputs,
|
|
520
|
+
"outputs": self._outputs,
|
|
521
|
+
"observations": self._observations,
|
|
522
|
+
"steps": [step.to_dict() for step in self._steps],
|
|
523
|
+
"checks": [check.to_dict() for check in self._checks],
|
|
524
|
+
"environment": self._environment,
|
|
525
|
+
"policy": {
|
|
526
|
+
"allowed_actions": sorted(self.policy.allowed_actions),
|
|
527
|
+
"approval_required": sorted(self.policy.approval_required),
|
|
528
|
+
"denied_actions": sorted(self.policy.denied_actions),
|
|
529
|
+
},
|
|
530
|
+
"replay": {
|
|
531
|
+
"possible_steps": sum(step.replayable for step in self._steps),
|
|
532
|
+
"total_steps": len(self._steps),
|
|
533
|
+
"note": "Replayability is reported from captured evidence; external sources may remain non-deterministic.",
|
|
534
|
+
},
|
|
535
|
+
}
|
|
536
|
+
write_json(self.artifact_dir / "manifest.json", manifest)
|
|
537
|
+
write_json(self.artifact_dir / "execution" / "trace.json", self._events)
|
|
538
|
+
write_json(self.artifact_dir / "checks" / "results.json", [check.to_dict() for check in self._checks])
|
|
539
|
+
write_json(self.artifact_dir / "execution" / "steps.json", [step.to_dict() for step in self._steps])
|
|
540
|
+
write_json(self.artifact_dir / "execution" / "outputs.json", self._outputs)
|
|
541
|
+
integrity_records = []
|
|
542
|
+
for artifact in sorted(self.artifact_dir.rglob("*")):
|
|
543
|
+
if artifact.is_file() and artifact.name != "integrity.json":
|
|
544
|
+
integrity_records.append({
|
|
545
|
+
"path": str(artifact.relative_to(self.artifact_dir)),
|
|
546
|
+
"sha256": sha256_file(artifact),
|
|
547
|
+
"size_bytes": artifact.stat().st_size,
|
|
548
|
+
})
|
|
549
|
+
write_json(self.artifact_dir / "integrity.json", {
|
|
550
|
+
"schema_version": "0.1",
|
|
551
|
+
"files": integrity_records,
|
|
552
|
+
"note": "Keep an external copy of this file or its digest for tamper evidence against the artifact itself.",
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def verified(name: str, **kwargs: Any) -> RunContext:
|
|
557
|
+
return RunContext(name, **kwargs)
|