Skip to content

Commit b39df80

Browse files
authored
PythonDeps: move cli_only handling logic to _parse_requirements (#2547)
* [test case] tox4: fails to process requirement files with --hash Add a test case for issue #2373 * Specifying `--hash` in the deps list doesn't work (pip would reject this anyway). * Specifying `--hash` in a requirements.txt file named in the deps list should work, and recursive parsing should correctly extract the hash. * PythonDeps: move cli_only handling logic to _parse_requirements Remove `cli_only` parameter from `build_parser`. Remove special case handling for `--hash` option (only valid in requirements.txt files, not `pip install`). Validate options in PythonDeps._parse_requirements: * Only check "cli_only" logic for ParsedRequirement objects that directly come from the PythonDeps. * Allow included requirements.txt lines to correctly parse `--hash` (Fix #2373). * Provides a more contextual error message to end users when `--hash` is used in the deps list. * changelog for issue #2373
1 parent 023a4ed commit b39df80

File tree

5 files changed

+54
-15
lines changed

5 files changed

+54
-15
lines changed

docs/changelog/2373.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Allow ``--hash`` to be specified in requirements.txt files. - by :user:`masenf`.

src/tox/tox_env/python/pip/req/args.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ def exit(self, status: int = 0, message: str | None = None) -> NoReturn: # noqa
1818
raise ValueError(msg)
1919

2020

21-
def build_parser(cli_only: bool) -> ArgumentParser:
21+
def build_parser() -> ArgumentParser:
2222
parser = _OurArgumentParser(add_help=False, prog="", allow_abbrev=False)
2323
_global_options(parser)
24-
_req_options(parser, cli_only)
24+
_req_options(parser)
2525
return parser
2626

2727

@@ -47,11 +47,10 @@ def _global_options(parser: ArgumentParser) -> None:
4747
)
4848

4949

50-
def _req_options(parser: ArgumentParser, cli_only: bool) -> None:
50+
def _req_options(parser: ArgumentParser) -> None:
5151
parser.add_argument("--install-option", action=AddSortedUniqueAction)
5252
parser.add_argument("--global-option", action=AddSortedUniqueAction)
53-
if not cli_only:
54-
parser.add_argument("--hash", action=AddSortedUniqueAction, type=_validate_hash)
53+
parser.add_argument("--hash", action=AddSortedUniqueAction, type=_validate_hash)
5554

5655

5756
_HASH = re.compile(r"sha(256:[a-f0-9]{64}|384:[a-f0-9]{96}|512:[a-f0-9]{128})")

src/tox/tox_env/python/pip/req/file.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ def requirements(self) -> list[ParsedRequirement]:
156156
@property
157157
def _parser(self) -> ArgumentParser:
158158
if self._parser_private is None:
159-
self._parser_private = build_parser(False)
159+
self._parser_private = build_parser()
160160
return self._parser_private
161161

162162
def _ensure_requirements_parsed(self) -> None:

src/tox/tox_env/python/pip/req_file.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
from __future__ import annotations
22

33
import re
4-
from argparse import ArgumentParser
4+
from argparse import Namespace
55
from pathlib import Path
66

7-
from .req.args import build_parser
8-
from .req.file import ReqFileLines, RequirementsFile
7+
from .req.file import ParsedRequirement, ReqFileLines, RequirementsFile
98

109

1110
class PythonDeps(RequirementsFile):
11+
# these options are valid in requirements.txt, but not via pip cli and
12+
# thus cannot be used in the testenv `deps` list
13+
_illegal_options = ["hash"]
14+
1215
def __init__(self, raw: str, root: Path):
1316
super().__init__(root / "tox.ini", constraint=False)
1417
self._raw = self._normalize_raw(raw)
@@ -28,12 +31,6 @@ def _pre_process(self, content: str) -> ReqFileLines:
2831
line = f"{line[0:2]} {line[2:]}"
2932
yield at, line
3033

31-
@property
32-
def _parser(self) -> ArgumentParser:
33-
if self._parser_private is None:
34-
self._parser_private = build_parser(cli_only=True) # e.g. no --hash for cli only
35-
return self._parser_private
36-
3734
def lines(self) -> list[str]:
3835
return self._raw.splitlines()
3936

@@ -68,6 +65,20 @@ def _normalize_raw(raw: str) -> str:
6865
raw = f"{adjusted}\n" if raw.endswith("\\\n") else adjusted # preserve trailing newline if input has it
6966
return raw
7067

68+
def _parse_requirements(self, opt: Namespace, recurse: bool) -> list[ParsedRequirement]:
69+
# check for any invalid options in the deps list
70+
# (requirements recursively included from other files are not checked)
71+
requirements = super()._parse_requirements(opt, recurse)
72+
for r in requirements:
73+
if r.from_file != str(self.path):
74+
continue
75+
for illegal_option in self._illegal_options:
76+
if r.options.get(illegal_option):
77+
raise ValueError(
78+
f"Cannot use --{illegal_option} in deps list, it must be in requirements file. ({r})",
79+
)
80+
return requirements
81+
7182
def unroll(self) -> tuple[list[str], list[str]]:
7283
if self._unroll is None:
7384
opts_dict = vars(self.options)

tests/tox_env/python/pip/test_req_file.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,31 @@ def test_legacy_requirement_file(tmp_path: Path, legacy_flag: str) -> None:
1414
assert python_deps.as_root_args == [legacy_flag, "a.txt"]
1515
assert vars(python_deps.options) == {}
1616
assert [str(i) for i in python_deps.requirements] == ["b" if legacy_flag == "-r" else "-c b"]
17+
18+
19+
def test_deps_with_hash(tmp_path: Path) -> None:
20+
"""deps with --hash should raise an exception."""
21+
python_deps = PythonDeps(
22+
raw="foo==1 --hash sha256:97a702083b0d906517b79672d8501eee470d60ae55df0fa9d4cfba56c7f65a82",
23+
root=tmp_path,
24+
)
25+
with pytest.raises(ValueError, match="Cannot use --hash in deps list"):
26+
_ = python_deps.requirements
27+
28+
29+
def test_deps_with_requirements_with_hash(tmp_path: Path) -> None:
30+
"""deps can point to a requirements file that has --hash."""
31+
exp_hash = "sha256:97a702083b0d906517b79672d8501eee470d60ae55df0fa9d4cfba56c7f65a82"
32+
requirements = tmp_path / "requirements.txt"
33+
requirements.write_text(
34+
f"foo==1 --hash {exp_hash}",
35+
)
36+
python_deps = PythonDeps(
37+
raw="-r requirements.txt",
38+
root=tmp_path,
39+
)
40+
assert len(python_deps.requirements) == 1
41+
parsed_req = python_deps.requirements[0]
42+
assert str(parsed_req.requirement) == "foo==1"
43+
assert parsed_req.options == {"hash": [exp_hash]}
44+
assert parsed_req.from_file == str(requirements)

0 commit comments

Comments
 (0)