flexlock 0.8.2__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.
- flexlock/__init__.py +87 -0
- flexlock/api.py +1444 -0
- flexlock/backends/__init__.py +11 -0
- flexlock/backends/base.py +41 -0
- flexlock/backends/pbs.py +219 -0
- flexlock/backends/slurm.py +333 -0
- flexlock/cli.py +953 -0
- flexlock/config.py +121 -0
- flexlock/context.py +7 -0
- flexlock/data_hash.py +310 -0
- flexlock/debug.py +473 -0
- flexlock/diff.py +253 -0
- flexlock/diff_cli.py +155 -0
- flexlock/exceptions.py +50 -0
- flexlock/export.py +134 -0
- flexlock/fingerprint.py +129 -0
- flexlock/flexcli.py +163 -0
- flexlock/freeze.py +291 -0
- flexlock/git_utils.py +196 -0
- flexlock/index.py +300 -0
- flexlock/load_stage.py +60 -0
- flexlock/mlflow.py +192 -0
- flexlock/parallel.py +378 -0
- flexlock/query.py +739 -0
- flexlock/report.py +78 -0
- flexlock/report_template.html +246 -0
- flexlock/resolvers.py +248 -0
- flexlock/run_cli.py +44 -0
- flexlock/run_record.py +173 -0
- flexlock/runner.py +571 -0
- flexlock/save_dir.py +232 -0
- flexlock/skills/flexlock-new-stage/SKILL.md +57 -0
- flexlock/skills/flexlock-report/SKILL.md +50 -0
- flexlock/skills/flexlock-run-and-watch/SKILL.md +60 -0
- flexlock/skills/flexlock-survey/SKILL.md +61 -0
- flexlock/snapshot.py +245 -0
- flexlock/status_cli.py +299 -0
- flexlock/taskdb.py +685 -0
- flexlock/utils.py +710 -0
- flexlock/worker.py +228 -0
- flexlock/worker_cli.py +143 -0
- flexlock-0.8.2.dist-info/METADATA +357 -0
- flexlock-0.8.2.dist-info/RECORD +45 -0
- flexlock-0.8.2.dist-info/WHEEL +4 -0
- flexlock-0.8.2.dist-info/entry_points.txt +7 -0
flexlock/run_record.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""The on-disk contract for a single run directory.
|
|
2
|
+
|
|
3
|
+
``RunRecord`` is the *one* owner of the files that define a run on disk —
|
|
4
|
+
``run.lock`` (the snapshot written before execution), ``run.complete`` (the
|
|
5
|
+
success marker), and ``results.json`` (the return payload). Every writer —
|
|
6
|
+
the serial ``Project.submit`` path, the sweep ``worker``, and the legacy
|
|
7
|
+
``write_complete_marker`` helper — routes through it, so the driver and worker
|
|
8
|
+
are structurally identical and there is a single place to hook the
|
|
9
|
+
fingerprint index (see ``index.py``, wired in Phase 2.2).
|
|
10
|
+
|
|
11
|
+
All writes are atomic (temp file + ``os.replace``) so a concurrent reader
|
|
12
|
+
never observes a partial file.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import tempfile
|
|
18
|
+
import traceback
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Optional
|
|
22
|
+
|
|
23
|
+
from loguru import logger
|
|
24
|
+
from omegaconf import OmegaConf
|
|
25
|
+
|
|
26
|
+
LOCK_NAME = "run.lock"
|
|
27
|
+
COMPLETE_MARKER = "run.complete"
|
|
28
|
+
RESULTS_NAME = "results.json"
|
|
29
|
+
ERROR_NAME = "run.error"
|
|
30
|
+
COMPLETE_VERSION = 1
|
|
31
|
+
ERROR_VERSION = 1
|
|
32
|
+
|
|
33
|
+
# Run lifecycle states derived from what's on disk.
|
|
34
|
+
STATUS_DONE = "DONE" # run.lock + run.complete
|
|
35
|
+
STATUS_INCOMPLETE = "INCOMPLETE" # run.lock only (interrupted / in-flight)
|
|
36
|
+
STATUS_MISSING = "MISSING" # nothing
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _atomic_write(target: Path, text: str) -> Path:
|
|
40
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
with tempfile.NamedTemporaryFile("w", dir=target.parent, delete=False) as tf:
|
|
42
|
+
tf.write(text)
|
|
43
|
+
tmp_name = tf.name
|
|
44
|
+
os.replace(tmp_name, target)
|
|
45
|
+
return target
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RunRecord:
|
|
49
|
+
"""Encapsulates one run directory's files and lifecycle."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, save_dir):
|
|
52
|
+
self.save_dir = Path(save_dir)
|
|
53
|
+
|
|
54
|
+
# ── paths ──
|
|
55
|
+
@property
|
|
56
|
+
def lock_path(self) -> Path:
|
|
57
|
+
return self.save_dir / LOCK_NAME
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def complete_path(self) -> Path:
|
|
61
|
+
return self.save_dir / COMPLETE_MARKER
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def results_path(self) -> Path:
|
|
65
|
+
return self.save_dir / RESULTS_NAME
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def error_path(self) -> Path:
|
|
69
|
+
return self.save_dir / ERROR_NAME
|
|
70
|
+
|
|
71
|
+
# ── writers ──
|
|
72
|
+
def write_lock(self, snapshot_data: dict) -> Path:
|
|
73
|
+
"""Write ``run.lock`` (the snapshot) atomically before execution."""
|
|
74
|
+
return _atomic_write(self.lock_path, OmegaConf.to_yaml(snapshot_data))
|
|
75
|
+
|
|
76
|
+
def write_results(self, result: Any) -> Path:
|
|
77
|
+
"""Write ``results.json`` with the run's return payload."""
|
|
78
|
+
payload = result if isinstance(result, dict) else {"result": result}
|
|
79
|
+
return _atomic_write(
|
|
80
|
+
self.results_path, json.dumps(payload, indent=2, default=str)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def mark_complete(self, result: Any = None) -> Path:
|
|
84
|
+
"""Write ``run.complete`` after the user function returns successfully.
|
|
85
|
+
|
|
86
|
+
A cache hit requires *both* ``run.lock`` and ``run.complete``; an
|
|
87
|
+
interrupted run leaves ``run.lock`` alone and is correctly skipped.
|
|
88
|
+
This is the single place the fingerprint index is refreshed (2.2).
|
|
89
|
+
"""
|
|
90
|
+
# Success removes any stale failure record. Because every writer routes
|
|
91
|
+
# through RunRecord, this single hook implements "a completed run has no
|
|
92
|
+
# run.error" for the serial, sweep-worker, and legacy marker paths.
|
|
93
|
+
self.clear_error()
|
|
94
|
+
payload = {"ts": datetime.now().isoformat(), "version": COMPLETE_VERSION}
|
|
95
|
+
if result is not None:
|
|
96
|
+
payload["has_result"] = True
|
|
97
|
+
return _atomic_write(self.complete_path, json.dumps(payload))
|
|
98
|
+
|
|
99
|
+
def write_error(self, exc: BaseException, *, task_id=None, node=None) -> "Path | None":
|
|
100
|
+
"""Record a ``run.error`` JSON sidecar describing a failed run.
|
|
101
|
+
|
|
102
|
+
Capturing the failure must *never* mask the original exception, so the
|
|
103
|
+
entire body is defensive: any problem writing the record is logged and
|
|
104
|
+
swallowed, and the caller re-raises the user's exception unchanged.
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
payload = {
|
|
108
|
+
"version": ERROR_VERSION,
|
|
109
|
+
"exc_type": type(exc).__name__,
|
|
110
|
+
"exc_message": str(exc),
|
|
111
|
+
"traceback": "".join(
|
|
112
|
+
traceback.format_exception(type(exc), exc, exc.__traceback__)
|
|
113
|
+
),
|
|
114
|
+
"timestamp": datetime.now().isoformat(),
|
|
115
|
+
}
|
|
116
|
+
if task_id is not None:
|
|
117
|
+
payload["task_id"] = task_id
|
|
118
|
+
if node is not None:
|
|
119
|
+
payload["node"] = node
|
|
120
|
+
return _atomic_write(self.error_path, json.dumps(payload, indent=2))
|
|
121
|
+
except Exception as write_exc: # pragma: no cover - defensive
|
|
122
|
+
logger.warning(f"Could not write {ERROR_NAME} at {self.save_dir}: {write_exc}")
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
def clear_error(self) -> None:
|
|
126
|
+
"""Remove a stale ``run.error`` if present (no-op otherwise)."""
|
|
127
|
+
try:
|
|
128
|
+
self.error_path.unlink(missing_ok=True)
|
|
129
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
130
|
+
logger.warning(f"Could not clear {ERROR_NAME} at {self.save_dir}: {exc}")
|
|
131
|
+
|
|
132
|
+
# ── readers ──
|
|
133
|
+
def load(self) -> Optional[dict]:
|
|
134
|
+
"""Return the parsed ``run.lock`` snapshot, or ``None`` if absent."""
|
|
135
|
+
if not self.lock_path.exists():
|
|
136
|
+
return None
|
|
137
|
+
return OmegaConf.to_container(OmegaConf.load(self.lock_path), resolve=True)
|
|
138
|
+
|
|
139
|
+
def load_results(self) -> Optional[Any]:
|
|
140
|
+
"""Return the parsed ``results.json`` payload, or ``None`` if absent."""
|
|
141
|
+
if not self.results_path.exists():
|
|
142
|
+
return None
|
|
143
|
+
with open(self.results_path) as f:
|
|
144
|
+
return json.load(f)
|
|
145
|
+
|
|
146
|
+
def load_error(self) -> Optional[dict]:
|
|
147
|
+
"""Return the parsed ``run.error`` payload, or ``None`` if absent."""
|
|
148
|
+
if not self.error_path.exists():
|
|
149
|
+
return None
|
|
150
|
+
try:
|
|
151
|
+
with open(self.error_path) as f:
|
|
152
|
+
return json.load(f)
|
|
153
|
+
except Exception as exc: # pragma: no cover - corrupt sidecar
|
|
154
|
+
logger.warning(f"Could not read {ERROR_NAME} at {self.save_dir}: {exc}")
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
# ── lifecycle ──
|
|
158
|
+
@property
|
|
159
|
+
def is_complete(self) -> bool:
|
|
160
|
+
return self.lock_path.exists() and self.complete_path.exists()
|
|
161
|
+
|
|
162
|
+
def is_complete_task(self) -> bool:
|
|
163
|
+
"""Completeness for a sweep-task dir, which carries ``run.complete`` and
|
|
164
|
+
``results.json`` but no ``run.lock`` (its snapshot lives in the task DB)."""
|
|
165
|
+
return self.complete_path.exists() and self.results_path.exists()
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def status(self) -> str:
|
|
169
|
+
if self.is_complete:
|
|
170
|
+
return STATUS_DONE
|
|
171
|
+
if self.lock_path.exists():
|
|
172
|
+
return STATUS_INCOMPLETE
|
|
173
|
+
return STATUS_MISSING
|