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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ repos:
verbose: true
files: ^db\.py|README\.md$

- rev: v0.5.5
- rev: v0.6.1
repo: https://github.com/astral-sh/ruff-pre-commit
hooks:
- id: ruff
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ PDM and Poetry plugin to sync your pre-commit versions with your lockfile and au

## Features

- 🔁 Sync pre-commit versions with your lockfile
- 🔁 Sync pre-commit versions (including `additional_dependencies`) with your lockfile
- ⏩ Run every time you run the lockfile is updated, not as a pre-commit hook
- 🔄 Install pre-commit hooks automatically, no need to run `pre-commit install` manually
- 💫 Preserve your pre-commit config file formatting
- 🍃 Lightweight, only depends on [strictyaml](https://pypi.org/project/strictyaml/)
- 🍃 Lightweight, only depends on [strictyaml](https://pypi.org/project/strictyaml/) and [packaging](https://pypi.org/project/packaging/)

## Supported versions

Expand Down Expand Up @@ -163,13 +163,15 @@ Feel free to open an issue or a PR if you have any idea, or if you want to help!
- [ ] Create a more verbose command
- [ ] Add support for other lockfiles / project managers (pipenv, flit, hatch, etc.)
- [ ] Expose a pre-commit hook to sync the lockfile
- [ ] Support nested params for some repos? Like mypy types
- [x] Support nested `additional_dependencies`, (ie. mypy types)
- [ ] Support reading DB from a Python module?
- [ ] Support reordering DB inputs (file/global config/python module/cli)?
- [ ] Test using SSH/file dependencies?
- [ ] Check ref existence before writing?
- [ ] New feature to convert from pre-commit online to local?
- [ ] Warning if pre-commit CI auto update is also set?
- [ ] Support automatic repository URL update (from legacy aliased repositories)


## Inspiration

Expand Down
720 changes: 402 additions & 318 deletions pdm.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dynamic = [
"version",
]
dependencies = [
"packaging>=24.1",
"strictyaml>=1.7.3",
"tomli>=2; python_version<'3.11'",
"typing-extensions; python_version<'3.10'",
Expand Down Expand Up @@ -63,6 +64,8 @@ lint-mypy = { cmd = "mypy src", help = "Run mypy type checker" }
# XXX(dugab): run mypy on tests as well
lint-ruff = { cmd = "ruff check .", help = "Run ruff linter" }
test-cov = { cmd = "pytest --junitxml=junit/test-results.xml --cov --cov-report=xml --cov-report=html --cov-report=term-missing", help = "Run tests with coverage" }
test-all = { cmd = "tox", help = "Test against all supported versions" }
test = { cmd = "pytest", help = "Run the test suite" }

[tool.pdm.dev-dependencies]
dev = [
Expand Down
2 changes: 1 addition & 1 deletion src/sync_pre_commit_lock/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,5 @@ def error(self, msg: str) -> None:
def success(self, msg: str) -> None:
raise NotImplementedError

def list_updated_packages(self, packages: dict[str, tuple[PreCommitRepo, str]]) -> None:
def list_updated_packages(self, packages: dict[str, tuple[PreCommitRepo, PreCommitRepo]]) -> None:
raise NotImplementedError
131 changes: 83 additions & 48 deletions src/sync_pre_commit_lock/actions/sync_hooks.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from __future__ import annotations

from typing import TYPE_CHECKING, NamedTuple
from functools import cached_property
from typing import TYPE_CHECKING, NamedTuple, Sequence

from sync_pre_commit_lock.db import DEPENDENCY_MAPPING, REPOSITORY_ALIASES, PackageRepoMapping, RepoInfo
from sync_pre_commit_lock.pre_commit_config import PreCommitHookConfig, PreCommitRepo
from packaging.requirements import InvalidRequirement, Requirement
from packaging.utils import canonicalize_name

from sync_pre_commit_lock.db import DEPENDENCY_MAPPING, REPOSITORY_ALIASES, PackageRepoMapping
from sync_pre_commit_lock.pre_commit_config import PreCommitHook, PreCommitHookConfig, PreCommitRepo

if TYPE_CHECKING:
from pathlib import Path
Expand All @@ -17,6 +21,9 @@ class GenericLockedPackage(NamedTuple):
version: str
# Add original data here?

def __str__(self) -> str:
return f"{self.name}=={self.version}"


class SyncPreCommitHooksVersion:
def __init__(
Expand Down Expand Up @@ -49,21 +56,22 @@ def execute(self) -> None:
self.printer.error(f"Invalid pre-commit config file: {self.pre_commit_config_file_path}: {e}")
return

mapping, mapping_reverse_by_url = self.build_mapping()
# XXX We should have the list of packages mapped, but already up to date and print it
to_fix, in_sync = self.analyze_repos(pre_commit_config_data.repos_normalized, mapping, mapping_reverse_by_url)
to_fix, in_sync = self.analyze_repos(pre_commit_config_data.repos_normalized)

if len(to_fix) == 0 and len(in_sync) == 0:
self.printer.info("No pre-commit hook detected that matches a locked package.")
return
if len(to_fix) == 0:
packages_str = ", ".join(f"{mapping_reverse_by_url[repo.repo]} ({rev})" for repo, rev in in_sync.items())
packages_str = ", ".join(
f"{self.mapping_reverse_by_url[repo.repo]} ({rev})" for repo, rev in in_sync.items()
)
self.printer.info(f"All pre-commit hooks are already up to date with the lockfile: {packages_str}")
return

self.printer.info("Detected pre-commit hooks that can be updated to match the lockfile:")
self.printer.list_updated_packages(
{mapping_reverse_by_url[repo.repo]: (repo, new_ver) for repo, new_ver in to_fix.items()}
{self.mapping_reverse_by_url[repo.repo]: (repo, new_ver) for repo, new_ver in to_fix.items()}
)

if self.dry_run:
Expand All @@ -72,20 +80,51 @@ def execute(self) -> None:
pre_commit_config_data.update_pre_commit_repo_versions(to_fix)
self.printer.success(f"Pre-commit hooks have been updated in {self.pre_commit_config_file_path.name}!")

@cached_property
def mapping(self) -> PackageRepoMapping:
return {**DEPENDENCY_MAPPING, **self.plugin_config.dependency_mapping}

@cached_property
def mapping_reverse_by_url(self) -> dict[str, str]:
"""Merge the default mapping with the user-provided mapping. Also build a reverse mapping by URL."""
mapping_reverse_by_url = {repo["repo"]: lib_name for lib_name, repo in self.mapping.items()}
for canonical_name, aliases in REPOSITORY_ALIASES.items():
if canonical_name in mapping_reverse_by_url:
for alias in aliases:
mapping_reverse_by_url[alias] = mapping_reverse_by_url[canonical_name]
# XXX Allow override / extend of aliases
return mapping_reverse_by_url

def get_pre_commit_repo_new_version(
self,
pre_commit_config_repo: PreCommitRepo,
mapping_db_repo_info: RepoInfo,
locked_package: GenericLockedPackage,
) -> str | None:
dependency = self.mapping[self.mapping_reverse_by_url[pre_commit_config_repo.repo]]
dependency_name = self.mapping_reverse_by_url[pre_commit_config_repo.repo]
locked_package = self.locked_packages.get(dependency_name)

if not locked_package:
self.printer.debug(
f"Pre-commit hook {pre_commit_config_repo.repo} has a mapping to Python package `{dependency_name}`, "
"but was not found in the lockfile"
)
return None

if "+" in locked_package.version:
self.printer.debug(
f"Pre-commit hook {pre_commit_config_repo.repo} has a mapping to Python package `{dependency_name}`, "
f"but is skipped because the locked version `{locked_package.version}` contaims a `+`, "
"which is a local version identifier."
)
return None
if locked_package.name in self.plugin_config.ignore:
self.printer.debug(f"Ignoring {locked_package.name} from configuration.")
return None

self.printer.debug(
f"Found mapping between pre-commit hook `{pre_commit_config_repo.repo}` and locked package `{locked_package.name}`."
)
formatted_rev = mapping_db_repo_info["rev"].replace("${rev}", str(locked_package.version))
formatted_rev = dependency["rev"].replace("${rev}", str(locked_package.version))
if formatted_rev != pre_commit_config_repo.rev:
self.printer.debug(
f"Pre-commit hook {pre_commit_config_repo.repo} and locked package {locked_package.name} have different versions:\n"
Expand All @@ -99,52 +138,48 @@ def get_pre_commit_repo_new_version(
)
return None

def build_mapping(self) -> tuple[PackageRepoMapping, dict[str, str]]:
"""Merge the default mapping with the user-provided mapping. Also build a reverse mapping by URL."""
mapping: PackageRepoMapping = {**DEPENDENCY_MAPPING, **self.plugin_config.dependency_mapping}
mapping_reverse_by_url = {repo["repo"]: lib_name for lib_name, repo in mapping.items()}
for canonical_name, aliases in REPOSITORY_ALIASES.items():
for alias in aliases:
mapping_reverse_by_url[alias] = mapping_reverse_by_url[canonical_name]
# XXX Allow override / extend of aliases
return mapping, mapping_reverse_by_url
def get_pre_commit_repo_new_hooks(self, hooks: Sequence[PreCommitHook]) -> Sequence[PreCommitHook]:
return [self.get_pre_commit_repo_new_hook(hook) for hook in hooks]

def get_pre_commit_repo_new_hook(self, hook: PreCommitHook) -> PreCommitHook:
return PreCommitHook(
hook.id, [self.get_pre_commit_repo_hook_new_dependency(dep) for dep in hook.additional_dependencies]
)

def get_pre_commit_repo_hook_new_dependency(self, dependency: str) -> str:
if "+" in dependency:
self.printer.debug(f"Additional dependency {dependency} is a local version. Ignoring.")
return dependency
try:
requirement = Requirement(dependency)
except InvalidRequirement:
self.printer.debug(f"Invalid additional dependency {dependency}. Ignoring.")
return dependency
normalized_name = canonicalize_name(requirement.name)
if not (locked_version := self.locked_packages.get(normalized_name)):
self.printer.debug(f"Additional dependency {dependency} not found in the lockfile. Ignoring.")
return dependency
return str(locked_version).replace(normalized_name, requirement.name)

def analyze_repos(
self,
pre_commit_repos: set[PreCommitRepo],
mapping: PackageRepoMapping,
mapping_reverse_by_url: dict[str, str],
) -> tuple[dict[PreCommitRepo, str], dict[PreCommitRepo, str]]:
to_fix: dict[PreCommitRepo, str] = {}
in_sync: dict[PreCommitRepo, str] = {}
) -> tuple[dict[PreCommitRepo, PreCommitRepo], dict[PreCommitRepo, PreCommitRepo]]:
to_fix: dict[PreCommitRepo, PreCommitRepo] = {}
in_sync: dict[PreCommitRepo, PreCommitRepo] = {}
for pre_commit_repo in pre_commit_repos:
if pre_commit_repo.repo not in mapping_reverse_by_url:
if pre_commit_repo.repo not in self.mapping_reverse_by_url:
self.printer.debug(f"Pre-commit hook {pre_commit_repo.repo} not found in the DB mapping")
continue

dependency = mapping[mapping_reverse_by_url[pre_commit_repo.repo]]
dependency_name = mapping_reverse_by_url[pre_commit_repo.repo]
dependency_locked = self.locked_packages.get(dependency_name)

if not dependency_locked:
self.printer.debug(
f"Pre-commit hook {pre_commit_repo.repo} has a mapping to Python package `{dependency_name}`, "
"but was not found in the lockfile"
)
continue

if "+" in dependency_locked.version:
self.printer.debug(
f"Pre-commit hook {pre_commit_repo.repo} has a mapping to Python package `{dependency_name}`, "
f"but is skipped because the locked version `{dependency_locked.version}` contaims a `+`, "
"which is a local version identifier."
)
continue

new_ver = self.get_pre_commit_repo_new_version(pre_commit_repo, dependency, dependency_locked)
if new_ver:
to_fix[pre_commit_repo] = new_ver
new_repo = PreCommitRepo(
repo=pre_commit_repo.repo,
rev=self.get_pre_commit_repo_new_version(pre_commit_repo) or pre_commit_repo.rev,
hooks=self.get_pre_commit_repo_new_hooks(pre_commit_repo.hooks),
)
if new_repo != pre_commit_repo:
to_fix[pre_commit_repo] = new_repo
else:
in_sync[pre_commit_repo] = dependency_locked.version
in_sync[pre_commit_repo] = pre_commit_repo

return to_fix, in_sync
63 changes: 50 additions & 13 deletions src/sync_pre_commit_lock/pdm_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from collections.abc import Iterable
from typing import TYPE_CHECKING, Any, ClassVar, Union

from packaging.requirements import Requirement
from pdm import termui
from pdm.__version__ import __version__ as pdm_version
from pdm.cli.commands.base import BaseCommand
Expand All @@ -29,7 +30,7 @@
from pdm.project import Project
from pdm.termui import UI

from sync_pre_commit_lock.pre_commit_config import PreCommitRepo
from sync_pre_commit_lock.pre_commit_config import PreCommitHook, PreCommitRepo


class PDMPrinter(Printer):
Expand Down Expand Up @@ -61,23 +62,59 @@ def success(self, msg: str) -> None:
def _format_repo_url(self, repo_url: str, package_name: str) -> str:
return repo_url.replace(package_name, f"[cyan][bold]{package_name}[/bold][/cyan]")

def list_updated_packages(self, packages: dict[str, tuple[PreCommitRepo, str]]) -> None:
def list_updated_packages(self, packages: dict[str, tuple[PreCommitRepo, PreCommitRepo]]) -> None:
"""
Args:
packages: Dict of package name -> (repo, new_rev)
"""
self.ui.display_columns(
[
(
"[info]" + self.plugin_prefix + "[/info]" + " " + self.success_list_token,
"[info]" + self._format_repo_url(repo[0].repo, package) + "[/info]",
" ",
"[error]" + repo[0].rev + "[/error]",
"[info]" + "->" + "[/info]",
"[green]" + repo[1] + "[/green]",
)
for package, repo in packages.items()
]
[row for package, (old, new) in packages.items() for row in self._format_repo(package, old, new)]
)

def _format_repo(self, package: str, old: PreCommitRepo, new: PreCommitRepo) -> Sequence[Sequence[str]]:
new_version = new.rev != old.rev
repo = (
f"[info]{self.plugin_prefix}[/info] {self.success_list_token}",
f"[info]{self._format_repo_url(old.repo, package)}[/info]",
" ",
f"[error]{old.rev}[/error]" if new_version else "",
"[info]->[/info]" if new_version else "",
f"[green]{new.rev}[/green]" 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 (nb_deps := len(old.additional_dependencies)):
return []
hook = (
f"[info]{self.plugin_prefix}[/info]",
f"{'└' if last else '├'} [cyan][bold]{old.id}[/bold][/cyan]",
"",
"",
"",
)
dependencies = [
self._format_additional_dependency(old_dep, new_dep, " " if last else "│", idx + 1 == nb_deps)
for idx, (old_dep, new_dep) in enumerate(zip(old.additional_dependencies, new.additional_dependencies))
]
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"[info]{self.plugin_prefix}[/info]",
f"{prefix} {'└' if last else '├'} [cyan][bold]{old_req.name}[/bold][/cyan]",
" ",
f"[error]{str(old_req.specifier).lstrip('==') or '*'}[/error]",
"[info]->[/info]",
f"[green]{str(new_req.specifier).lstrip('==')}[/green]",
)


Expand Down
Loading