diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index f05bc23c1..52fe53a0c 100644 --- a/commitizen/commands/init.py +++ b/commitizen/commands/init.py @@ -1,5 +1,6 @@ from __future__ import annotations +from importlib import metadata from pathlib import Path from typing import TYPE_CHECKING, Any, NamedTuple @@ -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, @@ -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" @@ -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() diff --git a/docs/config/version_provider.md b/docs/config/version_provider.md index b882cdba6..5d5fc4434 100644 --- a/docs/config/version_provider.md +++ b/docs/config/version_provider.md @@ -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: diff --git a/tests/commands/test_init_command.py b/tests/commands/test_init_command.py index db47fd064..2ee55befc 100644 --- a/tests/commands/test_init_command.py +++ b/tests/commands/test_init_command.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from importlib import metadata from pathlib import Path from typing import TYPE_CHECKING, Any @@ -483,3 +484,109 @@ def test_construct_name_choice_from_registry(config: BaseConfig): choices[2].description == " # " ) + + +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 + + 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