Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add --why option to poetry show #5444

Merged
merged 6 commits into from
May 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**)
Expand Down
127 changes: 110 additions & 17 deletions src/poetry/console/commands/show.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -36,6 +47,11 @@ class ShowCommand(GroupCommand):
"Do not list the development dependencies. (<warning>Deprecated</warning>)",
),
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",
Expand Down Expand Up @@ -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>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"):
self._io.input.set_option("latest", True)

Expand All @@ -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:
Expand Down Expand Up @@ -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 = [
["<info>name</>", f" : <c1>{pkg.pretty_name}</>"],
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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"
Expand Down Expand Up @@ -273,9 +345,21 @@ def handle(self) -> int | None:
)
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:
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

Expand All @@ -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"<c1>{package.pretty_name}</c1>")
description = ""
Expand All @@ -297,11 +386,15 @@ def display_package_tree(

io.write_line(f" <b>{package.pretty_version}</b>{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):
Expand Down
115 changes: 115 additions & 0 deletions tests/console/commands/test_show.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down