diff --git a/redbot/cogs/audio/__init__.py b/redbot/cogs/audio/__init__.py index 6ced4c3e4..2f35ddd32 100644 --- a/redbot/cogs/audio/__init__.py +++ b/redbot/cogs/audio/__init__.py @@ -34,7 +34,7 @@ async def download_lavalink(session): async def maybe_download_lavalink(loop, cog): jar_exists = LAVALINK_JAR_FILE.exists() - current_build = redbot.core.VersionInfo(*await cog.config.current_build()) + current_build = redbot.core.VersionInfo.from_json(await cog.config.current_build()) if not jar_exists or current_build < redbot.core.version_info: log.info("Downloading Lavalink.jar") diff --git a/redbot/core/__init__.py b/redbot/core/__init__.py index dbba4f8e0..5eba3de03 100644 --- a/redbot/core/__init__.py +++ b/redbot/core/__init__.py @@ -1,40 +1,152 @@ +import re as _re +from math import inf as _inf +from typing import ( + Any as _Any, + ClassVar as _ClassVar, + Dict as _Dict, + List as _List, + Optional as _Optional, + Pattern as _Pattern, + Tuple as _Tuple, + Union as _Union, +) + from .config import Config -__all__ = ["Config", "__version__"] +__all__ = ["Config", "__version__", "version_info", "VersionInfo"] class VersionInfo: - def __init__(self, major, minor, micro, releaselevel, serial): - self._levels = ["alpha", "beta", "release candidate", "final"] - self.major = major - self.minor = minor - self.micro = micro + ALPHA = "alpha" + BETA = "beta" + RELEASE_CANDIDATE = "release candidate" + FINAL = "final" - if releaselevel not in self._levels: - raise TypeError("'releaselevel' must be one of: {}".format(", ".join(self._levels))) + _VERSION_STR_PATTERN: _ClassVar[_Pattern[str]] = _re.compile( + r"^" + r"(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)" + r"(?:(?Pa|b|rc)(?P0|[1-9]\d*))?" + r"(?:\.post(?P0|[1-9]\d*))?" + r"(?:\.dev(?P0|[1-9]\d*))?" + r"$", + flags=_re.IGNORECASE, + ) + _RELEASE_LEVELS: _ClassVar[_List[str]] = [ALPHA, BETA, RELEASE_CANDIDATE, FINAL] + _SHORT_RELEASE_LEVELS: _ClassVar[_Dict[str, str]] = { + "a": ALPHA, + "b": BETA, + "rc": RELEASE_CANDIDATE, + } - self.releaselevel = releaselevel - self.serial = serial + def __init__( + self, + major: int, + minor: int, + micro: int, + releaselevel: str, + serial: _Optional[int] = None, + post_release: _Optional[int] = None, + dev_release: _Optional[int] = None, + ) -> None: + self.major: int = major + self.minor: int = minor + self.micro: int = micro - def __lt__(self, other): - my_index = self._levels.index(self.releaselevel) - other_index = self._levels.index(other.releaselevel) - return (self.major, self.minor, self.micro, my_index, self.serial) < ( - other.major, - other.minor, - other.micro, - other_index, - other.serial, + if releaselevel not in self._RELEASE_LEVELS: + raise TypeError(f"'releaselevel' must be one of: {', '.join(self._RELEASE_LEVELS)}") + + self.releaselevel: str = releaselevel + self.serial: _Optional[int] = serial + self.post_release: _Optional[int] = post_release + self.dev_release: _Optional[int] = dev_release + + @classmethod + def from_str(cls, version_str: str) -> "VersionInfo": + """Parse a string into a VersionInfo object. + + Raises + ------ + ValueError + If the version info string is invalid. + + """ + match = cls._VERSION_STR_PATTERN.match(version_str) + if not match: + raise ValueError(f"Invalid version string: {version_str}") + + kwargs: _Dict[str, _Union[str, int]] = {} + for key in ("major", "minor", "micro"): + kwargs[key] = int(match[key]) + releaselevel = match["releaselevel"] + if releaselevel is not None: + kwargs["releaselevel"] = cls._SHORT_RELEASE_LEVELS[releaselevel] + else: + kwargs["releaselevel"] = cls.FINAL + for key in ("serial", "post_release", "dev_release"): + if match[key] is not None: + kwargs[key] = int(match[key]) + return cls(**kwargs) + + @classmethod + def from_json( + cls, data: _Union[_Dict[str, _Union[int, str]], _List[_Union[int, str]]] + ) -> "VersionInfo": + if isinstance(data, _List): + # For old versions, data was stored as a list: + # [MAJOR, MINOR, MICRO, RELEASELEVEL, SERIAL] + return cls(*data) + else: + return cls(**data) + + def to_json(self) -> _Dict[str, _Union[int, str]]: + return { + "major": self.major, + "minor": self.minor, + "micro": self.micro, + "releaselevel": self.releaselevel, + "serial": self.serial, + "post_release": self.post_release, + "dev_release": self.dev_release, + } + + def __lt__(self, other: _Any) -> bool: + if not isinstance(other, VersionInfo): + return NotImplemented + tups: _List[_Tuple[int, int, int, int, int, int, int]] = [] + for obj in (self, other): + tups.append( + ( + obj.major, + obj.minor, + obj.micro, + obj._RELEASE_LEVELS.index(obj.releaselevel), + obj.serial if obj.serial is not None else _inf, + obj.post_release if obj.post_release is not None else -_inf, + obj.dev_release if obj.dev_release is not None else _inf, + ) + ) + return tups[0] < tups[1] + + def __str__(self) -> str: + ret = f"{self.major}.{self.minor}.{self.micro}" + if self.releaselevel != self.FINAL: + short = next( + k for k, v in self._SHORT_RELEASE_LEVELS.items() if v == self.releaselevel + ) + ret += f"{short}{self.serial}" + if self.post_release is not None: + ret += f".post{self.post_release}" + if self.dev_release is not None: + ret += f".dev{self.dev_release}" + return ret + + def __repr__(self) -> str: + return ( + "VersionInfo(major={major}, minor={minor}, micro={micro}, " + "releaselevel={releaselevel}, serial={serial}, post={post_release}, " + "dev={dev_release})".format(**self.to_json()) ) - def __repr__(self): - return "VersionInfo(major={}, minor={}, micro={}, releaselevel={}, serial={})".format( - self.major, self.minor, self.micro, self.releaselevel, self.serial - ) - - def to_json(self): - return [self.major, self.minor, self.micro, self.releaselevel, self.serial] - __version__ = "3.0.0rc1" -version_info = VersionInfo(3, 0, 0, "release candidate", 1) +version_info = VersionInfo.from_str(__version__) diff --git a/redbot/core/core_commands.py b/redbot/core/core_commands.py index 32adb15bb..3f0161cfd 100644 --- a/redbot/core/core_commands.py +++ b/redbot/core/core_commands.py @@ -13,17 +13,20 @@ from collections import namedtuple from pathlib import Path from random import SystemRandom from string import ascii_letters, digits -from distutils.version import StrictVersion from typing import TYPE_CHECKING, Union import aiohttp import discord import pkg_resources -from redbot.core import __version__ -from redbot.core import checks -from redbot.core import i18n -from redbot.core import commands +from redbot.core import ( + __version__, + version_info as red_version_info, + VersionInfo, + checks, + commands, + i18n, +) from .utils.predicates import MessagePredicate from .utils.chat_formatting import pagify, box, inline @@ -274,7 +277,7 @@ class Core(commands.Cog, CoreLogic): async with aiohttp.ClientSession() as session: async with session.get("{}/json".format(red_pypi)) as r: data = await r.json() - outdated = StrictVersion(data["info"]["version"]) > StrictVersion(__version__) + outdated = VersionInfo.from_str(data["info"]["version"]) > red_version_info about = ( "This is an instance of [Red, an open source Discord bot]({}) " "created by [Twentysix]({}) and [improved by many]({}).\n\n" diff --git a/redbot/core/events.py b/redbot/core/events.py index e10fcaecb..427eea041 100644 --- a/redbot/core/events.py +++ b/redbot/core/events.py @@ -1,10 +1,10 @@ +import contextlib import sys import codecs import datetime import logging import traceback from datetime import timedelta -from distutils.version import StrictVersion from typing import List import aiohttp @@ -13,7 +13,7 @@ import pkg_resources from colorama import Fore, Style, init from pkg_resources import DistributionNotFound -from . import __version__, commands +from . import __version__ as red_version, version_info as red_version_info, VersionInfo, commands from .data_manager import storage_type from .utils.chat_formatting import inline, bordered, humanize_list from .utils import fuzzy_command_search, format_fuzzy_results @@ -105,7 +105,6 @@ def init_events(bot, cli_flags): prefixes = cli_flags.prefix or (await bot.db.prefix()) lang = await bot.db.locale() - red_version = __version__ red_pkg = pkg_resources.get_distribution("Red-DiscordBot") dpy_version = discord.__version__ @@ -125,24 +124,22 @@ def init_events(bot, cli_flags): INFO.append("{} cogs with {} commands".format(len(bot.cogs), len(bot.commands))) - try: + with contextlib.suppress(aiohttp.ClientError, discord.HTTPException): async with aiohttp.ClientSession() as session: async with session.get("https://pypi.python.org/pypi/red-discordbot/json") as r: data = await r.json() - if StrictVersion(data["info"]["version"]) > StrictVersion(red_version): + if VersionInfo.from_str(data["info"]["version"]) > red_version_info: INFO.append( "Outdated version! {} is available " "but you're using {}".format(data["info"]["version"], red_version) ) - owner = discord.utils.get(bot.get_all_members(), id=bot.owner_id) + owner = await bot.get_user_info(bot.owner_id) await owner.send( "Your Red instance is out of date! {} is the current " "version, however you are using {}!".format( data["info"]["version"], red_version ) ) - except: - pass INFO2 = [] sentry = await bot.db.enable_sentry() diff --git a/redbot/launcher.py b/redbot/launcher.py index ceb77c4f0..0904a94cb 100644 --- a/redbot/launcher.py +++ b/redbot/launcher.py @@ -8,18 +8,14 @@ import asyncio import aiohttp import pkg_resources -from pathlib import Path -from distutils.version import StrictVersion from redbot.setup import ( basic_setup, load_existing_config, remove_instance, remove_instance_interaction, create_backup, - save_config, ) -from redbot.core import __version__ -from redbot.core.utils import safe_delete +from redbot.core import __version__, version_info as red_version_info, VersionInfo from redbot.core.cli import confirm if sys.platform == "linux": @@ -390,7 +386,7 @@ async def is_outdated(): async with session.get("{}/json".format(red_pypi)) as r: data = await r.json() new_version = data["info"]["version"] - return StrictVersion(new_version) > StrictVersion(__version__), new_version + return VersionInfo.from_str(new_version) > red_version_info, new_version def main_menu(): diff --git a/tests/core/test_version.py b/tests/core/test_version.py index 94c870b11..367000895 100644 --- a/tests/core/test_version.py +++ b/tests/core/test_version.py @@ -1,6 +1,36 @@ from redbot import core +from redbot.core import VersionInfo def test_version_working(): assert hasattr(core, "__version__") assert core.__version__[0] == "3" + + +# When adding more of these, ensure they are added in ascending order of precedence +version_tests = ( + "3.0.0a32.post10.dev12", + "3.0.0rc1.dev1", + "3.0.0rc1", + "3.0.0", + "3.0.1", + "3.0.1.post1.dev1", + "3.0.1.post1", + "2018.10.6b21", +) + + +def test_version_info_str_parsing(): + for version_str in version_tests: + assert version_str == str(VersionInfo.from_str(version_str)) + + +def test_version_info_lt(): + for next_idx, cur in enumerate(version_tests[:-1], start=1): + cur_test = VersionInfo.from_str(cur) + next_test = VersionInfo.from_str(version_tests[next_idx]) + assert cur_test < next_test + + +def test_version_info_gt(): + assert VersionInfo.from_str(version_tests[1]) > VersionInfo.from_str(version_tests[0])