lockfile.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. import base64
  2. import yaml
  3. import os
  4. import io
  5. import re
  6. from urllib import parse as urlparse
  7. from ..base import PackageJson, BaseLockfile, LockfilePackageMeta, LockfilePackageMetaInvalidError
  8. LOCKFILE_VERSION = "lockfileVersion"
  9. IMPORTER_KEYS = PackageJson.DEP_KEYS + ("specifiers",)
  10. class PnpmLockfileHelper:
  11. """
  12. The class is to contain functionality for converting data structures
  13. from old lockfile versions to current one and from one data structure to another.
  14. """
  15. @staticmethod
  16. def parse_package_id_v6(package_id_v6):
  17. """
  18. Parses the package_id from v6 lockfile
  19. In v6 we have 'packages' with keys like '/[@scope/]package_name@version[(optional_dep_it_came_from)...]'
  20. e.g. '/@some-scope/some-package@1.2.3(@other-scope/some-core@2.3.4)'
  21. In v9 we have
  22. 1. "snapshots" with keys like '[@scope/]package_name@version[(optional_dep_it_came_from)...]'.
  23. e.g. '@some-scope/some-package@1.2.3(@other-scope/some-core@2.3.4)'
  24. 2. "packages" with keys like "[@scope/]package_name@version".
  25. e.g. '@some-scope/some-package@1.2.3'
  26. 3. "dependencies" with keys like "[@scope/]package_name" e.g. '@some-scope/some-package'
  27. and "version" field for having full version specifier e.g. '1.2.3(@other-scope/some-core@2.3.4)'
  28. Args:
  29. package_id_v6 (str): package_id from v6 lockfile.
  30. Raises:
  31. Exception: in case of invalid package_id
  32. Returns:
  33. str[]: values for v9 lockfile: [snapshot_id, package_id, package_name, version_specifier]
  34. """
  35. snapshot_id = PnpmLockfileHelper.snapshot_id_from_v6(package_id_v6)
  36. package_id = PnpmLockfileHelper.package_id_from_snapshot_id(snapshot_id)
  37. package_name = PnpmLockfileHelper.package_name_from_package_id(package_id)
  38. snapshot_version = snapshot_id[len(package_name) + 1 :]
  39. return snapshot_id, package_id, package_name, snapshot_version
  40. @staticmethod
  41. def snapshot_id_from_v6(package_id_v6):
  42. """
  43. Parses the package_id from v6 lockfile.
  44. In v6 we have "packages" with keys like '/@optional_scope/package_name@version(optional_dep_it_came_from)'
  45. e.g. '/@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.25.8)'
  46. In v9 we have "snapshots" with keys like "@optional_scope/package_name@version(optional dep it came from)".
  47. e.g. '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.25.8)'
  48. Args:
  49. package_id_v6 (str): package_id from v6 lockfile.
  50. Raises:
  51. Exception: in case of invalid package_id
  52. Returns:
  53. str: snapshot_id that can be used in v9 lockfile
  54. """
  55. if package_id_v6[0] != "/":
  56. raise Exception(f"Can't get snapshot id from package id: '{package_id_v6}'")
  57. return package_id_v6[1:]
  58. @staticmethod
  59. def package_id_from_snapshot_id(snapshot_id):
  60. """
  61. Parses the snapshot_id from v9 lockfile.
  62. In v9 we have "snapshots" with keys like "@optional_scope/package_name@version(optional dep it came from)".
  63. e.g. '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.25.8)'
  64. In v9 we have "packages" with keys like "@optional_scope/package_name@version".
  65. e.g. '@babel/plugin-syntax-class-properties@7.12.13'
  66. So we need to take only the part before first round bracket
  67. Args:
  68. snapshot_id (str): snapshot_id from v9 lockfile.
  69. Raises:
  70. Exception: in case of invalid snapshot_id
  71. Returns:
  72. str: package_id that can be used in v9 lockfile
  73. """
  74. package_id = snapshot_id.split("(", 2)[0]
  75. if not package_id:
  76. raise Exception(f"Can't get package id from snapshot id: '{snapshot_id}'")
  77. return package_id
  78. @staticmethod
  79. def package_name_from_package_id(package_id):
  80. """
  81. In v9 we have "packages" with keys like "@optional_scope/package_name@version".
  82. e.g. '@babel/plugin-syntax-class-properties@7.12.13'
  83. In v9 we have "dependencies" with keys like "@optional_scope/package_name".
  84. e.g. '@babel/plugin-syntax-class-properties'
  85. So we need to take only the part before last '@', and we can have one '@' for scope:
  86. Args:
  87. package_id (str): package_id from v9 lockfile.
  88. Raises:
  89. Exception: in case of invalid package_id
  90. Returns:
  91. str: package_name that can be used in v9 lockfile
  92. """
  93. package_specifier_elements = package_id.split("@", 3)
  94. if len(package_specifier_elements) > 1:
  95. package_specifier_elements.pop(-1)
  96. package_name = "@".join(package_specifier_elements)
  97. if not package_name:
  98. raise Exception(f"Can't get package name from package id: '{package_id}'")
  99. return package_name
  100. @staticmethod
  101. def ensure_v9(lockfile_data):
  102. """
  103. Checks if lockfile_data has version 9, returns lockfile_data as-is if so.
  104. If lockfile_data has version 6 then tries to apply transformations from v6 to v9
  105. """
  106. lockfile_version = lockfile_data.get(LOCKFILE_VERSION)
  107. if lockfile_version == "9.0":
  108. return lockfile_data
  109. if lockfile_version != "6.0":
  110. raise Exception(f"Invalid lockfile version: {lockfile_version}")
  111. # according to the spec
  112. # https://github.com/pnpm/pnpm/blob/f76ff6389b6252cca1653248444dac160ac1f052/lockfile/types/src/lockfileFileTypes.ts#L12C52-L12C154
  113. snapshots_data_keys = [
  114. "dependencies",
  115. "optionalDependencies",
  116. "patched",
  117. "optional",
  118. "transitivePeerDependencies",
  119. "id",
  120. ]
  121. ignore_data_list = ["dev"]
  122. snapshots = {}
  123. packages = {}
  124. importers_data = {}
  125. importer_keys_by_package_name = {}
  126. for importer_key in IMPORTER_KEYS:
  127. for package_name, data in lockfile_data.get(importer_key, {}).items():
  128. if importer_key not in importers_data:
  129. importers_data[importer_key] = {}
  130. importers_data[importer_key][package_name] = data
  131. importer_keys_by_package_name[package_name] = importer_key
  132. for package_v6_specifier, data in lockfile_data.get("packages", {}).items():
  133. snapshot_id, package_id, package_name, snapshot_version = PnpmLockfileHelper.parse_package_id_v6(
  134. package_v6_specifier
  135. )
  136. package_data = packages.get(package_id, {})
  137. snapshot_data = {}
  138. for key, value in data.items():
  139. if key in ignore_data_list:
  140. continue
  141. if key in snapshots_data_keys:
  142. snapshot_data[key] = value
  143. else:
  144. package_data[key] = value
  145. if package_data:
  146. packages[package_id] = package_data
  147. # Saving it to snapshots even if it's empty
  148. snapshots[snapshot_id] = snapshot_data
  149. if package_name in importer_keys_by_package_name:
  150. importer_key = importer_keys_by_package_name[package_name]
  151. importers_data[importer_key][package_name]["version"] = snapshot_version
  152. new_lockfile_data = {}
  153. new_lockfile_data.update(lockfile_data)
  154. # This part is already converted to importers_data
  155. for importer_key in IMPORTER_KEYS:
  156. if importer_key in new_lockfile_data:
  157. new_lockfile_data.pop(importer_key)
  158. new_lockfile_data["lockfileVersion"] = "9.0"
  159. if importers_data:
  160. new_lockfile_data["importers"] = {".": importers_data}
  161. if packages:
  162. new_lockfile_data["packages"] = packages
  163. if snapshots:
  164. new_lockfile_data["snapshots"] = snapshots
  165. return new_lockfile_data
  166. class PnpmLockfile(BaseLockfile):
  167. def read(self):
  168. # raise Exception("Reading lock file is not supported")
  169. with io.open(self.path, "rb") as f:
  170. data = yaml.load(f, Loader=yaml.CSafeLoader) or {LOCKFILE_VERSION: "9.0"}
  171. lockfile_version = "<no-version>"
  172. if isinstance(data, dict) and LOCKFILE_VERSION in data:
  173. lockfile_version = str(data.get(LOCKFILE_VERSION))
  174. r = re.compile('^[69]\\.\\d$')
  175. if not lockfile_version or not r.match(lockfile_version):
  176. raise Exception(
  177. f"Error of project configuration: {self.path} has lockfileVersion: {lockfile_version}.\n"
  178. "This version is not supported. Please, delete pnpm-lock.yaml and regenerate it using "
  179. "`ya tool nots --clean update-lockfile`"
  180. )
  181. self.data = PnpmLockfileHelper.ensure_v9(data)
  182. def write(self, path=None):
  183. """
  184. :param path: path to store lockfile, defaults to original path
  185. :type path: str
  186. """
  187. if path is None:
  188. path = self.path
  189. with open(path, "w") as f:
  190. yaml.dump(self.data, f, Dumper=yaml.CSafeDumper)
  191. def get_packages_meta(self):
  192. """
  193. Extracts packages meta from lockfile.
  194. :rtype: list of LockfilePackageMeta
  195. """
  196. packages = self.data.get("packages", {})
  197. return map(lambda x: _parse_package_meta(*x), packages.items())
  198. def update_tarball_resolutions(self, fn):
  199. """
  200. :param fn: maps `LockfilePackageMeta` instance to new `resolution.tarball` value
  201. :type fn: lambda
  202. """
  203. packages = self.data.get("packages", {})
  204. for key, meta in packages.items():
  205. meta["resolution"]["tarball"] = fn(_parse_package_meta(key, meta, allow_file_protocol=True))
  206. packages[key] = meta
  207. def get_importers(self):
  208. """
  209. Returns "importers" section from the lockfile or creates similar structure from "dependencies" and "specifiers".
  210. :rtype: dict of dict of dict of str
  211. """
  212. importers = self.data.get("importers")
  213. if importers is not None:
  214. return importers
  215. importer = {k: self.data[k] for k in IMPORTER_KEYS if k in self.data}
  216. return {".": importer} if importer else {}
  217. def merge(self, lf):
  218. """
  219. Merges two lockfiles:
  220. 1. Converts the lockfile to monorepo-like lockfile with "importers" section instead of "dependencies" and "specifiers".
  221. 2. Merges `lf`'s dependencies and specifiers to importers.
  222. 3. Merges `lf`'s packages to the lockfile.
  223. :param lf: lockfile to merge
  224. :type lf: PnpmLockfile
  225. """
  226. importers = self.get_importers()
  227. build_path = os.path.dirname(self.path)
  228. self.data = PnpmLockfileHelper.ensure_v9(self.data)
  229. lf.data = PnpmLockfileHelper.ensure_v9(lf.data)
  230. for importer, imports in lf.get_importers().items():
  231. importer_path = os.path.normpath(os.path.join(os.path.dirname(lf.path), importer))
  232. importer_rel_path = os.path.relpath(importer_path, build_path)
  233. importers[importer_rel_path] = imports
  234. self.data["importers"] = importers
  235. for k in IMPORTER_KEYS:
  236. self.data.pop(k, None)
  237. packages = self.data.get("packages", {})
  238. for k, v in lf.data.get("packages", {}).items():
  239. if k not in packages:
  240. packages[k] = v
  241. self.data["packages"] = packages
  242. snapshots = self.data.get("snapshots", {})
  243. for k, v in lf.data.get("snapshots", {}).items():
  244. if k not in snapshots:
  245. snapshots[k] = v
  246. self.data["snapshots"] = snapshots
  247. def validate_has_addons_flags(self):
  248. packages = self.data.get("packages", {})
  249. invalid_keys = []
  250. for key, meta in packages.items():
  251. if meta.get("requiresBuild") and "hasAddons" not in meta:
  252. invalid_keys.append(key)
  253. return (not invalid_keys, invalid_keys)
  254. # TODO: remove after dropping v6 support
  255. def get_requires_build_packages(self):
  256. packages = self.data.get("packages", {})
  257. requires_build_packages = []
  258. for pkg, meta in packages.items():
  259. if meta.get("requiresBuild"):
  260. requires_build_packages.append(pkg)
  261. return requires_build_packages
  262. def _parse_package_meta(key, meta, allow_file_protocol=False):
  263. """
  264. :param key: uniq package key from lockfile
  265. :type key: string
  266. :param meta: package meta dict from lockfile
  267. :type meta: dict
  268. :rtype: LockfilePackageMetaInvalidError
  269. """
  270. try:
  271. tarball_url = _parse_tarball_url(meta["resolution"]["tarball"], allow_file_protocol)
  272. sky_id = _parse_sky_id_from_tarball_url(meta["resolution"]["tarball"])
  273. integrity_algorithm, integrity = _parse_package_integrity(meta["resolution"]["integrity"])
  274. except KeyError as e:
  275. raise TypeError(f"Invalid package meta for '{key}', missing {e} key")
  276. except LockfilePackageMetaInvalidError as e:
  277. raise TypeError(f"Invalid package meta for '{key}', parse error: {e}")
  278. return LockfilePackageMeta(key, tarball_url, sky_id, integrity, integrity_algorithm)
  279. def _parse_tarball_url(tarball_url, allow_file_protocol):
  280. if tarball_url.startswith("file:") and not allow_file_protocol:
  281. raise LockfilePackageMetaInvalidError(f"tarball cannot point to a file, got '{tarball_url}'")
  282. return tarball_url.split("?")[0]
  283. def _parse_sky_id_from_tarball_url(tarball_url):
  284. """
  285. :param tarball_url: tarball url
  286. :type tarball_url: string
  287. :rtype: string
  288. """
  289. if tarball_url.startswith("file:"):
  290. return ""
  291. rbtorrent_param = urlparse.parse_qs(urlparse.urlparse(tarball_url).query).get("rbtorrent")
  292. if rbtorrent_param is None:
  293. return ""
  294. return f"rbtorrent:{rbtorrent_param[0]}"
  295. def _parse_package_integrity(integrity):
  296. """
  297. Returns tuple of algorithm and hash (hex).
  298. :param integrity: package integrity in format "{algo}-{base64_of_hash}"
  299. :type integrity: string
  300. :rtype: (str, str)
  301. """
  302. algo, hash_b64 = integrity.split("-", 1)
  303. if algo not in ("sha1", "sha512"):
  304. raise LockfilePackageMetaInvalidError(
  305. f"Invalid package integrity algorithm, expected one of ('sha1', 'sha512'), got '{algo}'"
  306. )
  307. try:
  308. base64.b64decode(hash_b64)
  309. except TypeError as e:
  310. raise LockfilePackageMetaInvalidError(f"Invalid package integrity encoding, integrity: {integrity}, error: {e}")
  311. return (algo, hash_b64)