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

pantsbuild / pants / 25405422172

05 May 2026 10:18PM UTC coverage: 92.879% (-0.07%) from 92.944%
25405422172

Pull #23319

github

web-flow
Merge c82d0f333 into e8b784f89
Pull Request #23319: [pants_ng] Scaffolding for a pants_ng mode.

25 of 76 new or added lines in 9 files covered. (32.89%)

209 existing lines in 15 files now uncovered.

92234 of 99306 relevant lines covered (92.88%)

4.05 hits per line

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

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

4
from __future__ import annotations
12✔
5

6
import importlib.resources
12✔
7
import json
12✔
8
import logging
12✔
9
import os
12✔
10
from collections.abc import Callable, Iterable, Sequence
12✔
11
from dataclasses import dataclass
12✔
12
from functools import cache
12✔
13
from typing import ClassVar
12✔
14
from urllib.parse import urlparse
12✔
15

16
import toml
12✔
17

18
from pants.backend.python.target_types import ConsoleScript, EntryPoint, MainSpecification
12✔
19
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
12✔
20
from pants.backend.python.util_rules.lockfile_metadata import PythonLockfileMetadata
12✔
21
from pants.backend.python.util_rules.pex import PexRequest
12✔
22
from pants.backend.python.util_rules.pex_requirements import (
12✔
23
    EntireLockfile,
24
    LoadedLockfile,
25
    LoadedLockfileRequest,
26
    Lockfile,
27
    PexRequirements,
28
    Resolve,
29
    get_lockfile_for_resolve,
30
    load_lockfile,
31
    strip_comments_from_pex_json_lockfile,
32
)
33
from pants.core.goals.resolves import ExportableTool
12✔
34
from pants.engine.fs import Digest
12✔
35
from pants.engine.rules import implicitly
12✔
36
from pants.option.errors import OptionsError
12✔
37
from pants.option.option_types import StrListOption, StrOption
12✔
38
from pants.option.subsystem import Subsystem
12✔
39
from pants.util.docutil import doc_url, git_url
12✔
40
from pants.util.meta import classproperty
12✔
41
from pants.util.pip_requirement import PipRequirement
12✔
42
from pants.util.strutil import softwrap, strval
12✔
43

44
logger = logging.getLogger(__name__)
12✔
45

46

47
@dataclass(frozen=True)
12✔
48
class _PackageNameAndVersion:
12✔
49
    name: str
12✔
50
    version: str
12✔
51

52

53
class PythonToolRequirementsBase(Subsystem, ExportableTool):
12✔
54
    """Base class for subsystems that configure a set of requirements for a python tool."""
55

56
    # Subclasses must set.
57
    default_version: ClassVar[str]
12✔
58
    # Must be set by subclasses - will be used to set the help text in this class.
59
    help_short: ClassVar[str | Callable[[], str]]
12✔
60
    # Subclasses do not need to override.
61
    default_extra_requirements: ClassVar[Sequence[str]] = []
12✔
62

63
    # Subclasses may set to override the value computed from default_version and
64
    # default_extra_requirements.
65
    # The primary package used in the subsystem must always be the first requirement.
66
    # TODO: Once we get rid of those options, subclasses must set this to loose
67
    #  requirements that reflect any minimum capabilities Pants assumes about the tool.
68
    default_requirements: Sequence[str] = []
12✔
69

70
    default_interpreter_constraints: ClassVar[Sequence[str]] = ["CPython>=3.9,<3.15"]
12✔
71
    register_interpreter_constraints: ClassVar[bool] = False
12✔
72

73
    default_lockfile_resource: ClassVar[tuple[str, str] | None] = None
12✔
74

75
    @classmethod
12✔
76
    def _help_extended(cls) -> str:
12✔
77
        base_help = strval(cls.help_short)
12✔
78
        help_paragraphs = [base_help]
12✔
79
        package_and_version = cls._default_package_name_and_version()
12✔
80
        if package_and_version:
12✔
81
            new_paragraph = f"This version of Pants uses `{package_and_version.name}` version {package_and_version.version} by default. Use a dedicated lockfile and the `install_from_resolve` option to control this."
12✔
82
            help_paragraphs.append(new_paragraph)
12✔
83

84
        return "\n\n".join(help_paragraphs)
12✔
85

86
    help = classproperty(_help_extended)
12✔
87

88
    @classmethod
12✔
89
    def _install_from_resolve_help(cls) -> str:
12✔
90
        package_and_version = cls._default_package_name_and_version()
12✔
91
        version_clause = (
12✔
92
            f", which uses `{package_and_version.name}` version {package_and_version.version}"
93
            if package_and_version
94
            else ""
95
        )
96
        return softwrap(
12✔
97
            f"""\
98
            If specified, install the tool using the lockfile for this named resolve.
99

100
            This resolve must be defined in `[python].resolves`, as described in
101
            {doc_url("docs/python/overview/lockfiles#lockfiles-for-tools")}.
102

103
            The resolve's entire lockfile will be installed, unless specific requirements are
104
            listed via the `requirements` option, in which case only those requirements
105
            will be installed. This is useful if you don't want to invalidate the tool's
106
            outputs when the resolve incurs changes to unrelated requirements.
107

108
            If unspecified, and the `lockfile` option is unset, the tool will be installed
109
            using the default lockfile shipped with Pants{version_clause}.
110

111
            If unspecified, and the `lockfile` option is set, the tool will use the custom
112
            `{cls.options_scope}` "tool lockfile" generated from the `version` and
113
            `extra_requirements` options. But note that this mechanism is deprecated.
114
            """
115
        )
116

117
    install_from_resolve = StrOption(
12✔
118
        advanced=True,
119
        default=None,
120
        help=lambda cls: cls._install_from_resolve_help(),
121
    )
122

123
    requirements = StrListOption(
12✔
124
        advanced=True,
125
        help=lambda cls: softwrap(
126
            """\
127
            If `install_from_resolve` is specified, install these requirements,
128
            at the versions provided by the specified resolve's lockfile.
129

130
            Values can be pip-style requirements (e.g., `tool` or `tool==1.2.3` or `tool>=1.2.3`),
131
            or addresses of `python_requirement` targets (or targets that generate or depend on
132
            `python_requirement` targets). Make sure to use the `//` prefix to refer to targets
133
            using their full address from the root (e.g. `//3rdparty/python:tool`). This is necessary
134
            to distinguish address specs from local or VCS requirements.
135

136
            The lockfile will be validated against the requirements - if a lockfile doesn't
137
            provide the requirement (at a suitable version, if the requirement specifies version
138
            constraints) Pants will error.
139

140
            If unspecified, install the entire lockfile.
141
            """
142
        ),
143
    )
144
    _interpreter_constraints = StrListOption(
12✔
145
        register_if=lambda cls: cls.register_interpreter_constraints,
146
        advanced=True,
147
        default=lambda cls: cls.default_interpreter_constraints,
148
        help="Python interpreter constraints for this tool.",
149
    )
150

151
    def __init__(self, *args, **kwargs):
12✔
152
        if (
12✔
153
            self.default_interpreter_constraints
154
            != PythonToolRequirementsBase.default_interpreter_constraints
155
            and not self.register_interpreter_constraints
156
        ):
UNCOV
157
            raise ValueError(
×
158
                softwrap(
159
                    f"""
160
                    `default_interpreter_constraints` are configured for `{self.options_scope}`, but
161
                    `register_interpreter_constraints` is not set to `True`, so the
162
                    `--interpreter-constraints` option will not be registered. Did you mean to set
163
                    this?
164
                    """
165
                )
166
            )
167

168
        if not self.default_lockfile_resource:
12✔
UNCOV
169
            raise ValueError(
×
170
                softwrap(
171
                    f"""
172
                    The class property `default_lockfile_resource` must be set. See `{self.options_scope}`.
173
                    """
174
                )
175
            )
176

177
        super().__init__(*args, **kwargs)
12✔
178

179
    @classproperty
12✔
180
    def default_lockfile_url(cls) -> str:
12✔
181
        assert cls.default_lockfile_resource is not None
12✔
182
        return git_url(
12✔
183
            os.path.join(
184
                "src",
185
                "python",
186
                cls.default_lockfile_resource[0].replace(".", os.path.sep),
187
                cls.default_lockfile_resource[1],
188
            )
189
        )
190

191
    @classmethod
12✔
192
    def help_for_generate_lockfile_with_default_location(cls, resolve_name):
12✔
UNCOV
193
        return softwrap(
×
194
            f"""
195
            You requested to generate a lockfile for {resolve_name} because
196
            you included it in `--generate-lockfiles-resolve`, but
197
            {resolve_name} is a tool using its default lockfile.
198

199
            If you would like to generate a lockfile for {resolve_name},
200
            follow the instructions for setting up lockfiles for tools
201
            {doc_url("docs/python/overview/lockfiles#lockfiles-for-tools")}
202
        """
203
        )
204

205
    @classmethod
12✔
206
    def pex_requirements_for_default_lockfile(cls):
12✔
207
        """Generate the pex requirements using this subsystem's default lockfile resource."""
208
        assert cls.default_lockfile_resource is not None
12✔
209
        pkg, path = cls.default_lockfile_resource
12✔
210
        url = f"resource://{pkg}/{path}"
12✔
211
        origin = f"The built-in default lockfile for {cls.options_scope}"
12✔
212
        return Lockfile(
12✔
213
            url=url,
214
            url_description_of_origin=origin,
215
            resolve_name=cls.options_scope,
216
        )
217

218
    @classmethod
12✔
219
    @cache
12✔
220
    def _default_package_name_and_version(cls) -> _PackageNameAndVersion | None:
12✔
221
        if cls.default_lockfile_resource is None:
12✔
UNCOV
222
            return None
×
223

224
        lockfile = cls.pex_requirements_for_default_lockfile()
12✔
225
        parts = urlparse(lockfile.url)
12✔
226
        # urlparse retains the leading / in URLs with a netloc.
227
        lockfile_path = parts.path[1:] if parts.path.startswith("/") else parts.path
12✔
228
        if parts.scheme in {"", "file"}:
12✔
UNCOV
229
            with open(lockfile_path, "rb") as fp:
×
UNCOV
230
                lock_bytes = fp.read()
×
231
        elif parts.scheme == "resource":
12✔
232
            # The "netloc" in our made-up "resource://" scheme is the package.
233
            lock_bytes = (
12✔
234
                importlib.resources.files(parts.netloc).joinpath(lockfile_path).read_bytes()
235
            )
236
        else:
UNCOV
237
            raise ValueError(
×
238
                f"Unsupported scheme {parts.scheme} for lockfile URL: {lockfile.url} "
239
                f"(origin: {lockfile.url_description_of_origin})"
240
            )
241

242
        # Note that this code relies on knowing the internal structure of uv and pex lockfiles,
243
        # neither of which are guaranteed. If parsing fails we'll return None and the calling
244
        # code will deal with it (by omitting some information from help strings).
245
        first_default_requirement = PipRequirement.parse(cls.default_requirements[0])
12✔
246
        stripped_lock_bytes = strip_comments_from_pex_json_lockfile(lock_bytes)
12✔
247

248
        try:  # Is it uv.lock?
12✔
249
            lockfile_contents = toml.loads(lock_bytes.decode())
12✔
250
            # The first requirement must contain the primary package for this tool, otherwise
251
            # this will pick up the wrong requirement.
UNCOV
252
            return next(
×
253
                _PackageNameAndVersion(name=first_default_requirement.name, version=pkg["version"])
254
                for pkg in lockfile_contents["package"]
255
                if pkg["name"] == first_default_requirement.name
256
            )
257
        except KeyError:
12✔
258
            # It's toml, but the schema is off.
UNCOV
259
            logger.warning(f"File at {lockfile.url} was not of expected uv.lock schema.")
×
UNCOV
260
            return None
×
261
        except toml.TomlDecodeError:
12✔
262
            # It's not toml, so it's not uv.lock format. Fall through to try pex lockfile format.
263
            # TODO: This is only used internally, for generating help, based on the *default*
264
            #  lockfiles. Once those are all uv, and we are confident they will remain so,
265
            #  we can remove the JSON handling here.
266
            pass
12✔
267

268
        try:
12✔
269
            lockfile_contents = json.loads(stripped_lock_bytes)
12✔
270
            # The first requirement must contain the primary package for this tool, otherwise
271
            # this will pick up the wrong requirement.
272
            return next(
12✔
273
                _PackageNameAndVersion(
274
                    name=first_default_requirement.name, version=requirement["version"]
275
                )
276
                for resolve in lockfile_contents["locked_resolves"]
277
                for requirement in resolve["locked_requirements"]
278
                if requirement["project_name"] == first_default_requirement.name
279
            )
UNCOV
280
        except KeyError:
×
281
            # It's json, but the schema is off.
UNCOV
282
            logger.warning(f"File at {lockfile.url} was not of expected pex lock schema.")
×
UNCOV
283
            return None
×
UNCOV
284
        except json.JSONDecodeError:
×
285
            # It's not json, so it's not pex lock format.
UNCOV
286
            raise Exception(f"File at {lockfile.url} was not of any recognized lockfile format")
×
287

288
    def pex_requirements(
12✔
289
        self,
290
        *,
291
        extra_requirements: Iterable[str] = (),
292
    ) -> PexRequirements | EntireLockfile:
293
        """The requirements to be used when installing the tool."""
294
        description_of_origin = f"the requirements of the `{self.options_scope}` tool"
12✔
295
        if self.install_from_resolve:
12✔
296
            use_entire_lockfile = not self.requirements
6✔
297
            return PexRequirements(
6✔
298
                (*self.requirements, *extra_requirements),
299
                from_superset=Resolve(self.install_from_resolve, use_entire_lockfile),
300
                description_of_origin=description_of_origin,
301
            )
302
        else:
303
            return EntireLockfile(self.pex_requirements_for_default_lockfile())
12✔
304

305
    @property
12✔
306
    def interpreter_constraints(self) -> InterpreterConstraints:
12✔
307
        """The interpreter constraints to use when installing and running the tool.
308

309
        This assumes you have set the class property `register_interpreter_constraints = True`.
310
        """
311
        return InterpreterConstraints(self._interpreter_constraints)
10✔
312

313
    def to_pex_request(
12✔
314
        self,
315
        *,
316
        interpreter_constraints: InterpreterConstraints | None = None,
317
        extra_requirements: Iterable[str] = (),
318
        main: MainSpecification | None = None,
319
        sources: Digest | None = None,
320
    ) -> PexRequest:
321
        requirements = self.pex_requirements(extra_requirements=extra_requirements)
12✔
322
        if not interpreter_constraints:
12✔
323
            if self.options.is_default("interpreter_constraints") and (
12✔
324
                isinstance(requirements, EntireLockfile)
325
                or (
326
                    isinstance(requirements, PexRequirements)
327
                    and isinstance(requirements.from_superset, Resolve)
328
                )
329
            ):
330
                # If installing the tool from a resolve, and custom ICs weren't explicitly set,
331
                # leave these blank. This will cause the ones for the resolve to be used,
332
                # which is clearly what the user intends, rather than forcing the
333
                # user to override interpreter_constraints to match those of the resolve.
334
                interpreter_constraints = InterpreterConstraints()
12✔
335
            else:
336
                interpreter_constraints = self.interpreter_constraints
8✔
337
        return PexRequest(
12✔
338
            output_filename=f"{self.options_scope.replace('-', '_')}.pex",
339
            internal_only=True,
340
            requirements=requirements,
341
            interpreter_constraints=interpreter_constraints,
342
            main=main,
343
            sources=sources,
344
        )
345

346

347
class PythonToolBase(PythonToolRequirementsBase):
12✔
348
    """Base class for subsystems that configure a python tool to be invoked out-of-process."""
349

350
    # Subclasses must set.
351
    default_main: ClassVar[MainSpecification]
12✔
352

353
    # Though possible, we do not recommend setting `default_main` to an Executable
354
    # instead of a ConsoleScript or an EntryPoint. Executable is a niche pex feature
355
    # designed to support poorly named executable python scripts that cannot be imported
356
    # (eg when a file has a character like "-" that is not valid in python identifiers).
357
    # As this should be rare or even non-existent, we do NOT add an `executable` option
358
    # to mirror the other MainSpecification options.
359

360
    console_script = StrOption(
12✔
361
        advanced=True,
362
        default=lambda cls: (
363
            cls.default_main.spec if isinstance(cls.default_main, ConsoleScript) else None
364
        ),
365
        help=softwrap(
366
            """
367
            The console script for the tool. Using this option is generally preferable to
368
            (and mutually exclusive with) specifying an `--entry-point` since console script
369
            names have a higher expectation of staying stable across releases of the tool.
370
            Usually, you will not want to change this from the default.
371
            """
372
        ),
373
    )
374
    entry_point = StrOption(
12✔
375
        advanced=True,
376
        default=lambda cls: (
377
            cls.default_main.spec if isinstance(cls.default_main, EntryPoint) else None
378
        ),
379
        help=softwrap(
380
            """
381
            The entry point for the tool. Generally you only want to use this option if the
382
            tool does not offer a `--console-script` (which this option is mutually exclusive
383
            with). Usually, you will not want to change this from the default.
384
            """
385
        ),
386
    )
387

388
    @property
12✔
389
    def main(self) -> MainSpecification:
12✔
390
        is_default_console_script = self.options.is_default("console_script")
11✔
391
        is_default_entry_point = self.options.is_default("entry_point")
11✔
392
        if not is_default_console_script and not is_default_entry_point:
11✔
UNCOV
393
            raise OptionsError(
×
394
                softwrap(
395
                    f"""
396
                    Both [{self.options_scope}].console-script={self.console_script} and
397
                    [{self.options_scope}].entry-point={self.entry_point} are configured
398
                    but these options are mutually exclusive. Please pick one.
399
                    """
400
                )
401
            )
402
        if not is_default_console_script:
11✔
UNCOV
403
            assert self.console_script is not None
×
UNCOV
404
            return ConsoleScript(self.console_script)
×
405
        if not is_default_entry_point:
11✔
UNCOV
406
            assert self.entry_point is not None
×
UNCOV
407
            return EntryPoint.parse(self.entry_point)
×
408
        return self.default_main
11✔
409

410
    def to_pex_request(
12✔
411
        self,
412
        *,
413
        interpreter_constraints: InterpreterConstraints | None = None,
414
        extra_requirements: Iterable[str] = (),
415
        main: MainSpecification | None = None,
416
        sources: Digest | None = None,
417
    ) -> PexRequest:
418
        return super().to_pex_request(
11✔
419
            interpreter_constraints=interpreter_constraints,
420
            extra_requirements=extra_requirements,
421
            main=main or self.main,
422
            sources=sources,
423
        )
424

425

426
async def get_loaded_lockfile(subsystem: PythonToolBase) -> LoadedLockfile:
12✔
427
    requirements = subsystem.pex_requirements()
3✔
428
    if isinstance(requirements, EntireLockfile):
3✔
429
        lockfile = requirements.lockfile
3✔
430
    else:
431
        assert isinstance(requirements, PexRequirements)
2✔
432
        assert isinstance(requirements.from_superset, Resolve)
2✔
433
        lockfile = await get_lockfile_for_resolve(requirements.from_superset, **implicitly())
2✔
434
    loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
3✔
435
    return loaded_lockfile
3✔
436

437

438
async def get_lockfile_metadata(subsystem: PythonToolBase) -> PythonLockfileMetadata:
12✔
439
    loaded_lockfile = await get_loaded_lockfile(subsystem)
3✔
440
    assert loaded_lockfile.metadata is not None
3✔
441
    return loaded_lockfile.metadata
3✔
442

443

444
async def get_lockfile_interpreter_constraints(
12✔
445
    subsystem: PythonToolBase,
446
) -> InterpreterConstraints:
447
    """If a lockfile is used, will try to find the interpreter constraints used to generate the
448
    lock.
449

450
    This allows us to work around https://github.com/pantsbuild/pants/issues/14912.
451
    """
452
    # If the tool's interpreter constraints are explicitly set, or it is not using a lockfile at
453
    # all, then we should use the tool's interpreter constraints option.
454
    if not subsystem.options.is_default("interpreter_constraints"):
5✔
455
        return subsystem.interpreter_constraints
5✔
456

457
    lockfile_metadata = await get_lockfile_metadata(subsystem)
3✔
458
    return lockfile_metadata.valid_for_interpreter_constraints
3✔
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