From 43d5d27edbaf6c3e144f5d93ac6131bd5cc52d6e Mon Sep 17 00:00:00 2001 From: Arun Babu Neelicattu Date: Mon, 2 May 2022 15:58:10 -0500 Subject: [PATCH] semver/pep508: handle wildcard exclusions Prior to this change, when exporting PEP 508 strings for dependencies, wildcard exclusion constraints like `!=2.0.*` were incorrectly serialised as invalid PEP 508 due to how version unions were serialsed. This change allows for determining if a version union is a single wildcard range exclusion, and if so serialise it appropriately. Resolves: python-poetry/poetry#5470 --- src/poetry/core/packages/dependency.py | 5 +- src/poetry/core/semver/version_union.py | 61 ++++++++++++++++++- .../my_package/__init__.py | 0 .../pyproject.toml | 11 ++++ tests/masonry/builders/test_builder.py | 12 ++++ tests/packages/test_dependency.py | 11 ++++ 6 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 tests/masonry/builders/fixtures/with_wildcard_dependency_constraint/my_package/__init__.py create mode 100644 tests/masonry/builders/fixtures/with_wildcard_dependency_constraint/pyproject.toml diff --git a/src/poetry/core/packages/dependency.py b/src/poetry/core/packages/dependency.py index 1beac6cdc..32e485370 100644 --- a/src/poetry/core/packages/dependency.py +++ b/src/poetry/core/packages/dependency.py @@ -260,7 +260,10 @@ def base_pep_508_name(self) -> str: constraint = self.constraint if isinstance(constraint, VersionUnion): - if constraint.excludes_single_version(): + if ( + constraint.excludes_single_version() + or constraint.excludes_single_wildcard_range() + ): requirement += f" ({str(constraint)})" else: constraints = self.pretty_constraint.split(",") diff --git a/src/poetry/core/semver/version_union.py b/src/poetry/core/semver/version_union.py index 8e54742c2..9eb84d3f1 100644 --- a/src/poetry/core/semver/version_union.py +++ b/src/poetry/core/semver/version_union.py @@ -238,6 +238,62 @@ def _ranges_for( raise ValueError(f"Unknown VersionConstraint type {constraint}") + def _exclude_single_wildcard_range_string(self) -> str: + """ + Helper method to convert + """ + assert self.excludes_single_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.release + two = self._ranges[idx_order[1]].min.release + + # versions can have both semver and non semver parts + parts_one = [one.major, one.minor, one.patch, *list(one.extra or [])] + parts_two = [two.major, two.minor, two.patch, *list(two.extra 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)}" + + def excludes_single_wildcard_range(self) -> bool: + from poetry.core.semver.version_range import VersionRange + + if len(self._ranges) != 2: + return False + + idx_order = (0, 1) if self._ranges[0].max else (1, 0) + 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 + ) + return ( + is_range_exclusion + and one.max.release.precision == two.min.release.precision + and isinstance(VersionRange().difference(self), VersionRange) + ) + def excludes_single_version(self) -> bool: from poetry.core.semver.version import Version from poetry.core.semver.version_range import VersionRange @@ -264,7 +320,10 @@ def __str__(self) -> str: if self.excludes_single_version(): return f"!={VersionRange().difference(self)}" - return " || ".join([str(r) for r in self._ranges]) + try: + return self._exclude_single_wildcard_range_string() + except (AssertionError, ValueError): + return " || ".join([str(r) for r in self._ranges]) def __repr__(self) -> str: return f"" diff --git a/tests/masonry/builders/fixtures/with_wildcard_dependency_constraint/my_package/__init__.py b/tests/masonry/builders/fixtures/with_wildcard_dependency_constraint/my_package/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/masonry/builders/fixtures/with_wildcard_dependency_constraint/pyproject.toml b/tests/masonry/builders/fixtures/with_wildcard_dependency_constraint/pyproject.toml new file mode 100644 index 000000000..662684076 --- /dev/null +++ b/tests/masonry/builders/fixtures/with_wildcard_dependency_constraint/pyproject.toml @@ -0,0 +1,11 @@ +[tool.poetry] +name = "my-package" +version = "1.2.3" +description = "Some description." +authors = [ + "People Everywhere " +] + +[tool.poetry.dependencies] +python = "^3.10" +google-api-python-client = ">=1.8,!=2.0.*" diff --git a/tests/masonry/builders/test_builder.py b/tests/masonry/builders/test_builder.py index 22a42f63f..6e3db706d 100644 --- a/tests/masonry/builders/test_builder.py +++ b/tests/masonry/builders/test_builder.py @@ -285,3 +285,15 @@ def test_metadata_with_readme_files() -> None: description = "\n".join([readme1.read_text(), readme2.read_text(), ""]) assert metadata.get_payload() == description + + +def test_metadata_with_wildcard_dependency_constraint() -> None: + test_path = ( + Path(__file__).parent / "fixtures" / "with_wildcard_dependency_constraint" + ) + builder = Builder(Factory().create_poetry(test_path)) + + metadata = Parser().parsestr(builder.get_metadata_content()) + + requires = metadata.get_all("Requires-Dist") + assert requires == ["google-api-python-client (>=1.8,!=2.0.*)"] diff --git a/tests/packages/test_dependency.py b/tests/packages/test_dependency.py index 90cb1ff24..492be2bcd 100644 --- a/tests/packages/test_dependency.py +++ b/tests/packages/test_dependency.py @@ -245,6 +245,17 @@ def test_complete_name() -> None: ["x"], "A[x] (>=1.6.5,!=1.8.0,<3.1.0)", ), + # test single version range exclusions + ("A", ">=1.8,!=2.0.*", None, "A (>=1.8,!=2.0.*)"), + ("A", ">=1.8,!=2.*", None, "A (>=1.8,!=2.*)"), + ("A", ">=1.8,!=2.*.*", None, "A (>=1.8,!=2.*)"), + # we verify that the range exclusion logic is not too eager + ("A", ">=1.8,<2.0 || >=2.1.0", None, "A (>=1.8,<2.0 || >=2.1.0)"), + ("A", ">=1.8,!=2.0.*,!=3.0.*", None, "A (>=1.8,!=2.0.*,!=3.0.*)"), + # 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: E800 ], ) def test_dependency_string_representation(