8686from typing import TYPE_CHECKING , NamedTuple
8787
8888if 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
9495ROOT = pathlib .Path (__file__ ).resolve ().parents [1 ]
9596PYPROJECT = 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