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

yarn: Automated Yarn version detector #743

Merged
merged 2 commits into from
Dec 16, 2024
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
2 changes: 2 additions & 0 deletions cachi2/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class Config(BaseModel, extra="forbid"):
requests_timeout: int = 300
concurrency_limit: int = 5

allow_yarnberry_processing: bool = True
eskultety marked this conversation as resolved.
Show resolved Hide resolved

@model_validator(mode="before")
@classmethod
def _print_deprecation_warning(cls, data: Any) -> Any:
Expand Down
16 changes: 1 addition & 15 deletions cachi2/core/models/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,7 @@ def show_error(error: "ErrorDict") -> str:


# Supported package managers
PackageManagerType = Literal[
"bundler", "generic", "gomod", "npm", "pip", "rpm", "yarn", "yarn-classic"
]
PackageManagerType = Literal["bundler", "generic", "gomod", "npm", "pip", "rpm", "yarn"]

Flag = Literal[
"cgo-disable", "dev-package-managers", "force-gomod-tidy", "gomod-vendor", "gomod-vendor-check"
Expand Down Expand Up @@ -225,12 +223,6 @@ class YarnPackageInput(_PackageInputBase):
type: Literal["yarn"]


class YarnClassicPackageInput(_PackageInputBase):
"""Accepted input for a yarn classic package."""

type: Literal["yarn-classic"]


PackageInput = Annotated[
Union[
BundlerPackageInput,
Expand All @@ -239,7 +231,6 @@ class YarnClassicPackageInput(_PackageInputBase):
NpmPackageInput,
PipPackageInput,
RpmPackageInput,
YarnClassicPackageInput,
YarnPackageInput,
],
# https://pydantic-docs.helpmanual.io/usage/types/#discriminated-unions-aka-tagged-unions
Expand Down Expand Up @@ -341,10 +332,5 @@ def yarn_packages(self) -> list[YarnPackageInput]:
"""Get the yarn packages specified for this request."""
return self._packages_by_type(YarnPackageInput)

@property
def yarn_classic_packages(self) -> list[YarnClassicPackageInput]:
"""Get the yarn classic packages specified for this request."""
return self._packages_by_type(YarnClassicPackageInput)

def _packages_by_type(self, pkgtype: type[T]) -> list[T]:
return [package for package in self.packages if isinstance(package, pkgtype)]
28 changes: 28 additions & 0 deletions cachi2/core/package_managers/metayarn.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from cachi2.core.config import get_config
from cachi2.core.models.input import Request
from cachi2.core.models.output import RequestOutput
from cachi2.core.package_managers.yarn.main import fetch_yarn_source as fetch_yarnberry_source
from cachi2.core.package_managers.yarn_classic.main import MissingLockfile, NotV1Lockfile
from cachi2.core.package_managers.yarn_classic.main import (
fetch_yarn_source as fetch_yarn_classic_source,
)
from cachi2.core.utils import merge_outputs


def fetch_yarn_source(request: Request) -> RequestOutput:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a unit test for this function, as the coverage is very low on this module.

"""Fetch yarn source."""
# Packages could be a mixture of yarn v1 and v2 (at least this is how it
# looks now). To preserve this behavior each request is split into individual
# packages which are assessed one by one.
fetched_packages = []
for package in request.packages:
brunoapimentel marked this conversation as resolved.
Show resolved Hide resolved
new_request = request.model_copy(update={"packages": [package]})
try:
fetched_packages.append(fetch_yarn_classic_source(new_request))
except (MissingLockfile, NotV1Lockfile) as e:
# It is assumed that if a package is not v1 then it is probably v2.
if get_config().allow_yarnberry_processing:
fetched_packages.append(fetch_yarnberry_source(new_request))
else:
raise e
return merge_outputs(fetched_packages)
48 changes: 45 additions & 3 deletions cachi2/core/package_managers/yarn_classic/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import re
from collections import Counter
from pathlib import Path
from typing import Iterable
from typing import Any, Iterable
from urllib.parse import urlparse

from cachi2.core.errors import PackageManagerError, PackageRejected
Expand All @@ -27,6 +27,26 @@


MIRROR_DIR = "deps/yarn-classic"
_yarn_classic_pattern = "yarn lockfile v1" # See [yarn_classic_trait].


class MissingLockfile(PackageRejected):
"""Indicate that a lock file is missing."""

def __init__(self) -> None:
"""Initialize a Missing Lockfile error."""
reason = "Yarn lockfile 'yarn.lock' missing, refusing to continue"
solution = "Make sure your repository has a Yarn lockfile (i.e. yarn.lock) checked in"
super().__init__(reason, solution=solution)


class NotV1Lockfile(PackageRejected):
"""Indicate that a lockfile is of wrong tyrpoe."""

def __init__(self, package_path: Any) -> None:
"""Initialize a Missing Lockfile error."""
reason = f"{package_path} not a Yarn v1"
super().__init__(reason, solution=None)
Comment on lines +33 to +49
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recently, I got a comment from Erik, that all custom cachi2 exceptions should be inside cachi2.core.errors.py. But that's just a nitpick.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction - I meant any publicly exposed top-level exceptions like PathOutsideRoot, PackageRejected, etc. ...custom exceptions which are module-scoped are fine to keep in the given module where it makes the most sense for them. That said, these are module-scoped exceptions and so we should start their name with an underscore. Additionally, these should be re-raised in the fetch-yarn-... meta entrypoint as a PackageRejected exception IMO as that's the public-facing one.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are not module-scoped, but they are also not cachi2-scoped: they are raised here to send a message to meta-PM. They do not need to be re-wrapped because as we have agreed earlier everything that is not v1 is v2+ (and exceptions from v2+ propagate freely). Furthermore, any other exception will propagate to the user, so if something happens to conform to v1 and not trigger any of these, and also is a broken v1 then a user would know.



def fetch_yarn_source(request: Request) -> RequestOutput:
Expand All @@ -36,7 +56,7 @@ def fetch_yarn_source(request: Request) -> RequestOutput:
def _ensure_mirror_dir_exists(output_dir: RootedPath) -> None:
output_dir.join_within_root(MIRROR_DIR).path.mkdir(parents=True, exist_ok=True)

for package in request.yarn_classic_packages:
for package in request.yarn_packages:
package_path = request.source_dir.join_within_root(package.path)
_ensure_mirror_dir_exists(request.output_dir)
_resolve_yarn_project(Project.from_source_dir(package_path), request.output_dir)
Expand Down Expand Up @@ -121,9 +141,27 @@ def _reject_if_pnp_install(project: Project) -> None:
)


def _get_path_to_yarn_lock(project: Project) -> Path:
return project.source_dir.join_within_root("yarn.lock").path


def _reject_if_wrong_lockfile_version(project: Project) -> None:
yarnlock_path = _get_path_to_yarn_lock(project)
text = yarnlock_path.read_text()
if _yarn_classic_pattern not in text:
raise NotV1Lockfile(project.source_dir)


def _reject_if_lockfile_is_missing(project: Project) -> None:
yarnlock_path = _get_path_to_yarn_lock(project)
if not yarnlock_path.exists():
raise MissingLockfile()


def _verify_repository(project: Project) -> None:
_reject_if_lockfile_is_missing(project)
_reject_if_wrong_lockfile_version(project)
_reject_if_pnp_install(project)
# _check_lockfile(project)


def _verify_corepack_yarn_version(source_dir: RootedPath, env: dict[str, str]) -> None:
Expand Down Expand Up @@ -194,3 +232,7 @@ def _get_git_tarball_mirror_name(url: str) -> str:
package_filename = package_filename[1:]

return package_filename


# References
# [yarn_classic_trait]: https://github.com/yarnpkg/berry/blob/13d5b3041794c33171808fdce635461ff4ab5c4e/packages/yarnpkg-core/sources/Project.ts#L434
31 changes: 5 additions & 26 deletions cachi2/core/resolver.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
from collections.abc import Iterable
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any, Callable

from cachi2.core.errors import UnsupportedFeature
from cachi2.core.models.input import PackageManagerType, Request
from cachi2.core.models.output import RequestOutput
from cachi2.core.package_managers import bundler, generic, gomod, npm, pip, rpm, yarn, yarn_classic
from cachi2.core.package_managers import bundler, generic, gomod, metayarn, npm, pip, rpm
from cachi2.core.rooted_path import RootedPath
from cachi2.core.utils import copy_directory
from cachi2.core.utils import copy_directory, merge_outputs

Handler = Callable[[Request], RequestOutput]

Expand All @@ -17,15 +16,14 @@
"gomod": gomod.fetch_gomod_source,
"npm": npm.fetch_npm_source,
"pip": pip.fetch_pip_source,
"yarn": yarn.fetch_yarn_source,
"yarn": metayarn.fetch_yarn_source,
"generic": generic.fetch_generic_source,
}

# This is where we put package managers currently under development in order to
# invoke them via CLI
_dev_package_managers: dict[PackageManagerType, Handler] = {
"rpm": rpm.fetch_rpm_source,
"yarn-classic": yarn_classic.fetch_yarn_source,
}

# This is *only* used to provide a list for `cachi2 --version`
Expand All @@ -39,7 +37,7 @@ def resolve_packages(request: Request) -> RequestOutput:
This function performs the operations in a working copy of the source directory in case
a package manager that can make unwanted modifications will be used.
"""
if request.yarn_packages or request.yarn_classic_packages:
if request.yarn_packages:
original_source_dir = request.source_dir

with TemporaryDirectory(".cachi2-source-copy", dir=".") as temp_dir:
Expand Down Expand Up @@ -68,26 +66,7 @@ def _resolve_packages(request: Request) -> RequestOutput:
solution="But the good news is that we're already working on it!",
)
pkg_managers = [_supported_package_managers[type_] for type_ in sorted(requested_types)]
return _merge_outputs(pkg_manager(request) for pkg_manager in pkg_managers)


def _merge_outputs(outputs: Iterable[RequestOutput]) -> RequestOutput:
"""Merge RequestOutput instances."""
components = []
env_vars = []
project_files = []

for output in outputs:
components.extend(output.components)
env_vars.extend(output.build_config.environment_variables)
project_files.extend(output.build_config.project_files)

return RequestOutput.from_obj_list(
components=components,
environment_variables=env_vars,
project_files=project_files,
options=output.build_config.options if output.build_config.options else None,
)
return merge_outputs(pkg_manager(request) for pkg_manager in pkg_managers)

eskultety marked this conversation as resolved.
Show resolved Hide resolved

def inject_files_post(from_output_dir: Path, for_output_dir: Path, **kwargs: Any) -> None:
Expand Down
22 changes: 21 additions & 1 deletion cachi2/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
import sys
from functools import cache
from pathlib import Path
from typing import Callable, Iterator, Optional, Sequence
from typing import Callable, Iterable, Iterator, Optional, Sequence

from cachi2.core.config import get_config
from cachi2.core.errors import Cachi2Error
from cachi2.core.models.output import RequestOutput

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -195,3 +196,22 @@ def get_cache_dir() -> Path:
except KeyError:
cache_dir = Path.home().joinpath(".cache")
return cache_dir.joinpath("cachi2")


def merge_outputs(outputs: Iterable[RequestOutput]) -> RequestOutput:
"""Merge RequestOutput instances."""
components = []
env_vars = []
project_files = []

for output in outputs:
components.extend(output.components)
env_vars.extend(output.build_config.environment_variables)
project_files.extend(output.build_config.project_files)

return RequestOutput.from_obj_list(
components=components,
environment_variables=env_vars,
project_files=project_files,
options=output.build_config.options if output.build_config.options else None,
)
20 changes: 9 additions & 11 deletions tests/integration/test_yarn_classic.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
utils.TestParameters(
repo="https://github.com/cachito-testing/cachi2-yarn.git",
ref="corepack_packagemanager_ignored",
packages=({"path": ".", "type": "yarn-classic"},),
packages=({"path": ".", "type": "yarn"},),
flags=["--dev-package-managers"],
check_output=False,
check_deps_checksums=False,
Expand All @@ -29,7 +29,7 @@
utils.TestParameters(
repo="https://github.com/cachito-testing/cachi2-yarn.git",
ref="yarnpath_ignored",
packages=({"path": ".", "type": "yarn-classic"},),
packages=({"path": ".", "type": "yarn"},),
flags=["--dev-package-managers"],
check_output=False,
check_deps_checksums=False,
Expand All @@ -43,7 +43,7 @@
utils.TestParameters(
repo="https://github.com/cachito-testing/cachi2-yarn.git",
ref="invalid_checksum",
packages=({"path": ".", "type": "yarn-classic"},),
packages=({"path": ".", "type": "yarn"},),
flags=["--dev-package-managers"],
check_output=False,
check_deps_checksums=False,
Expand All @@ -57,7 +57,7 @@
utils.TestParameters(
repo="https://github.com/cachito-testing/cachi2-yarn.git",
ref="invalid_frozen_lockfile_add_dependency",
packages=({"path": ".", "type": "yarn-classic"},),
packages=({"path": ".", "type": "yarn"},),
flags=["--dev-package-managers"],
check_output=False,
check_deps_checksums=False,
Expand All @@ -71,7 +71,7 @@
utils.TestParameters(
repo="https://github.com/cachito-testing/cachi2-yarn.git",
ref="lifecycle_scripts",
packages=({"path": ".", "type": "yarn-classic"},),
packages=({"path": ".", "type": "yarn"},),
flags=["--dev-package-managers"],
check_output=False,
check_deps_checksums=False,
Expand All @@ -85,7 +85,7 @@
utils.TestParameters(
repo="https://github.com/cachito-testing/cachi2-yarn.git",
ref="offline-mirror-collision",
packages=({"path": ".", "type": "yarn-classic"},),
packages=({"path": ".", "type": "yarn"},),
flags=["--dev-package-managers"],
check_output=False,
check_deps_checksums=False,
Expand Down Expand Up @@ -128,8 +128,7 @@ def test_yarn_classic_packages(
utils.TestParameters(
repo="https://github.com/cachito-testing/cachi2-yarn.git",
ref="valid_yarn_all_dependency_types",
packages=({"path": ".", "type": "yarn-classic"},),
flags=["--dev-package-managers"],
packages=({"path": ".", "type": "yarn"},),
check_vendor_checksums=False,
expected_exit_code=0,
expected_output="All dependencies fetched successfully",
Expand All @@ -143,10 +142,9 @@ def test_yarn_classic_packages(
repo="https://github.com/cachito-testing/cachi2-yarn.git",
ref="valid_multiple_packages",
packages=(
{"path": "first-pkg", "type": "yarn-classic"},
{"path": "second-pkg", "type": "yarn-classic"},
{"path": "first-pkg", "type": "yarn"},
{"path": "second-pkg", "type": "yarn"},
),
flags=["--dev-package-managers"],
check_vendor_checksums=False,
expected_exit_code=0,
expected_output="All dependencies fetched successfully",
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/models/test_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ def test_valid_packages(self, input_data: dict[str, Any], expect_data: dict[str,
),
pytest.param(
{"type": "go-package"},
r"Input tag 'go-package' found using 'type' does not match any of the expected tags: 'bundler', 'generic', 'gomod', 'npm', 'pip', 'rpm', 'yarn-classic', 'yarn'",
r"Input tag 'go-package' found using 'type' does not match any of the expected tags: 'bundler', 'generic', 'gomod', 'npm', 'pip', 'rpm', 'yarn'",
id="incorrect_type_tag",
),
pytest.param(
Expand Down
Loading
Loading