Skip to content

Commit 2fdd639

Browse files
wmaynerclaude
andcommitted
Add format_stem, unique_path, and save_json provenance writers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEAxNzhDCaTrntX3o1JqMV
1 parent e366606 commit 2fdd639

2 files changed

Lines changed: 205 additions & 1 deletion

File tree

pyphi/provenance.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@
1111

1212
import functools
1313
import importlib.metadata
14+
import json
1415
import platform as _platform
16+
import re
1517
import subprocess
18+
from collections.abc import Mapping
19+
from dataclasses import asdict
1620
from dataclasses import dataclass
1721
from dataclasses import replace
1822
from datetime import UTC
@@ -158,6 +162,113 @@ def display_rows(self) -> list[tuple[str, str]]:
158162
return rows
159163

160164

165+
def _stem_value(value: Any) -> str:
166+
"""Format a parameter value for use in a filename stem."""
167+
return re.sub(r"[^A-Za-z0-9_+-]", "-", str(value).replace(".", "p"))
168+
169+
170+
def format_stem(
171+
name: str,
172+
params: Mapping[str, Any] | None = None,
173+
run_label: str | None = None,
174+
) -> str:
175+
"""Build a filename stem encoding a script's parameters.
176+
177+
Joins ``name``, one ``{key}{value}`` segment per ``params`` entry (in
178+
insertion order), and ``run_label`` when given, with underscores.
179+
Values and the run label are formatted with ``str()``; ``.`` becomes
180+
``p`` (so ``0.7`` → ``0p7`` and the filename keeps a single suffix)
181+
and any character outside ``[A-Za-z0-9_+-]`` becomes ``-``. ``name``
182+
is used verbatim.
183+
184+
Examples
185+
--------
186+
>>> format_stem("study", {"seed": 42, "noise": 0.7}, "pilot")
187+
'study_seed42_noise0p7_pilot'
188+
"""
189+
parts = [name]
190+
for key, value in (params or {}).items():
191+
parts.append(f"{key}{_stem_value(value)}")
192+
if run_label:
193+
parts.append(_stem_value(run_label))
194+
return "_".join(parts)
195+
196+
197+
def unique_path(directory: Path | str, stem: str, suffix: str) -> Path:
198+
"""Return a non-clobbering path: ``stem+suffix``, else ``stem_v2+suffix``, ...
199+
200+
Creates ``directory`` (with parents) if it does not exist. Never
201+
returns a path that already exists, so earlier outputs are never
202+
overwritten.
203+
"""
204+
directory = Path(directory)
205+
directory.mkdir(parents=True, exist_ok=True)
206+
path = directory / f"{stem}{suffix}"
207+
version = 2
208+
while path.exists():
209+
path = directory / f"{stem}_v{version}{suffix}"
210+
version += 1
211+
return path
212+
213+
214+
def _json_default(obj: Any) -> Any:
215+
"""``json.dumps`` fallback for numpy values and paths."""
216+
if isinstance(obj, np.generic):
217+
return obj.item()
218+
if isinstance(obj, np.ndarray):
219+
return obj.tolist()
220+
if isinstance(obj, Path):
221+
return str(obj)
222+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
223+
224+
225+
def _capture_metadata(
226+
params: Mapping[str, Any] | None,
227+
seed: int | None,
228+
note: str | None,
229+
) -> Provenance:
230+
"""Capture a :class:`Provenance`, resolving the seed from ``params``."""
231+
if seed is None and params is not None and "seed" in params:
232+
seed = int(params["seed"])
233+
prov = Provenance.capture(seed=seed)
234+
if note is not None:
235+
prov = replace(prov, note=note)
236+
return prov
237+
238+
239+
def save_json(
240+
data: Any,
241+
directory: Path | str,
242+
name: str,
243+
*,
244+
params: Mapping[str, Any] | None = None,
245+
run_label: str | None = None,
246+
seed: int | None = None,
247+
note: str | None = None,
248+
) -> Path:
249+
"""Write ``data`` to a self-describing, non-clobbering JSON file.
250+
251+
The file holds the envelope ``{"provenance": ..., "params": ...,
252+
"data": ...}``. The filename encodes ``params`` and ``run_label``
253+
(see :func:`format_stem`); an existing file is never overwritten (a
254+
``_v2``/``_v3`` suffix is added instead). The provenance record
255+
stores the seed from ``seed`` or, when omitted, from
256+
``params["seed"]``. numpy scalars and arrays in ``data`` are
257+
converted to JSON-native values.
258+
259+
Returns the written path.
260+
"""
261+
prov = _capture_metadata(params, seed, note)
262+
path = unique_path(directory, format_stem(name, params, run_label), ".json")
263+
envelope = {
264+
"provenance": asdict(prov),
265+
"params": dict(params or {}),
266+
"data": data,
267+
}
268+
path.write_text(json.dumps(envelope, indent=2, default=_json_default))
269+
return path
270+
271+
161272
def _set_provenance(result: Any, prov: Provenance) -> None:
162273
"""Assign ``prov`` to ``result.provenance``, working around frozen results."""
163274
try:
@@ -204,4 +315,11 @@ def with_provenance(self, **fields: Any) -> HasProvenance:
204315
return self
205316

206317

207-
__all__ = ["HasProvenance", "Provenance", "stamp_wall_time"]
318+
__all__ = [
319+
"HasProvenance",
320+
"Provenance",
321+
"format_stem",
322+
"save_json",
323+
"stamp_wall_time",
324+
"unique_path",
325+
]

test/test_provenance_writers.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Tests for the script-facing provenance writers."""
2+
3+
import json
4+
5+
import numpy as np
6+
import pytest
7+
8+
from pyphi import provenance
9+
10+
11+
class TestFormatStem:
12+
def test_params_in_insertion_order(self):
13+
assert (
14+
provenance.format_stem("study", {"seed": 42, "trials": 60})
15+
== "study_seed42_trials60"
16+
)
17+
18+
def test_float_dot_becomes_p(self):
19+
assert provenance.format_stem("study", {"noise": 0.7}) == "study_noise0p7"
20+
21+
def test_unsafe_characters_sanitized(self):
22+
assert provenance.format_stem("study", {"tag": "a/b c"}) == "study_taga-b-c"
23+
24+
def test_run_label_appended(self):
25+
assert (
26+
provenance.format_stem("study", {"seed": 1}, "post_reduction")
27+
== "study_seed1_post_reduction"
28+
)
29+
30+
def test_no_params(self):
31+
assert provenance.format_stem("study") == "study"
32+
33+
34+
class TestUniquePath:
35+
def test_fresh_path(self, tmp_path):
36+
assert provenance.unique_path(tmp_path, "run", ".json") == tmp_path / "run.json"
37+
38+
def test_versions_on_collision(self, tmp_path):
39+
(tmp_path / "run.json").touch()
40+
assert (
41+
provenance.unique_path(tmp_path, "run", ".json") == tmp_path / "run_v2.json"
42+
)
43+
(tmp_path / "run_v2.json").touch()
44+
assert (
45+
provenance.unique_path(tmp_path, "run", ".json") == tmp_path / "run_v3.json"
46+
)
47+
48+
def test_creates_directory(self, tmp_path):
49+
target = tmp_path / "a" / "b"
50+
provenance.unique_path(target, "run", ".json")
51+
assert target.is_dir()
52+
53+
54+
class TestSaveJson:
55+
def test_envelope_and_numpy_values(self, tmp_path):
56+
path = provenance.save_json(
57+
{"phi": np.float64(0.5), "counts": np.arange(3)},
58+
tmp_path,
59+
"study",
60+
params={"seed": 42},
61+
)
62+
assert path == tmp_path / "study_seed42.json"
63+
document = json.loads(path.read_text())
64+
assert document["data"] == {"phi": 0.5, "counts": [0, 1, 2]}
65+
assert document["params"] == {"seed": 42}
66+
assert document["provenance"]["seed"] == 42
67+
assert document["provenance"]["pyphi_version"]
68+
assert document["provenance"]["timestamp"]
69+
70+
def test_explicit_seed_overrides_params(self, tmp_path):
71+
path = provenance.save_json({}, tmp_path, "study", params={"seed": 42}, seed=7)
72+
assert json.loads(path.read_text())["provenance"]["seed"] == 7
73+
74+
def test_note_stored(self, tmp_path):
75+
path = provenance.save_json({}, tmp_path, "study", note="pilot run")
76+
assert json.loads(path.read_text())["provenance"]["note"] == "pilot run"
77+
78+
def test_unserializable_payload_raises(self, tmp_path):
79+
with pytest.raises(TypeError, match="not JSON serializable"):
80+
provenance.save_json({"bad": object()}, tmp_path, "study")
81+
82+
def test_no_clobber(self, tmp_path):
83+
first = provenance.save_json({"x": 1}, tmp_path, "study", params={"seed": 1})
84+
second = provenance.save_json({"x": 2}, tmp_path, "study", params={"seed": 1})
85+
assert second == tmp_path / "study_seed1_v2.json"
86+
assert json.loads(first.read_text())["data"] == {"x": 1}

0 commit comments

Comments
 (0)