From 4359e34432d5c27e733f4d3685b6c34be3ef72a8 Mon Sep 17 00:00:00 2001 From: Kuntal Majumder Date: Fri, 26 Jul 2024 21:46:21 +0200 Subject: [PATCH] Detect recursively referencing requirements files Fixes #12653 --- news/12653.feature.rst | 2 ++ src/pip/_internal/req/req_file.py | 25 +++++++++++++++++++++---- tests/unit/test_req_file.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 news/12653.feature.rst diff --git a/news/12653.feature.rst b/news/12653.feature.rst new file mode 100644 index 00000000000..83e1a46e6a8 --- /dev/null +++ b/news/12653.feature.rst @@ -0,0 +1,2 @@ +Detect recursively referencing requirements files and help users identify +the source. diff --git a/src/pip/_internal/req/req_file.py b/src/pip/_internal/req/req_file.py index 53ad8674cd8..83cacc5b836 100644 --- a/src/pip/_internal/req/req_file.py +++ b/src/pip/_internal/req/req_file.py @@ -324,11 +324,14 @@ def __init__( ) -> None: self._session = session self._line_parser = line_parser + self._parsed_files: dict[str, Optional[str]] = {} def parse( self, filename: str, constraint: bool ) -> Generator[ParsedLine, None, None]: """Parse a given file, yielding parsed lines.""" + filename = os.path.abspath(filename) + self._parsed_files[filename] = None # The primary requirements file passed yield from self._parse_and_recurse(filename, constraint) def _parse_and_recurse( @@ -353,11 +356,25 @@ def _parse_and_recurse( # original file and nested file are paths elif not SCHEME_RE.search(req_path): # do a join so relative paths work - req_path = os.path.join( - os.path.dirname(filename), - req_path, + # and then abspath so that we can identify recursive references + req_path = os.path.abspath( + os.path.join( + os.path.dirname(filename), + req_path, + ) ) - + if req_path in self._parsed_files.keys(): + initial_file = self._parsed_files[req_path] + tail = ( + f"and again in {initial_file}" + if initial_file is not None + else "" + ) + raise RecursionError( + f"{req_path} recursively references itself in {filename} {tail}" + ) + # Keeping a track where was each file first included in + self._parsed_files[req_path] = filename yield from self._parse_and_recurse(req_path, nested_constraint) else: yield line diff --git a/tests/unit/test_req_file.py b/tests/unit/test_req_file.py index f4f98b1901c..a4babe08600 100644 --- a/tests/unit/test_req_file.py +++ b/tests/unit/test_req_file.py @@ -345,6 +345,34 @@ def test_nested_constraints_file( assert reqs[0].name == req_name assert reqs[0].constraint + def test_recursive_requirements_file( + self, tmpdir: Path, session: PipSession + ) -> None: + req_files: list[Path] = [] + req_file_count = 4 + for i in range(req_file_count): + req_file = tmpdir / f"{i}.txt" + req_file.write_text(f"-r {(i+1) % req_file_count}.txt") + req_files.append(req_file) + + # When the passed requirements file recursively references itself + with pytest.raises( + RecursionError, + match=f"{req_files[0]} recursively references itself" + f" in {req_files[req_file_count - 1]}", + ): + list(parse_requirements(filename=str(req_files[0]), session=session)) + + # When one of other the requirements file recursively references itself + req_files[req_file_count - 1].write_text(f"-r {req_files[req_file_count - 2]}") + with pytest.raises( + RecursionError, + match=f"{req_files[req_file_count - 2]} recursively references itself " + f"in {req_files[req_file_count - 1]} and again in" + f" {req_files[req_file_count - 3]}", + ): + list(parse_requirements(filename=str(req_files[0]), session=session)) + def test_options_on_a_requirement_line(self, line_processor: LineProcessor) -> None: line = ( 'SomeProject --global-option="yo3" --global-option "yo4" '