furu 0.0.6__py3-none-any.whl → 0.0.7__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 CHANGED
@@ -36,15 +36,7 @@ from .runtime import (
36
36
  log,
37
37
  write_separator,
38
38
  )
39
- from .migrate import migrate
40
- from .migration import (
41
- NamespacePair,
42
- MigrationCandidate,
43
- MigrationSkip,
44
- apply_migration,
45
- find_migration_candidates,
46
- find_migration_candidates_initialized_target,
47
- )
39
+ from .migration import FuruRef, MigrationReport, MigrationSkip
48
40
  from .serialization import FuruSerializer
49
41
  from .storage import MetadataManager, StateManager
50
42
 
@@ -67,13 +59,9 @@ __all__ = [
67
59
  "DependencyChzSpec",
68
60
  "DependencySpec",
69
61
  "MISSING",
70
- "migrate",
71
- "NamespacePair",
72
- "MigrationCandidate",
62
+ "FuruRef",
63
+ "MigrationReport",
73
64
  "MigrationSkip",
74
- "apply_migration",
75
- "find_migration_candidates",
76
- "find_migration_candidates_initialized_target",
77
65
  "MetadataManager",
78
66
  "StateManager",
79
67
  "SubmititAdapter",
furu/aliases.py ADDED
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from collections.abc import Iterator
5
+ from pathlib import Path
6
+
7
+ from .config import FURU_CONFIG
8
+ from .storage import MigrationManager, MigrationRecord
9
+ from .storage.migration import RootKind
10
+ from .storage.state import StateManager
11
+
12
+ AliasKey = tuple[str, str, RootKind]
13
+
14
+
15
+ def iter_roots() -> Iterator[Path]:
16
+ """Iterate over all existing Furu storage roots."""
17
+ for version_controlled in (False, True):
18
+ root = FURU_CONFIG.get_root(version_controlled)
19
+ if root.exists():
20
+ yield root
21
+
22
+
23
+ def find_experiment_dirs(root: Path) -> list[Path]:
24
+ """Find all directories containing .furu/state.json files."""
25
+ experiments: list[Path] = []
26
+
27
+ for furu_dir in root.rglob(StateManager.INTERNAL_DIR):
28
+ if not furu_dir.is_dir():
29
+ continue
30
+ state_file = furu_dir / StateManager.STATE_FILE
31
+ if state_file.is_file():
32
+ experiments.append(furu_dir.parent)
33
+
34
+ return experiments
35
+
36
+
37
+ def alias_key(migration: MigrationRecord) -> AliasKey:
38
+ return (migration.from_namespace, migration.from_hash, migration.from_root)
39
+
40
+
41
+ def collect_aliases(
42
+ *, include_inactive: bool = True
43
+ ) -> dict[AliasKey, list[MigrationRecord]]:
44
+ aliases: dict[AliasKey, list[MigrationRecord]] = defaultdict(list)
45
+ for root in iter_roots():
46
+ for experiment_dir in find_experiment_dirs(root):
47
+ migration = MigrationManager.read_migration(experiment_dir)
48
+ if migration is None or migration.kind != "alias":
49
+ continue
50
+ if not include_inactive and migration.overwritten_at is not None:
51
+ continue
52
+ aliases[alias_key(migration)].append(migration)
53
+ return aliases
furu/core/furu.py CHANGED
@@ -18,13 +18,14 @@ from typing import (
18
18
  Callable,
19
19
  ClassVar,
20
20
  Hashable,
21
+ Iterable,
22
+ Literal,
21
23
  Mapping,
22
24
  Protocol,
23
25
  Self,
24
26
  Sequence,
25
27
  TypeAlias,
26
28
  TypedDict,
27
- TypeVar,
28
29
  cast,
29
30
  )
30
31
 
@@ -47,8 +48,19 @@ from ..runtime import current_holder
47
48
  from ..runtime.logging import enter_holder, get_logger, log, write_separator
48
49
  from ..runtime.tracebacks import format_traceback
49
50
  from ..runtime.overrides import has_override, lookup_override
51
+ from ..schema import schema_key_from_cls
50
52
  from ..serialization import FuruSerializer
51
53
  from ..serialization.serializer import JsonValue
54
+ from ..aliases import AliasKey, collect_aliases
55
+ from ..migration import (
56
+ FuruRef,
57
+ MigrationReport,
58
+ current as _current_refs,
59
+ migrate as _migrate,
60
+ migrate_one as _migrate_one,
61
+ resolve_original_ref,
62
+ stale as _stale_refs,
63
+ )
52
64
  from ..storage import (
53
65
  FuruMetadata,
54
66
  MetadataManager,
@@ -312,8 +324,108 @@ class Furu[T](ABC):
312
324
  """Convert to Python code."""
313
325
  return FuruSerializer.to_python(self, multiline=multiline)
314
326
 
327
+ @classmethod
328
+ def schema_key(cls) -> tuple[str, ...]:
329
+ """
330
+ Return the stable schema key for this class.
331
+
332
+ Returns:
333
+ tuple[str, ...]: Sorted field names for the class schema.
334
+ """
335
+ return schema_key_from_cls(cls)
336
+
337
+ @classmethod
338
+ def migrate(
339
+ cls,
340
+ *,
341
+ from_schema: Iterable[str] | None = None,
342
+ from_drop: Iterable[str] | None = None,
343
+ from_add: Iterable[str] | None = None,
344
+ from_hash: str | None = None,
345
+ from_furu_obj: dict[str, JsonValue] | None = None,
346
+ from_namespace: str | None = None,
347
+ default_field: Iterable[str] | None = None,
348
+ set_field: Mapping[str, JsonValue] | None = None,
349
+ drop_field: Iterable[str] | None = None,
350
+ rename_field: Mapping[str, str] | None = None,
351
+ include_alias_sources: bool = False,
352
+ conflict: Literal["throw", "skip"] = "throw",
353
+ origin: str | None = None,
354
+ note: str | None = None,
355
+ ) -> MigrationReport:
356
+ """
357
+ Create alias-only migrations for stored objects matching the selector.
358
+
359
+ Notes:
360
+ set_field only adds fields that are not already present. To replace
361
+ a field, drop it first and then set it.
362
+ """
363
+ return _migrate(
364
+ cls,
365
+ from_schema=from_schema,
366
+ from_drop=from_drop,
367
+ from_add=from_add,
368
+ from_hash=from_hash,
369
+ from_furu_obj=from_furu_obj,
370
+ from_namespace=from_namespace,
371
+ default_field=default_field,
372
+ set_field=set_field,
373
+ drop_field=drop_field,
374
+ rename_field=rename_field,
375
+ include_alias_sources=include_alias_sources,
376
+ conflict=conflict,
377
+ origin=origin,
378
+ note=note,
379
+ )
380
+
381
+ @classmethod
382
+ def migrate_one(
383
+ cls,
384
+ *,
385
+ from_hash: str,
386
+ from_namespace: str | None = None,
387
+ default_field: Iterable[str] | None = None,
388
+ set_field: Mapping[str, JsonValue] | None = None,
389
+ drop_field: Iterable[str] | None = None,
390
+ rename_field: Mapping[str, str] | None = None,
391
+ include_alias_sources: bool = False,
392
+ conflict: Literal["throw", "skip"] = "throw",
393
+ origin: str | None = None,
394
+ note: str | None = None,
395
+ ) -> MigrationRecord | None:
396
+ return _migrate_one(
397
+ cls,
398
+ from_hash=from_hash,
399
+ from_namespace=from_namespace,
400
+ default_field=default_field,
401
+ set_field=set_field,
402
+ drop_field=drop_field,
403
+ rename_field=rename_field,
404
+ include_alias_sources=include_alias_sources,
405
+ conflict=conflict,
406
+ origin=origin,
407
+ note=note,
408
+ )
409
+
410
+ @classmethod
411
+ def stale(cls, *, namespace: str | None = None) -> list[FuruRef]:
412
+ return _stale_refs(cls, namespace=namespace)
413
+
414
+ @classmethod
415
+ def current(cls, *, namespace: str | None = None) -> list[FuruRef]:
416
+ return _current_refs(cls, namespace=namespace)
417
+
315
418
  def log(self: Self, message: str, *, level: str = "INFO") -> Path:
316
- """Log a message to the current holder's `furu.log`."""
419
+ """
420
+ Log a message to the current holder's `furu.log`.
421
+
422
+ Parameters:
423
+ message (str): The message to log.
424
+ level (str): Logging level name (e.g., "INFO", "ERROR").
425
+
426
+ Returns:
427
+ Path: Path to the log file where the message was written.
428
+ """
317
429
  return log(message, level=level)
318
430
 
319
431
  def _exists_quiet(self: Self) -> bool:
@@ -369,6 +481,45 @@ class Furu[T](ABC):
369
481
  """Get migration record for this object."""
370
482
  return MigrationManager.read_migration(self._base_furu_dir())
371
483
 
484
+ def is_alias(self: Self) -> bool:
485
+ record = self._alias_record(self._base_furu_dir())
486
+ return record is not None
487
+
488
+ def original(self: Self) -> FuruRef:
489
+ base_dir = self._base_furu_dir()
490
+ ref = FuruRef(
491
+ namespace=".".join(self.__class__._namespace().parts),
492
+ furu_hash=self.furu_hash,
493
+ root=MigrationManager.root_kind_for_dir(base_dir),
494
+ directory=base_dir,
495
+ )
496
+ return resolve_original_ref(ref)
497
+
498
+ def aliases(
499
+ self: Self,
500
+ *,
501
+ include_inactive: bool = True,
502
+ alias_index: dict[AliasKey, list[MigrationRecord]] | None = None,
503
+ ) -> list[FuruRef]:
504
+ original_ref = self.original()
505
+ if alias_index is None:
506
+ alias_index = collect_aliases(include_inactive=include_inactive)
507
+ records = alias_index.get(
508
+ (original_ref.namespace, original_ref.furu_hash, original_ref.root),
509
+ [],
510
+ )
511
+ refs = [
512
+ FuruRef(
513
+ namespace=record.to_namespace,
514
+ furu_hash=record.to_hash,
515
+ root=record.to_root,
516
+ directory=MigrationManager.resolve_dir(record, target="to"),
517
+ )
518
+ for record in records
519
+ ]
520
+ refs.sort(key=lambda item: item.furu_hash)
521
+ return refs
522
+
372
523
  def get(self: Self, *, force: bool = False) -> T:
373
524
  """
374
525
  Load result if it exists, computing if necessary.
@@ -1593,6 +1744,3 @@ def _dependency_type_error(
1593
1744
  f"{path} must be a Furu instance or a collection of Furu instances; "
1594
1745
  f"got {type(value).__name__}"
1595
1746
  )
1596
-
1597
-
1598
- _H = TypeVar("_H", bound=Furu, covariant=True)
@@ -19,6 +19,17 @@ class HealthCheck(BaseModel):
19
19
  version: str
20
20
 
21
21
 
22
+ class AliasInfo(BaseModel):
23
+ """Alias reference information for an experiment."""
24
+
25
+ namespace: str
26
+ furu_hash: str
27
+ migrated_at: str | None = None
28
+ overwritten_at: str | None = None
29
+ origin: str | None = None
30
+ note: str | None = None
31
+
32
+
22
33
  class ExperimentSummary(BaseModel):
23
34
  """Summary of an experiment for list views."""
24
35
 
@@ -46,6 +57,13 @@ class ExperimentSummary(BaseModel):
46
57
  to_namespace: str | None = None
47
58
  to_hash: str | None = None
48
59
  original_result_status: str | None = None
60
+ original_namespace: str | None = None
61
+ original_hash: str | None = None
62
+ schema_key: list[str] | None = None
63
+ current_schema_key: list[str] | None = None
64
+ is_stale: bool | None = None
65
+ is_alias: bool | None = None
66
+ aliases: list[AliasInfo] | None = None
49
67
 
50
68
 
51
69
  class ExperimentDetail(ExperimentSummary):
@@ -55,10 +73,6 @@ class ExperimentDetail(ExperimentSummary):
55
73
  state: JsonDict
56
74
  metadata: JsonDict | None = None
57
75
  attempt: StateAttempt | None = None
58
- original_namespace: str | None = None
59
- original_hash: str | None = None
60
- alias_namespaces: list[str] | None = None
61
- alias_hashes: list[str] | None = None
62
76
 
63
77
 
64
78
  class ExperimentList(BaseModel):
@@ -1,5 +1,7 @@
1
1
  """API route definitions for the Furu Dashboard."""
2
2
 
3
+ from typing import Literal
4
+
3
5
  from fastapi import APIRouter, HTTPException, Query
4
6
 
5
7
  from .. import __version__
@@ -21,6 +23,8 @@ from .models import (
21
23
 
22
24
  router = APIRouter(prefix="/api", tags=["api"])
23
25
 
26
+ SchemaFilter = Literal["current", "stale", "any"]
27
+
24
28
 
25
29
  @router.get("/health", response_model=HealthCheck)
26
30
  async def health_check() -> HealthCheck:
@@ -57,6 +61,9 @@ async def list_experiments(
57
61
  migration_policy: str | None = Query(
58
62
  None, description="Filter by migration policy"
59
63
  ),
64
+ schema: SchemaFilter = Query(
65
+ "current", description="Filter by schema status (current, stale, any)"
66
+ ),
60
67
  view: str = Query("resolved", description="View mode: resolved or original"),
61
68
  limit: int = Query(100, ge=1, le=1000, description="Maximum number of results"),
62
69
  offset: int = Query(0, ge=0, description="Offset for pagination"),
@@ -76,6 +83,7 @@ async def list_experiments(
76
83
  config_filter=config_filter,
77
84
  migration_kind=migration_kind,
78
85
  migration_policy=migration_policy,
86
+ schema=schema,
79
87
  view=view,
80
88
  )
81
89