tlarsson commited on
Commit
1f4b5de
Β·
verified Β·
1 Parent(s): 97db4d2

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +174 -71
app.py CHANGED
@@ -4,17 +4,43 @@ 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 io
8
  import json
9
  import urllib.parse
10
 
11
  app = dash.Dash(__name__, suppress_callback_exceptions=True)
12
  server = app.server
13
 
 
 
14
  def parse_functions_from_files(file_dict):
15
  functions = {}
16
  defined_funcs = set()
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  for fname, code in file_dict.items():
19
  tree = ast.parse(code)
20
  for node in ast.walk(tree):
@@ -26,44 +52,56 @@ def parse_functions_from_files(file_dict):
26
  for node in ast.walk(tree):
27
  if isinstance(node, ast.FunctionDef):
28
  func_name = node.name
29
- args = [arg.arg for arg in node.args.args]
 
 
30
  returns = []
31
  calls = []
32
  reads_state = set()
33
  writes_state = set()
34
 
 
 
 
 
 
 
 
 
 
35
  for sub in ast.walk(node):
36
- # Function calls
37
  if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Name):
38
  if sub.func.id in defined_funcs:
39
  calls.append(sub.func.id)
40
-
41
- # Return values
 
 
42
  elif isinstance(sub, ast.Return):
43
  if sub.value is None:
44
  continue
45
  elif isinstance(sub.value, ast.Tuple):
46
- returns.extend([ast.unparse(elt) for elt in sub.value.elts])
 
 
 
47
  else:
48
- returns.append(ast.unparse(sub.value))
49
-
50
- # Streamlit session_state interactions
51
  elif isinstance(sub, ast.Subscript):
52
- if (
53
- isinstance(sub.value, ast.Attribute)
54
  and isinstance(sub.value.value, ast.Name)
55
  and sub.value.value.id == "st"
56
- and sub.value.attr == "session_state"
57
- ):
58
  key = None
59
  try:
60
- if isinstance(sub.slice, ast.Constant): # Python 3.9+
61
  key = sub.slice.value
62
- elif isinstance(sub.slice, ast.Index) and isinstance(sub.slice.value, ast.Constant): # <3.9
63
  key = sub.slice.value.value
64
  except Exception:
65
  pass
66
-
67
  if key:
68
  if isinstance(sub.ctx, ast.Store):
69
  writes_state.add(str(key))
@@ -76,7 +114,7 @@ def parse_functions_from_files(file_dict):
76
  "calls": calls,
77
  "filename": fname,
78
  "reads_state": sorted(reads_state),
79
- "writes_state": sorted(writes_state),
80
  }
81
 
82
  return functions
@@ -92,56 +130,113 @@ def get_reachable_functions(start, graph):
92
  return visited
93
 
94
  app.layout = html.Div([
95
- html.H2("Multi-File Function Dependency Graph (AST-based)"),
96
- html.Div([
97
- dcc.Upload(id="upload", children=html.Button("Upload Python Files"), multiple=True, style={"marginRight": "10px"}),
98
- html.Div(id="main-function-ui", style={"marginRight": "10px", "width": "300px"}),
99
- html.A("Download Graph JSON", id="download-link", download="graph.json", href="", target="_blank")
100
- ], style={"display": "flex", "flexDirection": "row", "alignItems": "center", "gap": "10px"}),
101
- html.Div(id="file-name", style={"marginTop": "10px"}),
102
- cyto.Cytoscape(
103
- id="cytoscape-graph",
104
- layout={"name": "breadthfirst", "directed": True, "padding": 30, "spacingFactor": 1.5},
105
- style={"width": "100%", "height": "600px"},
106
- elements=[],
107
- stylesheet=[
108
- {"selector": "node", "style": {
109
- "label": "data(label)",
110
- "text-wrap": "wrap",
111
- "text-max-width": 120,
112
- "text-valign": "center",
113
- "background-color": "#aed6f1"
114
- }},
115
- {"selector": "edge", "style": {
116
- "curve-style": "bezier",
117
- "target-arrow-shape": "triangle",
118
- "arrow-scale": 2,
119
- "line-color": "#888",
120
- "target-arrow-color": "#888"
121
- }}
122
- ],
123
- userZoomingEnabled=True,
124
- userPanningEnabled=True,
125
- minZoom=0.2,
126
- maxZoom=2,
127
- wheelSensitivity=0.1
128
- ),
129
- html.Hr(),
130
- html.H4("Function Input/Output Table"),
131
- dash_table.DataTable(
132
- id="function-table",
133
- columns=[], # dynamically injected
134
- style_table={"overflowX": "auto"},
135
- style_cell={
136
- "textAlign": "left",
137
- "whiteSpace": "normal",
138
- "wordBreak": "break-word",
139
- "maxWidth": 300
140
- }
141
- )
142
  ])
143
 
144
- uploaded_files = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
 
146
  @app.callback(
147
  Output("main-function-ui", "children"),
@@ -182,17 +277,25 @@ def update_multi_graph(main_func):
182
 
183
  edges = []
184
  for src in reachable:
185
- for tgt in parsed[src]["calls"]:
186
- if tgt in reachable:
 
 
 
 
187
  edge = {
188
  "data": {
189
  "source": src,
190
- "target": tgt
191
- }
 
 
 
 
 
192
  }
193
- if parsed[src]["filename"] != parsed[tgt]["filename"]:
194
- edge["classes"] = "crossfile"
195
  edges.append(edge)
 
196
 
197
  raw_table_data = [{
198
  "Function": fn,
 
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):
 
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))
 
114
  "calls": calls,
115
  "filename": fname,
116
  "reads_state": sorted(reads_state),
117
+ "writes_state": sorted(writes_state)
118
  }
119
 
120
  return functions
 
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"),
 
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,
289
+ "target": tgt,
290
+ "label": str(call_index)
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,