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