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/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