furu 0.0.1__tar.gz → 0.0.3__tar.gz

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.
Files changed (115) hide show
  1. furu-0.0.1/README.md → furu-0.0.3/PKG-INFO +60 -14
  2. furu-0.0.1/PKG-INFO → furu-0.0.3/README.md +41 -32
  3. {furu-0.0.1 → furu-0.0.3}/pyproject.toml +3 -9
  4. {furu-0.0.1 → furu-0.0.3}/src/furu/__init__.py +3 -1
  5. furu-0.0.3/src/furu/config.py +178 -0
  6. furu-0.0.3/src/furu/core/__init__.py +4 -0
  7. {furu-0.0.1 → furu-0.0.3}/src/furu/core/furu.py +438 -75
  8. furu-0.0.1/src/furu/dashboard/frontend/dist/assets/index-CbdDfSOZ.css → furu-0.0.3/src/furu/dashboard/frontend/dist/assets/index-BXAIKNNr.css +1 -1
  9. furu-0.0.1/src/furu/dashboard/frontend/dist/assets/index-DDv_TYB_.js → furu-0.0.3/src/furu/dashboard/frontend/dist/assets/index-DS3FsqcY.js +3 -3
  10. {furu-0.0.1/dashboard-frontend → furu-0.0.3/src/furu/dashboard/frontend/dist}/index.html +2 -1
  11. {furu-0.0.1 → furu-0.0.3}/src/furu/errors.py +47 -5
  12. {furu-0.0.1 → furu-0.0.3}/src/furu/migration.py +8 -4
  13. {furu-0.0.1 → furu-0.0.3}/src/furu/serialization/serializer.py +40 -2
  14. {furu-0.0.1 → furu-0.0.3}/src/furu/storage/metadata.py +17 -5
  15. {furu-0.0.1 → furu-0.0.3}/src/furu/storage/state.py +115 -3
  16. furu-0.0.1/.github/workflows/ci.yml +0 -33
  17. furu-0.0.1/.github/workflows/release.yml +0 -99
  18. furu-0.0.1/.gitignore +0 -231
  19. furu-0.0.1/.vscode/settings.json +0 -3
  20. furu-0.0.1/AGENTS.md +0 -265
  21. furu-0.0.1/CHANGELOG.md +0 -5
  22. furu-0.0.1/Makefile +0 -156
  23. furu-0.0.1/TODO.md +0 -131
  24. furu-0.0.1/dashboard-frontend/bun.lock +0 -1234
  25. furu-0.0.1/dashboard-frontend/orval.config.ts +0 -34
  26. furu-0.0.1/dashboard-frontend/package-lock.json +0 -7628
  27. furu-0.0.1/dashboard-frontend/package.json +0 -40
  28. furu-0.0.1/dashboard-frontend/postcss.config.js +0 -9
  29. furu-0.0.1/dashboard-frontend/src/api.test.ts +0 -314
  30. furu-0.0.1/dashboard-frontend/src/components/DAGVisualization.tsx +0 -392
  31. furu-0.0.1/dashboard-frontend/src/components/EmptyState.tsx +0 -27
  32. furu-0.0.1/dashboard-frontend/src/components/StatsCard.tsx +0 -63
  33. furu-0.0.1/dashboard-frontend/src/components/StatusBadge.tsx +0 -40
  34. furu-0.0.1/dashboard-frontend/src/components/ui/badge.tsx +0 -35
  35. furu-0.0.1/dashboard-frontend/src/components/ui/button.tsx +0 -56
  36. furu-0.0.1/dashboard-frontend/src/components/ui/card.tsx +0 -70
  37. furu-0.0.1/dashboard-frontend/src/components/ui/input.tsx +0 -20
  38. furu-0.0.1/dashboard-frontend/src/components/ui/table.tsx +0 -96
  39. furu-0.0.1/dashboard-frontend/src/index.css +0 -78
  40. furu-0.0.1/dashboard-frontend/src/lib/api-client.ts +0 -26
  41. furu-0.0.1/dashboard-frontend/src/lib/utils.ts +0 -6
  42. furu-0.0.1/dashboard-frontend/src/main.tsx +0 -52
  43. furu-0.0.1/dashboard-frontend/src/routes/__root.tsx +0 -68
  44. furu-0.0.1/dashboard-frontend/src/routes/dag.tsx +0 -113
  45. furu-0.0.1/dashboard-frontend/src/routes/experiments.tsx +0 -444
  46. furu-0.0.1/dashboard-frontend/src/routes/experiments_.$namespace.$furu_hash.tsx +0 -804
  47. furu-0.0.1/dashboard-frontend/src/routes/index.tsx +0 -195
  48. furu-0.0.1/dashboard-frontend/tailwind.config.js +0 -41
  49. furu-0.0.1/dashboard-frontend/tsconfig.json +0 -24
  50. furu-0.0.1/dashboard-frontend/tsconfig.node.json +0 -14
  51. furu-0.0.1/dashboard-frontend/vite.config.ts +0 -21
  52. furu-0.0.1/e2e/bun.lock +0 -21
  53. furu-0.0.1/e2e/generate_data.py +0 -347
  54. furu-0.0.1/e2e/global-setup.ts +0 -37
  55. furu-0.0.1/e2e/global-teardown.ts +0 -14
  56. furu-0.0.1/e2e/package.json +0 -15
  57. furu-0.0.1/e2e/playwright.config.ts +0 -66
  58. furu-0.0.1/e2e/tests/api.spec.ts +0 -182
  59. furu-0.0.1/e2e/tests/experiments.spec.ts +0 -163
  60. furu-0.0.1/e2e/tests/home.spec.ts +0 -36
  61. furu-0.0.1/e2e/tests/navigation.spec.ts +0 -435
  62. furu-0.0.1/examples/README.md +0 -21
  63. furu-0.0.1/examples/my_project/__init__.py +0 -3
  64. furu-0.0.1/examples/my_project/pipelines.py +0 -65
  65. furu-0.0.1/examples/run_logging.py +0 -26
  66. furu-0.0.1/examples/run_nested.py +0 -26
  67. furu-0.0.1/examples/run_train.py +0 -26
  68. furu-0.0.1/src/furu/config.py +0 -98
  69. furu-0.0.1/src/furu/core/__init__.py +0 -4
  70. furu-0.0.1/src/furu/dashboard/frontend/dist/favicon.svg +0 -10
  71. furu-0.0.1/src/furu/dashboard/frontend/dist/index.html +0 -22
  72. furu-0.0.1/tasks/migration.md +0 -360
  73. furu-0.0.1/tests/conftest.py +0 -25
  74. furu-0.0.1/tests/dashboard/__init__.py +0 -4
  75. furu-0.0.1/tests/dashboard/conftest.py +0 -448
  76. furu-0.0.1/tests/dashboard/pipelines.py +0 -192
  77. furu-0.0.1/tests/dashboard/test_api.py +0 -865
  78. furu-0.0.1/tests/dashboard/test_scanner.py +0 -786
  79. furu-0.0.1/tests/test_config.py +0 -10
  80. furu-0.0.1/tests/test_errors.py +0 -83
  81. furu-0.0.1/tests/test_furu_core.py +0 -158
  82. furu-0.0.1/tests/test_furu_inheritance.py +0 -44
  83. furu-0.0.1/tests/test_furu_inheritance_polymorphic.py +0 -118
  84. furu-0.0.1/tests/test_furu_list.py +0 -30
  85. furu-0.0.1/tests/test_furu_typing.py +0 -208
  86. furu-0.0.1/tests/test_logger.py +0 -141
  87. furu-0.0.1/tests/test_metadata.py +0 -46
  88. furu-0.0.1/tests/test_migrations.py +0 -848
  89. furu-0.0.1/tests/test_raw_dir.py +0 -15
  90. furu-0.0.1/tests/test_serializer.py +0 -71
  91. furu-0.0.1/tests/test_state_manager.py +0 -464
  92. furu-0.0.1/tests/test_submitit_path.py +0 -71
  93. furu-0.0.1/tests/test_tracebacks.py +0 -5
  94. furu-0.0.1/uv.lock +0 -745
  95. furu-0.0.1/version.txt +0 -1
  96. {furu-0.0.1 → furu-0.0.3}/src/furu/adapters/__init__.py +0 -0
  97. {furu-0.0.1 → furu-0.0.3}/src/furu/adapters/submitit.py +0 -0
  98. {furu-0.0.1 → furu-0.0.3}/src/furu/core/list.py +0 -0
  99. {furu-0.0.1 → furu-0.0.3}/src/furu/dashboard/__init__.py +0 -0
  100. {furu-0.0.1 → furu-0.0.3}/src/furu/dashboard/__main__.py +0 -0
  101. {furu-0.0.1 → furu-0.0.3}/src/furu/dashboard/api/__init__.py +0 -0
  102. {furu-0.0.1 → furu-0.0.3}/src/furu/dashboard/api/models.py +0 -0
  103. {furu-0.0.1 → furu-0.0.3}/src/furu/dashboard/api/routes.py +0 -0
  104. {furu-0.0.1/dashboard-frontend/public → furu-0.0.3/src/furu/dashboard/frontend/dist}/favicon.svg +0 -0
  105. {furu-0.0.1 → furu-0.0.3}/src/furu/dashboard/main.py +0 -0
  106. {furu-0.0.1 → furu-0.0.3}/src/furu/dashboard/scanner.py +0 -0
  107. {furu-0.0.1 → furu-0.0.3}/src/furu/migrate.py +0 -0
  108. {furu-0.0.1 → furu-0.0.3}/src/furu/runtime/__init__.py +0 -0
  109. {furu-0.0.1 → furu-0.0.3}/src/furu/runtime/env.py +0 -0
  110. {furu-0.0.1 → furu-0.0.3}/src/furu/runtime/logging.py +0 -0
  111. {furu-0.0.1 → furu-0.0.3}/src/furu/runtime/tracebacks.py +0 -0
  112. {furu-0.0.1 → furu-0.0.3}/src/furu/serialization/__init__.py +0 -0
  113. {furu-0.0.1 → furu-0.0.3}/src/furu/serialization/migrations.py +0 -0
  114. {furu-0.0.1 → furu-0.0.3}/src/furu/storage/__init__.py +0 -0
  115. {furu-0.0.1 → furu-0.0.3}/src/furu/storage/migration.py +0 -0
@@ -1,3 +1,22 @@
1
+ Metadata-Version: 2.3
2
+ Name: furu
3
+ Version: 0.0.3
4
+ Summary: Cacheable, nested pipelines for Python. Define computations as configs; furu handles caching, state tracking, and result reuse across runs.
5
+ Author: Herman Brunborg
6
+ Author-email: Herman Brunborg <herman@brunborg.com>
7
+ Requires-Dist: chz>=0.4.0
8
+ Requires-Dist: cloudpickle>=3.1.1
9
+ Requires-Dist: pydantic>=2.12.5
10
+ Requires-Dist: python-dotenv>=1.0.0
11
+ Requires-Dist: rich>=14.2.0
12
+ Requires-Dist: submitit>=1.5.3
13
+ Requires-Dist: fastapi>=0.109.0 ; extra == 'dashboard'
14
+ Requires-Dist: uvicorn[standard]>=0.27.0 ; extra == 'dashboard'
15
+ Requires-Dist: typer>=0.9.0 ; extra == 'dashboard'
16
+ Requires-Python: >=3.12
17
+ Provides-Extra: dashboard
18
+ Description-Content-Type: text/markdown
19
+
1
20
  # furu
2
21
 
3
22
  > **Note:** `v0.0.x` is alpha and may include breaking changes.
@@ -114,20 +133,25 @@ class TrainTextModel(furu.Furu[str]):
114
133
 
115
134
  ### Storage Structure
116
135
 
136
+ Furu uses two roots: `FURU_PATH` for `data/` + `raw/`, and
137
+ `FURU_VERSION_CONTROLLED_PATH` for `artifacts/`. Defaults:
138
+
139
+ ```
140
+ FURU_PATH=<project>/furu-data
141
+ FURU_VERSION_CONTROLLED_PATH=<project>/furu-data/artifacts
142
+ ```
143
+
144
+ `<project>` is the nearest directory containing `pyproject.toml` (falling back to
145
+ the git root). This means you can move `FURU_PATH` without relocating artifacts.
146
+
117
147
  ```
118
148
  $FURU_PATH/
119
- ├── data/ # Default storage (version_controlled=False)
120
- │ └── <module>/<Class>/
121
- └── <hash>/
122
- │ ├── .furu/
123
- │ │ ├── metadata.json # Config, git info, environment
124
- │ │ ├── state.json # Status and timestamps
125
- │ │ ├── furu.log # Captured logs
126
- │ │ └── SUCCESS.json # Marker file
127
- │ └── <your outputs> # Files from _create()
128
- ├── git/ # For version_controlled=True
129
- │ └── <same structure>
130
- └── raw/ # Shared directory for large files
149
+ ├── data/ # version_controlled=False
150
+ │ └── <module>/<Class>/<hash>/
151
+ └── raw/
152
+
153
+ $FURU_VERSION_CONTROLLED_PATH/ # version_controlled=True
154
+ └── <module>/<Class>/<hash>/
131
155
  ```
132
156
 
133
157
  ## Features
@@ -241,10 +265,17 @@ For artifacts that should be stored separately (e.g., checked into git):
241
265
 
242
266
  ```python
243
267
  class VersionedConfig(furu.Furu[dict], version_controlled=True):
244
- # Stored under $FURU_PATH/git/ instead of $FURU_PATH/data/
268
+ # Stored under $FURU_VERSION_CONTROLLED_PATH
269
+ # Default: <project>/furu-data/artifacts
245
270
  ...
246
271
  ```
247
272
 
273
+ `<project>` is the nearest directory containing `pyproject.toml`, or the git root
274
+ if `pyproject.toml` is missing.
275
+
276
+ It is typical to keep `furu-data/data/` and `furu-data/raw/` in `.gitignore` while
277
+ committing `furu-data/artifacts/`.
278
+
248
279
  ## Logging
249
280
 
250
281
  Furu installs stdlib `logging` handlers that capture logs to per-artifact files.
@@ -305,6 +336,17 @@ except FuruLockNotAcquired:
305
336
  print("Could not acquire lock")
306
337
  ```
307
338
 
339
+ By default, failed artifacts are retried on the next `load_or_create()` call. Set
340
+ `FURU_RETRY_FAILED=0` or pass `retry_failed=False` to keep failures sticky.
341
+
342
+ `FURU_MAX_WAIT_SECS` overrides the per-class `_max_wait_time_sec` (default 600s)
343
+ timeout used when waiting for compute locks before raising `FuruWaitTimeout`.
344
+
345
+ Failures during metadata collection or signal handler setup (before `_create()`
346
+ runs) raise `FuruComputeError` with the original exception attached. These
347
+ failures still mark the attempt as failed and record details in `state.json`
348
+ and `furu.log`.
349
+
308
350
  ## Submitit Integration
309
351
 
310
352
  Run computations on SLURM clusters via [submitit](https://github.com/facebookincubator/submitit):
@@ -379,10 +421,14 @@ The `/api/experiments` endpoint supports:
379
421
 
380
422
  | Variable | Default | Description |
381
423
  |----------|---------|-------------|
382
- | `FURU_PATH` | `./data-furu/` | Base storage directory |
424
+ | `FURU_PATH` | `<project>/furu-data` | Base storage directory for non-versioned artifacts |
425
+ | `FURU_VERSION_CONTROLLED_PATH` | `<project>/furu-data/artifacts` | Override version-controlled storage root |
383
426
  | `FURU_LOG_LEVEL` | `INFO` | Console verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
384
427
  | `FURU_IGNORE_DIFF` | `false` | Skip embedding git diff in metadata |
428
+ | `FURU_ALWAYS_RERUN` | `""` | Comma-separated class qualnames to always rerun (use `ALL` to bypass cache globally; cannot combine with other entries; entries must be importable) |
429
+ | `FURU_RETRY_FAILED` | `true` | Retry failed artifacts by default (set to `0` to keep failures sticky) |
385
430
  | `FURU_POLL_INTERVAL_SECS` | `10` | Polling interval for queued/running jobs |
431
+ | `FURU_MAX_WAIT_SECS` | unset | Override wait timeout (falls back to `_max_wait_time_sec`, default 600s) |
386
432
  | `FURU_WAIT_LOG_EVERY_SECS` | `10` | Interval between "waiting" log messages |
387
433
  | `FURU_STALE_AFTER_SECS` | `1800` | Consider running jobs stale after this duration |
388
434
  | `FURU_LEASE_SECS` | `120` | Compute lock lease duration |
@@ -1,21 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: furu
3
- Version: 0.0.1
4
- Summary: Cacheable, nested pipelines for Python. Define computations as configs; furu handles caching, state tracking, and result reuse across runs.
5
- Author-email: Herman Brunborg <herman@brunborg.com>
6
- Requires-Python: >=3.12
7
- Requires-Dist: chz>=0.4.0
8
- Requires-Dist: cloudpickle>=3.1.1
9
- Requires-Dist: pydantic>=2.12.5
10
- Requires-Dist: python-dotenv>=1.0.0
11
- Requires-Dist: rich>=14.2.0
12
- Requires-Dist: submitit>=1.5.3
13
- Provides-Extra: dashboard
14
- Requires-Dist: fastapi>=0.109.0; extra == 'dashboard'
15
- Requires-Dist: typer>=0.9.0; extra == 'dashboard'
16
- Requires-Dist: uvicorn[standard]>=0.27.0; extra == 'dashboard'
17
- Description-Content-Type: text/markdown
18
-
19
1
  # furu
20
2
 
21
3
  > **Note:** `v0.0.x` is alpha and may include breaking changes.
@@ -132,20 +114,25 @@ class TrainTextModel(furu.Furu[str]):
132
114
 
133
115
  ### Storage Structure
134
116
 
117
+ Furu uses two roots: `FURU_PATH` for `data/` + `raw/`, and
118
+ `FURU_VERSION_CONTROLLED_PATH` for `artifacts/`. Defaults:
119
+
120
+ ```
121
+ FURU_PATH=<project>/furu-data
122
+ FURU_VERSION_CONTROLLED_PATH=<project>/furu-data/artifacts
123
+ ```
124
+
125
+ `<project>` is the nearest directory containing `pyproject.toml` (falling back to
126
+ the git root). This means you can move `FURU_PATH` without relocating artifacts.
127
+
135
128
  ```
136
129
  $FURU_PATH/
137
- ├── data/ # Default storage (version_controlled=False)
138
- │ └── <module>/<Class>/
139
- └── <hash>/
140
- │ ├── .furu/
141
- │ │ ├── metadata.json # Config, git info, environment
142
- │ │ ├── state.json # Status and timestamps
143
- │ │ ├── furu.log # Captured logs
144
- │ │ └── SUCCESS.json # Marker file
145
- │ └── <your outputs> # Files from _create()
146
- ├── git/ # For version_controlled=True
147
- │ └── <same structure>
148
- └── raw/ # Shared directory for large files
130
+ ├── data/ # version_controlled=False
131
+ │ └── <module>/<Class>/<hash>/
132
+ └── raw/
133
+
134
+ $FURU_VERSION_CONTROLLED_PATH/ # version_controlled=True
135
+ └── <module>/<Class>/<hash>/
149
136
  ```
150
137
 
151
138
  ## Features
@@ -259,10 +246,17 @@ For artifacts that should be stored separately (e.g., checked into git):
259
246
 
260
247
  ```python
261
248
  class VersionedConfig(furu.Furu[dict], version_controlled=True):
262
- # Stored under $FURU_PATH/git/ instead of $FURU_PATH/data/
249
+ # Stored under $FURU_VERSION_CONTROLLED_PATH
250
+ # Default: <project>/furu-data/artifacts
263
251
  ...
264
252
  ```
265
253
 
254
+ `<project>` is the nearest directory containing `pyproject.toml`, or the git root
255
+ if `pyproject.toml` is missing.
256
+
257
+ It is typical to keep `furu-data/data/` and `furu-data/raw/` in `.gitignore` while
258
+ committing `furu-data/artifacts/`.
259
+
266
260
  ## Logging
267
261
 
268
262
  Furu installs stdlib `logging` handlers that capture logs to per-artifact files.
@@ -323,6 +317,17 @@ except FuruLockNotAcquired:
323
317
  print("Could not acquire lock")
324
318
  ```
325
319
 
320
+ By default, failed artifacts are retried on the next `load_or_create()` call. Set
321
+ `FURU_RETRY_FAILED=0` or pass `retry_failed=False` to keep failures sticky.
322
+
323
+ `FURU_MAX_WAIT_SECS` overrides the per-class `_max_wait_time_sec` (default 600s)
324
+ timeout used when waiting for compute locks before raising `FuruWaitTimeout`.
325
+
326
+ Failures during metadata collection or signal handler setup (before `_create()`
327
+ runs) raise `FuruComputeError` with the original exception attached. These
328
+ failures still mark the attempt as failed and record details in `state.json`
329
+ and `furu.log`.
330
+
326
331
  ## Submitit Integration
327
332
 
328
333
  Run computations on SLURM clusters via [submitit](https://github.com/facebookincubator/submitit):
@@ -397,10 +402,14 @@ The `/api/experiments` endpoint supports:
397
402
 
398
403
  | Variable | Default | Description |
399
404
  |----------|---------|-------------|
400
- | `FURU_PATH` | `./data-furu/` | Base storage directory |
405
+ | `FURU_PATH` | `<project>/furu-data` | Base storage directory for non-versioned artifacts |
406
+ | `FURU_VERSION_CONTROLLED_PATH` | `<project>/furu-data/artifacts` | Override version-controlled storage root |
401
407
  | `FURU_LOG_LEVEL` | `INFO` | Console verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
402
408
  | `FURU_IGNORE_DIFF` | `false` | Skip embedding git diff in metadata |
409
+ | `FURU_ALWAYS_RERUN` | `""` | Comma-separated class qualnames to always rerun (use `ALL` to bypass cache globally; cannot combine with other entries; entries must be importable) |
410
+ | `FURU_RETRY_FAILED` | `true` | Retry failed artifacts by default (set to `0` to keep failures sticky) |
403
411
  | `FURU_POLL_INTERVAL_SECS` | `10` | Polling interval for queued/running jobs |
412
+ | `FURU_MAX_WAIT_SECS` | unset | Override wait timeout (falls back to `_max_wait_time_sec`, default 600s) |
404
413
  | `FURU_WAIT_LOG_EVERY_SECS` | `10` | Interval between "waiting" log messages |
405
414
  | `FURU_STALE_AFTER_SECS` | `1800` | Consider running jobs stale after this duration |
406
415
  | `FURU_LEASE_SECS` | `120` | Compute lock lease duration |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "furu"
3
- version = "0.0.1"
3
+ version = "0.0.3"
4
4
  description = "Cacheable, nested pipelines for Python. Define computations as configs; furu handles caching, state tracking, and result reuse across runs."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -42,14 +42,8 @@ test = [
42
42
  ]
43
43
 
44
44
  [build-system]
45
- requires = ["hatchling"]
46
- build-backend = "hatchling.build"
47
-
48
- [tool.hatch.build]
49
- artifacts = ["src/furu/dashboard/frontend/dist/**/*"]
50
-
51
- [tool.hatch.build.targets.wheel]
52
- packages = ["src/furu"]
45
+ requires = ["uv_build>=0.9.26,<0.10.0"]
46
+ build-backend = "uv_build"
53
47
 
54
48
  [tool.pytest.ini_options]
55
49
  testpaths = ["tests"]
@@ -13,7 +13,7 @@ __version__ = version("furu")
13
13
 
14
14
  from .config import FURU_CONFIG, FuruConfig, get_furu_root, set_furu_root
15
15
  from .adapters import SubmititAdapter
16
- from .core import Furu, FuruList
16
+ from .core import DependencyChzSpec, DependencySpec, Furu, FuruList
17
17
  from .errors import (
18
18
  FuruComputeError,
19
19
  FuruError,
@@ -56,6 +56,8 @@ __all__ = [
56
56
  "FuruMigrationRequired",
57
57
  "FuruSerializer",
58
58
  "FuruWaitTimeout",
59
+ "DependencyChzSpec",
60
+ "DependencySpec",
59
61
  "MISSING",
60
62
  "migrate",
61
63
  "NamespacePair",
@@ -0,0 +1,178 @@
1
+ import os
2
+ from importlib import import_module
3
+ from pathlib import Path
4
+
5
+
6
+ class FuruConfig:
7
+ """Central configuration for Furu behavior."""
8
+
9
+ DEFAULT_ROOT_DIR = Path("furu-data")
10
+ VERSION_CONTROLLED_SUBDIR = DEFAULT_ROOT_DIR / "artifacts"
11
+
12
+ def __init__(self):
13
+ def _get_base_root() -> Path:
14
+ env = os.getenv("FURU_PATH")
15
+ if env:
16
+ return Path(env).expanduser().resolve()
17
+ project_root = self._find_project_root(fallback_to_cwd=True)
18
+ return (project_root / self.DEFAULT_ROOT_DIR).resolve()
19
+
20
+ self.base_root = _get_base_root()
21
+ self.version_controlled_root_override = self._get_version_controlled_override()
22
+ self.poll_interval = float(os.getenv("FURU_POLL_INTERVAL_SECS", "10"))
23
+ self.wait_log_every_sec = float(os.getenv("FURU_WAIT_LOG_EVERY_SECS", "10"))
24
+ self.stale_timeout = float(os.getenv("FURU_STALE_AFTER_SECS", str(30 * 60)))
25
+ max_wait_env = os.getenv("FURU_MAX_WAIT_SECS")
26
+ self.max_wait_time_sec = float(max_wait_env) if max_wait_env else None
27
+ self.lease_duration_sec = float(os.getenv("FURU_LEASE_SECS", "120"))
28
+ hb = os.getenv("FURU_HEARTBEAT_SECS")
29
+ self.heartbeat_interval_sec = (
30
+ float(hb) if hb is not None else max(1.0, self.lease_duration_sec / 3.0)
31
+ )
32
+ self.max_requeues = int(os.getenv("FURU_PREEMPT_MAX", "5"))
33
+ self.retry_failed = os.getenv("FURU_RETRY_FAILED", "1").lower() in {
34
+ "1",
35
+ "true",
36
+ "yes",
37
+ }
38
+ self.ignore_git_diff = os.getenv("FURU_IGNORE_DIFF", "0").lower() in {
39
+ "1",
40
+ "true",
41
+ "yes",
42
+ }
43
+ self.require_git = os.getenv("FURU_REQUIRE_GIT", "1").lower() in {
44
+ "1",
45
+ "true",
46
+ "yes",
47
+ }
48
+ self.require_git_remote = os.getenv("FURU_REQUIRE_GIT_REMOTE", "1").lower() in {
49
+ "1",
50
+ "true",
51
+ "yes",
52
+ }
53
+ always_rerun_items = {
54
+ item.strip()
55
+ for item in os.getenv("FURU_ALWAYS_RERUN", "").split(",")
56
+ if item.strip()
57
+ }
58
+ all_entries = {item for item in always_rerun_items if item.lower() == "all"}
59
+ if all_entries and len(always_rerun_items) > len(all_entries):
60
+ raise ValueError(
61
+ "FURU_ALWAYS_RERUN cannot combine 'ALL' with specific entries"
62
+ )
63
+ self.always_rerun_all = bool(all_entries)
64
+ if self.always_rerun_all:
65
+ always_rerun_items = {
66
+ item for item in always_rerun_items if item.lower() != "all"
67
+ }
68
+ self._require_namespaces_exist(always_rerun_items)
69
+ self.always_rerun = always_rerun_items
70
+ self.cancelled_is_preempted = os.getenv(
71
+ "FURU_CANCELLED_IS_PREEMPTED", "false"
72
+ ).lower() in {"1", "true", "yes"}
73
+
74
+ # Parse FURU_CACHE_METADATA: "never", "forever", or duration like "5m", "1h"
75
+ # Default: "5m" (5 minutes) - balances performance with freshness
76
+ self.cache_metadata_ttl_sec: float | None = self._parse_cache_duration(
77
+ os.getenv("FURU_CACHE_METADATA", "5m")
78
+ )
79
+
80
+ @staticmethod
81
+ def _parse_cache_duration(value: str) -> float | None:
82
+ """Parse cache duration string into seconds. Returns None for 'never', float('inf') for 'forever'."""
83
+ value = value.strip().lower()
84
+ if value in {"never", "0", "false", "no"}:
85
+ return None # No caching
86
+ if value in {"forever", "inf", "true", "yes", "1"}:
87
+ return float("inf") # Cache forever
88
+
89
+ # Parse duration like "5m", "1h", "30s"
90
+ import re
91
+
92
+ match = re.match(r"^(\d+(?:\.\d+)?)\s*([smh]?)$", value)
93
+ if not match:
94
+ raise ValueError(
95
+ f"Invalid FURU_CACHE_METADATA value: {value!r}. "
96
+ "Use 'never', 'forever', or duration like '5m', '1h', '30s'"
97
+ )
98
+
99
+ num = float(match.group(1))
100
+ unit = match.group(2) or "s"
101
+ multipliers = {"s": 1, "m": 60, "h": 3600}
102
+ return num * multipliers[unit]
103
+
104
+ def get_root(self, version_controlled: bool = False) -> Path:
105
+ """Get root directory for storage (version_controlled uses its own root)."""
106
+ if version_controlled:
107
+ if self.version_controlled_root_override is not None:
108
+ return self.version_controlled_root_override
109
+ return self._resolve_version_controlled_root()
110
+ return self.base_root / "data"
111
+
112
+ @classmethod
113
+ def _get_version_controlled_override(cls) -> Path | None:
114
+ env = os.getenv("FURU_VERSION_CONTROLLED_PATH")
115
+ if env:
116
+ return Path(env).expanduser().resolve()
117
+ return None
118
+
119
+ @classmethod
120
+ def _resolve_version_controlled_root(cls) -> Path:
121
+ project_root = cls._find_project_root()
122
+ return (project_root / cls.VERSION_CONTROLLED_SUBDIR).resolve()
123
+
124
+ @staticmethod
125
+ def _find_project_root(
126
+ start: Path | None = None, *, fallback_to_cwd: bool = False
127
+ ) -> Path:
128
+ base = (start or Path.cwd()).resolve()
129
+ git_root: Path | None = None
130
+ for path in (base, *base.parents):
131
+ if (path / "pyproject.toml").is_file():
132
+ return path
133
+ if git_root is None and (path / ".git").exists():
134
+ git_root = path
135
+ if git_root is not None:
136
+ return git_root
137
+ if fallback_to_cwd:
138
+ return base
139
+ raise ValueError(
140
+ "Cannot locate pyproject.toml or .git to determine version-controlled root. "
141
+ "Set FURU_VERSION_CONTROLLED_PATH to override."
142
+ )
143
+
144
+ @staticmethod
145
+ def _require_namespaces_exist(namespaces: set[str]) -> None:
146
+ if not namespaces:
147
+ return
148
+ missing_sentinel = object()
149
+ for namespace in namespaces:
150
+ module_name, _, qualname = namespace.rpartition(".")
151
+ if not module_name or not qualname:
152
+ raise ValueError(
153
+ "FURU_ALWAYS_RERUN entries must be 'module.QualifiedName', "
154
+ f"got {namespace!r}"
155
+ )
156
+ target: object = import_module(module_name)
157
+ for attr in qualname.split("."):
158
+ value = getattr(target, attr, missing_sentinel)
159
+ if value is missing_sentinel:
160
+ raise ValueError(
161
+ f"FURU_ALWAYS_RERUN entry does not exist: {namespace!r}"
162
+ )
163
+ target = value
164
+
165
+ @property
166
+ def raw_dir(self) -> Path:
167
+ return self.base_root / "raw"
168
+
169
+
170
+ FURU_CONFIG = FuruConfig()
171
+
172
+
173
+ def get_furu_root(*, version_controlled: bool = False) -> Path:
174
+ return FURU_CONFIG.get_root(version_controlled=version_controlled)
175
+
176
+
177
+ def set_furu_root(path: Path) -> None:
178
+ FURU_CONFIG.base_root = path.resolve()
@@ -0,0 +1,4 @@
1
+ from .furu import DependencyChzSpec, DependencySpec, Furu
2
+ from .list import FuruList
3
+
4
+ __all__ = ["DependencyChzSpec", "DependencySpec", "Furu", "FuruList"]