From 6101de6cd32a143571ec10b2a95bf74183760c6d Mon Sep 17 00:00:00 2001 From: Alexey Ovchinnikov Date: Mon, 21 Oct 2024 09:58:32 -0500 Subject: [PATCH] yarn: Universal utility for Yarn version detection This commit unifies handling of version info for Yarn and Yarn Classic package managers. Resolves: https://github.com/containerbuildsystem/cachi2/issues/660 Signed-off-by: Alexey Ovchinnikov --- cachi2/core/package_managers/yarn/main.py | 33 +++++---- cachi2/core/package_managers/yarn/utils.py | 69 ++++++++++++++++++- .../package_managers/yarn_classic/main.py | 29 +++----- tests/unit/package_managers/yarn/test_main.py | 4 +- .../yarn_classic/test_main.py | 6 +- 5 files changed, 98 insertions(+), 43 deletions(-) diff --git a/cachi2/core/package_managers/yarn/main.py b/cachi2/core/package_managers/yarn/main.py index 664d49d1c..0c4495a7c 100644 --- a/cachi2/core/package_managers/yarn/main.py +++ b/cachi2/core/package_managers/yarn/main.py @@ -13,7 +13,11 @@ get_semver_from_yarn_path, ) from cachi2.core.package_managers.yarn.resolver import create_components, resolve_packages -from cachi2.core.package_managers.yarn.utils import run_yarn_cmd +from cachi2.core.package_managers.yarn.utils import ( + Versions_range, + extract_yarn_version_from_env, + run_yarn_cmd, +) from cachi2.core.rooted_path import RootedPath log = logging.getLogger(__name__) @@ -118,7 +122,7 @@ def _configure_yarn_version(project: Project) -> None: yarn_path_version = get_semver_from_yarn_path(project.yarn_rc.yarn_path) package_manager_version = get_semver_from_package_manager(project.package_json.package_manager) - if not yarn_path_version and not package_manager_version: + if yarn_path_version is None and package_manager_version is None: raise PackageRejected( "Unable to determine the yarn version to use to process the request", solution=( @@ -127,9 +131,9 @@ def _configure_yarn_version(project: Project) -> None: ), ) - # Note (mypy): version cannot be None anymore after the next statement version = yarn_path_version if yarn_path_version else package_manager_version - if version.compare("3.0.0") < 0 or version.major == 4: # type: ignore + # By this point version is not Optional anymore, but mypy does not think so. + if version not in Versions_range("3.0.0", "4.0.0"): # type: ignore raise PackageRejected( f"Unsupported Yarn version '{version}' detected", solution="Please pick a different version of Yarn (3.0.0<= Yarn version <4.0.0)", @@ -228,19 +232,14 @@ def _generate_environment_variables() -> list[EnvironmentVariable]: def _verify_corepack_yarn_version(expected_version: semver.Version, source_dir: RootedPath) -> None: """Verify that corepack installed the correct version of yarn by checking `yarn --version`.""" - installed_yarn_version = run_yarn_cmd( - ["--version"], source_dir, env={"COREPACK_ENABLE_DOWNLOAD_PROMPT": "0"} - ).strip() - try: - if installed_yarn_version != expected_version: - raise PackageManagerError( - f"Cachi2 expected corepack to install yarn@{expected_version} but instead " - f"found yarn@{installed_yarn_version}." - ) - except ValueError as exc: + installed_yarn_version = extract_yarn_version_from_env( + source_dir, + env={"COREPACK_ENABLE_DOWNLOAD_PROMPT": "0"}, + ) + if installed_yarn_version != expected_version: raise PackageManagerError( - f"Cachi2 expected corepack to install yarn@{expected_version}, but " - "the command `yarn --version` did not return a valid semver." - ) from exc + f"Cachi2 expected corepack to install yarn@{expected_version} but instead " + f"found yarn@{installed_yarn_version}." + ) log.info("Processing the request using yarn@%s", installed_yarn_version) diff --git a/cachi2/core/package_managers/yarn/utils.py b/cachi2/core/package_managers/yarn/utils.py index 6fe110399..55fa88f7f 100644 --- a/cachi2/core/package_managers/yarn/utils.py +++ b/cachi2/core/package_managers/yarn/utils.py @@ -1,6 +1,8 @@ import os import subprocess -from typing import Optional +from typing import Optional, Union + +from semver import Version from cachi2.core.errors import PackageManagerError from cachi2.core.rooted_path import RootedPath @@ -27,3 +29,68 @@ def run_yarn_cmd( except subprocess.CalledProcessError as e: # the yarn command writes the errors to stdout raise PackageManagerError(f"Yarn command failed: {' '.join(cmd)}", stderr=e.stdout) + + +VersionLike = Union[Version, str] + + +class Versions_range: + """Represents a version range for cleaner version constrains checks. + + Versions range is a right-open interval: + >>> Version.parse("1.2.3") in Versions_range("3.0.0", "4.0.0") + False + >>> Version.parse("1.2.3") in Versions_range("1.0.0", "2.0.0") + True + >>> Version.parse("1.0.0") in Versions_range("1.0.0", "2.0.0") + True + >>> Version.parse("2.0.0") in Versions_range("1.0.0", "2.0.0") + False + + Release candidates are a special case, they are ignored within the + interval and cause immediate rejection on any of the boundaries: + >>> Version.parse("2.0.0-rc1") in Versions_range("1.0.0", "2.0.0") + False + >>> Version.parse("1.0.0-rc1") in Versions_range("1.0.0", "2.0.0") + False + >>> Version.parse("1.5.0-rc1") in Versions_range("1.0.0", "2.0.0") + True + """ + + def __init__(self, min_ver: VersionLike, max_ver: VersionLike) -> None: + """Initialize a version range.""" + self.min_ver = min_ver if isinstance(min_ver, Version) else Version.parse(min_ver) + self.max_ver = max_ver if isinstance(max_ver, Version) else Version.parse(max_ver) + + def __contains__(self, other: Version) -> bool: + if not isinstance(other, self.min_ver.__class__): + return False + # The original version check logic (as captured in UTs) broke with direct + # version comparison rules, e.g. 4.0.0.rc1 would have been considered + # version 4 and would have been rejected basing on major version + # only. The original implementation considered anything starting with + # a different major version an outlier. The logic below captures this + # with a special case for versions with prerelease field set. + if other.prerelease: + # Drop prerelease: + other_prime = Version(other.major, other.minor, other.patch) + # Treat boundaries separately: + if other_prime == self.min_ver or other_prime == self.max_ver: + return False + # Continue as usual otherwise: + return other_prime >= self.min_ver and other < self.max_ver + else: + return other >= self.min_ver and other < self.max_ver + + +def extract_yarn_version_from_env(source_dir: RootedPath, env: dict) -> Version: + """Extract yarn version from environment.""" + yarn_version_output = run_yarn_cmd(["--version"], source_dir, env=env).strip() + + try: + installed_yarn_version = Version.parse(yarn_version_output) + except ValueError as e: + raise PackageManagerError( + "The command `yarn --version` did not return a valid semver." + ) from e + return installed_yarn_version diff --git a/cachi2/core/package_managers/yarn_classic/main.py b/cachi2/core/package_managers/yarn_classic/main.py index da578ce8c..69aa0fcae 100644 --- a/cachi2/core/package_managers/yarn_classic/main.py +++ b/cachi2/core/package_managers/yarn_classic/main.py @@ -1,11 +1,13 @@ import logging -import semver - from cachi2.core.errors import PackageManagerError from cachi2.core.models.input import Request from cachi2.core.models.output import Component, EnvironmentVariable, RequestOutput -from cachi2.core.package_managers.yarn.utils import run_yarn_cmd +from cachi2.core.package_managers.yarn.utils import ( + Versions_range, + extract_yarn_version_from_env, + run_yarn_cmd, +) from cachi2.core.rooted_path import RootedPath log = logging.getLogger(__name__) @@ -85,25 +87,12 @@ def _generate_build_environment_variables() -> list[EnvironmentVariable]: def _verify_corepack_yarn_version(source_dir: RootedPath, env: dict[str, str]) -> None: """Verify that corepack installed the correct version of yarn by checking `yarn --version`.""" - yarn_version_output = run_yarn_cmd(["--version"], source_dir, env=env).strip() - - try: - installed_yarn_version = semver.version.Version.parse(yarn_version_output) - except ValueError as e: - raise PackageManagerError( - "The command `yarn --version` did not return a valid semver." - ) from e - - min_version_inclusive = semver.version.Version(1, 22, 0) - max_version_exclusive = semver.version.Version(2, 0, 0) + installed_yarn_version = extract_yarn_version_from_env(source_dir, env) - if ( - installed_yarn_version < min_version_inclusive - or installed_yarn_version >= max_version_exclusive - ): + if installed_yarn_version not in Versions_range("1.22.0", "2.0.0"): raise PackageManagerError( "Cachi2 expected corepack to install yarn >=1.22.0,<2.0.0, but instead " - f"found yarn@{yarn_version_output}." + f"found yarn@{installed_yarn_version}." ) - log.info("Processing the request using yarn@%s", yarn_version_output) + log.info("Processing the request using yarn@%s", installed_yarn_version) diff --git a/tests/unit/package_managers/yarn/test_main.py b/tests/unit/package_managers/yarn/test_main.py index 92ef67aa0..c3d2e6dd9 100644 --- a/tests/unit/package_managers/yarn/test_main.py +++ b/tests/unit/package_managers/yarn/test_main.py @@ -122,7 +122,7 @@ def test_configure_yarn_version( pytest.param("1.0.0\n", id="yarn_versions_match_with_whitespace"), ], ) -@mock.patch("cachi2.core.package_managers.yarn.main.run_yarn_cmd") +@mock.patch("cachi2.core.package_managers.yarn.utils.run_yarn_cmd") def test_corepack_installed_correct_yarn_version( mock_run_yarn_cmd: mock.Mock, corepack_yarn_version: str, @@ -144,7 +144,7 @@ def test_corepack_installed_correct_yarn_version( pytest.param("2", id="invalid_semver"), ], ) -@mock.patch("cachi2.core.package_managers.yarn.main.run_yarn_cmd") +@mock.patch("cachi2.core.package_managers.yarn.utils.run_yarn_cmd") def test_corepack_installed_correct_yarn_version_fail( mock_run_yarn_cmd: mock.Mock, corepack_yarn_version: str, diff --git a/tests/unit/package_managers/yarn_classic/test_main.py b/tests/unit/package_managers/yarn_classic/test_main.py index fb2ff6918..c7d47fae9 100644 --- a/tests/unit/package_managers/yarn_classic/test_main.py +++ b/tests/unit/package_managers/yarn_classic/test_main.py @@ -128,7 +128,7 @@ def test_get_prefetch_environment_variables(tmp_path: Path) -> None: pytest.param("1.22.0\n", id="valid_version_with_whitespace"), ], ) -@mock.patch("cachi2.core.package_managers.yarn_classic.main.run_yarn_cmd") +@mock.patch("cachi2.core.package_managers.yarn.utils.run_yarn_cmd") def test_verify_corepack_yarn_version( mock_run_yarn_cmd: mock.Mock, yarn_version_output: str, tmp_path: Path ) -> None: @@ -147,7 +147,7 @@ def test_verify_corepack_yarn_version( pytest.param("2.0.0", id="version_too_high"), ], ) -@mock.patch("cachi2.core.package_managers.yarn_classic.main.run_yarn_cmd") +@mock.patch("cachi2.core.package_managers.yarn.utils.run_yarn_cmd") def test_verify_corepack_yarn_version_disallowed_version( mock_run_yarn_cmd: mock.Mock, yarn_version_output: str, tmp_path: Path ) -> None: @@ -161,7 +161,7 @@ def test_verify_corepack_yarn_version_disallowed_version( _verify_corepack_yarn_version(RootedPath(tmp_path), env={"foo": "bar"}) -@mock.patch("cachi2.core.package_managers.yarn_classic.main.run_yarn_cmd") +@mock.patch("cachi2.core.package_managers.yarn.utils.run_yarn_cmd") def test_verify_corepack_yarn_version_invalid_version( mock_run_yarn_cmd: mock.Mock, tmp_path: Path ) -> None: