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/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("&", "&amp;")
76
+ .replace("<", "&lt;")
77
+ .replace(">", "&gt;")
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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()