aboutsummaryrefslogtreecommitdiffstats
path: root/build/plugins/lib/nots/package_manager/pnpm
diff options
context:
space:
mode:
authoralexv-smirnov <alex@ydb.tech>2023-06-13 11:05:01 +0300
committeralexv-smirnov <alex@ydb.tech>2023-06-13 11:05:01 +0300
commitbf0f13dd39ee3e65092ba3572bb5b1fcd125dcd0 (patch)
tree1d1df72c0541a59a81439842f46d95396d3e7189 /build/plugins/lib/nots/package_manager/pnpm
parent8bfdfa9a9bd19bddbc58d888e180fbd1218681be (diff)
downloadydb-bf0f13dd39ee3e65092ba3572bb5b1fcd125dcd0.tar.gz
add ymake export to ydb
Diffstat (limited to 'build/plugins/lib/nots/package_manager/pnpm')
-rw-r--r--build/plugins/lib/nots/package_manager/pnpm/__init__.py12
-rw-r--r--build/plugins/lib/nots/package_manager/pnpm/constants.py2
-rw-r--r--build/plugins/lib/nots/package_manager/pnpm/lockfile.py164
-rw-r--r--build/plugins/lib/nots/package_manager/pnpm/package_manager.py213
-rw-r--r--build/plugins/lib/nots/package_manager/pnpm/tests/lockfile.py326
-rw-r--r--build/plugins/lib/nots/package_manager/pnpm/tests/workspace.py68
-rw-r--r--build/plugins/lib/nots/package_manager/pnpm/tests/ya.make15
-rw-r--r--build/plugins/lib/nots/package_manager/pnpm/utils.py11
-rw-r--r--build/plugins/lib/nots/package_manager/pnpm/workspace.py81
-rw-r--r--build/plugins/lib/nots/package_manager/pnpm/ya.make24
10 files changed, 916 insertions, 0 deletions
diff --git a/build/plugins/lib/nots/package_manager/pnpm/__init__.py b/build/plugins/lib/nots/package_manager/pnpm/__init__.py
new file mode 100644
index 0000000000..b3a3c20c02
--- /dev/null
+++ b/build/plugins/lib/nots/package_manager/pnpm/__init__.py
@@ -0,0 +1,12 @@
+from . import constants
+from .lockfile import PnpmLockfile
+from .package_manager import PnpmPackageManager
+from .workspace import PnpmWorkspace
+
+
+__all__ = [
+ "constants",
+ "PnpmLockfile",
+ "PnpmPackageManager",
+ "PnpmWorkspace",
+]
diff --git a/build/plugins/lib/nots/package_manager/pnpm/constants.py b/build/plugins/lib/nots/package_manager/pnpm/constants.py
new file mode 100644
index 0000000000..e84a78c55e
--- /dev/null
+++ b/build/plugins/lib/nots/package_manager/pnpm/constants.py
@@ -0,0 +1,2 @@
+PNPM_WS_FILENAME = "pnpm-workspace.yaml"
+PNPM_LOCKFILE_FILENAME = "pnpm-lock.yaml"
diff --git a/build/plugins/lib/nots/package_manager/pnpm/lockfile.py b/build/plugins/lib/nots/package_manager/pnpm/lockfile.py
new file mode 100644
index 0000000000..79c351b7fa
--- /dev/null
+++ b/build/plugins/lib/nots/package_manager/pnpm/lockfile.py
@@ -0,0 +1,164 @@
+import base64
+import binascii
+import yaml
+import os
+
+from six.moves.urllib import parse as urlparse
+from six import iteritems
+
+from ..base import PackageJson, BaseLockfile, LockfilePackageMeta, LockfilePackageMetaInvalidError
+
+
+class PnpmLockfile(BaseLockfile):
+ IMPORTER_KEYS = PackageJson.DEP_KEYS + ("specifiers",)
+
+ def read(self):
+ with open(self.path, "r") as f:
+ self.data = yaml.load(f, Loader=yaml.CSafeLoader)
+
+ def write(self, path=None):
+ """
+ :param path: path to store lockfile, defaults to original path
+ :type path: str
+ """
+ if path is None:
+ path = self.path
+
+ with open(path, "w") as f:
+ yaml.dump(self.data, f, Dumper=yaml.CSafeDumper)
+
+ def get_packages_meta(self):
+ """
+ Extracts packages meta from lockfile.
+ :rtype: list of LockfilePackageMeta
+ """
+ packages = self.data.get("packages", {})
+
+ return map(lambda x: _parse_package_meta(*x), iteritems(packages))
+
+ def update_tarball_resolutions(self, fn):
+ """
+ :param fn: maps `LockfilePackageMeta` instance to new `resolution.tarball` value
+ :type fn: lambda
+ """
+ packages = self.data.get("packages", {})
+
+ for key, meta in iteritems(packages):
+ meta["resolution"]["tarball"] = fn(_parse_package_meta(key, meta))
+ packages[key] = meta
+
+ def get_importers(self):
+ """
+ Returns "importers" section from the lockfile or creates similar structure from "dependencies" and "specifiers".
+ :rtype: dict of dict of dict of str
+ """
+ importers = self.data.get("importers")
+ if importers is not None:
+ return importers
+
+ importer = {k: self.data[k] for k in self.IMPORTER_KEYS if k in self.data}
+
+ return {".": importer} if importer else {}
+
+ def merge(self, lf):
+ """
+ Merges two lockfiles:
+ 1. Converts the lockfile to monorepo-like lockfile with "importers" section instead of "dependencies" and "specifiers".
+ 2. Merges `lf`'s dependencies and specifiers to importers.
+ 3. Merges `lf`'s packages to the lockfile.
+ :param lf: lockfile to merge
+ :type lf: PnpmLockfile
+ """
+ importers = self.get_importers()
+ build_path = os.path.dirname(self.path)
+
+ for [importer, imports] in iteritems(lf.get_importers()):
+ importer_path = os.path.normpath(os.path.join(os.path.dirname(lf.path), importer))
+ importer_rel_path = os.path.relpath(importer_path, build_path)
+ importers[importer_rel_path] = imports
+
+ self.data["importers"] = importers
+
+ for k in self.IMPORTER_KEYS:
+ self.data.pop(k, None)
+
+ packages = self.data.get("packages", {})
+ for k, v in iteritems(lf.data.get("packages", {})):
+ if k not in packages:
+ packages[k] = v
+ self.data["packages"] = packages
+
+
+def _parse_package_meta(key, meta):
+ """
+ :param key: uniq package key from lockfile
+ :type key: string
+ :param meta: package meta dict from lockfile
+ :type meta: dict
+ :rtype: LockfilePackageMetaInvalidError
+ """
+ try:
+ name, version = _parse_package_key(key)
+ sky_id = _parse_sky_id_from_tarball_url(meta["resolution"]["tarball"])
+ integrity_algorithm, integrity = _parse_package_integrity(meta["resolution"]["integrity"])
+ except KeyError as e:
+ raise TypeError("Invalid package meta for key {}, missing {} key".format(key, e))
+ except LockfilePackageMetaInvalidError as e:
+ raise TypeError("Invalid package meta for key {}, parse error: {}".format(key, e))
+
+ return LockfilePackageMeta(name, version, sky_id, integrity, integrity_algorithm)
+
+
+def _parse_package_key(key):
+ """
+ Returns tuple of scoped package name and version.
+ :param key: package key in format "/({scope}/)?{package_name}/{package_version}(_{peer_dependencies})?"
+ :type key: string
+ :rtype: (str, str)
+ """
+ try:
+ tokens = key.split("/")[1:]
+ version = tokens.pop().split("_", 1)[0]
+
+ if len(tokens) < 1 or len(tokens) > 2:
+ raise TypeError()
+ except (IndexError, TypeError):
+ raise LockfilePackageMetaInvalidError("Invalid package key")
+
+ return ("/".join(tokens), version)
+
+
+def _parse_sky_id_from_tarball_url(tarball_url):
+ """
+ :param tarball_url: tarball url
+ :type tarball_url: string
+ :rtype: string
+ """
+ if tarball_url.startswith("file:"):
+ return ""
+
+ rbtorrent_param = urlparse.parse_qs(urlparse.urlparse(tarball_url).query).get("rbtorrent")
+
+ if rbtorrent_param is None:
+ raise LockfilePackageMetaInvalidError("Missing rbtorrent param in tarball url {}".format(tarball_url))
+
+ return "rbtorrent:{}".format(rbtorrent_param[0])
+
+
+def _parse_package_integrity(integrity):
+ """
+ Returns tuple of algorithm and hash (hex).
+ :param integrity: package integrity in format "{algo}-{base64_of_hash}"
+ :type integrity: string
+ :rtype: (str, str)
+ """
+ algo, hash_b64 = integrity.split("-", 1)
+
+ try:
+ hash_hex = binascii.hexlify(base64.b64decode(hash_b64))
+ except TypeError as e:
+ raise LockfilePackageMetaInvalidError(
+ "Invalid package integrity encoding, integrity: {}, error: {}".format(integrity, e)
+ )
+
+ return (algo, hash_hex)
diff --git a/build/plugins/lib/nots/package_manager/pnpm/package_manager.py b/build/plugins/lib/nots/package_manager/pnpm/package_manager.py
new file mode 100644
index 0000000000..3960f6498c
--- /dev/null
+++ b/build/plugins/lib/nots/package_manager/pnpm/package_manager.py
@@ -0,0 +1,213 @@
+import os
+import yaml
+
+from six import iteritems
+
+from ..base import BasePackageManager, PackageManagerError
+from ..base.utils import build_pj_path, build_nm_path, build_nm_bundle_path, s_rooted, b_rooted
+from ..base.node_modules_bundler import bundle_node_modules
+from ..base.constants import NODE_MODULES_BUNDLE_FILENAME
+from .lockfile import PnpmLockfile
+from .workspace import PnpmWorkspace
+from .utils import build_lockfile_path, build_ws_config_path
+
+
+class PnpmPackageManager(BasePackageManager):
+ _STORE_NM_PATH = os.path.join(".pnpm", "store")
+ _VSTORE_NM_PATH = os.path.join(".pnpm", "virtual-store")
+ _STORE_VER = "v3"
+
+ @classmethod
+ def load_lockfile(cls, path):
+ """
+ :param path: path to lockfile
+ :type path: str
+ :rtype: PnpmLockfile
+ """
+ return PnpmLockfile.load(path)
+
+ @classmethod
+ def load_lockfile_from_dir(cls, dir_path):
+ """
+ :param dir_path: path to directory with lockfile
+ :type dir_path: str
+ :rtype: PnpmLockfile
+ """
+ return cls.load_lockfile(build_lockfile_path(dir_path))
+
+ def create_node_modules(self):
+ """
+ Creates node_modules directory according to the lockfile.
+ """
+ ws = self._prepare_workspace()
+ self._exec_command(
+ [
+ "install",
+ "--offline",
+ "--frozen-lockfile",
+ "--store-dir",
+ self._nm_path(self._STORE_NM_PATH),
+ "--virtual-store-dir",
+ self._nm_path(self._VSTORE_NM_PATH),
+ "--no-verify-store-integrity",
+ "--package-import-method",
+ "hardlink",
+ "--ignore-pnpmfile",
+ "--ignore-scripts",
+ "--strict-peer-dependencies",
+ ]
+ )
+ self._fix_stores_in_modules_yaml()
+
+ bundle_node_modules(
+ build_root=self.build_root,
+ node_modules_path=self._nm_path(),
+ peers=ws.get_paths(base_path=self.module_path, ignore_self=True),
+ bundle_path=NODE_MODULES_BUNDLE_FILENAME,
+ )
+
+ def calc_node_modules_inouts(self):
+ """
+ Returns input and output paths for command that creates `node_modules` bundle.
+ Inputs:
+ - source package.json and lockfile,
+ - built package.jsons of all deps,
+ - merged lockfiles and workspace configs of direct non-leave deps,
+ - tarballs.
+ Outputs:
+ - merged lockfile,
+ - generated workspace config,
+ - created node_modules bundle.
+ :rtype: (list of str, list of str)
+ """
+ ins = [
+ s_rooted(build_pj_path(self.module_path)),
+ s_rooted(build_lockfile_path(self.module_path)),
+ ]
+ outs = [
+ b_rooted(build_lockfile_path(self.module_path)),
+ b_rooted(build_ws_config_path(self.module_path)),
+ b_rooted(build_nm_bundle_path(self.module_path)),
+ ]
+
+ # Source lockfiles are used only to get tarballs info.
+ src_lf_paths = [build_lockfile_path(self.sources_path)]
+ pj = self.load_package_json_from_dir(self.sources_path)
+
+ for [dep_src_path, (_, depth)] in iteritems(pj.get_workspace_map(ignore_self=True)):
+ dep_mod_path = dep_src_path[len(self.sources_root) + 1 :]
+ # pnpm requires all package.jsons.
+ ins.append(b_rooted(build_pj_path(dep_mod_path)))
+
+ dep_lf_src_path = build_lockfile_path(dep_src_path)
+ if not os.path.isfile(dep_lf_src_path):
+ # It is ok for leaves.
+ continue
+ src_lf_paths.append(dep_lf_src_path)
+
+ if depth == 1:
+ ins.append(b_rooted(build_ws_config_path(dep_mod_path)))
+ ins.append(b_rooted(build_lockfile_path(dep_mod_path)))
+
+ for pkg in self.extract_packages_meta_from_lockfiles(src_lf_paths):
+ ins.append(b_rooted(self._contrib_tarball_path(pkg)))
+
+ return (ins, outs)
+
+ def extract_packages_meta_from_lockfiles(self, lf_paths):
+ """
+ :type lf_paths: iterable of BaseLockfile
+ :rtype: iterable of LockfilePackageMeta
+ """
+ tarballs = set()
+
+ for lf_path in lf_paths:
+ try:
+ for pkg in self.load_lockfile(lf_path).get_packages_meta():
+ if pkg.tarball_path not in tarballs:
+ tarballs.add(pkg.tarball_path)
+ yield pkg
+ except Exception as e:
+ raise PackageManagerError("Unable to process lockfile {}: {}".format(lf_path, e))
+
+ def _prepare_workspace(self):
+ """
+ :rtype: PnpmWorkspace
+ """
+ pj = self._build_package_json()
+ ws = PnpmWorkspace(build_ws_config_path(self.build_path))
+ ws.set_from_package_json(pj)
+ dep_paths = ws.get_paths(ignore_self=True)
+ self._build_merged_workspace_config(ws, dep_paths)
+ self._build_merged_lockfile(dep_paths)
+
+ return ws
+
+ def _build_package_json(self):
+ """
+ :rtype: PackageJson
+ """
+ pj = self.load_package_json_from_dir(self.sources_path)
+
+ if not os.path.exists(self.build_path):
+ os.makedirs(self.build_path, exist_ok=True)
+
+ pj.path = build_pj_path(self.build_path)
+ pj.write()
+
+ return pj
+
+ def _build_merged_lockfile(self, dep_paths):
+ """
+ :type dep_paths: list of str
+ :rtype: PnpmLockfile
+ """
+ lf = self.load_lockfile_from_dir(self.sources_path)
+ # Change to the output path for correct path calcs on merging.
+ lf.path = build_lockfile_path(self.build_path)
+
+ for dep_path in dep_paths:
+ lf_path = build_lockfile_path(dep_path)
+ if os.path.isfile(lf_path):
+ lf.merge(self.load_lockfile(lf_path))
+
+ lf.update_tarball_resolutions(lambda p: self._contrib_tarball_url(p))
+ lf.write()
+
+ def _build_merged_workspace_config(self, ws, dep_paths):
+ """
+ NOTE: This method mutates `ws`.
+ :type ws: PnpmWorkspaceConfig
+ :type dep_paths: list of str
+ """
+ for dep_path in dep_paths:
+ ws_config_path = build_ws_config_path(dep_path)
+ if os.path.isfile(ws_config_path):
+ ws.merge(PnpmWorkspace.load(ws_config_path))
+
+ ws.write()
+
+ def _fix_stores_in_modules_yaml(self):
+ """
+ Ensures that store paths are the same as would be after installing deps in the source dir.
+ This is required to reuse `node_modules` after build.
+ """
+ with open(self._nm_path(".modules.yaml"), "r+") as f:
+ data = yaml.load(f, Loader=yaml.CSafeLoader)
+ # NOTE: pnpm requires absolute store path here.
+ data["storeDir"] = os.path.join(build_nm_path(self.sources_path), self._STORE_NM_PATH, self._STORE_VER)
+ data["virtualStoreDir"] = self._VSTORE_NM_PATH
+ f.seek(0)
+ yaml.dump(data, f, Dumper=yaml.CSafeDumper)
+ f.truncate()
+
+ def _get_default_options(self):
+ return super(PnpmPackageManager, self)._get_default_options() + [
+ "--stream",
+ "--reporter",
+ "append-only",
+ "--no-color",
+ ]
+
+ def _get_debug_log_path(self):
+ return self._nm_path(".pnpm-debug.log")
diff --git a/build/plugins/lib/nots/package_manager/pnpm/tests/lockfile.py b/build/plugins/lib/nots/package_manager/pnpm/tests/lockfile.py
new file mode 100644
index 0000000000..5985f0261e
--- /dev/null
+++ b/build/plugins/lib/nots/package_manager/pnpm/tests/lockfile.py
@@ -0,0 +1,326 @@
+import pytest
+
+from build.plugins.lib.nots.package_manager.pnpm.lockfile import PnpmLockfile
+
+
+def test_lockfile_get_packages_meta_ok():
+ lf = PnpmLockfile(path="/pnpm-lock.yaml")
+ lf.data = {
+ "packages": {
+ "/@babel/cli/7.6.2_@babel+core@7.6.2": {
+ "resolution": {
+ "integrity": "sha512-JDZ+T/br9pPfT2lmAMJypJDTTTHM9ePD/ED10TRjRzJVdEVy+JB3iRlhzYmTt5YkNgHvxWGlUVnLtdv6ruiDrQ==",
+ "tarball": "@babel%2fcli/-/cli-7.6.2.tgz?rbtorrent=cb1849da3e4947e56a8f6bde6a1ec42703ddd187",
+ },
+ },
+ },
+ }
+
+ packages = list(lf.get_packages_meta())
+ pkg = packages[0]
+
+ assert len(packages) == 1
+ assert pkg.name == "@babel/cli"
+ assert pkg.version == "7.6.2"
+ assert pkg.sky_id == "rbtorrent:cb1849da3e4947e56a8f6bde6a1ec42703ddd187"
+ assert (
+ pkg.integrity
+ == b"24367e4ff6ebf693df4f696600c272a490d34d31ccf5e3c3fc40f5d13463473255744572f89077891961cd8993b796243601efc561a55159cbb5dbfaaee883ad"
+ )
+ assert pkg.integrity_algorithm == "sha512"
+
+
+def test_lockfile_get_packages_empty():
+ lf = PnpmLockfile(path="/pnpm-lock.yaml")
+ lf.data = {}
+
+ assert len(list(lf.get_packages_meta())) == 0
+
+
+def test_package_meta_invalid_key():
+ lf = PnpmLockfile(path="/pnpm-lock.yaml")
+ lf.data = {
+ "packages": {
+ "in/valid": {},
+ },
+ }
+
+ with pytest.raises(TypeError) as e:
+ list(lf.get_packages_meta())
+
+ assert str(e.value) == "Invalid package meta for key in/valid, parse error: Invalid package key"
+
+
+def test_package_meta_missing_resolution():
+ lf = PnpmLockfile(path="/pnpm-lock.yaml")
+ lf.data = {
+ "packages": {
+ "/valid/1.2.3": {},
+ },
+ }
+
+ with pytest.raises(TypeError) as e:
+ list(lf.get_packages_meta())
+
+ assert str(e.value) == "Invalid package meta for key /valid/1.2.3, missing 'resolution' key"
+
+
+def test_package_meta_missing_tarball():
+ lf = PnpmLockfile(path="/pnpm-lock.yaml")
+ lf.data = {
+ "packages": {
+ "/valid/1.2.3": {
+ "resolution": {},
+ },
+ },
+ }
+
+ with pytest.raises(TypeError) as e:
+ list(lf.get_packages_meta())
+
+ assert str(e.value) == "Invalid package meta for key /valid/1.2.3, missing 'tarball' key"
+
+
+def test_package_meta_missing_rbtorrent():
+ lf = PnpmLockfile(path="/pnpm-lock.yaml")
+ lf.data = {
+ "packages": {
+ "/valid/1.2.3": {
+ "resolution": {
+ "tarball": "valid-1.2.3.tgz",
+ },
+ },
+ },
+ }
+
+ with pytest.raises(TypeError) as e:
+ list(lf.get_packages_meta())
+
+ assert (
+ str(e.value)
+ == "Invalid package meta for key /valid/1.2.3, parse error: Missing rbtorrent param in tarball url valid-1.2.3.tgz"
+ )
+
+
+def test_lockfile_meta_file_tarball():
+ lf = PnpmLockfile(path="/pnpm-lock.yaml")
+ lf.data = {
+ "packages": {
+ "/@babel/cli/7.6.2": {
+ "resolution": {
+ "integrity": "sha512-JDZ+T/br9pPfT2lmAMJypJDTTTHM9ePD/ED10TRjRzJVdEVy+JB3iRlhzYmTt5YkNgHvxWGlUVnLtdv6ruiDrQ==",
+ "tarball": "file:/some/abs/path.tgz",
+ },
+ },
+ },
+ }
+
+ packages = list(lf.get_packages_meta())
+ pkg = packages[0]
+
+ assert len(packages) == 1
+ assert pkg.name == "@babel/cli"
+ assert pkg.version == "7.6.2"
+ assert pkg.sky_id == ""
+
+
+def test_lockfile_update_tarball_resolutions_ok():
+ lf = PnpmLockfile(path="/pnpm-lock.yaml")
+ lf.data = {
+ "packages": {
+ "/@babel/cli/7.6.2_@babel+core@7.6.2": {
+ "resolution": {
+ "integrity": "sha512-JDZ+T/br9pPfT2lmAMJypJDTTTHM9ePD/ED10TRjRzJVdEVy+JB3iRlhzYmTt5YkNgHvxWGlUVnLtdv6ruiDrQ==",
+ "tarball": "@babel%2fcli/-/cli-7.6.2.tgz?rbtorrent=cb1849da3e4947e56a8f6bde6a1ec42703ddd187",
+ },
+ },
+ },
+ }
+
+ lf.update_tarball_resolutions(lambda p: p.name)
+
+ assert lf.data["packages"]["/@babel/cli/7.6.2_@babel+core@7.6.2"]["resolution"]["tarball"] == "@babel/cli"
+
+
+def test_lockfile_merge():
+ lf1 = PnpmLockfile(path="/foo/pnpm-lock.yaml")
+ lf1.data = {
+ "dependencies": {
+ "a": "1.0.0",
+ },
+ "specifiers": {
+ "a": "1.0.0",
+ },
+ "packages": {
+ "/a/1.0.0": {},
+ },
+ }
+
+ lf2 = PnpmLockfile(path="/bar/pnpm-lock.yaml")
+ lf2.data = {
+ "dependencies": {
+ "b": "1.0.0",
+ },
+ "specifiers": {
+ "b": "1.0.0",
+ },
+ "packages": {
+ "/b/1.0.0": {},
+ },
+ }
+
+ lf3 = PnpmLockfile(path="/another/baz/pnpm-lock.yaml")
+ lf3.data = {
+ "importers": {
+ ".": {
+ "dependencies": {
+ "@a/qux": "link:../qux",
+ "a": "1.0.0",
+ },
+ "specifiers": {
+ "@a/qux": "workspace:../qux",
+ "a": "1.0.0",
+ },
+ },
+ "../qux": {
+ "dependencies": {
+ "b": "1.0.1",
+ },
+ "specifiers": {
+ "b": "1.0.1",
+ },
+ },
+ },
+ "packages": {
+ "/a/1.0.0": {},
+ "/b/1.0.1": {},
+ },
+ }
+
+ lf4 = PnpmLockfile(path="/another/quux/pnpm-lock.yaml")
+ lf4.data = {
+ "dependencies": {
+ "@a/bar": "link:../../bar",
+ },
+ "specifiers": {
+ "@a/bar": "workspace:../../bar",
+ },
+ }
+
+ lf1.merge(lf2)
+ lf1.merge(lf3)
+ lf1.merge(lf4)
+
+ assert lf1.data == {
+ "importers": {
+ ".": {
+ "dependencies": {
+ "a": "1.0.0",
+ },
+ "specifiers": {
+ "a": "1.0.0",
+ },
+ },
+ "../bar": {
+ "dependencies": {
+ "b": "1.0.0",
+ },
+ "specifiers": {
+ "b": "1.0.0",
+ },
+ },
+ "../another/baz": {
+ "dependencies": {
+ "@a/qux": "link:../qux",
+ "a": "1.0.0",
+ },
+ "specifiers": {
+ "@a/qux": "workspace:../qux",
+ "a": "1.0.0",
+ },
+ },
+ "../another/qux": {
+ "dependencies": {
+ "b": "1.0.1",
+ },
+ "specifiers": {
+ "b": "1.0.1",
+ },
+ },
+ "../another/quux": {
+ "dependencies": {
+ "@a/bar": "link:../../bar",
+ },
+ "specifiers": {
+ "@a/bar": "workspace:../../bar",
+ },
+ },
+ },
+ "packages": {
+ "/a/1.0.0": {},
+ "/b/1.0.0": {},
+ "/b/1.0.1": {},
+ },
+ }
+
+
+def test_lockfile_merge_dont_overrides_packages():
+ lf1 = PnpmLockfile(path="/foo/pnpm-lock.yaml")
+ lf1.data = {
+ "dependencies": {
+ "a": "1.0.0",
+ },
+ "specifiers": {
+ "a": "1.0.0",
+ },
+ "packages": {
+ "/a/1.0.0": {},
+ },
+ }
+
+ lf2 = PnpmLockfile(path="/bar/pnpm-lock.yaml")
+ lf2.data = {
+ "dependencies": {
+ "a": "1.0.0",
+ "b": "1.0.0",
+ },
+ "specifiers": {
+ "a": "1.0.0",
+ "b": "1.0.0",
+ },
+ "packages": {
+ "/a/1.0.0": {
+ "overriden": True,
+ },
+ "/b/1.0.0": {},
+ },
+ }
+
+ lf1.merge(lf2)
+
+ assert lf1.data == {
+ "importers": {
+ ".": {
+ "dependencies": {
+ "a": "1.0.0",
+ },
+ "specifiers": {
+ "a": "1.0.0",
+ },
+ },
+ "../bar": {
+ "dependencies": {
+ "a": "1.0.0",
+ "b": "1.0.0",
+ },
+ "specifiers": {
+ "a": "1.0.0",
+ "b": "1.0.0",
+ },
+ },
+ },
+ "packages": {
+ "/a/1.0.0": {},
+ "/b/1.0.0": {},
+ },
+ }
diff --git a/build/plugins/lib/nots/package_manager/pnpm/tests/workspace.py b/build/plugins/lib/nots/package_manager/pnpm/tests/workspace.py
new file mode 100644
index 0000000000..ffc010de88
--- /dev/null
+++ b/build/plugins/lib/nots/package_manager/pnpm/tests/workspace.py
@@ -0,0 +1,68 @@
+from build.plugins.lib.nots.package_manager.base import PackageJson
+from build.plugins.lib.nots.package_manager.pnpm.workspace import PnpmWorkspace
+
+
+def test_workspace_get_paths():
+ ws = PnpmWorkspace(path="/packages/foo/pnpm-workspace.yaml")
+ ws.packages = set([".", "../bar", "../../another/baz"])
+
+ assert sorted(ws.get_paths()) == [
+ "/another/baz",
+ "/packages/bar",
+ "/packages/foo",
+ ]
+
+
+def test_workspace_get_paths_with_custom_base_path_without_self():
+ ws = PnpmWorkspace(path="/packages/foo/pnpm-workspace.yaml")
+ ws.packages = set([".", "../bar", "../../another/baz"])
+
+ assert sorted(ws.get_paths(base_path="some/custom/dir", ignore_self=True)) == [
+ "some/another/baz",
+ "some/custom/bar",
+ ]
+
+
+def test_workspace_set_from_package_json():
+ ws = PnpmWorkspace(path="/packages/foo/pnpm-workspace.yaml")
+ pj = PackageJson(path="/packages/foo/package.json")
+ pj.data = {
+ "dependencies": {
+ "@a/bar": "workspace:../bar",
+ },
+ "devDependencies": {
+ "@a/baz": "workspace:../../another/baz",
+ },
+ "peerDependencies": {
+ "@a/qux": "workspace:../../another/qux",
+ },
+ "optionalDependencies": {
+ "@a/quux": "workspace:../../another/quux",
+ },
+ }
+
+ ws.set_from_package_json(pj)
+
+ assert sorted(ws.get_paths()) == [
+ "/another/baz",
+ "/another/quux",
+ "/another/qux",
+ "/packages/bar",
+ "/packages/foo",
+ ]
+
+
+def test_workspace_merge():
+ ws1 = PnpmWorkspace(path="/packages/foo/pnpm-workspace.yaml")
+ ws1.packages = set([".", "../bar", "../../another/baz"])
+ ws2 = PnpmWorkspace(path="/another/baz/pnpm-workspace.yaml")
+ ws2.packages = set([".", "../qux"])
+
+ ws1.merge(ws2)
+
+ assert sorted(ws1.get_paths()) == [
+ "/another/baz",
+ "/another/qux",
+ "/packages/bar",
+ "/packages/foo",
+ ]
diff --git a/build/plugins/lib/nots/package_manager/pnpm/tests/ya.make b/build/plugins/lib/nots/package_manager/pnpm/tests/ya.make
new file mode 100644
index 0000000000..44877dfc1b
--- /dev/null
+++ b/build/plugins/lib/nots/package_manager/pnpm/tests/ya.make
@@ -0,0 +1,15 @@
+PY23_TEST()
+
+OWNER(g:frontend-build-platform)
+
+TEST_SRCS(
+ lockfile.py
+ workspace.py
+)
+
+PEERDIR(
+ build/plugins/lib/nots/package_manager/base
+ build/plugins/lib/nots/package_manager/pnpm
+)
+
+END()
diff --git a/build/plugins/lib/nots/package_manager/pnpm/utils.py b/build/plugins/lib/nots/package_manager/pnpm/utils.py
new file mode 100644
index 0000000000..1fa4291b9d
--- /dev/null
+++ b/build/plugins/lib/nots/package_manager/pnpm/utils.py
@@ -0,0 +1,11 @@
+import os
+
+from .constants import PNPM_LOCKFILE_FILENAME, PNPM_WS_FILENAME
+
+
+def build_lockfile_path(p):
+ return os.path.join(p, PNPM_LOCKFILE_FILENAME)
+
+
+def build_ws_config_path(p):
+ return os.path.join(p, PNPM_WS_FILENAME)
diff --git a/build/plugins/lib/nots/package_manager/pnpm/workspace.py b/build/plugins/lib/nots/package_manager/pnpm/workspace.py
new file mode 100644
index 0000000000..e596e20a18
--- /dev/null
+++ b/build/plugins/lib/nots/package_manager/pnpm/workspace.py
@@ -0,0 +1,81 @@
+import os
+import yaml
+
+
+class PnpmWorkspace(object):
+ @classmethod
+ def load(cls, path):
+ ws = cls(path)
+ ws.read()
+
+ return ws
+
+ def __init__(self, path):
+ if not os.path.isabs(path):
+ raise TypeError("Absolute path required, given: {}".format(path))
+
+ self.path = path
+ # NOTE: pnpm requires relative workspace paths.
+ self.packages = set()
+
+ def read(self):
+ with open(self.path) as f:
+ self.packages = set(yaml.load(f, Loader=yaml.CSafeLoader).get("packages", []))
+
+ def write(self, path=None):
+ if not path:
+ path = self.path
+
+ with open(path, "w") as f:
+ data = {
+ "packages": list(self.packages),
+ }
+ yaml.dump(data, f, Dumper=yaml.CSafeDumper)
+
+ def get_paths(self, base_path=None, ignore_self=False):
+ """
+ Returns absolute paths of the workspace packages.
+ :param base_path: base path to resolve relative dep paths
+ :type base_path: str
+ :param ignore_self: whether path of the current module will be excluded (if present)
+ :type ignore_self: bool
+ :rtype: list of str
+ """
+ if base_path is None:
+ base_path = os.path.dirname(self.path)
+
+ return [
+ os.path.normpath(os.path.join(base_path, pkg_path))
+ for pkg_path in self.packages
+ if not ignore_self or pkg_path != "."
+ ]
+
+ def set_from_package_json(self, package_json):
+ """
+ Sets packages to "workspace" deps from given package.json.
+ :param package_json: package.json of workspace
+ :type package_json: PackageJson
+ """
+ if os.path.dirname(package_json.path) != os.path.dirname(self.path):
+ raise TypeError(
+ "package.json should be in workspace directory {}, given: {}".format(
+ os.path.dirname(self.path), package_json.path
+ )
+ )
+
+ self.packages = set(path for _, path in package_json.get_workspace_dep_spec_paths())
+ # Add relative path to self.
+ self.packages.add(".")
+
+ def merge(self, ws):
+ """
+ Adds `ws`'s packages to the workspace.
+ :param ws: workspace to merge
+ :type ws: PnpmWorkspace
+ """
+ dir_path = os.path.dirname(self.path)
+ ws_dir_path = os.path.dirname(ws.path)
+
+ for p_rel_path in ws.packages:
+ p_path = os.path.normpath(os.path.join(ws_dir_path, p_rel_path))
+ self.packages.add(os.path.relpath(p_path, dir_path))
diff --git a/build/plugins/lib/nots/package_manager/pnpm/ya.make b/build/plugins/lib/nots/package_manager/pnpm/ya.make
new file mode 100644
index 0000000000..f57ae4a2ba
--- /dev/null
+++ b/build/plugins/lib/nots/package_manager/pnpm/ya.make
@@ -0,0 +1,24 @@
+PY23_LIBRARY()
+
+OWNER(g:frontend-build-platform)
+
+PY_SRCS(
+ __init__.py
+ constants.py
+ lockfile.py
+ package_manager.py
+ workspace.py
+ utils.py
+)
+
+PEERDIR(
+ build/plugins/lib/nots/package_manager/base
+ contrib/python/PyYAML
+ contrib/python/six
+)
+
+END()
+
+RECURSE_FOR_TESTS(
+ tests
+)