Tarek Ziade | 1231a4e | 2011-05-19 13:07:25 +0200 | [diff] [blame] | 1 | """Class and functions dealing with dependencies between distributions. |
| 2 | |
| 3 | This module provides a DependencyGraph class to represent the |
| 4 | dependencies between distributions. Auxiliary functions can generate a |
| 5 | graph, find reverse dependencies, and print a graph in DOT format. |
| 6 | """ |
| 7 | |
| 8 | import sys |
| 9 | |
| 10 | from io import StringIO |
| 11 | from packaging.errors import PackagingError |
| 12 | from packaging.version import VersionPredicate, IrrationalVersionError |
| 13 | |
| 14 | __all__ = ['DependencyGraph', 'generate_graph', 'dependent_dists', |
| 15 | 'graph_to_dot'] |
| 16 | |
| 17 | |
| 18 | class DependencyGraph: |
| 19 | """ |
| 20 | Represents a dependency graph between distributions. |
| 21 | |
| 22 | The dependency relationships are stored in an ``adjacency_list`` that maps |
| 23 | distributions to a list of ``(other, label)`` tuples where ``other`` |
| 24 | is a distribution and the edge is labeled with ``label`` (i.e. the version |
| 25 | specifier, if such was provided). Also, for more efficient traversal, for |
| 26 | every distribution ``x``, a list of predecessors is kept in |
| 27 | ``reverse_list[x]``. An edge from distribution ``a`` to |
| 28 | distribution ``b`` means that ``a`` depends on ``b``. If any missing |
| 29 | dependencies are found, they are stored in ``missing``, which is a |
| 30 | dictionary that maps distributions to a list of requirements that were not |
| 31 | provided by any other distributions. |
| 32 | """ |
| 33 | |
| 34 | def __init__(self): |
| 35 | self.adjacency_list = {} |
| 36 | self.reverse_list = {} |
| 37 | self.missing = {} |
| 38 | |
| 39 | def add_distribution(self, distribution): |
| 40 | """Add the *distribution* to the graph. |
| 41 | |
| 42 | :type distribution: :class:`packaging.database.Distribution` or |
| 43 | :class:`packaging.database.EggInfoDistribution` |
| 44 | """ |
| 45 | self.adjacency_list[distribution] = [] |
| 46 | self.reverse_list[distribution] = [] |
| 47 | self.missing[distribution] = [] |
| 48 | |
| 49 | def add_edge(self, x, y, label=None): |
| 50 | """Add an edge from distribution *x* to distribution *y* with the given |
| 51 | *label*. |
| 52 | |
| 53 | :type x: :class:`packaging.database.Distribution` or |
| 54 | :class:`packaging.database.EggInfoDistribution` |
| 55 | :type y: :class:`packaging.database.Distribution` or |
| 56 | :class:`packaging.database.EggInfoDistribution` |
| 57 | :type label: ``str`` or ``None`` |
| 58 | """ |
| 59 | self.adjacency_list[x].append((y, label)) |
| 60 | # multiple edges are allowed, so be careful |
Éric Araujo | df8ef02 | 2011-06-08 04:47:13 +0200 | [diff] [blame] | 61 | if x not in self.reverse_list[y]: |
Tarek Ziade | 1231a4e | 2011-05-19 13:07:25 +0200 | [diff] [blame] | 62 | self.reverse_list[y].append(x) |
| 63 | |
| 64 | def add_missing(self, distribution, requirement): |
| 65 | """ |
| 66 | Add a missing *requirement* for the given *distribution*. |
| 67 | |
| 68 | :type distribution: :class:`packaging.database.Distribution` or |
| 69 | :class:`packaging.database.EggInfoDistribution` |
| 70 | :type requirement: ``str`` |
| 71 | """ |
| 72 | self.missing[distribution].append(requirement) |
| 73 | |
| 74 | def _repr_dist(self, dist): |
Éric Araujo | bab50cb | 2011-07-29 02:37:21 +0200 | [diff] [blame] | 75 | return '%r %s' % (dist.name, dist.version) |
Tarek Ziade | 1231a4e | 2011-05-19 13:07:25 +0200 | [diff] [blame] | 76 | |
| 77 | def repr_node(self, dist, level=1): |
| 78 | """Prints only a subgraph""" |
| 79 | output = [] |
| 80 | output.append(self._repr_dist(dist)) |
| 81 | for other, label in self.adjacency_list[dist]: |
| 82 | dist = self._repr_dist(other) |
| 83 | if label is not None: |
| 84 | dist = '%s [%s]' % (dist, label) |
| 85 | output.append(' ' * level + str(dist)) |
| 86 | suboutput = self.repr_node(other, level + 1) |
| 87 | subs = suboutput.split('\n') |
| 88 | output.extend(subs[1:]) |
| 89 | return '\n'.join(output) |
| 90 | |
| 91 | def __repr__(self): |
| 92 | """Representation of the graph""" |
| 93 | output = [] |
| 94 | for dist, adjs in self.adjacency_list.items(): |
| 95 | output.append(self.repr_node(dist)) |
| 96 | return '\n'.join(output) |
| 97 | |
| 98 | |
| 99 | def graph_to_dot(graph, f, skip_disconnected=True): |
| 100 | """Writes a DOT output for the graph to the provided file *f*. |
| 101 | |
| 102 | If *skip_disconnected* is set to ``True``, then all distributions |
| 103 | that are not dependent on any other distribution are skipped. |
| 104 | |
| 105 | :type f: has to support ``file``-like operations |
| 106 | :type skip_disconnected: ``bool`` |
| 107 | """ |
| 108 | disconnected = [] |
| 109 | |
| 110 | f.write("digraph dependencies {\n") |
| 111 | for dist, adjs in graph.adjacency_list.items(): |
| 112 | if len(adjs) == 0 and not skip_disconnected: |
| 113 | disconnected.append(dist) |
| 114 | for other, label in adjs: |
| 115 | if not label is None: |
| 116 | f.write('"%s" -> "%s" [label="%s"]\n' % |
| 117 | (dist.name, other.name, label)) |
| 118 | else: |
| 119 | f.write('"%s" -> "%s"\n' % (dist.name, other.name)) |
| 120 | if not skip_disconnected and len(disconnected) > 0: |
| 121 | f.write('subgraph disconnected {\n') |
| 122 | f.write('label = "Disconnected"\n') |
| 123 | f.write('bgcolor = red\n') |
| 124 | |
| 125 | for dist in disconnected: |
| 126 | f.write('"%s"' % dist.name) |
| 127 | f.write('\n') |
| 128 | f.write('}\n') |
| 129 | f.write('}\n') |
| 130 | |
| 131 | |
| 132 | def generate_graph(dists): |
| 133 | """Generates a dependency graph from the given distributions. |
| 134 | |
| 135 | :parameter dists: a list of distributions |
| 136 | :type dists: list of :class:`packaging.database.Distribution` and |
| 137 | :class:`packaging.database.EggInfoDistribution` instances |
| 138 | :rtype: a :class:`DependencyGraph` instance |
| 139 | """ |
| 140 | graph = DependencyGraph() |
| 141 | provided = {} # maps names to lists of (version, dist) tuples |
| 142 | |
| 143 | # first, build the graph and find out the provides |
| 144 | for dist in dists: |
| 145 | graph.add_distribution(dist) |
| 146 | provides = (dist.metadata['Provides-Dist'] + |
| 147 | dist.metadata['Provides'] + |
Éric Araujo | bab50cb | 2011-07-29 02:37:21 +0200 | [diff] [blame] | 148 | ['%s (%s)' % (dist.name, dist.version)]) |
Tarek Ziade | 1231a4e | 2011-05-19 13:07:25 +0200 | [diff] [blame] | 149 | |
| 150 | for p in provides: |
| 151 | comps = p.strip().rsplit(" ", 1) |
| 152 | name = comps[0] |
| 153 | version = None |
| 154 | if len(comps) == 2: |
| 155 | version = comps[1] |
| 156 | if len(version) < 3 or version[0] != '(' or version[-1] != ')': |
Éric Araujo | 46bdcf7 | 2011-06-08 04:40:13 +0200 | [diff] [blame] | 157 | raise PackagingError('distribution %r has ill-formed' |
| 158 | 'provides field: %r' % (dist.name, p)) |
Tarek Ziade | 1231a4e | 2011-05-19 13:07:25 +0200 | [diff] [blame] | 159 | version = version[1:-1] # trim off parenthesis |
Éric Araujo | df8ef02 | 2011-06-08 04:47:13 +0200 | [diff] [blame] | 160 | if name not in provided: |
Tarek Ziade | 1231a4e | 2011-05-19 13:07:25 +0200 | [diff] [blame] | 161 | provided[name] = [] |
| 162 | provided[name].append((version, dist)) |
| 163 | |
| 164 | # now make the edges |
| 165 | for dist in dists: |
| 166 | requires = dist.metadata['Requires-Dist'] + dist.metadata['Requires'] |
| 167 | for req in requires: |
| 168 | try: |
| 169 | predicate = VersionPredicate(req) |
| 170 | except IrrationalVersionError: |
| 171 | # XXX compat-mode if cannot read the version |
| 172 | name = req.split()[0] |
| 173 | predicate = VersionPredicate(name) |
| 174 | |
| 175 | name = predicate.name |
| 176 | |
Éric Araujo | df8ef02 | 2011-06-08 04:47:13 +0200 | [diff] [blame] | 177 | if name not in provided: |
Tarek Ziade | 1231a4e | 2011-05-19 13:07:25 +0200 | [diff] [blame] | 178 | graph.add_missing(dist, req) |
| 179 | else: |
| 180 | matched = False |
| 181 | for version, provider in provided[name]: |
| 182 | try: |
| 183 | match = predicate.match(version) |
| 184 | except IrrationalVersionError: |
| 185 | # XXX small compat-mode |
| 186 | if version.split(' ') == 1: |
| 187 | match = True |
| 188 | else: |
| 189 | match = False |
| 190 | |
| 191 | if match: |
| 192 | graph.add_edge(dist, provider, req) |
| 193 | matched = True |
| 194 | break |
| 195 | if not matched: |
| 196 | graph.add_missing(dist, req) |
| 197 | return graph |
| 198 | |
| 199 | |
| 200 | def dependent_dists(dists, dist): |
| 201 | """Recursively generate a list of distributions from *dists* that are |
| 202 | dependent on *dist*. |
| 203 | |
| 204 | :param dists: a list of distributions |
| 205 | :param dist: a distribution, member of *dists* for which we are interested |
| 206 | """ |
Éric Araujo | 46bdcf7 | 2011-06-08 04:40:13 +0200 | [diff] [blame] | 207 | if dist not in dists: |
| 208 | raise ValueError('given distribution %r is not a member of the list' % |
| 209 | dist.name) |
Tarek Ziade | 1231a4e | 2011-05-19 13:07:25 +0200 | [diff] [blame] | 210 | graph = generate_graph(dists) |
| 211 | |
| 212 | dep = [dist] # dependent distributions |
| 213 | fringe = graph.reverse_list[dist] # list of nodes we should inspect |
| 214 | |
| 215 | while not len(fringe) == 0: |
| 216 | node = fringe.pop() |
| 217 | dep.append(node) |
| 218 | for prev in graph.reverse_list[node]: |
Éric Araujo | df8ef02 | 2011-06-08 04:47:13 +0200 | [diff] [blame] | 219 | if prev not in dep: |
Tarek Ziade | 1231a4e | 2011-05-19 13:07:25 +0200 | [diff] [blame] | 220 | fringe.append(prev) |
| 221 | |
| 222 | dep.pop(0) # remove dist from dep, was there to prevent infinite loops |
| 223 | return dep |
| 224 | |
| 225 | |
| 226 | def main(): |
| 227 | from packaging.database import get_distributions |
| 228 | tempout = StringIO() |
| 229 | try: |
| 230 | old = sys.stderr |
| 231 | sys.stderr = tempout |
| 232 | try: |
| 233 | dists = list(get_distributions(use_egg_info=True)) |
| 234 | graph = generate_graph(dists) |
| 235 | finally: |
| 236 | sys.stderr = old |
| 237 | except Exception as e: |
| 238 | tempout.seek(0) |
| 239 | tempout = tempout.read() |
Éric Araujo | 3cab2f1 | 2011-06-08 04:10:57 +0200 | [diff] [blame] | 240 | print('Could not generate the graph') |
| 241 | print(tempout) |
| 242 | print(e) |
Tarek Ziade | 1231a4e | 2011-05-19 13:07:25 +0200 | [diff] [blame] | 243 | sys.exit(1) |
| 244 | |
| 245 | for dist, reqs in graph.missing.items(): |
| 246 | if len(reqs) > 0: |
Éric Araujo | 46bdcf7 | 2011-06-08 04:40:13 +0200 | [diff] [blame] | 247 | print("Warning: Missing dependencies for %r:" % dist.name, |
Tarek Ziade | 1231a4e | 2011-05-19 13:07:25 +0200 | [diff] [blame] | 248 | ", ".join(reqs)) |
| 249 | # XXX replace with argparse |
| 250 | if len(sys.argv) == 1: |
| 251 | print('Dependency graph:') |
Éric Araujo | 3cab2f1 | 2011-06-08 04:10:57 +0200 | [diff] [blame] | 252 | print(' ', repr(graph).replace('\n', '\n ')) |
Tarek Ziade | 1231a4e | 2011-05-19 13:07:25 +0200 | [diff] [blame] | 253 | sys.exit(0) |
| 254 | elif len(sys.argv) > 1 and sys.argv[1] in ('-d', '--dot'): |
| 255 | if len(sys.argv) > 2: |
| 256 | filename = sys.argv[2] |
| 257 | else: |
| 258 | filename = 'depgraph.dot' |
| 259 | |
| 260 | with open(filename, 'w') as f: |
| 261 | graph_to_dot(graph, f, True) |
| 262 | tempout.seek(0) |
| 263 | tempout = tempout.read() |
| 264 | print(tempout) |
Éric Araujo | 46bdcf7 | 2011-06-08 04:40:13 +0200 | [diff] [blame] | 265 | print('Dot file written at %r' % filename) |
Tarek Ziade | 1231a4e | 2011-05-19 13:07:25 +0200 | [diff] [blame] | 266 | sys.exit(0) |
| 267 | else: |
| 268 | print('Supported option: -d [filename]') |
| 269 | sys.exit(1) |
| 270 | |
| 271 | |
| 272 | if __name__ == '__main__': |
| 273 | main() |