From 2497299701efc03adc5fdd1f37426de0dfdc81f0 Mon Sep 17 00:00:00 2001 From: johannesjh Date: Sun, 7 Jul 2024 13:20:32 +0200 Subject: [PATCH] Updated version of: Replace pkg_resources with packaging (#76) Since `pkg_resources` is deprecated in favour of `importlib` and `packaging`, all uses of `pkg_resources` are replaced in this commit. Sadly `importlib` doesn't seem to provide means for parsing requirement specifiers that's why `packaging` is now a hard dependency. * req2flatpak.py: Replace `pkg_resources` with `packaging`. Also Remove vendored alternative implementations to packaging; since `packaging` is no longer optional we don't need alternative implementations. * pyproject.toml : Remove `packaging` extra and remove `optional` flag for `packaging`. * poetry.lock : Updated dependencies * tests/test_tags_from_wheel_filename.py : Fix tests. * Update docs about (almost) standalone script --------- Co-authored-by: real-yfprojects Co-authored-by: Colin B. Macdonald --- docs/source/installation.rst | 11 ++- poetry.lock | 5 +- pyproject.toml | 5 +- req2flatpak.py | 112 +++++++------------------ tests/test_tags_from_wheel_filename.py | 54 ++---------- 5 files changed, 51 insertions(+), 136 deletions(-) diff --git a/docs/source/installation.rst b/docs/source/installation.rst index e07860d..59b3d1c 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -46,8 +46,13 @@ And it is possible to specify specific branches, commits and tags, see `pip's documentation on VCS support `_. -Simply Downloading and Copying the Script ------------------------------------------ +Downloading the (almost) Standalone Script +------------------------------------------ You can download the ``req2flatpak.py`` script to your computer and run it. -Simple as that. +You will need Python installed and the additional ``packaging`` package: + +.. code-block:: bash + + pip install packaging + ./req2flatpak.py --help diff --git a/poetry.lock b/poetry.lock index 38e2656..bdb823e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.0 and should not be changed by hand. [[package]] name = "alabaster" @@ -1324,10 +1324,9 @@ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [extras] -packaging = ["packaging"] yaml = ["pyyaml"] [metadata] lock-version = "2.0" python-versions = "^3.7.2" -content-hash = "a5a7ce05a58e74ed0c82bcbd8a8db6920810f324ab2b8c0ecd52e4e0bcd28bd3" +content-hash = "43968d6b84409817e668b2f0823a8dc4634b88cf095bfcd9889552942f2b74b0" diff --git a/pyproject.toml b/pyproject.toml index 7713c28..2566b7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,12 +15,11 @@ req2flatpak = 'req2flatpak:main' [tool.poetry.dependencies] python = "^3.7.2" -packaging = { version = "^21.3", optional = true } +packaging = { version = "^21.3" } pyyaml = { version = "^6.0.1", optional = true } [tool.poetry.extras] -packaging = ["packaging"] -yaml = ["pyyaml"] +yaml = ["pyyaml"] [tool.poetry.group.lint.dependencies] pylama = { extras = [ diff --git a/req2flatpak.py b/req2flatpak.py index 5982c04..ca243b4 100755 --- a/req2flatpak.py +++ b/req2flatpak.py @@ -43,11 +43,11 @@ import urllib.request from contextlib import nullcontext, suppress from dataclasses import asdict, dataclass, field +from importlib import metadata from itertools import product from typing import ( Any, Dict, - FrozenSet, Generator, Hashable, Iterable, @@ -60,7 +60,9 @@ ) from urllib.parse import urlparse -import pkg_resources +import packaging.requirements as packaging_reqs +import packaging.tags +from packaging.utils import parse_wheel_filename logger = logging.getLogger(__name__) @@ -109,64 +111,14 @@ class InvalidWheelFilename(Exception): """An invalid wheel filename was found, users should refer to PEP 427.""" -try: - # use packaging.tags functionality if available - from packaging.utils import parse_wheel_filename - - def tags_from_wheel_filename(filename: str) -> Set[str]: - """ - Parses a wheel filename into a list of compatible platform tags. - - Implemented using functionality from ``packaging.utils.parse_wheel_filename``. - """ - _, _, _, tags = parse_wheel_filename(filename) - return {str(tag) for tag in tags} - -except ModuleNotFoundError: - # fall back to a local implementation - # that is heavily inspired by / almost vendored from the `packaging` package: - def tags_from_wheel_filename(filename: str) -> Set[str]: - """ - Parses a wheel filename into a list of compatible platform tags. - - Implemented as (semi-)vendored functionality in req2flatpak. - """ - Tag = Tuple[str, str, str] - - # the following code is based on packaging.tags.parse_tag, - # it is needed for the parse_wheel_filename function: - def parse_tag(tag: str) -> FrozenSet[Tag]: - tags: Set[Tag] = set() - interpreters, abis, platforms = tag.split("-") - for interpreter in interpreters.split("."): - for abi in abis.split("."): - for platform_ in platforms.split("."): - tags.add((interpreter, abi, platform_)) - return frozenset(tags) - - # the following code is based on packaging.utils.parse_wheel_filename: - # pylint: disable=redefined-outer-name - def parse_wheel_filename( - wheel_filename: str, - ) -> Iterable[Tag]: - if not wheel_filename.endswith(".whl"): - raise InvalidWheelFilename( - "Error parsing wheel filename: " - "Invalid wheel filename (extension must be '.whl'): " - f"{wheel_filename}" - ) - wheel_filename = wheel_filename[:-4] - dashes = wheel_filename.count("-") - if dashes not in (4, 5): - raise InvalidWheelFilename( - "Error parsing wheel filename: " - "Invalid wheel filename (wrong number of parts): " - f"{wheel_filename}" - ) - parts = wheel_filename.split("-", dashes - 2) - return parse_tag(parts[-1]) +def tags_from_wheel_filename(filename: str) -> Set[str]: + """ + Parses a wheel filename into a list of compatible platform tags. - return {"-".join(tag_tuple) for tag_tuple in parse_wheel_filename(filename)} + Implemented using functionality from ``packaging.utils.parse_wheel_filename``. + """ + _, _, _, tags = parse_wheel_filename(filename) + return {str(tag) for tag in tags} # ============================================================================= @@ -271,17 +223,8 @@ def _get_current_python_version() -> List[str]: @staticmethod def _get_current_python_tags() -> List[str]: - try: - # pylint: disable=import-outside-toplevel - import packaging.tags - - tags = [str(tag) for tag in packaging.tags.sys_tags()] - return tags - except ModuleNotFoundError as e: - logger.warning( - 'Error trying to import the "packaging" package.', exc_info=e - ) - return [] + tags = [str(tag) for tag in packaging.tags.sys_tags()] + return tags @classmethod def from_current_interpreter(cls) -> Platform: @@ -431,27 +374,32 @@ class RequirementsParser: resolve dependencies. """ - # based on: https://stackoverflow.com/a/59971236 - # using functionality from pkg_resources.parse_requirements - @classmethod def parse_string(cls, requirements_txt: str) -> List[Requirement]: """Parses requirements.txt string content into a list of Requirement objects.""" - def validate_requirement(req: pkg_resources.Requirement) -> None: + def validate_requirement(req: packaging_reqs.Requirement) -> None: assert ( - len(req.specs) == 1 + len(req.specifier) == 1 ), "Error parsing requirements: A single version number must be specified." assert ( - req.specs[0][0] == "==" + list(req.specifier)[0].operator == "==" ), "Error parsing requirements: The exact version must specified as 'package==version'." - def make_requirement(req: pkg_resources.Requirement) -> Requirement: + def make_requirement(req: packaging_reqs.Requirement) -> Requirement: validate_requirement(req) - return Requirement(package=req.project_name, version=req.specs[0][1]) + return Requirement(package=req.name, version=list(req.specifier)[0].version) - reqs = pkg_resources.parse_requirements(requirements_txt) - return [make_requirement(req) for req in reqs] + requirements = [] + for line in requirements_txt.splitlines(): + if not (line := line.strip()): + continue + if line.startswith("#"): + continue + req = packaging_reqs.Requirement(line) + requirements.append(make_requirement(req)) + + return requirements @classmethod def parse_file(cls, file) -> List[Requirement]: @@ -759,7 +707,9 @@ def main(): # pylint: disable=too-many-branches # print installed packages if requested, and exit if options.installed_packages: # pylint: disable=not-an-iterable - pkgs = {p.key: p.version for p in pkg_resources.working_set} + pkgs = { + p.metadata["Name"]: p.metadata["Version"] for p in metadata.distributions() + } for pkg, version in pkgs.items(): print(f"{pkg}=={version}", file=output_stream) parser.exit() diff --git a/tests/test_tags_from_wheel_filename.py b/tests/test_tags_from_wheel_filename.py index 698b47a..b0466f4 100644 --- a/tests/test_tags_from_wheel_filename.py +++ b/tests/test_tags_from_wheel_filename.py @@ -1,9 +1,7 @@ """Test for the parsing of tags in wheel filenames.""" import unittest -from importlib import reload -from typing import Callable, Dict, Optional, Set -from unittest import mock +from typing import Dict, Set import req2flatpak @@ -12,24 +10,10 @@ class TestTagsFromWheelFilename(unittest.TestCase): """ Compatibility test for py:meth:`~req2flatpak.tags_from_wheel_filename`. - The need for this test comes from the fact that req2flatpak contains two - alternative implementations of ``tags_from_wheel_filename``: - A vendored implementation that does not need the packaging package. - And another implementation that relies on functionality from the packaging - package. - - Both implementations of ``tags_from_wheel_filename`` are tested using the - same testdata to ensure that their behavior is correct and equal. - The test thus serves as regression test for the vendored implementation. - And it also serves as future-compatibility test to safeguard against changes + This tests serves as future-compatibility test to safeguard against changes in the packaging package. """ - implementations: Dict[str, Optional[Callable]] = { - "with_packaging": None, - "without_packaging": None, - } - data: Dict[str, Set[str]] = { "pandas-1.5.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": { "cp310-cp310-manylinux_2_17_aarch64", @@ -43,34 +27,12 @@ class TestTagsFromWheelFilename(unittest.TestCase): }, } - @classmethod - def setUpClass(cls): - """Retrieves the two implementations of ``tags_from_wheel_filename``.""" - - # get the implementation that does not use the ``packaging`` package - # related work: - # - how to mock a ModuleNotFoundError: https://stackoverflow.com/a/67884737 - # - how to ignore certain imports: https://stackoverflow.com/a/63353431 - with mock.patch.dict("sys.modules", {"packaging.utils": None}): - reload(req2flatpak) - cls.implementations["without_packaging"] = ( - req2flatpak.tags_from_wheel_filename - ) - - # get the implementation that uses the ``packaging`` package - reload(req2flatpak) - cls.implementations["with_packaging"] = req2flatpak.tags_from_wheel_filename - - # ensure that we got two different implementations - assert ( - cls.implementations["without_packaging"] - != cls.implementations["with_packaging"] - ) - def test(self): """Tests the behavior of ``tags_from_wheel_filename``.""" for filename, expected_tags in self.data.items(): - for description, func in self.implementations.items(): - with self.subTest(filename=filename, impl=description): - parsed_tags = func(filename) # pylint: disable=not-callable - self.assertEqual(parsed_tags, expected_tags) + with self.subTest(filename=filename): + parsed_tags = req2flatpak.tags_from_wheel_filename( + filename + ) # pylint: disable=not-callable + self.assertEqual(parsed_tags, expected_tags) + self.assertEqual(parsed_tags, expected_tags)