Skip to content

Commit 3914e3e

Browse files
rd4398claude
andcommitted
fix(candidate): use exact dist-info path in get_metadata_for_wheel()
`get_metadata_for_wheel()` returned the first zip entry matching `*.dist-info/METADATA`, which could be a vendored package's metadata when a wheel bundles multiple dist-info directories. Derive the correct `{name}-{version}.dist-info/METADATA` path from the wheel URL instead and read it directly. Closes: #1146 Co-Authored-By: Claude <claude@anthropic.com> Signed-off-by: Rohan Devasthale <rdevasth@redhat.com>
1 parent cc322cd commit 3914e3e

6 files changed

Lines changed: 158 additions & 32 deletions

File tree

src/fromager/candidate.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import dataclasses
22
import datetime
33
import logging
4+
import posixpath
45
import typing
56
from io import BytesIO
7+
from urllib.parse import urlparse
68
from zipfile import ZipFile
79

810
from packaging.metadata import Metadata
911
from packaging.requirements import Requirement
1012
from packaging.utils import BuildTag, canonicalize_name
1113
from packaging.version import Version
1214

15+
from .pkgmetadata.pep376 import dist_info_name
1316
from .request_session import session
1417

1518
logger = logging.getLogger(__name__)
@@ -141,12 +144,15 @@ def get_metadata_for_wheel(
141144
logger.debug(f"Downloading full wheel to extract metadata: {url}")
142145
data = session.get(url).content
143146
with ZipFile(BytesIO(data)) as z:
144-
for n in z.namelist():
145-
if n.endswith(".dist-info/METADATA"):
146-
metadata_content = z.read(n)
147-
# Parse metadata directly using packaging.metadata.Metadata
148-
# (avoiding circular import with dependencies module)
149-
return Metadata.from_email(metadata_content, validate=validate)
150-
151-
# If we didn't find the metadata, raise an error
152-
raise ValueError(f"Could not find METADATA file in wheel: {url}")
147+
metadata_path = _wheel_metadata_path(url)
148+
try:
149+
metadata_content = z.read(metadata_path)
150+
except KeyError as err:
151+
raise ValueError(f"Could not find {metadata_path} in wheel: {url}") from err
152+
return Metadata.from_email(metadata_content, validate=validate)
153+
154+
155+
def _wheel_metadata_path(url: str) -> str:
156+
"""Derive the METADATA path inside a wheel from its URL."""
157+
filename = posixpath.basename(urlparse(url).path)
158+
return f"{dist_info_name(filename)}/METADATA"

src/fromager/dependencies.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import tomlkit
1313
from packaging.metadata import Metadata
1414
from packaging.requirements import Requirement
15-
from packaging.utils import NormalizedName, canonicalize_name, parse_wheel_filename
15+
from packaging.utils import NormalizedName, canonicalize_name
1616
from packaging.version import Version
1717

1818
from . import (
@@ -22,6 +22,7 @@
2222
packagesettings,
2323
requirements_file,
2424
)
25+
from .pkgmetadata.pep376 import dist_info_name
2526

2627
if typing.TYPE_CHECKING:
2728
from . import context
@@ -475,19 +476,15 @@ def _get_metadata_from_wheel(
475476
Raises:
476477
ValueError: If no METADATA file is found in the wheel
477478
"""
478-
# Get dist-info path from wheel filename.
479-
# Uses same pattern as wheels.extract_info_from_wheel_file:
480-
_, dist_version, _, _ = parse_wheel_filename(wheel_filename.name)
481-
dist_name = wheel_filename.name.split("-", 1)[0]
482-
metadata_path = f"{dist_name}-{dist_version}.dist-info/METADATA"
479+
metadata_path = f"{dist_info_name(wheel_filename.name)}/METADATA"
483480

484481
with zipfile.ZipFile(wheel_filename) as whl:
485482
try:
486483
metadata_content = whl.read(metadata_path)
487-
except KeyError:
484+
except KeyError as err:
488485
raise ValueError(
489486
f"Could not find METADATA file in wheel: {wheel_filename}"
490-
) from None
487+
) from err
491488
return parse_metadata(metadata_content, validate=validate)
492489

493490

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
"""Package metadata helpers
22
3+
- PEP 376 dist-info directory handling
34
- PEP 639-based license detection
45
- PEP 753 project URL normalization
56
"""
67

8+
from .pep376 import dist_info_name, verbatim_dist_name
79
from .pep639 import license_from_metadata, license_from_metadata_values
810
from .pep753 import normalize_project_urls, project_urls_from_metadata
911

1012
__all__ = (
13+
"dist_info_name",
1114
"license_from_metadata",
1215
"license_from_metadata_values",
1316
"normalize_project_urls",
1417
"project_urls_from_metadata",
18+
"verbatim_dist_name",
1519
)

src/fromager/pkgmetadata/pep376.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Dist-info directory helpers
2+
3+
- https://peps.python.org/pep-0376/
4+
"""
5+
6+
from packaging.utils import parse_wheel_filename
7+
8+
9+
def verbatim_dist_name(wheel_filename: str) -> str:
10+
"""Return the verbatim distribution name from a wheel filename.
11+
12+
``parse_wheel_filename`` normalises the name, but the dist-info
13+
directory inside a wheel uses the original casing (e.g.
14+
``MarkupSafe``, not ``markupsafe``). This helper extracts the
15+
first ``-``-separated segment after validating the filename.
16+
"""
17+
parse_wheel_filename(wheel_filename)
18+
return wheel_filename.split("-", 1)[0]
19+
20+
21+
def dist_info_name(wheel_filename: str) -> str:
22+
"""Return the dist-info directory name for a wheel filename.
23+
24+
Preserves the verbatim distribution name (e.g. ``MarkupSafe``, not
25+
``markupsafe``) because the dist-info directory inside a wheel uses
26+
the original casing.
27+
"""
28+
parse_wheel_filename(wheel_filename)
29+
name, version = wheel_filename.split("-", 2)[:2]
30+
return f"{name}-{version}.dist-info"

src/fromager/wheels.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from packaging.tags import Tag
1818
from packaging.utils import (
1919
BuildTag,
20-
canonicalize_name,
2120
parse_wheel_filename,
2221
)
2322
from packaging.version import Version
@@ -33,6 +32,7 @@
3332
sbom,
3433
sources,
3534
)
35+
from .pkgmetadata.pep376 import verbatim_dist_name
3636

3737
if typing.TYPE_CHECKING:
3838
from . import build_environment, context
@@ -143,20 +143,11 @@ def extract_info_from_wheel_file(
143143
144144
Returns the **verbatim** dist name (not normalized) because the
145145
dist-info directory inside the wheel uses the original casing
146-
(e.g. ``MarkupSafe``, not ``markupsafe``). Uses ``parse_wheel_filename``
147-
for validation and to extract version, build tag, and platform tags.
146+
(e.g. ``MarkupSafe``, not ``markupsafe``).
148147
"""
149-
# parse_wheel_filename normalizes the dist name, however the dist-info
150-
# directory uses the verbatim distribution name from the wheel file.
151-
# Packages with upper case names like "MarkupSafe" are affected.
152-
dist_name_normalized, dist_version, build_tag, wheel_tags = parse_wheel_filename(
153-
wheel_file.name
154-
)
155-
dist_name = wheel_file.name.split("-", 1)[0]
156-
if dist_name_normalized != canonicalize_name(dist_name):
157-
# sanity check, should never fail
158-
raise ValueError(f"{dist_name_normalized} does not match {dist_name}")
159-
return (dist_name, dist_version, build_tag, wheel_tags)
148+
_, version, build_tag, wheel_tags = parse_wheel_filename(wheel_file.name)
149+
dist_name = verbatim_dist_name(wheel_file.name)
150+
return (dist_name, version, build_tag, wheel_tags)
160151

161152

162153
def default_add_extra_metadata_to_wheels(

tests/test_pep658_support.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
"""Tests for PEP 658 metadata support."""
22

33
import typing
4+
from io import BytesIO
45
from unittest.mock import Mock, patch
6+
from zipfile import ZipFile
57

8+
import pytest
69
from packaging.version import Version
710

8-
from fromager.candidate import Candidate, get_metadata_for_wheel
11+
from fromager.candidate import (
12+
Candidate,
13+
_wheel_metadata_path,
14+
get_metadata_for_wheel,
15+
)
16+
from fromager.pkgmetadata.pep376 import dist_info_name
917

1018

1119
class TestPEP658Support:
@@ -191,3 +199,93 @@ def test_pep658_integration_with_resolver(self) -> None:
191199
assert candidate_with_metadata.name == candidate_without_metadata.name
192200
assert candidate_with_metadata.version == candidate_without_metadata.version
193201
assert candidate_with_metadata.url == candidate_without_metadata.url
202+
203+
204+
class TestDistInfoName:
205+
"""Test dist_info_name — single source of truth for dist-info directory names."""
206+
207+
def test_standard_wheel(self) -> None:
208+
assert (
209+
dist_info_name("test_package-1.0.0-py3-none-any.whl")
210+
== "test_package-1.0.0.dist-info"
211+
)
212+
213+
def test_preserves_verbatim_casing(self) -> None:
214+
assert (
215+
dist_info_name("MarkupSafe-2.1.0-cp311-cp311-linux_x86_64.whl")
216+
== "MarkupSafe-2.1.0.dist-info"
217+
)
218+
219+
def test_non_wheel_extension_raises(self) -> None:
220+
with pytest.raises(ValueError, match="Invalid wheel filename"):
221+
dist_info_name("pkg-1.0.tar.gz")
222+
223+
def test_malformed_wheel_filename_raises(self) -> None:
224+
with pytest.raises(ValueError, match="Invalid wheel filename"):
225+
dist_info_name("pkg-1.0-bad.whl")
226+
227+
228+
class TestWheelMetadataPath:
229+
"""Test _wheel_metadata_path — URL to METADATA zip path."""
230+
231+
def test_simple_wheel_url(self) -> None:
232+
url = "https://pkg.test/simple/test_package-1.0.0-py3-none-any.whl"
233+
assert _wheel_metadata_path(url) == "test_package-1.0.0.dist-info/METADATA"
234+
235+
def test_preserves_original_casing(self) -> None:
236+
url = "https://pkg.test/simple/MarkupSafe-2.1.0-cp311-cp311-linux_x86_64.whl"
237+
assert _wheel_metadata_path(url) == "MarkupSafe-2.1.0.dist-info/METADATA"
238+
239+
def test_url_with_path_segments(self) -> None:
240+
url = "https://pkg.test/packages/ab/cd/my_pkg-0.9-py3-none-any.whl"
241+
assert _wheel_metadata_path(url) == "my_pkg-0.9.dist-info/METADATA"
242+
243+
def test_url_with_fragment(self) -> None:
244+
url = "https://pkg.test/simple/pkg-1.0-py3-none-any.whl#sha256=abc123"
245+
assert _wheel_metadata_path(url) == "pkg-1.0.dist-info/METADATA"
246+
247+
@patch("fromager.candidate.session")
248+
def test_fallback_selects_correct_distinfo(self, mock_session: typing.Any) -> None:
249+
"""Wheels with vendored dist-info directories must not confuse extraction."""
250+
# Build a wheel zip with a vendored dist-info AND the real one
251+
buf = BytesIO()
252+
with ZipFile(buf, "w") as zf:
253+
zf.writestr(
254+
"vendored/six-1.16.0.dist-info/METADATA",
255+
"Metadata-Version: 2.1\nName: six\nVersion: 1.16.0\n",
256+
)
257+
zf.writestr(
258+
"my_pkg-1.0.0.dist-info/METADATA",
259+
(
260+
"Metadata-Version: 2.1\n"
261+
"Name: my-pkg\n"
262+
"Version: 1.0.0\n"
263+
"Summary: The real package\n"
264+
),
265+
)
266+
267+
mock_response = Mock()
268+
mock_response.content = buf.getvalue()
269+
mock_session.get.return_value = mock_response
270+
271+
wheel_url = "https://pkg.test/simple/my_pkg-1.0.0-py3-none-any.whl"
272+
metadata = get_metadata_for_wheel(wheel_url, metadata_url=None)
273+
274+
assert metadata.name == "my-pkg"
275+
assert str(metadata.version) == "1.0.0"
276+
assert metadata.summary == "The real package"
277+
278+
@patch("fromager.candidate.session")
279+
def test_fallback_missing_metadata_raises(self, mock_session: typing.Any) -> None:
280+
"""A wheel without the expected dist-info/METADATA raises ValueError."""
281+
buf = BytesIO()
282+
with ZipFile(buf, "w") as zf:
283+
zf.writestr("some_other_file.txt", "hello")
284+
285+
mock_response = Mock()
286+
mock_response.content = buf.getvalue()
287+
mock_session.get.return_value = mock_response
288+
289+
wheel_url = "https://pkg.test/simple/my_pkg-1.0.0-py3-none-any.whl"
290+
with pytest.raises(ValueError, match="Could not find"):
291+
get_metadata_for_wheel(wheel_url, metadata_url=None)

0 commit comments

Comments
 (0)