Skip to content

Commit

Permalink
yarn_v1: Simplifying versions detection code
Browse files Browse the repository at this point in the history
Now version detection is done in v1 only, everything that is not v1 is
assumed to be v2. This radically simplifies the dispatcher.
  • Loading branch information
a-ovchinnikov committed Dec 6, 2024
1 parent 734b198 commit 280b015
Show file tree
Hide file tree
Showing 4 changed files with 72 additions and 118 deletions.
21 changes: 20 additions & 1 deletion cachi2/core/errors.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import textwrap
from typing import ClassVar, Optional
from typing import Any, ClassVar, Optional

_argument_not_specified = "__argument_not_specified__"

Expand Down Expand Up @@ -69,6 +69,25 @@ def __init__(self, reason: str, *, solution: Optional[str], docs: Optional[str]
super().__init__(reason, solution=solution, docs=docs)


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)


class UnexpectedFormat(UsageError):
"""Cachi2 failed to parse a file in the user's package (e.g. requirements.txt)."""

Expand Down
129 changes: 14 additions & 115 deletions cachi2/core/package_managers/metayarn.py
Original file line number Diff line number Diff line change
@@ -1,122 +1,21 @@
import logging
import re
from collections import defaultdict
from functools import partial
from pathlib import Path
from typing import Iterable, Optional, Union

from cachi2.core.errors import PackageRejected
from cachi2.core.models.input import PackageInput, Request
from cachi2.core.errors import MissingLockfile, NotV1Lockfile
from cachi2.core.models.input import Request
from cachi2.core.models.output import RequestOutput
from cachi2.core.package_managers import yarn, yarn_classic
from cachi2.core.resolver import _merge_outputs
from cachi2.core.rooted_path import RootedPath

log = logging.getLogger(__name__)


def _get_path_to_yarn_lock(
package: PackageInput,
source_dir: RootedPath,
) -> Path:
"""Construct a path to package's lockfile.
Raise an exception when there is no lockfile.
"""
yarnlock_path = source_dir.join_within_root("yarn.lock")
if yarnlock_path.path.exists():
return yarnlock_path.path

raise PackageRejected(f"Yarn lockfile is missing in {package.path}", solution=None)


def _contains_yarn_version_trait(
package: PackageInput,
source_dir: RootedPath,
trait_pattern: re.Pattern[str],
) -> Optional[re.Match[str]]:
text = _get_path_to_yarn_lock(package, source_dir).read_text()
return trait_pattern.search(text)


_yarn_classic_pattern = re.compile("yarn lockfile v1") # See [yarn_classic_trait].
_yarnberry_pattern = re.compile("__metadata:") # See [yarnberry_trait] and [yarn_v2_test_repo].
contains_yarn_classic = partial(_contains_yarn_version_trait, trait_pattern=_yarn_classic_pattern)
contains_yarnberry = partial(_contains_yarn_version_trait, trait_pattern=_yarnberry_pattern)

_yarn_versions = {
"yarn_classic": contains_yarn_classic,
"yarnberry": contains_yarnberry,
}
_yarn_processors = {
"yarn_classic": yarn_classic.fetch_yarn_source,
"yarnberry": yarn.fetch_yarn_source,
}


def _yarn_selector(
package: PackageInput,
source_dir: RootedPath,
) -> tuple[str, Optional[Exception]]:
try:
for yarn_version, version_matches_for in _yarn_versions.items():
if version_matches_for(package, source_dir):
return yarn_version, None
else:
return "uncategorized", None
except Exception as e:
return "exception", e


def _separate_packages_by_yarn_version(
packages: Iterable[PackageInput],
source_dir: RootedPath,
) -> dict[str, Union[list[PackageInput], tuple[PackageInput, Exception]]]:
"""Sorts packages to bins depending on which Yarn version was used.
The output dictionary contains "uncategorized" entry to capture anything
that could not be categorized (likely yet-unsupported versions of Yarn).
The output dictionary also contains category "exceptions" to accumulate
exceptions that occured during pre-processing of packages.
"""
output = defaultdict(list)
for p in packages:
category, exception = _yarn_selector(p, source_dir)
if exception is None:
output[category].append(p)
else: # This is an exceptional result.
# Categories will never clash, but mypy does not know that and
# does not want to learn.
output[category].append((p, exception)) # type: ignore
return output # type: ignore


def dispatch_to_correct_fetcher(request: Request) -> RequestOutput:
"""Dispatch a request to correct yarn backend.
In order to save a user from the need to distinguish between different
flavors of Yarn this function attempts to separate Yarn packages and process each
with an appropriate manager.
"""
sorted_packages = _separate_packages_by_yarn_version(request.packages, request.source_dir)
if uncat := sorted_packages.pop("uncategorized", False):
log.warning(f"Failed to categorize the following packages: {uncat}")
if exceptions := sorted_packages.pop("exceptions", False):
log.warning(f"Following packages caused categorizer to fail: {exceptions}")

def fetch_yarn_source(request: Request) -> RequestOutput:
"""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 reqiest is split into individual
# packages which are assessed one by one.
fetched_packages = []
for pm, packages in sorted_packages.items():
new_request = request.model_copy(update={"packages": packages})
fetched_packages.append(_yarn_processors[pm](new_request))
# Judging from the resolver code it is safe to merge multiple packages.
# Moreover, it does not look like there is any mechanism in place now to
# prevent users from requesting both PMs simultaneously.
# The code below preserves this behavior.
for package in request.packages:
new_request = request.model_copy(update={"packages": [package]})
try:
fetched_packages.append(yarn_classic.fetch_yarn_source(new_request))
except (MissingLockfile, NotV1Lockfile):
# It is assumed that if a package is not v1 then it is probably v2.
fetched_packages.append(yarn.fetch_yarn_source(new_request))
return _merge_outputs(fetched_packages)


# References
# [yarn_classic_trait]: https://github.com/yarnpkg/berry/blob/13d5b3041794c33171808fdce635461ff4ab5c4e/packages/yarnpkg-core/sources/Project.ts#L434
# [yarnberry_trait]: https://github.com/yarnpkg/berry/blob/13d5b3041794c33171808fdce635461ff4ab5c4e/packages/yarnpkg-core/sources/Project.ts#L374
# [yarn_v2_test_repo]: https://github.com/cachito-testing/cachi2-yarn-berry/blob/70515793108df42547d3320c7ea4cd6b6e505c46/yarn.lock
30 changes: 28 additions & 2 deletions cachi2/core/package_managers/yarn_classic/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import logging
import re
from pathlib import Path

from cachi2.core.errors import PackageManagerError, PackageRejected
from cachi2.core.errors import MissingLockfile, NotV1Lockfile, PackageManagerError, PackageRejected
from cachi2.core.models.input import Request
from cachi2.core.models.output import Component, EnvironmentVariable, RequestOutput
from cachi2.core.package_managers.yarn.utils import (
Expand All @@ -16,6 +18,7 @@


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


def fetch_yarn_source(request: Request) -> RequestOutput:
Expand Down Expand Up @@ -109,9 +112,28 @@ 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()
search_result = _yarn_classic_pattern.search(text)
if search_result is None:
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 All @@ -125,3 +147,7 @@ def _verify_corepack_yarn_version(source_dir: RootedPath, env: dict[str, str]) -
)

log.info("Processing the request using yarn@%s", installed_yarn_version)


# References
# [yarn_classic_trait]: https://github.com/yarnpkg/berry/blob/13d5b3041794c33171808fdce635461ff4ab5c4e/packages/yarnpkg-core/sources/Project.ts#L434
10 changes: 10 additions & 0 deletions tests/unit/package_managers/yarn_classic/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,16 @@ def test_pnp_installs_false(
config_file_name,
config_file_content,
)
# A yarn v1 project needs a valid yarn lock to be accepted.
_prepare_config_file(
rooted_tmp_path,
# I will deal with
# incompatible type "type[YarnLock]"; expected "Union[PackageJson, YarnLock]"
# later, will ignore mypy for now:
YarnLock, # type: ignore
"yarn.lock",
VALID_YARN_LOCK_FILE,
)

project = Project.from_source_dir(rooted_tmp_path)

Expand Down

0 comments on commit 280b015

Please sign in to comment.