Skip to content

Commit

Permalink
yarn: Universal utility for Yarn version detection
Browse files Browse the repository at this point in the history
This commit unifies handling of version info for Yarn and Yarn Classic
package managers.

Resolves: #660

Signed-off-by: Alexey Ovchinnikov <[email protected]>
  • Loading branch information
a-ovchinnikov committed Oct 22, 2024
1 parent c94a1a2 commit 6101de6
Show file tree
Hide file tree
Showing 5 changed files with 98 additions and 43 deletions.
33 changes: 16 additions & 17 deletions cachi2/core/package_managers/yarn/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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=(
Expand All @@ -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)",
Expand Down Expand Up @@ -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)
69 changes: 68 additions & 1 deletion cachi2/core/package_managers/yarn/utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
29 changes: 9 additions & 20 deletions cachi2/core/package_managers/yarn_classic/main.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand Down Expand Up @@ -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)
4 changes: 2 additions & 2 deletions tests/unit/package_managers/yarn/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions tests/unit/package_managers/yarn_classic/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down

0 comments on commit 6101de6

Please sign in to comment.