Skip to content

Commit 246e22b

Browse files
authored
Block duplicate files in wheel archives (closes #2066) (#2269)
Using `force-include`, it was possible to create invalid wheels. For example, one could add a `MANIFEST` file, and zipfile will (un)happily add both to the archive, emitting only a warning. This commit will catch such cases and raise an exception instead.
1 parent d2afcb6 commit 246e22b

3 files changed

Lines changed: 55 additions & 2 deletions

File tree

backend/src/hatchling/builders/wheel.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,21 @@ def __exit__(
6565
self.__file_obj.close()
6666

6767

68+
class _WheelZipFile(zipfile.ZipFile):
69+
def open(self, name, mode="r", pwd=None, *, force_zip64=False):
70+
filename = name.filename if isinstance(name, zipfile.ZipInfo) else name
71+
if mode == "w" and filename in self.NameToInfo:
72+
message = (
73+
f"A second file is being added to the wheel archive at the same path: `{filename}`.\n\n"
74+
f"The most likely cause of this is an entry in the "
75+
f"`tool.hatch.build.targets.wheel.force-include` table. See: "
76+
f"https://hatch.pypa.io/1.8/config/build/#forced-inclusion\n\n"
77+
)
78+
raise ValueError(message)
79+
80+
return super().open(name, mode, pwd, force_zip64=force_zip64)
81+
82+
6883
class WheelArchive:
6984
def __init__(self, project_id: str, *, reproducible: bool) -> None:
7085
"""
@@ -82,7 +97,7 @@ def __init__(self, project_id: str, *, reproducible: bool) -> None:
8297

8398
raw_fd, self.path = tempfile.mkstemp(suffix=".whl")
8499
self.fd = os.fdopen(raw_fd, "w+b")
85-
self.zf = zipfile.ZipFile(self.fd, "w", compression=zipfile.ZIP_DEFLATED)
100+
self.zf = _WheelZipFile(self.fd, "w", compression=zipfile.ZIP_DEFLATED)
86101

87102
@staticmethod
88103
def get_reproducible_time_tuple() -> TIME_TUPLE:

docs/config/build.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ For example, if there was a directory alongside the project root named `artifact
118118
- Sources that do not exist will raise an error.
119119

120120
!!! warning
121-
Files included using this option will overwrite any file path that was already included by other file selection options.
121+
Attempting to overwrite any file path that was already included by other file selection options will raise an error.
122122

123123
### Default file selection
124124

tests/backend/builders/test_wheel.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,44 @@ def test_bypass_selection_option(self, temp_dir):
207207
assert builder.config.default_packages() == []
208208
assert builder.config.default_only_include() == []
209209

210+
def test_force_include_duplicate_path(self, hatch, temp_dir, config_file):
211+
config_file.model.template.plugins["default"]["src-layout"] = False
212+
config_file.save()
213+
214+
project_name = "My.App"
215+
216+
with temp_dir.as_cwd():
217+
result = hatch("new", project_name)
218+
219+
assert result.exit_code == 0, result.output
220+
221+
project_path = temp_dir / "my-app"
222+
(project_path / "my_datafile").write_text("hello world")
223+
config = {
224+
"project": {"name": project_name, "dynamic": ["version"]},
225+
"tool": {
226+
"hatch": {
227+
"version": {"path": "my_app/__about__.py"},
228+
"build": {
229+
"targets": {
230+
"wheel": {
231+
"force-include": {
232+
"my_datafile": "my_app-0.0.1.dist-info/METADATA",
233+
},
234+
},
235+
},
236+
},
237+
},
238+
},
239+
}
240+
builder = WheelBuilder(str(project_path), config=config)
241+
242+
build_path = project_path / "dist"
243+
build_path.mkdir()
244+
245+
with pytest.raises(ValueError, match="second file is being added"):
246+
list(builder.build(directory=str(build_path)))
247+
210248
def test_force_include_option_considered_selection(self, temp_dir):
211249
config = {
212250
"project": {"name": "my-app", "version": "0.0.1"},

0 commit comments

Comments
 (0)