Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,11 @@
args: []
pass_filenames: false
additional_dependencies: [poetry]
- id: sync-pre-commit-uv
name: Sync pre-commit with uv lock
description: Ensure pre-commit hooks versions are in sync with uv.lock
entry: sync-pre-commit-uv
language: python
files: ^uv\.lock$
args: []
pass_filenames: false
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ PDM and Poetry plugin to sync your pre-commit versions with your lockfile and au
- PDM 2.7.4 to 2.25+
- Python 3.12.7+ requires PDM 2.20.1+
- Poetry 1.6 to 2.1+
- uv (lock version 1)

> ℹ️ While we only test these versions, it should work with more recent versions.
>
> ⚠️ Only the latest patch version for each minor version is tested.
>
> 👉 We recommend using a recent version of Python, and a recent version of PDM/Poetry.
> 👉 We recommend using a recent version of Python, and a recent version of PDM/Poetry/uv.

## Installation

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

> Only Poetry 1.6.0+ is supported.


### For uv

`uv` does not yet support plugins, but you can still use the CLI command `sync-pre-commit-uv` or the `pre-commit` hook.

## Configuration

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

Both commands support `--dry-run` and verbosity options.
or

```bash
sync-pre-commit-uv
```

Those commands support `--dry-run` and verbosity options.

### PDM Github Action support

Expand Down Expand Up @@ -152,6 +164,7 @@ repos:
hooks: # Choose the one matching your package manager
- id: sync-pre-commit-pdm
- id: sync-pre-commit-poetry
- id: sync-pre-commit-uv
```


Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ optional-dependencies.poetry = [
urls."Bug Tracker" = "https://github.com/GabDug/sync-pre-commit-lock/issues"
urls."Changelog" = "https://github.com/GabDug/sync-pre-commit-lock/releases"
urls."Homepage" = "https://github.com/GabDug/sync-pre-commit-lock"
scripts.sync-pre-commit-uv = "sync_pre_commit_lock.uv:sync_pre_commit"

entry-points.pdm.pdm-sync-pre-commit-lock = "sync_pre_commit_lock.pdm_plugin:register_pdm_plugin"
entry-points."poetry.application.plugin".poetry-sync-pre-commit-lock = "sync_pre_commit_lock.poetry_plugin:SyncPreCommitLockPlugin"

Expand Down
8 changes: 8 additions & 0 deletions src/sync_pre_commit_lock/_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# ruff: noqa: F401
try:
# 3.11+
import tomllib as toml # type: ignore[import,unused-ignore]
except ImportError:
import tomli as toml # type: ignore[no-redef,unused-ignore]

__all__ = ["toml"]
9 changes: 1 addition & 8 deletions src/sync_pre_commit_lock/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,11 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, TypedDict

try:
# 3.11+
import tomllib as toml # type: ignore[import,unused-ignore]
except ImportError:
import tomli as toml # type: ignore[no-redef,unused-ignore]

from ._compat import toml

if TYPE_CHECKING:
from sync_pre_commit_lock.db import PackageRepoMapping

pass

ENV_PREFIX = "SYNC_PRE_COMMIT_LOCK"


Expand Down
186 changes: 186 additions & 0 deletions src/sync_pre_commit_lock/shell.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
"""
Generic shell utilities.
"""

from __future__ import annotations

import os
import sys
from enum import IntEnum, auto
from typing import TYPE_CHECKING, TextIO

from packaging.requirements import Requirement

from sync_pre_commit_lock import Printer
from sync_pre_commit_lock.utils import url_diff

if TYPE_CHECKING:
from collections.abc import Callable, Sequence

from sync_pre_commit_lock.pre_commit_config import PreCommitHook, PreCommitRepo


def use_color() -> bool:
"""
Determine if we should use color in the terminal output.
Follows the NO_COLOR and FORCE_COLOR conventions.
See:
- https://no-color.org/
- https://force-color.org/
"""
no_color = os.getenv("NO_COLOR") is not None
force_color = os.getenv("FORCE_COLOR") is not None
return not no_color and (sys.stdout.isatty() or force_color)


# Compute once
USE_COLOR = use_color()


def _color(escape: str) -> str:
return escape if USE_COLOR else ""


class Colors:
"""
ANSI color codes for terminal output
"""

BLUE = _color("\033[94m")
GREEN = _color("\033[92m")
YELLOW = _color("\033[93m")
RED = _color("\033[91m")
PURPLE = _color("\033[95m")
CYAN = _color("\033[96m")
BOLD = _color("\033[1m")
UNDERLINE = _color("\033[4m")
END = _color("\033[0m")


class Verbosity(IntEnum):
QUIET = auto()
NORMAL = auto()
DEBUG = auto()


def style(*colors: str) -> Callable[[str], str]:
prefix = "".join(colors)

def helper(msg: str) -> str:
return f"{prefix}{msg}{Colors.END}"

return helper


debug = style(Colors.PURPLE)
cyan = style(Colors.CYAN)
info = style(Colors.CYAN)
bold = style(Colors.BOLD)
success = style(Colors.GREEN, Colors.BOLD)
warning = style(Colors.YELLOW)
error = style(Colors.RED, Colors.BOLD)


class ShellPrinter(Printer):
success_list_token: str = f"{Colors.GREEN}✔{Colors.END}"

"""
A printer that outputs messages to the shell with color coding.
"""

def __init__(self, with_prefix: bool = True, verbosity: Verbosity = Verbosity.NORMAL) -> None:
self.plugin_prefix = "[sync-pre-commit-lock]" if with_prefix else ""
self.verbosity = verbosity

def with_prefix(self, msg: str) -> str:
if not self.plugin_prefix:
return msg
return "\n".join(f"{self.plugin_prefix} {line}" for line in msg.split("\n"))

def print(self, msg: str, verbosity: Verbosity = Verbosity.NORMAL, out: TextIO | None = None) -> None:
if self.verbosity >= verbosity:
# Bind late due to https://github.com/pytest-dev/pytest/issues/5997
(out or sys.stdout).write(f"{msg}\n")

def debug(self, msg: str) -> None:
self.print(debug(self.with_prefix(msg)), Verbosity.DEBUG)

def info(self, msg: str) -> None:
self.print(info(self.with_prefix(msg)))

def success(self, msg: str) -> None:
self.print(success(self.with_prefix(msg)))

def warning(self, msg: str) -> None:
self.print(warning(self.with_prefix(msg)))

def error(self, msg: str) -> None:
self.print(error(self.with_prefix(msg)), Verbosity.QUIET, out=sys.stderr)

def list_updated_packages(self, packages: dict[str, tuple[PreCommitRepo, PreCommitRepo]]) -> None:
for package, (old, new) in packages.items():
for row in self._format_repo(package, old, new):
line = " ".join(row).rstrip()
if self.plugin_prefix:
line = f"{info(self.plugin_prefix)} {line}"
self.print(line)

def _format_repo_url(self, old_repo_url: str, new_repo_url: str, package_name: str) -> str:
url = url_diff(
old_repo_url,
new_repo_url,
f"{cyan('{')}{Colors.RED}",
f"{Colors.END}{cyan(' -> ')}{Colors.GREEN}",
f"{Colors.END}{cyan('}')}",
)
return url.replace(package_name, cyan(bold(package_name)))

def _format_repo(self, package: str, old: PreCommitRepo, new: PreCommitRepo) -> Sequence[Sequence[str]]:
new_version = new.rev != old.rev
repo = (
self.success_list_token,
self._format_repo_url(old.repo, new.repo, package),
"\t",
error(old.rev) if new_version else "",
info("->") if new_version else "",
success(new.rev) if new_version else "",
)
nb_hooks = len(old.hooks)
hooks = [
row
for idx, (old_hook, new_hook) in enumerate(zip(old.hooks, new.hooks))
for row in self._format_hook(old_hook, new_hook, idx + 1 == nb_hooks)
]
return [repo, *hooks] if hooks else [repo]

def _format_hook(self, old: PreCommitHook, new: PreCommitHook, last: bool) -> Sequence[Sequence[str]]:
if not len(old.additional_dependencies):
return []
hook = (
f" {'└' if last else '├'} {cyan(bold(old.id))}",
"",
"",
"",
)
pairs = [
(old_dep, new_dep)
for old_dep, new_dep in zip(old.additional_dependencies, new.additional_dependencies)
if old_dep != new_dep
]

dependencies = [
self._format_additional_dependency(old_dep, new_dep, " " if last else "│", idx + 1 == len(pairs))
for idx, (old_dep, new_dep) in enumerate(pairs)
]
return (hook, *dependencies)

def _format_additional_dependency(self, old: str, new: str, prefix: str, last: bool) -> Sequence[str]:
old_req = Requirement(old)
new_req = Requirement(new)
return (
f" {prefix} {'└' if last else '├'} {cyan(bold(old_req.name))}",
"\t",
error(str(old_req.specifier).lstrip("==") or "*"),
info("->"),
success(str(new_req.specifier).lstrip("==")),
)
50 changes: 50 additions & 0 deletions src/sync_pre_commit_lock/uv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from __future__ import annotations

import argparse
import sys
from pathlib import Path

from ._compat import toml
from .actions.sync_hooks import GenericLockedPackage, SyncPreCommitHooksVersion
from .config import load_config
from .shell import ShellPrinter, Verbosity, cyan


def load_lock(path: Path | None = None) -> dict[str, GenericLockedPackage]:
path = path or Path("uv.lock")
with path.open("rb") as file:
lock = toml.load(file)

packages: dict[str, GenericLockedPackage] = {}

for package in lock.get("package", []):
name = package.get("name")
version = package.get("version")
if name and version:
packages[name] = GenericLockedPackage(name=name, version=version)

return packages


def sync_pre_commit() -> None:
parser = argparse.ArgumentParser(
description=f"Sync {cyan('.pre-commit-config.yaml')} hooks versions with {cyan('uv.lock')}"
)
parser.add_argument("--dry-run", action="store_true", help="Show the difference only and don't perform any action")
parser.add_argument("-v", "--verbose", action="store_true", help="Show detailed output")
parser.add_argument("-q", "--quiet", action="store_true", help="Hide all output except errors")

args = parser.parse_args(sys.argv[1:])

lock_data = load_lock()
verbosity = Verbosity.DEBUG if args.verbose else Verbosity.QUIET if args.quiet else Verbosity.NORMAL
printer = ShellPrinter(with_prefix=False, verbosity=verbosity)
config = load_config()
file_path = Path().cwd() / config.pre_commit_config_file
SyncPreCommitHooksVersion(
printer=printer,
pre_commit_config_file_path=file_path,
locked_packages=lock_data,
plugin_config=config,
dry_run=args.dry_run,
).execute()
6 changes: 6 additions & 0 deletions tests/fixtures/uv_project/.pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.0
hooks:
- id: ruff
9 changes: 9 additions & 0 deletions tests/fixtures/uv_project/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[project]
classifiers = [
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
Loading
Loading