tlarsson commited on
Commit
b82e7a7
·
verified ·
1 Parent(s): 763a717

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +117 -278
  2. introduction.py +44 -0
  3. utility.py +83 -0
app.py CHANGED
@@ -1,288 +1,87 @@
1
- import ast
2
  import base64
3
  import dash
4
  from dash import html, dcc, dash_table
5
  import dash_cytoscape as cyto
6
  from dash.dependencies import Input, Output, State
7
- import json
8
- import urllib.parse
 
 
9
 
10
  app = dash.Dash(__name__, suppress_callback_exceptions=True)
11
  server = app.server
12
 
13
- uploaded_files = {}
14
-
15
- def parse_functions_from_files(file_dict):
16
- functions = {}
17
- defined_funcs = set()
18
-
19
- def infer_type_from_value(value_node):
20
- if isinstance(value_node, ast.Call) and isinstance(value_node.func, ast.Attribute):
21
- if value_node.func.attr == "read_csv":
22
- return "pd.DataFrame"
23
- elif value_node.func.attr == "DataFrame":
24
- return "pd.DataFrame"
25
- elif value_node.func.attr == "array":
26
- return "np.ndarray"
27
- elif isinstance(value_node, ast.List):
28
- return "list"
29
- elif isinstance(value_node, ast.Dict):
30
- return "dict"
31
- elif isinstance(value_node, ast.Set):
32
- return "set"
33
- elif isinstance(value_node, ast.Constant):
34
- if isinstance(value_node.value, str):
35
- return "str"
36
- elif isinstance(value_node.value, bool):
37
- return "bool"
38
- elif isinstance(value_node.value, int):
39
- return "int"
40
- elif isinstance(value_node.value, float):
41
- return "float"
42
- return "?"
43
-
44
- for fname, code in file_dict.items():
45
- tree = ast.parse(code)
46
- for node in ast.walk(tree):
47
- if isinstance(node, ast.FunctionDef):
48
- defined_funcs.add(node.name)
49
-
50
- for fname, code in file_dict.items():
51
- tree = ast.parse(code)
52
- for node in ast.walk(tree):
53
- if isinstance(node, ast.FunctionDef):
54
- func_name = node.name
55
- args = []
56
- arg_types = {}
57
- local_assignments = {}
58
- returns = []
59
- calls = []
60
- reads_state = set()
61
- writes_state = set()
62
-
63
- for arg in node.args.args:
64
- name = arg.arg
65
- if arg.annotation:
66
- arg_type = ast.unparse(arg.annotation)
67
- else:
68
- arg_type = "?"
69
- arg_types[name] = arg_type
70
- args.append(f"{name}: {arg_type}")
71
-
72
- for sub in ast.walk(node):
73
- if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Name):
74
- if sub.func.id in defined_funcs:
75
- calls.append(sub.func.id)
76
- elif isinstance(sub, ast.Assign):
77
- for target in sub.targets:
78
- if isinstance(target, ast.Name):
79
- local_assignments[target.id] = infer_type_from_value(sub.value)
80
- elif isinstance(sub, ast.Return):
81
- if sub.value is None:
82
- continue
83
- elif isinstance(sub.value, ast.Tuple):
84
- for elt in sub.value.elts:
85
- label = ast.unparse(elt)
86
- ret_type = local_assignments.get(label, infer_type_from_value(elt))
87
- returns.append(f"{label}: {ret_type}")
88
- else:
89
- label = ast.unparse(sub.value)
90
- ret_type = local_assignments.get(label, infer_type_from_value(sub.value))
91
- returns.append(f"{label}: {ret_type}")
92
- elif isinstance(sub, ast.Subscript):
93
- if (isinstance(sub.value, ast.Attribute)
94
- and isinstance(sub.value.value, ast.Name)
95
- and sub.value.value.id == "st"
96
- and sub.value.attr == "session_state"):
97
- key = None
98
- try:
99
- if isinstance(sub.slice, ast.Constant):
100
- key = sub.slice.value
101
- elif isinstance(sub.slice, ast.Index) and isinstance(sub.slice.value, ast.Constant):
102
- key = sub.slice.value.value
103
- except Exception:
104
- pass
105
- if key:
106
- if isinstance(sub.ctx, ast.Store):
107
- writes_state.add(str(key))
108
- else:
109
- reads_state.add(str(key))
110
-
111
- functions[func_name] = {
112
- "args": args,
113
- "returns": returns,
114
- "calls": calls,
115
- "filename": fname,
116
- "reads_state": sorted(reads_state),
117
- "writes_state": sorted(writes_state)
118
- }
119
-
120
- return functions
121
-
122
- def get_reachable_functions(start, graph):
123
- visited = set()
124
- stack = [start]
125
- while stack:
126
- node = stack.pop()
127
- if node not in visited:
128
- visited.add(node)
129
- stack.extend(graph.get(node, []))
130
- return visited
131
-
132
  app.layout = html.Div([
133
  html.H2("Function Dependency Visualizer (AST-Based)"),
 
 
 
 
 
134
  dcc.Tabs(id="tab-selector", value="intro", children=[
135
  dcc.Tab(label="📘 Introduction", value="intro"),
136
- dcc.Tab(label="📊 Graph Explorer", value="graph")
 
137
  ]),
138
- html.Div(id="tab-content")
 
 
139
  ])
140
 
141
  @app.callback(
142
- Output("tab-content", "children"),
143
- Input("tab-selector", "value")
144
- )
145
- def render_tab(tab):
146
- if tab == "intro":
147
- return dcc.Markdown("""
148
- ### 📘 Introduction
149
-
150
- This tool analyzes uploaded Python files and visualizes how functions call one another.
151
-
152
- ---
153
-
154
- #### 🔍 Features
155
- - Upload multiple `.py` files
156
- - Select a top-level function to explore all functions it calls
157
- - Function table shows:
158
- - Arguments and return values with inferred types (e.g. `df: pd.DataFrame`)
159
- - Streamlit `session_state` variables read/written (if applicable)
160
- - Graph shows:
161
- - Call order on edges (`1`, `2`, `3`)
162
- - Thicker lines if a function is called multiple times
163
-
164
- ---
165
-
166
- #### 📂 How to Use
167
- 1. Switch to the **Graph Explorer** tab
168
- 2. Upload one or more `.py` files
169
- 3. Pick the entry point function
170
- 4. Explore the graph and table dynamically
171
-
172
- ---
173
-
174
- ### 👋 About the Creator
175
-
176
- This tool was built by **Tomas Larsson**, a data scientist and financial modeler with a passion for making complex topics easy to explore and understand.
177
-
178
- Tomas is also the creator of [**my.moneytoolbox.com**](https://mymoneytoolbox.com), a blog focused on:
179
-
180
- - Tax-efficient investing
181
- - Retirement modeling
182
- - Personal finance analytics
183
- - Tools for DIY investors and early retirees
184
-
185
- Whether you're a fellow data enthusiast or someone planning their financial future, Tomas's blog is a resource-rich destination with transparent tools, clear explanations, and practical guidance.
186
-
187
- """, style={"padding": "20px", "maxWidth": "900px"})
188
-
189
- return html.Div([
190
- html.Div([
191
- dcc.Upload(id="upload", children=html.Button("Upload Python Files"), multiple=True),
192
- html.Div(id="main-function-ui", style={"marginRight": "10px", "width": "300px"}),
193
- html.A("Download Graph JSON", id="download-link", download="graph.json", href="", target="_blank")
194
- ], style={"display": "flex", "flexDirection": "row", "alignItems": "center", "gap": "10px"}),
195
- html.Div(id="file-name", style={"marginTop": "10px"}),
196
- cyto.Cytoscape(
197
- id="cytoscape-graph",
198
- layout={"name": "breadthfirst", "directed": True, "padding": 30, "spacingFactor": 1.5},
199
- style={"width": "100%", "height": "600px"},
200
- elements=[],
201
- stylesheet=[
202
- {"selector": "node", "style": {
203
- "label": "data(label)",
204
- "text-wrap": "wrap",
205
- "text-max-width": 120,
206
- "text-valign": "center",
207
- "background-color": "#aed6f1"
208
- }},
209
- {"selector": "edge", "style": {
210
- "curve-style": "bezier",
211
- "target-arrow-shape": "triangle",
212
- "arrow-scale": 2,
213
- "line-color": "#888",
214
- "target-arrow-color": "#888",
215
- "label": "data(label)",
216
- "font-size": "14px",
217
- "text-margin-y": -10
218
- }}
219
- ],
220
- userZoomingEnabled=True,
221
- userPanningEnabled=True,
222
- minZoom=0.2,
223
- maxZoom=2,
224
- wheelSensitivity=0.1
225
- ),
226
- html.Hr(),
227
- html.H4("Function Input/Output Table"),
228
- dash_table.DataTable(
229
- id="function-table",
230
- columns=[],
231
- style_table={"overflowX": "auto"},
232
- style_cell={
233
- "textAlign": "left",
234
- "whiteSpace": "normal",
235
- "wordBreak": "break-word",
236
- "maxWidth": 300
237
- }
238
- )
239
- ])
240
-
241
- @app.callback(
242
- Output("main-function-ui", "children"),
243
- Output("file-name", "children"),
244
  Input("upload", "contents"),
245
  State("upload", "filename")
246
  )
247
- def store_multi_upload(list_of_contents, list_of_names):
248
- if not list_of_contents or not list_of_names:
249
- return "", ""
250
- uploaded_files.clear()
251
- for content, name in zip(list_of_contents, list_of_names):
252
  _, content_string = content.split(",")
253
  uploaded_files[name] = base64.b64decode(content_string).decode("utf-8")
254
-
255
  parsed = parse_functions_from_files(uploaded_files)
256
- main_candidates = [fn for fn, f in parsed.items() if len(f["calls"]) > 0]
257
- options = [{"label": fn, "value": fn} for fn in main_candidates]
258
- dropdown = dcc.Dropdown(id="main-function", options=options, value=options[0]["value"] if options else None)
259
- return dropdown, f"Uploaded files: {', '.join(list_of_names)}"
260
 
261
  @app.callback(
262
- Output("cytoscape-graph", "elements"),
263
- Output("function-table", "data"),
264
- Output("function-table", "columns"),
265
- Output("download-link", "href"),
266
- Input("main-function", "value")
267
  )
268
- def update_multi_graph(main_func):
269
- if not uploaded_files or not main_func:
270
- return [], [], [], ""
 
 
 
271
 
272
- parsed = parse_functions_from_files(uploaded_files)
273
- graph = {k: v["calls"] for k, v in parsed.items()}
274
- reachable = get_reachable_functions(main_func, graph)
275
 
 
 
 
 
 
 
 
 
 
 
 
276
  nodes = [{"data": {"id": name, "label": name}} for name in reachable]
277
-
278
  edges = []
279
  for src in reachable:
280
  call_sequence = parsed[src]["calls"]
281
  call_index = 1
282
  for tgt in call_sequence:
283
- if tgt not in reachable:
284
- continue
285
- if not any(e["data"]["source"] == src and e["data"]["target"] == tgt for e in edges):
286
  edge = {
287
  "data": {
288
  "source": src,
@@ -291,39 +90,79 @@ def update_multi_graph(main_func):
291
  },
292
  "style": {
293
  "line-width": 4 if call_sequence.count(tgt) > 1 else 2
294
- },
295
- "classes": "crossfile" if parsed[src]["filename"] != parsed[tgt]["filename"] else ""
296
  }
297
  edges.append(edge)
298
  call_index += 1
299
 
300
- raw_table_data = [{
301
- "Function": fn,
302
- "Arguments": ", ".join(parsed[fn]["args"]),
303
- "Returns": ", ".join(parsed[fn]["returns"]),
304
- "Reads State": ", ".join(parsed[fn]["reads_state"]),
305
- "Writes State": ", ".join(parsed[fn]["writes_state"]),
306
- "File": parsed[fn]["filename"]
307
- } for fn in reachable]
308
-
309
- show_reads = any(row["Reads State"] for row in raw_table_data)
310
- show_writes = any(row["Writes State"] for row in raw_table_data)
311
 
312
- columns = [
313
- {"name": "Function", "id": "Function"},
314
- {"name": "Arguments", "id": "Arguments"},
315
- {"name": "Returns", "id": "Returns"},
316
- ]
317
- if show_reads:
318
- columns.append({"name": "Reads State", "id": "Reads State"})
319
- if show_writes:
320
- columns.append({"name": "Writes State", "id": "Writes State"})
321
- columns.append({"name": "File", "id": "File"})
322
 
323
- json_data = json.dumps({"nodes": nodes, "edges": edges}, indent=2)
324
- href_data = "data:application/json;charset=utf-8," + urllib.parse.quote(json_data)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
 
326
- return nodes + edges, raw_table_data, columns, href_data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
 
328
  if __name__ == "__main__":
329
- app.run(host="0.0.0.0", port=7860)
 
 
1
  import base64
2
  import dash
3
  from dash import html, dcc, dash_table
4
  import dash_cytoscape as cyto
5
  from dash.dependencies import Input, Output, State
6
+ import pandas as pd
7
+
8
+ from utility import parse_functions_from_files, get_reachable_functions, get_backtrace_functions
9
+ from introduction import get_intro_markdown
10
 
11
  app = dash.Dash(__name__, suppress_callback_exceptions=True)
12
  server = app.server
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  app.layout = html.Div([
15
  html.H2("Function Dependency Visualizer (AST-Based)"),
16
+ dcc.Store(id="parsed-functions-store"),
17
+ dcc.Upload(id="upload", children=html.Button("Upload Python Files"), multiple=True),
18
+ dcc.Dropdown(id="main-function", style={"width": "300px"}),
19
+ dcc.Checklist(id="backtrace-toggle", options=[{"label": "Backtrace Mode", "value": "backtrace"}], value=[]),
20
+ dcc.Checklist(id="function-detail-toggle", options=[{"label": "Show Function-Level Detail in summary tab", "value": "show"}], value=[]),
21
  dcc.Tabs(id="tab-selector", value="intro", children=[
22
  dcc.Tab(label="📘 Introduction", value="intro"),
23
+ dcc.Tab(label="📊 Graph Explorer", value="graph"),
24
+ dcc.Tab(label="📁 File Summary", value="summary")
25
  ]),
26
+ html.Div(id="intro-tab"),
27
+ html.Div(id="graph-tab"),
28
+ html.Div(id="summary-tab")
29
  ])
30
 
31
  @app.callback(
32
+ Output("main-function", "options"),
33
+ Output("main-function", "value"),
34
+ Output("parsed-functions-store", "data"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  Input("upload", "contents"),
36
  State("upload", "filename")
37
  )
38
+ def handle_upload(contents, filenames):
39
+ if not contents:
40
+ return [], None, {}
41
+ uploaded_files = {}
42
+ for content, name in zip(contents, filenames):
43
  _, content_string = content.split(",")
44
  uploaded_files[name] = base64.b64decode(content_string).decode("utf-8")
 
45
  parsed = parse_functions_from_files(uploaded_files)
46
+ options = [{"label": fn, "value": fn} for fn in parsed if parsed[fn]["calls"]]
47
+ return options, options[0]["value"] if options else None, parsed
 
 
48
 
49
  @app.callback(
50
+ Output("intro-tab", "style"),
51
+ Output("graph-tab", "style"),
52
+ Output("summary-tab", "style"),
53
+ Input("tab-selector", "value")
 
54
  )
55
+ def toggle_tabs(tab):
56
+ return (
57
+ {"display": "block"} if tab == "intro" else {"display": "none"},
58
+ {"display": "block"} if tab == "graph" else {"display": "none"},
59
+ {"display": "block"} if tab == "summary" else {"display": "none"}
60
+ )
61
 
62
+ @app.callback(Output("intro-tab", "children"), Input("tab-selector", "value"))
63
+ def show_intro(_):
64
+ return get_intro_markdown()
65
 
66
+ @app.callback(
67
+ Output("graph-tab", "children"),
68
+ Input("main-function", "value"),
69
+ Input("backtrace-toggle", "value"),
70
+ State("parsed-functions-store", "data")
71
+ )
72
+ def update_graph(main_func, backtrace_mode, parsed):
73
+ if not parsed or not main_func:
74
+ return html.Div("Upload files and select a main function.")
75
+ graph = {k: v["calls"] for k, v in parsed.items()}
76
+ reachable = get_backtrace_functions(main_func, graph) if "backtrace" in backtrace_mode else get_reachable_functions(main_func, graph)
77
  nodes = [{"data": {"id": name, "label": name}} for name in reachable]
78
+ #edges = [{"data": {"source": src, "target": tgt}, "classes": "edge"} for src in reachable for tgt in parsed[src]["calls"] if tgt in reachable]
79
  edges = []
80
  for src in reachable:
81
  call_sequence = parsed[src]["calls"]
82
  call_index = 1
83
  for tgt in call_sequence:
84
+ if tgt in reachable:
 
 
85
  edge = {
86
  "data": {
87
  "source": src,
 
90
  },
91
  "style": {
92
  "line-width": 4 if call_sequence.count(tgt) > 1 else 2
93
+ }
 
94
  }
95
  edges.append(edge)
96
  call_index += 1
97
 
 
 
 
 
 
 
 
 
 
 
 
98
 
 
 
 
 
 
 
 
 
 
 
99
 
100
+ return cyto.Cytoscape(
101
+ id="cytoscape-graph",
102
+ layout={"name": "breadthfirst"},
103
+ style={"width": "100%", "height": "600px"},
104
+ elements=nodes + edges,
105
+ stylesheet=[
106
+ {"selector": "node", "style": {"label": "data(label)", "text-wrap": "wrap"}},
107
+ #{"selector": "edge", "style": {"curve-style": "bezier", "target-arrow-shape": "triangle", "target-arrow-color": "#888", "line-color": "#888"}}
108
+ {"selector": "edge", "style": {
109
+ "label": "data(label)",
110
+ "curve-style": "bezier",
111
+ "target-arrow-shape": "triangle",
112
+ "target-arrow-color": "#888",
113
+ "line-color": "#888",
114
+ "font-size": "14px",
115
+ "text-margin-y": -10
116
+ }}
117
+
118
+ ],
119
+ userZoomingEnabled=True,
120
+ userPanningEnabled=True,
121
+ minZoom=0.2,
122
+ maxZoom=2,
123
+ wheelSensitivity=0.1
124
+ )
125
 
126
+ @app.callback(
127
+ Output("summary-tab", "children"),
128
+ Input("parsed-functions-store", "data"),
129
+ Input("function-detail-toggle", "value")
130
+ )
131
+ def update_summary(parsed, toggle):
132
+ if not parsed:
133
+ return html.Div("No summary available.")
134
+ reverse_calls = {k: [] for k in parsed}
135
+ for caller, meta in parsed.items():
136
+ for callee in meta["calls"]:
137
+ reverse_calls[callee].append(caller)
138
+
139
+ if "show" in toggle:
140
+ df = pd.DataFrame([{
141
+ "Function": fn,
142
+ "File": parsed[fn]["filename"],
143
+ "Arguments": ", ".join(parsed[fn]["args"]),
144
+ "Returns": ", ".join(parsed[fn]["returns"]),
145
+ "Reads State": ", ".join(parsed[fn]["reads_state"]),
146
+ "Writes State": ", ".join(parsed[fn]["writes_state"]),
147
+ "Called By": len(reverse_calls[fn])
148
+ } for fn in parsed])
149
+ else:
150
+ file_summary = {}
151
+ for func, meta in parsed.items():
152
+ fname = meta["filename"]
153
+ file_summary.setdefault(fname, {"Total": 0, "Calls Others": 0, "Called By Others": 0, "Unused": 0})
154
+ file_summary[fname]["Total"] += 1
155
+ if meta["calls"]: file_summary[fname]["Calls Others"] += 1
156
+ if reverse_calls[func]: file_summary[fname]["Called By Others"] += 1
157
+ if not meta["calls"] and not reverse_calls[func]: file_summary[fname]["Unused"] += 1
158
+ df = pd.DataFrame([{"File": f, **stats} for f, stats in file_summary.items()])
159
+
160
+ return dash_table.DataTable(
161
+ data=df.to_dict("records"),
162
+ columns=[{"name": c, "id": c} for c in df.columns],
163
+ style_table={"overflowX": "auto"},
164
+ style_cell={"whiteSpace": "normal", "textAlign": "left", "padding": "5px", "maxWidth": 300}
165
+ )
166
 
167
  if __name__ == "__main__":
168
+ app.run(debug=False, port=7860)
introduction.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dash import dcc
2
+
3
+ def get_intro_markdown():
4
+ return dcc.Markdown("""
5
+ ### 📘 Introduction
6
+
7
+ This tool analyzes uploaded Python files and visualizes how functions call one another.
8
+
9
+ ---
10
+
11
+ #### 🔍 Features
12
+ - Upload multiple `.py` files
13
+ - Select a top-level function to explore all functions it calls
14
+ - Function table shows:
15
+ - Arguments and return values with inferred types (e.g. `df: pd.DataFrame`)
16
+ - Streamlit `session_state` variables read/written (if applicable)
17
+ - Graph shows:
18
+ - Call order on edges (`1`, `2`, `3`)
19
+ - Thicker lines if a function is called multiple times
20
+
21
+ ---
22
+
23
+ #### 📂 How to Use
24
+ 1. Switch to the **Graph Explorer** tab
25
+ 2. Upload one or more `.py` files
26
+ 3. Pick the entry point function
27
+ 4. Explore the graph and table dynamically
28
+
29
+ ---
30
+
31
+ ### 👋 About the Creator
32
+
33
+ This tool was built by **Tomas Larsson**, a data scientist and financial modeler with a passion for making complex topics easy to explore and understand.
34
+
35
+ Tomas is also the creator of [**my.moneytoolbox.com**](https://mymoneytoolbox.com), a blog focused on:
36
+
37
+ - Tax-efficient investing
38
+ - Retirement modeling
39
+ - Personal finance analytics
40
+ - Tools for DIY investors and early retirees
41
+
42
+ Whether you're a fellow data enthusiast or someone planning their financial future, Tomas's blog is a resource-rich destination with transparent tools, clear explanations, and practical guidance.
43
+
44
+ """)
utility.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ast
2
+
3
+ def parse_functions_from_files(file_dict):
4
+ functions = {}
5
+ defined_funcs = set()
6
+
7
+ def infer_type_from_value(value_node):
8
+ if isinstance(value_node, ast.Call) and isinstance(value_node.func, ast.Attribute):
9
+ if value_node.func.attr in ("read_csv", "DataFrame"): return "pd.DataFrame"
10
+ if value_node.func.attr == "array": return "np.ndarray"
11
+ elif isinstance(value_node, ast.List): return "list"
12
+ elif isinstance(value_node, ast.Dict): return "dict"
13
+ elif isinstance(value_node, ast.Set): return "set"
14
+ elif isinstance(value_node, ast.Constant):
15
+ if isinstance(value_node.value, str): return "str"
16
+ if isinstance(value_node.value, bool): return "bool"
17
+ if isinstance(value_node.value, int): return "int"
18
+ if isinstance(value_node.value, float): return "float"
19
+ return "?"
20
+
21
+ for fname, code in file_dict.items():
22
+ tree = ast.parse(code)
23
+ for node in ast.walk(tree):
24
+ if isinstance(node, ast.FunctionDef): defined_funcs.add(node.name)
25
+
26
+ for fname, code in file_dict.items():
27
+ tree = ast.parse(code)
28
+ for node in ast.walk(tree):
29
+ if isinstance(node, ast.FunctionDef):
30
+ func_name = node.name
31
+ args, returns, calls = [], [], []
32
+ local_assignments = {}
33
+ reads_state, writes_state = set(), set()
34
+ for arg in node.args.args:
35
+ arg_type = ast.unparse(arg.annotation) if arg.annotation else "?"
36
+ args.append(f"{arg.arg}: {arg_type}")
37
+ for sub in ast.walk(node):
38
+ if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Name):
39
+ if sub.func.id in defined_funcs: calls.append(sub.func.id)
40
+ elif isinstance(sub, ast.Assign):
41
+ for target in sub.targets:
42
+ if isinstance(target, ast.Name):
43
+ local_assignments[target.id] = infer_type_from_value(sub.value)
44
+ elif isinstance(sub, ast.Return):
45
+ if sub.value is None: continue
46
+ if isinstance(sub.value, ast.Tuple):
47
+ for elt in sub.value.elts:
48
+ label = ast.unparse(elt)
49
+ returns.append(f"{label}: {local_assignments.get(label, infer_type_from_value(elt))}")
50
+ else:
51
+ label = ast.unparse(sub.value)
52
+ returns.append(f"{label}: {local_assignments.get(label, infer_type_from_value(sub.value))}")
53
+ functions[func_name] = {
54
+ "args": args,
55
+ "returns": returns,
56
+ "calls": calls,
57
+ "filename": fname,
58
+ "reads_state": sorted(reads_state),
59
+ "writes_state": sorted(writes_state)
60
+ }
61
+ return functions
62
+
63
+ def get_reachable_functions(start, graph):
64
+ visited, stack = set(), [start]
65
+ while stack:
66
+ node = stack.pop()
67
+ if node not in visited:
68
+ visited.add(node)
69
+ stack.extend(graph.get(node, []))
70
+ return visited
71
+
72
+ def get_backtrace_functions(target, graph):
73
+ reverse_graph = {}
74
+ for caller, callees in graph.items():
75
+ for callee in callees:
76
+ reverse_graph.setdefault(callee, []).append(caller)
77
+ visited, stack = set(), [target]
78
+ while stack:
79
+ node = stack.pop()
80
+ if node not in visited:
81
+ visited.add(node)
82
+ stack.extend(reverse_graph.get(node, []))
83
+ return visited