furu 0.0.3__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/__init__.py +8 -0
- furu/adapters/submitit.py +23 -2
- furu/config.py +40 -41
- furu/core/furu.py +479 -252
- furu/core/list.py +4 -3
- furu/dashboard/__init__.py +10 -1
- furu/dashboard/frontend/dist/assets/{index-DS3FsqcY.js → index-BjyrY-Zz.js} +1 -1
- furu/dashboard/frontend/dist/index.html +1 -1
- furu/dashboard/main.py +10 -3
- furu/errors.py +17 -4
- furu/execution/__init__.py +22 -0
- furu/execution/context.py +30 -0
- furu/execution/local.py +186 -0
- furu/execution/paths.py +20 -0
- furu/execution/plan.py +330 -0
- furu/execution/plan_utils.py +13 -0
- furu/execution/slurm_dag.py +273 -0
- furu/execution/slurm_pool.py +878 -0
- furu/execution/slurm_spec.py +38 -0
- furu/execution/submitit_factory.py +47 -0
- furu/migration.py +1 -2
- furu/runtime/env.py +1 -1
- furu/runtime/logging.py +40 -14
- furu/storage/metadata.py +25 -29
- furu/storage/migration.py +0 -1
- furu/storage/state.py +120 -98
- {furu-0.0.3.dist-info → furu-0.0.5.dist-info}/METADATA +91 -42
- furu-0.0.5.dist-info/RECORD +46 -0
- {furu-0.0.3.dist-info → furu-0.0.5.dist-info}/WHEEL +1 -1
- furu-0.0.3.dist-info/RECORD +0 -36
- {furu-0.0.3.dist-info → furu-0.0.5.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Mapping, Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
SlurmSpecValue = str | int | float | bool
|
|
8
|
+
SlurmSpecExtraValue = SlurmSpecValue | Mapping[str, "SlurmSpecExtraValue"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class SlurmSpec:
|
|
13
|
+
partition: str | None = None
|
|
14
|
+
gpus: int = 0
|
|
15
|
+
cpus: int = 4
|
|
16
|
+
mem_gb: int = 16
|
|
17
|
+
time_min: int = 60
|
|
18
|
+
extra: Mapping[str, SlurmSpecExtraValue] | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _SpecNode(Protocol):
|
|
22
|
+
furu_hash: str
|
|
23
|
+
|
|
24
|
+
def _executor_spec_key(self) -> str: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_slurm_spec(specs: Mapping[str, SlurmSpec], node: _SpecNode) -> SlurmSpec:
|
|
28
|
+
if "default" not in specs:
|
|
29
|
+
raise KeyError("Missing slurm spec for key 'default'.")
|
|
30
|
+
|
|
31
|
+
spec_key = node._executor_spec_key()
|
|
32
|
+
if spec_key not in specs:
|
|
33
|
+
raise KeyError(
|
|
34
|
+
"Missing slurm spec for key "
|
|
35
|
+
f"'{spec_key}' for node {node.__class__.__name__} ({node.furu_hash})."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return specs[spec_key]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from .paths import submitit_logs_dir
|
|
7
|
+
from .slurm_spec import SlurmSpec, SlurmSpecExtraValue
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
import submitit
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def make_executor_for_spec(
|
|
15
|
+
spec_key: str,
|
|
16
|
+
spec: SlurmSpec,
|
|
17
|
+
*,
|
|
18
|
+
kind: str,
|
|
19
|
+
submitit_root: Path | None,
|
|
20
|
+
run_id: str | None = None,
|
|
21
|
+
) -> submitit.AutoExecutor:
|
|
22
|
+
import submitit
|
|
23
|
+
|
|
24
|
+
folder = submitit_logs_dir(
|
|
25
|
+
kind,
|
|
26
|
+
spec_key,
|
|
27
|
+
override=submitit_root,
|
|
28
|
+
run_id=run_id,
|
|
29
|
+
)
|
|
30
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
|
|
32
|
+
executor = submitit.AutoExecutor(folder=str(folder))
|
|
33
|
+
params: dict[str, SlurmSpecExtraValue | None] = {
|
|
34
|
+
"timeout_min": spec.time_min,
|
|
35
|
+
"slurm_partition": spec.partition,
|
|
36
|
+
"cpus_per_task": spec.cpus,
|
|
37
|
+
"mem_gb": spec.mem_gb,
|
|
38
|
+
}
|
|
39
|
+
if spec.gpus:
|
|
40
|
+
params["gpus_per_node"] = spec.gpus
|
|
41
|
+
if spec.extra:
|
|
42
|
+
params.update(spec.extra)
|
|
43
|
+
|
|
44
|
+
executor.update_parameters(
|
|
45
|
+
**{key: value for key, value in params.items() if value is not None}
|
|
46
|
+
)
|
|
47
|
+
return executor
|
furu/migration.py
CHANGED
|
@@ -456,6 +456,7 @@ def _apply_single_migration(
|
|
|
456
456
|
shutil.rmtree(to_dir)
|
|
457
457
|
|
|
458
458
|
to_dir.mkdir(parents=True, exist_ok=True)
|
|
459
|
+
StateManager.ensure_internal_dir(to_dir)
|
|
459
460
|
now = _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
|
|
460
461
|
|
|
461
462
|
if policy in {"move", "copy"}:
|
|
@@ -557,8 +558,6 @@ def _copy_state(from_dir: Path, to_dir: Path, *, clear_source: bool) -> None:
|
|
|
557
558
|
src_internal = from_dir / StateManager.INTERNAL_DIR
|
|
558
559
|
if not src_internal.exists():
|
|
559
560
|
return
|
|
560
|
-
dst_internal = to_dir / StateManager.INTERNAL_DIR
|
|
561
|
-
dst_internal.mkdir(parents=True, exist_ok=True)
|
|
562
561
|
state_path = StateManager.get_state_path(from_dir)
|
|
563
562
|
if state_path.is_file():
|
|
564
563
|
shutil.copy2(state_path, StateManager.get_state_path(to_dir))
|
furu/runtime/env.py
CHANGED
furu/runtime/logging.py
CHANGED
|
@@ -4,6 +4,7 @@ import datetime
|
|
|
4
4
|
import logging
|
|
5
5
|
import os
|
|
6
6
|
import threading
|
|
7
|
+
import sys
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Generator, Protocol
|
|
9
10
|
|
|
@@ -28,16 +29,16 @@ _FURU_HOLDER_STACK: contextvars.ContextVar[tuple[HolderType, ...]] = (
|
|
|
28
29
|
_FURU_LOG_LOCK = threading.Lock()
|
|
29
30
|
_FURU_CONSOLE_LOCK = threading.Lock()
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
_GET_PREFIX = "get"
|
|
32
33
|
|
|
33
34
|
|
|
34
|
-
def
|
|
35
|
+
def _strip_get_decision_suffix(message: str) -> str:
|
|
35
36
|
"""
|
|
36
|
-
Strip a trailing `(<decision>)` suffix from `
|
|
37
|
+
Strip a trailing `(<decision>)` suffix from `get ...` console lines.
|
|
37
38
|
|
|
38
39
|
This keeps detailed decision info in file logs, but makes console output cleaner.
|
|
39
40
|
"""
|
|
40
|
-
if not message.startswith(
|
|
41
|
+
if not message.startswith(_GET_PREFIX):
|
|
41
42
|
return message
|
|
42
43
|
if not message.endswith(")"):
|
|
43
44
|
return message
|
|
@@ -69,7 +70,7 @@ def enter_holder(holder: HolderType) -> Generator[None, None, None]:
|
|
|
69
70
|
"""
|
|
70
71
|
Push a holder object onto the logging stack for this context.
|
|
71
72
|
|
|
72
|
-
Furu calls this automatically during `
|
|
73
|
+
Furu calls this automatically during `get()`, so nested
|
|
73
74
|
dependencies will log to the active dependency's folder and then revert.
|
|
74
75
|
"""
|
|
75
76
|
configure_logging()
|
|
@@ -102,6 +103,16 @@ class _FuruLogFormatter(logging.Formatter):
|
|
|
102
103
|
dt = datetime.datetime.fromtimestamp(record.created, tz=datetime.timezone.utc)
|
|
103
104
|
return dt.isoformat(timespec="seconds")
|
|
104
105
|
|
|
106
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
107
|
+
caller_file = getattr(record, "furu_caller_file", None)
|
|
108
|
+
caller_line = getattr(record, "furu_caller_line", None)
|
|
109
|
+
if isinstance(caller_file, str) and isinstance(caller_line, int):
|
|
110
|
+
location = f"{Path(caller_file).name}:{caller_line}"
|
|
111
|
+
else:
|
|
112
|
+
location = f"{record.filename}:{record.lineno}"
|
|
113
|
+
record.furu_location = location # type: ignore[attr-defined]
|
|
114
|
+
return super().format(record)
|
|
115
|
+
|
|
105
116
|
|
|
106
117
|
class _FuruContextFileHandler(logging.Handler):
|
|
107
118
|
"""
|
|
@@ -112,7 +123,8 @@ class _FuruContextFileHandler(logging.Handler):
|
|
|
112
123
|
message = self.format(record)
|
|
113
124
|
|
|
114
125
|
directory = current_log_dir()
|
|
115
|
-
directory.
|
|
126
|
+
if directory.name != ".furu":
|
|
127
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
116
128
|
|
|
117
129
|
log_path = directory / "furu.log"
|
|
118
130
|
with _FURU_LOG_LOCK:
|
|
@@ -163,7 +175,7 @@ class _FuruRichConsoleHandler(logging.Handler):
|
|
|
163
175
|
|
|
164
176
|
@staticmethod
|
|
165
177
|
def _format_location(record: logging.LogRecord) -> str:
|
|
166
|
-
# Use caller location if available (for
|
|
178
|
+
# Use caller location if available (for get messages)
|
|
167
179
|
caller_file = getattr(record, "furu_caller_file", None)
|
|
168
180
|
caller_line = getattr(record, "furu_caller_line", None)
|
|
169
181
|
if caller_file is not None and caller_line is not None:
|
|
@@ -174,10 +186,10 @@ class _FuruRichConsoleHandler(logging.Handler):
|
|
|
174
186
|
|
|
175
187
|
@staticmethod
|
|
176
188
|
def _format_message_text(record: logging.LogRecord) -> Text:
|
|
177
|
-
message =
|
|
189
|
+
message = _strip_get_decision_suffix(record.getMessage())
|
|
178
190
|
action_color = getattr(record, "furu_action_color", None)
|
|
179
|
-
if isinstance(action_color, str) and message.startswith(
|
|
180
|
-
prefix =
|
|
191
|
+
if isinstance(action_color, str) and message.startswith(_GET_PREFIX):
|
|
192
|
+
prefix = _GET_PREFIX
|
|
181
193
|
rest = message[len(prefix) :]
|
|
182
194
|
text = Text()
|
|
183
195
|
text.append(prefix, style=action_color)
|
|
@@ -241,7 +253,7 @@ def configure_logging() -> None:
|
|
|
241
253
|
handler.addFilter(_FuruFileFilter())
|
|
242
254
|
handler.setFormatter(
|
|
243
255
|
_FuruLogFormatter(
|
|
244
|
-
"%(asctime)s [%(levelname)s] %(name)s %(
|
|
256
|
+
"%(asctime)s [%(levelname)s] %(name)s %(furu_location)s %(message)s"
|
|
245
257
|
)
|
|
246
258
|
)
|
|
247
259
|
root.addHandler(handler)
|
|
@@ -280,7 +292,20 @@ def log(message: str, *, level: str = "INFO") -> Path:
|
|
|
280
292
|
raise ValueError(f"Unknown log level: {level!r}")
|
|
281
293
|
|
|
282
294
|
configure_logging()
|
|
283
|
-
|
|
295
|
+
caller_info: dict[str, object] = {}
|
|
296
|
+
frame = sys._getframe(1)
|
|
297
|
+
if frame is not None:
|
|
298
|
+
furu_pkg_dir = str(Path(__file__).parent.parent)
|
|
299
|
+
while frame is not None:
|
|
300
|
+
filename = frame.f_code.co_filename
|
|
301
|
+
if not filename.startswith(furu_pkg_dir):
|
|
302
|
+
caller_info = {
|
|
303
|
+
"furu_caller_file": filename,
|
|
304
|
+
"furu_caller_line": frame.f_lineno,
|
|
305
|
+
}
|
|
306
|
+
break
|
|
307
|
+
frame = frame.f_back
|
|
308
|
+
get_logger().log(level_no, message, extra=caller_info)
|
|
284
309
|
return log_path
|
|
285
310
|
|
|
286
311
|
|
|
@@ -288,12 +313,13 @@ def write_separator(line: str = "------------------") -> Path:
|
|
|
288
313
|
"""
|
|
289
314
|
Write a raw separator line to the current holder's `furu.log`.
|
|
290
315
|
|
|
291
|
-
This bypasses standard formatting so repeated `
|
|
316
|
+
This bypasses standard formatting so repeated `get()` calls are easy to spot.
|
|
292
317
|
"""
|
|
293
318
|
directory = current_log_dir()
|
|
294
319
|
log_path = directory / "furu.log"
|
|
295
320
|
|
|
296
|
-
directory.
|
|
321
|
+
if directory.name != ".furu":
|
|
322
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
297
323
|
|
|
298
324
|
with _FURU_LOG_LOCK:
|
|
299
325
|
with log_path.open("a", encoding="utf-8") as fp:
|
furu/storage/metadata.py
CHANGED
|
@@ -19,7 +19,7 @@ from ..serialization.serializer import JsonValue
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
20
|
from ..core.furu import Furu
|
|
21
21
|
|
|
22
|
-
# Module-level cache for metadata (controlled via
|
|
22
|
+
# Module-level cache for metadata (controlled via FURU_RECORD_GIT=cached)
|
|
23
23
|
_cached_git_info: "GitInfo | None" = None
|
|
24
24
|
_cached_git_info_time: float = 0.0
|
|
25
25
|
|
|
@@ -113,6 +113,16 @@ class MetadataManager:
|
|
|
113
113
|
global _cached_git_info, _cached_git_info_time
|
|
114
114
|
import time
|
|
115
115
|
|
|
116
|
+
record_git = FURU_CONFIG.record_git
|
|
117
|
+
if record_git == "ignore":
|
|
118
|
+
return GitInfo(
|
|
119
|
+
git_commit="<ignored>",
|
|
120
|
+
git_branch="<ignored>",
|
|
121
|
+
git_remote=None,
|
|
122
|
+
git_patch="<ignored>",
|
|
123
|
+
git_submodules={},
|
|
124
|
+
)
|
|
125
|
+
|
|
116
126
|
ttl = FURU_CONFIG.cache_metadata_ttl_sec
|
|
117
127
|
# Return cached result if caching is enabled and not expired
|
|
118
128
|
if ttl is not None and _cached_git_info is not None:
|
|
@@ -120,41 +130,28 @@ class MetadataManager:
|
|
|
120
130
|
if age < ttl:
|
|
121
131
|
return _cached_git_info
|
|
122
132
|
|
|
123
|
-
|
|
133
|
+
try:
|
|
134
|
+
head = cls.run_git_command(["rev-parse", "HEAD"])
|
|
135
|
+
branch = cls.run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
136
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
137
|
+
raise RuntimeError(
|
|
138
|
+
"Failed to read git commit/branch for provenance. "
|
|
139
|
+
"If this is expected, set FURU_RECORD_GIT=ignore."
|
|
140
|
+
) from e
|
|
141
|
+
|
|
142
|
+
if FURU_CONFIG.allow_no_git_origin:
|
|
124
143
|
try:
|
|
125
|
-
|
|
126
|
-
branch = cls.run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
144
|
+
remote = cls.run_git_command(["remote", "get-url", "origin"])
|
|
127
145
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
128
|
-
|
|
129
|
-
git_commit="<no-git>",
|
|
130
|
-
git_branch="<no-git>",
|
|
131
|
-
git_remote=None,
|
|
132
|
-
git_patch="<no-git>",
|
|
133
|
-
git_submodules={},
|
|
134
|
-
)
|
|
146
|
+
remote = None
|
|
135
147
|
else:
|
|
136
|
-
try:
|
|
137
|
-
head = cls.run_git_command(["rev-parse", "HEAD"])
|
|
138
|
-
branch = cls.run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
139
|
-
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
140
|
-
raise RuntimeError(
|
|
141
|
-
"Failed to read git commit/branch for provenance. "
|
|
142
|
-
"If this is expected, set FURU_REQUIRE_GIT=0."
|
|
143
|
-
) from e
|
|
144
|
-
|
|
145
|
-
if FURU_CONFIG.require_git_remote:
|
|
146
148
|
try:
|
|
147
149
|
remote = cls.run_git_command(["remote", "get-url", "origin"])
|
|
148
150
|
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
149
151
|
raise RuntimeError(
|
|
150
152
|
"Git remote 'origin' is required for provenance but was not found. "
|
|
151
|
-
"Set
|
|
153
|
+
"Set FURU_ALLOW_NO_GIT_ORIGIN=1 to allow missing origin."
|
|
152
154
|
) from e
|
|
153
|
-
else:
|
|
154
|
-
try:
|
|
155
|
-
remote = cls.run_git_command(["remote", "get-url", "origin"])
|
|
156
|
-
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
157
|
-
remote = None
|
|
158
155
|
|
|
159
156
|
if ignore_diff:
|
|
160
157
|
patch = "<ignored-diff>"
|
|
@@ -187,7 +184,7 @@ class MetadataManager:
|
|
|
187
184
|
if len(patch) > 50_000:
|
|
188
185
|
raise ValueError(
|
|
189
186
|
f"Git diff too large ({len(patch):,} bytes). "
|
|
190
|
-
"
|
|
187
|
+
"Set FURU_RECORD_GIT=ignore to skip git metadata."
|
|
191
188
|
)
|
|
192
189
|
|
|
193
190
|
submodules: dict[str, str] = {}
|
|
@@ -265,7 +262,6 @@ class MetadataManager:
|
|
|
265
262
|
def write_metadata(cls, metadata: FuruMetadata, directory: Path) -> None:
|
|
266
263
|
"""Write metadata to file."""
|
|
267
264
|
metadata_path = cls.get_metadata_path(directory)
|
|
268
|
-
metadata_path.parent.mkdir(parents=True, exist_ok=True)
|
|
269
265
|
metadata_path.write_text(
|
|
270
266
|
json.dumps(
|
|
271
267
|
metadata.model_dump(mode="json"),
|
furu/storage/migration.py
CHANGED
|
@@ -52,7 +52,6 @@ class MigrationManager:
|
|
|
52
52
|
@classmethod
|
|
53
53
|
def write_migration(cls, record: MigrationRecord, directory: Path) -> None:
|
|
54
54
|
path = cls.get_migration_path(directory)
|
|
55
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
55
|
tmp = path.with_suffix(".tmp")
|
|
57
56
|
tmp.write_text(json.dumps(record.model_dump(mode="json"), indent=2))
|
|
58
57
|
tmp.replace(path)
|