Add Environment abstraction to internal Downloader API (#6710)

This commit is contained in:
Jakub Kuczys
2026-05-14 00:09:45 +02:00
committed by GitHub
parent 13f45f69ac
commit 899f24ceca
6 changed files with 120 additions and 67 deletions
+4 -5
View File
@@ -4,7 +4,8 @@ import re
from typing import Tuple, Iterable, Collection, Optional, Set, List from typing import Tuple, Iterable, Collection, Optional, Set, List
import discord import discord
from redbot.core import _downloader, commands, version_info as red_version_info from redbot import __version__
from redbot.core import _downloader, commands
from redbot.core._downloader import errors from redbot.core._downloader import errors
from redbot.core._downloader.installable import InstalledModule from redbot.core._downloader.installable import InstalledModule
from redbot.core.bot import Red from redbot.core.bot import Red
@@ -956,9 +957,7 @@ class Downloader(commands.Cog):
) + humanize_list( ) + humanize_list(
[ [
inline(cog.name) inline(cog.name)
+ _(" (Minimum: {min_version})").format( + _(" (Minimum: {min_version})").format(min_version=cog.min_python_version)
min_version=".".join([str(n) for n in cog.min_python_version])
)
for cog in update_check_result.incompatible_python_version for cog in update_check_result.incompatible_python_version
] ]
) )
@@ -973,7 +972,7 @@ class Downloader(commands.Cog):
"\nThis cog requires different Red version than you currently " "\nThis cog requires different Red version than you currently "
"have ({current_version}): " "have ({current_version}): "
) )
).format(current_version=red_version_info) + humanize_list( ).format(current_version=__version__) + humanize_list(
[ [
inline(cog.name) inline(cog.name)
+ _(" (Minimum: {min_version}").format(min_version=cog.min_bot_version) + _(" (Minimum: {min_version}").format(min_version=cog.min_bot_version)
+58 -16
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import contextlib import contextlib
import dataclasses import dataclasses
import functools
import json import json
import os import os
import shutil import shutil
@@ -35,7 +36,11 @@ from typing import (
) )
import discord import discord
from redbot.core import commands, Config, version_info as red_version_info from packaging.version import Version
from typing_extensions import Self
from redbot import __version__
from redbot.core import commands, Config
from redbot.core._cog_manager import CogManager from redbot.core._cog_manager import CogManager
from redbot.core.data_manager import cog_data_path from redbot.core.data_manager import cog_data_path
from redbot.core.utils._internal_utils import detailed_progress from redbot.core.utils._internal_utils import detailed_progress
@@ -554,8 +559,34 @@ async def reinstall_requirements() -> Tuple[Tuple[str, ...], Tuple[Installable,
return failed_reqs, tuple(all_failed_libs) return failed_reqs, tuple(all_failed_libs)
# TODO: make kw_only
@dataclasses.dataclass(frozen=True)
class Environment:
"""
Environment to check version bounds against.
Usually the metadata from current environment is used but this allows for alternative uses
such as checking, if the cog will work on a Red version you want to update to.
"""
red_version: Version
python_version: Version
@classmethod
@functools.lru_cache(maxsize=None)
def current(cls) -> Self:
return cls(
red_version=Version(__version__),
python_version=Version(".".join(map(str, sys.version_info[:3]))),
)
async def install_cogs( async def install_cogs(
repo: Repo, rev: Optional[str], cog_names: Iterable[str] repo: Repo,
rev: Optional[str],
cog_names: Iterable[str],
*,
env: Environment = Environment.current(),
) -> CogInstallResult: ) -> CogInstallResult:
commit = None commit = None
@@ -588,12 +619,12 @@ async def install_cogs(
already_installed.append(cog) already_installed.append(cog)
elif discord.utils.get(_installed_cogs, name=cog.name): elif discord.utils.get(_installed_cogs, name=cog.name):
name_already_used.append(cog) name_already_used.append(cog)
elif cog.min_python_version > sys.version_info: elif cog.min_python_version > env.python_version:
incompatible_python_version.append(cog) incompatible_python_version.append(cog)
elif cog.min_bot_version > red_version_info or ( elif cog.min_bot_version > env.red_version or (
# max version should be ignored when it's lower than min version # max version should be ignored when it's lower than min version
cog.min_bot_version <= cog.max_bot_version cog.min_bot_version <= cog.max_bot_version
and cog.max_bot_version < red_version_info and cog.max_bot_version < env.red_version
): ):
incompatible_bot_version.append(cog) incompatible_bot_version.append(cog)
else: else:
@@ -653,6 +684,7 @@ async def check_cog_updates(
repos: Optional[Iterable[Repo]] = None, repos: Optional[Iterable[Repo]] = None,
cogs: Optional[Iterable[InstalledModule]] = None, cogs: Optional[Iterable[InstalledModule]] = None,
update_repos: bool = True, update_repos: bool = True,
env: Environment = Environment.current(),
) -> CogUpdateCheckResult: ) -> CogUpdateCheckResult:
cogs_to_check, failed_repos = await _get_cogs_to_check( cogs_to_check, failed_repos = await _get_cogs_to_check(
repos=repos, cogs=cogs, update_repos=update_repos repos=repos, cogs=cogs, update_repos=update_repos
@@ -663,12 +695,12 @@ async def check_cog_updates(
incompatible_python_version: List[Installable] = [] incompatible_python_version: List[Installable] = []
incompatible_bot_version: List[Installable] = [] incompatible_bot_version: List[Installable] = []
for cog in outdated_cogs: for cog in outdated_cogs:
if cog.min_python_version > sys.version_info: if cog.min_python_version > env.python_version:
incompatible_python_version.append(cog) incompatible_python_version.append(cog)
elif cog.min_bot_version > red_version_info or ( elif cog.min_bot_version > env.red_version or (
# max version should be ignored when it's lower than min version # max version should be ignored when it's lower than min version
cog.min_bot_version <= cog.max_bot_version cog.min_bot_version <= cog.max_bot_version
and cog.max_bot_version < red_version_info and cog.max_bot_version < env.red_version
): ):
incompatible_bot_version.append(cog) incompatible_bot_version.append(cog)
else: else:
@@ -686,19 +718,26 @@ async def check_cog_updates(
# update given cogs or all cogs # update given cogs or all cogs
async def update_cogs( async def update_cogs(
*, cogs: Optional[List[InstalledModule]] = None, repos: Optional[List[Repo]] = None *,
cogs: Optional[List[InstalledModule]] = None,
repos: Optional[List[Repo]] = None,
env: Environment = Environment.current(),
) -> CogUpdateResult: ) -> CogUpdateResult:
if cogs is not None and repos is not None: if cogs is not None and repos is not None:
raise ValueError("You can specify cogs or repos argument, not both") raise ValueError("You can specify cogs or repos argument, not both")
cogs_to_check, failed_repos = await _get_cogs_to_check(repos=repos, cogs=cogs) cogs_to_check, failed_repos = await _get_cogs_to_check(repos=repos, cogs=cogs)
return await _update_cogs(cogs_to_check, failed_repos=failed_repos) return await _update_cogs(cogs_to_check, failed_repos=failed_repos, env=env)
# update given cogs or all cogs from the specified repo # update given cogs or all cogs from the specified repo
# using the specified revision (or latest if not specified) # using the specified revision (or latest if not specified)
async def update_repo_cogs( async def update_repo_cogs(
repo: Repo, cogs: Optional[List[InstalledModule]] = None, *, rev: Optional[str] = None repo: Repo,
cogs: Optional[List[InstalledModule]] = None,
*,
rev: Optional[str] = None,
env: Environment = Environment.current(),
) -> CogUpdateResult: ) -> CogUpdateResult:
try: try:
await repo.update() await repo.update()
@@ -712,11 +751,14 @@ async def update_repo_cogs(
commit = await repo.get_full_sha1(rev) commit = await repo.get_full_sha1(rev)
async with repo.checkout(commit, exit_to_rev=repo.branch): async with repo.checkout(commit, exit_to_rev=repo.branch):
cogs_to_check, __ = await _get_cogs_to_check(repos=[repo], cogs=cogs, update_repos=False) cogs_to_check, __ = await _get_cogs_to_check(repos=[repo], cogs=cogs, update_repos=False)
return await _update_cogs(cogs_to_check, failed_repos=()) return await _update_cogs(cogs_to_check, failed_repos=(), env=env)
async def _update_cogs( async def _update_cogs(
cogs_to_check: Set[InstalledModule], *, failed_repos: Sequence[str] cogs_to_check: Set[InstalledModule],
*,
failed_repos: Sequence[str],
env: Environment = Environment.current(),
) -> CogUpdateResult: ) -> CogUpdateResult:
pinned_cogs = {cog for cog in cogs_to_check if cog.pinned} pinned_cogs = {cog for cog in cogs_to_check if cog.pinned}
cogs_to_check -= pinned_cogs cogs_to_check -= pinned_cogs
@@ -737,12 +779,12 @@ async def _update_cogs(
outdated_cogs, outdated_libs = await _available_updates(cogs_to_check) outdated_cogs, outdated_libs = await _available_updates(cogs_to_check)
for cog in outdated_cogs: for cog in outdated_cogs:
if cog.min_python_version > sys.version_info: if cog.min_python_version > env.python_version:
incompatible_python_version.append(cog) incompatible_python_version.append(cog)
elif cog.min_bot_version > red_version_info or ( elif cog.min_bot_version > env.red_version or (
# max version should be ignored when it's lower than min version # max version should be ignored when it's lower than min version
cog.min_bot_version <= cog.max_bot_version cog.min_bot_version <= cog.max_bot_version
and cog.max_bot_version < red_version_info and cog.max_bot_version < env.red_version
): ):
incompatible_bot_version.append(cog) incompatible_bot_version.append(cog)
else: else:
+41 -35
View File
@@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, Tuple, Union, cast from typing import TYPE_CHECKING, Any, Callable, Dict, Tuple, Union
from redbot import VersionInfo, version_info as red_version_info from packaging.version import Version
from . import installable from . import installable
from .log import log from .log import log
@@ -67,38 +67,40 @@ def ensure_str(info_file: Path, key_name: str, value: Union[Any, UseDefault]) ->
return value return value
def ensure_red_version_info( def create_ensure_red_version(default: Version) -> EnsureCallable:
info_file: Path, key_name: str, value: Union[Any, UseDefault] def ensure_red_version(
) -> VersionInfo: info_file: Path, key_name: str, value: Union[Any, UseDefault]
default = red_version_info ) -> Version:
if value is USE_DEFAULT: if value is USE_DEFAULT:
return default return default
if not isinstance(value, str): if not isinstance(value, str):
log.warning( log.warning(
"Invalid value of '%s' key (expected str, got %s)" "Invalid value of '%s' key (expected str, got %s)"
" in JSON information file at path: %s", " in JSON information file at path: %s",
key_name, key_name,
type(value).__name__, type(value).__name__,
info_file, info_file,
) )
return default return default
try: try:
version_info = VersionInfo.from_str(value) version_info = Version(value)
except ValueError: except ValueError:
log.warning( log.warning(
"Invalid value of '%s' key (given value isn't a valid version string)" "Invalid value of '%s' key (given value isn't a valid version string)"
" in JSON information file at path: %s", " in JSON information file at path: %s",
key_name, key_name,
info_file, info_file,
) )
return default return default
return version_info return version_info
return ensure_red_version
def ensure_python_version_info( def ensure_python_version(
info_file: Path, key_name: str, value: Union[Any, UseDefault] info_file: Path, key_name: str, value: Union[Any, UseDefault]
) -> Tuple[int, int, int]: ) -> Version:
default = (3, 5, 1) default = Version("3.5.1")
if value is USE_DEFAULT: if value is USE_DEFAULT:
return default return default
if not isinstance(value, list): if not isinstance(value, list):
@@ -130,7 +132,7 @@ def ensure_python_version_info(
info_file, info_file,
) )
return default return default
return cast(Tuple[int, int, int], tuple(value)) return Version(".".join(map(str, value)))
def ensure_bool( def ensure_bool(
@@ -211,9 +213,13 @@ REPO_SCHEMA: SchemaType = {
"short": ensure_str, "short": ensure_str,
} }
INSTALLABLE_SCHEMA: SchemaType = { INSTALLABLE_SCHEMA: SchemaType = {
"min_bot_version": ensure_red_version_info, "min_bot_version": create_ensure_red_version(Version("0.0.dev0")),
"max_bot_version": ensure_red_version_info, # Using little-known version epoch feature to represent something that,
"min_python_version": ensure_python_version_info, # for all practical purposes, will be considered higher than any version number
# that we may ever have.
# https://packaging.python.org/en/latest/specifications/version-specifiers/#version-epochs
"max_bot_version": create_ensure_red_version(Version("99999!99999.99999.post99999+hi.mom")),
"min_python_version": ensure_python_version,
"hidden": ensure_bool, "hidden": ensure_bool,
"disabled": ensure_bool, "disabled": ensure_bool,
"required_cogs": ensure_required_cogs_mapping, "required_cogs": ensure_required_cogs_mapping,
+8 -8
View File
@@ -6,12 +6,12 @@ from enum import Enum
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, Union, cast from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, Union, cast
from packaging.version import Version
from .log import log from .log import log
from .info_schemas import INSTALLABLE_SCHEMA, update_mixin from .info_schemas import INSTALLABLE_SCHEMA, update_mixin
from .json_mixins import RepoJSONMixin from .json_mixins import RepoJSONMixin
from redbot.core import VersionInfo
if TYPE_CHECKING: if TYPE_CHECKING:
from .repo_manager import RepoManager, Repo from .repo_manager import RepoManager, Repo
@@ -44,12 +44,12 @@ class Installable(RepoJSONMixin):
Name(s) of the author(s). Name(s) of the author(s).
end_user_data_statement : `str` end_user_data_statement : `str`
End user data statement of the module. End user data statement of the module.
min_bot_version : `VersionInfo` min_bot_version : `packaging.version.Version`
The minimum bot version required for this Installable. The minimum bot version required for this Installable.
max_bot_version : `VersionInfo` max_bot_version : `packaging.version.Version`
The maximum bot version required for this Installable. The maximum bot version required for this Installable.
Ignored if `min_bot_version` is newer than `max_bot_version`. Ignored if `min_bot_version` is newer than `max_bot_version`.
min_python_version : `tuple` of `int` min_python_version : `packaging.version.Version`
The minimum python version required for this cog. The minimum python version required for this cog.
hidden : `bool` hidden : `bool`
Whether or not this cog will be hidden from the user when they use Whether or not this cog will be hidden from the user when they use
@@ -87,9 +87,9 @@ class Installable(RepoJSONMixin):
self.commit = commit self.commit = commit
self.end_user_data_statement: str self.end_user_data_statement: str
self.min_bot_version: VersionInfo self.min_bot_version: Version
self.max_bot_version: VersionInfo self.max_bot_version: Version
self.min_python_version: Tuple[int, int, int] self.min_python_version: Version
self.hidden: bool self.hidden: bool
self.disabled: bool self.disabled: bool
self.required_cogs: Dict[str, str] # Cog name -> repo URL self.required_cogs: Dict[str, str] # Cog name -> repo URL
+2
View File
@@ -87,6 +87,7 @@ INFO_JSON = {
"author": ("tekulvw",), "author": ("tekulvw",),
"min_bot_version": "3.0.0", "min_bot_version": "3.0.0",
"max_bot_version": "3.0.2", "max_bot_version": "3.0.2",
"min_python_version": [3, 7, 1],
"description": "A long description", "description": "A long description",
"hidden": False, "hidden": False,
"install_msg": "A post-installation message", "install_msg": "A post-installation message",
@@ -101,6 +102,7 @@ LIBRARY_INFO_JSON = {
"author": ("seputaes",), "author": ("seputaes",),
"min_bot_version": "3.0.0", "min_bot_version": "3.0.0",
"max_bot_version": "3.0.2", "max_bot_version": "3.0.2",
"min_python_version": [3, 7, 1],
"description": "A long library description", "description": "A long library description",
"hidden": False, # libraries are always hidden, this tests it will be flipped "hidden": False, # libraries are always hidden, this tests it will be flipped
"install_msg": "A library install message", "install_msg": "A library install message",
+7 -3
View File
@@ -2,10 +2,10 @@ import json
from pathlib import Path from pathlib import Path
import pytest import pytest
from packaging.version import Version
from redbot.pytest.downloader import * from redbot.pytest.downloader import *
from redbot.core._downloader.installable import Installable, InstallableType from redbot.core._downloader.installable import Installable, InstallableType
from redbot.core import VersionInfo
def test_process_info_file(installable): def test_process_info_file(installable):
@@ -13,7 +13,9 @@ def test_process_info_file(installable):
if k == "type": if k == "type":
assert installable.type is InstallableType.COG assert installable.type is InstallableType.COG
elif k in ("min_bot_version", "max_bot_version"): elif k in ("min_bot_version", "max_bot_version"):
assert getattr(installable, k) == VersionInfo.from_str(v) assert getattr(installable, k) == Version(v)
elif k == "min_python_version":
assert installable.min_python_version == Version(".".join(map(str, v)))
else: else:
assert getattr(installable, k) == v assert getattr(installable, k) == v
@@ -23,7 +25,9 @@ def test_process_lib_info_file(library_installable):
if k == "type": if k == "type":
assert library_installable.type is InstallableType.SHARED_LIBRARY assert library_installable.type is InstallableType.SHARED_LIBRARY
elif k in ("min_bot_version", "max_bot_version"): elif k in ("min_bot_version", "max_bot_version"):
assert getattr(library_installable, k) == VersionInfo.from_str(v) assert getattr(library_installable, k) == Version(v)
elif k == "min_python_version":
assert library_installable.min_python_version == Version(".".join(map(str, v)))
elif k == "hidden": elif k == "hidden":
# libraries are always hidden, even if False # libraries are always hidden, even if False
assert library_installable.hidden is True assert library_installable.hidden is True