Initial upload: Auto README Generator CLI v0.1.0
This commit is contained in:
223
src/auto_readme/parsers/python_parser.py
Normal file
223
src/auto_readme/parsers/python_parser.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"""Python dependency parser for requirements.txt and pyproject.toml."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
from . import BaseParser, Dependency
|
||||||
|
|
||||||
|
|
||||||
|
class PythonDependencyParser(BaseParser):
|
||||||
|
"""Parser for Python dependency files."""
|
||||||
|
|
||||||
|
SUPPORTED_FILES = ["requirements.txt", "pyproject.toml", "setup.py", "setup.cfg", "Pipfile"]
|
||||||
|
|
||||||
|
def can_parse(self, path: Path) -> bool:
|
||||||
|
"""Check if the file is a Python dependency file."""
|
||||||
|
return path.name.lower() in self.SUPPORTED_FILES
|
||||||
|
|
||||||
|
def parse(self, path: Path) -> list[Dependency]:
|
||||||
|
"""Parse dependencies from Python files."""
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
if path.name.lower() == "requirements.txt":
|
||||||
|
return self._parse_requirements_txt(path)
|
||||||
|
elif path.name.lower() in ["pyproject.toml", "setup.cfg"]:
|
||||||
|
return self._parse_toml(path)
|
||||||
|
elif path.name.lower() == "pipfile":
|
||||||
|
return self._parse_pipfile(path)
|
||||||
|
elif path.name.lower() in ["setup.py", "setup.cfg"]:
|
||||||
|
return self._parse_setup_file(path)
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _parse_requirements_txt(self, path: Path) -> list[Dependency]:
|
||||||
|
"""Parse requirements.txt file."""
|
||||||
|
dependencies = []
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
for line in content.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_dev = any(x in line for x in ["-e", "--dev", "[dev]"])
|
||||||
|
version = None
|
||||||
|
name = line
|
||||||
|
|
||||||
|
if ">=" in line:
|
||||||
|
parts = line.split(">=")
|
||||||
|
name = parts[0].strip()
|
||||||
|
version = ">=" + parts[1].strip()
|
||||||
|
elif "<=" in line:
|
||||||
|
parts = line.split("<=")
|
||||||
|
name = parts[0].strip()
|
||||||
|
version = "<=" + parts[1].strip()
|
||||||
|
elif ">" in line:
|
||||||
|
parts = line.split(">")
|
||||||
|
name = parts[0].strip()
|
||||||
|
version = ">" + parts[1].strip()
|
||||||
|
elif "<" in line:
|
||||||
|
parts = line.split("<")
|
||||||
|
name = parts[0].strip()
|
||||||
|
version = "<" + parts[1].strip()
|
||||||
|
elif "==" in line:
|
||||||
|
parts = line.split("==")
|
||||||
|
name = parts[0].strip()
|
||||||
|
version = parts[1].strip()
|
||||||
|
elif "~=" in line:
|
||||||
|
parts = line.split("~=")
|
||||||
|
name = parts[0].strip()
|
||||||
|
version = "~=" + parts[1].strip()
|
||||||
|
|
||||||
|
name = self._clean_package_name(name)
|
||||||
|
if name:
|
||||||
|
dependencies.append(
|
||||||
|
Dependency(
|
||||||
|
name=name,
|
||||||
|
version=version,
|
||||||
|
is_dev=is_dev,
|
||||||
|
source_file=path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
def _parse_toml(self, path: Path) -> list[Dependency]:
|
||||||
|
"""Parse pyproject.toml or setup.cfg file."""
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if path.name.endswith(".toml"):
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
else:
|
||||||
|
import configparser
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read(path)
|
||||||
|
data = {s: dict(config[s]) for s in config.sections()}
|
||||||
|
except Exception:
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
if "project" in data:
|
||||||
|
project_data = data["project"]
|
||||||
|
if "dependencies" in project_data:
|
||||||
|
deps = project_data["dependencies"]
|
||||||
|
if isinstance(deps, list):
|
||||||
|
for dep in deps:
|
||||||
|
parsed = self._parse_pep508_string(dep)
|
||||||
|
if parsed:
|
||||||
|
dependencies.append(parsed)
|
||||||
|
elif isinstance(deps, str):
|
||||||
|
for dep in deps.split("\n"):
|
||||||
|
parsed = self._parse_pep508_string(dep.strip())
|
||||||
|
if parsed:
|
||||||
|
dependencies.append(parsed)
|
||||||
|
|
||||||
|
if "optional-dependencies" in project_data:
|
||||||
|
for group, optional_deps in project_data["optional-dependencies"].items():
|
||||||
|
if isinstance(optional_deps, list):
|
||||||
|
for dep in optional_deps:
|
||||||
|
parsed = self._parse_pep508_string(dep)
|
||||||
|
if parsed:
|
||||||
|
parsed.is_dev = True
|
||||||
|
parsed.is_optional = True
|
||||||
|
dependencies.append(parsed)
|
||||||
|
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
def _parse_pep508_string(self, dep_string: str) -> Optional[Dependency]:
|
||||||
|
"""Parse a PEP 508 dependency specification string."""
|
||||||
|
dep_string = dep_string.strip()
|
||||||
|
if not dep_string or dep_string.startswith("#"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
is_dev = False
|
||||||
|
is_optional = False
|
||||||
|
|
||||||
|
if "[dev]" in dep_string:
|
||||||
|
is_dev = True
|
||||||
|
dep_string = dep_string.replace("[dev]", "")
|
||||||
|
elif "[tests]" in dep_string:
|
||||||
|
is_dev = True
|
||||||
|
dep_string = dep_string.replace("[tests]", "")
|
||||||
|
|
||||||
|
match = re.match(r"([a-zA-Z0-9_-]+)(.*)", dep_string)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = match.group(1).strip()
|
||||||
|
version_part = match.group(2).strip()
|
||||||
|
|
||||||
|
version = self._normalize_version(version_part)
|
||||||
|
|
||||||
|
return Dependency(
|
||||||
|
name=name,
|
||||||
|
version=version,
|
||||||
|
is_dev=is_dev,
|
||||||
|
is_optional=is_optional,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_pipfile(self, path: Path) -> list[Dependency]:
|
||||||
|
"""Parse Pipfile."""
|
||||||
|
dependencies = []
|
||||||
|
try:
|
||||||
|
import tomli
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = tomli.load(f)
|
||||||
|
except Exception:
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
for section in ["packages", "dev-packages"]:
|
||||||
|
if section in data:
|
||||||
|
for name, value in data[section].items():
|
||||||
|
if isinstance(value, str):
|
||||||
|
version = value
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
version = value.get("version", None)
|
||||||
|
else:
|
||||||
|
version = None
|
||||||
|
|
||||||
|
dependencies.append(
|
||||||
|
Dependency(
|
||||||
|
name=name,
|
||||||
|
version=self._normalize_version(version),
|
||||||
|
is_dev="dev" in section,
|
||||||
|
source_file=path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
def _parse_setup_file(self, path: Path) -> list[Dependency]:
|
||||||
|
"""Parse setup.py or setup.cfg file."""
|
||||||
|
dependencies = []
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
install_requires_match = re.search(
|
||||||
|
r"install_requires\s*=\s*\[([^\]]+)\]", content, re.DOTALL
|
||||||
|
)
|
||||||
|
if install_requires_match:
|
||||||
|
deps_str = install_requires_match.group(1)
|
||||||
|
for dep in deps_str.split(","):
|
||||||
|
dep = dep.strip().strip("'\"")
|
||||||
|
if dep:
|
||||||
|
parsed = self._parse_pep508_string(dep)
|
||||||
|
if parsed:
|
||||||
|
dependencies.append(parsed)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
def _clean_package_name(self, name: str) -> str:
|
||||||
|
"""Clean a package name."""
|
||||||
|
name = name.strip()
|
||||||
|
name = re.sub(r"^\.+", "", name)
|
||||||
|
name = re.sub(r"[<>=!~].*$", "", name)
|
||||||
|
name = name.strip()
|
||||||
|
return name if name else None
|
||||||
Reference in New Issue
Block a user