recursionlens 0.1.2__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.
@@ -0,0 +1,16 @@
1
+ """RecursionLens: recursive execution analysis for JupyterLab."""
2
+
3
+ from ._model import CallNode, DAGNode
4
+ from ._report import ComparisonReport, TraceReport
5
+ from ._trace import trace
6
+
7
+ __version__ = "0.1.2"
8
+
9
+ __all__ = [
10
+ "CallNode",
11
+ "ComparisonReport",
12
+ "DAGNode",
13
+ "TraceReport",
14
+ "trace",
15
+ "__version__",
16
+ ]
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
5
+
6
+
7
+ @dataclass
8
+ class CallNode:
9
+ """One observed function invocation, including cache-only invocations."""
10
+
11
+ call_id: int
12
+ identity: str
13
+ signature: str
14
+ depth: int
15
+ status: str = "running"
16
+ result: Optional[str] = None
17
+ error: Optional[str] = None
18
+ repeated: bool = False
19
+ children: List["CallNode"] = field(default_factory=list)
20
+
21
+ @property
22
+ def cached(self) -> bool:
23
+ return self.status == "cached"
24
+
25
+ def to_dict(self) -> Dict[str, Any]:
26
+ return {
27
+ "call_id": self.call_id,
28
+ "identity": self.identity,
29
+ "signature": self.signature,
30
+ "depth": self.depth,
31
+ "status": self.status,
32
+ "result": self.result,
33
+ "error": self.error,
34
+ "repeated": self.repeated,
35
+ "children": [child.to_dict() for child in self.children],
36
+ }
37
+
38
+
39
+ @dataclass
40
+ class DAGNode:
41
+ """All invocations with one internal argument identity, merged together."""
42
+
43
+ identity: str
44
+ signature: str
45
+ calls: int = 0
46
+ executed: int = 0
47
+ cached: int = 0
48
+ result: Optional[str] = None
49
+ children: List[str] = field(default_factory=list)
50
+
51
+ def to_dict(self) -> Dict[str, Any]:
52
+ return {
53
+ "identity": self.identity,
54
+ "signature": self.signature,
55
+ "calls": self.calls,
56
+ "executed": self.executed,
57
+ "cached": self.cached,
58
+ "result": self.result,
59
+ "children": list(self.children),
60
+ }
61
+
62
+
63
+ def build_dag(nodes: Iterable[CallNode]) -> Tuple[List[DAGNode], List[Dict[str, str]]]:
64
+ """Compress a call forest into a deterministic subproblem DAG."""
65
+
66
+ merged: Dict[str, DAGNode] = {}
67
+ edges = set()
68
+
69
+ def visit(node: CallNode) -> None:
70
+ dag_node = merged.get(node.identity)
71
+ if dag_node is None:
72
+ dag_node = DAGNode(identity=node.identity, signature=node.signature)
73
+ merged[node.identity] = dag_node
74
+
75
+ dag_node.calls += 1
76
+ if node.cached:
77
+ dag_node.cached += 1
78
+ else:
79
+ dag_node.executed += 1
80
+ if dag_node.result is None and node.result is not None:
81
+ dag_node.result = node.result
82
+
83
+ for child in node.children:
84
+ if child.identity not in dag_node.children:
85
+ dag_node.children.append(child.identity)
86
+ edges.add((node.identity, child.identity))
87
+ visit(child)
88
+
89
+ for root in nodes:
90
+ visit(root)
91
+
92
+ edge_dicts = [{"source": source, "target": target} for source, target in sorted(edges)]
93
+ return list(merged.values()), edge_dicts
@@ -0,0 +1,365 @@
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import re
5
+ from collections import defaultdict
6
+ from typing import Dict, List, Tuple
7
+
8
+ from ._model import DAGNode
9
+
10
+ _CSS = """
11
+ .rl-wrap{--rl-blue:#185fa5;--rl-blue-bg:#e6f1fb;--rl-coral:#d85a30;--rl-coral-bg:#faece7;--rl-green:#0f6e56;--rl-green-bg:#e1f5ee;--rl-text:#242424;--rl-muted:#66645f;--rl-border:#dedcd5;color:var(--rl-text);background:#fff;border:1px solid var(--rl-border);border-radius:12px;padding:18px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.5;box-sizing:border-box;margin:8px 0;max-width:100%;overflow:auto}
12
+ .rl-wrap *{box-sizing:border-box}.rl-title{font-size:18px;font-weight:600;margin:0 0 4px}.rl-subtitle{color:var(--rl-muted);font-size:13px;margin:0 0 16px}.rl-metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(105px,1fr));gap:8px;margin:12px 0 18px}.rl-metric{background:#f6f5f1;border-radius:8px;padding:10px}.rl-metric-label{font-size:11px;color:var(--rl-muted)}.rl-metric-value{font-size:21px;font-weight:600;margin-top:2px}.rl-section{font-size:14px;font-weight:600;margin:18px 0 8px}.rl-tree,.rl-tree ul{list-style:none;padding-left:20px}.rl-tree{padding-left:0}.rl-tree details{margin:4px 0}.rl-tree summary{cursor:pointer;padding:5px 8px;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}.rl-tree summary:hover{background:var(--rl-blue-bg)}.rl-result{color:var(--rl-muted);font-size:11px;margin-left:6px}.rl-badge{display:inline-block;border-radius:4px;padding:1px 5px;margin-left:5px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:10px;font-weight:500}.rl-repeat{color:#993c1d;background:var(--rl-coral-bg)}.rl-cached{color:var(--rl-green);background:var(--rl-green-bg)}.rl-error{color:#a32d2d;background:#fcebeb}.rl-warning{color:#854f0b;background:#faeeda;border-radius:6px;padding:8px;margin:8px 0;font-size:12px}.rl-dag{max-width:100%;overflow:auto;background:#fafaf8;border-radius:8px;padding:8px}.rl-legend{font-size:11px;color:var(--rl-muted);margin:6px 0}.rl-table{border-collapse:collapse;width:100%;font-size:12px;margin:8px 0 16px}.rl-table th,.rl-table td{text-align:right;border-bottom:1px solid var(--rl-border);padding:8px}.rl-table th:first-child,.rl-table td:first-child{text-align:left}.rl-table th{color:var(--rl-muted);font-weight:500}.rl-delta-up{color:#a32d2d}.rl-delta-down{color:var(--rl-green)}.rl-columns{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px}.rl-note{font-size:12px;color:var(--rl-muted)}.rl-expanded{max-width:100%;overflow:auto;background:#fafaf8;border-radius:8px;padding:8px}.rl-expanded svg{display:block;margin:auto}
13
+ """
14
+
15
+
16
+ def esc(value: object) -> str:
17
+ return html.escape(str(value), quote=True)
18
+
19
+
20
+ def short(value: str, limit: int = 34) -> str:
21
+ return value if len(value) <= limit else value[: limit - 3] + "..."
22
+
23
+
24
+ def compact_call(signature: str, limit: int = 18) -> str:
25
+ """Remove parameter names for the compact SVG tree while keeping values."""
26
+
27
+ if "(" not in signature or not signature.endswith(")"):
28
+ return short(signature, limit)
29
+ name, arguments = signature.split("(", 1)
30
+ arguments = arguments[:-1]
31
+ arguments = re.sub(r"(^|, )([A-Za-z_]\w*)=", r"\1", arguments)
32
+ return short(f"{name}({arguments})", limit)
33
+
34
+
35
+ def metric_cards(report) -> str:
36
+ metrics = [
37
+ ("Observed calls", report.total_calls),
38
+ ("Executed bodies", report.executed_calls),
39
+ ("Cache hits", report.cached_calls),
40
+ ("Unique subproblems", report.unique_subproblems),
41
+ ("Repeated calls", report.repeated_calls),
42
+ ("Maximum depth", report.max_depth),
43
+ ]
44
+ cards = "".join(
45
+ f'<div class="rl-metric"><div class="rl-metric-label">{esc(label)}</div>'
46
+ f'<div class="rl-metric-value">{esc(value)}</div></div>'
47
+ for label, value in metrics
48
+ )
49
+ return f'<div class="rl-metrics">{cards}</div>'
50
+
51
+
52
+ def report_header(report) -> str:
53
+ mode = "memoized" if report.memoize else "naive"
54
+ if report.succeeded:
55
+ outcome = f"returned <code>{esc(report.result)}</code>"
56
+ else:
57
+ outcome = f"raised <code>{esc(report.error)}</code>"
58
+ warnings = "".join(
59
+ f'<div class="rl-warning">{esc(message)}</div>' for message in report.warnings
60
+ )
61
+ if report.truncated:
62
+ warnings += (
63
+ '<div class="rl-warning">The recording limit was reached. '
64
+ "Tree and DAG metrics only include recorded nodes; observed calls still counts every call.</div>"
65
+ )
66
+ return (
67
+ f'<h2 class="rl-title">RecursionLens · {esc(report.function_name)}</h2>'
68
+ f'<p class="rl-subtitle">{mode} run · {outcome}</p>'
69
+ + metric_cards(report)
70
+ + warnings
71
+ )
72
+
73
+
74
+ def render_tree_node(node) -> str:
75
+ badges = ""
76
+ if node.repeated:
77
+ badges += '<span class="rl-badge rl-repeat">repeated</span>'
78
+ if node.cached:
79
+ badges += '<span class="rl-badge rl-cached">cache hit</span>'
80
+ if node.status == "error":
81
+ badges += '<span class="rl-badge rl-error">error</span>'
82
+
83
+ outcome = ""
84
+ if node.result is not None:
85
+ outcome = f'<span class="rl-result">→ {esc(short(node.result, 60))}</span>'
86
+ elif node.error is not None:
87
+ outcome = f'<span class="rl-result">{esc(short(node.error, 60))}</span>'
88
+
89
+ open_attr = " open" if node.depth <= 2 else ""
90
+ children = ""
91
+ if node.children:
92
+ children = "<ul>" + "".join(render_tree_node(child) for child in node.children) + "</ul>"
93
+ return (
94
+ f"<li><details{open_attr}><summary>{esc(short(node.signature, 90))}"
95
+ f"{badges}{outcome}</summary>{children}</details></li>"
96
+ )
97
+
98
+
99
+ def render_tree(report) -> str:
100
+ roots = "".join(render_tree_node(root) for root in report.roots)
101
+ return (
102
+ '<h3 class="rl-section">Call tree</h3>'
103
+ '<div class="rl-legend">Each node is one invocation. Expand a node to inspect its children.</div>'
104
+ f'<ul class="rl-tree">{roots}</ul>'
105
+ )
106
+
107
+
108
+ def render_expanded_tree(report, limit: int = 120) -> str:
109
+ """Render every selected invocation as a compact, fully visible SVG tree."""
110
+
111
+ selected = []
112
+ child_lists = {}
113
+ remaining = [limit]
114
+
115
+ def collect(node) -> bool:
116
+ if remaining[0] <= 0:
117
+ return False
118
+ selected.append(node)
119
+ remaining[0] -= 1
120
+ child_lists[id(node)] = []
121
+ for child in node.children:
122
+ if collect(child):
123
+ child_lists[id(node)].append(child)
124
+ return True
125
+
126
+ for root in report.roots:
127
+ collect(root)
128
+
129
+ positions: Dict[int, Tuple[float, float]] = {}
130
+ leaf_cursor = [0]
131
+ maximum_depth = 0
132
+
133
+ def place(node, depth: int) -> None:
134
+ nonlocal maximum_depth
135
+ maximum_depth = max(maximum_depth, depth)
136
+ children = child_lists[id(node)]
137
+ if not children:
138
+ x = 80 + leaf_cursor[0] * 135
139
+ leaf_cursor[0] += 1
140
+ else:
141
+ for child in children:
142
+ place(child, depth + 1)
143
+ first_x = positions[id(children[0])][0]
144
+ last_x = positions[id(children[-1])][0]
145
+ x = (first_x + last_x) / 2
146
+ positions[id(node)] = (x, 24 + depth * 72)
147
+
148
+ for root in report.roots:
149
+ if id(root) in child_lists:
150
+ place(root, 0)
151
+
152
+ tree_width = 160 + max(0, leaf_cursor[0] - 1) * 135
153
+ width = max(600, tree_width)
154
+ horizontal_shift = (width - tree_width) / 2
155
+ if horizontal_shift:
156
+ positions = {
157
+ node_id: (x + horizontal_shift, y)
158
+ for node_id, (x, y) in positions.items()
159
+ }
160
+ height = max(110, 24 + maximum_depth * 72 + 56)
161
+ parts = [
162
+ f'<svg role="img" aria-label="Fully expanded recursion tree for {esc(report.function_name)}" '
163
+ f'viewBox="0 0 {width} {height}" width="{width}" height="{height}" '
164
+ 'xmlns="http://www.w3.org/2000/svg">',
165
+ f"<title>{esc(report.function_name)} expanded call tree</title>",
166
+ ]
167
+
168
+ for parent in selected:
169
+ parent_x, parent_y = positions[id(parent)]
170
+ for child in child_lists[id(parent)]:
171
+ child_x, child_y = positions[id(child)]
172
+ parts.append(
173
+ f'<path class="rl-expanded-edge" d="M{parent_x},{parent_y + 28} '
174
+ f'L{child_x},{child_y + 6}" fill="none" stroke="#a9a7a0" stroke-width="1"/>'
175
+ )
176
+
177
+ for node in selected:
178
+ x, y = positions[id(node)]
179
+ note = [node.signature]
180
+ if node.cached:
181
+ note.append("cache hit")
182
+ color = "#0f6e56"
183
+ decoration = ""
184
+ elif node.status == "error":
185
+ note.append(node.error or "error")
186
+ color = "#a32d2d"
187
+ decoration = ""
188
+ elif node.repeated:
189
+ note.append("repeated")
190
+ color = "#993c1d"
191
+ decoration = ' text-decoration="underline"'
192
+ else:
193
+ color = "#242424"
194
+ decoration = ""
195
+ if node.result is not None:
196
+ note.append(f"returned {node.result}")
197
+ parts.extend(
198
+ [
199
+ f'<g class="rl-expanded-node"><title>{esc(" · ".join(note))}</title>',
200
+ f'<text x="{x}" y="{y + 17}" text-anchor="middle" '
201
+ f'font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="12" '
202
+ f'fill="{color}"{decoration}>{esc(compact_call(node.signature))}</text></g>',
203
+ ]
204
+ )
205
+ parts.append("</svg>")
206
+
207
+ omitted = report.recorded_calls - len(selected)
208
+ notice = (
209
+ f'<p class="rl-note">Showing the first {limit} calls; {omitted} omitted.</p>'
210
+ if omitted > 0
211
+ else ""
212
+ )
213
+ return (
214
+ '<h3 class="rl-section">Expanded call tree</h3>'
215
+ f'<div class="rl-legend">All {len(selected)} calls are visible. '
216
+ "Underlined coral = repeated; green = cache hit; red = error.</div>"
217
+ f'<div class="rl-expanded">{"".join(parts)}</div>{notice}'
218
+ )
219
+
220
+
221
+ def dag_depths(nodes: List[DAGNode]) -> Dict[str, int]:
222
+ children = {node.identity: node.children for node in nodes}
223
+ known = set(children)
224
+ memo: Dict[str, int] = {}
225
+
226
+ def depth(identity: str, visiting: set) -> int:
227
+ if identity in memo:
228
+ return memo[identity]
229
+ if identity in visiting:
230
+ return 0
231
+ next_nodes = [child for child in children.get(identity, []) if child in known]
232
+ value = 0 if not next_nodes else 1 + max(depth(child, visiting | {identity}) for child in next_nodes)
233
+ memo[identity] = value
234
+ return value
235
+
236
+ return {node.identity: depth(node.identity, set()) for node in nodes}
237
+
238
+
239
+ def render_dag(report) -> str:
240
+ nodes = report.dag_nodes
241
+ omitted = max(0, len(nodes) - 40)
242
+ nodes = nodes[:40]
243
+ known = {node.identity for node in nodes}
244
+ depths = dag_depths(nodes)
245
+ layers: Dict[int, List[DAGNode]] = defaultdict(list)
246
+ for node in nodes:
247
+ layers[depths[node.identity]].append(node)
248
+
249
+ box_width, box_height = 190, 52
250
+ horizontal_gap, vertical_gap = 235, 74
251
+ positions: Dict[str, Tuple[float, float]] = {}
252
+ maximum_depth = max(layers, default=0)
253
+ for depth, layer in layers.items():
254
+ for index, node in enumerate(layer):
255
+ positions[node.identity] = (
256
+ 20 + (maximum_depth - depth) * horizontal_gap,
257
+ 30 + index * vertical_gap,
258
+ )
259
+
260
+ width = max(600, 40 + (max(layers, default=0) + 1) * horizontal_gap)
261
+ height = max(130, 60 + max((len(layer) for layer in layers.values()), default=1) * vertical_gap)
262
+ parts = [
263
+ f'<svg role="img" aria-label="Subproblem DAG for {esc(report.function_name)}" '
264
+ f'viewBox="0 0 {width} {height}" width="{width}" height="{height}" '
265
+ 'xmlns="http://www.w3.org/2000/svg">',
266
+ "<defs><marker id=\"rl-arrow\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" "
267
+ 'markerWidth="6" markerHeight="6" orient="auto"><path d="M2 1L8 5L2 9" '
268
+ 'fill="none" stroke="#888780" stroke-width="1.5"/></marker></defs>',
269
+ ]
270
+
271
+ for edge in report.dag_edges:
272
+ source, target = edge["source"], edge["target"]
273
+ if source not in known or target not in known:
274
+ continue
275
+ sx, sy = positions[source]
276
+ tx, ty = positions[target]
277
+ parts.append(
278
+ f'<path d="M{sx + box_width},{sy + box_height / 2} L{tx - 8},{ty + box_height / 2}" '
279
+ 'fill="none" stroke="#888780" stroke-width="1" marker-end="url(#rl-arrow)"/>'
280
+ )
281
+
282
+ for node in nodes:
283
+ x, y = positions[node.identity]
284
+ if node.cached:
285
+ fill, stroke, text = "#e1f5ee", "#0f6e56", "#085041"
286
+ elif node.calls > 1:
287
+ fill, stroke, text = "#faece7", "#d85a30", "#993c1d"
288
+ else:
289
+ fill, stroke, text = "#e6f1fb", "#185fa5", "#042c53"
290
+ label = esc(short(node.signature, 29))
291
+ count = f"calls {node.calls} · executed {node.executed} · cached {node.cached}"
292
+ parts.extend(
293
+ [
294
+ f'<rect x="{x}" y="{y}" width="{box_width}" height="{box_height}" rx="8" '
295
+ f'fill="{fill}" stroke="{stroke}" stroke-width="1"/>',
296
+ f'<text x="{x + 10}" y="{y + 20}" font-family="monospace" font-size="11" fill="{text}">{label}</text>',
297
+ f'<text x="{x + 10}" y="{y + 39}" font-family="sans-serif" font-size="10" fill="{text}">{esc(count)}</text>',
298
+ ]
299
+ )
300
+ parts.append("</svg>")
301
+
302
+ notice = f'<p class="rl-note">Showing the first 40 subproblems; {omitted} omitted.</p>' if omitted else ""
303
+ return (
304
+ '<h3 class="rl-section">Subproblem DAG</h3>'
305
+ '<div class="rl-legend">Identical argument signatures are merged. Coral = repeated; green = contains cache hits.</div>'
306
+ f'<div class="rl-dag">{"".join(parts)}</div>{notice}'
307
+ )
308
+
309
+
310
+ def render_report(report, mode: str = "tree") -> str:
311
+ views = {"tree": render_tree, "dag": render_dag, "expanded": render_expanded_tree}
312
+ if mode not in views:
313
+ raise ValueError("mode must be 'tree', 'dag', or 'expanded'")
314
+ body = views[mode](report)
315
+ return f'<style>{_CSS}</style><section class="rl-wrap">{report_header(report)}{body}</section>'
316
+
317
+
318
+ def render_comparison(comparison) -> str:
319
+ left, right = comparison.left, comparison.right
320
+ left_metrics, right_metrics = left.metrics(), right.metrics()
321
+ rows = []
322
+ labels = {
323
+ "total_calls": "Observed calls",
324
+ "executed_calls": "Executed bodies",
325
+ "cached_calls": "Cache hits",
326
+ "unique_subproblems": "Unique subproblems",
327
+ "repeated_calls": "Repeated calls",
328
+ "max_depth": "Maximum depth",
329
+ }
330
+ for key, label in labels.items():
331
+ delta = right_metrics[key] - left_metrics[key]
332
+ delta_class = "rl-delta-down" if delta < 0 else "rl-delta-up" if delta > 0 else ""
333
+ delta_text = f"{delta:+d}"
334
+ rows.append(
335
+ f"<tr><td>{esc(label)}</td><td>{left_metrics[key]}</td>"
336
+ f"<td>{right_metrics[key]}</td><td class=\"{delta_class}\">{delta_text}</td></tr>"
337
+ )
338
+
339
+ same_result = "yes" if comparison.same_result else "no"
340
+ return (
341
+ f'<style>{_CSS}</style><section class="rl-wrap">'
342
+ '<h2 class="rl-title">RecursionLens · execution comparison</h2>'
343
+ f'<p class="rl-subtitle">{esc(comparison.labels[0])} vs {esc(comparison.labels[1])} · same returned value: {same_result}</p>'
344
+ '<table class="rl-table"><thead><tr><th>Metric</th>'
345
+ f'<th>{esc(comparison.labels[0])}</th><th>{esc(comparison.labels[1])}</th><th>Change</th>'
346
+ '</tr></thead><tbody>' + "".join(rows) + '</tbody></table>'
347
+ f'<div class="rl-compare-block"><h3 class="rl-section">{esc(comparison.labels[0])}</h3>'
348
+ + report_header(left)
349
+ + render_dag(left)
350
+ + '</div>'
351
+ f'<div class="rl-compare-block"><h3 class="rl-section">{esc(comparison.labels[1])}</h3>'
352
+ + report_header(right)
353
+ + render_dag(right)
354
+ + '</div></section>'
355
+ )
356
+
357
+
358
+ def render_document(fragment: str) -> str:
359
+ return (
360
+ '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'
361
+ '<meta name="viewport" content="width=device-width,initial-scale=1">'
362
+ '<title>RecursionLens report</title></head><body style="margin:20px;background:#f6f5f1">'
363
+ + fragment
364
+ + "</body></html>"
365
+ )
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional, Tuple, Union
7
+
8
+ from ._model import CallNode, DAGNode, build_dag
9
+
10
+
11
+ @dataclass
12
+ class TraceReport:
13
+ """A serializable record of one traced top-level invocation."""
14
+
15
+ function_name: str
16
+ value: Any
17
+ nodes: List[CallNode]
18
+ roots: List[CallNode]
19
+ total_calls: int
20
+ max_depth: int
21
+ memoize: bool = False
22
+ truncated: bool = False
23
+ warnings: List[str] = field(default_factory=list)
24
+ succeeded: bool = True
25
+
26
+ @property
27
+ def recorded_calls(self) -> int:
28
+ return len(self.nodes)
29
+
30
+ @property
31
+ def cached_calls(self) -> int:
32
+ return sum(node.cached for node in self.nodes)
33
+
34
+ @property
35
+ def executed_calls(self) -> int:
36
+ return self.recorded_calls - self.cached_calls
37
+
38
+ @property
39
+ def dag_nodes(self) -> List[DAGNode]:
40
+ return build_dag(self.roots)[0]
41
+
42
+ @property
43
+ def dag_edges(self) -> List[Dict[str, str]]:
44
+ return build_dag(self.roots)[1]
45
+
46
+ @property
47
+ def unique_subproblems(self) -> int:
48
+ return len(self.dag_nodes)
49
+
50
+ @property
51
+ def repeated_calls(self) -> int:
52
+ return self.recorded_calls - self.unique_subproblems
53
+
54
+ @property
55
+ def result(self) -> Optional[str]:
56
+ if self.roots:
57
+ return self.roots[0].result
58
+ return None
59
+
60
+ @property
61
+ def error(self) -> Optional[str]:
62
+ if self.roots:
63
+ return self.roots[0].error
64
+ return None
65
+
66
+ def metrics(self) -> Dict[str, Any]:
67
+ return {
68
+ "total_calls": self.total_calls,
69
+ "recorded_calls": self.recorded_calls,
70
+ "executed_calls": self.executed_calls,
71
+ "cached_calls": self.cached_calls,
72
+ "unique_subproblems": self.unique_subproblems,
73
+ "repeated_calls": self.repeated_calls,
74
+ "max_depth": self.max_depth,
75
+ "memoize": self.memoize,
76
+ "truncated": self.truncated,
77
+ "succeeded": self.succeeded,
78
+ }
79
+
80
+ def to_dict(self) -> Dict[str, Any]:
81
+ return {
82
+ "schema_version": 1,
83
+ "function_name": self.function_name,
84
+ "result": self.result,
85
+ "error": self.error,
86
+ "metrics": self.metrics(),
87
+ "warnings": list(self.warnings),
88
+ "roots": [root.to_dict() for root in self.roots],
89
+ "dag": {
90
+ "nodes": [node.to_dict() for node in self.dag_nodes],
91
+ "edges": list(self.dag_edges),
92
+ },
93
+ }
94
+
95
+ def export_json(self, path: Union[str, Path]) -> Path:
96
+ destination = Path(path)
97
+ destination.write_text(
98
+ json.dumps(self.to_dict(), indent=2, ensure_ascii=False),
99
+ encoding="utf-8",
100
+ )
101
+ return destination
102
+
103
+ def to_html(self, mode: str = "tree") -> str:
104
+ from ._render import render_report
105
+
106
+ return render_report(self, mode=mode)
107
+
108
+ def export_html(self, path: Union[str, Path], mode: str = "tree") -> Path:
109
+ from ._render import render_document
110
+
111
+ destination = Path(path)
112
+ destination.write_text(render_document(self.to_html(mode=mode)), encoding="utf-8")
113
+ return destination
114
+
115
+ def _repr_html_(self) -> str:
116
+ return self.to_html()
117
+
118
+ def show(self, mode: str = "tree") -> Any:
119
+ html = self.to_html(mode=mode)
120
+ try:
121
+ from IPython.display import HTML, display
122
+ except ImportError:
123
+ return html
124
+ display(HTML(html))
125
+ return None
126
+
127
+ def compare(
128
+ self,
129
+ other: "TraceReport",
130
+ labels: Tuple[str, str] = ("naive", "memoized"),
131
+ ) -> "ComparisonReport":
132
+ return ComparisonReport(self, other, labels)
133
+
134
+
135
+ @dataclass
136
+ class ComparisonReport:
137
+ """Quantitative and visual comparison of two traced runs."""
138
+
139
+ left: TraceReport
140
+ right: TraceReport
141
+ labels: Tuple[str, str] = ("naive", "memoized")
142
+
143
+ @property
144
+ def same_result(self) -> bool:
145
+ if not self.left.succeeded or not self.right.succeeded:
146
+ return False
147
+ try:
148
+ return bool(self.left.value == self.right.value)
149
+ except Exception:
150
+ return False
151
+
152
+ def to_dict(self) -> Dict[str, Any]:
153
+ left_metrics = self.left.metrics()
154
+ right_metrics = self.right.metrics()
155
+ keys = (
156
+ "total_calls",
157
+ "executed_calls",
158
+ "cached_calls",
159
+ "unique_subproblems",
160
+ "repeated_calls",
161
+ "max_depth",
162
+ )
163
+ return {
164
+ "labels": list(self.labels),
165
+ "left": left_metrics,
166
+ "right": right_metrics,
167
+ "delta": {key: right_metrics[key] - left_metrics[key] for key in keys},
168
+ "same_result": self.same_result,
169
+ }
170
+
171
+ def to_html(self) -> str:
172
+ from ._render import render_comparison
173
+
174
+ return render_comparison(self)
175
+
176
+ def export_html(self, path: Union[str, Path]) -> Path:
177
+ from ._render import render_document
178
+
179
+ destination = Path(path)
180
+ destination.write_text(render_document(self.to_html()), encoding="utf-8")
181
+ return destination
182
+
183
+ def _repr_html_(self) -> str:
184
+ return self.to_html()
185
+
186
+ def show(self) -> Any:
187
+ html = self.to_html()
188
+ try:
189
+ from IPython.display import HTML, display
190
+ except ImportError:
191
+ return html
192
+ display(HTML(html))
193
+ return None
@@ -0,0 +1,283 @@
1
+ from __future__ import annotations
2
+
3
+ import contextvars
4
+ import functools
5
+ import inspect
6
+ import itertools
7
+ from collections import Counter
8
+ from typing import Any, Callable, Dict, List, Optional, Tuple
9
+
10
+ from ._model import CallNode
11
+ from ._report import TraceReport
12
+
13
+ _ACTIVE: contextvars.ContextVar[Optional["TraceRecorder"]] = contextvars.ContextVar(
14
+ "recursionlens_active_recorder", default=None
15
+ )
16
+ _FUNCTION_IDS = itertools.count(1)
17
+ _MAX_REPR = 160
18
+
19
+
20
+ def full_repr(value: Any) -> str:
21
+ try:
22
+ return repr(value)
23
+ except Exception as error:
24
+ return f"<unrepresentable {type(value).__name__}: {type(error).__name__}>"
25
+
26
+
27
+ def safe_repr(value: Any) -> str:
28
+ """Return a bounded representation suitable for display and JSON."""
29
+
30
+ text = full_repr(value)
31
+ if len(text) > _MAX_REPR:
32
+ return text[: _MAX_REPR - 3] + "..."
33
+ return text
34
+
35
+
36
+ def safe_exception(error: BaseException) -> str:
37
+ try:
38
+ message = str(error)
39
+ except BaseException:
40
+ message = "<exception message unavailable>"
41
+ return f"{type(error).__name__}: {message}"
42
+
43
+
44
+ def bind_arguments(func: Callable[..., Any], args: tuple, kwargs: dict) -> inspect.BoundArguments:
45
+ bound = inspect.signature(func).bind(*args, **kwargs)
46
+ bound.apply_defaults()
47
+ return bound
48
+
49
+
50
+ def signature_for(func: Callable[..., Any], bound: inspect.BoundArguments) -> str:
51
+ arguments = ", ".join(
52
+ f"{name}={safe_repr(value)}" for name, value in bound.arguments.items()
53
+ )
54
+ return f"{func.__qualname__}({arguments})"
55
+
56
+
57
+ def identity_for(
58
+ function_id: int, func: Callable[..., Any], bound: inspect.BoundArguments
59
+ ) -> str:
60
+ """Build an untruncated internal identity independent of display formatting."""
61
+
62
+ arguments = ", ".join(
63
+ f"{name}:{type(value).__module__}.{type(value).__qualname__}={full_repr(value)}"
64
+ for name, value in bound.arguments.items()
65
+ )
66
+ return f"{function_id}:{func.__module__}:{func.__qualname__}({arguments})"
67
+
68
+
69
+ def cache_key_for(
70
+ func: Callable[..., Any], bound: inspect.BoundArguments
71
+ ) -> Optional[Tuple[Any, ...]]:
72
+ # Python considers 1, 1.0, and True equal with equal hashes. Types are part
73
+ # of this key so memoization cannot change a type-sensitive pure function.
74
+ key = (
75
+ func,
76
+ tuple((name, type(value), value) for name, value in bound.arguments.items()),
77
+ )
78
+ try:
79
+ hash(key)
80
+ except Exception:
81
+ return None
82
+ return key
83
+
84
+
85
+ class TraceRecorder:
86
+ def __init__(self, memoize: bool, max_calls: int) -> None:
87
+ if max_calls < 1:
88
+ raise ValueError("max_calls must be at least 1")
89
+ self.memoize = memoize
90
+ self.max_calls = max_calls
91
+ self.nodes: List[CallNode] = []
92
+ self.roots: List[CallNode] = []
93
+ self.stack: List[Optional[CallNode]] = []
94
+ self.identities: Counter = Counter()
95
+ self.cache: Dict[Tuple[Any, ...], Any] = {}
96
+ self.total_calls = 0
97
+ self.depth = 0
98
+ self.max_depth = 0
99
+ self.truncated = False
100
+ self.warnings: List[str] = []
101
+
102
+ def add_node(self, identity: str, signature: str, status: str) -> Optional[CallNode]:
103
+ if len(self.nodes) >= self.max_calls:
104
+ self.truncated = True
105
+ return None
106
+
107
+ repeated = self.identities[identity] > 0
108
+ self.identities[identity] += 1
109
+ node = CallNode(
110
+ call_id=len(self.nodes),
111
+ identity=identity,
112
+ signature=signature,
113
+ depth=self.depth,
114
+ status=status,
115
+ repeated=repeated,
116
+ )
117
+ self.nodes.append(node)
118
+
119
+ if not self.stack:
120
+ self.roots.append(node)
121
+ elif self.stack[-1] is not None:
122
+ self.stack[-1].children.append(node)
123
+ return node
124
+
125
+ def warn_unhashable(self) -> None:
126
+ message = "Arguments are unhashable; this run was not memoized for those calls."
127
+ if message not in self.warnings:
128
+ self.warnings.append(message)
129
+
130
+
131
+ def _invoke(
132
+ function_id: int,
133
+ func: Callable[..., Any],
134
+ args: tuple,
135
+ kwargs: dict,
136
+ recorder: TraceRecorder,
137
+ ) -> Any:
138
+ bound = bind_arguments(func, args, kwargs)
139
+ signature = signature_for(func, bound)
140
+ identity = identity_for(function_id, func, bound)
141
+ cache_key = cache_key_for(func, bound) if recorder.memoize else None
142
+
143
+ recorder.total_calls += 1
144
+ recorder.depth += 1
145
+ recorder.max_depth = max(recorder.max_depth, recorder.depth)
146
+
147
+ if recorder.memoize and cache_key is not None and cache_key in recorder.cache:
148
+ try:
149
+ value = recorder.cache[cache_key]
150
+ node = recorder.add_node(identity, signature, "cached")
151
+ if node is not None:
152
+ node.result = safe_repr(value)
153
+ return value
154
+ finally:
155
+ recorder.depth -= 1
156
+
157
+ node = recorder.add_node(identity, signature, "running")
158
+ recorder.stack.append(node)
159
+ try:
160
+ value = func(*args, **kwargs)
161
+ if node is not None:
162
+ node.status = "returned"
163
+ node.result = safe_repr(value)
164
+
165
+ if recorder.memoize:
166
+ if cache_key is None:
167
+ recorder.warn_unhashable()
168
+ else:
169
+ recorder.cache[cache_key] = value
170
+ return value
171
+ except BaseException as error:
172
+ if node is not None:
173
+ node.status = "error"
174
+ node.error = safe_exception(error)
175
+ raise
176
+ finally:
177
+ recorder.stack.pop()
178
+ recorder.depth -= 1
179
+
180
+
181
+ class RunConfiguration:
182
+ def __init__(
183
+ self,
184
+ wrapper: Callable[..., Any],
185
+ func: Callable[..., Any],
186
+ function_id: int,
187
+ memoize: bool,
188
+ max_calls: int,
189
+ ) -> None:
190
+ self.wrapper = wrapper
191
+ self.func = func
192
+ self.function_id = function_id
193
+ self.memoize = memoize
194
+ self.max_calls = max_calls
195
+
196
+ def run(self, *args: Any, **kwargs: Any) -> TraceReport:
197
+ return _run(
198
+ self.wrapper,
199
+ self.func,
200
+ self.function_id,
201
+ args,
202
+ kwargs,
203
+ self.memoize,
204
+ self.max_calls,
205
+ )
206
+
207
+
208
+ def _run(
209
+ wrapper: Callable[..., Any],
210
+ func: Callable[..., Any],
211
+ function_id: int,
212
+ args: tuple,
213
+ kwargs: dict,
214
+ memoize: bool,
215
+ max_calls: int,
216
+ ) -> TraceReport:
217
+ recorder = TraceRecorder(memoize=memoize, max_calls=max_calls)
218
+ token = _ACTIVE.set(recorder)
219
+ try:
220
+ value = _invoke(function_id, func, args, kwargs, recorder)
221
+ except BaseException:
222
+ wrapper.last_report = _make_report(func, recorder, None, succeeded=False)
223
+ raise
224
+ else:
225
+ report = _make_report(func, recorder, value, succeeded=True)
226
+ wrapper.last_report = report
227
+ return report
228
+ finally:
229
+ _ACTIVE.reset(token)
230
+
231
+
232
+ def trace(func: Callable[..., Any]) -> Callable[..., Any]:
233
+ """Instrument a synchronous recursive function while preserving its call behavior."""
234
+
235
+ function_id = next(_FUNCTION_IDS)
236
+
237
+ @functools.wraps(func)
238
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
239
+ active = _ACTIVE.get()
240
+ if active is not None:
241
+ return _invoke(function_id, func, args, kwargs, active)
242
+
243
+ recorder = TraceRecorder(memoize=False, max_calls=1000)
244
+ token = _ACTIVE.set(recorder)
245
+ try:
246
+ value = _invoke(function_id, func, args, kwargs, recorder)
247
+ except BaseException:
248
+ wrapper.last_report = _make_report(func, recorder, None, succeeded=False)
249
+ raise
250
+ else:
251
+ wrapper.last_report = _make_report(func, recorder, value, succeeded=True)
252
+ return value
253
+ finally:
254
+ _ACTIVE.reset(token)
255
+
256
+ def run(*args: Any, **kwargs: Any) -> TraceReport:
257
+ return RunConfiguration(wrapper, func, function_id, False, 1000).run(*args, **kwargs)
258
+
259
+ def with_options(*, memoize: bool = False, max_calls: int = 1000) -> RunConfiguration:
260
+ return RunConfiguration(wrapper, func, function_id, memoize, max_calls)
261
+
262
+ wrapper.run = run
263
+ wrapper.with_options = with_options
264
+ wrapper.last_report = None
265
+ wrapper.__recursionlens_original__ = func
266
+ return wrapper
267
+
268
+
269
+ def _make_report(
270
+ func: Callable[..., Any], recorder: TraceRecorder, value: Any, succeeded: bool
271
+ ) -> TraceReport:
272
+ return TraceReport(
273
+ function_name=func.__qualname__,
274
+ value=value,
275
+ nodes=recorder.nodes,
276
+ roots=recorder.roots,
277
+ total_calls=recorder.total_calls,
278
+ max_depth=recorder.max_depth,
279
+ memoize=recorder.memoize,
280
+ truncated=recorder.truncated,
281
+ warnings=list(recorder.warnings),
282
+ succeeded=succeeded,
283
+ )
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.4
2
+ Name: recursionlens
3
+ Version: 0.1.2
4
+ Summary: Interactive recursion tracing, subproblem DAGs, and memoization comparisons for JupyterLab.
5
+ Author: Ziyu Jiang, Xinyue Cai, Kehan Cao, Ruoxi Lyu, Haobo Ma
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ZiyuJiang07/recursionlens
8
+ Project-URL: Repository, https://github.com/ZiyuJiang07/recursionlens
9
+ Project-URL: Documentation, https://github.com/ZiyuJiang07/recursionlens/blob/main/docs/USER.md
10
+ Project-URL: Tutorial, https://github.com/ZiyuJiang07/recursionlens/blob/main/docs/RecursionLens_Tutorial.ipynb
11
+ Project-URL: Changelog, https://github.com/ZiyuJiang07/recursionlens/blob/main/CHANGELOG.md
12
+ Project-URL: Issues, https://github.com/ZiyuJiang07/recursionlens/issues
13
+ Keywords: recursion,jupyterlab,visualization,memoization,education
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Education
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Framework :: Jupyter
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8; extra == "dev"
24
+ Requires-Dist: coverage>=7; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # RecursionLens
28
+
29
+ **See not just what recursion returns, but where the repeated work happens.**
30
+
31
+ RecursionLens is a zero-runtime-dependency Python package for exploring recursive execution in JupyterLab. It turns a traced run into an expandable call tree, a compressed subproblem DAG, and a quantitative naive-versus-memoized comparison.
32
+
33
+ > **JupyterLab tutorial, already executed:** [Open RecursionLens_Tutorial.ipynb](https://github.com/ZiyuJiang07/recursionlens/blob/main/docs/RecursionLens_Tutorial.ipynb). All eight code cells have saved output. If Jupyter hides HTML/SVG from a downloaded notebook, use its **Trust** action or click **Run All Cells** once. For a zero-setup preview, download and open the [static HTML companion](https://github.com/ZiyuJiang07/recursionlens/raw/main/docs/RecursionLens_Tutorial.html).
34
+
35
+ ## Project team
36
+
37
+ CS1302A Group 50:
38
+
39
+ - Ziyu Jiang
40
+ - Xinyue CAI
41
+ - Kehan CAO
42
+ - Ruoxi LYU
43
+ - Haobo MA
44
+
45
+ ## Why RecursionLens?
46
+
47
+ A recursive function normally exposes only its final return value or traceback. Students still have to reconstruct the execution in their heads. RecursionLens answers three different questions:
48
+
49
+ 1. **Call tree:** what actually happened, invocation by invocation?
50
+ 2. **Subproblem DAG:** how many distinct problems and dependencies exist after identical arguments are merged?
51
+ 3. **Execution comparison:** what changes when memoization is enabled?
52
+
53
+ For `fib(5)`, that story is **15 observed calls → 6 unique subproblems → 6 executed bodies with memoization**.
54
+
55
+ ## Innovation beyond a call graph
56
+
57
+ The existing `rcviz` package documents decorator-based recursion trees with arguments, return values, and Jupyter rendering. RecursionLens deliberately focuses on a different workflow:
58
+
59
+ - transform one trace between an invocation tree and a compressed subproblem DAG;
60
+ - show the complete call tree at once in an SVG view or keep a collapsible tree for larger runs;
61
+ - quantify repeated calls, executed bodies, cache hits, and maximum depth;
62
+ - compare naive and memoized runs in one report with side-by-side DAGs;
63
+ - render self-contained HTML/SVG in JupyterLab without Graphviz, native libraries, external JavaScript, or a CDN;
64
+ - export versioned JSON for reproducible analysis and testing.
65
+
66
+ ## Installation
67
+
68
+ RecursionLens requires Python 3.9 or later.
69
+
70
+ Install from PyPI:
71
+
72
+ ```bash
73
+ python -m pip install recursionlens
74
+ ```
75
+
76
+ For local development:
77
+
78
+ ```bash
79
+ git clone https://github.com/ZiyuJiang07/recursionlens.git
80
+ cd recursionlens
81
+ python -m pip install -e ".[dev]"
82
+ ```
83
+
84
+ The package has no runtime dependencies.
85
+
86
+ ## Quickstart in JupyterLab
87
+
88
+ ```python
89
+ from recursionlens import trace
90
+
91
+ @trace
92
+ def fib(n):
93
+ if n < 2:
94
+ return n
95
+ return fib(n - 1) + fib(n - 2)
96
+
97
+ naive = fib.run(5)
98
+ naive # expandable call tree in the notebook output area
99
+ ```
100
+
101
+ Switch the same trace to the compressed subproblem DAG:
102
+
103
+ ```python
104
+ naive.show(mode="dag")
105
+ ```
106
+
107
+ Or show every call at once as one compact SVG tree, with no nodes collapsed:
108
+
109
+ ```python
110
+ naive.show(mode="expanded")
111
+ ```
112
+
113
+ Run the function again with memoization and compare the evidence:
114
+
115
+ ```python
116
+ memoized = fib.with_options(memoize=True).run(5)
117
+ comparison = naive.compare(memoized)
118
+ comparison
119
+ ```
120
+
121
+ The comparison confirms the returned values match and reports the measured changes, including **15 → 6 executed function bodies**.
122
+
123
+ Normal function behavior is preserved:
124
+
125
+ ```python
126
+ assert fib(5) == 5
127
+ assert fib.last_report.total_calls == 15
128
+ ```
129
+
130
+ ## Export a report
131
+
132
+ ```python
133
+ naive.export_html("fib-tree.html")
134
+ naive.export_html("fib-dag.html", mode="dag")
135
+ naive.export_json("fib-trace.json")
136
+ comparison.export_html("fib-comparison.html")
137
+ ```
138
+
139
+ HTML reports are standalone files that can be opened without a notebook server. JSON contains the tree, DAG nodes and edges, metrics, warnings, and schema version.
140
+
141
+ ## Supported scope
142
+
143
+ Version 0.1 targets synchronous, deterministic teaching examples such as factorial, Fibonacci, and pure mathematical recursion.
144
+
145
+ - Memoization requires hashable bound arguments; unhashable arguments produce a warning instead of a crash.
146
+ - Identical arguments do not necessarily imply redundant work for functions with side effects or external state.
147
+ - `max_calls` bounds the stored tree without changing the function's result. A truncated report explicitly marks partial tree/DAG metrics.
148
+ - RecursionLens executes your function; it is **not a sandbox for untrusted code**.
149
+ - Async functions, generators, automatic Big-O inference, and automatic code rewriting are non-goals for 0.1.
150
+
151
+ ## Documentation and demo
152
+
153
+ - [Executed JupyterLab tutorial](https://github.com/ZiyuJiang07/recursionlens/blob/main/docs/RecursionLens_Tutorial.ipynb) - setup, three views, comparison, export, troubleshooting, and a one-minute demo recipe
154
+ - [Static HTML tutorial preview](https://github.com/ZiyuJiang07/recursionlens/raw/main/docs/RecursionLens_Tutorial.html) - the same tutorial with all visual reports rendered, no Jupyter required
155
+ - [Runnable example notebook](https://github.com/ZiyuJiang07/recursionlens/blob/main/examples/demo.ipynb)
156
+ - [User guide](https://github.com/ZiyuJiang07/recursionlens/blob/main/docs/USER.md)
157
+ - [Developer guide](https://github.com/ZiyuJiang07/recursionlens/blob/main/docs/DEVELOPER.md)
158
+ - [Changelog](https://github.com/ZiyuJiang07/recursionlens/blob/main/CHANGELOG.md)
159
+
160
+ ## Development and testing
161
+
162
+ ```bash
163
+ python -m pytest tests -v
164
+ python -m build
165
+ python -m twine check dist/*
166
+ ```
167
+
168
+ The test suite covers trace invariants, cache behavior, signatures, exceptions, truncation, JSON, HTML escaping, DAG rendering, and comparison reports. Contributions should keep the public API, tests, and user documentation synchronized.
169
+
170
+ ## License
171
+
172
+ MIT © 2026 Ziyu Jiang, Xinyue Cai, Kehan Cao, Ruoxi Lyu, Haobo Ma.
@@ -0,0 +1,10 @@
1
+ recursionlens/__init__.py,sha256=biRR53VjQR9K6sXWQL_rzOOHDJsj4tGNzDZBEmNdBU8,326
2
+ recursionlens/_model.py,sha256=hZTcXRQF-YO0OBkHgQU-_V-52XrhdaZ3_50EU7ev8J4,2750
3
+ recursionlens/_render.py,sha256=a32QpaZhh6Kk_Ikc2GufBJ-iAJ_h0yLnIa3OP55jSbo,16368
4
+ recursionlens/_report.py,sha256=6KgAr2YjPH6s0vADGv5jfLFGczl9gt9tsTq01M78gQQ,5530
5
+ recursionlens/_trace.py,sha256=yyJ7E9mJdS5ZszQkjQbEMeVSyZccH5v2zoalWkfSV2g,8687
6
+ recursionlens-0.1.2.dist-info/licenses/LICENSE,sha256=M86k0jXdg-CXEEced2z6uD5VXMRJbQK1GiOuQwIOWD4,1084
7
+ recursionlens-0.1.2.dist-info/METADATA,sha256=pM-Au-a9BKRGbO0ybGfelevJn7vXFp9fGa-NmlDqt90,6994
8
+ recursionlens-0.1.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ recursionlens-0.1.2.dist-info/top_level.txt,sha256=JLn475SilL45f2yU_BozOrvo2pH4gdNox8fnnG8BOtk,14
10
+ recursionlens-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ziyu Jiang and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ recursionlens