diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index dd1da3b5..3f08c672 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -13,12 +13,12 @@ concurrency: jobs: test: name: test ${{ matrix.py }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest strategy: fail-fast: false matrix: py: - - "3.12.0-beta.1" + - "3.12.0-beta.2" - "3.11" - "3.10" - "3.9" @@ -83,7 +83,7 @@ jobs: publish: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') needs: [check, test] - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest steps: - name: setup python to build package uses: actions/setup-python@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2458d26..4023d599 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ on: jobs: release: - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest environment: name: release url: https://pypi.org/p/pipdeptree diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3a065ddc..763b5500 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,59 +2,33 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - - id: check-ast - - id: check-builtin-literals - - id: check-docstring-first - - id: check-merge-conflict - - id: check-yaml - - id: check-toml - - id: debug-statements - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.0.272" hooks: - - id: pyupgrade - args: ["--py37-plus"] - - repo: https://github.com/PyCQA/isort - rev: 5.12.0 - hooks: - - id: isort + - id: ruff + args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black rev: 23.3.0 hooks: - id: black - args: [--safe] - - repo: https://github.com/asottile/blacken-docs - rev: 1.13.0 - hooks: - - id: blacken-docs - additional_dependencies: [black==23.3] - repo: https://github.com/tox-dev/tox-ini-fmt rev: "1.3.0" hooks: - id: tox-ini-fmt args: ["-p", "fix"] - repo: https://github.com/tox-dev/pyproject-fmt - rev: "0.9.2" + rev: "0.11.2" hooks: - id: pyproject-fmt - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 - hooks: - - id: flake8 - additional_dependencies: - - flake8-bugbear==23.3.23 - - flake8-comprehensions==3.12 - - flake8-pytest-style==1.7.2 - - flake8-spellcheck==0.28 - - flake8-unused-arguments==0.0.13 - - flake8-noqa==1.3.1 - - pep8-naming==0.13.3 + additional_dependencies: ["tox>=4.6"] - repo: https://github.com/pre-commit/mirrors-prettier - rev: "v2.7.1" + rev: "v3.0.0-alpha.9-for-vscode" hooks: - id: prettier - additional_dependencies: - - "prettier@2.7.1" - - "@prettier/plugin-xml@2.2" + args: ["--print-width=120", "--prose-wrap=always"] + - repo: meta + hooks: + - id: check-hooks-apply + - id: check-useless-excludes diff --git a/.prettierrc.toml b/.prettierrc.toml deleted file mode 100644 index ba170fef..00000000 --- a/.prettierrc.toml +++ /dev/null @@ -1,2 +0,0 @@ -printWidth = 120 -proseWrap = "always" diff --git a/README.md b/README.md index 9d1b3a53..cf21bac1 100644 --- a/README.md +++ b/README.md @@ -199,8 +199,6 @@ $ pipdeptree --json-tree ## Visualizing the dependency graph -![image](https://raw.githubusercontent.com/tox-dev/pipdeptree/main/docs/twine-pdt.png) - The dependency graph can also be visualized using [GraphViz](http://www.graphviz.org/): ```bash diff --git a/docs/twine-pdt.png b/docs/twine-pdt.png deleted file mode 100644 index bea0494a..00000000 Binary files a/docs/twine-pdt.png and /dev/null differ diff --git a/docs/v2beta-opts.org b/docs/v2beta-opts.org deleted file mode 100644 index dd810955..00000000 --- a/docs/v2beta-opts.org +++ /dev/null @@ -1,43 +0,0 @@ -* Options in version 0.x v/s 2.x (upcoming release) - -Until version 0.13.2, the some of the options that pipdeptree supports -didn't work in combination with other options. In fact this was the -primary reason behind refactoring the code. - -The upcoming version 2.x plans to fix this as shown in the tables below. - -Note: The changes for upcoming 2.x release can be found in the -~v2beta~ branch. - -** Until version 0.13.2 - -| *Features* | all | local-only | user-only | freeze | warn | reverse | packages | json | json-tree | graph-output | -|--------------+-----+------------+-----------+--------+------+---------+----------+------+-----------+--------------| -| | | | | | | | | | | | -| all | | | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| local-only | | | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| user-only | | | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| freeze | ✓ | ✓ | ✓ | ✓ | | ✓ | ✓ | | | | -| warn | ✓ | ✓ | ✓ | | | | | ✗ | ✗ | | -| reverse | ✓ | ✓ | ✓ | ✓ | | | ✓ | | ✗ | ✗ | -| packages | ✓ | ✓ | ✓ | ✓ | | ✓ | | ✗ | ✗ | ✗ | -| json | ✓ | ✓ | ✓ | | ✗ | | ✗ | | | | -| json-tree | ✓ | ✓ | ✓ | | ✗ | ✗ | ✗ | | | | -| graph-output | ✓ | ✓ | ✓ | | | ✗ | ✗ | | | | - - -** Plan for version 2.0.0 (work in progress) - -| *Features* | all | local-only | user-only | freeze | warn | reverse | packages | json | json-tree | graph-output | | -|--------------+-----+------------+-----------+--------+----------+----------+----------+----------+-----------+--------------+---| -| | | | | | | | | | | | | -| all | | | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | -| local-only | | | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | -| user-only | | | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | -| freeze | ✓ | ✓ | ✓ | ✓ | | ✓ | ✓ | | | | | -| warn | ✓ | ✓ | ✓ | | | | | ✓ (todo) | ✓ (todo) | | | -| reverse | ✓ | ✓ | ✓ | ✓ | | | ✓ | | ✓ (done) | ✓ (done) | | -| packages | ✓ | ✓ | ✓ | ✓ | | ✓ | | ✓ (done) | ✓ (done) | ✓ (done) | | -| json | ✓ | ✓ | ✓ | | ✓ (todo) | | ✓ (done) | | | | | -| json-tree | ✓ | ✓ | ✓ | | ✓ (todo) | ✓ (done) | ✓ (done) | | | | | -| graph-output | ✓ | ✓ | ✓ | | | ✓ (done) | ✓ (done) | | | | | diff --git a/pyproject.toml b/pyproject.toml index eb1f4141..d31ee713 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ build-backend = "hatchling.build" requires = [ "hatch-vcs>=0.3", - "hatchling>=1.14", + "hatchling>=1.17.1", ] [project] @@ -29,8 +29,13 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] dynamic = [ "version", @@ -41,11 +46,11 @@ optional-dependencies.graphviz = [ optional-dependencies.test = [ "covdefaults>=2.3", "diff-cover>=7.5", - "pip>=23.1", + "pip>=23.1.2", "pytest>=7.3.1", - "pytest-cov>=4", + "pytest-cov>=4.1", "pytest-mock>=3.10", - "virtualenv<21,>=20.21", + "virtualenv<21,>=20.23", ] urls.Changelog = "https://github.com/tox-dev/pipdeptree/blob/main/CHANGES.md" urls.Documentation = "https://github.com/tox-dev/pipdeptree/blob/main/README.md#pipdeptree" @@ -61,13 +66,31 @@ version.source = "vcs" [tool.black] line-length = 120 -[tool.isort] -profile = "black" -known_first_party = ["pipdeptree"] - [tool.coverage] html.show_contexts = true html.skip_covered = false paths.source = ["src", ".tox/*/lib/python*/site-packages", "*/src"] run.parallel = true run.plugins = ["covdefaults"] + +[tool.ruff] +select = ["ALL"] +line-length = 120 +target-version = "py37" +isort = {known-first-party = ["pipdeptree"], required-imports = ["from __future__ import annotations"]} +ignore = [ + "INP001", # no implicit namespace + "D", # ignore documentation for now + "ANN", # No type annotations for now + "D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible + "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible + "S104", # Possible binding to all interface +] +[tool.ruff.per-file-ignores] +"tests/**/*.py" = [ + "S101", # asserts allowed in tests... + "FBT", # don"t care about booleans as positional arguments in tests + "D", # don"t care about documentation in tests + "S603", # `subprocess` call: check for execution of untrusted input + "PLR2004", # Magic value used in comparison, consider replacing with a constant variable +] diff --git a/src/pipdeptree/__init__.py b/src/pipdeptree/__init__.py index 0e850163..d66207ad 100644 --- a/src/pipdeptree/__init__.py +++ b/src/pipdeptree/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import argparse import fnmatch import inspect @@ -11,6 +13,7 @@ from collections.abc import Mapping from importlib import import_module from itertools import chain +from pathlib import Path from textwrap import dedent from pip._vendor import pkg_resources @@ -36,7 +39,8 @@ def sorted_tree(tree): def guess_version(pkg_key, default="?"): - """Guess the version of a pkg when pip doesn't provide it + """ + Guess the version of a pkg when pip doesn't provide it. :param str pkg_key: key of the package :param str default: default version to return if unable to find @@ -62,8 +66,7 @@ def guess_version(pkg_key, default="?"): v = getattr(m, "__version__", default) if inspect.ismodule(v): return getattr(v, "__version__", default) - else: - return v + return v def frozen_req_from_dist(dist): @@ -96,22 +99,21 @@ class Package: for `render_as_root` and `render_as_branch` methods. """ - def __init__(self, obj): + def __init__(self, obj) -> None: self._obj = obj self.project_name = obj.project_name self.key = obj.key - def render_as_root(self, frozen): # noqa: U100 + def render_as_root(self, frozen): # noqa: ARG002 return NotImplementedError - def render_as_branch(self, frozen): # noqa: U100 + def render_as_branch(self, frozen): # noqa: ARG002 return NotImplementedError - def render(self, parent=None, frozen=False): + def render(self, parent=None, frozen=False): # noqa: FBT002 if not parent: return self.render_as_root(frozen) - else: - return self.render_as_branch(frozen) + return self.render_as_branch(frozen) @staticmethod def frozen_repr(obj): @@ -121,7 +123,7 @@ def frozen_repr(obj): def __getattr__(self, key): return getattr(self._obj, key) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__}("{self.key}")>' def __lt__(self, rhs): @@ -130,14 +132,14 @@ def __lt__(self, rhs): class DistPackage(Package): """ - Wrapper class for pkg_resources.Distribution instances + Wrapper class for pkg_resources.Distribution instances. :param obj: pkg_resources.Distribution to wrap over :param req: optional ReqPackage object to associate this DistPackage with. This is useful for displaying the tree in reverse """ - def __init__(self, obj, req=None): + def __init__(self, obj, req=None) -> None: super().__init__(obj) self.version_spec = None self.req = req @@ -145,22 +147,20 @@ def __init__(self, obj, req=None): def render_as_root(self, frozen): if not frozen: return f"{self.project_name}=={self.version}" - else: - return self.__class__.frozen_repr(self._obj) + return self.__class__.frozen_repr(self._obj) def render_as_branch(self, frozen): - assert self.req is not None + assert self.req is not None # noqa: S101 if not frozen: parent_ver_spec = self.req.version_spec parent_str = self.req.project_name if parent_ver_spec: parent_str += parent_ver_spec return f"{self.project_name}=={self.version} [requires: {parent_str}]" - else: - return self.render_as_root(frozen) + return self.render_as_root(frozen) def as_requirement(self): - """Return a ReqPackage representation of this DistPackage""" + """Return a ReqPackage representation of this DistPackage.""" return ReqPackage(self._obj.as_requirement(), dist=self) def as_parent_of(self, req): @@ -184,7 +184,7 @@ def as_dict(self): class ReqPackage(Package): """ - Wrapper class for Requirements instance + Wrapper class for Requirements instance. :param obj: The `Requirements` instance to wrap over :param dist: optional `pkg_resources.Distribution` instance for this requirement @@ -192,7 +192,7 @@ class ReqPackage(Package): UNKNOWN_VERSION = "?" - def __init__(self, obj, dist=None): + def __init__(self, obj, dist=None) -> None: super().__init__(obj) self.dist = dist @@ -212,7 +212,7 @@ def is_missing(self): return self.installed_version == self.UNKNOWN_VERSION def is_conflicting(self): - """If installed version conflicts with required version""" + """If installed version conflicts with required version.""" # unknown installed version is also considered conflicting if self.installed_version == self.UNKNOWN_VERSION: return True @@ -224,17 +224,15 @@ def is_conflicting(self): def render_as_root(self, frozen): if not frozen: return f"{self.project_name}=={self.installed_version}" - elif self.dist: - return self.__class__.frozen_repr(self.dist._obj) - else: - return self.project_name + if self.dist: + return self.__class__.frozen_repr(self.dist._obj) # noqa: SLF001 + return self.project_name def render_as_branch(self, frozen): if not frozen: req_ver = self.version_spec if self.version_spec else "Any" return f"{self.project_name} [required: {req_ver}, installed: {self.installed_version}]" - else: - return self.render_as_root(frozen) + return self.render_as_root(frozen) def as_dict(self): return { @@ -275,8 +273,9 @@ def from_pkgs(cls, pkgs): m = {p: [ReqPackage(r, idx.get(r.key)) for r in p.requires()] for p in pkgs} return cls(m) - def __init__(self, m): - """Initialize the PackageDAG object + def __init__(self, m) -> None: + """ + Initialize the PackageDAG object. :param dict m: dict of node objects (refer class docstring) :returns: None @@ -304,7 +303,7 @@ def get_node_as_parent(self, node_key): def get_children(self, node_key): """ - Get child nodes for a node by its key + Get child nodes for a node by its key. :param str node_key: key of the node to get children of :returns: list of child nodes @@ -313,9 +312,9 @@ def get_children(self, node_key): node = self.get_node_as_parent(node_key) return self._obj[node] if node else [] - def filter(self, include, exclude): + def filter_nodes(self, include, exclude): # noqa: C901, PLR0912 """ - Filters nodes in a graph by given parameters + Filters nodes in a graph by given parameters. If a node is included, then all it's children are also included. @@ -335,22 +334,19 @@ def filter(self, include, exclude): # https://setuptools.readthedocs.io/en/latest/pkg_resources.html#distribution-objects if include: include = {s.lower() for s in include} - if exclude: - exclude = {s.lower() for s in exclude} - else: - exclude = set() + exclude = {s.lower() for s in exclude} if exclude else set() # Check for mutual exclusion of show_only and exclude sets # after normalizing the values to lowercase if include and exclude: - assert not (include & exclude) + assert not (include & exclude) # noqa: S101 # Traverse the graph in a depth first manner and filter the # nodes according to `show_only` and `exclude` sets stack = deque() m = {} seen = set() - for node in self._obj.keys(): + for node in self._obj: if any(fnmatch.fnmatch(node.key, e) for e in exclude): continue if include is None or any(fnmatch.fnmatch(node.key, i) for i in include): @@ -398,7 +394,7 @@ def reverse(self): # we are using the same object. This check is required # as we're using array mutation try: - node = [p for p in m.keys() if p.key == v.key][0] + node = [p for p in m if p.key == v.key][0] except IndexError: node = v m[node].append(k.as_parent_of(v)) @@ -421,12 +417,13 @@ def __getitem__(self, *args): def __iter__(self): return self._obj.__iter__() - def __len__(self): + def __len__(self) -> int: return len(self._obj) class ReversedPackageDAG(PackageDAG): - """Representation of Package dependencies in the reverse order. + """ + Representation of Package dependencies in the reverse order. Similar to it's super class `PackageDAG`, the underlying datastructure is a dict, but here the keys are expected to be of type `ReqPackage` and each item in the values of type `DistPackage`. @@ -436,7 +433,7 @@ class ReversedPackageDAG(PackageDAG): def reverse(self): """ - Reverse the already reversed DAG to get the PackageDAG again + Reverse the already reversed DAG to get the PackageDAG again. :returns: reverse of the reversed DAG :rtype: PackageDAG @@ -446,7 +443,7 @@ def reverse(self): for k, vs in self._obj.items(): for v in vs: try: - node = [p for p in m.keys() if p.key == v.key][0] + node = [p for p in m if p.key == v.key][0] except IndexError: node = v.as_parent_of(None) m[node].append(k) @@ -455,8 +452,9 @@ def reverse(self): return PackageDAG(dict(m)) -def render_text(tree, max_depth, list_all=True, frozen=False): - """Print tree as text on console +def render_text(tree, max_depth, list_all=True, frozen=False): # noqa: FBT002 + """ + Print tree as text on console. :param dict tree: the package tree :param bool list_all: whether to list all the pgks at the root level or only those that are the sub-dependencies @@ -479,16 +477,16 @@ def render_text(tree, max_depth, list_all=True, frozen=False): def _render_text_with_unicode(tree, nodes, max_depth, frozen): use_bullets = not frozen - def aux( + def aux( # noqa: PLR0913 node, parent=None, indent=0, cur_chain=None, prefix="", depth=0, - has_grand_parent=False, - is_last_child=False, - parent_is_last_child=False, + has_grand_parent=False, # noqa: FBT002 + is_last_child=False, # noqa: FBT002 + parent_is_last_child=False, # noqa: FBT002 ): cur_chain = cur_chain or [] node_str = node.render(parent, frozen) @@ -525,7 +523,7 @@ def aux( c, node, indent=next_indent, - cur_chain=cur_chain + [c.project_name], + cur_chain=[*cur_chain, c.project_name], prefix=next_prefix, depth=depth + 1, has_grand_parent=parent is not None, @@ -540,7 +538,7 @@ def aux( return result lines = chain.from_iterable([aux(p) for p in nodes]) - print("\n".join(lines)) + print("\n".join(lines)) # noqa: T201 def _render_text_without_unicode(tree, nodes, max_depth, frozen): @@ -554,7 +552,7 @@ def aux(node, parent=None, indent=0, cur_chain=None, depth=0): node_str = prefix + node_str result = [node_str] children = [ - aux(c, node, indent=indent + 2, cur_chain=cur_chain + [c.project_name], depth=depth + 1) + aux(c, node, indent=indent + 2, cur_chain=[*cur_chain, c.project_name], depth=depth + 1) for c in tree.get_children(node.key) if c.project_name not in cur_chain and depth + 1 <= max_depth ] @@ -562,7 +560,7 @@ def aux(node, parent=None, indent=0, cur_chain=None, depth=0): return result lines = chain.from_iterable([aux(p) for p in nodes]) - print("\n".join(lines)) + print("\n".join(lines)) # noqa: T201 def render_json(tree, indent): @@ -580,7 +578,8 @@ def render_json(tree, indent): """ tree = tree.sort() return json.dumps( - [{"package": k.as_dict(), "dependencies": [v.as_dict() for v in vs]} for k, vs in tree.items()], indent=indent + [{"package": k.as_dict(), "dependencies": [v.as_dict() for v in vs]} for k, vs in tree.items()], + indent=indent, ) @@ -603,7 +602,7 @@ def render_json_tree(tree, indent): """ tree = tree.sort() branch_keys = {r.key for r in chain.from_iterable(tree.values())} - nodes = [p for p in tree.keys() if p.key not in branch_keys] + nodes = [p for p in tree if p.key not in branch_keys] def aux(node, parent=None, cur_chain=None): if cur_chain is None: @@ -616,7 +615,7 @@ def aux(node, parent=None, cur_chain=None): d["required_version"] = d["installed_version"] d["dependencies"] = [ - aux(c, parent=node, cur_chain=cur_chain + [c.project_name]) + aux(c, parent=node, cur_chain=[*cur_chain, c.project_name]) for c in tree.get_children(node.key) if c.project_name not in cur_chain ] @@ -626,8 +625,9 @@ def aux(node, parent=None, cur_chain=None): return json.dumps([aux(p) for p in nodes], indent=indent) -def render_mermaid(tree) -> str: - """Produce a Mermaid flowchart from the dependency graph. +def render_mermaid(tree) -> str: # noqa: C901 + """ + Produce a Mermaid flowchart from the dependency graph. :param dict tree: dependency graph """ @@ -683,7 +683,7 @@ def mermaid_id(key: str) -> str: if isinstance(tree, ReversedPackageDAG): for package, reverse_dependencies in tree.items(): package_label = "\\n".join( - (package.project_name, "(missing)" if package.is_missing else package.installed_version) + (package.project_name, "(missing)" if package.is_missing else package.installed_version), ) package_key = mermaid_id(package.key) nodes.add(f'{package_key}["{package_label}"]') @@ -693,7 +693,7 @@ def mermaid_id(key: str) -> str: edges.add(f'{package_key} -- "{edge_label}" --> {reverse_dependency_key}') else: for package, dependencies in tree.items(): - package_label = "\\n".join((package.project_name, package.version)) + package_label = f"{package.project_name}\\n{package.version}" package_key = mermaid_id(package.key) nodes.add(f'{package_key}["{package_label}"]') for dependency in dependencies: @@ -712,7 +712,7 @@ def mermaid_id(key: str) -> str: f"""\ flowchart TD {indent}classDef missing stroke-dasharray: 5 - """ + """, ) # Sort the nodes and edges to make the output deterministic. output += indent @@ -723,8 +723,9 @@ def mermaid_id(key: str) -> str: return output -def dump_graphviz(tree, output_format="dot", is_reverse=False): - """Output dependency graph as one of the supported GraphViz output formats. +def dump_graphviz(tree, output_format="dot", is_reverse=False): # noqa: C901, FBT002, PLR0912 + """ + Output dependency graph as one of the supported GraphViz output formats. :param dict tree: dependency graph :param string output_format: output format @@ -735,9 +736,12 @@ def dump_graphviz(tree, output_format="dot", is_reverse=False): """ try: from graphviz import Digraph - except ImportError: - print("graphviz is not available, but necessary for the output " "option. Please install it.", file=sys.stderr) - sys.exit(1) + except ImportError as exc: + print( # noqa: T201 + "graphviz is not available, but necessary for the output option. Please install it.", + file=sys.stderr, + ) + raise SystemExit(1) from exc try: from graphviz import parameters @@ -745,7 +749,7 @@ def dump_graphviz(tree, output_format="dot", is_reverse=False): from graphviz import backend valid_formats = backend.FORMATS - print( + print( # noqa: T201 "Deprecation warning! Please upgrade graphviz to version >=0.18.0 " "Support for older versions will be removed in upcoming release", file=sys.stderr, @@ -754,9 +758,9 @@ def dump_graphviz(tree, output_format="dot", is_reverse=False): valid_formats = parameters.FORMATS if output_format not in valid_formats: - print(f"{output_format} is not a supported output format.", file=sys.stderr) - print(f"Supported formats are: {', '.join(sorted(valid_formats))}", file=sys.stderr) - sys.exit(1) + print(f"{output_format} is not a supported output format.", file=sys.stderr) # noqa: T201 + print(f"Supported formats are: {', '.join(sorted(valid_formats))}", file=sys.stderr) # noqa: T201 + raise SystemExit(1) graph = Digraph(format=output_format) @@ -789,7 +793,7 @@ def dump_graphviz(tree, output_format="dot", is_reverse=False): # Fixes https://github.com/tox-dev/pipdeptree/issues/188 # That way we can guarantee the output of the dot format is deterministic # and stable. - return "".join([tuple(graph)[0]] + sorted(graph.body) + [graph._tail]) + return "".join([tuple(graph)[0], *sorted(graph.body), graph._tail]) # noqa: SLF001 # As it's unknown if the selected output format is binary or not, try to # decode it as UTF8 and only print it out in binary if that's not possible. @@ -806,7 +810,7 @@ def print_graphviz(dump_output): :param dump_output: The output from dump_graphviz """ if hasattr(dump_output, "encode"): - print(dump_output) + print(dump_output) # noqa: T201 else: with os.fdopen(sys.stdout.fileno(), "wb") as bytestream: bytestream.write(dump_output) @@ -832,20 +836,20 @@ def conflicting_deps(tree): def render_conflicts_text(conflicts): if conflicts: - print("Warning!!! Possibly conflicting dependencies found:", file=sys.stderr) + print("Warning!!! Possibly conflicting dependencies found:", file=sys.stderr) # noqa: T201 # Enforce alphabetical order when listing conflicts pkgs = sorted(conflicts.keys()) for p in pkgs: - pkg = p.render_as_root(False) - print(f"* {pkg}", file=sys.stderr) + pkg = p.render_as_root(False) # noqa: FBT003 + print(f"* {pkg}", file=sys.stderr) # noqa: T201 for req in conflicts[p]: - req_str = req.render_as_branch(False) - print(f" - {req_str}", file=sys.stderr) + req_str = req.render_as_branch(False) # noqa: FBT003 + print(f" - {req_str}", file=sys.stderr) # noqa: T201 def cyclic_deps(tree): """ - Return cyclic dependencies as list of tuples + Return cyclic dependencies as list of tuples. :param PackageDAG tree: package tree/dag :returns: list of tuples representing cyclic dependencies @@ -863,12 +867,12 @@ def cyclic_deps(tree): def render_cycles_text(cycles): if cycles: - print("Warning!! Cyclic dependencies found:", file=sys.stderr) + print("Warning!! Cyclic dependencies found:", file=sys.stderr) # noqa: T201 # List in alphabetical order of the dependency that's cycling # (2nd item in the tuple) cycles = sorted(cycles, key=lambda xs: xs[1].key) for a, b, c in cycles: - print(f"* {a.project_name} => {b.project_name} => {c.project_name}", file=sys.stderr) + print(f"* {a.project_name} => {b.project_name} => {c.project_name}", file=sys.stderr) # noqa: T201 def get_parser(): @@ -878,14 +882,14 @@ def get_parser(): parser.add_argument( "--python", default=sys.executable, - help="Python to use to look for packages in it (default: where" " installed)", + help="Python to use to look for packages in it (default: where installed)", ) parser.add_argument("-a", "--all", action="store_true", help="list all deps at top level") parser.add_argument( "-l", "--local-only", action="store_true", - help="If in a virtualenv that has global access " "do not show globally installed packages", + help="If in a virtualenv that has global access do not show globally installed packages", ) parser.add_argument("-u", "--user-only", action="store_true", help="Only show installations in the user site dir") parser.add_argument( @@ -960,7 +964,7 @@ def get_parser(): "--mermaid", action="store_true", default=False, - help=("Display dependency tree as a Mermaid graph. " "This option overrides all other options."), + help="Display dependency tree as a Mermaid graph. This option overrides all other options.", ) parser.add_argument( "--graph-output", @@ -990,12 +994,14 @@ def _get_args(): def handle_non_host_target(args): - of_python = os.path.abspath(args.python) # if target is not current python re-invoke it under the actual host - if of_python != os.path.abspath(sys.executable): + if Path(args.python).absolute() != Path(sys.executable).absolute(): # there's no way to guarantee that graphviz is available, so refuse if args.output_format: - print("graphviz functionality is not supported when querying" " non-host python", file=sys.stderr) + print( # noqa: T201 + "graphviz functionality is not supported when querying non-host python", + file=sys.stderr, + ) raise SystemExit(1) argv = sys.argv[1:] # remove current python executable for py_at, value in enumerate(argv): @@ -1007,18 +1013,17 @@ def handle_non_host_target(args): main_file = inspect.getsourcefile(sys.modules[__name__]) with tempfile.TemporaryDirectory() as project: - dest = os.path.join(project, "pipdeptree") - shutil.copytree(os.path.dirname(main_file), dest) + shutil.copytree(Path(main_file).parent, Path(project) / "pipdeptree") # invoke from an empty folder to avoid cwd altering sys.path env = os.environ.copy() env["PYTHONPATH"] = project - cmd = [of_python, "-m", "pipdeptree"] + cmd = [args.python, "-m", "pipdeptree"] cmd.extend(argv) - return subprocess.call(cmd, cwd=project, env=env) + return subprocess.call(cmd, cwd=project, env=env) # noqa: S603 return None -def get_installed_distributions(local_only=False, user_only=False): +def get_installed_distributions(local_only=False, user_only=False): # noqa: FBT002 try: from pip._internal.metadata import pkg_resources except ImportError: @@ -1030,9 +1035,11 @@ def get_installed_distributions(local_only=False, user_only=False): return misc.get_installed_distributions(local_only=local_only, user_only=user_only) else: dists = pkg_resources.Environment.from_paths(None).iter_installed_distributions( - local_only=local_only, skip=(), user_only=user_only + local_only=local_only, + skip=(), + user_only=user_only, ) - return [d._dist for d in dists] + return [d._dist for d in dists] # noqa: SLF001 def main(): @@ -1042,13 +1049,28 @@ def main(): return result pkgs = get_installed_distributions(local_only=args.local_only, user_only=args.user_only) - tree = PackageDAG.from_pkgs(pkgs) - is_text_output = not any([args.json, args.json_tree, args.output_format]) - return_code = 0 + return_code = _check_cycle_conflict(args, is_text_output, tree) + + # Reverse the tree (if applicable) before filtering, thus ensuring + # that the filter will be applied on ReverseTree + if args.reverse: + tree = tree.reverse() + + show_only = set(args.packages.split(",")) if args.packages else None + exclude = set(args.exclude.split(",")) if args.exclude else None + + if show_only is not None or exclude is not None: + tree = tree.filter_nodes(show_only, exclude) + + _render(args, tree) + + return return_code + +def _check_cycle_conflict(args, is_text_output, tree): # Before any reversing or filtering, show warnings to console # about possibly conflicting or cyclic deps if found and warnings # are enabled (i.e. only if output is to be printed to console) @@ -1056,37 +1078,27 @@ def main(): conflicts = conflicting_deps(tree) if conflicts: render_conflicts_text(conflicts) - print("-" * 72, file=sys.stderr) + print("-" * 72, file=sys.stderr) # noqa: T201 cycles = cyclic_deps(tree) if cycles: render_cycles_text(cycles) - print("-" * 72, file=sys.stderr) + print("-" * 72, file=sys.stderr) # noqa: T201 if args.warn == "fail" and (conflicts or cycles): - return_code = 1 - - # Reverse the tree (if applicable) before filtering, thus ensuring - # that the filter will be applied on ReverseTree - if args.reverse: - tree = tree.reverse() + return 1 + return 0 - show_only = set(args.packages.split(",")) if args.packages else None - exclude = set(args.exclude.split(",")) if args.exclude else None - - if show_only is not None or exclude is not None: - tree = tree.filter(show_only, exclude) +def _render(args, tree): if args.json: - print(render_json(tree, indent=4)) + print(render_json(tree, indent=4)) # noqa: T201 elif args.json_tree: - print(render_json_tree(tree, indent=4)) + print(render_json_tree(tree, indent=4)) # noqa: T201 elif args.mermaid: - print(render_mermaid(tree)) + print(render_mermaid(tree)) # noqa: T201 elif args.output_format: output = dump_graphviz(tree, output_format=args.output_format, is_reverse=args.reverse) print_graphviz(output) else: render_text(tree, args.depth, args.all, args.freeze) - - return return_code diff --git a/src/pipdeptree/__main__.py b/src/pipdeptree/__main__.py index 85cca3cc..35ce6094 100644 --- a/src/pipdeptree/__main__.py +++ b/src/pipdeptree/__main__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import sys from pipdeptree import main diff --git a/tests/guess_version_setuptools.py b/tests/guess_version_setuptools.py index f9426f71..fcf70687 100644 --- a/tests/guess_version_setuptools.py +++ b/tests/guess_version_setuptools.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import sys import pipdeptree @@ -13,4 +15,4 @@ def raise_import_error(name): importlib_metadata.version = raise_import_error -print(pipdeptree.guess_version("setuptools"), end="") +print(pipdeptree.guess_version("setuptools"), end="") # noqa: T201 diff --git a/tests/test_pipdeptree.py b/tests/test_pipdeptree.py index f50094d8..8b63dc4b 100644 --- a/tests/test_pipdeptree.py +++ b/tests/test_pipdeptree.py @@ -1,13 +1,13 @@ +from __future__ import annotations + import platform import random import subprocess import sys -from contextlib import contextmanager from itertools import chain from pathlib import Path -from tempfile import NamedTemporaryFile from textwrap import dedent, indent -from typing import Any +from typing import TYPE_CHECKING, Any try: from unittest import mock @@ -19,6 +19,9 @@ import pipdeptree as p +if TYPE_CHECKING: + from pytest_mock import MockerFixture + # Tests for DAG classes @@ -44,7 +47,7 @@ def mock_package_dag(simple_graph): # util for comparing tree contents with a simple graph def dag_to_dict(g): - return {k.key: [v.key for v in vs] for k, vs in g._obj.items()} + return {k.key: [v.key for v in vs] for k, vs in g._obj.items()} # noqa: SLF001 def sort_map_values(m): @@ -60,39 +63,38 @@ def sort_map_values(m): ("e", "0.12.1"): [], ("f", "3.1"): [("b", [(">=", "2.1.0")])], ("g", "6.8.3rc1"): [("e", [(">=", "0.9.0")]), ("f", [(">=", "3.0.0")])], - } + }, ) def test_package_dag_get_node_as_parent(): - assert "b" == t.get_node_as_parent("b").key - assert "c" == t.get_node_as_parent("c").key + assert t.get_node_as_parent("b").key == "b" + assert t.get_node_as_parent("c").key == "c" def test_package_dag_filter(): # When both show_only and exclude are not specified, same tree - # object is returned - assert t.filter(None, None) is t + assert t.filter_nodes(None, None) is t # when show_only is specified - g1 = dag_to_dict(t.filter({"a", "d"}, None)) + g1 = dag_to_dict(t.filter_nodes({"a", "d"}, None)) expected = {"a": ["b", "c"], "b": ["d"], "c": ["d", "e"], "d": ["e"], "e": []} assert expected == g1 # when exclude is specified - g2 = dag_to_dict(t.filter(None, ["d"])) + g2 = dag_to_dict(t.filter_nodes(None, ["d"])) expected = {"a": ["b", "c"], "b": [], "c": ["e"], "e": [], "f": ["b"], "g": ["e", "f"]} assert expected == g2 # when both show_only and exclude are specified - g3 = dag_to_dict(t.filter({"a", "g"}, {"d", "e"})) + g3 = dag_to_dict(t.filter_nodes({"a", "g"}, {"d", "e"})) expected = {"a": ["b", "c"], "b": [], "c": [], "f": ["b"], "g": ["f"]} assert expected == g3 # when conflicting values in show_only and exclude, AssertionError # is raised with pytest.raises(AssertionError): - dag_to_dict(t.filter({"d"}, {"D", "e"})) + dag_to_dict(t.filter_nodes({"d"}, {"D", "e"})) @pytest.fixture(scope="session") @@ -103,32 +105,32 @@ def t_fnmatch() -> Any: ("a.b", "1"): [("a.c", [])], ("b.a", "1"): [("b.b", [])], ("b.b", "1"): [("a.b", [])], - } + }, ) def test_package_dag_filter_fnmatch_include_a(t_fnmatch: Any) -> None: # test include for a.*in the result we got only a.* nodes - graph = dag_to_dict(t_fnmatch.filter({"a.*"}, None)) + graph = dag_to_dict(t_fnmatch.filter_nodes({"a.*"}, None)) assert graph == {"a.a": ["a.b", "a.c"], "a.b": ["a.c"]} def test_package_dag_filter_fnmatch_include_b(t_fnmatch: Any) -> None: # test include for b.*, which has a.b and a.c in tree, but not a.a # in the result we got the b.* nodes plus the a.b node as child in the tree - graph = dag_to_dict(t_fnmatch.filter({"b.*"}, None)) + graph = dag_to_dict(t_fnmatch.filter_nodes({"b.*"}, None)) assert graph == {"b.a": ["b.b"], "b.b": ["a.b"], "a.b": ["a.c"]} def test_package_dag_filter_fnmatch_exclude_c(t_fnmatch: Any) -> None: # test exclude for b.* in the result we got only a.* nodes - graph = dag_to_dict(t_fnmatch.filter(None, {"b.*"})) + graph = dag_to_dict(t_fnmatch.filter_nodes(None, {"b.*"})) assert graph == {"a.a": ["a.b", "a.c"], "a.b": ["a.c"]} def test_package_dag_filter_fnmatch_exclude_a(t_fnmatch: Any) -> None: # test exclude for a.* in the result we got only b.* nodes - graph = dag_to_dict(t_fnmatch.filter(None, {"a.*"})) + graph = dag_to_dict(t_fnmatch.filter_nodes(None, {"a.*"})) assert graph == {"b.a": ["b.b"], "b.b": []} @@ -137,7 +139,7 @@ def test_package_dag_reverse(): expected = {"a": [], "b": ["a", "f"], "c": ["a"], "d": ["b", "c"], "e": ["c", "d", "g"], "f": ["g"], "g": []} assert isinstance(t1, p.ReversedPackageDAG) assert sort_map_values(expected) == sort_map_values(dag_to_dict(t1)) - assert all(isinstance(k, p.ReqPackage) for k in t1.keys()) + assert all(isinstance(k, p.ReqPackage) for k in t1) assert all(isinstance(v, p.DistPackage) for v in chain.from_iterable(t1.values())) # testing reversal of ReversedPackageDAG instance @@ -145,7 +147,7 @@ def test_package_dag_reverse(): t2 = t1.reverse() assert isinstance(t2, p.PackageDAG) assert sort_map_values(expected) == sort_map_values(dag_to_dict(t2)) - assert all(isinstance(k, p.DistPackage) for k in t2.keys()) + assert all(isinstance(k, p.DistPackage) for k in t2) assert all(isinstance(v, p.ReqPackage) for v in chain.from_iterable(t2.values())) @@ -159,7 +161,7 @@ def test_dist_package_render_as_root(): foo = mock.Mock(key="foo", project_name="foo", version="20.4.1") dp = p.DistPackage(foo) is_frozen = False - assert "foo==20.4.1" == dp.render_as_root(is_frozen) + assert dp.render_as_root(is_frozen) == "foo==20.4.1" def test_dist_package_render_as_branch(): @@ -169,7 +171,7 @@ def test_dist_package_render_as_branch(): rp = p.ReqPackage(bar_req, dist=bar) dp = p.DistPackage(foo).as_parent_of(rp) is_frozen = False - assert "foo==20.4.1 [requires: bar>=4.0]" == dp.render_as_branch(is_frozen) + assert dp.render_as_branch(is_frozen) == "foo==20.4.1 [requires: bar>=4.0]" def test_dist_package_as_parent_of(): @@ -181,7 +183,7 @@ def test_dist_package_as_parent_of(): bar_req = mock.Mock(key="bar", project_name="bar", version="4.1.0", specs=[(">=", "4.0")]) rp = p.ReqPackage(bar_req, dist=bar) dp1 = dp.as_parent_of(rp) - assert dp1._obj == dp._obj + assert dp1._obj == dp._obj # noqa: SLF001 assert dp1.req is rp dp2 = dp.as_parent_of(None) @@ -201,7 +203,7 @@ def test_req_package_render_as_root(): bar_req = mock.Mock(key="bar", project_name="bar", version="4.1.0", specs=[(">=", "4.0")]) rp = p.ReqPackage(bar_req, dist=bar) is_frozen = False - assert "bar==4.1.0" == rp.render_as_root(is_frozen) + assert rp.render_as_root(is_frozen) == "bar==4.1.0" def test_req_package_render_as_branch(): @@ -209,7 +211,7 @@ def test_req_package_render_as_branch(): bar_req = mock.Mock(key="bar", project_name="bar", version="4.1.0", specs=[(">=", "4.0")]) rp = p.ReqPackage(bar_req, dist=bar) is_frozen = False - assert "bar [required: >=4.0, installed: 4.1.0]" == rp.render_as_branch(is_frozen) + assert rp.render_as_branch(is_frozen) == "bar [required: >=4.0, installed: 4.1.0]" def test_req_package_as_dict(): @@ -230,7 +232,7 @@ class MockStdout: and `write()` (so that `print()` calls can write to stdout). """ - def __init__(self, encoding): + def __init__(self, encoding) -> None: self.stdout = sys.stdout self.encoding = encoding @@ -577,13 +579,13 @@ def randomized_dag_copy(t): """Returns a copy of the package tree fixture with dependencies in randomized order.""" # Extract the dependency graph from the package tree and randomize it. randomized_graph = {} - randomized_nodes = list(t._obj.keys()) + randomized_nodes = list(t._obj.keys()) # noqa: SLF001 random.shuffle(randomized_nodes) for node in randomized_nodes: - edges = t._obj[node] + edges = t._obj[node] # noqa: SLF001 random.shuffle(edges) randomized_graph[node] = edges - assert set(randomized_graph) == set(t._obj) + assert set(randomized_graph) == set(t._obj) # noqa: SLF001 # Create a randomized package tree. randomized_dag = p.PackageDAG(randomized_graph) @@ -612,7 +614,7 @@ def test_render_mermaid(): e["e\\n0.12.1"] f["f\\n3.1"] g["g\\n6.8.3rc1"] - """ + """, ) dependency_edges = indent( dedent( @@ -626,7 +628,7 @@ def test_render_mermaid(): f -- ">=2.1.0" --> b g -- ">=0.9.0" --> e g -- ">=3.0.0" --> f - """ + """, ), " " * 4, ).rstrip() @@ -642,7 +644,7 @@ def test_render_mermaid(): e -- ">=0.9.0" --> d e -- ">=0.9.0" --> g f -- ">=3.0.0" --> g - """ + """, ), " " * 4, ).rstrip() @@ -658,7 +660,7 @@ def test_mermaid_reserved_ids(): package_tree = mock_package_dag( { ("click", "3.4.0"): [("click-extra", [(">=", "2.0.0")])], - } + }, ) output = p.render_mermaid(package_tree) assert output == dedent( @@ -668,7 +670,7 @@ def test_mermaid_reserved_ids(): click-extra["click-extra\\n(missing)"]:::missing click_0["click\\n3.4.0"] click_0 -.-> click-extra - """ + """, ) @@ -700,27 +702,18 @@ def test_render_dot(capsys): \tg [label="g\\n6.8.3rc1"] } - """ + """, ) -def test_render_pdf(): +def test_render_pdf(tmp_path: Path, mocker: MockerFixture) -> None: output = p.dump_graphviz(t, output_format="pdf") - - @contextmanager - def redirect_stdout(new_target): - old_target, sys.stdout = sys.stdout, new_target - try: - yield new_target - finally: - sys.stdout = old_target - - with NamedTemporaryFile(delete=True) as f: - with redirect_stdout(f): + res = tmp_path / "file" + with pytest.raises(OSError, match="Bad file descriptor"): # noqa: PT012, SIM117 # because we reopen the file + with res.open("wb") as buf: + mocker.patch.object(sys, "stdout", buf) p.print_graphviz(output) - rf = open(f.name, "rb") - assert b"%PDF" == rf.read()[:4] - # @NOTE: rf is not closed to avoid "bad filedescriptor" error + assert res.read_bytes()[:4] == b"%PDF" def test_render_svg(capsys): @@ -914,13 +907,13 @@ def test_custom_interpreter(tmp_path, monkeypatch, capfd, args_joined): expected -= {"setuptools", "wheel"} assert found == expected, out - monkeypatch.setattr(sys, "argv", cmd + ["--graph-output", "something"]) + monkeypatch.setattr(sys, "argv", [*cmd, "--graph-output", "something"]) with pytest.raises(SystemExit) as context: p.main() out, err = capfd.readouterr() assert context.value.code == 1 assert not out - assert err == "graphviz functionality is not supported when querying" " non-host python\n" + assert err == "graphviz functionality is not supported when querying non-host python\n" def test_guess_version_setuptools(): diff --git a/tox.ini b/tox.ini index 5b233201..804b7b08 100644 --- a/tox.ini +++ b/tox.ini @@ -36,7 +36,7 @@ commands = description = format the code base to adhere to our styles, and complain about what we cannot do automatically skip_install = true deps = - pre-commit>=3.2.2 + pre-commit>=3.3.2 commands = pre-commit run --all-files --show-diff-on-failure diff --git a/whitelist.txt b/whitelist.txt deleted file mode 100644 index 2c3764fd..00000000 --- a/whitelist.txt +++ /dev/null @@ -1,36 +0,0 @@ -2nd -basedistribution -bytestream -capfd -capsys -cld -cldn -copytree -deque -distinfodistribution -dists -dp1 -dp2 -exe -filedescriptor -fileno -frozenrequirement -g1 -g2 -g3 -getitem -getsourcefile -graphviz -hacky -ismodule -mpkgs -nk -pipdeptree -pkgs -readouterr -reqs -rhs -svg -t1 -t2 -virtualenv