Skip to content

Commit 7a6e0ff

Browse files
Handle invalid requirement strings when using importlib.metadata (#345)
Resolves #344. This change also: - Makes `Distribution.requires()` into a generator for performance (i.e. avoid building `list[Requirement]` and then iterating `list[Requirement` and instead do both at the same time) and so that we can handle invalid requirement exceptions in `PackageDAG.from_pkgs()` for each individual requirement string - Modified conftest to avoid adding a extra comma at the end and removed some older code --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent ff31dc4 commit 7a6e0ff

File tree

5 files changed

+77
-22
lines changed

5 files changed

+77
-22
lines changed

src/pipdeptree/_models/dag.py

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

3+
import sys
34
from collections import defaultdict, deque
45
from fnmatch import fnmatch
56
from itertools import chain
@@ -11,7 +12,20 @@
1112
from importlib.metadata import Distribution
1213

1314

14-
from .package import DistPackage, ReqPackage
15+
from .package import DistPackage, InvalidRequirementError, ReqPackage
16+
17+
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
21+
22+
print("Warning!!! Invalid requirement strings found for the following distributions:", file=sys.stderr) # noqa: T201
23+
for dist_name, invalid_reqs in dist_name_to_invalid_reqs_dict.items():
24+
print(dist_name, file=sys.stderr) # noqa: T201
25+
26+
for invalid_req in invalid_reqs:
27+
print(f' Skipping "{invalid_req}"', file=sys.stderr) # noqa: T201
28+
print("-" * 72, file=sys.stderr) # noqa: T201
1529

1630

1731
class PackageDAG(Mapping[DistPackage, List[ReqPackage]]):
@@ -42,18 +56,30 @@ def from_pkgs(cls, pkgs: list[Distribution]) -> PackageDAG:
4256
dist_pkgs = [DistPackage(p) for p in pkgs]
4357
idx = {p.key: p for p in dist_pkgs}
4458
m: dict[DistPackage, list[ReqPackage]] = {}
59+
dist_name_to_invalid_reqs_dict: dict[str, list[str]] = {}
4560
for p in dist_pkgs:
4661
reqs = []
47-
for r in p.requires():
48-
d = idx.get(canonicalize_name(r.name))
49-
# Distribution.requires only return the name of requirements in metadata file, which may not be
50-
# the same with the capitalized one in pip. We should retain the casing of required package name.
51-
# see https://github.com/tox-dev/pipdeptree/issues/242
52-
r.name = d.project_name if d is not None else r.name
53-
pkg = ReqPackage(r, d)
62+
requires_iterator = p.requires()
63+
while True:
64+
try:
65+
req = next(requires_iterator)
66+
except InvalidRequirementError as err:
67+
# 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))
69+
continue
70+
except StopIteration:
71+
break
72+
d = idx.get(canonicalize_name(req.name))
73+
# Distribution.requires only returns the name of requirements in the metadata file, which may not be the
74+
# same as the name in PyPI. We should try to retain the original package names for requirements.
75+
# See https://github.com/tox-dev/pipdeptree/issues/242
76+
req.name = d.project_name if d is not None else req.name
77+
pkg = ReqPackage(req, d)
5478
reqs.append(pkg)
5579
m[p] = reqs
5680

81+
render_invalid_reqs_text_if_necessary(dist_name_to_invalid_reqs_dict)
82+
5783
return cls(m)
5884

5985
def __init__(self, m: dict[DistPackage, list[ReqPackage]]) -> None:

src/pipdeptree/_models/package.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
from importlib import import_module
55
from importlib.metadata import Distribution, PackageNotFoundError, metadata, version
66
from inspect import ismodule
7-
from typing import TYPE_CHECKING
7+
from typing import TYPE_CHECKING, Iterator
88

9-
from packaging.requirements import Requirement
9+
from packaging.requirements import InvalidRequirement, Requirement
1010
from packaging.utils import canonicalize_name
1111

1212
if TYPE_CHECKING:
@@ -15,6 +15,14 @@
1515
from pipdeptree._adapter import PipBaseDistributionAdapter
1616

1717

18+
class InvalidRequirementError(ValueError):
19+
"""
20+
An invalid requirement string was found.
21+
22+
When raising an exception, this should provide just the problem requirement string.
23+
"""
24+
25+
1826
class Package(ABC):
1927
"""Abstract class for wrappers around objects that pip returns."""
2028

@@ -96,17 +104,23 @@ def __init__(self, obj: Distribution, req: ReqPackage | None = None) -> None:
96104
self._obj = obj
97105
self.req = req
98106

99-
def requires(self) -> list[Requirement]:
100-
req_list = []
107+
def requires(self) -> Iterator[Requirement]:
108+
"""
109+
Return an iterator of the distribution's required dependencies.
110+
111+
:raises InvalidRequirementError: If the metadata contains invalid requirement strings.
112+
"""
101113
for r in self._obj.requires or []:
102-
req = Requirement(r)
114+
try:
115+
req = Requirement(r)
116+
except InvalidRequirement:
117+
raise InvalidRequirementError(r) from None
103118
if not req.marker or req.marker.evaluate():
104119
# Make sure that we're either dealing with a dependency that has no environment markers or does but
105120
# are evaluated True against the existing environment (if it's False, it means they cannot be
106121
# installed). "extra" markers are always evaluated False here which is what we want when retrieving
107122
# only required dependencies.
108-
req_list.append(req)
109-
return req_list
123+
yield req
110124

111125
@property
112126
def version(self) -> str:

tests/_models/test_dag.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,19 @@ def test_package_dag_from_pkgs_uses_pep503normalize(mock_pkgs: Callable[[MockGra
134134
c = package_dag.get_children(parent_key)
135135
assert c[0].dist
136136
assert c[0].key == "flufl-lock"
137+
138+
139+
def test_package_from_pkgs_given_invalid_requirements(
140+
mock_pkgs: Callable[[MockGraph], Iterator[Mock]], capfd: pytest.CaptureFixture[str]
141+
) -> None:
142+
graph: dict[tuple[str, str], list[tuple[str, list[tuple[str, str]]]]] = {
143+
("a-package", "1.2.3"): [("BAD**requirement", [(">=", "2.0.0")])],
144+
}
145+
package_dag = PackageDAG.from_pkgs(list(mock_pkgs(graph)))
146+
assert len(package_dag) == 1
147+
out, err = capfd.readouterr()
148+
assert not out
149+
assert err == (
150+
"Warning!!! Invalid requirement strings found for the following distributions:\na-package\n "
151+
'Skipping "BAD**requirement>=2.0.0"\n------------------------------------------------------------------------\n'
152+
)

tests/_models/test_package.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def test_dist_package_requires() -> None:
4545
requires=["bar", "baz >=2.7.2"],
4646
)
4747
dp = DistPackage(foo)
48-
reqs = dp.requires()
48+
reqs = list(dp.requires())
4949
assert len(reqs) == 2
5050

5151

@@ -55,7 +55,7 @@ def test_dist_package_requires_with_environment_markers_that_eval_to_false() ->
5555
requires=['foo ; sys_platform == "NoexistOS"', "bar >=2.7.2 ; extra == 'testing'"],
5656
)
5757
dp = DistPackage(foo)
58-
reqs = dp.requires()
58+
reqs = list(dp.requires())
5959
assert len(reqs) == 0
6060

6161

tests/conftest.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from random import shuffle
44
from typing import TYPE_CHECKING, Callable, Iterator
5-
from unittest.mock import MagicMock, Mock
5+
from unittest.mock import Mock
66

77
import pytest
88

@@ -18,16 +18,15 @@ def func(simple_graph: MockGraph) -> Iterator[Mock]:
1818
for node, children in simple_graph.items():
1919
nk, nv = node
2020
m = Mock(metadata={"Name": nk}, version=nv)
21-
as_req = MagicMock(specifier=[("==", nv)])
22-
as_req.name = nk
23-
m.as_requirement = Mock(return_value=as_req)
2421
reqs = []
2522
for ck, cv in children:
2623
r = ck
2724
for item in cv:
2825
if item:
2926
rs, rv = item
30-
r = r + rs + rv + ","
27+
r = r + rs + rv
28+
if item != cv[-1]:
29+
r += ","
3130
reqs.append(r)
3231
m.requires = reqs
3332
yield m

0 commit comments

Comments
 (0)