Skip to content
Draft
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: 2 additions & 0 deletions commitizen/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,6 +25,7 @@
"ComposerProvider",
"NpmProvider",
"Pep621Provider",
"Pep751Provider",
"PoetryProvider",
"ScmProvider",
"UvProvider",
Expand Down
39 changes: 39 additions & 0 deletions commitizen/providers/pep751_provider.py
Original file line number Diff line number Diff line change
@@ -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))
36 changes: 36 additions & 0 deletions docs/config/version_provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
190 changes: 190 additions & 0 deletions tests/providers/test_pep751_provider.py
Original file line number Diff line number Diff line change
@@ -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)
Loading