| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311 |
- """Version specifier support using only standard library (PEP 440 compatible)."""
- from __future__ import annotations
- import contextlib
- import operator
- import re
- import sys
- from dataclasses import dataclass
- from typing import TYPE_CHECKING, Final
- _DC_KW = {"frozen": True, "kw_only": True, "slots": True} if sys.version_info >= (3, 10) else {"frozen": True}
- if TYPE_CHECKING:
- from collections.abc import Iterator
- _VERSION_RE: Final[re.Pattern[str]] = re.compile(
- r"""
- ^
- (\d+) # major
- (?:\.(\d+))? # optional minor
- (?:\.(\d+))? # optional micro
- (?:(a|b|rc)(\d+))? # optional pre-release suffix
- $
- """,
- re.VERBOSE,
- )
- _SPECIFIER_RE: Final[re.Pattern[str]] = re.compile(
- r"""
- ^
- (===|==|~=|!=|<=|>=|<|>) # operator
- \s*
- (.+) # version string
- $
- """,
- re.VERBOSE,
- )
- _PRE_ORDER: Final[dict[str, int]] = {"a": 1, "b": 2, "rc": 3}
- @dataclass(**_DC_KW)
- class SimpleVersion:
- """
- Simple PEP 440-like version parser using only standard library.
- :param version_str: the original version string.
- :param major: major version number.
- :param minor: minor version number.
- :param micro: micro (patch) version number.
- :param pre_type: pre-release label (``"a"``, ``"b"``, or ``"rc"``), or ``None``.
- :param pre_num: pre-release sequence number, or ``None``.
- :param release: the ``(major, minor, micro)`` tuple.
- """
- version_str: str
- major: int
- minor: int
- micro: int
- pre_type: str | None
- pre_num: int | None
- release: tuple[int, int, int]
- @classmethod
- def from_string(cls, version_str: str) -> SimpleVersion:
- """
- Parse a PEP 440 version string (e.g. ``3.12.1``).
- :param version_str: the version string to parse.
- """
- stripped = version_str.strip()
- if not (match := _VERSION_RE.match(stripped)):
- msg = f"Invalid version: {version_str}"
- raise ValueError(msg)
- major = int(match.group(1))
- minor = int(match.group(2)) if match.group(2) else 0
- micro = int(match.group(3)) if match.group(3) else 0
- return cls(
- version_str=stripped,
- major=major,
- minor=minor,
- micro=micro,
- pre_type=match.group(4),
- pre_num=int(match.group(5)) if match.group(5) else None,
- release=(major, minor, micro),
- )
- def __eq__(self, other: object) -> bool:
- if not isinstance(other, SimpleVersion):
- return NotImplemented
- return self.release == other.release and self.pre_type == other.pre_type and self.pre_num == other.pre_num
- def __hash__(self) -> int:
- return hash((self.release, self.pre_type, self.pre_num))
- def __lt__(self, other: object) -> bool: # noqa: PLR0911
- if not isinstance(other, SimpleVersion):
- return NotImplemented
- if self.release != other.release:
- return self.release < other.release
- if self.pre_type is None and other.pre_type is None:
- return False
- if self.pre_type is None:
- return False
- if other.pre_type is None:
- return True
- if _PRE_ORDER[self.pre_type] != _PRE_ORDER[other.pre_type]:
- return _PRE_ORDER[self.pre_type] < _PRE_ORDER[other.pre_type]
- return (self.pre_num or 0) < (other.pre_num or 0)
- def __le__(self, other: object) -> bool:
- return self == other or self < other
- def __gt__(self, other: object) -> bool:
- if not isinstance(other, SimpleVersion):
- return NotImplemented
- return not self <= other
- def __ge__(self, other: object) -> bool:
- return not self < other
- def __str__(self) -> str:
- return self.version_str
- def __repr__(self) -> str:
- return f"SimpleVersion('{self.version_str}')"
- @dataclass(**_DC_KW)
- class SimpleSpecifier:
- """
- Simple PEP 440-like version specifier using only standard library.
- :param spec_str: the original specifier string (e.g. ``>=3.10``).
- :param operator: the comparison operator (``==``, ``>=``, ``<``, etc.).
- :param version_str: the version portion of the specifier, without the operator.
- :param is_wildcard: ``True`` if the specifier uses a wildcard suffix (``.*``).
- :param wildcard_precision: number of version components before the wildcard, or ``None``.
- :param version: the parsed version, or ``None`` if parsing failed.
- """
- spec_str: str
- operator: str
- version_str: str
- is_wildcard: bool
- wildcard_precision: int | None
- version: SimpleVersion | None
- @classmethod
- def from_string(cls, spec_str: str) -> SimpleSpecifier:
- """
- Parse a single PEP 440 specifier (e.g. ``>=3.10``).
- :param spec_str: the specifier string to parse.
- """
- stripped = spec_str.strip()
- if not (match := _SPECIFIER_RE.match(stripped)):
- msg = f"Invalid specifier: {spec_str}"
- raise ValueError(msg)
- op = match.group(1)
- version_str = match.group(2).strip()
- is_wildcard = version_str.endswith(".*")
- wildcard_precision: int | None = None
- if is_wildcard:
- version_str = version_str[:-2]
- wildcard_precision = len(version_str.split("."))
- try:
- version = SimpleVersion.from_string(version_str)
- except ValueError:
- version = None
- return cls(
- spec_str=stripped,
- operator=op,
- version_str=version_str,
- is_wildcard=is_wildcard,
- wildcard_precision=wildcard_precision,
- version=version,
- )
- def contains(self, version_str: str) -> bool:
- """
- Check if a version string satisfies this specifier.
- :param version_str: the version string to test.
- """
- try:
- candidate = SimpleVersion.from_string(version_str) if isinstance(version_str, str) else version_str
- except ValueError:
- return False
- if self.version is None:
- return False
- if self.is_wildcard:
- return self._check_wildcard(candidate)
- return self._check_standard(candidate)
- def _check_wildcard(self, candidate: SimpleVersion) -> bool:
- if self.version is None: # pragma: no branch
- return False # pragma: no cover
- if self.operator == "==":
- return candidate.release[: self.wildcard_precision] == self.version.release[: self.wildcard_precision]
- if self.operator == "!=":
- return candidate.release[: self.wildcard_precision] != self.version.release[: self.wildcard_precision]
- return False
- def _check_standard(self, candidate: SimpleVersion) -> bool:
- if self.version is None: # pragma: no branch
- return False # pragma: no cover
- if self.operator == "===":
- return str(candidate) == str(self.version)
- if self.operator == "~=":
- return self._check_compatible_release(candidate)
- cmp_ops = {
- "==": operator.eq,
- "!=": operator.ne,
- "<": operator.lt,
- "<=": operator.le,
- ">": operator.gt,
- ">=": operator.ge,
- }
- if self.operator in cmp_ops:
- return cmp_ops[self.operator](candidate, self.version)
- return False
- def _check_compatible_release(self, candidate: SimpleVersion) -> bool:
- if self.version is None:
- return False
- if candidate < self.version:
- return False
- if len(self.version.release) >= 2: # noqa: PLR2004 # pragma: no branch # SimpleVersion always has 3-part release
- upper_parts = list(self.version.release[:-1])
- upper_parts[-1] += 1
- upper = SimpleVersion.from_string(".".join(str(p) for p in upper_parts))
- return candidate < upper
- return True # pragma: no cover
- def __eq__(self, other: object) -> bool:
- if not isinstance(other, SimpleSpecifier):
- return NotImplemented
- return self.spec_str == other.spec_str
- def __hash__(self) -> int:
- return hash(self.spec_str)
- def __str__(self) -> str:
- return self.spec_str
- def __repr__(self) -> str:
- return f"SimpleSpecifier('{self.spec_str}')"
- @dataclass(**_DC_KW)
- class SimpleSpecifierSet:
- """
- Simple PEP 440-like specifier set using only standard library.
- :param specifiers_str: the original comma-separated specifier string.
- :param specifiers: the parsed individual specifiers.
- """
- specifiers_str: str
- specifiers: tuple[SimpleSpecifier, ...]
- @classmethod
- def from_string(cls, specifiers_str: str = "") -> SimpleSpecifierSet:
- """
- Parse a comma-separated PEP 440 specifier string (e.g. ``>=3.10,<4``).
- :param specifiers_str: the specifier string to parse.
- """
- stripped = specifiers_str.strip()
- specs: list[SimpleSpecifier] = []
- if stripped:
- for spec_item in stripped.split(","):
- item = spec_item.strip()
- if item:
- with contextlib.suppress(ValueError):
- specs.append(SimpleSpecifier.from_string(item))
- return cls(specifiers_str=stripped, specifiers=tuple(specs))
- def contains(self, version_str: str) -> bool:
- """
- Check if a version satisfies all specifiers in the set.
- :param version_str: the version string to test.
- """
- if not self.specifiers:
- return True
- return all(spec.contains(version_str) for spec in self.specifiers)
- def __iter__(self) -> Iterator[SimpleSpecifier]:
- return iter(self.specifiers)
- def __eq__(self, other: object) -> bool:
- if not isinstance(other, SimpleSpecifierSet):
- return NotImplemented
- return self.specifiers_str == other.specifiers_str
- def __hash__(self) -> int:
- return hash(self.specifiers_str)
- def __str__(self) -> str:
- return self.specifiers_str
- def __repr__(self) -> str:
- return f"SimpleSpecifierSet('{self.specifiers_str}')"
- __all__ = [
- "SimpleSpecifier",
- "SimpleSpecifierSet",
- "SimpleVersion",
- ]
|