From 04f497c9af8f160ca8553739e6abce67c010723c Mon Sep 17 00:00:00 2001 From: Daniele Nicolodi Date: Thu, 12 Dec 2024 22:23:23 +0100 Subject: [PATCH 1/2] Tighten regular expression used to validate wheel filenames Drop the .dist-info file extension. Enforce that each component of the filename does not contain a dash: dashes are used as separators. See https://packaging.python.org/en/latest/specifications/binary-distribution-format/#file-name-convention Remove spurious grouping, while at it. --- twine/wheel.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/twine/wheel.py b/twine/wheel.py index c1d82352..955214ae 100644 --- a/twine/wheel.py +++ b/twine/wheel.py @@ -20,9 +20,13 @@ from twine import exceptions wheel_file_re = re.compile( - r"""^(?P(?P.+?)(-(?P\d.+?))?) - ((-(?P\d.*?))?-(?P.+?)-(?P.+?)-(?P.+?) - \.whl|\.dist-info)$""", + r"""^(?P[^-]+)- + (?P[^-]+) + (:?-(?P\d[^-]*))?- + (?P[^-]+)- + (?P[^-]+)- + (?P[^-]+) + \.whl$""", re.VERBOSE, ) From b34f03a4b0d30020c5fe1a9358a549048bc0a150 Mon Sep 17 00:00:00 2001 From: Daniele Nicolodi Date: Thu, 12 Dec 2024 22:28:14 +0100 Subject: [PATCH 2/2] Tighten and simplify lookup for METADATA member in wheel archives The location of the METADATA member in the wheel archive is well defined. It is not necessary nor desirable look for it elsewhere. See https://packaging.python.org/en/latest/specifications/binary-distribution-format/#file-contents This simplifies the implementation and is more correct. Modernize the code a bit, while at it. --- tests/test_wheel.py | 78 ++++++++++++++++++++++----------------------- twine/wheel.py | 54 +++++++++---------------------- 2 files changed, 53 insertions(+), 79 deletions(-) diff --git a/tests/test_wheel.py b/tests/test_wheel.py index 4c4a2124..571da63c 100644 --- a/tests/test_wheel.py +++ b/tests/test_wheel.py @@ -12,10 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. import pathlib -import re import zipfile -import pretend import pytest from twine import exceptions @@ -33,26 +31,24 @@ def test_version_parsing(example_wheel): assert example_wheel.py_version == "py2.py3" -def test_version_parsing_missing_pyver(monkeypatch, example_wheel): - wheel.wheel_file_re = pretend.stub(match=lambda a: None) - assert example_wheel.py_version == "any" +@pytest.mark.parametrize( + "file_name,valid", + [ + ("name-1.2.3-py313-none-any.whl", True), + ("name-1.2.3-42-py313-none-any.whl", True), + ("long_name-1.2.3-py3-none-any.whl", True), + ("missing_components-1.2.3.whl", False), + ], +) +def test_parse_wheel_file_name(file_name, valid): + assert bool(wheel.wheel_file_re.match(file_name)) == valid -def test_find_metadata_files(): - names = [ - "package/lib/__init__.py", - "package/lib/version.py", - "package/METADATA.txt", - "package/METADATA.json", - "package/METADATA", - ] - expected = [ - ["package", "METADATA"], - ["package", "METADATA.json"], - ["package", "METADATA.txt"], - ] - candidates = wheel.Wheel.find_candidate_metadata_files(names) - assert expected == candidates +def test_invalid_file_name(monkeypatch): + parent = pathlib.Path(__file__).parent + file_name = str(parent / "fixtures" / "twine-1.5.0-py2.whl") + with pytest.raises(exceptions.InvalidDistribution, match="Invalid wheel filename"): + wheel.Wheel(file_name) def test_read_valid(example_wheel): @@ -64,33 +60,35 @@ def test_read_valid(example_wheel): def test_read_non_existent_wheel_file_name(): """Raise an exception when wheel file doesn't exist.""" - file_name = str(pathlib.Path("/foo/bar/baz.whl").resolve()) - with pytest.raises( - exceptions.InvalidDistribution, match=re.escape(f"No such file: {file_name}") - ): + file_name = str(pathlib.Path("/foo/bar/baz-1.2.3-py3-none-any.whl").resolve()) + with pytest.raises(exceptions.InvalidDistribution, match="No such file"): wheel.Wheel(file_name).read() def test_read_invalid_wheel_extension(): """Raise an exception when file is missing .whl extension.""" file_name = str(pathlib.Path(__file__).parent / "fixtures" / "twine-1.5.0.tar.gz") - with pytest.raises( - exceptions.InvalidDistribution, - match=re.escape(f"Not a known archive format for file: {file_name}"), - ): + with pytest.raises(exceptions.InvalidDistribution, match="Invalid wheel filename"): + wheel.Wheel(file_name).read() + + +def test_read_wheel_missing_metadata(example_wheel, monkeypatch): + """Raise an exception when a wheel file is missing METADATA.""" + + def patch(self, name): + raise KeyError + + monkeypatch.setattr(zipfile.ZipFile, "read", patch) + parent = pathlib.Path(__file__).parent + file_name = str(parent / "fixtures" / "twine-1.5.0-py2.py3-none-any.whl") + with pytest.raises(exceptions.InvalidDistribution, match="No METADATA in archive"): wheel.Wheel(file_name).read() -def test_read_wheel_empty_metadata(tmpdir): +def test_read_wheel_empty_metadata(example_wheel, monkeypatch): """Raise an exception when a wheel file is missing METADATA.""" - whl_file = tmpdir.mkdir("wheel").join("not-a-wheel.whl") - with zipfile.ZipFile(whl_file, "w") as zip_file: - zip_file.writestr("METADATA", "") - - with pytest.raises( - exceptions.InvalidDistribution, - match=re.escape( - f"No METADATA in archive or METADATA missing 'Metadata-Version': {whl_file}" - ), - ): - wheel.Wheel(whl_file).read() + monkeypatch.setattr(zipfile.ZipFile, "read", lambda self, name: b"") + parent = pathlib.Path(__file__).parent + file_name = str(parent / "fixtures" / "twine-1.5.0-py2.py3-none-any.whl") + with pytest.raises(exceptions.InvalidDistribution, match="No METADATA in archive"): + wheel.Wheel(file_name).read() diff --git a/twine/wheel.py b/twine/wheel.py index 955214ae..00bf1335 100644 --- a/twine/wheel.py +++ b/twine/wheel.py @@ -14,7 +14,7 @@ import os import re import zipfile -from typing import List +from contextlib import suppress from twine import distribution from twine import exceptions @@ -33,53 +33,29 @@ class Wheel(distribution.Distribution): def __init__(self, filename: str) -> None: + m = wheel_file_re.match(os.path.basename(filename)) + if not m: + raise exceptions.InvalidDistribution(f"Invalid wheel filename: {filename}") + self.name = m.group("name") + self.version = m.group("version") + self.pyver = m.group("pyver") + if not os.path.exists(filename): raise exceptions.InvalidDistribution(f"No such file: {filename}") self.filename = filename @property def py_version(self) -> str: - wheel_info = wheel_file_re.match(os.path.basename(self.filename)) - if wheel_info is None: - return "any" - else: - return wheel_info.group("pyver") - - @staticmethod - def find_candidate_metadata_files(names: List[str]) -> List[List[str]]: - """Filter files that may be METADATA files.""" - tuples = [x.split("/") for x in names if "METADATA" in x] - return [x[1] for x in sorted((len(x), x) for x in tuples)] + return self.pyver def read(self) -> bytes: - fqn = os.path.abspath(os.path.normpath(self.filename)) - if not os.path.exists(fqn): - raise exceptions.InvalidDistribution("No such file: %s" % fqn) - - if fqn.endswith(".whl"): - archive = zipfile.ZipFile(fqn) - names = archive.namelist() - - def read_file(name: str) -> bytes: - return archive.read(name) - - else: - raise exceptions.InvalidDistribution( - "Not a known archive format for file: %s" % fqn - ) - - searched_files: List[str] = [] - try: - for path in self.find_candidate_metadata_files(names): - candidate = "/".join(path) - data = read_file(candidate) + with zipfile.ZipFile(self.filename) as wheel: + with suppress(KeyError): + # The wheel needs to contain the METADATA file at this location. + data = wheel.read(f"{self.name}-{self.version}.dist-info/METADATA") if b"Metadata-Version" in data: return data - searched_files.append(candidate) - finally: - archive.close() - raise exceptions.InvalidDistribution( - "No METADATA in archive or METADATA missing 'Metadata-Version': " - "%s (searched %s)" % (fqn, ",".join(searched_files)) + "No METADATA in archive or " + f"METADATA missing 'Metadata-Version': {self.filename}" )