Skip to content
Merged
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
5 changes: 3 additions & 2 deletions .github/workflows/cron-conda.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 37 additions & 1 deletion tests/project/test_conda_platform_pin_refresh_1851.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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."""
Expand Down
77 changes: 61 additions & 16 deletions util/conda_vendor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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


Expand Down
Loading