Skip to content

Commit 36d1765

Browse files
authored
feat(devtools): Add support to dump the import graph (#37698)
1 parent 0419374 commit 36d1765

File tree

3 files changed

+142
-0
lines changed

3 files changed

+142
-0
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,6 @@ package-lock.json
5454
.webpack.meta
5555
pip-wheel-metadata/
5656
Brewfile.lock.json
57+
import-graph.txt
58+
import-graph.dot
5759
bin/rrweb-output

src/sentry/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# this must import first
2+
import sentry._importchecker # NOQA isort:skip
3+
14
import importlib.metadata
25
import os
36
import os.path

src/sentry/_importchecker.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import atexit
2+
import builtins
3+
import functools
4+
import os
5+
import sys
6+
7+
real_import = builtins.__import__
8+
9+
10+
TRACK_IMPORTS = os.environ.get("SENTRY_TRACK_IMPORTS") == "1"
11+
TRACKED_PACKAGES = ("sentry", "getsentry")
12+
13+
observations = set()
14+
import_order = []
15+
16+
17+
def resolve_full_name(base, name, level):
18+
"""Resolve a relative module name to an absolute one."""
19+
if level == 0:
20+
return name
21+
bits = base.rsplit(".", level - 1)
22+
base = bits[0]
23+
return f"{base}.{name}" if name else base
24+
25+
26+
def is_relevant_import(package):
27+
if package is None:
28+
return False
29+
return package in TRACKED_PACKAGES or package.split(".")[0] in TRACKED_PACKAGES
30+
31+
32+
def emit_dot(filename):
33+
modules = {}
34+
35+
def _register_module(name):
36+
if modules.get(name) is not None:
37+
return
38+
modules[name] = len(modules)
39+
40+
with open(filename, "w") as f:
41+
f.write("digraph {\n")
42+
43+
for from_name, to_name in observations:
44+
_register_module(from_name)
45+
_register_module(to_name)
46+
47+
for module_name, id in sorted(modules.items(), key=lambda x: x[1]):
48+
f.write(f' {id} [label="{module_name}" color="red"]\n')
49+
50+
for pair in observations:
51+
f.write(f' {modules[pair[0]]} -> {modules[pair[1]]} [color="gray"]\n')
52+
53+
f.write("}\n")
54+
55+
56+
def emit_ascii_tree(filename):
57+
dependencies = {}
58+
59+
for from_name, to_name in observations:
60+
dependencies.setdefault(from_name, set()).add(to_name)
61+
62+
indentation = 0
63+
64+
def _write_dep(f, name, seen=None):
65+
nonlocal indentation
66+
marker = f"{indentation:02}"
67+
children = dependencies.get(name) or []
68+
if seen is None:
69+
seen = {}
70+
if name in seen:
71+
count = seen[name]
72+
seen[name] = count + 1
73+
f.write(f"{' ' * indentation}-{marker} {name} ({count})\n")
74+
return
75+
seen[name] = 1
76+
f.write(f"{' ' * indentation}-{marker} {name}:\n")
77+
indentation += 1
78+
for child in sorted(children):
79+
_write_dep(f, child, seen=seen)
80+
indentation -= 1
81+
82+
seen = {}
83+
with open(filename, "w") as f:
84+
for name in import_order:
85+
_write_dep(f, name, seen=seen)
86+
87+
top_n = sorted(seen.items(), key=lambda x: x[1], reverse=True)
88+
f.write("\nTop dependencies:\n")
89+
for name, count in top_n[:30]:
90+
f.write(f" - {name}: {count}\n")
91+
92+
93+
def track_import(from_name, to_name, fromlist):
94+
if not is_relevant_import(from_name) or not is_relevant_import(to_name):
95+
return
96+
97+
if sys.modules.get(to_name) is not None and sys.modules.get(from_name) is not None:
98+
if to_name not in import_order:
99+
import_order.append(to_name)
100+
observations.add((from_name, to_name))
101+
102+
for name in fromlist or ():
103+
potential_module_name = to_name + "." + name
104+
if sys.modules.get(potential_module_name) is not None:
105+
observations.add((from_name, potential_module_name))
106+
107+
108+
@functools.wraps(real_import)
109+
def checking_import(name, globals=None, locals=None, fromlist=(), level=0):
110+
if globals is None:
111+
globals = sys._getframe(1).f_globals
112+
113+
from_name = globals.get("__name__")
114+
package = globals.get("__package__") or from_name
115+
116+
if not from_name or not package:
117+
return real_import(name, globals, locals, fromlist, level)
118+
119+
to_name = resolve_full_name(package, name, level)
120+
try:
121+
return real_import(name, globals, locals, fromlist, level)
122+
finally:
123+
track_import(from_name, to_name, fromlist)
124+
125+
126+
def write_files():
127+
import sentry
128+
129+
base = os.path.abspath(os.path.join(sentry.__file__, "../../.."))
130+
131+
emit_dot(os.path.join(base, "import-graph.dot"))
132+
emit_ascii_tree(os.path.join(base, "import-graph.txt"))
133+
134+
135+
if TRACK_IMPORTS:
136+
builtins.__import__ = checking_import
137+
atexit.register(write_files)

0 commit comments

Comments
 (0)