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/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
- def _classify(obj: Furu, completed_hashes: set[str] | None) -> Status:
39
- if completed_hashes is not None and obj._furu_hash in completed_hashes:
40
- return "DONE"
41
- if obj._exists_quiet() and not obj._always_rerun():
42
- return "DONE"
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
- state = obj.get_state()
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._furu_hash
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._furu_hash for dep in deps}
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
- updated_at: str | None,
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
- timestamp = attempt.started_at
157
- parsed = StateManager._parse_time(timestamp)
158
- if parsed is None:
159
- parsed = StateManager._parse_time(updated_at)
160
- if parsed is not None:
161
- _MISSING_TIMESTAMP_SEEN.pop(digest, None)
162
- return (StateManager._utcnow() - parsed).total_seconds()
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._furu_hash, None)
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._furu_hash, None)
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._furu_hash})"
290
+ name = f"{node.obj.__class__.__name__}({node.obj.furu_hash})"
199
291
  age = _attempt_age_sec(
200
292
  attempt,
201
- updated_at=state.updated_at,
293
+ directory=node.obj._base_furu_dir(),
202
294
  stale_timeout_sec=stale_timeout_sec,
203
- digest=node.obj._furu_hash,
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._furu_hash})"
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._furu_hash, None)
329
+ _MISSING_TIMESTAMP_SEEN.pop(node.obj.furu_hash, None)
238
330
  return stale_detected
@@ -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) -> None:
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
- updated = True
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._furu_hash}).{suffix}"
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._furu_hash}) without an attempt."
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._furu_hash}) status={attempt.status}."
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(state2.result, _StateResultFailed):
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._furu_hash}) status={attempt2.status} "
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._furu_hash})" for node in failed
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._furu_hash for root in roots}
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._furu_hash
259
+ digest = root.furu_hash
258
260
  if digest in root_job_ids:
259
261
  continue
260
262
  node = plan.nodes.get(digest)
@@ -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._furu_hash})"
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._furu_hash})"
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]._furu_hash) is not None
838
- and plan.nodes[roots[index]._furu_hash].status == "DONE"
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._furu_hash})"
862
+ f"{node.obj.__class__.__name__}({node.obj.furu_hash})"
863
863
  for node in todo_nodes[:3]
864
864
  )
865
865
  raise RuntimeError(
@@ -19,7 +19,7 @@ class SlurmSpec:
19
19
 
20
20
 
21
21
  class _SpecNode(Protocol):
22
- _furu_hash: str
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._furu_hash})."
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
@@ -5,4 +5,4 @@ def load_env() -> None:
5
5
 
6
6
 
7
7
  # Preserve previous behavior: attempt to load `.env` at import-time.
8
- load_env()
8
+ # load_env() # TODO: find a nice way to auto load .env if needed
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.mkdir(parents=True, exist_ok=True)
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 %(filename)s:%(lineno)d %(message)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
- get_logger().log(level_no, message)
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.mkdir(parents=True, exist_ok=True)
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 FURU_CACHE_METADATA)
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
- if not FURU_CONFIG.require_git:
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
- head = cls.run_git_command(["rev-parse", "HEAD"])
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
- return GitInfo(
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 FURU_REQUIRE_GIT_REMOTE=0 to allow missing origin."
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
- "Use ignore_diff=True or FURU_IGNORE_DIFF=1"
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)