diff --git a/node/README.md b/node/README.md index 1ee5ff77..c716dc86 100644 --- a/node/README.md +++ b/node/README.md @@ -1,6 +1,6 @@ # flatpak-node-generator -A more modern successor for flatpak-npm-generator and flatpak-yarn-generator, for Node 10+ only. +A more modern successor for flatpak-npm-generator and flatpak-yarn-generator, for Node 10+ only. Supports npm, yarn, and pnpm. (For Node 8, use flatpak-npm-generator and flatpak-yarn-generator.) **NOTE:** `--xdg-layout` was recently changed to be the default. In the stark @@ -50,16 +50,24 @@ get npm with electron-builder. ## Usage ``` -usage: flatpak-node-generator [-h] [-o OUTPUT] [-r] [-R RECURSIVE_PATTERN] [--registry REGISTRY] [--no-trim-index] [--no-devel] [--no-requests-cache] [--max-parallel MAX_PARALLEL] [--retries RETRIES] [-P] - [-s] [-S SPLIT_SIZE] [--node-chromedriver-from-electron NODE_CHROMEDRIVER_FROM_ELECTRON] [--electron-ffmpeg {archive,lib}] [--electron-node-headers] - [--nwjs-version NWJS_VERSION] [--nwjs-node-headers] [--nwjs-ffmpeg] [--no-xdg-layout] [--node-sdk-extension NODE_SDK_EXTENSION] - {npm,yarn} lockfile +usage: flatpak-node-generator [-h] [-o OUTPUT] [-r] [-R RECURSIVE_PATTERN] [--registry REGISTRY] [--no-trim-index] + [--no-devel] [--no-requests-cache] + [--max-parallel MAX_PARALLEL] + [--retries RETRIES] [-P] [-s] [-S SPLIT_SIZE] + [--node-chromedriver-from-electron NODE_CHROMEDRIVER_FROM_ELECTRON] + [--electron-ffmpeg {archive,lib}] + [--electron-node-headers] + [--nwjs-version NWJS_VERSION] + [--nwjs-node-headers] [--nwjs-ffmpeg] + [--no-xdg-layout] + [--node-sdk-extension NODE_SDK_EXTENSION] + {npm,yarn,pnpm} lockfile Flatpak Node generator positional arguments: - {npm,yarn} - lockfile The lockfile path (package-lock.json or yarn.lock) + {npm,yarn,pnpm} + lockfile The lockfile path (package-lock.json, yarn.lock, or pnpm-lock.yaml) options: -h, --help show this help message and exit @@ -67,9 +75,9 @@ options: -r, --recursive Recursively process all files under the lockfile directory with the lockfile basename -R, --recursive-pattern RECURSIVE_PATTERN Given -r, restrict files to those matching the given pattern. - --registry REGISTRY The registry to use (npm only) + --registry REGISTRY The registry to use (npm/pnpm) --no-trim-index Don't trim npm package metadata (npm only) - --no-devel Don't include devel dependencies (npm only) + --no-devel Don't include devel dependencies (npm/pnpm) --no-requests-cache Disable the requests cache --max-parallel MAX_PARALLEL Maximium number of packages to process in parallel @@ -93,12 +101,12 @@ options: Flatpak node SDK extension (e.g. org.freedesktop.Sdk.Extension.node24//25.08) ``` -flatpak-node-generator.py takes the package manager (npm or yarn), and a path to a lockfile for -that package manager. It will then write an output sources file (default is generated-sources.json) -containing all the sources set up like needed for the given package manager. +flatpak-node-generator takes a package manager (npm, yarn, or pnpm), and a path to a lockfile for +that package manager. It writes an output sources file (default is generated-sources.json) +containing all the sources needed for the given package manager. -If you're on npm and you don't want to include devel dependencies, pass `--no-devel`, and pass -`--production` to `npm install` itself when you call. +If you're on npm or pnpm and you don't want to include devel dependencies, pass `--no-devel`. +For npm, also pass `--production` to `npm install` itself. If you're using npm, you must run this script when the `node_modules` directory is **NOT** present. If you generate the `generated-sources.json` in CI, you can do this by passing `--package-lock-only` diff --git a/node/flatpak_node_generator/main.py b/node/flatpak_node_generator/main.py index c9b4d159..07ee6e0a 100644 --- a/node/flatpak_node_generator/main.py +++ b/node/flatpak_node_generator/main.py @@ -13,6 +13,10 @@ from .progress import GeneratorProgress from .providers import ProviderFactory from .providers.npm import NpmLockfileProvider, NpmModuleProvider, NpmProviderFactory +from .providers.pnpm import ( + PnpmLockfileProvider, + PnpmProviderFactory, +) from .providers.special import SpecialSourceProvider from .providers.yarn import YarnProviderFactory from .requests import Requests, StubRequests @@ -28,9 +32,10 @@ def _scan_for_lockfiles(base: Path, patterns: list[str]) -> Iterator[Path]: async def _async_main() -> None: parser = argparse.ArgumentParser(description='Flatpak Node generator') - parser.add_argument('type', choices=['npm', 'yarn']) + parser.add_argument('type', choices=['npm', 'yarn', 'pnpm']) parser.add_argument( - 'lockfile', help='The lockfile path (package-lock.json or yarn.lock)' + 'lockfile', + help='The lockfile path (package-lock.json, yarn.lock, or pnpm-lock.yaml)', ) parser.add_argument( '-o', @@ -53,7 +58,7 @@ async def _async_main() -> None: ) parser.add_argument( '--registry', - help='The registry to use (npm only)', + help='The registry to use (npm/pnpm)', default='https://registry.npmjs.org', ) parser.add_argument( @@ -64,7 +69,7 @@ async def _async_main() -> None: parser.add_argument( '--no-devel', action='store_true', - help="Don't include devel dependencies (npm only)", + help="Don't include devel dependencies (npm/pnpm)", ) parser.add_argument( '--no-requests-cache', @@ -157,6 +162,9 @@ async def _async_main() -> None: if args.type == 'yarn' and (args.no_devel or args.no_autopatch): sys.exit('--no-devel and --no-autopatch do not apply to Yarn.') + if args.type == 'pnpm' and args.no_autopatch: + sys.exit('--no-autopatch does not apply to pnpm.') + if args.electron_chromedriver: print('WARNING: --electron-chromedriver is deprecated', file=sys.stderr) print( @@ -196,6 +204,14 @@ async def _async_main() -> None: provider_factory = NpmProviderFactory(lockfile_root, npm_options) elif args.type == 'yarn': provider_factory = YarnProviderFactory() + elif args.type == 'pnpm': + pnpm_options = PnpmProviderFactory.Options( + PnpmLockfileProvider.Options( + no_devel=args.no_devel, + registry=args.registry, + ), + ) + provider_factory = PnpmProviderFactory(lockfile_root, pnpm_options) else: assert False, args.type diff --git a/node/flatpak_node_generator/populate_pnpm_store.py b/node/flatpak_node_generator/populate_pnpm_store.py new file mode 100644 index 00000000..e0c5899d --- /dev/null +++ b/node/flatpak_node_generator/populate_pnpm_store.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import re +import sys +import tarfile +import time + +_SANITIZE_RE = re.compile(r'[\\/:*?"<>|]') + + +def populate_store(manifest_path: str, tarball_dir: str, store_dir: str) -> None: + with open(manifest_path, encoding='utf-8') as f: + manifest = json.load(f) + + store_version = manifest['store_version'] + packages = manifest['packages'] + + store = os.path.join(store_dir, store_version) + os.makedirs(os.path.join(store, 'files'), exist_ok=True) + os.makedirs(os.path.join(store, 'index'), exist_ok=True) + + now = int(time.time() * 1000) + + for tarball_name, info in packages.items(): + tarball_path = os.path.join(tarball_dir, tarball_name) + if not os.path.isfile(tarball_path): + raise FileNotFoundError(tarball_path) + + _process_tarball( + tarball_path=tarball_path, + pkg_name=info['name'], + pkg_version=info['version'], + integrity_hex=info['integrity_hex'], + store=store, + now=now, + ) + + +def _process_tarball( + *, + tarball_path: str, + pkg_name: str, + pkg_version: str, + integrity_hex: str, + store: str, + now: int, +) -> None: + index_files: dict[str, dict[str, object]] = {} + + with tarfile.open(tarball_path, 'r:gz') as tf: + for member in tf.getmembers(): + if not member.isfile(): + continue + fobj = tf.extractfile(member) + if fobj is None: + continue + data = fobj.read() + + digest = hashlib.sha512(data).digest() + file_hex = digest.hex() + is_exec = bool(member.mode & 0o111) + + cas_dir = os.path.join(store, 'files', file_hex[:2]) + cas_name = file_hex[2:] + ('-exec' if is_exec else '') + cas_path = os.path.join(cas_dir, cas_name) + if not os.path.exists(cas_path): + os.makedirs(cas_dir, exist_ok=True) + with open(cas_path, 'wb') as out: + out.write(data) + if is_exec: + os.chmod(cas_path, 0o755) + + rel_name = member.name + if '/' in rel_name: + rel_name = rel_name.split('/', 1)[1] + + b64 = base64.b64encode(digest).decode() + index_files[rel_name] = { + 'checkedAt': now, + 'integrity': f'sha512-{b64}', + 'mode': member.mode, + 'size': len(data), + } + + idx_prefix = integrity_hex[:2] + idx_rest = integrity_hex[2:64] + pkg_id = _SANITIZE_RE.sub('+', f'{pkg_name}@{pkg_version}') + idx_dir = os.path.join(store, 'index', idx_prefix) + os.makedirs(idx_dir, exist_ok=True) + idx_path = os.path.join(idx_dir, f'{idx_rest}-{pkg_id}.json') + index_data = { + 'name': pkg_name, + 'version': pkg_version, + 'files': index_files, + } + with open(idx_path, 'w', encoding='utf-8') as out: + json.dump(index_data, out) + + +if __name__ == '__main__': + if len(sys.argv) != 4: + print( + f'Usage: {sys.argv[0]} ', + file=sys.stderr, + ) + sys.exit(1) + populate_store(sys.argv[1], sys.argv[2], sys.argv[3]) diff --git a/node/flatpak_node_generator/providers/pnpm.py b/node/flatpak_node_generator/providers/pnpm.py new file mode 100644 index 00000000..382ed531 --- /dev/null +++ b/node/flatpak_node_generator/providers/pnpm.py @@ -0,0 +1,285 @@ +import json +import re +import sys +import types +from collections.abc import Iterator +from pathlib import Path +from typing import ( + Any, + NamedTuple, +) + +import yaml + +from ..integrity import Integrity +from ..manifest import ManifestGenerator +from ..package import ( + GitSource, + LocalSource, + Lockfile, + Package, + PackageSource, + ResolvedSource, +) +from . import LockfileProvider, ModuleProvider, ProviderFactory, RCFileProvider +from .npm import NpmRCFileProvider +from .special import SpecialSourceProvider + +_V6_FORMAT_VERSIONS = {6, 7} +_SUPPORTED_VERSIONS = {6, 7, 9} + +_STORE_VERSION_BY_LOCKFILE: dict[int, str] = { + 6: 'v3', + 7: 'v3', + 9: 'v10', +} + +_POPULATE_STORE_SCRIPT = Path(__file__).parents[1] / 'populate_pnpm_store.py' + + +class PnpmLockfileProvider(LockfileProvider): + """Parses pnpm-lock.yaml (v6/v7 and v9) into Package objects.""" + + _V6_PACKAGE_RE = re.compile(r'^/(?P(?:@[^/]+/)?[^@]+)@(?P[^(]+)') + _V9_PACKAGE_RE = re.compile(r'^(?P(?:@[^/]+/)?[^@]+)@(?P[^(]+)') + + class Options(NamedTuple): + no_devel: bool + registry: str + + def __init__(self, options: 'PnpmLockfileProvider.Options') -> None: + self.no_devel = options.no_devel + self.registry = options.registry.rstrip('/') + + def _get_tarball_url( + self, + name: str, + version: str, + resolution: dict[str, Any], + ) -> str: + if 'tarball' in resolution: + return str(resolution['tarball']) + + if name.startswith('@'): + basename = name.split('/')[-1] + else: + basename = name + + return f'{self.registry}/{name}/-/{basename}-{version}.tgz' + + def _parse_package_key(self, key: str, major: int) -> tuple[str, str] | None: + if major in _V6_FORMAT_VERSIONS: + match = self._V6_PACKAGE_RE.match(key) + else: + match = self._V9_PACKAGE_RE.match(key) + + if match is None: + return None + return match.group('name'), match.group('version') + + def process_lockfile(self, lockfile_path: Path) -> Iterator[Package]: + with open(lockfile_path, encoding='utf-8') as fp: + data = yaml.safe_load(fp) + + raw_version = data.get('lockfileVersion') + if raw_version is None: + raise ValueError(f'{lockfile_path}: missing lockfileVersion field') + + major = int(str(raw_version).split('.', 1)[0]) + if major not in _SUPPORTED_VERSIONS: + supported = ', '.join(str(v) for v in sorted(_SUPPORTED_VERSIONS)) + raise ValueError( + f'{lockfile_path}: unsupported lockfileVersion {raw_version}. ' + f'Supported versions: {supported}.' + ) + + if self.no_devel and major not in _V6_FORMAT_VERSIONS: + print( + 'WARNING: --no-devel is not yet supported for pnpm lockfile v9; ' + 'all packages will be included.', + file=sys.stderr, + ) + + lockfile = Lockfile(lockfile_path, major) + + packages_dict: dict[str, Any] = data.get('packages', {}) + if not packages_dict: + return + + for key, info in packages_dict.items(): + if info is None: + continue + + parsed = self._parse_package_key(key, major) + if parsed is None: + continue + + name, version = parsed + + # NOTE v6/v7: dev packages have dev: true directly on the entry + if self.no_devel and info.get('dev', False): + continue + + resolution: dict[str, Any] = info.get('resolution', {}) + if not resolution: + continue + + source: PackageSource + + if resolution.get('type') == 'git': + source = self.parse_git_source( + f'git+{resolution["repo"]}#{resolution["commit"]}' + ) + elif 'directory' in resolution: + source = LocalSource(path=resolution['directory']) + else: + integrity = None + if 'integrity' in resolution: + integrity = Integrity.parse(resolution['integrity']) + + tarball_url = self._get_tarball_url(name, version, resolution) + source = ResolvedSource(resolved=tarball_url, integrity=integrity) + + yield Package( + name=name, + version=version, + source=source, + lockfile=lockfile, + ) + + +class PnpmModuleProvider(ModuleProvider): + """Generates flatpak sources for pnpm packages.""" + + class _TarballInfo(NamedTuple): + tarball_name: str + name: str + version: str + integrity: Integrity + + def __init__( + self, + gen: ManifestGenerator, + special: SpecialSourceProvider, + lockfile_root: Path, + ) -> None: + self.gen = gen + self.special_source_provider = special + self.lockfile_root = lockfile_root + self.tarball_dir = self.gen.data_root / 'pnpm-tarballs' + self.store_dir = self.gen.data_root / 'pnpm-store' + self._tarballs: list[PnpmModuleProvider._TarballInfo] = [] + self._store_version: str | None = None + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: types.TracebackType | None, + ) -> None: + if exc_type is None: + self._finalize() + + async def generate_package(self, package: Package) -> None: + if self._store_version is None: + self._store_version = _STORE_VERSION_BY_LOCKFILE[package.lockfile.version] + + source = package.source + + if isinstance(source, ResolvedSource): + assert source.resolved is not None + + if source.integrity is None: + print( + f'WARNING: skipping {package.name}@{package.version}: ' + 'no integrity in lockfile (required for pnpm store)', + file=sys.stderr, + ) + return + + # Use name-version as filename; replace / in scoped names + tarball_name = f'{package.name.replace("/", "__")}-{package.version}.tgz' + self.gen.add_url_source( + url=source.resolved, + integrity=source.integrity, + destination=self.tarball_dir / tarball_name, + ) + self._tarballs.append( + self._TarballInfo( + tarball_name=tarball_name, + name=package.name, + version=package.version, + integrity=source.integrity, + ) + ) + + await self.special_source_provider.generate_special_sources(package) + + elif isinstance(source, GitSource): + name = f'{package.name}-{source.commit}' + path = self.gen.data_root / 'git-packages' / name + self.gen.add_git_source(source.url, source.commit, path) + + elif isinstance(source, LocalSource): + pass + + else: + raise NotImplementedError( + f'Unknown source type {source.__class__.__name__}' + ) + + def _finalize(self) -> None: + if self._tarballs: + self._add_store_population_script() + self._add_pnpm_config() + + def _add_store_population_script(self) -> None: + packages = {} + for info in self._tarballs: + packages[info.tarball_name] = { + 'name': info.name, + 'version': info.version, + 'integrity_hex': info.integrity.digest, + } + + manifest = { + 'store_version': self._store_version, + 'packages': packages, + } + manifest_json = json.dumps(manifest, separators=(',', ':'), sort_keys=True) + manifest_dest = self.gen.data_root / 'pnpm-manifest.json' + self.gen.add_data_source(manifest_json, manifest_dest) + + with open(_POPULATE_STORE_SCRIPT, encoding='utf-8') as f: + script_source = f.read() + script_dest = self.gen.data_root / 'populate_pnpm_store.py' + self.gen.add_data_source(script_source, script_dest) + + self.gen.add_command( + f'python3 {script_dest} {manifest_dest} {self.tarball_dir} {self.store_dir}' + ) + + def _add_pnpm_config(self) -> None: + self.gen.add_command(f'echo "store-dir=$PWD/{self.store_dir}" >> .npmrc') + + +class PnpmProviderFactory(ProviderFactory): + class Options(NamedTuple): + lockfile: PnpmLockfileProvider.Options + + def __init__( + self, lockfile_root: Path, options: 'PnpmProviderFactory.Options' + ) -> None: + self.lockfile_root = lockfile_root + self.options = options + + def create_lockfile_provider(self) -> PnpmLockfileProvider: + return PnpmLockfileProvider(self.options.lockfile) + + def create_rcfile_providers(self) -> list[RCFileProvider]: + return [NpmRCFileProvider()] + + def create_module_provider( + self, gen: ManifestGenerator, special: SpecialSourceProvider + ) -> PnpmModuleProvider: + return PnpmModuleProvider(gen, special, self.lockfile_root) diff --git a/node/poetry.lock b/node/poetry.lock index 4730f477..d3de5bfe 100644 --- a/node/poetry.lock +++ b/node/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -1187,7 +1187,7 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -1350,6 +1350,18 @@ files = [ {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250915" +description = "Typing stubs for PyYAML" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6"}, + {file = "types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3"}, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1527,4 +1539,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "<4.0,>=3.10" -content-hash = "0a6b7a6e14a54af3d1b153b4ada3c3650090b39695cca8ff8893bccef30bf27e" +content-hash = "ad84316ae77727ff6b6501d7beb081c4a6098b1321c193c5b222d7acbea49918" diff --git a/node/pyproject.toml b/node/pyproject.toml index f532cfc3..77f86727 100644 --- a/node/pyproject.toml +++ b/node/pyproject.toml @@ -11,6 +11,7 @@ authors = [ requires-python = "<4.0,>=3.10" dependencies = [ "aiohttp<4.0.0,>=3.9.0", + "pyyaml<7.0,>=6.0", ] [tool.poetry.group.dev.dependencies] @@ -22,6 +23,7 @@ pytest-datadir = ">=1,<2" pytest-httpserver = ">=1,<2" pytest-xdist = ">=3,<4" poethepoet = ">=0.34,<1" +types-pyyaml = ">=6.0.12.2,<7" [tool.poetry.scripts] flatpak-node-generator = "flatpak_node_generator.main:main" diff --git a/node/tests/test_pnpm.py b/node/tests/test_pnpm.py new file mode 100644 index 00000000..24b3577b --- /dev/null +++ b/node/tests/test_pnpm.py @@ -0,0 +1,327 @@ +from pathlib import Path + +import pytest + +from flatpak_node_generator.integrity import Integrity +from flatpak_node_generator.package import ( + GitSource, + LocalSource, + Lockfile, + Package, + ResolvedSource, +) +from flatpak_node_generator.providers.pnpm import PnpmLockfileProvider + +TEST_LOCKFILE_V9 = """ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: + dependencies: + is-odd: + specifier: ^3.0.1 + version: 3.0.1 + +packages: + is-number@6.0.0: + resolution: {integrity: sha512-Wu1VHeILBK8KAWJUAiSZQX94GmOE45Rg6/538fKwiloUu21KncEkYGPqob2oSZ5mUT73vLGrHQjKw3KMPwfDzg==} + engines: {node: '>=0.10.0'} + + is-odd@3.0.1: + resolution: {integrity: sha512-CQpnWPrDwmP1+SMHXZhtLtJv90yiyVfluGsX5iNCVkrhQtU3TQHsUWPG9wkdk9Lgd5yNpAg9jQEo90CBaXgWMA==} + engines: {node: '>=4'} + + '@babel/core@7.23.0': + resolution: {integrity: sha256-dGVzdA==} + engines: {node: '>=6.9.0'} + +snapshots: + is-number@6.0.0: {} + + is-odd@3.0.1: + dependencies: + is-number: 6.0.0 + + '@babel/core@7.23.0': {} +""" + +TEST_LOCKFILE_V6 = """ +lockfileVersion: '6.0' + +dependencies: + is-odd: + specifier: ^3.0.1 + version: 3.0.1 + +devDependencies: + is-number: + specifier: ^6.0.0 + version: 6.0.0 + +packages: + /is-number@6.0.0: + resolution: {integrity: sha512-Wu1VHeILBK8KAWJUAiSZQX94GmOE45Rg6/538fKwiloUu21KncEkYGPqob2oSZ5mUT73vLGrHQjKw3KMPwfDzg==} + engines: {node: '>=0.10.0'} + dev: true + + /is-odd@3.0.1: + resolution: {integrity: sha512-CQpnWPrDwmP1+SMHXZhtLtJv90yiyVfluGsX5iNCVkrhQtU3TQHsUWPG9wkdk9Lgd5yNpAg9jQEo90CBaXgWMA==} + engines: {node: '>=4'} + dev: false + + /next@14.0.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha256-dGVzdA==} + engines: {node: '>=18'} + dev: false +""" + + +def test_lockfile_v9(tmp_path: Path) -> None: + provider = PnpmLockfileProvider( + PnpmLockfileProvider.Options( + no_devel=False, + registry='https://registry.npmjs.org', + ) + ) + + lockfile = Lockfile(tmp_path / 'pnpm-lock.yaml', 9) + lockfile.path.write_text(TEST_LOCKFILE_V9) + + packages = list(provider.process_lockfile(lockfile.path)) + + assert packages == [ + Package( + lockfile=lockfile, + name='is-number', + version='6.0.0', + source=ResolvedSource( + resolved='https://registry.npmjs.org/is-number/-/is-number-6.0.0.tgz', + integrity=Integrity( + 'sha512', + '5aed551de20b04af0a016254022499417f781a6384e39460ebfe77f1f2b08a5a14bb6d4a9dc1246063eaa1bda8499e66513ef7bcb1ab1d08cac3728c3f07c3ce', + ), + ), + ), + Package( + lockfile=lockfile, + name='is-odd', + version='3.0.1', + source=ResolvedSource( + resolved='https://registry.npmjs.org/is-odd/-/is-odd-3.0.1.tgz', + integrity=Integrity( + 'sha512', + '090a6758fac3c263f5f923075d986d2ed26ff74ca2c957e5b86b17e62342564ae142d5374d01ec5163c6f7091d93d2e0779c8da4083d8d0128f7408169781630', + ), + ), + ), + Package( + lockfile=lockfile, + name='@babel/core', + version='7.23.0', + source=ResolvedSource( + resolved='https://registry.npmjs.org/@babel/core/-/core-7.23.0.tgz', + integrity=Integrity( + 'sha256', + '74657374', + ), + ), + ), + ] + + +def test_lockfile_v6(tmp_path: Path) -> None: + provider = PnpmLockfileProvider( + PnpmLockfileProvider.Options( + no_devel=False, + registry='https://registry.npmjs.org', + ) + ) + + lockfile = Lockfile(tmp_path / 'pnpm-lock.yaml', 6) + lockfile.path.write_text(TEST_LOCKFILE_V6) + + packages = list(provider.process_lockfile(lockfile.path)) + + assert packages == [ + Package( + lockfile=lockfile, + name='is-number', + version='6.0.0', + source=ResolvedSource( + resolved='https://registry.npmjs.org/is-number/-/is-number-6.0.0.tgz', + integrity=Integrity( + 'sha512', + '5aed551de20b04af0a016254022499417f781a6384e39460ebfe77f1f2b08a5a14bb6d4a9dc1246063eaa1bda8499e66513ef7bcb1ab1d08cac3728c3f07c3ce', + ), + ), + ), + Package( + lockfile=lockfile, + name='is-odd', + version='3.0.1', + source=ResolvedSource( + resolved='https://registry.npmjs.org/is-odd/-/is-odd-3.0.1.tgz', + integrity=Integrity( + 'sha512', + '090a6758fac3c263f5f923075d986d2ed26ff74ca2c957e5b86b17e62342564ae142d5374d01ec5163c6f7091d93d2e0779c8da4083d8d0128f7408169781630', + ), + ), + ), + Package( + lockfile=lockfile, + name='next', + version='14.0.5', + source=ResolvedSource( + resolved='https://registry.npmjs.org/next/-/next-14.0.5.tgz', + integrity=Integrity( + 'sha256', + '74657374', + ), + ), + ), + ] + + +def test_lockfile_v6_no_devel(tmp_path: Path) -> None: + provider = PnpmLockfileProvider( + PnpmLockfileProvider.Options( + no_devel=True, + registry='https://registry.npmjs.org', + ) + ) + + lockfile = Lockfile(tmp_path / 'pnpm-lock.yaml', 6) + lockfile.path.write_text(TEST_LOCKFILE_V6) + + packages = list(provider.process_lockfile(lockfile.path)) + + names = [p.name for p in packages] + assert 'is-number' not in names + assert 'is-odd' in names + assert 'next' in names + + +def test_lockfile_v5_rejected(tmp_path: Path) -> None: + provider = PnpmLockfileProvider( + PnpmLockfileProvider.Options( + no_devel=False, + registry='https://registry.npmjs.org', + ) + ) + + lockfile_path = tmp_path / 'pnpm-lock.yaml' + lockfile_path.write_text( + 'lockfileVersion: 5.4\npackages:\n' + ' /is-odd/3.0.1:\n' + ' resolution: {integrity: sha256-dGVzdA==}\n' + ) + + with pytest.raises(ValueError, match='unsupported lockfileVersion 5.4'): + list(provider.process_lockfile(lockfile_path)) + + +def test_lockfile_unsupported_version_rejected(tmp_path: Path) -> None: + provider = PnpmLockfileProvider( + PnpmLockfileProvider.Options( + no_devel=False, + registry='https://registry.npmjs.org', + ) + ) + + lockfile_path = tmp_path / 'pnpm-lock.yaml' + lockfile_path.write_text( + 'lockfileVersion: 42\npackages:\n' + ' foo@1.0.0:\n' + ' resolution: {integrity: sha256-dGVzdA==}\n' + ) + + with pytest.raises(ValueError, match='unsupported lockfileVersion 42'): + list(provider.process_lockfile(lockfile_path)) + + +def test_lockfile_v9_no_devel_warns( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + provider = PnpmLockfileProvider( + PnpmLockfileProvider.Options( + no_devel=True, + registry='https://registry.npmjs.org', + ) + ) + + lockfile = Lockfile(tmp_path / 'pnpm-lock.yaml', 9) + lockfile.path.write_text(TEST_LOCKFILE_V9) + + packages = list(provider.process_lockfile(lockfile.path)) + + captured = capsys.readouterr() + assert '--no-devel is not yet supported for pnpm lockfile v9' in captured.err + assert len(packages) == 3 + + +TEST_LOCKFILE_V9_GIT_AND_LOCAL = """ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: + dependencies: + my-git-pkg: + specifier: github:user/repo#abc123 + version: github.com/user/repo/abc123 + my-local-pkg: + specifier: file:../local-pkg + version: file:../local-pkg + +packages: + my-git-pkg@github.com/user/repo/abc123: + resolution: {type: git, repo: https://github.com/user/repo, commit: abc123def456} + + my-local-pkg@file:../local-pkg: + resolution: {directory: ../local-pkg} + +snapshots: + my-git-pkg@github.com/user/repo/abc123: {} + my-local-pkg@file:../local-pkg: {} +""" + + +def test_lockfile_v9_git_and_local(tmp_path: Path) -> None: + provider = PnpmLockfileProvider( + PnpmLockfileProvider.Options( + no_devel=False, + registry='https://registry.npmjs.org', + ) + ) + + lockfile = Lockfile(tmp_path / 'pnpm-lock.yaml', 9) + lockfile.path.write_text(TEST_LOCKFILE_V9_GIT_AND_LOCAL) + + packages = list(provider.process_lockfile(lockfile.path)) + + assert packages == [ + Package( + lockfile=lockfile, + name='my-git-pkg', + version='github.com/user/repo/abc123', + source=GitSource( + original='git+https://github.com/user/repo#abc123def456', + url='https://github.com/user/repo', + commit='abc123def456', + from_=None, + ), + ), + Package( + lockfile=lockfile, + name='my-local-pkg', + version='file:../local-pkg', + source=LocalSource(path='../local-pkg'), + ), + ]