From d22c5a7187d8b5a30196a7df58111b3c90be7d22 Mon Sep 17 00:00:00 2001 From: Tom Solberg Date: Tue, 10 May 2022 11:31:16 +0200 Subject: [PATCH] cmd/show: add --why option (#5444) --- docs/cli.md | 1 + src/poetry/console/commands/show.py | 127 ++++++++++++++++++++++++---- tests/console/commands/test_show.py | 115 +++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 17 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 9fbb5b8c114..7b8ca165988 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -439,6 +439,7 @@ required by ### Options * `--without`: The dependency groups to ignore. +* `--why`: Include reverse dependencies where applicable. * `--with`: The optional dependency groups to include. * `--only`: The only dependency groups to include. * `--default`: Only include the main dependencies. (**Deprecated**) diff --git a/src/poetry/console/commands/show.py b/src/poetry/console/commands/show.py index 74c92af9ea4..8764595c3e3 100644 --- a/src/poetry/console/commands/show.py +++ b/src/poetry/console/commands/show.py @@ -22,6 +22,17 @@ from poetry.repositories.repository import Repository +def reverse_deps(pkg: Package, repo: Repository) -> dict[str, str]: + required_by = {} + for locked in repo.packages: + dependencies = {d.name: d.pretty_constraint for d in locked.requires} + + if pkg.name in dependencies: + required_by[locked.pretty_name] = dependencies[pkg.name] + + return required_by + + class ShowCommand(GroupCommand): name = "show" @@ -36,6 +47,11 @@ class ShowCommand(GroupCommand): "Do not list the development dependencies. (Deprecated)", ), option("tree", "t", "List the dependencies as a tree."), + option( + "why", + None, + "When listing the tree for a single package, start from parents.", + ), option("latest", "l", "Show the latest version."), option( "outdated", @@ -69,6 +85,23 @@ def handle(self) -> int | None: if self.option("tree"): self.init_styles(self.io) + if self.option("why"): + if self.option("tree") and package is None: + self.line_error( + "Error: --why requires a package when combined with" + " --tree." + ) + + return 1 + + if not self.option("tree") and package: + self.line_error( + "Error: --why cannot be used without --tree when displaying" + " a single package." + ) + + return 1 + if self.option("outdated"): self._io.input.set_option("latest", True) @@ -83,7 +116,7 @@ def handle(self) -> int | None: root = self.project_with_activated_groups_only() # Show tree view if requested - if self.option("tree") and not package: + if self.option("tree") and package is None: requires = root.all_requires packages = locked_repo.packages for p in packages: @@ -121,17 +154,38 @@ def handle(self) -> int | None: if not pkg: raise ValueError(f"Package {package} not found") + required_by = reverse_deps(pkg, locked_repo) + if self.option("tree"): - self.display_package_tree(self.io, pkg, locked_repo) + if self.option("why"): + # The default case if there's no reverse dependencies is to query + # the subtree for pkg but if any rev-deps exist we'll query for each + # of them in turn + packages = [pkg] + if required_by: + packages = [ + p + for p in locked_packages + for r in required_by.keys() + if p.name == r + ] + else: + # if no rev-deps exist we'll make this clear as it can otherwise + # look very odd for packages that also have no or few direct + # dependencies + self._io.write_line( + f"Package {package} is a direct dependency." + ) - return 0 + for p in packages: + self.display_package_tree( + self._io, p, locked_repo, why_package=pkg + ) - required_by = {} - for locked in locked_packages: - dependencies = {d.name: d.pretty_constraint for d in locked.requires} + else: + self.display_package_tree(self._io, pkg, locked_repo) - if pkg.name in dependencies: - required_by[locked.pretty_name] = dependencies[pkg.name] + return 0 rows = [ ["name", f" : {pkg.pretty_name}"], @@ -163,7 +217,7 @@ def handle(self) -> int | None: show_all = self.option("all") terminal = Terminal() width = terminal.width - name_length = version_length = latest_length = 0 + name_length = version_length = latest_length = required_by_length = 0 latest_packages = {} latest_statuses = {} installed_repo = InstalledRepository.load(self.env) @@ -208,6 +262,13 @@ def handle(self) -> int | None: ) ), ) + + if self.option("why"): + required_by = reverse_deps(locked, locked_repo) + required_by_length = max( + required_by_length, + len(" from " + ",".join(required_by.keys())), + ) else: name_length = max(name_length, current_length) version_length = max( @@ -219,9 +280,20 @@ def handle(self) -> int | None: ), ) + if self.option("why"): + required_by = reverse_deps(locked, locked_repo) + required_by_length = max( + required_by_length, len(" from " + ",".join(required_by.keys())) + ) + write_version = name_length + version_length + 3 <= width write_latest = name_length + version_length + latest_length + 3 <= width - write_description = name_length + version_length + latest_length + 24 <= width + + why_end_column = ( + name_length + version_length + latest_length + required_by_length + ) + write_why = self.option("why") and (why_end_column + 3) <= width + write_description = (why_end_column + 24) <= width for locked in locked_packages: color = "cyan" @@ -273,9 +345,21 @@ def handle(self) -> int | None: ) line += f" {version:{latest_length}}" + if write_why: + required_by = reverse_deps(locked, locked_repo) + if required_by: + content = ",".join(required_by.keys()) + # subtract 6 for ' from ' + line += f" from {content:{required_by_length - 6}}" + else: + line += " " * required_by_length + if write_description: description = locked.description - remaining = width - name_length - version_length - 4 + remaining = ( + width - name_length - version_length - required_by_length - 4 + ) + if show_latest: remaining -= latest_length @@ -285,10 +369,15 @@ def handle(self) -> int | None: line += " " + description self.line(line) + return None def display_package_tree( - self, io: IO, package: Package, installed_repo: Repository + self, + io: IO, + package: Package, + installed_repo: Repository, + why_package: Package | None = None, ) -> None: io.write(f"{package.pretty_name}") description = "" @@ -297,11 +386,15 @@ def display_package_tree( io.write_line(f" {package.pretty_version}{description}") - dependencies = package.requires - dependencies = sorted( - dependencies, - key=lambda x: x.name, # type: ignore[no-any-return] - ) + if why_package is not None: + dependencies = [p for p in package.requires if p.name == why_package.name] + else: + dependencies = package.requires + dependencies = sorted( + dependencies, + key=lambda x: x.name, # type: ignore[no-any-return] + ) + tree_bar = "├" total = len(dependencies) for i, dependency in enumerate(dependencies, 1): diff --git a/tests/console/commands/test_show.py b/tests/console/commands/test_show.py index 73a63c7f08e..a34bf422209 100644 --- a/tests/console/commands/test_show.py +++ b/tests/console/commands/test_show.py @@ -1686,6 +1686,121 @@ def test_show_tree_no_dev(tester: CommandTester, poetry: Poetry, installed: Repo assert tester.io.fetch_output() == expected +def test_show_tree_why_package( + tester: CommandTester, poetry: Poetry, installed: Repository +): + poetry.package.add_dependency(Factory.create_dependency("a", "=0.0.1")) + + a = get_package("a", "0.0.1") + installed.add_package(a) + a.add_dependency(Factory.create_dependency("b", "=0.0.1")) + + b = get_package("b", "0.0.1") + a.add_dependency(Factory.create_dependency("c", "=0.0.1")) + installed.add_package(b) + + c = get_package("c", "0.0.1") + installed.add_package(c) + + poetry.locker.mock_lock_data( + { + "package": [ + { + "name": "a", + "version": "0.0.1", + "dependencies": {"b": "=0.0.1"}, + "python-versions": "*", + "optional": False, + }, + { + "name": "b", + "version": "0.0.1", + "dependencies": {"c": "=0.0.1"}, + "python-versions": "*", + "optional": False, + }, + { + "name": "c", + "version": "0.0.1", + "python-versions": "*", + "optional": False, + }, + ], + "metadata": { + "python-versions": "*", + "platform": "*", + "content-hash": "123456789", + "hashes": {"a": [], "b": [], "c": []}, + }, + } + ) + + tester.execute("--tree --why b") + + expected = """\ +a 0.0.1 +└── b =0.0.1 + └── c =0.0.1 \n""" + + assert tester.io.fetch_output() == expected + + +def test_show_tree_why(tester: CommandTester, poetry: Poetry, installed: Repository): + poetry.package.add_dependency(Factory.create_dependency("a", "=0.0.1")) + + a = get_package("a", "0.0.1") + installed.add_package(a) + a.add_dependency(Factory.create_dependency("b", "=0.0.1")) + + b = get_package("b", "0.0.1") + a.add_dependency(Factory.create_dependency("c", "=0.0.1")) + installed.add_package(b) + + c = get_package("c", "0.0.1") + installed.add_package(c) + + poetry.locker.mock_lock_data( + { + "package": [ + { + "name": "a", + "version": "0.0.1", + "dependencies": {"b": "=0.0.1"}, + "python-versions": "*", + "optional": False, + }, + { + "name": "b", + "version": "0.0.1", + "dependencies": {"c": "=0.0.1"}, + "python-versions": "*", + "optional": False, + }, + { + "name": "c", + "version": "0.0.1", + "python-versions": "*", + "optional": False, + }, + ], + "metadata": { + "python-versions": "*", + "platform": "*", + "content-hash": "123456789", + "hashes": {"a": [], "b": [], "c": []}, + }, + } + ) + + tester.execute("--why") + + # this has to be on a single line due to the padding whitespace, which gets stripped + # by pre-commit. + expected = """a 0.0.1 \nb 0.0.1 from a \nc 0.0.1 from b \n""" + + assert tester.io.fetch_output() == expected + + def test_show_required_by_deps( tester: CommandTester, poetry: Poetry, installed: Repository ):