Skip to content

Commit ed47a74

Browse files
authored
Improve handling of non-normalized .dist-info folders (#168)
Co-authored-by: Pradyun Gedam <[email protected]>
1 parent 50ed1cc commit ed47a74

File tree

2 files changed

+80
-27
lines changed

2 files changed

+80
-27
lines changed

src/installer/sources.py

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
import stat
66
import zipfile
77
from contextlib import contextmanager
8-
from typing import BinaryIO, ClassVar, Iterator, List, Tuple, Type, cast
8+
from typing import BinaryIO, ClassVar, Iterator, List, Optional, Tuple, Type, cast
99

10+
from installer.exceptions import InstallerError
1011
from installer.records import RecordEntry, parse_record_file
1112
from installer.utils import canonicalize_name, parse_wheel_filename
1213

@@ -101,17 +102,32 @@ def get_contents(self) -> Iterator[WheelContentElement]:
101102
raise NotImplementedError
102103

103104

104-
class _WheelFileValidationError(ValueError):
105+
class _WheelFileValidationError(ValueError, InstallerError):
105106
"""Raised when a wheel file fails validation."""
106107

107-
def __init__(self, issues: List[str]) -> None: # noqa: D107
108+
def __init__(self, issues: List[str]) -> None:
108109
super().__init__(repr(issues))
109110
self.issues = issues
110111

111112
def __repr__(self) -> str:
112113
return f"WheelFileValidationError(issues={self.issues!r})"
113114

114115

116+
class _WheelFileBadDistInfo(ValueError, InstallerError):
117+
"""Raised when a wheel file has issues around `.dist-info`."""
118+
119+
def __init__(self, *, reason: str, filename: Optional[str], dist_info: str) -> None:
120+
super().__init__(reason)
121+
self.reason = reason
122+
self.filename = filename
123+
self.dist_info = dist_info
124+
125+
def __str__(self) -> str:
126+
return (
127+
f"{self.reason} (filename={self.filename!r}, dist_info={self.dist_info!r})"
128+
)
129+
130+
115131
class WheelFile(WheelSource):
116132
"""Implements `WheelSource`, for an existing file from the filesystem.
117133
@@ -137,6 +153,7 @@ def __init__(self, f: zipfile.ZipFile) -> None:
137153
version=parsed_name.version,
138154
distribution=parsed_name.distribution,
139155
)
156+
self._dist_info_dir: Optional[str] = None
140157

141158
@classmethod
142159
@contextmanager
@@ -148,29 +165,39 @@ def open(cls, path: "os.PathLike[str]") -> Iterator["WheelFile"]:
148165
@property
149166
def dist_info_dir(self) -> str:
150167
"""Name of the dist-info directory."""
151-
if not hasattr(self, "_dist_info_dir"):
152-
top_level_directories = {
153-
path.split("/", 1)[0] for path in self._zipfile.namelist()
154-
}
155-
dist_infos = [
156-
name for name in top_level_directories if name.endswith(".dist-info")
157-
]
158-
159-
assert (
160-
len(dist_infos) == 1
161-
), "Wheel doesn't contain exactly one .dist-info directory"
162-
dist_info_dir = dist_infos[0]
163-
164-
# NAME-VER.dist-info
165-
di_dname = dist_info_dir.rsplit("-", 2)[0]
166-
norm_di_dname = canonicalize_name(di_dname)
167-
norm_file_dname = canonicalize_name(self.distribution)
168-
assert (
169-
norm_di_dname == norm_file_dname
170-
), "Wheel .dist-info directory doesn't match wheel filename"
171-
172-
self._dist_info_dir = dist_info_dir
173-
return self._dist_info_dir
168+
if self._dist_info_dir is not None:
169+
return self._dist_info_dir
170+
171+
top_level_directories = {
172+
path.split("/", 1)[0] for path in self._zipfile.namelist()
173+
}
174+
dist_infos = [
175+
name for name in top_level_directories if name.endswith(".dist-info")
176+
]
177+
178+
try:
179+
(dist_info_dir,) = dist_infos
180+
except ValueError:
181+
raise _WheelFileBadDistInfo(
182+
reason="Wheel doesn't contain exactly one .dist-info directory",
183+
filename=self._zipfile.filename,
184+
dist_info=str(sorted(dist_infos)),
185+
) from None
186+
187+
# NAME-VER.dist-info
188+
di_dname = dist_info_dir.rsplit("-", 2)[0]
189+
norm_di_dname = canonicalize_name(di_dname)
190+
norm_file_dname = canonicalize_name(self.distribution)
191+
192+
if norm_di_dname != norm_file_dname:
193+
raise _WheelFileBadDistInfo(
194+
reason="Wheel .dist-info directory doesn't match wheel filename",
195+
filename=self._zipfile.filename,
196+
dist_info=dist_info_dir,
197+
)
198+
199+
self._dist_info_dir = dist_info_dir
200+
return dist_info_dir
174201

175202
@property
176203
def dist_info_filenames(self) -> List[str]:

tests/test_sources.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import pytest
88

9+
from installer.exceptions import InstallerError
910
from installer.records import parse_record_file
1011
from installer.sources import WheelFile, WheelSource
1112

@@ -133,10 +134,35 @@ def test_requires_dist_info_name_match(self, fancy_wheel):
133134
)
134135
# Python 3.7: rename doesn't return the new name:
135136
misnamed = fancy_wheel.parent / "misnamed-1.0.0-py3-none-any.whl"
136-
with pytest.raises(AssertionError):
137+
with pytest.raises(InstallerError) as ctx:
137138
with WheelFile.open(misnamed) as source:
138139
source.dist_info_filenames
139140

141+
error = ctx.value
142+
print(error)
143+
assert error.filename == str(misnamed)
144+
assert error.dist_info == "fancy-1.0.0.dist-info"
145+
assert "" in error.reason
146+
assert error.dist_info in str(error)
147+
148+
def test_enforces_single_dist_info(self, fancy_wheel):
149+
with zipfile.ZipFile(fancy_wheel, "a") as archive:
150+
archive.writestr(
151+
"name-1.0.0.dist-info/random.txt",
152+
b"This is a random file.",
153+
)
154+
155+
with pytest.raises(InstallerError) as ctx:
156+
with WheelFile.open(fancy_wheel) as source:
157+
source.dist_info_filenames
158+
159+
error = ctx.value
160+
print(error)
161+
assert error.filename == str(fancy_wheel)
162+
assert error.dist_info == str(["fancy-1.0.0.dist-info", "name-1.0.0.dist-info"])
163+
assert "exactly one .dist-info" in error.reason
164+
assert error.dist_info in str(error)
165+
140166
def test_rejects_no_record_on_validate(self, fancy_wheel):
141167
# Remove RECORD
142168
replace_file_in_zip(

0 commit comments

Comments
 (0)