1 | #!/usr/bin/env python |
---|
2 | |
---|
3 | # Run this as "./graph-deps.py ." from your source tree, then open out.png . |
---|
4 | # You can also use a PyPI package name, e.g. "./graph-deps.py tahoe-lafs". |
---|
5 | # |
---|
6 | # This builds all necessary wheels for your project (in a tempdir), scans |
---|
7 | # them to learn their inter-dependencies, generates a DOT-format graph |
---|
8 | # specification, then runs the "dot" program (from the "graphviz" package) to |
---|
9 | # turn this into a PNG image. |
---|
10 | |
---|
11 | # To hack on this script (e.g. change the way it generates DOT) without |
---|
12 | # re-building the wheels each time, set --wheeldir= to some not-existent |
---|
13 | # path. It will write the wheels to that directory instead of a tempdir. The |
---|
14 | # next time you run it, if --wheeldir= points to a directory, it will read |
---|
15 | # the wheels from there. |
---|
16 | |
---|
17 | # To hack on the DOT output without re-running this script, add --write-dot, |
---|
18 | # which will cause it to write "out.dot". Edit that file, then run "dot -Tpng |
---|
19 | # out.dot >out.png" to re-render the graph. |
---|
20 | |
---|
21 | # Install 'click' first. I run this with py2, but py3 might work too, if the |
---|
22 | # wheels can be built with py3. |
---|
23 | |
---|
24 | import os, sys, subprocess, json, tempfile, zipfile, re, itertools |
---|
25 | import email.parser |
---|
26 | from pprint import pprint |
---|
27 | from io import StringIO |
---|
28 | import click |
---|
29 | |
---|
30 | all_packages = {} # name -> version |
---|
31 | all_reqs = {} # name -> specs |
---|
32 | all_pure = set() |
---|
33 | |
---|
34 | # 1: build a local directory of wheels for the given target |
---|
35 | # pip wheel --wheel-dir=tempdir sys.argv[1] |
---|
36 | def build_wheels(target, wheeldir): |
---|
37 | print("-- building wheels for '%s' in %s" % (target, wheeldir)) |
---|
38 | pip = subprocess.Popen(["pip", "wheel", "--wheel-dir", wheeldir, target], |
---|
39 | stdout=subprocess.PIPE) |
---|
40 | stdout = pip.communicate()[0] |
---|
41 | if pip.returncode != 0: |
---|
42 | sys.exit(pip.returncode) |
---|
43 | # 'pip wheel .' starts with "Processing /path/to/." but ends with |
---|
44 | # "Successfully built PKGNAME". 'pip wheel PKGNAME' start with |
---|
45 | # "Collecting PKGNAME" but ends with e.g. "Skipping foo, due to already |
---|
46 | # being wheel." |
---|
47 | lines = stdout.decode("utf-8").splitlines() |
---|
48 | if lines[0].startswith("Collecting "): |
---|
49 | root_pkgname = lines[0].split()[-1] |
---|
50 | elif lines[-1].startswith("Successfully built "): |
---|
51 | root_pkgname = lines[-1].split()[-1] |
---|
52 | else: |
---|
53 | print("Unable to figure out root package name") |
---|
54 | print("'pip wheel %s' output is:" % target) |
---|
55 | print(stdout) |
---|
56 | sys.exit(1) |
---|
57 | with open(os.path.join(wheeldir, "root_pkgname"), "w") as f: |
---|
58 | f.write(root_pkgname+"\n") |
---|
59 | |
---|
60 | def get_root_pkgname(wheeldir): |
---|
61 | with open(os.path.join(wheeldir, "root_pkgname"), "r") as f: |
---|
62 | return f.read().strip() |
---|
63 | |
---|
64 | # 2: for each wheel, find the *.dist-info file, find metadata.json inside |
---|
65 | # that, extract metadata.run_requires[0].requires |
---|
66 | |
---|
67 | def add(name, version, extras, reqs, raw): |
---|
68 | if set(reqs) - set([None]) - set(extras): |
---|
69 | print("um, %s metadata has mismatching extras/reqs" % name) |
---|
70 | pprint(extras) |
---|
71 | pprint(reqs) |
---|
72 | print("raw data:") |
---|
73 | pprint(raw) |
---|
74 | raise ValueError |
---|
75 | if None not in reqs: |
---|
76 | print("um, %s has no reqs" % name) |
---|
77 | print("raw data:") |
---|
78 | pprint(raw) |
---|
79 | raise ValueError |
---|
80 | all_packages[name] = version |
---|
81 | all_reqs[name] = reqs |
---|
82 | |
---|
83 | def parse_metadata_json(f): |
---|
84 | md = json.loads(f.read().decode("utf-8")) |
---|
85 | name = md["name"].lower() |
---|
86 | version = md["version"] |
---|
87 | try: |
---|
88 | reqs = {None: []} # extra_name/None -> [specs] |
---|
89 | if "run_requires" in md: |
---|
90 | for r in md["run_requires"]: |
---|
91 | reqs[r.get("extra", None)] = r["requires"] |
---|
92 | # this package provides the following extras |
---|
93 | extras = md.get("extras", []) |
---|
94 | #for e in extras: |
---|
95 | # if e not in reqs: |
---|
96 | # reqs[e] = [] |
---|
97 | except KeyError: |
---|
98 | print("error in '%s'" % name) |
---|
99 | pprint(md) |
---|
100 | raise |
---|
101 | add(name, version, extras, reqs, md) |
---|
102 | return name |
---|
103 | |
---|
104 | def parse_METADATA(f): |
---|
105 | data = f.read().decode("utf-8") |
---|
106 | md = email.parser.Parser().parsestr(data) |
---|
107 | |
---|
108 | name = md.get_all("Name")[0].lower() |
---|
109 | version = md.get_all("Version")[0] |
---|
110 | reqs = {None: []} |
---|
111 | for req in md.get_all("Requires-Dist") or []: # untested |
---|
112 | pieces = [p.strip() for p in req.split(";")] |
---|
113 | spec = pieces[0] |
---|
114 | extra = None |
---|
115 | if len(pieces) > 1: |
---|
116 | mo = re.search(r"extra == '(\w+)'", pieces[1]) |
---|
117 | if mo: |
---|
118 | extra = mo.group(1) |
---|
119 | if extra not in reqs: |
---|
120 | reqs[extra] = [] |
---|
121 | reqs[extra].append(spec) |
---|
122 | extras = md.get_all("Provides-Extra") or [] # untested |
---|
123 | add(name, version, extras, reqs, data) |
---|
124 | return name |
---|
125 | |
---|
126 | def parse_wheels(wheeldir): |
---|
127 | for fn in os.listdir(wheeldir): |
---|
128 | if not fn.endswith(".whl"): |
---|
129 | continue |
---|
130 | zf = zipfile.ZipFile(os.path.join(wheeldir, fn)) |
---|
131 | zfnames = zf.namelist() |
---|
132 | mdfns = [n for n in zfnames if n.endswith(".dist-info/metadata.json")] |
---|
133 | if mdfns: |
---|
134 | name = parse_metadata_json(zf.open(mdfns[0])) |
---|
135 | else: |
---|
136 | mdfns = [n for n in zfnames if n.endswith(".dist-info/METADATA")] |
---|
137 | if mdfns: |
---|
138 | name = parse_METADATA(zf.open(mdfns[0])) |
---|
139 | else: |
---|
140 | print("no metadata for", fn) |
---|
141 | continue |
---|
142 | is_pure = False |
---|
143 | wheel_fns = [n for n in zfnames if n.endswith(".dist-info/WHEEL")] |
---|
144 | if wheel_fns: |
---|
145 | with zf.open(wheel_fns[0]) as wheel: |
---|
146 | for line in wheel: |
---|
147 | if line.lower().rstrip() == b"root-is-purelib: true": |
---|
148 | is_pure = True |
---|
149 | if is_pure: |
---|
150 | all_pure.add(name) |
---|
151 | return get_root_pkgname(wheeldir) |
---|
152 | |
---|
153 | # 3: emit a .dot file with a graph of all the dependencies |
---|
154 | |
---|
155 | def dot_name(name, extra): |
---|
156 | # the 'dot' format enforces C identifier syntax on node names |
---|
157 | assert name.lower() == name, name |
---|
158 | name = "%s__%s" % (name, extra) |
---|
159 | return name.replace("-", "_").replace(".", "_") |
---|
160 | |
---|
161 | def parse_spec(spec): |
---|
162 | # turn "twisted[tls] (>=16.0.0)" into "twisted" |
---|
163 | pieces = spec.split() |
---|
164 | name_and_extras = pieces[0] |
---|
165 | paren_constraint = pieces[1] if len(pieces) > 1 else "" |
---|
166 | if "[" in name_and_extras: |
---|
167 | name = name_and_extras[:name_and_extras.find("[")] |
---|
168 | extras_bracketed = name_and_extras[name_and_extras.find("["):] |
---|
169 | extras = extras_bracketed.strip("[]").split(",") |
---|
170 | else: |
---|
171 | name = name_and_extras |
---|
172 | extras = [] |
---|
173 | return name.lower(), extras, paren_constraint |
---|
174 | |
---|
175 | def format_attrs(**kwargs): |
---|
176 | # return "", or "[attr=value attr=value]" |
---|
177 | if not kwargs or all([not(v) for v in kwargs.values()]): |
---|
178 | return "" |
---|
179 | def escape(s): |
---|
180 | return s.replace('\n', r'\n').replace('"', r'\"') |
---|
181 | pieces = ['%s="%s"' % (k, escape(kwargs[k])) |
---|
182 | for k in sorted(kwargs) |
---|
183 | if kwargs[k]] |
---|
184 | body = " ".join(pieces) |
---|
185 | return "[%s]" % body |
---|
186 | |
---|
187 | # We draw a node for each wheel. When one of the inbound dependencies asks |
---|
188 | # for an extra, we assign that (target, extra) pair a color. We draw outbound |
---|
189 | # links for all non-extra dependencies in black. If something asked the |
---|
190 | # target for an extra, we also draw links for the extra deps using the |
---|
191 | # assigned color. |
---|
192 | |
---|
193 | COLORS = itertools.cycle(["green", "blue", "red", "purple"]) |
---|
194 | extras_to_show = {} # maps (target, extraname) -> colorname |
---|
195 | |
---|
196 | def add_extra_to_show(targetname, extraname): |
---|
197 | key = (targetname, extraname) |
---|
198 | if key not in extras_to_show: |
---|
199 | extras_to_show[key] = next(COLORS) |
---|
200 | |
---|
201 | _scanned = set() |
---|
202 | def scan(name, extra=None, path=""): |
---|
203 | dupkey = (name, extra) |
---|
204 | if dupkey in _scanned: |
---|
205 | #print("SCAN-SKIP %s %s[%s]" % (path, name, extra)) |
---|
206 | return |
---|
207 | _scanned.add(dupkey) |
---|
208 | #print("SCAN %s %s[%s]" % (path, name, extra)) |
---|
209 | add_extra_to_show(name, extra) |
---|
210 | for spec in all_reqs[name][extra]: |
---|
211 | #print("-", spec) |
---|
212 | dep_name, dep_extras, dep_constraint = parse_spec(spec) |
---|
213 | #print("--", dep_name, dep_extras) |
---|
214 | children = set(dep_extras) |
---|
215 | children.add(None) |
---|
216 | for dep_extra in children: |
---|
217 | scan(dep_name, dep_extra, |
---|
218 | path=path+"->%s[%s]" % (dep_name, dep_extra)) |
---|
219 | |
---|
220 | def generate_dot(): |
---|
221 | f = StringIO() |
---|
222 | f.write("digraph {\n") |
---|
223 | for name, extra in extras_to_show.keys(): |
---|
224 | version = all_packages[name] |
---|
225 | if extra: |
---|
226 | label = "%s[%s]\n%s" % (name, extra, version) |
---|
227 | else: |
---|
228 | label = "%s\n%s" % (name, version) |
---|
229 | color = None |
---|
230 | if name not in all_pure: |
---|
231 | color = "red" |
---|
232 | f.write('%s %s\n' % (dot_name(name, extra), |
---|
233 | format_attrs(label=label, color=color))) |
---|
234 | |
---|
235 | for (source, extra), color in extras_to_show.items(): |
---|
236 | if extra: |
---|
237 | f.write('%s -> %s [weight="50" style="dashed"]\n' % |
---|
238 | (dot_name(source, extra), |
---|
239 | dot_name(source, None))) |
---|
240 | specs = all_reqs[source][extra] |
---|
241 | for spec in specs: |
---|
242 | reqname, reqextras, paren_constraint = parse_spec(spec) |
---|
243 | #extras_bracketed = "[%s]" % ",".join(extras) if extras else "" |
---|
244 | #edge_label = " ".join([p for p in [extras_bracketed, |
---|
245 | # paren_constraint] if p]) |
---|
246 | assert None not in reqextras |
---|
247 | if not reqextras: |
---|
248 | reqextras = [None] |
---|
249 | for reqextra in reqextras: |
---|
250 | edge_label = "" |
---|
251 | if extra: |
---|
252 | edge_label += "(%s[%s] wants)\n" % (source, extra) |
---|
253 | edge_label += spec |
---|
254 | style = "bold" if reqextra else "solid" |
---|
255 | f.write('%s -> %s %s\n' % (dot_name(source, extra), |
---|
256 | dot_name(reqname, reqextra), |
---|
257 | format_attrs(label=edge_label, |
---|
258 | fontcolor=color, |
---|
259 | style=style, |
---|
260 | color=color))) |
---|
261 | f.write("}\n") |
---|
262 | return f |
---|
263 | |
---|
264 | # 4: convert to .png |
---|
265 | def dot_to_png(f, png_fn): |
---|
266 | png = open(png_fn, "wb") |
---|
267 | dot = subprocess.Popen(["dot", "-Tpng"], stdin=subprocess.PIPE, stdout=png) |
---|
268 | dot.communicate(f.getvalue().encode("utf-8")) |
---|
269 | if dot.returncode != 0: |
---|
270 | sys.exit(dot.returncode) |
---|
271 | png.close() |
---|
272 | print("wrote graph to %s" % png_fn) |
---|
273 | |
---|
274 | @click.command() |
---|
275 | @click.argument("target") |
---|
276 | @click.option("--wheeldir", default=None, type=str) |
---|
277 | @click.option("--write-dot/--no-write-dot", default=False) |
---|
278 | def go(target, wheeldir, write_dot): |
---|
279 | if wheeldir: |
---|
280 | if os.path.isdir(wheeldir): |
---|
281 | print("loading wheels from", wheeldir) |
---|
282 | root_pkgname = parse_wheels(wheeldir) |
---|
283 | else: |
---|
284 | assert not os.path.exists(wheeldir) |
---|
285 | print("loading wheels from", wheeldir) |
---|
286 | build_wheels(target, wheeldir) |
---|
287 | root_pkgname = parse_wheels(wheeldir) |
---|
288 | else: |
---|
289 | wheeldir = tempfile.mkdtemp() |
---|
290 | build_wheels(target, wheeldir) |
---|
291 | root_pkgname = parse_wheels(wheeldir) |
---|
292 | print("root package:", root_pkgname) |
---|
293 | |
---|
294 | # parse the requirement specs (which look like "Twisted[tls] (>=13.0.0)") |
---|
295 | # enough to identify the package name |
---|
296 | pprint(all_packages) |
---|
297 | pprint(all_reqs) |
---|
298 | print("pure:", " ".join(sorted(all_pure))) |
---|
299 | |
---|
300 | for name in all_packages.keys(): |
---|
301 | extras_to_show[(name, None)] = "black" |
---|
302 | |
---|
303 | scan(root_pkgname) |
---|
304 | f = generate_dot() |
---|
305 | |
---|
306 | if write_dot: |
---|
307 | with open("out.dot", "w") as dotf: |
---|
308 | dotf.write(f.getvalue()) |
---|
309 | print("wrote DOT to out.dot") |
---|
310 | dot_to_png(f, "out.png") |
---|
311 | |
---|
312 | return 0 |
---|
313 | |
---|
314 | if __name__ == "__main__": |
---|
315 | go() |
---|