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

pantsbuild / pants / 19250292619

11 Nov 2025 12:09AM UTC coverage: 77.865% (-2.4%) from 80.298%
19250292619

push

github

web-flow
flag non-runnable targets used with `code_quality_tool` (#22875)

2 of 5 new or added lines in 2 files covered. (40.0%)

1487 existing lines in 72 files now uncovered.

71448 of 91759 relevant lines covered (77.86%)

3.22 hits per line

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

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

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

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

46
if TYPE_CHECKING:
47
    from pants.backend.python.util_rules.pex import Pex
48

49

50
logger = logging.getLogger(__name__)
11✔
51

52

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

64
    use_entire_lockfile: bool
11✔
65

66

67
@dataclass(frozen=True)
11✔
68
class Lockfile:
11✔
69
    url: str
11✔
70
    url_description_of_origin: str
11✔
71
    resolve_name: str
11✔
72
    lockfile_hex_digest: str | None = None
11✔
73

74

75
@rule
11✔
76
async def get_lockfile_for_resolve(resolve: Resolve, python_setup: PythonSetup) -> Lockfile:
11✔
77
    lockfile_path = python_setup.resolves.get(resolve.name)
×
78
    if not lockfile_path:
×
79
        raise ValueError(f"No such resolve: {resolve.name}")
×
80
    return Lockfile(
×
81
        url=lockfile_path,
82
        url_description_of_origin=f"the resolve `{resolve.name}`",
83
        resolve_name=resolve.name,
84
    )
85

86

87
@dataclass(frozen=True)
11✔
88
class LoadedLockfile:
11✔
89
    """A lockfile after loading and header stripping.
90

91
    Validation is deferred until consumption time, because each consumed subset (in the case of a
92
    PEX-native lockfile) can be individually validated.
93
    """
94

95
    # The digest of the loaded lockfile (which may not be identical to the input).
96
    lockfile_digest: Digest
11✔
97
    # The path of the loaded lockfile within the Digest.
98
    lockfile_path: str
11✔
99
    # The loaded metadata for this lockfile, if any.
100
    metadata: PythonLockfileMetadata | None = field(hash=False)
11✔
101
    # An estimate of the number of requirements in this lockfile, to be used as a heuristic for
102
    # available parallelism.
103
    requirement_estimate: int
11✔
104
    # True if the loaded lockfile is in PEX's native format.
105
    is_pex_native: bool
11✔
106
    # If !is_pex_native, the lockfile parsed as constraints strings, for use when the lockfile
107
    # needs to be subsetted (see #15031, ##12222).
108
    as_constraints_strings: FrozenOrderedSet[str] | None
11✔
109
    # The original file or file content (which may not have identical content to the output
110
    # `lockfile_digest`).
111
    original_lockfile: Lockfile
11✔
112

113

114
@dataclass(frozen=True)
11✔
115
class LoadedLockfileRequest:
11✔
116
    """A request to load and validate the content of the given lockfile."""
117

118
    lockfile: Lockfile
11✔
119

120

121
def strip_comments_from_pex_json_lockfile(lockfile_bytes: bytes) -> bytes:
11✔
122
    """Pex does not like the header Pants adds to lockfiles, as it violates JSON.
123

124
    Note that we only strip lines starting with `//`, which is all that Pants will ever add. If
125
    users add their own comments, things will fail.
126
    """
127
    return b"\n".join(
11✔
128
        line for line in lockfile_bytes.splitlines() if not line.lstrip().startswith(b"//")
129
    )
130

131

132
def is_probably_pex_json_lockfile(lockfile_bytes: bytes) -> bool:
11✔
UNCOV
133
    for line in lockfile_bytes.splitlines():
×
UNCOV
134
        if line and not line.startswith(b"//"):
×
135
            # Note that pip/Pex complain if a requirements.txt style starts with `{`.
UNCOV
136
            return line.lstrip().startswith(b"{")
×
UNCOV
137
    return False
×
138

139

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

UNCOV
146
    num_lines = len(lockfile_bytes.splitlines())
×
147
    # These are very naive estimates, and they bias towards overcounting. For example, requirements
148
    # often are 20+ lines.
UNCOV
149
    num_lines_for_options = 10
×
UNCOV
150
    lines_per_req = 10
×
UNCOV
151
    return max((num_lines - num_lines_for_options) // lines_per_req, 2)
×
152

153

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

182

183
async def read_file_or_resource(url: str, description_of_origin: str) -> Digest:
11✔
184
    """Read from a path, file:// or resource:// URL and return the digest of the content.
185

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

212

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

226
    lockfile_contents = await get_digest_contents(lockfile_digest)
×
227
    lock_bytes = lockfile_contents[0].content
×
228
    is_pex_native = is_probably_pex_json_lockfile(lock_bytes)
×
229
    constraints_strings = None
×
230

231
    metadata_url = PythonLockfileMetadata.metadata_location_for_lockfile(lockfile.url)
×
232
    metadata = None
×
233
    try:
×
234
        metadata_digest = await read_file_or_resource(
×
235
            metadata_url,
236
            description_of_origin="We squelch errors, so this is never seen by users",
237
        )
238
        digest_contents = await get_digest_contents(metadata_digest)
×
239
        metadata_bytes = digest_contents[0].content
×
240
        json_dict = json.loads(metadata_bytes)
×
241
        metadata = PythonLockfileMetadata.from_json_dict(
×
242
            json_dict,
243
            lockfile_description=f"the lockfile for `{lockfile.resolve_name}`",
244
            error_suffix=softwrap(
245
                f"""
246
                To resolve this error, you will need to regenerate the lockfile by running
247
                `{bin_name()} generate-lockfiles --resolve={lockfile.resolve_name}.
248
                """
249
            ),
250
        )
251
        requirement_estimate = _pex_lockfile_requirement_count(lock_bytes)
×
252
    except (IntrinsicError, FileNotFoundError):
×
253
        # No metadata file or resource found, so fall through to finding a metadata
254
        # header block prepended to the lockfile itself.
255
        pass
×
256

257
    if not metadata:
×
258
        if is_pex_native:
×
259
            header_delimiter = "//"
×
260
            stripped_lock_bytes = strip_comments_from_pex_json_lockfile(lock_bytes)
×
261
            lockfile_digest = await create_digest(
×
262
                CreateDigest([FileContent(lockfile_path, stripped_lock_bytes)])
263
            )
264
            requirement_estimate = _pex_lockfile_requirement_count(lock_bytes)
×
265
        else:
266
            header_delimiter = "#"
×
267
            lock_string = lock_bytes.decode()
×
268
            # Note: this is a very naive heuristic. It will overcount because entries often
269
            # have >1 line due to `--hash`.
270
            requirement_estimate = len(lock_string.splitlines())
×
271
            constraints_strings = FrozenOrderedSet(
×
272
                str(req) for req in parse_requirements_file(lock_string, rel_path=lockfile_path)
273
            )
274

275
        metadata = get_metadata(
×
276
            python_setup,
277
            lock_bytes,
278
            None if synthetic_lock else lockfile_path,
279
            lockfile.resolve_name,
280
            header_delimiter,
281
        )
282

283
    return LoadedLockfile(
×
284
        lockfile_digest,
285
        lockfile_path,
286
        metadata,
287
        requirement_estimate,
288
        is_pex_native,
289
        constraints_strings,
290
        original_lockfile=lockfile,
291
    )
292

293

294
@dataclass(frozen=True)
11✔
295
class EntireLockfile:
11✔
296
    """A request to resolve the entire contents of a lockfile.
297

298
    This resolution mode is used in a few cases:
299
    1. for poetry or handwritten lockfiles (which do not support being natively subsetted the
300
       way that a PEX lockfile can be), in order to build a repository-PEX to subset separately.
301
    2. for tool lockfiles, which (regardless of format), need to resolve the entire lockfile
302
       content anyway.
303
    """
304

305
    lockfile: Lockfile
11✔
306
    # If available, the current complete set of requirement strings that influence this lockfile.
307
    # Used for metadata validation.
308
    complete_req_strings: tuple[str, ...] | None = None
11✔
309

310

311
@dataclass(frozen=True)
11✔
312
class PexRequirements:
11✔
313
    """A request to resolve a series of requirements (optionally from a "superset" resolve)."""
314

315
    req_strings_or_addrs: FrozenOrderedSet[str | Address]
11✔
316
    constraints_strings: FrozenOrderedSet[str]
11✔
317
    # If these requirements should be resolved as a subset of either a repository PEX, or a
318
    # PEX-native lockfile, the superset to use. # NB: Use of a lockfile here asserts that the
319
    # lockfile is PEX-native, because legacy lockfiles do not support subset resolves.
320
    from_superset: Pex | Resolve | None
11✔
321
    description_of_origin: str
11✔
322

323
    def __init__(
11✔
324
        self,
325
        req_strings_or_addrs: Iterable[str | Address] = (),
326
        *,
327
        constraints_strings: Iterable[str] = (),
328
        from_superset: Pex | Resolve | None = None,
329
        description_of_origin: str = "",
330
    ) -> None:
331
        """
332
        :param req_strings_or_addrs: The requirement strings to resolve, or addresses
333
          of targets that refer to them, or string specs of such addresses.
334
        :param constraints_strings: Constraints strings to apply during the resolve.
335
        :param from_superset: An optional superset PEX or lockfile to resolve the req strings from.
336
        :param description_of_origin: A human-readable description of what these requirements
337
          represent, for use in error messages.
338
        """
339
        object.__setattr__(
11✔
340
            self, "req_strings_or_addrs", FrozenOrderedSet(sorted(req_strings_or_addrs))
341
        )
342
        object.__setattr__(
11✔
343
            self, "constraints_strings", FrozenOrderedSet(sorted(constraints_strings))
344
        )
345
        object.__setattr__(self, "from_superset", from_superset)
11✔
346
        object.__setattr__(self, "description_of_origin", description_of_origin)
11✔
347

348
    @classmethod
11✔
349
    def req_strings_from_requirement_fields(
11✔
350
        cls, fields: Iterable[PythonRequirementsField]
351
    ) -> FrozenOrderedSet[str]:
352
        """A convenience when you only need the raw requirement strings from fields and don't need
353
        to consider things like constraints or resolves."""
354
        return FrozenOrderedSet(
×
355
            sorted(str(python_req) for fld in fields for python_req in fld.value)
356
        )
357

358
    def __bool__(self) -> bool:
11✔
359
        return bool(self.req_strings_or_addrs)
×
360

361

362
@dataclass(frozen=True)
11✔
363
class ResolvePexConstraintsFile:
11✔
364
    digest: Digest
11✔
365
    path: str
11✔
366
    constraints: FrozenOrderedSet[PipRequirement]
11✔
367

368

369
@dataclass(frozen=True)
11✔
370
class ResolvePexConfig:
11✔
371
    """Configuration from `[python]` that impacts how the resolve is created."""
372

373
    indexes: tuple[str, ...]
11✔
374
    find_links: tuple[str, ...]
11✔
375
    manylinux: str | None
11✔
376
    constraints_file: ResolvePexConstraintsFile | None
11✔
377
    only_binary: FrozenOrderedSet[str]
11✔
378
    no_binary: FrozenOrderedSet[str]
11✔
379
    excludes: FrozenOrderedSet[str]
11✔
380
    overrides: FrozenOrderedSet[str]
11✔
381
    sources: FrozenOrderedSet[str]
11✔
382
    path_mappings: tuple[str, ...]
11✔
383

384
    def pex_args(self) -> Iterator[str]:
11✔
385
        """Arguments for Pex for indexes/--find-links, manylinux, and path mappings.
386

387
        Does not include arguments for constraints files, which must be set up independently.
388
        """
389
        # NB: In setting `--no-pypi`, we rely on the default value of `[python-repos].indexes`
390
        # including PyPI, which will override `--no-pypi` and result in using PyPI in the default
391
        # case. Why set `--no-pypi`, then? We need to do this so that
392
        # `[python-repos].indexes = ['custom_url']` will only point to that index and not include
393
        # PyPI.
394
        yield "--no-pypi"
1✔
395
        yield from (f"--index={index}" for index in self.indexes)
1✔
396
        yield from (f"--find-links={repo}" for repo in self.find_links)
1✔
397

398
        if self.manylinux:
1✔
UNCOV
399
            yield "--manylinux"
×
UNCOV
400
            yield self.manylinux
×
401
        else:
402
            yield "--no-manylinux"
1✔
403

404
        # Pex logically plumbs through equivalent settings, but uses a
405
        # separate flag instead of the Pip magic :all:/:none: syntax.  To
406
        # support the exitings Pants config settings we need to go from
407
        # :all:/:none: --> Pex options, which Pex will translate back into Pip
408
        # options.  Note that Pex's --wheel (for example) means "allow
409
        # wheels", not "require wheels".
410
        if self.only_binary and ":all:" in self.only_binary:
1✔
UNCOV
411
            yield "--wheel"
×
UNCOV
412
            yield "--no-build"
×
413
        elif self.only_binary and ":none:" in self.only_binary:
1✔
UNCOV
414
            yield "--no-wheel"
×
UNCOV
415
            yield "--build"
×
416
        elif self.only_binary:
1✔
UNCOV
417
            yield from (f"--only-binary={pkg}" for pkg in self.only_binary)
×
418

419
        if self.no_binary and ":all:" in self.no_binary:
1✔
UNCOV
420
            yield "--no-wheel"
×
UNCOV
421
            yield "--build"
×
422
        elif self.no_binary and ":none:" in self.no_binary:
1✔
UNCOV
423
            yield "--wheel"
×
UNCOV
424
            yield "--no-build"
×
425
        elif self.no_binary:
1✔
UNCOV
426
            yield from (f"--only-build={pkg}" for pkg in self.no_binary)
×
427

428
        yield from (f"--path-mapping={v}" for v in self.path_mappings)
1✔
429

430
        yield from (f"--exclude={exclude}" for exclude in self.excludes)
1✔
431
        yield from (f"--source={source}" for source in self.sources)
1✔
432

433

434
@dataclass(frozen=True)
11✔
435
class ResolvePexConfigRequest(EngineAwareParameter):
11✔
436
    """Find all configuration from `[python]` that impacts how the resolve is created.
437

438
    If `resolve_name` is None, then most per-resolve options will be ignored because there is no way
439
    for users to configure them. However, some options like `[python-repos].indexes` will still be
440
    loaded.
441
    """
442

443
    resolve_name: str | None
11✔
444

445
    def debug_hint(self) -> str:
11✔
446
        return self.resolve_name or "<no resolve>"
×
447

448

449
@rule
11✔
450
async def determine_resolve_pex_config(
11✔
451
    request: ResolvePexConfigRequest,
452
    python_setup: PythonSetup,
453
    python_repos: PythonRepos,
454
    union_membership: UnionMembership,
455
) -> ResolvePexConfig:
456
    if request.resolve_name is None:
×
457
        return ResolvePexConfig(
×
458
            indexes=python_repos.indexes,
459
            find_links=python_repos.find_links,
460
            manylinux=python_setup.manylinux,
461
            constraints_file=None,
462
            no_binary=FrozenOrderedSet(),
463
            only_binary=FrozenOrderedSet(),
464
            excludes=FrozenOrderedSet(),
465
            overrides=FrozenOrderedSet(),
466
            sources=FrozenOrderedSet(),
467
            path_mappings=python_repos.path_mappings,
468
        )
469

470
    no_binary = python_setup.resolves_to_no_binary().get(request.resolve_name) or []
×
471
    only_binary = python_setup.resolves_to_only_binary().get(request.resolve_name) or []
×
472
    excludes = python_setup.resolves_to_excludes().get(request.resolve_name) or []
×
473
    overrides = python_setup.resolves_to_overrides().get(request.resolve_name) or []
×
474
    sources = python_setup.resolves_to_sources().get(request.resolve_name) or []
×
475

476
    constraints_file: ResolvePexConstraintsFile | None = None
×
477
    _constraints_file_path = python_setup.resolves_to_constraints_file().get(request.resolve_name)
×
478
    if _constraints_file_path:
×
479
        _constraints_origin = softwrap(
×
480
            f"""
481
            the option `[python].resolves_to_constraints_file` for the resolve
482
            '{request.resolve_name}'
483
            """
484
        )
485
        _constraints_path_globs = PathGlobs(
×
486
            [_constraints_file_path] if _constraints_file_path else [],
487
            glob_match_error_behavior=GlobMatchErrorBehavior.error,
488
            description_of_origin=_constraints_origin,
489
        )
490
        # TODO: Probably re-doing work here - instead of just calling one, then the next
491
        _constraints_digest, _constraints_digest_contents = await concurrently(
×
492
            path_globs_to_digest(_constraints_path_globs),
493
            get_digest_contents(**implicitly({_constraints_path_globs: PathGlobs})),
494
        )
495

496
        if len(_constraints_digest_contents) != 1:
×
497
            raise ValueError(
×
498
                softwrap(
499
                    f"""
500
                    Expected only one file from {_constraints_origin}, but matched:
501
                    {sorted(fc.path for fc in _constraints_digest_contents)}
502

503
                    Did you use a glob like `*`?
504
                    """
505
                )
506
            )
507
        _constraints_file_content = next(iter(_constraints_digest_contents))
×
508
        constraints = parse_requirements_file(
×
509
            _constraints_file_content.content.decode("utf-8"), rel_path=_constraints_file_path
510
        )
511
        constraints_file = ResolvePexConstraintsFile(
×
512
            _constraints_digest, _constraints_file_path, FrozenOrderedSet(constraints)
513
        )
514

515
    return ResolvePexConfig(
×
516
        indexes=python_repos.indexes,
517
        find_links=python_repos.find_links,
518
        manylinux=python_setup.manylinux,
519
        constraints_file=constraints_file,
520
        no_binary=FrozenOrderedSet(no_binary),
521
        only_binary=FrozenOrderedSet(only_binary),
522
        excludes=FrozenOrderedSet(excludes),
523
        overrides=FrozenOrderedSet(overrides),
524
        sources=FrozenOrderedSet(sources),
525
        path_mappings=python_repos.path_mappings,
526
    )
527

528

529
def validate_metadata(
11✔
530
    metadata: PythonLockfileMetadata,
531
    interpreter_constraints: InterpreterConstraints,
532
    lockfile: Lockfile,
533
    consumed_req_strings: Iterable[str],
534
    validate_consumed_req_strings: bool,
535
    python_setup: PythonSetup,
536
    resolve_config: ResolvePexConfig,
537
) -> None:
538
    """Given interpreter constraints and requirements to be consumed, validate lockfile metadata."""
539

540
    # TODO(#12314): Improve the exception if invalid strings
UNCOV
541
    user_requirements = [PipRequirement.parse(i) for i in consumed_req_strings]
×
UNCOV
542
    validation = metadata.is_valid_for(
×
543
        expected_invalidation_digest=lockfile.lockfile_hex_digest,
544
        user_interpreter_constraints=interpreter_constraints,
545
        interpreter_universe=python_setup.interpreter_versions_universe,
546
        user_requirements=user_requirements if validate_consumed_req_strings else {},
547
        manylinux=resolve_config.manylinux,
548
        requirement_constraints=(
549
            resolve_config.constraints_file.constraints
550
            if resolve_config.constraints_file
551
            else set()
552
        ),
553
        only_binary=resolve_config.only_binary,
554
        no_binary=resolve_config.no_binary,
555
        excludes=resolve_config.excludes,
556
        overrides=resolve_config.overrides,
557
        sources=resolve_config.sources,
558
    )
UNCOV
559
    if validation:
×
560
        return
×
561

UNCOV
562
    error_msg_kwargs = dict(
×
563
        metadata=metadata,
564
        validation=validation,
565
        lockfile=lockfile,
566
        is_default_user_lockfile=lockfile.resolve_name == python_setup.default_resolve,
567
        user_interpreter_constraints=interpreter_constraints,
568
        user_requirements=user_requirements,
569
        maybe_constraints_file_path=(
570
            resolve_config.constraints_file.path if resolve_config.constraints_file else None
571
        ),
572
    )
UNCOV
573
    msg_iter = _invalid_lockfile_error(**error_msg_kwargs)  # type: ignore[arg-type]
×
UNCOV
574
    msg = "".join(msg_iter).strip()
×
UNCOV
575
    if python_setup.invalid_lockfile_behavior == InvalidLockfileBehavior.error:
×
576
        raise InvalidLockfileError(msg)
×
UNCOV
577
    logger.warning(msg)
×
578

579

580
def _common_failure_reasons(
11✔
581
    failure_reasons: set[InvalidPythonLockfileReason], maybe_constraints_file_path: str | None
582
) -> Iterator[str]:
UNCOV
583
    if InvalidPythonLockfileReason.CONSTRAINTS_FILE_MISMATCH in failure_reasons:
×
UNCOV
584
        if maybe_constraints_file_path is None:
×
585
            yield softwrap(
×
586
                """
587
                - Constraint file expected from lockfile metadata but no
588
                constraints file configured.  See the option
589
                `[python].resolves_to_constraints_file`.
590
                """
591
            )
592
        else:
UNCOV
593
            yield softwrap(
×
594
                f"""
595
                - The constraints file at {maybe_constraints_file_path} has changed from when the
596
                lockfile was generated. (Constraints files are set via the option
597
                `[python].resolves_to_constraints_file`)
598
                """
599
            )
UNCOV
600
    if InvalidPythonLockfileReason.ONLY_BINARY_MISMATCH in failure_reasons:
×
UNCOV
601
        yield softwrap(
×
602
            """
603
            - The `only_binary` arguments have changed from when the lockfile was generated.
604
            (`only_binary` is set via the options `[python].resolves_to_only_binary` and deprecated
605
            `[python].only_binary`)
606
            """
607
        )
UNCOV
608
    if InvalidPythonLockfileReason.NO_BINARY_MISMATCH in failure_reasons:
×
UNCOV
609
        yield softwrap(
×
610
            """
611
            - The `no_binary` arguments have changed from when the lockfile was generated.
612
            (`no_binary` is set via the options `[python].resolves_to_no_binary` and deprecated
613
            `[python].no_binary`)
614
            """
615
        )
UNCOV
616
    if InvalidPythonLockfileReason.MANYLINUX_MISMATCH in failure_reasons:
×
UNCOV
617
        yield softwrap(
×
618
            """
619
            - The `manylinux` argument has changed from when the lockfile was generated.
620
            (manylinux is set via the option `[python].resolver_manylinux`)
621
            """
622
        )
623

624

625
def _invalid_lockfile_error(
11✔
626
    metadata: PythonLockfileMetadata,
627
    validation: LockfileMetadataValidation,
628
    lockfile: Lockfile,
629
    *,
630
    is_default_user_lockfile: bool,
631
    user_requirements: list[PipRequirement],
632
    user_interpreter_constraints: InterpreterConstraints,
633
    maybe_constraints_file_path: str | None,
634
) -> Iterator[str]:
UNCOV
635
    resolve = lockfile.resolve_name
×
UNCOV
636
    consumed_msg_parts = [f"`{str(r)}`" for r in user_requirements[0:2]]
×
UNCOV
637
    if len(user_requirements) > 2:
×
638
        consumed_msg_parts.append(
×
639
            f"{len(user_requirements) - 2} other "
640
            f"{pluralize(len(user_requirements) - 2, 'requirement', include_count=False)}"
641
        )
642

UNCOV
643
    yield f"\n\nYou are consuming {comma_separated_list(consumed_msg_parts)} from "
×
UNCOV
644
    if lockfile.url.startswith("resource://"):
×
645
        yield f"the built-in `{resolve}` lockfile provided by Pants "
×
646
    else:
UNCOV
647
        yield f"the `{resolve}` lockfile at {lockfile.url} "
×
UNCOV
648
    yield "with incompatible inputs.\n\n"
×
649

UNCOV
650
    if any(
×
651
        i
652
        in (
653
            InvalidPythonLockfileReason.INVALIDATION_DIGEST_MISMATCH,
654
            InvalidPythonLockfileReason.REQUIREMENTS_MISMATCH,
655
        )
656
        for i in validation.failure_reasons
657
    ):
UNCOV
658
        yield (
×
659
            softwrap(
660
                """
661
            - The lockfile does not provide all the necessary requirements. You must
662
            modify the input requirements and/or regenerate the lockfile (see below).
663
            """
664
            )
665
            + "\n\n"
666
        )
UNCOV
667
        if is_default_user_lockfile:
×
UNCOV
668
            yield softwrap(
×
669
                f"""
670
                - The necessary requirements are specified by requirements targets marked with
671
                `resolve="{resolve}"`, or those with no explicit resolve (since `{resolve}` is the
672
                default for this repo).
673

674
                - The lockfile destination is specified by the `{resolve}` key in `[python].resolves`.
675
                """
676
            )
677
        else:
678
            yield softwrap(
×
679
                f"""
680
                - The necessary requirements are specified by requirements targets marked with
681
                `resolve="{resolve}"`.
682

683
                - The lockfile destination is specified by the `{resolve}` key in
684
                `[python].resolves`.
685
                """
686
            )
687

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

UNCOV
697
    if InvalidPythonLockfileReason.INTERPRETER_CONSTRAINTS_MISMATCH in validation.failure_reasons:
×
UNCOV
698
        yield "\n\n"
×
UNCOV
699
        yield softwrap(
×
700
            f"""
701
            - The inputs use interpreter constraints (`{user_interpreter_constraints}`) that
702
            are not a subset of those used to generate the lockfile
703
            (`{metadata.valid_for_interpreter_constraints}`).
704

705
            - The input interpreter constraints are specified by your code, using
706
            the `[python].interpreter_constraints` option and the `interpreter_constraints`
707
            target field.
708

709
            - To create a lockfile with new interpreter constraints, update the option
710
            `[python].resolves_to_interpreter_constraints`, and then generate the lockfile
711
            (see below).
712
            """
713
        )
UNCOV
714
        yield f"\n\nSee {doc_url('docs/python/overview/interpreter-compatibility')} for details."
×
715

UNCOV
716
    yield "\n\n"
×
UNCOV
717
    yield from (
×
718
        f"{fail}\n"
719
        for fail in _common_failure_reasons(validation.failure_reasons, maybe_constraints_file_path)
720
    )
UNCOV
721
    yield "To regenerate your lockfile, "
×
UNCOV
722
    yield f"run `{bin_name()} generate-lockfiles --resolve={resolve}`."
×
UNCOV
723
    yield f"\n\nSee {doc_url('docs/python/overview/third-party-dependencies')} for details.\n\n"
×
724

725

726
def rules():
11✔
727
    return collect_rules()
11✔
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