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

pantsbuild / pants / 18143316655

30 Sep 2025 09:00PM UTC coverage: 80.263% (-0.01%) from 80.275%
18143316655

push

github

web-flow
Write Python lockfile metadata to separate files (#22713)

Currently we tack it on as a header to the lockfile, which
makes the lockfile unusable when working directly with Pex
without first manually editing it to remove the header.

Instead, we now (optionally) write to a separate metadata
sibling file. 

We always unconditionally try and read the metadata file,
falling back to the header if it doesn't exist. This will
allow us to regenerate the embedded lockfiles without
worrying about whether the user has the new metadata
files enabled.

42 of 87 new or added lines in 7 files covered. (48.28%)

1 existing line in 1 file now uncovered.

77226 of 96216 relevant lines covered (80.26%)

3.37 hits per line

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

96.24
/src/python/pants/backend/python/util_rules/lockfile_metadata.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
from collections.abc import Iterable
12✔
7
from dataclasses import dataclass
12✔
8
from enum import Enum
12✔
9
from typing import Any, cast
12✔
10

11
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
12✔
12
from pants.core.util_rules.lockfile_metadata import (
12✔
13
    LockfileMetadata,
14
    LockfileMetadataValidation,
15
    LockfileScope,
16
    _get_metadata,
17
    lockfile_metadata_registrar,
18
)
19
from pants.util.pip_requirement import PipRequirement
12✔
20

21
_python_lockfile_metadata = lockfile_metadata_registrar(LockfileScope.PYTHON)
12✔
22

23

24
class InvalidPythonLockfileReason(Enum):
12✔
25
    INVALIDATION_DIGEST_MISMATCH = "invalidation_digest_mismatch"
12✔
26
    INTERPRETER_CONSTRAINTS_MISMATCH = "interpreter_constraints_mismatch"
12✔
27
    REQUIREMENTS_MISMATCH = "requirements_mismatch"
12✔
28
    MANYLINUX_MISMATCH = "manylinux_mismatch"
12✔
29
    CONSTRAINTS_FILE_MISMATCH = "constraints_file_mismatch"
12✔
30
    ONLY_BINARY_MISMATCH = "only_binary_mismatch"
12✔
31
    NO_BINARY_MISMATCH = "no_binary_mismatch"
12✔
32
    EXCLUDES_MISMATCH = "excludes_mismatch"
12✔
33
    OVERRIDES_MISMATCH = "overrides_mismatch"
12✔
34

35

36
@dataclass(frozen=True)
12✔
37
class PythonLockfileMetadata(LockfileMetadata):
12✔
38
    scope = LockfileScope.PYTHON
12✔
39

40
    valid_for_interpreter_constraints: InterpreterConstraints
12✔
41

42
    @staticmethod
12✔
43
    def new(
12✔
44
        *,
45
        valid_for_interpreter_constraints: InterpreterConstraints,
46
        requirements: set[PipRequirement],
47
        manylinux: str | None,
48
        requirement_constraints: set[PipRequirement],
49
        only_binary: set[str],
50
        no_binary: set[str],
51
        excludes: set[str],
52
        overrides: set[str],
53
    ) -> PythonLockfileMetadata:
54
        """Call the most recent version of the `LockfileMetadata` class to construct a concrete
55
        instance.
56

57
        This static method should be used in place of the `LockfileMetadata` constructor. This gives
58
        calling sites a predictable method to call to construct a new `LockfileMetadata` for
59
        writing, while still allowing us to support _reading_ older, deprecated metadata versions.
60
        """
61

62
        return PythonLockfileMetadataV4(
2✔
63
            valid_for_interpreter_constraints,
64
            requirements,
65
            manylinux=manylinux,
66
            requirement_constraints=requirement_constraints,
67
            only_binary=only_binary,
68
            no_binary=no_binary,
69
            excludes=excludes,
70
            overrides=overrides,
71
        )
72

73
    @staticmethod
12✔
74
    def metadata_location_for_lockfile(lockfile_location: str) -> str:
12✔
NEW
75
        return f"{lockfile_location}.metadata"
×
76

77
    @classmethod
12✔
78
    def additional_header_attrs(cls, instance: LockfileMetadata) -> dict[Any, Any]:
12✔
79
        instance = cast(PythonLockfileMetadata, instance)
2✔
80
        return {
2✔
81
            "valid_for_interpreter_constraints": [
82
                str(ic) for ic in instance.valid_for_interpreter_constraints
83
            ]
84
        }
85

86
    def is_valid_for(
12✔
87
        self,
88
        *,
89
        expected_invalidation_digest: str | None,
90
        user_interpreter_constraints: InterpreterConstraints,
91
        interpreter_universe: Iterable[str],
92
        user_requirements: Iterable[PipRequirement],
93
        manylinux: str | None,
94
        requirement_constraints: Iterable[PipRequirement],
95
        only_binary: Iterable[str],
96
        no_binary: Iterable[str],
97
        excludes: Iterable[str],
98
        overrides: Iterable[str],
99
    ) -> LockfileMetadataValidation:
100
        """Returns Truthy if this `PythonLockfileMetadata` can be used in the current execution
101
        context."""
102

103
        raise NotImplementedError("call `is_valid_for` on subclasses only")
×
104

105

106
@_python_lockfile_metadata(1)
12✔
107
@dataclass(frozen=True)
12✔
108
class PythonLockfileMetadataV1(PythonLockfileMetadata):
12✔
109
    requirements_invalidation_digest: str
12✔
110

111
    @classmethod
12✔
112
    def _from_json_dict(
12✔
113
        cls: type[PythonLockfileMetadataV1],
114
        json_dict: dict[Any, Any],
115
        lockfile_description: str,
116
        error_suffix: str,
117
    ) -> PythonLockfileMetadataV1:
118
        metadata = _get_metadata(json_dict, lockfile_description, error_suffix)
1✔
119

120
        interpreter_constraints = metadata(
1✔
121
            "valid_for_interpreter_constraints", InterpreterConstraints, InterpreterConstraints
122
        )
123
        requirements_digest = metadata("requirements_invalidation_digest", str, None)
1✔
124

125
        return PythonLockfileMetadataV1(interpreter_constraints, requirements_digest)
1✔
126

127
    @classmethod
12✔
128
    def additional_header_attrs(cls, instance: LockfileMetadata) -> dict[Any, Any]:
12✔
129
        instance = cast(PythonLockfileMetadataV1, instance)
×
130
        return {"requirements_invalidation_digest": instance.requirements_invalidation_digest}
×
131

132
    def is_valid_for(
12✔
133
        self,
134
        *,
135
        expected_invalidation_digest: str | None,
136
        user_interpreter_constraints: InterpreterConstraints,
137
        interpreter_universe: Iterable[str],
138
        # Everything below is not used by v1.
139
        user_requirements: Iterable[PipRequirement],
140
        manylinux: str | None,
141
        requirement_constraints: Iterable[PipRequirement],
142
        only_binary: Iterable[str],
143
        no_binary: Iterable[str],
144
        excludes: Iterable[str],
145
        overrides: Iterable[str],
146
    ) -> LockfileMetadataValidation:
147
        failure_reasons: set[InvalidPythonLockfileReason] = set()
1✔
148

149
        if expected_invalidation_digest is None:
1✔
150
            return LockfileMetadataValidation(failure_reasons)
×
151

152
        if self.requirements_invalidation_digest != expected_invalidation_digest:
1✔
153
            failure_reasons.add(InvalidPythonLockfileReason.INVALIDATION_DIGEST_MISMATCH)
1✔
154

155
        if not self.valid_for_interpreter_constraints.contains(
1✔
156
            user_interpreter_constraints, interpreter_universe
157
        ):
158
            failure_reasons.add(InvalidPythonLockfileReason.INTERPRETER_CONSTRAINTS_MISMATCH)
1✔
159

160
        return LockfileMetadataValidation(failure_reasons)
1✔
161

162

163
@_python_lockfile_metadata(2)
12✔
164
@dataclass(frozen=True)
12✔
165
class PythonLockfileMetadataV2(PythonLockfileMetadata):
12✔
166
    """Lockfile version that permits specifying a requirements as a set rather than a digest.
167

168
    Validity is tested by the set of requirements strings being the same in the user requirements as
169
    those in the stored requirements.
170
    """
171

172
    requirements: set[PipRequirement]
12✔
173

174
    @classmethod
12✔
175
    def _from_json_dict(
12✔
176
        cls: type[PythonLockfileMetadataV2],
177
        json_dict: dict[Any, Any],
178
        lockfile_description: str,
179
        error_suffix: str,
180
    ) -> PythonLockfileMetadataV2:
181
        metadata = _get_metadata(json_dict, lockfile_description, error_suffix)
1✔
182

183
        requirements = metadata(
1✔
184
            "generated_with_requirements",
185
            set[PipRequirement],
186
            lambda l: {
187
                PipRequirement.parse(i, description_of_origin=lockfile_description) for i in l
188
            },
189
        )
190
        interpreter_constraints = metadata(
1✔
191
            "valid_for_interpreter_constraints", InterpreterConstraints, InterpreterConstraints
192
        )
193

194
        return PythonLockfileMetadataV2(interpreter_constraints, requirements)
1✔
195

196
    @classmethod
12✔
197
    def additional_header_attrs(cls, instance: LockfileMetadata) -> dict[Any, Any]:
12✔
198
        instance = cast(PythonLockfileMetadataV2, instance)
2✔
199
        # Requirements need to be stringified then sorted so that tests are deterministic. Sorting
200
        # followed by stringifying does not produce a meaningful result.
201
        return {"generated_with_requirements": (sorted(str(i) for i in instance.requirements))}
2✔
202

203
    def is_valid_for(
12✔
204
        self,
205
        *,
206
        expected_invalidation_digest: str | None,  # Not used by V2.
207
        user_interpreter_constraints: InterpreterConstraints,
208
        interpreter_universe: Iterable[str],
209
        user_requirements: Iterable[PipRequirement],
210
        # Everything below is not used by V2.
211
        manylinux: str | None,
212
        requirement_constraints: Iterable[PipRequirement],
213
        only_binary: Iterable[str],
214
        no_binary: Iterable[str],
215
        excludes: Iterable[str],
216
        overrides: Iterable[str],
217
    ) -> LockfileMetadataValidation:
218
        failure_reasons = set()
2✔
219
        if not set(user_requirements).issubset(self.requirements):
2✔
220
            failure_reasons.add(InvalidPythonLockfileReason.REQUIREMENTS_MISMATCH)
2✔
221

222
        if not self.valid_for_interpreter_constraints.contains(
2✔
223
            user_interpreter_constraints, interpreter_universe
224
        ):
225
            failure_reasons.add(InvalidPythonLockfileReason.INTERPRETER_CONSTRAINTS_MISMATCH)
2✔
226

227
        return LockfileMetadataValidation(failure_reasons)
2✔
228

229

230
@_python_lockfile_metadata(3)
12✔
231
@dataclass(frozen=True)
12✔
232
class PythonLockfileMetadataV3(PythonLockfileMetadataV2):
12✔
233
    """Lockfile version that considers constraints files."""
234

235
    manylinux: str | None
12✔
236
    requirement_constraints: set[PipRequirement]
12✔
237
    only_binary: set[str]
12✔
238
    no_binary: set[str]
12✔
239

240
    @classmethod
12✔
241
    def _from_json_dict(
12✔
242
        cls: type[PythonLockfileMetadataV3],
243
        json_dict: dict[Any, Any],
244
        lockfile_description: str,
245
        error_suffix: str,
246
    ) -> PythonLockfileMetadataV3:
247
        v2_metadata = super()._from_json_dict(json_dict, lockfile_description, error_suffix)
1✔
248
        metadata = _get_metadata(json_dict, lockfile_description, error_suffix)
1✔
249
        manylinux = metadata("manylinux", str, lambda l: l)
1✔
250
        requirement_constraints = metadata(
1✔
251
            "requirement_constraints",
252
            set[PipRequirement],
253
            lambda l: {
254
                PipRequirement.parse(i, description_of_origin=lockfile_description) for i in l
255
            },
256
        )
257
        only_binary = metadata("only_binary", set[str], lambda l: set(l))
1✔
258
        no_binary = metadata("no_binary", set[str], lambda l: set(l))
1✔
259

260
        return PythonLockfileMetadataV3(
1✔
261
            valid_for_interpreter_constraints=v2_metadata.valid_for_interpreter_constraints,
262
            requirements=v2_metadata.requirements,
263
            manylinux=manylinux,
264
            requirement_constraints=requirement_constraints,
265
            only_binary=only_binary,
266
            no_binary=no_binary,
267
        )
268

269
    @classmethod
12✔
270
    def additional_header_attrs(cls, instance: LockfileMetadata) -> dict[Any, Any]:
12✔
271
        instance = cast(PythonLockfileMetadataV3, instance)
2✔
272
        return {
2✔
273
            "manylinux": instance.manylinux,
274
            "requirement_constraints": sorted(str(i) for i in instance.requirement_constraints),
275
            "only_binary": sorted(instance.only_binary),
276
            "no_binary": sorted(instance.no_binary),
277
        }
278

279
    def is_valid_for(
12✔
280
        self,
281
        *,
282
        expected_invalidation_digest: str | None,  # Validation digests are not used by V2.
283
        user_interpreter_constraints: InterpreterConstraints,
284
        interpreter_universe: Iterable[str],
285
        user_requirements: Iterable[PipRequirement],
286
        manylinux: str | None,
287
        requirement_constraints: Iterable[PipRequirement],
288
        only_binary: Iterable[str],
289
        no_binary: Iterable[str],
290
        # not used for V3
291
        excludes: Iterable[str],
292
        overrides: Iterable[str],
293
    ) -> LockfileMetadataValidation:
294
        failure_reasons = (
2✔
295
            super()
296
            .is_valid_for(
297
                expected_invalidation_digest=expected_invalidation_digest,
298
                user_interpreter_constraints=user_interpreter_constraints,
299
                interpreter_universe=interpreter_universe,
300
                user_requirements=user_requirements,
301
                manylinux=manylinux,
302
                requirement_constraints=requirement_constraints,
303
                only_binary=only_binary,
304
                no_binary=no_binary,
305
                excludes=excludes,
306
                overrides=overrides,
307
            )
308
            .failure_reasons
309
        )
310

311
        if self.manylinux != manylinux:
2✔
312
            failure_reasons.add(InvalidPythonLockfileReason.MANYLINUX_MISMATCH)
2✔
313
        if self.requirement_constraints != set(requirement_constraints):
2✔
314
            failure_reasons.add(InvalidPythonLockfileReason.CONSTRAINTS_FILE_MISMATCH)
2✔
315
        if self.only_binary != set(only_binary):
2✔
316
            failure_reasons.add(InvalidPythonLockfileReason.ONLY_BINARY_MISMATCH)
2✔
317
        if self.no_binary != set(no_binary):
2✔
318
            failure_reasons.add(InvalidPythonLockfileReason.NO_BINARY_MISMATCH)
2✔
319

320
        return LockfileMetadataValidation(failure_reasons)
2✔
321

322

323
@_python_lockfile_metadata(4)
12✔
324
@dataclass(frozen=True)
12✔
325
class PythonLockfileMetadataV4(PythonLockfileMetadataV3):
12✔
326
    """Lockfile version with excludes/overrides."""
327

328
    excludes: set[str]
12✔
329
    overrides: set[str]
12✔
330

331
    @classmethod
12✔
332
    def _from_json_dict(
12✔
333
        cls: type[PythonLockfileMetadataV4],
334
        json_dict: dict[Any, Any],
335
        lockfile_description: str,
336
        error_suffix: str,
337
    ) -> PythonLockfileMetadataV4:
338
        v3_metadata = super()._from_json_dict(json_dict, lockfile_description, error_suffix)
1✔
339
        metadata = _get_metadata(json_dict, lockfile_description, error_suffix)
1✔
340

341
        excludes = metadata("excludes", set[str], lambda l: set(l))
1✔
342
        overrides = metadata("overrides", set[str], lambda l: set(l))
1✔
343

344
        return PythonLockfileMetadataV4(
1✔
345
            valid_for_interpreter_constraints=v3_metadata.valid_for_interpreter_constraints,
346
            requirements=v3_metadata.requirements,
347
            manylinux=v3_metadata.manylinux,
348
            requirement_constraints=v3_metadata.requirement_constraints,
349
            only_binary=v3_metadata.only_binary,
350
            no_binary=v3_metadata.no_binary,
351
            excludes=excludes,
352
            overrides=overrides,
353
        )
354

355
    @classmethod
12✔
356
    def additional_header_attrs(cls, instance: LockfileMetadata) -> dict[Any, Any]:
12✔
357
        instance = cast(PythonLockfileMetadataV4, instance)
2✔
358
        return {
2✔
359
            "excludes": sorted(instance.excludes),
360
            "overrides": sorted(instance.overrides),
361
        }
362

363
    def is_valid_for(
12✔
364
        self,
365
        *,
366
        expected_invalidation_digest: str | None,
367
        user_interpreter_constraints: InterpreterConstraints,
368
        interpreter_universe: Iterable[str],
369
        user_requirements: Iterable[PipRequirement],
370
        manylinux: str | None,
371
        requirement_constraints: Iterable[PipRequirement],
372
        only_binary: Iterable[str],
373
        no_binary: Iterable[str],
374
        excludes: Iterable[str],
375
        overrides: Iterable[str],
376
    ) -> LockfileMetadataValidation:
377
        failure_reasons = (
1✔
378
            super()
379
            .is_valid_for(
380
                expected_invalidation_digest=expected_invalidation_digest,
381
                user_interpreter_constraints=user_interpreter_constraints,
382
                interpreter_universe=interpreter_universe,
383
                user_requirements=user_requirements,
384
                manylinux=manylinux,
385
                requirement_constraints=requirement_constraints,
386
                only_binary=only_binary,
387
                no_binary=no_binary,
388
                excludes=excludes,
389
                overrides=overrides,
390
            )
391
            .failure_reasons
392
        )
393

394
        if self.excludes != set(excludes):
1✔
395
            failure_reasons.add(InvalidPythonLockfileReason.EXCLUDES_MISMATCH)
1✔
396
        if self.overrides != set(overrides):
1✔
397
            failure_reasons.add(InvalidPythonLockfileReason.OVERRIDES_MISMATCH)
1✔
398

399
        return LockfileMetadataValidation(failure_reasons)
1✔
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