4040import aiohttp
4141import discord
4242import yarl
43+ from packaging .markers import Variable as _MarkerVariable
4344from packaging .metadata import Metadata
45+ from packaging .ranges import VersionRange
4446from packaging .requirements import Requirement
4547from packaging .specifiers import SpecifierSet
46- from packaging .utils import parse_sdist_filename
48+ from packaging .utils import canonicalize_name , parse_sdist_filename
4749from packaging .version import Version
4850import rapidfuzz
4951import rich .progress
@@ -414,6 +416,16 @@ async def preprocessor(bot: Red, destination: discord.abc.Messageable, content:
414416 },
415417)
416418
419+ _REQUIRES_PYTHON_PKG_NAMES = tuple (
420+ map (
421+ canonicalize_name ,
422+ (
423+ "Red-does-not-support-this-version-of-Python.-Please-follow-one-of-the-install-guides-at-docs.discord.red" ,
424+ "package-does-not-support-this-version-of-python" ,
425+ ),
426+ )
427+ )
428+
417429
418430class AvailableVersion :
419431 def __init__ (self , version : Version , files : Dict [str , ReleaseFile ]) -> None :
@@ -422,23 +434,99 @@ def __init__(self, version: Version, files: Dict[str, ReleaseFile]) -> None:
422434 required_pythons = {f .get ("requires-python" ) or "" for f in files .values ()}
423435 if len (required_pythons ) > 1 :
424436 raise ValueError ("found multiple files with different Requires-Python values" )
425- self .requires_python = SpecifierSet (required_pythons .pop ())
437+ self .base_requires_python = SpecifierSet (required_pythons .pop (), prereleases = True )
438+ self ._requires_python : Optional [SpecifierSet ] = None
439+ self ._metadata : Optional [Metadata ] = None
440+
441+ @property
442+ def requires_python (self ) -> SpecifierSet :
443+ if self ._requires_python is None :
444+ raise TypeError (
445+ "`requires_python` attribute is missing - call `fetch_extra_info()` first."
446+ )
447+ return self ._requires_python
448+
449+ @property
450+ def metadata (self ) -> Metadata :
451+ if self ._metadata is None :
452+ raise TypeError ("`metadata` attribute is missing - call `fetch_extra_info()` first." )
453+ return self ._metadata
454+
455+ @classmethod
456+ def _markers_to_version_range (cls , markers : Any ) -> VersionRange :
457+ # this is basically _evaluate_markers() adapted for python_version version ranges
458+ # https://github.com/pypa/packaging/blob/07265129295b4b95b9143b50e3ce4709f31a8c49/src/packaging/markers.py#L260-L290
459+ groups : List [List [VersionRange ]] = [[]]
460+ for marker in markers :
461+ if isinstance (marker , list ):
462+ groups [- 1 ].append (cls ._markers_to_version_range (marker ))
463+ elif isinstance (marker , tuple ):
464+ lhs , op , rhs = marker
465+
466+ if isinstance (lhs , _MarkerVariable ):
467+ marker_field = lhs .value
468+ marker_value = rhs .value
469+ else :
470+ marker_field = rhs .value
471+ marker_value = lhs .value
472+
473+ if marker_field != "python_version" :
474+ raise ValueError ("Only 'python_version' field is supported in markers." )
475+ groups [- 1 ].append (
476+ SpecifierSet (f"{ op } { marker_value } " , prereleases = True ).to_range ()
477+ )
478+ elif marker == "or" :
479+ groups .append ([])
480+ elif marker == "and" :
481+ pass
482+ else :
483+ raise TypeError (f"Unexpected marker { marker !r} " )
484+
485+ ret = VersionRange .empty (prereleases = True )
486+ for group in groups :
487+ group_version_range = VersionRange .full (prereleases = True )
488+ for version_range in group :
489+ group_version_range &= version_range
490+ ret |= group_version_range
491+
492+ return version_range
493+
494+ async def fetch_extra_info (self ) -> None :
495+ if self ._metadata is not None :
496+ return
497+ metadata = await self ._fetch_core_metadata ()
498+ # requires https://github.com/pypa/packaging/pull/1267
499+ version_range = self .base_requires_python .to_range ()
500+ for req in metadata .requires_dist or ():
501+ if canonicalize_name (req .name ) not in _REQUIRES_PYTHON_PKG_NAMES :
502+ continue
503+ if req .marker is None :
504+ version_range &= VersionRange .empty (prereleases = True )
505+ break
506+ # there is no public API for Marker's tree structure:
507+ # https://github.com/pypa/packaging/issues/496
508+ version_range &= ~ self ._markers_to_version_range (req .marker ._markers )
509+ requires_python = version_range .to_specifier_set ()
510+ if requires_python is None :
511+ raise RuntimeError ("Could not calculate requires_python property." )
512+ self ._requires_python = requires_python
513+ self ._metadata = metadata
426514
427515 @classmethod
428516 def from_json_dict (cls , data : Dict [str , Any ]) -> Self :
429517 ret = cls (Version (data ["version" ]), data ["files" ])
430- if str (ret .requires_python ) != data ["requires_python " ]:
518+ if str (ret .base_requires_python ) != data ["base_requires_python " ]:
431519 raise ValueError ("requires_python key in given data is inconsistent with files" )
432520 return ret
433521
434522 def to_json_dict (self ) -> Dict [str , Any ]:
435523 return {
436524 "version" : str (self .version ),
437- "requires_python " : str (self .requires_python ),
525+ "base_requires_python " : str (self .base_requires_python ),
438526 "files" : self .files ,
439527 }
440528
441- async def fetch_core_metadata (self ) -> Metadata :
529+ async def _fetch_core_metadata (self ) -> Metadata :
442530 for release_file in self .files .values ():
443531 core_metadata_hashes = release_file .get ("core-metadata" , False )
444532 if core_metadata_hashes is False :
0 commit comments