vizcontrol 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.
Files changed (79) hide show
  1. vizcontrol/__init__.py +10 -0
  2. vizcontrol/__main__.py +3 -0
  3. vizcontrol/analysis.py +294 -0
  4. vizcontrol/capture.py +622 -0
  5. vizcontrol/cli.py +131 -0
  6. vizcontrol/config.py +105 -0
  7. vizcontrol/pytest_plugin.py +103 -0
  8. vizcontrol/runners.py +219 -0
  9. vizcontrol/serialize.py +311 -0
  10. vizcontrol/server.py +298 -0
  11. vizcontrol/store.py +291 -0
  12. vizcontrol/viewer/static/assets/index-D5C41TsO.js +43 -0
  13. vizcontrol/viewer/static/assets/index-Dsv2gkLb.css +1 -0
  14. vizcontrol/viewer/static/assets/inter-cyrillic-400-normal-BLGc9T1a.woff2 +0 -0
  15. vizcontrol/viewer/static/assets/inter-cyrillic-400-normal-ZzOtrSSW.woff +0 -0
  16. vizcontrol/viewer/static/assets/inter-cyrillic-600-normal-BGBWG807.woff2 +0 -0
  17. vizcontrol/viewer/static/assets/inter-cyrillic-600-normal-BuzJQFbW.woff +0 -0
  18. vizcontrol/viewer/static/assets/inter-cyrillic-700-normal-Bc8_fv8J.woff +0 -0
  19. vizcontrol/viewer/static/assets/inter-cyrillic-700-normal-bGtGjVdZ.woff2 +0 -0
  20. vizcontrol/viewer/static/assets/inter-cyrillic-ext-400-normal-BPnxn4xp.woff +0 -0
  21. vizcontrol/viewer/static/assets/inter-cyrillic-ext-400-normal-Dc4VJyIJ.woff2 +0 -0
  22. vizcontrol/viewer/static/assets/inter-cyrillic-ext-600-normal-Bt9VVOA-.woff +0 -0
  23. vizcontrol/viewer/static/assets/inter-cyrillic-ext-600-normal-CaqZN2hq.woff2 +0 -0
  24. vizcontrol/viewer/static/assets/inter-cyrillic-ext-700-normal-Ced3hgUT.woff +0 -0
  25. vizcontrol/viewer/static/assets/inter-cyrillic-ext-700-normal-ClVoMEGq.woff2 +0 -0
  26. vizcontrol/viewer/static/assets/inter-greek-400-normal-BZzXV7-1.woff +0 -0
  27. vizcontrol/viewer/static/assets/inter-greek-400-normal-DxZsaF_h.woff2 +0 -0
  28. vizcontrol/viewer/static/assets/inter-greek-600-normal-CwicyhtI.woff +0 -0
  29. vizcontrol/viewer/static/assets/inter-greek-600-normal-Dhlb-90d.woff2 +0 -0
  30. vizcontrol/viewer/static/assets/inter-greek-700-normal-BRYTaFLL.woff +0 -0
  31. vizcontrol/viewer/static/assets/inter-greek-700-normal-Cxpycf-U.woff2 +0 -0
  32. vizcontrol/viewer/static/assets/inter-greek-ext-400-normal-Bput3-QP.woff2 +0 -0
  33. vizcontrol/viewer/static/assets/inter-greek-ext-400-normal-DCpCPQOf.woff +0 -0
  34. vizcontrol/viewer/static/assets/inter-greek-ext-600-normal-C9WLioJ8.woff +0 -0
  35. vizcontrol/viewer/static/assets/inter-greek-ext-600-normal-Cnui8OiR.woff2 +0 -0
  36. vizcontrol/viewer/static/assets/inter-greek-ext-700-normal-DXvzx4Na.woff +0 -0
  37. vizcontrol/viewer/static/assets/inter-greek-ext-700-normal-SzCdnevJ.woff2 +0 -0
  38. vizcontrol/viewer/static/assets/inter-latin-400-normal-BOOGhInR.woff2 +0 -0
  39. vizcontrol/viewer/static/assets/inter-latin-400-normal-gitzw0hO.woff +0 -0
  40. vizcontrol/viewer/static/assets/inter-latin-600-normal-B5cFAncS.woff +0 -0
  41. vizcontrol/viewer/static/assets/inter-latin-600-normal-D273HNI0.woff2 +0 -0
  42. vizcontrol/viewer/static/assets/inter-latin-700-normal-B8MtJ_2k.woff +0 -0
  43. vizcontrol/viewer/static/assets/inter-latin-700-normal-Sckx8rpT.woff2 +0 -0
  44. vizcontrol/viewer/static/assets/inter-latin-ext-400-normal-C1t-h-pH.woff +0 -0
  45. vizcontrol/viewer/static/assets/inter-latin-ext-400-normal-hnt3BR84.woff2 +0 -0
  46. vizcontrol/viewer/static/assets/inter-latin-ext-600-normal-BnYJhD27.woff2 +0 -0
  47. vizcontrol/viewer/static/assets/inter-latin-ext-600-normal-CAF0vJDd.woff +0 -0
  48. vizcontrol/viewer/static/assets/inter-latin-ext-700-normal-6V9MnIL5.woff +0 -0
  49. vizcontrol/viewer/static/assets/inter-latin-ext-700-normal-CzikT_rs.woff2 +0 -0
  50. vizcontrol/viewer/static/assets/inter-vietnamese-400-normal-BUNmGMP1.woff +0 -0
  51. vizcontrol/viewer/static/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
  52. vizcontrol/viewer/static/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
  53. vizcontrol/viewer/static/assets/inter-vietnamese-600-normal-Cm6aH8_k.woff +0 -0
  54. vizcontrol/viewer/static/assets/inter-vietnamese-700-normal-CGpBpxLq.woff2 +0 -0
  55. vizcontrol/viewer/static/assets/inter-vietnamese-700-normal-dAnkLlTo.woff +0 -0
  56. vizcontrol/viewer/static/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  57. vizcontrol/viewer/static/assets/jetbrains-mono-cyrillic-400-normal-CIEtRijs.woff +0 -0
  58. vizcontrol/viewer/static/assets/jetbrains-mono-cyrillic-600-normal-BB5xRk30.woff +0 -0
  59. vizcontrol/viewer/static/assets/jetbrains-mono-cyrillic-600-normal-EVf6-Yzo.woff2 +0 -0
  60. vizcontrol/viewer/static/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  61. vizcontrol/viewer/static/assets/jetbrains-mono-greek-400-normal-CNojz0t3.woff +0 -0
  62. vizcontrol/viewer/static/assets/jetbrains-mono-greek-600-normal-3KAm9r92.woff +0 -0
  63. vizcontrol/viewer/static/assets/jetbrains-mono-greek-600-normal-H7WoG9Et.woff2 +0 -0
  64. vizcontrol/viewer/static/assets/jetbrains-mono-latin-400-normal-B11XCQ5g.woff +0 -0
  65. vizcontrol/viewer/static/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  66. vizcontrol/viewer/static/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
  67. vizcontrol/viewer/static/assets/jetbrains-mono-latin-600-normal-DXv3JcnD.woff +0 -0
  68. vizcontrol/viewer/static/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  69. vizcontrol/viewer/static/assets/jetbrains-mono-latin-ext-400-normal-DHMOLxAi.woff +0 -0
  70. vizcontrol/viewer/static/assets/jetbrains-mono-latin-ext-600-normal-BfB_LPfz.woff2 +0 -0
  71. vizcontrol/viewer/static/assets/jetbrains-mono-latin-ext-600-normal-DiQlUoE6.woff +0 -0
  72. vizcontrol/viewer/static/assets/jetbrains-mono-vietnamese-400-normal-BeU3T8wf.woff +0 -0
  73. vizcontrol/viewer/static/assets/jetbrains-mono-vietnamese-600-normal-CSpzN4cI.woff +0 -0
  74. vizcontrol/viewer/static/index.html +13 -0
  75. vizcontrol-0.1.0.dist-info/METADATA +65 -0
  76. vizcontrol-0.1.0.dist-info/RECORD +79 -0
  77. vizcontrol-0.1.0.dist-info/WHEEL +4 -0
  78. vizcontrol-0.1.0.dist-info/entry_points.txt +5 -0
  79. vizcontrol-0.1.0.dist-info/licenses/LICENSE +661 -0
vizcontrol/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """VizControl: observe real Python executions and explore them as graphs."""
2
+
3
+ __version__ = "0.1.0"
4
+ FORMAT_VERSION = "1.0"
5
+
6
+ from vizcontrol.config import Config, load_config # noqa: E402
7
+ from vizcontrol.runners import trace, traced # noqa: E402
8
+ from vizcontrol.serialize import register # noqa: E402
9
+
10
+ __all__ = ["Config", "load_config", "register", "trace", "traced", "__version__"]
vizcontrol/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from vizcontrol.cli import main
2
+
3
+ raise SystemExit(main())
vizcontrol/analysis.py ADDED
@@ -0,0 +1,294 @@
1
+ """Turn stored, observed events into what the viewer navigates. Pure functions over a Reader.
2
+
3
+ Everything computed here (stages, groups, flow links, change attribution) is *inferred* and says so.
4
+ See docs/components/analysis.md.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fnmatch
10
+ import json
11
+ from collections import defaultdict
12
+
13
+ from vizcontrol.serialize import preview
14
+ from vizcontrol.store import Reader
15
+
16
+ # -- nodes --------------------------------------------------------------------------------------------------------
17
+
18
+ def _duration(c: dict) -> int | None:
19
+ return c["end_ns"] - c["start_ns"] if c.get("end_ns") is not None else None
20
+
21
+
22
+ def call_node(c: dict, stage: str | None = None) -> dict:
23
+ """The JSON shape of one call, as the viewer receives it."""
24
+ return {
25
+ "type": "call", "id": c["id"], "parent_id": c["parent_id"], "code_id": c["code_id"], "qualname": c["qualname"],
26
+ "module": c["module"],
27
+ "kind": c["kind"], "observed": bool(c["observed"]), "outcome": c["outcome"], "start_ns": c["start_ns"],
28
+ "duration_ns": _duration(c), "end_exact": bool(c["end_exact"]), "children": c["children"],
29
+ "depth": c["depth"], "seq": c["seq_start"], "call_line": c["call_line"], "exception_id": c["exception_id"],
30
+ "thread_id": c["thread_id"], "file": c.get("relpath") or c.get("path"), "first_line": c["first_line"],
31
+ "stage": stage,
32
+ }
33
+
34
+
35
+ def subtree(reader: Reader, call_id: int | None = None, depth: int = 1, limit: int = 200,
36
+ stage_of=None) -> list[dict]:
37
+ """Children of `call_id` (roots if None), `depth` levels deep. Siblings running the same code fold into one
38
+ group node (FR-ANA-3): a 10,000-iteration loop is one node until expanded with `group_members`."""
39
+ groups = reader.grouped_children(call_id)
40
+ singles = [g["first_id"] for g in groups[:limit] if g["count"] == 1]
41
+ by_id = {c["id"]: c for c in reader.calls(singles)}
42
+ groups_code = {g["code_id"] for g in groups[:limit] if g["count"] > 1}
43
+ samples = {}
44
+ if groups_code: # one representative call per group, for its names and kind
45
+ rows = reader.calls([g["first_id"] for g in groups[:limit] if g["count"] > 1])
46
+ samples = {r["code_id"]: r for r in rows}
47
+ out = []
48
+ for g in groups[:limit]:
49
+ if g["count"] == 1:
50
+ c = by_id[g["first_id"]]
51
+ node = call_node(c, stage_of(c["module"], c["observed"]) if stage_of else None)
52
+ if depth > 1 and c["children"]:
53
+ node["items"] = subtree(reader, c["id"], depth - 1, limit, stage_of)
54
+ else:
55
+ s = samples[g["code_id"]]
56
+ node = {
57
+ "type": "group", "parent_id": call_id, "code_id": g["code_id"], "qualname": s["qualname"],
58
+ "module": s["module"], "kind": s["kind"], "observed": bool(s["observed"]), "count": g["count"],
59
+ "outcomes": {"raised": g["raised"], "unfinished": g["unfinished"],
60
+ "ok": g["count"] - g["raised"] - g["unfinished"]},
61
+ "total_ns": g["total_ns"], "first_call_id": g["first_id"], "seq": g["first_seq"],
62
+ "stage": stage_of(s["module"], s["observed"]) if stage_of else None, "inferred": True,
63
+ }
64
+ out.append(node)
65
+ if len(groups) > limit:
66
+ out.append({"type": "more", "parent_id": call_id, "remaining": len(groups) - limit})
67
+ return out
68
+
69
+
70
+ def group_members(reader: Reader, call_id: int | None, code_id: int, offset: int = 0, limit: int = 200) -> list[dict]:
71
+ return [call_node(c) for c in reader.group_members(call_id, code_id, offset, limit)]
72
+
73
+
74
+ # -- stages (Level 1) ---------------------------------------------------------------------------------------------
75
+
76
+ def stage_namer(config: dict):
77
+ """Map (module, observed) to a stage name: user rules first, then the module path cut to `stage_depth`."""
78
+ rules = config.get("stages") or {}
79
+ depth = config.get("stage_depth", 2)
80
+ cache: dict[tuple, str] = {}
81
+
82
+ def stage_of(module: str | None, observed) -> str:
83
+ key = (module, bool(observed))
84
+ name = cache.get(key)
85
+ if name is None:
86
+ name = "?" if module is None else next(
87
+ (stage for stage, globs in rules.items()
88
+ if any(fnmatch.fnmatch(module, g) or (g.endswith(".*") and module == g[:-2]) for g in globs)),
89
+ None)
90
+ if name is None:
91
+ parts = module.split(".")
92
+ name = ".".join(parts[: depth if observed else 1]) # libraries group under their top package
93
+ cache[key] = name
94
+ return name
95
+
96
+ return stage_of
97
+
98
+
99
+ def stages(reader: Reader) -> dict:
100
+ """Stage graph for the Level-1 overview (FR-ANA-2). One pass over all calls."""
101
+ stage_of = stage_namer(reader.run()["config"])
102
+ rows = reader.all_calls()
103
+ stage_by_call: dict[int, str] = {}
104
+ dur: dict[int, int] = {}
105
+ child_dur: dict[int, int] = defaultdict(int)
106
+ nodes: dict[str, dict] = {}
107
+ edges: dict[tuple[str, str], dict] = {}
108
+ roots: list[str] = []
109
+ for cid, parent, start, end, outcome, seq, module, observed, kind in rows:
110
+ stage = stage_by_call[cid] = stage_of(module, observed)
111
+ d = dur[cid] = end - start if end is not None else 0
112
+ n = nodes.get(stage)
113
+ if n is None:
114
+ n = nodes[stage] = {"name": stage, "calls": 0, "total_ns": 0, "self_ns": 0, "raised": 0,
115
+ "unfinished": 0, "first_seq": seq, "first_call": cid, "observed": bool(observed),
116
+ "modules": set(), "first_call_is_import": kind in ("module", "class")}
117
+ if n["first_call_is_import"] and kind not in ("module", "class"):
118
+ # where a stage "starts" for a reader is its first real call, not the import that defined it
119
+ n["first_call"], n["first_call_is_import"] = cid, False
120
+ n["calls"] += 1
121
+ n["raised"] += outcome == "raised"
122
+ n["unfinished"] += outcome == "unfinished"
123
+ n["modules"].add(module)
124
+ if parent is None:
125
+ roots.append(stage)
126
+ n["total_ns"] += d
127
+ else:
128
+ child_dur[parent] += d
129
+ pstage = stage_by_call.get(parent)
130
+ if pstage is not None and pstage != stage:
131
+ e = edges.get((pstage, stage))
132
+ if e is None:
133
+ e = edges[(pstage, stage)] = {"from": pstage, "to": stage, "calls": 0, "total_ns": 0,
134
+ "first_seq": seq, "calls_below": 0}
135
+ e["calls"] += 1
136
+ e["total_ns"] += d
137
+ nodes[stage]["total_ns"] += d # time spent in a stage counts from where it is entered
138
+ for cid, stage in stage_by_call.items():
139
+ nodes[stage]["self_ns"] += max(dur[cid] - child_dur.get(cid, 0), 0)
140
+ # subtree sizes (children come after parents in seq order): how much work each stage crossing leads to
141
+ size: dict[int, int] = defaultdict(lambda: 1)
142
+ for cid, parent, *_ in reversed(rows):
143
+ if parent is not None:
144
+ size[parent] += size[cid]
145
+ for cid, parent, *_ in rows:
146
+ if parent is not None and stage_by_call.get(parent) not in (None, stage_by_call[cid]):
147
+ edges[(stage_by_call[parent], stage_by_call[cid])]["calls_below"] += size[cid]
148
+ for n in nodes.values():
149
+ n["modules"] = sorted(m for m in n["modules"] if m)
150
+ del n["first_call_is_import"]
151
+ return {"nodes": sorted(nodes.values(), key=lambda n: n["first_seq"]),
152
+ "edges": sorted(edges.values(), key=lambda e: e["first_seq"]),
153
+ "main_path": _main_path(roots, edges), "inferred": True}
154
+
155
+
156
+ def _main_path(roots: list[str], edges: dict[tuple[str, str], dict]) -> list[str]:
157
+ """From the entry stage, keep following the outgoing edge that leads to the most calls (its subtree size; time
158
+ only breaks ties) to a stage not yet on the path. Call counts are deterministic, timings are noise in small runs."""
159
+ if not roots:
160
+ return []
161
+ out: dict[str, list[dict]] = defaultdict(list)
162
+ for e in edges.values():
163
+ out[e["from"]].append(e)
164
+ path = [roots[0]]
165
+ while True:
166
+ nxt = [e for e in out[path[-1]] if e["to"] not in path]
167
+ if not nxt:
168
+ return path
169
+ path.append(max(nxt, key=lambda e: (e["calls_below"], e["total_ns"], -e["first_seq"]))["to"])
170
+
171
+
172
+ # -- values and data flow -----------------------------------------------------------------------------------------
173
+
174
+ def value_diff(a: dict, b: dict, path: str = "") -> list[dict]:
175
+ """Field-level differences between two encoded value trees. Truncated parts compare as 'unknown', never equal."""
176
+ if a == b:
177
+ return []
178
+ ta, tb = a.get("t"), b.get("t")
179
+ if "trunc" in (ta, tb) or "redacted" in (ta, tb):
180
+ return [{"path": path or ".", "op": "unknown"}]
181
+ if ta == tb == "obj" and a.get("type") == b.get("type"):
182
+ return _diff_maps(a["attrs"], b["attrs"], path, ".")
183
+ if ta == tb == "dict":
184
+ def keyed(d):
185
+ return {preview(k, 40): v for k, v in d["items"]}
186
+ return _diff_maps(keyed(a), keyed(b), path, "[]")
187
+ if ta == tb and ta in ("list", "tuple") and "len" not in a and "len" not in b:
188
+ changes = []
189
+ for i in range(max(len(a["items"]), len(b["items"]))):
190
+ p = f"{path}[{i}]"
191
+ if i >= len(a["items"]):
192
+ changes.append({"path": p, "op": "added", "after": preview(b["items"][i])})
193
+ elif i >= len(b["items"]):
194
+ changes.append({"path": p, "op": "removed", "before": preview(a["items"][i])})
195
+ else:
196
+ changes.extend(value_diff(a["items"][i], b["items"][i], p))
197
+ return changes
198
+ if ta == tb and a.get("oid") is not None and a.get("oid") == b.get("oid") and ta in ("list", "set", "dict"):
199
+ return [{"path": path or ".", "op": "unknown"}] # truncated containers: can't tell what changed
200
+ return [{"path": path or ".", "op": "changed", "before": preview(a), "after": preview(b)}]
201
+
202
+
203
+ def _diff_maps(a: dict, b: dict, path: str, style: str) -> list[dict]:
204
+ changes = []
205
+ for key in list(a) + [k for k in b if k not in a]:
206
+ p = f"{path}.{key}" if style == "." else f"{path}[{key}]"
207
+ if key not in b:
208
+ changes.append({"path": p, "op": "removed", "before": preview(a[key])})
209
+ elif key not in a:
210
+ changes.append({"path": p, "op": "added", "after": preview(b[key])})
211
+ else:
212
+ changes.extend(value_diff(a[key], b[key], p))
213
+ return changes
214
+
215
+
216
+ def object_flow(reader: Reader, object_id: int) -> dict:
217
+ """Where one object was seen (as an argument, return value, local...), how it moved and what changed.
218
+ ponytail: only top-level bindings are searched; an object nested inside another value is not found yet."""
219
+ rows = reader.object_bindings(object_id)
220
+ appearances, links, changes = [], [], []
221
+ prev = None
222
+ for r in rows:
223
+ data = json.loads(r["data"])
224
+ appearances.append({"call_id": r["call_id"], "qualname": r["qualname"], "seq": r["seq"], "role": r["kind"],
225
+ "name": r["name"], "value_id": r["value_id"], "preview": preview(data)})
226
+ if prev is not None:
227
+ if prev[0]["call_id"] != r["call_id"]:
228
+ links.append({"from": prev[0]["call_id"], "to": r["call_id"], "seq": r["seq"], "inferred": True})
229
+ diff = value_diff(prev[1], data)
230
+ if diff:
231
+ changes.append({"seq_before": prev[0]["seq"], "seq_after": r["seq"], "call_before": prev[0]["call_id"],
232
+ "call_after": r["call_id"], "changes": diff, "inferred": True})
233
+ prev = (r, data)
234
+ return {"object_id": object_id, "type": rows and json.loads(rows[0]["data"]).get("type"),
235
+ "appearances": appearances, "links": links, "changes": changes}
236
+
237
+
238
+ # -- errors -------------------------------------------------------------------------------------------------------
239
+
240
+ def _binding_view(b: dict) -> dict:
241
+ data = json.loads(b["data"])
242
+ return {"name": b["name"], "kind": b["kind"], "value_id": b["value_id"], "object_id": b["object_id"],
243
+ "type": b["type"], "preview": preview(data)}
244
+
245
+
246
+ def error_path(reader: Reader) -> dict | None:
247
+ """The exception that ended the run: where it started, how execution got there, how it propagated,
248
+ and the latest observed changes to the objects in scope (FR-ANA-5)."""
249
+ escaped = [e for e in reader.exceptions() if e["handled_call_id"] is None and e["raised_call_id"] is not None]
250
+ if not escaped:
251
+ return None
252
+ exc = escaped[-1]
253
+ path = reader.ancestors(exc["raised_call_id"])
254
+ origin = path[-1]
255
+ stage_of = stage_namer(reader.run()["config"])
256
+ locals_at_raise = [b for b in reader.bindings(origin["id"]) if b["kind"] == "raise"]
257
+ recent = []
258
+ for b in locals_at_raise:
259
+ if b["object_id"] is not None:
260
+ for ch in object_flow(reader, b["object_id"])["changes"][-3:]:
261
+ recent.append({"name": b["name"], "object_id": b["object_id"], **ch})
262
+ recent.sort(key=lambda c: c["seq_after"])
263
+ chain, cause = [], exc["cause_id"]
264
+ while cause is not None and len(chain) < 10:
265
+ c = reader.exception(cause)
266
+ if c is None:
267
+ break
268
+ chain.append({k: c[k] for k in ("id", "type", "message", "raised_call_id", "raised_line")})
269
+ cause = c["cause_id"]
270
+ return {
271
+ "exception": {k: exc[k] for k in ("id", "type", "message", "raised_line", "value_id")},
272
+ "traceback": json.loads(exc["traceback"]),
273
+ "origin": call_node(origin, stage_of(origin["module"], origin["observed"])),
274
+ "path": [call_node(c, stage_of(c["module"], c["observed"])) for c in path],
275
+ "propagation": [c["id"] for c in reversed(path) if c["exception_id"] == exc["id"]],
276
+ "locals": [_binding_view(b) for b in locals_at_raise],
277
+ "recent_changes": recent[-5:],
278
+ "chain": chain,
279
+ }
280
+
281
+
282
+ # -- search -------------------------------------------------------------------------------------------------------
283
+
284
+ def search(reader: Reader, text: str, limit: int = 100) -> list[dict]:
285
+ if not text.strip():
286
+ return []
287
+ found = reader.search(text.strip(), limit)
288
+ hits = [{"kind": "function", "label": f["qualname"], "module": f["module"], "call_id": f["call_id"],
289
+ "count": f["count"], "observed": bool(f["observed"])} for f in found["functions"]]
290
+ for kind in ("variables", "values"):
291
+ for v in found[kind]:
292
+ hits.append({"kind": kind[:-1], "label": v["name"] or v["kind"], "call_id": v["call_id"],
293
+ "role": v["kind"], "preview": preview(json.loads(v["data"]))})
294
+ return hits[:limit]