tlarsson commited on
Commit
3d19c46
·
verified ·
1 Parent(s): e78a807

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +121 -42
  2. introduction.py +33 -20
  3. utility.py +63 -0
app.py CHANGED
@@ -5,29 +5,102 @@ 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"),
@@ -36,6 +109,7 @@ app.layout = html.Div([
36
  State("upload", "filename")
37
  )
38
  def handle_upload(contents, filenames):
 
39
  if not contents:
40
  return [], None, {}
41
  uploaded_files = {}
@@ -43,13 +117,19 @@ def handle_upload(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):
@@ -63,48 +143,46 @@ def toggle_tabs(tab):
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,
88
- "target": tgt,
89
- "label": str(call_index)
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",
@@ -113,9 +191,9 @@ def update_graph(main_func, backtrace_mode, parsed):
113
  "line-color": "#888",
114
  "arrow-scale": 2,
115
  "font-size": "14px",
116
- "text-margin-y": -10
 
117
  }}
118
-
119
  ],
120
  userZoomingEnabled=True,
121
  userPanningEnabled=True,
@@ -124,8 +202,9 @@ def update_graph(main_func, backtrace_mode, parsed):
124
  wheelSensitivity=0.1
125
  )
126
 
 
127
  @app.callback(
128
- Output("summary-tab", "children"),
129
  Input("parsed-functions-store", "data"),
130
  Input("function-detail-toggle", "value")
131
  )
 
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, build_nodes_and_edges
9
  from introduction import get_intro_markdown
10
 
11
  app = dash.Dash(__name__, suppress_callback_exceptions=True)
12
  server = app.server
13
 
14
+
15
  app.layout = html.Div([
16
+ html.H2("Function Dependency Visualizer (AST-Based)", style={
17
+ "textAlign": "center",
18
+ "marginTop": "20px",
19
+ "fontSize": "28px",
20
+ "color": "#333"
21
+ }),
22
  dcc.Store(id="parsed-functions-store"),
23
+
24
+ html.Div([
25
+ html.Div([
26
+ html.Label("Upload Files"),
27
+ dcc.Upload(
28
+ id="upload",
29
+ children=html.Div("📤 Drag and drop or click to upload Python files", style={
30
+ "border": "2px dashed #ccc",
31
+ "padding": "10px",
32
+ "textAlign": "center",
33
+ "cursor": "pointer",
34
+ "color": "#555",
35
+ "fontSize": "14px"
36
+ }),
37
+ multiple=True
38
+ )
39
+ ], style={"width": "24%", "display": "inline-block", "verticalAlign": "top", "marginRight": "1%"}),
40
+
41
+ html.Div([
42
+ html.Label("Main Function"),
43
+ dcc.Dropdown(id="main-function", style={"width": "100%"})
44
+ ], style={"width": "24%", "display": "inline-block", "verticalAlign": "top", "marginRight": "1%"}),
45
+
46
+ html.Div([
47
+ html.Label("Max Depth"),
48
+ dcc.Dropdown(
49
+ id="max-depth",
50
+ options=[{"label": str(i), "value": i} for i in range(1, 11)],
51
+ value=10,
52
+ clearable=False,
53
+ style={"width": "100%"}
54
+ )
55
+ ], style={"width": "24%", "display": "inline-block", "verticalAlign": "top", "marginRight": "1%"}),
56
+
57
+ html.Div([
58
+ html.Label("Options"),
59
+ dcc.Checklist(
60
+ id="backtrace-toggle",
61
+ options=[{"label": "Backtrace Mode", "value": "backtrace"}],
62
+ value=[],
63
+ labelStyle={"display": "block", "fontWeight": "normal"}
64
+ ),
65
+ dcc.Checklist(
66
+ id="function-detail-toggle",
67
+ options=[{"label": "Show Function-Level Detail", "value": "show"}],
68
+ value=[],
69
+ labelStyle={"display": "block", "fontWeight": "normal"},
70
+ style={"marginTop": "5px"}
71
+ )
72
+ ], style={"width": "24%", "display": "inline-block", "verticalAlign": "top"})
73
+ ], style={
74
+ "backgroundColor": "#f9f9f9",
75
+ "padding": "15px",
76
+ "borderRadius": "10px",
77
+ "margin": "20px auto",
78
+ "width": "95%",
79
+ "boxShadow": "0 2px 6px rgba(0,0,0,0.1)"
80
+ }),
81
+
82
+
83
  dcc.Tabs(id="tab-selector", value="intro", children=[
84
  dcc.Tab(label="📘 Introduction", value="intro"),
85
  dcc.Tab(label="📊 Graph Explorer", value="graph"),
86
  dcc.Tab(label="📁 File Summary", value="summary")
87
+ ], style={"margin": "0 20px"}),
88
+
89
+ html.Div(html.Div(id="intro-tab", style={"margin": "0 20px"}), id="intro-tab-container"),
90
+ html.Div(id="graph-tab-container", style={"margin": "0 20px"}),
91
+ html.Div(id="summary-tab-container", style={"margin": "0 20px"}),
92
+
93
+
94
+ html.Footer("Built by Tomas Larsson • MIT Licensed", style={
95
+ "textAlign": "center",
96
+ "fontSize": "14px",
97
+ "marginTop": "40px",
98
+ "marginBottom": "20px",
99
+ "color": "#888"
100
+ })
101
  ])
102
 
103
+
104
  @app.callback(
105
  Output("main-function", "options"),
106
  Output("main-function", "value"),
 
109
  State("upload", "filename")
110
  )
111
  def handle_upload(contents, filenames):
112
+ print("=== handle_upload triggered ===")
113
  if not contents:
114
  return [], None, {}
115
  uploaded_files = {}
 
117
  _, content_string = content.split(",")
118
  uploaded_files[name] = base64.b64decode(content_string).decode("utf-8")
119
  parsed = parse_functions_from_files(uploaded_files)
120
+ print("== PARSED FUNCTION NAMES ==")
121
+ for fn, meta in parsed.items():
122
+ print(f"{fn} → calls: {meta.get('calls', [])}")
123
+ print("== END ==")
124
+
125
+ # options = [{"label": fn, "value": fn} for fn in parsed if parsed[fn]["calls"]]
126
+ options = [{"label": fn, "value": fn} for fn in parsed]
127
  return options, options[0]["value"] if options else None, parsed
128
 
129
  @app.callback(
130
+ Output("intro-tab-container", "style"),
131
+ Output("graph-tab-container", "style"),
132
+ Output("summary-tab-container", "style"),
133
  Input("tab-selector", "value")
134
  )
135
  def toggle_tabs(tab):
 
143
  def show_intro(_):
144
  return get_intro_markdown()
145
 
146
+ from collections import deque, defaultdict
147
+
148
  @app.callback(
149
+ Output("graph-tab-container", "children"),
150
  Input("main-function", "value"),
151
  Input("backtrace-toggle", "value"),
152
+ Input("max-depth", "value"),
153
  State("parsed-functions-store", "data")
154
  )
155
+ def update_graph(main_func, backtrace_mode, max_depth, parsed):
156
  if not parsed or not main_func:
157
  return html.Div("Upload files and select a main function.")
158
+
159
  graph = {k: v["calls"] for k, v in parsed.items()}
160
  reachable = get_backtrace_functions(main_func, graph) if "backtrace" in backtrace_mode else get_reachable_functions(main_func, graph)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
162
+ nodes, edges = build_nodes_and_edges(parsed, main_func, reachable, reverse="backtrace" in backtrace_mode, max_depth=max_depth)
163
 
164
 
165
  return cyto.Cytoscape(
166
  id="cytoscape-graph",
167
+ layout={"name": "preset"},
168
  style={"width": "100%", "height": "600px"},
169
  elements=nodes + edges,
170
  stylesheet=[
171
+ {"selector": "node", "style": {
172
+ "label": "data(label)",
173
+ "text-wrap": "wrap",
174
+ "text-valign": "bottom",
175
+ "text-halign": "center"
176
+ }},
177
+ {"selector": ".main", "style": {
178
+ "background-color": "blue",
179
+ "line-color": "blue",
180
+ "color": "black",
181
+ "label": "data(label)",
182
+ "text-wrap": "wrap",
183
+ "text-valign": "bottom",
184
+ "text-halign": "center"
185
+ }},
186
  {"selector": "edge", "style": {
187
  "label": "data(label)",
188
  "curve-style": "bezier",
 
191
  "line-color": "#888",
192
  "arrow-scale": 2,
193
  "font-size": "14px",
194
+ "text-margin-y": -10,
195
+ "text-margin-x": 10,
196
  }}
 
197
  ],
198
  userZoomingEnabled=True,
199
  userPanningEnabled=True,
 
202
  wheelSensitivity=0.1
203
  )
204
 
205
+
206
  @app.callback(
207
+ Output("summary-tab-container", "children"),
208
  Input("parsed-functions-store", "data"),
209
  Input("function-detail-toggle", "value")
210
  )
introduction.py CHANGED
@@ -2,43 +2,56 @@ 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
  """)
 
2
 
3
  def get_intro_markdown():
4
  return dcc.Markdown("""
5
+ ### Function Call Visualizer for Python
6
 
7
+ Upload `.py` files and instantly see how functions connect—**visually**.
8
+ Ideal for refactoring, onboarding, debugging, or exploring unfamiliar code.
9
 
10
  ---
11
 
12
  #### 🔍 Features
13
+ - Upload **one or more** Python files
14
+ - Pick a top-level function to trace all its calls
15
+ - Auto-inferred:
16
+ - Function arguments and return types (e.g. `df: pd.DataFrame`)
17
+ - Session state usage (e.g. Streamlit `session_state`)
18
+ - Interactive graph with:
19
  - Call order on edges (`1`, `2`, `3`)
20
+ - Thicker lines for repeated calls
21
+ - Directional arrows and clear layout by call depth
22
+ - Toggleable summary tables
23
 
24
  ---
25
 
26
+ #### 🚀 How to Use
27
  1. Switch to the **Graph Explorer** tab
28
+ 2. Upload `.py` file(s)
29
+ 3. Select an entry-point function
30
+ 4. Explore how your code is structured—at a glance
31
+
32
  ---
 
 
33
 
34
+ ### 👋 About the Creator
35
 
36
+ This open-source tool is built by **Tomas Larsson**,
37
+ a data scientist and creator of [**my.moneytoolbox.com**](https://mymoneytoolbox.com), where he shares tools and insights for:
38
 
39
  - Tax-efficient investing
40
  - Retirement modeling
41
  - Personal finance analytics
42
+ - Code-first decision tools for DIY investors and engineers
43
+
44
+ ---
45
+
46
+ ### 🤝 Connect
47
+
48
+ Love dev tools like this?
49
+ Tomas shares more on [**LinkedIn**](https://www.linkedin.com/in/tomaslarsson/) —
50
+ follow along for open-source tools at the intersection of data, finance, and visualization.
51
+
52
+ ---
53
 
54
+ ### 🧠 Tags
55
+ `#python` `#static-analysis` `#ast` `#code-visualization` `#developer-tools` `#streamlit` `#dash` `#visualization`
56
 
57
  """)
utility.py CHANGED
@@ -81,3 +81,66 @@ def get_backtrace_functions(target, graph):
81
  visited.add(node)
82
  stack.extend(reverse_graph.get(node, []))
83
  return visited
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  visited.add(node)
82
  stack.extend(reverse_graph.get(node, []))
83
  return visited
84
+
85
+ from collections import deque, defaultdict
86
+
87
+ def build_nodes_and_edges(parsed, root_func, reachable, reverse=False, max_depth=10):
88
+ depth_map = {}
89
+ x_offset_map = defaultdict(int)
90
+ positions = {}
91
+ visited = set()
92
+ queue = deque([(root_func, 0)])
93
+
94
+ while queue:
95
+ current, depth = queue.popleft()
96
+ if current in visited or depth > max_depth:
97
+ continue
98
+ visited.add(current)
99
+
100
+ adjusted_depth = -depth if reverse else depth
101
+ x = x_offset_map[adjusted_depth] * 300
102
+ y = adjusted_depth * 150
103
+ positions[current] = {"x": x, "y": y}
104
+ x_offset_map[adjusted_depth] += 1
105
+
106
+ if reverse:
107
+ # Find callers of this function
108
+ next_funcs = [
109
+ caller for caller, meta in parsed.items()
110
+ if current in meta["calls"] and caller in reachable
111
+ ]
112
+ else:
113
+ # Find callees
114
+ next_funcs = [
115
+ callee for callee in parsed[current]["calls"]
116
+ if callee in reachable
117
+ ]
118
+
119
+ for nxt in next_funcs:
120
+ queue.append((nxt, depth + 1))
121
+
122
+ nodes = [{
123
+ "data": {"id": name, "label": name},
124
+ "position": positions.get(name, {"x": 0, "y": 0}),
125
+ "classes": "main" if name == root_func else ""
126
+ } for name in visited]
127
+
128
+ edges = []
129
+ for src in visited:
130
+ call_sequence = parsed[src]["calls"]
131
+ call_index = 1
132
+ for tgt in call_sequence:
133
+ if tgt in visited:
134
+ edges.append({
135
+ "data": {
136
+ "source": src,
137
+ "target": tgt,
138
+ "label": str(call_index)
139
+ },
140
+ "style": {
141
+ "line-width": 4 if call_sequence.count(tgt) > 1 else 2
142
+ }
143
+ })
144
+ call_index += 1
145
+
146
+ return nodes, edges