• 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

96.77
/src/python/pants/backend/javascript/goals/lockfile.py
1
# Copyright 2023 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3
from __future__ import annotations
1✔
4

5
import os.path
1✔
6
from collections.abc import Iterable
1✔
7
from dataclasses import dataclass
1✔
8

9
from pants.backend.javascript import nodejs_project_environment
1✔
10
from pants.backend.javascript.nodejs_project import AllNodeJSProjects, NodeJSProject
1✔
11
from pants.backend.javascript.nodejs_project_environment import (
1✔
12
    NodeJsProjectEnvironment,
13
    NodeJsProjectEnvironmentProcess,
14
)
15
from pants.backend.javascript.package_json import PackageJsonTarget
1✔
16
from pants.backend.javascript.package_manager import PackageManager
1✔
17
from pants.backend.javascript.resolve import NodeJSProjectResolves
1✔
18
from pants.backend.javascript.subsystems.nodejs import (
1✔
19
    NodeJS,
20
    NodeJSToolProcess,
21
    UserChosenNodeJSResolveAliases,
22
)
23
from pants.backend.javascript.subsystems.nodejs_tool import (
1✔
24
    NodeJSToolBase,
25
    _lockfile_dest_for_resource,
26
    _parse_package_name_and_version,
27
    _tool_package_json_bytes,
28
)
29
from pants.core.goals.generate_lockfiles import (
1✔
30
    GenerateLockfile,
31
    GenerateLockfileResult,
32
    KnownUserResolveNames,
33
    KnownUserResolveNamesRequest,
34
    RequestedUserResolveNames,
35
    UserGenerateLockfiles,
36
)
37
from pants.core.goals.resolves import ExportableTool
1✔
38
from pants.core.goals.tailor import TailorGoal
1✔
39
from pants.engine.fs import CreateDigest, FileContent
1✔
40
from pants.engine.internals.native_engine import AddPrefix
1✔
41
from pants.engine.intrinsics import add_prefix, create_digest, get_digest_contents
1✔
42
from pants.engine.process import fallible_to_exec_result_or_raise
1✔
43
from pants.engine.rules import Rule, collect_rules, implicitly, rule
1✔
44
from pants.engine.unions import UnionMembership, UnionRule
1✔
45
from pants.util.docutil import bin_name
1✔
46
from pants.util.frozendict import FrozenDict
1✔
47
from pants.util.ordered_set import FrozenOrderedSet
1✔
48
from pants.util.strutil import pluralize, softwrap
1✔
49

50

51
@dataclass(frozen=True)
1✔
52
class GeneratePackageLockJsonFile(GenerateLockfile):
1✔
53
    project: NodeJSProject
1✔
54

55

56
class KnownPackageJsonUserResolveNamesRequest(KnownUserResolveNamesRequest):
1✔
57
    pass
1✔
58

59

60
class RequestedPackageJsonUserResolveNames(RequestedUserResolveNames):
1✔
61
    pass
1✔
62

63

64
@rule
1✔
65
async def determine_package_json_user_resolves(
1✔
66
    _: KnownPackageJsonUserResolveNamesRequest,
67
    all_projects: AllNodeJSProjects,
68
    user_chosen_resolves: UserChosenNodeJSResolveAliases,
69
) -> KnownUserResolveNames:
70
    names = FrozenOrderedSet(
1✔
71
        user_chosen_resolves.get(
72
            os.path.join(project.root_dir, project.lockfile_name), project.default_resolve_name
73
        )
74
        for project in all_projects
75
    )
76
    unmatched_aliases = set(user_chosen_resolves.values()).difference(names)
1✔
77
    if unmatched_aliases:
1✔
78
        projects = pluralize(len(unmatched_aliases), "project", include_count=False)
1✔
79
        lockfiles = ", ".join(
1✔
80
            lockfile
81
            for lockfile, alias in user_chosen_resolves.items()
82
            if alias in unmatched_aliases
83
        )
84
        paths = pluralize(len(unmatched_aliases), "path", include_count=False)
1✔
85
        raise ValueError(
1✔
86
            softwrap(
87
                f"""
88
                No nodejs {projects} could be found for {lockfiles}, but
89
                some are configured under [nodejs].resolves.
90

91
                Ensure that a package.json file you intend to manage with pants has
92
                a corresponding BUILD file containing a `{PackageJsonTarget.alias}` target
93
                by running `{bin_name()} {TailorGoal.name} ::`.
94

95
                Also confirm that {lockfiles} would be generated by your
96
                chosen nodejs package manager at the specified {paths}.
97
                """
98
            )
99
        )
100

101
    return KnownUserResolveNames(
1✔
102
        names=tuple(names),
103
        option_name="[nodejs].resolves",
104
        requested_resolve_names_cls=RequestedPackageJsonUserResolveNames,
105
    )
106

107

108
@rule
1✔
109
async def setup_user_lockfile_requests(
1✔
110
    resolves: NodeJSProjectResolves,
111
    requested: RequestedPackageJsonUserResolveNames,
112
) -> UserGenerateLockfiles:
113
    return UserGenerateLockfiles(
1✔
114
        GeneratePackageLockJsonFile(
115
            resolve_name=name,
116
            lockfile_dest=os.path.join(resolves[name].root_dir, resolves[name].lockfile_name),
117
            diff=False,
118
            project=resolves[name],
119
        )
120
        for name in requested
121
    )
122

123

124
@rule
1✔
125
async def generate_lockfile_from_package_jsons(
1✔
126
    request: GeneratePackageLockJsonFile,
127
) -> GenerateLockfileResult:
128
    result = await fallible_to_exec_result_or_raise(
1✔
129
        **implicitly(
130
            NodeJsProjectEnvironmentProcess(
131
                env=NodeJsProjectEnvironment.from_root(request.project),
132
                args=request.project.generate_lockfile_args,
133
                description=f"generate {request.project.lockfile_name} for '{request.resolve_name}'.",
134
                output_files=(request.project.lockfile_name,),
135
            )
136
        )
137
    )
138
    output_digest = await add_prefix(AddPrefix(result.output_digest, request.project.root_dir))
1✔
139
    return GenerateLockfileResult(output_digest, request.resolve_name, request.lockfile_dest)
1✔
140

141

142
@dataclass(frozen=True)
1✔
143
class GenerateNodeJSToolLockfile(GenerateLockfile):
1✔
144
    package: str
1✔
145
    package_manager: PackageManager
1✔
146

147

148
class KnownNodeJSToolResolveNamesRequest(KnownUserResolveNamesRequest):
1✔
149
    pass
1✔
150

151

152
class RequestedNodeJSToolResolveNames(RequestedUserResolveNames):
1✔
153
    pass
1✔
154

155

156
@rule
1✔
157
async def determine_nodejs_tool_resolves(
1✔
158
    _: KnownNodeJSToolResolveNamesRequest,
159
    union_membership: UnionMembership,
160
) -> KnownUserResolveNames:
161
    tool_classes = ExportableTool.filter_for_subclasses(union_membership, NodeJSToolBase)
1✔
162
    names = tuple(
1✔
163
        sorted(
164
            tool_cls.options_scope
165
            for tool_cls in tool_classes.values()
166
            if tool_cls.default_lockfile_resources
167
        )
168
    )
169
    return KnownUserResolveNames(
1✔
170
        names=names,
171
        option_name="[nodejs].tool_lockfiles",
172
        requested_resolve_names_cls=RequestedNodeJSToolResolveNames,
173
    )
174

175

176
@rule
1✔
177
async def setup_nodejs_tool_lockfile_requests(
1✔
178
    requested: RequestedNodeJSToolResolveNames,
179
    nodejs: NodeJS,
180
    union_membership: UnionMembership,
181
) -> UserGenerateLockfiles:
182
    pkg_manager_and_version = nodejs.default_package_manager
1✔
183
    if pkg_manager_and_version is None:
1✔
NEW
184
        raise ValueError(
×
185
            softwrap(
186
                f"""
187
                A package manager version must be configured in
188
                [{nodejs.options_scope}].package_managers to generate NodeJS tool lockfiles.
189
                """
190
            )
191
        )
192
    pkg_manager = PackageManager.from_string(pkg_manager_and_version)
1✔
193

194
    # Discover tool classes dynamically via the ExportableTool union membership.
195
    tool_classes_by_scope: dict[str, type[NodeJSToolBase]] = {
1✔
196
        scope: tool_cls
197
        for scope, tool_cls in ExportableTool.filter_for_subclasses(
198
            union_membership, NodeJSToolBase
199
        ).items()
200
        if tool_cls.default_lockfile_resources
201
    }
202

203
    requests = []
1✔
204
    for name in requested:
1✔
205
        tool_cls = tool_classes_by_scope.get(name)
1✔
206
        if tool_cls is None:
1✔
NEW
207
            continue
×
208
        lockfile_resources = tool_cls.default_lockfile_resources
1✔
209
        if lockfile_resources and pkg_manager.name in lockfile_resources:
1✔
210
            resource_pkg, filename = lockfile_resources[pkg_manager.name]
1✔
211
            lockfile_dest = _lockfile_dest_for_resource(resource_pkg, filename)
1✔
212
        else:
NEW
213
            lockfile_dest = f"{name}.{pkg_manager.lockfile_name}"
×
214

215
        requests.append(
1✔
216
            GenerateNodeJSToolLockfile(
217
                resolve_name=name,
218
                lockfile_dest=lockfile_dest,
219
                diff=False,
220
                package=tool_cls.default_version,
221
                package_manager=pkg_manager,
222
            )
223
        )
224
    return UserGenerateLockfiles(requests)
1✔
225

226

227
@rule
1✔
228
async def generate_nodejs_tool_lockfile(
1✔
229
    request: GenerateNodeJSToolLockfile,
230
) -> GenerateLockfileResult:
231
    package_name, package_version = _parse_package_name_and_version(request.package)
1✔
232

233
    input_digest = await create_digest(
1✔
234
        CreateDigest(
235
            [
236
                FileContent(
237
                    "package.json",
238
                    _tool_package_json_bytes(request.resolve_name, package_name, package_version),
239
                )
240
            ]
241
        ),
242
        **implicitly(),
243
    )
244

245
    result = await fallible_to_exec_result_or_raise(
1✔
246
        **implicitly(
247
            NodeJSToolProcess(
248
                request.package_manager.name,
249
                request.package_manager.version,
250
                args=request.package_manager.generate_lockfile_args,
251
                description=(
252
                    f"Generate {request.package_manager.lockfile_name} "
253
                    f"for '{request.resolve_name}'."
254
                ),
255
                input_digest=input_digest,
256
                output_files=(request.package_manager.lockfile_name,),
257
                extra_env=FrozenDict(request.package_manager.extra_env),
258
            )
259
        )
260
    )
261

262
    # The sandbox output is `<lockfile_name>` at the digest root; relocate to `lockfile_dest`
263
    # so `workspace.write_digest` lands it at the expected path (and filename).
264
    digest_contents = await get_digest_contents(result.output_digest)
1✔
265
    [file_content] = digest_contents
1✔
266
    output_digest = await create_digest(
1✔
267
        CreateDigest([FileContent(request.lockfile_dest, file_content.content)]),
268
        **implicitly(),
269
    )
270
    return GenerateLockfileResult(output_digest, request.resolve_name, request.lockfile_dest)
1✔
271

272

273
def rules() -> Iterable[Rule | UnionRule]:
1✔
274
    return (
1✔
275
        *collect_rules(),
276
        *nodejs_project_environment.rules(),
277
        UnionRule(GenerateLockfile, GeneratePackageLockJsonFile),
278
        UnionRule(KnownUserResolveNamesRequest, KnownPackageJsonUserResolveNamesRequest),
279
        UnionRule(RequestedUserResolveNames, RequestedPackageJsonUserResolveNames),
280
        UnionRule(GenerateLockfile, GenerateNodeJSToolLockfile),
281
        UnionRule(KnownUserResolveNamesRequest, KnownNodeJSToolResolveNamesRequest),
282
        UnionRule(RequestedUserResolveNames, RequestedNodeJSToolResolveNames),
283
    )
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