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
):