-
Notifications
You must be signed in to change notification settings - Fork 28
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
yarn v1: Add utilities for dependency resolution
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
1 parent
0b0809a
commit bc53c19
Showing
2 changed files
with
172 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |