Skip to content

Commit 4a5f90a

Browse files
authored
Have duplicate metadata and invalid reqs warnings honor --warn (#357)
This resolves #355 by making changes and refactors to the warning logic. It does so by introducing a module-level singleton "WarningPrinter" object and refactors the code in such a way to integrate this object for it to be used.
1 parent c325803 commit 4a5f90a

File tree

10 files changed

+305
-73
lines changed

10 files changed

+305
-73
lines changed

src/pipdeptree/__main__.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,26 @@
1010
from pipdeptree._models import PackageDAG
1111
from pipdeptree._render import render
1212
from pipdeptree._validate import validate
13+
from pipdeptree._warning import WarningPrinter, WarningType, get_warning_printer
1314

1415

1516
def main(args: Sequence[str] | None = None) -> None | int:
1617
"""CLI - The main function called as entry point."""
1718
options = get_options(args)
1819

20+
# Warnings are only enabled when using text output.
21+
is_text_output = not any([options.json, options.json_tree, options.output_format])
22+
if not is_text_output:
23+
options.warn = WarningType.SILENCE
24+
warning_printer = get_warning_printer()
25+
warning_printer.warning_type = options.warn
26+
1927
pkgs = get_installed_distributions(
2028
interpreter=options.python, local_only=options.local_only, user_only=options.user_only
2129
)
2230
tree = PackageDAG.from_pkgs(pkgs)
23-
is_text_output = not any([options.json, options.json_tree, options.output_format])
2431

25-
return_code = validate(options, is_text_output, tree)
32+
validate(tree)
2633

2734
# Reverse the tree (if applicable) before filtering, thus ensuring, that the filter will be applied on ReverseTree
2835
if options.reverse:
@@ -35,14 +42,17 @@ def main(args: Sequence[str] | None = None) -> None | int:
3542
try:
3643
tree = tree.filter_nodes(show_only, exclude)
3744
except ValueError as e:
38-
if options.warn in {"suppress", "fail"}:
39-
print(e, file=sys.stderr) # noqa: T201
40-
return_code |= 1 if options.warn == "fail" else 0
41-
return return_code
45+
if warning_printer.should_warn():
46+
warning_printer.print_single_line(str(e))
47+
return _determine_return_code(warning_printer)
4248

4349
render(options, tree)
4450

45-
return return_code
51+
return _determine_return_code(warning_printer)
52+
53+
54+
def _determine_return_code(warning_printer: WarningPrinter) -> int:
55+
return 1 if warning_printer.has_warned_with_failure() else 0
4656

4757

4858
if __name__ == "__main__":

src/pipdeptree/_cli.py

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
from __future__ import annotations
22

3+
import enum
34
import sys
4-
from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace
5-
from typing import TYPE_CHECKING, Sequence, cast
5+
from argparse import Action, ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace
6+
from typing import Any, Sequence, cast
67

7-
from .version import __version__
8+
from pipdeptree._warning import WarningType
89

9-
if TYPE_CHECKING:
10-
from typing import Literal
10+
from .version import __version__
1111

1212

1313
class Options(Namespace):
@@ -16,7 +16,7 @@ class Options(Namespace):
1616
all: bool
1717
local_only: bool
1818
user_only: bool
19-
warn: Literal["silence", "suppress", "fail"]
19+
warn: WarningType
2020
reverse: bool
2121
packages: str
2222
exclude: str
@@ -40,11 +40,11 @@ def build_parser() -> ArgumentParser:
4040
parser.add_argument(
4141
"-w",
4242
"--warn",
43-
action="store",
4443
dest="warn",
44+
type=WarningType,
4545
nargs="?",
4646
default="suppress",
47-
choices=("silence", "suppress", "fail"),
47+
action=EnumAction,
4848
help=(
4949
"warning control: suppress will show warnings but return 0 whether or not they are present; silence will "
5050
"not show warnings at all and always return 0; fail will show warnings and return 1 if any are present"
@@ -154,6 +154,71 @@ def get_options(args: Sequence[str] | None) -> Options:
154154
return cast(Options, parsed_args)
155155

156156

157+
class EnumAction(Action):
158+
"""
159+
Generic action that exists to convert a string into a Enum value that is then added into a `Namespace` object.
160+
161+
This custom action exists because argparse doesn't have support for enums.
162+
163+
References
164+
----------
165+
- https://github.com/python/cpython/issues/69247#issuecomment-1308082792
166+
- https://docs.python.org/3/library/argparse.html#action-classes
167+
168+
"""
169+
170+
def __init__( # noqa: PLR0913, PLR0917
171+
self,
172+
option_strings: list[str],
173+
dest: str,
174+
nargs: str | None = None,
175+
const: Any | None = None,
176+
default: Any | None = None,
177+
type: Any | None = None, # noqa: A002
178+
choices: Any | None = None,
179+
required: bool = False, # noqa: FBT001, FBT002
180+
help: str | None = None, # noqa: A002
181+
metavar: str | None = None,
182+
) -> None:
183+
if not type or not issubclass(type, enum.Enum):
184+
msg = "type must be a subclass of Enum"
185+
raise TypeError(msg)
186+
if not isinstance(default, str):
187+
msg = "default must be defined with a string value"
188+
raise TypeError(msg)
189+
190+
choices = tuple(e.name.lower() for e in type)
191+
if default not in choices:
192+
msg = "default value should be among the enum choices"
193+
raise ValueError(msg)
194+
195+
super().__init__(
196+
option_strings=option_strings,
197+
dest=dest,
198+
nargs=nargs,
199+
const=const,
200+
default=default,
201+
type=None, # We return None here so that we default to str.
202+
choices=choices,
203+
required=required,
204+
help=help,
205+
metavar=metavar,
206+
)
207+
208+
self._enum = type
209+
210+
def __call__(
211+
self,
212+
parser: ArgumentParser, # noqa: ARG002
213+
namespace: Namespace,
214+
value: Any,
215+
option_string: str | None = None, # noqa: ARG002
216+
) -> None:
217+
value = value or self.default
218+
value = next(e for e in self._enum if e.name.lower() == value)
219+
setattr(namespace, self.dest, value)
220+
221+
157222
__all__ = [
158223
"Options",
159224
"get_options",

src/pipdeptree/_discovery.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
from packaging.utils import canonicalize_name
1212

13+
from pipdeptree._warning import get_warning_printer
14+
1315

1416
def get_installed_distributions(
1517
interpreter: str = str(sys.executable),
@@ -42,6 +44,8 @@ def get_installed_distributions(
4244
else:
4345
original_dists = distributions()
4446

47+
warning_printer = get_warning_printer()
48+
4549
# Since importlib.metadata.distributions() can return duplicate packages, we need to handle this. pip's approach is
4650
# to keep track of each package metadata it finds, and if it encounters one again it will simply just ignore it. We
4751
# take it one step further and warn the user that there are duplicate packages in their environment.
@@ -55,11 +59,17 @@ def get_installed_distributions(
5559
seen_dists[normalized_name] = dist
5660
dists.append(dist)
5761
continue
58-
already_seen_dists = first_seen_to_already_seen_dists_dict.setdefault(seen_dists[normalized_name], [])
59-
already_seen_dists.append(dist)
60-
61-
if first_seen_to_already_seen_dists_dict:
62-
render_duplicated_dist_metadata_text(first_seen_to_already_seen_dists_dict)
62+
if warning_printer.should_warn():
63+
already_seen_dists = first_seen_to_already_seen_dists_dict.setdefault(seen_dists[normalized_name], [])
64+
already_seen_dists.append(dist)
65+
66+
should_print_warning = warning_printer.should_warn() and first_seen_to_already_seen_dists_dict
67+
if should_print_warning:
68+
warning_printer.print_multi_line(
69+
"Duplicate package metadata found",
70+
lambda: render_duplicated_dist_metadata_text(first_seen_to_already_seen_dists_dict),
71+
ignore_fail=True,
72+
)
6373

6474
return dists
6575

@@ -77,7 +87,6 @@ def render_duplicated_dist_metadata_text(
7787
dist_list = entries_to_pairs_dict.setdefault(entry, [])
7888
dist_list.append((first_seen, dist))
7989

80-
print("Warning!!! Duplicate package metadata found:", file=sys.stderr) # noqa: T201
8190
for entry, pairs in entries_to_pairs_dict.items():
8291
print(f'"{entry}"', file=sys.stderr) # noqa: T201
8392
for first_seen, dist in pairs:
@@ -88,7 +97,6 @@ def render_duplicated_dist_metadata_text(
8897
),
8998
file=sys.stderr,
9099
)
91-
print("-" * 72, file=sys.stderr) # noqa: T201
92100

93101

94102
__all__ = [

src/pipdeptree/_models/dag.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,17 @@
1212
from importlib.metadata import Distribution
1313

1414

15-
from .package import DistPackage, InvalidRequirementError, ReqPackage
15+
from pipdeptree._warning import get_warning_printer
1616

17+
from .package import DistPackage, InvalidRequirementError, ReqPackage
1718

18-
def render_invalid_reqs_text_if_necessary(dist_name_to_invalid_reqs_dict: dict[str, list[str]]) -> None:
19-
if not dist_name_to_invalid_reqs_dict:
20-
return
2119

22-
print("Warning!!! Invalid requirement strings found for the following distributions:", file=sys.stderr) # noqa: T201
20+
def render_invalid_reqs_text(dist_name_to_invalid_reqs_dict: dict[str, list[str]]) -> None:
2321
for dist_name, invalid_reqs in dist_name_to_invalid_reqs_dict.items():
2422
print(dist_name, file=sys.stderr) # noqa: T201
2523

2624
for invalid_req in invalid_reqs:
2725
print(f' Skipping "{invalid_req}"', file=sys.stderr) # noqa: T201
28-
print("-" * 72, file=sys.stderr) # noqa: T201
2926

3027

3128
class PackageDAG(Mapping[DistPackage, List[ReqPackage]]):
@@ -53,6 +50,7 @@ class PackageDAG(Mapping[DistPackage, List[ReqPackage]]):
5350

5451
@classmethod
5552
def from_pkgs(cls, pkgs: list[Distribution]) -> PackageDAG:
53+
warning_printer = get_warning_printer()
5654
dist_pkgs = [DistPackage(p) for p in pkgs]
5755
idx = {p.key: p for p in dist_pkgs}
5856
m: dict[DistPackage, list[ReqPackage]] = {}
@@ -65,7 +63,8 @@ def from_pkgs(cls, pkgs: list[Distribution]) -> PackageDAG:
6563
req = next(requires_iterator)
6664
except InvalidRequirementError as err:
6765
# We can't work with invalid requirement strings. Let's warn the user about them.
68-
dist_name_to_invalid_reqs_dict.setdefault(p.project_name, []).append(str(err))
66+
if warning_printer.should_warn():
67+
dist_name_to_invalid_reqs_dict.setdefault(p.project_name, []).append(str(err))
6968
continue
7069
except StopIteration:
7170
break
@@ -78,7 +77,12 @@ def from_pkgs(cls, pkgs: list[Distribution]) -> PackageDAG:
7877
reqs.append(pkg)
7978
m[p] = reqs
8079

81-
render_invalid_reqs_text_if_necessary(dist_name_to_invalid_reqs_dict)
80+
should_print_warning = warning_printer.should_warn() and dist_name_to_invalid_reqs_dict
81+
if should_print_warning:
82+
warning_printer.print_multi_line(
83+
"Invalid requirement strings found for the following distributions",
84+
lambda: render_invalid_reqs_text(dist_name_to_invalid_reqs_dict),
85+
)
8286

8387
return cls(m)
8488

src/pipdeptree/_validate.py

Lines changed: 29 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,28 @@
44
from collections import defaultdict
55
from typing import TYPE_CHECKING
66

7+
from pipdeptree._warning import get_warning_printer
8+
79
if TYPE_CHECKING:
810
from pipdeptree._models.package import Package
911

10-
from ._cli import Options
1112
from ._models import DistPackage, PackageDAG, ReqPackage
1213

1314

14-
def validate(args: Options, is_text_output: bool, tree: PackageDAG) -> int: # noqa: FBT001
15+
def validate(tree: PackageDAG) -> None:
1516
# Before any reversing or filtering, show warnings to console, about possibly conflicting or cyclic deps if found
1617
# and warnings are enabled (i.e. only if output is to be printed to console)
17-
if is_text_output and args.warn != "silence":
18+
warning_printer = get_warning_printer()
19+
if warning_printer.should_warn():
1820
conflicts = conflicting_deps(tree)
1921
if conflicts:
20-
render_conflicts_text(conflicts)
21-
print("-" * 72, file=sys.stderr) # noqa: T201
22+
warning_printer.print_multi_line(
23+
"Possibly conflicting dependencies found", lambda: render_conflicts_text(conflicts)
24+
)
2225

2326
cycles = cyclic_deps(tree)
2427
if cycles:
25-
render_cycles_text(cycles)
26-
print("-" * 72, file=sys.stderr) # noqa: T201
27-
28-
if args.warn == "fail" and (conflicts or cycles):
29-
return 1
30-
return 0
28+
warning_printer.print_multi_line("Cyclic dependencies found", lambda: render_cycles_text(cycles))
3129

3230

3331
def conflicting_deps(tree: PackageDAG) -> dict[DistPackage, list[ReqPackage]]:
@@ -50,16 +48,14 @@ def conflicting_deps(tree: PackageDAG) -> dict[DistPackage, list[ReqPackage]]:
5048

5149

5250
def render_conflicts_text(conflicts: dict[DistPackage, list[ReqPackage]]) -> None:
53-
if conflicts:
54-
print("Warning!!! Possibly conflicting dependencies found:", file=sys.stderr) # noqa: T201
55-
# Enforce alphabetical order when listing conflicts
56-
pkgs = sorted(conflicts.keys())
57-
for p in pkgs:
58-
pkg = p.render_as_root(frozen=False)
59-
print(f"* {pkg}", file=sys.stderr) # noqa: T201
60-
for req in conflicts[p]:
61-
req_str = req.render_as_branch(frozen=False)
62-
print(f" - {req_str}", file=sys.stderr) # noqa: T201
51+
# Enforce alphabetical order when listing conflicts
52+
pkgs = sorted(conflicts.keys())
53+
for p in pkgs:
54+
pkg = p.render_as_root(frozen=False)
55+
print(f"* {pkg}", file=sys.stderr) # noqa: T201
56+
for req in conflicts[p]:
57+
req_str = req.render_as_branch(frozen=False)
58+
print(f" - {req_str}", file=sys.stderr) # noqa: T201
6359

6460

6561
def cyclic_deps(tree: PackageDAG) -> list[list[Package]]:
@@ -104,20 +100,18 @@ def dfs(root: DistPackage, current: Package, visited: set[str], cdeps: list[Pack
104100

105101

106102
def render_cycles_text(cycles: list[list[Package]]) -> None:
107-
if cycles:
108-
print("Warning!! Cyclic dependencies found:", file=sys.stderr) # noqa: T201
109-
# List in alphabetical order the dependency that caused the cycle (i.e. the second-to-last Package element)
110-
cycles = sorted(cycles, key=lambda c: c[len(c) - 2].key)
111-
for cycle in cycles:
112-
print("*", end=" ", file=sys.stderr) # noqa: T201
113-
114-
size = len(cycle) - 1
115-
for idx, pkg in enumerate(cycle):
116-
if idx == size:
117-
print(f"{pkg.project_name}", end="", file=sys.stderr) # noqa: T201
118-
else:
119-
print(f"{pkg.project_name} =>", end=" ", file=sys.stderr) # noqa: T201
120-
print(file=sys.stderr) # noqa: T201
103+
# List in alphabetical order the dependency that caused the cycle (i.e. the second-to-last Package element)
104+
cycles = sorted(cycles, key=lambda c: c[len(c) - 2].key)
105+
for cycle in cycles:
106+
print("*", end=" ", file=sys.stderr) # noqa: T201
107+
108+
size = len(cycle) - 1
109+
for idx, pkg in enumerate(cycle):
110+
if idx == size:
111+
print(f"{pkg.project_name}", end="", file=sys.stderr) # noqa: T201
112+
else:
113+
print(f"{pkg.project_name} =>", end=" ", file=sys.stderr) # noqa: T201
114+
print(file=sys.stderr) # noqa: T201
121115

122116

123117
__all__ = [

0 commit comments

Comments
 (0)