From 91f2b6e6ef31f782ffaa2e249203f9049f2ea1a3 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Wed, 21 Oct 2026 22:13:01 -0400 Subject: [PATCH] fix(ci): warn when --refresh-pins cannot move a pin (#2025) - refresh: a pin for which newest_wheel finds no release with a wheel for every conda Python now gets a ::warning, not silence; the step still passes, as cron-conda's non-blocking rule has it - stuck: names the Python tags no release up to the installed version has a wheel for, or says no single release serves them all - _releases / lacking: factored out of newest_wheel so both share the release filter and the per-Python wheel check - cron-conda.yml: the Refresh the platform pins comment names the new warning - tests: BumpTests and RefreshCommandTests cover both warnings and the exit status Tests: test_conda_platform_pin_refresh_1851 and the other conda_vendor test files pass; the new tests fail without the fix. --- .github/workflows/cron-conda.yml | 5 +- .../test_conda_platform_pin_refresh_1851.py | 38 ++++++++- util/conda_vendor.py | 77 +++++++++++++++---- 3 files changed, 101 insertions(+), 19 deletions(-) diff --git a/.github/workflows/cron-conda.yml b/.github/workflows/cron-conda.yml index 26a273fecc..92b7afb947 100644 --- a/.github/workflows/cron-conda.yml +++ b/.github/workflows/cron-conda.yml @@ -92,8 +92,9 @@ jobs: # than the one installed above, and renews its review-by date; a pin that # reaches the installed release goes. Not one of # util/verdict_covers_checkout.py's bot-managed paths, which says why - # (#1643). An unreadable package keeps its pin and a pin past its - # review-by date warns; neither fails the step. + # (#1643). An unreadable package keeps its pin, as does a pin no release + # can move to (#2025), and each warns, as does a pin past its review-by + # date; none fails the step. - name: Refresh the platform pins run: python util/conda_vendor.py --refresh-pins diff --git a/tests/project/test_conda_platform_pin_refresh_1851.py b/tests/project/test_conda_platform_pin_refresh_1851.py index e49ed41949..4e0170862f 100644 --- a/tests/project/test_conda_platform_pin_refresh_1851.py +++ b/tests/project/test_conda_platform_pin_refresh_1851.py @@ -8,7 +8,9 @@ musllinux, yanked or pre-release file. * :func:`conda_vendor.refresh` moves a pin up to that release with a fresh review-by date, drops it once it reaches the installed release or if it - already pins it, and leaves it, with a warning, when PyPI cannot be read. + already pins it, and leaves it, with a warning, when PyPI cannot be read or + when no release has a wheel for every conda Python, naming the Pythons no + release has one for (#2025). ``--refresh-pins`` rewrites :file:`conda/platform-pins.toml` only on a change. * :func:`conda_vendor._fetch` reads PyPI through :mod:`requests`, a stand-in here, and raises each of its errors as :class:`OSError`. It imports requests @@ -372,6 +374,25 @@ def test_json_without_releases_leaves_the_pin_and_warns(self) -> None: self.assertEqual(notes, [('warning', "cryptography 48.0.1 on osx-64 stays as it is: " "PyPI's JSON has no releases")]) + def test_a_pin_no_release_can_move_to_warns_with_the_missing_tags(self) -> None: + # #2025: no release has a cp38 wheel, so the pin used to stay without a word. + result, notes = vendor().refresh(table(), lambda name: recorded(), lambda name: '50.0.2', + ('3.8', *PYTHONS), TODAY) + self.assertEqual(result, table()) + self.assertEqual(notes, [('warning', 'cryptography 48.0.1 on osx-64 stays as it is, and may no longer ' + 'hold: no release up to 50.0.2 has a wheel for cp38 on osx-64')]) + + def test_a_pin_no_single_release_serves_warns_for_every_tag(self) -> None: + # #2025: each Python has an osx-64 wheel in some release, but none has all of them. + project = {'releases': {'1.0': files('1.0', 'cp311-cp311-macosx_10_9_x86_64'), + '2.0': files('2.0', 'cp312-cp312-macosx_10_9_x86_64')}} + pins = {'cryptography': {'osx-64': vendor().Pin('1.0', TODAY, 'a stand-in')}} + result, notes = vendor().refresh(pins, lambda name: project, lambda name: '3.0', ['3.11', '3.12'], TODAY) + self.assertEqual(result, pins) + self.assertEqual(notes, [('warning', 'cryptography 1.0 on osx-64 stays as it is, and may no longer ' + 'hold: no release up to 3.0 has wheels for all of cp311, cp312 ' + 'on osx-64 at once')]) + def test_a_package_not_installed_leaves_the_pin_and_warns(self) -> None: result, notes = self.refresh(recorded(**{'51.0.0': INTEL_AGAIN}), installed=None) self.assertEqual(result, table()) @@ -512,6 +533,21 @@ def test_an_overdue_pin_left_where_it_is_is_warned_about(self) -> None: '::warning title=Platform pins::overdue: cryptography is pinned to 48.0.1 on osx-64, and was due for ' 'review by 2026-10-16; check the pin still holds, then set a new review-by date, such as 2027-01-16']) + def test_a_stuck_pin_is_warned_about_and_passes(self) -> None: + # #2025: the step stays green, as the workflow's non-blocking rule has it, but says so. + module = vendor() + with Scratch(table()) as scratch: + before = scratch.table.read_bytes() + module._fetch = lambda name: recorded() + with mock.patch.object(module, 'conda_pythons', lambda: ['3.8', *PYTHONS]): + status, output = scratch.main('--refresh-pins') + after = scratch.table.read_bytes() + self.assertEqual(status, 0) + self.assertEqual(after, before) + self.assertEqual(output, '::warning title=Platform pins::cryptography 48.0.1 on osx-64 stays as it is, ' + 'and may no longer hold: no release up to 52.0.0 has a wheel for cp38 ' + 'on osx-64\n') + class CheckTests(unittest.TestCase): """``--check`` on a pin in date and past it, never reading the network.""" diff --git a/util/conda_vendor.py b/util/conda_vendor.py index 66b8402246..03b4a11a0d 100644 --- a/util/conda_vendor.py +++ b/util/conda_vendor.py @@ -86,10 +86,11 @@ from typing import TYPE_CHECKING, NamedTuple if TYPE_CHECKING: - from typing import Any, Callable, Iterable, Literal, Optional + from typing import Any, Callable, Iterable, Iterator, Literal, Optional from packaging.markers import Marker from packaging.requirements import Requirement + from packaging.version import Version ROOT = pathlib.Path(__file__).resolve().parents[1] PYPROJECT = ROOT / 'pyproject.toml' @@ -546,38 +547,77 @@ def installs(filename: 'str', subdir: 'str', python: 'str', requires_python: 'Op for tag in tags) -def newest_wheel(project: 'dict[str, Any]', subdir: 'str', pythons: 'Iterable[str]', - ceiling: 'Optional[str]' = None) -> 'Optional[str]': - """The newest release in ``project`` with a wheel each of ``pythons`` installs on ``subdir`` (#1851). +def _releases(project: 'dict[str, Any]', ceiling: 'Optional[str]' = None, + ) -> 'Iterator[tuple[Version, str, list[dict[str, Any]]]]': + """Each release in ``project`` :func:`newest_wheel` weighs, its version, its key and its wheels (#1851). ``project`` is PyPI's JSON for a package, whose ``releases`` map each version to its files. A yanked file is passed over, and so are a pre-release and, - given a ``ceiling``, a release above it. :data:`None` when no release has such - a wheel; :class:`ValueError` when ``project`` lists no releases at all. + given a ``ceiling``, a release above it. :class:`ValueError` when ``project`` + lists no releases at all. """ from packaging.version import InvalidVersion, Version # pylint: disable=import-outside-toplevel releases = project.get('releases') if isinstance(project, dict) else None if not isinstance(releases, dict): raise ValueError("PyPI's JSON has no releases") - pythons = list(pythons) top = None if ceiling is None else Version(ceiling) - best, found = None, None # type: Optional[Version], Optional[str] for text, files in releases.items(): try: version = Version(text) except InvalidVersion: continue - if version.is_prerelease or (top is not None and version > top) or (best is not None and version <= best): + if version.is_prerelease or (top is not None and version > top): + continue + yield version, text, [file for file in files or () if isinstance(file, dict) and not file.get('yanked') + and str(file.get('filename', '')).endswith('.whl')] + + +def lacking(wheels: 'Iterable[dict[str, Any]]', subdir: 'str', pythons: 'Iterable[str]') -> 'list[str]': + """Each of ``pythons`` that none of ``wheels``, files of PyPI's JSON, installs on ``subdir`` (#2025).""" + wheels = list(wheels) + return [python for python in pythons + if not any(installs(file['filename'], subdir, python, file.get('requires_python')) for file in wheels)] + + +def newest_wheel(project: 'dict[str, Any]', subdir: 'str', pythons: 'Iterable[str]', + ceiling: 'Optional[str]' = None) -> 'Optional[str]': + """The newest release in ``project`` with a wheel each of ``pythons`` installs on ``subdir`` (#1851). + + Of the releases :func:`_releases` weighs. :data:`None` when no release has + such a wheel; :class:`ValueError` when ``project`` lists no releases at all. + + """ + pythons = list(pythons) + best, found = None, None # type: Optional[Version], Optional[str] + for version, text, wheels in _releases(project, ceiling): + if best is not None and version <= best: continue - wheels = [file for file in files or () if isinstance(file, dict) and not file.get('yanked') - and str(file.get('filename', '')).endswith('.whl')] - if pythons and all(any(installs(file['filename'], subdir, python, file.get('requires_python')) - for file in wheels) for python in pythons): + if pythons and not lacking(wheels, subdir, pythons): best, found = version, text return found +def _tags(pythons: 'Iterable[str]') -> 'str': + """``pythons`` by their wheel tags, such as ``cp314`` for 3.14, for a note.""" + return ', '.join(f"cp{python.replace('.', '')}" for python in pythons) + + +def stuck(project: 'dict[str, Any]', subdir: 'str', pythons: 'list[str]', have: 'str') -> 'str': + """Why no release in ``project`` up to ``have`` moves a pin on ``subdir``, for its warning (#2025). + + It names the conda Pythons that no such release has a wheel for at all, or, + when each has one in some release, says no release has one for every Python. + + """ + if not pythons: + return 'conda builds for no Python, so no release qualifies' + missing = lacking((file for _, _, files in _releases(project, have) for file in files), subdir, pythons) + if missing: + return f'no release up to {have} has a wheel for {_tags(missing)} on {subdir}' + return f'no release up to {have} has wheels for all of {_tags(pythons)} on {subdir} at once' + + #: What :func:`_fetch` raises when PyPI cannot be read: :class:`OSError` for a #: failed request, and :class:`ValueError` for a reply that is not JSON, as #: requests releases before its own ``JSONDecodeError`` raise it. @@ -595,9 +635,10 @@ def refresh(table: 'dict[str, dict[str, Pin]]', fetch: 'Callable[[str], dict[str version, due for review on ``today`` plus :data:`REVIEW_HORIZON`. A pin that reaches the installed version that way, or already pins it, is dropped, as the other platforms vendor that version. A package or pin it cannot settle - stays as it is. Each note is a - workflow command, ``notice`` for a change and ``warning`` for a pin left - alone, and its text. + stays as it is, and so does a pin no release up to the installed version has + a wheel for every conda Python, which :func:`stuck` warns about (#2025). Each + note is a workflow command, ``notice`` for a change and ``warning`` for a pin + left alone, and its text. """ from packaging.version import Version # pylint: disable=import-outside-toplevel @@ -639,6 +680,10 @@ def refresh(table: 'dict[str, dict[str, Pin]]', fetch: 'Callable[[str], dict[str kept[subdir] = pin._replace(version=newest, review_by=today + REVIEW_HORIZON) notes.append(('notice', f'{name} on {subdir} moves from {pin.version} to {newest}, the newest ' f'release with a wheel there; due for review by {kept[subdir].review_by}')) + elif newest is None: + # #2025: a pin no release can move to used to stay without a word. + notes.append(('warning', f'{name} {pin.version} on {subdir} stays as it is, and may no longer ' + f'hold: {stuck(project, subdir, pythons, have)}')) return {name: platforms for name, platforms in result.items() if platforms}, notes