Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 28 additions & 16 deletions src/fromager/bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -926,26 +926,34 @@ def _look_for_existing_wheel(
search_in: pathlib.Path,
) -> tuple[pathlib.Path | None, pathlib.Path | None]:
pbi = self.ctx.package_build_info(req)
expected_build_tag = pbi.build_tag(resolved_version)
base_build_tag = pbi.build_tag(resolved_version)
logger.info(
f"looking for existing wheel for version {resolved_version} with build tag {expected_build_tag} in {search_in}"
f"looking for existing wheel for version {resolved_version} with build tag {base_build_tag} in {search_in}"
)
wheel_filename = finders.find_wheel(
downloads_dir=search_in,
req=req,
dist_version=str(resolved_version),
build_tag=expected_build_tag,
build_tag=base_build_tag,
)
if not wheel_filename:
return None, None

# Re-validate build tag from the actual wheel metadata because
# finders.find_wheel matches by filename prefix, which may not
# enforce an exact build tag match.
_, _, build_tag, _ = wheels.extract_info_from_wheel_file(req, wheel_filename)
if expected_build_tag and expected_build_tag != build_tag:
# Re-validate build tag from the actual wheel metadata.
# Compute the full expected tag (including any hook suffix)
# now that we have the wheel's tags.
_, _, actual_build_tag, wheel_tags = wheels.extract_info_from_wheel_file(
req, wheel_filename
)
expected_build_tag = wheels.get_build_tag(
ctx=self.ctx,
req=req,
version=resolved_version,
wheel_tags=wheel_tags,
)
if expected_build_tag and expected_build_tag != actual_build_tag:
logger.info(
f"found wheel for {resolved_version} in {wheel_filename} but build tag does not match. Got {build_tag} but expected {expected_build_tag}"
f"found wheel for {resolved_version} in {wheel_filename} but build tag does not match. Got {actual_build_tag} but expected {expected_build_tag}"
Comment on lines +948 to +956

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Normalize empty build tags before comparing.

These checks only reject mismatches when expected_build_tag is truthy, so () will silently accept a cached wheel whose filename carries a real build tag. That is inconsistent with src/fromager/commands/build.py, which normalizes falsy tags to (0, "") and compares exactly. As written, the bootstrapper can reuse a wheel that the build verification path would reject.

Suggested fix
-        if expected_build_tag and expected_build_tag != actual_build_tag:
+        normalized_expected_build_tag = expected_build_tag or (0, "")
+        normalized_actual_build_tag = actual_build_tag or (0, "")
+        if normalized_expected_build_tag != normalized_actual_build_tag:
             logger.info(
                 f"found wheel for {resolved_version} in {wheel_filename} but build tag does not match. Got {actual_build_tag} but expected {expected_build_tag}"
             )
             return None, None
-            if expected_build_tag and expected_build_tag != actual_build_tag:
+            normalized_expected_build_tag = expected_build_tag or (0, "")
+            normalized_actual_build_tag = actual_build_tag or (0, "")
+            if normalized_expected_build_tag != normalized_actual_build_tag:
                 logger.info(
                     f"found wheel for {resolved_version} in cache but build tag does not match. Got {actual_build_tag} but expected {expected_build_tag}"
                 )
                 return None, None

Also applies to: 992-1000

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/fromager/bootstrapper.py` around lines 948 - 956, Normalize build tags
before comparing in the bootstrapper cache check so empty tags are treated
consistently with the build verification path. Update the logic around
expected_build_tag in the bootstrapper flow to normalize falsy values to the
same canonical form used in src/fromager/commands/build.py (for example via the
existing build-tag helper or equivalent normalization) before comparing against
actual_build_tag. Apply the same fix anywhere the bootstrapper repeats this
check, including the other build-tag comparison block, so cached wheels are
rejected whenever their normalized tag differs.

)
return None, None

Expand Down Expand Up @@ -973,19 +981,23 @@ def _download_wheel_from_cache(
wheel_url, _ = results[0]
wheelfile_name = pathlib.Path(urlparse(wheel_url).path)
pbi = self.ctx.package_build_info(req)
expected_build_tag = pbi.build_tag(resolved_version)
# Log the expected build tag for debugging
logger.info(f"has expected build tag {expected_build_tag}")
# Get changelogs for debug info
base_build_tag = pbi.build_tag(resolved_version)
logger.info(f"has expected base build tag {base_build_tag}")
changelogs = pbi.get_changelog(resolved_version)
logger.debug(f"has change logs {changelogs}")

_, _, build_tag, _ = wheels.extract_info_from_wheel_file(
_, _, actual_build_tag, wheel_tags = wheels.extract_info_from_wheel_file(
req, wheelfile_name
)
if expected_build_tag and expected_build_tag != build_tag:
expected_build_tag = wheels.get_build_tag(
ctx=self.ctx,
req=req,
version=resolved_version,
wheel_tags=wheel_tags,
)
Comment on lines +992 to +997

@coderabbitai coderabbitai Bot Jun 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don’t downgrade hook validation failures to a cache miss.

wheels.get_build_tag() now validates hook output, but this call is still covered by the broad except Exception below. A buggy build_tag_hook will be logged as an unexpected cache lookup error and execution will continue, even though these errors are supposed to fail the build.

Also applies to: 1027-1031

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/fromager/bootstrapper.py` around lines 992 - 997, The broad cache-lookup
exception handling around wheels.get_build_tag() is masking build_tag_hook
validation failures as recoverable cache misses. Update the try/except logic in
the bootstrapper code path that calls wheels.get_build_tag() so hook validation
errors are not caught by the generic except Exception and are allowed to fail
the build, while still preserving cache-miss handling for genuine lookup issues;
apply the same fix to both call sites in the bootstrapper flow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_download_wheel_from_cache uses except ResolverException (line 1016), not except Exception. A ValueError from the hook would propagate just fine. And _look_for_existing_wheel has no try/except at all.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shifa-khan, the except ResolverException isn't the only handler in _download_wheel_from_cache. There's also a broad except Exception as err further down in the same method:

        except ResolverException:
            logger.info(...)
            return None, None
        except requests.exceptions.RequestException as err:
            logger.warning(...)
            return None, None
        except Exception as err:                          # ← this one
            logger.warning(
                f"unexpected error checking wheel cache for {resolved_version} "
                f"at {self.cache_wheel_server_url}: {err}"
            )
            return None, None

A ValueError from wheels.get_build_tag() (hook validation failure) falls through to that last clause, gets logged as an "unexpected error checking wheel cache", and returns (None, None) — silently treating a hook bug as a cache miss. The build continues unchecked.

You're right that _look_for_existing_wheel is clean (no try/except there).


🧠 Learnings used
Learnt from: tiran
Repo: python-wheel-build/fromager PR: 1202
File: src/fromager/sources.py:207-223
Timestamp: 2026-06-24T11:25:31.914Z
Learning: In this fromager codebase, any VCS URL parsing for pip-style `git+https://...` or `git+ssh://...` must use the single canonical URL-parsing helper introduced in PR `#1215` (the shared utility), not new inline/ad-hoc parsers. If reviewing code that currently parses these URLs in an ad-hoc way (e.g., in `sources.py` or elsewhere), refactor to call the shared helper to avoid divergent behavior.

if expected_build_tag and expected_build_tag != actual_build_tag:
logger.info(
f"found wheel for {resolved_version} in cache but build tag does not match. Got {build_tag} but expected {expected_build_tag}"
f"found wheel for {resolved_version} in cache but build tag does not match. Got {actual_build_tag} but expected {expected_build_tag}"
)
return None, None

Expand Down
12 changes: 8 additions & 4 deletions src/fromager/commands/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,12 +492,16 @@ def _is_wheel_built(
wheel_server_urls=wheel_server_urls,
)
logger.info("found candidate wheel %s", url)
pbi = wkctx.package_build_info(req)
build_tag_from_settings = pbi.build_tag(resolved_version)
build_tag = build_tag_from_settings if build_tag_from_settings else (0, "")
wheel_basename = resolver.extract_filename_from_url(url)
_, _, build_tag_from_name, _ = parse_wheel_filename(wheel_basename)
_, _, build_tag_from_name, wheel_tags = parse_wheel_filename(wheel_basename)
existing_build_tag = build_tag_from_name if build_tag_from_name else (0, "")
expected_tag = wheels.get_build_tag(
ctx=wkctx,
req=req,
version=resolved_version,
wheel_tags=wheel_tags,
)
build_tag = expected_tag if expected_tag else (0, "")
Comment on lines +498 to +504

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Let build_tag_hook failures propagate here.

wheels.get_build_tag() is now where invalid suffix segments and hook errors surface, but this block still sits under except Exception and falls back to None. That turns a bad hook configuration into “no cached wheel” instead of failing the build, which contradicts the new build-tag contract. Narrow the try scope or re-raise errors coming from get_build_tag().

Also applies to: 532-541

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/fromager/commands/build.py` around lines 498 - 504, The build-tag
resolution in build.py is swallowing errors from wheels.get_build_tag(), causing
invalid suffix segments or build_tag_hook failures to fall back to a
cached-wheel miss instead of failing the build. Narrow the surrounding
try/except in the build flow so only the intended cache lookup is caught, and
let errors from wheels.get_build_tag() propagate (or explicitly re-raise them)
in the expected_tag/build_tag handling path and the analogous block later in the
file.

if (
existing_build_tag[0] > build_tag[0]
and existing_build_tag[1] == build_tag[1]
Expand Down
49 changes: 26 additions & 23 deletions src/fromager/finders.py

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The finder API is an old, badly designed part of Fromager. It tries to be clever, but gets it wrong in some cases. We may need to reconsider and redesign this part of Fromager, first. :/

Original file line number Diff line number Diff line change
Expand Up @@ -159,33 +159,36 @@ def find_wheel(
and dotted), each suffixed with the build tag when present. Uses
case-insensitive ``startswith`` matching rather than exact base comparison
because wheel filenames include platform/Python tags after the version.

When the build tag has an empty suffix, also matches wheels whose
build tag contains a hook-provided suffix (e.g. ``2_el9.6``).
Callers should validate the full build tag after finding a match.
"""
filename_prefix = _dist_name_to_filename(req.name)
canonical_name = canonicalize_name(req.name)
# if build tag is 0 then we can ignore to handle non tagged wheels for backward compatibility
candidate_bases_build_tag = f"{build_tag[0]}{build_tag[1]}-" if build_tag else ""

candidate_bases = set(
[
# First check if the file is there using the canonically
# transformed name.
f"{filename_prefix}-{dist_version}-{candidate_bases_build_tag}",
# If that didn't work, try the canonical dist name. That's not
# "correct" but we do see it. (charset-normalizer-3.3.2-
# and setuptools-scm-8.0.4-) for example
f"{canonical_name}-{dist_version}-{candidate_bases_build_tag}",
# If *that* didn't work, try the dist name we've been
# given as a dependency. That's not "correct", either but we do
# see it. (oslo.messaging-14.7.0-) for example
f"{req.name}-{dist_version}-{candidate_bases_build_tag}",
# Sometimes the sdist uses '.' instead of '-' in the
# package name portion.
f"{req.name.replace('-', '.')}-{dist_version}-{candidate_bases_build_tag}",
]
)
# Case-insensitive globbing was added to Python 3.12, but we
# have to run with older versions, too, so do our own name
# comparison.
if build_tag:
suffix = build_tag[1]
if suffix:
# Exact match including suffix: "2_el9.6-"
build_tag_prefixes = [f"{build_tag[0]}{suffix}-"]
else:
# No suffix: match "2-" (plain) or "2_" (hook-suffixed)
build_tag_prefixes = [f"{build_tag[0]}-", f"{build_tag[0]}_"]
else:
build_tag_prefixes = [""]

candidate_bases = set()
for bt_prefix in build_tag_prefixes:
candidate_bases.update(
[
f"{filename_prefix}-{dist_version}-{bt_prefix}",
f"{canonical_name}-{dist_version}-{bt_prefix}",
f"{req.name}-{dist_version}-{bt_prefix}",
f"{req.name.replace('-', '.')}-{dist_version}-{bt_prefix}",
]
)

for base in candidate_bases:
logger.debug('looking for wheel as "%s"', base)
for filename in downloads_dir.glob("*.whl"):
Expand Down
2 changes: 2 additions & 0 deletions src/fromager/packagesettings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ResolverDist,
SbomSettings,
VariantInfo,
WheelSettings,
)
from ._pbi import PackageBuildInfo
from ._resolver import (
Expand Down Expand Up @@ -82,6 +83,7 @@
"Variant",
"VariantChangelog",
"VariantInfo",
"WheelSettings",
"default_update_extra_environ",
"get_extra_environ",
"pep440_tag_matcher",
Expand Down
30 changes: 30 additions & 0 deletions src/fromager/packagesettings/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,36 @@ class SbomSettings(pydantic.BaseModel):
"""


class WheelSettings(pydantic.BaseModel):
"""Global wheel settings

::

wheels:
build_tag_hook: "mypackage.hooks:build_tag_hook"

.. versionadded:: 0.82.0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fromager 0.88.0 is out, the new feature will land in 0.89.0 or later.

Suggested change
.. versionadded:: 0.82.0
.. versionadded:: 0.89.0

"""

model_config = MODEL_CONFIG

build_tag_hook: pydantic.ImportString[typing.Callable[..., typing.Any]] | None = (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's define a more detailed callable:

BuildTagHookCallable = typing.Callable[
    ["context.WorkContext", Requirement, Version, frozenset[Tag]],
    typing.Sequence[str],
]

the define the hook as:

build_tag_hook: pydantic.ImportString[BuildTagHookCallable] | None = None

None
)
"""Dotted import path to a callable that returns build tag suffix segments.

The callable receives keyword-only arguments ``ctx``, ``req``,
``version``, and ``wheel_tags``, and returns ``Sequence[str]``.
"""

@pydantic.field_validator("build_tag_hook", mode="before")
@classmethod
def _colon_to_dot(cls, v: typing.Any) -> typing.Any:
if isinstance(v, str) and ":" in v:
return v.replace(":", ".", 1)
return v
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +97 to +102

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need to modify the string. ImportString supports the : syntax.

>>> import pydantic
>>> class WheelSettings(pydantic.BaseModel):
...     build_tag_hook: pydantic.ImportString
...
>>> WheelSettings(build_tag_hook="fromager.context:WorkContext")
WheelSettings(build_tag_hook=<class 'fromager.context.WorkContext'>)



class PurlConfig(pydantic.BaseModel):
"""Per-package purl configuration for SBOM generation.

Expand Down
15 changes: 14 additions & 1 deletion src/fromager/packagesettings/_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from pydantic import Field

from .. import overrides
from ._models import PackageSettings, SbomSettings
from ._models import PackageSettings, SbomSettings, WheelSettings
from ._pbi import PackageBuildInfo
from ._typedefs import MODEL_CONFIG, GlobalChangelog, Package, Variant

Expand Down Expand Up @@ -45,6 +45,12 @@ class SettingsFile(pydantic.BaseModel):
are generated.
"""

wheels: WheelSettings | None = None
"""Wheel build settings

.. versionadded:: 0.82.0
"""

@classmethod
def from_string(
cls,
Expand Down Expand Up @@ -175,6 +181,13 @@ def sbom_settings(self) -> SbomSettings | None:
"""Get global SBOM settings, or None if SBOM generation is disabled."""
return self._settings.sbom

@property
def build_tag_hook(self) -> typing.Callable[..., typing.Any] | None:
"""Get the configured build tag hook callable, or None."""
if self._settings.wheels is not None:
return self._settings.wheels.build_tag_hook
return None

def variant_changelog(self) -> list[str]:
"""Get global changelog for current variant"""
return list(self._settings.changelog.get(self.variant, []))
Expand Down
56 changes: 55 additions & 1 deletion src/fromager/wheels.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import os
import pathlib
import re
import shutil
import sys
import tempfile
Expand Down Expand Up @@ -39,12 +40,60 @@

logger = logging.getLogger(__name__)

_BUILD_TAG_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9.]+$")

FROMAGER_BUILD_SETTINGS = "fromager-build-settings"
FROMAGER_ELF_PROVIDES = "fromager-elf-provides.txt"
FROMAGER_ELF_REQUIRES = "fromager-elf-requires.txt"
FROMAGER_BUILD_REQ_PREFIX = "fromager"


def get_build_tag(
*,
ctx: context.WorkContext,
req: Requirement,
version: Version,
wheel_tags: frozenset[Tag] | None = None,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under which circumstances is wheel_tags None? All wheels must have at least one wheel tag.

) -> BuildTag:
"""Compute the full build tag, including any hook-provided suffix.

When *wheel_tags* is provided and a ``build_tag_hook`` is configured
in global settings, the hook is called to obtain suffix segments
that are joined with ``_`` and appended to the base build tag.

When *wheel_tags* is ``None`` or no hook is configured, returns the
base build tag from changelog settings (identical to
``pbi.build_tag(version)``).
"""
pbi = ctx.package_build_info(req)
base_tag = pbi.build_tag(version)
if not base_tag:
return base_tag

hook = ctx.settings.build_tag_hook
if hook is None or wheel_tags is None:
return base_tag

segments = hook(ctx=ctx, req=req, version=version, wheel_tags=wheel_tags)
_validate_build_tag_segments(segments)

if not segments:
return base_tag

suffix = base_tag[1] + "_" + "_".join(segments)
return (base_tag[0], suffix)


def _validate_build_tag_segments(segments: typing.Sequence[str]) -> None:
"""Validate that each segment matches ``[a-zA-Z0-9.]``."""
for seg in segments:
if not _BUILD_TAG_SEGMENT_RE.match(seg):
raise ValueError(
f"build tag hook returned invalid segment {seg!r}: "
"each segment must match [a-zA-Z0-9.]"
)

Comment on lines +77 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Materialize and type-check the hook result before validating it.

Right now a hook that returns "el9.6" is accepted as six one-character segments, and a generator gets exhausted by _validate_build_tag_segments() before "_".join(...) runs. Both cases yield incorrect wheel tags instead of failing fast, which breaks the filename/cache contract this helper is supposed to enforce.

Suggested fix
-    segments = hook(ctx=ctx, req=req, version=version, wheel_tags=wheel_tags)
-    _validate_build_tag_segments(segments)
+    raw_segments = hook(ctx=ctx, req=req, version=version, wheel_tags=wheel_tags)
+    if isinstance(raw_segments, (str, bytes)):
+        raise ValueError(
+            "build_tag_hook must return a sequence of segment strings"
+        )
+    try:
+        segments = list(raw_segments)
+    except TypeError as err:
+        raise ValueError(
+            "build_tag_hook must return a sequence of segment strings"
+        ) from err
+    if any(not isinstance(seg, str) for seg in segments):
+        raise ValueError("build_tag_hook must return only strings")
+    _validate_build_tag_segments(segments)

A regression test for a single-string return and a generator return would lock this down.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
segments = hook(ctx=ctx, req=req, version=version, wheel_tags=wheel_tags)
_validate_build_tag_segments(segments)
if not segments:
return base_tag
suffix = base_tag[1] + "_" + "_".join(segments)
return (base_tag[0], suffix)
def _validate_build_tag_segments(segments: typing.Sequence[str]) -> None:
"""Validate that each segment matches ``[a-zA-Z0-9.]``."""
for seg in segments:
if not _BUILD_TAG_SEGMENT_RE.match(seg):
raise ValueError(
f"build tag hook returned invalid segment {seg!r}: "
"each segment must match [a-zA-Z0-9.]"
)
raw_segments = hook(ctx=ctx, req=req, version=version, wheel_tags=wheel_tags)
if isinstance(raw_segments, (str, bytes)):
raise ValueError(
"build_tag_hook must return a sequence of segment strings"
)
try:
segments = list(raw_segments)
except TypeError as err:
raise ValueError(
"build_tag_hook must return a sequence of segment strings"
) from err
if any(not isinstance(seg, str) for seg in segments):
raise ValueError("build_tag_hook must return only strings")
_validate_build_tag_segments(segments)
if not segments:
return base_tag
suffix = base_tag[1] + "_" + "_".join(segments)
return (base_tag[0], suffix)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/fromager/wheels.py` around lines 77 - 95, The build-tag hook result in
the wheel tag helper is being used as an arbitrary iterable before validation,
which lets a single string be treated as multiple segments and can exhaust
generators. In the wheel-tag logic around the hook call and
_validate_build_tag_segments, first materialize the hook result into a concrete
sequence and verify every item is a string segment before validating/joining it,
so _validate_build_tag_segments and the subsequent "_".join use the same stable
data. Add regression coverage for both a single-string return and a generator
return to ensure invalid hook shapes fail fast.


def _log_existing_sboms(
req: Requirement,
dist_info_dir: pathlib.Path,
Expand Down Expand Up @@ -274,7 +323,12 @@ def add_extra_metadata_to_wheels(
)
sbom.write_sbom(sbom=sbom_doc, dist_info_dir=dist_info_dir)

build_tag_from_settings = pbi.build_tag(version)
build_tag_from_settings = get_build_tag(
ctx=ctx,
req=req,
version=version,
wheel_tags=wheel_tags,
)
build_tag = build_tag_from_settings if build_tag_from_settings else (0, "")

cmd = [
Expand Down
47 changes: 47 additions & 0 deletions tests/test_finders.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,50 @@ def test_pypi_cache_provider() -> None:
finders.PyPICacheProvider(
cache_server_url=url, include_sdists=False, include_wheels=False
)


def test_find_wheel_with_build_tag(tmp_path: pathlib.Path) -> None:
downloads = tmp_path / "downloads"
downloads.mkdir()
wheel = downloads / "mypkg-1.0.0-2-py3-none-any.whl"
wheel.write_text("not-empty")

req = Requirement("mypkg")
actual = finders.find_wheel(downloads, req, "1.0.0", (2, ""))
assert actual == wheel


def test_find_wheel_with_build_tag_suffix(tmp_path: pathlib.Path) -> None:
"""find_wheel with an empty suffix should also match suffixed filenames."""
downloads = tmp_path / "downloads"
downloads.mkdir()
wheel = downloads / "mypkg-1.0.0-2_el9.6-cp312-cp312-linux_x86_64.whl"
wheel.write_text("not-empty")

req = Requirement("mypkg")
# Search with base tag (no suffix) — should still find the suffixed wheel
actual = finders.find_wheel(downloads, req, "1.0.0", (2, ""))
assert actual == wheel


def test_find_wheel_with_exact_suffix(tmp_path: pathlib.Path) -> None:
"""find_wheel with an exact suffix matches the right wheel."""
downloads = tmp_path / "downloads"
downloads.mkdir()
wheel = downloads / "mypkg-1.0.0-2_el9.6_cuda13.0-cp312-cp312-linux_x86_64.whl"
wheel.write_text("not-empty")

req = Requirement("mypkg")
actual = finders.find_wheel(downloads, req, "1.0.0", (2, "_el9.6_cuda13.0"))
assert actual == wheel


def test_find_wheel_suffix_no_false_positive(tmp_path: pathlib.Path) -> None:
"""Build tag 2 should not match build tag 20."""
downloads = tmp_path / "downloads"
downloads.mkdir()
(downloads / "mypkg-1.0.0-20-py3-none-any.whl").write_text("not-empty")

req = Requirement("mypkg")
actual = finders.find_wheel(downloads, req, "1.0.0", (2, ""))
assert actual is None
Comment on lines +151 to +195

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add the coexistence case for plain and suffixed wheels.

These tests only cover plain and suffixed filenames in isolation. The regression shows up when both are present in the same directory and the caller rejects the first candidate after revalidation. Please add a case with both ...-2-...whl and ...-2_<suffix>-...whl present so this behavior is locked down. As per path instructions, "Verify test actually tests the intended behavior. Check for missing edge cases."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_finders.py` around lines 151 - 195, The wheel finder tests are
missing the coexistence scenario where both plain and suffixed build-tag wheels
exist together and the first candidate is rejected on revalidation. Add a test
around finders.find_wheel that places both the `...-2-...whl` and
`...-2_<suffix>-...whl` files in the same downloads directory, then verifies the
function still resolves the correct suffixed wheel after the plain one is
skipped. Use the existing test patterns in test_find_wheel_with_build_tag,
test_find_wheel_with_build_tag_suffix, and test_find_wheel_with_exact_suffix as
the reference points.

Source: Path instructions

Loading
Loading