1# Copyright 2023 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5from __future__ import annotations 6 7import abc 8import dataclasses 9import enum 10import functools 11import re 12from typing import Any, Final, Iterable, Optional, Tuple, Type, TypeVar 13 14 15@dataclasses.dataclass 16class _BrowserVersionChannelMixin: 17 label: str 18 index: int 19 20 21@functools.total_ordering 22class BrowserVersionChannel(_BrowserVersionChannelMixin, enum.Enum): 23 # Explicit channel enums: 24 LTS = ("lts", 0) 25 STABLE = ("stable", 1) 26 BETA = ("beta", 2) 27 ALPHA = ("alpha", 3) 28 PRE_ALPHA = ("pre-alpha", 4) 29 # Use as sentinel if the channel can be ignored: 30 ANY = ("any", 5) 31 32 def __str__(self) -> str: 33 return self.label 34 35 def __lt__(self, other: Any) -> bool: 36 if not isinstance(other, BrowserVersionChannel): 37 raise TypeError("BrowserVersionChannel can not be compared to {other}") 38 return self.index < other.index 39 40 def __hash__(self) -> int: 41 return hash(self.name) 42 43 def matches(self, other: BrowserVersionChannel) -> bool: 44 if BrowserVersionChannel.ANY in (self, other): 45 return True 46 return self == other 47 48 49class BrowserVersionParseError(ValueError): 50 51 def __init__(self, name: str, msg: str, version: str): 52 self._version = version 53 super().__init__(f"Invalid {name} {repr(version)}: {msg}") 54 55 56class PartialBrowserVersionError(ValueError): 57 pass 58 59 60class BrowserVersionNoChannelError(ValueError): 61 pass 62 63 64BrowserVersionT = TypeVar("BrowserVersionT", bound="BrowserVersion") 65 66_VERSION_DIGITS_ONLY_RE = re.compile(r"\d+(\.\d+)*") 67 68 69@functools.total_ordering 70class BrowserVersion(abc.ABC): 71 72 _MAX_PART_VALUE: Final[int] = 0xFFFF 73 74 _parts: Tuple[int, ...] 75 _channel: BrowserVersionChannel 76 _version_str: str 77 78 @classmethod 79 def parse_unique(cls: Type[BrowserVersionT], value: str) -> BrowserVersionT: 80 """Parse a unique version identifier for a browser. 81 Unlike the parse() method, this should only parse input values that can 82 be unambiguously associated with a specific BrowserVersion.""" 83 if _VERSION_DIGITS_ONLY_RE.fullmatch(str(value)): 84 raise cls.parse_error( 85 "Ambiguous version, missing browser specific prefix or suffix", value) 86 return cls.parse(value) 87 88 @classmethod 89 def parse(cls: Type[BrowserVersionT], 90 value: str, 91 channel: Optional[BrowserVersionChannel] = None) -> BrowserVersionT: 92 (parts, parsed_channel, version_str) = cls._parse(value) 93 parts = cls._validate_parts(parts, value) 94 return cls(parts, channel or parsed_channel, version_str) 95 96 @classmethod 97 def _validate_parts(cls, parts: Iterable[int], value: str) -> Tuple[int, ...]: 98 if parts is None: 99 raise cls.parse_error("Invalid version format", value) 100 parts_tpl = tuple(parts) 101 for part in parts_tpl: 102 if part < 0: 103 raise cls.parse_error("Version parts must be positive", value) 104 return parts_tpl 105 106 @classmethod 107 def is_valid_unique(cls, value: str) -> bool: 108 try: 109 cls.parse_unique(value) 110 return True 111 except BrowserVersionParseError: 112 return False 113 114 @classmethod 115 @abc.abstractmethod 116 def _parse( 117 cls, 118 full_version: str) -> Tuple[Tuple[int, ...], BrowserVersionChannel, str]: 119 pass 120 121 @classmethod 122 def parse_error(cls, msg: str, version: str) -> BrowserVersionParseError: 123 return BrowserVersionParseError(cls.__name__, msg, version) 124 125 @classmethod 126 def any(cls: Type[BrowserVersionT], 127 parts: Iterable[int], 128 version_str: str = "") -> BrowserVersionT: 129 return cls(parts, BrowserVersionChannel.ANY, version_str) 130 131 @classmethod 132 def lts(cls: Type[BrowserVersionT], 133 parts: Iterable[int], 134 version_str: str = "") -> BrowserVersionT: 135 return cls(parts, BrowserVersionChannel.LTS, version_str) 136 137 @classmethod 138 def stable(cls: Type[BrowserVersionT], 139 parts: Iterable[int], 140 version_str: str = "") -> BrowserVersionT: 141 return cls(parts, BrowserVersionChannel.STABLE, version_str) 142 143 @classmethod 144 def beta(cls: Type[BrowserVersionT], 145 parts: Iterable[int], 146 version_str: str = "") -> BrowserVersionT: 147 return cls(parts, BrowserVersionChannel.BETA, version_str) 148 149 @classmethod 150 def alpha(cls: Type[BrowserVersionT], 151 parts: Iterable[int], 152 version_str: str = "") -> BrowserVersionT: 153 return cls(parts, BrowserVersionChannel.ALPHA, version_str) 154 155 @classmethod 156 def pre_alpha(cls: Type[BrowserVersionT], 157 parts: Iterable[int], 158 version_str: str = "") -> BrowserVersionT: 159 return cls(parts, BrowserVersionChannel.PRE_ALPHA, version_str) 160 161 def __init__(self, 162 parts: Iterable[int], 163 channel: BrowserVersionChannel = BrowserVersionChannel.STABLE, 164 version_str: str = "") -> None: 165 self._parts = self._validate_parts(parts, version_str or repr(parts)) 166 self._channel = channel 167 self._version_str = version_str 168 169 @property 170 def parts(self) -> Tuple[int, ...]: 171 return self._parts 172 173 @property 174 def version_str(self) -> str: 175 return self._version_str 176 177 @property 178 def parts_str(self) -> str: 179 return ".".join(map(str, self._parts)) 180 181 def comparable_parts(self, padded_len) -> Tuple[int, ...]: 182 if self.is_complete: 183 return self._parts 184 padding = (self._MAX_PART_VALUE,) * (padded_len - len(self._parts)) 185 return self._parts + padding 186 187 @property 188 def is_complete(self) -> bool: 189 return self.has_complete_parts and self.has_channel 190 191 @property 192 @abc.abstractmethod 193 def has_complete_parts(self) -> bool: 194 pass 195 196 @property 197 def is_unknown(self) -> bool: 198 # Only True for UnknownBrowserVersion 199 return False 200 201 @property 202 def is_channel_version(self) -> bool: 203 return not self._parts and self.has_channel 204 205 @property 206 def major(self) -> int: 207 if not self._parts: 208 raise PartialBrowserVersionError() 209 return self._parts[0] 210 211 @property 212 def minor(self) -> int: 213 if len(self._parts) <= 1: 214 raise PartialBrowserVersionError() 215 return self._parts[1] 216 217 @property 218 def channel(self) -> BrowserVersionChannel: 219 if not self.has_channel: 220 raise BrowserVersionNoChannelError( 221 f"BrowserVersion {self} has no channel") 222 return self._channel 223 224 def matches_channel(self, channel: BrowserVersionChannel) -> bool: 225 return self._channel.matches(channel) 226 227 @property 228 def has_channel(self) -> bool: 229 return self._channel is not BrowserVersionChannel.ANY 230 231 @property 232 def is_lts(self) -> bool: 233 return self._channel == BrowserVersionChannel.LTS 234 235 @property 236 def is_stable(self) -> bool: 237 return self._channel == BrowserVersionChannel.STABLE 238 239 @property 240 def is_beta(self) -> bool: 241 return self._channel == BrowserVersionChannel.BETA 242 243 @property 244 def is_alpha(self) -> bool: 245 return self._channel == BrowserVersionChannel.ALPHA 246 247 @property 248 def is_pre_alpha(self) -> bool: 249 return self._channel == BrowserVersionChannel.PRE_ALPHA 250 251 @property 252 def channel_name(self) -> str: 253 if not self.has_channel: 254 return "any" 255 return self._channel_name(self._channel) 256 257 @abc.abstractmethod 258 def _channel_name(self, channel: BrowserVersionChannel) -> str: 259 pass 260 261 @property 262 def key(self) -> Tuple[Tuple[int, ...], BrowserVersionChannel]: 263 return (self._parts, self._channel) 264 265 def __str__(self) -> str: 266 if not self._version_str: 267 if not self._parts: 268 return self.channel_name 269 return f"{self.parts_str} {self.channel_name}" 270 return f"{self._version_str} {self.channel_name}" 271 272 def __repr__(self) -> str: 273 return ( 274 f"{self.__class__.__name__}" 275 f"({self.parts_str}, {self.channel_name}, {repr(self._version_str)})") 276 277 def __eq__(self, other: Any) -> bool: 278 if not isinstance(other, type(self)): 279 return False 280 return self.key == other.key 281 282 def __le__(self, other: Any) -> bool: 283 if not isinstance(other, type(self)): 284 raise TypeError("Cannot compare versions from different browsers: " 285 f"{self} vs. {other}.") 286 if self.is_channel_version and other.is_channel_version: 287 return self._channel <= other._channel 288 if self.is_channel_version: 289 raise ValueError(f"Cannot compare channel {self} against {other}") 290 if other.is_channel_version: 291 raise ValueError(f"Cannot compare {self} against channel {other}") 292 return self.key <= other.key 293 294 def contains(self, other: BrowserVersion) -> bool: 295 if not isinstance(other, type(self)): 296 raise TypeError("Cannot compare versions from different browsers: " 297 f"{self} vs. {other}.") 298 if self == other: 299 return True 300 if self.has_channel and other.has_channel: 301 if self.channel != other.channel: 302 return False 303 # A less precise version (e.g. channel or partial version) can never be 304 # part of a more complete version. 305 other_parts = other.parts 306 common_part_len = min(len(self._parts), len(other_parts)) 307 if common_part_len < len(self._parts): 308 return False 309 return self._parts[:common_part_len] == other_parts[:common_part_len] 310 311 312class UnknownBrowserVersion(BrowserVersion): 313 """Sentinel helper object for initializing version variables before 314 knowing which exact browser/version is used.""" 315 316 def __init__(self, 317 parts: Tuple[int, ...] = (), 318 channel: BrowserVersionChannel = BrowserVersionChannel.ANY, 319 version_str: str = "unknown") -> None: 320 super().__init__(parts, BrowserVersionChannel.ANY, version_str) 321 322 @classmethod 323 def _parse( 324 cls, 325 full_version: str) -> Tuple[Tuple[int, ...], BrowserVersionChannel, str]: 326 raise RuntimeError("UnknownBrowserVersion does not support parsing") 327 328 def _channel_name(self, channel: BrowserVersionChannel) -> str: 329 return "unknown" 330 331 @property 332 def has_complete_parts(self) -> bool: 333 return False 334 335 @property 336 def is_unknown(self) -> bool: 337 return True 338