def test_version_getitem(): version = Version("3.8.6") assert version[0] == 3 assert version[1] == 8 assert version[2] == 6 assert version[1:2] == Version("8") assert version[:-1] == Version("3.8")
def test_support_prerelease_version() -> None: assert not Version("3.9.0").is_prerelease v = Version("3.9.0a4") assert v.is_prerelease assert str(v) == "3.9.0a4" assert v.complete() == v assert v.bump() == Version("3.9.0a5") assert v.bump(2) == Version("3.9.1")
def _populate_version_range( self, lower: Version, upper: Version ) -> Iterable[Version]: """Expand the version range to a collection of versions to exclude, taking the released python versions into consideration. """ assert lower < upper prev = lower while prev < upper: if prev[-2:] == Version((0, 0)): # X.0.0 cur = prev.bump(0) # X+1.0.0 if cur <= upper: # It is still within the range yield Version((prev[0], "*")) # Exclude the whole major series: X.* prev = cur continue if prev[-1] == 0: # X.Y.0 cur = prev.bump(1) # X.Y+1.0 if cur <= upper: # It is still within the range yield prev[:2].complete("*") # Exclude X.Y.* prev = ( prev.bump(0) if cur.is_py2 and cast(int, cur[1]) > self.PY_MAX_MINOR_VERSION[cur[:1]] else cur ) # If prev is 2.7, next is 3.0, otherwise next is X.Y+1.0 continue while prev < upper: # Iterate each version from X.Y.0(prev) to X.Y.Z(upper) yield prev prev = prev.bump() break # Can't produce any wildcard versions cur = prev.bump(1) if cur <= upper: # X.Y+1.0 is still within the range current_max = self.PY_MAX_MINOR_VERSION[prev[:2]] for z in range(cast(int, prev[2]), current_max + 1): yield prev[:2].complete(z) prev = ( prev.bump(0) if cur.is_py2 and cast(int, cur[1]) > self.PY_MAX_MINOR_VERSION[cur[:1]] else cur ) else: # Produce each version from X.Y.Z to X.Y.W while prev < upper: yield prev prev = prev.bump() break
def _normalize_op_specifier(op: str, version_str: str) -> Tuple[str, Version]: version = Version(version_str) if version.is_wildcard: if op == "==": op = "~=" version[-1] = 0 elif op == ">": # >X.Y.* => >=X.Y+1.0 op = ">=" version = version.bump(-2) elif op in ("<", ">=", "<="): # <X.Y.* => <X.Y.0 # >=X.Y.* => >=X.Y.0 # <=X.Y.* => <X.Y.0 version[-1] = 0 if op == "<=": op = "<" elif op != "!=": raise InvalidPyVersion(f"Unsupported version specifier: {op}{version}") if op != "~=" and not (op == "!=" and version.is_wildcard): # Don't complete with .0 for ~=3.5 and !=3.4.*: version = version.complete() return op, version
def test_version_setitem(): version = Version("3.8.*") version1 = version.complete() version1[-1] = 0 assert version1 == Version("3.8.0") version2 = version.complete() version2[0] = 4 assert version2 == Version("4.8.*") version3 = version.complete() with pytest.raises(TypeError): version3[:2] = (1, 2)
def test_version_startswith(version, other, result): assert Version(version).startswith(Version(other)) is result
def test_unsupported_prerelease_version(): with pytest.raises(InvalidPyVersion): Version("3.9.0a4")
def test_version_bump(version, idx, result): assert str(Version(version).bump(idx)) == result
def test_version_complete(version, args, result): assert str(Version(version).complete(*args)) == result
def test_version_comparison(): assert Version("3.9.0") < Version("3.9.1") assert Version("3.4") < Version("3.9.1") assert Version("3.7.*") < Version("3.7.5") assert Version("3.7") == Version((3, 7)) assert Version("3.9.0a") != Version("3.9.0") assert Version("3.9.0a") == Version("3.9.0a0") assert Version("3.10.0a9") < Version("3.10.0a12") assert Version("3.10.0a12") < Version("3.10.0b1") assert Version("3.7.*") < Version("3.7.1b")
def test_version_is_wildcard(): assert not Version("3").is_wildcard assert Version("3.*").is_wildcard
def test_version_comparison(): assert Version("3.9.0") < Version("3.9.1") assert Version("3.4") < Version("3.9.1") assert Version("3.7.*") < Version("3.7.5") assert Version("3.7") == Version((3, 7))
def test_normalize_non_standard_version(): version = Version("3.9*") assert str(version) == "3.9.*"
def test_unsupported_post_version() -> None: with pytest.raises(InvalidPyVersion): Version("3.10.0post1")
def _merge_bounds_and_excludes( cls, lower: Version, upper: Version, excludes: Iterable[Version], ) -> Tuple[Version, Version, List[Version]]: sorted_excludes = sorted(excludes) wildcard_excludes = { version[:-1] for version in sorted_excludes if version.is_wildcard } # Remove versions that are already excluded by another wildcard exclude. sorted_excludes = [ version for version in sorted_excludes if version.is_wildcard or not any( version.startswith(wv) for wv in wildcard_excludes) ] if lower == Version.MIN and upper == Version.MAX: # Nothing we can do here, it is a non-constraint. return lower, upper, sorted_excludes for version in list(sorted_excludes): # from to low to high if version >= upper: sorted_excludes[:] = [] break if version.is_wildcard: valid_length = len(version._version) - 1 valid_version = version[:valid_length] if valid_version < lower[:valid_length]: # Useless excludes sorted_excludes.remove(version) elif lower.startswith(valid_version): # The lower bound is excluded, e.g: >=3.7.3,!=3.7.* # bump the lower version in the last common bit: >=3.8.0 lower = version.bump(-2) sorted_excludes.remove(version) else: break else: if version < lower: sorted_excludes.remove(version) elif version == lower: lower = version.bump() sorted_excludes.remove(version) else: break for version in reversed(sorted_excludes): # from high to low if version >= upper: sorted_excludes.remove(version) continue if not version.is_wildcard: break valid_length = len(version._version) - 1 valid_version = version[:valid_length] if upper.startswith(valid_version) or version.bump(-2) == upper: # Case 1: The upper bound is excluded, e.g: <3.7.3,!=3.7.* # set the upper to the zero version: <3.7.0 # Case 2: The upper bound is adjacent to the excluded one, # e.g: <3.7.0,!=3.6.* # Move the upper bound to below the excluded: <3.6.0 upper = valid_version.complete() sorted_excludes.remove(version) else: break return lower, upper, sorted_excludes
class PySpecSet(SpecifierSet): """A custom SpecifierSet that supports merging with logic operators (&, |).""" PY_MAX_MINOR_VERSION = { Version(key): value for key, value in json.loads(MAX_VERSIONS_FILE.read_text()).items() } MAX_MAJOR_VERSION = max(PY_MAX_MINOR_VERSION)[:1].bump() def __init__(self, version_str: str = "", analyze: bool = True) -> None: if version_str == "*": version_str = "" super().__init__(version_str) self._lower_bound = Version.MIN self._upper_bound = Version.MAX self._excludes: List[Version] = [] if version_str and analyze: self._analyze_specifiers() def _analyze_specifiers(self) -> None: lower_bound, upper_bound = Version.MIN, Version.MAX excludes: Set[Version] = set() for spec in self: op, version = _normalize_op_specifier(spec.operator, spec.version) if op in ("==", "==="): lower_bound = version upper_bound = version.bump() break if op == "!=": excludes.add(version) elif op[0] == ">": lower_bound = max(lower_bound, version if op == ">=" else version.bump()) elif op[0] == "<": upper_bound = min(upper_bound, version.bump() if op == "<=" else version) elif op == "~=": new_lower = version.complete() new_upper = version.bump(-2) if new_upper < upper_bound: upper_bound = new_upper if new_lower > lower_bound: lower_bound = new_lower else: raise InvalidPyVersion( f"Unsupported version specifier: {op}{version}") self._rearrange(lower_bound, upper_bound, excludes) @classmethod def _merge_bounds_and_excludes( cls, lower: Version, upper: Version, excludes: Iterable[Version], ) -> Tuple[Version, Version, List[Version]]: sorted_excludes = sorted(excludes) wildcard_excludes = { version[:-1] for version in sorted_excludes if version.is_wildcard } # Remove versions that are already excluded by another wildcard exclude. sorted_excludes = [ version for version in sorted_excludes if version.is_wildcard or not any( version.startswith(wv) for wv in wildcard_excludes) ] if lower == Version.MIN and upper == Version.MAX: # Nothing we can do here, it is a non-constraint. return lower, upper, sorted_excludes for version in list(sorted_excludes): # from to low to high if version >= upper: sorted_excludes[:] = [] break if version.is_wildcard: valid_length = len(version._version) - 1 valid_version = version[:valid_length] if valid_version < lower[:valid_length]: # Useless excludes sorted_excludes.remove(version) elif lower.startswith(valid_version): # The lower bound is excluded, e.g: >=3.7.3,!=3.7.* # bump the lower version in the last common bit: >=3.8.0 lower = version.bump(-2) sorted_excludes.remove(version) else: break else: if version < lower: sorted_excludes.remove(version) elif version == lower: lower = version.bump() sorted_excludes.remove(version) else: break for version in reversed(sorted_excludes): # from high to low if version >= upper: sorted_excludes.remove(version) continue if not version.is_wildcard: break valid_length = len(version._version) - 1 valid_version = version[:valid_length] if upper.startswith(valid_version) or version.bump(-2) == upper: # Case 1: The upper bound is excluded, e.g: <3.7.3,!=3.7.* # set the upper to the zero version: <3.7.0 # Case 2: The upper bound is adjacent to the excluded one, # e.g: <3.7.0,!=3.6.* # Move the upper bound to below the excluded: <3.6.0 upper = valid_version.complete() sorted_excludes.remove(version) else: break return lower, upper, sorted_excludes def _rearrange(self, lower_bound: Version, upper_bound: Version, excludes: Iterable[Version]) -> None: """Rearrange the version bounds with the given inputs.""" ( self._lower_bound, self._upper_bound, self._excludes, ) = self._merge_bounds_and_excludes(lower_bound, upper_bound, excludes) if not self.is_impossible: super().__init__(str(self)) def _comp_key(self) -> Tuple[Version, Version, Tuple[Version, ...]]: return (self._lower_bound, self._upper_bound, tuple(self._excludes)) def __hash__(self) -> int: return hash(self._comp_key()) def __eq__(self, other: Any) -> bool: if not isinstance(other, PySpecSet): return False return self._comp_key() == other._comp_key() @property def is_impossible(self) -> bool: """Check whether the specifierset contains any valid versions.""" if self._lower_bound == Version.MIN or self._upper_bound == Version.MAX: return False return self._lower_bound >= self._upper_bound @property def is_allow_all(self) -> bool: """Return True if the specifierset accepts all versions.""" if self.is_impossible: return False return (self._lower_bound == Version.MIN and self._upper_bound == Version.MAX and not self._excludes) def __bool__(self) -> bool: return not self.is_allow_all def __str__(self) -> str: if self.is_impossible: return "impossible" if self.is_allow_all: return "" lower = self._lower_bound upper = self._upper_bound if lower[-1] == 0 and not lower.is_prerelease: lower = lower[:-1] if upper[-1] == 0 and not upper.is_prerelease: upper = upper[:-1] lower_str = "" if lower == Version.MIN else f">={lower}" upper_str = "" if upper == Version.MAX else f"<{upper}" excludes_str = ",".join(f"!={version}" for version in self._excludes) return ",".join(filter(None, [lower_str, upper_str, excludes_str])) def __repr__(self) -> str: return f"<PySpecSet {self}>" def copy(self) -> "PySpecSet": """Create a new specifierset that is same as the original one.""" if self.is_impossible: return ImpossiblePySpecSet() instance = self.__class__(str(self), False) instance._lower_bound = self._lower_bound instance._upper_bound = self._upper_bound instance._excludes = self._excludes[:] return instance @lru_cache() def __and__(self, other: "PySpecSet") -> "PySpecSet": if any(s.is_impossible for s in (self, other)): return ImpossiblePySpecSet() if self.is_allow_all: return other.copy() elif other.is_allow_all: return self.copy() rv = self.copy() excludes = set(rv._excludes) | set(other._excludes) lower = max(rv._lower_bound, other._lower_bound) upper = min(rv._upper_bound, other._upper_bound) rv._rearrange(lower, upper, excludes) return rv @lru_cache() def __or__(self, other: "PySpecSet") -> "PySpecSet": if self.is_impossible: return other.copy() elif other.is_impossible: return self.copy() if self.is_allow_all: return self.copy() elif other.is_allow_all: return other.copy() rv = self.copy() left, right = sorted([rv, other], key=lambda x: x._lower_bound) excludes = set(left._excludes) & set(right._excludes) lower = left._lower_bound upper = max(left._upper_bound, right._upper_bound) if right._lower_bound > left._upper_bound: # two ranges has no overlap excludes.update( self._populate_version_range(left._upper_bound, right._lower_bound)) rv._rearrange(lower, upper, excludes) return rv def _populate_version_range(self, lower: Version, upper: Version) -> Iterable[Version]: """Expand the version range to a collection of versions to exclude, taking the released python versions into consideration. """ assert lower < upper prev = lower while prev < upper: if prev[-2:] == Version((0, 0)): # X.0.0 cur = prev.bump(0) # X+1.0.0 if cur <= upper: # It is still within the range yield Version( (prev[0], "*")) # Exclude the whole major series: X.* prev = cur continue if prev[-1] == 0: # X.Y.0 cur = prev.bump(1) # X.Y+1.0 if cur <= upper: # It is still within the range yield prev[:2].complete("*") # Exclude X.Y.* prev = ( prev.bump(0) if cur.is_py2 and cast(int, cur[1]) > self.PY_MAX_MINOR_VERSION[cur[:1]] else cur ) # If prev is 2.7, next is 3.0, otherwise next is X.Y+1.0 continue while prev < upper: # Iterate each version from X.Y.0(prev) to X.Y.Z(upper) yield prev prev = prev.bump() break # Can't produce any wildcard versions cur = prev.bump(1) if cur <= upper: # X.Y+1.0 is still within the range current_max = self.PY_MAX_MINOR_VERSION[prev[:2]] for z in range(cast(int, prev[2]), current_max + 1): yield prev[:2].complete(z) prev = (prev.bump(0) if cur.is_py2 and cast(int, cur[1]) > self.PY_MAX_MINOR_VERSION[cur[:1]] else cur) else: # Produce each version from X.Y.Z to X.Y.W while prev < upper: yield prev prev = prev.bump() break @lru_cache() def is_superset(self, other: Union[str, SpecifierSet]) -> bool: if self.is_impossible: return False if self.is_allow_all: return True other = type(self)(str(other)) if other._upper_bound >= self.MAX_MAJOR_VERSION: # XXX: narrow down the upper bound to ``MAX_MAJOR_VERSION`` # So that `>=3.6,<4.0` is considered a superset of `>=3.7`, see issues/66 other._upper_bound = self.MAX_MAJOR_VERSION lower, upper, excludes = self._merge_bounds_and_excludes( other._lower_bound, other._upper_bound, self._excludes) if (self._lower_bound > other._lower_bound or self._upper_bound < other._upper_bound): return False return (lower <= other._lower_bound and upper >= other._upper_bound and set(excludes) <= set(other._excludes)) @lru_cache() def is_subset(self, other: Union[str, SpecifierSet]) -> bool: if self.is_impossible: return False other = type(self)(str(other)) if other._upper_bound >= self.MAX_MAJOR_VERSION: # Relax the upper bound to max version other._upper_bound = Version.MAX if other.is_allow_all: return True lower, upper, excludes = self._merge_bounds_and_excludes( self._lower_bound, self._upper_bound, other._excludes) if (self._lower_bound < other._lower_bound or self._upper_bound > other._upper_bound): return False return (lower <= self._lower_bound and upper >= self._upper_bound and set(self._excludes) >= set(excludes)) def as_marker_string(self) -> str: if self.is_allow_all: return "" result = [] excludes = [] full_excludes = [] for spec in sorted(self, key=attrgetter("version")): op, version = spec.operator, spec.version if len(version.split(".")) < 3: key = "python_version" else: key = "python_full_version" if version[-2:] == ".*": version = version[:-2] key = "python_version" if op == "!=": if key == "python_version": excludes.append(version) else: full_excludes.append(version) else: result.append(f"{key}{op}{version!r}") if excludes: result.append("python_version not in {!r}".format(", ".join( sorted(excludes)))) if full_excludes: result.append("python_full_version not in {!r}".format(", ".join( sorted(full_excludes)))) return " and ".join(result) def supports_py2(self) -> bool: return self._lower_bound.is_py2
def test_version_is_py2(): assert not Version("3.8").is_py2 assert Version("2.7").is_py2