• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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