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