• 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

78.23
/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
11✔
5

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

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

42
logger = logging.getLogger(__name__)
11✔
43

44

45
@dataclass(frozen=True)
11✔
46
class _PackageNameAndVersion:
11✔
47
    name: str
11✔
48
    version: str
11✔
49

50

51
class PythonToolRequirementsBase(Subsystem, ExportableTool):
11✔
52
    """Base class for subsystems that configure a set of requirements for a python tool."""
53

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

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

68
    default_interpreter_constraints: ClassVar[Sequence[str]] = ["CPython>=3.9,<3.15"]
11✔
69
    register_interpreter_constraints: ClassVar[bool] = False
11✔
70

71
    default_lockfile_resource: ClassVar[tuple[str, str] | None] = None
11✔
72

73
    @classmethod
11✔
74
    def _help_extended(cls) -> str:
11✔
75
        base_help = strval(cls.help_short)
11✔
76
        help_paragraphs = [base_help]
11✔
77
        package_and_version = cls._default_package_name_and_version()
11✔
78
        if package_and_version:
11✔
79
            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."
11✔
80
            help_paragraphs.append(new_paragraph)
11✔
81

82
        return "\n\n".join(help_paragraphs)
11✔
83

84
    help = classproperty(_help_extended)
11✔
85

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

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

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

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

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

115
    install_from_resolve = StrOption(
11✔
116
        advanced=True,
117
        default=None,
118
        help=lambda cls: cls._install_from_resolve_help(),
119
    )
120

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

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

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

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

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

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

175
        super().__init__(*args, **kwargs)
2✔
176

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

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

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

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

216
    @classmethod
11✔
217
    @cache
11✔
218
    def _default_package_name_and_version(cls) -> _PackageNameAndVersion | None:
11✔
219
        if cls.default_lockfile_resource is None:
11✔
220
            return None
×
221

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

240
        stripped_lock_bytes = strip_comments_from_pex_json_lockfile(lock_bytes)
11✔
241
        lockfile_contents = json.loads(stripped_lock_bytes)
11✔
242
        # The first requirement must contain the primary package for this tool, otherwise
243
        # this will pick up the wrong requirement.
244
        first_default_requirement = PipRequirement.parse(cls.default_requirements[0])
11✔
245
        return next(
11✔
246
            _PackageNameAndVersion(
247
                name=first_default_requirement.name, version=requirement["version"]
248
            )
249
            for resolve in lockfile_contents["locked_resolves"]
250
            for requirement in resolve["locked_requirements"]
251
            if requirement["project_name"] == first_default_requirement.name
252
        )
253

254
    def pex_requirements(
11✔
255
        self,
256
        *,
257
        extra_requirements: Iterable[str] = (),
258
    ) -> PexRequirements | EntireLockfile:
259
        """The requirements to be used when installing the tool."""
260
        description_of_origin = f"the requirements of the `{self.options_scope}` tool"
2✔
261
        if self.install_from_resolve:
2✔
262
            use_entire_lockfile = not self.requirements
2✔
263
            return PexRequirements(
2✔
264
                (*self.requirements, *extra_requirements),
265
                from_superset=Resolve(self.install_from_resolve, use_entire_lockfile),
266
                description_of_origin=description_of_origin,
267
            )
268
        else:
UNCOV
269
            return EntireLockfile(self.pex_requirements_for_default_lockfile())
×
270

271
    @property
11✔
272
    def interpreter_constraints(self) -> InterpreterConstraints:
11✔
273
        """The interpreter constraints to use when installing and running the tool.
274

275
        This assumes you have set the class property `register_interpreter_constraints = True`.
276
        """
UNCOV
277
        return InterpreterConstraints(self._interpreter_constraints)
×
278

279
    def to_pex_request(
11✔
280
        self,
281
        *,
282
        interpreter_constraints: InterpreterConstraints | None = None,
283
        extra_requirements: Iterable[str] = (),
284
        main: MainSpecification | None = None,
285
        sources: Digest | None = None,
286
    ) -> PexRequest:
287
        requirements = self.pex_requirements(extra_requirements=extra_requirements)
×
288
        if not interpreter_constraints:
×
289
            if self.options.is_default("interpreter_constraints") and (
×
290
                isinstance(requirements, EntireLockfile)
291
                or (
292
                    isinstance(requirements, PexRequirements)
293
                    and isinstance(requirements.from_superset, Resolve)
294
                )
295
            ):
296
                # If installing the tool from a resolve, and custom ICs weren't explicitly set,
297
                # leave these blank. This will cause the ones for the resolve to be used,
298
                # which is clearly what the user intends, rather than forcing the
299
                # user to override interpreter_constraints to match those of the resolve.
300
                interpreter_constraints = InterpreterConstraints()
×
301
            else:
302
                interpreter_constraints = self.interpreter_constraints
×
303
        return PexRequest(
×
304
            output_filename=f"{self.options_scope.replace('-', '_')}.pex",
305
            internal_only=True,
306
            requirements=requirements,
307
            interpreter_constraints=interpreter_constraints,
308
            main=main,
309
            sources=sources,
310
        )
311

312

313
class PythonToolBase(PythonToolRequirementsBase):
11✔
314
    """Base class for subsystems that configure a python tool to be invoked out-of-process."""
315

316
    # Subclasses must set.
317
    default_main: ClassVar[MainSpecification]
11✔
318

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

326
    console_script = StrOption(
11✔
327
        advanced=True,
328
        default=lambda cls: (
329
            cls.default_main.spec if isinstance(cls.default_main, ConsoleScript) else None
330
        ),
331
        help=softwrap(
332
            """
333
            The console script for the tool. Using this option is generally preferable to
334
            (and mutually exclusive with) specifying an `--entry-point` since console script
335
            names have a higher expectation of staying stable across releases of the tool.
336
            Usually, you will not want to change this from the default.
337
            """
338
        ),
339
    )
340
    entry_point = StrOption(
11✔
341
        advanced=True,
342
        default=lambda cls: (
343
            cls.default_main.spec if isinstance(cls.default_main, EntryPoint) else None
344
        ),
345
        help=softwrap(
346
            """
347
            The entry point for the tool. Generally you only want to use this option if the
348
            tool does not offer a `--console-script` (which this option is mutually exclusive
349
            with). Usually, you will not want to change this from the default.
350
            """
351
        ),
352
    )
353

354
    @property
11✔
355
    def main(self) -> MainSpecification:
11✔
356
        is_default_console_script = self.options.is_default("console_script")
×
357
        is_default_entry_point = self.options.is_default("entry_point")
×
358
        if not is_default_console_script and not is_default_entry_point:
×
359
            raise OptionsError(
×
360
                softwrap(
361
                    f"""
362
                    Both [{self.options_scope}].console-script={self.console_script} and
363
                    [{self.options_scope}].entry-point={self.entry_point} are configured
364
                    but these options are mutually exclusive. Please pick one.
365
                    """
366
                )
367
            )
368
        if not is_default_console_script:
×
369
            assert self.console_script is not None
×
370
            return ConsoleScript(self.console_script)
×
371
        if not is_default_entry_point:
×
372
            assert self.entry_point is not None
×
373
            return EntryPoint.parse(self.entry_point)
×
374
        return self.default_main
×
375

376
    def to_pex_request(
11✔
377
        self,
378
        *,
379
        interpreter_constraints: InterpreterConstraints | None = None,
380
        extra_requirements: Iterable[str] = (),
381
        main: MainSpecification | None = None,
382
        sources: Digest | None = None,
383
    ) -> PexRequest:
384
        return super().to_pex_request(
×
385
            interpreter_constraints=interpreter_constraints,
386
            extra_requirements=extra_requirements,
387
            main=main or self.main,
388
            sources=sources,
389
        )
390

391

392
async def get_loaded_lockfile(subsystem: PythonToolBase) -> LoadedLockfile:
11✔
393
    requirements = subsystem.pex_requirements()
2✔
394
    if isinstance(requirements, EntireLockfile):
2✔
UNCOV
395
        lockfile = requirements.lockfile
×
396
    else:
397
        assert isinstance(requirements, PexRequirements)
2✔
398
        assert isinstance(requirements.from_superset, Resolve)
2✔
399
        lockfile = await get_lockfile_for_resolve(requirements.from_superset, **implicitly())
2✔
400
    loaded_lockfile = await load_lockfile(LoadedLockfileRequest(lockfile), **implicitly())
2✔
401
    return loaded_lockfile
2✔
402

403

404
async def get_lockfile_metadata(subsystem: PythonToolBase) -> PythonLockfileMetadata:
11✔
405
    loaded_lockfile = await get_loaded_lockfile(subsystem)
2✔
406
    assert loaded_lockfile.metadata is not None
2✔
407
    return loaded_lockfile.metadata
2✔
408

409

410
async def get_lockfile_interpreter_constraints(
11✔
411
    subsystem: PythonToolBase,
412
) -> InterpreterConstraints:
413
    """If a lockfile is used, will try to find the interpreter constraints used to generate the
414
    lock.
415

416
    This allows us to work around https://github.com/pantsbuild/pants/issues/14912.
417
    """
418
    # If the tool's interpreter constraints are explicitly set, or it is not using a lockfile at
419
    # all, then we should use the tool's interpreter constraints option.
UNCOV
420
    if not subsystem.options.is_default("interpreter_constraints"):
×
UNCOV
421
        return subsystem.interpreter_constraints
×
422

UNCOV
423
    lockfile_metadata = await get_lockfile_metadata(subsystem)
×
UNCOV
424
    return lockfile_metadata.valid_for_interpreter_constraints
×
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