furu 0.0.5__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/core/furu.py CHANGED
@@ -46,6 +46,7 @@ from ..errors import (
46
46
  from ..runtime import current_holder
47
47
  from ..runtime.logging import enter_holder, get_logger, log, write_separator
48
48
  from ..runtime.tracebacks import format_traceback
49
+ from ..runtime.overrides import has_override, lookup_override
49
50
  from ..serialization import FuruSerializer
50
51
  from ..serialization.serializer import JsonValue
51
52
  from ..storage import (
@@ -316,6 +317,8 @@ class Furu[T](ABC):
316
317
  return log(message, level=level)
317
318
 
318
319
  def _exists_quiet(self: Self) -> bool:
320
+ if has_override(self.furu_hash):
321
+ return True
319
322
  directory = self._base_furu_dir()
320
323
  success_dir = self._success_marker_dir(directory)
321
324
  if success_dir is None:
@@ -345,6 +348,9 @@ class Furu[T](ABC):
345
348
  """Check if result exists and is valid."""
346
349
  logger = get_logger()
347
350
  directory = self._base_furu_dir()
351
+ if has_override(self.furu_hash):
352
+ logger.info("exists %s -> true (override)", directory)
353
+ return True
348
354
  success_dir = self._success_marker_dir(directory)
349
355
  if success_dir is None:
350
356
  logger.info("exists %s -> false", directory)
@@ -376,6 +382,9 @@ class Furu[T](ABC):
376
382
  Raises:
377
383
  FuruComputeError: If computation fails with detailed error information
378
384
  """
385
+ has_override_value, override_value = lookup_override(self.furu_hash)
386
+ if has_override_value:
387
+ return cast(T, override_value)
379
388
  from furu.errors import (
380
389
  FuruExecutionError,
381
390
  FuruMissingArtifact,
@@ -0,0 +1,37 @@
1
+ from collections.abc import Generator, Mapping
2
+ from contextlib import contextmanager
3
+ from contextvars import ContextVar
4
+ from typing import Any
5
+
6
+
7
+ OverrideValue = Any
8
+
9
+ _OVERRIDES: ContextVar[dict[str, OverrideValue]] = ContextVar(
10
+ "FURU_RESULT_OVERRIDES",
11
+ default={},
12
+ )
13
+
14
+
15
+ def has_override(furu_hash: str) -> bool:
16
+ return furu_hash in _OVERRIDES.get()
17
+
18
+
19
+ def lookup_override(furu_hash: str) -> tuple[bool, OverrideValue]:
20
+ overrides = _OVERRIDES.get()
21
+ if furu_hash in overrides:
22
+ return True, overrides[furu_hash]
23
+ return False, None
24
+
25
+
26
+ @contextmanager
27
+ def override_furu_hashes(
28
+ overrides: Mapping[str, OverrideValue],
29
+ ) -> Generator[None, None, None]:
30
+ current = _OVERRIDES.get()
31
+ merged = dict(current)
32
+ merged.update(overrides)
33
+ token = _OVERRIDES.set(merged)
34
+ try:
35
+ yield
36
+ finally:
37
+ _OVERRIDES.reset(token)
furu/storage/metadata.py CHANGED
@@ -150,7 +150,8 @@ class MetadataManager:
150
150
  except (subprocess.CalledProcessError, FileNotFoundError) as e:
151
151
  raise RuntimeError(
152
152
  "Git remote 'origin' is required for provenance but was not found. "
153
- "Set FURU_ALLOW_NO_GIT_ORIGIN=1 to allow missing origin."
153
+ "Set FURU_ALLOW_NO_GIT_ORIGIN=1 to allow missing origin, "
154
+ "or set FURU_RECORD_GIT=ignore to disable git metadata."
154
155
  ) from e
155
156
 
156
157
  if ignore_diff:
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: furu
3
- Version: 0.0.5
3
+ Version: 0.0.6
4
4
  Summary: Cacheable, nested pipelines for Python. Define computations as configs; furu handles caching, state tracking, and result reuse across runs.
5
5
  Author: Herman Brunborg
6
6
  Author-email: Herman Brunborg <herman@brunborg.com>
@@ -503,6 +503,89 @@ furu.FURU_CONFIG.record_git = "uncached"
503
503
  furu.FURU_CONFIG.poll_interval = 5.0
504
504
  ```
505
505
 
506
+ ### Testing with pytest
507
+
508
+ Use the built-in pytest fixture to isolate Furu storage in tests (each test gets
509
+ its own temp root, so identical configs in separate tests will not collide):
510
+
511
+ ```python
512
+ # conftest.py
513
+ pytest_plugins = ["furu.testing"]
514
+ ```
515
+
516
+ ```python
517
+ # test_pipeline.py
518
+ import json
519
+ from pathlib import Path
520
+
521
+ import furu
522
+
523
+
524
+ class TrainModel(furu.Furu[Path]):
525
+ lr: float = furu.chz.field(default=1e-3)
526
+
527
+ def _create(self) -> Path:
528
+ path = self.furu_dir / "metrics.json"
529
+ path.write_text(json.dumps({"lr": self.lr}))
530
+ return path
531
+
532
+ def _load(self) -> Path:
533
+ return self.furu_dir / "metrics.json"
534
+
535
+
536
+ def test_create_and_reload(furu_tmp_root):
537
+ obj = TrainModel(lr=1e-3)
538
+ first = obj.get()
539
+ second = obj.get()
540
+ assert first.read_text() == second.read_text()
541
+ assert (furu_tmp_root / "data").exists()
542
+ ```
543
+
544
+ Override specific dependencies when you want to skip deeper chains:
545
+
546
+ ```python
547
+ from furu.testing import override_results
548
+
549
+
550
+ class Normalize(furu.Furu[str]):
551
+ def _create(self) -> str:
552
+ return "normalized"
553
+
554
+ def _load(self) -> str:
555
+ return "normalized"
556
+
557
+
558
+ class TrainModel(furu.Furu[str]):
559
+ normalizer: Normalize = furu.chz.field(default_factory=Normalize)
560
+
561
+ def _create(self) -> str:
562
+ return f"trained:{self.normalizer.get()}"
563
+
564
+ def _load(self) -> str:
565
+ return "trained"
566
+
567
+
568
+ def test_override_dependency(furu_tmp_root):
569
+ normalizer = Normalize()
570
+ model = TrainModel(normalizer=normalizer)
571
+ with override_results({normalizer: "stub"}):
572
+ assert model.get() == "trained:stub"
573
+ ```
574
+
575
+ If you want to override without instantiating the dependency directly, target it
576
+ by dotted path from the root object (chz-style paths, e.g. `deps.0` for lists and
577
+ `deps.key` for mappings):
578
+
579
+ ```python
580
+ from furu.testing import override_results_for
581
+
582
+
583
+ def test_override_by_path(furu_tmp_root):
584
+ model = TrainModel()
585
+ with override_results_for(model, {"normalizer": "stub"}):
586
+ assert model.get() == "trained:stub"
587
+ ```
588
+
506
589
  ### Class-Level Options
507
590
 
508
591
  ```python
@@ -3,7 +3,7 @@ furu/adapters/__init__.py,sha256=onLzEj9hccPK15g8a8va2T19nqQXoxb9rQlJIjKSKnE,69
3
3
  furu/adapters/submitit.py,sha256=FV3XEUSQuS5vIyzkW-Iuqtf8SRL-fsokPG67u7tMF5I,7276
4
4
  furu/config.py,sha256=UGnH8QAKMUgrGMGNkfBgLXideXEpDlozUSsX9iNN8Lw,6844
5
5
  furu/core/__init__.py,sha256=6hH7i6r627c0FZn6eQVsSG7LD4QmTta6iQw0AiPQPTM,156
6
- furu/core/furu.py,sha256=tGUtHVAgSV_oKeW5hlSH5h6OvZG1h4BDBltpjFyJByQ,61375
6
+ furu/core/furu.py,sha256=2KTeMjQjRoDaXNIoKUlYmJ0JWJFG4krom7w6UsUpvDg,61785
7
7
  furu/core/list.py,sha256=QaGSh8NFg1K2WFncM8duOYQ6KLZ6EW2pRLArN_e5Juw,3662
8
8
  furu/dashboard/__init__.py,sha256=ziAordJfkbbXNIM7iA9O7vR2gsCq34AInYiMYOCfWOc,362
9
9
  furu/dashboard/__main__.py,sha256=cNs65IMl4kwZFpxa9xLXmFSy4-M5D1X1ZBfTDxW11vo,144
@@ -32,15 +32,17 @@ furu/migration.py,sha256=EYWULuH8lEVvESthO2qEF95WJTo1Uj6d4L6VU2zmWpw,31350
32
32
  furu/runtime/__init__.py,sha256=fQqE7wUuWunLD73Vm3lss7BFSij3UVxXOKQXBAOS8zw,504
33
33
  furu/runtime/env.py,sha256=lb-LWl-1EM_CP8sy0z3HAY20NXQ-v3QdOgte1i0HYVA,214
34
34
  furu/runtime/logging.py,sha256=Xni1hWyH21bKc6D2owBZzThsj6q8yQOBD9zUrDS4jtI,10760
35
+ furu/runtime/overrides.py,sha256=E3fsZ0ReNOnC9xioHHFlmudm5K2DZLFFcEIvrnA6t2o,871
35
36
  furu/runtime/tracebacks.py,sha256=PGCuOq8QkWSoun791gjUXM8frOP2wWV8IBlqaA4nuGE,1631
36
37
  furu/serialization/__init__.py,sha256=L7oHuIbxdSh7GCY3thMQnDwlt_ERH-TMy0YKEAZLrPs,341
37
38
  furu/serialization/migrations.py,sha256=HD5g8JCBdH3Y0rHJYc4Ug1IXBVcUDxLE7nfiXZnXcUE,7772
38
39
  furu/serialization/serializer.py,sha256=_nfUaAOy_KHegvfXlpPh4rCuvkzalJva75OvDg5nXiI,10114
39
40
  furu/storage/__init__.py,sha256=cLLL-GPpSu9C72Mdk5S6TGu3g-SnBfEuxzfpx5ZJPtw,616
40
- furu/storage/metadata.py,sha256=fJ_0G0vWRl9vNb7IigjXd__aokTok2ZHowmttoXjTsM,9581
41
+ furu/storage/metadata.py,sha256=V16aePXsVo4qIBsKCVtNQvZDpMl0AcCCD2Fr-f-Q75I,9659
41
42
  furu/storage/migration.py,sha256=FNExLdPu1ekKZR2XJkAgags9U8pV2FfkKAECSXkSra8,2585
42
43
  furu/storage/state.py,sha256=kcIfAwdKWT8Q2ElbC5qofQC6noS_k6eNSPkNAdYXoaY,43707
43
- furu-0.0.5.dist-info/WHEEL,sha256=e_m4S054HL0hyR3CpOk-b7Q7fDX6BuFkgL5OjAExXas,80
44
- furu-0.0.5.dist-info/entry_points.txt,sha256=hZkjtFzNlb33Zk-aUfLMRj-XgVDxdT82-JXG9d4bu2E,60
45
- furu-0.0.5.dist-info/METADATA,sha256=1ugdjmF6ECLzzO21v123_GdyMmSb6tni6avp6YJRbG4,17101
46
- furu-0.0.5.dist-info/RECORD,,
44
+ furu/testing.py,sha256=lS-30bOu_RI1l4OV4lGWNpx5HOAwX2JYHHqakOkz8so,7804
45
+ furu-0.0.6.dist-info/WHEEL,sha256=fAguSjoiATBe7TNBkJwOjyL1Tt4wwiaQGtNtjRPNMQA,80
46
+ furu-0.0.6.dist-info/entry_points.txt,sha256=hZkjtFzNlb33Zk-aUfLMRj-XgVDxdT82-JXG9d4bu2E,60
47
+ furu-0.0.6.dist-info/METADATA,sha256=Z22LYfC7htpUg9VleX9vYA-GlruCYyycpe8VTpm5aYQ,19117
48
+ furu-0.0.6.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.9.27
2
+ Generator: uv 0.9.28
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any