Skip to content

Commit 111eed1

Browse files
authored
Merge pull request #12877 from hellozee/recursive-requirements
2 parents c33c188 + 26c6a45 commit 111eed1

File tree

3 files changed

+82
-4
lines changed

3 files changed

+82
-4
lines changed

news/12653.feature.rst

Lines changed: 2 additions & 0 deletions
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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -324,11 +324,15 @@ 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+
self._parsed_files[os.path.abspath(filename)] = (
334+
None # The primary requirements file passed
335+
)
332336
yield from self._parse_and_recurse(filename, constraint)
333337

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

tests/unit/test_req_file.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import collections
22
import logging
33
import os
4+
import re
45
import textwrap
56
from optparse import Values
67
from pathlib import Path
@@ -345,6 +346,63 @@ def test_nested_constraints_file(
345346
assert reqs[0].name == req_name
346347
assert reqs[0].constraint
347348

349+
def test_recursive_requirements_file(
350+
self, tmpdir: Path, session: PipSession
351+
) -> None:
352+
req_files: list[Path] = []
353+
req_file_count = 4
354+
for i in range(req_file_count):
355+
req_file = tmpdir / f"{i}.txt"
356+
req_file.write_text(f"-r {(i+1) % req_file_count}.txt")
357+
req_files.append(req_file)
358+
359+
# When the passed requirements file recursively references itself
360+
with pytest.raises(
361+
RequirementsFileParseError,
362+
match=(
363+
f"{re.escape(str(req_files[0]))} recursively references itself"
364+
f" in {re.escape(str(req_files[req_file_count - 1]))}"
365+
),
366+
):
367+
list(parse_requirements(filename=str(req_files[0]), session=session))
368+
369+
# When one of other the requirements file recursively references itself
370+
req_files[req_file_count - 1].write_text(
371+
# Just name since they are in the same folder
372+
f"-r {req_files[req_file_count - 2].name}"
373+
)
374+
with pytest.raises(
375+
RequirementsFileParseError,
376+
match=(
377+
f"{re.escape(str(req_files[req_file_count - 2]))} recursively"
378+
" references itself in"
379+
f" {re.escape(str(req_files[req_file_count - 1]))} and again in"
380+
f" {re.escape(str(req_files[req_file_count - 3]))}"
381+
),
382+
):
383+
list(parse_requirements(filename=str(req_files[0]), session=session))
384+
385+
def test_recursive_relative_requirements_file(
386+
self, tmpdir: Path, session: PipSession
387+
) -> None:
388+
root_req_file = tmpdir / "root.txt"
389+
(tmpdir / "nest" / "nest").mkdir(parents=True)
390+
level_1_req_file = tmpdir / "nest" / "level_1.txt"
391+
level_2_req_file = tmpdir / "nest" / "nest" / "level_2.txt"
392+
393+
root_req_file.write_text("-r nest/level_1.txt")
394+
level_1_req_file.write_text("-r nest/level_2.txt")
395+
level_2_req_file.write_text("-r ../../root.txt")
396+
397+
with pytest.raises(
398+
RequirementsFileParseError,
399+
match=(
400+
f"{re.escape(str(root_req_file))} recursively references itself in"
401+
f" {re.escape(str(level_2_req_file))}"
402+
),
403+
):
404+
list(parse_requirements(filename=str(root_req_file), session=session))
405+
348406
def test_options_on_a_requirement_line(self, line_processor: LineProcessor) -> None:
349407
line = (
350408
'SomeProject --global-option="yo3" --global-option "yo4" '

0 commit comments

Comments
 (0)