source: trunk/misc/coding_tools/graph-deps.py

Last change on this file was 53084f7, checked in by Alexandre Detiste <alexandre.detiste@…>, at 2024-02-27T23:49:07Z

remove more Python2 compatibility

  • Property mode set to 100755
File size: 11.3 KB
Line 
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
24import os, sys, subprocess, json, tempfile, zipfile, re, itertools
25import email.parser
26from pprint import pprint
27from io import StringIO
28import click
29
30all_packages = {} # name -> version
31all_reqs = {} # name -> specs
32all_pure = set()
33
34# 1: build a local directory of wheels for the given target
35# pip wheel --wheel-dir=tempdir sys.argv[1]
36def 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
60def 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
67def 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
83def 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
104def 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
126def 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
155def 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
161def 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
175def 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
193COLORS = itertools.cycle(["green", "blue", "red", "purple"])
194extras_to_show = {} # maps (target, extraname) -> colorname
195
196def 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()
202def 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
220def 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
265def 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)
278def 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
314if __name__ == "__main__":
315    go()
Note: See TracBrowser for help on using the repository browser.