Skip to content

fix: read files included with -r in the requirements.txt parser - #551

Merged
scastlara merged 2 commits into
elementsinteractive:mainfrom
memx13:fix/requirements-txt-r-includes
Oct 10, 2026
Merged

scastlara merged 2 commits into
elementsinteractive:mainfrom
memx13:fix/requirements-txt-r-includes

Conversation

@memx13

@memx13 memx13 commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

What

requirements.txt files that pull in other files with -r other.txt had the included packages silently dropped.

RequirementsTxtParser._parse_internal resolved the referenced path correctly but then always reopened self.file_handler (the entry file) instead of the referenced file. The -r target was added to seen_files and skipped on the re-read, so its packages never made it into the result.

Why it matters

twyn checks dependency names against typosquats. Any dependency declared in a file reached through -r was never parsed, so it was never checked — a silent gap in coverage for a very common requirements layout (e.g. requirements.txt with -r requirements-dev.txt).

Reproduction (before this change)

# requirements.txt
flask==2.0
-r extra.txt

# extra.txt
requests==2.31
colorama>=0.4

RequirementsTxtParser("requirements.txt").parse() returned {"flask"} instead of {"flask", "requests", "colorama"}.

Change

  • Read each file from its own path. The entry file keeps using the parser's handler (so its existence/emptiness errors are unchanged); each -r include is read from its resolved path.
  • A missing or empty -r target is logged and skipped instead of aborting the parse.
  • Extracted the include handling into a small _parse_include helper.

Tests

  • test_parse_requirements_txt_file_with_includes — one- and two-level -r includes are all collected.
  • test_parse_requirements_txt_file_skips_missing_include — a missing -r target is skipped, the rest is still parsed.

pytest tests is green. ruff format --check, ruff check and mypy pass on the changed files.

_parse_internal always reopened the parser's own entry file via self.file_handler, even when it recursed into a file referenced with -r. The referenced file was never read: its -r line was marked as seen and skipped, so its packages were dropped. For a typosquatting scanner this silently hides every dependency pulled in through -r.

Read each file from its own path instead. The entry file keeps using the parser's handler (so its existence/emptiness errors are unchanged); each -r include is parsed from its resolved path, and a missing or empty include is logged and skipped.
One- and two-level -r includes are all collected, and a missing -r target is skipped without aborting the parse.
@github-actions github-actions Bot added the fix label Oct 10, 2026
@scastlara

Copy link
Copy Markdown
Collaborator

/lgtm review

@github-actions github-actions Bot left a comment

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.

🦉 lgtm Review

Score: Nitpicks 🤓

🔍 Summary

This is a great PR that fixes a critical bug where dependencies from included requirements.txt files were silently ignored. The solution is well-structured, extracting the logic for handling includes into a separate method and adding robust error handling for missing files. The added tests are comprehensive and cover the new functionality well.

I've found one minor logical issue in how the file handler is selected, which I've detailed in a line comment. Addressing this will make the implementation more correct and efficient.

Overall, excellent work! With that one change, this PR will be in perfect shape.

More information
  • Id: c73cfb96c23542a28640e64624714d20
  • Model: gemini-2.5-pro
  • Created at: 2026-10-10T09:54:15.368200+00:00
Usage summary
  • Request count: 2
  • Request tokens: 16,093
  • Response tokens: 10,714
  • Total tokens: 26,807
Configuration
  • model: gemini-2.5-pro

  • model_url: None

  • technologies: ('Python',)

  • categories: ('Correctness', 'Quality', 'Testing', 'Security')

  • exclude: ('*.md', 'uv.lock', 'eval/classified_articles.py')

  • additional_context: ()

  • publish: True

  • output_format: pretty

  • silent: False

  • ai_retries: None

  • ai_input_tokens_limit: 500000

  • issues_url: None

  • issues_regex: (?:refs?|closes?|resolves?)[:\s]*((?:#\d+)|(?:#?[A-Z]+-\d+))|(?:fix|feat|docs|style|refactor|perf|test|build|ci)\((?:#(\d+)|#?([A-Z]+-\d+))\)!?:

  • issues_platform: None

  • compare: HEAD

See the 📚 lgtm-ai repository for more information about lgtm.

# 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.

@scastlara
scastlara merged commit d97fe1c into elementsinteractive:main Oct 10, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants