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/snapshot.py ADDED
@@ -0,0 +1,245 @@
1
+ """Snapshotting utilities for FlexLock."""
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from omegaconf import OmegaConf
6
+ from .git_utils import create_shadow_snapshot
7
+ from .data_hash import hash_data
8
+ from .load_stage import load_stage_from_path
9
+ from .run_record import RunRecord, COMPLETE_MARKER, COMPLETE_VERSION
10
+ from loguru import logger
11
+ import uuid
12
+
13
+
14
+ def write_complete_marker(save_dir: Path, result=None) -> Path:
15
+ """Write run.complete atomically into ``save_dir``.
16
+
17
+ Thin wrapper over :meth:`RunRecord.mark_complete` kept for backward
18
+ compatibility. ``_find_matching_run`` requires both ``run.lock`` and
19
+ ``run.complete`` for a cache hit.
20
+ """
21
+ return RunRecord(save_dir).mark_complete(result=result)
22
+
23
+
24
+ def is_complete(run_dir: Path) -> bool:
25
+ """Return True if ``run_dir`` has both ``run.lock`` and ``run.complete``."""
26
+ return RunRecord(run_dir).is_complete
27
+
28
+
29
+ class RunTracker:
30
+ def __init__(self, save_dir, parent_lock=None, note=None):
31
+ self.save_dir = Path(save_dir)
32
+ self.parent_lock = Path(parent_lock) if parent_lock else None
33
+ self.data = {"timestamp": datetime.now().isoformat()}
34
+
35
+ # Free-text intent recorded alongside run metadata (sibling of
36
+ # timestamp/config). It never enters the fingerprint, so it can't
37
+ # perturb caching or diffs.
38
+ if note:
39
+ self.data["note"] = str(note)
40
+
41
+ if self.parent_lock:
42
+ # We record the link, effectively saying "See parent for Git/Env"
43
+ self.data["parent"] = str(self.parent_lock)
44
+
45
+ def record_env(self, repos: dict):
46
+ # Optimization: If we have a parent, we MIGHT skip recording git
47
+ # IF we are sure code hasn't changed between main process and worker
48
+ # (which is true for multiprocessing/slurm typically).
49
+ if self.parent_lock:
50
+ return
51
+
52
+ self.data["repos"] = {}
53
+ for name, repo_info in repos.items():
54
+ path = repo_info["path"]
55
+ snapshot_data = create_shadow_snapshot(
56
+ path,
57
+ ref_name=str(self.save_dir) + f"_{uuid.uuid1().hex}",
58
+ )
59
+ # Store metadata for comparison-time filtering
60
+ if repo_info.get("include"):
61
+ snapshot_data["include"] = repo_info["include"]
62
+ if repo_info.get("exclude"):
63
+ snapshot_data["exclude"] = repo_info["exclude"]
64
+ if repo_info.get("module"):
65
+ snapshot_data["module"] = repo_info["module"]
66
+ snapshot_data["path"] = path
67
+ self.data["repos"][name] = snapshot_data
68
+
69
+ def record_data(self, data_paths: dict):
70
+ self.data["data"] = {k: hash_data(v) for k, v in data_paths.items()}
71
+
72
+ def add_lineage(self, name: str, path: str, info: dict):
73
+ """Add lineage information from upstream FlexLock runs."""
74
+ if "lineage" not in self.data:
75
+ self.data["lineage"] = {}
76
+
77
+ self.data["lineage"][name] = {"path": path, "info": info}
78
+
79
+ def finalize(self, config):
80
+ """
81
+ Prepares the final snapshot dict but does not write it to disk.
82
+
83
+ This allows callers to decide where to store the snapshot
84
+ (file, DB, or both).
85
+
86
+ Args:
87
+ config: OmegaConf configuration object
88
+
89
+ Returns:
90
+ dict: Complete snapshot data structure
91
+ """
92
+ self.data["config"] = OmegaConf.to_container(config, resolve=True)
93
+ return self.data
94
+
95
+ def save(self, config):
96
+ """
97
+ Finalize and write to disk as run.lock.
98
+ Now implemented by calling finalize() then writing.
99
+
100
+ Returns:
101
+ dict: The snapshot data that was written
102
+ """
103
+ snapshot_data = self.finalize(config)
104
+
105
+ # Read the concrete save_dir from the finalized snapshot. save_dir is
106
+ # baked to a string once at submit time (apply_save_dir_policy), so this
107
+ # is just a lookup — no resolver fires here.
108
+ resolved_save_dir = Path(
109
+ snapshot_data["config"].get("save_dir", str(self.save_dir))
110
+ )
111
+
112
+ # Single on-disk-contract owner performs the atomic write.
113
+ RunRecord(resolved_save_dir).write_lock(snapshot_data)
114
+
115
+ # Remember the resolved dir so mark_complete can write next to run.lock
116
+ self._resolved_save_dir = resolved_save_dir
117
+
118
+ return snapshot_data
119
+
120
+ def mark_complete(self, result=None) -> Path:
121
+ """Write ``run.complete`` next to the previously-written ``run.lock``.
122
+
123
+ Call this only after the user function returns successfully. The
124
+ ``_find_matching_run`` cache check requires both files; an
125
+ interrupted run leaves ``run.lock`` without ``run.complete`` and is
126
+ correctly skipped instead of being treated as a stale cache hit.
127
+ """
128
+ target = getattr(self, "_resolved_save_dir", self.save_dir)
129
+ return RunRecord(target).mark_complete(result=result)
130
+
131
+
132
+ def snapshot(
133
+ cfg,
134
+ repos=None,
135
+ data=None,
136
+ prevs=None,
137
+ parent_lock=None,
138
+ save_path=None,
139
+ return_snapshot=False,
140
+ fingerprint=None,
141
+ note=None,
142
+ ):
143
+ """
144
+ Create a snapshot of the current run state.
145
+
146
+ Args:
147
+ cfg: OmegaConf configuration
148
+ repos: Dict of repository paths to track
149
+ data: Dict of data paths to hash
150
+ prevs: List of paths to search for upstream lineage
151
+ parent_lock: Path to parent run.lock (for delta snapshots)
152
+ save_path: Custom save directory (overrides cfg.save_dir)
153
+ return_snapshot: If True, return snapshot dict instead of/in addition to saving
154
+
155
+ Returns:
156
+ dict if return_snapshot=True, else None
157
+ """
158
+ if "save_dir" not in cfg:
159
+ logger.warning("No save_dir specified in config; skipping snapshot.")
160
+ return None
161
+
162
+ # Use custom save_path if provided, otherwise use cfg.save_dir
163
+ save_dir = Path(save_path) if save_path else Path(cfg.save_dir)
164
+
165
+ tracker = RunTracker(save_dir, parent_lock=parent_lock, note=note)
166
+
167
+ # Store the precomputed fingerprint (index key) so run.lock/DB snapshots
168
+ # carry it and `flexlock reindex` can rebuild the index from disk.
169
+ if fingerprint is not None:
170
+ tracker.data["fingerprint"] = fingerprint
171
+
172
+ # 1. Record Git & Data (Hashing)
173
+ if repos:
174
+ tracker.record_env(repos)
175
+ if data:
176
+ logger.debug(f"Recording data for snapshot: {data}")
177
+ tracker.record_data(data)
178
+
179
+ # 2. Record Lineage (Automatic Discovery)
180
+ if prevs:
181
+ logger.debug(f"Looking for upstream FlexLock runs in: {prevs}")
182
+
183
+ def _find_snapshot_dir(start_path: Path) -> tuple[Path, dict] | None:
184
+ """
185
+ Find run.lock at or directly in start_path.
186
+
187
+ Returns:
188
+ tuple: (snapshot_dir, snapshot_data) or None
189
+ """
190
+ try:
191
+ p = Path(start_path).resolve()
192
+ except Exception:
193
+ return None
194
+ if p.is_file():
195
+ p = p.parent
196
+
197
+ lock_file = p / "run.lock"
198
+ if lock_file.exists():
199
+ try:
200
+ data = OmegaConf.to_container(
201
+ OmegaConf.load(lock_file), resolve=True
202
+ )
203
+ logger.debug(f"Found snapshot at: {p}")
204
+ return (p, data)
205
+ except Exception as e:
206
+ logger.warning(f"Failed to read run.lock at {p}: {e}")
207
+
208
+ return None
209
+
210
+ for path_str in prevs:
211
+ result = _find_snapshot_dir(path_str)
212
+
213
+ if result:
214
+ snapshot_dir, snapshot_data = result
215
+ # We found an upstream FlexLock run!
216
+ logger.debug(f"Found upstream FlexLock run at: {snapshot_dir}")
217
+ try:
218
+ # Extract stage info from snapshot data
219
+ stage_info = {
220
+ "config": snapshot_data.get("config", {}),
221
+ "timestamp": snapshot_data.get("timestamp"),
222
+ "repos": snapshot_data.get("repos", {}),
223
+ "parent": snapshot_data.get("parent"),
224
+ }
225
+
226
+ # Add to lineage
227
+ tracker.add_lineage(
228
+ name=snapshot_dir.name, # e.g. "run_2023..." or "task_abc123"
229
+ path=str(snapshot_dir),
230
+ info=stage_info,
231
+ )
232
+ except Exception as e:
233
+ logger.warning(
234
+ f"Found snapshot at {snapshot_dir} but failed to process: {e}"
235
+ )
236
+
237
+ # 3. Save and/or return
238
+ if return_snapshot:
239
+ # For DB storage: return snapshot without writing file
240
+ snapshot_data = tracker.finalize(cfg)
241
+ return snapshot_data
242
+ else:
243
+ # Traditional behavior: write to file
244
+ tracker.save(cfg)
245
+ return None
flexlock/status_cli.py ADDED
@@ -0,0 +1,299 @@
1
+ """CLI for monitoring FlexLock task database status."""
2
+
3
+ import argparse
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+ from loguru import logger
8
+ from flexlock.taskdb import (
9
+ get_status_counts,
10
+ get_status_counts_by_tag,
11
+ get_failed_tasks,
12
+ get_all_tasks,
13
+ )
14
+
15
+
16
+ def print_status_summary(db_path: Path, tags=None, label: str = ""):
17
+ """Print summary of task statuses, optionally scoped to ``tags``."""
18
+ status_counts = get_status_counts(db_path, tags=tags)
19
+
20
+ pending = status_counts.get("pending", 0)
21
+ running = status_counts.get("running", 0)
22
+ done = status_counts.get("done", 0)
23
+ failed = status_counts.get("failed", 0)
24
+ interrupted = status_counts.get("interrupted", 0)
25
+ total = pending + running + done + failed + interrupted
26
+
27
+ header = f"Task Status Summary{label}"
28
+ print("\n" + "=" * 60)
29
+ print(header)
30
+ print("=" * 60)
31
+ print(f"Pending: {pending:>5}")
32
+ print(f"Running: {running:>5}")
33
+ print(f"Done: {done:>5}")
34
+ print(f"Failed: {failed:>5}")
35
+ print(f"Interrupted: {interrupted:>5}")
36
+ print("-" * 60)
37
+ print(f"Total: {total:>5}")
38
+
39
+ if total > 0:
40
+ terminal = done + failed + interrupted
41
+ progress = terminal / total * 100
42
+ print(f"Progress: {progress:>5.1f}% ({terminal}/{total} completed)")
43
+
44
+ if pending == 0 and running == 0:
45
+ issues = failed + interrupted
46
+ if issues:
47
+ label_parts = []
48
+ if failed:
49
+ label_parts.append(f"{failed} failed")
50
+ if interrupted:
51
+ label_parts.append(f"{interrupted} interrupted")
52
+ print(f"\nStatus: ⚠️ Finished with issues ({', '.join(label_parts)})")
53
+ else:
54
+ print(f"\nStatus: ✓ All tasks completed successfully")
55
+ elif pending == 0 and running > 0:
56
+ print(f"\nStatus: ⚠️ {running} task(s) running with no pending — possible zombie(s)")
57
+ else:
58
+ print(f"\nStatus: ⏳ In progress")
59
+
60
+ print("=" * 60 + "\n")
61
+
62
+
63
+ def print_tags_breakdown(db_path: Path):
64
+ """Print a per-tag status breakdown table."""
65
+ by_tag = get_status_counts_by_tag(db_path)
66
+ if not by_tag:
67
+ print("No tasks found in DB.\n")
68
+ return
69
+
70
+ print("\n" + "=" * 60)
71
+ print("Per-Tag Status Breakdown")
72
+ print("=" * 60)
73
+
74
+ for tag, counts in sorted(by_tag.items(), key=lambda x: (x[0] is None, x[0])):
75
+ tag_label = repr(tag) if tag is not None else "(untagged)"
76
+ pending = counts.get("pending", 0)
77
+ running = counts.get("running", 0)
78
+ done = counts.get("done", 0)
79
+ failed = counts.get("failed", 0)
80
+ interrupted = counts.get("interrupted", 0)
81
+ total = pending + running + done + failed + interrupted
82
+ terminal = done + failed + interrupted
83
+ pct = terminal / total * 100 if total else 0
84
+
85
+ print(f"\nTag: {tag_label} ({total} total, {pct:.1f}% done)")
86
+ print(f" pending={pending} running={running} done={done} "
87
+ f"failed={failed} interrupted={interrupted}")
88
+
89
+ print("\n" + "=" * 60 + "\n")
90
+
91
+
92
+ def print_failed_tasks(db_path: Path, verbose: bool = False, tags=None):
93
+ """Print details of failed and interrupted tasks."""
94
+ failed_tasks = get_failed_tasks(db_path, tags=tags)
95
+
96
+ if not failed_tasks:
97
+ print("No failed or interrupted tasks found.\n")
98
+ return
99
+
100
+ print(f"\nFailed / Interrupted Tasks ({len(failed_tasks)})")
101
+ print("=" * 60)
102
+
103
+ for i, task_info in enumerate(failed_tasks, 1):
104
+ status = task_info.get("status", "failed")
105
+ print(f"\nTask #{i} [{status.upper()}]")
106
+ print("-" * 60)
107
+
108
+ task = task_info["task"]
109
+ print("Config:")
110
+ for key, value in task.items():
111
+ if not key.startswith("_"):
112
+ print(f" {key}: {value}")
113
+
114
+ error = task_info["error"]
115
+ if error:
116
+ print(f"\nError:")
117
+ if len(error) > 500 and not verbose:
118
+ print(f" {error[:500]}...")
119
+ print(f" (Use --verbose to see full error)")
120
+ else:
121
+ for line in error.split("\n"):
122
+ print(f" {line}")
123
+
124
+ if task_info["node"]:
125
+ print(f"\nNode: {task_info['node']}")
126
+ if task_info["ts_start"]:
127
+ print(f"Started: {task_info['ts_start']}")
128
+ if task_info["ts_end"]:
129
+ print(f"Finished: {task_info['ts_end']}")
130
+
131
+ print("\n" + "=" * 60 + "\n")
132
+
133
+
134
+ def print_all_tasks(db_path: Path, status_filter: str = None, tags=None):
135
+ """Print all tasks, optionally filtered by status and/or tag."""
136
+ tasks = get_all_tasks(db_path, status=status_filter, tags=tags)
137
+
138
+ filter_desc = f" ({status_filter})" if status_filter else ""
139
+ print(f"\nAll Tasks{filter_desc} ({len(tasks)})")
140
+ print("=" * 60)
141
+
142
+ if not tasks:
143
+ print("No tasks found.\n")
144
+ return
145
+
146
+ print(f"{'Status':<13} {'Task ID':<12} {'Node':<15} {'Timestamp'}")
147
+ print("-" * 60)
148
+
149
+ for task_info in tasks:
150
+ status = task_info["status"]
151
+ task_id = task_info["task_id"][:10]
152
+ node = task_info["node"] or "N/A"
153
+ node = node[:13] if len(node) > 13 else node
154
+ timestamp = task_info["ts_end"] or task_info["ts_start"] or "N/A"
155
+ if timestamp != "N/A":
156
+ timestamp = timestamp[:19]
157
+
158
+ print(f"{status:<13} {task_id:<12} {node:<15} {timestamp}")
159
+
160
+ print("=" * 60 + "\n")
161
+
162
+
163
+ def watch_status(db_path: Path, interval: int = 10, tags=None):
164
+ """Watch task status in real-time, optionally scoped to ``tags``."""
165
+ print("Watching task status (Ctrl+C to stop)...\n")
166
+
167
+ try:
168
+ while True:
169
+ print("\033[2J\033[H", end="")
170
+
171
+ from datetime import datetime
172
+ print(f"Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
173
+
174
+ print_status_summary(db_path, tags=tags)
175
+
176
+ status_counts = get_status_counts(db_path, tags=tags)
177
+ pending = status_counts.get("pending", 0)
178
+ running = status_counts.get("running", 0)
179
+
180
+ if pending == 0 and running == 0:
181
+ print("All tasks reached a terminal state. Exiting watch mode.\n")
182
+ break
183
+
184
+ print(f"Refreshing in {interval}s... (Ctrl+C to stop)")
185
+ time.sleep(interval)
186
+
187
+ except KeyboardInterrupt:
188
+ print("\n\nWatch mode stopped.\n")
189
+
190
+
191
+ def main():
192
+ """CLI entry point for flexlock-status command."""
193
+ parser = argparse.ArgumentParser(
194
+ description="Monitor FlexLock task database status",
195
+ formatter_class=argparse.RawDescriptionHelpFormatter,
196
+ epilog="""
197
+ Examples:
198
+ # Show status summary
199
+ flexlock-status outputs/sweep/run.lock.tasks.db
200
+
201
+ # Per-tag breakdown (useful when multiple sweeps share a DB)
202
+ flexlock-status outputs/sweep/run.lock.tasks.db --tags
203
+
204
+ # Scope the entire view to one tag
205
+ flexlock-status outputs/sweep/run.lock.tasks.db --tag extract
206
+
207
+ # Show failed / interrupted tasks
208
+ flexlock-status outputs/sweep/run.lock.tasks.db --failed
209
+
210
+ # Show all tasks
211
+ flexlock-status outputs/sweep/run.lock.tasks.db --all
212
+
213
+ # Filter by status
214
+ flexlock-status outputs/sweep/run.lock.tasks.db --all --status interrupted
215
+
216
+ # Watch status in real-time (scoped to a tag)
217
+ flexlock-status outputs/sweep/run.lock.tasks.db --watch --tag collocate
218
+ """,
219
+ )
220
+
221
+ parser.add_argument(
222
+ "db_path", type=Path, help="Path to task database (run.lock.tasks.db)"
223
+ )
224
+ parser.add_argument(
225
+ "--failed", action="store_true", help="Show details of failed / interrupted tasks"
226
+ )
227
+ parser.add_argument("--all", action="store_true", help="Show all tasks")
228
+ parser.add_argument(
229
+ "--status",
230
+ choices=["pending", "running", "done", "failed", "interrupted"],
231
+ help="Filter tasks by status (use with --all)",
232
+ )
233
+ parser.add_argument(
234
+ "--watch",
235
+ action="store_true",
236
+ help="Watch status in real-time (updates every 10s)",
237
+ )
238
+ parser.add_argument(
239
+ "--interval",
240
+ type=int,
241
+ default=10,
242
+ help="Update interval for watch mode in seconds (default: 10)",
243
+ )
244
+ parser.add_argument(
245
+ "--verbose", "-v", action="store_true",
246
+ help="Show verbose output (full error messages)",
247
+ )
248
+ parser.add_argument(
249
+ "--tag",
250
+ metavar="NAME",
251
+ help="Scope the entire view (summary, --failed, --all, --watch) to a "
252
+ "single tag. Useful to monitor one sweep inside a shared DB.",
253
+ )
254
+ parser.add_argument(
255
+ "--tags",
256
+ action="store_true",
257
+ dest="show_tags",
258
+ help="Print a per-tag status breakdown before the global summary. "
259
+ "Useful to discover which tags exist and what to pass to "
260
+ "flexlock-worker --tags.",
261
+ )
262
+
263
+ args = parser.parse_args()
264
+
265
+ if not args.db_path.exists():
266
+ logger.error(f"Database not found: {args.db_path}")
267
+ sys.exit(1)
268
+
269
+ # --tag NAME scopes everything; convert to a list for the taskdb API.
270
+ scope_tags = [args.tag] if args.tag else None
271
+ tag_label = f" [tag={args.tag!r}]" if args.tag else ""
272
+
273
+ try:
274
+ if args.watch:
275
+ watch_status(args.db_path, args.interval, tags=scope_tags)
276
+ elif args.failed:
277
+ if args.show_tags:
278
+ print_tags_breakdown(args.db_path)
279
+ print_status_summary(args.db_path, tags=scope_tags, label=tag_label)
280
+ print_failed_tasks(args.db_path, verbose=args.verbose, tags=scope_tags)
281
+ elif args.all:
282
+ if args.show_tags:
283
+ print_tags_breakdown(args.db_path)
284
+ print_status_summary(args.db_path, tags=scope_tags, label=tag_label)
285
+ print_all_tasks(args.db_path, status_filter=args.status, tags=scope_tags)
286
+ else:
287
+ if args.show_tags:
288
+ print_tags_breakdown(args.db_path)
289
+ print_status_summary(args.db_path, tags=scope_tags, label=tag_label)
290
+
291
+ except Exception as e:
292
+ logger.error(f"Error reading database: {e}")
293
+ import traceback
294
+ traceback.print_exc()
295
+ sys.exit(1)
296
+
297
+
298
+ if __name__ == "__main__":
299
+ main()