From b5675b6e04c294082333b52de2dd1eff121dd6bf Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Wed, 21 Oct 2026 23:54:00 -0400 Subject: [PATCH] fix(ci): warn when a pinned conda release has lost a wheel (#2028) - refresh() now warns when the newest release with a wheel for every conda Python is older than the pin, naming the tags the pinned release lacks and the newest release that still qualifies; the new lost() helper words it. The pin stays and the step still passes. - A pin that is still the newest qualifying release stays silent, as does one above the installed release. - cron-conda.yml: the refresh step's comment covers the new warning. - Tests: unit and end-to-end cases for the warning, and the silent case. --- .github/workflows/cron-conda.yml | 5 +- .../test_conda_platform_pin_refresh_1851.py | 76 ++++++++++++++++++- util/conda_vendor.py | 27 ++++++- 3 files changed, 102 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cron-conda.yml b/.github/workflows/cron-conda.yml index 92b7afb947..fc218f662d 100644 --- a/.github/workflows/cron-conda.yml +++ b/.github/workflows/cron-conda.yml @@ -93,8 +93,9 @@ jobs: # 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, 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. + # can move to (#2025) and a pin whose own release has lost a wheel while + # an older one still qualifies (#2028), 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 4e0170862f..6c69459c47 100644 --- a/tests/project/test_conda_platform_pin_refresh_1851.py +++ b/tests/project/test_conda_platform_pin_refresh_1851.py @@ -10,7 +10,8 @@ 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 or when no release has a wheel for every conda Python, naming the Pythons no - release has one for (#2025). + release has one for (#2025), or when the pinned release has lost the wheel an + older release still has, naming the Pythons it lacks (#2028). ``--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 @@ -146,6 +147,17 @@ def recorded(**more: 'list[dict[str, Any]]') -> 'dict[str, Any]': INTEL_AGAIN = files('51.0.0', 'cp311-abi3-macosx_10_9_universal2', 'cp311-abi3-manylinux_2_34_x86_64') +def lost_wheel() -> 'dict[str, Any]': + """The recording with 48.0.1's cp311 osx-64 wheel yanked, and 47.0.0 still serving every Python (#2028). + + 48.0.1 keeps a cp312 ``abi3`` universal2 wheel, so it lacks cp311 alone. + + """ + yanked = files('48.0.1', 'cp311-abi3-macosx_10_9_universal2', yanked=True)[:1] + return recorded(**{'47.0.0': files('47.0.0', 'cp311-abi3-macosx_10_9_universal2'), + '48.0.1': yanked + files('48.0.1', 'cp312-abi3-macosx_10_9_universal2')}) + + def table(version: 'str' = '48.0.1', review_by: 'datetime.date' = datetime.date(2026, 12, 1), ) -> 'dict[str, dict[str, Any]]': """A ``PLATFORM_PINS`` pinning cryptography to ``version`` on osx-64.""" @@ -393,6 +405,39 @@ def test_a_pin_no_single_release_serves_warns_for_every_tag(self) -> None: 'hold: no release up to 3.0 has wheels for all of cp311, cp312 ' 'on osx-64 at once')]) + def test_a_pin_whose_release_lost_a_wheel_warns_with_the_tag_it_lacks(self) -> None: + # #2028: 47.0.0 still has every wheel, so the 48.0.1 pin used to stay without a word. + result, notes = self.refresh(lost_wheel()) + self.assertEqual(result, table()) + self.assertEqual(notes, [('warning', 'cryptography 48.0.1 on osx-64 stays as it is, and may no longer ' + 'hold: 48.0.1 has no wheel for cp311 on osx-64, and the newest ' + 'release up to 52.0.0 with wheels for all of cp311, cp312, cp313, ' + 'cp314, cp315 there is 47.0.0')]) + + def test_a_pin_whose_release_lost_every_wheel_warns_for_every_tag(self) -> None: + # #2028: 49.0.0 has arm64 macOS wheels alone, and 48.0.1 has universal2 ones. + result, notes = self.refresh(recorded(), pins=table('49.0.0')) + self.assertEqual(result, table('49.0.0')) + self.assertEqual(notes, [('warning', 'cryptography 49.0.0 on osx-64 stays as it is, and may no longer ' + 'hold: 49.0.0 has no wheel for cp311, cp312, cp313, cp314, cp315 ' + 'on osx-64, and the newest release up to 52.0.0 with wheels for ' + 'all of cp311, cp312, cp313, cp314, cp315 there is 48.0.1')]) + + def test_a_pin_pypi_no_longer_lists_warns(self) -> None: + # #2028: a pinned release gone from PyPI, or a pre-release, is not one newest_wheel weighs. + for version in ('48.5.0', '49.0.0rc1'): + with self.subTest(pin=version): + result, notes = self.refresh(recorded(**{'49.0.0rc1': INTEL_AGAIN}), pins=table(version)) + self.assertEqual(result, table(version)) + self.assertEqual(notes, [('warning', f'cryptography {version} on osx-64 stays as it is, and may no ' + f'longer hold: PyPI lists no stable release {version}, and ' + 'the newest release up to 52.0.0 with wheels for all of ' + 'cp311, cp312, cp313, cp314, cp315 there is 48.0.1')]) + + def test_a_pin_above_the_installed_release_is_not_called_lost(self) -> None: + # #2028: 49.0.0 is above the installed 48.5.0, so newest_wheel never weighed it. + self.assertEqual(self.refresh(recorded(), installed='48.5.0', pins=table('49.0.0')), (table('49.0.0'), [])) + 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()) @@ -548,6 +593,35 @@ def test_a_stuck_pin_is_warned_about_and_passes(self) -> None: 'and may no longer hold: no release up to 52.0.0 has a wheel for cp38 ' 'on osx-64\n') + def test_a_pin_whose_release_lost_a_wheel_is_warned_about_and_passes(self) -> None: + # #2028: 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: lost_wheel() + with mock.patch.object(module, 'conda_pythons', lambda: list(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: 48.0.1 has no wheel for cp311 on osx-64, and the newest ' + 'release up to 52.0.0 with wheels for all of cp311, cp312, cp313, cp314, cp315 ' + 'there is 47.0.0\n') + + def test_a_pin_still_the_newest_to_qualify_passes_silently(self) -> None: + # #2028: 48.0.1 is the newest release with an osx-64 wheel for every Python, so it is not lost. + module = vendor() + with Scratch(table()) as scratch: + before = scratch.table.read_bytes() + module._fetch = lambda name: recorded(**{'47.0.0': files('47.0.0', 'cp311-abi3-macosx_10_9_universal2')}) + with mock.patch.object(module, 'conda_pythons', lambda: list(PYTHONS)): + status, output = scratch.main('--refresh-pins') + after = scratch.table.read_bytes() + self.assertEqual(status, 0) + self.assertEqual(after, before) + self.assertEqual(output, '') + 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 03b4a11a0d..f94cc93840 100644 --- a/util/conda_vendor.py +++ b/util/conda_vendor.py @@ -618,6 +618,20 @@ def stuck(project: 'dict[str, Any]', subdir: 'str', pythons: 'list[str]', have: return f'no release up to {have} has wheels for all of {_tags(pythons)} on {subdir} at once' +def lost(project: 'dict[str, Any]', subdir: 'str', pythons: 'list[str]', pin: 'str') -> 'str': + """Why the release ``pin`` names no longer qualifies on ``subdir``, for its warning (#2028). + + It names the conda Pythons the pinned release has no wheel for there, or + says PyPI lists no such release, as :func:`_releases` weighs them. + + """ + from packaging.version import Version # pylint: disable=import-outside-toplevel + wheels = next((files for version, _, files in _releases(project) if version == Version(pin)), None) + if wheels is None: + return f'PyPI lists no stable release {pin}' + return f'{pin} has no wheel for {_tags(lacking(wheels, subdir, pythons))} on {subdir}' + + #: 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. @@ -636,9 +650,10 @@ def refresh(table: 'dict[str, dict[str, Pin]]', fetch: 'Callable[[str], dict[str 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, 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. + a wheel for every conda Python, which :func:`stuck` warns about (#2025), or + whose own release no longer has one when an older release does, which + :func:`lost` warns about (#2028). 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 @@ -684,6 +699,12 @@ def refresh(table: 'dict[str, dict[str, Pin]]', fetch: 'Callable[[str], dict[str # #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)}')) + elif Version(newest) < current < ceiling: + # #2028: the pinned release itself lost a wheel, which used to pass without a word too. + notes.append(('warning', f'{name} {pin.version} on {subdir} stays as it is, and may no longer ' + f'hold: {lost(project, subdir, pythons, pin.version)}, and the ' + f'newest release up to {have} with wheels for all of {_tags(pythons)} ' + f'there is {newest}')) return {name: platforms for name, platforms in result.items() if platforms}, notes