Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ensure packageManager is correctly set for yarn #343

Merged
merged 1 commit into from
Oct 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -52,10 +57,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:
brunoapimentel marked this conversation as resolved.
Show resolved Hide resolved
"""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
chmeliik marked this conversation as resolved.
Show resolved Hide resolved


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
chmeliik marked this conversation as resolved.
Show resolved Hide resolved
95 changes: 93 additions & 2 deletions tests/unit/package_managers/yarn/test_main.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,103 @@
import re
from typing import Optional, Union
from unittest import mock

import pytest
import semver

from cachi2.core.errors import YarnCommandError
from cachi2.core.package_managers.yarn.main import _fetch_dependencies
from cachi2.core.errors import PackageRejected, UnexpectedFormat, YarnCommandError
from cachi2.core.package_managers.yarn.main import _configure_yarn_version, _fetch_dependencies
from cachi2.core.rooted_path import RootedPath


@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_project.package_json.package_manager = None
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:
brunoapimentel marked this conversation as resolved.
Show resolved Hide resolved
assert mock_project.package_json.package_manager == f"yarn@{yarn_path_version}"
mock_project.package_json.write_to_file.assert_called_once()
else:
assert mock_project.package_json.package_manager is None
mock_project.package_json.write_to_file.assert_not_called()


@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)


@mock.patch("cachi2.core.package_managers.yarn.main.run_yarn_cmd")
def test_fetch_dependencies(mock_yarn_cmd: mock.Mock, rooted_tmp_path: RootedPath) -> None:
source_dir = rooted_tmp_path
Expand Down
Loading