diff --git a/cachi2/core/package_managers/yarn/main.py b/cachi2/core/package_managers/yarn/main.py index eb20ece37..999abae5d 100644 --- a/cachi2/core/package_managers/yarn/main.py +++ b/cachi2/core/package_managers/yarn/main.py @@ -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, @@ -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: diff --git a/cachi2/core/package_managers/yarn/project.py b/cachi2/core/package_managers/yarn/project.py index 34ed3b133..eebdde683 100644 --- a/cachi2/core/package_managers/yarn/project.py +++ b/cachi2/core/package_managers/yarn/project.py @@ -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. @@ -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.""" @@ -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-.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 diff --git a/tests/unit/package_managers/yarn/test_main.py b/tests/unit/package_managers/yarn/test_main.py new file mode 100644 index 000000000..ee9b5aee2 --- /dev/null +++ b/tests/unit/package_managers/yarn/test_main.py @@ -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 (yarn@1.0.1) and packageManager (yarn@1.0.0)", + 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) diff --git a/tests/unit/package_managers/yarn/test_project.py b/tests/unit/package_managers/yarn/test_project.py new file mode 100644 index 000000000..c37d0f158 --- /dev/null +++ b/tests/unit/package_managers/yarn/test_project.py @@ -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, + ), + ( + "yarn@1.0.0", + semver.VersionInfo(1, 0, 0), + ), + ( + "yarn@1.0.0-rc", + semver.VersionInfo(1, 0, 0, prerelease="rc"), + ), + ( + "yarn@1.0.0+sha224.953c8233f7a92884eee2de69a1b92d1f2ec1655e66d08071ba9a02fa", + semver.VersionInfo( + 1, 0, 0, build="sha224.953c8233f7a92884eee2de69a1b92d1f2ec1655e66d08071ba9a02fa" + ), + ), + ( + "yarn@1.0.0-rc+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)", + ), + ( + "yarn@1.0", + "1.0 is not a valid semver for packageManager in package.json", + ), + ( + "npm@1.0.0", + "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)