• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pantsbuild / pants / 24874135578

24 Apr 2026 05:37AM UTC coverage: 92.941% (+0.005%) from 92.936%
24874135578

Pull #23290

github

web-flow
Merge c4cd249c9 into 7c1af4068
Pull Request #23290: Support lockfile generation and include lockfiles for NodeJSToolBase subsystems

218 of 229 new or added lines in 5 files covered. (95.2%)

1 existing line in 1 file now uncovered.

92029 of 99019 relevant lines covered (92.94%)

4.04 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.33
/src/python/pants/backend/javascript/subsystems/nodejs_tool.py
1
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
8✔
5

6
import importlib.resources
8✔
7
import json
8✔
8
import os.path
8✔
9
from collections.abc import Iterable, Mapping
8✔
10
from dataclasses import dataclass, field
8✔
11
from typing import ClassVar
8✔
12

13
from pants.backend.javascript import install_node_package, nodejs_project_environment
8✔
14
from pants.backend.javascript.install_node_package import (
8✔
15
    InstalledNodePackageRequest,
16
    install_node_packages_for_address,
17
)
18
from pants.backend.javascript.nodejs_project_environment import (
8✔
19
    NodeJsProjectEnvironmentProcess,
20
    setup_nodejs_project_environment_process,
21
)
22
from pants.backend.javascript.package_manager import PackageManager
8✔
23
from pants.backend.javascript.resolve import (
8✔
24
    resolve_to_first_party_node_package,
25
    resolve_to_projects,
26
)
27
from pants.backend.javascript.subsystems.nodejs import (
8✔
28
    NodeJS,
29
    NodeJSToolProcess,
30
    setup_node_tool_process,
31
)
32
from pants.core.goals.generate_lockfiles import DEFAULT_TOOL_LOCKFILE
8✔
33
from pants.core.goals.resolves import ExportableTool
8✔
34
from pants.engine.fs import CreateDigest, FileContent, GlobMatchErrorBehavior, PathGlobs
8✔
35
from pants.engine.internals.native_engine import Digest, MergeDigests
8✔
36
from pants.engine.intrinsics import (
8✔
37
    create_digest,
38
    get_digest_contents,
39
    merge_digests,
40
    path_globs_to_digest,
41
)
42
from pants.engine.process import Process, fallible_to_exec_result_or_raise
8✔
43
from pants.engine.rules import Rule, collect_rules, implicitly, rule
8✔
44
from pants.engine.unions import UnionRule
8✔
45
from pants.option.option_types import StrOption
8✔
46
from pants.option.subsystem import Subsystem
8✔
47
from pants.util.frozendict import FrozenDict
8✔
48
from pants.util.logging import LogLevel
8✔
49
from pants.util.strutil import softwrap
8✔
50

51

52
class NodeJSToolBase(Subsystem, ExportableTool):
8✔
53
    # Subclasses must set.
54
    default_version: ClassVar[str]
8✔
55

56
    default_lockfile_resources: ClassVar[dict[str, tuple[str, str]] | None] = None
8✔
57

58
    # Set to False for tools that always use a resolve (e.g. TypeScript).
59
    lockfile_required: ClassVar[bool] = True
8✔
60

61
    version = StrOption(
8✔
62
        advanced=True,
63
        default=lambda cls: cls.default_version,
64
        help="Version string for the tool in the form package@version (e.g. prettier@3.6.2)",
65
    )
66

67
    _binary_name = StrOption(
8✔
68
        advanced=True,
69
        default=None,
70
        help="Override the binary to run for this tool. Defaults to the package name.",
71
    )
72

73
    lockfile = StrOption(
8✔
74
        advanced=True,
75
        default=lambda cls: DEFAULT_TOOL_LOCKFILE if cls.default_lockfile_resources else None,
76
        help=lambda cls: softwrap(
77
            f"""\
78
            Path to a lockfile for the tool's npm dependencies. If set to
79
            `{DEFAULT_TOOL_LOCKFILE}`, the bundled lockfile will be used. If set to a
80
            file path, that lockfile will be used instead. If set to `None` or empty
81
            string, no lockfile will be used and the tool will be installed without
82
            pinned transitive dependencies.
83
            """
84
        ),
85
    )
86

87
    @property
8✔
88
    def binary_name(self) -> str:
8✔
89
        if self._binary_name:
5✔
90
            return self._binary_name
1✔
91

92
        package_name, _ = _split_package_spec(self.version)
5✔
93
        if not package_name:
5✔
94
            raise ValueError(f"Invalid npm package specification: {self.version}")
1✔
95
        if package_name.startswith("@"):
5✔
96
            # Scoped packages (`@scope/name`) conventionally expose a binary named after the scope,
97
            # e.g. `@redocly/cli` → `redocly`.
98
            return package_name[1:].split("/", 1)[0]
2✔
99
        return package_name
4✔
100

101
    install_from_resolve = StrOption(
8✔
102
        advanced=True,
103
        default=None,
104
        help=lambda cls: softwrap(
105
            f"""\
106
            If specified, install the tool using the lockfile for this named resolve,
107
            instead of the version configured in this subsystem.
108

109
            If unspecified, the tool will use the default configured package manager
110
            [{NodeJS.options_scope}].package_manager`, and install the tool without a
111
            lockfile.
112
            """
113
        ),
114
    )
115

116
    def __init_subclass__(cls, **kwargs):
8✔
117
        super().__init_subclass__(**kwargs)
8✔
118
        # If a tool subsystem didn't set `default_lockfile_resources` explicitly, derive it by
119
        # convention from the subsystem's own module and `options_scope`. Tools that don't ship
120
        # bundled lockfiles opt out with `lockfile_required = False` (e.g. TypeScript).
121
        if cls.lockfile_required and cls.default_lockfile_resources is None:
8✔
122
            cls.default_lockfile_resources = bundled_lockfiles(
7✔
123
                cls.__module__.rsplit(".", 1)[0], cls.options_scope
124
            )
125

126
    def request(
8✔
127
        self,
128
        args: tuple[str, ...],
129
        input_digest: Digest,
130
        description: str,
131
        level: LogLevel,
132
        output_files: tuple[str, ...] = (),
133
        output_directories: tuple[str, ...] = (),
134
        append_only_caches: FrozenDict[str, str] | None = None,
135
        project_caches: FrozenDict[str, str] | None = None,
136
        timeout_seconds: int | None = None,
137
        extra_env: Mapping[str, str] | None = None,
138
    ) -> NodeJSToolRequest:
139
        return NodeJSToolRequest(
7✔
140
            package=self.version,
141
            binary_name=self.binary_name,
142
            resolve=self.install_from_resolve,
143
            lockfile=self.lockfile if self.lockfile else None,
144
            default_lockfile_resources=FrozenDict(self.default_lockfile_resources)
145
            if self.default_lockfile_resources
146
            else None,
147
            args=args,
148
            input_digest=input_digest,
149
            description=description,
150
            level=level,
151
            output_files=output_files,
152
            output_directories=output_directories,
153
            append_only_caches=append_only_caches or FrozenDict(),
154
            project_caches=project_caches or FrozenDict(),
155
            timeout_seconds=timeout_seconds,
156
            extra_env=extra_env or FrozenDict(),
157
            options_scope=self.options_scope,
158
        )
159

160

161
@dataclass(frozen=True)
8✔
162
class NodeJSToolRequest:
8✔
163
    package: str
8✔
164
    binary_name: str
8✔
165
    resolve: str | None
8✔
166
    lockfile: str | None
8✔
167
    default_lockfile_resources: FrozenDict[str, tuple[str, str]] | None
8✔
168
    args: tuple[str, ...]
8✔
169
    input_digest: Digest
8✔
170
    description: str
8✔
171
    level: LogLevel
8✔
172
    options_scope: str
8✔
173
    output_files: tuple[str, ...] = ()
8✔
174
    output_directories: tuple[str, ...] = ()
8✔
175
    append_only_caches: FrozenDict[str, str] = field(default_factory=FrozenDict)
8✔
176
    project_caches: FrozenDict[str, str] = field(default_factory=FrozenDict)
8✔
177
    timeout_seconds: int | None = None
8✔
178
    extra_env: Mapping[str, str] = field(default_factory=FrozenDict)
8✔
179

180

181
def _split_package_spec(package: str) -> tuple[str, str | None]:
8✔
182
    """Split `name[@version]` or `@scope/name[@version]` into (name, version_or_None).
183

184
    Scoped packages (leading `@`) have their version separator at the second `@`, not the first.
185
    """
186
    # Skip the leading `@` for scoped packages so we find the version separator (the second `@`).
187
    search_start = 1 if package.startswith("@") else 0
7✔
188
    at_idx = package.find("@", search_start)
7✔
189
    if at_idx == -1:
7✔
190
        return package, None
1✔
191
    return package[:at_idx], package[at_idx + 1 :]
7✔
192

193

194
def _parse_package_name_and_version(package: str) -> tuple[str, str]:
8✔
195
    name, version = _split_package_spec(package)
7✔
196
    if not name or version is None:
7✔
197
        raise ValueError(
1✔
198
            f"Invalid npm package specification '{package}': expected format 'package@version' "
199
            f"or '@scope/package@version'."
200
        )
201
    return name, version
7✔
202

203

204
def _tool_package_json_bytes(resolve_name: str, package_name: str, package_version: str) -> bytes:
8✔
205
    return json.dumps(
7✔
206
        {
207
            "name": f"pants-tool-{resolve_name}",
208
            "private": True,
209
            "dependencies": {package_name: package_version},
210
        },
211
        indent=2,
212
    ).encode()
213

214

215
def _lockfile_dest_for_resource(resource_pkg: str, filename: str) -> str:
8✔
216
    """In-repo path for a bundled lockfile resource, relative to the buildroot."""
217
    return os.path.join("src", "python", resource_pkg.replace(".", os.path.sep), filename)
1✔
218

219

220
def bundled_lockfiles(package: str, prefix: str) -> dict[str, tuple[str, str]]:
8✔
221
    """Build a `NodeJSToolBase.default_lockfile_resources` dict for a tool that ships lockfiles for
222
    npm/yarn/pnpm at `{prefix}.{pm.lockfile_name}` within `package`.
223

224
    Example:
225
        default_lockfile_resources = bundled_lockfiles(__package__, "prettier")
226
    """
227
    pms = (PackageManager.npm(None), PackageManager.yarn(None), PackageManager.pnpm(None))
7✔
228
    return {pm.name: (package, f"{prefix}.{pm.lockfile_name}") for pm in pms}
7✔
229

230

231
def _active_package_manager(nodejs: NodeJS, for_tool: str) -> PackageManager:
8✔
232
    default_package_manager = nodejs.default_package_manager
6✔
233
    if (
6✔
234
        nodejs.package_managers.get(nodejs.package_manager) is None
235
        or default_package_manager is None
236
    ):
UNCOV
237
        raise ValueError(
×
238
            softwrap(
239
                f"""
240
                Version for {nodejs.package_manager} has to be configured
241
                in [{nodejs.options_scope}].package_managers to run the tool '{for_tool}'.
242
                """
243
            )
244
        )
245
    return PackageManager.from_string(default_package_manager)
6✔
246

247

248
@dataclass(frozen=True)
8✔
249
class _NodeJSUserLockfileRequest:
8✔
250
    path: str
8✔
251
    description_of_origin: str
8✔
252

253

254
@dataclass(frozen=True)
8✔
255
class _NodeJSLockfileContents:
8✔
256
    content: bytes
8✔
257

258

259
@rule
8✔
260
async def read_nodejs_tool_user_lockfile(
8✔
261
    request: _NodeJSUserLockfileRequest,
262
) -> _NodeJSLockfileContents:
NEW
263
    digest = await path_globs_to_digest(
×
264
        PathGlobs(
265
            [request.path],
266
            glob_match_error_behavior=GlobMatchErrorBehavior.error,
267
            description_of_origin=request.description_of_origin,
268
        )
269
    )
NEW
270
    contents = await get_digest_contents(digest)
×
NEW
271
    return _NodeJSLockfileContents(contents[0].content)
×
272

273

274
@dataclass(frozen=True)
8✔
275
class _NodeJSBundledToolInstallRequest:
8✔
276
    pkg_manager_name: str
8✔
277
    pkg_manager_version: str
8✔
278
    lockfile_name: str
8✔
279
    lockfile_bytes: bytes
8✔
280
    package_name: str
8✔
281
    package_version: str
8✔
282
    options_scope: str
8✔
283
    extra_env: FrozenDict[str, str]
8✔
284
    timeout_seconds: int | None
8✔
285

286

287
@dataclass(frozen=True)
8✔
288
class _NodeJSBundledToolInstalled:
8✔
289
    digest: Digest
8✔
290

291

292
@rule
8✔
293
async def install_nodejs_tool_from_bundled_lockfile(
8✔
294
    request: _NodeJSBundledToolInstallRequest,
295
) -> _NodeJSBundledToolInstalled:
296
    project_digest = await create_digest(
6✔
297
        CreateDigest(
298
            [
299
                FileContent(
300
                    "package.json",
301
                    _tool_package_json_bytes(
302
                        request.options_scope, request.package_name, request.package_version
303
                    ),
304
                ),
305
                FileContent(request.lockfile_name, request.lockfile_bytes),
306
            ]
307
        ),
308
        **implicitly(),
309
    )
310

311
    pkg_manager = PackageManager.from_string(
6✔
312
        f"{request.pkg_manager_name}@{request.pkg_manager_version}"
313
    )
314

315
    install_result = await fallible_to_exec_result_or_raise(
6✔
316
        **implicitly(
317
            NodeJSToolProcess(
318
                pkg_manager.name,
319
                pkg_manager.version,
320
                args=pkg_manager.immutable_install_args,
321
                description=f"Install {request.options_scope} dependencies from lockfile",
322
                input_digest=project_digest,
323
                output_directories=("node_modules",),
324
                timeout_seconds=request.timeout_seconds,
325
                extra_env=request.extra_env,
326
            )
327
        )
328
    )
329

330
    return _NodeJSBundledToolInstalled(digest=install_result.output_digest)
6✔
331

332

333
async def _run_tool_with_bundled_lockfile(request: NodeJSToolRequest, nodejs: NodeJS) -> Process:
8✔
334
    pkg_manager = _active_package_manager(nodejs, request.binary_name)
6✔
335

336
    if request.lockfile == DEFAULT_TOOL_LOCKFILE:
6✔
337
        if not request.default_lockfile_resources:
6✔
NEW
338
            raise ValueError(
×
339
                f"No default lockfile resources configured for tool '{request.options_scope}'."
340
            )
341
        if pkg_manager.name not in request.default_lockfile_resources:
6✔
NEW
342
            raise ValueError(
×
343
                softwrap(
344
                    f"""
345
                    No bundled lockfile for package manager '{pkg_manager.name}' in tool
346
                    '{request.options_scope}'. Available: {", ".join(request.default_lockfile_resources.keys())}.
347
                    """
348
                )
349
            )
350
        pkg, filename = request.default_lockfile_resources[pkg_manager.name]
6✔
351
        lockfile_bytes = importlib.resources.files(pkg).joinpath(filename).read_bytes()
6✔
352
    else:
NEW
353
        assert request.lockfile is not None
×
NEW
354
        user_lockfile = await read_nodejs_tool_user_lockfile(
×
355
            _NodeJSUserLockfileRequest(
356
                path=request.lockfile,
357
                description_of_origin=f"the option `[{request.options_scope}].lockfile`",
358
            )
359
        )
NEW
360
        lockfile_bytes = user_lockfile.content
×
361

362
    package_name, package_version = _parse_package_name_and_version(request.package)
6✔
363

364
    assert pkg_manager.version is not None
6✔
365
    installed = await install_nodejs_tool_from_bundled_lockfile(
6✔
366
        _NodeJSBundledToolInstallRequest(
367
            pkg_manager_name=pkg_manager.name,
368
            pkg_manager_version=pkg_manager.version,
369
            lockfile_name=pkg_manager.lockfile_name,
370
            lockfile_bytes=lockfile_bytes,
371
            package_name=package_name,
372
            package_version=package_version,
373
            options_scope=request.options_scope,
374
            extra_env=FrozenDict(pkg_manager.extra_env),
375
            timeout_seconds=request.timeout_seconds,
376
        )
377
    )
378

379
    execution_input = await merge_digests(MergeDigests([request.input_digest, installed.digest]))
6✔
380

381
    # Invoke the binary from node_modules/.bin directly rather than via `npx`/`pnpm run` so the
382
    # immutable lockfile install isn't repeated.
383
    return await setup_node_tool_process(
6✔
384
        NodeJSToolProcess(
385
            f"node_modules/.bin/{request.binary_name}",
386
            tool_version=None,
387
            args=request.args,
388
            description=request.description,
389
            input_digest=execution_input,
390
            output_files=request.output_files,
391
            output_directories=request.output_directories,
392
            append_only_caches=request.append_only_caches,
393
            timeout_seconds=request.timeout_seconds,
394
            extra_env=FrozenDict({**pkg_manager.extra_env, **request.extra_env}),
395
        ),
396
        **implicitly(),
397
    )
398

399

400
async def _run_tool_without_resolve(request: NodeJSToolRequest, nodejs: NodeJS) -> Process:
8✔
401
    # Corepack requires a configured version in [nodejs].package_managers to pick a
402
    # "good known release"; there's no project package.json here to derive it from.
403
    pkg_manager = _active_package_manager(nodejs, request.binary_name)
1✔
404

405
    return await setup_node_tool_process(
1✔
406
        NodeJSToolProcess(
407
            pkg_manager.name,
408
            pkg_manager.version,
409
            args=pkg_manager.make_download_and_execute_args(
410
                request.package,
411
                request.binary_name,
412
                request.args,
413
            ),
414
            description=request.description,
415
            input_digest=request.input_digest,
416
            output_files=request.output_files,
417
            output_directories=request.output_directories,
418
            append_only_caches=request.append_only_caches,
419
            timeout_seconds=request.timeout_seconds,
420
            extra_env=FrozenDict({**pkg_manager.extra_env, **request.extra_env}),
421
        ),
422
        **implicitly(),
423
    )
424

425

426
async def _run_tool_with_resolve(request: NodeJSToolRequest, resolve: str) -> Process:
8✔
427
    resolves = await resolve_to_projects(**implicitly())
3✔
428

429
    if request.resolve not in resolves:
3✔
430
        reason = (
×
431
            f"Available resolves are {', '.join(resolves.keys())}."
432
            if resolves
433
            else "This project contains no resolves."
434
        )
435
        raise ValueError(f"{resolve} is not a named NodeJS resolve. {reason}")
×
436

437
    all_first_party = await resolve_to_first_party_node_package(**implicitly())
3✔
438
    package_for_resolve = all_first_party[resolve]
3✔
439
    project = resolves[resolve]
3✔
440

441
    installed = await install_node_packages_for_address(
3✔
442
        InstalledNodePackageRequest(package_for_resolve.address), **implicitly()
443
    )
444
    merged_input_digest = await merge_digests(
3✔
445
        MergeDigests([request.input_digest, installed.digest])
446
    )
447

448
    return await setup_nodejs_project_environment_process(
3✔
449
        NodeJsProjectEnvironmentProcess(
450
            env=installed.project_env,
451
            args=(*project.package_manager.execute_args, request.binary_name, *request.args),
452
            description=request.description,
453
            input_digest=merged_input_digest,
454
            output_files=request.output_files,
455
            output_directories=request.output_directories,
456
            per_package_caches=request.append_only_caches,
457
            project_caches=request.project_caches,
458
            timeout_seconds=request.timeout_seconds,
459
            extra_env=FrozenDict(request.extra_env),
460
        ),
461
        **implicitly(),
462
    )
463

464

465
@rule
8✔
466
async def prepare_tool_process(request: NodeJSToolRequest, nodejs: NodeJS) -> Process:
8✔
467
    if request.resolve is not None:
7✔
468
        return await _run_tool_with_resolve(request, request.resolve)
3✔
469
    if request.lockfile is not None:
6✔
470
        return await _run_tool_with_bundled_lockfile(request, nodejs)
6✔
471
    return await _run_tool_without_resolve(request, nodejs)
1✔
472

473

474
def rules() -> Iterable[Rule | UnionRule]:
8✔
475
    return [*collect_rules(), *nodejs_project_environment.rules(), *install_node_package.rules()]
8✔
476

477

478
def rules_for_tool(tool_cls: type[NodeJSToolBase]) -> Iterable[Rule | UnionRule]:
8✔
479
    """All rules needed to register a NodeJSToolBase subsystem in its backend's `rules()`."""
480
    return [*rules(), UnionRule(ExportableTool, tool_cls)]
6✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc