whytrail 0.1.0__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.
whytrail/__init__.py ADDED
@@ -0,0 +1,210 @@
1
+ """Python tells you where. whytrail tells you why.
2
+
3
+ Two tiers, one entry point (ADR §02, §05):
4
+
5
+ Tier 1 -- zero configuration. why(some_exception) reassembles a
6
+ causal chain from data CPython already retains: __traceback__,
7
+ __cause__, __context__, and live frame locals.
8
+
9
+ Tier 2 -- opt-in and scoped. why(some_tracked_value) walks a small
10
+ provenance graph built only for values a developer deliberately
11
+ watched with track(), @tracked, or trace().
12
+
13
+ Both answer through why(). Something that was never tracked gets an
14
+ honest "unknown," never a fabricated answer -- see ADR §11.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import typing as t
20
+
21
+ from ._repr import safe_repr
22
+ from .core import serialize as _serialize
23
+ from .core.explanation import Explanation, ExplanationStep
24
+ from .core.graph import ProvenanceGraph
25
+ from .core.node import Confidence, Edge, Node
26
+ from .explainers.builtin import explain_exception
27
+ from .protocols import call_why_protocol
28
+ from .registry import coerce, register, register_from_plugin, resolve_explainer
29
+ from .runtime.capture import track, tracked
30
+ from .runtime.context import current_scope, default_graph, trace
31
+
32
+ __version__ = "0.1.0"
33
+
34
+ # Deliberately small: five verbs, two persistence helpers, and the two
35
+ # names every explainer author touches (Explanation, ExplanationStep,
36
+ # Confidence). Everything else a plugin author or advanced user needs
37
+ # -- ProvenanceGraph, TraceScope, SupportsWhy, NodeKind, EdgeKind, the
38
+ # raw Node/Edge types -- is one submodule import away
39
+ # (whytrail.core.graph, whytrail.runtime.context, whytrail.protocols,
40
+ # whytrail.core.node) rather than crowding `import whytrail; whytrail.<tab>`
41
+ # for the median user who only ever calls why(). See the strategy
42
+ # review's namespace audit.
43
+ __all__ = [
44
+ "why",
45
+ "track",
46
+ "tracked",
47
+ "trace",
48
+ "register",
49
+ "register_from_plugin",
50
+ "snapshot",
51
+ "restore",
52
+ "Explanation",
53
+ "ExplanationStep",
54
+ "Confidence",
55
+ "__version__",
56
+ ]
57
+
58
+
59
+ def why(obj: t.Any, *, max_depth: int = 8) -> Explanation:
60
+ """The one public entry point (ADR §05, §10).
61
+
62
+ Tier 1: obj is an exception -- reconstructs its causal chain from
63
+ data CPython already retains. No setup required.
64
+
65
+ Tier 2: obj is anything else -- looks for a __why__ method, then a
66
+ registered explainer, then a provenance-graph node captured by
67
+ track()/@tracked/trace(). If none of those know anything about
68
+ obj, returns an Explanation that says so plainly.
69
+
70
+ Never raises: a failure anywhere in resolution degrades to an
71
+ "unknown" Explanation rather than propagating into caller code
72
+ (ADR §19).
73
+ """
74
+ try:
75
+ return _why_impl(obj, max_depth=max_depth)
76
+ except Exception: # noqa: BLE001 - why() must never raise, see ADR §19
77
+ return Explanation(subject=safe_repr(obj), steps=[], tracked=False)
78
+
79
+
80
+ def _why_impl(obj: t.Any, *, max_depth: int) -> Explanation:
81
+ # Protocol and registry get first look even for exceptions -- a
82
+ # plugin explaining e.g. requests.RequestException with domain
83
+ # detail (method, URL, response body) should win over the generic
84
+ # traceback walk. Tier 1's exception explainer is the built-in,
85
+ # always-available *fallback* for exceptions nothing more specific
86
+ # claims, not a shortcut that preempts plugins (ADR §02, §06).
87
+ protocol_result = call_why_protocol(obj)
88
+ if protocol_result is not None:
89
+ return protocol_result
90
+
91
+ explainer = resolve_explainer(type(obj))
92
+ if explainer is not None:
93
+ try:
94
+ raw = explainer(obj)
95
+ except Exception: # noqa: BLE001 - a broken plugin must not crash why()
96
+ raw = None
97
+ coerced = coerce(obj, raw)
98
+ if coerced is not None:
99
+ return coerced
100
+
101
+ if isinstance(obj, BaseException):
102
+ return explain_exception(obj, max_depth=max_depth)
103
+
104
+ return _explain_from_graph(obj, max_depth=max_depth)
105
+
106
+
107
+ def _explain_from_graph(obj: t.Any, *, max_depth: int) -> Explanation:
108
+ candidates: list[ProvenanceGraph] = []
109
+ scope = current_scope()
110
+ if scope is not None:
111
+ candidates.append(scope.graph)
112
+ default = default_graph()
113
+ if default not in candidates:
114
+ candidates.append(default)
115
+
116
+ for graph in candidates:
117
+ node = graph.node_for(obj)
118
+ if node is not None:
119
+ nodes, edges = graph.ancestors(node.id, max_depth=max_depth)
120
+ steps = _steps_from_traversal(node, nodes, edges)
121
+ return Explanation(subject=safe_repr(obj), steps=steps, tracked=True, nodes=nodes, edges=edges)
122
+
123
+ return Explanation(subject=safe_repr(obj), steps=[], tracked=False)
124
+
125
+
126
+ def _steps_from_traversal(
127
+ target: Node, nodes: list[Node], edges: list[Edge]
128
+ ) -> list[ExplanationStep]:
129
+ """Turn the ancestor subgraph into an ordered, human-readable
130
+ *single dominant path* -- root cause first, ending at the node the
131
+ caller asked about. When a node has more than one real parent (a
132
+ diamond: two calls converging on the same value, common under
133
+ trace(deep=True)), this picks the highest-confidence one and does
134
+ not narrate the rest -- .text/.steps is a readable summary, not
135
+ the full picture. Explanation.graph()/.nodes/.edges carry the
136
+ complete captured DAG for anyone who needs it; that distinction is
137
+ deliberate, not a bug (see ADR §11: honest partial answers are
138
+ fine, silently-lossy ones presented as complete are not).
139
+
140
+ At each step, follows the highest-confidence parent; ties are
141
+ broken by which edge was recorded first."""
142
+ nodes_by_id = {n.id: n for n in nodes}
143
+ incoming: dict[int, list[Edge]] = {}
144
+ for edge in edges:
145
+ incoming.setdefault(edge.target, []).append(edge)
146
+
147
+ chain: list[Node] = []
148
+ branch_counts: dict[int, int] = {} # node.id -> how many real parents it had
149
+ seen: set[int] = set()
150
+ current: Node | None = target
151
+ while current is not None and current.id not in seen:
152
+ seen.add(current.id)
153
+ chain.append(current)
154
+ parents = incoming.get(current.id, [])
155
+ branch_counts[current.id] = len(parents)
156
+ if not parents:
157
+ break
158
+ best_edge = max(parents, key=lambda e: e.confidence)
159
+ current = nodes_by_id.get(best_edge.source)
160
+ chain.reverse()
161
+
162
+ steps: list[ExplanationStep] = []
163
+ for i, node in enumerate(chain):
164
+ confidence = Confidence.EXPLICIT.value
165
+ if i > 0:
166
+ # named distinctly from the `edge` loop variable above --
167
+ # Python has no block scoping, and mypy --strict correctly
168
+ # flags reusing a name for a differently-typed (Optional)
169
+ # value in the same function scope.
170
+ matching_edge = next(
171
+ (e for e in incoming.get(chain[i].id, []) if e.source == chain[i - 1].id),
172
+ None,
173
+ )
174
+ confidence = matching_edge.confidence if matching_edge is not None else Confidence.INFERRED.value
175
+ description = f"{node.kind.value}: {node.label}"
176
+ if node.tombstoned:
177
+ description += " (garbage collected)"
178
+ # Surface, don't hide, when this step's summary skipped real
179
+ # branches (ADR 0002 §3 item 3): this function documents that it
180
+ # follows one dominant path through a DAG, which is only honest
181
+ # if a reader can tell *from the output itself* that there was
182
+ # more to see, not just from reading this function's docstring.
183
+ other_parents = branch_counts.get(node.id, 0) - 1
184
+ if other_parents > 0:
185
+ plural = "path" if other_parents == 1 else "paths"
186
+ description += f" (+{other_parents} other {plural} converge here, see .graph())"
187
+ steps.append(
188
+ ExplanationStep(
189
+ description=description,
190
+ confidence=confidence,
191
+ location=node.location,
192
+ kind=node.kind.value,
193
+ )
194
+ )
195
+ return steps
196
+
197
+
198
+ def snapshot(graph: ProvenanceGraph | None = None) -> str:
199
+ """Persist a graph as JSON-lines (ADR §05, §12). Defaults to the
200
+ shared process graph. Explicit and opt-in -- capture itself never
201
+ writes to disk on its own."""
202
+ return _serialize.dumps(graph if graph is not None else default_graph())
203
+
204
+
205
+ def restore(data: str) -> ProvenanceGraph:
206
+ """Rebuild a read-only replay graph from a snapshot() (ADR §14).
207
+ Replayed nodes carry their original metadata but, having outlived
208
+ the process that captured them, behave as tombstones -- there is
209
+ no live object left to hold a weakref to."""
210
+ return _serialize.loads(data)
whytrail/_repr.py ADDED
@@ -0,0 +1,27 @@
1
+ """Shared bounded, never-raising repr helper.
2
+
3
+ whytrail's core promise (ADR §19) is that it never raises from inside
4
+ its own machinery, even when the object being described has a broken
5
+ or hostile __repr__. Every place that needs a short label for an
6
+ arbitrary object goes through here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import reprlib
12
+ import typing as t
13
+
14
+ _repr = reprlib.Repr()
15
+ _repr.maxstring = 100
16
+ _repr.maxother = 100
17
+ _repr.maxlist = 8
18
+ _repr.maxdict = 8
19
+ _repr.maxtuple = 8
20
+ _repr.maxset = 8
21
+
22
+
23
+ def safe_repr(value: t.Any) -> str:
24
+ try:
25
+ return _repr.repr(value)
26
+ except Exception: # noqa: BLE001 - deliberately broad, see module docstring
27
+ return f"<unrepresentable {type(value).__name__}>"
File without changes
@@ -0,0 +1,74 @@
1
+ """whytrail CLI (ADR §14 -- v2.0): `whytrail run script.py` runs a script
2
+ and, if it raises, prints why() instead of a bare traceback.
3
+
4
+ Deliberately the only subcommand for now -- a CLI that grows verbs
5
+ ahead of demonstrated need repeats the mistake ADR §10 rejected for
6
+ the Python API itself.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import runpy
14
+ import sys
15
+ import typing as t
16
+
17
+ import whytrail
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ parser = argparse.ArgumentParser(prog="whytrail", description="Python tells you where. whytrail tells you why.")
22
+ subparsers = parser.add_subparsers(dest="command", required=True)
23
+
24
+ run_parser = subparsers.add_parser(
25
+ "run", help="Run a script; on an uncaught exception, print why() instead of a bare traceback."
26
+ )
27
+ run_parser.add_argument("script", help="path to the Python script to run")
28
+ run_parser.add_argument("script_args", nargs=argparse.REMAINDER, help="arguments passed to the script")
29
+ run_parser.add_argument("--json", action="store_true", help="print the explanation as JSON")
30
+ run_parser.add_argument("--graph", action="store_true", help="also print a Mermaid provenance graph")
31
+
32
+ return parser
33
+
34
+
35
+ def main(argv: list[str] | None = None) -> int:
36
+ parser = build_parser()
37
+ args = parser.parse_args(argv)
38
+ if args.command == "run":
39
+ return _run(args)
40
+ parser.print_help()
41
+ return 1
42
+
43
+
44
+ def _run(args: argparse.Namespace) -> int:
45
+ old_argv = sys.argv
46
+ sys.argv = [args.script, *args.script_args]
47
+ try:
48
+ runpy.run_path(args.script, run_name="__main__")
49
+ except SystemExit as exc:
50
+ return exc.code if isinstance(exc.code, int) else (0 if exc.code is None else 1)
51
+ except BaseException as exc: # noqa: BLE001 - the whole point is to catch whatever the script raises
52
+ explanation = whytrail.why(exc)
53
+ _report(explanation, as_json=args.json, with_graph=args.graph)
54
+ return 1
55
+ finally:
56
+ sys.argv = old_argv
57
+ return 0
58
+
59
+
60
+ def _report(explanation: "whytrail.Explanation", *, as_json: bool, with_graph: bool) -> None:
61
+ if as_json:
62
+ payload: dict[str, t.Any] = explanation.json()
63
+ if with_graph:
64
+ payload["graph"] = explanation.graph()
65
+ print(json.dumps(payload, indent=2))
66
+ return
67
+ print(explanation.text, file=sys.stderr)
68
+ if with_graph:
69
+ print(file=sys.stderr)
70
+ print(explanation.graph(), file=sys.stderr)
71
+
72
+
73
+ if __name__ == "__main__":
74
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,187 @@
1
+ """The result type every why() call returns -- one model, several
2
+ renderings (ADR §05)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import dataclasses
7
+ import typing as t
8
+
9
+ from .node import Confidence, Edge, Node
10
+
11
+ _STYLES = {
12
+ Confidence.EXPLICIT.value: "bold",
13
+ Confidence.INFERRED.value: "yellow",
14
+ Confidence.HEURISTIC.value: "dim yellow",
15
+ }
16
+
17
+
18
+ def _confidence_label(confidence: float) -> str:
19
+ if confidence >= Confidence.EXPLICIT.value:
20
+ return "explicit"
21
+ if confidence >= Confidence.INFERRED.value:
22
+ return "inferred"
23
+ if confidence >= Confidence.HEURISTIC.value:
24
+ return "heuristic"
25
+ return "unknown"
26
+
27
+
28
+ def _confidence_marker(confidence: float) -> str:
29
+ # Spelled out, not a symbol: an earlier version used ASCII markers
30
+ # (==/~~/..) that were legible only after reading the docs -- see
31
+ # ADR 0002 §3 item 2. Still plain ASCII deliberately: box-drawing
32
+ # or bracket-free glyphs have crashed stdout on a default Windows
33
+ # console (cp1252) before, and a debugging tool that crashes
34
+ # trying to print its own output would be absurd.
35
+ return f"[{_confidence_label(confidence)}]"
36
+
37
+
38
+ def _confidence_style(confidence: float) -> str:
39
+ return _STYLES.get(confidence, "dim")
40
+
41
+
42
+ @dataclasses.dataclass(slots=True)
43
+ class ExplanationStep:
44
+ """One causal hop in the chain, ordered root-cause first.
45
+
46
+ `locals` is a separate field from `description` on purpose: local
47
+ variables at an exception's origin frame are exactly the kind of
48
+ thing that can hold a password, an API key, or a customer record,
49
+ and anything that exports an Explanation off-box (Sentry, OTel, a
50
+ PR comment, an HTTP error response) needs a way to drop them
51
+ without hand-parsing text out of a human-readable string. See
52
+ Explanation.redacted() and ADR 0002 §3 item 5.
53
+ """
54
+
55
+ description: str
56
+ confidence: float = Confidence.EXPLICIT.value
57
+ location: str | None = None
58
+ kind: str = "value"
59
+ locals: dict[str, str] | None = None
60
+
61
+
62
+ @dataclasses.dataclass(slots=True)
63
+ class Explanation:
64
+ """Returned by every why() call. Honest by construction: an
65
+ Explanation with no steps says so plainly rather than fabricating a
66
+ chain (ADR §11)."""
67
+
68
+ subject: str
69
+ steps: list[ExplanationStep] = dataclasses.field(default_factory=list)
70
+ tracked: bool = True
71
+ nodes: list[Node] = dataclasses.field(default_factory=list)
72
+ edges: list[Edge] = dataclasses.field(default_factory=list)
73
+
74
+ @property
75
+ def confidence(self) -> float:
76
+ if not self.steps:
77
+ return Confidence.UNKNOWN.value
78
+ return min(step.confidence for step in self.steps)
79
+
80
+ @property
81
+ def known(self) -> bool:
82
+ return bool(self.steps)
83
+
84
+ @property
85
+ def text(self) -> str:
86
+ if not self.steps:
87
+ return (
88
+ f"why({self.subject}): unknown -- no provenance captured.\n"
89
+ f" This value was never tracked. Wrap it with whytrail.track(), "
90
+ f"@whytrail.tracked, or raise it as an exception to get an answer."
91
+ )
92
+ lines = [f"why({self.subject}):"]
93
+ for step in self.steps:
94
+ marker = _confidence_marker(step.confidence)
95
+ loc = f" [{step.location}]" if step.location else ""
96
+ lines.append(f" {marker} {step.description}{loc}")
97
+ if step.locals:
98
+ lines.append(f" locals: {_format_locals(step.locals)}")
99
+ return "\n".join(lines)
100
+
101
+ def __str__(self) -> str:
102
+ return self.text
103
+
104
+ def __repr__(self) -> str:
105
+ return (
106
+ f"<Explanation subject={self.subject!r} steps={len(self.steps)} "
107
+ f"confidence={_confidence_label(self.confidence)} tracked={self.tracked}>"
108
+ )
109
+
110
+ def rich(self) -> t.Any:
111
+ """Render as a rich.tree.Tree. Requires the 'rich' extra."""
112
+ try:
113
+ from rich.text import Text
114
+ from rich.tree import Tree
115
+ except ImportError as exc: # pragma: no cover - exercised via extras test
116
+ raise ImportError(
117
+ "Rich rendering needs the 'rich' extra: pip install whytrail[rich]"
118
+ ) from exc
119
+
120
+ tree = Tree(f"why({self.subject})")
121
+ if not self.steps:
122
+ tree.add(Text("unknown -- no provenance captured", style="dim italic"))
123
+ return tree
124
+ for step in self.steps:
125
+ label = Text(step.description, style=_confidence_style(step.confidence))
126
+ if step.location:
127
+ label.append(f" {step.location}", style="dim")
128
+ label.append(f" ({_confidence_label(step.confidence)})", style="dim italic")
129
+ if step.locals:
130
+ label.append(f"\n locals: {_format_locals(step.locals)}", style="dim")
131
+ tree.add(label)
132
+ return tree
133
+
134
+ def json(self) -> dict[str, t.Any]:
135
+ return {
136
+ "subject": self.subject,
137
+ "tracked": self.tracked,
138
+ "known": self.known,
139
+ "confidence": self.confidence,
140
+ "steps": [
141
+ {
142
+ "description": s.description,
143
+ "confidence": s.confidence,
144
+ "confidence_label": _confidence_label(s.confidence),
145
+ "location": s.location,
146
+ "kind": s.kind,
147
+ "locals": s.locals,
148
+ }
149
+ for s in self.steps
150
+ ],
151
+ }
152
+
153
+ def redacted(self) -> "Explanation":
154
+ """A copy with every step's locals stripped -- the one-line
155
+ way for any integration that exports off-box (Sentry, OTel, a
156
+ CI comment, an HTTP error response) to get a safe-to-share
157
+ version. Everything else (description, location, confidence,
158
+ the causal chain itself) is preserved; only the raw local
159
+ variable values are dropped, since those are the one thing
160
+ that can plausibly contain a secret.
161
+ """
162
+ return dataclasses.replace(
163
+ self,
164
+ steps=[dataclasses.replace(step, locals=None) for step in self.steps],
165
+ )
166
+
167
+ def graph(self) -> str:
168
+ """Render the traversed provenance subgraph as a Mermaid
169
+ flowchart (ADR §12, Fig. 1)."""
170
+ if not self.nodes:
171
+ return f'graph TD\n A["{_escape(self.subject)} -- no provenance captured"]'
172
+ lines = ["graph TD"]
173
+ for node in self.nodes:
174
+ marker = " (tombstoned)" if node.tombstoned else ""
175
+ lines.append(f' N{node.id}["{node.kind.value}: {_escape(node.label)}{marker}"]')
176
+ for edge in self.edges:
177
+ arrow = "-->" if edge.confidence >= Confidence.EXPLICIT.value else "-.->"
178
+ lines.append(f" N{edge.source} {arrow}|{edge.kind.value}| N{edge.target}")
179
+ return "\n".join(lines)
180
+
181
+
182
+ def _escape(label: str) -> str:
183
+ return label.replace('"', "'").replace("\n", " ")
184
+
185
+
186
+ def _format_locals(locals_: dict[str, str]) -> str:
187
+ return ", ".join(f"{name}={value}" for name, value in locals_.items())
whytrail/core/graph.py ADDED
@@ -0,0 +1,160 @@
1
+ """The provenance graph store (ADR §08, §12).
2
+
3
+ An append-only, bounded-retention DAG. Nodes never hold a strong reference
4
+ to the object they describe -- only an id() and a bounded repr snapshot --
5
+ so the graph can never be the reason an object outlives its natural
6
+ lifetime. When a tracked object is garbage collected its node is
7
+ tombstoned rather than silently disappearing (ADR §11's honesty
8
+ principle applied to memory management).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import collections
14
+ import threading
15
+ import weakref
16
+ import typing as t
17
+
18
+ from .node import Confidence, Edge, EdgeKind, Node, NodeKind
19
+
20
+ DEFAULT_MAX_NODES = 10_000
21
+
22
+
23
+ class ProvenanceGraph:
24
+ def __init__(self, *, max_nodes: int = DEFAULT_MAX_NODES) -> None:
25
+ self.max_nodes = max_nodes
26
+ self._lock = threading.RLock()
27
+ self._nodes: "collections.OrderedDict[int, Node]" = collections.OrderedDict()
28
+ self._edges: list[Edge] = []
29
+ self._edges_by_target: dict[int, list[Edge]] = collections.defaultdict(list)
30
+ self._edges_by_source: dict[int, list[Edge]] = collections.defaultdict(list)
31
+ # id(obj) -> node id. Never holds a strong reference to obj itself.
32
+ self._object_to_node: dict[int, int] = {}
33
+ # weakref.finalize isn't usefully genericizable here (its type
34
+ # parameter tracks the finalized object's type, which varies
35
+ # per call site) -- t.Any is the correct escape hatch, not a
36
+ # laziness shortcut.
37
+ self._finalizers: dict[int, t.Any] = {}
38
+
39
+ # -- writing ---------------------------------------------------------
40
+
41
+ def add_node(
42
+ self,
43
+ kind: NodeKind,
44
+ label: str,
45
+ *,
46
+ obj: t.Any = None,
47
+ location: str | None = None,
48
+ thread: str | None = None,
49
+ metadata: dict[str, t.Any] | None = None,
50
+ ) -> Node:
51
+ node = Node.create(kind, label, location=location, thread=thread, metadata=metadata)
52
+ with self._lock:
53
+ self._nodes[node.id] = node
54
+ if obj is not None:
55
+ self._track_identity(obj, node.id)
56
+ self._evict_if_needed()
57
+ return node
58
+
59
+ def add_edge(
60
+ self,
61
+ source: Node | int,
62
+ target: Node | int,
63
+ kind: EdgeKind,
64
+ *,
65
+ confidence: float = Confidence.EXPLICIT.value,
66
+ note: str | None = None,
67
+ ) -> Edge:
68
+ source_id = source.id if isinstance(source, Node) else source
69
+ target_id = target.id if isinstance(target, Node) else target
70
+ edge = Edge(source=source_id, target=target_id, kind=kind, confidence=confidence, note=note)
71
+ with self._lock:
72
+ self._edges.append(edge)
73
+ self._edges_by_target[target_id].append(edge)
74
+ self._edges_by_source[source_id].append(edge)
75
+ return edge
76
+
77
+ def _track_identity(self, obj: t.Any, node_id: int) -> None:
78
+ key = id(obj)
79
+ self._object_to_node[key] = node_id
80
+ try:
81
+ fin = weakref.finalize(obj, self._on_collected, key, node_id)
82
+ # atexit is a real, documented, settable property on
83
+ # weakref.finalize at runtime; typeshed's stub omits it
84
+ # from __slots__, which is a stub gap, not a real error.
85
+ fin.atexit = False # type: ignore[misc]
86
+ self._finalizers[node_id] = fin
87
+ except TypeError:
88
+ # Object doesn't support weak references (e.g. plain int, str,
89
+ # tuple). We keep the id()->node mapping without a collection
90
+ # hook; it may go stale under id() reuse in long-running
91
+ # processes. Documented limitation, not silently pretended away.
92
+ pass
93
+
94
+ def _on_collected(self, key: int, node_id: int) -> None:
95
+ with self._lock:
96
+ if self._object_to_node.get(key) == node_id:
97
+ del self._object_to_node[key]
98
+ node = self._nodes.get(node_id)
99
+ if node is not None:
100
+ node.tombstone()
101
+ self._finalizers.pop(node_id, None)
102
+
103
+ def _evict_if_needed(self) -> None:
104
+ while len(self._nodes) > self.max_nodes:
105
+ oldest_id, _ = self._nodes.popitem(last=False)
106
+ self._edges_by_target.pop(oldest_id, None)
107
+ self._edges_by_source.pop(oldest_id, None)
108
+ self._edges = [e for e in self._edges if oldest_id not in (e.source, e.target)]
109
+
110
+ # -- reading -----------------------------------------------------------
111
+
112
+ def node_for(self, obj: t.Any) -> Node | None:
113
+ with self._lock:
114
+ node_id = self._object_to_node.get(id(obj))
115
+ if node_id is None:
116
+ return None
117
+ return self._nodes.get(node_id)
118
+
119
+ def get(self, node_id: int) -> Node | None:
120
+ return self._nodes.get(node_id)
121
+
122
+ def ancestors(self, node_id: int, *, max_depth: int = 8) -> tuple[list[Node], list[Edge]]:
123
+ """Walk causal edges backward from node_id, breadth-first, bounded
124
+ by max_depth. Returns the visited nodes and the edges connecting
125
+ them, in traversal order."""
126
+ visited_nodes: dict[int, Node] = {}
127
+ visited_edges: list[Edge] = []
128
+ frontier = [node_id]
129
+ depth = 0
130
+ with self._lock:
131
+ start = self._nodes.get(node_id)
132
+ if start is not None:
133
+ visited_nodes[node_id] = start
134
+ while frontier and depth < max_depth:
135
+ next_frontier: list[int] = []
136
+ for nid in frontier:
137
+ for edge in self._edges_by_target.get(nid, ()):
138
+ visited_edges.append(edge)
139
+ if edge.source not in visited_nodes:
140
+ src = self._nodes.get(edge.source)
141
+ if src is not None:
142
+ visited_nodes[edge.source] = src
143
+ next_frontier.append(edge.source)
144
+ frontier = next_frontier
145
+ depth += 1
146
+ return list(visited_nodes.values()), visited_edges
147
+
148
+ def __len__(self) -> int:
149
+ return len(self._nodes)
150
+
151
+ def clear(self) -> None:
152
+ """Drop all nodes and edges. Mainly a test/notebook convenience;
153
+ production code should rely on max_nodes retention instead."""
154
+ with self._lock:
155
+ self._nodes.clear()
156
+ self._edges.clear()
157
+ self._edges_by_target.clear()
158
+ self._edges_by_source.clear()
159
+ self._object_to_node.clear()
160
+ self._finalizers.clear()