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 +210 -0
- whytrail/_repr.py +27 -0
- whytrail/cli/__init__.py +0 -0
- whytrail/cli/__main__.py +74 -0
- whytrail/core/__init__.py +0 -0
- whytrail/core/explanation.py +187 -0
- whytrail/core/graph.py +160 -0
- whytrail/core/node.py +84 -0
- whytrail/core/serialize.py +117 -0
- whytrail/explainers/__init__.py +0 -0
- whytrail/explainers/builtin.py +125 -0
- whytrail/otel.py +87 -0
- whytrail/propagation.py +104 -0
- whytrail/protocols.py +48 -0
- whytrail/py.typed +0 -0
- whytrail/registry.py +107 -0
- whytrail/runtime/__init__.py +0 -0
- whytrail/runtime/capture.py +162 -0
- whytrail/runtime/context.py +128 -0
- whytrail/runtime/monitoring.py +154 -0
- whytrail-0.1.0.dist-info/METADATA +185 -0
- whytrail-0.1.0.dist-info/RECORD +25 -0
- whytrail-0.1.0.dist-info/WHEEL +4 -0
- whytrail-0.1.0.dist-info/entry_points.txt +2 -0
- whytrail-0.1.0.dist-info/licenses/LICENSE +21 -0
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__}>"
|
whytrail/cli/__init__.py
ADDED
|
File without changes
|
whytrail/cli/__main__.py
ADDED
|
@@ -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()
|