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/runner.py
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
"""Runner for FlexLock experiments."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import yaml
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Any, Dict
|
|
8
|
+
from omegaconf import OmegaConf, open_dict
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from .utils import (
|
|
11
|
+
load_python_defaults,
|
|
12
|
+
py2cfg,
|
|
13
|
+
select_and_freeze_root_refs,
|
|
14
|
+
parse_sweep_string,
|
|
15
|
+
load_sweep,
|
|
16
|
+
enqueue_to_file,
|
|
17
|
+
)
|
|
18
|
+
from .exceptions import FlexLockValidationError
|
|
19
|
+
from . import config
|
|
20
|
+
from loguru import logger
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FlexLockRunner:
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self.parser = self._build_parser()
|
|
26
|
+
|
|
27
|
+
def _print_config_and_docstring(self, node_cfg):
|
|
28
|
+
"""Print compiled config and target docstring."""
|
|
29
|
+
print("=== COMPILED CONFIG ===")
|
|
30
|
+
print(OmegaConf.to_yaml(node_cfg))
|
|
31
|
+
|
|
32
|
+
if "_target_" in node_cfg:
|
|
33
|
+
print("=== TARGET FUNCTION DOCSTRING ===")
|
|
34
|
+
try:
|
|
35
|
+
target_path = node_cfg._target_
|
|
36
|
+
module_name, func_name = target_path.rsplit(".", 1)
|
|
37
|
+
module = __import__(module_name, fromlist=[func_name])
|
|
38
|
+
target_func = getattr(module, func_name)
|
|
39
|
+
docstring = getattr(target_func, "__doc__", None)
|
|
40
|
+
print(f"Target: {target_path}")
|
|
41
|
+
print(
|
|
42
|
+
f"Docstring:\n{docstring}"
|
|
43
|
+
if docstring
|
|
44
|
+
else "No docstring available."
|
|
45
|
+
)
|
|
46
|
+
except (ImportError, AttributeError, ValueError) as e:
|
|
47
|
+
print(f"Could not import target function '{node_cfg._target_}': {e}")
|
|
48
|
+
|
|
49
|
+
def _build_parser(self):
|
|
50
|
+
parser = argparse.ArgumentParser(
|
|
51
|
+
description="FlexLock Execution Manager", add_help=False
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"-h", "--help", action="store_true", help="Show this help message and exit."
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--print-config",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="Print the compiled configuration and target function docstring, then exit.",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--check",
|
|
64
|
+
action="store_true",
|
|
65
|
+
help="Preflight: fully resolve the config (and every sweep item) "
|
|
66
|
+
"without touching the filesystem or executing. Reports every "
|
|
67
|
+
"unresolved interpolation with its key, then exits.",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--dump",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="Print the compiled configuration as clean YAML and exit (no headers). "
|
|
73
|
+
"Redirect to a file to capture it for later use with --sweep-file or --enqueue.",
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--edit-config", "-e",
|
|
77
|
+
action="store_true",
|
|
78
|
+
help="Open the compiled configuration in $EDITOR before running. "
|
|
79
|
+
"Like 'git commit': edit the YAML, save and quit to proceed.",
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--enqueue",
|
|
83
|
+
metavar="FILE",
|
|
84
|
+
help="Append the compiled configuration to a YAML queue file and exit. "
|
|
85
|
+
"Creates the file if it does not exist. Run the queue later with "
|
|
86
|
+
"--sweep-file FILE.",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Existing Config/Select args
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--defaults", "-d", help="Python import path for default config"
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument("--config", "-c", help="Path to base YAML config file")
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--select",
|
|
96
|
+
"-s",
|
|
97
|
+
nargs="+",
|
|
98
|
+
help="Dot-separated key selecting the node to run. Pass several "
|
|
99
|
+
"(space- or comma-separated, e.g. '-s train linear_probe' or "
|
|
100
|
+
"'-s train,linear_probe') to run stages in order. Multi-stage is "
|
|
101
|
+
"incompatible with -O/-M, sweeps, and HPC backends (run those "
|
|
102
|
+
"stages one at a time).",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Existing Override args
|
|
106
|
+
parser.add_argument("--merge", "-m", help="Merge file into Root config")
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
"--overrides",
|
|
109
|
+
"-o",
|
|
110
|
+
nargs="+",
|
|
111
|
+
action="append",
|
|
112
|
+
default=[],
|
|
113
|
+
help="Dot-list overrides for Root config (repeatable: -o a=1 b=2 or -o a=1 -o b=2)",
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"--merge-after-select", "-M", help="Merge file into Selected config"
|
|
117
|
+
)
|
|
118
|
+
parser.add_argument(
|
|
119
|
+
"--overrides-after-select",
|
|
120
|
+
"-O",
|
|
121
|
+
nargs="+",
|
|
122
|
+
action="append",
|
|
123
|
+
default=[],
|
|
124
|
+
help="Dot-list overrides for Selected config (repeatable)",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# NEW SWEEP ARGUMENTS
|
|
128
|
+
sweep_group = parser.add_argument_group("Sweep Configuration")
|
|
129
|
+
|
|
130
|
+
# Source (Mutually Exclusive)
|
|
131
|
+
source = sweep_group.add_mutually_exclusive_group()
|
|
132
|
+
source.add_argument(
|
|
133
|
+
"--sweep-key",
|
|
134
|
+
help="Key in config containing the sweep list (e.g. 'experiments.grid')",
|
|
135
|
+
)
|
|
136
|
+
source.add_argument(
|
|
137
|
+
"--sweep-file",
|
|
138
|
+
nargs="+",
|
|
139
|
+
metavar="FILE",
|
|
140
|
+
help="One or more files (yaml, json, txt) providing sweep tasks. "
|
|
141
|
+
"Each file may contain a single config dict (one task) or a list "
|
|
142
|
+
"of dicts (multiple tasks). Results are concatenated in order.",
|
|
143
|
+
)
|
|
144
|
+
source.add_argument(
|
|
145
|
+
"--sweep",
|
|
146
|
+
help="Comma-separated values (e.g. '0.01,0.02' or 'lr=0.1,lr=0.2')",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Sweep root override
|
|
150
|
+
sweep_group.add_argument(
|
|
151
|
+
"--sweep-root",
|
|
152
|
+
metavar="DIR",
|
|
153
|
+
help="Override the directory used as the sweep root for the tasks DB "
|
|
154
|
+
"and lineage markers. Use this when your sweep items have pre-set "
|
|
155
|
+
"save_dirs that don't nest under the base config's save_dir.",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Injection Target
|
|
159
|
+
sweep_group.add_argument(
|
|
160
|
+
"--sweep-target",
|
|
161
|
+
help="Dot-path key to inject the sweep value into (e.g. 'optimizer.lr'). "
|
|
162
|
+
"If omitted, sweep items are merged at the root.",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Execution
|
|
166
|
+
parser.add_argument(
|
|
167
|
+
"--n_jobs",
|
|
168
|
+
type=int,
|
|
169
|
+
default=config.DEFAULT_N_JOBS,
|
|
170
|
+
help="Number of parallel jobs",
|
|
171
|
+
)
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
"--check-exists",
|
|
174
|
+
action="store_true",
|
|
175
|
+
help="Check if run already exists and skip if so.",
|
|
176
|
+
)
|
|
177
|
+
parser.add_argument(
|
|
178
|
+
"--debug",
|
|
179
|
+
action="store_true",
|
|
180
|
+
help="Enable debug mode (Post-mortem PDB in scripts, Locals Injection in Notebooks).",
|
|
181
|
+
)
|
|
182
|
+
parser.add_argument(
|
|
183
|
+
"--note",
|
|
184
|
+
metavar="TEXT",
|
|
185
|
+
help="Free-text intent recorded as a top-level 'note:' key in run.lock "
|
|
186
|
+
"(e.g. --note 'baseline before lr sweep'). Never affects caching. "
|
|
187
|
+
"For sweeps the note lands on the master run.lock.",
|
|
188
|
+
)
|
|
189
|
+
parser.add_argument(
|
|
190
|
+
"--save-dir-policy",
|
|
191
|
+
choices=["raise", "increment", "overwrite", "skip", "unsafe", "timestamp"],
|
|
192
|
+
default=None,
|
|
193
|
+
help="What to do when save_dir already holds a run (run.lock "
|
|
194
|
+
"present). 'raise' (default): refuse; 'increment': version the "
|
|
195
|
+
"dir (run -> run_0000, claimed atomically); 'overwrite': clean "
|
|
196
|
+
"the dir then run; 'skip': reuse the existing complete run's "
|
|
197
|
+
"result; 'unsafe': run in place, no check; 'timestamp': append "
|
|
198
|
+
"the timestamp format. Replaces the ${vinc:}/${now:} resolvers.",
|
|
199
|
+
)
|
|
200
|
+
parser.add_argument(
|
|
201
|
+
"--force",
|
|
202
|
+
action="store_true",
|
|
203
|
+
help="Invalidate run.complete and re-execute in place (bypasses "
|
|
204
|
+
"the default save-dir guard). Use to rerun a crashed or stale run "
|
|
205
|
+
"in its existing save_dir.",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# HPC Backend Configuration
|
|
209
|
+
backend_group = parser.add_argument_group("HPC Backend Configuration")
|
|
210
|
+
backend = backend_group.add_mutually_exclusive_group()
|
|
211
|
+
backend.add_argument(
|
|
212
|
+
"--slurm-config",
|
|
213
|
+
help="Path to Slurm configuration YAML file for HPC execution",
|
|
214
|
+
)
|
|
215
|
+
backend.add_argument(
|
|
216
|
+
"--pbs-config", help="Path to PBS configuration YAML file for HPC execution"
|
|
217
|
+
)
|
|
218
|
+
backend_group.add_argument(
|
|
219
|
+
"--dry-run",
|
|
220
|
+
action="store_true",
|
|
221
|
+
help="Render the HPC submission script and exit without submitting "
|
|
222
|
+
"(also prints validation warnings).",
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return parser
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _flatten_overrides(value):
|
|
229
|
+
"""Normalize ``--overrides``/``-o`` into a flat list of ``key=value``.
|
|
230
|
+
|
|
231
|
+
Because the argument uses ``nargs="+"`` plus ``action="append"`` to be
|
|
232
|
+
repeatable (``-o a=1 b=2`` and ``-o a=1 -o b=2``), argparse yields a
|
|
233
|
+
list of lists. This collapses it to the flat list OmegaConf expects.
|
|
234
|
+
Idempotent: an already-flat list of strings passes through unchanged.
|
|
235
|
+
"""
|
|
236
|
+
if not value:
|
|
237
|
+
return []
|
|
238
|
+
flat = []
|
|
239
|
+
for item in value:
|
|
240
|
+
if isinstance(item, str):
|
|
241
|
+
flat.append(item)
|
|
242
|
+
else: # a group produced by action="append"
|
|
243
|
+
flat.extend(item)
|
|
244
|
+
return flat
|
|
245
|
+
|
|
246
|
+
def load_config(self, args):
|
|
247
|
+
# 1. Start with Injected Base (from decorator) or Empty
|
|
248
|
+
cfg = OmegaConf.create()
|
|
249
|
+
|
|
250
|
+
# 2. Merge Python Defaults (if --defaults passed)
|
|
251
|
+
# Note: --defaults flag overrides decorator defaults if both exist
|
|
252
|
+
if args.defaults:
|
|
253
|
+
ext_defaults = load_python_defaults(args.defaults)
|
|
254
|
+
cfg.merge_with(OmegaConf.create(ext_defaults))
|
|
255
|
+
|
|
256
|
+
# 3. Outer Overrides
|
|
257
|
+
if args.config:
|
|
258
|
+
cfg.merge_with(OmegaConf.load(args.config))
|
|
259
|
+
if args.merge:
|
|
260
|
+
cfg.merge_with(OmegaConf.load(args.merge))
|
|
261
|
+
overrides = self._flatten_overrides(args.overrides)
|
|
262
|
+
if overrides:
|
|
263
|
+
cfg.merge_with(OmegaConf.from_dotlist(overrides))
|
|
264
|
+
|
|
265
|
+
if args.debug:
|
|
266
|
+
logger.debug(f"Final Root Config: {cfg}")
|
|
267
|
+
return cfg
|
|
268
|
+
|
|
269
|
+
def _parse_cli_sweep(self, sweep_str: str) -> List[Any]:
|
|
270
|
+
"""Back-compat shim — delegates to :func:`flexlock.utils.parse_sweep_string`."""
|
|
271
|
+
return parse_sweep_string(sweep_str)
|
|
272
|
+
|
|
273
|
+
def _load_sweep_tasks(self, args, root_cfg) -> List[Dict]:
|
|
274
|
+
"""Back-compat shim — delegates to :func:`flexlock.utils.load_sweep`."""
|
|
275
|
+
return load_sweep(
|
|
276
|
+
sweep=args.sweep,
|
|
277
|
+
sweep_file=args.sweep_file,
|
|
278
|
+
sweep_key=args.sweep_key,
|
|
279
|
+
root_cfg=root_cfg,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _parse_selects(value) -> List:
|
|
284
|
+
"""Normalize ``--select``/``-s`` into a flat list of stage keys.
|
|
285
|
+
|
|
286
|
+
``--select`` uses ``nargs="+"`` so space-separated stages arrive as a
|
|
287
|
+
list; each entry may also be comma-separated. Returns ``[None]`` when
|
|
288
|
+
no selection was given (the whole root config is the node).
|
|
289
|
+
"""
|
|
290
|
+
if not value:
|
|
291
|
+
return [None]
|
|
292
|
+
keys = []
|
|
293
|
+
for item in value:
|
|
294
|
+
for part in str(item).split(","):
|
|
295
|
+
part = part.strip()
|
|
296
|
+
if part:
|
|
297
|
+
keys.append(part)
|
|
298
|
+
return keys or [None]
|
|
299
|
+
|
|
300
|
+
def _validate_multiselect(self, args):
|
|
301
|
+
"""Reject flags whose meaning is ambiguous with a stage *sequence*.
|
|
302
|
+
|
|
303
|
+
Phase 1 keeps multi-stage strictly local + sequential. After-select
|
|
304
|
+
overrides target *the* selected node (undefined with many), sweeps and
|
|
305
|
+
HPC backends need a defined cross-stage semantic that isn't wired yet.
|
|
306
|
+
Root-level ``-o``/``-m``/``-c`` remain valid — that's how you pass a
|
|
307
|
+
shared anchor like ``pipeline_dir``.
|
|
308
|
+
"""
|
|
309
|
+
offending = []
|
|
310
|
+
if args.overrides_after_select:
|
|
311
|
+
offending.append("-O/--overrides-after-select")
|
|
312
|
+
if args.merge_after_select:
|
|
313
|
+
offending.append("-M/--merge-after-select")
|
|
314
|
+
if args.sweep or args.sweep_file or args.sweep_key:
|
|
315
|
+
offending.append("--sweep/--sweep-file/--sweep-key")
|
|
316
|
+
if getattr(args, "slurm_config", None) or getattr(args, "pbs_config", None):
|
|
317
|
+
offending.append("--slurm-config/--pbs-config")
|
|
318
|
+
if args.enqueue:
|
|
319
|
+
offending.append("--enqueue")
|
|
320
|
+
if args.edit_config:
|
|
321
|
+
offending.append("-e/--edit-config")
|
|
322
|
+
if offending:
|
|
323
|
+
raise FlexLockValidationError(
|
|
324
|
+
"Multi-stage selection (-s with >1 stage) is incompatible with: "
|
|
325
|
+
+ ", ".join(offending)
|
|
326
|
+
+ ". Run these stages one at a time, or pass shared values as "
|
|
327
|
+
"root-level overrides (-o pipeline_dir=..., -m, -c)."
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def _build_node_cfg(self, args, root_cfg, base_cfg, select, name=None):
|
|
331
|
+
"""Select a node from ``root_cfg`` and prepare it for submission.
|
|
332
|
+
|
|
333
|
+
Mirrors the single-stage path (selection → base_cfg mutual-merge →
|
|
334
|
+
save_dir default) so each stage in a sequence is built identically.
|
|
335
|
+
"""
|
|
336
|
+
if select:
|
|
337
|
+
try:
|
|
338
|
+
node_cfg = select_and_freeze_root_refs(root_cfg, select)
|
|
339
|
+
except KeyError as e:
|
|
340
|
+
raise FlexLockValidationError(
|
|
341
|
+
f"Selection '{select}' returned None."
|
|
342
|
+
) from e
|
|
343
|
+
else:
|
|
344
|
+
node_cfg = root_cfg
|
|
345
|
+
|
|
346
|
+
if base_cfg is not None:
|
|
347
|
+
_b = base_cfg.copy()
|
|
348
|
+
_b.merge_with(node_cfg)
|
|
349
|
+
node_cfg.merge_with(_b)
|
|
350
|
+
|
|
351
|
+
return self._prepare_node(node_cfg, name=name or select or "exp")
|
|
352
|
+
|
|
353
|
+
def _run_multi(self, args, root_cfg, base_cfg, selects):
|
|
354
|
+
"""Run a sequence of selected stages in order, locally and blocking.
|
|
355
|
+
|
|
356
|
+
Each stage is an independent :meth:`Project.submit` with ``wait=True``,
|
|
357
|
+
so downstream stages see upstream artifacts on disk (the ``pipeline_dir``
|
|
358
|
+
anchor pattern). Returns the list of raw stage return values.
|
|
359
|
+
"""
|
|
360
|
+
from .api import Project
|
|
361
|
+
|
|
362
|
+
self._validate_multiselect(args)
|
|
363
|
+
debug = args.debug or config.get_env_bool("FLEXLOCK_DEBUG", False)
|
|
364
|
+
proj = Project(root_cfg)
|
|
365
|
+
|
|
366
|
+
results = []
|
|
367
|
+
for sel in selects:
|
|
368
|
+
node_cfg = self._build_node_cfg(args, root_cfg, base_cfg, sel)
|
|
369
|
+
|
|
370
|
+
# Preview flags iterate over the whole sequence rather than submit.
|
|
371
|
+
if args.print_config:
|
|
372
|
+
print(f"# --- stage: {sel} ---")
|
|
373
|
+
self._print_config_and_docstring(node_cfg)
|
|
374
|
+
continue
|
|
375
|
+
if args.dump:
|
|
376
|
+
print(f"# --- stage: {sel} ---")
|
|
377
|
+
print(OmegaConf.to_yaml(node_cfg), end="")
|
|
378
|
+
continue
|
|
379
|
+
|
|
380
|
+
logger.info(
|
|
381
|
+
f"[multi-select] stage '{sel}' → {node_cfg.get('save_dir')}"
|
|
382
|
+
)
|
|
383
|
+
outcome = proj.submit(
|
|
384
|
+
node_cfg,
|
|
385
|
+
n_jobs=args.n_jobs,
|
|
386
|
+
smart_run=bool(args.check_exists),
|
|
387
|
+
debug=debug,
|
|
388
|
+
print_config=False,
|
|
389
|
+
dry_run=getattr(args, "dry_run", False),
|
|
390
|
+
note=getattr(args, "note", None),
|
|
391
|
+
save_dir_policy=getattr(args, "save_dir_policy", None),
|
|
392
|
+
force=getattr(args, "force", False),
|
|
393
|
+
)
|
|
394
|
+
results.append(outcome)
|
|
395
|
+
|
|
396
|
+
if args.print_config or args.dump:
|
|
397
|
+
return None
|
|
398
|
+
return [r.result if hasattr(r, "result") else r for r in results]
|
|
399
|
+
|
|
400
|
+
def _prepare_node(self, cfg, name="exp"):
|
|
401
|
+
"""Ensure ``cfg`` has a ``save_dir`` — fall back to ``outputs/<name>/<timestamp>``."""
|
|
402
|
+
if "save_dir" not in cfg or cfg.save_dir is None:
|
|
403
|
+
ts = datetime.now().strftime(config.TIMESTAMP_FORMAT)
|
|
404
|
+
with open_dict(cfg):
|
|
405
|
+
cfg.save_dir = str(Path("outputs") / name / ts)
|
|
406
|
+
cfg.save_dir = cfg.save_dir # Force interpolation resolution
|
|
407
|
+
return cfg
|
|
408
|
+
|
|
409
|
+
def run(self, cli_args=None, base_cfg=None):
|
|
410
|
+
"""Thin layer over :meth:`Project.submit` — shapes CLI args and dispatches."""
|
|
411
|
+
from .api import Project
|
|
412
|
+
|
|
413
|
+
args = self.parser.parse_args(cli_args)
|
|
414
|
+
args.overrides = self._flatten_overrides(args.overrides)
|
|
415
|
+
args.overrides_after_select = self._flatten_overrides(args.overrides_after_select)
|
|
416
|
+
|
|
417
|
+
# `--help` / `-h` is registered with action='store_true' (we own
|
|
418
|
+
# help formatting), so argparse parses but doesn't auto-exit. We
|
|
419
|
+
# honour it here before doing any work.
|
|
420
|
+
if args.help:
|
|
421
|
+
self.parser.print_help()
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
# Build the root config from CLI inputs (defaults + config + merge + overrides).
|
|
425
|
+
root_cfg = self.load_config(args)
|
|
426
|
+
logger.info(f"Loaded root config: {root_cfg}")
|
|
427
|
+
|
|
428
|
+
# Normalize -s into a list of stage keys. Several stages run in order
|
|
429
|
+
# via the multi-stage path; a single stage keeps the original flow.
|
|
430
|
+
selects = self._parse_selects(args.select)
|
|
431
|
+
if len(selects) > 1:
|
|
432
|
+
return self._run_multi(args, root_cfg, base_cfg, selects)
|
|
433
|
+
select = selects[0]
|
|
434
|
+
|
|
435
|
+
# Selection happens at the node level — freezes root-scope refs.
|
|
436
|
+
if select:
|
|
437
|
+
try:
|
|
438
|
+
node_cfg = select_and_freeze_root_refs(root_cfg, select)
|
|
439
|
+
except KeyError as e:
|
|
440
|
+
raise FlexLockValidationError(
|
|
441
|
+
f"Selection '{select}' returned None."
|
|
442
|
+
) from e
|
|
443
|
+
# Warn when a -o key also exists in the selected subtree — the
|
|
444
|
+
# override landed on the root, not the stage. Root-only anchors
|
|
445
|
+
# (params, pipeline_dir, …) are intentional and stay silent.
|
|
446
|
+
if args.overrides:
|
|
447
|
+
_missing = object()
|
|
448
|
+
for kv in args.overrides:
|
|
449
|
+
key = kv.split("=", 1)[0]
|
|
450
|
+
if OmegaConf.select(node_cfg, key, default=_missing) is not _missing:
|
|
451
|
+
logger.warning(
|
|
452
|
+
f"'-o {kv}' was applied to the root config, but key '{key}' "
|
|
453
|
+
f"also exists in the selected node '{select}' — "
|
|
454
|
+
f"your override did NOT reach the stage. "
|
|
455
|
+
f"Use '-O {kv}' to override the stage directly. "
|
|
456
|
+
f"See docs/cli_reference.md § Configuration Overrides."
|
|
457
|
+
)
|
|
458
|
+
else:
|
|
459
|
+
node_cfg = root_cfg
|
|
460
|
+
|
|
461
|
+
# @flexcli decorator may inject a base_cfg — preserve the existing
|
|
462
|
+
# mutual-merge semantics so global keys keep their root pointers.
|
|
463
|
+
if base_cfg is not None:
|
|
464
|
+
_b = base_cfg.copy()
|
|
465
|
+
_b.merge_with(node_cfg)
|
|
466
|
+
node_cfg.merge_with(_b)
|
|
467
|
+
|
|
468
|
+
# Inject a default save_dir if the selected node doesn't carry one.
|
|
469
|
+
node_cfg = self._prepare_node(node_cfg)
|
|
470
|
+
|
|
471
|
+
# --edit-config / -e: open compiled config in $EDITOR before running.
|
|
472
|
+
if args.edit_config:
|
|
473
|
+
import subprocess
|
|
474
|
+
import tempfile
|
|
475
|
+
editor = os.environ.get("EDITOR", "vi")
|
|
476
|
+
original_yaml = OmegaConf.to_yaml(node_cfg)
|
|
477
|
+
with tempfile.NamedTemporaryFile(
|
|
478
|
+
suffix=".yaml", mode="w", delete=False, prefix="flexlock_edit_"
|
|
479
|
+
) as f:
|
|
480
|
+
f.write(original_yaml)
|
|
481
|
+
tmppath = f.name
|
|
482
|
+
try:
|
|
483
|
+
while True:
|
|
484
|
+
subprocess.call([editor, tmppath])
|
|
485
|
+
with open(tmppath) as fh:
|
|
486
|
+
edited_raw = fh.read()
|
|
487
|
+
try:
|
|
488
|
+
node_cfg = OmegaConf.create(yaml.safe_load(edited_raw))
|
|
489
|
+
break
|
|
490
|
+
except Exception as exc:
|
|
491
|
+
print(f"[flexlock] Invalid YAML: {exc}")
|
|
492
|
+
answer = input("Re-open editor? [Y/n] ").strip().lower()
|
|
493
|
+
if answer in ("n", "no"):
|
|
494
|
+
raise SystemExit(1)
|
|
495
|
+
if edited_raw == original_yaml:
|
|
496
|
+
answer = input("No changes detected, run anyway? [y/N] ").strip().lower()
|
|
497
|
+
if answer not in ("y", "yes"):
|
|
498
|
+
raise SystemExit(0)
|
|
499
|
+
finally:
|
|
500
|
+
Path(tmppath).unlink(missing_ok=True)
|
|
501
|
+
|
|
502
|
+
# --dump: emit clean YAML (no decorative headers) and exit.
|
|
503
|
+
if args.dump:
|
|
504
|
+
print(OmegaConf.to_yaml(node_cfg), end="")
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
# --enqueue: append compiled config to a YAML queue file and exit.
|
|
508
|
+
if args.enqueue:
|
|
509
|
+
cfg_dict = OmegaConf.to_container(node_cfg, resolve=False, throw_on_missing=False)
|
|
510
|
+
n = enqueue_to_file(args.enqueue, cfg_dict)
|
|
511
|
+
logger.info(f"Enqueued 1 task → {args.enqueue} ({n} task(s) in queue)")
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
# Load the sweep list from whichever source the user picked.
|
|
515
|
+
sweep_tasks = load_sweep(
|
|
516
|
+
sweep=args.sweep,
|
|
517
|
+
sweep_file=args.sweep_file,
|
|
518
|
+
sweep_key=args.sweep_key,
|
|
519
|
+
root_cfg=root_cfg,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
# Honour FLEXLOCK_DEBUG env var as a CLI-side debug toggle.
|
|
523
|
+
debug = args.debug or config.get_env_bool("FLEXLOCK_DEBUG", False)
|
|
524
|
+
|
|
525
|
+
# Hand off to the single execution kernel.
|
|
526
|
+
proj = Project(root_cfg)
|
|
527
|
+
|
|
528
|
+
# --check: side-effect-free preflight resolution, then exit.
|
|
529
|
+
if args.check:
|
|
530
|
+
errors = proj.check(
|
|
531
|
+
node_cfg,
|
|
532
|
+
sweep=sweep_tasks or None,
|
|
533
|
+
sweep_target=args.sweep_target,
|
|
534
|
+
overrides=args.overrides_after_select or None,
|
|
535
|
+
merge=args.merge_after_select,
|
|
536
|
+
)
|
|
537
|
+
if not errors:
|
|
538
|
+
print("[flexlock] check OK — all interpolations resolve.")
|
|
539
|
+
return None
|
|
540
|
+
print(f"[flexlock] check FAILED — {len(errors)} unresolved interpolation(s):")
|
|
541
|
+
for e in errors:
|
|
542
|
+
where = "" if e["item"] is None else f"sweep item {e['item']}, "
|
|
543
|
+
print(f" - {where}{e['full_key']}: {e['error']}")
|
|
544
|
+
raise SystemExit(1)
|
|
545
|
+
outcome = proj.submit(
|
|
546
|
+
node_cfg,
|
|
547
|
+
sweep=sweep_tasks or None,
|
|
548
|
+
sweep_target=args.sweep_target,
|
|
549
|
+
sweep_root=getattr(args, "sweep_root", None),
|
|
550
|
+
n_jobs=args.n_jobs,
|
|
551
|
+
smart_run=bool(args.check_exists),
|
|
552
|
+
slurm_config=getattr(args, "slurm_config", None),
|
|
553
|
+
pbs_config=getattr(args, "pbs_config", None),
|
|
554
|
+
overrides=args.overrides_after_select or None,
|
|
555
|
+
merge=args.merge_after_select,
|
|
556
|
+
debug=debug,
|
|
557
|
+
print_config=args.print_config,
|
|
558
|
+
dry_run=getattr(args, "dry_run", False),
|
|
559
|
+
note=getattr(args, "note", None),
|
|
560
|
+
save_dir_policy=getattr(args, "save_dir_policy", None),
|
|
561
|
+
force=getattr(args, "force", False),
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
# Back-compat: the runner historically returned the user function's
|
|
565
|
+
# raw return value (and is consumed by @flexcli as such). Unwrap the
|
|
566
|
+
# ExecutionResult so existing callers keep working.
|
|
567
|
+
if outcome is None:
|
|
568
|
+
return None
|
|
569
|
+
if isinstance(outcome, list):
|
|
570
|
+
return [r.result if hasattr(r, "result") else r for r in outcome]
|
|
571
|
+
return outcome.result if hasattr(outcome, "result") else outcome
|