src/eric7/PipInterface/pipdeptree.py

branch
eric7
changeset 10177
27a6e35c64ed
parent 10176
5e26785b93b8
child 10178
d13545cfafa1
equal deleted inserted replaced
10176:5e26785b93b8 10177:27a6e35c64ed
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())

eric ide

mercurial