Skip to content

Commit 318fea7

Browse files
Use a list of requirement constraints for lockfile invalidation (#16469)
Improves upon #16420. @huonw wisely suggested we avoid using `hash` for constraints files because it makes Git diffs and merge conflicts much worse. python-poetry/poetry#496 rust-lang/cargo#7070 [ci skip-rust] [ci skip-build-wheels]
1 parent d5df1b1 commit 318fea7

File tree

10 files changed

+125
-108
lines changed

10 files changed

+125
-108
lines changed

src/python/pants/backend/python/goals/lockfile.py

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@
4848
)
4949
from pants.core.util_rules.lockfile_metadata import calculate_invalidation_digest
5050
from pants.engine.fs import CreateDigest, Digest, DigestContents, FileContent, MergeDigests
51-
from pants.engine.internals.native_engine import FileDigest
5251
from pants.engine.process import ProcessCacheScope, ProcessResult
5352
from pants.engine.rules import Get, MultiGet, collect_rules, rule, rule_helper
5453
from pants.engine.target import AllTargets
@@ -155,35 +154,41 @@ def warn_python_repos(option: str) -> None:
155154
return MaybeWarnPythonRepos()
156155

157156

157+
@dataclass(frozen=True)
158+
class _PipArgsAndConstraintsSetup:
159+
args: tuple[str, ...]
160+
digest: Digest
161+
constraints: FrozenOrderedSet[PipRequirement]
162+
163+
158164
@rule_helper
159165
async def _setup_pip_args_and_constraints_file(
160166
python_setup: PythonSetup, *, resolve_name: str
161-
) -> tuple[list[str], Digest, FileDigest | None]:
162-
extra_args = []
163-
extra_digests = []
164-
constraints_file_digest: None | FileDigest = None
167+
) -> _PipArgsAndConstraintsSetup:
168+
args = []
169+
digests = []
165170

166171
if python_setup.no_binary or python_setup.only_binary:
167172
pip_args_file = "__pip_args.txt"
168-
extra_args.extend(["-r", pip_args_file])
173+
args.extend(["-r", pip_args_file])
169174
pip_args_file_content = "\n".join(
170175
[f"--no-binary {pkg}" for pkg in python_setup.no_binary]
171176
+ [f"--only-binary {pkg}" for pkg in python_setup.only_binary]
172177
)
173178
pip_args_digest = await Get(
174179
Digest, CreateDigest([FileContent(pip_args_file, pip_args_file_content.encode())])
175180
)
176-
extra_digests.append(pip_args_digest)
181+
digests.append(pip_args_digest)
177182

183+
constraints: FrozenOrderedSet[PipRequirement] = FrozenOrderedSet()
178184
resolve_config = await Get(ResolvePexConfig, ResolvePexConfigRequest(resolve_name))
179185
if resolve_config.constraints_file:
180-
_constraints_file_entry = resolve_config.constraints_file[1]
181-
extra_args.append(f"--constraints={_constraints_file_entry.path}")
182-
constraints_file_digest = _constraints_file_entry.file_digest
183-
extra_digests.append(resolve_config.constraints_file[0])
186+
args.append(f"--constraints={resolve_config.constraints_file.path}")
187+
digests.append(resolve_config.constraints_file.digest)
188+
constraints = resolve_config.constraints_file.constraints
184189

185-
input_digest = await Get(Digest, MergeDigests(extra_digests))
186-
return extra_args, input_digest, constraints_file_digest
190+
input_digest = await Get(Digest, MergeDigests(digests))
191+
return _PipArgsAndConstraintsSetup(tuple(args), input_digest, constraints)
187192

188193

189194
@rule(desc="Generate Python lockfile", level=LogLevel.DEBUG)
@@ -194,16 +199,13 @@ async def generate_lockfile(
194199
python_repos: PythonRepos,
195200
python_setup: PythonSetup,
196201
) -> GenerateLockfileResult:
197-
constraints_file_hash: str | None = None
202+
requirement_constraints: FrozenOrderedSet[PipRequirement] = FrozenOrderedSet()
198203

199204
if req.use_pex:
200-
(
201-
extra_args,
202-
input_digest,
203-
constraints_file_digest,
204-
) = await _setup_pip_args_and_constraints_file(python_setup, resolve_name=req.resolve_name)
205-
if constraints_file_digest:
206-
constraints_file_hash = constraints_file_digest.fingerprint
205+
pip_args_setup = await _setup_pip_args_and_constraints_file(
206+
python_setup, resolve_name=req.resolve_name
207+
)
208+
requirement_constraints = pip_args_setup.constraints
207209

208210
header_delimiter = "//"
209211
result = await Get(
@@ -233,13 +235,13 @@ async def generate_lockfile(
233235
"mac",
234236
# This makes diffs more readable when lockfiles change.
235237
"--indent=2",
236-
*extra_args,
238+
*pip_args_setup.args,
237239
*python_repos.pex_args,
238240
*python_setup.manylinux_pex_args,
239241
*req.interpreter_constraints.generate_pex_arg_list(),
240242
*req.requirements,
241243
),
242-
additional_input_digest=input_digest,
244+
additional_input_digest=pip_args_setup.digest,
243245
output_files=("lock.json",),
244246
description=f"Generate lockfile for {req.resolve_name}",
245247
# Instead of caching lockfile generation with LMDB, we instead use the invalidation
@@ -306,7 +308,7 @@ async def generate_lockfile(
306308
metadata = PythonLockfileMetadata.new(
307309
valid_for_interpreter_constraints=req.interpreter_constraints,
308310
requirements={PipRequirement.parse(i) for i in req.requirements},
309-
constraints_file_hash=constraints_file_hash,
311+
requirement_constraints=set(requirement_constraints),
310312
)
311313
lockfile_with_header = metadata.add_header_to_lockfile(
312314
initial_lockfile_digest_contents[0].content,

src/python/pants/backend/python/goals/lockfile_test.py

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def _generate(
4444
rule_runner: RuleRunner,
4545
use_pex: bool,
4646
ansicolors_version: str = "==1.1.8",
47-
constraints_file_hash: str | None = None,
47+
requirement_constraints_str: str = '// "requirement_constraints": []',
4848
) -> str:
4949
result = rule_runner.request(
5050
GenerateLockfileResult,
@@ -64,24 +64,29 @@ def _generate(
6464
if not use_pex:
6565
return content
6666

67-
constraints_file_hash_str = f'"{constraints_file_hash}"' if constraints_file_hash else "null"
68-
pex_header = dedent(
69-
f"""\
70-
// This lockfile was autogenerated by Pants. To regenerate, run:
71-
//
72-
// ./pants generate-lockfiles --resolve=test
73-
//
74-
// --- BEGIN PANTS LOCKFILE METADATA: DO NOT EDIT OR REMOVE ---
75-
// {{
76-
// "version": 3,
77-
// "valid_for_interpreter_constraints": [],
78-
// "generated_with_requirements": [
79-
// "ansicolors{ansicolors_version}"
80-
// ],
81-
// "constraints_file_hash": {constraints_file_hash_str}
82-
// }}
83-
// --- END PANTS LOCKFILE METADATA ---
84-
"""
67+
pex_header = (
68+
dedent(
69+
f"""\
70+
// This lockfile was autogenerated by Pants. To regenerate, run:
71+
//
72+
// ./pants generate-lockfiles --resolve=test
73+
//
74+
// --- BEGIN PANTS LOCKFILE METADATA: DO NOT EDIT OR REMOVE ---
75+
// {{
76+
// "version": 3,
77+
// "valid_for_interpreter_constraints": [],
78+
// "generated_with_requirements": [
79+
// "ansicolors{ansicolors_version}"
80+
// ],
81+
"""
82+
)
83+
+ requirement_constraints_str
84+
+ dedent(
85+
"""
86+
// }
87+
// --- END PANTS LOCKFILE METADATA ---
88+
"""
89+
)
8590
)
8691
assert content.startswith(pex_header)
8792
return strip_prefix(content, pex_header)
@@ -167,8 +172,11 @@ def test_constraints_file(rule_runner: RuleRunner) -> None:
167172
rule_runner=rule_runner,
168173
use_pex=True,
169174
ansicolors_version=">=1.0",
170-
constraints_file_hash=(
171-
"1999760ce9dd0f82847def308992e3345592fc9e77a937c1e9bbb78a42ae3943"
175+
requirement_constraints_str=dedent(
176+
"""\
177+
// "requirement_constraints": [
178+
// "ansicolors==1.1.7"
179+
// ]"""
172180
),
173181
)
174182
)

src/python/pants/backend/python/pip_requirement.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,5 +82,8 @@ def __eq__(self, other):
8282
return False
8383
return self._req == other._req
8484

85+
def __repr__(self) -> str:
86+
return f"{self.__class__.__name__}({self._req})"
87+
8588
def __str__(self):
8689
return str(self._req)

src/python/pants/backend/python/util_rules/lockfile_metadata.py

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def new(
3939
*,
4040
valid_for_interpreter_constraints: InterpreterConstraints,
4141
requirements: set[PipRequirement],
42-
constraints_file_hash: str | None,
42+
requirement_constraints: set[PipRequirement],
4343
) -> PythonLockfileMetadata:
4444
"""Call the most recent version of the `LockfileMetadata` class to construct a concrete
4545
instance.
@@ -50,7 +50,7 @@ def new(
5050
"""
5151

5252
return PythonLockfileMetadataV3(
53-
valid_for_interpreter_constraints, requirements, constraints_file_hash
53+
valid_for_interpreter_constraints, requirements, requirement_constraints
5454
)
5555

5656
@classmethod
@@ -70,7 +70,7 @@ def is_valid_for(
7070
user_interpreter_constraints: InterpreterConstraints,
7171
interpreter_universe: Iterable[str],
7272
user_requirements: Iterable[PipRequirement],
73-
constraints_file_path_and_hash: tuple[str, str] | None,
73+
requirement_constraints: Iterable[PipRequirement],
7474
) -> LockfileMetadataValidation:
7575
"""Returns Truthy if this `PythonLockfileMetadata` can be used in the current execution
7676
context."""
@@ -114,7 +114,7 @@ def is_valid_for(
114114
interpreter_universe: Iterable[str],
115115
# Everything below is not used by v1.
116116
user_requirements: Iterable[PipRequirement],
117-
constraints_file_path_and_hash: tuple[str, str] | None,
117+
requirement_constraints: Iterable[PipRequirement],
118118
) -> LockfileMetadataValidation:
119119
failure_reasons: set[InvalidPythonLockfileReason] = set()
120120

@@ -168,13 +168,7 @@ def additional_header_attrs(cls, instance: LockfileMetadata) -> dict[Any, Any]:
168168
instance = cast(PythonLockfileMetadataV2, instance)
169169
# Requirements need to be stringified then sorted so that tests are deterministic. Sorting
170170
# followed by stringifying does not produce a meaningful result.
171-
return {
172-
"generated_with_requirements": (
173-
sorted(str(i) for i in instance.requirements)
174-
if instance.requirements is not None
175-
else None
176-
)
177-
}
171+
return {"generated_with_requirements": (sorted(str(i) for i in instance.requirements))}
178172

179173
def is_valid_for(
180174
self,
@@ -185,7 +179,7 @@ def is_valid_for(
185179
interpreter_universe: Iterable[str],
186180
user_requirements: Iterable[PipRequirement],
187181
# Everything below is not used by V2.
188-
constraints_file_path_and_hash: tuple[str, str] | None,
182+
requirement_constraints: Iterable[PipRequirement],
189183
) -> LockfileMetadataValidation:
190184
failure_reasons = set()
191185

@@ -210,7 +204,7 @@ def is_valid_for(
210204
class PythonLockfileMetadataV3(PythonLockfileMetadataV2):
211205
"""Lockfile version that considers constraints files."""
212206

213-
constraints_file_hash: str | None
207+
requirement_constraints: set[PipRequirement]
214208

215209
@classmethod
216210
def _from_json_dict(
@@ -221,19 +215,23 @@ def _from_json_dict(
221215
) -> PythonLockfileMetadataV3:
222216
v2_metadata = super()._from_json_dict(json_dict, lockfile_description, error_suffix)
223217
metadata = _get_metadata(json_dict, lockfile_description, error_suffix)
224-
constraints_file_hash = metadata(
225-
"constraints_file_hash", str, lambda x: x # type: ignore[no-any-return]
218+
requirement_constraints = metadata(
219+
"requirement_constraints",
220+
Set[PipRequirement],
221+
lambda l: {PipRequirement.parse(i) for i in l},
226222
)
227223
return PythonLockfileMetadataV3(
228224
valid_for_interpreter_constraints=v2_metadata.valid_for_interpreter_constraints,
229225
requirements=v2_metadata.requirements,
230-
constraints_file_hash=constraints_file_hash,
226+
requirement_constraints=requirement_constraints,
231227
)
232228

233229
@classmethod
234230
def additional_header_attrs(cls, instance: LockfileMetadata) -> dict[Any, Any]:
235231
instance = cast(PythonLockfileMetadataV3, instance)
236-
return {"constraints_file_hash": instance.constraints_file_hash}
232+
return {
233+
"requirement_constraints": (sorted(str(i) for i in instance.requirement_constraints))
234+
}
237235

238236
def is_valid_for(
239237
self,
@@ -243,7 +241,7 @@ def is_valid_for(
243241
user_interpreter_constraints: InterpreterConstraints,
244242
interpreter_universe: Iterable[str],
245243
user_requirements: Iterable[PipRequirement],
246-
constraints_file_path_and_hash: tuple[str, str] | None,
244+
requirement_constraints: Iterable[PipRequirement],
247245
) -> LockfileMetadataValidation:
248246
failure_reasons = (
249247
super()
@@ -253,14 +251,11 @@ def is_valid_for(
253251
user_interpreter_constraints=user_interpreter_constraints,
254252
interpreter_universe=interpreter_universe,
255253
user_requirements=user_requirements,
256-
constraints_file_path_and_hash=constraints_file_path_and_hash,
254+
requirement_constraints=requirement_constraints,
257255
)
258256
.failure_reasons
259257
)
260258

261-
provided_constraints_file_hash = (
262-
constraints_file_path_and_hash[1] if constraints_file_path_and_hash else None
263-
)
264-
if provided_constraints_file_hash != self.constraints_file_hash:
259+
if self.requirement_constraints != set(requirement_constraints):
265260
failure_reasons.add(InvalidPythonLockfileReason.CONSTRAINTS_FILE_MISMATCH)
266261
return LockfileMetadataValidation(failure_reasons)

src/python/pants/backend/python/util_rules/lockfile_metadata_test.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def test_metadata_header_round_trip() -> None:
3232
["CPython==2.7.*", "PyPy", "CPython>=3.6,<4,!=3.7.*"]
3333
),
3434
requirements=reqset("ansicolors==0.1.0"),
35-
constraints_file_hash="abc",
35+
requirement_constraints={PipRequirement.parse("constraint")},
3636
)
3737
serialized_lockfile = input_metadata.add_header_to_lockfile(
3838
b"req1==1.0", regenerate_command="./pants lock", delimeter="#"
@@ -62,7 +62,7 @@ def test_add_header_to_lockfile() -> None:
6262
# "generated_with_requirements": [
6363
# "ansicolors==0.1.0"
6464
# ],
65-
# "constraints_file_hash": null
65+
# "requirement_constraints": []
6666
# }
6767
# --- END PANTS LOCKFILE METADATA ---
6868
dave==3.1.4 \\
@@ -75,7 +75,7 @@ def line_by_line(b: bytes) -> list[bytes]:
7575
metadata = PythonLockfileMetadata.new(
7676
valid_for_interpreter_constraints=InterpreterConstraints([">=3.7"]),
7777
requirements=reqset("ansicolors==0.1.0"),
78-
constraints_file_hash=None,
78+
requirement_constraints=set(),
7979
)
8080
result = metadata.add_header_to_lockfile(
8181
input_lockfile, regenerate_command="./pants lock", delimeter="#"
@@ -158,7 +158,7 @@ def test_is_valid_for_v1(user_digest, expected_digest, user_ic, expected_ic, mat
158158
user_interpreter_constraints=InterpreterConstraints(user_ic),
159159
interpreter_universe=INTERPRETER_UNIVERSE,
160160
user_requirements=set(),
161-
constraints_file_path_and_hash=None,
161+
requirement_constraints=set(),
162162
)
163163
)
164164
== matches
@@ -232,7 +232,7 @@ def test_is_valid_for_interpreter_constraints_and_requirements(
232232
for m in [
233233
PythonLockfileMetadataV2(InterpreterConstraints(lock_ics), reqset(*lock_reqs)),
234234
PythonLockfileMetadataV3(
235-
InterpreterConstraints(lock_ics), reqset(*lock_reqs), constraints_file_hash=None
235+
InterpreterConstraints(lock_ics), reqset(*lock_reqs), requirement_constraints=set()
236236
),
237237
]:
238238
result = m.is_valid_for(
@@ -241,21 +241,21 @@ def test_is_valid_for_interpreter_constraints_and_requirements(
241241
user_interpreter_constraints=InterpreterConstraints(user_ics),
242242
interpreter_universe=INTERPRETER_UNIVERSE,
243243
user_requirements=reqset(*user_reqs),
244-
constraints_file_path_and_hash=None,
244+
requirement_constraints=set(),
245245
)
246246
assert result.failure_reasons == set(expected)
247247

248248

249249
@pytest.mark.parametrize("is_tool", [True, False])
250-
def test_is_valid_for_constraints_file_hash(is_tool: bool) -> None:
250+
def test_is_valid_for_requirement_constraints(is_tool: bool) -> None:
251251
result = PythonLockfileMetadataV3(
252-
InterpreterConstraints([]), reqset(), constraints_file_hash="abc"
252+
InterpreterConstraints([]), reqset(), requirement_constraints={PipRequirement.parse("c1")}
253253
).is_valid_for(
254254
is_tool=is_tool,
255255
expected_invalidation_digest="",
256256
user_interpreter_constraints=InterpreterConstraints([]),
257257
interpreter_universe=INTERPRETER_UNIVERSE,
258258
user_requirements=reqset(),
259-
constraints_file_path_and_hash=("c.txt", "xyz"),
259+
requirement_constraints={PipRequirement.parse("c2")},
260260
)
261261
assert result.failure_reasons == {InvalidPythonLockfileReason.CONSTRAINTS_FILE_MISMATCH}

0 commit comments

Comments
 (0)