furu 0.0.1__py3-none-any.whl → 0.0.2__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 CHANGED
@@ -1,18 +1,24 @@
1
1
  import os
2
+ from importlib import import_module
2
3
  from pathlib import Path
3
4
 
4
5
 
5
6
  class FuruConfig:
6
7
  """Central configuration for Furu behavior."""
7
8
 
9
+ DEFAULT_ROOT_DIR = Path("furu-data")
10
+ VERSION_CONTROLLED_SUBDIR = DEFAULT_ROOT_DIR / "artifacts"
11
+
8
12
  def __init__(self):
9
13
  def _get_base_root() -> Path:
10
14
  env = os.getenv("FURU_PATH")
11
15
  if env:
12
16
  return Path(env).expanduser().resolve()
13
- return Path("data-furu").resolve()
17
+ project_root = self._find_project_root(fallback_to_cwd=True)
18
+ return (project_root / self.DEFAULT_ROOT_DIR).resolve()
14
19
 
15
20
  self.base_root = _get_base_root()
21
+ self.version_controlled_root_override = self._get_version_controlled_override()
16
22
  self.poll_interval = float(os.getenv("FURU_POLL_INTERVAL_SECS", "10"))
17
23
  self.wait_log_every_sec = float(os.getenv("FURU_WAIT_LOG_EVERY_SECS", "10"))
18
24
  self.stale_timeout = float(os.getenv("FURU_STALE_AFTER_SECS", str(30 * 60)))
@@ -37,11 +43,23 @@ class FuruConfig:
37
43
  "true",
38
44
  "yes",
39
45
  }
40
- self.force_recompute = {
46
+ always_rerun_items = {
41
47
  item.strip()
42
- for item in os.getenv("FURU_FORCE_RECOMPUTE", "").split(",")
48
+ for item in os.getenv("FURU_ALWAYS_RERUN", "").split(",")
43
49
  if item.strip()
44
50
  }
51
+ all_entries = {item for item in always_rerun_items if item.lower() == "all"}
52
+ if all_entries and len(always_rerun_items) > len(all_entries):
53
+ raise ValueError(
54
+ "FURU_ALWAYS_RERUN cannot combine 'ALL' with specific entries"
55
+ )
56
+ self.always_rerun_all = bool(all_entries)
57
+ if self.always_rerun_all:
58
+ always_rerun_items = {
59
+ item for item in always_rerun_items if item.lower() != "all"
60
+ }
61
+ self._require_namespaces_exist(always_rerun_items)
62
+ self.always_rerun = always_rerun_items
45
63
  self.cancelled_is_preempted = os.getenv(
46
64
  "FURU_CANCELLED_IS_PREEMPTED", "false"
47
65
  ).lower() in {"1", "true", "yes"}
@@ -77,11 +95,67 @@ class FuruConfig:
77
95
  return num * multipliers[unit]
78
96
 
79
97
  def get_root(self, version_controlled: bool = False) -> Path:
80
- """Get root directory for storage (version_controlled determines subdirectory)."""
98
+ """Get root directory for storage (version_controlled uses its own root)."""
81
99
  if version_controlled:
82
- return self.base_root / "git"
100
+ if self.version_controlled_root_override is not None:
101
+ return self.version_controlled_root_override
102
+ return self._resolve_version_controlled_root()
83
103
  return self.base_root / "data"
84
104
 
105
+ @classmethod
106
+ def _get_version_controlled_override(cls) -> Path | None:
107
+ env = os.getenv("FURU_VERSION_CONTROLLED_PATH")
108
+ if env:
109
+ return Path(env).expanduser().resolve()
110
+ return None
111
+
112
+ @classmethod
113
+ def _resolve_version_controlled_root(cls) -> Path:
114
+ project_root = cls._find_project_root()
115
+ return (project_root / cls.VERSION_CONTROLLED_SUBDIR).resolve()
116
+
117
+ @staticmethod
118
+ def _find_project_root(
119
+ start: Path | None = None, *, fallback_to_cwd: bool = False
120
+ ) -> Path:
121
+ base = (start or Path.cwd()).resolve()
122
+ git_root: Path | None = None
123
+ for path in (base, *base.parents):
124
+ if (path / "pyproject.toml").is_file():
125
+ return path
126
+ if git_root is None and (path / ".git").exists():
127
+ git_root = path
128
+ if git_root is not None:
129
+ return git_root
130
+ if fallback_to_cwd:
131
+ return base
132
+ raise ValueError(
133
+ "Cannot locate pyproject.toml or .git to determine version-controlled root. "
134
+ "Set FURU_VERSION_CONTROLLED_PATH to override."
135
+ )
136
+
137
+ @staticmethod
138
+ def _require_namespaces_exist(namespaces: set[str]) -> None:
139
+ if not namespaces:
140
+ return
141
+ missing_sentinel = object()
142
+ for namespace in namespaces:
143
+ module_name, _, qualname = namespace.rpartition(".")
144
+ if not module_name or not qualname:
145
+ raise ValueError(
146
+ "FURU_ALWAYS_RERUN entries must be 'module.QualifiedName', "
147
+ f"got {namespace!r}"
148
+ )
149
+ target: object = import_module(module_name)
150
+ for attr in qualname.split("."):
151
+ value = getattr(target, attr, missing_sentinel)
152
+ if value is missing_sentinel:
153
+ raise ValueError(
154
+ "FURU_ALWAYS_RERUN entry does not exist: "
155
+ f"{namespace!r}"
156
+ )
157
+ target = value
158
+
85
159
  @property
86
160
  def raw_dir(self) -> Path:
87
161
  return self.base_root / "raw"
furu/core/furu.py CHANGED
@@ -204,11 +204,13 @@ class Furu[T](ABC):
204
204
  """Compute hash of this object's content for storage identification."""
205
205
  return FuruSerializer.compute_hash(self)
206
206
 
207
- def _force_recompute(self: Self) -> bool:
208
- if not FURU_CONFIG.force_recompute:
207
+ def _always_rerun(self: Self) -> bool:
208
+ if FURU_CONFIG.always_rerun_all:
209
+ return True
210
+ if not FURU_CONFIG.always_rerun:
209
211
  return False
210
212
  qualname = f"{self.__class__.__module__}.{self.__class__.__qualname__}"
211
- return qualname in FURU_CONFIG.force_recompute
213
+ return qualname in FURU_CONFIG.always_rerun
212
214
 
213
215
  def _base_furu_dir(self: Self) -> Path:
214
216
  root = FURU_CONFIG.get_root(self.version_controlled)
@@ -333,12 +335,12 @@ class Furu[T](ABC):
333
335
  )
334
336
  migration = MigrationManager.read_migration(base_dir)
335
337
 
336
- if alias_active and self._force_recompute():
338
+ if alias_active and self._always_rerun():
337
339
  if migration is not None:
338
340
  self._maybe_detach_alias(
339
341
  directory=base_dir,
340
342
  record=migration,
341
- reason="force_recompute",
343
+ reason="always_rerun",
342
344
  )
343
345
  migration = MigrationManager.read_migration(base_dir)
344
346
  alias_active = False
@@ -350,9 +352,9 @@ class Furu[T](ABC):
350
352
  success_marker = StateManager.get_success_marker_path(directory)
351
353
  if success_marker.is_file():
352
354
  # We have a success marker. Check if we can use it.
353
- if self._force_recompute():
355
+ if self._always_rerun():
354
356
  self._invalidate_cached_success(
355
- directory, reason="force_recompute enabled"
357
+ directory, reason="always_rerun enabled"
356
358
  )
357
359
  # Fall through to normal load
358
360
  else:
@@ -381,9 +383,9 @@ class Furu[T](ABC):
381
383
  needs_reconcile = True
382
384
  if isinstance(state0.result, _StateResultSuccess):
383
385
  # Double check logic if we fell through to here (e.g. race condition or invalidation above)
384
- if self._force_recompute():
386
+ if self._always_rerun():
385
387
  self._invalidate_cached_success(
386
- directory, reason="force_recompute enabled"
388
+ directory, reason="always_rerun enabled"
387
389
  )
388
390
  state0 = StateManager.read_state(directory)
389
391
  else:
furu/storage/state.py CHANGED
@@ -1008,6 +1008,35 @@ def compute_lock(
1008
1008
  FuruLockNotAcquired: If lock cannot be acquired (after waiting)
1009
1009
  FuruWaitTimeout: If max_wait_time_sec is exceeded
1010
1010
  """
1011
+ def _format_wait_duration(seconds: float) -> str:
1012
+ if seconds < 60.0:
1013
+ return f"{seconds:.1f}s"
1014
+ minutes = seconds / 60.0
1015
+ if minutes < 60.0:
1016
+ return f"{minutes:.1f}m"
1017
+ hours = minutes / 60.0
1018
+ if hours < 24.0:
1019
+ return f"{hours:.1f}h"
1020
+ days = hours / 24.0
1021
+ return f"{days:.1f}d"
1022
+
1023
+ def _describe_wait(attempt: _StateAttempt, waited_sec: float) -> str:
1024
+ label = "last heartbeat"
1025
+ timestamp = attempt.heartbeat_at
1026
+ if attempt.status == "queued":
1027
+ label = "queued at"
1028
+ timestamp = attempt.started_at
1029
+ parsed = StateManager._parse_time(timestamp)
1030
+ timestamp_info = timestamp
1031
+ if parsed is not None:
1032
+ age = (StateManager._utcnow() - parsed).total_seconds()
1033
+ timestamp_info = f"{timestamp} ({_format_wait_duration(age)} ago)"
1034
+ return (
1035
+ "waited "
1036
+ f"{_format_wait_duration(waited_sec)}, {label} {timestamp_info}, "
1037
+ f"status {attempt.status}, backend {attempt.backend}"
1038
+ )
1039
+
1011
1040
  lock_path = StateManager.get_lock_path(directory, StateManager.COMPUTE_LOCK)
1012
1041
 
1013
1042
  lock_fd: int | None = None
@@ -1031,6 +1060,49 @@ def compute_lock(
1031
1060
 
1032
1061
  lock_fd = StateManager.try_lock(lock_path)
1033
1062
  if lock_fd is not None:
1063
+ state = StateManager.read_state(directory)
1064
+ if isinstance(state.result, _StateResultSuccess):
1065
+ StateManager.release_lock(lock_fd, lock_path)
1066
+ raise FuruLockNotAcquired(
1067
+ "Cannot acquire lock: experiment already succeeded"
1068
+ )
1069
+ if isinstance(state.result, _StateResultFailed):
1070
+ StateManager.release_lock(lock_fd, lock_path)
1071
+ raise FuruLockNotAcquired("Cannot acquire lock: experiment already failed")
1072
+ attempt = state.attempt
1073
+ if (
1074
+ isinstance(attempt, (_StateAttemptQueued, _StateAttemptRunning))
1075
+ and attempt.backend != backend
1076
+ ):
1077
+ StateManager.release_lock(lock_fd, lock_path)
1078
+ lock_fd = None
1079
+ if reconcile_fn is not None:
1080
+ reconcile_fn(directory)
1081
+ state = StateManager.read_state(directory)
1082
+ if isinstance(state.result, _StateResultSuccess):
1083
+ raise FuruLockNotAcquired(
1084
+ "Cannot acquire lock: experiment already succeeded"
1085
+ )
1086
+ if isinstance(state.result, _StateResultFailed):
1087
+ raise FuruLockNotAcquired(
1088
+ "Cannot acquire lock: experiment already failed"
1089
+ )
1090
+ attempt = state.attempt
1091
+ if not isinstance(attempt, (_StateAttemptQueued, _StateAttemptRunning)):
1092
+ continue
1093
+ if attempt.backend == backend:
1094
+ continue
1095
+ now = time.time()
1096
+ if now >= next_wait_log_at:
1097
+ waited_sec = now - start_time
1098
+ logger.info(
1099
+ "compute_lock: waiting for lock creation %s (%s)",
1100
+ directory,
1101
+ _describe_wait(attempt, waited_sec),
1102
+ )
1103
+ next_wait_log_at = now + wait_log_every_sec
1104
+ time.sleep(poll_interval_sec)
1105
+ continue
1034
1106
  break
1035
1107
 
1036
1108
  # Lock held by someone else - reconcile and check state
@@ -1064,9 +1136,11 @@ def compute_lock(
1064
1136
  # Active attempt exists - wait for it
1065
1137
  now = time.time()
1066
1138
  if now >= next_wait_log_at:
1139
+ waited_sec = now - start_time
1067
1140
  logger.info(
1068
- "compute_lock: waiting for lock %s",
1141
+ "compute_lock: waiting for lock %s (%s)",
1069
1142
  directory,
1143
+ _describe_wait(attempt, waited_sec),
1070
1144
  )
1071
1145
  next_wait_log_at = now + wait_log_every_sec
1072
1146
  time.sleep(poll_interval_sec)
@@ -1,19 +1,20 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.3
2
2
  Name: furu
3
- Version: 0.0.1
3
+ Version: 0.0.2
4
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
5
6
  Author-email: Herman Brunborg <herman@brunborg.com>
6
- Requires-Python: >=3.12
7
7
  Requires-Dist: chz>=0.4.0
8
8
  Requires-Dist: cloudpickle>=3.1.1
9
9
  Requires-Dist: pydantic>=2.12.5
10
10
  Requires-Dist: python-dotenv>=1.0.0
11
11
  Requires-Dist: rich>=14.2.0
12
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
13
17
  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
18
  Description-Content-Type: text/markdown
18
19
 
19
20
  # furu
@@ -132,20 +133,25 @@ class TrainTextModel(furu.Furu[str]):
132
133
 
133
134
  ### Storage Structure
134
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
+
135
147
  ```
136
148
  $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
149
+ ├── data/ # version_controlled=False
150
+ │ └── <module>/<Class>/<hash>/
151
+ └── raw/
152
+
153
+ $FURU_VERSION_CONTROLLED_PATH/ # version_controlled=True
154
+ └── <module>/<Class>/<hash>/
149
155
  ```
150
156
 
151
157
  ## Features
@@ -259,10 +265,17 @@ For artifacts that should be stored separately (e.g., checked into git):
259
265
 
260
266
  ```python
261
267
  class VersionedConfig(furu.Furu[dict], version_controlled=True):
262
- # Stored under $FURU_PATH/git/ instead of $FURU_PATH/data/
268
+ # Stored under $FURU_VERSION_CONTROLLED_PATH
269
+ # Default: <project>/furu-data/artifacts
263
270
  ...
264
271
  ```
265
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
+
266
279
  ## Logging
267
280
 
268
281
  Furu installs stdlib `logging` handlers that capture logs to per-artifact files.
@@ -397,9 +410,11 @@ The `/api/experiments` endpoint supports:
397
410
 
398
411
  | Variable | Default | Description |
399
412
  |----------|---------|-------------|
400
- | `FURU_PATH` | `./data-furu/` | Base storage directory |
413
+ | `FURU_PATH` | `<project>/furu-data` | Base storage directory for non-versioned artifacts |
414
+ | `FURU_VERSION_CONTROLLED_PATH` | `<project>/furu-data/artifacts` | Override version-controlled storage root |
401
415
  | `FURU_LOG_LEVEL` | `INFO` | Console verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
402
416
  | `FURU_IGNORE_DIFF` | `false` | Skip embedding git diff in metadata |
417
+ | `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) |
403
418
  | `FURU_POLL_INTERVAL_SECS` | `10` | Polling interval for queued/running jobs |
404
419
  | `FURU_WAIT_LOG_EVERY_SECS` | `10` | Interval between "waiting" log messages |
405
420
  | `FURU_STALE_AFTER_SECS` | `1800` | Consider running jobs stale after this duration |
@@ -1,24 +1,24 @@
1
1
  furu/__init__.py,sha256=fhSViHOJ9W-64swuaBFdZOfq0ZMuSj6LSiX2ZfcjhD8,1736
2
- furu/config.py,sha256=F_Bh9vs0Dq5-3fXMylEBbm7F9-Q2n9aLt1iTb-RAl-4,3538
3
- furu/errors.py,sha256=d1Kp5O9cVoQwXmQeZC-35u7xldw_c3ryYXrbVfv-Lws,2001
4
- furu/migrate.py,sha256=x_Uh7oXAv40L5ZAHJhdnw-o7ct56rWUSZLbHHfRObeY,1313
5
- furu/migration.py,sha256=A91dng1XRn1N_xJrmBhh-OvU22GlseqOh6PmVhNZh3w,31307
6
2
  furu/adapters/__init__.py,sha256=onLzEj9hccPK15g8a8va2T19nqQXoxb9rQlJIjKSKnE,69
7
3
  furu/adapters/submitit.py,sha256=OuCP0pEkO1kI4WLcSUvMqXwVCCy-8uwUE7v1qvkLZnU,6214
4
+ furu/config.py,sha256=C9mYQLgP4ciPmONCpQUu2YVV8adscCkfLsiyjXZVcpQ,6636
8
5
  furu/core/__init__.py,sha256=gzFMgaAYnffofQksR6E1NegiwBF99h0ysn_QeD5wIhw,82
9
- furu/core/furu.py,sha256=MjwpJtS0T8aRtLsFiiVTB8oh5UtIQrF3ohzYbD9XFIc,39047
6
+ furu/core/furu.py,sha256=7swlMfGXBB_jmGABgMSl28v_qiE8Ot4vuDSos42cweQ,39085
10
7
  furu/core/list.py,sha256=hwwlvqaKB1grPBGKXc15scF1RCqDvWc0AoDbhKlN4W0,3625
11
8
  furu/dashboard/__init__.py,sha256=zNVddterfpjQtcpihIl3TRJdgdjOHYR0uO0cOSaGABg,172
12
9
  furu/dashboard/__main__.py,sha256=cNs65IMl4kwZFpxa9xLXmFSy4-M5D1X1ZBfTDxW11vo,144
13
- furu/dashboard/main.py,sha256=8JYc79gbJ9MjvIRdGDuAcR2Mme9kyY4ryZb11ZZ4uVA,4069
14
- furu/dashboard/scanner.py,sha256=qXCvkvFByBc09TUdth5Js67rS8zpRBlRkVQ9dJ7YbdE,34696
15
10
  furu/dashboard/api/__init__.py,sha256=9-WyWOt-VQJJBIsdW29D-7JvR-BivJd9G_SRaRptCz0,80
16
11
  furu/dashboard/api/models.py,sha256=SCu-kLJyW7dwSKswdgQNS3wQuj25ORs0pHkvX9xBbo4,4767
17
12
  furu/dashboard/api/routes.py,sha256=iZez0khIUvbgfeSoy1BJvmoEEbgUrdSQA8SN8iAIkM8,4813
18
- furu/dashboard/frontend/dist/favicon.svg,sha256=3TSLHNZITFe3JTPoYHZnDgiGsJxIzf39v97l2A1Hodo,369
19
- furu/dashboard/frontend/dist/index.html,sha256=o3XhvegC9rBpUiWNfXdCHqf_tg2795nob1NI0nBpFS4,810
20
13
  furu/dashboard/frontend/dist/assets/index-CbdDfSOZ.css,sha256=k3kxCuCqyxKgIv4M9itoAImMU8NMzkzAdTNQ4v_4fMU,34612
21
14
  furu/dashboard/frontend/dist/assets/index-DDv_TYB_.js,sha256=FH0uqY7P7vm3rikvDaJ504FZh0Z97nCkVcIglK-ElAY,543928
15
+ furu/dashboard/frontend/dist/favicon.svg,sha256=3TSLHNZITFe3JTPoYHZnDgiGsJxIzf39v97l2A1Hodo,369
16
+ furu/dashboard/frontend/dist/index.html,sha256=o3XhvegC9rBpUiWNfXdCHqf_tg2795nob1NI0nBpFS4,810
17
+ furu/dashboard/main.py,sha256=8JYc79gbJ9MjvIRdGDuAcR2Mme9kyY4ryZb11ZZ4uVA,4069
18
+ furu/dashboard/scanner.py,sha256=qXCvkvFByBc09TUdth5Js67rS8zpRBlRkVQ9dJ7YbdE,34696
19
+ furu/errors.py,sha256=d1Kp5O9cVoQwXmQeZC-35u7xldw_c3ryYXrbVfv-Lws,2001
20
+ furu/migrate.py,sha256=x_Uh7oXAv40L5ZAHJhdnw-o7ct56rWUSZLbHHfRObeY,1313
21
+ furu/migration.py,sha256=A91dng1XRn1N_xJrmBhh-OvU22GlseqOh6PmVhNZh3w,31307
22
22
  furu/runtime/__init__.py,sha256=fQqE7wUuWunLD73Vm3lss7BFSij3UVxXOKQXBAOS8zw,504
23
23
  furu/runtime/env.py,sha256=o1phhoTDhOnhALr3Ozf1ldrdvk2ClyEvBWbebHM6BXg,160
24
24
  furu/runtime/logging.py,sha256=JkuTFtbv6dYk088P6_Bga46bnKSDt-ElAqmiY86hMys,9773
@@ -29,8 +29,8 @@ furu/serialization/serializer.py,sha256=THWqHzpSwXj3Nj3PZ3JhwlWJ8sgvVyGrwBEDB_EW
29
29
  furu/storage/__init__.py,sha256=cLLL-GPpSu9C72Mdk5S6TGu3g-SnBfEuxzfpx5ZJPtw,616
30
30
  furu/storage/metadata.py,sha256=u4F4V1dDZtsiniO5xDCy8YxJZxGnreriYnJ1fOvQ2Bg,9232
31
31
  furu/storage/migration.py,sha256=Ars9aYwvhXpIBDf6L9ojGjp_l656-RfdtEAFKN0sZZY,2640
32
- furu/storage/state.py,sha256=tbVX74P6nVHhL1EBztgKp9BCe0UHpW0nyGkSeJXPejs,37581
33
- furu-0.0.1.dist-info/METADATA,sha256=mGC5hO68kGPxMUepH1Cnws-TDowOyCi1cgJ36pgTTOA,13294
34
- furu-0.0.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
35
- furu-0.0.1.dist-info/entry_points.txt,sha256=pIkNLYq-gaxYbh_lATWl31BHTrKBg1jN6jK1AgN6-QY,59
36
- furu-0.0.1.dist-info/RECORD,,
32
+ furu/storage/state.py,sha256=q8wWJnGMWzx56PfsRMAMRB62p5vVw-iZ5rnUPfw2-js,40878
33
+ furu-0.0.2.dist-info/WHEEL,sha256=XV0cjMrO7zXhVAIyyc8aFf1VjZ33Fen4IiJk5zFlC3g,80
34
+ furu-0.0.2.dist-info/entry_points.txt,sha256=hZkjtFzNlb33Zk-aUfLMRj-XgVDxdT82-JXG9d4bu2E,60
35
+ furu-0.0.2.dist-info/METADATA,sha256=8Zvp5E5XHn11a8YedVhAOi9KAvH9LwKvxiW4Jn7-Hsg,13833
36
+ furu-0.0.2.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.28.0
2
+ Generator: uv 0.9.26
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
2
  furu-dashboard = furu.dashboard.main:cli
3
+