Skip to content

Commit 264d3ae

Browse files
committed
Allow defining Python requirements through special packages
1 parent c1aa59e commit 264d3ae

3 files changed

Lines changed: 97 additions & 5 deletions

File tree

redbot/_update/cmd/cog_compatibility.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ async def _check_cog_compatibility_command_impl(
9292
latest = await fetch_latest_red_version(
9393
include_prereleases=common.get_current_red_version().is_prerelease
9494
)
95+
await latest.fetch_extra_info()
9596
red_version = latest.version
9697

9798
python_version = Version(".".join(map(str, sys.version_info[:3])))

redbot/_update/updater.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ async def _prepare_metadata(self) -> None:
307307
)
308308
)
309309
latest_major = available_versions[0]
310+
await latest_major.fetch_extra_info()
310311

311312
self.metadata = UpdaterMetadata(
312313
self.options,
@@ -356,6 +357,8 @@ async def _prepare_metadata(self) -> None:
356357
)
357358
raise SystemExit(1)
358359

360+
await self.metadata.latest.fetch_extra_info()
361+
359362
async def _show_changelog(self) -> None:
360363
with self.console.status("Fetching changelogs..."):
361364
changelogs = await changelog.fetch_changelogs()

redbot/core/utils/_internal_utils.py

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@
4040
import aiohttp
4141
import discord
4242
import yarl
43+
from packaging.markers import Variable as _MarkerVariable
4344
from packaging.metadata import Metadata
45+
from packaging.ranges import VersionRange
4446
from packaging.requirements import Requirement
4547
from packaging.specifiers import SpecifierSet
46-
from packaging.utils import parse_sdist_filename
48+
from packaging.utils import canonicalize_name, parse_sdist_filename
4749
from packaging.version import Version
4850
import rapidfuzz
4951
import 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

418430
class 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

Comments
 (0)