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/core/furu.py CHANGED
@@ -9,6 +9,7 @@ import threading
9
9
  import time
10
10
  import traceback
11
11
  from abc import ABC, abstractmethod
12
+ from functools import cached_property
12
13
  from pathlib import Path
13
14
  from types import FrameType
14
15
  from typing import (
@@ -21,17 +22,16 @@ from typing import (
21
22
  Protocol,
22
23
  Self,
23
24
  Sequence,
24
- TypedDict,
25
25
  TypeAlias,
26
+ TypedDict,
26
27
  TypeVar,
27
28
  cast,
28
29
  )
29
30
 
30
31
  import chz
31
32
  import submitit
32
- from typing_extensions import dataclass_transform
33
-
34
33
  from chz.field import Field as ChzField
34
+ from typing_extensions import dataclass_transform
35
35
 
36
36
  from ..adapters import SubmititAdapter
37
37
  from ..adapters.submitit import SubmititJob
@@ -63,7 +63,6 @@ from ..storage.state import (
63
63
  _StateAttemptRunning,
64
64
  _StateResultAbsent,
65
65
  _StateResultFailed,
66
- _StateResultMigrated,
67
66
  _StateResultSuccess,
68
67
  compute_lock,
69
68
  )
@@ -207,7 +206,7 @@ class Furu[T](ABC):
207
206
 
208
207
  def _get_dependencies(self: Self, *, recursive: bool = True) -> list["Furu"]:
209
208
  """Collect Furu dependencies from fields and `_dependencies()`."""
210
- seen = {self._furu_hash}
209
+ seen = {self.furu_hash}
211
210
  dependencies: list[Furu] = []
212
211
  _collect_dependencies(self, dependencies, seen, recursive=recursive)
213
212
  return dependencies
@@ -221,7 +220,7 @@ class Furu[T](ABC):
221
220
  for dependency in dependencies:
222
221
  if dependency is self:
223
222
  raise ValueError("Furu dependencies cannot include self")
224
- digests.add(dependency._furu_hash)
223
+ digests.add(dependency.furu_hash)
225
224
  return sorted(digests)
226
225
 
227
226
  def _invalidate_cached_success(self: Self, directory: Path, *, reason: str) -> None:
@@ -229,7 +228,7 @@ class Furu[T](ABC):
229
228
  logger.warning(
230
229
  "invalidate %s %s %s (%s)",
231
230
  self.__class__.__name__,
232
- self._furu_hash,
231
+ self.furu_hash,
233
232
  directory,
234
233
  reason,
235
234
  )
@@ -262,14 +261,9 @@ class Furu[T](ABC):
262
261
  if isinstance(state.result, _StateResultSuccess):
263
262
  self._invalidate_cached_success(directory, reason="always_rerun enabled")
264
263
 
265
- @property
264
+ @cached_property
266
265
  def furu_hash(self: Self) -> str:
267
266
  """Return the stable content hash for this Furu object."""
268
- return self._furu_hash
269
-
270
- @property
271
- def _furu_hash(self: Self) -> str:
272
- """Compute hash of this object's content for storage identification."""
273
267
  return FuruSerializer.compute_hash(self)
274
268
 
275
269
  def _always_rerun(self: Self) -> bool:
@@ -282,15 +276,17 @@ class Furu[T](ABC):
282
276
 
283
277
  def _base_furu_dir(self: Self) -> Path:
284
278
  root = FURU_CONFIG.get_root(self.version_controlled)
285
- return root / self.__class__._namespace() / self._furu_hash
279
+ return root / self.__class__._namespace() / self.furu_hash
286
280
 
287
- @property
281
+ @cached_property
288
282
  def furu_dir(self: Self) -> Path:
289
283
  """Get the directory for this Furu object."""
290
284
  directory = self._base_furu_dir()
291
285
  migration = self._alias_record(directory)
292
- if migration is not None and self._alias_is_active(directory, migration):
293
- return MigrationManager.resolve_dir(migration, target="from")
286
+ if migration is not None:
287
+ target_dir = self._alias_target_dir(directory, migration)
288
+ if target_dir is not None:
289
+ return target_dir
294
290
  return directory
295
291
 
296
292
  @property
@@ -321,9 +317,8 @@ class Furu[T](ABC):
321
317
 
322
318
  def _exists_quiet(self: Self) -> bool:
323
319
  directory = self._base_furu_dir()
324
- state = self.get_state(directory)
325
-
326
- if not isinstance(state.result, _StateResultSuccess):
320
+ success_dir = self._success_marker_dir(directory)
321
+ if success_dir is None:
327
322
  return False
328
323
  try:
329
324
  return self._validate()
@@ -332,7 +327,7 @@ class Furu[T](ABC):
332
327
  logger.warning(
333
328
  "exists %s -> false (validate invalid for %s: %s)",
334
329
  directory,
335
- f"{self.__class__.__name__}({self._furu_hash})",
330
+ f"{self.__class__.__name__}({self.furu_hash})",
336
331
  exc,
337
332
  )
338
333
  return False
@@ -341,7 +336,7 @@ class Furu[T](ABC):
341
336
  logger.exception(
342
337
  "exists %s -> false (validate crashed for %s: %s)",
343
338
  directory,
344
- f"{self.__class__.__name__}({self._furu_hash})",
339
+ f"{self.__class__.__name__}({self.furu_hash})",
345
340
  exc,
346
341
  )
347
342
  return False
@@ -350,9 +345,8 @@ class Furu[T](ABC):
350
345
  """Check if result exists and is valid."""
351
346
  logger = get_logger()
352
347
  directory = self._base_furu_dir()
353
- state = self.get_state(directory)
354
-
355
- if not isinstance(state.result, _StateResultSuccess):
348
+ success_dir = self._success_marker_dir(directory)
349
+ if success_dir is None:
356
350
  logger.info("exists %s -> false", directory)
357
351
  return False
358
352
 
@@ -382,72 +376,110 @@ class Furu[T](ABC):
382
376
  Raises:
383
377
  FuruComputeError: If computation fails with detailed error information
384
378
  """
385
- from furu.execution.context import EXEC_CONTEXT
386
379
  from furu.errors import (
387
380
  FuruExecutionError,
388
381
  FuruMissingArtifact,
389
382
  FuruSpecMismatch,
390
383
  )
384
+ from furu.execution.context import EXEC_CONTEXT
391
385
 
392
386
  ctx = EXEC_CONTEXT.get()
393
387
  if ctx.mode == "executor":
394
- directory = self._base_furu_dir()
395
- if force:
396
- if (
397
- ctx.current_node_hash is None
398
- or self._furu_hash != ctx.current_node_hash
399
- ):
400
- raise FuruExecutionError(
401
- "force=True not allowed: only the current node may compute in executor mode. "
402
- f"current_node_hash={ctx.current_node_hash!r} "
403
- f"obj={self.__class__.__name__}({self._furu_hash})",
404
- hints=[
405
- "Declare this object as a dependency instead of calling dep.get(force=True).",
406
- "Inside executor mode, use get(force=True) only on the node being executed.",
407
- ],
408
- )
409
- self._prepare_executor_rerun(directory)
388
+ logger = get_logger()
389
+ parent_holder = current_holder()
390
+ has_parent = parent_holder is not None and parent_holder is not self
391
+ needs_holder = parent_holder is None or has_parent
392
+ caller_info: _CallerInfo = {}
393
+ if has_parent:
394
+ caller_info = self._get_caller_info()
395
+
396
+ def _executor_get() -> T:
397
+ directory = self._base_furu_dir()
398
+ if force:
399
+ if (
400
+ ctx.current_node_hash is None
401
+ or self.furu_hash != ctx.current_node_hash
402
+ ):
403
+ raise FuruExecutionError(
404
+ "force=True not allowed: only the current node may compute in executor mode. "
405
+ f"current_node_hash={ctx.current_node_hash!r} "
406
+ f"obj={self.__class__.__name__}({self.furu_hash})",
407
+ hints=[
408
+ "Declare this object as a dependency instead of calling dep.get(force=True).",
409
+ "Inside executor mode, use get(force=True) only on the node being executed.",
410
+ ],
411
+ )
412
+ self._prepare_executor_rerun(directory)
413
+
414
+ exists_ok = self._exists_quiet()
415
+ if exists_ok and not (force and self._always_rerun()):
416
+ return self._load()
410
417
 
411
- exists_ok = self._exists_quiet()
412
- if exists_ok and not (force and self._always_rerun()):
413
- return self._load()
418
+ if force and not exists_ok:
419
+ state = self.get_state(directory)
420
+ if isinstance(state.result, _StateResultSuccess):
421
+ self._invalidate_cached_success(
422
+ directory, reason="_validate returned false (executor)"
423
+ )
414
424
 
415
- if force and not exists_ok:
416
- state = self.get_state(directory)
417
- if isinstance(state.result, _StateResultSuccess):
418
- self._invalidate_cached_success(
419
- directory, reason="_validate returned false (executor)"
425
+ if not force:
426
+ raise FuruMissingArtifact(
427
+ "Missing artifact "
428
+ f"{self.__class__.__name__}({self.furu_hash}) in executor mode. "
429
+ f"Requested by {ctx.current_node_hash}. Declare it as a dependency."
420
430
  )
421
431
 
422
- if not force:
423
- raise FuruMissingArtifact(
424
- "Missing artifact "
425
- f"{self.__class__.__name__}({self._furu_hash}) in executor mode. "
426
- f"Requested by {ctx.current_node_hash}. Declare it as a dependency."
432
+ required = self._executor_spec_key()
433
+ if ctx.spec_key is None or required != ctx.spec_key:
434
+ raise FuruSpecMismatch(
435
+ "force=True not allowed: "
436
+ f"required={required!r} != worker={ctx.spec_key!r} (v1 exact match)"
437
+ )
438
+
439
+ StateManager.ensure_internal_dir(directory)
440
+ status, created_here, result = self._run_locally(
441
+ start_time=time.time(),
442
+ allow_failed=FURU_CONFIG.retry_failed,
443
+ executor_mode=True,
427
444
  )
445
+ if status == "success":
446
+ if created_here:
447
+ return cast(T, result)
448
+ return self._load()
428
449
 
429
- required = self._executor_spec_key()
430
- if ctx.spec_key is None or required != ctx.spec_key:
431
- raise FuruSpecMismatch(
432
- "force=True not allowed: "
433
- f"required={required!r} != worker={ctx.spec_key!r} (v1 exact match)"
450
+ raise self._build_failed_state_error(
451
+ self._base_furu_dir(),
452
+ None,
453
+ message="Computation previously failed",
434
454
  )
435
455
 
436
- status, created_here, result = self._run_locally(
437
- start_time=time.time(),
438
- allow_failed=FURU_CONFIG.retry_failed,
439
- executor_mode=True,
440
- )
441
- if status == "success":
442
- if created_here:
443
- return cast(T, result)
444
- return self._load()
456
+ if has_parent:
457
+ logger.debug(
458
+ "dep: begin %s %s %s",
459
+ self.__class__.__name__,
460
+ self.furu_hash,
461
+ self._base_furu_dir(),
462
+ extra=caller_info,
463
+ )
445
464
 
446
- raise self._build_failed_state_error(
447
- self._base_furu_dir(),
448
- None,
449
- message="Computation previously failed",
450
- )
465
+ ok = False
466
+ try:
467
+ if needs_holder:
468
+ with enter_holder(self):
469
+ result = _executor_get()
470
+ else:
471
+ result = _executor_get()
472
+ ok = True
473
+ return result
474
+ finally:
475
+ if has_parent:
476
+ logger.debug(
477
+ "dep: end %s %s (%s)",
478
+ self.__class__.__name__,
479
+ self.furu_hash,
480
+ "ok" if ok else "error",
481
+ extra=caller_info,
482
+ )
451
483
 
452
484
  return self._get_impl_interactive(force=force)
453
485
 
@@ -455,13 +487,15 @@ class Furu[T](ABC):
455
487
  logger = get_logger()
456
488
  parent_holder = current_holder()
457
489
  has_parent = parent_holder is not None and parent_holder is not self
490
+ caller_info = self._get_caller_info()
458
491
  retry_failed_effective = FURU_CONFIG.retry_failed
459
492
  if has_parent:
460
493
  logger.debug(
461
494
  "dep: begin %s %s %s",
462
495
  self.__class__.__name__,
463
- self._furu_hash,
496
+ self.furu_hash,
464
497
  self._base_furu_dir(),
498
+ extra=caller_info,
465
499
  )
466
500
 
467
501
  ok = False
@@ -469,19 +503,21 @@ class Furu[T](ABC):
469
503
  with enter_holder(self):
470
504
  start_time = time.time()
471
505
  base_dir = self._base_furu_dir()
472
- base_dir.mkdir(parents=True, exist_ok=True)
473
506
  directory = base_dir
474
507
  migration = self._alias_record(base_dir)
475
508
  alias_active = False
509
+ base_marker = StateManager.success_marker_exists(base_dir)
476
510
 
477
511
  if (
478
512
  migration is not None
479
513
  and migration.kind == "alias"
480
514
  and migration.overwritten_at is None
515
+ and not base_marker
481
516
  ):
482
- target_dir = MigrationManager.resolve_dir(migration, target="from")
483
- target_state = StateManager.read_state(target_dir)
484
- if isinstance(target_state.result, _StateResultSuccess):
517
+ target_dir = self._alias_target_dir(
518
+ base_dir, migration, base_marker=base_marker
519
+ )
520
+ if target_dir is not None:
485
521
  alias_active = True
486
522
  directory = target_dir
487
523
  else:
@@ -582,17 +618,25 @@ class Furu[T](ABC):
582
618
  # Cache hits can be extremely noisy in pipelines; keep logs for state
583
619
  # transitions (create/wait) and error cases, but suppress repeated
584
620
  # "success->load" lines and the raw separator on successful loads.
585
- self._log_console_start(action_color=action_color)
621
+ self._log_console_start(
622
+ action_color=action_color,
623
+ caller_info=caller_info,
624
+ )
586
625
 
587
626
  if decision != "success->load":
627
+ if decision == "create":
628
+ StateManager.ensure_internal_dir(directory)
588
629
  write_separator()
589
630
  logger.debug(
590
631
  "get %s %s %s (%s)",
591
632
  self.__class__.__name__,
592
- self._furu_hash,
633
+ self.furu_hash,
593
634
  directory,
594
635
  decision,
595
- extra={"furu_action_color": action_color},
636
+ extra={
637
+ "furu_action_color": action_color,
638
+ **caller_info,
639
+ },
596
640
  )
597
641
 
598
642
  # Fast path: already successful
@@ -609,7 +653,7 @@ class Furu[T](ABC):
609
653
  logger.error(
610
654
  "get %s %s (load failed)",
611
655
  self.__class__.__name__,
612
- self._furu_hash,
656
+ self.furu_hash,
613
657
  )
614
658
  raise FuruComputeError(
615
659
  f"Failed to load result from {directory}",
@@ -646,15 +690,14 @@ class Furu[T](ABC):
646
690
  logger.debug(
647
691
  "dep: end %s %s (%s)",
648
692
  self.__class__.__name__,
649
- self._furu_hash,
693
+ self.furu_hash,
650
694
  "ok" if ok else "error",
695
+ extra=caller_info,
651
696
  )
652
697
 
653
- def _log_console_start(self, action_color: str) -> None:
654
- """Log the start of get to console with caller info."""
655
- logger = get_logger()
698
+ @staticmethod
699
+ def _get_caller_info() -> _CallerInfo:
656
700
  frame = sys._getframe(1)
657
-
658
701
  caller_info: _CallerInfo = {}
659
702
  if frame is not None:
660
703
  # Walk up the stack to find the caller outside of furu package
@@ -669,11 +712,20 @@ class Furu[T](ABC):
669
712
  }
670
713
  break
671
714
  frame = frame.f_back
715
+ return caller_info
716
+
717
+ def _log_console_start(
718
+ self, action_color: str, caller_info: _CallerInfo | None = None
719
+ ) -> None:
720
+ """Log the start of get to console with caller info."""
721
+ logger = get_logger()
722
+ if caller_info is None:
723
+ caller_info = self._get_caller_info()
672
724
 
673
725
  logger.info(
674
726
  "get %s %s",
675
727
  self.__class__.__name__,
676
- self._furu_hash,
728
+ self.furu_hash,
677
729
  extra={
678
730
  "furu_console_only": True,
679
731
  "furu_action_color": action_color,
@@ -744,9 +796,11 @@ class Furu[T](ABC):
744
796
  """Return the alias-aware state for this Furu directory."""
745
797
  base_dir = directory or self._base_furu_dir()
746
798
  record = self._alias_record(base_dir)
747
- if record is None or not self._alias_is_active(base_dir, record):
799
+ if record is None:
800
+ return StateManager.read_state(base_dir)
801
+ target_dir = self._alias_target_dir(base_dir, record)
802
+ if target_dir is None:
748
803
  return StateManager.read_state(base_dir)
749
- target_dir = MigrationManager.resolve_dir(record, target="from")
750
804
  return StateManager.read_state(target_dir)
751
805
 
752
806
  def _alias_record(self, directory: Path) -> MigrationRecord | None:
@@ -755,15 +809,36 @@ class Furu[T](ABC):
755
809
  return None
756
810
  return record
757
811
 
758
- def _alias_is_active(self, directory: Path, record: MigrationRecord) -> bool:
812
+ def _alias_target_dir(
813
+ self,
814
+ directory: Path,
815
+ record: MigrationRecord,
816
+ *,
817
+ base_marker: bool | None = None,
818
+ ) -> Path | None:
759
819
  if record.overwritten_at is not None:
760
- return False
761
- state = StateManager.read_state(directory)
762
- if not isinstance(state.result, _StateResultMigrated):
763
- return False
820
+ return None
821
+ if base_marker is None:
822
+ base_marker = StateManager.success_marker_exists(directory)
823
+ if base_marker:
824
+ return None
764
825
  target = MigrationManager.resolve_dir(record, target="from")
765
- target_state = StateManager.read_state(target)
766
- return isinstance(target_state.result, _StateResultSuccess)
826
+ if StateManager.success_marker_exists(target):
827
+ return target
828
+ return None
829
+
830
+ def _success_marker_dir(self, directory: Path) -> Path | None:
831
+ base_marker = StateManager.success_marker_exists(directory)
832
+ record = self._alias_record(directory)
833
+ if record is None:
834
+ return directory if base_marker else None
835
+ target_dir = self._alias_target_dir(directory, record, base_marker=base_marker)
836
+ if target_dir is not None:
837
+ return target_dir
838
+ return directory if base_marker else None
839
+
840
+ def _alias_is_active(self, directory: Path, record: MigrationRecord) -> bool:
841
+ return self._alias_target_dir(directory, record) is not None
767
842
 
768
843
  def _maybe_detach_alias(
769
844
  self: Self,
@@ -804,6 +879,7 @@ class Furu[T](ABC):
804
879
  ) -> SubmititJob | None:
805
880
  """Submit job once without waiting (fire-and-forget mode)."""
806
881
  logger = get_logger()
882
+ StateManager.ensure_internal_dir(directory)
807
883
  self._reconcile(directory, adapter=adapter)
808
884
  state = StateManager.read_state(directory)
809
885
  attempt = state.attempt
@@ -824,7 +900,7 @@ class Furu[T](ABC):
824
900
  logger.debug(
825
901
  "submit: waiting for submit lock %s %s %s",
826
902
  self.__class__.__name__,
827
- self._furu_hash,
903
+ self.furu_hash,
828
904
  directory,
829
905
  )
830
906
  time.sleep(0.5)
@@ -833,9 +909,7 @@ class Furu[T](ABC):
833
909
  attempt_id: str | None = None
834
910
  try:
835
911
  # Create metadata
836
- metadata = MetadataManager.create_metadata(
837
- self, directory, ignore_diff=FURU_CONFIG.ignore_git_diff
838
- )
912
+ metadata = MetadataManager.create_metadata(self, directory)
839
913
  MetadataManager.write_metadata(metadata, directory)
840
914
 
841
915
  env_info = MetadataManager.collect_environment_info()
@@ -919,12 +993,12 @@ class Furu[T](ABC):
919
993
  mode="executor",
920
994
  spec_key=self._executor_spec_key(),
921
995
  backend="submitit",
922
- current_node_hash=self._furu_hash,
996
+ current_node_hash=self.furu_hash,
923
997
  )
924
998
  )
925
999
  try:
926
1000
  directory = self._base_furu_dir()
927
- directory.mkdir(parents=True, exist_ok=True)
1001
+ StateManager.ensure_internal_dir(directory)
928
1002
  always_rerun = self._always_rerun()
929
1003
  needs_success_invalidation = False
930
1004
  if not always_rerun:
@@ -979,11 +1053,7 @@ class Furu[T](ABC):
979
1053
  stage = "metadata"
980
1054
  try:
981
1055
  # Refresh metadata (now safe - attempt is already recorded)
982
- metadata = MetadataManager.create_metadata(
983
- self,
984
- directory,
985
- ignore_diff=FURU_CONFIG.ignore_git_diff,
986
- )
1056
+ metadata = MetadataManager.create_metadata(self, directory)
987
1057
  MetadataManager.write_metadata(metadata, directory)
988
1058
 
989
1059
  # Set up signal handlers
@@ -999,14 +1069,14 @@ class Furu[T](ABC):
999
1069
  logger.debug(
1000
1070
  "_create: begin %s %s %s",
1001
1071
  self.__class__.__name__,
1002
- self._furu_hash,
1072
+ self.furu_hash,
1003
1073
  directory,
1004
1074
  )
1005
1075
  self._create()
1006
1076
  logger.debug(
1007
1077
  "_create: ok %s %s %s",
1008
1078
  self.__class__.__name__,
1009
- self._furu_hash,
1079
+ self.furu_hash,
1010
1080
  directory,
1011
1081
  )
1012
1082
  StateManager.write_success_marker(
@@ -1018,7 +1088,7 @@ class Furu[T](ABC):
1018
1088
  logger.info(
1019
1089
  "_create ok %s %s",
1020
1090
  self.__class__.__name__,
1021
- self._furu_hash,
1091
+ self.furu_hash,
1022
1092
  extra={"furu_console_only": True},
1023
1093
  )
1024
1094
  except Exception as e:
@@ -1026,7 +1096,7 @@ class Furu[T](ABC):
1026
1096
  logger.error(
1027
1097
  "_create failed %s %s %s",
1028
1098
  self.__class__.__name__,
1029
- self._furu_hash,
1099
+ self.furu_hash,
1030
1100
  directory,
1031
1101
  extra={"furu_file_only": True},
1032
1102
  )
@@ -1035,7 +1105,7 @@ class Furu[T](ABC):
1035
1105
  "attempt failed (%s) %s %s %s",
1036
1106
  stage,
1037
1107
  self.__class__.__name__,
1038
- self._furu_hash,
1108
+ self.furu_hash,
1039
1109
  directory,
1040
1110
  extra={"furu_file_only": True},
1041
1111
  )
@@ -1082,7 +1152,7 @@ class Furu[T](ABC):
1082
1152
  f"backend {attempt.backend}"
1083
1153
  )
1084
1154
  hints = [
1085
- f"Furu hash: {self._furu_hash}",
1155
+ f"Furu hash: {self.furu_hash}",
1086
1156
  f"Directory: {directory}",
1087
1157
  f"State file: {state_path}",
1088
1158
  f"Attempt: {attempt_info}",
@@ -1169,9 +1239,7 @@ class Furu[T](ABC):
1169
1239
  stage = "metadata"
1170
1240
  try:
1171
1241
  # Create metadata (now safe - attempt is already recorded)
1172
- metadata = MetadataManager.create_metadata(
1173
- self, directory, ignore_diff=FURU_CONFIG.ignore_git_diff
1174
- )
1242
+ metadata = MetadataManager.create_metadata(self, directory)
1175
1243
  MetadataManager.write_metadata(metadata, directory)
1176
1244
 
1177
1245
  # Set up preemption handler
@@ -1185,7 +1253,7 @@ class Furu[T](ABC):
1185
1253
  logger.debug(
1186
1254
  "_create: begin %s %s %s",
1187
1255
  self.__class__.__name__,
1188
- self._furu_hash,
1256
+ self.furu_hash,
1189
1257
  directory,
1190
1258
  )
1191
1259
  token = None
@@ -1197,7 +1265,7 @@ class Furu[T](ABC):
1197
1265
  mode="executor",
1198
1266
  spec_key=self._executor_spec_key(),
1199
1267
  backend="local",
1200
- current_node_hash=self._furu_hash,
1268
+ current_node_hash=self.furu_hash,
1201
1269
  )
1202
1270
  )
1203
1271
  try:
@@ -1208,7 +1276,7 @@ class Furu[T](ABC):
1208
1276
  logger.debug(
1209
1277
  "_create: ok %s %s %s",
1210
1278
  self.__class__.__name__,
1211
- self._furu_hash,
1279
+ self.furu_hash,
1212
1280
  directory,
1213
1281
  )
1214
1282
  StateManager.write_success_marker(
@@ -1220,7 +1288,7 @@ class Furu[T](ABC):
1220
1288
  logger.info(
1221
1289
  "_create ok %s %s",
1222
1290
  self.__class__.__name__,
1223
- self._furu_hash,
1291
+ self.furu_hash,
1224
1292
  extra={"furu_console_only": True},
1225
1293
  )
1226
1294
  return "success", True, result
@@ -1229,7 +1297,7 @@ class Furu[T](ABC):
1229
1297
  logger.error(
1230
1298
  "_create failed %s %s %s",
1231
1299
  self.__class__.__name__,
1232
- self._furu_hash,
1300
+ self.furu_hash,
1233
1301
  directory,
1234
1302
  extra={"furu_file_only": True},
1235
1303
  )
@@ -1238,7 +1306,7 @@ class Furu[T](ABC):
1238
1306
  "attempt failed (%s) %s %s %s",
1239
1307
  stage,
1240
1308
  self.__class__.__name__,
1241
- self._furu_hash,
1309
+ self.furu_hash,
1242
1310
  directory,
1243
1311
  extra={"furu_file_only": True},
1244
1312
  )
@@ -1350,7 +1418,7 @@ def _collect_dependencies(
1350
1418
  recursive: bool,
1351
1419
  ) -> None:
1352
1420
  for dependency in _direct_dependencies(obj):
1353
- digest = dependency._furu_hash
1421
+ digest = dependency.furu_hash
1354
1422
  if digest in seen:
1355
1423
  continue
1356
1424
  seen.add(digest)
@@ -1504,7 +1572,7 @@ def _sorted_dependency_set(
1504
1572
 
1505
1573
  def _dependency_sort_key(value: DependencyScanValue) -> tuple[int, str]:
1506
1574
  if isinstance(value, Furu):
1507
- return (0, value._furu_hash)
1575
+ return (0, cast(str, value.furu_hash))
1508
1576
  return (1, f"{type(value).__name__}:{value!r}")
1509
1577
 
1510
1578
 
furu/core/list.py CHANGED
@@ -25,10 +25,11 @@ class _FuruListMeta(type):
25
25
  if not isinstance(obj, Furu):
26
26
  raise TypeError(f"{obj!r} is not a Furu instance")
27
27
 
28
- digest = obj._furu_hash
28
+ furu_obj = cast(Furu, obj)
29
+ digest = furu_obj.furu_hash
29
30
  if digest not in seen:
30
31
  seen.add(digest)
31
- items.append(cast(_H, obj))
32
+ items.append(cast(_H, furu_obj))
32
33
 
33
34
  for name, value in cls.__dict__.items():
34
35
  if name.startswith("_") or callable(value):