_specifier.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. """Version specifier support using only standard library (PEP 440 compatible)."""
  2. from __future__ import annotations
  3. import contextlib
  4. import operator
  5. import re
  6. import sys
  7. from dataclasses import dataclass
  8. from typing import TYPE_CHECKING, Final
  9. _DC_KW = {"frozen": True, "kw_only": True, "slots": True} if sys.version_info >= (3, 10) else {"frozen": True}
  10. if TYPE_CHECKING:
  11. from collections.abc import Iterator
  12. _VERSION_RE: Final[re.Pattern[str]] = re.compile(
  13. r"""
  14. ^
  15. (\d+) # major
  16. (?:\.(\d+))? # optional minor
  17. (?:\.(\d+))? # optional micro
  18. (?:(a|b|rc)(\d+))? # optional pre-release suffix
  19. $
  20. """,
  21. re.VERBOSE,
  22. )
  23. _SPECIFIER_RE: Final[re.Pattern[str]] = re.compile(
  24. r"""
  25. ^
  26. (===|==|~=|!=|<=|>=|<|>) # operator
  27. \s*
  28. (.+) # version string
  29. $
  30. """,
  31. re.VERBOSE,
  32. )
  33. _PRE_ORDER: Final[dict[str, int]] = {"a": 1, "b": 2, "rc": 3}
  34. @dataclass(**_DC_KW)
  35. class SimpleVersion:
  36. """
  37. Simple PEP 440-like version parser using only standard library.
  38. :param version_str: the original version string.
  39. :param major: major version number.
  40. :param minor: minor version number.
  41. :param micro: micro (patch) version number.
  42. :param pre_type: pre-release label (``"a"``, ``"b"``, or ``"rc"``), or ``None``.
  43. :param pre_num: pre-release sequence number, or ``None``.
  44. :param release: the ``(major, minor, micro)`` tuple.
  45. """
  46. version_str: str
  47. major: int
  48. minor: int
  49. micro: int
  50. pre_type: str | None
  51. pre_num: int | None
  52. release: tuple[int, int, int]
  53. @classmethod
  54. def from_string(cls, version_str: str) -> SimpleVersion:
  55. """
  56. Parse a PEP 440 version string (e.g. ``3.12.1``).
  57. :param version_str: the version string to parse.
  58. """
  59. stripped = version_str.strip()
  60. if not (match := _VERSION_RE.match(stripped)):
  61. msg = f"Invalid version: {version_str}"
  62. raise ValueError(msg)
  63. major = int(match.group(1))
  64. minor = int(match.group(2)) if match.group(2) else 0
  65. micro = int(match.group(3)) if match.group(3) else 0
  66. return cls(
  67. version_str=stripped,
  68. major=major,
  69. minor=minor,
  70. micro=micro,
  71. pre_type=match.group(4),
  72. pre_num=int(match.group(5)) if match.group(5) else None,
  73. release=(major, minor, micro),
  74. )
  75. def __eq__(self, other: object) -> bool:
  76. if not isinstance(other, SimpleVersion):
  77. return NotImplemented
  78. return self.release == other.release and self.pre_type == other.pre_type and self.pre_num == other.pre_num
  79. def __hash__(self) -> int:
  80. return hash((self.release, self.pre_type, self.pre_num))
  81. def __lt__(self, other: object) -> bool: # noqa: PLR0911
  82. if not isinstance(other, SimpleVersion):
  83. return NotImplemented
  84. if self.release != other.release:
  85. return self.release < other.release
  86. if self.pre_type is None and other.pre_type is None:
  87. return False
  88. if self.pre_type is None:
  89. return False
  90. if other.pre_type is None:
  91. return True
  92. if _PRE_ORDER[self.pre_type] != _PRE_ORDER[other.pre_type]:
  93. return _PRE_ORDER[self.pre_type] < _PRE_ORDER[other.pre_type]
  94. return (self.pre_num or 0) < (other.pre_num or 0)
  95. def __le__(self, other: object) -> bool:
  96. return self == other or self < other
  97. def __gt__(self, other: object) -> bool:
  98. if not isinstance(other, SimpleVersion):
  99. return NotImplemented
  100. return not self <= other
  101. def __ge__(self, other: object) -> bool:
  102. return not self < other
  103. def __str__(self) -> str:
  104. return self.version_str
  105. def __repr__(self) -> str:
  106. return f"SimpleVersion('{self.version_str}')"
  107. @dataclass(**_DC_KW)
  108. class SimpleSpecifier:
  109. """
  110. Simple PEP 440-like version specifier using only standard library.
  111. :param spec_str: the original specifier string (e.g. ``>=3.10``).
  112. :param operator: the comparison operator (``==``, ``>=``, ``<``, etc.).
  113. :param version_str: the version portion of the specifier, without the operator.
  114. :param is_wildcard: ``True`` if the specifier uses a wildcard suffix (``.*``).
  115. :param wildcard_precision: number of version components before the wildcard, or ``None``.
  116. :param version: the parsed version, or ``None`` if parsing failed.
  117. """
  118. spec_str: str
  119. operator: str
  120. version_str: str
  121. is_wildcard: bool
  122. wildcard_precision: int | None
  123. version: SimpleVersion | None
  124. @classmethod
  125. def from_string(cls, spec_str: str) -> SimpleSpecifier:
  126. """
  127. Parse a single PEP 440 specifier (e.g. ``>=3.10``).
  128. :param spec_str: the specifier string to parse.
  129. """
  130. stripped = spec_str.strip()
  131. if not (match := _SPECIFIER_RE.match(stripped)):
  132. msg = f"Invalid specifier: {spec_str}"
  133. raise ValueError(msg)
  134. op = match.group(1)
  135. version_str = match.group(2).strip()
  136. is_wildcard = version_str.endswith(".*")
  137. wildcard_precision: int | None = None
  138. if is_wildcard:
  139. version_str = version_str[:-2]
  140. wildcard_precision = len(version_str.split("."))
  141. try:
  142. version = SimpleVersion.from_string(version_str)
  143. except ValueError:
  144. version = None
  145. return cls(
  146. spec_str=stripped,
  147. operator=op,
  148. version_str=version_str,
  149. is_wildcard=is_wildcard,
  150. wildcard_precision=wildcard_precision,
  151. version=version,
  152. )
  153. def contains(self, version_str: str) -> bool:
  154. """
  155. Check if a version string satisfies this specifier.
  156. :param version_str: the version string to test.
  157. """
  158. try:
  159. candidate = SimpleVersion.from_string(version_str) if isinstance(version_str, str) else version_str
  160. except ValueError:
  161. return False
  162. if self.version is None:
  163. return False
  164. if self.is_wildcard:
  165. return self._check_wildcard(candidate)
  166. return self._check_standard(candidate)
  167. def _check_wildcard(self, candidate: SimpleVersion) -> bool:
  168. if self.version is None: # pragma: no branch
  169. return False # pragma: no cover
  170. if self.operator == "==":
  171. return candidate.release[: self.wildcard_precision] == self.version.release[: self.wildcard_precision]
  172. if self.operator == "!=":
  173. return candidate.release[: self.wildcard_precision] != self.version.release[: self.wildcard_precision]
  174. return False
  175. def _check_standard(self, candidate: SimpleVersion) -> bool:
  176. if self.version is None: # pragma: no branch
  177. return False # pragma: no cover
  178. if self.operator == "===":
  179. return str(candidate) == str(self.version)
  180. if self.operator == "~=":
  181. return self._check_compatible_release(candidate)
  182. cmp_ops = {
  183. "==": operator.eq,
  184. "!=": operator.ne,
  185. "<": operator.lt,
  186. "<=": operator.le,
  187. ">": operator.gt,
  188. ">=": operator.ge,
  189. }
  190. if self.operator in cmp_ops:
  191. return cmp_ops[self.operator](candidate, self.version)
  192. return False
  193. def _check_compatible_release(self, candidate: SimpleVersion) -> bool:
  194. if self.version is None:
  195. return False
  196. if candidate < self.version:
  197. return False
  198. if len(self.version.release) >= 2: # noqa: PLR2004 # pragma: no branch # SimpleVersion always has 3-part release
  199. upper_parts = list(self.version.release[:-1])
  200. upper_parts[-1] += 1
  201. upper = SimpleVersion.from_string(".".join(str(p) for p in upper_parts))
  202. return candidate < upper
  203. return True # pragma: no cover
  204. def __eq__(self, other: object) -> bool:
  205. if not isinstance(other, SimpleSpecifier):
  206. return NotImplemented
  207. return self.spec_str == other.spec_str
  208. def __hash__(self) -> int:
  209. return hash(self.spec_str)
  210. def __str__(self) -> str:
  211. return self.spec_str
  212. def __repr__(self) -> str:
  213. return f"SimpleSpecifier('{self.spec_str}')"
  214. @dataclass(**_DC_KW)
  215. class SimpleSpecifierSet:
  216. """
  217. Simple PEP 440-like specifier set using only standard library.
  218. :param specifiers_str: the original comma-separated specifier string.
  219. :param specifiers: the parsed individual specifiers.
  220. """
  221. specifiers_str: str
  222. specifiers: tuple[SimpleSpecifier, ...]
  223. @classmethod
  224. def from_string(cls, specifiers_str: str = "") -> SimpleSpecifierSet:
  225. """
  226. Parse a comma-separated PEP 440 specifier string (e.g. ``>=3.10,<4``).
  227. :param specifiers_str: the specifier string to parse.
  228. """
  229. stripped = specifiers_str.strip()
  230. specs: list[SimpleSpecifier] = []
  231. if stripped:
  232. for spec_item in stripped.split(","):
  233. item = spec_item.strip()
  234. if item:
  235. with contextlib.suppress(ValueError):
  236. specs.append(SimpleSpecifier.from_string(item))
  237. return cls(specifiers_str=stripped, specifiers=tuple(specs))
  238. def contains(self, version_str: str) -> bool:
  239. """
  240. Check if a version satisfies all specifiers in the set.
  241. :param version_str: the version string to test.
  242. """
  243. if not self.specifiers:
  244. return True
  245. return all(spec.contains(version_str) for spec in self.specifiers)
  246. def __iter__(self) -> Iterator[SimpleSpecifier]:
  247. return iter(self.specifiers)
  248. def __eq__(self, other: object) -> bool:
  249. if not isinstance(other, SimpleSpecifierSet):
  250. return NotImplemented
  251. return self.specifiers_str == other.specifiers_str
  252. def __hash__(self) -> int:
  253. return hash(self.specifiers_str)
  254. def __str__(self) -> str:
  255. return self.specifiers_str
  256. def __repr__(self) -> str:
  257. return f"SimpleSpecifierSet('{self.specifiers_str}')"
  258. __all__ = [
  259. "SimpleSpecifier",
  260. "SimpleSpecifierSet",
  261. "SimpleVersion",
  262. ]