uv-lock-report 0.12.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ """
2
+ uv-lock-report: Parses uv.lock changes and generates Markdown reports.
3
+ """
4
+
5
+ from uv_lock_report.cli import main as cli_main
6
+ from uv_lock_report.report import report
7
+ from uv_lock_report.models import (
8
+ LockFile,
9
+ UvLockFile,
10
+ LockFileReporter,
11
+ LockfileChanges,
12
+ LockfilePackage,
13
+ UpdatedPackage,
14
+ OutputFormat,
15
+ )
16
+
17
+ __all__ = [
18
+ "cli_main",
19
+ "report",
20
+ "LockFile",
21
+ "UvLockFile",
22
+ "LockFileReporter",
23
+ "LockfileChanges",
24
+ "LockfilePackage",
25
+ "UpdatedPackage",
26
+ "OutputFormat",
27
+ ]
28
+
29
+ try:
30
+ from uv_lock_report._version import __version__
31
+ except ImportError: # pragma: no cover
32
+ __version__ = "0.0.0"
@@ -0,0 +1,11 @@
1
+ """
2
+ Entry point for running uv_lock_report as a module.
3
+
4
+ This allows the package to be executed with:
5
+ python -m uv_lock_report
6
+ """
7
+
8
+ from uv_lock_report.cli import main
9
+
10
+ if __name__ == "__main__":
11
+ main()
uv_lock_report/cli.py ADDED
@@ -0,0 +1,41 @@
1
+ from argparse import ArgumentParser, Namespace
2
+
3
+ from uv_lock_report.models import OutputFormat
4
+ from uv_lock_report.report import report
5
+
6
+
7
+ def parse_args() -> Namespace:
8
+ parser = ArgumentParser()
9
+ parser.add_argument("--base-sha", required=True)
10
+ parser.add_argument("--base-path", required=True)
11
+ parser.add_argument("--output-path", required=True)
12
+ parser.add_argument(
13
+ "--output-format",
14
+ choices=list(OutputFormat),
15
+ default=OutputFormat.TABLE.value,
16
+ required=False,
17
+ )
18
+ parser.add_argument(
19
+ "--show-learn-more-link",
20
+ choices=["true", "false"],
21
+ default="true",
22
+ required=False,
23
+ help='Whether to show a "Learn More" link in the report comment.',
24
+ )
25
+ return parser.parse_args()
26
+
27
+
28
+ def main():
29
+ args = parse_args()
30
+ print(args)
31
+ base_sha = args.base_sha
32
+ base_path = args.base_path
33
+ output_path = args.output_path
34
+ output_format = OutputFormat(args.output_format)
35
+ report(
36
+ base_sha=base_sha,
37
+ base_path=base_path,
38
+ output_path=output_path,
39
+ output_format=output_format,
40
+ show_learn_more_link=args.show_learn_more_link == "true",
41
+ )
@@ -0,0 +1,430 @@
1
+ import re
2
+ import tomllib
3
+ from enum import IntEnum, StrEnum, auto
4
+ from functools import cached_property
5
+
6
+ from packaging.version import parse
7
+ from pydantic import (
8
+ BaseModel,
9
+ ConfigDict,
10
+ Field,
11
+ computed_field,
12
+ )
13
+
14
+
15
+ class OutputFormat(StrEnum):
16
+ TABLE = auto()
17
+ SIMPLE = auto()
18
+
19
+
20
+ class VersionChangeType(StrEnum):
21
+ UPGRADE = auto()
22
+ DOWNGRADE = auto()
23
+
24
+
25
+ class VersionChangeLevel(IntEnum):
26
+ MAJOR = 0
27
+ MINOR = 1
28
+ PATCH = 2
29
+ UNKNOWN = 10
30
+
31
+ @property
32
+ def gitmoji(self) -> str:
33
+ match self:
34
+ case VersionChangeLevel.MAJOR:
35
+ return ":collision:"
36
+ case VersionChangeLevel.MINOR:
37
+ return ":sparkles:"
38
+ case VersionChangeLevel.PATCH:
39
+ return ":hammer_and_wrench:"
40
+ case VersionChangeLevel.UNKNOWN:
41
+ return ":question:"
42
+ case _:
43
+ raise NotImplementedError()
44
+
45
+
46
+ BASEVERSION = re.compile(
47
+ r"""[vV]?
48
+ (?P<major>0|[1-9]\d*)
49
+ (\.
50
+ (?P<minor>0|[1-9]\d*)
51
+ (\.
52
+ (?P<patch>0|[1-9]\d*)
53
+ )?
54
+ )?
55
+ """,
56
+ re.VERBOSE,
57
+ )
58
+
59
+
60
+ class LockfilePackage(BaseModel):
61
+ model_config = ConfigDict(
62
+ arbitrary_types_allowed=True,
63
+ validate_by_name=True,
64
+ validate_by_alias=False,
65
+ populate_by_name=True,
66
+ )
67
+
68
+ name: str = Field(alias="Package")
69
+ version: str | None = Field(alias="Version", default=None)
70
+
71
+ def __str__(self) -> str:
72
+ return f"{self.name}: {self.version}"
73
+
74
+ def __eq__(self, other):
75
+ if not isinstance(other, LockfilePackage):
76
+ return NotImplemented
77
+ if self.version is None or other.version is None:
78
+ return self.name == other.name and self.version == other.version
79
+ return self.name == other.name and parse(self.version) == parse(other.version)
80
+
81
+ def markdown_row(self) -> str:
82
+ return f"| {self.name} | {self.version} |"
83
+
84
+ def markdown_simple(self) -> str:
85
+ return f"\\`{self.name}\\`: \\`{self.version}\\`"
86
+
87
+
88
+ class UpdatedPackage(BaseModel):
89
+ model_config = ConfigDict(
90
+ arbitrary_types_allowed=True,
91
+ validate_by_name=True,
92
+ validate_by_alias=False,
93
+ populate_by_name=True,
94
+ )
95
+
96
+ name: str = Field(alias="Package")
97
+ old_version: str = Field(alias="Old Version")
98
+ new_version: str = Field(alias="New Version")
99
+
100
+ def __str__(self) -> str:
101
+ return f"{self.name}: {self.old_version} -> {self.new_version}"
102
+
103
+ def change_type(self) -> VersionChangeType:
104
+ old_ver = parse(self.old_version)
105
+ new_ver = parse(self.new_version)
106
+ if new_ver > old_ver:
107
+ return VersionChangeType.UPGRADE
108
+ else:
109
+ return VersionChangeType.DOWNGRADE
110
+
111
+ def change_level(self) -> VersionChangeLevel:
112
+ old_version = parse(self.old_version)
113
+ new_version = parse(self.new_version)
114
+
115
+ if new_version.major != old_version.major:
116
+ return VersionChangeLevel.MAJOR
117
+ elif new_version.minor != old_version.minor:
118
+ return VersionChangeLevel.MINOR
119
+ elif new_version.micro != old_version.micro:
120
+ return VersionChangeLevel.PATCH
121
+
122
+ return VersionChangeLevel.UNKNOWN
123
+
124
+ def markdown_row(self) -> str:
125
+ return f"| {self.name} | {self.old_version} | {self.new_version} |"
126
+
127
+ def markdown_simple(self) -> str:
128
+ return f"{self.change_level().gitmoji} \\`{self.name}\\`: \\`{self.old_version}\\` -> \\`{self.new_version}\\`"
129
+
130
+
131
+ class RequiresPythonChanges(BaseModel):
132
+ old: str | None
133
+ new: str | None
134
+
135
+ def has_changes(self) -> bool:
136
+ return self.old != self.new
137
+
138
+ def __str__(self) -> str:
139
+ return f"Requires-Python: {self.old} -> {self.new}"
140
+
141
+
142
+ class LockfileChanges(BaseModel):
143
+ model_config = ConfigDict(arbitrary_types_allowed=True)
144
+
145
+ requires_python: RequiresPythonChanges
146
+
147
+ added: list[LockfilePackage] = []
148
+ removed: list[LockfilePackage] = []
149
+ updated: list[UpdatedPackage] = []
150
+ output_format: OutputFormat
151
+ show_learn_more_link: bool
152
+
153
+ def __str__(self) -> str:
154
+ all = []
155
+ if self.requires_python.has_changes():
156
+ all.append("Python Constraint Changed:")
157
+ all.append(
158
+ f"\\`{self.requires_python.old}\\` -> \\`{self.requires_python.new}\\`"
159
+ )
160
+ if self.added:
161
+ all.append("Added:")
162
+ all.extend([str(e) for e in self.added])
163
+ if self.updated:
164
+ all.append("Updated:")
165
+ all.extend([str(e) for e in self.updated])
166
+ if self.removed:
167
+ all.append("Removed:")
168
+ all.extend([str(e) for e in self.removed])
169
+ return "\n".join(all)
170
+
171
+ @computed_field
172
+ @property
173
+ def items(self) -> int:
174
+ return len(self.added) + len(self.removed) + len(self.updated)
175
+
176
+ @computed_field
177
+ @property
178
+ def markdown(self) -> str:
179
+ match self.output_format:
180
+ case OutputFormat.TABLE:
181
+ return self.markdown_table
182
+ case OutputFormat.SIMPLE:
183
+ return self.markdown_simple
184
+ case _:
185
+ raise ValueError(f"Unknown format: {format}")
186
+
187
+ @computed_field
188
+ @property
189
+ def markdown_table(self) -> str:
190
+ title = "##"
191
+ sections = "###"
192
+
193
+ all = [f"{title} uv Lockfile Report"]
194
+ if self.requires_python.has_changes():
195
+ all.append(f"{sections} Python Constraint Changed")
196
+ all.append(
197
+ f"\\`{self.requires_python.old}\\` -> \\`{self.requires_python.new}\\`"
198
+ )
199
+ if self.added:
200
+ all.append(f"{sections} Added")
201
+ all.extend(self.__lockfile_package_table_header())
202
+ all.extend([added.markdown_row() for added in self.added])
203
+
204
+ if self.updated:
205
+ all.append(f"{sections} Changed")
206
+ all.extend(self.__updated_package_table_header())
207
+ all.extend([updated.markdown_row() for updated in self.updated])
208
+
209
+ if self.removed:
210
+ all.append(f"{sections} Removed")
211
+ all.extend(self.__lockfile_package_table_header())
212
+ all.extend([removed.markdown_row() for removed in self.removed])
213
+ return "\n".join(all)
214
+
215
+ def __lockfile_package_table_header(self) -> list[str]:
216
+ header = []
217
+ attribute_order = ("name", "version")
218
+
219
+ header.append(
220
+ "| "
221
+ + " | ".join(
222
+ [
223
+ str(LockfilePackage.model_fields[attr].alias)
224
+ for attr in attribute_order
225
+ ]
226
+ )
227
+ + " |"
228
+ )
229
+ header.append("|" + "|".join(["--" for _ in attribute_order]) + "|")
230
+ return header
231
+
232
+ def __updated_package_table_header(self) -> list[str]:
233
+ header = []
234
+ attribute_order = ("name", "old_version", "new_version")
235
+
236
+ header.append(
237
+ "| "
238
+ + " | ".join(
239
+ [
240
+ str(UpdatedPackage.model_fields[attr].alias)
241
+ for attr in attribute_order
242
+ ]
243
+ )
244
+ + " |"
245
+ )
246
+ header.append("|" + "|".join(["--" for _ in attribute_order]) + "|")
247
+ return header
248
+
249
+ @computed_field
250
+ @property
251
+ def markdown_simple(self) -> str:
252
+ title = "##"
253
+ sections = "###"
254
+ all = [f"{title} uv Lockfile Report"]
255
+ if self.requires_python.has_changes():
256
+ all.append(f"{sections} Python Constraint Changed")
257
+ all.append(
258
+ f"\\`{self.requires_python.old}\\` -> \\`{self.requires_python.new}\\`"
259
+ )
260
+ if self.added:
261
+ all.append(f"{sections} Added")
262
+ all.extend([added.markdown_simple() for added in self.added])
263
+ if self.updated:
264
+ all.append(f"{sections} Changed")
265
+ all.extend([updated.markdown_simple() for updated in self.updated])
266
+
267
+ if self.removed:
268
+ all.append(f"{sections} Removed")
269
+ all.extend([removed.markdown_simple() for removed in self.removed])
270
+
271
+ if self.show_learn_more_link:
272
+ all.append(self.learn_more_link_text)
273
+ return "\n".join(all)
274
+
275
+ @computed_field
276
+ @property
277
+ def learn_more_link_text(self) -> str:
278
+ return "\n".join(
279
+ [
280
+ "",
281
+ "---",
282
+ "Learn more about this report at https://github.com/mw-root/uv-lock-report",
283
+ ]
284
+ )
285
+
286
+
287
+ class LockFileType(StrEnum):
288
+ UV = auto()
289
+
290
+
291
+ class LockFile(BaseModel):
292
+ type: LockFileType
293
+ packages: list[LockfilePackage]
294
+
295
+ @cached_property
296
+ def packages_by_name(self) -> dict[str, LockfilePackage]:
297
+ return {p.name: p for p in self.packages}
298
+
299
+ @cached_property
300
+ def package_names(self) -> set[str]:
301
+ return set(self.packages_by_name.keys())
302
+
303
+
304
+ class UvLockFile(LockFile):
305
+ type: LockFileType = LockFileType.UV
306
+ version: int
307
+ revision: int
308
+ packages: list[LockfilePackage] = Field(alias="package")
309
+ requires_python: str = Field(alias="requires-python")
310
+
311
+ @classmethod
312
+ def from_toml_str(cls, toml_str: str) -> "UvLockFile":
313
+ return cls.model_validate(tomllib.loads(toml_str))
314
+
315
+
316
+ class LockFileReporter:
317
+ def __init__(
318
+ self,
319
+ old_lockfile: UvLockFile | None,
320
+ new_lockfile: UvLockFile | None,
321
+ output_format: OutputFormat,
322
+ show_learn_more_link: bool,
323
+ ) -> None:
324
+ self.old_lockfile = old_lockfile
325
+ self.new_lockfile = new_lockfile
326
+ self.output_format = output_format
327
+ self.show_learn_more_link = show_learn_more_link
328
+
329
+ @cached_property
330
+ def both_lockfile_package_names(self) -> set[str]:
331
+ old_package_names = (
332
+ self.old_lockfile.package_names if self.old_lockfile else set()
333
+ )
334
+ new_package_names = (
335
+ self.new_lockfile.package_names if self.new_lockfile else set()
336
+ )
337
+
338
+ return old_package_names & new_package_names
339
+
340
+ def get_changes(self) -> LockfileChanges:
341
+ return LockfileChanges(
342
+ requires_python=self.get_requires_python_changes(),
343
+ added=self.get_added_packages(),
344
+ removed=self.get_removed_packages(),
345
+ updated=self.get_updated_packages(),
346
+ show_learn_more_link=self.show_learn_more_link,
347
+ output_format=self.output_format,
348
+ )
349
+
350
+ def get_requires_python_changes(self) -> RequiresPythonChanges:
351
+ old_requires_python = (
352
+ self.old_lockfile.requires_python if self.old_lockfile else None
353
+ )
354
+ new_requires_python = (
355
+ self.new_lockfile.requires_python if self.new_lockfile else None
356
+ )
357
+ return RequiresPythonChanges(
358
+ old=old_requires_python,
359
+ new=new_requires_python,
360
+ )
361
+
362
+ @cached_property
363
+ def added_package_names(self) -> set[str]:
364
+ if self.old_lockfile is None:
365
+ if self.new_lockfile is None:
366
+ return set()
367
+
368
+ return self.new_lockfile.package_names
369
+
370
+ if self.new_lockfile is None:
371
+ return set()
372
+
373
+ return self.new_lockfile.package_names.difference(
374
+ self.old_lockfile.package_names
375
+ )
376
+
377
+ @cached_property
378
+ def removed_package_names(self) -> set[str]:
379
+ if self.new_lockfile is None:
380
+ if self.old_lockfile is None:
381
+ return set()
382
+
383
+ return self.old_lockfile.package_names
384
+ if self.old_lockfile is None:
385
+ return set()
386
+
387
+ return self.old_lockfile.package_names.difference(
388
+ self.new_lockfile.package_names
389
+ )
390
+
391
+ def get_removed_packages(self) -> list[LockfilePackage]:
392
+ if self.old_lockfile is None:
393
+ return []
394
+ return [
395
+ pkg
396
+ for pkg in self.old_lockfile.packages
397
+ if pkg.name in self.removed_package_names
398
+ ]
399
+
400
+ def get_added_packages(self) -> list[LockfilePackage]:
401
+ if self.new_lockfile is None:
402
+ return []
403
+ return [
404
+ pkg
405
+ for pkg in self.new_lockfile.packages
406
+ if pkg.name in self.added_package_names
407
+ ]
408
+
409
+ def sort_packages_by_change_level(
410
+ self, packages: list[UpdatedPackage]
411
+ ) -> list[UpdatedPackage]:
412
+ return sorted(packages, key=lambda x: (x.change_level(), x.name))
413
+
414
+ def get_updated_packages(self) -> list[UpdatedPackage]:
415
+ if self.old_lockfile is None or self.new_lockfile is None:
416
+ return []
417
+ updated_packages: list[UpdatedPackage] = []
418
+
419
+ for pkg_name in self.both_lockfile_package_names:
420
+ old_pkg = self.old_lockfile.packages_by_name[pkg_name]
421
+ new_pkg = self.new_lockfile.packages_by_name[pkg_name]
422
+ if old_pkg != new_pkg:
423
+ updated_packages.append(
424
+ UpdatedPackage( # type: ignore
425
+ name=pkg_name, # type: ignore
426
+ old_version=old_pkg.version, # type: ignore
427
+ new_version=new_pkg.version, # type: ignore
428
+ ) # type: ignore
429
+ )
430
+ return self.sort_packages_by_change_level(updated_packages)
@@ -0,0 +1,68 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ from uv_lock_report.models import (
5
+ LockfileChanges,
6
+ LockFileReporter,
7
+ OutputFormat,
8
+ UvLockFile,
9
+ )
10
+
11
+ CURRENT_UV_LOCK = Path("uv.lock")
12
+
13
+
14
+ def get_new_uv_lock_file(base_path: str) -> UvLockFile | None:
15
+ path = Path(base_path)
16
+ uv_lock_path = path / CURRENT_UV_LOCK
17
+ if not uv_lock_path.exists():
18
+ print("uv.lock not found in current working directory")
19
+ return None
20
+ return UvLockFile.from_toml_str(uv_lock_path.read_text())
21
+
22
+
23
+ def get_old_uv_lock_file(base_sha: str, base_path: str) -> UvLockFile | None:
24
+ cmd = ["git", "show", f"{base_sha}:uv.lock"]
25
+
26
+ run = subprocess.run(
27
+ cmd,
28
+ capture_output=True,
29
+ text=True,
30
+ cwd=base_path,
31
+ )
32
+
33
+ if run.returncode != 0:
34
+ print("uv.lock not found in base commit")
35
+ print(run.stderr)
36
+ print(run.stdout)
37
+ print(run.args)
38
+ return None
39
+
40
+ print("Found uv.lock in base commit.")
41
+ return UvLockFile.from_toml_str(run.stdout)
42
+
43
+
44
+ def write_changes_file(lockfile_changes: LockfileChanges, output_path: str) -> None:
45
+ Path(output_path).write_text(lockfile_changes.model_dump_json())
46
+
47
+
48
+ def report(
49
+ base_sha: str,
50
+ base_path: str,
51
+ output_path: str,
52
+ output_format: OutputFormat = OutputFormat.TABLE,
53
+ show_learn_more_link: bool = True,
54
+ ) -> None:
55
+ old_lockfile = get_old_uv_lock_file(base_sha, base_path)
56
+ new_lockfile = get_new_uv_lock_file(base_path)
57
+
58
+ reporter = LockFileReporter(
59
+ old_lockfile=old_lockfile,
60
+ new_lockfile=new_lockfile,
61
+ output_format=output_format,
62
+ show_learn_more_link=show_learn_more_link,
63
+ )
64
+
65
+ write_changes_file(
66
+ lockfile_changes=reporter.get_changes(),
67
+ output_path=output_path,
68
+ )
File without changes