diff --git a/commitizen/providers/__init__.py b/commitizen/providers/__init__.py index 6bec8f156..3f860365a 100644 --- a/commitizen/providers/__init__.py +++ b/commitizen/providers/__init__.py @@ -10,6 +10,7 @@ from commitizen.providers.composer_provider import ComposerProvider from commitizen.providers.npm_provider import NpmProvider from commitizen.providers.pep621_provider import Pep621Provider +from commitizen.providers.pep751_provider import Pep751Provider from commitizen.providers.poetry_provider import PoetryProvider from commitizen.providers.scm_provider import ScmProvider from commitizen.providers.uv_provider import UvProvider @@ -24,6 +25,7 @@ "ComposerProvider", "NpmProvider", "Pep621Provider", + "Pep751Provider", "PoetryProvider", "ScmProvider", "UvProvider", diff --git a/commitizen/providers/pep751_provider.py b/commitizen/providers/pep751_provider.py new file mode 100644 index 000000000..83a472007 --- /dev/null +++ b/commitizen/providers/pep751_provider.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from pathlib import Path + +import tomlkit +from packaging.utils import canonicalize_name + +from commitizen.providers.pep621_provider import Pep621Provider + + +class Pep751Provider(Pep621Provider): + """ + PEP 621 + PEP 751 lockfile awareness + + Updates pyproject.toml (via Pep621Provider) and any pylock*.toml + lock files that contain a matching local directory package entry. + """ + + lock_patterns: tuple[str, ...] = ("pylock.toml", "pylock.*.toml") + + def set_version(self, version: str) -> None: + doc = tomlkit.parse(self.file.read_text()) + project_name = canonicalize_name(doc["project"]["name"]) # type: ignore[index,arg-type] + + super().set_version(version) + + for pattern in self.lock_patterns: + for lock_file in Path().glob(pattern): + lock_doc = tomlkit.parse(lock_file.read_text()) + updated = False + for pkg in lock_doc.get("packages", []): + if ( + canonicalize_name(pkg.get("name", "")) == project_name + and "directory" in pkg + ): + pkg["version"] = version + updated = True + if updated: + lock_file.write_text(tomlkit.dumps(lock_doc)) diff --git a/docs/config/version_provider.md b/docs/config/version_provider.md index ccbbe6cfd..fe9530527 100644 --- a/docs/config/version_provider.md +++ b/docs/config/version_provider.md @@ -96,6 +96,40 @@ name = "my-package" version = "0.1.0" # Managed by Commitizen ``` +### `pep751` + +Manages version in `pyproject.toml` (`project.version`) and updates the project's own entry in [PEP 751](https://peps.python.org/pep-0751/) `pylock.toml` lock files. Only updates `[[packages]]` entries that reference the project as a local directory source (`[packages.directory]`), since those are the only entries safe to edit without invalidating hashes and URLs. + +!!! note + `pylock.toml` is a standardized Python lock file format (PEP 751). Unlike `package-lock.json` or `uv.lock`, it has no root-level project version — the project may appear as a `[[packages]]` entry with a `[packages.directory]` source. This provider handles that case automatically. If your project doesn't appear in its own lock file (the common case), it behaves identically to `pep621`. + +**Use when:** + +- You're using a PEP 751-compliant lock tool and have `pylock.toml` files +- You want version synchronization between `pyproject.toml` and `pylock.toml` + +**Configuration:** +```toml +[tool.commitizen] +version_provider = "pep751" +``` + +**Example `pylock.toml` entry that gets updated:** +```toml +lock-version = "1.0" +created-by = "uv" + +[[packages]] +name = "my-package" +version = "0.1.0" # Updated by Commitizen + +[packages.directory] +path = "." +editable = true +``` + +Also handles named lock files like `pylock.dev.toml`, `pylock.prod.toml`, etc. + ### `uv` Manages version in both `pyproject.toml` (`project.version`) and `uv.lock` (`package.version` for the matching package name). This ensures consistency between your project metadata and lock file. @@ -190,6 +224,7 @@ version_provider = "composer" | `commitizen` | Commitizen config file | No | General use, flexible projects | | `scm` | None (reads from Git tags) | Yes | `setuptools-scm` users | | `pep621` | `pyproject.toml` (`project.version`) | No | Modern Python (PEP 621) | +| `pep751` | `pyproject.toml` + `pylock*.toml` | No | PEP 751 lock file users | | `poetry` | `pyproject.toml` (`tool.poetry.version`) | No | Poetry projects | | `uv` | `pyproject.toml` + `uv.lock` | No | uv package manager | | `cargo` | `Cargo.toml` + `Cargo.lock` | No | Rust/Cargo projects | @@ -324,6 +359,7 @@ Select a version provider based on your project's characteristics: - **Python projects** - **with `uv`**: Use `uv` + - **with PEP 751 lock files (`pylock.toml`)**: Use `pep751` - **with `pyproject.toml` that follows PEP 621**: Use `pep621` - **with Poetry**: Use `poetry` - **setuptools-scm**: Use `scm` diff --git a/pyproject.toml b/pyproject.toml index 64f7d7c91..cd1bd535c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,7 @@ commitizen = "commitizen.providers:CommitizenProvider" composer = "commitizen.providers:ComposerProvider" npm = "commitizen.providers:NpmProvider" pep621 = "commitizen.providers:Pep621Provider" +pep751 = "commitizen.providers:Pep751Provider" poetry = "commitizen.providers:PoetryProvider" scm = "commitizen.providers:ScmProvider" uv = "commitizen.providers:UvProvider" diff --git a/tests/providers/test_pep751_provider.py b/tests/providers/test_pep751_provider.py new file mode 100644 index 000000000..5f1ba00a5 --- /dev/null +++ b/tests/providers/test_pep751_provider.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from textwrap import dedent +from typing import TYPE_CHECKING + +import pytest + +from commitizen.providers import get_provider +from commitizen.providers.pep751_provider import Pep751Provider + +if TYPE_CHECKING: + from pathlib import Path + + from commitizen.config.base_config import BaseConfig + +PYPROJECT_TOML = """\ +[project] +name = "my-package" +version = "0.1.0" +""" + +PYPROJECT_TOML_EXPECTED = """\ +[project] +name = "my-package" +version = "42.1" +""" + +PYLOCK_TOML_WITH_DIRECTORY = """\ +lock-version = "1.0" +created-by = "test" + +[[packages]] +name = "my-package" +version = "0.1.0" + +[packages.directory] +path = "." +editable = true + +[[packages]] +name = "some-dep" +version = "1.2.3" +""" + +PYLOCK_TOML_WITH_DIRECTORY_EXPECTED = """\ +lock-version = "1.0" +created-by = "test" + +[[packages]] +name = "my-package" +version = "42.1" + +[packages.directory] +path = "." +editable = true + +[[packages]] +name = "some-dep" +version = "1.2.3" +""" + +PYLOCK_TOML_NON_MATCHING_NAME = """\ +lock-version = "1.0" +created-by = "test" + +[[packages]] +name = "other-package" +version = "0.1.0" + +[packages.directory] +path = "." +editable = true +""" + +PYLOCK_TOML_NON_DIRECTORY = """\ +lock-version = "1.0" +created-by = "test" + +[[packages]] +name = "my-package" +version = "0.1.0" +""" + + +@pytest.fixture +def pyproject(chdir: Path) -> Path: + file = chdir / "pyproject.toml" + file.write_text(dedent(PYPROJECT_TOML)) + return file + + +def test_get_version(config: BaseConfig, pyproject: Path): + config.settings["version_provider"] = "pep751" + provider = get_provider(config) + assert isinstance(provider, Pep751Provider) + assert provider.get_version() == "0.1.0" + + +def test_set_version_without_lock_files( + config: BaseConfig, pyproject: Path, chdir: Path +): + config.settings["version_provider"] = "pep751" + provider = get_provider(config) + + provider.set_version("42.1") + + assert pyproject.read_text() == dedent(PYPROJECT_TOML_EXPECTED) + # No pylock*.toml files should exist + assert list(chdir.glob("pylock*.toml")) == [] + + +def test_set_version_with_pylock_toml(config: BaseConfig, pyproject: Path, chdir: Path): + lock_file = chdir / "pylock.toml" + lock_file.write_text(dedent(PYLOCK_TOML_WITH_DIRECTORY)) + config.settings["version_provider"] = "pep751" + provider = get_provider(config) + + provider.set_version("42.1") + + assert pyproject.read_text() == dedent(PYPROJECT_TOML_EXPECTED) + assert lock_file.read_text() == dedent(PYLOCK_TOML_WITH_DIRECTORY_EXPECTED) + + +def test_set_version_with_named_lock_file( + config: BaseConfig, pyproject: Path, chdir: Path +): + lock_file = chdir / "pylock.dev.toml" + lock_file.write_text(dedent(PYLOCK_TOML_WITH_DIRECTORY)) + config.settings["version_provider"] = "pep751" + provider = get_provider(config) + + provider.set_version("42.1") + + assert pyproject.read_text() == dedent(PYPROJECT_TOML_EXPECTED) + assert lock_file.read_text() == dedent(PYLOCK_TOML_WITH_DIRECTORY_EXPECTED) + + +def test_set_version_non_matching_package_not_updated( + config: BaseConfig, pyproject: Path, chdir: Path +): + lock_file = chdir / "pylock.toml" + lock_file.write_text(dedent(PYLOCK_TOML_NON_MATCHING_NAME)) + original_lock_content = lock_file.read_text() + config.settings["version_provider"] = "pep751" + provider = get_provider(config) + + provider.set_version("42.1") + + assert pyproject.read_text() == dedent(PYPROJECT_TOML_EXPECTED) + # Lock file should be unchanged since no matching package name + assert lock_file.read_text() == original_lock_content + + +def test_set_version_non_directory_source_not_updated( + config: BaseConfig, pyproject: Path, chdir: Path +): + lock_file = chdir / "pylock.toml" + lock_file.write_text(dedent(PYLOCK_TOML_NON_DIRECTORY)) + original_lock_content = lock_file.read_text() + config.settings["version_provider"] = "pep751" + provider = get_provider(config) + + provider.set_version("42.1") + + assert pyproject.read_text() == dedent(PYPROJECT_TOML_EXPECTED) + # Lock file should be unchanged since package has no directory source + assert lock_file.read_text() == original_lock_content + + +def test_set_version_multiple_lock_files( + config: BaseConfig, pyproject: Path, chdir: Path +): + lock_file1 = chdir / "pylock.toml" + lock_file1.write_text(dedent(PYLOCK_TOML_WITH_DIRECTORY)) + lock_file2 = chdir / "pylock.dev.toml" + lock_file2.write_text(dedent(PYLOCK_TOML_WITH_DIRECTORY)) + config.settings["version_provider"] = "pep751" + provider = get_provider(config) + + provider.set_version("42.1") + + assert pyproject.read_text() == dedent(PYPROJECT_TOML_EXPECTED) + assert lock_file1.read_text() == dedent(PYLOCK_TOML_WITH_DIRECTORY_EXPECTED) + assert lock_file2.read_text() == dedent(PYLOCK_TOML_WITH_DIRECTORY_EXPECTED) + + +def test_provider_registration(config: BaseConfig, pyproject: Path): + config.settings["version_provider"] = "pep751" + provider = get_provider(config) + assert isinstance(provider, Pep751Provider)