furu 0.0.4__py3-none-any.whl → 0.0.5__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.
- furu/config.py +27 -40
- furu/core/furu.py +194 -126
- furu/core/list.py +3 -2
- furu/dashboard/frontend/dist/assets/{index-DS3FsqcY.js → index-BjyrY-Zz.js} +1 -1
- furu/dashboard/frontend/dist/index.html +1 -1
- furu/execution/local.py +9 -7
- furu/execution/plan.py +117 -25
- furu/execution/slurm_dag.py +16 -14
- furu/execution/slurm_pool.py +5 -5
- furu/execution/slurm_spec.py +2 -2
- furu/migration.py +1 -2
- furu/runtime/env.py +1 -1
- furu/runtime/logging.py +30 -4
- furu/storage/metadata.py +25 -29
- furu/storage/migration.py +0 -1
- furu/storage/state.py +86 -92
- {furu-0.0.4.dist-info → furu-0.0.5.dist-info}/METADATA +18 -6
- {furu-0.0.4.dist-info → furu-0.0.5.dist-info}/RECORD +20 -20
- {furu-0.0.4.dist-info → furu-0.0.5.dist-info}/WHEEL +1 -1
- {furu-0.0.4.dist-info → furu-0.0.5.dist-info}/entry_points.txt +0 -0
furu/execution/plan.py
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
4
6
|
from typing import Literal
|
|
5
7
|
|
|
6
8
|
from ..config import FURU_CONFIG
|
|
7
9
|
from ..core import Furu
|
|
10
|
+
from ..errors import FuruValidationError
|
|
8
11
|
from ..runtime.logging import get_logger
|
|
12
|
+
from ..storage.migration import MigrationManager, MigrationRecord
|
|
9
13
|
from ..storage.state import (
|
|
10
14
|
StateManager,
|
|
11
15
|
_StateAttemptFailed,
|
|
12
16
|
_StateAttemptQueued,
|
|
13
17
|
_StateAttemptRunning,
|
|
18
|
+
_FuruState,
|
|
14
19
|
_StateResultFailed,
|
|
15
20
|
)
|
|
16
21
|
|
|
@@ -35,13 +40,93 @@ class DependencyPlan:
|
|
|
35
40
|
nodes: dict[str, PlanNode]
|
|
36
41
|
|
|
37
42
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
@dataclass
|
|
44
|
+
class _PlanCache:
|
|
45
|
+
migration_records: dict[Path, MigrationRecord | None]
|
|
46
|
+
alias_targets: dict[Path, Path | None]
|
|
47
|
+
marker_exists: dict[Path, bool]
|
|
48
|
+
states: dict[Path, _FuruState]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _marker_exists(directory: Path, cache: _PlanCache) -> bool:
|
|
52
|
+
if directory in cache.marker_exists:
|
|
53
|
+
return cache.marker_exists[directory]
|
|
54
|
+
exists = StateManager.success_marker_exists(directory)
|
|
55
|
+
cache.marker_exists[directory] = exists
|
|
56
|
+
return exists
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _migration_record(directory: Path, cache: _PlanCache) -> MigrationRecord | None:
|
|
60
|
+
if directory not in cache.migration_records:
|
|
61
|
+
cache.migration_records[directory] = MigrationManager.read_migration(directory)
|
|
62
|
+
return cache.migration_records[directory]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _alias_target_dir(base_dir: Path, cache: _PlanCache) -> Path | None:
|
|
66
|
+
if base_dir in cache.alias_targets:
|
|
67
|
+
return cache.alias_targets[base_dir]
|
|
68
|
+
record = _migration_record(base_dir, cache)
|
|
69
|
+
if record is None or record.kind != "alias" or record.overwritten_at is not None:
|
|
70
|
+
cache.alias_targets[base_dir] = None
|
|
71
|
+
return None
|
|
72
|
+
if _marker_exists(base_dir, cache):
|
|
73
|
+
cache.alias_targets[base_dir] = None
|
|
74
|
+
return None
|
|
75
|
+
target_dir = MigrationManager.resolve_dir(record, target="from")
|
|
76
|
+
if _marker_exists(target_dir, cache):
|
|
77
|
+
cache.alias_targets[base_dir] = target_dir
|
|
78
|
+
return target_dir
|
|
79
|
+
cache.alias_targets[base_dir] = None
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _state_for(directory: Path, cache: _PlanCache) -> _FuruState:
|
|
84
|
+
if directory not in cache.states:
|
|
85
|
+
cache.states[directory] = StateManager.read_state(directory)
|
|
86
|
+
return cache.states[directory]
|
|
43
87
|
|
|
44
|
-
|
|
88
|
+
|
|
89
|
+
def _validate_cached(obj: Furu, *, directory: Path) -> bool:
|
|
90
|
+
try:
|
|
91
|
+
return obj._validate()
|
|
92
|
+
except FuruValidationError as exc:
|
|
93
|
+
logger = get_logger()
|
|
94
|
+
logger.warning(
|
|
95
|
+
"exists %s -> false (validate invalid for %s: %s)",
|
|
96
|
+
directory,
|
|
97
|
+
f"{obj.__class__.__name__}({obj.furu_hash})",
|
|
98
|
+
exc,
|
|
99
|
+
)
|
|
100
|
+
return False
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
logger = get_logger()
|
|
103
|
+
logger.exception(
|
|
104
|
+
"exists %s -> false (validate crashed for %s: %s)",
|
|
105
|
+
directory,
|
|
106
|
+
f"{obj.__class__.__name__}({obj.furu_hash})",
|
|
107
|
+
exc,
|
|
108
|
+
)
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _classify(
|
|
113
|
+
obj: Furu,
|
|
114
|
+
completed_hashes: set[str] | None,
|
|
115
|
+
cache: _PlanCache,
|
|
116
|
+
) -> Status:
|
|
117
|
+
if completed_hashes is not None and obj.furu_hash in completed_hashes:
|
|
118
|
+
return "DONE"
|
|
119
|
+
base_dir = obj._base_furu_dir()
|
|
120
|
+
alias_target = None
|
|
121
|
+
if not obj._always_rerun():
|
|
122
|
+
alias_target = _alias_target_dir(base_dir, cache)
|
|
123
|
+
success_dir = alias_target or base_dir
|
|
124
|
+
if _marker_exists(success_dir, cache):
|
|
125
|
+
if _validate_cached(obj, directory=base_dir):
|
|
126
|
+
return "DONE"
|
|
127
|
+
|
|
128
|
+
state_dir = alias_target or base_dir
|
|
129
|
+
state = _state_for(state_dir, cache)
|
|
45
130
|
attempt = state.attempt
|
|
46
131
|
if isinstance(attempt, (_StateAttemptQueued, _StateAttemptRunning)):
|
|
47
132
|
return "IN_PROGRESS"
|
|
@@ -59,18 +144,24 @@ def build_plan(
|
|
|
59
144
|
*,
|
|
60
145
|
completed_hashes: set[str] | None = None,
|
|
61
146
|
) -> DependencyPlan:
|
|
147
|
+
cache = _PlanCache(
|
|
148
|
+
migration_records={},
|
|
149
|
+
alias_targets={},
|
|
150
|
+
marker_exists={},
|
|
151
|
+
states={},
|
|
152
|
+
)
|
|
62
153
|
nodes: dict[str, PlanNode] = {}
|
|
63
154
|
stack = list(roots)
|
|
64
155
|
seen: set[str] = set()
|
|
65
156
|
|
|
66
157
|
while stack:
|
|
67
158
|
obj = stack.pop()
|
|
68
|
-
digest = obj.
|
|
159
|
+
digest = obj.furu_hash
|
|
69
160
|
if digest in seen:
|
|
70
161
|
continue
|
|
71
162
|
seen.add(digest)
|
|
72
163
|
|
|
73
|
-
status = _classify(obj, completed_hashes)
|
|
164
|
+
status = _classify(obj, completed_hashes, cache)
|
|
74
165
|
node = PlanNode(
|
|
75
166
|
obj=obj,
|
|
76
167
|
status=status,
|
|
@@ -85,7 +176,7 @@ def build_plan(
|
|
|
85
176
|
continue
|
|
86
177
|
|
|
87
178
|
deps = obj._get_dependencies(recursive=False)
|
|
88
|
-
node.deps_all = {dep.
|
|
179
|
+
node.deps_all = {dep.furu_hash for dep in deps}
|
|
89
180
|
for dep in deps:
|
|
90
181
|
stack.append(dep)
|
|
91
182
|
|
|
@@ -146,20 +237,21 @@ def ready_todo(plan: DependencyPlan) -> list[str]:
|
|
|
146
237
|
def _attempt_age_sec(
|
|
147
238
|
attempt: _StateAttemptQueued | _StateAttemptRunning,
|
|
148
239
|
*,
|
|
149
|
-
|
|
240
|
+
directory: Path,
|
|
150
241
|
stale_timeout_sec: float,
|
|
151
242
|
digest: str,
|
|
152
243
|
name: str,
|
|
153
244
|
) -> float | None:
|
|
154
|
-
timestamp = attempt.heartbeat_at
|
|
155
245
|
if attempt.status == "queued":
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
246
|
+
parsed = StateManager._parse_time(attempt.started_at)
|
|
247
|
+
if parsed is not None:
|
|
248
|
+
_MISSING_TIMESTAMP_SEEN.pop(digest, None)
|
|
249
|
+
return (StateManager._utcnow() - parsed).total_seconds()
|
|
250
|
+
else:
|
|
251
|
+
last_heartbeat = StateManager.last_heartbeat_mtime(directory)
|
|
252
|
+
if last_heartbeat is not None:
|
|
253
|
+
_MISSING_TIMESTAMP_SEEN.pop(digest, None)
|
|
254
|
+
return max(0.0, time.time() - last_heartbeat)
|
|
163
255
|
if stale_timeout_sec <= 0:
|
|
164
256
|
return None
|
|
165
257
|
now = StateManager._utcnow().timestamp()
|
|
@@ -186,21 +278,21 @@ def reconcile_in_progress(
|
|
|
186
278
|
] = []
|
|
187
279
|
for node in plan.nodes.values():
|
|
188
280
|
if node.status != "IN_PROGRESS":
|
|
189
|
-
_MISSING_TIMESTAMP_SEEN.pop(node.obj.
|
|
281
|
+
_MISSING_TIMESTAMP_SEEN.pop(node.obj.furu_hash, None)
|
|
190
282
|
continue
|
|
191
283
|
state = StateManager.reconcile(node.obj._base_furu_dir())
|
|
192
284
|
attempt = state.attempt
|
|
193
285
|
if not isinstance(attempt, (_StateAttemptQueued, _StateAttemptRunning)):
|
|
194
|
-
_MISSING_TIMESTAMP_SEEN.pop(node.obj.
|
|
286
|
+
_MISSING_TIMESTAMP_SEEN.pop(node.obj.furu_hash, None)
|
|
195
287
|
continue
|
|
196
288
|
if stale_timeout_sec <= 0:
|
|
197
289
|
continue
|
|
198
|
-
name = f"{node.obj.__class__.__name__}({node.obj.
|
|
290
|
+
name = f"{node.obj.__class__.__name__}({node.obj.furu_hash})"
|
|
199
291
|
age = _attempt_age_sec(
|
|
200
292
|
attempt,
|
|
201
|
-
|
|
293
|
+
directory=node.obj._base_furu_dir(),
|
|
202
294
|
stale_timeout_sec=stale_timeout_sec,
|
|
203
|
-
digest=node.obj.
|
|
295
|
+
digest=node.obj.furu_hash,
|
|
204
296
|
name=name,
|
|
205
297
|
)
|
|
206
298
|
if age is None or age < stale_timeout_sec:
|
|
@@ -211,7 +303,7 @@ def reconcile_in_progress(
|
|
|
211
303
|
return False
|
|
212
304
|
|
|
213
305
|
names = ", ".join(
|
|
214
|
-
f"{node.obj.__class__.__name__}({node.obj.
|
|
306
|
+
f"{node.obj.__class__.__name__}({node.obj.furu_hash})"
|
|
215
307
|
for node, _attempt in stale_attempts
|
|
216
308
|
)
|
|
217
309
|
if not FURU_CONFIG.retry_failed:
|
|
@@ -234,5 +326,5 @@ def reconcile_in_progress(
|
|
|
234
326
|
},
|
|
235
327
|
reason="stale_timeout",
|
|
236
328
|
)
|
|
237
|
-
_MISSING_TIMESTAMP_SEEN.pop(node.obj.
|
|
329
|
+
_MISSING_TIMESTAMP_SEEN.pop(node.obj.furu_hash, None)
|
|
238
330
|
return stale_detected
|
furu/execution/slurm_dag.py
CHANGED
|
@@ -48,24 +48,24 @@ def _attempt_is_terminal(obj: Furu, directory: Path | None = None) -> bool:
|
|
|
48
48
|
def _set_submitit_job_id(directory: Path, job_id: str) -> bool:
|
|
49
49
|
updated = False
|
|
50
50
|
|
|
51
|
-
def mutate(state: _FuruState) ->
|
|
51
|
+
def mutate(state: _FuruState) -> bool:
|
|
52
52
|
nonlocal updated
|
|
53
53
|
attempt = state.attempt
|
|
54
54
|
if attempt is None:
|
|
55
|
-
return
|
|
55
|
+
return False
|
|
56
56
|
if attempt.backend != "submitit":
|
|
57
|
-
return
|
|
57
|
+
return False
|
|
58
58
|
if (
|
|
59
59
|
attempt.status not in {"queued", "running"}
|
|
60
60
|
and attempt.status not in StateManager.TERMINAL_STATUSES
|
|
61
61
|
):
|
|
62
|
-
return
|
|
62
|
+
return False
|
|
63
63
|
existing = attempt.scheduler.get("job_id")
|
|
64
64
|
if existing == job_id:
|
|
65
|
-
|
|
66
|
-
return
|
|
65
|
+
return False
|
|
67
66
|
attempt.scheduler["job_id"] = job_id
|
|
68
67
|
updated = True
|
|
68
|
+
return True
|
|
69
69
|
|
|
70
70
|
StateManager.update_state(directory, mutate)
|
|
71
71
|
return updated
|
|
@@ -117,7 +117,7 @@ def _wait_for_job_id(
|
|
|
117
117
|
suffix = f" Last seen job_id={last_job_id}." if last_job_id else ""
|
|
118
118
|
raise TimeoutError(
|
|
119
119
|
"Timed out waiting for submitit job_id for "
|
|
120
|
-
f"{obj.__class__.__name__} ({obj.
|
|
120
|
+
f"{obj.__class__.__name__} ({obj.furu_hash}).{suffix}"
|
|
121
121
|
)
|
|
122
122
|
|
|
123
123
|
time.sleep(poll_interval_sec)
|
|
@@ -129,7 +129,7 @@ def _job_id_for_in_progress(obj: Furu) -> str:
|
|
|
129
129
|
if attempt is None:
|
|
130
130
|
raise RuntimeError(
|
|
131
131
|
"Cannot wire Slurm DAG dependency for IN_PROGRESS "
|
|
132
|
-
f"{obj.__class__.__name__} ({obj.
|
|
132
|
+
f"{obj.__class__.__name__} ({obj.furu_hash}) without an attempt."
|
|
133
133
|
)
|
|
134
134
|
if attempt.backend != "submitit":
|
|
135
135
|
raise FuruExecutionError(
|
|
@@ -144,7 +144,7 @@ def _job_id_for_in_progress(obj: Furu) -> str:
|
|
|
144
144
|
):
|
|
145
145
|
raise FuruExecutionError(
|
|
146
146
|
"Cannot wire afterok dependency to a terminal non-success dependency. "
|
|
147
|
-
f"Dependency {obj.__class__.__name__} ({obj.
|
|
147
|
+
f"Dependency {obj.__class__.__name__} ({obj.furu_hash}) status={attempt.status}."
|
|
148
148
|
)
|
|
149
149
|
|
|
150
150
|
job_id = attempt.scheduler.get("job_id")
|
|
@@ -160,10 +160,12 @@ def _job_id_for_in_progress(obj: Furu) -> str:
|
|
|
160
160
|
state2 = obj.get_state()
|
|
161
161
|
attempt2 = state2.attempt
|
|
162
162
|
if attempt2 is not None and attempt2.status in StateManager.TERMINAL_STATUSES:
|
|
163
|
-
if attempt2.status != "success" or isinstance(
|
|
163
|
+
if attempt2.status != "success" or isinstance(
|
|
164
|
+
state2.result, _StateResultFailed
|
|
165
|
+
):
|
|
164
166
|
raise FuruExecutionError(
|
|
165
167
|
"Cannot wire afterok dependency: dependency became terminal and did not succeed. "
|
|
166
|
-
f"Dependency {obj.__class__.__name__} ({obj.
|
|
168
|
+
f"Dependency {obj.__class__.__name__} ({obj.furu_hash}) status={attempt2.status} "
|
|
167
169
|
f"job_id={resolved}."
|
|
168
170
|
)
|
|
169
171
|
|
|
@@ -190,7 +192,7 @@ def submit_slurm_dag(
|
|
|
190
192
|
failed = [node for node in plan.nodes.values() if node.status == "FAILED"]
|
|
191
193
|
if failed:
|
|
192
194
|
names = ", ".join(
|
|
193
|
-
f"{node.obj.__class__.__name__}({node.obj.
|
|
195
|
+
f"{node.obj.__class__.__name__}({node.obj.furu_hash})" for node in failed
|
|
194
196
|
)
|
|
195
197
|
raise RuntimeError(f"Cannot submit slurm DAG with failed dependencies: {names}")
|
|
196
198
|
|
|
@@ -198,7 +200,7 @@ def submit_slurm_dag(
|
|
|
198
200
|
job_id_by_hash: dict[str, str] = {}
|
|
199
201
|
root_job_ids: dict[str, str] = {}
|
|
200
202
|
|
|
201
|
-
root_hashes = {root.
|
|
203
|
+
root_hashes = {root.furu_hash for root in roots}
|
|
202
204
|
|
|
203
205
|
for digest in order:
|
|
204
206
|
node = plan.nodes[digest]
|
|
@@ -254,7 +256,7 @@ def submit_slurm_dag(
|
|
|
254
256
|
root_job_ids[digest] = job_id
|
|
255
257
|
|
|
256
258
|
for root in roots:
|
|
257
|
-
digest = root.
|
|
259
|
+
digest = root.furu_hash
|
|
258
260
|
if digest in root_job_ids:
|
|
259
261
|
continue
|
|
260
262
|
node = plan.nodes.get(digest)
|
furu/execution/slurm_pool.py
CHANGED
|
@@ -299,7 +299,7 @@ def _missing_spec_keys(
|
|
|
299
299
|
if node.spec_key in specs:
|
|
300
300
|
continue
|
|
301
301
|
missing.setdefault(node.spec_key, []).append(
|
|
302
|
-
f"{node.obj.__class__.__name__}({node.obj.
|
|
302
|
+
f"{node.obj.__class__.__name__}({node.obj.furu_hash})"
|
|
303
303
|
)
|
|
304
304
|
return missing
|
|
305
305
|
|
|
@@ -777,7 +777,7 @@ def run_slurm_pool(
|
|
|
777
777
|
failed = [node for node in plan.nodes.values() if node.status == "FAILED"]
|
|
778
778
|
if failed:
|
|
779
779
|
names = ", ".join(
|
|
780
|
-
f"{node.obj.__class__.__name__}({node.obj.
|
|
780
|
+
f"{node.obj.__class__.__name__}({node.obj.furu_hash})"
|
|
781
781
|
for node in failed
|
|
782
782
|
)
|
|
783
783
|
raise RuntimeError(
|
|
@@ -834,8 +834,8 @@ def run_slurm_pool(
|
|
|
834
834
|
finished_indices = [
|
|
835
835
|
index
|
|
836
836
|
for index in active_indices
|
|
837
|
-
if plan.nodes.get(roots[index].
|
|
838
|
-
and plan.nodes[roots[index].
|
|
837
|
+
if plan.nodes.get(roots[index].furu_hash) is not None
|
|
838
|
+
and plan.nodes[roots[index].furu_hash].status == "DONE"
|
|
839
839
|
]
|
|
840
840
|
for index in finished_indices:
|
|
841
841
|
active_indices.remove(index)
|
|
@@ -859,7 +859,7 @@ def run_slurm_pool(
|
|
|
859
859
|
todo_nodes = [node for node in plan.nodes.values() if node.status == "TODO"]
|
|
860
860
|
if todo_nodes:
|
|
861
861
|
sample = ", ".join(
|
|
862
|
-
f"{node.obj.__class__.__name__}({node.obj.
|
|
862
|
+
f"{node.obj.__class__.__name__}({node.obj.furu_hash})"
|
|
863
863
|
for node in todo_nodes[:3]
|
|
864
864
|
)
|
|
865
865
|
raise RuntimeError(
|
furu/execution/slurm_spec.py
CHANGED
|
@@ -19,7 +19,7 @@ class SlurmSpec:
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
class _SpecNode(Protocol):
|
|
22
|
-
|
|
22
|
+
furu_hash: str
|
|
23
23
|
|
|
24
24
|
def _executor_spec_key(self) -> str: ...
|
|
25
25
|
|
|
@@ -32,7 +32,7 @@ def resolve_slurm_spec(specs: Mapping[str, SlurmSpec], node: _SpecNode) -> Slurm
|
|
|
32
32
|
if spec_key not in specs:
|
|
33
33
|
raise KeyError(
|
|
34
34
|
"Missing slurm spec for key "
|
|
35
|
-
f"'{spec_key}' for node {node.__class__.__name__} ({node.
|
|
35
|
+
f"'{spec_key}' for node {node.__class__.__name__} ({node.furu_hash})."
|
|
36
36
|
)
|
|
37
37
|
|
|
38
38
|
return specs[spec_key]
|
furu/migration.py
CHANGED
|
@@ -456,6 +456,7 @@ def _apply_single_migration(
|
|
|
456
456
|
shutil.rmtree(to_dir)
|
|
457
457
|
|
|
458
458
|
to_dir.mkdir(parents=True, exist_ok=True)
|
|
459
|
+
StateManager.ensure_internal_dir(to_dir)
|
|
459
460
|
now = _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
|
|
460
461
|
|
|
461
462
|
if policy in {"move", "copy"}:
|
|
@@ -557,8 +558,6 @@ def _copy_state(from_dir: Path, to_dir: Path, *, clear_source: bool) -> None:
|
|
|
557
558
|
src_internal = from_dir / StateManager.INTERNAL_DIR
|
|
558
559
|
if not src_internal.exists():
|
|
559
560
|
return
|
|
560
|
-
dst_internal = to_dir / StateManager.INTERNAL_DIR
|
|
561
|
-
dst_internal.mkdir(parents=True, exist_ok=True)
|
|
562
561
|
state_path = StateManager.get_state_path(from_dir)
|
|
563
562
|
if state_path.is_file():
|
|
564
563
|
shutil.copy2(state_path, StateManager.get_state_path(to_dir))
|
furu/runtime/env.py
CHANGED
furu/runtime/logging.py
CHANGED
|
@@ -4,6 +4,7 @@ import datetime
|
|
|
4
4
|
import logging
|
|
5
5
|
import os
|
|
6
6
|
import threading
|
|
7
|
+
import sys
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Generator, Protocol
|
|
9
10
|
|
|
@@ -102,6 +103,16 @@ class _FuruLogFormatter(logging.Formatter):
|
|
|
102
103
|
dt = datetime.datetime.fromtimestamp(record.created, tz=datetime.timezone.utc)
|
|
103
104
|
return dt.isoformat(timespec="seconds")
|
|
104
105
|
|
|
106
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
107
|
+
caller_file = getattr(record, "furu_caller_file", None)
|
|
108
|
+
caller_line = getattr(record, "furu_caller_line", None)
|
|
109
|
+
if isinstance(caller_file, str) and isinstance(caller_line, int):
|
|
110
|
+
location = f"{Path(caller_file).name}:{caller_line}"
|
|
111
|
+
else:
|
|
112
|
+
location = f"{record.filename}:{record.lineno}"
|
|
113
|
+
record.furu_location = location # type: ignore[attr-defined]
|
|
114
|
+
return super().format(record)
|
|
115
|
+
|
|
105
116
|
|
|
106
117
|
class _FuruContextFileHandler(logging.Handler):
|
|
107
118
|
"""
|
|
@@ -112,7 +123,8 @@ class _FuruContextFileHandler(logging.Handler):
|
|
|
112
123
|
message = self.format(record)
|
|
113
124
|
|
|
114
125
|
directory = current_log_dir()
|
|
115
|
-
directory.
|
|
126
|
+
if directory.name != ".furu":
|
|
127
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
116
128
|
|
|
117
129
|
log_path = directory / "furu.log"
|
|
118
130
|
with _FURU_LOG_LOCK:
|
|
@@ -241,7 +253,7 @@ def configure_logging() -> None:
|
|
|
241
253
|
handler.addFilter(_FuruFileFilter())
|
|
242
254
|
handler.setFormatter(
|
|
243
255
|
_FuruLogFormatter(
|
|
244
|
-
"%(asctime)s [%(levelname)s] %(name)s %(
|
|
256
|
+
"%(asctime)s [%(levelname)s] %(name)s %(furu_location)s %(message)s"
|
|
245
257
|
)
|
|
246
258
|
)
|
|
247
259
|
root.addHandler(handler)
|
|
@@ -280,7 +292,20 @@ def log(message: str, *, level: str = "INFO") -> Path:
|
|
|
280
292
|
raise ValueError(f"Unknown log level: {level!r}")
|
|
281
293
|
|
|
282
294
|
configure_logging()
|
|
283
|
-
|
|
295
|
+
caller_info: dict[str, object] = {}
|
|
296
|
+
frame = sys._getframe(1)
|
|
297
|
+
if frame is not None:
|
|
298
|
+
furu_pkg_dir = str(Path(__file__).parent.parent)
|
|
299
|
+
while frame is not None:
|
|
300
|
+
filename = frame.f_code.co_filename
|
|
301
|
+
if not filename.startswith(furu_pkg_dir):
|
|
302
|
+
caller_info = {
|
|
303
|
+
"furu_caller_file": filename,
|
|
304
|
+
"furu_caller_line": frame.f_lineno,
|
|
305
|
+
}
|
|
306
|
+
break
|
|
307
|
+
frame = frame.f_back
|
|
308
|
+
get_logger().log(level_no, message, extra=caller_info)
|
|
284
309
|
return log_path
|
|
285
310
|
|
|
286
311
|
|
|
@@ -293,7 +318,8 @@ def write_separator(line: str = "------------------") -> Path:
|
|
|
293
318
|
directory = current_log_dir()
|
|
294
319
|
log_path = directory / "furu.log"
|
|
295
320
|
|
|
296
|
-
directory.
|
|
321
|
+
if directory.name != ".furu":
|
|
322
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
297
323
|
|
|
298
324
|
with _FURU_LOG_LOCK:
|
|
299
325
|
with log_path.open("a", encoding="utf-8") as fp:
|
furu/storage/metadata.py
CHANGED
|
@@ -19,7 +19,7 @@ from ..serialization.serializer import JsonValue
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
20
|
from ..core.furu import Furu
|
|
21
21
|
|
|
22
|
-
# Module-level cache for metadata (controlled via
|
|
22
|
+
# Module-level cache for metadata (controlled via FURU_RECORD_GIT=cached)
|
|
23
23
|
_cached_git_info: "GitInfo | None" = None
|
|
24
24
|
_cached_git_info_time: float = 0.0
|
|
25
25
|
|
|
@@ -113,6 +113,16 @@ class MetadataManager:
|
|
|
113
113
|
global _cached_git_info, _cached_git_info_time
|
|
114
114
|
import time
|
|
115
115
|
|
|
116
|
+
record_git = FURU_CONFIG.record_git
|
|
117
|
+
if record_git == "ignore":
|
|
118
|
+
return GitInfo(
|
|
119
|
+
git_commit="<ignored>",
|
|
120
|
+
git_branch="<ignored>",
|
|
121
|
+
git_remote=None,
|
|
122
|
+
git_patch="<ignored>",
|
|
123
|
+
git_submodules={},
|
|
124
|
+
)
|
|
125
|
+
|
|
116
126
|
ttl = FURU_CONFIG.cache_metadata_ttl_sec
|
|
117
127
|
# Return cached result if caching is enabled and not expired
|
|
118
128
|
if ttl is not None and _cached_git_info is not None:
|
|
@@ -120,41 +130,28 @@ class MetadataManager:
|
|
|
120
130
|
if age < ttl:
|
|
121
131
|
return _cached_git_info
|
|
122
132
|
|
|
123
|
-
|
|
133
|
+
try:
|
|
134
|
+
head = cls.run_git_command(["rev-parse", "HEAD"])
|
|
135
|
+
branch = cls.run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
136
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
137
|
+
raise RuntimeError(
|
|
138
|
+
"Failed to read git commit/branch for provenance. "
|
|
139
|
+
"If this is expected, set FURU_RECORD_GIT=ignore."
|
|
140
|
+
) from e
|
|
141
|
+
|
|
142
|
+
if FURU_CONFIG.allow_no_git_origin:
|
|
124
143
|
try:
|
|
125
|
-
|
|
126
|
-
branch = cls.run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
144
|
+
remote = cls.run_git_command(["remote", "get-url", "origin"])
|
|
127
145
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
128
|
-
|
|
129
|
-
git_commit="<no-git>",
|
|
130
|
-
git_branch="<no-git>",
|
|
131
|
-
git_remote=None,
|
|
132
|
-
git_patch="<no-git>",
|
|
133
|
-
git_submodules={},
|
|
134
|
-
)
|
|
146
|
+
remote = None
|
|
135
147
|
else:
|
|
136
|
-
try:
|
|
137
|
-
head = cls.run_git_command(["rev-parse", "HEAD"])
|
|
138
|
-
branch = cls.run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
139
|
-
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
140
|
-
raise RuntimeError(
|
|
141
|
-
"Failed to read git commit/branch for provenance. "
|
|
142
|
-
"If this is expected, set FURU_REQUIRE_GIT=0."
|
|
143
|
-
) from e
|
|
144
|
-
|
|
145
|
-
if FURU_CONFIG.require_git_remote:
|
|
146
148
|
try:
|
|
147
149
|
remote = cls.run_git_command(["remote", "get-url", "origin"])
|
|
148
150
|
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
149
151
|
raise RuntimeError(
|
|
150
152
|
"Git remote 'origin' is required for provenance but was not found. "
|
|
151
|
-
"Set
|
|
153
|
+
"Set FURU_ALLOW_NO_GIT_ORIGIN=1 to allow missing origin."
|
|
152
154
|
) from e
|
|
153
|
-
else:
|
|
154
|
-
try:
|
|
155
|
-
remote = cls.run_git_command(["remote", "get-url", "origin"])
|
|
156
|
-
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
157
|
-
remote = None
|
|
158
155
|
|
|
159
156
|
if ignore_diff:
|
|
160
157
|
patch = "<ignored-diff>"
|
|
@@ -187,7 +184,7 @@ class MetadataManager:
|
|
|
187
184
|
if len(patch) > 50_000:
|
|
188
185
|
raise ValueError(
|
|
189
186
|
f"Git diff too large ({len(patch):,} bytes). "
|
|
190
|
-
"
|
|
187
|
+
"Set FURU_RECORD_GIT=ignore to skip git metadata."
|
|
191
188
|
)
|
|
192
189
|
|
|
193
190
|
submodules: dict[str, str] = {}
|
|
@@ -265,7 +262,6 @@ class MetadataManager:
|
|
|
265
262
|
def write_metadata(cls, metadata: FuruMetadata, directory: Path) -> None:
|
|
266
263
|
"""Write metadata to file."""
|
|
267
264
|
metadata_path = cls.get_metadata_path(directory)
|
|
268
|
-
metadata_path.parent.mkdir(parents=True, exist_ok=True)
|
|
269
265
|
metadata_path.write_text(
|
|
270
266
|
json.dumps(
|
|
271
267
|
metadata.model_dump(mode="json"),
|
furu/storage/migration.py
CHANGED
|
@@ -52,7 +52,6 @@ class MigrationManager:
|
|
|
52
52
|
@classmethod
|
|
53
53
|
def write_migration(cls, record: MigrationRecord, directory: Path) -> None:
|
|
54
54
|
path = cls.get_migration_path(directory)
|
|
55
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
55
|
tmp = path.with_suffix(".tmp")
|
|
57
56
|
tmp.write_text(json.dumps(record.model_dump(mode="json"), indent=2))
|
|
58
57
|
tmp.replace(path)
|