Skip to content

Commit 44a5fbb

Browse files
rasmusfaberclaude
andauthored
Add per-sample report and artifact folder helpers (#17)
* build: make universal-pathlib a core dependency * feat: add report_dir and artifacts_dir path helpers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add write_report writer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add write_artifacts writer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add write_artifact single-file writer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: cover path-traversal rejection in artifacts helpers * refactor: remove write_report_artifacts in favor of artifacts module Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: document per-sample report and artifact helpers * refactor: tighten flat-path-component validation and add edge-case tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: harden clear loop against symlinks and reject control-char names Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: extract active_log fixture to remove setup boilerplate * fix: heal non-directory dest in _write_files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: apply ruff format --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 21b97ec commit 44a5fbb

9 files changed

Lines changed: 500 additions & 222 deletions

File tree

‎README.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,43 @@ from inspect_eval_utils.common import (
486486
These were ported from `harder-tasks` and are now shared across METR
487487
Inspect AI eval repos.
488488

489+
## Per-sample reports and artifacts
490+
491+
METR evals write two kinds of per-sample output next to the active sample's
492+
eval log. The destination works for a local path or an `s3://...` URL — the
493+
same code path serves both (via `universal-pathlib`).
494+
495+
- `reports/{sample_uuid}/` — one report per sample (possibly several files that
496+
together form it, e.g. `report.html` + `plot.png`).
497+
- `artifacts/{sample_uuid}/` — many files, which may accumulate over the run.
498+
499+
```python
500+
from inspect_eval_utils.artifacts import (
501+
report_dir,
502+
artifacts_dir,
503+
write_report,
504+
write_artifacts,
505+
write_artifact,
506+
)
507+
508+
# Folder paths (do not create the directory; None outside an eval):
509+
report_dir(sample_uuid) # -> {eval_log_folder}/reports/{sample_uuid}/
510+
artifacts_dir(sample_uuid) # -> {eval_log_folder}/artifacts/{sample_uuid}/
511+
512+
# Write the report (replaces the whole report directory):
513+
write_report(sample_uuid, {"report.html": html, "plot.png": png_bytes})
514+
515+
# Write artifacts (additive; pass clear=True to wipe the folder first):
516+
write_artifacts(sample_uuid, {"trace.json": data})
517+
write_artifact(sample_uuid, "screenshot.png", png_bytes)
518+
```
519+
520+
Each function returns `None` when there is no active sample (e.g. running
521+
outside an Inspect AI evaluation). The writers return the destination
522+
directory path, except `write_artifact`, which returns the written file path.
523+
File and folder names are restricted to single flat path components (no
524+
separators, `..`, or drive letters).
525+
489526
## Development
490527

491528
```bash

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ dependencies = [
2222
"inspect-ai>=0.3.200",
2323
"jinja2>=3.0",
2424
"boto3>=1.40",
25+
"universal-pathlib>=0.2",
2526
]
2627

2728
[project.urls]
@@ -31,7 +32,6 @@ Issues = "https://github.com/METR/inspect-eval-utils/issues"
3132
[project.optional-dependencies]
3233
report = [
3334
"matplotlib>=3.8",
34-
"universal-pathlib>=0.2",
3535
]
3636

3737
[project.scripts]
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Per-sample report and artifact folders next to the active sample's eval log.
2+
3+
METR evals write two kinds of per-sample output next to the eval log:
4+
5+
- ``reports/{sample_uuid}/`` — one report per sample (possibly several files
6+
that together form it).
7+
- ``artifacts/{sample_uuid}/`` — many files, potentially accumulated over a run.
8+
9+
Uses ``UPath`` so the destination can be a local path or an ``s3://...`` URL
10+
without separate code paths.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from collections.abc import Mapping
16+
from posixpath import basename
17+
18+
from inspect_ai.log._samples import sample_active # noqa: PLC2701
19+
from upath import UPath
20+
21+
22+
def _validate_flat_path_component(component: str) -> None:
23+
if (
24+
not component
25+
or component in {".", ".."}
26+
or basename(component) != component
27+
or "\\" in component
28+
or ":" in component
29+
or any(ord(c) < 32 or ord(c) == 0x7F for c in component)
30+
):
31+
raise ValueError(f"invalid path component: {component!r}")
32+
33+
34+
def _sample_dir(subdir: str, sample_uuid: str) -> UPath | None:
35+
"""Resolve ``{eval_log_folder}/{subdir}/{sample_uuid}`` for the active sample.
36+
37+
Returns ``None`` when there is no active sample (e.g. running outside an
38+
Inspect AI evaluation). Does not create the directory.
39+
"""
40+
active = sample_active()
41+
if active is None:
42+
return None
43+
44+
_validate_flat_path_component(subdir)
45+
_validate_flat_path_component(sample_uuid)
46+
47+
log_path = UPath(active.log_location)
48+
return log_path.parent / subdir / sample_uuid
49+
50+
51+
def report_dir(sample_uuid: str) -> UPath | None:
52+
"""Return the sample's report directory (``reports/{sample_uuid}/``).
53+
54+
Returns ``None`` when there is no active sample. Does not create the
55+
directory.
56+
"""
57+
return _sample_dir("reports", sample_uuid)
58+
59+
60+
def artifacts_dir(sample_uuid: str) -> UPath | None:
61+
"""Return the sample's artifacts directory (``artifacts/{sample_uuid}/``).
62+
63+
Returns ``None`` when there is no active sample. Does not create the
64+
directory.
65+
"""
66+
return _sample_dir("artifacts", sample_uuid)
67+
68+
69+
def _write_files(dest: UPath, files: Mapping[str, bytes | str], *, clear: bool) -> None:
70+
"""Write ``files`` into ``dest``, validating each name.
71+
72+
When ``clear`` is true, removes any pre-existing contents of ``dest`` first.
73+
"""
74+
for name in files:
75+
_validate_flat_path_component(name)
76+
77+
if dest.is_dir() and not dest.is_symlink():
78+
if clear:
79+
for old in dest.iterdir():
80+
if old.is_dir() and not old.is_symlink():
81+
old.rmdir(recursive=True)
82+
else:
83+
old.unlink(missing_ok=True)
84+
elif dest.is_symlink() or dest.exists():
85+
# a file or symlink sits where the directory should be; remove it
86+
dest.unlink(missing_ok=True)
87+
88+
dest.mkdir(parents=True, exist_ok=True)
89+
90+
for name, content in files.items():
91+
target = dest / name
92+
if isinstance(content, bytes):
93+
target.write_bytes(content)
94+
else:
95+
target.write_text(content, encoding="utf-8")
96+
97+
98+
def write_report(sample_uuid: str, files: Mapping[str, bytes | str]) -> str | None:
99+
"""Write the sample's report to ``reports/{sample_uuid}/``.
100+
101+
Replaces the whole report directory (the report is regenerated as a unit).
102+
Returns the destination directory path as a string, or ``None`` when there
103+
is no active sample.
104+
"""
105+
dest = report_dir(sample_uuid)
106+
if dest is None:
107+
return None
108+
_write_files(dest, files, clear=True)
109+
return str(dest)
110+
111+
112+
def write_artifacts(
113+
sample_uuid: str,
114+
files: Mapping[str, bytes | str],
115+
clear: bool = False,
116+
) -> str | None:
117+
"""Write artifact ``files`` to ``artifacts/{sample_uuid}/``.
118+
119+
Additive by default: writes/overwrites only the named files, leaving other
120+
existing artifacts in place. Pass ``clear=True`` to wipe the directory
121+
first. Returns the destination directory path as a string, or ``None`` when
122+
there is no active sample. In additive mode, writing a file whose name
123+
collides with an existing subdirectory raises an error.
124+
"""
125+
dest = artifacts_dir(sample_uuid)
126+
if dest is None:
127+
return None
128+
_write_files(dest, files, clear=clear)
129+
return str(dest)
130+
131+
132+
def write_artifact(sample_uuid: str, name: str, content: bytes | str) -> str | None:
133+
"""Write a single artifact file to ``artifacts/{sample_uuid}/{name}``.
134+
135+
Additive: never clears the directory. Returns the written file path as a
136+
string, or ``None`` when there is no active sample. Writing a file whose
137+
name collides with an existing subdirectory raises an error.
138+
"""
139+
dest = artifacts_dir(sample_uuid)
140+
if dest is None:
141+
return None
142+
_write_files(dest, {name: content}, clear=False)
143+
return str(dest / name)

‎src/inspect_eval_utils/report/__init__.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99

1010
try:
1111
from inspect_eval_utils.report.plot import build_plot
12-
from inspect_eval_utils.report.writer import write_report_artifacts
1312
except ImportError as exc: # pragma: no cover - depends on optional deps
1413
raise ImportError(
1514
"Install inspect-eval-utils[report] to use inspect_eval_utils.report."
@@ -23,5 +22,4 @@
2322
"build_plot",
2423
"cumulative_cost",
2524
"events_from_transcript",
26-
"write_report_artifacts",
2725
]

‎src/inspect_eval_utils/report/writer.py‎

Lines changed: 0 additions & 68 deletions
This file was deleted.

‎tests/report/test_html.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,3 @@ def test_report_package_reexports_public_helpers() -> None:
7171
assert report.HtmlTable.__name__ == "HtmlTable"
7272
assert callable(report.build_html)
7373
assert callable(report.build_plot)
74-
assert callable(report.write_report_artifacts)

0 commit comments

Comments
 (0)