Skip to content
Closed
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
111 changes: 74 additions & 37 deletions commitizen/commands/init.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from importlib import metadata
from pathlib import Path
from typing import TYPE_CHECKING, Any, NamedTuple

Expand All @@ -17,6 +18,7 @@
NoAnswersError,
)
from commitizen.git import get_latest_tag_name, get_tag_names, smart_open
from commitizen.providers import PROVIDER_ENTRYPOINT
from commitizen.version_schemes import (
KNOWN_SCHEMES,
VersionProtocol,
Expand All @@ -38,45 +40,80 @@ def title(self) -> str:
return f"{self.provider_name}: {self.description}"


_VERSION_PROVIDER_CHOICES = tuple(
questionary.Choice(title=option.title, value=option.provider_name)
for option in (
_VersionProviderOption(
provider_name="commitizen",
description="Fetch and set version in commitizen config (default)",
),
_VersionProviderOption(
provider_name="cargo",
description="Get and set version from Cargo.toml:project.version field",
),
_VersionProviderOption(
provider_name="composer",
description="Get and set version from composer.json:project.version field",
),
_VersionProviderOption(
provider_name="npm",
description="Get and set version from package.json:project.version field",
),
_VersionProviderOption(
provider_name="pep621",
description="Get and set version from pyproject.toml:project.version field",
),
_VersionProviderOption(
provider_name="poetry",
description="Get and set version from pyproject.toml:tool.poetry.version field",
),
_VersionProviderOption(
provider_name="uv",
description="Get and set version from pyproject.toml and uv.lock",
),
_VersionProviderOption(
provider_name="scm",
description="Fetch the version from git and does not need to set it back",
),
)
_BUILTIN_VERSION_PROVIDER_OPTIONS: tuple[_VersionProviderOption, ...] = (
_VersionProviderOption(
provider_name="commitizen",
description="Fetch and set version in commitizen config (default)",
),
_VersionProviderOption(
provider_name="cargo",
description="Get and set version from Cargo.toml:project.version field",
),
_VersionProviderOption(
provider_name="composer",
description="Get and set version from composer.json:project.version field",
),
_VersionProviderOption(
provider_name="npm",
description="Get and set version from package.json:project.version field",
),
_VersionProviderOption(
provider_name="pep621",
description="Get and set version from pyproject.toml:project.version field",
),
_VersionProviderOption(
provider_name="poetry",
description="Get and set version from pyproject.toml:tool.poetry.version field",
),
_VersionProviderOption(
provider_name="uv",
description="Get and set version from pyproject.toml and uv.lock",
),
_VersionProviderOption(
provider_name="scm",
description="Fetch the version from git and does not need to set it back",
),
)


def _construct_version_provider_choices() -> list[questionary.Choice]:
"""Build the version-provider picker for `cz init`.

Built-in providers come first (with curated descriptions), then any
third-party providers that register themselves under the
`commitizen.provider` entry-point group. Third-party providers are
not loaded — only their entry-point name is used for the choice.

Names already registered as built-ins are skipped to avoid showing
them twice (commitizen's own providers register under the same
entry-point group). When two distributions register a third-party
provider under the same name, the choice list deduplicates by name
so the user does not see ambiguous duplicate entries; the conflict
will surface with a clear error from `get_provider()` if the user
actually selects that name.
"""
builtin_names = {
option.provider_name for option in _BUILTIN_VERSION_PROVIDER_OPTIONS
}
builtin_choices = [
questionary.Choice(title=option.title, value=option.provider_name)
for option in _BUILTIN_VERSION_PROVIDER_OPTIONS
]
third_party_choices: list[questionary.Choice] = []
seen_names: set[str] = set(builtin_names)
for ep in metadata.entry_points(group=PROVIDER_ENTRYPOINT):
if ep.name in seen_names:
continue
seen_names.add(ep.name)
third_party_choices.append(
questionary.Choice(
title=f"{ep.name}: third-party version provider",
value=ep.name,
)
)
return [*builtin_choices, *third_party_choices]


class Init:
_PRE_COMMIT_CONFIG_PATH = ".pre-commit-config.yaml"

Expand Down Expand Up @@ -254,7 +291,7 @@ def _ask_version_provider(self) -> str:

version_provider: str = questionary.select(
"Choose the source of the version:",
choices=_VERSION_PROVIDER_CHOICES,
choices=_construct_version_provider_choices(),
style=self.cz.style,
default=project_info.get_default_version_provider(),
).unsafe_ask()
Expand Down
4 changes: 4 additions & 0 deletions docs/config/version_provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,10 @@ setup(
version_provider = "my-provider"
```

Once installed, the provider is also offered as a choice during `cz init`,
labelled `my-provider: third-party version provider`. Built-in providers
keep their curated descriptions and appear first in the picker.

### Provider Implementation Guidelines

When creating a custom provider, keep these guidelines in mind:
Expand Down
107 changes: 107 additions & 0 deletions tests/commands/test_init_command.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
from importlib import metadata
from pathlib import Path
from typing import TYPE_CHECKING, Any

Expand Down Expand Up @@ -483,3 +484,109 @@ def test_construct_name_choice_from_registry(config: BaseConfig):
choices[2].description
== "<ignored text> <ISSUE_KEY> <ignored text> #<COMMAND> <optional COMMAND_ARGUMENTS>"
)


def test_construct_version_provider_choices_includes_builtins():
"""Built-in providers appear first with curated descriptions."""
from commitizen.commands.init import _construct_version_provider_choices

choices = _construct_version_provider_choices()
values = [choice.value for choice in choices]
titles = [choice.title for choice in choices]

# All eight built-ins must be present.
for builtin in (
"commitizen",
"cargo",
"composer",
"npm",
"pep621",
"poetry",
"uv",
"scm",
):
assert builtin in values

# And they must come with their curated descriptions, not the
# generic "third-party version provider" suffix.
assert "commitizen: Fetch and set version in commitizen config (default)" in titles
for title in titles[:8]:
assert "third-party" not in title


def test_construct_version_provider_choices_discovers_third_party(
mocker: MockFixture,
):
"""Third-party providers registered under `commitizen.provider` are appended."""
from commitizen.commands.init import _construct_version_provider_choices
from commitizen.providers import PROVIDER_ENTRYPOINT

Comment on lines +517 to +523
real_entry_points = metadata.entry_points

fake_third_party = metadata.EntryPoint(
name="my-third-party",
value="some.module:Provider",
group=PROVIDER_ENTRYPOINT,
)

def fake_entry_points(*args: Any, **kwargs: Any):
eps = real_entry_points(*args, **kwargs)
if kwargs.get("group") == PROVIDER_ENTRYPOINT:
return list(eps) + [fake_third_party]
return eps

mocker.patch(
"commitizen.commands.init.metadata.entry_points",
side_effect=fake_entry_points,
)

choices = _construct_version_provider_choices()
values = [choice.value for choice in choices]
titles = [choice.title for choice in choices]

assert "my-third-party" in values
assert "my-third-party: third-party version provider" in titles
# Built-in `pep621` already lives in the entry-point group; ensure we
# didn't duplicate it once with the curated description and again
# with the generic third-party suffix.
assert values.count("pep621") == 1
assert "pep621: third-party version provider" not in titles


def test_construct_version_provider_choices_dedupes_duplicate_third_party(
mocker: MockFixture,
):
"""Two distributions registering the same provider name only show once."""
from commitizen.commands.init import _construct_version_provider_choices
from commitizen.providers import PROVIDER_ENTRYPOINT

real_entry_points = metadata.entry_points

duplicate_eps = [
metadata.EntryPoint(
name="duplicated",
value="dist_a.module:Provider",
group=PROVIDER_ENTRYPOINT,
),
metadata.EntryPoint(
name="duplicated",
value="dist_b.module:Provider",
group=PROVIDER_ENTRYPOINT,
),
]

def fake_entry_points(*args: Any, **kwargs: Any):
eps = real_entry_points(*args, **kwargs)
if kwargs.get("group") == PROVIDER_ENTRYPOINT:
return list(eps) + duplicate_eps
return eps

mocker.patch(
"commitizen.commands.init.metadata.entry_points",
side_effect=fake_entry_points,
)

choices = _construct_version_provider_choices()
values = [choice.value for choice in choices]

assert values.count("duplicated") == 1
Loading