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/config.py +27 -40
- furu/core/furu.py +203 -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/runtime/overrides.py +37 -0
- furu/storage/metadata.py +26 -29
- furu/storage/migration.py +0 -1
- furu/storage/state.py +86 -92
- furu/testing.py +232 -0
- {furu-0.0.4.dist-info → furu-0.0.6.dist-info}/METADATA +101 -6
- {furu-0.0.4.dist-info → furu-0.0.6.dist-info}/RECORD +22 -20
- {furu-0.0.4.dist-info → furu-0.0.6.dist-info}/WHEEL +1 -1
- {furu-0.0.4.dist-info → furu-0.0.6.dist-info}/entry_points.txt +0 -0
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,
|
|
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 = "
|
|
290
|
-
SUBMIT_LOCK = "
|
|
291
|
-
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
|
666
|
-
|
|
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) ->
|
|
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
|
|
894
|
-
|
|
895
|
-
|
|
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
|
|
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
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|