package_manager.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. import hashlib
  2. import json
  3. import os
  4. import shutil
  5. from .constants import PNPM_PRE_LOCKFILE_FILENAME
  6. from .lockfile import PnpmLockfile
  7. from .utils import build_lockfile_path, build_pre_lockfile_path, build_ws_config_path
  8. from .workspace import PnpmWorkspace
  9. from ..base import BasePackageManager, PackageManagerError
  10. from ..base.constants import (
  11. NODE_MODULES_WORKSPACE_BUNDLE_FILENAME,
  12. PACKAGE_JSON_FILENAME,
  13. PNPM_LOCKFILE_FILENAME,
  14. )
  15. from ..base.node_modules_bundler import bundle_node_modules
  16. from ..base.package_json import PackageJson
  17. from ..base.timeit import timeit
  18. from ..base.utils import (
  19. b_rooted,
  20. build_nm_bundle_path,
  21. build_nm_path,
  22. build_nm_store_path,
  23. build_pj_path,
  24. home_dir,
  25. s_rooted,
  26. )
  27. class PnpmPackageManager(BasePackageManager):
  28. _STORE_NM_PATH = os.path.join(".pnpm", "store")
  29. _VSTORE_NM_PATH = os.path.join(".pnpm", "virtual-store")
  30. _STORE_VER = "v3"
  31. @classmethod
  32. def load_lockfile(cls, path):
  33. """
  34. :param path: path to lockfile
  35. :type path: str
  36. :rtype: PnpmLockfile
  37. """
  38. return PnpmLockfile.load(path)
  39. @classmethod
  40. def load_lockfile_from_dir(cls, dir_path):
  41. """
  42. :param dir_path: path to directory with lockfile
  43. :type dir_path: str
  44. :rtype: PnpmLockfile
  45. """
  46. return cls.load_lockfile(build_lockfile_path(dir_path))
  47. @staticmethod
  48. def get_local_pnpm_store():
  49. return os.path.join(home_dir(), ".cache", "pnpm-9-store")
  50. @staticmethod
  51. def get_local_old_pnpm_store():
  52. return os.path.join(home_dir(), ".cache", "pnpm-store")
  53. @timeit
  54. def _get_file_hash(self, path: str):
  55. sha256 = hashlib.sha256()
  56. with open(path, "rb") as f:
  57. # Read the file in chunks
  58. for chunk in iter(lambda: f.read(4096), b""):
  59. sha256.update(chunk)
  60. return sha256.hexdigest()
  61. @timeit
  62. def _create_local_node_modules(self, nm_store_path: str, store_dir: str, virtual_store_dir: str):
  63. """
  64. Creates ~/.nots/nm_store/$MODDIR/node_modules folder (with installed packages and .pnpm/virtual-store)
  65. Should be used after build for local development ($SOURCE_DIR/node_modules should be a symlink to this folder).
  66. But now it is also a workaround to provide valid node_modules structure in the parent folder of virtual-store
  67. It is needed for fixing custom module resolvers (like in tsc, webpack, etc...), which are trying to find modules in the parents directories
  68. """
  69. # provide files required for `pnpm install`
  70. pj = PackageJson.load(os.path.join(self.build_path, PACKAGE_JSON_FILENAME))
  71. required_files = [
  72. PACKAGE_JSON_FILENAME,
  73. PNPM_LOCKFILE_FILENAME,
  74. *list(pj.bins_iter()),
  75. *pj.get_pnpm_patched_dependencies().values(),
  76. ]
  77. for f in required_files:
  78. src = os.path.join(self.build_path, f)
  79. if os.path.exists(src):
  80. dst = os.path.join(nm_store_path, f)
  81. try:
  82. os.remove(dst)
  83. except FileNotFoundError:
  84. pass
  85. os.makedirs(os.path.dirname(dst), exist_ok=True)
  86. shutil.copy(src, dst)
  87. self._run_pnpm_install(store_dir, virtual_store_dir, nm_store_path)
  88. # Write node_modules.json to prevent extra `pnpm install` running 1
  89. with open(os.path.join(nm_store_path, "node_modules.json"), "w") as f:
  90. pre_pnpm_lockfile_hash = self._get_file_hash(build_pre_lockfile_path(self.build_path))
  91. json.dump({PNPM_PRE_LOCKFILE_FILENAME: {"hash": pre_pnpm_lockfile_hash}}, f)
  92. @timeit
  93. def create_node_modules(self, yatool_prebuilder_path=None, local_cli=False, bundle=True):
  94. """
  95. Creates node_modules directory according to the lockfile.
  96. """
  97. ws = self._prepare_workspace()
  98. self._copy_pnpm_patches()
  99. # Pure `tier 0` logic - isolated stores in the `build_root` (works in `distbuild` and `CI autocheck`)
  100. store_dir = self._nm_path(self._STORE_NM_PATH)
  101. virtual_store_dir = self._nm_path(self._VSTORE_NM_PATH)
  102. # Local mode optimizations (run from the `ya tool nots`)
  103. if local_cli:
  104. # Use single CAS for all the projects built locally
  105. store_dir = self.get_local_pnpm_store()
  106. nm_store_path = build_nm_store_path(self.module_path)
  107. # Use single virtual-store location in ~/.nots/nm_store/$MODDIR/node_modules/.pnpm/virtual-store
  108. virtual_store_dir = os.path.join(build_nm_path(nm_store_path), self._VSTORE_NM_PATH)
  109. self._create_local_node_modules(nm_store_path, store_dir, virtual_store_dir)
  110. self._run_pnpm_install(store_dir, virtual_store_dir, self.build_path)
  111. self._run_apply_addons_if_need(yatool_prebuilder_path, virtual_store_dir)
  112. self._replace_internal_lockfile_with_original(virtual_store_dir)
  113. if not local_cli and bundle:
  114. bundle_node_modules(
  115. build_root=self.build_root,
  116. node_modules_path=self._nm_path(),
  117. peers=ws.get_paths(base_path=self.module_path, ignore_self=True),
  118. bundle_path=os.path.join(self.build_path, NODE_MODULES_WORKSPACE_BUNDLE_FILENAME),
  119. )
  120. @timeit
  121. def _run_pnpm_install(self, store_dir: str, virtual_store_dir: str, cwd: str):
  122. install_cmd = [
  123. "install",
  124. "--frozen-lockfile",
  125. "--ignore-pnpmfile",
  126. "--ignore-scripts",
  127. "--no-verify-store-integrity",
  128. "--offline",
  129. "--config.confirmModulesPurge=false", # hack for https://st.yandex-team.ru/FBP-1295
  130. "--package-import-method",
  131. "hardlink",
  132. # "--registry" will be set later inside self._exec_command()
  133. "--store-dir",
  134. store_dir,
  135. "--strict-peer-dependencies",
  136. "--virtual-store-dir",
  137. virtual_store_dir,
  138. ]
  139. self._exec_command(install_cmd, cwd=cwd)
  140. @timeit
  141. def calc_prepare_deps_inouts_and_resources(
  142. self, store_path: str, has_deps: bool
  143. ) -> tuple[list[str], list[str], list[str]]:
  144. ins = [
  145. s_rooted(build_pj_path(self.module_path)),
  146. s_rooted(build_lockfile_path(self.module_path)),
  147. ]
  148. outs = [
  149. b_rooted(build_ws_config_path(self.module_path)),
  150. b_rooted(build_pre_lockfile_path(self.module_path)),
  151. ]
  152. resources = []
  153. if has_deps:
  154. for pkg in self.extract_packages_meta_from_lockfiles([build_lockfile_path(self.sources_path)]):
  155. resources.append(pkg.to_uri())
  156. outs.append(b_rooted(self._tarballs_store_path(pkg, store_path)))
  157. return ins, outs, resources
  158. @timeit
  159. def calc_node_modules_inouts(self, local_cli: bool, has_deps: bool) -> tuple[list[str], list[str]]:
  160. """
  161. Returns input and output paths for command that creates `node_modules` bundle.
  162. It relies on .PEERDIRSELF=TS_PREPARE_DEPS
  163. Inputs:
  164. - source package.json
  165. Outputs:
  166. - created node_modules bundle
  167. """
  168. ins = [s_rooted(build_pj_path(self.module_path))]
  169. outs = []
  170. if not local_cli and has_deps:
  171. outs.append(b_rooted(build_nm_bundle_path(self.module_path)))
  172. return ins, outs
  173. @timeit
  174. def extract_packages_meta_from_lockfiles(self, lf_paths):
  175. """
  176. :type lf_paths: iterable of BaseLockfile
  177. :rtype: iterable of LockfilePackageMeta
  178. """
  179. tarballs = set()
  180. errors = []
  181. for lf_path in lf_paths:
  182. try:
  183. for pkg in self.load_lockfile(lf_path).get_packages_meta():
  184. if pkg.tarball_path not in tarballs:
  185. tarballs.add(pkg.tarball_path)
  186. yield pkg
  187. except Exception as e:
  188. errors.append("{}: {}".format(lf_path, e))
  189. if errors:
  190. raise PackageManagerError("Unable to process some lockfiles:\n{}".format("\n".join(errors)))
  191. @timeit
  192. def _prepare_workspace(self):
  193. lf = self.load_lockfile(build_pre_lockfile_path(self.build_path))
  194. lf.update_tarball_resolutions(lambda p: "file:" + os.path.join(self.build_root, p.tarball_url))
  195. lf.write(build_lockfile_path(self.build_path))
  196. return PnpmWorkspace.load(build_ws_config_path(self.build_path))
  197. @timeit
  198. def build_workspace(self, tarballs_store: str):
  199. """
  200. :rtype: PnpmWorkspace
  201. """
  202. pj = self._build_package_json()
  203. ws = PnpmWorkspace(build_ws_config_path(self.build_path))
  204. ws.set_from_package_json(pj)
  205. dep_paths = ws.get_paths(ignore_self=True)
  206. self._build_merged_workspace_config(ws, dep_paths)
  207. self._build_merged_pre_lockfile(tarballs_store, dep_paths)
  208. return ws
  209. @timeit
  210. def _build_merged_pre_lockfile(self, tarballs_store, dep_paths):
  211. """
  212. :type dep_paths: list of str
  213. :rtype: PnpmLockfile
  214. """
  215. lf = self.load_lockfile_from_dir(self.sources_path)
  216. # Change to the output path for correct path calcs on merging.
  217. lf.path = build_pre_lockfile_path(self.build_path)
  218. lf.update_tarball_resolutions(lambda p: self._tarballs_store_path(p, tarballs_store))
  219. for dep_path in dep_paths:
  220. pre_lf_path = build_pre_lockfile_path(dep_path)
  221. if os.path.isfile(pre_lf_path):
  222. lf.merge(self.load_lockfile(pre_lf_path))
  223. lf.write()
  224. @timeit
  225. def _build_merged_workspace_config(self, ws, dep_paths):
  226. """
  227. NOTE: This method mutates `ws`.
  228. :type ws: PnpmWorkspaceConfig
  229. :type dep_paths: list of str
  230. """
  231. for dep_path in dep_paths:
  232. ws_config_path = build_ws_config_path(dep_path)
  233. if os.path.isfile(ws_config_path):
  234. ws.merge(PnpmWorkspace.load(ws_config_path))
  235. ws.write()
  236. @timeit
  237. def _run_apply_addons_if_need(self, yatool_prebuilder_path, virtual_store_dir):
  238. if not yatool_prebuilder_path:
  239. return
  240. self._exec_command(
  241. [
  242. "apply-addons",
  243. "--virtual-store",
  244. virtual_store_dir,
  245. ],
  246. cwd=self.build_path,
  247. include_defaults=False,
  248. script_path=os.path.join(yatool_prebuilder_path, "build", "bin", "prebuilder.js"),
  249. )
  250. @timeit
  251. def _replace_internal_lockfile_with_original(self, virtual_store_dir):
  252. original_lf_path = build_lockfile_path(self.sources_path)
  253. vs_lf_path = os.path.join(virtual_store_dir, "lock.yaml")
  254. shutil.copyfile(original_lf_path, vs_lf_path)
  255. @timeit
  256. def _copy_pnpm_patches(self):
  257. pj = self.load_package_json_from_dir(self.sources_path)
  258. patched_dependencies: dict[str, str] = pj.data.get("pnpm", {}).get("patchedDependencies", {})
  259. for p in patched_dependencies.values():
  260. patch_source_path = os.path.join(self.sources_path, p)
  261. patch_build_path = os.path.join(self.build_path, p)
  262. os.makedirs(os.path.dirname(patch_build_path), exist_ok=True)
  263. shutil.copyfile(patch_source_path, patch_build_path)
  264. @timeit
  265. def _get_default_options(self):
  266. return super(PnpmPackageManager, self)._get_default_options() + [
  267. "--stream",
  268. "--reporter",
  269. "append-only",
  270. "--no-color",
  271. ]
  272. @timeit
  273. def _get_debug_log_path(self):
  274. return self._nm_path(".pnpm-debug.log")