Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 27 additions & 8 deletions src/twyn/dependency_parser/parsers/requirements_txt_parser.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
"""Parser for requirements.txt dependencies."""

import logging
import re
from pathlib import Path

from typing_extensions import override

from twyn.dependency_parser.parsers.abstract_parser import AbstractParser
from twyn.dependency_parser.parsers.constants import REQUIREMENTS_TXT
from twyn.file_handler.exceptions import EmptyFileError, PathIsNotFileError, PathNotFoundError
from twyn.file_handler.file_handler import FileHandler

logger = logging.getLogger("twyn")


class RequirementsTxtParser(AbstractParser):
Expand Down Expand Up @@ -37,23 +42,25 @@ def parse(self) -> set[str]:
return self._parse_internal(self.file_path, seen_files=set())

def _parse_internal(self, source: str | Path, seen_files: set[Path]) -> set[str]:
"""Parse requirements file and handle includes recursively."""
"""Parse a requirements file and handle ``-r`` includes recursively."""
packages: set[str] = set()
base_dir = Path(source).parent if isinstance(source, Path) else Path(".")
source = Path(source)
base_dir = source.parent

# The entry file reuses the parser's own handler (keeping its
# existence/emptiness errors); every ``-r`` include is read from its
# own path instead of re-reading the entry file.
handler = self.file_handler if source == self.file_path else FileHandler(str(source))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🦉 🎯 Correctness

Severity: MEDIUM 🟡

handler = self.file_handler if source == self.file_path else FileHandler(str(source))

The comparison source == self.file_path will always evaluate to False because it compares a pathlib.Path object with a string. This leads to a new FileHandler instance being created unnecessarily for the main file, which contradicts the comment's intent to reuse self.file_handler.

To fix this, you should compare two Path objects. For robustness, it's best to compare their resolved absolute paths.

Suggested change
handler = self.file_handler if source == self.file_path else FileHandler(str(source))
handler = (
self.file_handler
if source.resolve() == Path(self.file_path).resolve()
else FileHandler(str(source))
)
More information about this comment
  • File: src/twyn/dependency_parser/parsers/requirements_txt_parser.py
  • Line: 53
  • Relative line: 32
  • With suggestion: Yes
  • Suggestion ready for replacement: Yes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review!

On the line comment about source == self.file_path: both sides are Path objects here. source is converted with source = Path(source) on the line just above, and self.file_path is already a Path (set in AbstractParser.__init__). So for the entry file the comparison evaluates to True and self.file_handler is reused as intended; only -r includes fall through to a fresh FileHandler. I double-checked by running it — for the entry file handler is self.file_handler is True, and the parse result is correct.

That said, I'm happy to switch to source.resolve() == Path(self.file_path).resolve() if you'd prefer it for extra robustness against symlinked or non-normalized paths. Just let me know.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey! No worries, AI did an oopsie as is tradition. The PR looks good! Thanks a lot for the contribution!

I guess this bug has been here since a long time ago; we never catched it cause we don't use requirements files.


with self.file_handler.open("r") as fp:
with handler.open("r") as fp:
for raw_line in fp:
line = raw_line.strip()

if not self._is_valid_line(line):
continue

if line.startswith("-r "):
ref = line[3:].strip()
ref_path = (base_dir / ref).resolve()
if ref_path not in seen_files:
seen_files.add(ref_path)
packages.update(self._parse_internal(ref_path, seen_files))
packages.update(self._parse_include(line[3:].strip(), base_dir, seen_files))
continue

if line.startswith("-e "):
Expand All @@ -73,6 +80,18 @@ def _parse_internal(self, source: str | Path, seen_files: set[Path]) -> set[str]

return packages

def _parse_include(self, ref: str, base_dir: Path, seen_files: set[Path]) -> set[str]:
"""Parse a file referenced with ``-r``, skipping it if already seen, missing, or empty."""
ref_path = (base_dir / ref).resolve()
if ref_path in seen_files:
return set()
seen_files.add(ref_path)
try:
return self._parse_internal(ref_path, seen_files)
except (PathNotFoundError, PathIsNotFileError, EmptyFileError):
logger.warning("Skipping referenced requirements file (missing or empty): %s", ref_path)
return set()

@staticmethod
def _is_valid_line(line: str) -> bool:
"""Check if line is valid for parsing."""
Expand Down
18 changes: 18 additions & 0 deletions tests/dependency_parser/test_dependency_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,24 @@ def test_parse_requirements_txt_file(self, requirements_txt_file: Path) -> None:
parser = RequirementsTxtParser(file_path=requirements_txt_file)
assert parser.parse() == {"South", "pycrypto", "Flask", "django", "requests", "urllib3"}

def test_parse_requirements_txt_file_with_includes(self, tmp_path: Path) -> None:
"""Packages from files pulled in with ``-r`` are included, recursively."""
(tmp_path / "deep.txt").write_text("rich\n")
(tmp_path / "extra.txt").write_text("requests==2.31\ncolorama>=0.4\n-r deep.txt\n")
main = tmp_path / "requirements.txt"
main.write_text("flask==2.0\n-r extra.txt\n")

parser = RequirementsTxtParser(file_path=str(main))
assert parser.parse() == {"flask", "requests", "colorama", "rich"}

def test_parse_requirements_txt_file_skips_missing_include(self, tmp_path: Path) -> None:
"""A missing ``-r`` target is skipped without aborting the rest of the parse."""
main = tmp_path / "requirements.txt"
main.write_text("flask==2.0\n-r does-not-exist.txt\n")

parser = RequirementsTxtParser(file_path=str(main))
assert parser.parse() == {"flask"}


class TestLockParser:
def test_parse_poetry_lock_file_lt_1_5(self, poetry_lock_file_lt_1_5: Path) -> None:
Expand Down
Loading