| # Copyright 2023 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| from __future__ import annotations |
| |
| import abc |
| import dataclasses |
| import enum |
| import functools |
| import re |
| from typing import Any, Final, Iterable, Optional, Tuple, Type, TypeVar |
| |
| |
| @dataclasses.dataclass |
| class _BrowserVersionChannelMixin: |
| label: str |
| index: int |
| |
| |
| @functools.total_ordering |
| class BrowserVersionChannel(_BrowserVersionChannelMixin, enum.Enum): |
| # Explicit channel enums: |
| LTS = ("lts", 0) |
| STABLE = ("stable", 1) |
| BETA = ("beta", 2) |
| ALPHA = ("alpha", 3) |
| PRE_ALPHA = ("pre-alpha", 4) |
| # Use as sentinel if the channel can be ignored: |
| ANY = ("any", 5) |
| |
| def __str__(self) -> str: |
| return self.label |
| |
| def __lt__(self, other: Any) -> bool: |
| if not isinstance(other, BrowserVersionChannel): |
| raise TypeError("BrowserVersionChannel can not be compared to {other}") |
| return self.index < other.index |
| |
| def __hash__(self) -> int: |
| return hash(self.name) |
| |
| def matches(self, other: BrowserVersionChannel) -> bool: |
| if BrowserVersionChannel.ANY in (self, other): |
| return True |
| return self == other |
| |
| |
| class BrowserVersionParseError(ValueError): |
| |
| def __init__(self, name: str, msg: str, version: str): |
| self._version = version |
| super().__init__(f"Invalid {name} {repr(version)}: {msg}") |
| |
| |
| class PartialBrowserVersionError(ValueError): |
| pass |
| |
| |
| class BrowserVersionNoChannelError(ValueError): |
| pass |
| |
| |
| BrowserVersionT = TypeVar("BrowserVersionT", bound="BrowserVersion") |
| |
| _VERSION_DIGITS_ONLY_RE = re.compile(r"\d+(\.\d+)*") |
| |
| |
| @functools.total_ordering |
| class BrowserVersion(abc.ABC): |
| |
| _MAX_PART_VALUE: Final[int] = 0xFFFF |
| |
| _parts: Tuple[int, ...] |
| _channel: BrowserVersionChannel |
| _version_str: str |
| |
| @classmethod |
| def parse_unique(cls: Type[BrowserVersionT], value: str) -> BrowserVersionT: |
| """Parse a unique version identifier for a browser. |
| Unlike the parse() method, this should only parse input values that can |
| be unambiguously associated with a specific BrowserVersion.""" |
| if _VERSION_DIGITS_ONLY_RE.fullmatch(str(value)): |
| raise cls.parse_error( |
| "Ambiguous version, missing browser specific prefix or suffix", value) |
| return cls.parse(value) |
| |
| @classmethod |
| def parse(cls: Type[BrowserVersionT], |
| value: str, |
| channel: Optional[BrowserVersionChannel] = None) -> BrowserVersionT: |
| (parts, parsed_channel, version_str) = cls._parse(value) |
| parts = cls._validate_parts(parts, value) |
| return cls(parts, channel or parsed_channel, version_str) |
| |
| @classmethod |
| def _validate_parts(cls, parts: Iterable[int], value: str) -> Tuple[int, ...]: |
| if parts is None: |
| raise cls.parse_error("Invalid version format", value) |
| parts_tpl = tuple(parts) |
| for part in parts_tpl: |
| if part < 0: |
| raise cls.parse_error("Version parts must be positive", value) |
| return parts_tpl |
| |
| @classmethod |
| def is_valid_unique(cls, value: str) -> bool: |
| try: |
| cls.parse_unique(value) |
| return True |
| except BrowserVersionParseError: |
| return False |
| |
| @classmethod |
| @abc.abstractmethod |
| def _parse( |
| cls, |
| full_version: str) -> Tuple[Tuple[int, ...], BrowserVersionChannel, str]: |
| pass |
| |
| @classmethod |
| def parse_error(cls, msg: str, version: str) -> BrowserVersionParseError: |
| return BrowserVersionParseError(cls.__name__, msg, version) |
| |
| @classmethod |
| def any(cls: Type[BrowserVersionT], |
| parts: Iterable[int], |
| version_str: str = "") -> BrowserVersionT: |
| return cls(parts, BrowserVersionChannel.ANY, version_str) |
| |
| @classmethod |
| def lts(cls: Type[BrowserVersionT], |
| parts: Iterable[int], |
| version_str: str = "") -> BrowserVersionT: |
| return cls(parts, BrowserVersionChannel.LTS, version_str) |
| |
| @classmethod |
| def stable(cls: Type[BrowserVersionT], |
| parts: Iterable[int], |
| version_str: str = "") -> BrowserVersionT: |
| return cls(parts, BrowserVersionChannel.STABLE, version_str) |
| |
| @classmethod |
| def beta(cls: Type[BrowserVersionT], |
| parts: Iterable[int], |
| version_str: str = "") -> BrowserVersionT: |
| return cls(parts, BrowserVersionChannel.BETA, version_str) |
| |
| @classmethod |
| def alpha(cls: Type[BrowserVersionT], |
| parts: Iterable[int], |
| version_str: str = "") -> BrowserVersionT: |
| return cls(parts, BrowserVersionChannel.ALPHA, version_str) |
| |
| @classmethod |
| def pre_alpha(cls: Type[BrowserVersionT], |
| parts: Iterable[int], |
| version_str: str = "") -> BrowserVersionT: |
| return cls(parts, BrowserVersionChannel.PRE_ALPHA, version_str) |
| |
| def __init__(self, |
| parts: Iterable[int], |
| channel: BrowserVersionChannel = BrowserVersionChannel.STABLE, |
| version_str: str = "") -> None: |
| self._parts = self._validate_parts(parts, version_str or repr(parts)) |
| self._channel = channel |
| self._version_str = version_str |
| |
| @property |
| def parts(self) -> Tuple[int, ...]: |
| return self._parts |
| |
| @property |
| def version_str(self) -> str: |
| return self._version_str |
| |
| @property |
| def parts_str(self) -> str: |
| return ".".join(map(str, self._parts)) |
| |
| def comparable_parts(self, padded_len) -> Tuple[int, ...]: |
| if self.is_complete: |
| return self._parts |
| padding = (self._MAX_PART_VALUE,) * (padded_len - len(self._parts)) |
| return self._parts + padding |
| |
| @property |
| def is_complete(self) -> bool: |
| return self.has_complete_parts and self.has_channel |
| |
| @property |
| @abc.abstractmethod |
| def has_complete_parts(self) -> bool: |
| pass |
| |
| @property |
| def is_unknown(self) -> bool: |
| # Only True for UnknownBrowserVersion |
| return False |
| |
| @property |
| def is_channel_version(self) -> bool: |
| return not self._parts and self.has_channel |
| |
| @property |
| def major(self) -> int: |
| if not self._parts: |
| raise PartialBrowserVersionError() |
| return self._parts[0] |
| |
| @property |
| def minor(self) -> int: |
| if len(self._parts) <= 1: |
| raise PartialBrowserVersionError() |
| return self._parts[1] |
| |
| @property |
| def channel(self) -> BrowserVersionChannel: |
| if not self.has_channel: |
| raise BrowserVersionNoChannelError( |
| f"BrowserVersion {self} has no channel") |
| return self._channel |
| |
| def matches_channel(self, channel: BrowserVersionChannel) -> bool: |
| return self._channel.matches(channel) |
| |
| @property |
| def has_channel(self) -> bool: |
| return self._channel is not BrowserVersionChannel.ANY |
| |
| @property |
| def is_lts(self) -> bool: |
| return self._channel == BrowserVersionChannel.LTS |
| |
| @property |
| def is_stable(self) -> bool: |
| return self._channel == BrowserVersionChannel.STABLE |
| |
| @property |
| def is_beta(self) -> bool: |
| return self._channel == BrowserVersionChannel.BETA |
| |
| @property |
| def is_alpha(self) -> bool: |
| return self._channel == BrowserVersionChannel.ALPHA |
| |
| @property |
| def is_pre_alpha(self) -> bool: |
| return self._channel == BrowserVersionChannel.PRE_ALPHA |
| |
| @property |
| def channel_name(self) -> str: |
| if not self.has_channel: |
| return "any" |
| return self._channel_name(self._channel) |
| |
| @abc.abstractmethod |
| def _channel_name(self, channel: BrowserVersionChannel) -> str: |
| pass |
| |
| @property |
| def key(self) -> Tuple[Tuple[int, ...], BrowserVersionChannel]: |
| return (self._parts, self._channel) |
| |
| def __str__(self) -> str: |
| if not self._version_str: |
| if not self._parts: |
| return self.channel_name |
| return f"{self.parts_str} {self.channel_name}" |
| return f"{self._version_str} {self.channel_name}" |
| |
| def __repr__(self) -> str: |
| return ( |
| f"{self.__class__.__name__}" |
| f"({self.parts_str}, {self.channel_name}, {repr(self._version_str)})") |
| |
| def __eq__(self, other: Any) -> bool: |
| if not isinstance(other, type(self)): |
| return False |
| return self.key == other.key |
| |
| def __le__(self, other: Any) -> bool: |
| if not isinstance(other, type(self)): |
| raise TypeError("Cannot compare versions from different browsers: " |
| f"{self} vs. {other}.") |
| if self.is_channel_version and other.is_channel_version: |
| return self._channel <= other._channel |
| if self.is_channel_version: |
| raise ValueError(f"Cannot compare channel {self} against {other}") |
| if other.is_channel_version: |
| raise ValueError(f"Cannot compare {self} against channel {other}") |
| return self.key <= other.key |
| |
| def contains(self, other: BrowserVersion): |
| if not isinstance(other, type(self)): |
| raise TypeError("Cannot compare versions from different browsers: " |
| f"{self} vs. {other}.") |
| if self == other: |
| return True |
| if self.has_channel and other.has_channel: |
| if self.channel != other.channel: |
| return False |
| # A less precise version (e.g. channel or partial version) can never be |
| # part of a more complete version. |
| other_parts = other.parts |
| common_part_len = min(len(self._parts), len(other_parts)) |
| if common_part_len < len(self._parts): |
| return False |
| return self._parts[:common_part_len] == other_parts[:common_part_len] |
| |
| |
| class UnknownBrowserVersion(BrowserVersion): |
| """Sentinel helper object for initializing version variables before |
| knowing which exact browser/version is used.""" |
| |
| def __init__(self, |
| parts: Tuple[int, ...] = (), |
| channel: BrowserVersionChannel = BrowserVersionChannel.ANY, |
| version_str: str = "unknown") -> None: |
| super().__init__(parts, BrowserVersionChannel.ANY, version_str) |
| |
| @classmethod |
| def _parse( |
| cls, |
| full_version: str) -> Tuple[Tuple[int, ...], BrowserVersionChannel, str]: |
| raise RuntimeError("UnknownBrowserVersion does not support parsing") |
| |
| def _channel_name(self, channel: BrowserVersionChannel) -> str: |
| return "unknown" |
| |
| @property |
| def has_complete_parts(self) -> bool: |
| return False |
| |
| @property |
| def is_unknown(self) -> bool: |
| return True |