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

pantsbuild / pants / 26080722777

19 May 2026 06:37AM UTC coverage: 52.106% (-11.5%) from 63.597%
26080722777

Pull #23250

github

web-flow
Merge 63ec06323 into 2693df832
Pull Request #23250: Feature: Add generic option to docker image

12 of 50 new or added lines in 3 files covered. (24.0%)

5382 existing lines in 201 files now uncovered.

32053 of 61515 relevant lines covered (52.11%)

1.04 hits per line

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

49.86
/src/python/pants/backend/python/util_rules/pex_requirements.py
1
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
2✔
5

6
import importlib.resources
2✔
7
import json
2✔
8
import logging
2✔
9
import tomllib
2✔
10
from collections.abc import Iterable, Iterator
2✔
11
from dataclasses import dataclass, field
2✔
12
from typing import TYPE_CHECKING
2✔
13
from urllib.parse import urlparse
2✔
14

15
from packaging.requirements import Requirement
2✔
16

17
from pants.backend.python.subsystems.repos import PythonRepos
2✔
18
from pants.backend.python.subsystems.setup import InvalidLockfileBehavior, PythonSetup
2✔
19
from pants.backend.python.target_types import PythonRequirementsField
2✔
20
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
2✔
21
from pants.backend.python.util_rules.lockfile_metadata import (
2✔
22
    InvalidPythonLockfileReason,
23
    LockfileFormat,
24
    PythonLockfileMetadata,
25
    PythonLockfileMetadataV2,
26
    PythonLockfileMetadataV8,
27
)
28
from pants.build_graph.address import Address
2✔
29
from pants.core.util_rules.lockfile_metadata import (
2✔
30
    InvalidLockfileError,
31
    LockfileMetadataValidation,
32
    NoLockfileMetadataBlock,
33
)
34
from pants.engine.engine_aware import EngineAwareParameter
2✔
35
from pants.engine.fs import CreateDigest, Digest, FileContent, GlobMatchErrorBehavior, PathGlobs
2✔
36
from pants.engine.internals.native_engine import IntrinsicError
2✔
37
from pants.engine.intrinsics import (
2✔
38
    create_digest,
39
    get_digest_contents,
40
    get_digest_entries,
41
    path_globs_to_digest,
42
)
43
from pants.engine.rules import collect_rules, concurrently, implicitly, rule
2✔
44
from pants.engine.unions import UnionMembership
2✔
45
from pants.util.docutil import bin_name, doc_url
2✔
46
from pants.util.ordered_set import FrozenOrderedSet
2✔
47
from pants.util.pip_requirement import PipRequirement
2✔
48
from pants.util.requirements import parse_requirements_file
2✔
49
from pants.util.strutil import comma_separated_list, pluralize, softwrap
2✔
50

51
if TYPE_CHECKING:
52
    from pants.backend.python.util_rules.pex import Pex
53

54

55
logger = logging.getLogger(__name__)
2✔
56

57

58
@dataclass(frozen=True)
2✔
59
class Resolve:
2✔
60
    # A named resolve for a "user lockfile".
61
    # Soon to be the only kind of lockfile, as this class will help
62
    # get rid of the "tool lockfile" concept.
63
    # TODO: Once we get rid of old-style tool lockfiles we can possibly
64
    #  unify this with EntireLockfile.
65
    # TODO: We might want to add the requirements subset to this data structure,
66
    #  to further detangle this from PexRequirements.
67
    name: str
2✔
68

69
    use_entire_lockfile: bool
2✔
70

71

72
@dataclass(frozen=True)
2✔
73
class Lockfile:
2✔
74
    url: str
2✔
75
    url_description_of_origin: str
2✔
76
    resolve_name: str
2✔
77
    lockfile_hex_digest: str | None = None
2✔
78

79

80
@rule
2✔
81
async def get_lockfile_for_resolve(resolve: Resolve, python_setup: PythonSetup) -> Lockfile:
2✔
82
    lockfile_path = python_setup.resolves.get(resolve.name)
×
83
    if not lockfile_path:
×
84
        raise ValueError(f"No such resolve: {resolve.name}")
×
85
    return Lockfile(
×
86
        url=lockfile_path,
87
        url_description_of_origin=f"the resolve `{resolve.name}`",
88
        resolve_name=resolve.name,
89
    )
90

91

92
@dataclass(frozen=True)
2✔
93
class LoadedLockfile:
2✔
94
    """A lockfile after loading and header stripping.
95

96
    Validation is deferred until consumption time, because each consumed subset (in the case of a
97
    PEX-native lockfile) can be individually validated.
98
    """
99

100
    # The digest of the loaded lockfile (which may not be identical to the input).
101
    lockfile_digest: Digest
2✔
102
    # The path of the loaded lockfile within the Digest.
103
    lockfile_path: str
2✔
104
    # The loaded metadata for this lockfile, if any.
105
    metadata: PythonLockfileMetadata | None = field(hash=False)
2✔
106
    # An estimate of the number of requirements in this lockfile, to be used as a heuristic for
107
    # available parallelism.
108
    requirement_estimate: int
2✔
109
    # The format of the loaded lockfile.
110
    lockfile_format: LockfileFormat
2✔
111
    # If lockfile_format is ConstraintsDeprecated, the lockfile parsed as constraints strings,
112
    # for use when the lockfile needs to be subsetted (see #15031, ##12222).
113
    as_constraints_strings: FrozenOrderedSet[str] | None
2✔
114
    # The original file or file content (which may not have identical content to the output
115
    # `lockfile_digest`).
116
    original_lockfile: Lockfile
2✔
117

118

119
@dataclass(frozen=True)
2✔
120
class LoadedLockfileRequest:
2✔
121
    """A request to load and validate the content of the given lockfile."""
122

123
    lockfile: Lockfile
2✔
124

125

126
def strip_comments_from_pex_json_lockfile(lockfile_bytes: bytes) -> bytes:
2✔
127
    """Pex does not like the header Pants adds to lockfiles, as it violates JSON.
128

129
    Note that we only strip lines starting with `//`, which is all that Pants will ever add. If
130
    users add their own comments, things will fail.
131
    """
132
    return b"\n".join(
2✔
133
        line for line in lockfile_bytes.splitlines() if not line.lstrip().startswith(b"//")
134
    )
135

136

137
def is_probably_pex_json_lockfile(lockfile_bytes: bytes) -> bool:
2✔
UNCOV
138
    for line in lockfile_bytes.splitlines():
×
UNCOV
139
        if line and not line.startswith(b"//"):
×
140
            # Note that pip/Pex complain if a requirements.txt style starts with `{`.
UNCOV
141
            return line.lstrip().startswith(b"{")
×
142
    return False
×
143

144

145
def _pex_lockfile_requirement_count(lockfile_bytes: bytes) -> int:
2✔
146
    # TODO: this is a very naive heuristic that will overcount, and also relies on Pants
147
    #  setting `--indent` when generating lockfiles. More robust would be parsing the JSON
148
    #  and getting the len(locked_resolves.locked_requirements.project_name), but we risk
149
    #  if Pex ever changes its lockfile format.
150

UNCOV
151
    num_lines = len(lockfile_bytes.splitlines())
×
152
    # These are very naive estimates, and they bias towards overcounting. For example, requirements
153
    # often are 20+ lines.
UNCOV
154
    num_lines_for_options = 10
×
UNCOV
155
    lines_per_req = 10
×
UNCOV
156
    return max((num_lines - num_lines_for_options) // lines_per_req, 2)
×
157

158

159
def get_metadata(
2✔
160
    python_setup: PythonSetup,
161
    lock_bytes: bytes,
162
    lockfile_path: str | None,
163
    resolve_name: str,
164
    delimiter: str,
165
) -> PythonLockfileMetadata | None:
UNCOV
166
    metadata: PythonLockfileMetadata | None = None
×
UNCOV
167
    if python_setup.invalid_lockfile_behavior != InvalidLockfileBehavior.ignore:
×
UNCOV
168
        try:
×
UNCOV
169
            metadata = PythonLockfileMetadata.from_lockfile(
×
170
                lockfile=lock_bytes,
171
                lockfile_path=lockfile_path,
172
                resolve_name=resolve_name,
173
                delimeter=delimiter,
174
            )
UNCOV
175
        except NoLockfileMetadataBlock:
×
176
            # We don't validate if the file isn't a pants-generated lockfile (as determined
177
            # by the lack of a metadata block). But we propagate any other type of
178
            # InvalidLockfileError incurred while parsing the metadata block.
UNCOV
179
            logger.debug(
×
180
                f"Lockfile for resolve {resolve_name} "
181
                f"{('at ' + lockfile_path) if lockfile_path else ''}"
182
                f" has no metadata block, so was not generated by Pants. "
183
                f"Lockfile will not be validated."
184
            )
UNCOV
185
    return metadata
×
186

187

188
async def read_file_or_resource(url: str, description_of_origin: str) -> Digest:
2✔
189
    """Read from a path, file:// or resource:// URL and return the digest of the content.
190

191
    If no content is found at the path/URL, raise.
192
    """
193
    parts = urlparse(url)
2✔
194
    # urlparse retains the leading / in URLs with a netloc.
195
    path = parts.path[1:] if parts.path.startswith("/") else parts.path
2✔
196
    if parts.scheme in {"", "file"}:
2✔
UNCOV
197
        digest = await path_globs_to_digest(
×
198
            PathGlobs(
199
                [path],
200
                glob_match_error_behavior=GlobMatchErrorBehavior.error,
201
                description_of_origin=description_of_origin,
202
            )
203
        )
204
    elif parts.scheme == "resource":
2✔
205
        _fc = FileContent(
2✔
206
            path,
207
            # The "netloc" in our made-up "resource://" scheme is the package.
208
            importlib.resources.files(parts.netloc).joinpath(path).read_bytes(),
209
        )
210
        digest = await create_digest(CreateDigest([_fc]))
2✔
211
    else:
212
        raise ValueError(
×
213
            f"Unsupported scheme {parts.scheme} for URL: {url} (origin: {description_of_origin})"
214
        )
215
    return digest
2✔
216

217

218
@rule
2✔
219
async def load_lockfile(
2✔
220
    request: LoadedLockfileRequest,
221
    python_setup: PythonSetup,
222
) -> LoadedLockfile:
223
    lockfile = request.lockfile
2✔
224
    # TODO: This is temporary. Once we regenerate all embedded lockfiles to have sidecar metadata
225
    #  files instead of metadata front matter, we won't need to call get_metadata() on them.
226
    synthetic_lock = lockfile.url.startswith("resource://")
2✔
227
    lockfile_digest = await read_file_or_resource(lockfile.url, lockfile.url_description_of_origin)
2✔
228
    lockfile_digest_entries = await get_digest_entries(lockfile_digest)
2✔
229
    lockfile_path = lockfile_digest_entries[0].path
2✔
230

231
    lockfile_contents = await get_digest_contents(lockfile_digest)
2✔
232
    lock_bytes = lockfile_contents[0].content
2✔
233
    lockfile_format: LockfileFormat | None = None
2✔
234
    constraints_strings = None
2✔
235

236
    metadata_url = PythonLockfileMetadata.metadata_location_for_lockfile(lockfile.url)
2✔
237
    metadata = None
2✔
238

239
    # If there's a sidecar metadata file, always load it, at least to get the lockfile_format.
240
    try:
2✔
241
        metadata_digest = await read_file_or_resource(
2✔
242
            metadata_url,
243
            description_of_origin="We squelch errors, so this is never seen by users",
244
        )
245
        digest_contents = await get_digest_contents(metadata_digest)
2✔
246
        metadata_bytes = digest_contents[0].content
2✔
247
        json_dict = json.loads(metadata_bytes)
2✔
248
        metadata = PythonLockfileMetadata.from_json_dict(
2✔
249
            json_dict,
250
            lockfile_description=f"the lockfile for `{lockfile.resolve_name}`",
251
            error_suffix=softwrap(
252
                f"""
253
                To resolve this error, you will need to regenerate the lockfile by running
254
                `{bin_name()} generate-lockfiles --resolve={lockfile.resolve_name}.
255
                """
256
            ),
257
        )
258
        if isinstance(metadata, PythonLockfileMetadataV8):
2✔
259
            lockfile_format = metadata.lockfile_format
2✔
UNCOV
260
    except IntrinsicError:
×
261
        # No metadata file or resource found, so fall through to finding a metadata header block
262
        # prepended to the lockfile itself.
UNCOV
263
        pass
×
264

265
    # If this is a uv lockfile then its metadata will have told us so, otherwise fall back
266
    # to detection (since older lockfiles may not have the format field in the metadata).
267
    lockfile_format = lockfile_format or (
2✔
268
        LockfileFormat.PEX
269
        if is_probably_pex_json_lockfile(lock_bytes)
270
        else LockfileFormat.CONSTRAINTS_DEPRECATED
271
    )
272

273
    if lockfile_format == LockfileFormat.CONSTRAINTS_DEPRECATED:
2✔
UNCOV
274
        lock_string = lock_bytes.decode()
×
UNCOV
275
        constraints_strings = FrozenOrderedSet(
×
276
            str(req) for req in parse_requirements_file(lock_string, rel_path=lockfile_path)
277
        )
278

279
    if lockfile_format == LockfileFormat.PEX:
2✔
UNCOV
280
        stripped_lock_bytes = strip_comments_from_pex_json_lockfile(lock_bytes)
×
UNCOV
281
        lockfile_digest = await create_digest(
×
282
            CreateDigest([FileContent(lockfile_path, stripped_lock_bytes)])
283
        )
284

285
    if not metadata and python_setup.invalid_lockfile_behavior != InvalidLockfileBehavior.ignore:
2✔
286
        # uv lockfiles must have sidecar metadata, so this can only be Pex or ConstraintsDeprecated.
UNCOV
287
        header_delimiter = "//" if lockfile_format == LockfileFormat.PEX else "#"
×
UNCOV
288
        metadata = get_metadata(
×
289
            python_setup,
290
            lock_bytes,
291
            None if synthetic_lock else lockfile_path,
292
            lockfile.resolve_name,
293
            header_delimiter,
294
        )
295

296
    match lockfile_format:
2✔
297
        case LockfileFormat.UV:
2✔
298
            # Use the virtual root package's direct dependencies as a rough estimate
299
            # of how many packages need resolving.
300
            # NB: The uv project recommends not relying on lockfile internals, but
301
            # this particular aspect seems relatively stable in practice.
302
            lockfile_toml = tomllib.loads(lock_bytes.decode())
2✔
303
            root_package = next(
2✔
304
                (
305
                    p
306
                    for p in lockfile_toml.get("package", [])
307
                    if p.get("source", {}).get("virtual") == "."
308
                ),
309
                None,
310
            )
311
            deps = root_package.get("dependencies") if root_package else None
2✔
312
            if deps is None:
2✔
313
                logger.warning(
×
314
                    f"Couldn't find the virtual root [[package]].dependencies entry in {lockfile_path}. "
315
                    "Has the uv lockfile format changed? This will not affect correctness but "
316
                    "may affect performance. Please reach out to the Pants team if you encounter "
317
                    "this warning."
318
                )
319
            requirement_estimate = 4 if deps is None else len(deps)
2✔
UNCOV
320
        case LockfileFormat.PEX:
×
UNCOV
321
            requirement_estimate = _pex_lockfile_requirement_count(lock_bytes)
×
UNCOV
322
        case LockfileFormat.CONSTRAINTS_DEPRECATED:
×
323
            # Note: this is a very naive heuristic. It will overcount because entries often
324
            # have >1 line due to `--hash`.
UNCOV
325
            requirement_estimate = len(lock_bytes.splitlines())
×
326
        case _:
×
327
            raise ValueError(f"Unknown lockfile format: {lockfile_format}")
×
328

329
    return LoadedLockfile(
2✔
330
        lockfile_digest,
331
        lockfile_path,
332
        metadata,
333
        requirement_estimate,
334
        lockfile_format,
335
        constraints_strings,
336
        original_lockfile=lockfile,
337
    )
338

339

340
@dataclass(frozen=True)
2✔
341
class EntireLockfile:
2✔
342
    """A request to resolve the entire contents of a lockfile.
343

344
    This resolution mode is used in a few cases:
345
    1. for poetry or handwritten lockfiles (which do not support being natively subsetted the
346
       way that a PEX lockfile can be), in order to build a repository-PEX to subset separately.
347
    2. for tool lockfiles, which (regardless of format), need to resolve the entire lockfile
348
       content anyway.
349
    """
350

351
    lockfile: Lockfile
2✔
352
    # If available, the current complete set of requirement strings that influence this lockfile.
353
    # Used for metadata validation.
354
    complete_req_strings: tuple[str, ...] | None = None
2✔
355

356

357
@dataclass(frozen=True)
2✔
358
class PexRequirements:
2✔
359
    """A request to resolve a series of requirements (optionally from a "superset" resolve)."""
360

361
    req_strings_or_addrs: FrozenOrderedSet[str | Address]
2✔
362
    constraints_strings: FrozenOrderedSet[str]
2✔
363
    # If these requirements should be resolved as a subset of either a repository PEX, or a
364
    # PEX-native lockfile, the superset to use. # NB: Use of a lockfile here asserts that the
365
    # lockfile is PEX-native, because legacy lockfiles do not support subset resolves.
366
    from_superset: Pex | Resolve | None
2✔
367
    description_of_origin: str
2✔
368

369
    def __init__(
2✔
370
        self,
371
        req_strings_or_addrs: Iterable[str | Address] = (),
372
        *,
373
        constraints_strings: Iterable[str] = (),
374
        from_superset: Pex | Resolve | None = None,
375
        description_of_origin: str = "",
376
    ) -> None:
377
        """
378
        :param req_strings_or_addrs: The requirement strings to resolve, or addresses
379
          of targets that refer to them, or string specs of such addresses.
380
        :param constraints_strings: Constraints strings to apply during the resolve.
381
        :param from_superset: An optional superset PEX or lockfile to resolve the req strings from.
382
        :param description_of_origin: A human-readable description of what these requirements
383
          represent, for use in error messages.
384
        """
385
        object.__setattr__(
2✔
386
            self, "req_strings_or_addrs", FrozenOrderedSet(sorted(req_strings_or_addrs))
387
        )
388
        object.__setattr__(
2✔
389
            self, "constraints_strings", FrozenOrderedSet(sorted(constraints_strings))
390
        )
391
        object.__setattr__(self, "from_superset", from_superset)
2✔
392
        object.__setattr__(self, "description_of_origin", description_of_origin)
2✔
393

394
    @classmethod
2✔
395
    def req_strings_from_requirement_fields(
2✔
396
        cls, fields: Iterable[PythonRequirementsField]
397
    ) -> FrozenOrderedSet[str]:
398
        """A convenience when you only need the raw requirement strings from fields and don't need
399
        to consider things like constraints or resolves."""
400
        return FrozenOrderedSet(
2✔
401
            sorted(str(python_req) for fld in fields for python_req in fld.value)
402
        )
403

404
    def __bool__(self) -> bool:
2✔
UNCOV
405
        return bool(self.req_strings_or_addrs)
×
406

407

408
@dataclass(frozen=True)
2✔
409
class ResolvePexConstraintsFile:
2✔
410
    digest: Digest
2✔
411
    path: str
2✔
412
    constraints: FrozenOrderedSet[PipRequirement]
2✔
413

414

415
@dataclass(frozen=True)
2✔
416
class ResolveConfig:
2✔
417
    """Configuration from `[python]` that impacts how the resolve is created."""
418

419
    indexes: tuple[str, ...]
2✔
420
    find_links: tuple[str, ...]
2✔
421
    manylinux: str | None
2✔
422
    constraints_file: ResolvePexConstraintsFile | None
2✔
423
    only_binary: FrozenOrderedSet[str]
2✔
424
    no_binary: FrozenOrderedSet[str]
2✔
425
    excludes: FrozenOrderedSet[str]
2✔
426
    overrides: FrozenOrderedSet[str]
2✔
427
    sources: FrozenOrderedSet[str]
2✔
428
    path_mappings: tuple[str, ...]
2✔
429
    lock_style: str
2✔
430
    complete_platforms: tuple[str, ...]
2✔
431
    uploaded_prior_to: str | None
2✔
432

433
    def pex_args(self) -> Iterator[str]:
2✔
434
        """Arguments for Pex for indexes/--find-links, manylinux, and path mappings.
435

436
        Does not include arguments for constraints files, which must be set up independently.
437
        """
438
        # NB: In setting `--no-pypi`, we rely on the default value of `[python-repos].indexes`
439
        # including PyPI, which will override `--no-pypi` and result in using PyPI in the default
440
        # case. Why set `--no-pypi`, then? We need to do this so that
441
        # `[python-repos].indexes = ['custom_url']` will only point to that index and not include
442
        # PyPI.
443
        yield "--no-pypi"
2✔
444
        yield from (f"--index={index}" for index in self.indexes)
2✔
445
        yield from (f"--find-links={repo}" for repo in self.find_links)
2✔
446

447
        if self.manylinux:
2✔
448
            yield "--manylinux"
2✔
449
            yield self.manylinux
2✔
450
        else:
UNCOV
451
            yield "--no-manylinux"
×
452

453
        # Pex logically plumbs through equivalent settings, but uses a
454
        # separate flag instead of the Pip magic :all:/:none: syntax.  To
455
        # support the exitings Pants config settings we need to go from
456
        # :all:/:none: --> Pex options, which Pex will translate back into Pip
457
        # options.  Note that Pex's --wheel (for example) means "allow
458
        # wheels", not "require wheels".
459
        if self.only_binary and ":all:" in self.only_binary:
2✔
460
            yield "--wheel"
×
461
            yield "--no-build"
×
462
        elif self.only_binary and ":none:" in self.only_binary:
2✔
463
            yield "--no-wheel"
×
464
            yield "--build"
×
465
        elif self.only_binary:
2✔
466
            yield from (f"--only-binary={pkg}" for pkg in self.only_binary)
×
467

468
        if self.no_binary and ":all:" in self.no_binary:
2✔
469
            yield "--no-wheel"
×
470
            yield "--build"
×
471
        elif self.no_binary and ":none:" in self.no_binary:
2✔
472
            yield "--wheel"
×
473
            yield "--no-build"
×
474
        elif self.no_binary:
2✔
475
            yield from (f"--only-build={pkg}" for pkg in self.no_binary)
×
476

477
        yield from (f"--path-mapping={v}" for v in self.path_mappings)
2✔
478

479
        yield from (f"--exclude={exclude}" for exclude in self.excludes)
2✔
480
        yield from (f"--source={source}" for source in self.sources)
2✔
481

482
        if self.uploaded_prior_to:
2✔
483
            yield f"--uploaded-prior-to={self.uploaded_prior_to}"
×
484

485
    def uv_config(self, extra_find_links: Iterable[str] = ()) -> str:
2✔
486
        """Content for uv.toml based on this resolve's configuration.
487

488
        Only uv-supported fields are used. Call validate_for_uv() first to ensure no
489
        pex-specific fields are set.
490
        """
UNCOV
491
        config_lines: list[str] = []
×
492

UNCOV
493
        all_find_links = (*self.find_links, *extra_find_links)
×
UNCOV
494
        if all_find_links:
×
495
            config_lines.append("find-links = [")
×
496
            for fl in all_find_links:
×
497
                config_lines.append(f'    "{fl}",')
×
498
            config_lines.append("]")
×
499
            config_lines.append("")
×
500

UNCOV
501
        if self.sources:
×
502
            config_lines.append("[sources]")
×
503
            for source in self.sources:
×
504
                index_name, _, scope = source.partition("=")
×
505
                req = Requirement(scope)
×
506
                # Markers may contain double-quotes, so we use single quotes in the TOML.
507
                marker = f", marker = '{req.marker}'" if req.marker else ""
×
508
                config_lines.append(f'{req.name} = {{ index = "{index_name}"{marker} }}')
×
509
            config_lines.append("")
×
510

UNCOV
511
        if self.no_binary:
×
512
            if ":all:" in self.no_binary:
×
513
                config_lines.append("no-binary = true")
×
514
            elif ":none:" not in self.no_binary:
×
515
                config_lines.append("no-binary-package = [")
×
516
                for pkg in self.no_binary:
×
517
                    config_lines.append(f'    "{pkg}",')
×
518
                config_lines.append("]")
×
519
            config_lines.append("")
×
520

UNCOV
521
        if self.only_binary:
×
522
            if ":all:" in self.only_binary:
×
523
                config_lines.append("no-build = true")
×
524
            elif ":none:" not in self.only_binary:
×
525
                config_lines.append("no-build-package = [")
×
526
                for pkg in self.only_binary:
×
527
                    config_lines.append(f'    "{pkg}",')
×
528
                config_lines.append("]")
×
529
            config_lines.append("")
×
530

UNCOV
531
        if self.uploaded_prior_to:
×
532
            config_lines.append(f'exclude-newer = "{self.uploaded_prior_to}"')
×
533
            config_lines.append("")
×
534

UNCOV
535
        indexes = []
×
UNCOV
536
        for index in self.indexes:
×
UNCOV
537
            part1, _, part2 = index.partition("=")
×
UNCOV
538
            (name, url) = (part1, part2) if part2 else ("", part1)
×
UNCOV
539
            index_data = {"url": url}
×
UNCOV
540
            if name:
×
541
                index_data["name"] = name
×
UNCOV
542
            indexes.append(index_data)
×
UNCOV
543
        if indexes:
×
544
            # To turn off uv's fallback to PyPI we must set some other index to be the default.
545
            # In uv the default index has the lowest priority, regardless of its position in the
546
            # list of indexes, so we set the last index to be that default, to match user intent.
UNCOV
547
            indexes[-1]["default"] = "true"
×
UNCOV
548
            for index_data in indexes:
×
UNCOV
549
                name = index_data.get("name", "")
×
UNCOV
550
                url = index_data.get("url", "")
×
UNCOV
551
                default = index_data.get("default", False)
×
UNCOV
552
                config_lines.append("[[index]]")
×
UNCOV
553
                if name:
×
554
                    config_lines.append(f'name = "{name}"')
×
UNCOV
555
                config_lines.append(f'url = "{url}"')
×
UNCOV
556
                if default:
×
UNCOV
557
                    config_lines.append("default = true")
×
UNCOV
558
                config_lines.append("")
×
559
        else:
560
            config_lines.append("no-index = true")
×
561
            config_lines.append("")
×
562

UNCOV
563
        return "\n".join(config_lines) + "\n" if config_lines else ""
×
564

565
    def validate_for_uv(self, resolve_name: str) -> None:
2✔
566
        """Raise if any pex-specific resolve options are set that have no uv equivalent."""
UNCOV
567
        pex_specific: list[str] = []
×
UNCOV
568
        if self.constraints_file:
×
569
            pex_specific.append("`[python].resolves_to_constraints_file`")
×
UNCOV
570
        if self.complete_platforms:
×
571
            pex_specific.append("`[python].resolves_to_complete_platforms`")
×
UNCOV
572
        if self.excludes:
×
573
            pex_specific.append("`[python].resolves_to_excludes`")
×
UNCOV
574
        if self.overrides:
×
575
            pex_specific.append("`[python].resolves_to_overrides`")
×
UNCOV
576
        if self.lock_style != "universal":
×
577
            pex_specific.append("`[python]._resolves_to_lock_style`")
×
UNCOV
578
        if self.path_mappings:
×
579
            pex_specific.append("`[python-repos].path_mappings`")
×
UNCOV
580
        if pex_specific:
×
581
            raise ValueError(
×
582
                f"The following options are set for the resolve `{resolve_name}` but are not "
583
                f"supported when using the uv resolver:\n"
584
                + "\n".join(f"  - {opt}" for opt in pex_specific)
585
            )
586

587

588
@dataclass(frozen=True)
2✔
589
class ResolveConfigRequest(EngineAwareParameter):
2✔
590
    """Find all configuration from `[python]` that impacts how the resolve is created.
591

592
    If `resolve_name` is None, then most per-resolve options will be ignored because there is no way
593
    for users to configure them. However, some options like `[python-repos].indexes` will still be
594
    loaded.
595
    """
596

597
    resolve_name: str | None
2✔
598

599
    def debug_hint(self) -> str:
2✔
600
        return self.resolve_name or "<no resolve>"
×
601

602

603
@rule
2✔
604
async def determine_resolve_config(
2✔
605
    request: ResolveConfigRequest,
606
    python_setup: PythonSetup,
607
    python_repos: PythonRepos,
608
    union_membership: UnionMembership,
609
) -> ResolveConfig:
610
    if request.resolve_name is None:
2✔
611
        return ResolveConfig(
2✔
612
            indexes=python_repos.indexes,
613
            find_links=python_repos.find_links,
614
            manylinux=python_setup.manylinux,
615
            constraints_file=None,
616
            no_binary=FrozenOrderedSet(),
617
            only_binary=FrozenOrderedSet(),
618
            excludes=FrozenOrderedSet(),
619
            overrides=FrozenOrderedSet(),
620
            sources=FrozenOrderedSet(),
621
            path_mappings=python_repos.path_mappings,
622
            lock_style="universal",  # Default to universal when no resolve name
623
            complete_platforms=(),  # No complete platforms by default
624
            uploaded_prior_to=None,
625
        )
626

627
    no_binary = python_setup.resolves_to_no_binary().get(request.resolve_name) or []
2✔
628
    only_binary = python_setup.resolves_to_only_binary().get(request.resolve_name) or []
2✔
629
    excludes = python_setup.resolves_to_excludes().get(request.resolve_name) or []
2✔
630
    overrides = python_setup.resolves_to_overrides().get(request.resolve_name) or []
2✔
631
    sources = python_setup.resolves_to_sources().get(request.resolve_name) or []
2✔
632
    lock_style = python_setup.resolves_to_lock_style().get(request.resolve_name) or "universal"
2✔
633
    complete_platforms = tuple(
2✔
634
        python_setup.resolves_to_complete_platforms().get(request.resolve_name) or []
635
    )
636
    uploaded_prior_to = python_setup.resolves_to_uploaded_prior_to().get(request.resolve_name)
2✔
637

638
    constraints_file: ResolvePexConstraintsFile | None = None
2✔
639
    _constraints_file_path = python_setup.resolves_to_constraints_file().get(request.resolve_name)
2✔
640
    if _constraints_file_path:
2✔
641
        _constraints_origin = softwrap(
×
642
            f"""
643
            the option `[python].resolves_to_constraints_file` for the resolve
644
            '{request.resolve_name}'
645
            """
646
        )
647
        _constraints_path_globs = PathGlobs(
×
648
            [_constraints_file_path] if _constraints_file_path else [],
649
            glob_match_error_behavior=GlobMatchErrorBehavior.error,
650
            description_of_origin=_constraints_origin,
651
        )
652
        # TODO: Probably re-doing work here - instead of just calling one, then the next
653
        _constraints_digest, _constraints_digest_contents = await concurrently(
×
654
            path_globs_to_digest(_constraints_path_globs),
655
            get_digest_contents(**implicitly({_constraints_path_globs: PathGlobs})),
656
        )
657

658
        if len(_constraints_digest_contents) != 1:
×
659
            raise ValueError(
×
660
                softwrap(
661
                    f"""
662
                    Expected only one file from {_constraints_origin}, but matched:
663
                    {sorted(fc.path for fc in _constraints_digest_contents)}
664

665
                    Did you use a glob like `*`?
666
                    """
667
                )
668
            )
669
        _constraints_file_content = next(iter(_constraints_digest_contents))
×
670
        constraints = parse_requirements_file(
×
671
            _constraints_file_content.content.decode("utf-8"), rel_path=_constraints_file_path
672
        )
673
        constraints_file = ResolvePexConstraintsFile(
×
674
            _constraints_digest, _constraints_file_path, FrozenOrderedSet(constraints)
675
        )
676

677
    return ResolveConfig(
2✔
678
        indexes=python_repos.indexes,
679
        find_links=python_repos.find_links,
680
        manylinux=python_setup.manylinux,
681
        constraints_file=constraints_file,
682
        no_binary=FrozenOrderedSet(no_binary),
683
        only_binary=FrozenOrderedSet(only_binary),
684
        excludes=FrozenOrderedSet(excludes),
685
        overrides=FrozenOrderedSet(overrides),
686
        sources=FrozenOrderedSet(sources),
687
        path_mappings=python_repos.path_mappings,
688
        lock_style=lock_style,
689
        complete_platforms=complete_platforms,
690
        uploaded_prior_to=uploaded_prior_to,
691
    )
692

693

694
def validate_metadata(
2✔
695
    metadata: PythonLockfileMetadata,
696
    interpreter_constraints: InterpreterConstraints,
697
    lockfile: Lockfile,
698
    consumed_req_strings: Iterable[str],
699
    validate_consumed_req_strings: bool,
700
    python_setup: PythonSetup,
701
    resolve_config: ResolveConfig,
702
) -> None:
703
    """Given interpreter constraints and requirements to be consumed, validate lockfile metadata."""
704

705
    # TODO(#12314): Improve the exception if invalid strings
UNCOV
706
    user_requirements = [PipRequirement.parse(i) for i in consumed_req_strings]
×
UNCOV
707
    validation = metadata.is_valid_for(
×
708
        expected_invalidation_digest=lockfile.lockfile_hex_digest,
709
        user_interpreter_constraints=interpreter_constraints,
710
        interpreter_universe=python_setup.interpreter_versions_universe,
711
        user_requirements=user_requirements if validate_consumed_req_strings else {},
712
        manylinux=resolve_config.manylinux,
713
        requirement_constraints=(
714
            resolve_config.constraints_file.constraints
715
            if resolve_config.constraints_file
716
            else set()
717
        ),
718
        only_binary=resolve_config.only_binary,
719
        no_binary=resolve_config.no_binary,
720
        excludes=resolve_config.excludes,
721
        overrides=resolve_config.overrides,
722
        sources=resolve_config.sources,
723
        lock_style=resolve_config.lock_style,
724
        complete_platforms=resolve_config.complete_platforms,
725
        uploaded_prior_to=resolve_config.uploaded_prior_to,
726
    )
UNCOV
727
    if validation:
×
UNCOV
728
        return
×
729

UNCOV
730
    error_msg_kwargs = dict(
×
731
        metadata=metadata,
732
        validation=validation,
733
        lockfile=lockfile,
734
        is_default_user_lockfile=lockfile.resolve_name == python_setup.default_resolve,
735
        user_interpreter_constraints=interpreter_constraints,
736
        user_requirements=user_requirements,
737
        maybe_constraints_file_path=(
738
            resolve_config.constraints_file.path if resolve_config.constraints_file else None
739
        ),
740
    )
UNCOV
741
    msg_iter = _invalid_lockfile_error(**error_msg_kwargs)  # type: ignore[arg-type]
×
UNCOV
742
    msg = "".join(msg_iter).strip()
×
UNCOV
743
    if python_setup.invalid_lockfile_behavior == InvalidLockfileBehavior.error:
×
UNCOV
744
        raise InvalidLockfileError(msg)
×
745
    logger.warning(msg)
×
746

747

748
def _common_failure_reasons(
2✔
749
    failure_reasons: set[InvalidPythonLockfileReason], maybe_constraints_file_path: str | None
750
) -> Iterator[str]:
UNCOV
751
    if InvalidPythonLockfileReason.CONSTRAINTS_FILE_MISMATCH in failure_reasons:
×
752
        if maybe_constraints_file_path is None:
×
753
            yield softwrap(
×
754
                """
755
                - Constraint file expected from lockfile metadata but no
756
                constraints file configured.  See the option
757
                `[python].resolves_to_constraints_file`.
758
                """
759
            )
760
        else:
761
            yield softwrap(
×
762
                f"""
763
                - The constraints file at {maybe_constraints_file_path} has changed from when the
764
                lockfile was generated. (Constraints files are set via the option
765
                `[python].resolves_to_constraints_file`)
766
                """
767
            )
UNCOV
768
    if InvalidPythonLockfileReason.ONLY_BINARY_MISMATCH in failure_reasons:
×
769
        yield softwrap(
×
770
            """
771
            - The `only_binary` arguments have changed from when the lockfile was generated.
772
            (`only_binary` is set via the options `[python].resolves_to_only_binary` and deprecated
773
            `[python].only_binary`)
774
            """
775
        )
UNCOV
776
    if InvalidPythonLockfileReason.NO_BINARY_MISMATCH in failure_reasons:
×
777
        yield softwrap(
×
778
            """
779
            - The `no_binary` arguments have changed from when the lockfile was generated.
780
            (`no_binary` is set via the options `[python].resolves_to_no_binary` and deprecated
781
            `[python].no_binary`)
782
            """
783
        )
UNCOV
784
    if InvalidPythonLockfileReason.MANYLINUX_MISMATCH in failure_reasons:
×
UNCOV
785
        yield softwrap(
×
786
            """
787
            - The `manylinux` argument has changed from when the lockfile was generated.
788
            (manylinux is set via the option `[python].resolver_manylinux`)
789
            """
790
        )
UNCOV
791
    if InvalidPythonLockfileReason.UPLOADED_PRIOR_TO_MISMATCH in failure_reasons:
×
792
        yield softwrap(
×
793
            """
794
            - The `uploaded_prior_to` argument has changed from when the lockfile was generated.
795
            (uploaded_prior_to is set via the option `[python].resolves_to_uploaded_prior_to`)
796
            """
797
        )
798

799

800
def _invalid_lockfile_error(
2✔
801
    metadata: PythonLockfileMetadata,
802
    validation: LockfileMetadataValidation,
803
    lockfile: Lockfile,
804
    *,
805
    is_default_user_lockfile: bool,
806
    user_requirements: list[PipRequirement],
807
    user_interpreter_constraints: InterpreterConstraints,
808
    maybe_constraints_file_path: str | None,
809
) -> Iterator[str]:
UNCOV
810
    resolve = lockfile.resolve_name
×
UNCOV
811
    consumed_msg_parts = [f"`{str(r)}`" for r in user_requirements[0:2]]
×
UNCOV
812
    if len(user_requirements) > 2:
×
813
        consumed_msg_parts.append(
×
814
            f"{len(user_requirements) - 2} other "
815
            f"{pluralize(len(user_requirements) - 2, 'requirement', include_count=False)}"
816
        )
817

UNCOV
818
    yield f"\n\nYou are consuming {comma_separated_list(consumed_msg_parts)} from "
×
UNCOV
819
    if lockfile.url.startswith("resource://"):
×
820
        yield f"the built-in `{resolve}` lockfile provided by Pants "
×
821
    else:
UNCOV
822
        yield f"the `{resolve}` lockfile at {lockfile.url} "
×
UNCOV
823
    yield "with incompatible inputs.\n\n"
×
824

UNCOV
825
    if any(
×
826
        i
827
        in (
828
            InvalidPythonLockfileReason.INVALIDATION_DIGEST_MISMATCH,
829
            InvalidPythonLockfileReason.REQUIREMENTS_MISMATCH,
830
        )
831
        for i in validation.failure_reasons
832
    ):
UNCOV
833
        yield (
×
834
            softwrap(
835
                """
836
            - The lockfile does not provide all the necessary requirements. You must
837
            modify the input requirements and/or regenerate the lockfile (see below).
838
            """
839
            )
840
            + "\n\n"
841
        )
UNCOV
842
        if is_default_user_lockfile:
×
843
            yield softwrap(
×
844
                f"""
845
                - The necessary requirements are specified by requirements targets marked with
846
                `resolve="{resolve}"`, or those with no explicit resolve (since `{resolve}` is the
847
                default for this repo).
848

849
                - The lockfile destination is specified by the `{resolve}` key in `[python].resolves`.
850
                """
851
            )
852
        else:
UNCOV
853
            yield softwrap(
×
854
                f"""
855
                - The necessary requirements are specified by requirements targets marked with
856
                `resolve="{resolve}"`.
857

858
                - The lockfile destination is specified by the `{resolve}` key in
859
                `[python].resolves`.
860
                """
861
            )
862

UNCOV
863
        if isinstance(metadata, PythonLockfileMetadataV2):
×
864
            # Note that by the time we have gotten to this error message, we should have already
865
            # validated that the transitive closure is using the same resolve, via
866
            # pex_from_targets.py. This implies that we don't need to worry about users depending
867
            # on python_requirement targets that aren't in that code's resolve.
UNCOV
868
            not_in_lock = sorted(str(r) for r in set(user_requirements) - metadata.requirements)
×
UNCOV
869
            yield f"\n\n- The requirements not provided by the `{resolve}` resolve are:\n  "
×
UNCOV
870
            yield str(not_in_lock)
×
871

UNCOV
872
    if InvalidPythonLockfileReason.INTERPRETER_CONSTRAINTS_MISMATCH in validation.failure_reasons:
×
UNCOV
873
        yield "\n\n"
×
UNCOV
874
        yield softwrap(
×
875
            f"""
876
            - The inputs use interpreter constraints (`{user_interpreter_constraints}`) that
877
            are not a subset of those used to generate the lockfile
878
            (`{metadata.valid_for_interpreter_constraints}`).
879

880
            - The input interpreter constraints are specified by your code, using
881
            the `[python].interpreter_constraints` option and the `interpreter_constraints`
882
            target field.
883

884
            - To create a lockfile with new interpreter constraints, update the option
885
            `[python].resolves_to_interpreter_constraints`, and then generate the lockfile
886
            (see below).
887
            """
888
        )
UNCOV
889
        yield f"\n\nSee {doc_url('docs/python/overview/interpreter-compatibility')} for details."
×
890

UNCOV
891
    yield "\n\n"
×
UNCOV
892
    yield from (
×
893
        f"{fail}\n"
894
        for fail in _common_failure_reasons(validation.failure_reasons, maybe_constraints_file_path)
895
    )
UNCOV
896
    yield "To regenerate your lockfile, "
×
UNCOV
897
    yield f"run `{bin_name()} generate-lockfiles --resolve={resolve}`."
×
UNCOV
898
    yield f"\n\nSee {doc_url('docs/python/overview/third-party-dependencies')} for details.\n\n"
×
899

900

901
def rules():
2✔
902
    return collect_rules()
2✔
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