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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ All notable changes to this project will be documented in this file.

### New Features

- **Sync `additional_dependencies`**:
Pin dependencies in `additional_dependencies` (or any dependency line)
to the `uv.lock` version. Opt-in per line via a `# sync-with-uv` pragma comment;
any specifier (`==`, `>=`, `~=`, ...) is rewritten to an exact `==` pin, and a
pin is added to a bare dependency that has none.
Since the pragma is an explicit request, the tool errors if an annotated line's
package is missing from `uv.lock`, or the line has no dependency to sync.
- **`prek.toml` support**:
Sync hook versions in `prek.toml` configs, in addition to `.pre-commit-config.yaml` (#35)

Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ This tool synchronizes pre-commit hook versions with those in uv.lock to ensure
src/sync_with_uv/
├── cli.py # Cyclopts-based CLI interface with diff/check/write modes
├── sync_with_uv.py # Core logic for parsing uv.lock and updating configs
├── dependency_line.py # Parsing/pinning of `# sync-with-uv` dependency lines
├── repo_data.py # Mapping tables for GitHub repo URLs to package names
├── __init__.py # Package initialization
├── __main__.py # Module entry point (python -m sync_with_uv)
Expand All @@ -63,6 +64,7 @@ src/sync_with_uv/
### Key Functions

- `load_uv_lock()`: Parses uv.lock TOML and extracts package versions
- `process_precommit_text()`: Regex-based parsing and updating of .pre-commit-config.yaml
- `process_config_text()`: Regex-based parsing and updating of .pre-commit-config.yaml / prek.toml
- `sync_dependency_line()`: Pins a `# sync-with-uv`-annotated dependency line to its uv.lock version
- `repo_to_package()`: Maps GitHub URLs to Python package names
- `repo_to_version_template()`: Handles version prefix patterns (v-prefixed vs plain versions)
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,55 @@ sync-with-uv -p custom-precommit.yaml -u custom-lock.toml

Note: If you use the tools with a custom file location, remember to also change the `files` setting in `.pre-commit-config.toml`.

## Syncing additional dependencies

Besides the `rev` of each hook, the tool can also sync version pins inside
`additional_dependencies` (or any other dependency line).
Because these lists can contain arbitrary packages,
syncing is strictly opt-in per line:
a dependency is only touched if its line carries a `# sync-with-uv` pragma comment.

```yaml
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.5.1
hooks:
- id: mypy
additional_dependencies:
- pydantic==2.0.0 # sync-with-uv
- types-requests>=2.0 # sync-with-uv
- httpx # sync-with-uv
- some-other-lib==1.0.0 # left alone, no pragma
```

For every annotated line, the dependency is pinned to an exact version
(`==`) from `uv.lock`, adding a specifier when the dependency has none,
so the example above becomes `pydantic==<locked>`, `types-requests==<locked>`,
and `httpx==<locked>`.
The package name, extras (like `pydantic[email]`), environment markers
(like `; python_version < "3.11"`), quoting, and the comment itself are preserved.

Because the pragma is an explicit request to sync a line,
the tool errors (exit code 123) when an annotated line cannot be synced,
so mistakes surface instead of being silently ignored. This happens when:

- the package is not present in `uv.lock`
(usually a typo or a forgotten `uv add`),
- the line has no dependency to sync
(the pragma landed on a comment or a non-dependency line), or
- the line has more than one dependency
(only one dependency per pragma line is supported).

All offending lines are reported together, with their line numbers,
and the config file is left unchanged until they are fixed.

Notes:

- The package name is matched against `uv.lock` after
[PEP 503](https://peps.python.org/pep-0503/) normalization,
so `types-PyYAML` syncs with the `types-pyyaml` package.
- The same pragma works in `prek.toml`:
`"pydantic>=2.0", # sync-with-uv`.

## Advanced Configuration

Most users don't need this section -
Expand Down
43 changes: 28 additions & 15 deletions src/sync_with_uv/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from cyclopts import App, Parameter

from .repo_data import load_user_mappings
from .sync_with_uv import load_uv_lock, process_config_text
from .sync_with_uv import Changes, load_uv_lock, process_config_text

app = App(name="sync-with-uv")
app.register_install_completion_command()
Expand Down Expand Up @@ -78,7 +78,7 @@


@app.default()
def process_precommit( # noqa: PLR0913

Check notice on line 81 in src/sync_with_uv/cli.py

View workflow job for this annotation

GitHub Actions / pylint

R0914

Too many local variables (16/15)

Check notice on line 81 in src/sync_with_uv/cli.py

View workflow job for this annotation

GitHub Actions / pylint

R0913

Too many arguments (7/5)
*,
precommit_filename: Annotated[
Path | None, Parameter(["-p", "--pre-commit-config"])
Expand Down Expand Up @@ -146,7 +146,7 @@
)
# report the results / change files
if verbose:
_print_packages(changes)
_print_changes(changes)
# output a diff to to stdout
if diff:
_print_diff(config_text, fixed_text, config_path, color=color)
Expand All @@ -158,19 +158,28 @@
_print_summary(changes, dry_mode=diff or check)
# return 1 if check and changed
return int(check and fixed_text != config_text)
except Exception as e: # noqa: BLE001

Check warning on line 161 in src/sync_with_uv/cli.py

View workflow job for this annotation

GitHub Actions / pylint

W0718

Catching too general exception Exception
print("Error:", e, file=sys.stderr)
return 123


def _print_packages(changes: dict[str, bool | tuple[str, str]]) -> None:
for package, change in changes.items():
def _print_changes(changes: Changes) -> None:
for package, change in changes.repos.items():
if isinstance(change, tuple):
print(f"{package}: {change[0]} -> {change[1]}", file=sys.stderr)
elif change:
print(f"{package}: unchanged", file=sys.stderr)
else:
print(f"{package}: not managed in uv", file=sys.stderr)
for line_number, dep in sorted(changes.lines.items()):
if dep.changed:
old_spec = dep.old_spec or "(unpinned)"
print(
f"line {line_number}: {dep.package} {old_spec} -> {dep.new_spec}",
file=sys.stderr,
)
else:
print(f"line {line_number}: {dep.package} unchanged", file=sys.stderr)
print(file=sys.stderr)


Expand All @@ -195,21 +204,25 @@
return singular if count == 1 else plural


def _print_summary(
changes: dict[str, bool | tuple[str, str]], *, dry_mode: bool
) -> None:
def _print_summary(changes: Changes, *, dry_mode: bool) -> None:
print("All done!", file=sys.stderr)
n_changed = n_unchanged = 0
for change in changes.values():
if isinstance(change, tuple):
n_changed += 1
else:
n_unchanged += 1
would_be = "would be " if dry_mode else ""
n_pkg_changed = sum(isinstance(c, tuple) for c in changes.repos.values())
n_pkg_unchanged = len(changes.repos) - n_pkg_changed
print(
f"{n_changed} {_plural(n_changed, 'package', 'packages')} "
f"{n_pkg_changed} {_plural(n_pkg_changed, 'package', 'packages')} "
f"{would_be}changed, "
f"{n_unchanged} {_plural(n_unchanged, 'package', 'packages')} "
f"{n_pkg_unchanged} {_plural(n_pkg_unchanged, 'package', 'packages')} "
f"{would_be}left unchanged.",
file=sys.stderr,
)
if changes.lines:
n_changed = sum(d.changed for d in changes.lines.values())
n_unchanged = len(changes.lines) - n_changed
print(
f"{n_changed} {_plural(n_changed, 'dependency', 'dependencies')} "
f"{would_be}changed, "
f"{n_unchanged} {_plural(n_unchanged, 'dependency', 'dependencies')} "
f"{would_be}left unchanged.",
file=sys.stderr,
)
126 changes: 126 additions & 0 deletions src/sync_with_uv/dependency_line.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Sync a single ``# sync-with-uv`` dependency line with uv.lock."""

import re
from typing import NamedTuple

# A dependency line is only synced when it carries this pragma comment,
# e.g. ``- pydantic==2.0.0 # sync-with-uv``. The pragma is an explicit,
# per-line opt-in, so the sync is safe regardless of where the line lives.
_DEP_PRAGMA_RE = re.compile(r"#\s*sync-with-uv(?![\w-])")
# A PEP 440 version specifier: one or more comma-separated ``<operator><version>``
# clauses, e.g. ``==2.0.0`` or ``>=1.0,<2.0``.
_DEP_OP = r"(?:===|==|~=|!=|<=|>=|<|>)"
_DEP_VERSION = r"[0-9A-Za-z._*+!-]+"
_DEP_CLAUSE = rf"{_DEP_OP}\s*{_DEP_VERSION}"
_DEP_SPEC = rf"{_DEP_CLAUSE}(?:\s*,\s*{_DEP_CLAUSE})*"
# A PEP 508 name, shared by the dependency matchers below.
_DEP_NAME = r"[A-Za-z0-9][A-Za-z0-9._-]*"
# A dependency item (YAML ``- `` dash or a quote) followed by a PEP 508 name and
# optional extras, with a required version specifier. Matches ``- pydantic==2.0``.
_DEP_LINE_RE = re.compile(
rf"""^
\s*(?:-\s+['"]?|['"])
(?P<name>{_DEP_NAME})
(?:\[[^\]]*\])?
\s*
(?P<spec>{_DEP_SPEC})
""",
re.VERBOSE,
)
# A dependency item with a name but no version specifier, e.g. ``- pydantic`` or
# ``"pydantic",``. The name must be followed (via a zero-width lookahead, so the
# match ends right after the name/extras and marks the insertion point) by a
# bare-dependency terminator (closing quote, comma, marker, comment or end of
# line) and never a ``:``, so structural lines such as ``rev:`` or ``- id: mypy``
# are not matched.
_DEP_BARE_RE = re.compile(
rf"""^
\s*(?:-\s+['"]?|['"])
(?P<name>{_DEP_NAME})
(?:\[[^\]]*\])?
(?=\s*(?:['"]|,|;|\#|$))
""",
re.VERBOSE,
)
# The only text allowed between the first dependency's specifier and its pragma
# comment: whitespace, quotes and commas, then an optional ``;`` environment
# marker (which may itself contain anything). Anything else -- another name or a
# second quoted string -- means the line carries more than one dependency, of
# which only the first would be synced.
_DEP_TAIL_RE = re.compile(r"""[\s'",]*(?:;.*)?""")


class DepLineChange(NamedTuple):
"""A synced ``# sync-with-uv`` dependency line.

``old_spec`` is the original version specifier (``""`` when the dependency
had none and a pin was added); ``new_spec`` is the applied ``==`` pin.
"""

package: str
old_spec: str
new_spec: str

@property
def changed(self) -> bool:
"""Whether the applied pin differs from the original specifier."""
return self.old_spec != self.new_spec


def _normalize_package_name(name: str) -> str:
"""Normalize a package name to its PEP 503 form (as used in uv.lock)."""
return re.sub(r"[-_.]+", "-", name).lower()


def sync_dependency_line(
line: str, uv_data: dict[str, str]
) -> tuple[str, DepLineChange] | str | None:
"""Sync a dependency on a ``# sync-with-uv`` line.

The pragma is a strict, per-line opt-in: an annotated line must be a
dependency whose package is present in uv.lock. A dependency with a version
specifier has it replaced with an exact ``==`` pin
(``pydantic>=2.0`` -> ``pydantic==<locked>``); a bare dependency has a pin
added (``pydantic`` -> ``pydantic==<locked>``). The package name, extras,
quoting, environment markers and the comment itself are preserved.

Only one dependency per pragma line is supported; a line with more than one
is rejected rather than silently syncing only the first.

Args:
line: A single config line (with its line ending, if any).
uv_data: Package name to version mapping from uv.lock.

Returns:
``None`` if the line does not carry the pragma. A tuple of (updated line,
:class:`DepLineChange`) when the dependency was processed. A ``str``
describing the problem when the annotated line is invalid (its package is
not in uv.lock, it has no dependency to sync, or it has more than one);
the caller collects these and raises.
"""
pragma = _DEP_PRAGMA_RE.search(line)
if pragma is None:
return None
# Locate the dependency and the span of its version specifier. A specifier is
# replaced in place; a bare dependency has a pin inserted after its name, so
# its specifier span is the empty slice at the name's end.
spec_match = _DEP_LINE_RE.match(line)
if spec_match is not None:
name = spec_match.group("name")
old_spec = spec_match.group("spec")
spec_start, spec_end = spec_match.start("spec"), spec_match.end("spec")
else:
bare_match = _DEP_BARE_RE.match(line)
if bare_match is None:
return "no dependency to sync"
name = bare_match.group("name")
old_spec = ""
spec_start = spec_end = bare_match.end()
if not _DEP_TAIL_RE.fullmatch(line, spec_end, pragma.start()):
return "more than one dependency on the line; use one per line"
package = _normalize_package_name(name)
if package not in uv_data:
return f"{package!r} is not in uv.lock"
target_spec = f"=={uv_data[package]}"
line_fixed = line[:spec_start] + target_spec + line[spec_end:]
return line_fixed, DepLineChange(package, old_spec, target_spec)
Loading
Loading