Commit d22c5a71 by Tom Solberg Committed by GitHub

cmd/show: add --why option (#5444)

parent 5840ac5d
...@@ -439,6 +439,7 @@ required by ...@@ -439,6 +439,7 @@ required by
### Options ### Options
* `--without`: The dependency groups to ignore. * `--without`: The dependency groups to ignore.
* `--why`: Include reverse dependencies where applicable.
* `--with`: The optional dependency groups to include. * `--with`: The optional dependency groups to include.
* `--only`: The only dependency groups to include. * `--only`: The only dependency groups to include.
* `--default`: Only include the main dependencies. (**Deprecated**) * `--default`: Only include the main dependencies. (**Deprecated**)
......
...@@ -22,6 +22,17 @@ if TYPE_CHECKING: ...@@ -22,6 +22,17 @@ if TYPE_CHECKING:
from poetry.repositories.repository import Repository 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): class ShowCommand(GroupCommand):
name = "show" name = "show"
...@@ -36,6 +47,11 @@ class ShowCommand(GroupCommand): ...@@ -36,6 +47,11 @@ class ShowCommand(GroupCommand):
"Do not list the development dependencies. (<warning>Deprecated</warning>)", "Do not list the development dependencies. (<warning>Deprecated</warning>)",
), ),
option("tree", "t", "List the dependencies as a tree."), 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("latest", "l", "Show the latest version."),
option( option(
"outdated", "outdated",
...@@ -69,6 +85,23 @@ lists all packages available.""" ...@@ -69,6 +85,23 @@ lists all packages available."""
if self.option("tree"): if self.option("tree"):
self.init_styles(self.io) self.init_styles(self.io)
if self.option("why"):
if self.option("tree") and package is None:
self.line_error(
"<error>Error: --why requires a package when combined with"
" --tree.</error>"
)
return 1
if not self.option("tree") and package:
self.line_error(
"<error>Error: --why cannot be used without --tree when displaying"
" a single package.</error>"
)
return 1
if self.option("outdated"): if self.option("outdated"):
self._io.input.set_option("latest", True) self._io.input.set_option("latest", True)
...@@ -83,7 +116,7 @@ lists all packages available.""" ...@@ -83,7 +116,7 @@ lists all packages available."""
root = self.project_with_activated_groups_only() root = self.project_with_activated_groups_only()
# Show tree view if requested # Show tree view if requested
if self.option("tree") and not package: if self.option("tree") and package is None:
requires = root.all_requires requires = root.all_requires
packages = locked_repo.packages packages = locked_repo.packages
for p in packages: for p in packages:
...@@ -121,17 +154,38 @@ lists all packages available.""" ...@@ -121,17 +154,38 @@ lists all packages available."""
if not pkg: if not pkg:
raise ValueError(f"Package {package} not found") raise ValueError(f"Package {package} not found")
required_by = reverse_deps(pkg, locked_repo)
if self.option("tree"): 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 = {} else:
for locked in locked_packages: self.display_package_tree(self._io, pkg, locked_repo)
dependencies = {d.name: d.pretty_constraint for d in locked.requires}
if pkg.name in dependencies: return 0
required_by[locked.pretty_name] = dependencies[pkg.name]
rows = [ rows = [
["<info>name</>", f" : <c1>{pkg.pretty_name}</>"], ["<info>name</>", f" : <c1>{pkg.pretty_name}</>"],
...@@ -163,7 +217,7 @@ lists all packages available.""" ...@@ -163,7 +217,7 @@ lists all packages available."""
show_all = self.option("all") show_all = self.option("all")
terminal = Terminal() terminal = Terminal()
width = terminal.width width = terminal.width
name_length = version_length = latest_length = 0 name_length = version_length = latest_length = required_by_length = 0
latest_packages = {} latest_packages = {}
latest_statuses = {} latest_statuses = {}
installed_repo = InstalledRepository.load(self.env) installed_repo = InstalledRepository.load(self.env)
...@@ -208,6 +262,13 @@ lists all packages available.""" ...@@ -208,6 +262,13 @@ lists all packages available."""
) )
), ),
) )
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: else:
name_length = max(name_length, current_length) name_length = max(name_length, current_length)
version_length = max( version_length = max(
...@@ -219,9 +280,20 @@ lists all packages available.""" ...@@ -219,9 +280,20 @@ lists all packages available."""
), ),
) )
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_version = name_length + version_length + 3 <= width
write_latest = name_length + version_length + latest_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: for locked in locked_packages:
color = "cyan" color = "cyan"
...@@ -273,9 +345,21 @@ lists all packages available.""" ...@@ -273,9 +345,21 @@ lists all packages available."""
) )
line += f" <fg={color}>{version:{latest_length}}</>" line += f" <fg={color}>{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: if write_description:
description = locked.description description = locked.description
remaining = width - name_length - version_length - 4 remaining = (
width - name_length - version_length - required_by_length - 4
)
if show_latest: if show_latest:
remaining -= latest_length remaining -= latest_length
...@@ -285,10 +369,15 @@ lists all packages available.""" ...@@ -285,10 +369,15 @@ lists all packages available."""
line += " " + description line += " " + description
self.line(line) self.line(line)
return None return None
def display_package_tree( 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: ) -> None:
io.write(f"<c1>{package.pretty_name}</c1>") io.write(f"<c1>{package.pretty_name}</c1>")
description = "" description = ""
...@@ -297,11 +386,15 @@ lists all packages available.""" ...@@ -297,11 +386,15 @@ lists all packages available."""
io.write_line(f" <b>{package.pretty_version}</b>{description}") io.write_line(f" <b>{package.pretty_version}</b>{description}")
dependencies = package.requires if why_package is not None:
dependencies = sorted( dependencies = [p for p in package.requires if p.name == why_package.name]
dependencies, else:
key=lambda x: x.name, # type: ignore[no-any-return] dependencies = package.requires
) dependencies = sorted(
dependencies,
key=lambda x: x.name, # type: ignore[no-any-return]
)
tree_bar = "├" tree_bar = "├"
total = len(dependencies) total = len(dependencies)
for i, dependency in enumerate(dependencies, 1): for i, dependency in enumerate(dependencies, 1):
......
...@@ -1686,6 +1686,121 @@ cachy 0.2.0 ...@@ -1686,6 +1686,121 @@ cachy 0.2.0
assert tester.io.fetch_output() == expected 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( def test_show_required_by_deps(
tester: CommandTester, poetry: Poetry, installed: Repository tester: CommandTester, poetry: Poetry, installed: Repository
): ):
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment