Skip to content

Commit

Permalink
ensure packageManager is correctly set for yarn
Browse files Browse the repository at this point in the history
STONEBLD-1776

Since we will rely on corepack to download and configure
the correct version of yarn for a request, ensure that
packageManager is defined in package.json.

Use either yarnPath from .yarnrc, packageManager, or a
combination of the two to set the correct yarn version.

Raise exceptions for the following cases:
  - packageManager is already set, but we can't parse it
  - neither yarnPath or packageManager is set (or parseable)
  - the versions specified by yarnPath and packageManager
    are different

Signed-off-by: Taylor Madore <[email protected]>
  • Loading branch information
taylormadore committed Oct 12, 2023
1 parent c53b7b2 commit b74ff6c
Show file tree
Hide file tree
Showing 4 changed files with 342 additions and 4 deletions.
44 changes: 40 additions & 4 deletions cachi2/core/package_managers/yarn/main.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import logging

from cachi2.core.errors import PackageRejected
from cachi2.core.models.input import Request
from cachi2.core.models.output import Component, EnvironmentVariable, RequestOutput
from cachi2.core.package_managers.yarn.project import Project
from cachi2.core.package_managers.yarn.project import (
Project,
get_semver_from_package_manager,
get_semver_from_yarn_path,
)
from cachi2.core.package_managers.yarn.resolver import (
create_component_from_package,
resolve_packages,
Expand Down Expand Up @@ -51,10 +56,41 @@ def _resolve_yarn_project(project: Project, output_dir: RootedPath) -> list[Comp
def _configure_yarn_version(project: Project) -> None:
"""Resolve the yarn version and set it in the package.json file if needed.
:raises PackageRejected: in case the yarn version can't be determined, or if there is a
mismatch between the version in package.json and yarnrc.yml.
:raises PackageRejected:
if the yarn version can't be determined from either yarnPath or packageManager
if there is a mismatch between the yarn version specified by yarnPath and PackageManager
"""
pass
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:
raise PackageRejected(
"Unable to determine the yarn version to use to process the request",
solution=(
"Ensure that either yarnPath is defined in .yarnrc.yml or that packageManager "
"is defined in package.json"
),
)

if (
yarn_path_version
and package_manager_version
and yarn_path_version != package_manager_version
):
raise PackageRejected(
(
f"Mismatch between the yarn versions specified by yarnPath (yarn@{yarn_path_version}) "
f"and packageManager (yarn@{package_manager_version})"
),
solution=(
"Ensure that the versions of yarn specified by yarnPath in .yarnrc.yml and "
"packageManager in package.json agree"
),
)

if not package_manager_version:
project.package_json.package_manager = f"yarn@{yarn_path_version}"
project.package_json.write_to_file()


def _set_yarnrc_configuration(project: Project, output_dir: RootedPath) -> None:
Expand Down
86 changes: 86 additions & 0 deletions cachi2/core/package_managers/yarn/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,19 @@
It also provides basic utility functions. The main logic to resolve and prefetch the dependencies
should be implemented in other modules.
"""
import json
import logging
import re
from pathlib import Path
from typing import Any, NamedTuple, Optional

import semver

from cachi2.core.errors import UnexpectedFormat
from cachi2.core.rooted_path import RootedPath

log = logging.getLogger(__name__)


class YarnRc:
"""A yarnrc file.
Expand Down Expand Up @@ -91,11 +100,22 @@ def package_manager(self) -> Optional[str]:
"""Get the package manager string."""
return NotImplemented

@package_manager.setter
def package_manager(self, package_manager: str) -> None:
"""Set the package manager string."""
self._data["packageManager"] = package_manager

@classmethod
def from_file(cls, file_path: RootedPath) -> "PackageJson":
"""Parse the content of a package.json file."""
return NotImplemented

def write_to_file(self) -> None:
"""Write the data to the package.json file."""
with self._path.path.open("w") as f:
json.dump(self._data, f, indent=2)
f.write("\n")


class Project(NamedTuple):
"""A directory containing yarn sources."""
Expand Down Expand Up @@ -126,3 +146,69 @@ def yarn_cache(self) -> RootedPath:
def from_source_dir(cls, source_dir: RootedPath) -> "Project":
"""Create a Project from a sources directory path."""
return cls(source_dir, NotImplemented, NotImplemented)


def get_semver_from_yarn_path(yarn_path: Optional[str]) -> Optional[semver.version.Version]:
"""Parse yarnPath from yarnrc and return a semver Version if possible else None."""
if not yarn_path:
return None

# https://github.com/yarnpkg/berry/blob/2dc59443e541098bc0104d97b5fc452781c64baf/packages/plugin-essentials/sources/commands/set/version.ts#L208
yarn_spec_pattern = re.compile(r"^yarn-(.+)\.cjs$")
match = yarn_spec_pattern.match(Path(yarn_path).name)
if not match:
log.warning(
(
"The yarn version specified by yarnPath in .yarnrc.yml (%s) does not match the "
"expected format yarn-<semver>.cjs. Attempting to use the version specified by "
"packageManager in package.json."
),
yarn_path,
)
return None

yarn_version = match.group(1)
try:
return semver.version.Version.parse(yarn_version)
except ValueError:
log.warning(
(
"The yarn version specified by yarnPath in .yarnrc.yml (%s) is not a valid semver. "
"Attempting to use the version specified by packageManager in package.json."
),
yarn_path,
)
return None


def get_semver_from_package_manager(
package_manager: Optional[str],
) -> Optional[semver.version.Version]:
"""Parse packageManager from package.json and return a semver Version if possible.
:raises UnexpectedFormat:
if packageManager doesn't match the name@semver format
if packageManager does not specify yarn
if packageManager version is not a valid semver
"""
if not package_manager:
return None

# https://github.com/nodejs/corepack/blob/787e24df609513702eafcd8c6a5f03544d7d45cc/sources/specUtils.ts#L10
package_manager_spec_pattern = re.compile(r"^(?!_)(.+)@(.+)$")
match = package_manager_spec_pattern.match(package_manager)
if not match:
raise UnexpectedFormat(
"could not parse packageManager spec in package.json (expected name@semver)"
)

name, version = match.groups()
if name != "yarn":
raise UnexpectedFormat("packageManager in package.json must be yarn")

try:
return semver.version.Version.parse(version)
except ValueError as e:
raise UnexpectedFormat(
f"{version} is not a valid semver for packageManager in package.json"
) from e
93 changes: 93 additions & 0 deletions tests/unit/package_managers/yarn/test_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import re
from typing import Optional, Union
from unittest import mock

import pytest
import semver

from cachi2.core.errors import PackageRejected, UnexpectedFormat
from cachi2.core.package_managers.yarn.main import _configure_yarn_version


@pytest.mark.parametrize(
"yarn_path_version, package_manager_version",
[
pytest.param(semver.VersionInfo(1, 0, 0), None, id="valid-yarnpath-no-packagemanager"),
pytest.param(None, semver.VersionInfo(1, 0, 0), id="no-yarnpath-valid-packagemanager"),
pytest.param(
semver.VersionInfo(1, 0, 0),
semver.VersionInfo(1, 0, 0),
id="matching-yarnpath-and-packagemanager",
),
pytest.param(
semver.VersionInfo(1, 0, 0),
semver.VersionInfo(
1, 0, 0, build="sha224.953c8233f7a92884eee2de69a1b92d1f2ec1655e66d08071ba9a02fa"
),
id="matching-yarnpath-and-packagemanager-with-build",
),
],
)
@mock.patch("cachi2.core.package_managers.yarn.main.get_semver_from_package_manager")
@mock.patch("cachi2.core.package_managers.yarn.main.get_semver_from_yarn_path")
def test_configure_yarn_version(
mock_yarn_path_semver: mock.Mock,
mock_package_manager_semver: mock.Mock,
yarn_path_version: Optional[semver.version.Version],
package_manager_version: Optional[semver.version.Version],
) -> None:
mock_project = mock.Mock()
mock_yarn_path_semver.return_value = yarn_path_version
mock_package_manager_semver.return_value = package_manager_version

_configure_yarn_version(mock_project)

if package_manager_version is None:
assert mock_project.package_json.package_manager == f"yarn@{yarn_path_version}"
mock_project.package_json.write_to_file.assert_called_once()


@pytest.mark.parametrize(
"yarn_path_version, package_manager_version, expected_error",
[
pytest.param(
None,
None,
PackageRejected(
"Unable to determine the yarn version to use to process the request",
solution="Ensure that either yarnPath is defined in .yarnrc or that packageManager is defined in package.json",
),
id="no-yarnpath-no-packagemanager",
),
pytest.param(
None,
UnexpectedFormat("some error about packageManager formatting"),
UnexpectedFormat("some error about packageManager formatting"),
id="exception-parsing-packagemanager",
),
pytest.param(
semver.VersionInfo(1, 0, 1),
semver.VersionInfo(1, 0, 0),
PackageRejected(
"Mismatch between the yarn versions specified by yarnPath ([email protected]) and packageManager ([email protected])",
solution="Ensure that the yarnPath version in .yarnrc and the packageManager version in package.json agree",
),
id="yarnpath-packagemanager-mismatch",
),
],
)
@mock.patch("cachi2.core.package_managers.yarn.main.get_semver_from_package_manager")
@mock.patch("cachi2.core.package_managers.yarn.main.get_semver_from_yarn_path")
def test_configure_yarn_version_fail(
mock_yarn_path_semver: mock.Mock,
mock_package_manager_semver: mock.Mock,
yarn_path_version: Optional[semver.version.Version],
package_manager_version: Union[semver.version.Version, None, Exception],
expected_error: Exception,
) -> None:
mock_project = mock.Mock()
mock_yarn_path_semver.return_value = yarn_path_version
mock_package_manager_semver.side_effect = [package_manager_version]

with pytest.raises(type(expected_error), match=re.escape(str(expected_error))):
_configure_yarn_version(mock_project)
123 changes: 123 additions & 0 deletions tests/unit/package_managers/yarn/test_project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import re
from typing import Optional

import pytest
import semver

from cachi2.core.errors import UnexpectedFormat
from cachi2.core.package_managers.yarn.project import (
get_semver_from_package_manager,
get_semver_from_yarn_path,
)


@pytest.mark.parametrize(
"yarn_path, expected_result",
[
(
None,
None,
),
(
"",
None,
),
(
"/some/path/yarn-1.0.cjs",
None,
),
(
"/some/path/yarn-1.0.0.cjs",
semver.VersionInfo(1, 0, 0),
),
(
"/some/path/yarn-1.0.0-rc.cjs",
semver.VersionInfo(1, 0, 0, prerelease="rc"),
),
(
"/some/path/yarn.cjs",
None,
),
],
)
def test_get_semver_from_yarn_path(
yarn_path: str, expected_result: Optional[semver.version.Version]
) -> None:
yarn_semver = get_semver_from_yarn_path(yarn_path)

if yarn_semver is None:
assert expected_result is None
else:
assert expected_result is not None
assert yarn_semver == expected_result


@pytest.mark.parametrize(
"package_manager, expected_result",
[
(
None,
None,
),
(
"",
None,
),
(
"[email protected]",
semver.VersionInfo(1, 0, 0),
),
(
"[email protected]",
semver.VersionInfo(1, 0, 0, prerelease="rc"),
),
(
"[email protected]+sha224.953c8233f7a92884eee2de69a1b92d1f2ec1655e66d08071ba9a02fa",
semver.VersionInfo(
1, 0, 0, build="sha224.953c8233f7a92884eee2de69a1b92d1f2ec1655e66d08071ba9a02fa"
),
),
(
"[email protected]+sha224.953c8233f7a92884eee2de69a1b92d1f2ec1655e66d08071ba9a02fa",
semver.VersionInfo(
1,
0,
0,
prerelease="rc",
build="sha224.953c8233f7a92884eee2de69a1b92d1f2ec1655e66d08071ba9a02fa",
),
),
],
)
def test_get_semver_from_package_manager(
package_manager: str, expected_result: Optional[semver.version.Version]
) -> None:
yarn_semver = get_semver_from_package_manager(package_manager)

if yarn_semver is None:
assert expected_result is None
else:
assert expected_result is not None
assert yarn_semver == expected_result


@pytest.mark.parametrize(
"package_manager, expected_error",
[
(
"no-one-expected-it",
"could not parse packageManager spec in package.json (expected name@semver)",
),
(
"[email protected]",
"1.0 is not a valid semver for packageManager in package.json",
),
(
"[email protected]",
"packageManager in package.json must be yarn",
),
],
)
def test_get_semver_from_package_manager_fail(package_manager: str, expected_error: str) -> None:
with pytest.raises(UnexpectedFormat, match=re.escape(expected_error)):
get_semver_from_package_manager(package_manager)

0 comments on commit b74ff6c

Please sign in to comment.