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/fingerprint.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Pure, stable run fingerprints.
|
|
2
|
+
|
|
3
|
+
A *fingerprint* is a deterministic digest over the inputs that define a run:
|
|
4
|
+
|
|
5
|
+
* the resolved config, with ``save_dir`` **prefix-normalized** so two runs
|
|
6
|
+
that differ only in where they write share a fingerprint;
|
|
7
|
+
* per-repo git **tree hashes** (via :func:`create_shadow_tree` — no commit,
|
|
8
|
+
no ref side effects), restricted to each repo's ``include``/``exclude``
|
|
9
|
+
pathspec so the "relevant files unchanged" notion becomes plain digest
|
|
10
|
+
equality rather than a special case in the matcher;
|
|
11
|
+
* data hashes for tracked data paths.
|
|
12
|
+
|
|
13
|
+
The digest is the single key used by the project-wide index (see ``index.py``)
|
|
14
|
+
to decide cache hits, so it must be stable across processes and machines for
|
|
15
|
+
identical inputs.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from typing import Any, Mapping, Optional
|
|
22
|
+
|
|
23
|
+
from omegaconf import OmegaConf, DictConfig
|
|
24
|
+
|
|
25
|
+
from .git_utils import create_shadow_tree
|
|
26
|
+
from .data_hash import hash_data
|
|
27
|
+
|
|
28
|
+
# Placeholder substituted for the run's own save_dir so location doesn't leak
|
|
29
|
+
# into the fingerprint.
|
|
30
|
+
SAVE_DIR_PLACEHOLDER = "<SAVE_DIR>"
|
|
31
|
+
|
|
32
|
+
# Keys that describe *tracking intent* rather than run inputs; excluded from the
|
|
33
|
+
# config portion of the digest.
|
|
34
|
+
_TRACKING_KEYS = ("_snapshot_",)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _to_container(cfg: Any) -> Any:
|
|
38
|
+
"""Resolve an OmegaConf config to a plain container; pass dicts through."""
|
|
39
|
+
if isinstance(cfg, DictConfig):
|
|
40
|
+
return OmegaConf.to_container(cfg, resolve=True)
|
|
41
|
+
if OmegaConf.is_config(cfg):
|
|
42
|
+
return OmegaConf.to_container(cfg, resolve=True)
|
|
43
|
+
return cfg
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _normalize_paths(value: Any, save_dir: Optional[str]) -> Any:
|
|
47
|
+
"""Replace the run's save_dir *prefix* with a stable placeholder.
|
|
48
|
+
|
|
49
|
+
Only an exact match or a genuine path prefix (``save_dir`` + separator) is
|
|
50
|
+
rewritten — never an arbitrary substring — so unrelated paths that merely
|
|
51
|
+
start with the same characters are left untouched.
|
|
52
|
+
"""
|
|
53
|
+
if not save_dir:
|
|
54
|
+
return value
|
|
55
|
+
if isinstance(value, str):
|
|
56
|
+
if value == save_dir:
|
|
57
|
+
return SAVE_DIR_PLACEHOLDER
|
|
58
|
+
if value.startswith(save_dir + os.sep):
|
|
59
|
+
return SAVE_DIR_PLACEHOLDER + value[len(save_dir):]
|
|
60
|
+
return value
|
|
61
|
+
if isinstance(value, dict):
|
|
62
|
+
return {k: _normalize_paths(v, save_dir) for k, v in value.items()}
|
|
63
|
+
if isinstance(value, list):
|
|
64
|
+
return [_normalize_paths(v, save_dir) for v in value]
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def canonical_config(cfg: Any) -> Any:
|
|
69
|
+
"""Return the config portion of the fingerprint: resolved, save_dir-
|
|
70
|
+
normalized, and stripped of tracking-only keys."""
|
|
71
|
+
container = _to_container(cfg)
|
|
72
|
+
if not isinstance(container, dict):
|
|
73
|
+
return container
|
|
74
|
+
|
|
75
|
+
save_dir = container.get("save_dir")
|
|
76
|
+
# Drop the run's own save_dir and tracking keys, then normalize any nested
|
|
77
|
+
# references to save_dir into the placeholder.
|
|
78
|
+
stripped = {
|
|
79
|
+
k: v
|
|
80
|
+
for k, v in container.items()
|
|
81
|
+
if k != "save_dir" and k not in _TRACKING_KEYS
|
|
82
|
+
}
|
|
83
|
+
return _normalize_paths(stripped, save_dir if isinstance(save_dir, str) else None)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _repo_tree(repo_info: Mapping[str, Any]) -> str:
|
|
87
|
+
"""Tree hash for one tracked repo, restricted to its include/exclude."""
|
|
88
|
+
result = create_shadow_tree(
|
|
89
|
+
repo_info["path"],
|
|
90
|
+
include=repo_info.get("include"),
|
|
91
|
+
exclude=repo_info.get("exclude"),
|
|
92
|
+
)
|
|
93
|
+
return result["tree"]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def fingerprint(
|
|
97
|
+
cfg: Any,
|
|
98
|
+
repos: Optional[Mapping[str, Mapping[str, Any]]] = None,
|
|
99
|
+
data: Optional[Mapping[str, str]] = None,
|
|
100
|
+
use_cache: bool = True,
|
|
101
|
+
) -> str:
|
|
102
|
+
"""Compute the stable fingerprint digest for a run.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
cfg: The run config (OmegaConf or plain dict). Resolved and
|
|
106
|
+
save_dir-normalized before hashing.
|
|
107
|
+
repos: ``{name: {path, include?, exclude?, module?}}`` — per-repo git
|
|
108
|
+
identity. Tree hashes are computed via ``create_shadow_tree``.
|
|
109
|
+
data: ``{name: path}`` data inputs to hash.
|
|
110
|
+
use_cache: Passed through to :func:`hash_data` for data paths.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
A hex sha256 digest.
|
|
114
|
+
"""
|
|
115
|
+
parts: dict[str, Any] = {"config": canonical_config(cfg)}
|
|
116
|
+
|
|
117
|
+
if repos:
|
|
118
|
+
parts["repos"] = {
|
|
119
|
+
name: _repo_tree(info) for name, info in sorted(repos.items())
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if data:
|
|
123
|
+
parts["data"] = {
|
|
124
|
+
name: hash_data(path, use_cache=use_cache)
|
|
125
|
+
for name, path in sorted(data.items())
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
blob = json.dumps(parts, sort_keys=True, default=str)
|
|
129
|
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
flexlock/flexcli.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Configuration decorator for FlexLock with progressive framework support."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import os
|
|
5
|
+
import functools
|
|
6
|
+
from loguru import logger
|
|
7
|
+
import inspect
|
|
8
|
+
from typing import Dict, Optional
|
|
9
|
+
from .runner import FlexLockRunner
|
|
10
|
+
from .utils import py2cfg, instantiate
|
|
11
|
+
from .debug import debug_on_fail
|
|
12
|
+
from .snapshot import snapshot
|
|
13
|
+
from . import config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_jupyter_or_interactive():
|
|
17
|
+
"""
|
|
18
|
+
Check if running in Jupyter/IPython interactive environment.
|
|
19
|
+
|
|
20
|
+
Returns True for:
|
|
21
|
+
- Jupyter notebooks
|
|
22
|
+
- Jupyter console
|
|
23
|
+
- VSCode interactive windows
|
|
24
|
+
- IPython REPL
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
from IPython import get_ipython
|
|
28
|
+
|
|
29
|
+
ipython = get_ipython()
|
|
30
|
+
if ipython is not None:
|
|
31
|
+
return True
|
|
32
|
+
except ImportError:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
# Also check for python -i
|
|
36
|
+
if hasattr(sys, "ps1"):
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _should_use_cli_mode():
|
|
43
|
+
"""
|
|
44
|
+
Determine if we should parse CLI arguments or execute directly.
|
|
45
|
+
|
|
46
|
+
Returns False if:
|
|
47
|
+
- sys.argv[0] is ipykernel_launcher.py (Jupyter kernel startup)
|
|
48
|
+
- sys.argv has kernel arguments like --f=... or -f ...
|
|
49
|
+
|
|
50
|
+
Returns True if:
|
|
51
|
+
- Normal CLI execution
|
|
52
|
+
- IPython %run command (sys.argv[0] is the script name)
|
|
53
|
+
- Script execution
|
|
54
|
+
|
|
55
|
+
This allows:
|
|
56
|
+
- %run file.py -o param=2 → Parse CLI args ✓
|
|
57
|
+
- train() in Jupyter → Don't parse ✗
|
|
58
|
+
- ipykernel_launcher.py --f=kernel.json → Don't parse ✗
|
|
59
|
+
"""
|
|
60
|
+
if len(sys.argv) == 0:
|
|
61
|
+
# No arguments, likely interactive
|
|
62
|
+
logger.debug("No sys.argv detected, assuming interactive mode")
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
# Check sys.argv[0] for kernel launcher
|
|
66
|
+
if sys.argv[0].endswith("ipykernel_launcher.py"):
|
|
67
|
+
logger.debug(
|
|
68
|
+
f"Detected ipykernel_launcher in sys.argv[0], skipping CLI parsing"
|
|
69
|
+
)
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
# Check for kernel connection file arguments
|
|
73
|
+
for arg in sys.argv[1:]:
|
|
74
|
+
if "--f=" in arg or arg.startswith("-f"):
|
|
75
|
+
# Check if next arg or same arg contains .json (connection file)
|
|
76
|
+
if ".json" in arg or (
|
|
77
|
+
sys.argv.index(arg) + 1 < len(sys.argv)
|
|
78
|
+
and ".json" in sys.argv[sys.argv.index(arg) + 1]
|
|
79
|
+
):
|
|
80
|
+
logger.debug(f"Detected Jupyter kernel connection file argument: {arg}")
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
# If we get here, either:
|
|
84
|
+
# - Normal script execution
|
|
85
|
+
# - %run command (sys.argv[0] is script name, args look normal)
|
|
86
|
+
# Both should parse CLI
|
|
87
|
+
logger.debug(f"CLI mode enabled. sys.argv: {sys.argv}")
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def flexcli(_func=None, snapshot_config: Optional[Dict] = None, **defaults):
|
|
92
|
+
"""
|
|
93
|
+
Decorator for FlexLock entry points with progressive framework support.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
snapshot_config: Dict defining repos, data, and lineage tracking.
|
|
97
|
+
Injects '_snapshot_' into the config.
|
|
98
|
+
**defaults: Default values for the function arguments.
|
|
99
|
+
|
|
100
|
+
Usage::
|
|
101
|
+
|
|
102
|
+
@flexcli
|
|
103
|
+
def main(param=10, db="postgres"): ...
|
|
104
|
+
|
|
105
|
+
@flexcli(param=10)
|
|
106
|
+
def main(param, db="postgres"): ...
|
|
107
|
+
|
|
108
|
+
@flexcli(
|
|
109
|
+
data_path="./data/mnist",
|
|
110
|
+
snapshot_config={
|
|
111
|
+
"repos": {"main": "."},
|
|
112
|
+
"data": {"input_dataset": "${data_path}"}
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
def train(data_path, save_dir=None): ...
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def decorator(fn):
|
|
119
|
+
@functools.wraps(fn)
|
|
120
|
+
def wrapper(*args, **kwargs):
|
|
121
|
+
if len(args) > 0 or len(kwargs) > 0:
|
|
122
|
+
# Direct call with arguments - execute immediately
|
|
123
|
+
# Apply debug wrapper if enabled
|
|
124
|
+
if config.get_env_bool("FLEXLOCK_DEBUG", False):
|
|
125
|
+
return debug_on_fail(fn)(*args, **kwargs)
|
|
126
|
+
else:
|
|
127
|
+
return fn(*args, **kwargs)
|
|
128
|
+
|
|
129
|
+
# Called with no arguments - check if we should parse CLI
|
|
130
|
+
# 1. Build Base Config from signature + defaults
|
|
131
|
+
base_cfg = py2cfg(fn, **defaults)
|
|
132
|
+
|
|
133
|
+
# 2. Inject Snapshot Config if provided
|
|
134
|
+
if snapshot_config:
|
|
135
|
+
base_cfg["_snapshot_"] = snapshot_config
|
|
136
|
+
|
|
137
|
+
# 3. Determine execution mode
|
|
138
|
+
if _should_use_cli_mode():
|
|
139
|
+
# CLI mode (normal script or %run): Launch Runner to parse arguments
|
|
140
|
+
runner = FlexLockRunner()
|
|
141
|
+
return runner.run(base_cfg=base_cfg)
|
|
142
|
+
else:
|
|
143
|
+
# Interactive mode (Jupyter kernel): Execute directly with defaults
|
|
144
|
+
# This happens when train() is called in Jupyter without %run
|
|
145
|
+
logger.info(
|
|
146
|
+
"Interactive mode detected: Executing with defaults (no CLI parsing)"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Execute with debug wrapper if enabled
|
|
150
|
+
if config.get_env_bool("FLEXLOCK_DEBUG", False):
|
|
151
|
+
return debug_on_fail(fn)(**defaults)
|
|
152
|
+
else:
|
|
153
|
+
return fn(**defaults)
|
|
154
|
+
|
|
155
|
+
wrapper._original_fn = fn
|
|
156
|
+
wrapper._defaults = defaults # Store metadata
|
|
157
|
+
return wrapper
|
|
158
|
+
|
|
159
|
+
# Support both @flexcli and @flexcli(...) syntax
|
|
160
|
+
if _func is None:
|
|
161
|
+
return decorator
|
|
162
|
+
else:
|
|
163
|
+
return decorator(_func)
|
flexlock/freeze.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Partial-freeze of OmegaConf interpolations when detaching a sub-node.
|
|
2
|
+
|
|
3
|
+
``select_and_freeze_root_refs`` selects a sub-node out of a root config and
|
|
4
|
+
makes it *self-contained* — pickle-able, mergeable, and safe to hand to a
|
|
5
|
+
worker — by resolving the references that point *outside* the sub-tree while
|
|
6
|
+
preserving the ones that must stay live.
|
|
7
|
+
|
|
8
|
+
OmegaConf resolves interpolations lazily against the config's root at access
|
|
9
|
+
time, so a detached node loses every ``${...}`` that pointed to the root. But
|
|
10
|
+
plain ``to_container(resolve=True)`` is too blunt: it would also fire resolver
|
|
11
|
+
calls (``${vinc:}``, ``${latest:}``, ``${run_lock:}``) that must run *later*,
|
|
12
|
+
on the worker, at submit time. So this module walks the ``${...}`` grammar and
|
|
13
|
+
decides, per interpolation, whether to freeze or preserve it:
|
|
14
|
+
|
|
15
|
+
- **Simple refs outside the sub-tree** (``${root_anchor}``, ``${a.b}``) are
|
|
16
|
+
resolved against the root by OmegaConf itself — following multi-hop chains,
|
|
17
|
+
mixed strings (``${pipeline_dir}/split``), and nested resolver-arg refs — and
|
|
18
|
+
baked to concrete values. Delegating to OmegaConf (rather than hand-rolling a
|
|
19
|
+
chain-follower) is what makes the historical ``_resolve_in_root`` class of
|
|
20
|
+
bugs structurally impossible.
|
|
21
|
+
- **Resolver calls** (``${name:args}``) are preserved as frozen call strings
|
|
22
|
+
(their arguments are pre-resolved by OmegaConf under the freeze stubs), so
|
|
23
|
+
they fire later at submit/stage time.
|
|
24
|
+
- **Relative refs** (``${.foo}``) and **refs inside the sub-tree** are preserved
|
|
25
|
+
verbatim.
|
|
26
|
+
|
|
27
|
+
The only public entry points are :func:`select_and_freeze_root_refs` and
|
|
28
|
+
:func:`freeze_deferred`; everything else is a private helper for the scan.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from omegaconf import OmegaConf, DictConfig, ListConfig
|
|
32
|
+
|
|
33
|
+
# Sentinel: an external ref that OmegaConf could not resolve from the root.
|
|
34
|
+
_UNRESOLVED = object()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def freeze_deferred(cfg: "DictConfig | ListConfig") -> "DictConfig | ListConfig":
|
|
38
|
+
"""Eager-resolve every interpolation except the deferred resolvers.
|
|
39
|
+
|
|
40
|
+
Deep-copies ``cfg`` (detaching it from any parent), then resolves it under
|
|
41
|
+
:func:`flexlock.resolvers.deferred_stubbed`. OmegaConf resolves all simple
|
|
42
|
+
refs, cross-tree refs, multi-hop chains, mixed strings, and relative refs
|
|
43
|
+
itself; the deferred resolvers (``run_lock``/``latest``) collapse to
|
|
44
|
+
self-contained call strings that fire later at stage start. The result is
|
|
45
|
+
a plain config of concrete values plus frozen deferred call strings, safe
|
|
46
|
+
to serialize with ``resolve=False``.
|
|
47
|
+
"""
|
|
48
|
+
from .resolvers import deferred_stubbed
|
|
49
|
+
|
|
50
|
+
detached = OmegaConf.create(
|
|
51
|
+
OmegaConf.to_container(cfg, resolve=False, throw_on_missing=False)
|
|
52
|
+
)
|
|
53
|
+
with deferred_stubbed():
|
|
54
|
+
OmegaConf.resolve(detached)
|
|
55
|
+
return detached
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def select_and_freeze_root_refs(root_cfg: DictConfig, key: str | None) -> DictConfig:
|
|
59
|
+
"""Select a sub-node from `root_cfg` and freeze its root-scope references.
|
|
60
|
+
|
|
61
|
+
The returned config is self-contained: it can be pickled, round-tripped
|
|
62
|
+
through ``OmegaConf.to_container``/``OmegaConf.create``, and merged with
|
|
63
|
+
other configs without losing context.
|
|
64
|
+
|
|
65
|
+
Three kinds of interpolations are handled distinctly:
|
|
66
|
+
|
|
67
|
+
- **Simple refs to keys outside the sub-tree** (e.g. ``${root_anchor}``)
|
|
68
|
+
are resolved against ``root_cfg`` (by OmegaConf) and substituted as
|
|
69
|
+
concrete values. If the target is itself a resolver call
|
|
70
|
+
(``${vinc:}``/``${run_lock:}``), the frozen call string is substituted so
|
|
71
|
+
the resolver still fires later.
|
|
72
|
+
- **Resolver calls** (``${name:args}``) are preserved as frozen call
|
|
73
|
+
strings, with any nested simple refs in their args resolved.
|
|
74
|
+
- **Relative refs** (``${.foo}``) and **refs to keys inside the sub-tree**
|
|
75
|
+
are preserved verbatim.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
root_cfg: The full root configuration.
|
|
79
|
+
key: Dot-path to the sub-node. If ``None``, returns ``root_cfg``
|
|
80
|
+
unchanged.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A new ``DictConfig`` (or the same root if ``key is None``).
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
KeyError: If ``key`` is not found in ``root_cfg``.
|
|
87
|
+
UnresolvedInterpolationError: If the sub-node references a key that
|
|
88
|
+
exists in neither the sub-tree nor the root.
|
|
89
|
+
"""
|
|
90
|
+
if key is None:
|
|
91
|
+
return root_cfg
|
|
92
|
+
|
|
93
|
+
sub_node = OmegaConf.select(root_cfg, key, throw_on_missing=False)
|
|
94
|
+
if sub_node is None:
|
|
95
|
+
raise KeyError(f"Key '{key}' not found in config")
|
|
96
|
+
|
|
97
|
+
if not isinstance(sub_node, (DictConfig, ListConfig)):
|
|
98
|
+
return sub_node
|
|
99
|
+
|
|
100
|
+
from .resolvers import frozen_resolvers
|
|
101
|
+
|
|
102
|
+
sub_raw = OmegaConf.to_container(sub_node, resolve=False, throw_on_missing=False)
|
|
103
|
+
# A detached working copy of the whole root. External refs are resolved
|
|
104
|
+
# against it by OmegaConf; the sub-tree stays attached for context. Stub
|
|
105
|
+
# every resolver so ${name:args} calls freeze to call strings instead of
|
|
106
|
+
# firing at selection time.
|
|
107
|
+
work = OmegaConf.create(
|
|
108
|
+
OmegaConf.to_container(root_cfg, resolve=False, throw_on_missing=False)
|
|
109
|
+
)
|
|
110
|
+
with frozen_resolvers():
|
|
111
|
+
transformed = _freeze_walk(sub_raw, sub_raw, work)
|
|
112
|
+
return OmegaConf.create(transformed)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _freeze_walk(value, sub_raw, work):
|
|
116
|
+
if isinstance(value, dict):
|
|
117
|
+
return {k: _freeze_walk(v, sub_raw, work) for k, v in value.items()}
|
|
118
|
+
if isinstance(value, list):
|
|
119
|
+
return [_freeze_walk(v, sub_raw, work) for v in value]
|
|
120
|
+
if isinstance(value, str) and "${" in value:
|
|
121
|
+
whole = _whole_string_interp(value)
|
|
122
|
+
if whole is not None:
|
|
123
|
+
return _freeze_whole_string(whole, sub_raw, work, fallback_str=value)
|
|
124
|
+
return _freeze_embedded(value, sub_raw, work)
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --- string-level scan helpers ------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _whole_string_interp(s: str) -> "str | None":
|
|
132
|
+
"""If ``s`` is exactly ``${...}`` (one balanced block, nothing else), return
|
|
133
|
+
the inner expression. Otherwise return None."""
|
|
134
|
+
if not s.startswith("${"):
|
|
135
|
+
return None
|
|
136
|
+
found = _find_balanced_interp(s, 0)
|
|
137
|
+
if found is None:
|
|
138
|
+
return None
|
|
139
|
+
start, end = found
|
|
140
|
+
if start == 0 and end == len(s):
|
|
141
|
+
return s[2:-1]
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _find_balanced_interp(s: str, start: int) -> "tuple[int, int] | None":
|
|
146
|
+
"""Find the next ``${...}`` block in ``s`` starting at ``start``. Returns
|
|
147
|
+
``(open_idx, close_idx_exclusive)`` or ``None``. Handles nested ``${...}``."""
|
|
148
|
+
i = s.find("${", start)
|
|
149
|
+
if i == -1:
|
|
150
|
+
return None
|
|
151
|
+
depth = 0
|
|
152
|
+
j = i
|
|
153
|
+
while j < len(s):
|
|
154
|
+
if s[j : j + 2] == "${":
|
|
155
|
+
depth += 1
|
|
156
|
+
j += 2
|
|
157
|
+
elif s[j] == "}":
|
|
158
|
+
depth -= 1
|
|
159
|
+
j += 1
|
|
160
|
+
if depth == 0:
|
|
161
|
+
return (i, j)
|
|
162
|
+
else:
|
|
163
|
+
j += 1
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _find_top_level_colon(inner: str) -> "int | None":
|
|
168
|
+
"""In the content between ``${`` and ``}``, find the first ``:`` not nested
|
|
169
|
+
inside a ``${...}`` block. Returns the index, or ``None`` if no colon."""
|
|
170
|
+
depth = 0
|
|
171
|
+
for i, ch in enumerate(inner):
|
|
172
|
+
if inner[i : i + 2] == "${":
|
|
173
|
+
depth += 1
|
|
174
|
+
elif ch == "}" and depth > 0:
|
|
175
|
+
depth -= 1
|
|
176
|
+
elif ch == ":" and depth == 0:
|
|
177
|
+
return i
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _path_exists(d, dotted: str) -> bool:
|
|
182
|
+
cur = d
|
|
183
|
+
for p in dotted.split("."):
|
|
184
|
+
if isinstance(cur, dict) and p in cur:
|
|
185
|
+
cur = cur[p]
|
|
186
|
+
else:
|
|
187
|
+
return False
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _get_raw(d, dotted: str):
|
|
192
|
+
cur = d
|
|
193
|
+
for p in dotted.split("."):
|
|
194
|
+
if isinstance(cur, dict) and p in cur:
|
|
195
|
+
cur = cur[p]
|
|
196
|
+
else:
|
|
197
|
+
return None
|
|
198
|
+
return cur
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# --- freezing --------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _resolve_external(ref: str, work):
|
|
205
|
+
"""Resolve a simple ref against the root via OmegaConf.
|
|
206
|
+
|
|
207
|
+
OmegaConf follows the full chain (multi-hop, mixed strings, nested
|
|
208
|
+
resolver-arg refs). Under the freeze stubs, a target that is itself a
|
|
209
|
+
resolver call comes back as a frozen call string. Returns ``_UNRESOLVED``
|
|
210
|
+
when the ref is absent from the root.
|
|
211
|
+
"""
|
|
212
|
+
val = OmegaConf.select(work, ref, throw_on_missing=False, default=_UNRESOLVED)
|
|
213
|
+
if isinstance(val, (DictConfig, ListConfig)):
|
|
214
|
+
return OmegaConf.to_container(val, resolve=False)
|
|
215
|
+
return val
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _is_internal(ref: str, sub_raw) -> bool:
|
|
219
|
+
"""A simple ref is *internal* (preserve verbatim) when it resolves within
|
|
220
|
+
the sub-tree and isn't self-shadowing (its own value isn't ``${ref}``)."""
|
|
221
|
+
if not _path_exists(sub_raw, ref):
|
|
222
|
+
return False
|
|
223
|
+
sub_val = _get_raw(sub_raw, ref)
|
|
224
|
+
return not (isinstance(sub_val, str) and f"${{{ref}}}" in sub_val)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _unresolved_error(ref: str):
|
|
228
|
+
from .exceptions import UnresolvedInterpolationError
|
|
229
|
+
|
|
230
|
+
first = ref.split(".")[0]
|
|
231
|
+
raise UnresolvedInterpolationError(
|
|
232
|
+
f"Interpolation ${{{ref}}} could not be resolved: '{first}' not "
|
|
233
|
+
f"found in the sub-tree or root config. Set it via overrides= or "
|
|
234
|
+
f"OmegaConf.update(proj.defaults, '{first}', ...)."
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _freeze_whole_string(inner: str, sub_raw, work, fallback_str: str):
|
|
239
|
+
"""Freeze a whole-string interpolation ``${inner}``. Preserves the target's
|
|
240
|
+
native type for simple external refs (e.g. ``${batch_size}`` → int)."""
|
|
241
|
+
if _find_top_level_colon(inner) is not None:
|
|
242
|
+
# Resolver call — preserve as a frozen call string.
|
|
243
|
+
return "${" + _freeze_resolver_args(inner, sub_raw, work) + "}"
|
|
244
|
+
if inner.startswith("."):
|
|
245
|
+
return fallback_str # relative ref — resolved later at access site
|
|
246
|
+
if _is_internal(inner, sub_raw):
|
|
247
|
+
return fallback_str # intra-sub-tree ref — resolves after detachment
|
|
248
|
+
val = _resolve_external(inner, work)
|
|
249
|
+
if val is _UNRESOLVED:
|
|
250
|
+
_unresolved_error(inner)
|
|
251
|
+
return val
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _freeze_embedded(s: str, sub_raw, work) -> str:
|
|
255
|
+
"""Freeze every ``${...}`` block embedded in a larger string."""
|
|
256
|
+
out = []
|
|
257
|
+
pos = 0
|
|
258
|
+
while pos < len(s):
|
|
259
|
+
found = _find_balanced_interp(s, pos)
|
|
260
|
+
if found is None:
|
|
261
|
+
out.append(s[pos:])
|
|
262
|
+
break
|
|
263
|
+
start, end = found
|
|
264
|
+
out.append(s[pos:start])
|
|
265
|
+
inner = s[start + 2 : end - 1]
|
|
266
|
+
out.append(_freeze_one_block(inner, sub_raw, work))
|
|
267
|
+
pos = end
|
|
268
|
+
return "".join(out)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _freeze_one_block(inner: str, sub_raw, work) -> str:
|
|
272
|
+
"""Freeze one ``${inner}`` block, returning the ``${...}`` text to embed."""
|
|
273
|
+
if _find_top_level_colon(inner) is not None:
|
|
274
|
+
return "${" + _freeze_resolver_args(inner, sub_raw, work) + "}"
|
|
275
|
+
if inner.startswith("."):
|
|
276
|
+
return "${" + inner + "}" # relative ref — preserved
|
|
277
|
+
if _is_internal(inner, sub_raw):
|
|
278
|
+
return "${" + inner + "}" # intra-sub-tree ref — preserved
|
|
279
|
+
val = _resolve_external(inner, work)
|
|
280
|
+
if val is _UNRESOLVED:
|
|
281
|
+
_unresolved_error(inner)
|
|
282
|
+
return str(val) if val is not None else "null"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _freeze_resolver_args(inner: str, sub_raw, work) -> str:
|
|
286
|
+
"""Freeze the ``name:args`` body of a resolver call — the call is preserved,
|
|
287
|
+
nested simple refs in the args are frozen."""
|
|
288
|
+
colon = _find_top_level_colon(inner)
|
|
289
|
+
name = inner[:colon]
|
|
290
|
+
args = inner[colon + 1 :]
|
|
291
|
+
return name + ":" + _freeze_embedded(args, sub_raw, work)
|