Skip to content

Commit

Permalink
yarn v1: Add utilities for dependency resolution
Browse files Browse the repository at this point in the history
Add utility functions to identify non-dev dependencies in Yarn Classic
projects:

- Implement find_non_dev_deps() to identify production dependencies
  across workspaces
- Support compound key expansion in yarn.lock files
- Use BFS algorithm for efficient transitive dependency resolution

This enables proper classification of dependencies as dev or non-dev
based on their usage in package.json files and transitive relationships.

Signed-off-by: Michal Šoltis <[email protected]>
  • Loading branch information
slimreaper35 committed Nov 25, 2024
1 parent 0b0809a commit bc53c19
Show file tree
Hide file tree
Showing 2 changed files with 172 additions and 0 deletions.
103 changes: 103 additions & 0 deletions cachi2/core/package_managers/yarn_classic/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from collections import deque
from typing import Any

from pyarn.lockfile import Package as PYarnPackage

from cachi2.core.package_managers.yarn_classic.project import PackageJson, YarnLock
from cachi2.core.package_managers.yarn_classic.workspaces import Workspace
from cachi2.core.rooted_path import RootedPath


def find_non_dev_deps(
main_package_json: PackageJson,
yarn_lock: YarnLock,
workspaces: list[Workspace],
) -> set[str]:
"""
Identify all non-dev dependencies in the root package and its workspaces.
A dependency is classified as "non-dev" if:
- It is listed in `dependencies`, `peerDependencies`, or `optionalDependencies`
of any `package.json` file.
- It is a transitive dependency of another non-dev dependency.
A dependency is classified as "dev" if:
- It is listed in `devDependencies` of any `package.json` file.
- It is a transitive dependency of a dev dependency.
This function ensures that a dependency appearing as both a dev and non-dev
dependency is marked as non-dev.
"""
# TODO: PackageJson object should be an attribute of the Workspace object
ws_package_jsons = [
PackageJson.from_file(RootedPath(ws.path / "package.json")) for ws in workspaces
]
all_package_jsons = [main_package_json] + ws_package_jsons

root_dep_ids = []
for package_json in all_package_jsons:
for dep_type in ["dependencies", "peerDependencies", "optionalDependencies"]:
for name, version in package_json.data.get(dep_type, {}).items():
root_dep_id = f"{name}@{version}"
root_dep_ids.append(root_dep_id)

expanded_yarn_lock = _expand_yarn_lock_keys(yarn_lock)

all_dep_ids = set()
for root_dep_id in root_dep_ids:
transitive_dep_ids = _find_transitive_deps(root_dep_id, expanded_yarn_lock)
all_dep_ids.update(transitive_dep_ids)

return all_dep_ids


def _expand_yarn_lock_keys(yarn_lock: YarnLock) -> dict[str, dict[str, Any]]:
"""
Expand compound keys in the yarn.lock dictionary into individual keys.
In the original yarn.lock dictionary, a single key may represent multiple package names,
separated by commas (e.g., "package-a@^1.0.0, package-b@^2.0.0"). These are referred to
as compound keys, where multiple keys share the same value (N:1 mapping).
This function splits such compound keys into individual keys, creating a new dictionary
where each key maps directly to the same shared value as in the original dictionary.
The result is a dictionary with only one-to-one (1:1) key-value mappings.
Note: This function does not copy the values. The newly created individual keys will
all reference the same original value object.
"""

def split_multi_key(dep_id: str) -> list[str]:
return dep_id.replace('"', "").split(", ")

result = {}
for dep_id in yarn_lock.data.keys():
for key in split_multi_key(dep_id):
result[key] = yarn_lock.data[dep_id]

return result


def _find_transitive_deps(
root_dep_id: str,
expanded_yarn_lock: dict[str, dict[str, Any]],
) -> set[str]:
"""Perform a breadth-first search (BFS) to find all transitive dependencies of a given root dependency."""
bfs_queue = deque([root_dep_id])
visited = set()

while bfs_queue:
current_dep_id = bfs_queue.popleft()
visited.add(current_dep_id)

dep_info = expanded_yarn_lock.get(current_dep_id)
if dep_info is None:
continue

package = PYarnPackage.from_dict(current_dep_id, dep_info)
for name, version in package.dependencies.items():
new_dep_id = f"{name}@{version}"
if new_dep_id not in visited:
bfs_queue.append(new_dep_id)

return visited
69 changes: 69 additions & 0 deletions tests/unit/package_managers/yarn_classic/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from unittest import mock

from cachi2.core.package_managers.yarn_classic.project import PackageJson
from cachi2.core.package_managers.yarn_classic.utils import find_non_dev_deps
from cachi2.core.rooted_path import RootedPath

PACKAGE_JSON_CONTENT = """
{
"name": "project",
"dependencies": {
"main-dep1": "^1.0.0"
},
"optionalDependencies": {
"optional-dep1": "^2.0.0"
},
"peerDependencies": {
"peer-dep1": "^3.0.0"
},
"devDependencies": {
"dev-dep1": "^4.0.0"
}
}
"""


@mock.patch("cachi2.core.package_managers.yarn_classic.project.YarnLock")
@mock.patch("cachi2.core.package_managers.yarn_classic.utils._expand_yarn_lock_keys")
def test_find_non_dev_deps(
mock_expand_yarn_lock_keys: mock.Mock,
mock_yarn_lock: mock.Mock,
rooted_tmp_path: RootedPath,
) -> None:
rooted_tmp_path.join_within_root("package.json").path.write_text(PACKAGE_JSON_CONTENT)
package_json = PackageJson.from_file(rooted_tmp_path.join_within_root("package.json"))

mock_expand_yarn_lock_keys.return_value = {
# dependencies
"main-dep1@^1.0.0": {
"version": "1.0.0",
"dependencies": {"sub-dep1": "^1.1.0"},
},
# optional dependencies
"optional-dep1@^2.0.0": {
"version": "2.0.0",
"dependencies": {"multi-dep1": "^4.0.0", "multi-dep2": "^4.0.0"},
},
"multi-dep1@^4.0.0, multi-dep2@^4.0.0": {"version": "5.0.0", "dependencies": {}},
# peer dependencies
"peer-dep1@^3.0.0": {"version": "3.0.0", "dependencies": {}},
# transitive dependencies
"sub-dep1@^1.1.0": {"version": "1.1.0", "dependencies": {"sub-dep11": "^1.1.1"}},
"sub-dep11@^1.1.1": {"version": "1.1.1", "dependencies": {}},
# dev dependencies
"dev-dep1@^4.0.0": {"version": "4.0.0", "dependencies": {}},
}

result = find_non_dev_deps(package_json, mock_yarn_lock, [])

mock_expand_yarn_lock_keys.assert_called_once_with(mock_yarn_lock)
assert result == {
"main-dep1@^1.0.0",
"optional-dep1@^2.0.0",
"multi-dep1@^4.0.0",
"multi-dep2@^4.0.0",
"peer-dep1@^3.0.0",
"sub-dep1@^1.1.0",
"sub-dep11@^1.1.1",
# no dev dependencies
}

0 comments on commit bc53c19

Please sign in to comment.