Skip to content

Commit 6b2c302

Browse files
authored
Print diff report when generating Python lockfiles. (#17347)
Enable with `--diff` (or `--diff-include-unchanged`). Only support for Python/PEX lockfiles currently implemented.
1 parent 7117bad commit 6b2c302

15 files changed

+681
-20
lines changed

pants.toml

+1
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ template_by_globs = "@build-support/preambles/config.yaml"
212212

213213
[generate-lockfiles]
214214
custom_command = "build-support/bin/generate_all_lockfiles.sh"
215+
diff = true
215216

216217
[jvm]
217218
default_resolve = "jvm_testprojects"

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

+13-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from pants.backend.python.subsystems.setup import PythonSetup
1616
from pants.backend.python.target_types import PythonRequirementResolveField, PythonRequirementsField
1717
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
18+
from pants.backend.python.util_rules.lockfile_diff import _generate_python_lockfile_diff
1819
from pants.backend.python.util_rules.lockfile_metadata import PythonLockfileMetadata
1920
from pants.backend.python.util_rules.pex_cli import PexCliProcess
2021
from pants.backend.python.util_rules.pex_requirements import ( # noqa: F401
@@ -72,6 +73,7 @@ def from_tool(
7273
interpreter_constraints=InterpreterConstraints(),
7374
resolve_name=subsystem.options_scope,
7475
lockfile_dest=subsystem.lockfile,
76+
diff=False,
7577
)
7678
return cls(
7779
requirements=FrozenOrderedSet((*subsystem.all_requirements, *extra_requirements)),
@@ -82,6 +84,7 @@ def from_tool(
8284
),
8385
resolve_name=subsystem.options_scope,
8486
lockfile_dest=subsystem.lockfile,
87+
diff=False,
8588
)
8689

8790
@property
@@ -218,7 +221,15 @@ async def generate_lockfile(
218221
final_lockfile_digest = await Get(
219222
Digest, CreateDigest([FileContent(req.lockfile_dest, lockfile_with_header)])
220223
)
221-
return GenerateLockfileResult(final_lockfile_digest, req.resolve_name, req.lockfile_dest)
224+
225+
if req.diff:
226+
diff = await _generate_python_lockfile_diff(
227+
final_lockfile_digest, req.resolve_name, req.lockfile_dest
228+
)
229+
else:
230+
diff = None
231+
232+
return GenerateLockfileResult(final_lockfile_digest, req.resolve_name, req.lockfile_dest, diff)
222233

223234

224235
class RequestedPythonUserResolveNames(RequestedUserResolveNames):
@@ -266,6 +277,7 @@ async def setup_user_lockfile_requests(
266277
),
267278
resolve_name=resolve,
268279
lockfile_dest=python_setup.resolves[resolve],
280+
diff=False,
269281
)
270282
for resolve in requested
271283
)

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

+3
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def _generate(
5454
interpreter_constraints=InterpreterConstraints(),
5555
resolve_name="test",
5656
lockfile_dest="test.lock",
57+
diff=False,
5758
)
5859
],
5960
)
@@ -240,11 +241,13 @@ def test_multiple_resolves() -> None:
240241
),
241242
resolve_name="a",
242243
lockfile_dest="a.lock",
244+
diff=False,
243245
),
244246
GeneratePythonLockfile(
245247
requirements=FrozenOrderedSet(["b"]),
246248
interpreter_constraints=InterpreterConstraints(["==3.7.*"]),
247249
resolve_name="b",
248250
lockfile_dest="b.lock",
251+
diff=False,
249252
),
250253
}

src/python/pants/backend/python/typecheck/mypy/subsystem.py

+2
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@ async def setup_mypy_extra_type_stubs_lockfile(
402402
interpreter_constraints=InterpreterConstraints(),
403403
resolve_name=request.resolve_name,
404404
lockfile_dest=mypy.extra_type_stubs_lockfile,
405+
diff=False,
405406
)
406407

407408
# While MyPy will run in partitions, we need a set of constraints that works with every
@@ -427,6 +428,7 @@ async def setup_mypy_extra_type_stubs_lockfile(
427428
interpreter_constraints=interpreter_constraints,
428429
resolve_name=request.resolve_name,
429430
lockfile_dest=mypy.extra_type_stubs_lockfile,
431+
diff=False,
430432
)
431433

432434

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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
5+
6+
import itertools
7+
import json
8+
import logging
9+
from dataclasses import dataclass
10+
from typing import TYPE_CHECKING, Any, Mapping
11+
12+
from packaging.version import parse
13+
14+
if TYPE_CHECKING:
15+
# We seem to get a version of `packaging` that doesn't have `LegacyVersion` when running
16+
# pytest..
17+
from packaging.version import LegacyVersion, Version
18+
19+
from pants.backend.python.util_rules.pex_requirements import (
20+
LoadedLockfile,
21+
LoadedLockfileRequest,
22+
Lockfile,
23+
LockfileContent,
24+
)
25+
from pants.base.exceptions import EngineError
26+
from pants.core.goals.generate_lockfiles import LockfileDiff, LockfilePackages, PackageName
27+
from pants.engine.fs import Digest, DigestContents
28+
from pants.engine.rules import Get, rule_helper
29+
from pants.util.frozendict import FrozenDict
30+
31+
logger = logging.getLogger(__name__)
32+
33+
34+
@dataclass(frozen=True, order=True)
35+
class PythonRequirementVersion:
36+
_parsed: LegacyVersion | Version
37+
38+
@classmethod
39+
def parse(cls, version: str) -> PythonRequirementVersion:
40+
return cls(parse(version))
41+
42+
def __str__(self) -> str:
43+
return str(self._parsed)
44+
45+
def __getattr__(self, key: str) -> Any:
46+
return getattr(self._parsed, key)
47+
48+
49+
def _pex_lockfile_requirements(
50+
lockfile_data: Mapping[str, Any] | None, path: str | None = None
51+
) -> LockfilePackages:
52+
if not lockfile_data:
53+
return LockfilePackages({})
54+
55+
try:
56+
# Setup generators
57+
locked_resolves = (
58+
(
59+
(PackageName(r["project_name"]), PythonRequirementVersion.parse(r["version"]))
60+
for r in resolve["locked_requirements"]
61+
)
62+
for resolve in lockfile_data["locked_resolves"]
63+
)
64+
requirements = dict(itertools.chain.from_iterable(locked_resolves))
65+
except KeyError as e:
66+
if path:
67+
logger.warning(f"{path}: Failed to parse lockfile: {e}")
68+
69+
requirements = {}
70+
71+
return LockfilePackages(requirements)
72+
73+
74+
@rule_helper
75+
async def _parse_lockfile(lockfile: Lockfile | LockfileContent) -> FrozenDict[str, Any] | None:
76+
try:
77+
loaded = await Get(
78+
LoadedLockfile,
79+
LoadedLockfileRequest(lockfile),
80+
)
81+
fc = await Get(DigestContents, Digest, loaded.lockfile_digest)
82+
parsed_lockfile = json.loads(fc[0].content)
83+
return FrozenDict.deep_freeze(parsed_lockfile)
84+
except EngineError:
85+
# May fail in case the file doesn't exist, which is expected when parsing the "old" lockfile
86+
# the first time a new lockfile is generated.
87+
return None
88+
except json.JSONDecodeError as e:
89+
file_path = (
90+
lockfile.file_path if isinstance(lockfile, Lockfile) else lockfile.file_content.path
91+
)
92+
logger.debug(f"{file_path}: Failed to parse lockfile contents: {e}")
93+
return None
94+
95+
96+
@rule_helper
97+
async def _generate_python_lockfile_diff(
98+
digest: Digest, resolve_name: str, path: str
99+
) -> LockfileDiff:
100+
new_content = await Get(DigestContents, Digest, digest)
101+
new = await _parse_lockfile(
102+
LockfileContent(
103+
file_content=next(c for c in new_content if c.path == path),
104+
resolve_name=resolve_name,
105+
)
106+
)
107+
old = await _parse_lockfile(
108+
Lockfile(
109+
file_path=path,
110+
file_path_description_of_origin="generated lockfile",
111+
resolve_name=resolve_name,
112+
)
113+
)
114+
return LockfileDiff.create(
115+
path=path,
116+
resolve_name=resolve_name,
117+
old=_pex_lockfile_requirements(old),
118+
new=_pex_lockfile_requirements(new, path),
119+
)

0 commit comments

Comments
 (0)