Skip to content

Commit 0782a3e

Browse files
committed
Detect recursively referencing requirements files
Fixes pypa#12653
1 parent 858a515 commit 0782a3e

File tree

3 files changed

+51
-4
lines changed

3 files changed

+51
-4
lines changed

news/12653.feature.rst

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Detect recursively referencing requirements files and help users identify
2+
the source.

src/pip/_internal/req/req_file.py

+21-4
Original file line numberDiff line numberDiff line change
@@ -324,11 +324,14 @@ def __init__(
324324
) -> None:
325325
self._session = session
326326
self._line_parser = line_parser
327+
self._parsed_files: dict[str, Optional[str]] = {}
327328

328329
def parse(
329330
self, filename: str, constraint: bool
330331
) -> Generator[ParsedLine, None, None]:
331332
"""Parse a given file, yielding parsed lines."""
333+
filename = os.path.abspath(filename)
334+
self._parsed_files[filename] = None # The primary requirements file passed
332335
yield from self._parse_and_recurse(filename, constraint)
333336

334337
def _parse_and_recurse(
@@ -353,11 +356,25 @@ def _parse_and_recurse(
353356
# original file and nested file are paths
354357
elif not SCHEME_RE.search(req_path):
355358
# do a join so relative paths work
356-
req_path = os.path.join(
357-
os.path.dirname(filename),
358-
req_path,
359+
# and then abspath so that we can identify recursive references
360+
req_path = os.path.abspath(
361+
os.path.join(
362+
os.path.dirname(filename),
363+
req_path,
364+
)
359365
)
360-
366+
if req_path in self._parsed_files.keys():
367+
initial_file = self._parsed_files[req_path]
368+
tail = (
369+
f"and again in {initial_file}"
370+
if initial_file is not None
371+
else ""
372+
)
373+
raise RecursionError(
374+
f"{req_path} recursively references itself in {filename} {tail}"
375+
)
376+
# Keeping a track where was each file first included in
377+
self._parsed_files[req_path] = filename
361378
yield from self._parse_and_recurse(req_path, nested_constraint)
362379
else:
363380
yield line

tests/unit/test_req_file.py

+28
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,34 @@ def test_nested_constraints_file(
345345
assert reqs[0].name == req_name
346346
assert reqs[0].constraint
347347

348+
def test_recursive_requirements_file(
349+
self, tmpdir: Path, session: PipSession
350+
) -> None:
351+
req_files: list[Path] = []
352+
req_file_count = 4
353+
for i in range(req_file_count):
354+
req_file = tmpdir / f"{i}.txt"
355+
req_file.write_text(f"-r {(i+1) % req_file_count}.txt")
356+
req_files.append(req_file)
357+
358+
# When the passed requirements file recursively references itself
359+
with pytest.raises(
360+
RecursionError,
361+
match=f"{req_files[0]} recursively references itself"
362+
f" in {req_files[req_file_count - 1]}",
363+
):
364+
list(parse_requirements(filename=str(req_files[0]), session=session))
365+
366+
# When one of other the requirements file recursively references itself
367+
req_files[req_file_count - 1].write_text(f"-r {req_files[req_file_count - 2]}")
368+
with pytest.raises(
369+
RecursionError,
370+
match=f"{req_files[req_file_count - 2]} recursively references itself "
371+
f"in {req_files[req_file_count - 1]} and again in"
372+
f" {req_files[req_file_count - 3]}",
373+
):
374+
list(parse_requirements(filename=str(req_files[0]), session=session))
375+
348376
def test_options_on_a_requirement_line(self, line_processor: LineProcessor) -> None:
349377
line = (
350378
'SomeProject --global-option="yo3" --global-option "yo4" '

0 commit comments

Comments
 (0)