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.
@@ -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
@@ -5,4 +5,4 @@ def load_env() -> None:
5
5
 
6
6
 
7
7
  # Preserve previous behavior: attempt to load `.env` at import-time.
8
- load_env()
8
+ # load_env() # TODO: find a nice way to auto load .env if needed
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
- _LOAD_OR_CREATE_PREFIX = "load_or_create"
32
+ _GET_PREFIX = "get"
32
33
 
33
34
 
34
- def _strip_load_or_create_decision_suffix(message: str) -> str:
35
+ def _strip_get_decision_suffix(message: str) -> str:
35
36
  """
36
- Strip a trailing `(<decision>)` suffix from `load_or_create ...` console lines.
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(_LOAD_OR_CREATE_PREFIX):
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 `load_or_create()`, so nested
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.mkdir(parents=True, exist_ok=True)
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 load_or_create messages)
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 = _strip_load_or_create_decision_suffix(record.getMessage())
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(_LOAD_OR_CREATE_PREFIX):
180
- prefix = _LOAD_OR_CREATE_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 %(filename)s:%(lineno)d %(message)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
- get_logger().log(level_no, message)
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 `load_or_create()` calls are easy to spot.
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.mkdir(parents=True, exist_ok=True)
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 FURU_CACHE_METADATA)
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
- if not FURU_CONFIG.require_git:
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
- head = cls.run_git_command(["rev-parse", "HEAD"])
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
- return GitInfo(
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 FURU_REQUIRE_GIT_REMOTE=0 to allow missing origin."
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
- "Use ignore_diff=True or FURU_IGNORE_DIFF=1"
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)