diff --git a/README.md b/README.md index 4b555927b..6b7b787a0 100644 --- a/README.md +++ b/README.md @@ -41,5 +41,5 @@ dependencies are not required when the objective is to simply build either a sou project. In order to improve the above situation, `poetry-core` was created. Shared functionality pertaining to PEP 517 build -backends, including reading lock file, `pyproject.toml` and building wheel/sdist, were implemented in this package. This +backends, including reading `pyproject.toml` and building wheel/sdist, were implemented in this package. This makes PEP 517 builds extremely fast for Poetry managed packages. diff --git a/src/poetry/core/constraints/version/__init__.py b/src/poetry/core/constraints/version/__init__.py index 86b4e55b7..0dd504d55 100644 --- a/src/poetry/core/constraints/version/__init__.py +++ b/src/poetry/core/constraints/version/__init__.py @@ -2,6 +2,7 @@ from poetry.core.constraints.version.empty_constraint import EmptyConstraint from poetry.core.constraints.version.parser import parse_constraint +from poetry.core.constraints.version.parser import parse_marker_version_constraint from poetry.core.constraints.version.util import constraint_regions from poetry.core.constraints.version.version import Version from poetry.core.constraints.version.version_constraint import VersionConstraint @@ -21,4 +22,5 @@ "VersionUnion", "constraint_regions", "parse_constraint", + "parse_marker_version_constraint", ) diff --git a/src/poetry/core/constraints/version/parser.py b/src/poetry/core/constraints/version/parser.py index 8fee223d8..62364787a 100644 --- a/src/poetry/core/constraints/version/parser.py +++ b/src/poetry/core/constraints/version/parser.py @@ -10,11 +10,22 @@ if TYPE_CHECKING: + from poetry.core.constraints.version.version import Version from poetry.core.constraints.version.version_constraint import VersionConstraint @functools.lru_cache(maxsize=None) def parse_constraint(constraints: str) -> VersionConstraint: + return _parse_constraint(constraints=constraints) + + +def parse_marker_version_constraint(constraints: str) -> VersionConstraint: + return _parse_constraint(constraints=constraints, is_marker_constraint=True) + + +def _parse_constraint( + constraints: str, *, is_marker_constraint: bool = False +) -> VersionConstraint: if constraints == "*": from poetry.core.constraints.version.version_range import VersionRange @@ -33,9 +44,17 @@ def parse_constraint(constraints: str) -> VersionConstraint: if len(and_constraints) > 1: for constraint in and_constraints: - constraint_objects.append(parse_single_constraint(constraint)) + constraint_objects.append( + parse_single_constraint( + constraint, is_marker_constraint=is_marker_constraint + ) + ) else: - constraint_objects.append(parse_single_constraint(and_constraints[0])) + constraint_objects.append( + parse_single_constraint( + and_constraints[0], is_marker_constraint=is_marker_constraint + ) + ) if len(constraint_objects) == 1: constraint = constraint_objects[0] @@ -54,7 +73,9 @@ def parse_constraint(constraints: str) -> VersionConstraint: return VersionUnion.of(*or_groups) -def parse_single_constraint(constraint: str) -> VersionConstraint: +def parse_single_constraint( + constraint: str, *, is_marker_constraint: bool = False +) -> VersionConstraint: from poetry.core.constraints.version.patterns import BASIC_CONSTRAINT from poetry.core.constraints.version.patterns import CARET_CONSTRAINT from poetry.core.constraints.version.patterns import TILDE_CONSTRAINT @@ -117,25 +138,15 @@ def parse_single_constraint(constraint: str) -> VersionConstraint: m = X_CONSTRAINT.match(constraint) if m: op = m.group("op") - major = int(m.group(2)) - minor = m.group(3) - if minor is not None: - version = Version.from_parts(major, int(minor), 0) - result: VersionConstraint = VersionRange( - version, version.next_minor(), include_min=True + try: + return _make_x_constraint_range( + version=Version.parse(m.group("version")), + invert=op == "!=", + is_marker_constraint=is_marker_constraint, ) - elif major == 0: - result = VersionRange(max=Version.from_parts(1, 0, 0)) - else: - version = Version.from_parts(major, 0, 0) - - result = VersionRange(version, version.next_major(), include_min=True) - - if op == "!=": - result = VersionRange().difference(result) - - return result + except ValueError: + raise ValueError(f"Could not parse version constraint: {constraint}") # Basic comparator m = BASIC_CONSTRAINT.match(constraint) @@ -161,8 +172,49 @@ def parse_single_constraint(constraint: str) -> VersionConstraint: return VersionRange(min=version) if op == ">=": return VersionRange(min=version, include_min=True) + + if m.group("wildcard") is not None: + return _make_x_constraint_range( + version=version, + invert=op == "!=", + is_marker_constraint=is_marker_constraint, + ) + if op == "!=": return VersionUnion(VersionRange(max=version), VersionRange(min=version)) + return version raise ParseConstraintError(f"Could not parse version constraint: {constraint}") + + +def _make_x_constraint_range( + version: Version, *, invert: bool = False, is_marker_constraint: bool = False +) -> VersionConstraint: + from poetry.core.constraints.version.version_range import VersionRange + + if version.is_postrelease(): + _next = version.next_postrelease() + elif version.is_stable(): + _next = version.next_stable() + elif version.is_prerelease(): + _next = version.next_prerelease() + elif version.is_devrelease(): + _next = version.next_devrelease() + else: + raise RuntimeError("version is neither stable, nor pre-release nor dev-release") + + _min = version + _max = _next + + if not is_marker_constraint: + _min = _min.first_devrelease() + if not _max.is_devrelease(): + _max = _max.first_devrelease() + + result = VersionRange(_min, _max, include_min=True) + + if invert: + return VersionRange().difference(result) + + return result diff --git a/src/poetry/core/constraints/version/patterns.py b/src/poetry/core/constraints/version/patterns.py index 0dd213cf3..32ff70424 100644 --- a/src/poetry/core/constraints/version/patterns.py +++ b/src/poetry/core/constraints/version/patterns.py @@ -17,12 +17,12 @@ rf"^~=\s*(?P{VERSION_PATTERN})$", re.VERBOSE | re.IGNORECASE ) X_CONSTRAINT = re.compile( - r"^(?P!=|==)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.[xX*])+$" + r"^(?P!=|==)?\s*v?(?P(\d+)(?:\.(\d+))?(?:\.(\d+))?)(?:\.[xX*])+$" ) # note that we also allow technically incorrect version patterns with astrix (eg: 3.5.*) # as this is supported by pip and appears in metadata within python packages BASIC_CONSTRAINT = re.compile( - rf"^(?P<>|!=|>=?|<=?|==?)?\s*(?P{VERSION_PATTERN}|dev)(\.\*)?$", + rf"^(?P<>|!=|>=?|<=?|==?)?\s*(?P{VERSION_PATTERN}|dev)(?P\.\*)?$", re.VERBOSE | re.IGNORECASE, ) diff --git a/src/poetry/core/constraints/version/version_constraint.py b/src/poetry/core/constraints/version/version_constraint.py index bbb4b1fe9..ecabc3e31 100644 --- a/src/poetry/core/constraints/version/version_constraint.py +++ b/src/poetry/core/constraints/version/version_constraint.py @@ -63,3 +63,64 @@ def __hash__(self) -> int: def __eq__(self, other: object) -> bool: raise NotImplementedError + + +def _is_wildcard_candidate( + min_: Version, max_: Version, *, inverted: bool = False +) -> bool: + if ( + min_.is_local() + or max_.is_local() + or min_.is_prerelease() + or max_.is_prerelease() + or min_.is_postrelease() is not max_.is_postrelease() + or min_.first_devrelease() != min_ + or (max_.is_devrelease() and max_.first_devrelease() != max_) + ): + return False + + first = max_ if inverted else min_ + second = min_ if inverted else max_ + + parts_first = list(first.parts) + parts_second = list(second.parts) + + # remove trailing zeros from second + while parts_second and parts_second[-1] == 0: + del parts_second[-1] + + # fill up first with zeros + parts_first += [0] * (len(parts_second) - len(parts_first)) + + # all exceeding parts of first must be zero + if set(parts_first[len(parts_second) :]) not in [set(), {0}]: + return False + + parts_first = parts_first[: len(parts_second)] + + if first.is_postrelease(): + assert first.post is not None + return parts_first == parts_second and first.post.next() == second.post + + return ( + parts_first[:-1] == parts_second[:-1] + and parts_first[-1] + 1 == parts_second[-1] + ) + + +def _single_wildcard_range_string(first: Version, second: Version) -> str: + if first.is_postrelease(): + base_version = str(first.without_devrelease()) + + else: + parts = list(second.parts) + + # remove trailing zeros from max + while parts and parts[-1] == 0: + del parts[-1] + + parts[-1] = parts[-1] - 1 + + base_version = ".".join(str(part) for part in parts) + + return f"{base_version}.*" diff --git a/src/poetry/core/constraints/version/version_range.py b/src/poetry/core/constraints/version/version_range.py index 11354b25a..6d02797e6 100644 --- a/src/poetry/core/constraints/version/version_range.py +++ b/src/poetry/core/constraints/version/version_range.py @@ -1,8 +1,13 @@ from __future__ import annotations +from contextlib import suppress from typing import TYPE_CHECKING from poetry.core.constraints.version.empty_constraint import EmptyConstraint +from poetry.core.constraints.version.version_constraint import _is_wildcard_candidate +from poetry.core.constraints.version.version_constraint import ( + _single_wildcard_range_string, +) from poetry.core.constraints.version.version_range_constraint import ( VersionRangeConstraint, ) @@ -21,22 +26,9 @@ def __init__( max: Version | None = None, include_min: bool = False, include_max: bool = False, - always_include_max_prerelease: bool = False, ) -> None: - full_max = max - if ( - not always_include_max_prerelease - and not include_max - and full_max is not None - and full_max.is_stable() - and not full_max.is_postrelease() - and (min is None or min.is_stable() or min.release != full_max.release) - ): - full_max = full_max.first_prerelease() - - self._min = min self._max = max - self._full_max = full_max + self._min = min self._include_min = include_min self._include_max = include_max @@ -48,10 +40,6 @@ def min(self) -> Version | None: def max(self) -> Version | None: return self._max - @property - def full_max(self) -> Version | None: - return self._full_max - @property def include_min(self) -> bool: return self._include_min @@ -71,27 +59,43 @@ def is_simple(self) -> bool: def allows(self, other: Version) -> bool: if self._min is not None: - if other < self._min: + _this, _other = self.allowed_min, other + + assert _this is not None + + if not _this.is_postrelease() and _other.is_postrelease(): + # The exclusive ordered comparison >V MUST NOT allow a post-release + # of the given version unless V itself is a post release. + # https://peps.python.org/pep-0440/#exclusive-ordered-comparison + # e.g. "2.0.post1" does not match ">2" + _other = _other.without_postrelease() + + if not _this.is_local() and _other.is_local(): + # The exclusive ordered comparison >V MUST NOT match + # a local version of the specified version. + # https://peps.python.org/pep-0440/#exclusive-ordered-comparison + # e.g. "2.0+local.version" does not match ">2" + _other = other.without_local() + + if _other < _this: return False - if not self._include_min and other == self._min: + if not self._include_min and (_other == self._min or _other == _this): return False - if self.full_max is not None: - _this, _other = self.full_max, other + if self.max is not None: + _this, _other = self.allowed_max, other + + assert _this is not None if not _this.is_local() and _other.is_local(): # allow weak equality to allow `3.0.0+local.1` for `<=3.0.0` _other = _other.without_local() - if not _this.is_postrelease() and _other.is_postrelease(): - # allow weak equality to allow `3.0.0-1` for `<=3.0.0` - _other = _other.without_postrelease() - if _other > _this: return False - if not self._include_max and _other == _this: + if not self._include_max and (_other == self._max or _other == _this): return False return True @@ -335,6 +339,29 @@ def difference(self, other: VersionConstraint) -> VersionConstraint: def flatten(self) -> list[VersionRangeConstraint]: return [self] + def _single_wildcard_range_string(self) -> str: + if not self.is_single_wildcard_range(): + raise ValueError("Not a valid wildcard range") + + assert self.min is not None + assert self.max is not None + return f"=={_single_wildcard_range_string(self.min, self.max)}" + + def is_single_wildcard_range(self) -> bool: + # e.g. + # - "1.*" equals ">=1.0.dev0, <2" (equivalent to ">=1.0.dev0, <2.0.dev0") + # - "1.0.*" equals ">=1.0.dev0, <1.1" + # - "1.2.*" equals ">=1.2.dev0, <1.3" + if ( + self.min is None + or self.max is None + or not self.include_min + or self.include_max + ): + return False + + return _is_wildcard_candidate(self.min, self.max) + def __eq__(self, other: object) -> bool: if not isinstance(other, VersionRangeConstraint): return False @@ -391,6 +418,9 @@ def _compare_max(self, other: VersionRangeConstraint) -> int: return 0 def __str__(self) -> str: + with suppress(ValueError): + return self._single_wildcard_range_string() + text = "" if self.min is not None: diff --git a/src/poetry/core/constraints/version/version_range_constraint.py b/src/poetry/core/constraints/version/version_range_constraint.py index bf4a30147..f775510b1 100644 --- a/src/poetry/core/constraints/version/version_range_constraint.py +++ b/src/poetry/core/constraints/version/version_range_constraint.py @@ -21,11 +21,6 @@ def min(self) -> Version | None: def max(self) -> Version | None: raise NotImplementedError - @property - @abstractmethod - def full_max(self) -> Version | None: - raise NotImplementedError - @property @abstractmethod def include_min(self) -> bool: @@ -36,44 +31,83 @@ def include_min(self) -> bool: def include_max(self) -> bool: raise NotImplementedError - def allows_lower(self, other: VersionRangeConstraint) -> bool: + @property + def allowed_min(self) -> Version | None: if self.min is None: - return other.min is not None + return None + + # That is a bit inaccurate because + # 1) The exclusive ordered comparison >V MUST NOT allow a post-release + # of the given version unless V itself is a post release. + # 2) The exclusive ordered comparison >V MUST NOT match + # a local version of the specified version. + # https://peps.python.org/pep-0440/#exclusive-ordered-comparison + # However, there is no specific min greater than the greatest post release + # or greatest local version identifier. These cases have to be handled by + # the callers of allowed_min. + return self.min + + @property + def allowed_max(self) -> Version | None: + if self.max is None: + return None - if other.min is None: + if self.include_max or self.max.is_unstable(): + return self.max + + if self.min == self.max and (self.include_min or self.include_max): + # this is an equality range + return self.max + + # The exclusive ordered comparison bool: + _this, _other = self.allowed_min, other.allowed_min + + if _this is None: + return _other is not None + + if _other is None: return False - if self.min < other.min: + if _this < _other: return True - if self.min > other.min: + if _this > _other: return False return self.include_min and not other.include_min def allows_higher(self, other: VersionRangeConstraint) -> bool: - if self.full_max is None: - return other.max is not None + _this, _other = self.allowed_max, other.allowed_max - if other.full_max is None: + if _this is None: + return _other is not None + + if _other is None: return False - if self.full_max < other.full_max: + if _this < _other: return False - if self.full_max > other.full_max: + if _this > _other: return True return self.include_max and not other.include_max def is_strictly_lower(self, other: VersionRangeConstraint) -> bool: - if self.full_max is None or other.min is None: + _this, _other = self.allowed_max, other.allowed_min + + if _this is None or _other is None: return False - if self.full_max < other.min: + if _this < _other: return True - if self.full_max > other.min: + if _this > _other: return False return not (self.include_max and other.include_min) diff --git a/src/poetry/core/constraints/version/version_union.py b/src/poetry/core/constraints/version/version_union.py index 5d97fc1c5..26900a77f 100644 --- a/src/poetry/core/constraints/version/version_union.py +++ b/src/poetry/core/constraints/version/version_union.py @@ -7,6 +7,10 @@ from poetry.core.constraints.version.empty_constraint import EmptyConstraint from poetry.core.constraints.version.version_constraint import VersionConstraint +from poetry.core.constraints.version.version_constraint import _is_wildcard_candidate +from poetry.core.constraints.version.version_constraint import ( + _single_wildcard_range_string, +) from poetry.core.constraints.version.version_range_constraint import ( VersionRangeConstraint, ) @@ -91,6 +95,18 @@ def is_simple(self) -> bool: return self.excludes_single_version() def allows(self, version: Version) -> bool: + if self.excludes_single_version(): + # when excluded version is local, special handling is required + # to ensure that a constraint (!=2.0+deadbeef) will allow the + # provided version (2.0) + from poetry.core.constraints.version.version import Version + from poetry.core.constraints.version.version_range import VersionRange + + excluded = VersionRange().difference(self) + + if isinstance(excluded, Version) and excluded.is_local(): + return excluded != version + return any(constraint.allows(version) for constraint in self._ranges) def allows_all(self, other: VersionConstraint) -> bool: @@ -244,135 +260,15 @@ def _exclude_single_wildcard_range_string(self) -> str: if not self.excludes_single_wildcard_range(): raise ValueError("Not a valid wildcard range") - # we assume here that since it is a single exclusion range - # that it is one of "< 2.0.0 || >= 2.1.0" or ">= 2.1.0 || < 2.0.0" - # and the one with the max is the first part idx_order = (0, 1) if self._ranges[0].max else (1, 0) - one = self._ranges[idx_order[0]].max - assert one is not None - two = self._ranges[idx_order[1]].min - assert two is not None - - # versions can have both semver and non semver parts - parts_one = [ - one.major, - one.minor or 0, - one.patch or 0, - *list(one.non_semver_parts or []), - ] - parts_two = [ - two.major, - two.minor or 0, - two.patch or 0, - *list(two.non_semver_parts or []), - ] - - # we assume here that a wildcard range implies that the part following the - # first part that is different in the second range is the wildcard, this means - # that multiple wildcards are not supported right now. - parts = [] - - for idx, part in enumerate(parts_one): - parts.append(str(part)) - if parts_two[idx] != part: - # since this part is different the next one is the wildcard - # for example, "< 2.0.0 || >= 2.1.0" gets us a wildcard range - # 2.0.* - parts.append("*") - break - else: - # we should not ever get here, however it is likely that poorly - # constructed metadata exists - raise ValueError("Not a valid wildcard range") - - return f"!={'.'.join(parts)}" - - @staticmethod - def _excludes_single_wildcard_range_check_is_valid_range( - one: VersionRangeConstraint, two: VersionRangeConstraint - ) -> bool: - """ - Helper method to determine if two versions define a single wildcard range. - - In cases where !=2.0.* was parsed by us, the union is of the range - <2.0.0 || >=2.1.0. In user defined ranges, precision might be different. - For example, a union <2.0 || >= 2.1.0 is still !=2.0.*. In order to - handle these cases we make sure that if precisions do not match, extra - checks are performed to validate that the constraint is a valid single - wildcard range. - """ + one = self._ranges[idx_order[0]] + two = self._ranges[idx_order[1]] assert one.max is not None assert two.min is not None - - max_precision = max(one.max.precision, two.min.precision) - - if max_precision <= 3: - # In cases where both versions have a precision less than 3, - # we can make use of the next major/minor/patch versions. - return two.min in { - one.max.next_major(), - one.max.next_minor(), - one.max.next_patch(), - } - else: - # When there are non-semver parts in one of the versions, we need to - # ensure we use zero padded version and in addition to next major/minor/ - # patch versions, also check each next release for the extra parts. - from_parts = one.max.__class__.from_parts - - _extras: list[list[int]] = [] - _versions: list[Version] = [] - - for _version in (one.max, two.min): - _extra = list(_version.non_semver_parts or []) - - while len(_extra) < (max_precision - 3): - # pad zeros for extra parts to ensure precisions are equal - _extra.append(0) - - # create a new release with unspecified parts padded with zeros - _padded_version: Version = from_parts( - major=_version.major, - minor=_version.minor or 0, - patch=_version.patch or 0, - extra=tuple(_extra), - ) - - _extras.append(_extra) - _versions.append(_padded_version) - - _extra_one = _extras[0] - _padded_version_one = _versions[0] - _padded_version_two = _versions[1] - - _check_versions = { - _padded_version_one.next_major(), - _padded_version_one.next_minor(), - _padded_version_one.next_patch(), - } - - # for each non-semver (extra) part, bump a version - for idx, val in enumerate(_extra_one): - _extra = [ - *_extra_one[: idx - 1], - (val + 1), - *_extra_one[idx + 1 :], - ] - _check_versions.add( - from_parts( - _padded_version_one.major, - _padded_version_one.minor, - _padded_version_one.patch, - tuple(_extra), - ) - ) - - return _padded_version_two in _check_versions + return f"!={_single_wildcard_range_string(one.max, two.min)}" def excludes_single_wildcard_range(self) -> bool: - from poetry.core.constraints.version.version_range import VersionRange - if len(self._ranges) != 2: return False @@ -380,17 +276,17 @@ def excludes_single_wildcard_range(self) -> bool: one = self._ranges[idx_order[0]] two = self._ranges[idx_order[1]] - is_range_exclusion = ( - one.max and not one.include_max and two.min and two.include_min - ) - - if not is_range_exclusion: - return False - - if not self._excludes_single_wildcard_range_check_is_valid_range(one, two): + if ( + one.max is None + or one.include_max + or one.min is not None + or two.min is None + or not two.include_min + or two.max is not None + ): return False - return isinstance(VersionRange().difference(self), VersionRange) + return _is_wildcard_candidate(two.min, one.max, inverted=True) def excludes_single_version(self) -> bool: from poetry.core.constraints.version.version import Version diff --git a/src/poetry/core/packages/utils/utils.py b/src/poetry/core/packages/utils/utils.py index 6fdaf81a8..f8ea8b527 100644 --- a/src/poetry/core/packages/utils/utils.py +++ b/src/poetry/core/packages/utils/utils.py @@ -17,7 +17,7 @@ from poetry.core.constraints.version import Version from poetry.core.constraints.version import VersionRange -from poetry.core.constraints.version import parse_constraint +from poetry.core.constraints.version import parse_marker_version_constraint from poetry.core.pyproject.toml import PyProjectTOML from poetry.core.version.markers import SingleMarkerLike from poetry.core.version.markers import dnf @@ -322,7 +322,7 @@ def get_python_constraint_from_marker( python_version_markers = markers["python_version"] normalized = normalize_python_version_markers(python_version_markers) - constraint = parse_constraint(normalized) + constraint = parse_marker_version_constraint(normalized) return constraint diff --git a/src/poetry/core/utils/helpers.py b/src/poetry/core/utils/helpers.py index d8b095537..887bb2532 100644 --- a/src/poetry/core/utils/helpers.py +++ b/src/poetry/core/utils/helpers.py @@ -3,7 +3,9 @@ import os import shutil import stat +import sys import tempfile +import time import unicodedata import warnings @@ -40,9 +42,16 @@ def normalize_version(version: str) -> str: @contextmanager def temporary_directory(*args: Any, **kwargs: Any) -> Iterator[str]: - name = tempfile.mkdtemp(*args, **kwargs) - yield name - safe_rmtree(name) + if sys.version_info >= (3, 10): + # mypy reports an error if ignore_cleanup_errors is + # specified literally in the call + kwargs["ignore_cleanup_errors"] = True + with tempfile.TemporaryDirectory(*args, **kwargs) as name: + yield name + else: + name = tempfile.mkdtemp(*args, **kwargs) + yield name + robust_rmtree(name) def parse_requires(requires: str) -> list[str]: @@ -90,10 +99,30 @@ def _on_rm_error(func: Any, path: str | Path, exc_info: Any) -> None: func(path) -def safe_rmtree(path: str | Path) -> None: - if Path(path).is_symlink(): - return os.unlink(str(path)) - +def robust_rmtree(path: str | Path, max_timeout: float = 1) -> None: + """ + Robustly tries to delete paths. + Retries several times if an OSError occurs. + If the final attempt fails, the Exception is propagated + to the caller. + """ + path = Path(path) # make sure this is a Path object, not str + timeout = 0.001 + while timeout < max_timeout: + try: + # both os.unlink and shutil.rmtree can throw exceptions on Windows + # if the files are in use when called + if path.is_symlink(): + path.unlink() + else: + shutil.rmtree(path) + return # Only hits this on success + except OSError: + # Increase the timeout and try again + time.sleep(timeout) + timeout *= 2 + + # Final attempt, pass any Exceptions up to caller. shutil.rmtree(path, onerror=_on_rm_error) diff --git a/src/poetry/core/version/markers.py b/src/poetry/core/version/markers.py index 5fea77989..4fccf3736 100644 --- a/src/poetry/core/version/markers.py +++ b/src/poetry/core/version/markers.py @@ -303,9 +303,7 @@ def __init__( from poetry.core.constraints.generic import ( parse_constraint as parse_generic_constraint, ) - from poetry.core.constraints.version import ( - parse_constraint as parse_version_constraint, - ) + from poetry.core.constraints.version import parse_marker_version_constraint parsed_constraint: BaseConstraint | VersionConstraint parser: Callable[[str], BaseConstraint | VersionConstraint] @@ -324,7 +322,7 @@ def __init__( parser = parse_generic_constraint if name in self._VERSION_LIKE_MARKER_NAME: - parser = parse_version_constraint + parser = parse_marker_version_constraint if self._operator in {"in", "not in"}: versions = [] diff --git a/src/poetry/core/version/pep440/segments.py b/src/poetry/core/version/pep440/segments.py index 37f2dd6ce..1c6aaa9ef 100644 --- a/src/poetry/core/version/pep440/segments.py +++ b/src/poetry/core/version/pep440/segments.py @@ -3,6 +3,7 @@ import dataclasses from typing import Optional +from typing import Sequence from typing import Tuple from typing import Union @@ -68,6 +69,13 @@ def from_parts(cls, *parts: int) -> Release: extra=parts[3:], ) + def to_parts(self) -> Sequence[int]: + return tuple( + part + for part in [self.major, self.minor, self.patch, *self.extra] + if part is not None + ) + def to_string(self) -> str: return self.text @@ -98,6 +106,24 @@ def next_patch(self) -> Release: extra=tuple(0 for _ in self.extra), ) + def next(self) -> Release: + if self.precision == 1: + return self.next_major() + + if self.precision == 2: + return self.next_minor() + + if self.precision == 3: + return self.next_patch() + + return dataclasses.replace( + self, + major=self.major, + minor=self.minor, + patch=self.patch, + extra=(*self.extra[:-1], self.extra[-1] + 1), + ) + @dataclasses.dataclass(frozen=True, eq=True, order=True) class ReleaseTag: diff --git a/src/poetry/core/version/pep440/version.py b/src/poetry/core/version/pep440/version.py index 3142783d2..b9326ec3f 100644 --- a/src/poetry/core/version/pep440/version.py +++ b/src/poetry/core/version/pep440/version.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from typing import Any +from typing import Sequence from typing import TypeVar from poetry.core.version.pep440.segments import RELEASE_PHASE_ID_ALPHA @@ -139,10 +140,13 @@ def patch(self) -> int | None: return self.release.patch @property - def non_semver_parts(self) -> tuple[int, ...]: - assert isinstance(self.release.extra, tuple) + def non_semver_parts(self) -> Sequence[int]: return self.release.extra + @property + def parts(self) -> Sequence[int]: + return self.release.to_parts() + def to_string(self, short: bool = False) -> str: if short: import warnings @@ -231,7 +235,11 @@ def next_patch(self: T) -> T: release = release.next_patch() return self.__class__(epoch=self.epoch, release=release) - def next_prerelease(self: T, next_phase: bool = False) -> PEP440Version: + def next_stable(self: T) -> T: + release = self.release.next() if self.is_stable() else self.release + return self.__class__(epoch=self.epoch, release=release, local=self.local) + + def next_prerelease(self: T, next_phase: bool = False) -> T: if self.is_stable(): warnings.warn( ( @@ -320,4 +328,9 @@ def without_local(self: T) -> T: return self.replace(local=None) def without_postrelease(self: T) -> T: - return self.replace(post=None) + if self.is_postrelease(): + return self.replace(post=None, dev=None) + return self + + def without_devrelease(self: T) -> T: + return self.replace(dev=None) diff --git a/tests/constraints/version/test_helpers.py b/tests/constraints/version/test_helpers.py index a0f79627e..79613633f 100644 --- a/tests/constraints/version/test_helpers.py +++ b/tests/constraints/version/test_helpers.py @@ -42,55 +42,39 @@ def test_parse_constraint(input: str, constraint: Version | VersionRange) -> Non [ ( "v2.*", - VersionRange( - Version.from_parts(2, 0, 0), Version.from_parts(3, 0, 0), True - ), + VersionRange(Version.parse("2.dev0"), Version.parse("3.dev0"), True), ), ( "2.*.*", - VersionRange( - Version.from_parts(2, 0, 0), Version.from_parts(3, 0, 0), True - ), + VersionRange(Version.parse("2.dev0"), Version.parse("3.dev0"), True), ), ( "20.*", - VersionRange( - Version.from_parts(20, 0, 0), Version.from_parts(21, 0, 0), True - ), + VersionRange(Version.parse("20.dev0"), Version.parse("21.dev0"), True), ), ( "20.*.*", - VersionRange( - Version.from_parts(20, 0, 0), Version.from_parts(21, 0, 0), True - ), + VersionRange(Version.parse("20.dev0"), Version.parse("21.dev0"), True), ), ( "2.0.*", - VersionRange( - Version.from_parts(2, 0, 0), Version.from_parts(2, 1, 0), True - ), + VersionRange(Version.parse("2.0.dev0"), Version.parse("2.1.dev0"), True), ), ( "2.x", - VersionRange( - Version.from_parts(2, 0, 0), Version.from_parts(3, 0, 0), True - ), + VersionRange(Version.parse("2.dev0"), Version.parse("3.dev0"), True), ), ( "2.x.x", - VersionRange( - Version.from_parts(2, 0, 0), Version.from_parts(3, 0, 0), True - ), + VersionRange(Version.parse("2.dev0"), Version.parse("3.dev0"), True), ), ( "2.2.X", - VersionRange( - Version.from_parts(2, 2, 0), Version.from_parts(2, 3, 0), True - ), + VersionRange(Version.parse("2.2.dev0"), Version.parse("2.3.dev0"), True), ), - ("0.*", VersionRange(max=Version.from_parts(1, 0, 0))), - ("0.*.*", VersionRange(max=Version.from_parts(1, 0, 0))), - ("0.x", VersionRange(max=Version.from_parts(1, 0, 0))), + ("0.*", VersionRange(Version.parse("0.dev0"), Version.parse("1.dev0"), True)), + ("0.*.*", VersionRange(Version.parse("0.dev0"), Version.parse("1.dev0"), True)), + ("0.x", VersionRange(Version.parse("0.dev0"), Version.parse("1.dev0"), True)), ], ) def test_parse_constraint_wildcard(input: str, constraint: VersionRange) -> None: @@ -331,10 +315,8 @@ def test_parse_constraint_multi_with_epochs(input: str, output: VersionRange) -> ) def test_parse_constraint_multi_wilcard(input: str) -> None: assert parse_constraint(input) == VersionUnion( - VersionRange( - Version.from_parts(2, 7, 0), Version.from_parts(3, 0, 0), True, False - ), - VersionRange(Version.from_parts(3, 2, 0), None, True, False), + VersionRange(Version.parse("2.7"), Version.parse("3.0.dev0"), True, False), + VersionRange(Version.parse("3.2.dev0"), None, True, False), ) @@ -343,24 +325,34 @@ def test_parse_constraint_multi_wilcard(input: str) -> None: [ ( "!=v2.*", - VersionRange(max=Version.parse("2.0")).union( - VersionRange(Version.parse("3.0"), include_min=True) + VersionRange(max=Version.parse("2.0.0.dev0")).union( + VersionRange(Version.parse("3.0.dev0"), include_min=True) ), ), ( "!=2.*.*", - VersionRange(max=Version.parse("2.0")).union( - VersionRange(Version.parse("3.0"), include_min=True) + VersionRange(max=Version.parse("2.0.0.dev0")).union( + VersionRange(Version.parse("3.0.dev0"), include_min=True) ), ), ( "!=2.0.*", - VersionRange(max=Version.parse("2.0")).union( - VersionRange(Version.parse("2.1"), include_min=True) + VersionRange(max=Version.parse("2.0.0.dev0")).union( + VersionRange(Version.parse("2.1.dev0"), include_min=True) + ), + ), + ( + "!=0.*", + VersionRange(max=Version.parse("0.dev0")).union( + VersionRange(Version.parse("1.0.dev0"), include_min=True) + ), + ), + ( + "!=0.*.*", + VersionRange(max=Version.parse("0.dev0")).union( + VersionRange(Version.parse("1.0.dev0"), include_min=True) ), ), - ("!=0.*", VersionRange(Version.parse("1.0"), include_min=True)), - ("!=0.*.*", VersionRange(Version.parse("1.0"), include_min=True)), ], ) def test_parse_constraints_negative_wildcard( diff --git a/tests/constraints/version/test_parse_constraint.py b/tests/constraints/version/test_parse_constraint.py index c27a7b2cf..a12640f8c 100644 --- a/tests/constraints/version/test_parse_constraint.py +++ b/tests/constraints/version/test_parse_constraint.py @@ -23,16 +23,16 @@ ( "== 3.8.*", VersionRange( - min=Version.from_parts(3, 8), - max=Version.from_parts(3, 9, 0), + min=Version.parse("3.8.0.dev0"), + max=Version.parse("3.9.0.dev0"), include_min=True, ), ), ( "== 3.8.x", VersionRange( - min=Version.from_parts(3, 8), - max=Version.from_parts(3, 9, 0), + min=Version.parse("3.8.0.dev0"), + max=Version.parse("3.9.0.dev0"), include_min=True, ), ), @@ -246,6 +246,33 @@ include_min=True, ), ), + ( + "2.0.post1.*", + VersionRange( + min=Version.parse("2.0.post1.dev0"), + max=Version.parse("2.0.post2.dev0"), + include_min=True, + include_max=False, + ), + ), + ( + "2.0a1.*", + VersionRange( + min=Version.parse("2.0a1.dev0"), + max=Version.parse("2.0a2.dev0"), + include_min=True, + include_max=False, + ), + ), + ( + "2.0dev0.*", + VersionRange( + min=Version.parse("2.0dev0"), + max=Version.parse("2.0dev1"), + include_min=True, + include_max=False, + ), + ), ], ) @pytest.mark.parametrize(("with_whitespace_padding",), [(True,), (False,)]) diff --git a/tests/constraints/version/test_version_range.py b/tests/constraints/version/test_version_range.py index fcac2477d..51508a50b 100644 --- a/tests/constraints/version/test_version_range.py +++ b/tests/constraints/version/test_version_range.py @@ -5,6 +5,7 @@ from poetry.core.constraints.version import EmptyConstraint from poetry.core.constraints.version import Version from poetry.core.constraints.version import VersionRange +from poetry.core.constraints.version import parse_constraint @pytest.fixture() @@ -80,7 +81,7 @@ def v300b1() -> Version: @pytest.mark.parametrize( "base,other", [ - pytest.param(Version.parse("3.0.0"), Version.parse("3.0.0-1"), id="post"), + pytest.param(Version.parse("3.0.0-1"), Version.parse("3.0.0-1"), id="post"), pytest.param( Version.parse("3.0.0"), Version.parse("3.0.0+local.1"), id="local" ), @@ -111,7 +112,7 @@ def test_allows_post_releases_with_post_and_local_min() -> None: three = Version.parse("3.0.0-1+local.1") four = Version.parse("3.0.0+local.2") - assert VersionRange(min=one, include_min=True).allows(two) + assert not VersionRange(min=one, include_min=True).allows(two) assert VersionRange(min=one, include_min=True).allows(three) assert VersionRange(min=one, include_min=True).allows(four) @@ -124,8 +125,8 @@ def test_allows_post_releases_with_post_and_local_min() -> None: assert not VersionRange(min=three, include_min=True).allows(four) assert not VersionRange(min=four, include_min=True).allows(one) - assert VersionRange(min=four, include_min=True).allows(two) - assert VersionRange(min=four, include_min=True).allows(three) + assert not VersionRange(min=four, include_min=True).allows(two) + assert not VersionRange(min=four, include_min=True).allows(three) def test_allows_post_releases_with_post_and_local_max() -> None: @@ -134,8 +135,8 @@ def test_allows_post_releases_with_post_and_local_max() -> None: three = Version.parse("3.0.0-1+local.1") four = Version.parse("3.0.0+local.2") - assert VersionRange(max=one, include_max=True).allows(two) - assert VersionRange(max=one, include_max=True).allows(three) + assert not VersionRange(max=one, include_max=True).allows(two) + assert not VersionRange(max=one, include_max=True).allows(three) assert not VersionRange(max=one, include_max=True).allows(four) assert VersionRange(max=two, include_max=True).allows(one) @@ -147,8 +148,8 @@ def test_allows_post_releases_with_post_and_local_max() -> None: assert VersionRange(max=three, include_max=True).allows(four) assert VersionRange(max=four, include_max=True).allows(one) - assert VersionRange(max=four, include_max=True).allows(two) - assert VersionRange(max=four, include_max=True).allows(three) + assert not VersionRange(max=four, include_max=True).allows(two) + assert not VersionRange(max=four, include_max=True).allows(three) @pytest.mark.parametrize( @@ -345,7 +346,7 @@ def test_allows_any( # pre-release min does not allow lesser than itself range = VersionRange(Version.parse("1.9b1"), include_min=True) assert not range.allows_any( - VersionRange(Version.parse("1.8.0"), Version.parse("1.9.0"), include_min=True) + VersionRange(Version.parse("1.8.0"), Version.parse("1.9.0b0"), include_min=True) ) @@ -446,15 +447,302 @@ def test_union( assert result == VersionRange(v003, v200) -def test_include_max_prerelease(v200: Version, v300: Version, v300b1: Version) -> None: - result = VersionRange(v200, v300) +@pytest.mark.parametrize( + ("version", "spec", "expected"), + [ + (v, s, True) + for v, s in [ + # Test the equality operation + ("2.0", "==2"), + ("2.0", "==2.0"), + ("2.0", "==2.0.0"), + ("2.0+deadbeef", "==2"), + ("2.0+deadbeef", "==2.0"), + ("2.0+deadbeef", "==2.0.0"), + ("2.0+deadbeef", "==2+deadbeef"), + ("2.0+deadbeef", "==2.0+deadbeef"), + ("2.0+deadbeef", "==2.0.0+deadbeef"), + ("2.0+deadbeef.0", "==2.0.0+deadbeef.00"), + # Test the equality operation with a prefix + ("2.dev1", "==2.*"), + ("2a1", "==2.*"), + ("2a1.post1", "==2.*"), + ("2b1", "==2.*"), + ("2b1.dev1", "==2.*"), + ("2c1", "==2.*"), + ("2c1.post1.dev1", "==2.*"), + ("2rc1", "==2.*"), + ("2", "==2.*"), + ("2.0", "==2.*"), + ("2.0.0", "==2.*"), + ("2.0.post1", "==2.0.post1.*"), + ("2.0.post1.dev1", "==2.0.post1.*"), + ("2.1+local.version", "==2.1.*"), + # Test the in-equality operation + ("2.1", "!=2"), + ("2.1", "!=2.0"), + ("2.0.1", "!=2"), + ("2.0.1", "!=2.0"), + ("2.0.1", "!=2.0.0"), + ("2.0", "!=2.0+deadbeef"), + # Test the in-equality operation with a prefix + ("2.0", "!=3.*"), + ("2.1", "!=2.0.*"), + # Test the greater than equal operation + ("2.0", ">=2"), + ("2.0", ">=2.0"), + ("2.0", ">=2.0.0"), + ("2.0.post1", ">=2"), + ("2.0.post1.dev1", ">=2"), + ("3", ">=2"), + # Test the less than equal operation + ("2.0", "<=2"), + ("2.0", "<=2.0"), + ("2.0", "<=2.0.0"), + ("2.0.dev1", "<=2"), + ("2.0a1", "<=2"), + ("2.0a1.dev1", "<=2"), + ("2.0b1", "<=2"), + ("2.0b1.post1", "<=2"), + ("2.0c1", "<=2"), + ("2.0c1.post1.dev1", "<=2"), + ("2.0rc1", "<=2"), + ("1", "<=2"), + # Test the greater than operation + ("3", ">2"), + ("2.1", ">2.0"), + ("2.0.1", ">2"), + ("2.1.post1", ">2"), + ("2.1+local.version", ">2"), + # Test the less than operation + ("1", "<2"), + ("2.0", "<2.1"), + ("2.0.dev0", "<2.1"), + # Test the compatibility operation + ("1", "~=1.0"), + ("1.0.1", "~=1.0"), + ("1.1", "~=1.0"), + ("1.9999999", "~=1.0"), + ("1.1", "~=1.0a1"), + # Test that epochs are handled sanely + ("2!1.0", "~=2!1.0"), + ("2!1.0", "==2!1.*"), + ("2!1.0", "==2!1.0"), + ("2!1.0", "!=1.0"), + ("1.0", "!=2!1.0"), + ("1.0", "<=2!0.1"), + ("2!1.0", ">=2.0"), + ("1.0", "<2!0.1"), + ("2!1.0", ">2.0"), + # Test some normalization rules + ("2.0.5", ">2.0dev"), + ] + ] + + [ + (v, s, False) + for v, s in [ + # Test the equality operation + ("2.1", "==2"), + ("2.1", "==2.0"), + ("2.1", "==2.0.0"), + ("2.0", "==2.0+deadbeef"), + # Test the equality operation with a prefix + ("2.0", "==3.*"), + ("2.1", "==2.0.*"), + # Test the in-equality operation + ("2.0", "!=2"), + ("2.0", "!=2.0"), + ("2.0", "!=2.0.0"), + ("2.0+deadbeef", "!=2"), + ("2.0+deadbeef", "!=2.0"), + ("2.0+deadbeef", "!=2.0.0"), + ("2.0+deadbeef", "!=2+deadbeef"), + ("2.0+deadbeef", "!=2.0+deadbeef"), + ("2.0+deadbeef", "!=2.0.0+deadbeef"), + ("2.0+deadbeef.0", "!=2.0.0+deadbeef.00"), + # Test the in-equality operation with a prefix + ("2.dev1", "!=2.*"), + ("2a1", "!=2.*"), + ("2a1.post1", "!=2.*"), + ("2b1", "!=2.*"), + ("2b1.dev1", "!=2.*"), + ("2c1", "!=2.*"), + ("2c1.post1.dev1", "!=2.*"), + ("2rc1", "!=2.*"), + ("2", "!=2.*"), + ("2.0", "!=2.*"), + ("2.0.0", "!=2.*"), + ("2.0.post1", "!=2.0.post1.*"), + ("2.0.post1.dev1", "!=2.0.post1.*"), + # Test the greater than equal operation + ("2.0.dev1", ">=2"), + ("2.0a1", ">=2"), + ("2.0a1.dev1", ">=2"), + ("2.0b1", ">=2"), + ("2.0b1.post1", ">=2"), + ("2.0c1", ">=2"), + ("2.0c1.post1.dev1", ">=2"), + ("2.0rc1", ">=2"), + ("1", ">=2"), + # Test the less than equal operation + ("2.0.post1", "<=2"), + ("2.0.post1.dev1", "<=2"), + ("3", "<=2"), + # Test the greater than operation + ("1", ">2"), + ("2.0.dev1", ">2"), + ("2.0a1", ">2"), + ("2.0a1.post1", ">2"), + ("2.0b1", ">2"), + ("2.0b1.dev1", ">2"), + ("2.0c1", ">2"), + ("2.0c1.post1.dev1", ">2"), + ("2.0rc1", ">2"), + ("2.0", ">2"), + ("2.0.post1", ">2"), + ("2.0.post1.dev1", ">2"), + ("2.0+local.version", ">2"), + # Test the less than operation + ("2.0.dev1", "<2"), + ("2.0a1", "<2"), + ("2.0a1.post1", "<2"), + ("2.0b1", "<2"), + ("2.0b2.dev1", "<2"), + ("2.0c1", "<2"), + ("2.0c1.post1.dev1", "<2"), + ("2.0rc1", "<2"), + ("2.0", "<2"), + ("2.post1", "<2"), + ("2.post1.dev1", "<2"), + ("3", "<2"), + # Test the compatibility operation + ("2.0", "~=1.0"), + ("1.1.0", "~=1.0.0"), + ("1.1.post1", "~=1.0.0"), + # Test that epochs are handled sanely + ("1.0", "~=2!1.0"), + ("2!1.0", "~=1.0"), + ("2!1.0", "==1.0"), + ("1.0", "==2!1.0"), + ("2!1.0", "==1.*"), + ("1.0", "==2!1.*"), + ("2!1.0", "!=2!1.0"), + ] + ], +) +def test_specifiers(version: str, spec: str, expected: bool) -> None: + """ + Test derived from + https://github.com/pypa/packaging/blob/8b86d85797b9f26d98ecfbe0271ce4dc9495d98c/tests/test_specifiers.py#L469 + """ + constraint = parse_constraint(spec) + + v = Version.parse(version) + + if expected: + # Test that the plain string form works + # assert version in spec + assert constraint.allows(v) + + # Test that the version instance form works + # assert version in spec + assert constraint.allows(v) + else: + # Test that the plain string form works + # assert version not in spec + assert not constraint.allows(v) + + # Test that the version instance form works + # assert version not in spec + assert not constraint.allows(v) + + +@pytest.mark.parametrize( + ("include_min", "include_max", "expected"), + [ + (True, False, True), + (False, False, False), + (False, True, False), + (True, True, False), + ], +) +def test_is_single_wildcard_range_include_min_include_max( + include_min: bool, include_max: bool, expected: bool +) -> None: + version_range = VersionRange( + Version.parse("1.2.dev0"), Version.parse("1.3"), include_min, include_max + ) + assert version_range.is_single_wildcard_range() is expected + - assert not result.allows(v300b1) - assert not result.allows_any(VersionRange(v300b1)) - assert not result.allows_all(VersionRange(v200, v300b1)) +@pytest.mark.parametrize( + ("min", "max", "expected"), + [ + # simple wildcard ranges + ("1.2.dev0", "1.3", True), + ("1.2.dev0", "1.3.dev0", True), + ("1.dev0", "2", True), + ("1.2.3.4.5.dev0", "1.2.3.4.6", True), + # simple non wilcard ranges + (None, "1.3", False), + ("1.2.dev0", None, False), + (None, None, False), + ("1.2a0", "1.3", False), + ("1.2.post0", "1.3", False), + ("1.2.dev0+local", "1.3", False), + ("1.2", "1.3", False), + ("1.2.dev1", "1.3", False), + ("1.2.dev0", "1.3.post0.dev0", False), + ("1.2.dev0", "1.3a0.dev0", False), + ("1.2.dev0", "1.3.dev0+local", False), + ("1.2.dev0", "1.3.dev1", False), + # more complicated ranges + ("1.dev0", "1.0.0.1", True), + ("1.2.dev0", "1.3.0.0", True), + ("1.2.dev0", "1.3.0.0.dev0", True), + ("1.2.0.dev0", "1.3", True), + ("1.2.1.dev0", "1.3.0.0", False), + ("1.2.dev0", "1.4", False), + ("1.2.dev0", "2.3", False), + # post releases + ("2.0.post1.dev0", "2.0.post2", True), + ("2.0.post1.dev0", "2.0.post2.dev0", True), + ("2.0.post1.dev1", "2.0.post2", False), + ("2.0.post1.dev0", "2.0.post2.dev1", False), + ("2.0.post1.dev0", "2.0.post3", False), + ("2.0.post1.dev0", "2.0.post1", False), + ], +) +def test_is_single_wildcard_range( + min: str | None, max: str | None, expected: bool +) -> None: + version_range = VersionRange( + Version.parse(min) if min else None, + Version.parse(max) if max else None, + include_min=True, + ) + assert version_range.is_single_wildcard_range() is expected - result = VersionRange(v200, v300, always_include_max_prerelease=True) - assert result.allows(v300b1) - assert result.allows_any(VersionRange(v300b1)) - assert result.allows_all(VersionRange(v200, v300b1)) +@pytest.mark.parametrize( + ("version", "expected"), + [ + # simple ranges + ("*", "*"), + (">1.2", ">1.2"), + (">=1.2", ">=1.2"), + ("<1.3", "<1.3"), + ("<=1.3", "<=1.3"), + (">=1.2,<1.3", ">=1.2,<1.3"), + # wildcard ranges + ("1.*", "==1.*"), + ("1.0.*", "==1.0.*"), + ("1.2.*", "==1.2.*"), + ("1.2.3.4.5.*", "==1.2.3.4.5.*"), + ("2.0.post1.*", "==2.0.post1.*"), + ("2.1.post0.*", "==2.1.post0.*"), + (">=1.dev0,<2", "==1.*"), + ], +) +def test_str(version: str, expected: str) -> None: + assert str(parse_constraint(version)) == expected diff --git a/tests/constraints/version/test_version_union.py b/tests/constraints/version/test_version_union.py new file mode 100644 index 000000000..0fd64fe42 --- /dev/null +++ b/tests/constraints/version/test_version_union.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import pytest + +from poetry.core.constraints.version import Version +from poetry.core.constraints.version import VersionRange +from poetry.core.constraints.version import VersionUnion +from poetry.core.constraints.version import parse_constraint + + +@pytest.mark.parametrize( + ("ranges", "expected"), + [ + ( # positive + [ + VersionRange(max=Version.parse("1.2")), + VersionRange(Version.parse("1.3.dev0"), include_min=True), + ], + True, + ), + ( # positive inverted order + [ + VersionRange(Version.parse("1.3.dev0"), include_min=True), + VersionRange(max=Version.parse("1.2")), + ], + True, + ), + ( # too many ranges + [ + VersionRange(max=Version.parse("1.2")), + VersionRange(Version.parse("1.3"), include_min=True), + VersionRange(max=Version.parse("1.4")), + ], + False, + ), + ([VersionRange(max=Version.parse("1.2"))], False), # too little ranges + ( # additional include_max + [ + VersionRange(max=Version.parse("1.2"), include_max=True), + VersionRange(Version.parse("1.3"), include_min=True), + ], + False, + ), + ( # missing include_min + [ + VersionRange(max=Version.parse("1.2")), + VersionRange(Version.parse("1.3")), + ], + False, + ), + ( # additional min + [ + VersionRange(Version.parse("1.0"), Version.parse("1.2")), + VersionRange(Version.parse("1.3"), include_min=True), + ], + False, + ), + ( # additional max + [ + VersionRange(max=Version.parse("1.2")), + VersionRange( + Version.parse("1.3"), Version.parse("1.4"), include_min=True + ), + ], + False, + ), + ( # missing max + [ + VersionRange(), + VersionRange(Version.parse("1.3"), include_min=True), + ], + False, + ), + ( # missing min + [ + VersionRange(max=Version.parse("1.2")), + VersionRange(include_min=True), + ], + False, + ), + ], +) +def test_excludes_single_wildcard_range_basics( + ranges: list[VersionRange], expected: bool +) -> None: + assert VersionUnion(*ranges).excludes_single_wildcard_range() is expected + + +@pytest.mark.parametrize( + ("max", "min", "expected"), + [ + # simple exclude wildcard range + ("1.2", "1.3.dev0", True), + ("1.2.dev0", "1.3.dev0", True), + ("1", "2.dev0", True), + ("1.2.3.4.5", "1.2.3.4.6.dev0", True), + # simple non exclude wildcard range + ("1.2", "1.3", False), + ("1.2", "1.3a0.dev0", False), + ("1.2", "1.3.post0.dev0", False), + ("1.2", "1.3.dev0+local", False), + ("1.2", "1.3.dev1", False), + ("1.2.post0", "1.3.dev0", False), + ("1.2a0", "1.3.dev0", False), + ("1.2+local", "1.3.dev0", False), + ("1.2.dev1", "1.3.dev0", False), + # more complicated cases + ("1", "1.0.0.1.dev0", True), + ("1.2.0.0", "1.3.dev0", True), + ("1.2.0.0.dev0", "1.3.dev0", True), + ("1.2", "1.3.0.dev0", True), + ("1.2.0.0", "1.3.1.dev0", False), + ("1.2", "1.4.dev0", False), + ("1.2", "2.3.dev0", False), + # post releases + ("2.0.post1", "2.0.post2.dev0", True), + ("2.0.post1.dev0", "2.0.post2.dev0", True), + ("2.0.post1.dev1", "2.0.post2.dev0", False), + ("2.0.post1", "2.0.post2.dev1", False), + ("2.0.post0", "2.0.post2.dev0", False), + ("2.0.post1", "2.0.post1.dev0", False), + ], +) +def test_excludes_single_wildcard_range(max: str, min: str, expected: bool) -> None: + version_union = VersionUnion( + VersionRange(max=Version.parse(max)), + VersionRange(Version.parse(min), include_min=True), + ) + assert version_union.excludes_single_wildcard_range() is expected + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + # simple unions + ("<1 || >=2", "<1 || >=2"), + ("<1.2 || >=2.3.dev0", "<1.2 || >=2.3.dev0"), + # version exclusions + ("!=1.0", "!=1.0"), + ("!=1.0+local", "!=1.0+local"), + # wildcard exclusions + ("!=1.*", "!=1.*"), + ("!=1.0.*", "!=1.0.*"), + ("!=1.2.*", "!=1.2.*"), + ("!=1.2.3.4.5.*", "!=1.2.3.4.5.*"), + ("!=2.0.post1.*", "!=2.0.post1.*"), + ("!=2.1.post0.*", "!=2.1.post0.*"), + ("<1 || >=2.dev0", "!=1.*"), + ], +) +def test_str(version: str, expected: str) -> None: + assert str(parse_constraint(version)) == expected diff --git a/tests/packages/test_dependency.py b/tests/packages/test_dependency.py index 21ae7acd7..cf2e7e1c4 100644 --- a/tests/packages/test_dependency.py +++ b/tests/packages/test_dependency.py @@ -109,7 +109,7 @@ def test_to_pep_508_in_extras_parsed() -> None: [ ("!=1.2.3", "!=1.2.3"), ("!=1.2.*", "!=1.2.*"), - ("<2.0 || >=2.1", "!=2.0.*"), + ("<2.0 || >=2.1.dev0", "!=2.0.*"), ], ) def test_to_pep_508_with_excluded_versions(exclusion: str, expected: str) -> None: @@ -229,29 +229,41 @@ def test_complete_name() -> None: ["x"], "A[x] (>=1.6.5,!=1.8.0,<3.1.0)", ), - # test single version range exclusions + # test single version range (wildcard) + ("A", "==2.*", None, "A (==2.*)"), + ("A", "==2.0.*", None, "A (==2.0.*)"), + ("A", "==0.0.*", None, "A (==0.0.*)"), + ("A", "==0.1.*", None, "A (==0.1.*)"), + ("A", "==0.*", None, "A (==0.*)"), + ("A", ">=1.0.dev0,<2", None, "A (==1.*)"), + ("A", ">=1.dev0,<2", None, "A (==1.*)"), + ("A", ">=1.0.dev1,<2", None, "A (>=1.0.dev1,<2)"), + ("A", ">=1.1.dev0,<2", None, "A (>=1.1.dev0,<2)"), + ("A", ">=1.0.dev0,<2.0.dev0", None, "A (==1.*)"), + ("A", ">=1.0.dev0,<2.0.dev1", None, "A (>=1.0.dev0,<2.0.dev1)"), + ("A", ">=1,<2", None, "A (>=1,<2)"), + ("A", ">=1.0.dev0,<1.1", None, "A (==1.0.*)"), + ("A", ">=1.0.0.0.dev0,<1.1.0.0.0", None, "A (==1.0.*)"), + # test single version range (wildcard) exclusions ("A", ">=1.8,!=2.0.*", None, "A (>=1.8,!=2.0.*)"), ("A", "!=0.0.*", None, "A (!=0.0.*)"), ("A", "!=0.1.*", None, "A (!=0.1.*)"), - ("A", "!=0.*", None, "A (>=1.0.0)"), + ("A", "!=0.*", None, "A (!=0.*)"), ("A", ">=1.8,!=2.*", None, "A (>=1.8,!=2.*)"), ("A", ">=1.8,!=2.*.*", None, "A (>=1.8,!=2.*)"), - ("A", ">=1.8,<2.0 || >=2.1.0", None, "A (>=1.8,!=2.0.*)"), - ("A", ">=1.8,<2.0.0 || >=3.0.0", None, "A (>=1.8,!=2.*)"), - ("A", ">=1.8,<2.0 || >=3", None, "A (>=1.8,!=2.*)"), - ("A", ">=1.8,<2 || >=2.1.0", None, "A (>=1.8,!=2.0.*)"), - ("A", ">=1.8,<2 || >=2.1", None, "A (>=1.8,!=2.0.*)"), + ("A", ">=1.8,<2.0 || >=2.1.0.dev0", None, "A (>=1.8,!=2.0.*)"), + ("A", ">=1.8,<2.0.0 || >=3.0.0.dev0", None, "A (>=1.8,!=2.*)"), + ("A", ">=1.8,<2.0 || >=3.dev0", None, "A (>=1.8,!=2.*)"), + ("A", ">=1.8,<2 || >=2.1.0.dev0", None, "A (>=1.8,!=2.0.*)"), + ("A", ">=1.8,<2 || >=2.1.dev0", None, "A (>=1.8,!=2.0.*)"), ("A", ">=1.8,!=2.0.*,!=3.0.*", None, "A (>=1.8,!=2.0.*,!=3.0.*)"), - ("A", ">=1.8.0.0,<2.0.0.0 || >=2.0.1.0", None, "A (>=1.8.0.0,!=2.0.0.*)"), - ("A", ">=1.8.0.0,<2 || >=2.0.1.0", None, "A (>=1.8.0.0,!=2.0.0.*)"), + ("A", ">=1.8.0.0,<2.0.0.0 || >=2.0.1.0.dev0", None, "A (>=1.8.0.0,!=2.0.0.*)"), + ("A", ">=1.8.0.0,<2 || >=2.0.1.0.dev0", None, "A (>=1.8.0.0,!=2.0.0.*)"), # we verify that the range exclusion logic is not too eager ("A", ">=1.8,<2.0 || >=2.2.0", None, "A (>=1.8,<2.0 || >=2.2.0)"), ("A", ">=1.8,<2.0 || >=2.1.5", None, "A (>=1.8,<2.0 || >=2.1.5)"), ("A", ">=1.8.0.0,<2 || >=2.0.1.5", None, "A (>=1.8.0.0,<2 || >=2.0.1.5)"), - # non-semver version test is ignored due to existing bug in wildcard - # constraint parsing that ignores non-semver versions - # TODO: re-enable for verification once fixed - # ("A", ">=1.8.0.0,!=2.0.0.*", None, "A (>=1.8.0.0,!=2.0.0.*)"), # noqa: ERA001 + ("A", ">=1.8.0.0,!=2.0.0.*", None, "A (>=1.8.0.0,!=2.0.0.*)"), ], ) def test_dependency_string_representation( diff --git a/tests/packages/test_main.py b/tests/packages/test_main.py index 22d612421..b102065a7 100644 --- a/tests/packages/test_main.py +++ b/tests/packages/test_main.py @@ -41,7 +41,7 @@ def test_dependency_from_pep_508_with_constraint() -> None: dep = Dependency.create_from_pep_508(name) assert dep.name == "requests" - assert str(dep.constraint) == ">=2.12.0,<2.17.0 || >=2.18.0,<3.0" + assert str(dep.constraint) == ">=2.12.0,<2.17.dev0 || >=2.18.dev0,<3.0" def test_dependency_from_pep_508_with_extras() -> None: diff --git a/tests/packages/utils/test_utils.py b/tests/packages/utils/test_utils.py index 757e98a4a..ed3e52834 100644 --- a/tests/packages/utils/test_utils.py +++ b/tests/packages/utils/test_utils.py @@ -6,6 +6,7 @@ from poetry.core.constraints.generic import parse_constraint as parse_generic_constraint from poetry.core.constraints.version import parse_constraint as parse_version_constraint +from poetry.core.constraints.version import parse_marker_version_constraint from poetry.core.packages.utils.utils import convert_markers from poetry.core.packages.utils.utils import create_nested_marker from poetry.core.packages.utils.utils import get_python_constraint_from_marker @@ -232,7 +233,7 @@ def test_create_nested_marker_version_constraint( ) def test_get_python_constraint_from_marker(marker: str, constraint: str) -> None: marker_parsed = parse_marker(marker) - constraint_parsed = parse_version_constraint(constraint) + constraint_parsed = parse_marker_version_constraint(constraint) assert get_python_constraint_from_marker(marker_parsed) == constraint_parsed diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py index 5baf098be..af54bc979 100644 --- a/tests/utils/test_helpers.py +++ b/tests/utils/test_helpers.py @@ -1,15 +1,23 @@ from __future__ import annotations import os +import sys +import tempfile from pathlib import Path from stat import S_IREAD +from typing import TYPE_CHECKING import pytest + +if TYPE_CHECKING: + from pytest_mock import MockerFixture + from poetry.core.utils.helpers import combine_unicode from poetry.core.utils.helpers import parse_requires from poetry.core.utils.helpers import readme_content_type +from poetry.core.utils.helpers import robust_rmtree from poetry.core.utils.helpers import temporary_directory @@ -118,3 +126,60 @@ def test_utils_helpers_readme_content_type( readme: str | Path, content_type: str ) -> None: assert readme_content_type(readme) == content_type + + +def test_temporary_directory_python_3_10_or_newer(mocker: MockerFixture) -> None: + mocked_rmtree = mocker.patch("shutil.rmtree") + mocked_temp_dir = mocker.patch("tempfile.TemporaryDirectory") + mocked_mkdtemp = mocker.patch("tempfile.mkdtemp") + + mocker.patch.object(sys, "version_info", (3, 10)) + with temporary_directory() as tmp: + assert tmp + + assert not mocked_rmtree.called + assert not mocked_mkdtemp.called + mocked_temp_dir.assert_called_with(ignore_cleanup_errors=True) + + +def test_temporary_directory_python_3_9_or_older(mocker: MockerFixture) -> None: + mocked_rmtree = mocker.patch("shutil.rmtree") + mocked_temp_dir = mocker.patch("tempfile.TemporaryDirectory") + mocked_mkdtemp = mocker.patch("tempfile.mkdtemp") + + mocked_mkdtemp.return_value = "hello from test" + + mocker.patch.object(sys, "version_info", (3, 9)) + with temporary_directory() as tmp: + assert tmp == "hello from test" + + assert mocked_rmtree.called + assert mocked_mkdtemp.called + assert not mocked_temp_dir.called + + +def test_robust_rmtree(mocker: MockerFixture) -> None: + mocked_rmtree = mocker.patch("shutil.rmtree") + + # this should work after an initial exception + name = tempfile.mkdtemp() + mocked_rmtree.side_effect = [ + OSError( + "Couldn't delete file yet, waiting for references to clear", "mocked path" + ), + None, + ] + robust_rmtree(name) + + # this should give up after retrying multiple times + mocked_rmtree.side_effect = OSError( + "Couldn't delete file yet, this error won't go away after first attempt" + ) + with pytest.raises(OSError): + robust_rmtree(name, max_timeout=0.04) + + # clear the side effect (breaks the tear-down otherwise) + mocker.stop(mocked_rmtree) + # use the real method to remove the temp folder we created for this test + robust_rmtree(name) + assert not Path(name).exists() diff --git a/tests/version/pep440/test_segments.py b/tests/version/pep440/test_segments.py index ffa1a231d..6a991f05a 100644 --- a/tests/version/pep440/test_segments.py +++ b/tests/version/pep440/test_segments.py @@ -51,8 +51,9 @@ def test_release_equal_zero_padding(precision1: int, precision2: int) -> None: ((1, 2, 3, 4, 5, 6), Release(1, 2, 3, (4, 5, 6))), ], ) -def test_release_from_parts(parts: tuple[int, ...], result: Release) -> None: +def test_release_from_parts_to_parts(parts: tuple[int, ...], result: Release) -> None: assert Release.from_parts(*parts) == result + assert result.to_parts() == parts @pytest.mark.parametrize("precision", list(range(1, 6))) @@ -60,7 +61,9 @@ def test_release_precision(precision: int) -> None: """ Semantically identical releases might have a different precision, e.g. 1 vs. 1.0 """ - assert Release.from_parts(1, *[0] * (precision - 1)).precision == precision + release = Release.from_parts(1, *[0] * (precision - 1)) + assert release.precision == precision + assert len(release.to_parts()) == precision @pytest.mark.parametrize("precision", list(range(1, 6))) @@ -93,6 +96,25 @@ def test_release_next_patch(precision: int) -> None: assert release.next_patch() == expected +@pytest.mark.parametrize( + ("release", "expected"), + [ + (Release(0), Release(1)), + (Release(1), Release(2)), + (Release(0, 0), Release(0, 1)), + (Release(1, 2), Release(1, 3)), + (Release(0, 0, 0), Release(0, 0, 1)), + (Release(1, 2, 3), Release(1, 2, 4)), + (Release(0, 0, 0, (0,)), Release(0, 0, 0, (1,))), + (Release(1, 2, 3, (4,)), Release(1, 2, 3, (5,))), + (Release(0, 0, 0, (0, 0)), Release(0, 0, 0, (0, 1))), + (Release(1, 2, 3, (4, 5)), Release(1, 2, 3, (4, 6))), + ], +) +def test_release_next(release: Release, expected: Release) -> None: + assert release.next() == expected + + @pytest.mark.parametrize( "parts,result", [ diff --git a/tests/version/pep440/test_version.py b/tests/version/pep440/test_version.py index 49ffe9469..c262d53a9 100644 --- a/tests/version/pep440/test_version.py +++ b/tests/version/pep440/test_version.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import pytest from poetry.core.version.exceptions import InvalidVersion @@ -8,6 +10,10 @@ from poetry.core.version.pep440 import ReleaseTag +if TYPE_CHECKING: + from collections.abc import Sequence + + @pytest.mark.parametrize( "text,result", [ @@ -112,6 +118,189 @@ def test_pep440_parse_text_invalid_versions(text: str) -> None: PEP440Version.parse(text) +@pytest.mark.parametrize( + ("version", "major", "minor", "patch", "non_semver_parts", "parts"), + [ + ("1", 1, None, None, (), (1,)), + ("1.2", 1, 2, None, (), (1, 2)), + ("1.2.3", 1, 2, 3, (), (1, 2, 3)), + ("1.2.3.4", 1, 2, 3, (4,), (1, 2, 3, 4)), + ("1.2.3.4.5", 1, 2, 3, (4, 5), (1, 2, 3, 4, 5)), + ("9!1.2.3.4.5a6.post7.dev8", 1, 2, 3, (4, 5), (1, 2, 3, 4, 5)), + ], +) +def test_properties( + version: str, + major: int, + minor: int | None, + patch: int | None, + non_semver_parts: Sequence[int], + parts: Sequence[int], +) -> None: + v = PEP440Version.parse(version) + assert v.major == major + assert v.minor == minor + assert v.patch == patch + assert v.non_semver_parts == non_semver_parts + assert v.parts == parts + + +@pytest.mark.parametrize( + "version, expected", + [ + ("1", False), + ("1.dev0", False), + ("1.a0", True), + ("1.b1", True), + ("1.rc3", True), + ("1.a0.dev0", True), + ("9!1.2.3a1.post2.dev3", True), + ], +) +def test_is_prerelease(version: str, expected: bool) -> None: + v = PEP440Version.parse(version) + assert v.is_prerelease() is expected + + +@pytest.mark.parametrize( + "version, expected", + [ + ("1", False), + ("1.post1", True), + ("9!1.2.3a1.post2.dev3", True), + ], +) +def test_is_postrelease(version: str, expected: bool) -> None: + v = PEP440Version.parse(version) + assert v.is_postrelease() is expected + + +@pytest.mark.parametrize( + "version, expected", + [ + ("1", False), + ("1.dev0", True), + ("1.a0.dev0", True), + ("9!1.2.3a1.post2.dev3", True), + ], +) +def test_is_devrelease(version: str, expected: bool) -> None: + v = PEP440Version.parse(version) + assert v.is_devrelease() is expected + + +@pytest.mark.parametrize( + "version, expected", + [ + ("1", False), + ("1+local", True), + ("1+local.dev0", True), + ("9!1.2.3a1.post2.dev3+local", True), + ], +) +def test_is_local(version: str, expected: bool) -> None: + v = PEP440Version.parse(version) + assert v.is_local() is expected + + +@pytest.mark.parametrize( + "version, expected", + [ + ("1", True), + ("1.2", True), + ("1+local", True), + ("1.dev0", False), + ("1a0", False), + ("1.post0", False), + ], +) +def test_is_no_suffix_release(version: str, expected: bool) -> None: + v = PEP440Version.parse(version) + assert v.is_no_suffix_release() is expected + + +@pytest.mark.parametrize( + "version, expected", + [ + ("1", True), + ("1.2", True), + ("1.2.3", True), + ("2!1.2.3", True), + ("1.2.3+local", True), + ("1.2.3.4", True), + ("1.dev0", False), + ("1.2dev0", False), + ("1.2.3dev0", False), + ("1.2.3.4dev0", False), + ("1.post1", True), + ("1.2.post1", True), + ("1.2.3.post1", True), + ("1.post1.dev0", False), + ("1.2.post1.dev0", False), + ("1.2.3.post1.dev0", False), + ("1.a1", False), + ("1.2a1", False), + ("1.2.3a1", False), + ("1.2.3.4a1", False), + ("1.a1.post2", False), + ("1.2a1.post2", False), + ("1.2.3a1.post2", False), + ("1.2.3.4a1.post2", False), + ("1.a1.post2.dev0", False), + ("1.2a1.post2.dev0", False), + ("1.2.3a1.post2.dev0", False), + ("1.2.3.4a1.post2.dev0", False), + ], +) +def test_is_stable(version: str, expected: bool) -> None: + subject = PEP440Version.parse(version) + + assert subject.is_stable() is expected + assert subject.is_unstable() is not expected + + +@pytest.mark.parametrize( + "version, expected", + [ + ("0", True), + ("0.2", True), + ("0.2.3", True), + ("2!0.2.3", True), + ("0.2.3+local", True), + ("0.2.3.4", True), + ("0.dev0", False), + ("0.2dev0", False), + ("0.2.3dev0", False), + ("0.2.3.4dev0", False), + ("0.post1", True), + ("0.2.post1", True), + ("0.2.3.post1", True), + ("0.post1.dev0", False), + ("0.2.post1.dev0", False), + ("0.2.3.post1.dev0", False), + ("0.a1", False), + ("0.2a1", False), + ("0.2.3a1", False), + ("0.2.3.4a1", False), + ("0.a1.post2", False), + ("0.2a1.post2", False), + ("0.2.3a1.post2", False), + ("0.2.3.4a1.post2", False), + ("0.a1.post2.dev0", False), + ("0.2a1.post2.dev0", False), + ("0.2.3a1.post2.dev0", False), + ("0.2.3.4a1.post2.dev0", False), + ], +) +def test_is_stable_all_major_0_versions_are_treated_as_normal_versions( + version: str, expected: bool +) -> None: + subject = PEP440Version.parse(version) + + assert subject.is_stable() is expected + assert subject.is_unstable() is not expected + + @pytest.mark.parametrize( "version, expected", [ @@ -211,6 +400,24 @@ def test_next_patch(version: str, expected: str) -> None: assert v.next_patch().text == expected +@pytest.mark.parametrize( + ("version", "expected"), + [ + # simple versions (only "release" attribute) are tested in test_segments + # via Release.next() + ("1", "2"), + ("2!1", "2!2"), + ("1+local", "2+local"), + ("1.post4", "2"), + ("1.dev4", "1"), + ("1.a4", "1"), + ], +) +def test_next_stable(version: str, expected: str) -> None: + v = PEP440Version.parse(version) + assert v.next_stable().text == expected + + @pytest.mark.parametrize( "version, expected", [ @@ -229,115 +436,95 @@ def test_next_prerelease(version: str, expected: str) -> None: @pytest.mark.parametrize( "version, expected", [ - ("1", True), - ("1.2", True), - ("1.2.3", True), - ("2!1.2.3", True), - ("1.2.3+local", True), - ("1.2.3.4", True), - ("1.dev0", False), - ("1.2dev0", False), - ("1.2.3dev0", False), - ("1.2.3.4dev0", False), - ("1.post1", True), - ("1.2.post1", True), - ("1.2.3.post1", True), - ("1.post1.dev0", False), - ("1.2.post1.dev0", False), - ("1.2.3.post1.dev0", False), - ("1.a1", False), - ("1.2a1", False), - ("1.2.3a1", False), - ("1.2.3.4a1", False), - ("1.a1.post2", False), - ("1.2a1.post2", False), - ("1.2.3a1.post2", False), - ("1.2.3.4a1.post2", False), - ("1.a1.post2.dev0", False), - ("1.2a1.post2.dev0", False), - ("1.2.3a1.post2.dev0", False), - ("1.2.3.4a1.post2.dev0", False), + ("1", "1.post0"), + ("1.post1", "1.post2"), + ("9!1.2.3.4", "9!1.2.3.4.post0"), + ("9!1.2.3.4.post2", "9!1.2.3.4.post3"), + ("1.dev0", "1.post0"), + ("1.post1.dev0", "1.post1"), + ("1a1", "1a1.post0"), + ("1a1.dev0", "1a1.post0"), + ("1a1.post2", "1a1.post3"), + ("1a1.post2.dev0", "1a1.post2"), ], ) -def test_is_stable(version: str, expected: bool) -> None: - subject = PEP440Version.parse(version) - - assert subject.is_stable() == expected - assert subject.is_unstable() == (not expected) +def test_next_postrelease(version: str, expected: str) -> None: + v = PEP440Version.parse(version) + assert v.next_postrelease().text == expected @pytest.mark.parametrize( "version, expected", [ - ("0", True), - ("0.2", True), - ("0.2.3", True), - ("2!0.2.3", True), - ("0.2.3+local", True), - ("0.2.3.4", True), - ("0.dev0", False), - ("0.2dev0", False), - ("0.2.3dev0", False), - ("0.2.3.4dev0", False), - ("0.post1", True), - ("0.2.post1", True), - ("0.2.3.post1", True), - ("0.post1.dev0", False), - ("0.2.post1.dev0", False), - ("0.2.3.post1.dev0", False), - ("0.a1", False), - ("0.2a1", False), - ("0.2.3a1", False), - ("0.2.3.4a1", False), - ("0.a1.post2", False), - ("0.2a1.post2", False), - ("0.2.3a1.post2", False), - ("0.2.3.4a1.post2", False), - ("0.a1.post2.dev0", False), - ("0.2a1.post2.dev0", False), - ("0.2.3a1.post2.dev0", False), - ("0.2.3.4a1.post2.dev0", False), + ("0.dev0", "0.dev1"), + ("9!1.2.3a1.post2.dev3", "9!1.2.3a1.post2.dev4"), ], ) -def test_is_stable_all_major_0_versions_are_treated_as_normal_versions( - version: str, expected: bool -) -> None: - subject = PEP440Version.parse(version) +def test_next_devrelease(version: str, expected: str) -> None: + v = PEP440Version.parse(version) + assert v.next_devrelease().text == expected - assert subject.is_stable() == expected - assert subject.is_unstable() == (not expected) + +@pytest.mark.parametrize( + "version, expected", + [ + ("1", "1a0"), + ("9!1.2.3a1.post2.dev3", "9!1.2.3a0"), + ], +) +def test_first_prerelease(version: str, expected: str) -> None: + v = PEP440Version.parse(version) + assert v.first_prerelease().text == expected @pytest.mark.parametrize( "version, expected", [ - ("1", "1.post0"), - ("1.post1", "1.post2"), - ("9!1.2.3.4", "9!1.2.3.4.post0"), - ("9!1.2.3.4.post2", "9!1.2.3.4.post3"), - ("1.dev0", "1.post0"), - ("1.post1.dev0", "1.post1"), - ("1a1", "1a1.post0"), - ("1a1.dev0", "1a1.post0"), - ("1a1.post2", "1a1.post3"), - ("1a1.post2.dev0", "1a1.post2"), + ("1", "1.dev0"), + ("1a1", "1a1.dev0"), + ("1.post2", "1.post2.dev0"), + ("9!1.2.3a1.post2.dev3", "9!1.2.3a1.post2.dev0"), ], ) -def test_next_postrelease(version: str, expected: str) -> None: +def test_first_devrelease(version: str, expected: str) -> None: v = PEP440Version.parse(version) - assert v.next_postrelease().text == expected + assert v.first_devrelease().text == expected -def test_next_devrelease() -> None: - v = PEP440Version.parse("9!1.2.3a1.post2.dev3") - assert v.next_devrelease().text == "9!1.2.3a1.post2.dev4" +@pytest.mark.parametrize( + "version, expected", + [ + ("1", "1"), + ("1+local.dev0", "1"), + ("9!1.2.3a1.post2.dev3+local", "9!1.2.3a1.post2.dev3"), + ], +) +def test_without_local(version: str, expected: str) -> None: + v = PEP440Version.parse(version) + assert v.without_local().text == expected -def test_first_prerelease() -> None: - v = PEP440Version.parse("9!1.2.3a1.post2.dev3") - assert v.first_prerelease().text == "9!1.2.3a0" +@pytest.mark.parametrize( + "version, expected", + [ + ("1", "1"), + ("1.dev0", "1.dev0"), + ("9!1.2.3a1.post2.dev3", "9!1.2.3a1"), + ], +) +def test_without_postrelease(version: str, expected: str) -> None: + v = PEP440Version.parse(version) + assert v.without_postrelease().text == expected -def test_first_devrelease() -> None: - v = PEP440Version.parse("9!1.2.3a1.post2.dev3") - assert v.first_devrelease().text == "9!1.2.3a1.post2.dev0" +@pytest.mark.parametrize( + "version, expected", + [ + ("1", "1"), + ("1.dev0", "1"), + ("9!1.2.3a1.post2.dev3", "9!1.2.3a1.post2"), + ], +) +def test_without_devrelease(version: str, expected: str) -> None: + v = PEP440Version.parse(version) + assert v.without_devrelease().text == expected diff --git a/tests/version/test_markers.py b/tests/version/test_markers.py index 31ec3de6a..d5db34513 100644 --- a/tests/version/test_markers.py +++ b/tests/version/test_markers.py @@ -66,19 +66,19 @@ def test_single_marker() -> None: assert isinstance(m, SingleMarker) assert m.name == "python_version" - assert str(m.constraint) == ">=2.7.0,<2.8.0 || >=3.0.0,<3.2.0" + assert str(m.constraint) == ">=2.7,<2.8 || >=3.0,<3.2" m = parse_marker('"2.7" in python_version') assert isinstance(m, SingleMarker) assert m.name == "python_version" - assert str(m.constraint) == ">=2.7.0,<2.8.0" + assert str(m.constraint) == ">=2.7,<2.8" m = parse_marker('python_version not in "2.7, 3.0, 3.1"') assert isinstance(m, SingleMarker) assert m.name == "python_version" - assert str(m.constraint) == "<2.7.0 || >=2.8.0,<3.0.0 || >=3.2.0" + assert str(m.constraint) == "<2.7 || >=2.8,<3.0 || >=3.2" m = parse_marker( "platform_machine in 'x86_64 X86_64 aarch64 AARCH64 ppc64le PPC64LE amd64 AMD64"