Skip to content

Commit

Permalink
Updated version of: Replace pkg_resources with packaging (#76)
Browse files Browse the repository at this point in the history
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 <[email protected]>
Co-authored-by: Colin B. Macdonald <[email protected]>
  • Loading branch information
3 people authored Jul 7, 2024
1 parent b4bc012 commit 2497299
Show file tree
Hide file tree
Showing 5 changed files with 51 additions and 136 deletions.
11 changes: 8 additions & 3 deletions docs/source/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,13 @@ And it is possible to specify specific branches, commits and tags,
see `pip's documentation on VCS support <https://pip.pypa.io/en/stable/topics/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
5 changes: 2 additions & 3 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
112 changes: 31 additions & 81 deletions req2flatpak.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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__)

Expand Down Expand Up @@ -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}


# =============================================================================
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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()
Expand Down
54 changes: 8 additions & 46 deletions tests/test_tags_from_wheel_filename.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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",
Expand All @@ -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)

0 comments on commit 2497299

Please sign in to comment.