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

pantsbuild / pants / 19015773527

02 Nov 2025 05:33PM UTC coverage: 17.872% (-62.4%) from 80.3%
19015773527

Pull #22816

github

web-flow
Merge a12d75757 into 6c024e162
Pull Request #22816: Update Pants internal Python to 3.14

4 of 5 new or added lines in 3 files covered. (80.0%)

28452 existing lines in 683 files now uncovered.

9831 of 55007 relevant lines covered (17.87%)

0.18 hits per line

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

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

UNCOV
5
import itertools
×
UNCOV
6
import os.path
×
UNCOV
7
from collections.abc import Iterable
×
UNCOV
8
from dataclasses import dataclass, replace
×
UNCOV
9
from pathlib import PurePath
×
10

UNCOV
11
from pants.backend.javascript import package_json
×
UNCOV
12
from pants.backend.javascript.package_json import (
×
13
    AllPackageJson,
14
    PackageJson,
15
    PnpmWorkspaceGlobs,
16
    PnpmWorkspaces,
17
)
UNCOV
18
from pants.backend.javascript.package_manager import PackageManager
×
UNCOV
19
from pants.backend.javascript.subsystems import nodejs
×
UNCOV
20
from pants.backend.javascript.subsystems.nodejs import NodeJS, UserChosenNodeJSResolveAliases
×
UNCOV
21
from pants.core.util_rules import stripped_source_files
×
UNCOV
22
from pants.core.util_rules.stripped_source_files import StrippedFileNameRequest, strip_file_name
×
UNCOV
23
from pants.engine.collection import Collection
×
UNCOV
24
from pants.engine.internals.native_engine import MergeDigests
×
UNCOV
25
from pants.engine.rules import Rule, collect_rules, rule
×
UNCOV
26
from pants.engine.unions import UnionRule
×
UNCOV
27
from pants.util.ordered_set import FrozenOrderedSet
×
UNCOV
28
from pants.util.strutil import bullet_list, softwrap
×
29

30

UNCOV
31
@dataclass(frozen=True)
×
UNCOV
32
class _TentativeProject:
×
UNCOV
33
    root_dir: str
×
UNCOV
34
    workspaces: FrozenOrderedSet[PackageJson]
×
UNCOV
35
    default_resolve_name: str
×
36

UNCOV
37
    def is_parent(self, project: _TentativeProject) -> bool:
×
38
        return self.root_dir != project.root_dir and any(
×
39
            project.root_dir == workspace.root_dir for workspace in self.workspaces
40
        )
41

UNCOV
42
    def including_workspaces_from(self, child: _TentativeProject) -> _TentativeProject:
×
43
        return replace(self, workspaces=self.workspaces | child.workspaces)
×
44

UNCOV
45
    def root_workspace(self) -> PackageJson | None:
×
46
        for ws in self.workspaces:
×
47
            if ws.root_dir == self.root_dir:
×
48
                return ws
×
49
        return None
×
50

51

UNCOV
52
@dataclass(frozen=True)
×
UNCOV
53
class NodeJSProject:
×
UNCOV
54
    root_dir: str
×
UNCOV
55
    workspaces: FrozenOrderedSet[PackageJson]
×
UNCOV
56
    default_resolve_name: str
×
UNCOV
57
    package_manager: PackageManager
×
UNCOV
58
    pnpm_workspace: PnpmWorkspaceGlobs | None = None
×
59

UNCOV
60
    @property
×
UNCOV
61
    def lockfile_name(self) -> str:
×
UNCOV
62
        return self.package_manager.lockfile_name
×
63

UNCOV
64
    @property
×
UNCOV
65
    def generate_lockfile_args(self) -> tuple[str, ...]:
×
66
        return self.package_manager.generate_lockfile_args
×
67

UNCOV
68
    @property
×
UNCOV
69
    def immutable_install_args(self) -> tuple[str, ...]:
×
UNCOV
70
        return self.package_manager.immutable_install_args
×
71

UNCOV
72
    @property
×
UNCOV
73
    def workspace_specifier_arg(self) -> str:
×
74
        return self.package_manager.workspace_specifier_arg
×
75

UNCOV
76
    @property
×
UNCOV
77
    def args_separator(self) -> tuple[str, ...]:
×
78
        return self.package_manager.run_arg_separator
×
79

UNCOV
80
    def extra_env(self) -> dict[str, str]:
×
81
        return dict(self.package_manager.extra_env)
×
82

UNCOV
83
    @property
×
UNCOV
84
    def pack_archive_format(self) -> str:
×
85
        return self.package_manager.pack_archive_format
×
86

UNCOV
87
    def extra_caches(self) -> dict[str, str]:
×
88
        return dict(self.package_manager.extra_caches)
×
89

UNCOV
90
    def get_project_digest(self) -> MergeDigests:
×
91
        return MergeDigests(
×
92
            itertools.chain(
93
                (ws.digest for ws in self.workspaces),
94
                [self.pnpm_workspace.digest] if self.pnpm_workspace else [],
95
            )
96
        )
97

UNCOV
98
    @property
×
UNCOV
99
    def single_workspace(self) -> bool:
×
100
        return len(self.workspaces) == 1 and next(iter(self.workspaces)).root_dir == self.root_dir
×
101

UNCOV
102
    @classmethod
×
UNCOV
103
    def from_tentative(
×
104
        cls,
105
        project: _TentativeProject,
106
        nodejs: NodeJS,
107
        pnpm_workspaces: PnpmWorkspaces,
108
    ) -> NodeJSProject:
109
        root_ws = project.root_workspace()
×
110
        package_manager: str | None = None
×
111
        if root_ws:
×
112
            package_manager = root_ws.package_manager or nodejs.default_package_manager
×
113
        if not package_manager:
×
114
            raise ValueError(
×
115
                softwrap(
116
                    f"""
117
                    Could not determine package manager for project {project.default_resolve_name}.
118

119
                    Either configure a default [{NodeJS.name}].package_manager, or set the root
120
                    `package.json#packageManager` property.
121
                    """
122
                )
123
            )
124

125
        for workspace in project.workspaces:
×
126
            if workspace.package_manager:
×
127
                if not package_manager == workspace.package_manager:
×
128
                    ws_ref = f"{workspace.name}@{workspace.version}"
×
129
                    raise ValueError(
×
130
                        softwrap(
131
                            f"""
132
                            Workspace {ws_ref}'s package manager
133
                            {workspace.package_manager} is not compatible with
134
                            project {project.default_resolve_name}'s package manager {package_manager}.
135

136
                            Move or duplicate the `package.json#packageManager` entry from the
137
                            workspace {ws_ref} to the root package to resolve this error.
138
                            """
139
                        )
140
                    )
141

142
        return NodeJSProject(
×
143
            root_dir=project.root_dir,
144
            workspaces=project.workspaces,
145
            default_resolve_name=project.default_resolve_name or "nodejs-default",
146
            package_manager=PackageManager.from_string(package_manager),
147
            pnpm_workspace=pnpm_workspaces.for_root(project.root_dir),
148
        )
149

150

UNCOV
151
class AllNodeJSProjects(Collection[NodeJSProject]):
×
UNCOV
152
    def project_for_directory(self, directory: str) -> NodeJSProject:
×
153
        for project in self:
×
154
            if directory in (workspace.root_dir for workspace in project.workspaces):
×
155
                return project
×
156
        raise ValueError(
×
157
            f"{directory} is not a package directory that is part of a project. This is likely a bug."
158
        )
159

160

UNCOV
161
@dataclass(frozen=True)
×
UNCOV
162
class ProjectPaths:
×
UNCOV
163
    root: str
×
UNCOV
164
    project_globs: list[str]
×
165

UNCOV
166
    def full_globs(self) -> Iterable[str]:
×
167
        return (os.path.join(self.root, project) for project in self.project_globs)
×
168

UNCOV
169
    def matches_glob(self, pkg_json: PackageJson) -> bool:
×
170
        path = PurePath(pkg_json.root_dir)
×
171

172
        def safe_match(glob: str) -> bool:
×
173
            if glob == "":
×
174
                return pkg_json.root_dir == ""
×
175
            return path.match(glob)
×
176

177
        return any(safe_match(glob) for glob in self.full_globs())
×
178

179

UNCOV
180
async def _get_default_resolve_name(path: str) -> str:
×
181
    stripped = await strip_file_name(StrippedFileNameRequest(path))
×
182
    return stripped.value.replace(os.path.sep, ".")
×
183

184

UNCOV
185
@rule
×
UNCOV
186
async def find_node_js_projects(
×
187
    package_workspaces: AllPackageJson,
188
    pnpm_workspaces: PnpmWorkspaces,
189
    nodejs: NodeJS,
190
    resolve_names: UserChosenNodeJSResolveAliases,
191
) -> AllNodeJSProjects:
192
    project_paths = []
×
193
    for pkg in package_workspaces:
×
194
        package_manager = pkg.package_manager or nodejs.default_package_manager
×
195
        if (
×
196
            package_manager and PackageManager.from_string(package_manager).name == "pnpm"
197
        ):  # case for pnpm
198
            if pkg in pnpm_workspaces:
×
199
                project_paths.append(
×
200
                    ProjectPaths(pkg.root_dir, ["", *pnpm_workspaces[pkg].packages])
201
                )
202
            else:
203
                project_paths.append(ProjectPaths(pkg.root_dir, [""]))
×
204
        else:  # case for npm, yarn
205
            project_paths.append(ProjectPaths(pkg.root_dir, ["", *pkg.workspaces]))
×
206

207
    node_js_projects = {
×
208
        _TentativeProject(
209
            paths.root,
210
            FrozenOrderedSet(pkg for pkg in package_workspaces if paths.matches_glob(pkg)),
211
            await _get_default_resolve_name(paths.root),
212
        )
213
        for paths in project_paths
214
    }
215
    merged_projects = _merge_workspaces(node_js_projects)
×
216
    all_projects = AllNodeJSProjects(
×
217
        NodeJSProject.from_tentative(p, nodejs, pnpm_workspaces) for p in merged_projects
218
    )
219
    _ensure_resolve_names_are_unique(all_projects, resolve_names)
×
220

221
    return all_projects
×
222

223

UNCOV
224
_AMBIGUOUS_RESOLVE_SOLUTIONS = [
×
225
    f"Configure [{NodeJS.options_scope}].resolves to grant the package.json directories different names.",
226
    "Make one package a workspace of the other.",
227
    "Re-configure your source root(s).",
228
]
229

230

UNCOV
231
def _ensure_resolve_names_are_unique(
×
232
    all_projects: AllNodeJSProjects, resolve_names: UserChosenNodeJSResolveAliases
233
) -> None:
234
    seen: dict[str, NodeJSProject] = {}
×
235
    for project in all_projects:
×
236
        resolve_name = resolve_names.get(project.root_dir, project.default_resolve_name)
×
237
        seen_project = seen.get(resolve_name)
×
238
        if seen_project:
×
239
            raise ValueError(
×
240
                softwrap(
241
                    f"""
242
                    Projects with root directories '{project.root_dir}' and '{seen_project.root_dir}'
243
                    have the same resolve name {resolve_name}. This will cause ambiguities.
244

245
                    To disambiguate, either:\n\n
246
                    {bullet_list(_AMBIGUOUS_RESOLVE_SOLUTIONS)}
247
                    """
248
                )
249
            )
250
        seen[resolve_name] = project
×
251

252

UNCOV
253
def _project_to_parents(
×
254
    projects: set[_TentativeProject],
255
) -> dict[_TentativeProject, list[_TentativeProject]]:
256
    return {
×
257
        project: [
258
            candidate_parent for candidate_parent in projects if candidate_parent.is_parent(project)
259
        ]
260
        for project in sorted(projects, key=lambda p: p.root_dir, reverse=False)
261
    }
262

263

UNCOV
264
def _merge_workspaces(node_js_projects: set[_TentativeProject]) -> Iterable[_TentativeProject]:
×
265
    project_to_parents = _project_to_parents(node_js_projects)
×
266
    while any(parents for parents in project_to_parents.values()):
×
267
        _ensure_one_parent(project_to_parents)
×
268
        node_js_projects = set()
×
269
        for project, parents in project_to_parents.items():
×
270
            node_js_projects -= {project, *parents}
×
271
            node_js_projects.add(
×
272
                parents[0].including_workspaces_from(project) if parents else project
273
            )
274
        project_to_parents = _project_to_parents(node_js_projects)
×
275
    return node_js_projects
×
276

277

UNCOV
278
def _ensure_one_parent(
×
279
    project_to_parents: dict[_TentativeProject, list[_TentativeProject]],
280
) -> None:
281
    for project, parents in project_to_parents.items():
×
282
        if len(parents) > 1:
×
283
            raise ValueError(
×
284
                softwrap(
285
                    f"""
286
                    Nodejs projects {", ".join(parent.root_dir for parent in parents)}
287
                    are specifying {project.root_dir} to be part of their workspaces.
288

289
                    A package can only be part of one project.
290
                    """
291
                )
292
            )
293

294

UNCOV
295
def rules() -> Iterable[Rule | UnionRule]:
×
UNCOV
296
    return [
×
297
        *nodejs.rules(),
298
        *package_json.rules(),
299
        *stripped_source_files.rules(),
300
        *collect_rules(),
301
    ]
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