Skip to content

Commit 9e9261e

Browse files
committed
feat(uv): add support for uv.lock by command or pre-commit hook (fix #42)
1 parent 9a2b0e3 commit 9e9261e

File tree

12 files changed

+2596
-10
lines changed

12 files changed

+2596
-10
lines changed

.pre-commit-hooks.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,11 @@
1616
args: []
1717
pass_filenames: false
1818
additional_dependencies: [poetry]
19+
- id: sync-pre-commit-uv
20+
name: Sync pre-commit with uv lock
21+
description: Ensure pre-commit hooks versions are in sync with uv.lock
22+
entry: sync-pre-commit-uv
23+
language: python
24+
files: ^uv\.lock$
25+
args: []
26+
pass_filenames: false

README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,13 @@ PDM and Poetry plugin to sync your pre-commit versions with your lockfile and au
2727
- PDM 2.7.4 to 2.25+
2828
- Python 3.12.7+ requires PDM 2.20.1+
2929
- Poetry 1.6 to 2.1+
30+
- uv (lock version 1)
3031

3132
> ℹ️ While we only test these versions, it should work with more recent versions.
3233
>
3334
> ⚠️ Only the latest patch version for each minor version is tested.
3435
>
35-
> 👉 We recommend using a recent version of Python, and a recent version of PDM/Poetry.
36+
> 👉 We recommend using a recent version of Python, and a recent version of PDM/Poetry/uv.
3637
3738
## Installation
3839

@@ -66,6 +67,11 @@ poetry self add "sync-pre-commit-lock[poetry]"
6667

6768
> Only Poetry 1.6.0+ is supported.
6869
70+
71+
### For uv
72+
73+
`uv` does not yet support plugins, but you can still use the CLI command `sync-pre-commit-uv` or the `pre-commit` hook.
74+
6975
## Configuration
7076

7177
This plugin is configured using the `tool.sync-pre-commit-lock` section in your `pyproject.toml` file.
@@ -120,7 +126,13 @@ or
120126
poetry sync-pre-commit
121127
```
122128

123-
Both commands support `--dry-run` and verbosity options.
129+
or
130+
131+
```bash
132+
sync-pre-commit-uv
133+
```
134+
135+
Those commands support `--dry-run` and verbosity options.
124136

125137
### PDM Github Action support
126138

@@ -152,6 +164,7 @@ repos:
152164
hooks: # Choose the one matching your package manager
153165
- id: sync-pre-commit-pdm
154166
- id: sync-pre-commit-poetry
167+
- id: sync-pre-commit-uv
155168
```
156169
157170

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ optional-dependencies.poetry = [
4545
urls."Bug Tracker" = "https://github.com/GabDug/sync-pre-commit-lock/issues"
4646
urls."Changelog" = "https://github.com/GabDug/sync-pre-commit-lock/releases"
4747
urls."Homepage" = "https://github.com/GabDug/sync-pre-commit-lock"
48+
scripts.sync-pre-commit-uv = "sync_pre_commit_lock.uv:sync_pre_commit"
49+
4850
entry-points.pdm.pdm-sync-pre-commit-lock = "sync_pre_commit_lock.pdm_plugin:register_pdm_plugin"
4951
entry-points."poetry.application.plugin".poetry-sync-pre-commit-lock = "sync_pre_commit_lock.poetry_plugin:SyncPreCommitLockPlugin"
5052

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# ruff: noqa: F401
2+
try:
3+
# 3.11+
4+
import tomllib as toml # type: ignore[import,unused-ignore]
5+
except ImportError:
6+
import tomli as toml # type: ignore[no-redef,unused-ignore]
7+
8+
__all__ = ["toml"]

src/sync_pre_commit_lock/config.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,11 @@
55
from pathlib import Path
66
from typing import TYPE_CHECKING, Any, Callable, TypedDict
77

8-
try:
9-
# 3.11+
10-
import tomllib as toml # type: ignore[import,unused-ignore]
11-
except ImportError:
12-
import tomli as toml # type: ignore[no-redef,unused-ignore]
13-
8+
from ._compat import toml
149

1510
if TYPE_CHECKING:
1611
from sync_pre_commit_lock.db import PackageRepoMapping
1712

18-
pass
19-
2013
ENV_PREFIX = "SYNC_PRE_COMMIT_LOCK"
2114

2215

src/sync_pre_commit_lock/shell.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""
2+
Generic shell utilities.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
import os
8+
import sys
9+
from enum import IntEnum, auto
10+
from typing import TYPE_CHECKING, TextIO
11+
12+
from packaging.requirements import Requirement
13+
14+
from sync_pre_commit_lock import Printer
15+
from sync_pre_commit_lock.utils import url_diff
16+
17+
if TYPE_CHECKING:
18+
from collections.abc import Callable, Sequence
19+
20+
from sync_pre_commit_lock.pre_commit_config import PreCommitHook, PreCommitRepo
21+
22+
23+
def use_color() -> bool:
24+
"""
25+
Determine if we should use color in the terminal output.
26+
Follows the NO_COLOR and FORCE_COLOR conventions.
27+
See:
28+
- https://no-color.org/
29+
- https://force-color.org/
30+
"""
31+
no_color = os.getenv("NO_COLOR") is not None
32+
force_color = os.getenv("FORCE_COLOR") is not None
33+
return not no_color and (sys.stdout.isatty() or force_color)
34+
35+
36+
# Compute once
37+
USE_COLOR = use_color()
38+
39+
40+
def _color(escape: str) -> str:
41+
return escape if USE_COLOR else ""
42+
43+
44+
class Colors:
45+
"""
46+
ANSI color codes for terminal output
47+
"""
48+
49+
BLUE = _color("\033[94m")
50+
GREEN = _color("\033[92m")
51+
YELLOW = _color("\033[93m")
52+
RED = _color("\033[91m")
53+
PURPLE = _color("\033[95m")
54+
CYAN = _color("\033[96m")
55+
BOLD = _color("\033[1m")
56+
UNDERLINE = _color("\033[4m")
57+
END = _color("\033[0m")
58+
59+
60+
class Verbosity(IntEnum):
61+
QUIET = auto()
62+
NORMAL = auto()
63+
DEBUG = auto()
64+
65+
66+
def style(*colors: str) -> Callable[[str], str]:
67+
prefix = "".join(colors)
68+
69+
def helper(msg: str) -> str:
70+
return f"{prefix}{msg}{Colors.END}"
71+
72+
return helper
73+
74+
75+
debug = style(Colors.PURPLE)
76+
cyan = style(Colors.CYAN)
77+
info = style(Colors.CYAN)
78+
bold = style(Colors.BOLD)
79+
success = style(Colors.GREEN, Colors.BOLD)
80+
warning = style(Colors.YELLOW)
81+
error = style(Colors.RED, Colors.BOLD)
82+
83+
84+
class ShellPrinter(Printer):
85+
success_list_token: str = f"{Colors.GREEN}{Colors.END}"
86+
87+
"""
88+
A printer that outputs messages to the shell with color coding.
89+
"""
90+
91+
def __init__(self, with_prefix: bool = True, verbosity: Verbosity = Verbosity.NORMAL) -> None:
92+
self.plugin_prefix = "[sync-pre-commit-lock]" if with_prefix else ""
93+
self.verbosity = verbosity
94+
95+
def with_prefix(self, msg: str) -> str:
96+
if not self.plugin_prefix:
97+
return msg
98+
return "\n".join(f"{self.plugin_prefix} {line}" for line in msg.split("\n"))
99+
100+
def print(self, msg: str, verbosity: Verbosity = Verbosity.NORMAL, out: TextIO | None = None) -> None:
101+
if self.verbosity >= verbosity:
102+
# Bind late due to https://github.com/pytest-dev/pytest/issues/5997
103+
(out or sys.stdout).write(f"{msg}\n")
104+
105+
def debug(self, msg: str) -> None:
106+
self.print(debug(self.with_prefix(msg)), Verbosity.DEBUG)
107+
108+
def info(self, msg: str) -> None:
109+
self.print(info(self.with_prefix(msg)))
110+
111+
def success(self, msg: str) -> None:
112+
self.print(success(self.with_prefix(msg)))
113+
114+
def warning(self, msg: str) -> None:
115+
self.print(warning(self.with_prefix(msg)))
116+
117+
def error(self, msg: str) -> None:
118+
self.print(error(self.with_prefix(msg)), Verbosity.QUIET, out=sys.stderr)
119+
120+
def list_updated_packages(self, packages: dict[str, tuple[PreCommitRepo, PreCommitRepo]]) -> None:
121+
for package, (old, new) in packages.items():
122+
for row in self._format_repo(package, old, new):
123+
line = " ".join(row).rstrip()
124+
if self.plugin_prefix:
125+
line = f"{info(self.plugin_prefix)} {line}"
126+
self.print(line)
127+
128+
def _format_repo_url(self, old_repo_url: str, new_repo_url: str, package_name: str) -> str:
129+
url = url_diff(
130+
old_repo_url,
131+
new_repo_url,
132+
f"{cyan('{')}{Colors.RED}",
133+
f"{Colors.END}{cyan(' -> ')}{Colors.GREEN}",
134+
f"{Colors.END}{cyan('}')}",
135+
)
136+
return url.replace(package_name, cyan(bold(package_name)))
137+
138+
def _format_repo(self, package: str, old: PreCommitRepo, new: PreCommitRepo) -> Sequence[Sequence[str]]:
139+
new_version = new.rev != old.rev
140+
repo = (
141+
self.success_list_token,
142+
self._format_repo_url(old.repo, new.repo, package),
143+
"\t",
144+
error(old.rev) if new_version else "",
145+
info("->") if new_version else "",
146+
success(new.rev) if new_version else "",
147+
)
148+
nb_hooks = len(old.hooks)
149+
hooks = [
150+
row
151+
for idx, (old_hook, new_hook) in enumerate(zip(old.hooks, new.hooks))
152+
for row in self._format_hook(old_hook, new_hook, idx + 1 == nb_hooks)
153+
]
154+
return [repo, *hooks] if hooks else [repo]
155+
156+
def _format_hook(self, old: PreCommitHook, new: PreCommitHook, last: bool) -> Sequence[Sequence[str]]:
157+
if not len(old.additional_dependencies):
158+
return []
159+
hook = (
160+
f" {'└' if last else '├'} {cyan(bold(old.id))}",
161+
"",
162+
"",
163+
"",
164+
)
165+
pairs = [
166+
(old_dep, new_dep)
167+
for old_dep, new_dep in zip(old.additional_dependencies, new.additional_dependencies)
168+
if old_dep != new_dep
169+
]
170+
171+
dependencies = [
172+
self._format_additional_dependency(old_dep, new_dep, " " if last else "│", idx + 1 == len(pairs))
173+
for idx, (old_dep, new_dep) in enumerate(pairs)
174+
]
175+
return (hook, *dependencies)
176+
177+
def _format_additional_dependency(self, old: str, new: str, prefix: str, last: bool) -> Sequence[str]:
178+
old_req = Requirement(old)
179+
new_req = Requirement(new)
180+
return (
181+
f" {prefix} {'└' if last else '├'} {cyan(bold(old_req.name))}",
182+
"\t",
183+
error(str(old_req.specifier).lstrip("==") or "*"),
184+
info("->"),
185+
success(str(new_req.specifier).lstrip("==")),
186+
)

src/sync_pre_commit_lock/uv.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import sys
5+
from pathlib import Path
6+
7+
from ._compat import toml
8+
from .actions.sync_hooks import GenericLockedPackage, SyncPreCommitHooksVersion
9+
from .config import load_config
10+
from .shell import ShellPrinter, Verbosity, cyan
11+
12+
13+
def load_lock(path: Path | None = None) -> dict[str, GenericLockedPackage]:
14+
path = path or Path("uv.lock")
15+
with path.open("rb") as file:
16+
lock = toml.load(file)
17+
18+
packages: dict[str, GenericLockedPackage] = {}
19+
20+
for package in lock.get("package", []):
21+
name = package.get("name")
22+
version = package.get("version")
23+
if name and version:
24+
packages[name] = GenericLockedPackage(name=name, version=version)
25+
26+
return packages
27+
28+
29+
def sync_pre_commit() -> None:
30+
parser = argparse.ArgumentParser(
31+
description=f"Sync {cyan('.pre-commit-config.yaml')} hooks versions with {cyan('uv.lock')}"
32+
)
33+
parser.add_argument("--dry-run", action="store_true", help="Show the difference only and don't perform any action")
34+
parser.add_argument("-v", "--verbose", action="store_true", help="Show detailed output")
35+
parser.add_argument("-q", "--quiet", action="store_true", help="Hide all output except errors")
36+
37+
args = parser.parse_args(sys.argv[1:])
38+
39+
lock_data = load_lock()
40+
verbosity = Verbosity.DEBUG if args.verbose else Verbosity.QUIET if args.quiet else Verbosity.NORMAL
41+
printer = ShellPrinter(with_prefix=False, verbosity=verbosity)
42+
config = load_config()
43+
file_path = Path().cwd() / config.pre_commit_config_file
44+
SyncPreCommitHooksVersion(
45+
printer=printer,
46+
pre_commit_config_file_path=file_path,
47+
locked_packages=lock_data,
48+
plugin_config=config,
49+
dry_run=args.dry_run,
50+
).execute()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
repos:
3+
- repo: https://github.com/astral-sh/ruff-pre-commit
4+
rev: v0.1.0
5+
hooks:
6+
- id: ruff
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[project]
2+
classifiers = [
3+
"Programming Language :: Python :: 3 :: Only",
4+
"Programming Language :: Python :: 3.9",
5+
"Programming Language :: Python :: 3.10",
6+
"Programming Language :: Python :: 3.11",
7+
"Programming Language :: Python :: 3.12",
8+
"Programming Language :: Python :: 3.13",
9+
]

0 commit comments

Comments
 (0)