From e4dcc5b34f9e37a09d632c0349ae8556761a111b Mon Sep 17 00:00:00 2001 From: Tim Hsiung Date: Sat, 9 May 2026 19:24:11 +0800 Subject: [PATCH 1/2] feat(init): discover third-party version providers via entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1258. `cz init` previously presented a hard-coded tuple of eight built-in version providers (`commitizen/commands/init.py:_VERSION_PROVIDER_CHOICES`). Third-party providers registered under the `commitizen.provider` entry-point group — already loaded at runtime by `get_provider()` in `commitizen/providers/__init__.py` — were therefore invisible during `cz init`, forcing users to edit the config by hand. This change: - Renames the tuple to `_BUILTIN_VERSION_PROVIDER_OPTIONS` (data only, no `questionary.Choice` objects). - Adds `_construct_version_provider_choices()`, which builds the picker list at call time: built-in providers first (curated descriptions), followed by any third-party providers discovered via `metadata.entry_points(group=PROVIDER_ENTRYPOINT)` that are not already listed as built-ins. - Wires `_ask_version_provider()` to call the new constructor. - Documents the auto-discovery in `docs/config/version_provider.md` so custom-provider authors know `cz init` will surface their plugin once installed. Adds two unit tests: - `test_construct_version_provider_choices_includes_builtins` — asserts all eight built-ins appear with curated titles and no `third-party` suffix. - `test_construct_version_provider_choices_discovers_third_party` — patches `metadata.entry_points` to add a fake plugin and asserts it is appended (and that built-ins registered in the entry-point group are not duplicated under the generic suffix). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- commitizen/commands/init.py | 99 ++++++++++++++++++----------- docs/config/version_provider.md | 4 ++ tests/commands/test_init_command.py | 68 ++++++++++++++++++++ 3 files changed, 134 insertions(+), 37 deletions(-) diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index f05bc23c1..cc79af74a 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,68 @@ 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. + """ + 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 = [ + questionary.Choice( + title=f"{ep.name}: third-party version provider", + value=ep.name, + ) + for ep in metadata.entry_points(group=PROVIDER_ENTRYPOINT) + if ep.name not in builtin_names + ] + return [*builtin_choices, *third_party_choices] + + class Init: _PRE_COMMIT_CONFIG_PATH = ".pre-commit-config.yaml" @@ -254,7 +279,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..7e7432576 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,70 @@ 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 From 1bad9047d9558cad4658493a7c91ad5ad52609b4 Mon Sep 17 00:00:00 2001 From: Tim Hsiung Date: Sat, 9 May 2026 20:43:03 +0800 Subject: [PATCH 2/2] fix(init): dedupe duplicate third-party provider names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address GitHub Copilot review feedback on PR #1971: when two distributions register a third-party provider under the same name in the `commitizen.provider` entry-point group, the user previously saw two ambiguous choices in `cz init`. Track names in a `seen_names` set (seeded with the built-in names) and skip subsequent matches so each name appears at most once. The conflict is not silenced — `commitizen.providers.get_provider` already raises `VersionProviderUnknown` if more than one entry point shares the name, so the user gets a clear error if they pick the duplicated provider, instead of a confusing UI showing two identical choices that resolve the same way. Add `test_construct_version_provider_choices_dedupes_duplicate_third_party` which simulates two `EntryPoint(name="duplicated", ...)` values and asserts only one appears in the choice list. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- commitizen/commands/init.py | 26 +++++++++++++------ tests/commands/test_init_command.py | 39 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index cc79af74a..52fe53a0c 100644 --- a/commitizen/commands/init.py +++ b/commitizen/commands/init.py @@ -83,6 +83,14 @@ def _construct_version_provider_choices() -> list[questionary.Choice]: 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 @@ -91,14 +99,18 @@ def _construct_version_provider_choices() -> list[questionary.Choice]: questionary.Choice(title=option.title, value=option.provider_name) for option in _BUILTIN_VERSION_PROVIDER_OPTIONS ] - third_party_choices = [ - questionary.Choice( - title=f"{ep.name}: third-party version provider", - value=ep.name, + 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, + ) ) - for ep in metadata.entry_points(group=PROVIDER_ENTRYPOINT) - if ep.name not in builtin_names - ] return [*builtin_choices, *third_party_choices] diff --git a/tests/commands/test_init_command.py b/tests/commands/test_init_command.py index 7e7432576..2ee55befc 100644 --- a/tests/commands/test_init_command.py +++ b/tests/commands/test_init_command.py @@ -551,3 +551,42 @@ def fake_entry_points(*args: Any, **kwargs: Any): # 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