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

pantsbuild / pants / 20147226056

11 Dec 2025 08:58PM UTC coverage: 78.827% (-1.5%) from 80.293%
20147226056

push

github

web-flow
Forwarded the `style` and `complete-platform` args from pants.toml to PEX (#22910)

## Context

After Apple switched to the `arm64` architecture, some package
publishers stopped releasing `x86_64` variants of their packages for
`darwin`. As a result, generating a universal lockfile now fails because
no single package version is compatible with both `x86_64` and `arm64`
on `darwin`.

The solution is to use the `--style` and `--complete-platform` flags
with PEX. For example:
```
pex3 lock create \
    --style strict \
    --complete-platform 3rdparty/platforms/manylinux_2_28_aarch64.json \
    --complete-platform 3rdparty/platforms/macosx_26_0_arm64.json \
    -r 3rdparty/python/requirements_pyarrow.txt \
    -o python-pyarrow.lock
```

See the Slack discussion here:
https://pantsbuild.slack.com/archives/C046T6T9U/p1760098582461759

## Reproduction

* `BUILD`
```
python_requirement(
    name="awswrangler",
    requirements=["awswrangler==3.12.1"],
    resolve="awswrangler",
)
```
* Run `pants generate-lockfiles --resolve=awswrangler` on macOS with an
`arm64` CPU
```
pip: ERROR: Cannot install awswrangler==3.12.1 because these package versions have conflicting dependencies.
pip: ERROR: ResolutionImpossible: for help visit https://pip.pypa.io/en/latest/topics/dependency-resolution/#dealing-with-dependency-conflicts
pip:  
pip:  The conflict is caused by:
pip:      awswrangler 3.12.1 depends on pyarrow<18.0.0 and >=8.0.0; sys_platform == "darwin" and platform_machine == "x86_64"
pip:      awswrangler 3.12.1 depends on pyarrow<21.0.0 and >=18.0.0; sys_platform != "darwin" or platform_machine != "x86_64"
pip:  
pip:  Additionally, some packages in these conflicts have no matching distributions available for your environment:
pip:      pyarrow
pip:  
pip:  To fix this you could try to:
pip:  1. loosen the range of package versions you've specified
pip:  2. remove package versions to allow pip to attempt to solve the dependency conflict
```

## Implementation
... (continued)

77 of 100 new or added lines in 6 files covered. (77.0%)

868 existing lines in 42 files now uncovered.

74471 of 94474 relevant lines covered (78.83%)

3.18 hits per line

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

69.76
/src/python/pants/backend/javascript/package_json.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
11✔
4

5
import itertools
11✔
6
import json
11✔
7
import logging
11✔
8
import os.path
11✔
9
from abc import ABC
11✔
10
from collections.abc import Iterable, Mapping
11✔
11
from dataclasses import dataclass, field
11✔
12
from typing import Any, ClassVar, Literal
11✔
13

14
import yaml
11✔
15

16
from pants.backend.project_info import dependencies
11✔
17
from pants.base.glob_match_error_behavior import GlobMatchErrorBehavior
11✔
18
from pants.base.specs import AncestorGlobSpec, RawSpecs
11✔
19
from pants.build_graph.address import Address
11✔
20
from pants.build_graph.build_file_aliases import BuildFileAliases
11✔
21
from pants.core.goals.package import OutputPathField
11✔
22
from pants.core.target_types import (
11✔
23
    TargetGeneratorSourcesHelperSourcesField,
24
    TargetGeneratorSourcesHelperTarget,
25
)
26
from pants.core.util_rules import stripped_source_files
11✔
27
from pants.engine import fs
11✔
28
from pants.engine.collection import Collection, DeduplicatedCollection
11✔
29
from pants.engine.env_vars import EXTRA_ENV_VARS_USAGE_HELP
11✔
30
from pants.engine.fs import CreateDigest, FileContent, GlobExpansionConjunction, PathGlobs
11✔
31
from pants.engine.internals import graph
11✔
32
from pants.engine.internals.graph import (
11✔
33
    ResolveAllTargetGeneratorRequests,
34
    resolve_all_generator_target_requests,
35
    resolve_targets,
36
)
37
from pants.engine.internals.native_engine import Digest, Snapshot
11✔
38
from pants.engine.internals.selectors import concurrently
11✔
39
from pants.engine.intrinsics import create_digest, digest_to_snapshot, get_digest_contents
11✔
40
from pants.engine.rules import Rule, collect_rules, implicitly, rule
11✔
41
from pants.engine.target import (
11✔
42
    COMMON_TARGET_FIELDS,
43
    AllTargets,
44
    Dependencies,
45
    DependenciesRequest,
46
    DescriptionField,
47
    GeneratedTargets,
48
    GenerateTargetsRequest,
49
    InvalidFieldException,
50
    ScalarField,
51
    SequenceField,
52
    SingleSourceField,
53
    SourcesField,
54
    StringField,
55
    StringSequenceField,
56
    Tags,
57
    Target,
58
    TargetGenerator,
59
    Targets,
60
)
61
from pants.engine.unions import UnionMembership, UnionRule
11✔
62
from pants.option.bootstrap_options import UnmatchedBuildFileGlobs
11✔
63
from pants.util.frozendict import FrozenDict
11✔
64
from pants.util.strutil import help_text, softwrap
11✔
65

66
_logger = logging.getLogger(__name__)
11✔
67

68

69
class NodePackageDependenciesField(Dependencies):
11✔
70
    pass
11✔
71

72

73
class PackageJsonSourceField(SingleSourceField):
11✔
74
    default = "package.json"
11✔
75
    required = False
11✔
76

77

78
class NodeScript(ABC):
11✔
79
    entry_point: str
11✔
80
    alias: ClassVar[str]
11✔
81

82

83
@dataclass(frozen=True)
11✔
84
class NodeRunScript(NodeScript):
11✔
85
    entry_point: str
11✔
86
    extra_env_vars: tuple[str, ...] = ()
11✔
87

88
    alias: ClassVar[str] = "node_run_script"
11✔
89

90
    def __str__(self) -> str:
11✔
91
        return f'{self.alias}(entry_point="{self.entry_point}", ...)'
×
92

93
    @classmethod
11✔
94
    def create(
11✔
95
        cls,
96
        entry_point: str,
97
        extra_env_vars: Iterable[str] = (),
98
    ) -> NodeRunScript:
99
        """A script that can be run directly via the run goal, mapped from the `scripts` section of
100
        a package.json file.
101

102
        This allows running any script defined in package.json directly through pants run.
103
        """
104
        return cls(
×
105
            entry_point=entry_point,
106
            extra_env_vars=tuple(extra_env_vars),
107
        )
108

109

110
@dataclass(frozen=True)
11✔
111
class NodeBuildScript(NodeScript):
11✔
112
    entry_point: str
11✔
113
    output_directories: tuple[str, ...] = ()
11✔
114
    output_files: tuple[str, ...] = ()
11✔
115
    extra_caches: tuple[str, ...] = ()
11✔
116
    extra_env_vars: tuple[str, ...] = ()
11✔
117
    description: str | None = None
11✔
118
    tags: tuple[str, ...] = ()
11✔
119

120
    alias: ClassVar[str] = "node_build_script"
11✔
121

122
    @classmethod
11✔
123
    def create(
11✔
124
        cls,
125
        entry_point: str,
126
        output_directories: Iterable[str] = (),
127
        output_files: Iterable[str] = (),
128
        extra_caches: Iterable[str] = (),
129
        extra_env_vars: Iterable[str] = (),
130
        description: str | None = None,
131
        tags: Iterable[str] = (),
132
    ) -> NodeBuildScript:
133
        """A build script, mapped from the `scripts` section of a package.json file.
134

135
        Either the `output_directories` or the `output_files` argument has to be set to capture the
136
        output artifacts of the build.
137
        """
138

139
        return cls(
×
140
            entry_point=entry_point,
141
            output_directories=tuple(output_directories),
142
            output_files=tuple(output_files),
143
            extra_caches=tuple(extra_caches),
144
            extra_env_vars=tuple(extra_env_vars),
145
            description=description,
146
            tags=tuple(tags),
147
        )
148

149

150
@dataclass(frozen=True)
11✔
151
class NodeTestScript(NodeScript):
11✔
152
    entry_point: str = "test"
11✔
153
    report_args: tuple[str, ...] = ()
11✔
154
    report_output_files: tuple[str, ...] = ()
11✔
155
    report_output_directories: tuple[str, ...] = ()
11✔
156
    coverage_args: tuple[str, ...] = ()
11✔
157
    coverage_output_files: tuple[str, ...] = ()
11✔
158
    coverage_output_directories: tuple[str, ...] = ()
11✔
159
    coverage_entry_point: str | None = None
11✔
160
    extra_caches: tuple[str, ...] = ()
11✔
161

162
    alias: ClassVar[str] = "node_test_script"
11✔
163

164
    def __str__(self) -> str:
11✔
165
        return f'{self.alias}(entry_point="{self.entry_point}", ...)'
×
166

167
    @classmethod
11✔
168
    def create(
11✔
169
        cls,
170
        entry_point: str = "test",
171
        report_args: Iterable[str] = (),
172
        report_output_files: Iterable[str] = (),
173
        report_output_directories: Iterable[str] = (),
174
        coverage_args: Iterable[str] = (),
175
        coverage_output_files: Iterable[str] = (),
176
        coverage_output_directories: Iterable[str] = (),
177
        coverage_entry_point: str | None = None,
178
    ) -> NodeTestScript:
179
        """The test script for this package, mapped from the `scripts` section of a package.json
180
        file. The pointed to script should accept a variadic number of ([ARG]...) path arguments.
181

182
        This entry point is the "test" script, by default.
183
        """
184
        return cls(
×
185
            entry_point=entry_point,
186
            report_args=tuple(report_args),
187
            report_output_files=tuple(report_output_files),
188
            report_output_directories=tuple(report_output_directories),
189
            coverage_args=tuple(coverage_args),
190
            coverage_output_files=tuple(coverage_output_files),
191
            coverage_output_directories=tuple(coverage_output_directories),
192
            coverage_entry_point=coverage_entry_point,
193
        )
194

195
    def supports_coverage(self) -> bool:
11✔
196
        return bool(self.coverage_entry_point) or bool(self.coverage_args)
×
197

198
    def coverage_globs(self, working_directory: str) -> PathGlobs:
11✔
199
        return self.coverage_globs_for(
×
200
            working_directory,
201
            self.coverage_output_files,
202
            self.coverage_output_directories,
203
            GlobMatchErrorBehavior.ignore,
204
        )
205

206
    @classmethod
11✔
207
    def coverage_globs_for(
11✔
208
        cls,
209
        working_directory: str,
210
        files: tuple[str, ...],
211
        directories: tuple[str, ...],
212
        error_behaviour: GlobMatchErrorBehavior,
213
        conjunction: GlobExpansionConjunction = GlobExpansionConjunction.any_match,
214
        description_of_origin: str | None = None,
215
    ) -> PathGlobs:
216
        dir_globs = (os.path.join(directory, "*") for directory in directories)
×
217
        return PathGlobs(
×
218
            (os.path.join(working_directory, glob) for glob in itertools.chain(files, dir_globs)),
219
            conjunction=conjunction,
220
            glob_match_error_behavior=error_behaviour,
221
            description_of_origin=description_of_origin,
222
        )
223

224

225
class NodePackageScriptsField(SequenceField[NodeScript]):
11✔
226
    alias = "scripts"
11✔
227
    expected_element_type = NodeScript
11✔
228

229
    help = help_text(
11✔
230
        """
231
        Custom node package manager scripts that should be known
232
        and ran as part of relevant goals.
233

234
        Maps the package.json#scripts section to a cacheable pants invocation.
235
        """
236
    )
237
    expected_type_description = (
11✔
238
        '[node_build_script(entry_point="build", output_directories=["./dist/"], ...])'
239
    )
240
    default = ()
11✔
241

242
    @classmethod
11✔
243
    def compute_value(
11✔
244
        cls, raw_value: Iterable[Any] | None, address: Address
245
    ) -> tuple[NodeScript, ...] | None:
246
        values = super().compute_value(raw_value, address)
×
247
        test_scripts = [value for value in values or () if isinstance(value, NodeTestScript)]
×
248
        if len(test_scripts) > 1:
×
249
            entry_points = ", ".join(str(script) for script in test_scripts)
×
250
            raise InvalidFieldException(
×
251
                softwrap(
252
                    f"""
253
                    You can only specify one `{NodeTestScript.alias}` per `{PackageJsonTarget.alias}`,
254
                    but the {cls.alias} contains {entry_points}.
255
                    """
256
                )
257
            )
258
        return values
×
259

260
    def build_scripts(self) -> Iterable[NodeBuildScript]:
11✔
261
        for script in self.value or ():
×
262
            if isinstance(script, NodeBuildScript):
×
263
                yield script
×
264

265
    def get_test_script(self) -> NodeTestScript:
11✔
266
        for script in self.value or ():
×
267
            if isinstance(script, NodeTestScript):
×
268
                return script
×
269
        return NodeTestScript()
×
270

271

272
class NodePackageTestScriptField(ScalarField[NodeTestScript]):
11✔
273
    alias = "_node_test_script"
11✔
274
    expected_type = NodeTestScript
11✔
275
    expected_type_description = (
11✔
276
        'node_test_script(entry_point="test", coverage_args="--coverage=true")'
277
    )
278
    default = NodeTestScript()
11✔
279
    value: NodeTestScript
11✔
280

281

282
class NodePackageVersionField(StringField):
11✔
283
    alias = "version"
11✔
284
    help = help_text(
11✔
285
        """
286
        Version of the Node package, as specified in the package.json.
287

288
        This field should not be overridden; use the value from target generation.
289
        """
290
    )
291
    required = False
11✔
292
    value: str | None
11✔
293

294

295
class NodeThirdPartyPackageVersionField(NodePackageVersionField):
11✔
296
    alias = "version"
11✔
297
    help = help_text(
11✔
298
        """
299
        Version of the Node package, as specified in the package.json.
300

301
        This field should not be overridden; use the value from target generation.
302
        """
303
    )
304
    required = True
11✔
305
    value: str
11✔
306

307

308
class NodePackageNameField(StringField):
11✔
309
    alias = "package"
11✔
310
    help = help_text(
11✔
311
        """
312
        Name of the Node package, as specified in the package.json.
313

314
        This field should not be overridden; use the value from target generation.
315
        """
316
    )
317
    required = True
11✔
318
    value: str
11✔
319

320

321
class NodeThirdPartyPackageNameField(NodePackageNameField):
11✔
322
    pass
11✔
323

324

325
class NodeThirdPartyPackageDependenciesField(Dependencies):
11✔
326
    pass
11✔
327

328

329
class NodeThirdPartyPackageTarget(Target):
11✔
330
    alias = "node_third_party_package"
11✔
331

332
    help = "A third party node package."
11✔
333

334
    core_fields = (
11✔
335
        *COMMON_TARGET_FIELDS,
336
        NodeThirdPartyPackageNameField,
337
        NodeThirdPartyPackageVersionField,
338
        NodeThirdPartyPackageDependenciesField,
339
    )
340

341

342
class NodePackageExtraEnvVarsField(StringSequenceField):
11✔
343
    alias = "extra_env_vars"
11✔
344
    help = help_text(
11✔
345
        f"""
346
        Environment variables to set when running package manager operations.
347

348
        {EXTRA_ENV_VARS_USAGE_HELP}
349
        """
350
    )
351
    required = False
11✔
352

353

354
class NodePackageTarget(Target):
11✔
355
    alias = "node_package"
11✔
356

357
    help = "A first party node package."
11✔
358

359
    core_fields = (
11✔
360
        *COMMON_TARGET_FIELDS,
361
        PackageJsonSourceField,
362
        NodePackageNameField,
363
        NodePackageVersionField,
364
        NodePackageDependenciesField,
365
        NodePackageTestScriptField,
366
        NodePackageExtraEnvVarsField,
367
    )
368

369

370
class NPMDistributionTarget(Target):
11✔
371
    alias = "npm_distribution"
11✔
372

373
    help = help_text(
11✔
374
        """
375
        A publishable npm registry distribution, typically a gzipped tarball
376
        of the sources and any resources, but omitting the lockfile.
377

378
        Generated using the projects package manager `pack` implementation.
379
        """
380
    )
381

382
    core_fields = (
11✔
383
        *COMMON_TARGET_FIELDS,
384
        PackageJsonSourceField,
385
        OutputPathField,
386
    )
387

388

389
class PackageJsonTarget(TargetGenerator):
11✔
390
    alias = "package_json"
11✔
391
    core_fields = (
11✔
392
        *COMMON_TARGET_FIELDS,
393
        PackageJsonSourceField,
394
        NodePackageScriptsField,
395
        NodePackageExtraEnvVarsField,
396
    )
397
    help = help_text(
11✔
398
        f"""
399
        A package.json file describing a nodejs package. (https://nodejs.org/api/packages.html#introduction)
400

401
        Generates a `{NodePackageTarget.alias}` target for the package.
402

403
        Generates `{NodeThirdPartyPackageTarget.alias}` targets for each specified
404
        3rd party dependency (e.g. in the package.json#devDependencies field).
405
        """
406
    )
407

408
    copied_fields = COMMON_TARGET_FIELDS
11✔
409
    moved_fields = (NodePackageDependenciesField,)
11✔
410

411

412
class NodeBuildScriptEntryPointField(StringField):
11✔
413
    alias = "entry_point"
11✔
414
    required = True
11✔
415
    value: str
11✔
416

417
    help = help_text(
11✔
418
        """
419
        The name of the script from the package.json#scripts section to execute for the build.
420

421
        This script should produce the output files/directories specified in the build script configuration.
422
        """
423
    )
424

425

426
class NodeBuildScriptSourcesField(SourcesField):
11✔
427
    alias = "_sources"
11✔
428
    required = False
11✔
429
    default = None
11✔
430

431
    help = "Marker field for node_build_scripts used in export-codegen."
11✔
432

433

434
class NodeBuildScriptOutputFilesField(StringSequenceField):
11✔
435
    alias = "output_files"
11✔
436
    required = False
11✔
437
    default = ()
11✔
438
    help = help_text(
11✔
439
        """
440
        Specify the build script's output files to capture, relative to the package.json.
441

442
        For directories, use `output_directories`. At least one of `output_files` and
443
        `output_directories` must be specified.
444

445
        Relative paths (including `..`) may be used, as long as the path does not ascend further
446
        than the package.json parent directory.
447
        """
448
    )
449

450

451
class NodeBuildScriptOutputDirectoriesField(StringSequenceField):
11✔
452
    alias = "output_directories"
11✔
453
    required = False
11✔
454
    default = ()
11✔
455
    help = help_text(
11✔
456
        """
457
        Specify full directories (including recursive descendants) of output to capture from the
458
        build script, relative to the package.json.
459

460
        For individual files, use `output_files`. At least one of `output_files` and
461
        `output_directories` must be specified.
462

463
        Relative paths (including `..`) may be used, as long as the path does not ascend further
464
        than the package.json parent directory.
465
        """
466
    )
467

468

469
class NodeBuildScriptExtraEnvVarsField(StringSequenceField):
11✔
470
    alias = "extra_env_vars"
11✔
471
    required = False
11✔
472
    default = ()
11✔
473
    help = help_text(
11✔
474
        f"""
475
        Additional environment variables to include in environment when running a build script process.
476

477
        {EXTRA_ENV_VARS_USAGE_HELP}
478
        """
479
    )
480

481

482
class NodeBuildScriptExtraCaches(StringSequenceField):
11✔
483
    alias = "extra_caches"
11✔
484
    required = False
11✔
485
    default = ()
11✔
486
    help = help_text(
487
        f"""
488
        Specify directories that pants should treat as caches for the build script.
489

490
        These directories will not be available as sources, but are available to
491
        subsequent executions of the build script.
492

493
        Example usage:
494
        # BUILD
495
        {PackageJsonTarget.alias}(
496
            scripts={NodeBuildScript.alias}(
497
                entry_point="build",
498
                output_directories=["dist"],
499
                extra_caches=[".parcel-cache"],
500
            )
501
        )
502

503
        # package.json
504
        {{
505
            ...
506
            "scripts": {{
507
                "build": "parcel build --dist-dir=dist --cache-dir=.parcel-cache"
508
                ...
509
            }}
510
            ...
511
        }}
512
        """
513
    )
514

515

516
class NodeBuildScriptTarget(Target):
11✔
517
    core_fields = (
11✔
518
        *COMMON_TARGET_FIELDS,
519
        NodeBuildScriptEntryPointField,
520
        NodeBuildScriptOutputDirectoriesField,
521
        NodeBuildScriptOutputFilesField,
522
        NodeBuildScriptSourcesField,
523
        NodeBuildScriptExtraCaches,
524
        NodeBuildScriptExtraEnvVarsField,
525
        NodePackageDependenciesField,
526
        OutputPathField,
527
    )
528

529
    alias = "_node_build_script"
11✔
530

531
    help = help_text(
11✔
532
        """
533
        A package.json script that is invoked by the configured package manager
534
        to produce `resource` targets or a packaged artifact.
535
        """
536
    )
537

538

539
class NodeRunScriptEntryPointField(StringField):
11✔
540
    alias = "entry_point"
11✔
541
    required = True
11✔
542
    value: str
11✔
543

544

545
class NodeRunScriptExtraEnvVarsField(StringSequenceField):
11✔
546
    alias = "extra_env_vars"
11✔
547
    required = False
11✔
548
    default = ()
11✔
549
    help = help_text(
11✔
550
        f"""
551
        Additional environment variables to include in environment when running a script process.
552

553
        {EXTRA_ENV_VARS_USAGE_HELP}
554
        """
555
    )
556

557

558
class NodeRunScriptTarget(Target):
11✔
559
    core_fields = (
11✔
560
        *COMMON_TARGET_FIELDS,
561
        NodeRunScriptEntryPointField,
562
        NodeRunScriptExtraEnvVarsField,
563
        NodePackageDependenciesField,
564
    )
565

566
    alias = "_node_run_script"
11✔
567

568
    help = help_text(
11✔
569
        """
570
        A package.json script that can be invoked directly via the run goal.
571
        """
572
    )
573

574

575
@dataclass(frozen=True)
11✔
576
class PackageJsonImports:
11✔
577
    """https://nodejs.org/api/packages.html#subpath-imports."""
578

579
    imports: FrozenDict[str, tuple[str, ...]]
11✔
580
    root_dir: str
11✔
581

582
    @classmethod
11✔
583
    def from_package_json(cls, pkg_json: PackageJson) -> PackageJsonImports:
11✔
584
        return cls(
×
585
            imports=cls._import_from_package_json(pkg_json),
586
            root_dir=pkg_json.root_dir,
587
        )
588

589
    @staticmethod
11✔
590
    def _import_from_package_json(
11✔
591
        pkg_json: PackageJson,
592
    ) -> FrozenDict[str, tuple[str, ...]]:
593
        imports: Mapping[str, Any] | None = pkg_json.content.get("imports")
×
594

595
        def get_subpaths(value: str | Mapping[str, Any]) -> Iterable[str]:
×
596
            if isinstance(value, str):
×
597
                yield value
×
598
            elif isinstance(value, Mapping):
×
599
                for v in value.values():
×
600
                    yield from get_subpaths(v)
×
601

602
        if not imports:
×
603
            return FrozenDict()
×
604
        return FrozenDict(
×
605
            {key: tuple(sorted(get_subpaths(subpath))) for key, subpath in imports.items()}
606
        )
607

608

609
@dataclass(frozen=True)
11✔
610
class PackageJsonEntryPoints:
11✔
611
    """See https://nodejs.org/api/packages.html#package-entry-points and
612
    https://docs.npmjs.com/cli/v9/configuring-npm/package-json#browser."""
613

614
    exports: FrozenDict[str, str]
11✔
615
    bin: FrozenDict[str, str]
11✔
616
    root_dir: str
11✔
617

618
    @property
11✔
619
    def globs(self) -> Iterable[str]:
11✔
620
        for export in self.exports.values():
×
621
            yield export.replace("*", "**/*")
×
622
        yield from self.bin.values()
×
623

624
    def globs_from_root(self) -> Iterable[str]:
11✔
625
        for path in self.globs:
×
626
            yield os.path.normpath(os.path.join(self.root_dir, path))
×
627

628
    @classmethod
11✔
629
    def from_package_json(cls, pkg_json: PackageJson) -> PackageJsonEntryPoints:
11✔
630
        return cls(
×
631
            exports=cls._exports_form_package_json(pkg_json),
632
            bin=cls._binaries_from_package_json(pkg_json),
633
            root_dir=pkg_json.root_dir,
634
        )
635

636
    @staticmethod
11✔
637
    def _exports_form_package_json(pkg_json: PackageJson) -> FrozenDict[str, str]:
11✔
638
        content = pkg_json.content
×
639
        exports: str | Mapping[str, str] | None = content.get("exports")
×
640
        main: str | None = content.get("main")
×
641
        browser: str | None = content.get("browser")
×
642
        source: str | None = content.get("source")
×
643
        if exports:
×
644
            if isinstance(exports, str):
×
645
                return FrozenDict({".": exports})
×
646
            else:
647
                return FrozenDict(exports)
×
648
        elif browser:
×
649
            return FrozenDict({".": browser})
×
650
        elif main:
×
651
            return FrozenDict({".": main})
×
652
        elif source:
×
653
            return FrozenDict({".": source})
×
654
        return FrozenDict()
×
655

656
    @staticmethod
11✔
657
    def _binaries_from_package_json(pkg_json: PackageJson) -> FrozenDict[str, str]:
11✔
658
        binaries: str | Mapping[str, str] | None = pkg_json.content.get("bin")
×
659
        if binaries:
×
660
            if isinstance(binaries, str):
×
661
                return FrozenDict({pkg_json.name: binaries})
×
662
            else:
663
                return FrozenDict(binaries)
×
664
        return FrozenDict()
×
665

666

667
@dataclass(frozen=True)
11✔
668
class PackageJsonScripts:
11✔
669
    scripts: FrozenDict[str, str]
11✔
670

671
    @classmethod
11✔
672
    def from_package_json(cls, pkg_json: PackageJson) -> PackageJsonScripts:
11✔
673
        return cls(FrozenDict.deep_freeze(pkg_json.content.get("scripts", {})))
×
674

675

676
@dataclass(frozen=True)
11✔
677
class PackageJson:
11✔
678
    content: FrozenDict[str, Any]
11✔
679
    name: str
11✔
680
    version: str | None
11✔
681
    snapshot: Snapshot
11✔
682
    workspaces: tuple[str, ...] = ()
11✔
683
    module: Literal["commonjs", "module"] | None = None
11✔
684
    dependencies: FrozenDict[str, str] = field(default_factory=FrozenDict)
11✔
685
    package_manager: str | None = None
11✔
686

687
    def __post_init__(self) -> None:
11✔
UNCOV
688
        if self.module not in (None, "commonjs", "module"):
×
689
            raise ValueError(
×
690
                f'package.json "type" can only be one of "commonjs", "module", but was "{self.module}".'
691
            )
692

693
    @property
11✔
694
    def digest(self) -> Digest:
11✔
695
        return self.snapshot.digest
×
696

697
    @property
11✔
698
    def file(self) -> str:
11✔
699
        return self.snapshot.files[0]
×
700

701
    @property
11✔
702
    def root_dir(self) -> str:
11✔
703
        return os.path.dirname(self.file)
×
704

705

706
class FirstPartyNodePackageTargets(Targets):
11✔
707
    pass
11✔
708

709

710
class AllPackageJson(Collection[PackageJson]):
11✔
711
    pass
11✔
712

713

714
class PackageJsonForGlobs(Collection[PackageJson]):
11✔
715
    pass
11✔
716

717

718
@rule
11✔
719
async def all_first_party_node_package_targets(targets: AllTargets) -> FirstPartyNodePackageTargets:
11✔
720
    return FirstPartyNodePackageTargets(
×
721
        tgt for tgt in targets if tgt.has_fields((PackageJsonSourceField, NodePackageNameField))
722
    )
723

724

725
@dataclass(frozen=True)
11✔
726
class OwningNodePackageRequest:
11✔
727
    address: Address
11✔
728

729

730
@dataclass(frozen=True)
11✔
731
class OwningNodePackage:
11✔
732
    target: Target | None = None
11✔
733
    third_party: tuple[Target, ...] = ()
11✔
734

735
    @classmethod
11✔
736
    def no_owner(cls) -> OwningNodePackage:
11✔
737
        return cls()
1✔
738

739
    def ensure_owner(self) -> Target:
11✔
740
        if self != OwningNodePackage.no_owner():
1✔
741
            assert self.target
1✔
742
            return self.target
1✔
743
        raise ValueError("No owner could be determined.")
×
744

745

746
@rule
11✔
747
async def find_owning_package(request: OwningNodePackageRequest) -> OwningNodePackage:
11✔
748
    candidate_targets = await resolve_targets(
×
749
        **implicitly(
750
            RawSpecs(
751
                ancestor_globs=(AncestorGlobSpec(request.address.spec_path),),
752
                description_of_origin=f"the `{OwningNodePackage.__name__}` rule",
753
            )
754
        )
755
    )
756
    package_json_tgts = sorted(
×
757
        (
758
            tgt
759
            for tgt in candidate_targets
760
            if tgt.has_field(PackageJsonSourceField) and tgt.has_field(NodePackageNameField)
761
        ),
762
        key=lambda tgt: tgt.address.spec_path,
763
        reverse=True,
764
    )
765
    tgt = package_json_tgts[0] if package_json_tgts else None
×
766
    if tgt:
×
767
        deps = await resolve_targets(**implicitly(DependenciesRequest(tgt[Dependencies])))
×
768
        return OwningNodePackage(
×
769
            tgt, tuple(dep for dep in deps if dep.has_field(NodeThirdPartyPackageNameField))
770
        )
771
    return OwningNodePackage()
×
772

773

774
@rule
11✔
775
async def parse_package_json(content: FileContent) -> PackageJson:
11✔
776
    parsed_package_json = FrozenDict.deep_freeze(json.loads(content.content))
×
777
    package_name = parsed_package_json.get("name")
×
778
    if not package_name:
×
779
        raise ValueError("No package name found in package.json")
×
780

781
    return PackageJson(
×
782
        content=parsed_package_json,
783
        name=package_name,
784
        version=parsed_package_json.get("version"),
785
        snapshot=await digest_to_snapshot(**implicitly(PathGlobs([content.path]))),
786
        module=parsed_package_json.get("type"),
787
        workspaces=tuple(parsed_package_json.get("workspaces", ())),
788
        dependencies=FrozenDict.deep_freeze(
789
            {
790
                **parsed_package_json.get("dependencies", {}),
791
                **parsed_package_json.get("devDependencies", {}),
792
                **parsed_package_json.get("peerDependencies", {}),
793
            }
794
        ),
795
        package_manager=parsed_package_json.get("packageManager"),
796
    )
797

798

799
@rule
11✔
800
async def read_package_jsons(globs: PathGlobs) -> PackageJsonForGlobs:
11✔
801
    digest_contents = await get_digest_contents(**implicitly(globs))
×
802
    return PackageJsonForGlobs(
×
803
        await concurrently(parse_package_json(digest_content) for digest_content in digest_contents)
804
    )
805

806

807
@rule
11✔
808
async def all_package_json() -> AllPackageJson:
11✔
809
    # Avoids using `AllTargets` due to a circular rule dependency.
810
    # `generate_node_package_targets` requires knowledge of all
811
    # first party package names.
812
    description_of_origin = "The `AllPackageJson` rule"
×
813
    requests = await resolve_all_generator_target_requests(
×
814
        ResolveAllTargetGeneratorRequests(
815
            description_of_origin=description_of_origin, of_type=PackageJsonTarget
816
        )
817
    )
818
    globs = [
×
819
        glob
820
        for req in requests.requests
821
        for glob in req.generator[PackageJsonSourceField]
822
        .path_globs(UnmatchedBuildFileGlobs.error())
823
        .globs
824
    ]
825
    return AllPackageJson(
×
826
        await read_package_jsons(
827
            PathGlobs(
828
                globs, GlobMatchErrorBehavior.error, description_of_origin=description_of_origin
829
            )
830
        )
831
    )
832

833

834
@dataclass(frozen=True)
11✔
835
class PnpmWorkspaceGlobs:
11✔
836
    packages: tuple[str, ...]
11✔
837
    digest: Digest
11✔
838

839

840
class PnpmWorkspaces(FrozenDict[PackageJson, PnpmWorkspaceGlobs]):
11✔
841
    def for_root(self, root_dir: str) -> PnpmWorkspaceGlobs | None:
11✔
842
        for pkg, workspaces in self.items():
×
843
            if pkg.root_dir == root_dir:
×
844
                return workspaces
×
845
        return None
×
846

847

848
@rule
11✔
849
async def pnpm_workspace_files(pkgs: AllPackageJson) -> PnpmWorkspaces:
11✔
850
    digest_contents = await get_digest_contents(
×
851
        **implicitly(PathGlobs(os.path.join(pkg.root_dir, "pnpm-workspace.yaml") for pkg in pkgs))
852
    )
853

854
    async def parse_package_globs(content: FileContent) -> PnpmWorkspaceGlobs:
×
855
        parsed = yaml.safe_load(content.content) or {"packages": ("**",)}
×
856
        return PnpmWorkspaceGlobs(
×
857
            tuple(parsed.get("packages", ("**",)) or ("**",)),
858
            await create_digest(CreateDigest([content])),
859
        )
860

861
    globs_per_root = {
×
862
        os.path.dirname(digest_content.path): await parse_package_globs(digest_content)
863
        for digest_content in digest_contents
864
    }
865

866
    return PnpmWorkspaces(
×
867
        {pkg: globs_per_root[pkg.root_dir] for pkg in pkgs if pkg.root_dir in globs_per_root}
868
    )
869

870

871
class AllPackageJsonNames(DeduplicatedCollection[str]):
11✔
872
    """Used to not invalidate all generated node package targets when any package.json contents are
873
    changed."""
874

875

876
@rule
11✔
877
async def all_package_json_names(all_pkg_jsons: AllPackageJson) -> AllPackageJsonNames:
11✔
878
    return AllPackageJsonNames(pkg.name for pkg in all_pkg_jsons)
×
879

880

881
@rule
11✔
882
async def package_json_for_source(source_field: PackageJsonSourceField) -> PackageJson:
11✔
883
    [pkg_json] = await read_package_jsons(source_field.path_globs(UnmatchedBuildFileGlobs.error()))
×
884
    return pkg_json
×
885

886

887
@rule
11✔
888
async def script_entrypoints_for_source(
11✔
889
    source_field: PackageJsonSourceField,
890
) -> PackageJsonEntryPoints:
891
    return PackageJsonEntryPoints.from_package_json(await package_json_for_source(source_field))
×
892

893

894
@rule
11✔
895
async def subpath_imports_for_source(
11✔
896
    source_field: PackageJsonSourceField,
897
) -> PackageJsonImports:
898
    return PackageJsonImports.from_package_json(await package_json_for_source(source_field))
×
899

900

901
class GenerateNodePackageTargets(GenerateTargetsRequest):
11✔
902
    generate_from = PackageJsonTarget
11✔
903

904

905
def _script_missing_error(entry_point: str, scripts: Iterable[str], address: Address) -> ValueError:
11✔
906
    return ValueError(
×
907
        softwrap(
908
            f"""
909
            {entry_point} was not found in package.json#scripts section
910
            of the `{PackageJsonTarget.alias}` target with address {address}.
911

912
            Available scripts are: {", ".join(scripts)}.
913
            """
914
        )
915
    )
916

917

918
@rule
11✔
919
async def generate_node_package_targets(
11✔
920
    request: GenerateNodePackageTargets,
921
    union_membership: UnionMembership,
922
    first_party_names: AllPackageJsonNames,
923
) -> GeneratedTargets:
924
    file = request.generator[PackageJsonSourceField].file_path
×
925
    file_tgt = TargetGeneratorSourcesHelperTarget(
×
926
        {TargetGeneratorSourcesHelperSourcesField.alias: os.path.basename(file)},
927
        request.generator.address.create_generated(file),
928
        union_membership,
929
    )
930

931
    pkg_json = await package_json_for_source(request.generator[PackageJsonSourceField])
×
932

933
    third_party_tgts = [
×
934
        NodeThirdPartyPackageTarget(
935
            {
936
                **{
937
                    key: value
938
                    for key, value in request.template.items()
939
                    if key != PackageJsonSourceField.alias
940
                },
941
                NodeThirdPartyPackageNameField.alias: name,
942
                NodeThirdPartyPackageVersionField.alias: version,
943
                NodeThirdPartyPackageDependenciesField.alias: [file_tgt.address.spec],
944
            },
945
            request.generator.address.create_generated(name.replace("@", "__")),
946
            union_membership,
947
        )
948
        for name, version in pkg_json.dependencies.items()
949
        if name not in first_party_names
950
    ]
951

952
    package_target = NodePackageTarget(
×
953
        {
954
            **request.template,
955
            NodePackageNameField.alias: pkg_json.name,
956
            NodePackageVersionField.alias: pkg_json.version,
957
            NodePackageExtraEnvVarsField.alias: request.generator[
958
                NodePackageExtraEnvVarsField
959
            ].value,
960
            NodePackageDependenciesField.alias: [
961
                file_tgt.address.spec,
962
                *(tgt.address.spec for tgt in third_party_tgts),
963
                *request.template.get("dependencies", []),
964
            ],
965
            NodePackageTestScriptField.alias: request.generator[
966
                NodePackageScriptsField
967
            ].get_test_script(),
968
        },
969
        request.generator.address.create_generated(pkg_json.name.replace("@", "__")),
970
        union_membership,
971
    )
972
    scripts = PackageJsonScripts.from_package_json(pkg_json).scripts
×
973
    build_script_tgts = []
×
974
    for build_script in request.generator[NodePackageScriptsField].build_scripts():
×
975
        if build_script.entry_point in scripts:
×
976
            build_script_tgts.append(
×
977
                NodeBuildScriptTarget(
978
                    {
979
                        **request.template,
980
                        NodeBuildScriptEntryPointField.alias: build_script.entry_point,
981
                        NodeBuildScriptOutputDirectoriesField.alias: build_script.output_directories,
982
                        NodeBuildScriptOutputFilesField.alias: build_script.output_files,
983
                        NodeBuildScriptExtraEnvVarsField.alias: build_script.extra_env_vars,
984
                        NodeBuildScriptExtraCaches.alias: build_script.extra_caches,
985
                        NodePackageDependenciesField.alias: [
986
                            file_tgt.address.spec,
987
                            *(tgt.address.spec for tgt in third_party_tgts),
988
                            *request.template.get("dependencies", []),
989
                            package_target.address.spec,
990
                        ],
991
                        DescriptionField.alias: build_script.description,
992
                        Tags.alias: build_script.tags,
993
                    },
994
                    request.generator.address.create_generated(build_script.entry_point),
995
                    union_membership,
996
                )
997
            )
998
        else:
999
            raise _script_missing_error(
×
1000
                build_script.entry_point, scripts, request.generator.address
1001
            )
1002

1003
    run_script_tgts = []
×
1004
    for script in request.generator[NodePackageScriptsField].value or ():
×
1005
        if isinstance(script, NodeRunScript):
×
1006
            if script.entry_point in scripts:
×
1007
                run_script_tgts.append(
×
1008
                    NodeRunScriptTarget(
1009
                        {
1010
                            **request.template,
1011
                            NodeRunScriptEntryPointField.alias: script.entry_point,
1012
                            NodeRunScriptExtraEnvVarsField.alias: script.extra_env_vars,
1013
                            NodePackageDependenciesField.alias: [
1014
                                file_tgt.address.spec,
1015
                                *(tgt.address.spec for tgt in third_party_tgts),
1016
                                *request.template.get("dependencies", []),
1017
                                package_target.address.spec,
1018
                            ],
1019
                        },
1020
                        request.generator.address.create_generated(script.entry_point),
1021
                        union_membership,
1022
                    )
1023
                )
1024
            else:
1025
                raise _script_missing_error(script.entry_point, scripts, request.generator.address)
×
1026

1027
    coverage_script = package_target[NodePackageTestScriptField].value.coverage_entry_point
×
1028
    if coverage_script and coverage_script not in scripts:
×
1029
        raise _script_missing_error(coverage_script, scripts, request.generator.address)
×
1030

1031
    return GeneratedTargets(
×
1032
        request.generator,
1033
        [
1034
            package_target,
1035
            file_tgt,
1036
            *third_party_tgts,
1037
            *build_script_tgts,
1038
            *run_script_tgts,
1039
        ],
1040
    )
1041

1042

1043
def target_types() -> Iterable[type[Target]]:
11✔
1044
    return [
3✔
1045
        PackageJsonTarget,
1046
        NodePackageTarget,
1047
        NodeThirdPartyPackageTarget,
1048
        NPMDistributionTarget,
1049
        NodeBuildScriptTarget,
1050
    ]
1051

1052

1053
def rules() -> Iterable[Rule | UnionRule]:
11✔
1054
    return [
11✔
1055
        *graph.rules(),
1056
        *dependencies.rules(),
1057
        *stripped_source_files.rules(),
1058
        *fs.rules(),
1059
        *collect_rules(),
1060
        UnionRule(GenerateTargetsRequest, GenerateNodePackageTargets),
1061
    ]
1062

1063

1064
def build_file_aliases() -> BuildFileAliases:
11✔
1065
    return BuildFileAliases(
5✔
1066
        objects={
1067
            NodeBuildScript.alias: NodeBuildScript.create,
1068
            NodeTestScript.alias: NodeTestScript.create,
1069
            NodeRunScript.alias: NodeRunScript.create,
1070
        }
1071
    )
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

© 2025 Coveralls, Inc