Skip to content

Commit 3c20dec

Browse files
committed
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.
1 parent 609bd93 commit 3c20dec

3 files changed

Lines changed: 101 additions & 19 deletions

File tree

‎.github/workflows/cron-conda.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,8 @@ jobs:
151151
# util/verdict_covers_checkout.py's bot-managed paths, which says why:
152152
# the workflows chained off Unit Tests wait for such a commit's own run
153153
# (#1643). A package PyPI cannot be read for keeps its pin, with a
154-
# warning, and a pin past its review-by date gets one too; either way the
155-
# step passes.
154+
# warning, as does a pin no release can move to (#2025), and a pin past
155+
# its review-by date gets one too; either way the step passes.
156156
- name: Refresh the platform pins
157157
run: python util/conda_vendor.py --refresh-pins
158158

‎tests/project/test_conda_platform_pin_refresh_1851.py‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
musllinux, yanked or pre-release file.
99
* :func:`conda_vendor.refresh` moves a pin up to that release with a fresh
1010
review-by date, drops it once it reaches the installed release or if it
11-
already pins it, and leaves it, with a warning, when PyPI cannot be read.
11+
already pins it, and leaves it, with a warning, when PyPI cannot be read or
12+
when no release has a wheel for every conda Python, naming the Pythons no
13+
release has one for (#2025).
1214
``--refresh-pins`` rewrites :file:`conda/platform-pins.toml` only on a change.
1315
* :func:`conda_vendor._fetch` reads PyPI through :mod:`requests`, a stand-in
1416
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:
372374
self.assertEqual(notes, [('warning', "cryptography 48.0.1 on osx-64 stays as it is: "
373375
"PyPI's JSON has no releases")])
374376

377+
def test_a_pin_no_release_can_move_to_warns_with_the_missing_tags(self) -> None:
378+
# #2025: no release has a cp38 wheel, so the pin used to stay without a word.
379+
result, notes = vendor().refresh(table(), lambda name: recorded(), lambda name: '50.0.2',
380+
('3.8', *PYTHONS), TODAY)
381+
self.assertEqual(result, table())
382+
self.assertEqual(notes, [('warning', 'cryptography 48.0.1 on osx-64 stays as it is, and may no longer '
383+
'hold: no release up to 50.0.2 has a wheel for cp38 on osx-64')])
384+
385+
def test_a_pin_no_single_release_serves_warns_for_every_tag(self) -> None:
386+
# #2025: each Python has an osx-64 wheel in some release, but none has all of them.
387+
project = {'releases': {'1.0': files('1.0', 'cp311-cp311-macosx_10_9_x86_64'),
388+
'2.0': files('2.0', 'cp312-cp312-macosx_10_9_x86_64')}}
389+
pins = {'cryptography': {'osx-64': vendor().Pin('1.0', TODAY, 'a stand-in')}}
390+
result, notes = vendor().refresh(pins, lambda name: project, lambda name: '3.0', ['3.11', '3.12'], TODAY)
391+
self.assertEqual(result, pins)
392+
self.assertEqual(notes, [('warning', 'cryptography 1.0 on osx-64 stays as it is, and may no longer '
393+
'hold: no release up to 3.0 has wheels for all of cp311, cp312 '
394+
'on osx-64 at once')])
395+
375396
def test_a_package_not_installed_leaves_the_pin_and_warns(self) -> None:
376397
result, notes = self.refresh(recorded(**{'51.0.0': INTEL_AGAIN}), installed=None)
377398
self.assertEqual(result, table())
@@ -513,6 +534,22 @@ def test_an_overdue_pin_left_where_it_is_is_warned_about(self) -> None:
513534
'review by 2026-10-16; check the pin still holds, then set a new review-by date, such as 2027-01-16'])
514535

515536

537+
def test_a_stuck_pin_is_warned_about_and_passes(self) -> None:
538+
# #2025: the step stays green, as the workflow's non-blocking rule has it, but says so.
539+
module = vendor()
540+
with Scratch(table()) as scratch:
541+
before = scratch.table.read_bytes()
542+
module._fetch = lambda name: recorded()
543+
with mock.patch.object(module, 'conda_pythons', lambda: ['3.8', *PYTHONS]):
544+
status, output = scratch.main('--refresh-pins')
545+
after = scratch.table.read_bytes()
546+
self.assertEqual(status, 0)
547+
self.assertEqual(after, before)
548+
self.assertEqual(output, '::warning title=Platform pins::cryptography 48.0.1 on osx-64 stays as it is, '
549+
'and may no longer hold: no release up to 52.0.0 has a wheel for cp38 '
550+
'on osx-64\n')
551+
552+
516553
class CheckTests(unittest.TestCase):
517554
"""``--check`` on a pin in date and past it, never reading the network."""
518555

‎util/conda_vendor.py‎

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,11 @@
8686
from typing import TYPE_CHECKING, NamedTuple
8787

8888
if TYPE_CHECKING:
89-
from typing import Any, Callable, Iterable, Literal, Optional
89+
from typing import Any, Callable, Iterable, Iterator, Literal, Optional
9090

9191
from packaging.markers import Marker
9292
from packaging.requirements import Requirement
93+
from packaging.version import Version
9394

9495
ROOT = pathlib.Path(__file__).resolve().parents[1]
9596
PYPROJECT = ROOT / 'pyproject.toml'
@@ -546,38 +547,77 @@ def installs(filename: 'str', subdir: 'str', python: 'str', requires_python: 'Op
546547
for tag in tags)
547548

548549

549-
def newest_wheel(project: 'dict[str, Any]', subdir: 'str', pythons: 'Iterable[str]',
550-
ceiling: 'Optional[str]' = None) -> 'Optional[str]':
551-
"""The newest release in ``project`` with a wheel each of ``pythons`` installs on ``subdir`` (#1851).
550+
def _releases(project: 'dict[str, Any]', ceiling: 'Optional[str]' = None,
551+
) -> 'Iterator[tuple[Version, str, list[dict[str, Any]]]]':
552+
"""Each release in ``project`` :func:`newest_wheel` weighs, its version, its key and its wheels (#1851).
552553
553554
``project`` is PyPI's JSON for a package, whose ``releases`` map each version
554555
to its files. A yanked file is passed over, and so are a pre-release and,
555-
given a ``ceiling``, a release above it. :data:`None` when no release has such
556-
a wheel; :class:`ValueError` when ``project`` lists no releases at all.
556+
given a ``ceiling``, a release above it. :class:`ValueError` when ``project``
557+
lists no releases at all.
557558
558559
"""
559560
from packaging.version import InvalidVersion, Version # pylint: disable=import-outside-toplevel
560561
releases = project.get('releases') if isinstance(project, dict) else None
561562
if not isinstance(releases, dict):
562563
raise ValueError("PyPI's JSON has no releases")
563-
pythons = list(pythons)
564564
top = None if ceiling is None else Version(ceiling)
565-
best, found = None, None # type: Optional[Version], Optional[str]
566565
for text, files in releases.items():
567566
try:
568567
version = Version(text)
569568
except InvalidVersion:
570569
continue
571-
if version.is_prerelease or (top is not None and version > top) or (best is not None and version <= best):
570+
if version.is_prerelease or (top is not None and version > top):
571+
continue
572+
yield version, text, [file for file in files or () if isinstance(file, dict) and not file.get('yanked')
573+
and str(file.get('filename', '')).endswith('.whl')]
574+
575+
576+
def lacking(wheels: 'Iterable[dict[str, Any]]', subdir: 'str', pythons: 'Iterable[str]') -> 'list[str]':
577+
"""Each of ``pythons`` that none of ``wheels``, files of PyPI's JSON, installs on ``subdir`` (#2025)."""
578+
wheels = list(wheels)
579+
return [python for python in pythons
580+
if not any(installs(file['filename'], subdir, python, file.get('requires_python')) for file in wheels)]
581+
582+
583+
def newest_wheel(project: 'dict[str, Any]', subdir: 'str', pythons: 'Iterable[str]',
584+
ceiling: 'Optional[str]' = None) -> 'Optional[str]':
585+
"""The newest release in ``project`` with a wheel each of ``pythons`` installs on ``subdir`` (#1851).
586+
587+
Of the releases :func:`_releases` weighs. :data:`None` when no release has
588+
such a wheel; :class:`ValueError` when ``project`` lists no releases at all.
589+
590+
"""
591+
pythons = list(pythons)
592+
best, found = None, None # type: Optional[Version], Optional[str]
593+
for version, text, wheels in _releases(project, ceiling):
594+
if best is not None and version <= best:
572595
continue
573-
wheels = [file for file in files or () if isinstance(file, dict) and not file.get('yanked')
574-
and str(file.get('filename', '')).endswith('.whl')]
575-
if pythons and all(any(installs(file['filename'], subdir, python, file.get('requires_python'))
576-
for file in wheels) for python in pythons):
596+
if pythons and not lacking(wheels, subdir, pythons):
577597
best, found = version, text
578598
return found
579599

580600

601+
def _tags(pythons: 'Iterable[str]') -> 'str':
602+
"""``pythons`` by their wheel tags, such as ``cp314`` for 3.14, for a note."""
603+
return ', '.join(f"cp{python.replace('.', '')}" for python in pythons)
604+
605+
606+
def stuck(project: 'dict[str, Any]', subdir: 'str', pythons: 'list[str]', have: 'str') -> 'str':
607+
"""Why no release in ``project`` up to ``have`` moves a pin on ``subdir``, for its warning (#2025).
608+
609+
It names the conda Pythons that no such release has a wheel for at all, or,
610+
when each has one in some release, says no release has one for every Python.
611+
612+
"""
613+
if not pythons:
614+
return 'conda builds for no Python, so no release qualifies'
615+
missing = lacking((file for _, _, files in _releases(project, have) for file in files), subdir, pythons)
616+
if missing:
617+
return f'no release up to {have} has a wheel for {_tags(missing)} on {subdir}'
618+
return f'no release up to {have} has wheels for all of {_tags(pythons)} on {subdir} at once'
619+
620+
581621
#: What :func:`_fetch` raises when PyPI cannot be read: :class:`OSError` for a
582622
#: failed request, and :class:`ValueError` for a reply that is not JSON, as
583623
#: 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
595635
version, due for review on ``today`` plus :data:`REVIEW_HORIZON`. A pin that
596636
reaches the installed version that way, or already pins it, is dropped, as
597637
the other platforms vendor that version. A package or pin it cannot settle
598-
stays as it is. Each note is a
599-
workflow command, ``notice`` for a change and ``warning`` for a pin left
600-
alone, and its text.
638+
stays as it is, and so does a pin no release up to the installed version has
639+
a wheel for every conda Python, which :func:`stuck` warns about (#2025). Each
640+
note is a workflow command, ``notice`` for a change and ``warning`` for a pin
641+
left alone, and its text.
601642
602643
"""
603644
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
639680
kept[subdir] = pin._replace(version=newest, review_by=today + REVIEW_HORIZON)
640681
notes.append(('notice', f'{name} on {subdir} moves from {pin.version} to {newest}, the newest '
641682
f'release with a wheel there; due for review by {kept[subdir].review_by}'))
683+
elif newest is None:
684+
# #2025: a pin no release can move to used to stay without a word.
685+
notes.append(('warning', f'{name} {pin.version} on {subdir} stays as it is, and may no longer '
686+
f'hold: {stuck(project, subdir, pythons, have)}'))
642687
return {name: platforms for name, platforms in result.items() if platforms}, notes
643688

644689

0 commit comments

Comments
 (0)