diff --git a/pippackagelist/__main__.py b/pippackagelist/__main__.py index d7e7ecc..4d020f0 100644 --- a/pippackagelist/__main__.py +++ b/pippackagelist/__main__.py @@ -1,26 +1,38 @@ +import argparse import sys -from .requirements import RequirementsEditableEntry, RequirementsRecursiveEntry -from .requirements_txt_parser import parse_requirements_txt -from .setup_py_parser import parse_setup_py +from .list import list as list_packages def main() -> int: - input_file = sys.argv[1] + parser = argparse.ArgumentParser() + parser.add_argument( + "--recurse-recursive", + default=False, + help="recurse into -r entries", + action="store_true", + ) + parser.add_argument( + "--recurse-editable", + default=False, + help="recurse into -e entries", + action="store_true", + ) + parser.add_argument( + "file_paths", + nargs="+", + help="list of requirements.txt or setup.py files", + ) - aggregated_entries = [] - for requirement in parse_requirements_txt(input_file): - if isinstance(requirement, RequirementsRecursiveEntry): - aggregated_entries.extend( - list(parse_requirements_txt(requirement.path)) - ) - elif isinstance(requirement, RequirementsEditableEntry): - aggregated_entries.extend(list(parse_setup_py(requirement.path))) - else: - aggregated_entries.append(requirement) + args = parser.parse_args() + + for requirement in list_packages( + args.file_paths, + recurse_recursive=args.recurse_recursive, + recurse_editable=args.recurse_editable, + ): + print(requirement) - for req in aggregated_entries: - print(req.source.line, req.source.path) return 0 diff --git a/pippackagelist/identify_package_list_file_type.py b/pippackagelist/identify_package_list_file_type.py new file mode 100644 index 0000000..7f7dabc --- /dev/null +++ b/pippackagelist/identify_package_list_file_type.py @@ -0,0 +1,13 @@ +import enum + + +class PackageListFileType(enum.Enum): + REQUIREMENTS_TXT = "requirements.txt" + SETUP_PY = "setup.py" + + +def identify_package_list_file_type(file_path: str) -> PackageListFileType: + if file_path.endswith("setup.py"): + return PackageListFileType.SETUP_PY + + return PackageListFileType.REQUIREMENTS_TXT diff --git a/pippackagelist/list.py b/pippackagelist/list.py new file mode 100644 index 0000000..33aa1d4 --- /dev/null +++ b/pippackagelist/list.py @@ -0,0 +1,49 @@ +from typing import Generator, List + +from .identify_package_list_file_type import ( + PackageListFileType, + identify_package_list_file_type, +) +from .requirements import ( + RequirementsEditableEntry, + RequirementsEntry, + RequirementsRecursiveEntry, +) +from .requirements_txt_parser import parse_requirements_txt +from .setup_py_parser import parse_setup_py + + +def list( + file_paths: List[str], + recurse_recursive: bool = True, + recurse_editable: bool = True, +) -> Generator[RequirementsEntry, None, None]: + generators = [] + + for file_path in file_paths: + package_list_file_type = identify_package_list_file_type(file_path) + if package_list_file_type == PackageListFileType.REQUIREMENTS_TXT: + generators.append(parse_requirements_txt(file_path)) + elif package_list_file_type == PackageListFileType.SETUP_PY: + generators.append(parse_setup_py(file_path)) + + while len(generators) > 0: + for requirement in generators[0]: + if isinstance(requirement, RequirementsRecursiveEntry): + if recurse_recursive: + generators.append( + parse_requirements_txt(requirement.absolute_path) + ) + else: + yield requirement + elif isinstance(requirement, RequirementsEditableEntry): + if recurse_editable: + generators.append( + parse_setup_py(requirement.resolved_absolute_path) + ) + else: + yield requirement + else: + yield requirement + + generators = generators[1:] diff --git a/pippackagelist/requirements.py b/pippackagelist/requirements.py index 6d4a079..b074c8c 100644 --- a/pippackagelist/requirements.py +++ b/pippackagelist/requirements.py @@ -19,12 +19,23 @@ class RequirementsEntry: @dataclass class RequirementsRecursiveEntry(RequirementsEntry): - path: str + original_path: str + absolute_path: str + + def __str__(self) -> str: + return f"-r {self.absolute_path}" @dataclass class RequirementsEditableEntry(RequirementsEntry): - path: str + original_path: str + absolute_path: str + + resolved_path: str + resolved_absolute_path: str + + def __str__(self) -> str: + return f"-e {self.absolute_path}" @dataclass @@ -33,6 +44,13 @@ class RequirementsVCSPackageEntry(RequirementsEntry): uri: str tag: Optional[str] + def __str__(self) -> str: + result = f"{self.vcs}+{self.uri}" + if self.tag: + result += f"#{self.tag}" + + return result + @dataclass class RequirementsPackageEntry(RequirementsEntry): @@ -40,6 +58,9 @@ class RequirementsPackageEntry(RequirementsEntry): operator: str version: str + def __str__(self) -> str: + return f"{self.name}{self.operator}{self.version}" + def parse_requirements( source: Optional[RequirementsEntrySource], lines: List[str] @@ -63,7 +84,6 @@ def parse_requirements( elif stripped_line.startswith("-e"): yield parse_editable_requirements_entry(line_source, stripped_line) - # TODO: add support for other VCS's elif re.match(r"^(.+)\+", stripped_line): yield parse_vcs_requirements_entry(line_source, stripped_line) else: @@ -73,20 +93,38 @@ def parse_requirements( def parse_recursive_requirements_entry( source: RequirementsEntrySource, line: str ) -> RequirementsRecursiveEntry: - path = _clean_line(line.replace("-r", "")) + original_path = _clean_line(line.replace("-r", "")) + absolute_path = os.path.realpath( + os.path.join(os.path.dirname(source.path), original_path) + ) + return RequirementsRecursiveEntry( - source=source, - path=os.path.realpath(os.path.join(os.path.dirname(source.path), path)), + source=source, original_path=original_path, absolute_path=absolute_path, ) def parse_editable_requirements_entry( source: RequirementsEntrySource, line: str ) -> RequirementsEditableEntry: - path = _clean_line(line.replace("-e", "")) + original_path = _clean_line(line.replace("-e", "")) + resolved_path = original_path + + if not original_path.endswith(".py"): + resolved_path = os.path.join(original_path, "setup.py") + + absolute_path = os.path.realpath( + os.path.join(os.path.dirname(source.path), original_path) + ) + resolved_absolute_path = os.path.realpath( + os.path.join(os.path.dirname(source.path), resolved_path) + ) + return RequirementsEditableEntry( source=source, - path=os.path.realpath(os.path.join(os.path.dirname(source.path), path)), + original_path=original_path, + absolute_path=absolute_path, + resolved_path=resolved_path, + resolved_absolute_path=resolved_absolute_path, ) diff --git a/pippackagelist/requirements_txt_parser.py b/pippackagelist/requirements_txt_parser.py index 9b6facd..fc0703b 100644 --- a/pippackagelist/requirements_txt_parser.py +++ b/pippackagelist/requirements_txt_parser.py @@ -1,3 +1,5 @@ +import os + from typing import Generator from .requirements import ( @@ -11,7 +13,7 @@ def parse_requirements_txt( file_path: str, ) -> Generator[RequirementsEntry, None, None]: source = RequirementsEntrySource( - path=file_path, line=None, line_number=None + path=os.path.realpath(file_path), line=None, line_number=None ) with open(file_path, "r") as fp: diff --git a/pippackagelist/setup_py_parser.py b/pippackagelist/setup_py_parser.py index 707e7eb..fee4cc8 100644 --- a/pippackagelist/setup_py_parser.py +++ b/pippackagelist/setup_py_parser.py @@ -1,3 +1,5 @@ +import os + from typing import Generator import setuptools @@ -21,7 +23,7 @@ def parse_setup_py(file_path: str) -> Generator[RequirementsEntry, None, None]: exec(fp.read()) source = RequirementsEntrySource( - path=file_path, line=None, line_number=None + path=os.path.realpath(file_path), line=None, line_number=None ) requirements = setup_kwargs.get("install_requires") or [] diff --git a/tests/test_parse_requirements.py b/tests/test_parse_requirements.py index ffa109f..1a74332 100644 --- a/tests/test_parse_requirements.py +++ b/tests/test_parse_requirements.py @@ -27,7 +27,7 @@ def test_parse_requirements_recursive_entry(path): assert requirements[0].source.path == source.path assert requirements[0].source.line == line assert requirements[0].source.line_number == 1 - assert requirements[0].path == os.path.realpath( + assert requirements[0].absolute_path == os.path.realpath( os.path.join(os.getcwd(), path) ) @@ -43,7 +43,7 @@ def test_parse_requirements_editable_entry(path): assert requirements[0].source.path == source.path assert requirements[0].source.line == line assert requirements[0].source.line_number == 1 - assert requirements[0].path == os.path.realpath( + assert requirements[0].absolute_path == os.path.realpath( os.path.join(os.getcwd(), path) ) @@ -126,13 +126,15 @@ def test_parse_requirements_ignores_leading_and_trailing_whitespace(): assert requirements[1].source.path == source.path assert requirements[1].source.line == "-r ./otherfile.txt" assert requirements[1].source.line_number == 2 - assert requirements[1].path == os.path.join(os.getcwd(), "otherfile.txt") + assert requirements[1].absolute_path == os.path.join( + os.getcwd(), "otherfile.txt" + ) assert isinstance(requirements[2], RequirementsEditableEntry) assert requirements[2].source.path == source.path assert requirements[2].source.line == "-e ../" assert requirements[2].source.line_number == 3 - assert requirements[2].path == os.path.realpath( + assert requirements[2].absolute_path == os.path.realpath( os.path.join(os.getcwd(), "..") ) diff --git a/tests/test_parse_requirements_txt.py b/tests/test_parse_requirements_txt.py index 128c5a0..a425d3b 100644 --- a/tests/test_parse_requirements_txt.py +++ b/tests/test_parse_requirements_txt.py @@ -22,5 +22,7 @@ def test_parse_requirements_txt(): assert isinstance(requirements[3], RequirementsEditableEntry) for index, requirement in enumerate(requirements): - assert requirement.source.path == requirements_txt_path + assert requirement.source.path == os.path.realpath( + requirements_txt_path + ) assert requirement.source.line_number == index + 1 diff --git a/tests/test_parse_setup_py.py b/tests/test_parse_setup_py.py index c7254a4..803119b 100644 --- a/tests/test_parse_setup_py.py +++ b/tests/test_parse_setup_py.py @@ -23,7 +23,7 @@ def test_parse_setup_py(): assert isinstance(requirements[3], RequirementsEditableEntry) for index, requirement in enumerate(requirements): - assert requirement.source.path == setup_py_path + assert requirement.source.path == os.path.realpath(setup_py_path) assert requirement.source.line_number == index + 1