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/utils.py
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
"""Utility functions for FlexLock."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import importlib
|
|
5
|
+
import sys
|
|
6
|
+
import functools
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Tuple, Dict, List
|
|
9
|
+
from omegaconf import OmegaConf, DictConfig, ListConfig, open_dict
|
|
10
|
+
from dataclasses import is_dataclass
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from loguru import logger
|
|
13
|
+
import warnings
|
|
14
|
+
from dataclasses import fields
|
|
15
|
+
|
|
16
|
+
# select_and_freeze_root_refs moved to flexlock.freeze; re-exported here for the
|
|
17
|
+
# existing import sites (api.py, runner.py, tests) that import it from utils.
|
|
18
|
+
from .freeze import select_and_freeze_root_refs # noqa: F401
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def collect_target_include_patterns(cfg, repo_path=None):
|
|
22
|
+
"""
|
|
23
|
+
Recursively walk a config, collect all _target_ values, resolve them to
|
|
24
|
+
source file paths relative to the repo root. Returns a list of relative
|
|
25
|
+
file paths suitable for use as match_include patterns in smart_run.
|
|
26
|
+
|
|
27
|
+
External targets (site-packages, builtins) are silently skipped.
|
|
28
|
+
"""
|
|
29
|
+
import os
|
|
30
|
+
|
|
31
|
+
targets = set()
|
|
32
|
+
_walk_targets(cfg, targets)
|
|
33
|
+
|
|
34
|
+
if not targets:
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
patterns = []
|
|
38
|
+
for target_str in targets:
|
|
39
|
+
try:
|
|
40
|
+
module_name, _ = target_str.rsplit(".", 1)
|
|
41
|
+
mod = importlib.import_module(module_name)
|
|
42
|
+
source = inspect.getfile(mod)
|
|
43
|
+
|
|
44
|
+
if repo_path is None:
|
|
45
|
+
repo_path = resolve_module_to_repo_path(module_name)
|
|
46
|
+
|
|
47
|
+
rel = os.path.relpath(source, repo_path)
|
|
48
|
+
# Skip if outside the repo (e.g. ../site-packages/...)
|
|
49
|
+
if rel.startswith(".."):
|
|
50
|
+
continue
|
|
51
|
+
patterns.append(rel)
|
|
52
|
+
except Exception:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
return sorted(set(patterns))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _walk_targets(cfg, out):
|
|
59
|
+
"""Recursively collect _target_ strings from a nested config."""
|
|
60
|
+
if isinstance(cfg, (dict, DictConfig)):
|
|
61
|
+
for key, val in cfg.items():
|
|
62
|
+
if key == "_snapshot_":
|
|
63
|
+
continue
|
|
64
|
+
if key == "_target_" and isinstance(val, str):
|
|
65
|
+
out.add(val)
|
|
66
|
+
else:
|
|
67
|
+
_walk_targets(val, out)
|
|
68
|
+
elif isinstance(cfg, (list, ListConfig)):
|
|
69
|
+
for item in cfg:
|
|
70
|
+
_walk_targets(item, out)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def resolve_module_to_repo_path(module_name: str) -> str:
|
|
74
|
+
"""Resolve a Python module name to its containing git repository's working directory."""
|
|
75
|
+
mod = importlib.import_module(module_name)
|
|
76
|
+
source_file = inspect.getfile(mod)
|
|
77
|
+
from git.repo import Repo as GitRepo
|
|
78
|
+
|
|
79
|
+
repo_obj = GitRepo(source_file, search_parent_directories=True)
|
|
80
|
+
return repo_obj.working_tree_dir
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def extract_tracking_info(cfg) -> Tuple[Dict, Dict, List]:
|
|
84
|
+
"""
|
|
85
|
+
Extract tracking information from config's _snapshot_ field.
|
|
86
|
+
|
|
87
|
+
This is a unified function used across FlexLock to extract repository
|
|
88
|
+
tracking, data hashing, and lineage information from configurations.
|
|
89
|
+
|
|
90
|
+
If no repos.main is specified but the config has a _target_, automatically
|
|
91
|
+
detects the git repository of the target function's source file.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
cfg: OmegaConf DictConfig with optional _snapshot_ field
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
tuple: (repos, data, prevs) where:
|
|
98
|
+
- repos: dict of {name: {"path": str, "include": list|None, "exclude": list|None}}
|
|
99
|
+
- data: dict of {name: path} for data files/dirs to hash
|
|
100
|
+
- prevs: list of paths to check for upstream FlexLock runs
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
FlexLockConfigError: If _snapshot_ contains invalid keys like 'repo' (singular)
|
|
104
|
+
|
|
105
|
+
Examples:
|
|
106
|
+
>>> cfg = OmegaConf.create({
|
|
107
|
+
... '_snapshot_': {
|
|
108
|
+
... 'repos': {'main': '.'},
|
|
109
|
+
... 'data': {'input': 'data/train.csv'}
|
|
110
|
+
... }
|
|
111
|
+
... })
|
|
112
|
+
>>> repos, data, prevs = extract_tracking_info(cfg)
|
|
113
|
+
>>> repos
|
|
114
|
+
{'main': {'path': '.'}}
|
|
115
|
+
"""
|
|
116
|
+
from .exceptions import FlexLockConfigError
|
|
117
|
+
|
|
118
|
+
repos = {}
|
|
119
|
+
data = {}
|
|
120
|
+
prevs = []
|
|
121
|
+
|
|
122
|
+
if "_snapshot_" in cfg:
|
|
123
|
+
snap_cfg = cfg._snapshot_
|
|
124
|
+
|
|
125
|
+
# Extract repos (only plural supported)
|
|
126
|
+
if "repos" in snap_cfg:
|
|
127
|
+
repos_container = OmegaConf.to_container(snap_cfg.repos, resolve=True)
|
|
128
|
+
for name, val in repos_container.items():
|
|
129
|
+
if isinstance(val, str):
|
|
130
|
+
repos[name] = {"path": val}
|
|
131
|
+
elif isinstance(val, dict):
|
|
132
|
+
has_path = "path" in val
|
|
133
|
+
has_module = "module" in val
|
|
134
|
+
|
|
135
|
+
if not has_path and not has_module:
|
|
136
|
+
raise FlexLockConfigError(
|
|
137
|
+
f"Repo '{name}' must specify either 'path' or 'module'."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if has_module and not has_path:
|
|
141
|
+
try:
|
|
142
|
+
resolved_path = resolve_module_to_repo_path(val["module"])
|
|
143
|
+
except Exception as e:
|
|
144
|
+
raise FlexLockConfigError(
|
|
145
|
+
f"Repo '{name}': could not resolve module '{val['module']}': {e}"
|
|
146
|
+
)
|
|
147
|
+
repos[name] = {
|
|
148
|
+
"path": resolved_path,
|
|
149
|
+
"module": val["module"],
|
|
150
|
+
"include": val.get("include"),
|
|
151
|
+
"exclude": val.get("exclude"),
|
|
152
|
+
}
|
|
153
|
+
else:
|
|
154
|
+
repos[name] = {
|
|
155
|
+
"path": val["path"],
|
|
156
|
+
"module": val.get("module"),
|
|
157
|
+
"include": val.get("include"),
|
|
158
|
+
"exclude": val.get("exclude"),
|
|
159
|
+
}
|
|
160
|
+
else:
|
|
161
|
+
raise FlexLockConfigError(
|
|
162
|
+
f"Repo '{name}' value must be a string (path) or dict. Got: {type(val)}"
|
|
163
|
+
)
|
|
164
|
+
elif "repo" in snap_cfg:
|
|
165
|
+
raise FlexLockConfigError(
|
|
166
|
+
"Found 'repo' (singular) in _snapshot_ configuration. "
|
|
167
|
+
"Please use 'repos' (plural) instead. "
|
|
168
|
+
"Example: _snapshot_={'repos': {'main': '.'}}"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Extract data
|
|
172
|
+
if "data" in snap_cfg:
|
|
173
|
+
data_raw = snap_cfg.data
|
|
174
|
+
if not isinstance(data_raw, (dict, DictConfig)):
|
|
175
|
+
raise FlexLockConfigError(
|
|
176
|
+
"snapshot's data should be a mapping"
|
|
177
|
+
"Example: _snapshot_={'data': {'dataset': 'data/dataset.csv'}}"
|
|
178
|
+
)
|
|
179
|
+
data = OmegaConf.to_container(data_raw, resolve=True)
|
|
180
|
+
|
|
181
|
+
# Extract prevs (lineage paths)
|
|
182
|
+
if "prevs" in snap_cfg:
|
|
183
|
+
prevs_raw = snap_cfg.prevs
|
|
184
|
+
if not isinstance(prevs_raw, (list, ListConfig)):
|
|
185
|
+
raise FlexLockConfigError(
|
|
186
|
+
"snapshot's prevs should be a list"
|
|
187
|
+
"Example: _snapshot_={'prevs': ['path/to/run/toto']}"
|
|
188
|
+
)
|
|
189
|
+
prevs = OmegaConf.to_container(prevs_raw, resolve=True)
|
|
190
|
+
|
|
191
|
+
# Auto-populate repo from _target_ using top-level module name
|
|
192
|
+
if "_target_" in cfg:
|
|
193
|
+
try:
|
|
194
|
+
target_str = (
|
|
195
|
+
cfg._target_ if isinstance(cfg, DictConfig) else cfg["_target_"]
|
|
196
|
+
)
|
|
197
|
+
top_level_module = target_str.split(".")[0]
|
|
198
|
+
if top_level_module not in repos:
|
|
199
|
+
module_name, _ = target_str.rsplit(".", 1)
|
|
200
|
+
resolved_path = resolve_module_to_repo_path(module_name)
|
|
201
|
+
repos[top_level_module] = {
|
|
202
|
+
"path": resolved_path,
|
|
203
|
+
"module": top_level_module,
|
|
204
|
+
}
|
|
205
|
+
logger.debug(
|
|
206
|
+
f"Auto-populated repos['{top_level_module}'] from _target_ '{target_str}': {resolved_path}"
|
|
207
|
+
)
|
|
208
|
+
except Exception:
|
|
209
|
+
logger.debug(
|
|
210
|
+
"Could not auto-populate repo from _target_ (REPL or built-in?)"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# "prevs_from_data": Walk up from each data path to find the nearest
|
|
214
|
+
# run.lock, resolving data files to their containing flexlock run dir.
|
|
215
|
+
# This way snapshot() receives clean directory paths, not raw file paths.
|
|
216
|
+
for data_path in data.values():
|
|
217
|
+
run_dir = _find_run_dir(data_path)
|
|
218
|
+
if run_dir and run_dir not in prevs:
|
|
219
|
+
prevs.append(run_dir)
|
|
220
|
+
|
|
221
|
+
return repos, data, prevs
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _find_run_dir(start_path: str) -> str | None:
|
|
225
|
+
"""Walk up from a path to find the nearest directory containing run.lock."""
|
|
226
|
+
p = Path(start_path)
|
|
227
|
+
if p.is_file():
|
|
228
|
+
p = p.parent
|
|
229
|
+
# Walk up, but stop at filesystem root
|
|
230
|
+
while p != p.parent:
|
|
231
|
+
if (p / "run.lock").exists():
|
|
232
|
+
return str(p)
|
|
233
|
+
p = p.parent
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def to_dictconfig(incfg):
|
|
238
|
+
"""
|
|
239
|
+
Convert various config formats (dataclass, dict, class instance, DictConfig)
|
|
240
|
+
into a DictConfig object.
|
|
241
|
+
Warns if dataclass fields are missing type annotations.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
# Case 1: already DictConfig
|
|
245
|
+
if isinstance(incfg, DictConfig):
|
|
246
|
+
return incfg
|
|
247
|
+
|
|
248
|
+
# Case 2: dataclass
|
|
249
|
+
if is_dataclass(incfg):
|
|
250
|
+
# Find missing type hints
|
|
251
|
+
missing_types = [f.name for f in fields(incfg) if f.type is None]
|
|
252
|
+
if missing_types:
|
|
253
|
+
warnings.warn(
|
|
254
|
+
f"Dataclass {type(incfg).__name__} has fields without type hints: "
|
|
255
|
+
f"{', '.join(missing_types)}. "
|
|
256
|
+
"These fields will be ignored by OmegaConf.structured().",
|
|
257
|
+
UserWarning,
|
|
258
|
+
stacklevel=2,
|
|
259
|
+
)
|
|
260
|
+
return OmegaConf.structured(incfg)
|
|
261
|
+
|
|
262
|
+
# Case 3: dict
|
|
263
|
+
if isinstance(incfg, dict):
|
|
264
|
+
return OmegaConf.create(incfg)
|
|
265
|
+
|
|
266
|
+
# Case 4: plain class instance
|
|
267
|
+
if hasattr(incfg, "__dict__"):
|
|
268
|
+
obj_dict = {
|
|
269
|
+
k: v
|
|
270
|
+
for k, v in vars(incfg).items()
|
|
271
|
+
if not k.startswith("_") and not callable(v)
|
|
272
|
+
}
|
|
273
|
+
return OmegaConf.create(obj_dict)
|
|
274
|
+
|
|
275
|
+
# Case 5: __slots__-based classes
|
|
276
|
+
if hasattr(incfg, "__slots__"):
|
|
277
|
+
obj_dict = {slot: getattr(incfg, slot) for slot in incfg.__slots__}
|
|
278
|
+
return OmegaConf.create(obj_dict)
|
|
279
|
+
|
|
280
|
+
# Fallback: try creating directly
|
|
281
|
+
return OmegaConf.create(incfg)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def py2cfg(obj, /, *pos, **overrides):
|
|
285
|
+
"""
|
|
286
|
+
Generates a default configuration dict from a function or class signature.
|
|
287
|
+
Supports nested py2cfg calls and handles decorated functions.
|
|
288
|
+
"""
|
|
289
|
+
# 1. Unwrap decorated functions
|
|
290
|
+
if hasattr(obj, "_original_fn"):
|
|
291
|
+
obj = obj._original_fn
|
|
292
|
+
|
|
293
|
+
# 1. Handle functools.partial
|
|
294
|
+
# If it's a partial, we unwrap it, capture the fixed args, and mark as _partial_
|
|
295
|
+
if isinstance(obj, functools.partial):
|
|
296
|
+
config = py2cfg(obj.func)
|
|
297
|
+
config["_partial_"] = True
|
|
298
|
+
# Add positional arguments as _args_ if they exist
|
|
299
|
+
if obj.args:
|
|
300
|
+
config["_args_"] = list(obj.args)
|
|
301
|
+
config.update(obj.keywords) # Add bound keyword arguments
|
|
302
|
+
config.update(overrides) # Apply runtime overrides
|
|
303
|
+
return config
|
|
304
|
+
|
|
305
|
+
# 2. Determine target and signature source
|
|
306
|
+
if inspect.isclass(obj):
|
|
307
|
+
target = f"{obj.__module__}.{obj.__qualname__}"
|
|
308
|
+
sig_obj = obj.__init__
|
|
309
|
+
elif inspect.isroutine(obj):
|
|
310
|
+
target = f"{obj.__module__}.{obj.__qualname__}"
|
|
311
|
+
sig_obj = obj
|
|
312
|
+
else:
|
|
313
|
+
raise ValueError(f"py2cfg expects class or function, got {type(obj)}")
|
|
314
|
+
|
|
315
|
+
# If the module is __main__ the _target_ won't import correctly in a
|
|
316
|
+
# Slurm/PBS worker or any subprocess that doesn't run the user's script
|
|
317
|
+
# as the entry point. Try to resolve the real dotted module path from the
|
|
318
|
+
# file's location relative to CWD (which run_cli adds to sys.path).
|
|
319
|
+
if target.startswith("__main__."):
|
|
320
|
+
try:
|
|
321
|
+
file_path = Path(inspect.getfile(obj)).resolve()
|
|
322
|
+
module_name = str(
|
|
323
|
+
file_path.relative_to(Path.cwd().resolve()).with_suffix("")
|
|
324
|
+
).replace("/", ".").replace("\\", ".")
|
|
325
|
+
target = f"{module_name}.{obj.__qualname__}"
|
|
326
|
+
logger.debug(f"py2cfg: resolved __main__ to '{target}'")
|
|
327
|
+
except (TypeError, ValueError, OSError):
|
|
328
|
+
logger.warning(
|
|
329
|
+
f"py2cfg: _target_ is '{target}'. This won't import correctly in a "
|
|
330
|
+
f"Slurm/PBS worker. Run your script as a module "
|
|
331
|
+
f"('python -m your.module') instead of 'python script.py' to get a "
|
|
332
|
+
f"stable importable _target_."
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# 3. Build Config
|
|
336
|
+
config = {"_target_": target}
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
sig = inspect.signature(sig_obj)
|
|
340
|
+
params = list(sig.parameters.values())
|
|
341
|
+
|
|
342
|
+
# Skip 'self' for classes or bound methods
|
|
343
|
+
if inspect.isclass(obj) or (
|
|
344
|
+
hasattr(sig_obj, "__self__") and sig_obj.__name__ != "__init__"
|
|
345
|
+
):
|
|
346
|
+
if params and params[0].name == "self":
|
|
347
|
+
params = params[1:]
|
|
348
|
+
|
|
349
|
+
for param in params:
|
|
350
|
+
if param.default is not param.empty:
|
|
351
|
+
# We generally only capture primitive defaults here.
|
|
352
|
+
# Complex defaults (classes) should be handled via explicit overrides
|
|
353
|
+
# or None defaults in the function signature.
|
|
354
|
+
val = param.default
|
|
355
|
+
# If a default value is itself a class/function, convert it too.
|
|
356
|
+
if inspect.isclass(val) or inspect.isfunction(val):
|
|
357
|
+
try:
|
|
358
|
+
val = py2cfg(val)
|
|
359
|
+
except ValueError:
|
|
360
|
+
pass # Keep original if conversion fails
|
|
361
|
+
config[param.name] = val
|
|
362
|
+
except (ValueError, TypeError):
|
|
363
|
+
pass
|
|
364
|
+
|
|
365
|
+
# 4. Apply overrides (nested py2cfg calls happen here)
|
|
366
|
+
config.update(overrides)
|
|
367
|
+
|
|
368
|
+
if len(pos) > 0:
|
|
369
|
+
config.update(
|
|
370
|
+
OmegaConf.create(dict(_args_=list(pos)))
|
|
371
|
+
) # Add positional arguments if any
|
|
372
|
+
|
|
373
|
+
return OmegaConf.create(config)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def parse_sweep_string(sweep_str: str) -> list:
|
|
377
|
+
"""Parse a comma-separated CLI sweep value into a list.
|
|
378
|
+
|
|
379
|
+
Examples::
|
|
380
|
+
|
|
381
|
+
parse_sweep_string("1,2,3") # → [1, 2, 3]
|
|
382
|
+
parse_sweep_string("lr=0.1,lr=0.2") # → [{'lr': 0.1}, {'lr': 0.2}]
|
|
383
|
+
parse_sweep_string('"a,b",c') # → ['a,b', 'c']
|
|
384
|
+
"""
|
|
385
|
+
import csv
|
|
386
|
+
import yaml as _yaml
|
|
387
|
+
|
|
388
|
+
reader = csv.reader([sweep_str], skipinitialspace=True)
|
|
389
|
+
items = next(reader)
|
|
390
|
+
out = []
|
|
391
|
+
for item in items:
|
|
392
|
+
if "=" in item:
|
|
393
|
+
try:
|
|
394
|
+
conf = OmegaConf.from_dotlist([item])
|
|
395
|
+
out.append(OmegaConf.to_container(conf))
|
|
396
|
+
except Exception:
|
|
397
|
+
out.append(item)
|
|
398
|
+
else:
|
|
399
|
+
try:
|
|
400
|
+
out.append(_yaml.safe_load(item))
|
|
401
|
+
except Exception:
|
|
402
|
+
out.append(item)
|
|
403
|
+
return out
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _load_one_sweep_file(fpath: Path) -> list:
|
|
407
|
+
"""Load a single sweep file and return its contents as a list of tasks.
|
|
408
|
+
|
|
409
|
+
YAML/JSON files that contain a list are expanded in-place; a single dict
|
|
410
|
+
is wrapped in a one-element list. Plain text files treat each non-empty
|
|
411
|
+
line as one task value.
|
|
412
|
+
|
|
413
|
+
Interpolation strings (``${...}``) are intentionally preserved as raw
|
|
414
|
+
strings so that deferred resolvers (``${run_lock:}``/``${latest:}``) fire at
|
|
415
|
+
execution time on the worker rather than at sweep-loading time.
|
|
416
|
+
"""
|
|
417
|
+
import json as _json
|
|
418
|
+
import yaml as _yaml
|
|
419
|
+
from .exceptions import FlexLockConfigError
|
|
420
|
+
|
|
421
|
+
if not fpath.exists():
|
|
422
|
+
raise FlexLockConfigError(f"Sweep file '{fpath}' not found.")
|
|
423
|
+
if fpath.suffix in (".yaml", ".yml"):
|
|
424
|
+
raw = _yaml.safe_load(fpath.read_text())
|
|
425
|
+
elif fpath.suffix == ".json":
|
|
426
|
+
with open(fpath) as f:
|
|
427
|
+
raw = _json.load(f)
|
|
428
|
+
else:
|
|
429
|
+
raw = [_yaml.safe_load(line.strip()) for line in fpath.read_text().splitlines() if line.strip()]
|
|
430
|
+
if raw is None:
|
|
431
|
+
return []
|
|
432
|
+
return raw if isinstance(raw, list) else [raw]
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def load_sweep(
|
|
436
|
+
*,
|
|
437
|
+
sweep: "list | str | None" = None,
|
|
438
|
+
sweep_file: "str | Path | list[str | Path] | None" = None,
|
|
439
|
+
sweep_key: "str | None" = None,
|
|
440
|
+
root_cfg: "DictConfig | None" = None,
|
|
441
|
+
) -> list:
|
|
442
|
+
"""Load a sweep list from one of several sources.
|
|
443
|
+
|
|
444
|
+
Exactly one source must be provided. Returns the parsed sweep list. If
|
|
445
|
+
the source yields a single value or dict, it is wrapped in a list.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
sweep: A pre-built list, or a CLI-style comma-separated string.
|
|
449
|
+
sweep_file: Path (or list of paths) to .yaml/.yml, .json, or text
|
|
450
|
+
files. When a list is given, each file is loaded independently
|
|
451
|
+
and results are concatenated. A file containing a single dict
|
|
452
|
+
is treated as one task; a file containing a list expands into
|
|
453
|
+
multiple tasks.
|
|
454
|
+
sweep_key: Dotted key into ``root_cfg`` whose value is the sweep list.
|
|
455
|
+
root_cfg: Required when ``sweep_key`` is used.
|
|
456
|
+
"""
|
|
457
|
+
import yaml as _yaml
|
|
458
|
+
from .exceptions import FlexLockValidationError, FlexLockConfigError
|
|
459
|
+
|
|
460
|
+
sources = sum(x is not None for x in (sweep, sweep_file, sweep_key))
|
|
461
|
+
if sources > 1:
|
|
462
|
+
raise FlexLockValidationError(
|
|
463
|
+
"Multiple sweep sources provided. Use only ONE of "
|
|
464
|
+
"`sweep`, `sweep_file`, or `sweep_key`."
|
|
465
|
+
)
|
|
466
|
+
if sources == 0:
|
|
467
|
+
return []
|
|
468
|
+
|
|
469
|
+
if sweep is not None:
|
|
470
|
+
raw = parse_sweep_string(sweep) if isinstance(sweep, str) else sweep
|
|
471
|
+
elif sweep_file is not None:
|
|
472
|
+
# Normalise to a list of Path objects.
|
|
473
|
+
if isinstance(sweep_file, (str, Path)):
|
|
474
|
+
files = [Path(sweep_file)]
|
|
475
|
+
else:
|
|
476
|
+
files = [Path(f) for f in sweep_file]
|
|
477
|
+
raw = []
|
|
478
|
+
for f in files:
|
|
479
|
+
raw.extend(_load_one_sweep_file(f))
|
|
480
|
+
else: # sweep_key
|
|
481
|
+
if root_cfg is None:
|
|
482
|
+
raise FlexLockValidationError(
|
|
483
|
+
"sweep_key requires root_cfg to look up the value."
|
|
484
|
+
)
|
|
485
|
+
node = OmegaConf.select(root_cfg, sweep_key)
|
|
486
|
+
if node is None:
|
|
487
|
+
raise FlexLockValidationError(
|
|
488
|
+
f"Sweep key '{sweep_key}' not found in config."
|
|
489
|
+
)
|
|
490
|
+
raw = OmegaConf.to_container(node, resolve=True) if isinstance(
|
|
491
|
+
node, (DictConfig, ListConfig)
|
|
492
|
+
) else node
|
|
493
|
+
|
|
494
|
+
if raw is None:
|
|
495
|
+
return []
|
|
496
|
+
if not isinstance(raw, list):
|
|
497
|
+
raw = [raw]
|
|
498
|
+
return raw
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def load_python_defaults(import_path: str):
|
|
502
|
+
"""Dynamically import a module or file path to retrieve a variable.
|
|
503
|
+
|
|
504
|
+
Accepted forms:
|
|
505
|
+
|
|
506
|
+
- ``'pkg.config.defaults'`` — dotted module path, variable is the last
|
|
507
|
+
segment.
|
|
508
|
+
- ``'pkg.config:defaults'`` — module + colon-separated variable name.
|
|
509
|
+
- ``'configs/defaults.py:defaults'`` — file path + colon-separated
|
|
510
|
+
variable name.
|
|
511
|
+
- ``'configs/defaults.py'`` — bare file path, variable defaults to
|
|
512
|
+
``defaults`` by convention.
|
|
513
|
+
"""
|
|
514
|
+
# Bare file path: auto-append ":defaults" so users don't need to spell
|
|
515
|
+
# the conventional variable name.
|
|
516
|
+
if ":" not in import_path and (
|
|
517
|
+
import_path.endswith(".py") or Path(import_path).is_file()
|
|
518
|
+
):
|
|
519
|
+
import_path = f"{import_path}:defaults"
|
|
520
|
+
|
|
521
|
+
if ":" in import_path:
|
|
522
|
+
path_str, var_name = import_path.split(":", 1)
|
|
523
|
+
file_path = Path(path_str)
|
|
524
|
+
if not file_path.exists():
|
|
525
|
+
# A slashed left side that doesn't resolve to a file is almost
|
|
526
|
+
# always a typo — surface a clear hint instead of letting
|
|
527
|
+
# importlib.import_module mangle it.
|
|
528
|
+
if "/" in path_str:
|
|
529
|
+
from .exceptions import FlexLockConfigError
|
|
530
|
+
|
|
531
|
+
raise FlexLockConfigError(
|
|
532
|
+
f"'{path_str}' does not exist. For file-based "
|
|
533
|
+
f"defaults, use 'path/to/file.py:variable' (or just "
|
|
534
|
+
f"'path/to/file.py' to default to the 'defaults' "
|
|
535
|
+
f"variable)."
|
|
536
|
+
)
|
|
537
|
+
# Otherwise treat the left side as a dotted module name.
|
|
538
|
+
module = importlib.import_module(path_str)
|
|
539
|
+
return getattr(module, var_name)
|
|
540
|
+
# Load file under its real stem and register in sys.modules so any
|
|
541
|
+
# `_target_: <stem>.fn` captured by py2cfg in that file remains
|
|
542
|
+
# importable later (during instantiate()). Also make sibling
|
|
543
|
+
# imports work by ensuring the parent dir is on sys.path.
|
|
544
|
+
file_path = file_path.resolve()
|
|
545
|
+
module_name = file_path.stem
|
|
546
|
+
parent_dir = str(file_path.parent)
|
|
547
|
+
if parent_dir not in sys.path:
|
|
548
|
+
sys.path.insert(0, parent_dir)
|
|
549
|
+
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
550
|
+
module = importlib.util.module_from_spec(spec)
|
|
551
|
+
sys.modules[module_name] = module
|
|
552
|
+
spec.loader.exec_module(module)
|
|
553
|
+
return getattr(module, var_name)
|
|
554
|
+
|
|
555
|
+
# Dotted module: "pkg.config.defaults" — module is "pkg.config", var is "defaults".
|
|
556
|
+
# If the left part looks like a filesystem path (contains '/'), the user
|
|
557
|
+
# almost certainly meant a file — surface a clear hint instead of letting
|
|
558
|
+
# importlib's mangled error bubble up.
|
|
559
|
+
if "/" in import_path:
|
|
560
|
+
from .exceptions import FlexLockConfigError
|
|
561
|
+
|
|
562
|
+
raise FlexLockConfigError(
|
|
563
|
+
f"'{import_path}' looks like a file path but does not exist and "
|
|
564
|
+
f"has no colon-separated variable name. For file-based defaults, "
|
|
565
|
+
f"use 'path/to/file.py:variable' (or just 'path/to/file.py' to "
|
|
566
|
+
f"default to the 'defaults' variable)."
|
|
567
|
+
)
|
|
568
|
+
module_name, var_name = import_path.rsplit(".", 1)
|
|
569
|
+
module = importlib.import_module(module_name)
|
|
570
|
+
return getattr(module, var_name)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def merge_task_into_cfg(cfg: DictConfig, task: Any, task_to: str | None) -> DictConfig:
|
|
574
|
+
"""Merge a task into the config."""
|
|
575
|
+
# Create a minimal config with just the task structure
|
|
576
|
+
|
|
577
|
+
if (task_to is not None) and (task_to != "."):
|
|
578
|
+
task_branch = OmegaConf.create({})
|
|
579
|
+
OmegaConf.update(task_branch, task_to, task, force_add=True)
|
|
580
|
+
task = task_branch
|
|
581
|
+
return OmegaConf.merge(cfg, task)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
@contextmanager
|
|
585
|
+
def log_to_file(path):
|
|
586
|
+
# Add sink
|
|
587
|
+
lid = logger.add(path)
|
|
588
|
+
try:
|
|
589
|
+
yield
|
|
590
|
+
finally:
|
|
591
|
+
# Remove sink
|
|
592
|
+
logger.remove(lid)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def instantiate(config, *args, **kwargs):
|
|
596
|
+
r"""
|
|
597
|
+
Recursively instantiate objects defined in dictionaries with a "_target_" key.
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
config: The configuration dictionary (or value).
|
|
601
|
+
\*args, \*\*kwargs: Additional arguments to pass to the root object.
|
|
602
|
+
"""
|
|
603
|
+
# 1. Base case: If config is not a dict or list, return it as is.
|
|
604
|
+
logger.debug(f"Instantiating config: {config} of type {type(config)}")
|
|
605
|
+
if not isinstance(config, (dict, list, DictConfig, ListConfig)):
|
|
606
|
+
logger.debug(f"Returning primitive config: {config}")
|
|
607
|
+
return config
|
|
608
|
+
|
|
609
|
+
if isinstance(config, (list, ListConfig)):
|
|
610
|
+
return [instantiate(item) for item in config]
|
|
611
|
+
|
|
612
|
+
# config is a dict — never mutate the caller's object. Copy before
|
|
613
|
+
# stripping the tracking-only "_snapshot_" key so the original keeps it.
|
|
614
|
+
if "_snapshot_" in config:
|
|
615
|
+
if isinstance(config, DictConfig):
|
|
616
|
+
config = config.copy()
|
|
617
|
+
with open_dict(config):
|
|
618
|
+
del config["_snapshot_"]
|
|
619
|
+
else:
|
|
620
|
+
config = dict(config)
|
|
621
|
+
del config["_snapshot_"]
|
|
622
|
+
|
|
623
|
+
# 2. Check if this dict represents a target object
|
|
624
|
+
if "_target_" not in config:
|
|
625
|
+
# It's just a regular dictionary, but we should check values recursively
|
|
626
|
+
return {k: instantiate(v) for k, v in config.items()}
|
|
627
|
+
|
|
628
|
+
# 3. Prepare the configuration
|
|
629
|
+
# Copy to avoid mutating the original dict
|
|
630
|
+
conf_copy = config.copy()
|
|
631
|
+
target_path = conf_copy.pop("_target_")
|
|
632
|
+
|
|
633
|
+
# Handle positional arguments
|
|
634
|
+
config_args = conf_copy.pop("_args_", [])
|
|
635
|
+
is_partial = conf_copy.pop("_partial_", False)
|
|
636
|
+
|
|
637
|
+
# 4. recursive instantiation of arguments
|
|
638
|
+
# We instantiate the arguments BEFORE creating the main object
|
|
639
|
+
init_args = {k: instantiate(v) for k, v in conf_copy.items()}
|
|
640
|
+
|
|
641
|
+
# Merge with runtime args (kwargs override config)
|
|
642
|
+
init_args.update(kwargs)
|
|
643
|
+
|
|
644
|
+
# 5. Import the class or function
|
|
645
|
+
try:
|
|
646
|
+
module_path, class_name = target_path.rsplit(".", 1)
|
|
647
|
+
module = importlib.import_module(module_path)
|
|
648
|
+
target_class = getattr(module, class_name)
|
|
649
|
+
except (ValueError, ImportError, AttributeError) as e:
|
|
650
|
+
raise ImportError(f"Could not import target '{target_path}': {e}")
|
|
651
|
+
|
|
652
|
+
# 6. Combine positional arguments
|
|
653
|
+
# First config args, then runtime args
|
|
654
|
+
all_args = instantiate(config_args) + list(args)
|
|
655
|
+
|
|
656
|
+
# 7. Instantiate or return partial
|
|
657
|
+
if is_partial:
|
|
658
|
+
return functools.partial(target_class, *all_args, **init_args)
|
|
659
|
+
|
|
660
|
+
return target_class(*all_args, **init_args)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def enqueue_to_file(path: "str | Path", cfg_dict: dict) -> int:
|
|
664
|
+
"""Atomically append *cfg_dict* to a YAML list file and return the new queue length.
|
|
665
|
+
|
|
666
|
+
The file is created if it does not exist. Existing content must be a YAML
|
|
667
|
+
list (or an empty file). The write is atomic: a temporary sibling file is
|
|
668
|
+
written first, then renamed over the target so partial writes are never
|
|
669
|
+
visible to concurrent readers.
|
|
670
|
+
|
|
671
|
+
Args:
|
|
672
|
+
path: Destination YAML file (created if absent).
|
|
673
|
+
cfg_dict: A plain Python dict (no OmegaConf nodes) to append.
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
Number of items in the queue after appending.
|
|
677
|
+
"""
|
|
678
|
+
import tempfile, os
|
|
679
|
+
import yaml as _yaml
|
|
680
|
+
|
|
681
|
+
path = Path(path)
|
|
682
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
683
|
+
|
|
684
|
+
existing: list = []
|
|
685
|
+
if path.exists() and path.stat().st_size > 0:
|
|
686
|
+
data = _yaml.safe_load(path.read_text())
|
|
687
|
+
if data is None:
|
|
688
|
+
existing = []
|
|
689
|
+
elif isinstance(data, list):
|
|
690
|
+
existing = data
|
|
691
|
+
else:
|
|
692
|
+
raise ValueError(
|
|
693
|
+
f"Queue file '{path}' must contain a YAML list, got {type(data).__name__}."
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
existing.append(cfg_dict)
|
|
697
|
+
|
|
698
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.tmp-", suffix=".yaml")
|
|
699
|
+
try:
|
|
700
|
+
with os.fdopen(fd, "w") as f:
|
|
701
|
+
_yaml.safe_dump(existing, f, default_flow_style=False, allow_unicode=True)
|
|
702
|
+
os.replace(tmp, path)
|
|
703
|
+
except Exception:
|
|
704
|
+
try:
|
|
705
|
+
os.unlink(tmp)
|
|
706
|
+
except OSError:
|
|
707
|
+
pass
|
|
708
|
+
raise
|
|
709
|
+
|
|
710
|
+
return len(existing)
|