|
1 #!/usr/bin/env python |
|
2 # -*- coding: utf-8 -*- |
|
3 |
|
4 """ |
|
5 Copyright (c) 2015 Vineet Naik (naikvin@gmail.com) |
|
6 |
|
7 Permission is hereby granted, free of charge, to any person obtaining |
|
8 a copy of this software and associated documentation files (the |
|
9 "Software"), to deal in the Software without restriction, including |
|
10 without limitation the rights to use, copy, modify, merge, publish, |
|
11 distribute, sublicense, and/or sell copies of the Software, and to |
|
12 permit persons to whom the Software is furnished to do so, subject to |
|
13 the following conditions: |
|
14 |
|
15 The above copyright notice and this permission notice shall be |
|
16 included in all copies or substantial portions of the Software. |
|
17 |
|
18 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
|
19 EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
|
20 MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
|
21 NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE |
|
22 LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION |
|
23 OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION |
|
24 WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
|
25 """ |
|
26 |
|
27 # |
|
28 # Modified to be used within the eric-ide project. |
|
29 # |
|
30 # Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de> |
|
31 # |
|
32 |
|
33 from __future__ import print_function |
|
34 import os |
|
35 import inspect |
|
36 import sys |
|
37 import subprocess |
|
38 from itertools import chain |
|
39 from collections import defaultdict, deque |
|
40 import argparse |
|
41 import json |
|
42 from importlib import import_module |
|
43 import tempfile |
|
44 |
|
45 try: |
|
46 from collections import OrderedDict |
|
47 except ImportError: |
|
48 from ordereddict import OrderedDict |
|
49 |
|
50 try: |
|
51 from collections.abc import Mapping |
|
52 except ImportError: |
|
53 from collections import Mapping |
|
54 |
|
55 from pip._vendor import pkg_resources |
|
56 try: |
|
57 from pip._internal.operations.freeze import FrozenRequirement |
|
58 except ImportError: |
|
59 from pip import FrozenRequirement |
|
60 |
|
61 |
|
62 __version__ = '2.2.1' |
|
63 |
|
64 |
|
65 flatten = chain.from_iterable |
|
66 |
|
67 |
|
68 def sorted_tree(tree): |
|
69 """Sorts the dict representation of the tree |
|
70 |
|
71 The root packages as well as the intermediate packages are sorted |
|
72 in the alphabetical order of the package names. |
|
73 |
|
74 :param dict tree: the pkg dependency tree obtained by calling |
|
75 `construct_tree` function |
|
76 :returns: sorted tree |
|
77 :rtype: collections.OrderedDict |
|
78 |
|
79 """ |
|
80 return OrderedDict([(k, sorted(v)) for k, v in sorted(tree.items())]) |
|
81 |
|
82 |
|
83 def guess_version(pkg_key, default='?'): |
|
84 """Guess the version of a pkg when pip doesn't provide it |
|
85 |
|
86 :param str pkg_key: key of the package |
|
87 :param str default: default version to return if unable to find |
|
88 :returns: version |
|
89 :rtype: string |
|
90 |
|
91 """ |
|
92 try: |
|
93 m = import_module(pkg_key) |
|
94 except ImportError: |
|
95 return default |
|
96 else: |
|
97 v = getattr(m, '__version__', default) |
|
98 if inspect.ismodule(v): |
|
99 return getattr(v, '__version__', default) |
|
100 else: |
|
101 return v |
|
102 |
|
103 |
|
104 def frozen_req_from_dist(dist): |
|
105 # The `pip._internal.metadata` modules were introduced in 21.1.1 |
|
106 # and the `pip._internal.operations.freeze.FrozenRequirement` |
|
107 # class now expects dist to be a subclass of |
|
108 # `pip._internal.metadata.BaseDistribution`, however the |
|
109 # `pip._internal.utils.misc.get_installed_distributions` continues |
|
110 # to return objects of type |
|
111 # pip._vendor.pkg_resources.DistInfoDistribution. |
|
112 # |
|
113 # This is a hacky backward compatible (with older versions of pip) |
|
114 # fix. |
|
115 try: |
|
116 from pip._internal import metadata |
|
117 except ImportError: |
|
118 pass |
|
119 else: |
|
120 dist = metadata.pkg_resources.Distribution(dist) |
|
121 |
|
122 try: |
|
123 return FrozenRequirement.from_dist(dist) |
|
124 except TypeError: |
|
125 return FrozenRequirement.from_dist(dist, []) |
|
126 |
|
127 |
|
128 class Package(object): |
|
129 """Abstract class for wrappers around objects that pip returns. |
|
130 |
|
131 This class needs to be subclassed with implementations for |
|
132 `render_as_root` and `render_as_branch` methods. |
|
133 |
|
134 """ |
|
135 |
|
136 def __init__(self, obj): |
|
137 self._obj = obj |
|
138 self.project_name = obj.project_name |
|
139 self.key = obj.key |
|
140 |
|
141 def render_as_root(self, frozen): |
|
142 return NotImplementedError |
|
143 |
|
144 def render_as_branch(self, frozen): |
|
145 return NotImplementedError |
|
146 |
|
147 def render(self, parent=None, frozen=False): |
|
148 if not parent: |
|
149 return self.render_as_root(frozen) |
|
150 else: |
|
151 return self.render_as_branch(frozen) |
|
152 |
|
153 @staticmethod |
|
154 def frozen_repr(obj): |
|
155 fr = frozen_req_from_dist(obj) |
|
156 return str(fr).strip() |
|
157 |
|
158 def __getattr__(self, key): |
|
159 return getattr(self._obj, key) |
|
160 |
|
161 def __repr__(self): |
|
162 return '<{0}("{1}")>'.format(self.__class__.__name__, self.key) |
|
163 |
|
164 def __lt__(self, rhs): |
|
165 return self.key < rhs.key |
|
166 |
|
167 |
|
168 class DistPackage(Package): |
|
169 """Wrapper class for pkg_resources.Distribution instances |
|
170 |
|
171 :param obj: pkg_resources.Distribution to wrap over |
|
172 :param req: optional ReqPackage object to associate this |
|
173 DistPackage with. This is useful for displaying the |
|
174 tree in reverse |
|
175 """ |
|
176 |
|
177 def __init__(self, obj, req=None): |
|
178 super(DistPackage, self).__init__(obj) |
|
179 self.version_spec = None |
|
180 self.req = req |
|
181 |
|
182 def render_as_root(self, frozen): |
|
183 if not frozen: |
|
184 return '{0}=={1}'.format(self.project_name, self.version) |
|
185 else: |
|
186 return self.__class__.frozen_repr(self._obj) |
|
187 |
|
188 def render_as_branch(self, frozen): |
|
189 assert self.req is not None |
|
190 if not frozen: |
|
191 parent_ver_spec = self.req.version_spec |
|
192 parent_str = self.req.project_name |
|
193 if parent_ver_spec: |
|
194 parent_str += parent_ver_spec |
|
195 return ( |
|
196 '{0}=={1} [requires: {2}]' |
|
197 ).format(self.project_name, self.version, parent_str) |
|
198 else: |
|
199 return self.render_as_root(frozen) |
|
200 |
|
201 def as_requirement(self): |
|
202 """Return a ReqPackage representation of this DistPackage""" |
|
203 return ReqPackage(self._obj.as_requirement(), dist=self) |
|
204 |
|
205 def as_parent_of(self, req): |
|
206 """Return a DistPackage instance associated to a requirement |
|
207 |
|
208 This association is necessary for reversing the PackageDAG. |
|
209 |
|
210 If `req` is None, and the `req` attribute of the current |
|
211 instance is also None, then the same instance will be |
|
212 returned. |
|
213 |
|
214 :param ReqPackage req: the requirement to associate with |
|
215 :returns: DistPackage instance |
|
216 |
|
217 """ |
|
218 if req is None and self.req is None: |
|
219 return self |
|
220 return self.__class__(self._obj, req) |
|
221 |
|
222 def as_dict(self): |
|
223 return {'key': self.key, |
|
224 'package_name': self.project_name, |
|
225 'installed_version': self.version} |
|
226 |
|
227 |
|
228 class ReqPackage(Package): |
|
229 """Wrapper class for Requirements instance |
|
230 |
|
231 :param obj: The `Requirements` instance to wrap over |
|
232 :param dist: optional `pkg_resources.Distribution` instance for |
|
233 this requirement |
|
234 """ |
|
235 |
|
236 UNKNOWN_VERSION = '?' |
|
237 |
|
238 def __init__(self, obj, dist=None): |
|
239 super(ReqPackage, self).__init__(obj) |
|
240 self.dist = dist |
|
241 |
|
242 @property |
|
243 def version_spec(self): |
|
244 specs = sorted(self._obj.specs, reverse=True) # `reverse` makes '>' prior to '<' |
|
245 return ','.join([''.join(sp) for sp in specs]) if specs else None |
|
246 |
|
247 @property |
|
248 def installed_version(self): |
|
249 if not self.dist: |
|
250 return guess_version(self.key, self.UNKNOWN_VERSION) |
|
251 return self.dist.version |
|
252 |
|
253 @property |
|
254 def is_missing(self): |
|
255 return self.installed_version == self.UNKNOWN_VERSION |
|
256 |
|
257 def is_conflicting(self): |
|
258 """If installed version conflicts with required version""" |
|
259 # unknown installed version is also considered conflicting |
|
260 if self.installed_version == self.UNKNOWN_VERSION: |
|
261 return True |
|
262 ver_spec = (self.version_spec if self.version_spec else '') |
|
263 req_version_str = '{0}{1}'.format(self.project_name, ver_spec) |
|
264 req_obj = pkg_resources.Requirement.parse(req_version_str) |
|
265 return self.installed_version not in req_obj |
|
266 |
|
267 def render_as_root(self, frozen): |
|
268 if not frozen: |
|
269 return '{0}=={1}'.format(self.project_name, self.installed_version) |
|
270 elif self.dist: |
|
271 return self.__class__.frozen_repr(self.dist._obj) |
|
272 else: |
|
273 return self.project_name |
|
274 |
|
275 def render_as_branch(self, frozen): |
|
276 if not frozen: |
|
277 req_ver = self.version_spec if self.version_spec else 'Any' |
|
278 return ( |
|
279 '{0} [required: {1}, installed: {2}]' |
|
280 ).format(self.project_name, req_ver, self.installed_version) |
|
281 else: |
|
282 return self.render_as_root(frozen) |
|
283 |
|
284 def as_dict(self): |
|
285 return {'key': self.key, |
|
286 'package_name': self.project_name, |
|
287 'installed_version': self.installed_version, |
|
288 'required_version': self.version_spec} |
|
289 |
|
290 |
|
291 class PackageDAG(Mapping): |
|
292 """Representation of Package dependencies as directed acyclic graph |
|
293 using a dict (Mapping) as the underlying datastructure. |
|
294 |
|
295 The nodes and their relationships (edges) are internally |
|
296 stored using a map as follows, |
|
297 |
|
298 {a: [b, c], |
|
299 b: [d], |
|
300 c: [d, e], |
|
301 d: [e], |
|
302 e: [], |
|
303 f: [b], |
|
304 g: [e, f]} |
|
305 |
|
306 Here, node `a` has 2 children nodes `b` and `c`. Consider edge |
|
307 direction from `a` -> `b` and `a` -> `c` respectively. |
|
308 |
|
309 A node is expected to be an instance of a subclass of |
|
310 `Package`. The keys are must be of class `DistPackage` and each |
|
311 item in values must be of class `ReqPackage`. (See also |
|
312 ReversedPackageDAG where the key and value types are |
|
313 interchanged). |
|
314 |
|
315 """ |
|
316 |
|
317 @classmethod |
|
318 def from_pkgs(cls, pkgs): |
|
319 pkgs = [DistPackage(p) for p in pkgs] |
|
320 idx = {p.key: p for p in pkgs} |
|
321 m = {p: [ReqPackage(r, idx.get(r.key)) |
|
322 for r in p.requires()] |
|
323 for p in pkgs} |
|
324 return cls(m) |
|
325 |
|
326 def __init__(self, m): |
|
327 """Initialize the PackageDAG object |
|
328 |
|
329 :param dict m: dict of node objects (refer class docstring) |
|
330 :returns: None |
|
331 :rtype: NoneType |
|
332 |
|
333 """ |
|
334 self._obj = m |
|
335 self._index = {p.key: p for p in list(self._obj)} |
|
336 |
|
337 def get_node_as_parent(self, node_key): |
|
338 """Get the node from the keys of the dict representing the DAG. |
|
339 |
|
340 This method is useful if the dict representing the DAG |
|
341 contains different kind of objects in keys and values. Use |
|
342 this method to lookup a node obj as a parent (from the keys of |
|
343 the dict) given a node key. |
|
344 |
|
345 :param node_key: identifier corresponding to key attr of node obj |
|
346 :returns: node obj (as present in the keys of the dict) |
|
347 :rtype: Object |
|
348 |
|
349 """ |
|
350 try: |
|
351 return self._index[node_key] |
|
352 except KeyError: |
|
353 return None |
|
354 |
|
355 def get_children(self, node_key): |
|
356 """Get child nodes for a node by it's key |
|
357 |
|
358 :param str node_key: key of the node to get children of |
|
359 :returns: list of child nodes |
|
360 :rtype: ReqPackage[] |
|
361 |
|
362 """ |
|
363 node = self.get_node_as_parent(node_key) |
|
364 return self._obj[node] if node else [] |
|
365 |
|
366 def filter(self, include, exclude): |
|
367 """Filters nodes in a graph by given parameters |
|
368 |
|
369 If a node is included, then all it's children are also |
|
370 included. |
|
371 |
|
372 :param set include: set of node keys to include (or None) |
|
373 :param set exclude: set of node keys to exclude (or None) |
|
374 :returns: filtered version of the graph |
|
375 :rtype: PackageDAG |
|
376 |
|
377 """ |
|
378 # If neither of the filters are specified, short circuit |
|
379 if include is None and exclude is None: |
|
380 return self |
|
381 |
|
382 # Note: In following comparisons, we use lower cased values so |
|
383 # that user may specify `key` or `project_name`. As per the |
|
384 # documentation, `key` is simply |
|
385 # `project_name.lower()`. Refer: |
|
386 # https://setuptools.readthedocs.io/en/latest/pkg_resources.html#distribution-objects |
|
387 if include: |
|
388 include = set([s.lower() for s in include]) |
|
389 if exclude: |
|
390 exclude = set([s.lower() for s in exclude]) |
|
391 else: |
|
392 exclude = set([]) |
|
393 |
|
394 # Check for mutual exclusion of show_only and exclude sets |
|
395 # after normalizing the values to lowercase |
|
396 if include and exclude: |
|
397 assert not (include & exclude) |
|
398 |
|
399 # Traverse the graph in a depth first manner and filter the |
|
400 # nodes according to `show_only` and `exclude` sets |
|
401 stack = deque() |
|
402 m = {} |
|
403 seen = set([]) |
|
404 for node in self._obj.keys(): |
|
405 if node.key in exclude: |
|
406 continue |
|
407 if include is None or node.key in include: |
|
408 stack.append(node) |
|
409 while True: |
|
410 if len(stack) > 0: |
|
411 n = stack.pop() |
|
412 cldn = [c for c in self._obj[n] |
|
413 if c.key not in exclude] |
|
414 m[n] = cldn |
|
415 seen.add(n.key) |
|
416 for c in cldn: |
|
417 if c.key not in seen: |
|
418 cld_node = self.get_node_as_parent(c.key) |
|
419 if cld_node: |
|
420 stack.append(cld_node) |
|
421 else: |
|
422 # It means there's no root node |
|
423 # corresponding to the child node |
|
424 # ie. a dependency is missing |
|
425 continue |
|
426 else: |
|
427 break |
|
428 |
|
429 return self.__class__(m) |
|
430 |
|
431 def reverse(self): |
|
432 """Reverse the DAG, or turn it upside-down |
|
433 |
|
434 In other words, the directions of edges of the nodes in the |
|
435 DAG will be reversed. |
|
436 |
|
437 Note that this function purely works on the nodes in the |
|
438 graph. This implies that to perform a combination of filtering |
|
439 and reversing, the order in which `filter` and `reverse` |
|
440 methods should be applied is important. For eg. if reverse is |
|
441 called on a filtered graph, then only the filtered nodes and |
|
442 it's children will be considered when reversing. On the other |
|
443 hand, if filter is called on reversed DAG, then the definition |
|
444 of "child" nodes is as per the reversed DAG. |
|
445 |
|
446 :returns: DAG in the reversed form |
|
447 :rtype: ReversedPackageDAG |
|
448 |
|
449 """ |
|
450 m = defaultdict(list) |
|
451 child_keys = set(r.key for r in flatten(self._obj.values())) |
|
452 for k, vs in self._obj.items(): |
|
453 for v in vs: |
|
454 # if v is already added to the dict, then ensure that |
|
455 # we are using the same object. This check is required |
|
456 # as we're using array mutation |
|
457 try: |
|
458 node = [p for p in m.keys() if p.key == v.key][0] |
|
459 except IndexError: |
|
460 node = v |
|
461 m[node].append(k.as_parent_of(v)) |
|
462 if k.key not in child_keys: |
|
463 m[k.as_requirement()] = [] |
|
464 return ReversedPackageDAG(dict(m)) |
|
465 |
|
466 def sort(self): |
|
467 """Return sorted tree in which the underlying _obj dict is an |
|
468 OrderedDict, sorted alphabetically by the keys |
|
469 |
|
470 :returns: Instance of same class with OrderedDict |
|
471 |
|
472 """ |
|
473 return self.__class__(sorted_tree(self._obj)) |
|
474 |
|
475 # Methods required by the abstract base class Mapping |
|
476 def __getitem__(self, *args): |
|
477 return self._obj.get(*args) |
|
478 |
|
479 def __iter__(self): |
|
480 return self._obj.__iter__() |
|
481 |
|
482 def __len__(self): |
|
483 return len(self._obj) |
|
484 |
|
485 |
|
486 class ReversedPackageDAG(PackageDAG): |
|
487 """Representation of Package dependencies in the reverse |
|
488 order. |
|
489 |
|
490 Similar to it's super class `PackageDAG`, the underlying |
|
491 datastructure is a dict, but here the keys are expected to be of |
|
492 type `ReqPackage` and each item in the values of type |
|
493 `DistPackage`. |
|
494 |
|
495 Typically, this object will be obtained by calling |
|
496 `PackageDAG.reverse`. |
|
497 |
|
498 """ |
|
499 |
|
500 def reverse(self): |
|
501 """Reverse the already reversed DAG to get the PackageDAG again |
|
502 |
|
503 :returns: reverse of the reversed DAG |
|
504 :rtype: PackageDAG |
|
505 |
|
506 """ |
|
507 m = defaultdict(list) |
|
508 child_keys = set(r.key for r in flatten(self._obj.values())) |
|
509 for k, vs in self._obj.items(): |
|
510 for v in vs: |
|
511 try: |
|
512 node = [p for p in m.keys() if p.key == v.key][0] |
|
513 except IndexError: |
|
514 node = v.as_parent_of(None) |
|
515 m[node].append(k) |
|
516 if k.key not in child_keys: |
|
517 m[k.dist] = [] |
|
518 return PackageDAG(dict(m)) |
|
519 |
|
520 |
|
521 def render_text(tree, list_all=True, frozen=False): |
|
522 """Print tree as text on console |
|
523 |
|
524 :param dict tree: the package tree |
|
525 :param bool list_all: whether to list all the pgks at the root |
|
526 level or only those that are the |
|
527 sub-dependencies |
|
528 :param bool frozen: whether or not show the names of the pkgs in |
|
529 the output that's favourable to pip --freeze |
|
530 :returns: None |
|
531 |
|
532 """ |
|
533 tree = tree.sort() |
|
534 nodes = tree.keys() |
|
535 branch_keys = set(r.key for r in flatten(tree.values())) |
|
536 use_bullets = not frozen |
|
537 |
|
538 if not list_all: |
|
539 nodes = [p for p in nodes if p.key not in branch_keys] |
|
540 |
|
541 def aux(node, parent=None, indent=0, chain=None): |
|
542 chain = chain or [] |
|
543 node_str = node.render(parent, frozen) |
|
544 if parent: |
|
545 prefix = ' '*indent + ('- ' if use_bullets else '') |
|
546 node_str = prefix + node_str |
|
547 result = [node_str] |
|
548 children = [aux(c, node, indent=indent+2, |
|
549 chain=chain+[c.project_name]) |
|
550 for c in tree.get_children(node.key) |
|
551 if c.project_name not in chain] |
|
552 result += list(flatten(children)) |
|
553 return result |
|
554 |
|
555 lines = flatten([aux(p) for p in nodes]) |
|
556 print('\n'.join(lines)) |
|
557 |
|
558 |
|
559 def render_json(tree, indent): |
|
560 """Converts the tree into a flat json representation. |
|
561 |
|
562 The json repr will be a list of hashes, each hash having 2 fields: |
|
563 - package |
|
564 - dependencies: list of dependencies |
|
565 |
|
566 :param dict tree: dependency tree |
|
567 :param int indent: no. of spaces to indent json |
|
568 :returns: json representation of the tree |
|
569 :rtype: str |
|
570 |
|
571 """ |
|
572 tree = tree.sort() |
|
573 return json.dumps([{'package': k.as_dict(), |
|
574 'dependencies': [v.as_dict() for v in vs]} |
|
575 for k, vs in tree.items()], |
|
576 indent=indent) |
|
577 |
|
578 |
|
579 def render_json_tree(tree, indent): |
|
580 """Converts the tree into a nested json representation. |
|
581 |
|
582 The json repr will be a list of hashes, each hash having the following fields: |
|
583 - package_name |
|
584 - key |
|
585 - required_version |
|
586 - installed_version |
|
587 - dependencies: list of dependencies |
|
588 |
|
589 :param dict tree: dependency tree |
|
590 :param int indent: no. of spaces to indent json |
|
591 :returns: json representation of the tree |
|
592 :rtype: str |
|
593 |
|
594 """ |
|
595 tree = tree.sort() |
|
596 branch_keys = set(r.key for r in flatten(tree.values())) |
|
597 nodes = [p for p in tree.keys() if p.key not in branch_keys] |
|
598 |
|
599 def aux(node, parent=None, chain=None): |
|
600 if chain is None: |
|
601 chain = [node.project_name] |
|
602 |
|
603 d = node.as_dict() |
|
604 if parent: |
|
605 d['required_version'] = node.version_spec if node.version_spec else 'Any' |
|
606 else: |
|
607 d['required_version'] = d['installed_version'] |
|
608 |
|
609 d['dependencies'] = [ |
|
610 aux(c, parent=node, chain=chain+[c.project_name]) |
|
611 for c in tree.get_children(node.key) |
|
612 if c.project_name not in chain |
|
613 ] |
|
614 |
|
615 return d |
|
616 |
|
617 return json.dumps([aux(p) for p in nodes], indent=indent) |
|
618 |
|
619 |
|
620 def dump_graphviz(tree, output_format='dot', is_reverse=False): |
|
621 """Output dependency graph as one of the supported GraphViz output formats. |
|
622 |
|
623 :param dict tree: dependency graph |
|
624 :param string output_format: output format |
|
625 :returns: representation of tree in the specified output format |
|
626 :rtype: str or binary representation depending on the output format |
|
627 |
|
628 """ |
|
629 try: |
|
630 from graphviz import Digraph |
|
631 except ImportError: |
|
632 print('graphviz is not available, but necessary for the output ' |
|
633 'option. Please install it.', file=sys.stderr) |
|
634 sys.exit(1) |
|
635 |
|
636 try: |
|
637 from graphviz import parameters |
|
638 except ImportError: |
|
639 from graphviz import backend |
|
640 valid_formats = backend.FORMATS |
|
641 print('Deprecation warning! Please upgrade graphviz to version >=0.18.0 ' |
|
642 'Support for older versions will be removed in upcoming release', |
|
643 file=sys.stderr) |
|
644 else: |
|
645 valid_formats = parameters.FORMATS |
|
646 |
|
647 if output_format not in valid_formats: |
|
648 print('{0} is not a supported output format.'.format(output_format), |
|
649 file=sys.stderr) |
|
650 print('Supported formats are: {0}'.format( |
|
651 ', '.join(sorted(valid_formats))), file=sys.stderr) |
|
652 sys.exit(1) |
|
653 |
|
654 graph = Digraph(format=output_format) |
|
655 |
|
656 if not is_reverse: |
|
657 for pkg, deps in tree.items(): |
|
658 pkg_label = '{0}\\n{1}'.format(pkg.project_name, pkg.version) |
|
659 graph.node(pkg.key, label=pkg_label) |
|
660 for dep in deps: |
|
661 edge_label = dep.version_spec or 'any' |
|
662 if dep.is_missing: |
|
663 dep_label = '{0}\\n(missing)'.format(dep.project_name) |
|
664 graph.node(dep.key, label=dep_label, style='dashed') |
|
665 graph.edge(pkg.key, dep.key, style='dashed') |
|
666 else: |
|
667 graph.edge(pkg.key, dep.key, label=edge_label) |
|
668 else: |
|
669 for dep, parents in tree.items(): |
|
670 dep_label = '{0}\\n{1}'.format(dep.project_name, |
|
671 dep.installed_version) |
|
672 graph.node(dep.key, label=dep_label) |
|
673 for parent in parents: |
|
674 # req reference of the dep associated with this |
|
675 # particular parent package |
|
676 req_ref = parent.req |
|
677 edge_label = req_ref.version_spec or 'any' |
|
678 graph.edge(dep.key, parent.key, label=edge_label) |
|
679 |
|
680 # Allow output of dot format, even if GraphViz isn't installed. |
|
681 if output_format == 'dot': |
|
682 return graph.source |
|
683 |
|
684 # As it's unknown if the selected output format is binary or not, try to |
|
685 # decode it as UTF8 and only print it out in binary if that's not possible. |
|
686 try: |
|
687 return graph.pipe().decode('utf-8') |
|
688 except UnicodeDecodeError: |
|
689 return graph.pipe() |
|
690 |
|
691 |
|
692 def print_graphviz(dump_output): |
|
693 """Dump the data generated by GraphViz to stdout. |
|
694 |
|
695 :param dump_output: The output from dump_graphviz |
|
696 """ |
|
697 if hasattr(dump_output, 'encode'): |
|
698 print(dump_output) |
|
699 else: |
|
700 with os.fdopen(sys.stdout.fileno(), 'wb') as bytestream: |
|
701 bytestream.write(dump_output) |
|
702 |
|
703 |
|
704 def conflicting_deps(tree): |
|
705 """Returns dependencies which are not present or conflict with the |
|
706 requirements of other packages. |
|
707 |
|
708 e.g. will warn if pkg1 requires pkg2==2.0 and pkg2==1.0 is installed |
|
709 |
|
710 :param tree: the requirements tree (dict) |
|
711 :returns: dict of DistPackage -> list of unsatisfied/unknown ReqPackage |
|
712 :rtype: dict |
|
713 |
|
714 """ |
|
715 conflicting = defaultdict(list) |
|
716 for p, rs in tree.items(): |
|
717 for req in rs: |
|
718 if req.is_conflicting(): |
|
719 conflicting[p].append(req) |
|
720 return conflicting |
|
721 |
|
722 |
|
723 def render_conflicts_text(conflicts): |
|
724 if conflicts: |
|
725 print('Warning!!! Possibly conflicting dependencies found:', |
|
726 file=sys.stderr) |
|
727 # Enforce alphabetical order when listing conflicts |
|
728 pkgs = sorted(conflicts.keys()) |
|
729 for p in pkgs: |
|
730 pkg = p.render_as_root(False) |
|
731 print('* {}'.format(pkg), file=sys.stderr) |
|
732 for req in conflicts[p]: |
|
733 req_str = req.render_as_branch(False) |
|
734 print(' - {}'.format(req_str), file=sys.stderr) |
|
735 |
|
736 |
|
737 def cyclic_deps(tree): |
|
738 """Return cyclic dependencies as list of tuples |
|
739 |
|
740 :param PackageDAG pkgs: package tree/dag |
|
741 :returns: list of tuples representing cyclic dependencies |
|
742 :rtype: list |
|
743 |
|
744 """ |
|
745 index = {p.key: set([r.key for r in rs]) for p, rs in tree.items()} |
|
746 cyclic = [] |
|
747 for p, rs in tree.items(): |
|
748 for r in rs: |
|
749 if p.key in index.get(r.key, []): |
|
750 p_as_dep_of_r = [x for x |
|
751 in tree.get(tree.get_node_as_parent(r.key)) |
|
752 if x.key == p.key][0] |
|
753 cyclic.append((p, r, p_as_dep_of_r)) |
|
754 return cyclic |
|
755 |
|
756 |
|
757 def render_cycles_text(cycles): |
|
758 if cycles: |
|
759 print('Warning!! Cyclic dependencies found:', file=sys.stderr) |
|
760 # List in alphabetical order of the dependency that's cycling |
|
761 # (2nd item in the tuple) |
|
762 cycles = sorted(cycles, key=lambda xs: xs[1].key) |
|
763 for a, b, c in cycles: |
|
764 print('* {0} => {1} => {2}'.format(a.project_name, |
|
765 b.project_name, |
|
766 c.project_name), |
|
767 file=sys.stderr) |
|
768 |
|
769 |
|
770 def get_parser(): |
|
771 parser = argparse.ArgumentParser(description=( |
|
772 'Dependency tree of the installed python packages' |
|
773 )) |
|
774 parser.add_argument('-v', '--version', action='version', |
|
775 version='{0}'.format(__version__)) |
|
776 parser.add_argument('-f', '--freeze', action='store_true', |
|
777 help='Print names so as to write freeze files') |
|
778 parser.add_argument('--python', default=sys.executable, |
|
779 help='Python to use to look for packages in it (default: where' |
|
780 ' installed)') |
|
781 parser.add_argument('-a', '--all', action='store_true', |
|
782 help='list all deps at top level') |
|
783 parser.add_argument('-l', '--local-only', |
|
784 action='store_true', help=( |
|
785 'If in a virtualenv that has global access ' |
|
786 'do not show globally installed packages' |
|
787 )) |
|
788 parser.add_argument('-u', '--user-only', action='store_true', |
|
789 help=( |
|
790 'Only show installations in the user site dir' |
|
791 )) |
|
792 parser.add_argument('-w', '--warn', action='store', dest='warn', |
|
793 nargs='?', default='suppress', |
|
794 choices=('silence', 'suppress', 'fail'), |
|
795 help=( |
|
796 'Warning control. "suppress" will show warnings ' |
|
797 'but return 0 whether or not they are present. ' |
|
798 '"silence" will not show warnings at all and ' |
|
799 'always return 0. "fail" will show warnings and ' |
|
800 'return 1 if any are present. The default is ' |
|
801 '"suppress".' |
|
802 )) |
|
803 parser.add_argument('-r', '--reverse', action='store_true', |
|
804 default=False, help=( |
|
805 'Shows the dependency tree in the reverse fashion ' |
|
806 'ie. the sub-dependencies are listed with the ' |
|
807 'list of packages that need them under them.' |
|
808 )) |
|
809 parser.add_argument('-p', '--packages', |
|
810 help=( |
|
811 'Comma separated list of select packages to show ' |
|
812 'in the output. If set, --all will be ignored.' |
|
813 )) |
|
814 parser.add_argument('-e', '--exclude', |
|
815 help=( |
|
816 'Comma separated list of select packages to exclude ' |
|
817 'from the output. If set, --all will be ignored.' |
|
818 ), metavar='PACKAGES') |
|
819 parser.add_argument('-j', '--json', action='store_true', default=False, |
|
820 help=( |
|
821 'Display dependency tree as json. This will yield ' |
|
822 '"raw" output that may be used by external tools. ' |
|
823 'This option overrides all other options.' |
|
824 )) |
|
825 parser.add_argument('--json-tree', action='store_true', default=False, |
|
826 help=( |
|
827 'Display dependency tree as json which is nested ' |
|
828 'the same way as the plain text output printed by default. ' |
|
829 'This option overrides all other options (except --json).' |
|
830 )) |
|
831 parser.add_argument('--graph-output', dest='output_format', |
|
832 help=( |
|
833 'Print a dependency graph in the specified output ' |
|
834 'format. Available are all formats supported by ' |
|
835 'GraphViz, e.g.: dot, jpeg, pdf, png, svg' |
|
836 )) |
|
837 return parser |
|
838 |
|
839 |
|
840 def _get_args(): |
|
841 parser = get_parser() |
|
842 return parser.parse_args() |
|
843 |
|
844 |
|
845 def handle_non_host_target(args): |
|
846 of_python = os.path.abspath(args.python) |
|
847 # if target is not current python re-invoke it under the actual host |
|
848 if of_python != os.path.abspath(sys.executable): |
|
849 # there's no way to guarantee that graphviz is available, so refuse |
|
850 if args.output_format: |
|
851 print("graphviz functionality is not supported when querying" |
|
852 " non-host python", file=sys.stderr) |
|
853 raise SystemExit(1) |
|
854 argv = sys.argv[1:] # remove current python executable |
|
855 for py_at, value in enumerate(argv): |
|
856 if value == "--python": |
|
857 del argv[py_at] |
|
858 del argv[py_at] |
|
859 elif value.startswith("--python"): |
|
860 del argv[py_at] |
|
861 # feed the file as argument, instead of file |
|
862 # to avoid adding the file path to sys.path, that can affect result |
|
863 file_path = inspect.getsourcefile(sys.modules[__name__]) |
|
864 with open(file_path, 'rt') as file_handler: |
|
865 content = file_handler.read() |
|
866 cmd = [of_python, "-c", content] |
|
867 cmd.extend(argv) |
|
868 # invoke from an empty folder to avoid cwd altering sys.path |
|
869 cwd = tempfile.mkdtemp() |
|
870 try: |
|
871 return subprocess.call(cmd, cwd=cwd) |
|
872 finally: |
|
873 os.removedirs(cwd) |
|
874 return None |
|
875 |
|
876 |
|
877 def get_installed_distributions(local_only=False, user_only=False): |
|
878 try: |
|
879 from pip._internal.metadata import get_environment |
|
880 except ImportError: |
|
881 # For backward compatibility with python ver. 2.7 and pip |
|
882 # version 20.3.4 (latest pip version that works with python |
|
883 # version 2.7) |
|
884 from pip._internal.utils import misc |
|
885 return misc.get_installed_distributions( |
|
886 local_only=local_only, |
|
887 user_only=user_only |
|
888 ) |
|
889 else: |
|
890 dists = get_environment(None).iter_installed_distributions( |
|
891 local_only=local_only, |
|
892 skip=(), |
|
893 user_only=user_only |
|
894 ) |
|
895 return [d._dist for d in dists] |
|
896 |
|
897 |
|
898 def main(): |
|
899 os.environ["_PIP_USE_IMPORTLIB_METADATA"] = "False" |
|
900 # patched for 3.11+ compatibility |
|
901 |
|
902 args = _get_args() |
|
903 result = handle_non_host_target(args) |
|
904 if result is not None: |
|
905 return result |
|
906 |
|
907 pkgs = get_installed_distributions(local_only=args.local_only, |
|
908 user_only=args.user_only) |
|
909 |
|
910 tree = PackageDAG.from_pkgs(pkgs) |
|
911 |
|
912 is_text_output = not any([args.json, args.json_tree, args.output_format]) |
|
913 |
|
914 return_code = 0 |
|
915 |
|
916 # Before any reversing or filtering, show warnings to console |
|
917 # about possibly conflicting or cyclic deps if found and warnings |
|
918 # are enabled (ie. only if output is to be printed to console) |
|
919 if is_text_output and args.warn != 'silence': |
|
920 conflicts = conflicting_deps(tree) |
|
921 if conflicts: |
|
922 render_conflicts_text(conflicts) |
|
923 print('-'*72, file=sys.stderr) |
|
924 |
|
925 cycles = cyclic_deps(tree) |
|
926 if cycles: |
|
927 render_cycles_text(cycles) |
|
928 print('-'*72, file=sys.stderr) |
|
929 |
|
930 if args.warn == 'fail' and (conflicts or cycles): |
|
931 return_code = 1 |
|
932 |
|
933 # Reverse the tree (if applicable) before filtering, thus ensuring |
|
934 # that the filter will be applied on ReverseTree |
|
935 if args.reverse: |
|
936 tree = tree.reverse() |
|
937 |
|
938 show_only = set(args.packages.split(',')) if args.packages else None |
|
939 exclude = set(args.exclude.split(',')) if args.exclude else None |
|
940 |
|
941 if show_only is not None or exclude is not None: |
|
942 tree = tree.filter(show_only, exclude) |
|
943 |
|
944 if args.json: |
|
945 print(render_json(tree, indent=4)) |
|
946 elif args.json_tree: |
|
947 print(render_json_tree(tree, indent=4)) |
|
948 elif args.output_format: |
|
949 output = dump_graphviz(tree, |
|
950 output_format=args.output_format, |
|
951 is_reverse=args.reverse) |
|
952 print_graphviz(output) |
|
953 else: |
|
954 render_text(tree, args.all, args.freeze) |
|
955 |
|
956 return return_code |
|
957 |
|
958 |
|
959 if __name__ == '__main__': |
|
960 sys.exit(main()) |