furu 0.0.4__py3-none-any.whl → 0.0.6__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/storage/state.py CHANGED
@@ -9,13 +9,12 @@ from collections.abc import Generator
9
9
  from contextlib import contextmanager
10
10
  from dataclasses import dataclass
11
11
  from pathlib import Path
12
- from typing import Annotated, Any, Callable, Literal, Mapping, TypedDict, TypeAlias
12
+ from typing import Annotated, Any, Callable, Literal, Mapping, TypeAlias, TypedDict
13
13
 
14
14
  from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
15
15
 
16
16
  from ..errors import FuruLockNotAcquired, FuruWaitTimeout
17
17
 
18
-
19
18
  # Type alias for scheduler-specific metadata. Different schedulers (SLURM, LSF, PBS, local)
20
19
  # return different fields, so this must remain dynamic.
21
20
  SchedulerMetadata = dict[str, Any]
@@ -167,7 +166,6 @@ class _StateAttemptBase(BaseModel):
167
166
  number: int = 1
168
167
  backend: str
169
168
  started_at: str
170
- heartbeat_at: str
171
169
  lease_duration_sec: float
172
170
  lease_expires_at: str
173
171
  owner: StateOwner
@@ -228,7 +226,6 @@ class StateAttempt(BaseModel):
228
226
  backend: str
229
227
  status: str
230
228
  started_at: str
231
- heartbeat_at: str
232
229
  lease_duration_sec: float
233
230
  lease_expires_at: str
234
231
  owner: StateOwner
@@ -246,7 +243,6 @@ class StateAttempt(BaseModel):
246
243
  backend=attempt.backend,
247
244
  status=attempt.status,
248
245
  started_at=attempt.started_at,
249
- heartbeat_at=attempt.heartbeat_at,
250
246
  lease_duration_sec=attempt.lease_duration_sec,
251
247
  lease_expires_at=attempt.lease_expires_at,
252
248
  owner=attempt.owner,
@@ -286,9 +282,9 @@ class StateManager:
286
282
  EVENTS_FILE = "events.jsonl"
287
283
  SUCCESS_MARKER = "SUCCESS.json"
288
284
 
289
- COMPUTE_LOCK = ".compute.lock"
290
- SUBMIT_LOCK = ".submit.lock"
291
- STATE_LOCK = ".state.lock"
285
+ COMPUTE_LOCK = "compute.lock"
286
+ SUBMIT_LOCK = "submit.lock"
287
+ STATE_LOCK = "state.lock"
292
288
 
293
289
  TERMINAL_STATUSES = {
294
290
  "success",
@@ -302,6 +298,12 @@ class StateManager:
302
298
  def get_internal_dir(cls, directory: Path) -> Path:
303
299
  return directory / cls.INTERNAL_DIR
304
300
 
301
+ @classmethod
302
+ def ensure_internal_dir(cls, directory: Path) -> Path:
303
+ internal_dir = cls.get_internal_dir(directory)
304
+ internal_dir.mkdir(parents=True, exist_ok=True)
305
+ return internal_dir
306
+
305
307
  @classmethod
306
308
  def get_state_path(cls, directory: Path) -> Path:
307
309
  return cls.get_internal_dir(directory) / cls.STATE_FILE
@@ -366,7 +368,6 @@ class StateManager:
366
368
  @classmethod
367
369
  def _write_state_unlocked(cls, directory: Path, state: _FuruState) -> None:
368
370
  state_path = cls.get_state_path(directory)
369
- state_path.parent.mkdir(parents=True, exist_ok=True)
370
371
  tmp_path = state_path.with_suffix(".tmp")
371
372
  tmp_path.write_text(json.dumps(state.model_dump(mode="json"), indent=2))
372
373
  os.replace(tmp_path, state_path)
@@ -385,7 +386,6 @@ class StateManager:
385
386
  @classmethod
386
387
  def try_lock(cls, lock_path: Path) -> int | None:
387
388
  try:
388
- lock_path.parent.mkdir(parents=True, exist_ok=True)
389
389
  fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_RDWR, 0o644)
390
390
  payload = {
391
391
  "pid": os.getpid(),
@@ -485,19 +485,23 @@ class StateManager:
485
485
 
486
486
  @classmethod
487
487
  def update_state(
488
- cls, directory: Path, mutator: Callable[[_FuruState], None]
488
+ cls, directory: Path, mutator: Callable[[_FuruState], bool | None]
489
489
  ) -> _FuruState:
490
490
  lock_path = cls.get_lock_path(directory, cls.STATE_LOCK)
491
491
  fd: int | None = None
492
492
  try:
493
493
  fd = cls._acquire_lock_blocking(lock_path)
494
+ state_path = cls.get_state_path(directory)
495
+ force_write = not state_path.is_file()
494
496
  state = cls.read_state(directory)
495
- mutator(state)
496
- state.schema_version = cls.SCHEMA_VERSION
497
- state.updated_at = cls._iso_now()
498
- validated = _FuruState.model_validate(state)
499
- cls._write_state_unlocked(directory, validated)
500
- return validated
497
+ changed = mutator(state)
498
+ if force_write or changed is not False:
499
+ state.schema_version = cls.SCHEMA_VERSION
500
+ state.updated_at = cls._iso_now()
501
+ validated = _FuruState.model_validate(state)
502
+ cls._write_state_unlocked(directory, validated)
503
+ return validated
504
+ return state
501
505
  finally:
502
506
  cls.release_lock(fd, lock_path)
503
507
 
@@ -510,14 +514,12 @@ class StateManager:
510
514
  "host": socket.gethostname(),
511
515
  **event,
512
516
  }
513
- path.parent.mkdir(parents=True, exist_ok=True)
514
517
  with path.open("a", encoding="utf-8") as f:
515
518
  f.write(json.dumps(enriched) + "\n")
516
519
 
517
520
  @classmethod
518
521
  def write_success_marker(cls, directory: Path, *, attempt_id: str) -> None:
519
522
  marker = cls.get_success_marker_path(directory)
520
- marker.parent.mkdir(parents=True, exist_ok=True)
521
523
  payload = {"attempt_id": attempt_id, "created_at": cls._iso_now()}
522
524
  tmp = marker.with_suffix(".tmp")
523
525
  tmp.write_text(json.dumps(payload, indent=2))
@@ -536,6 +538,26 @@ class StateManager:
536
538
  return True
537
539
  return cls._utcnow() >= expires
538
540
 
541
+ @classmethod
542
+ def last_heartbeat_mtime(cls, directory: Path) -> float | None:
543
+ lock_path = cls.get_lock_path(directory, cls.COMPUTE_LOCK)
544
+ try:
545
+ return lock_path.stat().st_mtime
546
+ except FileNotFoundError:
547
+ return None
548
+
549
+ @classmethod
550
+ def _running_heartbeat_reason(
551
+ cls, directory: Path, attempt: _StateAttemptRunning
552
+ ) -> str | None:
553
+ last_heartbeat = cls.last_heartbeat_mtime(directory)
554
+ if last_heartbeat is None:
555
+ return "missing_heartbeat"
556
+ expires_at = last_heartbeat + float(attempt.lease_duration_sec)
557
+ if time.time() >= expires_at:
558
+ return "lease_expired"
559
+ return None
560
+
539
561
  @classmethod
540
562
  def start_attempt_queued(
541
563
  cls,
@@ -604,7 +626,6 @@ class StateManager:
604
626
 
605
627
  owner_state = StateOwner.model_validate(owner)
606
628
  started_at = now.isoformat(timespec="seconds")
607
- heartbeat_at = started_at
608
629
  lease_duration = float(lease_duration_sec)
609
630
  lease_expires_at = expires.isoformat(timespec="seconds")
610
631
  scheduler_state: SchedulerMetadata = scheduler or {}
@@ -614,7 +635,6 @@ class StateManager:
614
635
  number=int(number),
615
636
  backend=backend,
616
637
  started_at=started_at,
617
- heartbeat_at=heartbeat_at,
618
638
  lease_duration_sec=lease_duration,
619
639
  lease_expires_at=lease_expires_at,
620
640
  owner=owner_state,
@@ -661,49 +681,9 @@ class StateManager:
661
681
  return attempt.id
662
682
 
663
683
  @classmethod
664
- def heartbeat(
665
- cls, directory: Path, *, attempt_id: str, lease_duration_sec: float
666
- ) -> bool:
667
- ok = False
668
-
669
- def mutate(state: _FuruState) -> None:
670
- nonlocal ok
671
- attempt = state.attempt
672
- if not isinstance(attempt, _StateAttemptRunning):
673
- return
674
- if attempt.id != attempt_id:
675
- return
676
- now = cls._utcnow()
677
- expires = now + _dt.timedelta(seconds=float(lease_duration_sec))
678
- attempt.heartbeat_at = now.isoformat(timespec="seconds")
679
- attempt.lease_duration_sec = float(lease_duration_sec)
680
- attempt.lease_expires_at = expires.isoformat(timespec="seconds")
681
- ok = True
682
-
683
- cls.update_state(directory, mutate)
684
- return ok
685
-
686
- @classmethod
687
- def set_attempt_fields(
688
- cls, directory: Path, *, attempt_id: str, fields: SchedulerMetadata
689
- ) -> bool:
690
- ok = False
691
-
692
- def mutate(state: _FuruState) -> None:
693
- nonlocal ok
694
- attempt = state.attempt
695
- if attempt is None or attempt.id != attempt_id:
696
- return
697
- for key, value in fields.items():
698
- if key == "scheduler" and isinstance(value, dict):
699
- attempt.scheduler.update(value)
700
- continue
701
- if hasattr(attempt, key):
702
- setattr(attempt, key, value)
703
- ok = True
704
-
705
- cls.update_state(directory, mutate)
706
- return ok
684
+ def heartbeat(cls, directory: Path) -> None:
685
+ lock_path = cls.get_lock_path(directory, cls.COMPUTE_LOCK)
686
+ os.utime(lock_path)
707
687
 
708
688
  @classmethod
709
689
  def finish_attempt_success(cls, directory: Path, *, attempt_id: str) -> None:
@@ -717,7 +697,6 @@ class StateManager:
717
697
  number=attempt.number,
718
698
  backend=attempt.backend,
719
699
  started_at=attempt.started_at,
720
- heartbeat_at=attempt.heartbeat_at,
721
700
  lease_duration_sec=attempt.lease_duration_sec,
722
701
  lease_expires_at=attempt.lease_expires_at,
723
702
  owner=attempt.owner,
@@ -754,7 +733,6 @@ class StateManager:
754
733
  number=attempt.number,
755
734
  backend=attempt.backend,
756
735
  started_at=attempt.started_at,
757
- heartbeat_at=attempt.heartbeat_at,
758
736
  lease_duration_sec=attempt.lease_duration_sec,
759
737
  lease_expires_at=attempt.lease_expires_at,
760
738
  owner=attempt.owner,
@@ -792,7 +770,6 @@ class StateManager:
792
770
  number=attempt.number,
793
771
  backend=attempt.backend,
794
772
  started_at=attempt.started_at,
795
- heartbeat_at=attempt.heartbeat_at,
796
773
  lease_duration_sec=attempt.lease_duration_sec,
797
774
  lease_expires_at=attempt.lease_expires_at,
798
775
  owner=attempt.owner,
@@ -842,10 +819,10 @@ class StateManager:
842
819
  to lease expiry.
843
820
  """
844
821
 
845
- def mutate(state: _FuruState) -> None:
822
+ def mutate(state: _FuruState) -> bool:
846
823
  attempt = state.attempt
847
824
  if not isinstance(attempt, (_StateAttemptQueued, _StateAttemptRunning)):
848
- return
825
+ return False
849
826
 
850
827
  # Fast promotion if we can see a durable success marker.
851
828
  if cls.success_marker_exists(directory):
@@ -855,7 +832,6 @@ class StateManager:
855
832
  number=attempt.number,
856
833
  backend=attempt.backend,
857
834
  started_at=attempt.started_at,
858
- heartbeat_at=attempt.heartbeat_at,
859
835
  lease_duration_sec=attempt.lease_duration_sec,
860
836
  lease_expires_at=attempt.lease_expires_at,
861
837
  owner=attempt.owner,
@@ -865,7 +841,7 @@ class StateManager:
865
841
  state.result = _coerce_result(
866
842
  state.result, status="success", created_at=ended
867
843
  )
868
- return
844
+ return True
869
845
 
870
846
  backend = attempt.backend
871
847
  now = cls._iso_now()
@@ -878,6 +854,10 @@ class StateManager:
878
854
  if alive is False:
879
855
  terminal_status = "crashed"
880
856
  reason = "pid_dead"
857
+ elif isinstance(attempt, _StateAttemptRunning):
858
+ reason = cls._running_heartbeat_reason(directory, attempt)
859
+ if reason is not None:
860
+ terminal_status = "crashed"
881
861
  elif cls._lease_expired(attempt):
882
862
  terminal_status = "crashed"
883
863
  reason = "lease_expired"
@@ -890,16 +870,25 @@ class StateManager:
890
870
  attempt.scheduler.update(
891
871
  {k: v for k, v in verdict.items() if k != "terminal_status"}
892
872
  )
893
- if terminal_status is None and cls._lease_expired(attempt):
894
- terminal_status = "crashed"
895
- reason = "lease_expired"
873
+ if terminal_status is None:
874
+ if isinstance(attempt, _StateAttemptRunning):
875
+ reason = cls._running_heartbeat_reason(directory, attempt)
876
+ if reason is not None:
877
+ terminal_status = "crashed"
878
+ elif cls._lease_expired(attempt):
879
+ terminal_status = "crashed"
880
+ reason = "lease_expired"
896
881
  else:
897
- if cls._lease_expired(attempt):
882
+ if isinstance(attempt, _StateAttemptRunning):
883
+ reason = cls._running_heartbeat_reason(directory, attempt)
884
+ if reason is not None:
885
+ terminal_status = "crashed"
886
+ elif cls._lease_expired(attempt):
898
887
  terminal_status = "crashed"
899
888
  reason = "lease_expired"
900
889
 
901
890
  if terminal_status is None:
902
- return
891
+ return False
903
892
  if terminal_status == "success":
904
893
  terminal_status = "crashed"
905
894
  reason = reason or "scheduler_success_no_success_marker"
@@ -910,7 +899,6 @@ class StateManager:
910
899
  number=attempt.number,
911
900
  backend=attempt.backend,
912
901
  started_at=attempt.started_at,
913
- heartbeat_at=attempt.heartbeat_at,
914
902
  lease_duration_sec=attempt.lease_duration_sec,
915
903
  lease_expires_at=attempt.lease_expires_at,
916
904
  owner=attempt.owner,
@@ -927,7 +915,6 @@ class StateManager:
927
915
  number=attempt.number,
928
916
  backend=attempt.backend,
929
917
  started_at=attempt.started_at,
930
- heartbeat_at=attempt.heartbeat_at,
931
918
  lease_duration_sec=attempt.lease_duration_sec,
932
919
  lease_expires_at=attempt.lease_expires_at,
933
920
  owner=attempt.owner,
@@ -942,7 +929,6 @@ class StateManager:
942
929
  number=attempt.number,
943
930
  backend=attempt.backend,
944
931
  started_at=attempt.started_at,
945
- heartbeat_at=attempt.heartbeat_at,
946
932
  lease_duration_sec=attempt.lease_duration_sec,
947
933
  lease_expires_at=attempt.lease_expires_at,
948
934
  owner=attempt.owner,
@@ -957,7 +943,6 @@ class StateManager:
957
943
  number=attempt.number,
958
944
  backend=attempt.backend,
959
945
  started_at=attempt.started_at,
960
- heartbeat_at=attempt.heartbeat_at,
961
946
  lease_duration_sec=attempt.lease_duration_sec,
962
947
  lease_expires_at=attempt.lease_expires_at,
963
948
  owner=attempt.owner,
@@ -970,6 +955,7 @@ class StateManager:
970
955
  state.result,
971
956
  status="failed" if terminal_status == "failed" else "incomplete",
972
957
  )
958
+ return True
973
959
 
974
960
  state = cls.update_state(directory, mutate)
975
961
  attempt = state.attempt
@@ -1067,16 +1053,28 @@ def compute_lock(
1067
1053
  return ", ".join(parts)
1068
1054
 
1069
1055
  def _describe_wait(attempt: _StateAttempt, waited_sec: float) -> str:
1070
- label = "last heartbeat"
1071
- timestamp = attempt.heartbeat_at
1072
1056
  if attempt.status == "queued":
1073
1057
  label = "queued at"
1074
1058
  timestamp = attempt.started_at
1075
- parsed = StateManager._parse_time(timestamp)
1076
- timestamp_info = timestamp
1077
- if parsed is not None:
1078
- age = (StateManager._utcnow() - parsed).total_seconds()
1079
- timestamp_info = f"{timestamp} ({_format_wait_duration(age)} ago)"
1059
+ parsed = StateManager._parse_time(timestamp)
1060
+ timestamp_info = timestamp
1061
+ if parsed is not None:
1062
+ age = (StateManager._utcnow() - parsed).total_seconds()
1063
+ timestamp_info = f"{timestamp} ({_format_wait_duration(age)} ago)"
1064
+ else:
1065
+ label = "last heartbeat"
1066
+ last_heartbeat = StateManager.last_heartbeat_mtime(directory)
1067
+ if last_heartbeat is None:
1068
+ timestamp_info = "missing"
1069
+ else:
1070
+ heartbeat_dt = _dt.datetime.fromtimestamp(
1071
+ last_heartbeat, tz=_dt.timezone.utc
1072
+ )
1073
+ age = time.time() - last_heartbeat
1074
+ timestamp_info = (
1075
+ f"{heartbeat_dt.isoformat(timespec='seconds')} "
1076
+ f"({_format_wait_duration(age)} ago)"
1077
+ )
1080
1078
  return (
1081
1079
  "waited "
1082
1080
  f"{_format_wait_duration(waited_sec)}, {label} {timestamp_info}, "
@@ -1228,11 +1226,7 @@ def compute_lock(
1228
1226
  # Start heartbeat IMMEDIATELY
1229
1227
  def heartbeat() -> None:
1230
1228
  while not stop_event.wait(heartbeat_interval_sec):
1231
- StateManager.heartbeat(
1232
- directory,
1233
- attempt_id=attempt_id, # type: ignore[arg-type]
1234
- lease_duration_sec=lease_duration_sec,
1235
- )
1229
+ StateManager.heartbeat(directory)
1236
1230
 
1237
1231
  thread = threading.Thread(target=heartbeat, daemon=True)
1238
1232
  thread.start()
furu/testing.py ADDED
@@ -0,0 +1,232 @@
1
+ from collections.abc import Generator, Mapping, Sequence
2
+ from contextlib import contextmanager
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import cast, overload
6
+
7
+ import chz
8
+ import pytest
9
+
10
+ from .config import FURU_CONFIG, RecordGitMode
11
+ from .core import Furu
12
+ from .runtime.overrides import OverrideValue, override_furu_hashes
13
+
14
+ OverrideKey = Furu | str
15
+ _PATH_MISSING = object()
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class _FuruConfigSnapshot:
20
+ base_root: Path
21
+ version_controlled_root_override: Path | None
22
+ record_git: RecordGitMode
23
+ allow_no_git_origin: bool
24
+ poll_interval: float
25
+ stale_timeout: float
26
+ max_wait_time_sec: float | None
27
+ lease_duration_sec: float
28
+ heartbeat_interval_sec: float
29
+ cancelled_is_preempted: bool
30
+ retry_failed: bool
31
+ submitit_root: Path
32
+
33
+ @classmethod
34
+ def capture(cls) -> "_FuruConfigSnapshot":
35
+ return cls(
36
+ base_root=FURU_CONFIG.base_root,
37
+ version_controlled_root_override=FURU_CONFIG.version_controlled_root_override,
38
+ record_git=FURU_CONFIG.record_git,
39
+ allow_no_git_origin=FURU_CONFIG.allow_no_git_origin,
40
+ poll_interval=FURU_CONFIG.poll_interval,
41
+ stale_timeout=FURU_CONFIG.stale_timeout,
42
+ max_wait_time_sec=FURU_CONFIG.max_wait_time_sec,
43
+ lease_duration_sec=FURU_CONFIG.lease_duration_sec,
44
+ heartbeat_interval_sec=FURU_CONFIG.heartbeat_interval_sec,
45
+ cancelled_is_preempted=FURU_CONFIG.cancelled_is_preempted,
46
+ retry_failed=FURU_CONFIG.retry_failed,
47
+ submitit_root=FURU_CONFIG.submitit_root,
48
+ )
49
+
50
+ def restore(self) -> None:
51
+ FURU_CONFIG.base_root = self.base_root
52
+ FURU_CONFIG.version_controlled_root_override = (
53
+ self.version_controlled_root_override
54
+ )
55
+ FURU_CONFIG.record_git = self.record_git
56
+ FURU_CONFIG.allow_no_git_origin = self.allow_no_git_origin
57
+ FURU_CONFIG.poll_interval = self.poll_interval
58
+ FURU_CONFIG.stale_timeout = self.stale_timeout
59
+ FURU_CONFIG.max_wait_time_sec = self.max_wait_time_sec
60
+ FURU_CONFIG.lease_duration_sec = self.lease_duration_sec
61
+ FURU_CONFIG.heartbeat_interval_sec = self.heartbeat_interval_sec
62
+ FURU_CONFIG.cancelled_is_preempted = self.cancelled_is_preempted
63
+ FURU_CONFIG.retry_failed = self.retry_failed
64
+ FURU_CONFIG.submitit_root = self.submitit_root
65
+
66
+
67
+ def _apply_test_config(base_root: Path) -> Path:
68
+ root = base_root.resolve()
69
+ FURU_CONFIG.base_root = root
70
+ FURU_CONFIG.version_controlled_root_override = root / "furu-data" / "artifacts"
71
+ FURU_CONFIG.record_git = "ignore"
72
+ FURU_CONFIG.allow_no_git_origin = False
73
+ FURU_CONFIG.poll_interval = 0.01
74
+ FURU_CONFIG.stale_timeout = 0.1
75
+ FURU_CONFIG.max_wait_time_sec = None
76
+ FURU_CONFIG.lease_duration_sec = 0.05
77
+ FURU_CONFIG.heartbeat_interval_sec = 0.01
78
+ FURU_CONFIG.cancelled_is_preempted = True
79
+ FURU_CONFIG.retry_failed = True
80
+ FURU_CONFIG.submitit_root = root / "submitit"
81
+ return root
82
+
83
+
84
+ @contextmanager
85
+ def furu_test_env(base_root: Path) -> Generator[Path, None, None]:
86
+ snapshot = _FuruConfigSnapshot.capture()
87
+ root = _apply_test_config(base_root)
88
+ try:
89
+ yield root
90
+ finally:
91
+ snapshot.restore()
92
+
93
+
94
+ @overload
95
+ def override_results(
96
+ overrides: Mapping[Furu, OverrideValue],
97
+ ) -> Generator[None, None, None]: ...
98
+
99
+
100
+ @overload
101
+ def override_results(
102
+ overrides: Mapping[str, OverrideValue],
103
+ ) -> Generator[None, None, None]: ...
104
+
105
+
106
+ @contextmanager
107
+ def override_results(
108
+ overrides: Mapping[OverrideKey, OverrideValue],
109
+ ) -> Generator[None, None, None]:
110
+ """Override specific Furu results within the context.
111
+
112
+ Overrides are keyed by furu_hash, so identical configs share a stub.
113
+ Keys may be a Furu instance or a furu_hash string.
114
+ """
115
+ hash_overrides: dict[str, OverrideValue] = {}
116
+ for key, value in overrides.items():
117
+ hash_key: str
118
+ if isinstance(key, Furu):
119
+ hash_key = cast(str, key.furu_hash)
120
+ elif isinstance(key, str):
121
+ if not key:
122
+ raise ValueError("override furu_hash must be non-empty")
123
+ hash_key = key
124
+ else:
125
+ raise TypeError(
126
+ "override_results keys must be Furu instances or furu_hash strings"
127
+ )
128
+ hash_overrides[hash_key] = value
129
+ with override_furu_hashes(hash_overrides):
130
+ yield
131
+
132
+
133
+ @overload
134
+ def override_results_for(
135
+ root: Furu,
136
+ overrides: Mapping[str, OverrideValue],
137
+ ) -> Generator[None, None, None]: ...
138
+
139
+
140
+ @contextmanager
141
+ def override_results_for(
142
+ root: Furu,
143
+ overrides: Mapping[str, OverrideValue],
144
+ ) -> Generator[None, None, None]:
145
+ """Override results by dotted field path relative to a root Furu object.
146
+
147
+ Paths follow chz traversal: use numeric segments for list indices and
148
+ key segments for mappings (e.g. "deps.0" or "deps.key").
149
+ """
150
+ hash_overrides: dict[str, OverrideValue] = {}
151
+ for path, value in overrides.items():
152
+ target = _resolve_override_path(root, path)
153
+ hash_overrides[cast(str, target.furu_hash)] = value
154
+ with override_furu_hashes(hash_overrides):
155
+ yield
156
+
157
+
158
+ def _resolve_override_path(root: Furu, path: str) -> Furu:
159
+ if not path:
160
+ raise ValueError("override path must be non-empty")
161
+ current: object = root
162
+ for segment in path.split("."):
163
+ if not segment:
164
+ raise ValueError(f"override path has empty segment: {path!r}")
165
+ current = _resolve_path_segment(current, segment, path)
166
+ if not isinstance(current, Furu):
167
+ raise TypeError(
168
+ f"override path {path!r} must resolve to a Furu instance; got {type(current).__name__}"
169
+ )
170
+ return current
171
+
172
+
173
+ def _resolve_path_segment(current: object, segment: str, path: str) -> object:
174
+ if chz.is_chz(current):
175
+ value = getattr(current, segment, _PATH_MISSING)
176
+ if value is _PATH_MISSING:
177
+ raise AttributeError(f"override path {path!r} has no attribute {segment!r}")
178
+ return value
179
+ if isinstance(current, Mapping):
180
+ mapping = cast(Mapping[object, OverrideValue], current)
181
+ return _resolve_mapping_segment(mapping, segment, path)
182
+ if _is_indexable_sequence(current):
183
+ sequence = cast(Sequence[OverrideValue], current)
184
+ return _resolve_sequence_segment(sequence, segment, path)
185
+ raise TypeError(
186
+ f"override path {path!r} cannot traverse into {type(current).__name__}"
187
+ )
188
+
189
+
190
+ def _resolve_mapping_segment(
191
+ mapping: Mapping[object, OverrideValue],
192
+ segment: str,
193
+ path: str,
194
+ ) -> OverrideValue:
195
+ if segment in mapping:
196
+ return mapping[segment]
197
+ index = _parse_index_segment(segment)
198
+ if index is not None and index in mapping:
199
+ return mapping[index]
200
+ raise KeyError(f"override path {path!r} has no key {segment!r}")
201
+
202
+
203
+ def _resolve_sequence_segment(
204
+ sequence: Sequence[OverrideValue],
205
+ segment: str,
206
+ path: str,
207
+ ) -> OverrideValue:
208
+ index = _parse_index_segment(segment)
209
+ if index is None:
210
+ raise TypeError(f"override path {path!r} index {segment!r} is not an integer")
211
+ if index < 0:
212
+ raise ValueError(f"override path {path!r} index must be non-negative")
213
+ return sequence[index]
214
+
215
+
216
+ def _parse_index_segment(segment: str) -> int | None:
217
+ if not segment.isdigit():
218
+ return None
219
+ return int(segment)
220
+
221
+
222
+ def _is_indexable_sequence(value: object) -> bool:
223
+ if isinstance(value, (str, bytes, bytearray)):
224
+ return False
225
+ return isinstance(value, Sequence)
226
+
227
+
228
+ @pytest.fixture()
229
+ def furu_tmp_root(tmp_path: Path) -> Generator[Path, None, None]:
230
+ """Configure furu to use a temporary root for the test."""
231
+ with furu_test_env(tmp_path) as root:
232
+ yield root