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/report.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Zero-dependency static HTML report built on the graph JSON.
|
|
2
|
+
|
|
3
|
+
The report is a single self-contained HTML file (all CSS/JS inline, no CDN) so
|
|
4
|
+
it opens offline on an HPC login node. Its stable contract is the embedded JSON
|
|
5
|
+
island (``<script id="flexlock-data">``), which external tools can extract and
|
|
6
|
+
parse independently of the rendering.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from importlib.resources import files
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import query
|
|
14
|
+
from .run_record import _atomic_write
|
|
15
|
+
|
|
16
|
+
TEMPLATE_NAME = "report_template.html"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _load_template() -> str:
|
|
20
|
+
return files("flexlock").joinpath(TEMPLATE_NAME).read_text(encoding="utf-8")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _json_island(data: dict) -> str:
|
|
24
|
+
"""Serialize + escape so a ``</script>`` (e.g. inside a note) can't break out.
|
|
25
|
+
|
|
26
|
+
``</`` → ``<\\/`` is a valid JSON string escape (``\\/`` decodes to ``/``),
|
|
27
|
+
so ``JSON.parse`` round-trips the original text.
|
|
28
|
+
"""
|
|
29
|
+
return json.dumps(data, default=str).replace("</", "<\\/")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def generate_report(
|
|
33
|
+
results_root,
|
|
34
|
+
out_path,
|
|
35
|
+
title=None,
|
|
36
|
+
include_groups=True,
|
|
37
|
+
embed_configs=False,
|
|
38
|
+
) -> Path:
|
|
39
|
+
"""Render the static HTML report for ``results_root`` to ``out_path``."""
|
|
40
|
+
results_root = Path(results_root)
|
|
41
|
+
graph = query.build_graph(results_root, include_groups=include_groups)
|
|
42
|
+
|
|
43
|
+
if embed_configs:
|
|
44
|
+
for node in graph["nodes"]:
|
|
45
|
+
lock = query._load_lock(Path(node["id"]))
|
|
46
|
+
node["config"] = (lock or {}).get("config", {}) if lock else {}
|
|
47
|
+
# Attach the failure payload too, for the detail pane.
|
|
48
|
+
if node.get("status") == "failed":
|
|
49
|
+
st = query.run_status(Path(node["id"]))
|
|
50
|
+
if st.get("error"):
|
|
51
|
+
node["error"] = st["error"]
|
|
52
|
+
else:
|
|
53
|
+
# Even without full configs, surface error payloads for failed nodes.
|
|
54
|
+
for node in graph["nodes"]:
|
|
55
|
+
if node.get("status") == "failed":
|
|
56
|
+
st = query.run_status(Path(node["id"]))
|
|
57
|
+
if st.get("error"):
|
|
58
|
+
node["error"] = st["error"]
|
|
59
|
+
|
|
60
|
+
payload = {"graph": graph, "colors": query.STATUS_COLORS}
|
|
61
|
+
report_title = title or f"FlexLock report — {results_root}"
|
|
62
|
+
|
|
63
|
+
html = _load_template()
|
|
64
|
+
html = html.replace("{{TITLE}}", _html_escape(report_title))
|
|
65
|
+
html = html.replace("{{DATA}}", _json_island(payload))
|
|
66
|
+
|
|
67
|
+
out_path = Path(out_path)
|
|
68
|
+
_atomic_write(out_path, html)
|
|
69
|
+
return out_path
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _html_escape(s: str) -> str:
|
|
73
|
+
return (
|
|
74
|
+
str(s)
|
|
75
|
+
.replace("&", "&")
|
|
76
|
+
.replace("<", "<")
|
|
77
|
+
.replace(">", ">")
|
|
78
|
+
)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>{{TITLE}}</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { --bg:#fafafa; --fg:#222; --line:#ddd; --panel:#fff; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
11
|
+
background:var(--bg); color:var(--fg); }
|
|
12
|
+
header { padding:12px 20px; border-bottom:1px solid var(--line); background:var(--panel); }
|
|
13
|
+
h1 { font-size:18px; margin:0; }
|
|
14
|
+
.sub { color:#888; font-size:12px; margin-top:2px; }
|
|
15
|
+
.wrap { display:flex; gap:16px; padding:16px; align-items:flex-start; flex-wrap:wrap; }
|
|
16
|
+
.col { background:var(--panel); border:1px solid var(--line); border-radius:6px; padding:12px; }
|
|
17
|
+
.col.table { flex:2 1 520px; min-width:320px; }
|
|
18
|
+
.col.graph { flex:1 1 320px; min-width:280px; }
|
|
19
|
+
.col.detail { flex:1 1 320px; min-width:280px; }
|
|
20
|
+
.controls { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px; align-items:center; }
|
|
21
|
+
input[type=text], select { padding:4px 6px; border:1px solid var(--line); border-radius:4px; font-size:13px; }
|
|
22
|
+
table { border-collapse:collapse; width:100%; font-size:13px; }
|
|
23
|
+
th, td { text-align:left; padding:5px 8px; border-bottom:1px solid var(--line); white-space:nowrap;
|
|
24
|
+
overflow:hidden; text-overflow:ellipsis; max-width:220px; }
|
|
25
|
+
th { cursor:pointer; user-select:none; }
|
|
26
|
+
tr.run { cursor:pointer; }
|
|
27
|
+
tr.run:hover { background:#f0f7ff; }
|
|
28
|
+
tr.sel { background:#e3f2fd; }
|
|
29
|
+
.badge { display:inline-block; padding:1px 7px; border-radius:10px; color:#fff; font-size:11px; }
|
|
30
|
+
.statusbox { display:inline-flex; align-items:center; gap:3px; font-size:12px; margin-right:6px; }
|
|
31
|
+
pre { background:#f5f5f5; padding:8px; border-radius:4px; overflow:auto; font-size:12px; max-height:340px; }
|
|
32
|
+
.err { color:#c62828; }
|
|
33
|
+
svg { width:100%; height:auto; border:1px solid var(--line); border-radius:4px; background:#fff; }
|
|
34
|
+
.nodeRect { cursor:pointer; }
|
|
35
|
+
.nodeLabel { font-size:10px; fill:#fff; pointer-events:none; }
|
|
36
|
+
.muted { color:#999; }
|
|
37
|
+
</style>
|
|
38
|
+
</head>
|
|
39
|
+
<body>
|
|
40
|
+
<header>
|
|
41
|
+
<h1>{{TITLE}}</h1>
|
|
42
|
+
<div class="sub" id="meta"></div>
|
|
43
|
+
</header>
|
|
44
|
+
<div class="wrap">
|
|
45
|
+
<div class="col table">
|
|
46
|
+
<div class="controls">
|
|
47
|
+
<input type="text" id="filter" placeholder="filter text…">
|
|
48
|
+
<select id="stageSel"><option value="">all stages</option></select>
|
|
49
|
+
<span id="statusFilters"></span>
|
|
50
|
+
</div>
|
|
51
|
+
<table>
|
|
52
|
+
<thead><tr id="head"></tr></thead>
|
|
53
|
+
<tbody id="rows"></tbody>
|
|
54
|
+
</table>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="col graph">
|
|
57
|
+
<b>Lineage</b>
|
|
58
|
+
<div id="svgHost"></div>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="col detail">
|
|
61
|
+
<b>Detail</b>
|
|
62
|
+
<div id="detail"><span class="muted">select a run…</span></div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<script id="flexlock-data" type="application/json">{{DATA}}</script>
|
|
67
|
+
<script>
|
|
68
|
+
(function () {
|
|
69
|
+
const payload = JSON.parse(document.getElementById("flexlock-data").textContent);
|
|
70
|
+
const graph = payload.graph;
|
|
71
|
+
const nodes = graph.nodes;
|
|
72
|
+
const edges = graph.edges;
|
|
73
|
+
const byId = {};
|
|
74
|
+
nodes.forEach(n => byId[n.id] = n);
|
|
75
|
+
|
|
76
|
+
const COLORS = payload.colors || {};
|
|
77
|
+
function color(status){ return COLORS[status] || "#bdbdbd"; }
|
|
78
|
+
|
|
79
|
+
document.getElementById("meta").textContent =
|
|
80
|
+
graph.nodes.length + " runs · root " + graph.root + " · generated " + graph.generated_at;
|
|
81
|
+
|
|
82
|
+
// Most-frequent metric keys (up to 8) become table columns.
|
|
83
|
+
const freq = {};
|
|
84
|
+
nodes.forEach(n => Object.keys(n.metrics || {}).forEach(k => freq[k] = (freq[k]||0)+1));
|
|
85
|
+
const metricCols = Object.keys(freq).sort((a,b)=>freq[b]-freq[a]).slice(0, 8);
|
|
86
|
+
|
|
87
|
+
const baseCols = [["label","Stage"],["status","Status"],["timestamp","Timestamp"],["note","Note"]];
|
|
88
|
+
const head = document.getElementById("head");
|
|
89
|
+
baseCols.concat(metricCols.map(m=>[m,m])).forEach(([key,lbl]) => {
|
|
90
|
+
const th = document.createElement("th");
|
|
91
|
+
th.textContent = lbl; th.dataset.key = key;
|
|
92
|
+
th.onclick = () => sortBy(key);
|
|
93
|
+
head.appendChild(th);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Status filter checkboxes.
|
|
97
|
+
const statuses = Array.from(new Set(nodes.map(n=>n.status))).sort();
|
|
98
|
+
const sf = document.getElementById("statusFilters");
|
|
99
|
+
const active = new Set(statuses);
|
|
100
|
+
statuses.forEach(s => {
|
|
101
|
+
const span = document.createElement("label");
|
|
102
|
+
span.className = "statusbox";
|
|
103
|
+
const cb = document.createElement("input");
|
|
104
|
+
cb.type = "checkbox"; cb.checked = true;
|
|
105
|
+
cb.onchange = () => { cb.checked ? active.add(s) : active.delete(s); render(); };
|
|
106
|
+
span.appendChild(cb);
|
|
107
|
+
const b = document.createElement("span");
|
|
108
|
+
b.className = "badge"; b.style.background = color(s); b.textContent = s;
|
|
109
|
+
span.appendChild(b);
|
|
110
|
+
sf.appendChild(span);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Stage dropdown.
|
|
114
|
+
const stageSel = document.getElementById("stageSel");
|
|
115
|
+
Array.from(new Set(nodes.map(n=>n.label))).sort().forEach(l => {
|
|
116
|
+
const o = document.createElement("option"); o.value=l; o.textContent=l; stageSel.appendChild(o);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
let sortKey = "timestamp", sortDir = -1, selected = null;
|
|
120
|
+
function sortBy(k){ if(sortKey===k){sortDir*=-1;}else{sortKey=k;sortDir=1;} render(); }
|
|
121
|
+
|
|
122
|
+
function visibleNodes(){
|
|
123
|
+
const q = document.getElementById("filter").value.toLowerCase();
|
|
124
|
+
const stage = stageSel.value;
|
|
125
|
+
return nodes.filter(n => active.has(n.status)
|
|
126
|
+
&& (!stage || n.label === stage)
|
|
127
|
+
&& (!q || JSON.stringify(n).toLowerCase().includes(q)));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function fmtVal(n, key){
|
|
131
|
+
if (["label","status","timestamp","note"].includes(key)) return n[key] == null ? "" : n[key];
|
|
132
|
+
const m = n.metrics || {}; return (key in m) ? m[key] : "";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function render(){
|
|
136
|
+
const rows = document.getElementById("rows");
|
|
137
|
+
rows.innerHTML = "";
|
|
138
|
+
const list = visibleNodes().slice().sort((a,b)=>{
|
|
139
|
+
const va = fmtVal(a, sortKey), vb = fmtVal(b, sortKey);
|
|
140
|
+
return (va > vb ? 1 : va < vb ? -1 : 0) * sortDir;
|
|
141
|
+
});
|
|
142
|
+
list.forEach(n => {
|
|
143
|
+
const tr = document.createElement("tr");
|
|
144
|
+
tr.className = "run" + (selected === n.id ? " sel" : "");
|
|
145
|
+
tr.onclick = () => selectNode(n.id);
|
|
146
|
+
baseCols.forEach(([key]) => {
|
|
147
|
+
const td = document.createElement("td");
|
|
148
|
+
if (key === "status"){
|
|
149
|
+
const b = document.createElement("span");
|
|
150
|
+
b.className = "badge"; b.style.background = color(n.status); b.textContent = n.status;
|
|
151
|
+
td.appendChild(b);
|
|
152
|
+
} else { td.textContent = fmtVal(n, key); td.title = fmtVal(n, key); }
|
|
153
|
+
tr.appendChild(td);
|
|
154
|
+
});
|
|
155
|
+
metricCols.forEach(m => {
|
|
156
|
+
const td = document.createElement("td"); td.textContent = fmtVal(n, m); tr.appendChild(td);
|
|
157
|
+
});
|
|
158
|
+
rows.appendChild(tr);
|
|
159
|
+
});
|
|
160
|
+
drawGraph();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function selectNode(id){
|
|
164
|
+
selected = id;
|
|
165
|
+
const n = byId[id];
|
|
166
|
+
const d = document.getElementById("detail");
|
|
167
|
+
let html = "<h3>"+esc(n.label)+" — <span style='color:"+color(n.status)+"'>"+esc(n.status)+"</span></h3>";
|
|
168
|
+
html += "<div class='muted' style='font-size:11px'>"+esc(n.id)+"</div>";
|
|
169
|
+
if (n.note) html += "<p><b>note:</b> "+esc(n.note)+"</p>";
|
|
170
|
+
if (n.target) html += "<p><b>target:</b> <code>"+esc(n.target)+"</code></p>";
|
|
171
|
+
if (n.tag) html += "<p><b>tag:</b> "+esc(n.tag)+"</p>";
|
|
172
|
+
if (n.metrics && Object.keys(n.metrics).length){
|
|
173
|
+
html += "<p><b>metrics:</b></p><pre>"+esc(JSON.stringify(n.metrics, null, 2))+"</pre>";
|
|
174
|
+
}
|
|
175
|
+
if (n.error){
|
|
176
|
+
html += "<p class='err'><b>error:</b> "+esc(n.error.exc_type||"")+" "+esc(n.error.exc_message||"")+"</p>";
|
|
177
|
+
if (n.error.traceback) html += "<pre class='err'>"+esc(n.error.traceback)+"</pre>";
|
|
178
|
+
}
|
|
179
|
+
if (n.config){
|
|
180
|
+
html += "<p><b>config:</b></p><pre>"+esc(JSON.stringify(n.config, null, 2))+"</pre>";
|
|
181
|
+
}
|
|
182
|
+
d.innerHTML = html;
|
|
183
|
+
render();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Kahn topological layering for the SVG lineage view.
|
|
187
|
+
function layers(){
|
|
188
|
+
const indeg = {}, adj = {};
|
|
189
|
+
nodes.forEach(n => { indeg[n.id]=0; adj[n.id]=[]; });
|
|
190
|
+
edges.forEach(e => { if (byId[e.source] && byId[e.target]){ adj[e.source].push(e.target); indeg[e.target]++; } });
|
|
191
|
+
let frontier = nodes.filter(n=>indeg[n.id]===0).map(n=>n.id);
|
|
192
|
+
const layer = {}; let depth = 0; const seen = new Set();
|
|
193
|
+
while (frontier.length){
|
|
194
|
+
const next = [];
|
|
195
|
+
frontier.forEach(id => { if (seen.has(id)) return; seen.add(id); layer[id]=depth;
|
|
196
|
+
adj[id].forEach(t => { indeg[t]--; if (indeg[t]<=0) next.push(t); }); });
|
|
197
|
+
frontier = next; depth++;
|
|
198
|
+
if (depth > nodes.length + 2) break;
|
|
199
|
+
}
|
|
200
|
+
nodes.forEach(n => { if (!(n.id in layer)) layer[n.id]=0; });
|
|
201
|
+
return layer;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function drawGraph(){
|
|
205
|
+
const host = document.getElementById("svgHost");
|
|
206
|
+
const vis = new Set(visibleNodes().map(n=>n.id));
|
|
207
|
+
const layer = layers();
|
|
208
|
+
const cols = {};
|
|
209
|
+
nodes.forEach(n => { if (!vis.has(n.id)) return; (cols[layer[n.id]] = cols[layer[n.id]]||[]).push(n); });
|
|
210
|
+
const colKeys = Object.keys(cols).map(Number).sort((a,b)=>a-b);
|
|
211
|
+
const W = 300, rowH = 34, boxW = 120, boxH = 22;
|
|
212
|
+
const maxRows = Math.max(1, ...colKeys.map(k=>cols[k].length));
|
|
213
|
+
const H = Math.max(60, maxRows * rowH + 20);
|
|
214
|
+
const pos = {};
|
|
215
|
+
let svg = "<svg viewBox='0 0 "+W+" "+H+"'>";
|
|
216
|
+
colKeys.forEach((k, ci) => {
|
|
217
|
+
const x = colKeys.length>1 ? 10 + ci*( (W-boxW-20)/(colKeys.length-1) ) : 10;
|
|
218
|
+
cols[k].forEach((n, ri) => { pos[n.id] = { x: x, y: 12 + ri*rowH }; });
|
|
219
|
+
});
|
|
220
|
+
edges.forEach(e => {
|
|
221
|
+
const a = pos[e.source], b = pos[e.target];
|
|
222
|
+
if (!a || !b) return;
|
|
223
|
+
const dash = e.type === "sweep_item" ? " stroke-dasharray='3,3'" : "";
|
|
224
|
+
svg += "<line x1='"+(a.x+boxW)+"' y1='"+(a.y+boxH/2)+"' x2='"+b.x+"' y2='"+(b.y+boxH/2)+"' stroke='#bbb'"+dash+"/>";
|
|
225
|
+
});
|
|
226
|
+
nodes.forEach(n => {
|
|
227
|
+
const p = pos[n.id]; if (!p) return;
|
|
228
|
+
const stroke = selected === n.id ? "#000" : "none";
|
|
229
|
+
svg += "<g class='nodeRect' onclick=\"__sel('"+n.id.replace(/'/g,"")+"')\">";
|
|
230
|
+
svg += "<rect x='"+p.x+"' y='"+p.y+"' width='"+boxW+"' height='"+boxH+"' rx='3' fill='"+color(n.status)+"' stroke='"+stroke+"'/>";
|
|
231
|
+
svg += "<text class='nodeLabel' x='"+(p.x+6)+"' y='"+(p.y+15)+"'>"+esc(n.label.slice(0,16))+"</text></g>";
|
|
232
|
+
});
|
|
233
|
+
svg += "</svg>";
|
|
234
|
+
host.innerHTML = svg;
|
|
235
|
+
}
|
|
236
|
+
window.__sel = selectNode;
|
|
237
|
+
|
|
238
|
+
function esc(s){ return String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
|
239
|
+
|
|
240
|
+
document.getElementById("filter").oninput = render;
|
|
241
|
+
stageSel.onchange = render;
|
|
242
|
+
render();
|
|
243
|
+
})();
|
|
244
|
+
</script>
|
|
245
|
+
</body>
|
|
246
|
+
</html>
|
flexlock/resolvers.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""OmegaConf resolvers for FlexLock."""
|
|
2
|
+
|
|
3
|
+
from omegaconf import OmegaConf, DictConfig
|
|
4
|
+
from .data_hash import hash_data
|
|
5
|
+
from .load_stage import load_stage_from_path
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from functools import wraps
|
|
11
|
+
from . import config
|
|
12
|
+
|
|
13
|
+
# Resolvers whose evaluation is *deferred* to stage start (they read live
|
|
14
|
+
# filesystem/upstream-run state that only exists on the worker, not at submit
|
|
15
|
+
# time). During the submit-time freeze these are stubbed to re-emit their own
|
|
16
|
+
# call string (see ``deferred_stubbed``); everything else resolves eagerly.
|
|
17
|
+
DEFERRED_RESOLVERS = ("run_lock", "latest")
|
|
18
|
+
|
|
19
|
+
# Distinct "no default supplied" sentinel so that an explicit ``null`` default
|
|
20
|
+
# (e.g. ``${run_lock:${dir},key,null}``) is honoured rather than treated as
|
|
21
|
+
# "raise". ``None`` cannot serve as this marker because it is a valid default.
|
|
22
|
+
_MISSING = object()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def now_resolver(fmt: str = None) -> str:
|
|
26
|
+
"""
|
|
27
|
+
OmegaConf resolver that returns the current time as a formatted string.
|
|
28
|
+
|
|
29
|
+
Deprecated: prefer ``submit(..., save_dir_policy="timestamp")`` over
|
|
30
|
+
embedding ``${now:}`` in ``save_dir``. See
|
|
31
|
+
``docs/resolution_simplification_plan.md``.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
fmt: Format string for strftime (defaults to config.TIMESTAMP_FORMAT)
|
|
35
|
+
"""
|
|
36
|
+
import warnings
|
|
37
|
+
|
|
38
|
+
warnings.warn(
|
|
39
|
+
"${now:} is deprecated; use save_dir_policy='timestamp' on submit()/run "
|
|
40
|
+
"instead of embedding ${now:} in save_dir.",
|
|
41
|
+
DeprecationWarning,
|
|
42
|
+
stacklevel=2,
|
|
43
|
+
)
|
|
44
|
+
if fmt is None:
|
|
45
|
+
fmt = config.TIMESTAMP_FORMAT
|
|
46
|
+
return datetime.now().strftime(fmt)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def latest_resolver(path_glob: str, default=_MISSING) -> str:
|
|
50
|
+
"""
|
|
51
|
+
OmegaConf resolver that returns the latest path matching the given pattern.
|
|
52
|
+
|
|
53
|
+
Raises ``FileNotFoundError`` when nothing matches, unless a ``default`` is
|
|
54
|
+
supplied (``${latest:pattern,fallback}``), in which case the default is
|
|
55
|
+
returned. Returning the unmatched pattern silently — the old behaviour —
|
|
56
|
+
produced a bogus path that failed far downstream.
|
|
57
|
+
"""
|
|
58
|
+
from glob import glob
|
|
59
|
+
import os
|
|
60
|
+
|
|
61
|
+
# Expand user (~) and resolve the path
|
|
62
|
+
path_glob = os.path.expanduser(path_glob)
|
|
63
|
+
|
|
64
|
+
# Find all paths matching the pattern
|
|
65
|
+
matching_paths = glob(path_glob, recursive=True)
|
|
66
|
+
|
|
67
|
+
if not matching_paths:
|
|
68
|
+
if default is not _MISSING:
|
|
69
|
+
return default
|
|
70
|
+
raise FileNotFoundError(
|
|
71
|
+
f"latest resolver: no paths match pattern {path_glob!r}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Find the latest path by modification time
|
|
75
|
+
latest_path = max(matching_paths, key=os.path.getmtime)
|
|
76
|
+
|
|
77
|
+
return latest_path
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def vinc_resolver(path: str, fmt: str = "_{i:04d}") -> str:
|
|
81
|
+
"""
|
|
82
|
+
OmegaConf resolver that finds the highest existing version of a folder/file
|
|
83
|
+
and returns the next versioned path as a string.
|
|
84
|
+
|
|
85
|
+
The value is a pure function of the current filesystem state (highest
|
|
86
|
+
existing version + 1) so that multiple references to the same ``${vinc:}``
|
|
87
|
+
within one ``submit()`` resolve idempotently to the *same* directory — the
|
|
88
|
+
counter only advances once the previous run's directory actually exists on
|
|
89
|
+
disk.
|
|
90
|
+
|
|
91
|
+
Concurrency note: because the claim is not taken here, two submits racing
|
|
92
|
+
from separate processes can compute the same next version and collide.
|
|
93
|
+
|
|
94
|
+
Deprecated: prefer ``submit(..., save_dir_policy="increment")``, which takes
|
|
95
|
+
the atomic ``mkdir(exist_ok=False)`` claim at the right layer and so fixes
|
|
96
|
+
the race this resolver could not. See ``docs/resolution_simplification_plan.md``.
|
|
97
|
+
"""
|
|
98
|
+
import warnings
|
|
99
|
+
|
|
100
|
+
warnings.warn(
|
|
101
|
+
"${vinc:} is deprecated; use save_dir_policy='increment' on submit()/run "
|
|
102
|
+
"instead of embedding ${vinc:} in save_dir. The policy also claims the "
|
|
103
|
+
"directory atomically, fixing the concurrent-submit race.",
|
|
104
|
+
DeprecationWarning,
|
|
105
|
+
stacklevel=2,
|
|
106
|
+
)
|
|
107
|
+
from .save_dir import next_versioned_path
|
|
108
|
+
|
|
109
|
+
return next_versioned_path(path, fmt)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def run_lock_resolver(run_dir: str, key: str, default=_MISSING):
|
|
113
|
+
"""
|
|
114
|
+
OmegaConf resolver that reads a field from an upstream run.lock.
|
|
115
|
+
|
|
116
|
+
Navigates a dot-separated key path into the run.lock YAML and returns
|
|
117
|
+
the value with its native type (int, float, bool, str, list, dict).
|
|
118
|
+
|
|
119
|
+
Usage in configs:
|
|
120
|
+
${run_lock:path/to/run_dir,config.datamodule.stats_file}
|
|
121
|
+
${run_lock:${run_dir},config.lit_module.regression_checkpoint_path,null}
|
|
122
|
+
"""
|
|
123
|
+
from loguru import logger
|
|
124
|
+
import yaml
|
|
125
|
+
|
|
126
|
+
lock_path = Path(run_dir) / "run.lock"
|
|
127
|
+
if not lock_path.exists():
|
|
128
|
+
if default is not _MISSING:
|
|
129
|
+
logger.warning(f"run_lock resolver: no run.lock at {run_dir}, using default")
|
|
130
|
+
return default
|
|
131
|
+
raise FileNotFoundError(f"run_lock resolver: no run.lock found in {run_dir}")
|
|
132
|
+
|
|
133
|
+
with open(lock_path) as f:
|
|
134
|
+
data = yaml.safe_load(f)
|
|
135
|
+
|
|
136
|
+
# Navigate dot-path
|
|
137
|
+
value = data
|
|
138
|
+
for part in key.split("."):
|
|
139
|
+
if isinstance(value, dict) and part in value:
|
|
140
|
+
value = value[part]
|
|
141
|
+
else:
|
|
142
|
+
if default is not _MISSING:
|
|
143
|
+
return default
|
|
144
|
+
raise KeyError(
|
|
145
|
+
f"run_lock resolver: key '{key}' not found in {lock_path} "
|
|
146
|
+
f"(failed at '{part}')"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if value is None:
|
|
150
|
+
# A legitimately null value is returned as-is when no default was
|
|
151
|
+
# supplied; only substitute an explicitly provided default.
|
|
152
|
+
return None if default is _MISSING else default
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def register_resolvers():
|
|
157
|
+
"""
|
|
158
|
+
Registers the flexlock resolvers with OmegaConf.
|
|
159
|
+
"""
|
|
160
|
+
OmegaConf.register_new_resolver("now", now_resolver)
|
|
161
|
+
OmegaConf.register_new_resolver("vinc", vinc_resolver)
|
|
162
|
+
OmegaConf.register_new_resolver("latest", latest_resolver, use_cache=False)
|
|
163
|
+
OmegaConf.register_new_resolver("run_lock", run_lock_resolver, use_cache=False)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# The real resolver implementations plus their registration options, so a stub
|
|
167
|
+
# context can faithfully restore them on exit.
|
|
168
|
+
def _real_resolvers():
|
|
169
|
+
return {
|
|
170
|
+
"now": (now_resolver, {}),
|
|
171
|
+
"vinc": (vinc_resolver, {}),
|
|
172
|
+
"latest": (latest_resolver, {"use_cache": False}),
|
|
173
|
+
"run_lock": (run_lock_resolver, {"use_cache": False}),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _emit_stub_arg(value) -> str:
|
|
178
|
+
"""Render a (pre-resolved) resolver argument back into interpolation text.
|
|
179
|
+
|
|
180
|
+
``None`` becomes ``null`` so a real ``${run_lock:${dir},key,null}`` default
|
|
181
|
+
survives the freeze; everything else is stringified as-is.
|
|
182
|
+
"""
|
|
183
|
+
if value is None:
|
|
184
|
+
return "null"
|
|
185
|
+
return str(value)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@contextmanager
|
|
189
|
+
def _stubbed(names):
|
|
190
|
+
"""Temporarily replace the named resolvers with stubs that re-emit their own
|
|
191
|
+
call string, with arguments already resolved by OmegaConf.
|
|
192
|
+
|
|
193
|
+
OmegaConf resolves nested interpolations in a resolver's arguments *before*
|
|
194
|
+
invoking the resolver. So under this context, resolving
|
|
195
|
+
``${run_lock:${precompute_dir},key}`` yields the frozen string
|
|
196
|
+
``${run_lock:/data/precomputed,key}`` — every non-stubbed interpolation
|
|
197
|
+
(simple refs, cross-tree, multi-hop, mixed strings, relative refs) is
|
|
198
|
+
resolved by OmegaConf itself, while the stubbed call collapses back to a
|
|
199
|
+
self-contained call string with zero hand-rolled grammar parsing.
|
|
200
|
+
|
|
201
|
+
Restores the real resolvers (with their original options) on exit.
|
|
202
|
+
"""
|
|
203
|
+
def _make_stub(name):
|
|
204
|
+
def _stub(*args):
|
|
205
|
+
return "${" + name + ":" + ",".join(_emit_stub_arg(a) for a in args) + "}"
|
|
206
|
+
return _stub
|
|
207
|
+
|
|
208
|
+
reals = _real_resolvers()
|
|
209
|
+
for name in names:
|
|
210
|
+
OmegaConf.register_new_resolver(
|
|
211
|
+
name, _make_stub(name), replace=True, use_cache=False
|
|
212
|
+
)
|
|
213
|
+
try:
|
|
214
|
+
yield
|
|
215
|
+
finally:
|
|
216
|
+
for name in names:
|
|
217
|
+
func, opts = reals[name]
|
|
218
|
+
OmegaConf.register_new_resolver(name, func, replace=True, **opts)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def deferred_stubbed():
|
|
222
|
+
"""Stub only the deferred resolvers (``run_lock``/``latest``).
|
|
223
|
+
|
|
224
|
+
Used by the merge-before-resolve sweep freeze, where ``vinc``/``now`` should
|
|
225
|
+
still fire (they are being retired via ``save_dir_policy``).
|
|
226
|
+
"""
|
|
227
|
+
return _stubbed(DEFERRED_RESOLVERS)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def frozen_resolvers():
|
|
231
|
+
"""Stub every flexlock resolver, so all ``${name:args}`` calls are preserved
|
|
232
|
+
as frozen call strings during the submit-time node freeze.
|
|
233
|
+
"""
|
|
234
|
+
return _stubbed(tuple(_real_resolvers().keys()))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def resolve_deferred(cfg: DictConfig) -> DictConfig:
|
|
238
|
+
"""Fire the deferred resolvers once, at stage start, and return a detached
|
|
239
|
+
config of concrete values.
|
|
240
|
+
|
|
241
|
+
This is the single point where ``run_lock``/``latest`` (and any other
|
|
242
|
+
interpolations left after the submit-time freeze) are evaluated with the
|
|
243
|
+
real resolvers. Called on the worker right after the task override is merged
|
|
244
|
+
(and on the local single-run path). Re-wrapping as a plain config detaches
|
|
245
|
+
it from any parent so downstream ``config.copy()`` cannot re-fire anything.
|
|
246
|
+
"""
|
|
247
|
+
resolved = OmegaConf.to_container(cfg, resolve=True, throw_on_missing=False)
|
|
248
|
+
return OmegaConf.create(resolved)
|
flexlock/run_cli.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""CLI entry point for FlexLock runner."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from loguru import logger
|
|
6
|
+
from flexlock.runner import FlexLockRunner
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
"""
|
|
11
|
+
CLI entry point for flexlock-run command.
|
|
12
|
+
|
|
13
|
+
This is a standalone CLI that provides direct access to FlexLockRunner
|
|
14
|
+
without requiring a Python script with @flexcli decorator.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
flexlock-run --config experiment.yml
|
|
18
|
+
flexlock-run --defaults module.config:defaults --config exp.yml
|
|
19
|
+
flexlock-run --config exp.yml --select experiments.exp1
|
|
20
|
+
flexlock-run --sweep-file sweep.yaml --n_jobs 4
|
|
21
|
+
"""
|
|
22
|
+
# Make the current working directory importable so users can pass
|
|
23
|
+
# `-d mypkg.module.var` for a package in their project without having
|
|
24
|
+
# to set PYTHONPATH manually. Mirrors what `python -m mypkg` would do.
|
|
25
|
+
cwd = os.getcwd()
|
|
26
|
+
if cwd not in sys.path:
|
|
27
|
+
sys.path.insert(0, cwd)
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
runner = FlexLockRunner()
|
|
31
|
+
runner.run()
|
|
32
|
+
except KeyboardInterrupt:
|
|
33
|
+
logger.info("Interrupted by user")
|
|
34
|
+
sys.exit(130) # Standard exit code for SIGINT
|
|
35
|
+
except Exception as e:
|
|
36
|
+
logger.error(f"FlexLock run failed: {e}")
|
|
37
|
+
import traceback
|
|
38
|
+
|
|
39
|
+
traceback.print_exc()
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
main()
|