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

pantsbuild / pants / 20438429929

22 Dec 2025 04:55PM UTC coverage: 80.287% (+0.003%) from 80.284%
20438429929

Pull #22934

github

web-flow
Merge b49c09e21 into 06f105be8
Pull Request #22934: feat(go): add multi-module support to golangci-lint plugin and upgrade to v2

37 of 62 new or added lines in 3 files covered. (59.68%)

183 existing lines in 9 files now uncovered.

78528 of 97809 relevant lines covered (80.29%)

3.36 hits per line

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

50.0
/src/python/pants/backend/python/goals/lockfile.py
1
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
12✔
5

6
import itertools
12✔
7
import os.path
12✔
8
from collections import defaultdict
12✔
9
from dataclasses import dataclass
12✔
10
from operator import itemgetter
12✔
11

12
from pants.backend.python.subsystems.python_tool_base import PythonToolBase
12✔
13
from pants.backend.python.subsystems.setup import PythonSetup
12✔
14
from pants.backend.python.target_types import (
12✔
15
    PythonRequirementFindLinksField,
16
    PythonRequirementResolveField,
17
    PythonRequirementsField,
18
)
19
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
12✔
20
from pants.backend.python.util_rules.lockfile_diff import _generate_python_lockfile_diff
12✔
21
from pants.backend.python.util_rules.lockfile_metadata import PythonLockfileMetadata
12✔
22
from pants.backend.python.util_rules.pex import (
12✔
23
    CompletePlatforms,
24
    digest_complete_platform_addresses,
25
    find_interpreter,
26
)
27
from pants.backend.python.util_rules.pex_cli import PexCliProcess, maybe_log_pex_stderr
12✔
28
from pants.backend.python.util_rules.pex_environment import PexSubsystem
12✔
29
from pants.backend.python.util_rules.pex_requirements import (
12✔
30
    PexRequirements,
31
    ResolvePexConfig,
32
    ResolvePexConfigRequest,
33
    determine_resolve_pex_config,
34
)
35
from pants.core.goals.generate_lockfiles import (
12✔
36
    DEFAULT_TOOL_LOCKFILE,
37
    GenerateLockfile,
38
    GenerateLockfileResult,
39
    GenerateLockfilesSubsystem,
40
    KnownUserResolveNames,
41
    KnownUserResolveNamesRequest,
42
    RequestedUserResolveNames,
43
    UserGenerateLockfiles,
44
    WrappedGenerateLockfile,
45
)
46
from pants.core.goals.resolves import ExportableTool
12✔
47
from pants.core.util_rules.lockfile_metadata import calculate_invalidation_digest
12✔
48
from pants.engine.addresses import UnparsedAddressInputs
12✔
49
from pants.engine.fs import CreateDigest, Digest, FileContent, MergeDigests
12✔
50
from pants.engine.internals.synthetic_targets import SyntheticAddressMaps, SyntheticTargetsRequest
12✔
51
from pants.engine.internals.target_adaptor import TargetAdaptor
12✔
52
from pants.engine.intrinsics import create_digest, get_digest_contents, merge_digests
12✔
53
from pants.engine.process import ProcessCacheScope, fallible_to_exec_result_or_raise
12✔
54
from pants.engine.rules import collect_rules, implicitly, rule
12✔
55
from pants.engine.target import AllTargets
12✔
56
from pants.engine.unions import UnionMembership, UnionRule
12✔
57
from pants.option.subsystem import _construct_subsystem
12✔
58
from pants.util.docutil import bin_name
12✔
59
from pants.util.logging import LogLevel
12✔
60
from pants.util.ordered_set import FrozenOrderedSet
12✔
61
from pants.util.pip_requirement import PipRequirement
12✔
62

63

64
@dataclass(frozen=True)
12✔
65
class GeneratePythonLockfile(GenerateLockfile):
12✔
66
    requirements: FrozenOrderedSet[str]
12✔
67
    find_links: FrozenOrderedSet[str]
12✔
68
    interpreter_constraints: InterpreterConstraints
12✔
69
    lock_style: str
12✔
70
    complete_platforms: tuple[str, ...]
12✔
71

72
    @property
12✔
73
    def requirements_hex_digest(self) -> str:
12✔
74
        """Produces a hex digest of the requirements input for this lockfile."""
UNCOV
75
        return calculate_invalidation_digest(self.requirements)
×
76

77

78
@rule
12✔
79
async def wrap_python_lockfile_request(request: GeneratePythonLockfile) -> WrappedGenerateLockfile:
12✔
UNCOV
80
    return WrappedGenerateLockfile(request)
×
81

82

83
@dataclass(frozen=True)
12✔
84
class _PipArgsAndConstraintsSetup:
12✔
85
    resolve_config: ResolvePexConfig
12✔
86
    args: tuple[str, ...]
12✔
87
    digest: Digest
12✔
88

89

90
async def _setup_pip_args_and_constraints_file(resolve_name: str) -> _PipArgsAndConstraintsSetup:
12✔
91
    resolve_config = await determine_resolve_pex_config(
×
92
        ResolvePexConfigRequest(resolve_name), **implicitly()
93
    )
94

95
    args = list(resolve_config.pex_args())
×
96
    digests: list[Digest] = []
×
97

UNCOV
98
    if resolve_config.constraints_file:
×
UNCOV
99
        args.append(f"--constraints={resolve_config.constraints_file.path}")
×
UNCOV
100
        digests.append(resolve_config.constraints_file.digest)
×
101

UNCOV
102
    input_digest = await merge_digests(MergeDigests(digests))
×
UNCOV
103
    return _PipArgsAndConstraintsSetup(resolve_config, tuple(args), input_digest)
×
104

105

106
@rule(desc="Generate Python lockfile", level=LogLevel.DEBUG)
12✔
107
async def generate_lockfile(
12✔
108
    req: GeneratePythonLockfile,
109
    generate_lockfiles_subsystem: GenerateLockfilesSubsystem,
110
    python_setup: PythonSetup,
111
    pex_subsystem: PexSubsystem,
112
) -> GenerateLockfileResult:
UNCOV
113
    if not req.requirements:
×
114
        raise ValueError(
×
115
            f"Cannot generate lockfile with no requirements. Please add some requirements to {req.resolve_name}."
116
        )
117

UNCOV
118
    pip_args_setup = await _setup_pip_args_and_constraints_file(req.resolve_name)
×
UNCOV
119
    header_delimiter = "//"
×
120

UNCOV
121
    python = await find_interpreter(req.interpreter_constraints, **implicitly())
×
122

123
    # Resolve complete platform targets if specified
UNCOV
124
    complete_platforms: CompletePlatforms | None = None
×
UNCOV
125
    if req.complete_platforms:
×
126
        # Resolve target addresses to get platform JSON files
UNCOV
127
        complete_platforms = await digest_complete_platform_addresses(
×
128
            UnparsedAddressInputs(
129
                req.complete_platforms,
130
                owning_address=None,
131
                description_of_origin=f"the `[python].resolves_to_complete_platforms` for resolve `{req.resolve_name}`",
132
            )
133
        )
134

135
    # Add complete platforms if specified, otherwise use default target systems for universal locks
UNCOV
136
    if complete_platforms:
×
UNCOV
137
        target_system_args = tuple(
×
138
            f"--complete-platform={platform}" for platform in complete_platforms
139
        )
UNCOV
140
    elif req.lock_style == "universal":
×
141
        # PEX files currently only run on Linux and Mac machines; so we hard code this
142
        # limit on lock universality to avoid issues locking due to irrelevant
143
        # Windows-only dependency issues. See this Pex issue that originated from a
144
        # Pants user issue presented in Slack:
145
        #   https://github.com/pex-tool/pex/issues/1821
146
        #
147
        # Note: --target-system only applies to universal locks. For other lock styles
148
        # (strict, sources) without complete platforms, we don't specify platform args
149
        # and PEX will lock for the current platform only.
UNCOV
150
        target_system_args = (
×
151
            "--target-system",
152
            "linux",
153
            "--target-system",
154
            "mac",
155
        )
156
    else:
157
        # For non-universal lock styles without complete platforms, don't specify
158
        # platform arguments - PEX will lock for the current platform only
UNCOV
159
        target_system_args = ()
×
160

UNCOV
161
    result = await fallible_to_exec_result_or_raise(
×
162
        **implicitly(
163
            PexCliProcess(
164
                subcommand=("lock", "create"),
165
                extra_args=(
166
                    f"--output={req.lockfile_dest}",
167
                    f"--style={req.lock_style}",
168
                    "--pip-version",
169
                    python_setup.pip_version,
170
                    "--resolver-version",
171
                    "pip-2020-resolver",
172
                    "--preserve-pip-download-log",
173
                    "pex-pip-download.log",
174
                    *target_system_args,
175
                    # This makes diffs more readable when lockfiles change.
176
                    "--indent=2",
177
                    f"--python-path={python.path}",
178
                    *(f"--find-links={link}" for link in req.find_links),
179
                    *pip_args_setup.args,
180
                    # When complete platforms are specified, don't pass interpreter constraints.
181
                    # The complete platforms already define Python versions and platforms.
182
                    # Passing both causes PEX to generate duplicate locked_requirements entries
183
                    # when the local platform matches a complete platform.
184
                    # TODO(#9560): Consider validating that these platforms are valid with the
185
                    #  interpreter constraints.
186
                    *(
187
                        req.interpreter_constraints.generate_pex_arg_list()
188
                        if not complete_platforms
189
                        else ()
190
                    ),
191
                    *(
192
                        f"--override={override}"
193
                        for override in pip_args_setup.resolve_config.overrides
194
                    ),
195
                    *req.requirements,
196
                ),
197
                additional_input_digest=await merge_digests(
198
                    MergeDigests(
199
                        [pip_args_setup.digest]
200
                        + ([complete_platforms.digest] if complete_platforms else [])
201
                    )
202
                ),
203
                output_files=(req.lockfile_dest,),
204
                description=f"Generate lockfile for {req.resolve_name}",
205
                # Instead of caching lockfile generation with LMDB, we instead use the invalidation
206
                # scheme from `lockfile_metadata.py` to check for stale/invalid lockfiles. This is
207
                # necessary so that our invalidation is resilient to deleting LMDB or running on a
208
                # new machine.
209
                #
210
                # We disable caching with LMDB so that when you generate a lockfile, you always get
211
                # the most up-to-date snapshot of the world. This is generally desirable and also
212
                # necessary to avoid an awkward edge case where different developers generate
213
                # different lockfiles even when generating at the same time. See
214
                # https://github.com/pantsbuild/pants/issues/12591.
215
                cache_scope=ProcessCacheScope.PER_SESSION,
216
            )
217
        )
218
    )
UNCOV
219
    maybe_log_pex_stderr(result.stderr, pex_subsystem.verbosity)
×
220

UNCOV
221
    metadata = PythonLockfileMetadata.new(
×
222
        valid_for_interpreter_constraints=req.interpreter_constraints,
223
        requirements={
224
            PipRequirement.parse(
225
                i,
226
                description_of_origin=f"the lockfile {req.lockfile_dest} for the resolve {req.resolve_name}",
227
            )
228
            for i in req.requirements
229
        },
230
        manylinux=pip_args_setup.resolve_config.manylinux,
231
        requirement_constraints=(
232
            set(pip_args_setup.resolve_config.constraints_file.constraints)
233
            if pip_args_setup.resolve_config.constraints_file
234
            else set()
235
        ),
236
        only_binary=set(pip_args_setup.resolve_config.only_binary),
237
        no_binary=set(pip_args_setup.resolve_config.no_binary),
238
        excludes=set(pip_args_setup.resolve_config.excludes),
239
        overrides=set(pip_args_setup.resolve_config.overrides),
240
        sources=set(pip_args_setup.resolve_config.sources),
241
        lock_style=req.lock_style,
242
        complete_platforms=req.complete_platforms,
243
    )
UNCOV
244
    regenerate_command = (
×
245
        generate_lockfiles_subsystem.custom_command
246
        or f"{bin_name()} generate-lockfiles --resolve={req.resolve_name}"
247
    )
UNCOV
248
    if python_setup.separate_lockfile_metadata_file:
×
UNCOV
249
        descr = f"This lockfile was generated by Pants. To regenerate, run: {regenerate_command}"
×
UNCOV
250
        metadata_digest = await create_digest(
×
251
            CreateDigest(
252
                [
253
                    FileContent(
254
                        PythonLockfileMetadata.metadata_location_for_lockfile(req.lockfile_dest),
255
                        metadata.to_json(with_description=descr).encode(),
256
                    ),
257
                ]
258
            )
259
        )
UNCOV
260
        final_lockfile_digest = await merge_digests(
×
261
            MergeDigests([metadata_digest, result.output_digest])
262
        )
263
    else:
UNCOV
264
        initial_lockfile_digest_contents = await get_digest_contents(result.output_digest)
×
UNCOV
265
        lockfile_with_header = metadata.add_header_to_lockfile(
×
266
            initial_lockfile_digest_contents[0].content,
267
            regenerate_command=regenerate_command,
268
            delimeter=header_delimiter,
269
        )
UNCOV
270
        final_lockfile_digest = await create_digest(
×
271
            CreateDigest(
272
                [
273
                    FileContent(req.lockfile_dest, lockfile_with_header),
274
                ]
275
            )
276
        )
277

UNCOV
278
    if req.diff:
×
UNCOV
279
        diff = await _generate_python_lockfile_diff(
×
280
            final_lockfile_digest, req.resolve_name, req.lockfile_dest
281
        )
282
    else:
UNCOV
283
        diff = None
×
284

285
    return GenerateLockfileResult(final_lockfile_digest, req.resolve_name, req.lockfile_dest, diff)
×
286

287

288
class RequestedPythonUserResolveNames(RequestedUserResolveNames):
12✔
289
    pass
12✔
290

291

292
class KnownPythonUserResolveNamesRequest(KnownUserResolveNamesRequest):
12✔
293
    pass
12✔
294

295

296
@rule
12✔
297
async def determine_python_user_resolves(
12✔
298
    _: KnownPythonUserResolveNamesRequest,
299
    python_setup: PythonSetup,
300
    union_membership: UnionMembership,
301
) -> KnownUserResolveNames:
302
    """Find all know Python resolves, from both user-created resolves and internal tools."""
UNCOV
303
    python_tool_resolves = ExportableTool.filter_for_subclasses(union_membership, PythonToolBase)
×
304

UNCOV
305
    tools_using_default_resolve = [
×
306
        resolve_name
307
        for resolve_name, subsystem_cls in python_tool_resolves.items()
308
        if (await _construct_subsystem(subsystem_cls)).install_from_resolve is None
309
    ]
310

UNCOV
311
    return KnownUserResolveNames(
×
312
        names=(
313
            *python_setup.resolves.keys(),
314
            *tools_using_default_resolve,
315
        ),  # the order of the keys doesn't matter since shadowing is done in `setup_user_lockfile_requests`
316
        option_name="[python].resolves",
317
        requested_resolve_names_cls=RequestedPythonUserResolveNames,
318
    )
319

320

321
@rule
12✔
322
async def setup_user_lockfile_requests(
12✔
323
    requested: RequestedPythonUserResolveNames,
324
    all_targets: AllTargets,
325
    python_setup: PythonSetup,
326
    union_membership: UnionMembership,
327
) -> UserGenerateLockfiles:
328
    """Transform the names of resolves requested into the `GeneratePythonLockfile` request object.
329

330
    Shadowing is done here by only checking internal resolves if the resolve is not a user-created
331
    resolve.
332
    """
UNCOV
333
    if not (python_setup.enable_resolves and python_setup.resolves_generate_lockfiles):
×
UNCOV
334
        return UserGenerateLockfiles()
×
335

UNCOV
336
    resolve_to_requirements_fields = defaultdict(set)
×
UNCOV
337
    find_links: set[str] = set()
×
UNCOV
338
    for tgt in all_targets:
×
UNCOV
339
        if not tgt.has_fields((PythonRequirementResolveField, PythonRequirementsField)):
×
340
            continue
×
UNCOV
341
        resolve = tgt[PythonRequirementResolveField].normalized_value(python_setup)
×
UNCOV
342
        resolve_to_requirements_fields[resolve].add(tgt[PythonRequirementsField])
×
UNCOV
343
        find_links.update(tgt[PythonRequirementFindLinksField].value or ())
×
344

UNCOV
345
    tools = ExportableTool.filter_for_subclasses(union_membership, PythonToolBase)
×
346

UNCOV
347
    out = set()
×
UNCOV
348
    for resolve in requested:
×
UNCOV
349
        if resolve in python_setup.resolves:
×
UNCOV
350
            out.add(
×
351
                GeneratePythonLockfile(
352
                    requirements=PexRequirements.req_strings_from_requirement_fields(
353
                        resolve_to_requirements_fields[resolve]
354
                    ),
355
                    find_links=FrozenOrderedSet(find_links),
356
                    interpreter_constraints=InterpreterConstraints(
357
                        python_setup.resolves_to_interpreter_constraints.get(
358
                            resolve, python_setup.interpreter_constraints
359
                        )
360
                    ),
361
                    resolve_name=resolve,
362
                    lockfile_dest=python_setup.resolves[resolve],
363
                    diff=False,
364
                    lock_style=python_setup.resolves_to_lock_style().get(resolve, "universal"),
365
                    complete_platforms=tuple(
366
                        python_setup.resolves_to_complete_platforms().get(resolve, [])
367
                    ),
368
                )
369
            )
370
        else:
UNCOV
371
            tool_cls: type[PythonToolBase] = tools[resolve]
×
372
            tool = await _construct_subsystem(tool_cls)
×
373

374
            # TODO: we shouldn't be managing default ICs in lockfile identification.
375
            #   We should find a better place to do this or a better way to default
UNCOV
376
            if tool.register_interpreter_constraints:
×
UNCOV
377
                ic = tool.interpreter_constraints
×
378
            else:
UNCOV
379
                ic = InterpreterConstraints(tool.default_interpreter_constraints)
×
380

UNCOV
381
            out.add(
×
382
                GeneratePythonLockfile(
383
                    requirements=FrozenOrderedSet(sorted(tool.requirements)),
384
                    find_links=FrozenOrderedSet(find_links),
385
                    interpreter_constraints=ic,
386
                    resolve_name=resolve,
387
                    lockfile_dest=DEFAULT_TOOL_LOCKFILE,
388
                    diff=False,
389
                    lock_style="universal",  # Tools always use universal style
390
                    complete_platforms=(),  # Tools don't use complete platforms
391
                )
392
            )
393

UNCOV
394
    return UserGenerateLockfiles(out)
×
395

396

397
@dataclass(frozen=True)
12✔
398
class PythonSyntheticLockfileTargetsRequest(SyntheticTargetsRequest):
12✔
399
    """Register the type used to create synthetic targets for Python lockfiles.
400

401
    As the paths for all lockfiles are known up-front, we set the `path` field to
402
    `SyntheticTargetsRequest.SINGLE_REQUEST_FOR_ALL_TARGETS` so that we get a single request for all
403
    our synthetic targets rather than one request per directory.
404
    """
405

406
    path: str = SyntheticTargetsRequest.SINGLE_REQUEST_FOR_ALL_TARGETS
12✔
407

408

409
def synthetic_lockfile_target_name(resolve: str) -> str:
12✔
UNCOV
410
    return f"_{resolve}_lockfile"
×
411

412

413
@rule
12✔
414
async def python_lockfile_synthetic_targets(
12✔
415
    request: PythonSyntheticLockfileTargetsRequest,
416
    python_setup: PythonSetup,
417
) -> SyntheticAddressMaps:
UNCOV
418
    if not python_setup.enable_synthetic_lockfiles:
×
UNCOV
419
        return SyntheticAddressMaps()
×
420

UNCOV
421
    resolves = [
×
422
        (os.path.dirname(lockfile), os.path.basename(lockfile), name)
423
        for name, lockfile in python_setup.resolves.items()
424
    ]
425

UNCOV
426
    return SyntheticAddressMaps.for_targets_request(
×
427
        request,
428
        [
429
            (
430
                os.path.join(spec_path, "BUILD.python-lockfiles"),
431
                tuple(
432
                    TargetAdaptor(
433
                        "_lockfiles",
434
                        name=synthetic_lockfile_target_name(name),
435
                        sources=(lockfile,),
436
                        __description_of_origin__=f"the [python].resolves option {name!r}",
437
                    )
438
                    for _, lockfile, name in lockfiles
439
                ),
440
            )
441
            for spec_path, lockfiles in itertools.groupby(sorted(resolves), key=itemgetter(0))
442
        ],
443
    )
444

445

446
def rules():
12✔
447
    return (
11✔
448
        *collect_rules(),
449
        UnionRule(GenerateLockfile, GeneratePythonLockfile),
450
        UnionRule(KnownUserResolveNamesRequest, KnownPythonUserResolveNamesRequest),
451
        UnionRule(RequestedUserResolveNames, RequestedPythonUserResolveNames),
452
        UnionRule(SyntheticTargetsRequest, PythonSyntheticLockfileTargetsRequest),
453
    )
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