diff --git a/src/depcheck/parsers/pip.py b/src/depcheck/parsers/pip.py new file mode 100644 index 0000000..09fa303 --- /dev/null +++ b/src/depcheck/parsers/pip.py @@ -0,0 +1,124 @@ +"""Pip requirements.txt and pyproject.toml parser.""" + +import re +from pathlib import Path + +import toml + +from depcheck.models import Dependency, PackageManager +from depcheck.parsers import Parser +from depcheck.utils import parse_version_string + + +class PipParser(Parser): + """Parser for pip requirements.txt and pyproject.toml files.""" + + package_manager = PackageManager.PIP + + def supports_file(self, file_path: Path) -> bool: + return file_path.name in ("requirements.txt", "pyproject.toml") + + def get_file_patterns(self) -> list[str]: + return ["requirements.txt", "pyproject.toml"] + + def parse(self, file_path: Path) -> list[Dependency]: + if file_path.name == "requirements.txt": + return self._parse_requirements_txt(file_path) + elif file_path.name == "pyproject.toml": + return self._parse_pyproject_toml(file_path) + return [] + + def _parse_requirements_txt(self, file_path: Path) -> list[Dependency]: + dependencies: list[Dependency] = [] + + try: + content = file_path.read_text() + except OSError: + return dependencies + + for line in content.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + + match = self._parse_requirement_line(line) + if match: + name, version = match + dependencies.append( + Dependency( + name=name, + current_version=version, + package_manager=self.package_manager, + category="dependencies", + source_file=str(file_path), + ) + ) + + return dependencies + + def _parse_requirement_line(self, line: str) -> tuple[str, str] | None: + line = line.split("#")[0].strip() + line = line.split(";")[0].strip() + + patterns = [ + r"^([a-zA-Z0-9_-]+)==([a-zA-Z0-9._-]+)$", + r"^([a-zA-Z0-9_-]+)>=([a-zA-Z0-9._-]+)$", + r"^([a-zA-Z0-9_-]+)<=([a-zA-Z0-9._-]+)$", + r"^([a-zA-Z0-9_-]+)~=([a-zA-Z0-9._-]+)$", + r"^([a-zA-Z0-9_-]+)!=([a-zA-Z0-9._-]+)$", + r"^([a-zA-Z0-9_-]+)$", + ] + + for pattern in patterns: + match = re.match(pattern, line) + if match: + name = match.group(1) + lastindex = match.lastindex + version = match.group(2) if lastindex is not None and lastindex >= 2 else "latest" + version = parse_version_string(version) + return (name, version) if version else (name, "unknown") + + return None + + def _parse_pyproject_toml(self, file_path: Path) -> list[Dependency]: + dependencies: list[Dependency] = [] + + try: + content = file_path.read_text() + data = toml.loads(content) + except (toml.TomlDecodeError, OSError): + return dependencies + + project_section = data.get("project", {}) + for key in ["dependencies", "optional-dependencies"]: + section = project_section.get(key, []) + if isinstance(section, list): + for item in section: + if isinstance(item, str): + match = self._parse_requirement_line(item) + if match: + name, version = match + dependencies.append( + Dependency( + name=name, + current_version=version, + package_manager=self.package_manager, + category="dependencies", + source_file=str(file_path), + ) + ) + elif isinstance(item, dict): + for name, version in item.items(): + if isinstance(version, str): + parsed_version = parse_version_string(version) + dependencies.append( + Dependency( + name=name, + current_version=parsed_version or "unknown", + package_manager=self.package_manager, + category="dependencies", + source_file=str(file_path), + ) + ) + + return dependencies