Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-installer for
openSUSE:Factory checked in at 2023-03-29 23:25:57
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-installer (Old)
and /work/SRC/openSUSE:Factory/.python-installer.new.31432 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-installer"
Wed Mar 29 23:25:57 2023 rev:6 rq:1074810 version:0.7.0
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-installer/python-installer.changes
2023-02-14 16:42:28.293391951 +0100
+++
/work/SRC/openSUSE:Factory/.python-installer.new.31432/python-installer.changes
2023-03-29 23:26:00.315119544 +0200
@@ -1,0 +2,11 @@
+Tue Mar 28 03:51:53 UTC 2023 - Steve Kowalik <[email protected]>
+
+- Update to 0.7.0:
+ * Improve handling of non-normalized .dist-info folders (#168)
+ * Explicitly use policy=compat32 (#163)
+ * Normalize RECORD file paths when parsing (#152)
+ * Search wheels for .dist-info directories (#137)
+ * Separate validation of RECORD (#147, #167)
+- Only build the wheel once.
+
+-------------------------------------------------------------------
Old:
----
installer-0.6.0.tar.gz
New:
----
installer-0.7.0.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-installer.spec ++++++
--- /var/tmp/diff_new_pack.iBappl/_old 2023-03-29 23:26:00.819121912 +0200
+++ /var/tmp/diff_new_pack.iBappl/_new 2023-03-29 23:26:00.823121931 +0200
@@ -25,7 +25,7 @@
%bcond_with test
%endif
Name: python-installer%{pkg_suffix}
-Version: 0.6.0
+Version: 0.7.0
Release: 0
Summary: A library for installing Python wheels
License: MIT
@@ -50,7 +50,7 @@
%if !%{with test}
%build
-%python_expand $python -m flit_core.wheel
+python3 -m flit_core.wheel
%endif
%if !%{with test}
++++++ installer-0.6.0.tar.gz -> installer-0.7.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/installer-0.6.0/.pre-commit-config.yaml
new/installer-0.7.0/.pre-commit-config.yaml
--- old/installer-0.6.0/.pre-commit-config.yaml 2022-12-07 03:28:06.838862000
+0100
+++ new/installer-0.7.0/.pre-commit-config.yaml 2023-03-17 21:36:16.472300500
+0100
@@ -1,24 +1,24 @@
repos:
- repo: https://github.com/psf/black
- rev: "22.10.0"
+ rev: "23.1.0"
hooks:
- id: black
language_version: python3.8
- repo: https://github.com/PyCQA/isort
- rev: "5.10.1"
+ rev: "5.12.0"
hooks:
- id: isort
files: \.py$
- repo: https://github.com/pre-commit/mirrors-mypy
- rev: "v0.991"
+ rev: "v1.1.1"
hooks:
- id: mypy
exclude: docs/.*|tests/.*|noxfile.py
- repo: https://github.com/pre-commit/mirrors-prettier
- rev: "v3.0.0-alpha.4"
+ rev: "v3.0.0-alpha.6"
hooks:
- id: prettier
args: [--prose-wrap, always]
@@ -42,13 +42,12 @@
- id: flake8
- repo: https://github.com/PyCQA/pydocstyle.git
- rev: "6.1.1"
+ rev: "6.3.0"
hooks:
- id: pydocstyle
files: src/.*\.py$
- repo: https://github.com/asottile/blacken-docs
- rev: "v1.12.1"
+ rev: "1.13.0"
hooks:
- id: blacken-docs
- additional_dependencies: [black==21.9b0]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/installer-0.6.0/PKG-INFO new/installer-0.7.0/PKG-INFO
--- old/installer-0.6.0/PKG-INFO 1970-01-01 01:00:00.000000000 +0100
+++ new/installer-0.7.0/PKG-INFO 1970-01-01 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
Metadata-Version: 2.1
Name: installer
-Version: 0.6.0
+Version: 0.7.0
Summary: A library for installing Python wheels.
Author-email: Pradyun Gedam <[email protected]>
Requires-Python: >=3.7
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/installer-0.6.0/docs/changelog.md
new/installer-0.7.0/docs/changelog.md
--- old/installer-0.6.0/docs/changelog.md 2022-12-07 03:29:41.190069000
+0100
+++ new/installer-0.7.0/docs/changelog.md 2023-03-17 21:38:45.610304000
+0100
@@ -1,5 +1,14 @@
# Changelog
+## v0.7.0 (Mar 17, 2023)
+
+- Improve handling of non-normalized `.dist-info` folders (#168)
+- Refactor `validate_record` (#167)
+- Explicitly use `policy=compat32` (#163)
+- Normalize `RECORD` file paths when parsing (#152)
+- Search wheels for `.dist-info` directories (#137)
+- Separate validation of `RECORD` (#147)
+
## v0.6.0 (Dec 7, 2022)
- Add support for Python 3.11 (#154)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/installer-0.6.0/src/installer/__init__.py
new/installer-0.7.0/src/installer/__init__.py
--- old/installer-0.6.0/src/installer/__init__.py 2022-12-07
03:30:07.284015000 +0100
+++ new/installer-0.7.0/src/installer/__init__.py 2023-03-17
21:39:00.952675800 +0100
@@ -1,6 +1,6 @@
"""A library for installing Python wheels."""
-__version__ = "0.6.0"
+__version__ = "0.7.0"
__all__ = ["install"]
from installer._core import install # noqa
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/installer-0.6.0/src/installer/records.py
new/installer-0.7.0/src/installer/records.py
--- old/installer-0.6.0/src/installer/records.py 2022-12-07
03:28:06.840245700 +0100
+++ new/installer-0.7.0/src/installer/records.py 2023-03-02
23:06:48.987741500 +0100
@@ -213,5 +213,8 @@
)
raise InvalidRecordEntry(elements=elements, issues=[message])
+ # Convert Windows paths to use / for consistency
+ elements[0] = elements[0].replace("\\", "/")
+
value = cast(Tuple[str, str, str], tuple(elements))
yield value
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/installer-0.6.0/src/installer/sources.py
new/installer-0.7.0/src/installer/sources.py
--- old/installer-0.6.0/src/installer/sources.py 2022-03-24
09:42:55.220565300 +0100
+++ new/installer-0.7.0/src/installer/sources.py 2023-03-17
21:36:16.472648000 +0100
@@ -5,10 +5,11 @@
import stat
import zipfile
from contextlib import contextmanager
-from typing import BinaryIO, Iterator, List, Tuple, cast
+from typing import BinaryIO, ClassVar, Iterator, List, Optional, Tuple, Type,
cast
-from installer.records import parse_record_file
-from installer.utils import parse_wheel_filename
+from installer.exceptions import InstallerError
+from installer.records import RecordEntry, parse_record_file
+from installer.utils import canonicalize_name, parse_wheel_filename
WheelContentElement = Tuple[Tuple[str, str, str], BinaryIO, bool]
@@ -22,6 +23,8 @@
This is an abstract class, whose methods have to be implemented by
subclasses.
"""
+ validation_error: ClassVar[Type[Exception]] = ValueError
+
def __init__(self, distribution: str, version: str) -> None:
"""Initialize a WheelSource object.
@@ -65,6 +68,14 @@
"""
raise NotImplementedError
+ def validate_record(self) -> None:
+ """Validate ``RECORD`` of the wheel.
+
+ This method should be called before :py:func:`install
<installer.install>`
+ if validation is required.
+ """
+ raise NotImplementedError
+
def get_contents(self) -> Iterator[WheelContentElement]:
"""Sequential access to all contents of the wheel (including dist-info
files).
@@ -91,6 +102,32 @@
raise NotImplementedError
+class _WheelFileValidationError(ValueError, InstallerError):
+ """Raised when a wheel file fails validation."""
+
+ def __init__(self, issues: List[str]) -> None:
+ super().__init__(repr(issues))
+ self.issues = issues
+
+ def __repr__(self) -> str:
+ return f"WheelFileValidationError(issues={self.issues!r})"
+
+
+class _WheelFileBadDistInfo(ValueError, InstallerError):
+ """Raised when a wheel file has issues around `.dist-info`."""
+
+ def __init__(self, *, reason: str, filename: Optional[str], dist_info:
str) -> None:
+ super().__init__(reason)
+ self.reason = reason
+ self.filename = filename
+ self.dist_info = dist_info
+
+ def __str__(self) -> str:
+ return (
+ f"{self.reason} (filename={self.filename!r},
dist_info={self.dist_info!r})"
+ )
+
+
class WheelFile(WheelSource):
"""Implements `WheelSource`, for an existing file from the filesystem.
@@ -100,6 +137,8 @@
... installer.install(source, destination)
"""
+ validation_error = _WheelFileValidationError
+
def __init__(self, f: zipfile.ZipFile) -> None:
"""Initialize a WheelFile object.
@@ -114,6 +153,7 @@
version=parsed_name.version,
distribution=parsed_name.distribution,
)
+ self._dist_info_dir: Optional[str] = None
@classmethod
@contextmanager
@@ -123,6 +163,43 @@
yield cls(f)
@property
+ def dist_info_dir(self) -> str:
+ """Name of the dist-info directory."""
+ if self._dist_info_dir is not None:
+ return self._dist_info_dir
+
+ top_level_directories = {
+ path.split("/", 1)[0] for path in self._zipfile.namelist()
+ }
+ dist_infos = [
+ name for name in top_level_directories if
name.endswith(".dist-info")
+ ]
+
+ try:
+ (dist_info_dir,) = dist_infos
+ except ValueError:
+ raise _WheelFileBadDistInfo(
+ reason="Wheel doesn't contain exactly one .dist-info
directory",
+ filename=self._zipfile.filename,
+ dist_info=str(sorted(dist_infos)),
+ ) from None
+
+ # NAME-VER.dist-info
+ di_dname = dist_info_dir.rsplit("-", 2)[0]
+ norm_di_dname = canonicalize_name(di_dname)
+ norm_file_dname = canonicalize_name(self.distribution)
+
+ if norm_di_dname != norm_file_dname:
+ raise _WheelFileBadDistInfo(
+ reason="Wheel .dist-info directory doesn't match wheel
filename",
+ filename=self._zipfile.filename,
+ dist_info=dist_info_dir,
+ )
+
+ self._dist_info_dir = dist_info_dir
+ return dist_info_dir
+
+ @property
def dist_info_filenames(self) -> List[str]:
"""Get names of all files in the dist-info directory."""
base = self.dist_info_dir
@@ -138,6 +215,79 @@
path = posixpath.join(self.dist_info_dir, filename)
return self._zipfile.read(path).decode("utf-8")
+ def validate_record(self, *, validate_contents: bool = True) -> None:
+ """Validate ``RECORD`` of the wheel.
+
+ This method should be called before :py:func:`install
<installer.install>`
+ if validation is required.
+
+ File names will always be validated against ``RECORD``.
+
+ If ``validate_contents`` is true, sizes and hashes of files
+ will also be validated against ``RECORD``.
+
+ :param validate_contents: Whether to validate content integrity.
+ """
+ try:
+ record_lines = self.read_dist_info("RECORD").splitlines()
+ record_mapping = {
+ record[0]: record for record in parse_record_file(record_lines)
+ }
+ except Exception as exc:
+ raise _WheelFileValidationError(
+ [f"Unable to retrieve `RECORD` from {self._zipfile.filename}:
{exc!r}"]
+ ) from exc
+
+ issues: List[str] = []
+
+ for item in self._zipfile.infolist():
+ if item.filename[-1:] == "/": # looks like a directory
+ continue
+
+ record_args = record_mapping.pop(item.filename, None)
+
+ if self.dist_info_dir == posixpath.commonprefix(
+ [self.dist_info_dir, item.filename]
+ ) and item.filename.split("/")[-1] in ("RECORD.p7s", "RECORD.jws"):
+ # both are for digital signatures, and not mentioned in RECORD
+ if record_args is not None:
+ # Incorrectly contained
+ issues.append(
+ f"In {self._zipfile.filename}, digital signature file
{item.filename} is incorrectly contained in RECORD."
+ )
+ continue
+
+ if record_args is None:
+ issues.append(
+ f"In {self._zipfile.filename}, {item.filename} is not
mentioned in RECORD"
+ )
+ continue
+
+ record = RecordEntry.from_elements(*record_args)
+
+ if item.filename == f"{self.dist_info_dir}/RECORD":
+ # Assert that RECORD doesn't have size and hash.
+ if record.hash_ is not None or record.size is not None:
+ # Incorrectly contained hash / size
+ issues.append(
+ f"In {self._zipfile.filename}, RECORD file incorrectly
contains hash / size."
+ )
+ continue
+ if record.hash_ is None or record.size is None:
+ # Report empty hash / size
+ issues.append(
+ f"In {self._zipfile.filename}, hash / size of
{item.filename} is not included in RECORD"
+ )
+ if validate_contents:
+ data = self._zipfile.read(item)
+ if not record.validate(data):
+ issues.append(
+ f"In {self._zipfile.filename}, hash / size of
{item.filename} didn't match RECORD"
+ )
+
+ if issues:
+ raise _WheelFileValidationError(issues)
+
def get_contents(self) -> Iterator[WheelContentElement]:
"""Sequential access to all contents of the wheel (including dist-info
files).
@@ -154,11 +304,8 @@
if item.filename[-1:] == "/": # looks like a directory
continue
- record = record_mapping.pop(item.filename, None)
- assert record is not None, "In {}, {} is not mentioned in
RECORD".format(
- self._zipfile.filename,
- item.filename,
- ) # should not happen for valid wheels
+ # Pop record with empty default, because validation is handled by
`validate_record`
+ record = record_mapping.pop(item.filename, (item.filename, "", ""))
# Borrowed from:
#
https://github.com/pypa/pip/blob/0f21fb92/src/pip/_internal/utils/unpacking.py#L96-L100
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/installer-0.6.0/src/installer/utils.py
new/installer-0.7.0/src/installer/utils.py
--- old/installer-0.6.0/src/installer/utils.py 2022-12-07 03:28:06.840684400
+0100
+++ new/installer-0.7.0/src/installer/utils.py 2023-03-02 23:06:48.988392400
+0100
@@ -12,6 +12,7 @@
from configparser import ConfigParser
from email.message import Message
from email.parser import FeedParser
+from email.policy import compat32
from typing import (
TYPE_CHECKING,
BinaryIO,
@@ -89,11 +90,19 @@
:param contents: The entire contents of the file
"""
- feed_parser = FeedParser()
+ feed_parser = FeedParser(policy=compat32)
feed_parser.feed(contents)
return feed_parser.close()
+def canonicalize_name(name: str) -> str:
+ """Canonicalize a project name according to PEP-503.
+
+ :param name: The project name to canonicalize
+ """
+ return re.sub(r"[-_.]+", "-", name).lower()
+
+
def parse_wheel_filename(filename: str) -> WheelFilename:
"""Parse a wheel filename, into it's various components.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/installer-0.6.0/tests/conftest.py
new/installer-0.7.0/tests/conftest.py
--- old/installer-0.6.0/tests/conftest.py 2022-02-16 20:24:54.876479100
+0100
+++ new/installer-0.7.0/tests/conftest.py 2023-03-02 23:06:48.988669000
+0100
@@ -51,16 +51,14 @@
Platform: UNKNOWN
Classifier: Intended Audience :: Developers
""",
- # The RECORD file is indirectly validated by the WheelFile, since it
only
- # provides the items that are a part of the wheel.
"fancy-1.0.0.dist-info/RECORD": b"""\
- fancy/__init__.py,,
- fancy/__main__.py,,
- fancy-1.0.0.data/data/fancy/data.py,,
- fancy-1.0.0.dist-info/top_level.txt,,
- fancy-1.0.0.dist-info/entry_points.txt,,
- fancy-1.0.0.dist-info/WHEEL,,
- fancy-1.0.0.dist-info/METADATA,,
+
fancy/__init__.py,sha256=qZ2qq7xVBAiUFQVv-QBHhdtCUF5p1NsWwSOiD7qdHN0,36
+
fancy/__main__.py,sha256=Wd4SyWJOIMsHf_5-0oN6aNFwen8ehJnRo-erk2_K-eY,61
+
fancy-1.0.0.data/data/fancy/data.py,sha256=nuFRUNQF5vP7FWE-v5ysyrrfpIaAvfzSiGOgfPpLOeI,17
+
fancy-1.0.0.dist-info/top_level.txt,sha256=SW-yrrF_c8KlserorMw54inhLjZ3_YIuLz7fYT4f8ao,6
+
fancy-1.0.0.dist-info/entry_points.txt,sha256=AxJl21_zgoNWjCfvSkC9u_rWSzGyCtCzhl84n979jCc,75
+
fancy-1.0.0.dist-info/WHEEL,sha256=1DrXMF1THfnBjsdS5sZn-e7BKcmUn7jnMbShGeZomgc,84
+
fancy-1.0.0.dist-info/METADATA,sha256=hRhZavK_Y6WqKurFFAABDnoVMjZFBH0NJRjwLOutnJI,236
fancy-1.0.0.dist-info/RECORD,,
""",
}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/installer-0.6.0/tests/test_core.py
new/installer-0.7.0/tests/test_core.py
--- old/installer-0.6.0/tests/test_core.py 2022-12-07 03:28:06.841139300
+0100
+++ new/installer-0.7.0/tests/test_core.py 2023-03-02 23:06:48.989035600
+0100
@@ -65,6 +65,10 @@
def read_dist_info(self, filename):
return self.dist_info_files[filename]
+ def validate_record(self) -> None:
+ # Skip validation since the logic is different.
+ return
+
def get_contents(self):
# Sort for deterministic behaviour for Python versions that do not
preserve
# insertion order for dictionaries.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/installer-0.6.0/tests/test_records.py
new/installer-0.7.0/tests/test_records.py
--- old/installer-0.6.0/tests/test_records.py 2022-12-07 03:28:06.842787300
+0100
+++ new/installer-0.7.0/tests/test_records.py 2023-03-02 23:06:48.989353400
+0100
@@ -273,3 +273,12 @@
),
("distribution-1.0.dist-info/RECORD", "", ""),
]
+
+ def test_parse_record_entry_with_backslash_path(self):
+ record_lines = [
+ "distribution-1.0.dist-info\\RECORD,,",
+ ]
+ records = list(parse_record_file(record_lines))
+ assert records == [
+ ("distribution-1.0.dist-info/RECORD", "", ""),
+ ]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/installer-0.6.0/tests/test_sources.py
new/installer-0.7.0/tests/test_sources.py
--- old/installer-0.6.0/tests/test_sources.py 2022-02-16 20:24:54.878092800
+0100
+++ new/installer-0.7.0/tests/test_sources.py 2023-03-17 21:36:16.473140700
+0100
@@ -1,8 +1,12 @@
+import json
import posixpath
import zipfile
+from base64 import urlsafe_b64encode
+from hashlib import sha256
import pytest
+from installer.exceptions import InstallerError
from installer.records import parse_record_file
from installer.sources import WheelFile, WheelSource
@@ -30,6 +34,30 @@
with pytest.raises(NotImplementedError):
source.get_contents()
+ with pytest.raises(NotImplementedError):
+ source.validate_record()
+
+
+def replace_file_in_zip(path: str, filename: str, content: "str | None") ->
None:
+ """Helper function for replacing a file in the zip.
+
+ Exists because ZipFile doesn't support remove.
+ """
+ files = {}
+ # Copy everything except `filename`, and replace it with `content`.
+ with zipfile.ZipFile(path) as archive:
+ for file in archive.namelist():
+ if file == filename:
+ if content is None:
+ continue # Remove the file
+ files[file] = content.encode()
+ else:
+ files[file] = archive.read(file)
+ # Replace original archive
+ with zipfile.ZipFile(path, mode="w") as archive:
+ for name, content in files.items():
+ archive.writestr(name, content)
+
class TestWheelFile:
def test_rejects_not_okay_name(self, tmp_path):
@@ -92,3 +120,208 @@
assert sorted(got_records) == sorted(expected_records)
assert got_files == files
+
+ def test_finds_dist_info(self, fancy_wheel):
+ denorm = fancy_wheel.rename(fancy_wheel.parent /
"Fancy-1.0.0-py3-none-any.whl")
+ # Python 3.7: rename doesn't return the new name:
+ denorm = fancy_wheel.parent / "Fancy-1.0.0-py3-none-any.whl"
+ with WheelFile.open(denorm) as source:
+ assert source.dist_info_filenames
+
+ def test_requires_dist_info_name_match(self, fancy_wheel):
+ misnamed = fancy_wheel.rename(
+ fancy_wheel.parent / "misnamed-1.0.0-py3-none-any.whl"
+ )
+ # Python 3.7: rename doesn't return the new name:
+ misnamed = fancy_wheel.parent / "misnamed-1.0.0-py3-none-any.whl"
+ with pytest.raises(InstallerError) as ctx:
+ with WheelFile.open(misnamed) as source:
+ source.dist_info_filenames
+
+ error = ctx.value
+ print(error)
+ assert error.filename == str(misnamed)
+ assert error.dist_info == "fancy-1.0.0.dist-info"
+ assert "" in error.reason
+ assert error.dist_info in str(error)
+
+ def test_enforces_single_dist_info(self, fancy_wheel):
+ with zipfile.ZipFile(fancy_wheel, "a") as archive:
+ archive.writestr(
+ "name-1.0.0.dist-info/random.txt",
+ b"This is a random file.",
+ )
+
+ with pytest.raises(InstallerError) as ctx:
+ with WheelFile.open(fancy_wheel) as source:
+ source.dist_info_filenames
+
+ error = ctx.value
+ print(error)
+ assert error.filename == str(fancy_wheel)
+ assert error.dist_info == str(["fancy-1.0.0.dist-info",
"name-1.0.0.dist-info"])
+ assert "exactly one .dist-info" in error.reason
+ assert error.dist_info in str(error)
+
+ def test_rejects_no_record_on_validate(self, fancy_wheel):
+ # Remove RECORD
+ replace_file_in_zip(
+ fancy_wheel,
+ filename="fancy-1.0.0.dist-info/RECORD",
+ content=None,
+ )
+ with WheelFile.open(fancy_wheel) as source:
+ with pytest.raises(
+ WheelFile.validation_error, match="Unable to retrieve `RECORD`"
+ ):
+ source.validate_record(validate_contents=False)
+
+ def test_rejects_invalid_record_entry(self, fancy_wheel):
+ with WheelFile.open(fancy_wheel) as source:
+ record_file_contents = source.read_dist_info("RECORD")
+
+ replace_file_in_zip(
+ fancy_wheel,
+ filename="fancy-1.0.0.dist-info/RECORD",
+ content="\n".join(
+ line.replace("sha256=", "") for line in record_file_contents
+ ),
+ )
+ with WheelFile.open(fancy_wheel) as source:
+ with pytest.raises(
+ WheelFile.validation_error,
+ match="Unable to retrieve `RECORD`",
+ ):
+ source.validate_record()
+
+ def test_rejects_record_missing_file_on_validate(self, fancy_wheel):
+ with WheelFile.open(fancy_wheel) as source:
+ record_file_contents = source.read_dist_info("RECORD")
+
+ # Remove the first two entries from the RECORD file
+ new_record_file_contents =
"\n".join(record_file_contents.split("\n")[2:])
+ replace_file_in_zip(
+ fancy_wheel,
+ filename="fancy-1.0.0.dist-info/RECORD",
+ content=new_record_file_contents,
+ )
+ with WheelFile.open(fancy_wheel) as source:
+ with pytest.raises(
+ WheelFile.validation_error, match="not mentioned in RECORD"
+ ):
+ source.validate_record(validate_contents=False)
+
+ def test_rejects_record_missing_hash(self, fancy_wheel):
+ with WheelFile.open(fancy_wheel) as source:
+ record_file_contents = source.read_dist_info("RECORD")
+
+ new_record_file_contents = "\n".join(
+ line.split(",")[0] + ",," # file name with empty size and hash
+ for line in record_file_contents.split("\n")
+ )
+ replace_file_in_zip(
+ fancy_wheel,
+ filename="fancy-1.0.0.dist-info/RECORD",
+ content=new_record_file_contents,
+ )
+ with WheelFile.open(fancy_wheel) as source:
+ with pytest.raises(
+ WheelFile.validation_error,
+ match="hash / size of (.+) is not included in RECORD",
+ ):
+ source.validate_record(validate_contents=False)
+
+ def test_accept_wheel_with_signature_file(self, fancy_wheel):
+ with WheelFile.open(fancy_wheel) as source:
+ record_file_contents = source.read_dist_info("RECORD")
+ hash_b64_nopad = (
+ urlsafe_b64encode(sha256(record_file_contents.encode()).digest())
+ .decode("utf-8")
+ .rstrip("=")
+ )
+ jws_content = json.dumps({"hash": f"sha256={hash_b64_nopad}"})
+ with zipfile.ZipFile(fancy_wheel, "a") as archive:
+ archive.writestr("fancy-1.0.0.dist-info/RECORD.jws", jws_content)
+ with WheelFile.open(fancy_wheel) as source:
+ source.validate_record()
+
+ def test_reject_signature_file_in_record(self, fancy_wheel):
+ with WheelFile.open(fancy_wheel) as source:
+ record_file_contents = source.read_dist_info("RECORD")
+ record_hash_nopad = (
+ urlsafe_b64encode(sha256(record_file_contents.encode()).digest())
+ .decode("utf-8")
+ .rstrip("=")
+ )
+ jws_content = json.dumps({"hash": f"sha256={record_hash_nopad}"})
+ with zipfile.ZipFile(fancy_wheel, "a") as archive:
+ archive.writestr("fancy-1.0.0.dist-info/RECORD.jws", jws_content)
+
+ # Add signature file to RECORD
+ jws_content = jws_content.encode()
+ jws_hash_nopad = (
+
urlsafe_b64encode(sha256(jws_content).digest()).decode("utf-8").rstrip("=")
+ )
+ replace_file_in_zip(
+ fancy_wheel,
+ filename="fancy-1.0.0.dist-info/RECORD",
+ content=record_file_contents.rstrip("\n")
+ +
f"\nfancy-1.0.0.dist-info/RECORD.jws,sha256={jws_hash_nopad},{len(jws_content)}\n",
+ )
+ with WheelFile.open(fancy_wheel) as source:
+ with pytest.raises(
+ WheelFile.validation_error,
+ match="digital signature file (.+) is incorrectly contained in
RECORD.",
+ ):
+ source.validate_record(validate_contents=False)
+
+ def test_rejects_record_contain_self_hash(self, fancy_wheel):
+ with WheelFile.open(fancy_wheel) as source:
+ record_file_contents = source.read_dist_info("RECORD")
+
+ new_record_file_lines = []
+ for line in record_file_contents.split("\n"):
+ if not line:
+ continue
+ filename, hash_, size = line.split(",")
+ if filename.split("/")[-1] == "RECORD":
+ hash_ = "sha256=pREiHcl39jRySUXMCOrwmSsnOay8FB7fOJP5mZQ3D3A"
+ size = str(len(record_file_contents))
+ new_record_file_lines.append(",".join((filename, hash_, size)))
+
+ replace_file_in_zip(
+ fancy_wheel,
+ filename="fancy-1.0.0.dist-info/RECORD",
+ content="\n".join(new_record_file_lines),
+ )
+ with WheelFile.open(fancy_wheel) as source:
+ with pytest.raises(
+ WheelFile.validation_error,
+ match="RECORD file incorrectly contains hash / size.",
+ ):
+ source.validate_record(validate_contents=False)
+
+ def test_rejects_record_validation_failed(self, fancy_wheel):
+ with WheelFile.open(fancy_wheel) as source:
+ record_file_contents = source.read_dist_info("RECORD")
+
+ new_record_file_lines = []
+ for line in record_file_contents.split("\n"):
+ if not line:
+ continue
+ filename, hash_, size = line.split(",")
+ if filename.split("/")[-1] != "RECORD":
+ hash_ = "sha256=pREiHcl39jRySUXMCOrwmSsnOay8FB7fOJP5mZQ3D3A"
+ new_record_file_lines.append(",".join((filename, hash_, size)))
+
+ replace_file_in_zip(
+ fancy_wheel,
+ filename="fancy-1.0.0.dist-info/RECORD",
+ content="\n".join(new_record_file_lines),
+ )
+ with WheelFile.open(fancy_wheel) as source:
+ with pytest.raises(
+ WheelFile.validation_error,
+ match="hash / size of (.+) didn't match RECORD",
+ ):
+ source.validate_record()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/installer-0.6.0/tests/test_utils.py
new/installer-0.7.0/tests/test_utils.py
--- old/installer-0.6.0/tests/test_utils.py 2022-12-07 03:28:06.843163700
+0100
+++ new/installer-0.7.0/tests/test_utils.py 2023-03-02 23:06:44.167729900
+0100
@@ -13,6 +13,7 @@
from installer.records import RecordEntry
from installer.utils import (
WheelFilename,
+ canonicalize_name,
construct_record_file,
copyfileobj_with_hashing,
fix_shebang,
@@ -41,6 +42,27 @@
assert result.get_all("MULTI-USE-FIELD") == ["1", "2", "3"]
+class TestCanonicalizeDistributionName:
+ @pytest.mark.parametrize(
+ "string, expected",
+ [
+ # Noop
+ (
+ "package-1",
+ "package-1",
+ ),
+ # PEP 508 canonicalization
+ (
+ "ABC..12",
+ "abc-12",
+ ),
+ ],
+ )
+ def test_valid_cases(self, string, expected):
+ got = canonicalize_name(string)
+ assert expected == got, (expected, got)
+
+
class TestParseWheelFilename:
@pytest.mark.parametrize(
"string, expected",