exptrail 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.
exptrail/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """exptrail: local experiment logging that keeps README numbers verifiable."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .run import ConfigWarning, DirtyTreeWarning, NoGitWarning, Run, track # noqa: E402
6
+
7
+ __all__ = ["Run", "track", "DirtyTreeWarning", "NoGitWarning", "ConfigWarning", "__version__"]
exptrail/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
exptrail/_stack.py ADDED
@@ -0,0 +1,27 @@
1
+ """Point warnings at the user's line, however many exptrail frames are in between."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ _PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) + os.sep
9
+
10
+
11
+ def _in_package(filename: str) -> bool:
12
+ return os.path.abspath(filename).startswith(_PACKAGE_DIR)
13
+
14
+
15
+ def user_stacklevel() -> int:
16
+ """``stacklevel`` for a ``warnings.warn`` made by the caller of this function.
17
+
18
+ Walks up to the first frame outside the exptrail package, so the warning
19
+ names the user's ``with Run(...)``, ``run.start()`` or ``@track``-decorated
20
+ call, whichever path led here.
21
+ """
22
+ frame = sys._getframe(1) # the function that is about to call warnings.warn
23
+ level = 1
24
+ while frame is not None and _in_package(frame.f_code.co_filename):
25
+ frame = frame.f_back
26
+ level += 1
27
+ return level
exptrail/cli.py ADDED
@@ -0,0 +1,210 @@
1
+ """Command-line interface: ls, show, compare, plot, table, verify, mark."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import shlex
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from .readme import render_table, verify_readme, write_table
12
+ from .run import END_STATUSES, _now, _write_json, default_root
13
+ from .store import SavedRun, find_run, glob_runs, list_runs
14
+
15
+
16
+ def _fmt(value) -> str:
17
+ if isinstance(value, float):
18
+ return f"{value:.4g}"
19
+ if isinstance(value, (dict, list)):
20
+ return json.dumps(value)
21
+ return "—" if value is None else str(value)
22
+
23
+
24
+ def print_table(header: list[str], rows: list[list[str]], out=None) -> None:
25
+ out = out or sys.stdout
26
+ widths = [max(len(str(c)) for c in col) for col in zip(header, *rows)]
27
+ for i, row in enumerate([header, *rows]):
28
+ print(" ".join(str(c).ljust(w) for c, w in zip(row, widths)).rstrip(), file=out)
29
+ if i == 0:
30
+ print(" ".join("-" * w for w in widths), file=out)
31
+
32
+
33
+ def _resolve(refs: list[str], root: Path) -> list[SavedRun]:
34
+ return [find_run(r, root) for r in refs]
35
+
36
+
37
+ def cmd_ls(args) -> int:
38
+ runs = list_runs(args.root)
39
+ if not runs:
40
+ print(f"no runs in {args.root}")
41
+ return 0
42
+ rows = []
43
+ for run in runs:
44
+ summary = run.summary
45
+ keys = args.metric or list(summary)[:3]
46
+ metrics = ", ".join(f"{k}={_fmt(summary.get(k))}" for k in keys)
47
+ rows.append([run.id, run.status_display(), metrics, run.short_commit()])
48
+ print_table(["run", "status", "summary", "commit"], rows)
49
+ if any(r.dirty for r in runs):
50
+ print("\n* = ran on a dirty working tree")
51
+ return 0
52
+
53
+
54
+ def cmd_show(args) -> int:
55
+ run = find_run(args.run, args.root)
56
+ print(f"# {run.id} ({run.path})\n")
57
+ print(f"status: {run.status_display()}\n")
58
+ for title, data in (("config", run.config), ("summary", run.summary), ("meta", run.meta)):
59
+ print(f"## {title}")
60
+ print(json.dumps(data, indent=2))
61
+ print()
62
+ return 0
63
+
64
+
65
+ def cmd_compare(args) -> int:
66
+ runs = _resolve(args.runs, args.root)
67
+ ids = [r.id for r in runs]
68
+ configs = [r.config for r in runs]
69
+ keys = list(dict.fromkeys(k for c in configs for k in c))
70
+ differing = [k for k in keys if len({json.dumps(c.get(k), sort_keys=True) for c in configs}) > 1]
71
+ shown = keys if args.all else differing
72
+ print("## config" + ("" if args.all else " (differing keys only; --all for everything)"))
73
+ if shown:
74
+ print_table(["key", *ids], [[k, *(_fmt(c.get(k)) for c in configs)] for k in shown])
75
+ else:
76
+ print("(identical)")
77
+ summaries = [r.summary for r in runs]
78
+ mkeys = list(dict.fromkeys(k for s in summaries for k in s))
79
+ print("\n## summary")
80
+ print_table(["metric", *ids], [[k, *(_fmt(s.get(k)) for s in summaries)] for k in mkeys])
81
+ print("\n## provenance")
82
+ print_table(["", *ids], [["status", *(r.status_display() for r in runs)], ["commit", *(r.short_commit() for r in runs)]])
83
+ return 0
84
+
85
+
86
+ def cmd_plot(args) -> int:
87
+ from .plot import plot_runs
88
+
89
+ runs = _resolve(args.runs, args.root)
90
+ out = Path(args.out or f"{args.metric}.png")
91
+ n = plot_runs(runs, args.metric, out)
92
+ if n == 0:
93
+ print(f"error: no run has logged {args.metric!r}", file=sys.stderr)
94
+ return 1
95
+ print(f"wrote {out} ({n} run{'s' if n != 1 else ''})")
96
+ return 0
97
+
98
+
99
+ def cmd_table(args) -> int:
100
+ runs = []
101
+ for pattern in args.runs:
102
+ runs += [r for r in glob_runs(pattern, args.root) if r.path not in {x.path for x in runs}]
103
+ if not args.include_failed:
104
+ runs = [r for r in runs if r.status == "finished"]
105
+ if not runs:
106
+ print("error: no finished runs match " + " ".join(args.runs), file=sys.stderr)
107
+ return 1
108
+ readme = Path(args.readme)
109
+ command = "exptrail " + shlex.join(args.argv)
110
+ block = render_table(runs, args.metric, readme, args.config or [], args.precision, command,
111
+ absolute_links=args.absolute_links)
112
+ changed = write_table(readme, block)
113
+ print(f"{'updated' if changed else 'unchanged'}: {readme} ({len(runs)} run{'s' if len(runs) != 1 else ''})")
114
+ for r in runs:
115
+ if r.dirty:
116
+ print(f"warning: {r.id} ran on a dirty working tree", file=sys.stderr)
117
+ return 0
118
+
119
+
120
+ def cmd_verify(args) -> int:
121
+ failed = False
122
+ for readme in args.readme:
123
+ res = verify_readme(Path(readme), strict=args.strict)
124
+ for w in res.warnings:
125
+ print(f"warning: {w}", file=sys.stderr)
126
+ for e in res.errors:
127
+ print(f"MISMATCH: {e}", file=sys.stderr)
128
+ status = "OK" if res.ok else "FAILED"
129
+ print(f"{status}: {readme} — {res.rows} rows, {res.checked} values checked")
130
+ failed |= not res.ok
131
+ return 1 if failed else 0
132
+
133
+
134
+ def cmd_mark(args) -> int:
135
+ run = find_run(args.run, args.root)
136
+ shown = run.status_display()
137
+ if run.status == "running" and not args.force:
138
+ print(f"error: {run.id} shows as {shown}; pass --force to mark it anyway", file=sys.stderr)
139
+ return 1
140
+ meta = run.read_json("meta.json")
141
+ previous = meta.get("status")
142
+ meta["status"] = args.status
143
+ meta["marked"] = {"by": "hand", "at": _now().isoformat(), "previous_status": previous, "shown_as": shown}
144
+ _write_json(run.path / "meta.json", meta)
145
+ print(f"marked {run.id} as {args.status} (was {shown})")
146
+ return 0
147
+
148
+
149
+ def build_parser() -> argparse.ArgumentParser:
150
+ p = argparse.ArgumentParser(prog="exptrail", description=__doc__)
151
+ p.add_argument("--root", type=Path, default=None, help="runs directory (default: $EXPTRAIL_ROOT or ./runs)")
152
+ sub = p.add_subparsers(dest="cmd", required=True)
153
+
154
+ s = sub.add_parser("ls", help="list runs")
155
+ s.add_argument("--metric", action="append", help="summary metric to show (repeatable)")
156
+ s.set_defaults(func=cmd_ls)
157
+
158
+ s = sub.add_parser("show", help="show config, summary and meta of a run")
159
+ s.add_argument("run")
160
+ s.set_defaults(func=cmd_show)
161
+
162
+ s = sub.add_parser("compare", help="config diff and metric table for several runs")
163
+ s.add_argument("runs", nargs="+")
164
+ s.add_argument("--all", action="store_true", help="show all config keys, not just differing ones")
165
+ s.set_defaults(func=cmd_compare)
166
+
167
+ s = sub.add_parser("plot", help="overlay a metric's curves to a PNG")
168
+ s.add_argument("runs", nargs="+")
169
+ s.add_argument("--metric", required=True)
170
+ s.add_argument("--out", "-o", help="output PNG (default: <metric>.png)")
171
+ s.set_defaults(func=cmd_plot)
172
+
173
+ s = sub.add_parser("table", help="write a results table into a README")
174
+ s.add_argument("--metric", action="append", required=True, help="summary metric column (repeatable)")
175
+ s.add_argument("--runs", nargs="+", default=["*"], help="glob(s) of run folders (default: all)")
176
+ s.add_argument("--readme", default="README.md")
177
+ s.add_argument("--config", action="append", help="config key to show as a column (repeatable)")
178
+ s.add_argument("--precision", type=int, default=4, help="decimals for floats (default 4)")
179
+ s.add_argument("--include-failed", action="store_true")
180
+ s.add_argument("--absolute-links", action="store_true",
181
+ help="link run folders by their GitHub/GitLab URL (for READMEs shown on PyPI)")
182
+ s.set_defaults(func=cmd_table)
183
+
184
+ s = sub.add_parser("verify", help="check README results against saved runs")
185
+ s.add_argument("readme", nargs="+")
186
+ s.add_argument("--strict", action="store_true", help="also fail on dirty or git-less runs")
187
+ s.set_defaults(func=cmd_verify)
188
+
189
+ s = sub.add_parser("mark", help="permanently record how a run ended (e.g. one that shows as dead)")
190
+ s.add_argument("run")
191
+ s.add_argument("status", choices=END_STATUSES)
192
+ s.add_argument("--force", action="store_true", help="mark it even if it still shows as running")
193
+ s.set_defaults(func=cmd_mark)
194
+ return p
195
+
196
+
197
+ def main(argv: list[str] | None = None) -> int:
198
+ argv = list(sys.argv[1:] if argv is None else argv)
199
+ args = build_parser().parse_args(argv)
200
+ args.root = args.root or default_root()
201
+ args.argv = argv
202
+ try:
203
+ return args.func(args)
204
+ except (LookupError, ValueError, RuntimeError, FileNotFoundError) as exc:
205
+ print(f"error: {exc}", file=sys.stderr)
206
+ return 2
207
+
208
+
209
+ if __name__ == "__main__": # pragma: no cover
210
+ sys.exit(main())
exptrail/liveness.py ADDED
@@ -0,0 +1,271 @@
1
+ """Is a run whose meta.json says ``running`` really still running?
2
+
3
+ The usual ways ML jobs die (SIGKILL, the out-of-memory killer, a Colab
4
+ disconnect, a laptop losing power) run no cleanup code, so nothing can write
5
+ a "died" status. Instead, a run leaves evidence while it is alive:
6
+
7
+ - at start, who it is: hostname, boot ID, pid and the process start time;
8
+ - every ``heartbeat_s`` seconds, a daemon thread touches ``<run>/heartbeat``.
9
+
10
+ Readers combine the two into a status at read time, without writing anything.
11
+ On the same machine the answer is certain (``running`` or ``dead``). From
12
+ another machine, or where the process can't be inspected, only the heartbeat's
13
+ age is available, so an old heartbeat means ``stale``: probably dead.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ import os
20
+ import re
21
+ import socket
22
+ import subprocess
23
+ import sys
24
+ import threading
25
+ import time
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ IS_WINDOWS = os.name == "nt"
30
+ DEFAULT_HEARTBEAT_S = 30.0
31
+ STALE_FACTOR = 3
32
+ HEARTBEAT_FILE = "heartbeat"
33
+ # kern.boottime (macOS) shifts when the wall clock is stepped; a real reboot
34
+ # closer than this to the previous boot is still caught by the pid check.
35
+ BOOT_TOLERANCE_S = 300.0
36
+
37
+
38
+ def _positive_seconds(value, what: str) -> float:
39
+ try:
40
+ seconds = float(value)
41
+ except (TypeError, ValueError):
42
+ raise ValueError(f"{what} must be a number of seconds, got {value!r}") from None
43
+ if not (math.isfinite(seconds) and seconds > 0):
44
+ raise ValueError(f"{what} must be a positive number of seconds, got {value!r}")
45
+ return seconds
46
+
47
+
48
+ def _env_seconds(name: str) -> float | None:
49
+ raw = os.environ.get(name, "").strip()
50
+ return _positive_seconds(raw, f"${name}") if raw else None
51
+
52
+
53
+ def heartbeat_interval(value=None) -> float:
54
+ """``value``, else ``$EXPTRAIL_HEARTBEAT_S``, else 30 seconds."""
55
+ if value is not None:
56
+ return _positive_seconds(value, "heartbeat_s")
57
+ return _env_seconds("EXPTRAIL_HEARTBEAT_S") or DEFAULT_HEARTBEAT_S
58
+
59
+
60
+ def stale_after(interval: float) -> float:
61
+ """``$EXPTRAIL_STALE_S``, else 3x the run's heartbeat interval."""
62
+ return _env_seconds("EXPTRAIL_STALE_S") or STALE_FACTOR * interval
63
+
64
+
65
+ # -- identity of this machine and of a process ------------------------------
66
+
67
+ def _run(args: list[str]) -> str:
68
+ try:
69
+ out = subprocess.run(args, capture_output=True, text=True, timeout=5,
70
+ env={**os.environ, "LC_ALL": "C"})
71
+ except (OSError, subprocess.SubprocessError):
72
+ return ""
73
+ return out.stdout if out.returncode == 0 else ""
74
+
75
+
76
+ def _linux_stat(pid: int) -> list[str] | None:
77
+ """Fields 3.. of /proc/<pid>/stat (the command name may contain spaces)."""
78
+ try:
79
+ stat = Path(f"/proc/{pid}/stat").read_text()
80
+ except OSError:
81
+ return None
82
+ return stat[stat.rindex(")") + 2:].split()
83
+
84
+
85
+ def process_start_time(pid: int) -> str | None:
86
+ """An opaque token that changes if ``pid`` is reused by another process."""
87
+ if sys.platform.startswith("linux"):
88
+ fields = _linux_stat(pid)
89
+ # field 22: start time in clock ticks since boot
90
+ return fields[19] if fields and len(fields) > 19 else None
91
+ if sys.platform == "darwin":
92
+ return " ".join(_run(["ps", "-o", "lstart=", "-p", str(pid)]).split()) or None
93
+ return None
94
+
95
+
96
+ def _is_zombie(pid: int) -> bool:
97
+ if sys.platform.startswith("linux"):
98
+ fields = _linux_stat(pid)
99
+ return bool(fields) and fields[0] == "Z"
100
+ return False
101
+
102
+
103
+ def boot_id() -> str | None:
104
+ """Changes on every reboot, so a recorded pid can't be mistaken for a new one."""
105
+ if sys.platform.startswith("linux"):
106
+ try:
107
+ return Path("/proc/sys/kernel/random/boot_id").read_text().strip() or None
108
+ except OSError:
109
+ return None
110
+ if sys.platform == "darwin":
111
+ m = re.search(r"sec\s*=\s*(\d+),\s*usec\s*=\s*(\d+)", _run(["sysctl", "-n", "kern.boottime"]))
112
+ return f"boottime:{m.group(1)}.{int(m.group(2)):06d}" if m else None
113
+ return None
114
+
115
+
116
+ def _same_boot(a: str, b: str) -> bool:
117
+ if a.startswith("boottime:") and b.startswith("boottime:"):
118
+ try:
119
+ return abs(float(a[9:]) - float(b[9:])) < BOOT_TOLERANCE_S
120
+ except ValueError:
121
+ pass
122
+ return a == b
123
+
124
+
125
+ def pid_alive(pid: int) -> bool | None:
126
+ """True/False, or None when it can't be checked."""
127
+ if IS_WINDOWS:
128
+ return None # never os.kill(pid, 0) on Windows: signal 0 is CTRL_C_EVENT there
129
+ if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 0:
130
+ return None # os.kill(0 or -1, ...) would address a whole process group
131
+ try:
132
+ os.kill(pid, 0)
133
+ except ProcessLookupError:
134
+ return False
135
+ except PermissionError:
136
+ return True # exists, owned by someone else
137
+ except OSError:
138
+ return None
139
+ return True
140
+
141
+
142
+ def record(heartbeat_s: float) -> dict:
143
+ """What a starting run writes into meta.json under ``liveness``."""
144
+ pid = os.getpid()
145
+ return {
146
+ "hostname": socket.gethostname(),
147
+ "pid": pid,
148
+ "proc_start": process_start_time(pid),
149
+ "boot_id": boot_id(),
150
+ "heartbeat_s": heartbeat_s,
151
+ }
152
+
153
+
154
+ # -- heartbeat --------------------------------------------------------------
155
+
156
+ class Heartbeat:
157
+ """Daemon thread that touches ``path`` every ``interval`` seconds.
158
+
159
+ Only the file's mtime is used, so it never races with meta.json writes,
160
+ and it beats whether or not the job calls log().
161
+ """
162
+
163
+ def __init__(self, path: Path, interval: float):
164
+ self.path = path
165
+ self.interval = interval
166
+ self._stop = threading.Event()
167
+ self._thread: threading.Thread | None = None
168
+
169
+ def start(self) -> None:
170
+ self.path.touch()
171
+ self._thread = threading.Thread(target=self._beat, name="exptrail-heartbeat", daemon=True)
172
+ self._thread.start()
173
+
174
+ def _beat(self) -> None:
175
+ while not self._stop.wait(self.interval):
176
+ try:
177
+ os.utime(self.path)
178
+ except OSError:
179
+ pass # folder moved or disk hiccup: keep trying, never crash the job
180
+
181
+ def stop(self) -> None:
182
+ self._stop.set()
183
+ if self._thread is not None and self._thread is not threading.current_thread():
184
+ # os.utime can block for a long time on a network or FUSE mount (Google
185
+ # Drive on Colab). Never hang finish() on it: the thread is a daemon, so
186
+ # abandoning it is safe, and it exits once the pending utime returns.
187
+ self._thread.join(timeout=min(self.interval, 5.0))
188
+ self._thread = None
189
+
190
+
191
+ # -- read-time status -------------------------------------------------------
192
+
193
+ @dataclass(frozen=True)
194
+ class Liveness:
195
+ status: str # running | dead | stale
196
+ reason: str = ""
197
+ certain: bool = False
198
+
199
+ def display(self) -> str:
200
+ return f"{self.status} ({self.reason})" if self.reason else self.status
201
+
202
+
203
+ def _ago(seconds: float) -> str:
204
+ if seconds < 90:
205
+ return f"{seconds:.0f}s"
206
+ if seconds < 90 * 60:
207
+ return f"{seconds / 60:.0f}m"
208
+ if seconds < 48 * 3600:
209
+ return f"{seconds / 3600:.0f}h"
210
+ return f"{seconds / 86400:.0f}d"
211
+
212
+
213
+ def _check_local(rec: dict) -> Liveness | None:
214
+ """Certain answer when the run was on this machine, this boot; else None."""
215
+ if not rec.get("hostname") or rec["hostname"] != socket.gethostname():
216
+ return None
217
+ recorded, current = rec.get("boot_id"), boot_id()
218
+ if not isinstance(recorded, str) or not recorded or not current:
219
+ return None
220
+ if not _same_boot(recorded, current):
221
+ return Liveness("dead", "machine rebooted", True)
222
+ pid = rec.get("pid")
223
+ alive = pid_alive(pid)
224
+ if alive is None:
225
+ return None
226
+ if not alive or _is_zombie(pid):
227
+ return Liveness("dead", "pid gone", True)
228
+ started, now_started = rec.get("proc_start"), process_start_time(pid)
229
+ if not started or not now_started:
230
+ return None
231
+ if str(started) != now_started:
232
+ return Liveness("dead", "pid reused", True)
233
+ return Liveness("running", "", True)
234
+
235
+
236
+ def _last_sign_of_life(run_dir: Path) -> tuple[float, str] | None:
237
+ try:
238
+ return (run_dir / HEARTBEAT_FILE).stat().st_mtime, "heartbeat"
239
+ except OSError:
240
+ pass
241
+ # runs from before heartbeats existed: the newest file they wrote
242
+ times = []
243
+ for name in ("meta.json", "metrics.csv", "summary.json"):
244
+ try:
245
+ times.append((run_dir / name).stat().st_mtime)
246
+ except OSError:
247
+ pass
248
+ return (max(times), "activity") if times else None
249
+
250
+
251
+ def assess(run_dir: Path, meta: dict, now: float | None = None) -> Liveness:
252
+ """Status of a run whose stored status is ``running``. Reads only."""
253
+ rec = meta.get("liveness")
254
+ rec = rec if isinstance(rec, dict) else {}
255
+ if not IS_WINDOWS:
256
+ local = _check_local(rec)
257
+ if local is not None:
258
+ return local
259
+ try:
260
+ interval = _positive_seconds(rec.get("heartbeat_s"), "heartbeat_s")
261
+ except ValueError:
262
+ interval = DEFAULT_HEARTBEAT_S
263
+ host = rec.get("hostname") or meta.get("hostname")
264
+ where = f"on {host}, " if host and host != socket.gethostname() else ""
265
+ seen = _last_sign_of_life(run_dir)
266
+ if seen is None:
267
+ return Liveness("stale", f"{where}no heartbeat")
268
+ last, what = seen
269
+ age = max(0.0, (time.time() if now is None else now) - last)
270
+ status = "stale" if age > stale_after(interval) else "running"
271
+ return Liveness(status, f"{where}last {what} {_ago(age)} ago")