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.
@@ -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)