From dd96ae7ae7eb2f8ddbaf39fc3b5f0a3141b13281 Mon Sep 17 00:00:00 2001 From: David Hotham Date: Thu, 2 Jun 2022 02:36:14 +0100 Subject: [PATCH] fix and make common python version normalization (#385) Also handle a single digit of precision more carefully at the boundary. --- src/poetry/core/packages/dependency.py | 38 ++------------ src/poetry/core/packages/utils/utils.py | 70 ++++++++++++++++--------- tests/packages/test_main.py | 6 +-- tests/packages/utils/test_utils.py | 4 +- 4 files changed, 56 insertions(+), 62 deletions(-) diff --git a/src/poetry/core/packages/dependency.py b/src/poetry/core/packages/dependency.py index 1767d6a60..5d75cd2e6 100644 --- a/src/poetry/core/packages/dependency.py +++ b/src/poetry/core/packages/dependency.py @@ -16,6 +16,7 @@ from poetry.core.packages.dependency_group import MAIN_GROUP from poetry.core.packages.specification import PackageSpecification from poetry.core.packages.utils.utils import contains_group_without_marker +from poetry.core.packages.utils.utils import normalize_python_version_markers from poetry.core.semver.helpers import parse_constraint from poetry.core.semver.version_range_constraint import VersionRangeConstraint from poetry.core.version.markers import parse_marker @@ -192,39 +193,10 @@ def marker(self, marker: str | BaseMarker) -> None: # Recalculate python versions. self._python_versions = "*" if not contains_group_without_marker(markers, "python_version"): - ors = [] - for or_ in markers["python_version"]: - ands = [] - for op, version in or_: - # Expand python version - if op == "==" and "*" not in version: - version = "~" + version - op = "" - elif op == "!=": - version += ".*" - elif op in ("in", "not in"): - versions = [] - for v in re.split("[ ,]+", version): - split = v.split(".") - if len(split) in [1, 2]: - split.append("*") - op_ = "" if op == "in" else "!=" - else: - op_ = "==" if op == "in" else "!=" - - versions.append(op_ + ".".join(split)) - - glue = " || " if op == "in" else ", " - if versions: - ands.append(glue.join(versions)) - - continue - - ands.append(f"{op}{version}") - - ors.append(" ".join(ands)) - - self._python_versions = " || ".join(ors) + python_version_markers = markers["python_version"] + self._python_versions = normalize_python_version_markers( + python_version_markers + ) self._python_constraint = parse_constraint(self._python_versions) diff --git a/src/poetry/core/packages/utils/utils.py b/src/poetry/core/packages/utils/utils.py index 701cc1b5f..5aed5b0c9 100644 --- a/src/poetry/core/packages/utils/utils.py +++ b/src/poetry/core/packages/utils/utils.py @@ -15,13 +15,14 @@ from urllib.request import url2pathname from poetry.core.pyproject.toml import PyProjectTOML +from poetry.core.semver.helpers import parse_constraint +from poetry.core.semver.version import Version from poetry.core.semver.version_range import VersionRange from poetry.core.version.markers import dnf if TYPE_CHECKING: from poetry.core.packages.constraints import BaseConstraint - from poetry.core.semver.version import Version from poetry.core.semver.version_constraint import VersionConstraint from poetry.core.semver.version_union import VersionUnion from poetry.core.version.markers import BaseMarker @@ -206,7 +207,6 @@ def create_nested_marker( from poetry.core.packages.constraints.constraint import Constraint from poetry.core.packages.constraints.multi_constraint import MultiConstraint from poetry.core.packages.constraints.union_constraint import UnionConstraint - from poetry.core.semver.version import Version from poetry.core.semver.version_union import VersionUnion if constraint.is_any(): @@ -286,8 +286,6 @@ def get_python_constraint_from_marker( marker: BaseMarker, ) -> VersionConstraint: from poetry.core.semver.empty_constraint import EmptyConstraint - from poetry.core.semver.helpers import parse_constraint - from poetry.core.semver.version import Version from poetry.core.semver.version_range import VersionRange python_marker = marker.only("python_version", "python_full_version") @@ -304,34 +302,58 @@ def get_python_constraint_from_marker( # which means that python_version is arbitrary for this group return VersionRange() + python_version_markers = markers["python_version"] + normalized = normalize_python_version_markers(python_version_markers) + constraint = parse_constraint(normalized) + return constraint + + +def normalize_python_version_markers( # NOSONAR + disjunction: list[list[tuple[str, str]]], +) -> str: ors = [] - for or_ in markers["python_version"]: + for or_ in disjunction: ands = [] for op, version in or_: # Expand python version - if op == "==": - if "*" not in version: - version = "~" + version - op = "" - elif op == "!=": - if "*" not in version: - version += ".*" + if op == "==" and "*" not in version: + version = "~" + version + op = "" + + elif op == "!=" and "*" not in version: + version += ".*" + elif op in ("<=", ">"): + # Make adjustments on encountering versions with less than full + # precision. + # + # Per PEP-508: + # python_version <-> '.'.join(platform.python_version_tuple()[:2]) + # + # So for two digits of precision we make the following adjustments: + # - `python_version > "x.y"` requires version >= x.(y+1).anything + # - `python_version <= "x.y"` requires version < x.(y+1).anything + # + # Treatment when we see a single digit of precision is less clear: is + # that even a legitimate marker? + # + # Experiment suggests that pip behaviour is essentially to make a + # lexicographical comparison, for example `python_version > "3"` is + # satisfied by version 3.anything, whereas `python_version <= "3"` is + # satisfied only by version 2.anything. + # + # We achieve the above by fiddling with the operator and version in the + # marker. parsed_version = Version.parse(version) - if parsed_version.precision == 1: + if parsed_version.precision < 3: if op == "<=": op = "<" - version = parsed_version.next_major().text elif op == ">": op = ">=" - version = parsed_version.next_major().text - elif parsed_version.precision == 2: - if op == "<=": - op = "<" - version = parsed_version.next_minor().text - elif op == ">": - op = ">=" - version = parsed_version.next_minor().text + + if parsed_version.precision == 2: + version = parsed_version.next_minor().text + elif op in ("in", "not in"): versions = [] for v in re.split("[ ,]+", version): @@ -344,8 +366,8 @@ def get_python_constraint_from_marker( versions.append(op_ + ".".join(split)) - glue = " || " if op == "in" else ", " if versions: + glue = " || " if op == "in" else ", " ands.append(glue.join(versions)) continue @@ -354,4 +376,4 @@ def get_python_constraint_from_marker( ors.append(" ".join(ands)) - return parse_constraint(" || ".join(ors)) + return " || ".join(ors) diff --git a/tests/packages/test_main.py b/tests/packages/test_main.py index d6693dd93..e7e599f69 100644 --- a/tests/packages/test_main.py +++ b/tests/packages/test_main.py @@ -294,10 +294,10 @@ def test_dependency_from_pep_508_should_not_produce_empty_constraints_for_correc assert dep.name == "pytest-mypy" assert str(dep.constraint) == "*" - assert dep.python_versions == "<=3.10 >3" + assert dep.python_versions == "<3.11 >=3" assert dep.python_constraint.allows(Version.parse("3.6")) - assert dep.python_constraint.allows(Version.parse("3.10")) - assert not dep.python_constraint.allows(Version.parse("3")) + assert dep.python_constraint.allows(Version.parse("3.10.4")) + assert dep.python_constraint.allows(Version.parse("3")) assert dep.python_constraint.allows(Version.parse("3.0.1")) assert ( str(dep.marker) diff --git a/tests/packages/utils/test_utils.py b/tests/packages/utils/test_utils.py index 455f8dde7..e4517b082 100644 --- a/tests/packages/utils/test_utils.py +++ b/tests/packages/utils/test_utils.py @@ -96,8 +96,8 @@ def test_convert_markers( ('python_version != "3.6.* "', "!=3.6.*"), # <, <=, >, >= precision 1 ('python_version < "3"', "<3"), - ('python_version <= "3"', "<4"), - ('python_version > "3"', ">=4"), + ('python_version <= "3"', "<3"), + ('python_version > "3"', ">=3"), ('python_version >= "3"', ">=3"), # <, <=, >, >= precision 2 ('python_version < "3.6"', "<3.6"),