Coverage for collectra/types/links.py: 80.88%
68 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:56 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:56 +0000
1from __future__ import annotations
3from dataclasses import dataclass, field
4from pathlib import Path
5from typing import Any
7from .base import Artefact
9__all__ = ["Link"]
12@dataclass
13class Link(Artefact):
14 """A soft reference to the artefact identified by its first parent."""
16 target: Artefact | None = field(default=None, repr=False, compare=False)
18 @property
19 def parent_id(self) -> str:
20 if isinstance(self.parents, str):
21 return self.parents
22 return self.parents[0] if self.parents else ""
24 @property
25 def __class__(self) -> type:
26 """Present the resolved target type to ``isinstance`` consumers."""
27 target = self.__dict__.get("target")
28 seen = {id(self)}
29 while type(target) is Link:
30 if id(target) in seen:
31 return Link
32 seen.add(id(target))
33 target = target.__dict__.get("target")
34 return type(target) if target is not None else Link
36 def bind(self, artefacts: dict[str, Artefact]) -> Artefact | None:
37 """Resolve the first parent ID and retain it as the runtime target."""
38 self.target = artefacts.get(self.parent_id)
39 return self.target
41 def resolve(self) -> Artefact:
42 """Follow any number of Links and return the concrete artefact."""
43 current: Artefact = self
44 chain: list[str] = []
45 seen: set[int] = set()
46 while type(current) is Link:
47 if id(current) in seen:
48 chain.append(current.id)
49 raise RuntimeError(f"Cyclic Link chain: {' -> '.join(chain)}")
50 seen.add(id(current))
51 chain.append(current.id)
52 target = current.__dict__.get("target")
53 if target is None:
54 raise RuntimeError(
55 f"Link chain {' -> '.join(chain)!r} is unresolved at "
56 f"parent {current.parent_id!r}"
57 )
58 current = target
59 return current
61 def __call__(self) -> Any:
62 return self.resolve()()
64 def display(self, context) -> dict:
65 return self.resolve().display(context)
67 def __getattr__(self, name: str) -> Any:
68 # Dataclass initialization probes attributes before ``target`` exists.
69 target = self.__dict__.get("target")
70 if target is None:
71 raise AttributeError(name)
72 return getattr(self.resolve(), name)
74 def attributes_to_ignore(self) -> set:
75 attributes = super().attributes_to_ignore()
76 attributes.add("target")
77 return attributes
79 def serialize(self) -> dict:
80 """Serialize only the soft-link identity and parent reference."""
81 serialized = {
82 "type": self.get_class_path(),
83 "id": self.id,
84 }
85 if self.parents:
86 serialized["parents"] = (
87 self.parents
88 if isinstance(self.parents, str)
89 else self.parents[0] if len(self.parents) == 1 else self.parents
90 )
91 return serialized
93 def extract(self, path: Path | str) -> None:
94 self.resolve().extract(path)
96 def evaluate(self, gold: Artefact) -> float:
97 if type(gold) is Link:
98 if self.target is not None and gold.target is not None:
99 return self.resolve().evaluate(gold.resolve())
100 return float(bool(self.parent_id) and self.parent_id == gold.parent_id)
101 return self.resolve().evaluate(gold)