# 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) -> bool: 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