From 641269532eecbc239ed3a1a889d49e89ac3220c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20=27ax=27=20H=C5=AFla?= Date: Thu, 7 Aug 2025 02:24:04 +0200 Subject: [PATCH 1/2] feat(conventional_commits): add ability to overide settings from tool.commitizen.customize --- .../conventional_commits.py | 56 +- docs/customization.md | 10 +- ...test_customized_cz_conventional_commits.py | 562 ++++++++++++++++++ 3 files changed, 614 insertions(+), 14 deletions(-) create mode 100644 tests/test_customized_cz_conventional_commits.py diff --git a/commitizen/cz/conventional_commits/conventional_commits.py b/commitizen/cz/conventional_commits/conventional_commits.py index 689342347..3bdd22e04 100644 --- a/commitizen/cz/conventional_commits/conventional_commits.py +++ b/commitizen/cz/conventional_commits/conventional_commits.py @@ -1,11 +1,21 @@ import os -from typing import TypedDict +from collections.abc import Iterable +from typing import TYPE_CHECKING, TypedDict from commitizen import defaults +from commitizen.config import BaseConfig from commitizen.cz.base import BaseCommitizen from commitizen.cz.utils import multiple_line_breaker, required_validator from commitizen.question import CzQuestion +if TYPE_CHECKING: + from jinja2 import Template +else: + try: + from jinja2 import Template + except ImportError: + from string import Template + __all__ = ["ConventionalCommitsCz"] @@ -39,8 +49,31 @@ class ConventionalCommitsCz(BaseCommitizen): } changelog_pattern = defaults.BUMP_PATTERN - def questions(self) -> list[CzQuestion]: - return [ + def __init__(self, config: BaseConfig) -> None: + super().__init__(config) + + self.custom_settings: defaults.CzSettings = self.config.settings.get( + "customize", {} + ) + + if self.custom_settings: + for attr_name in [ + "bump_pattern", + "bump_map", + "bump_map_major_version_zero", + "change_type_order", + "commit_parser", + "change_type_map", + ]: + if value := self.custom_settings.get(attr_name): + setattr(self, attr_name, value) + + self.changelog_pattern = ( + self.custom_settings.get("changelog_pattern") or self.bump_pattern + ) + + def questions(self) -> Iterable[CzQuestion]: + return self.custom_settings.get("questions") or [ { "type": "list", "name": "prefix", @@ -147,6 +180,12 @@ def questions(self) -> list[CzQuestion]: ] def message(self, answers: ConventionalCommitsAnswers) -> str: # type: ignore[override] + if _message_template := self.custom_settings.get("message_template"): + message_template = Template(_message_template) + if getattr(Template, "substitute", None): + return message_template.substitute(**answers) # type: ignore[attr-defined,no-any-return] # pragma: no cover # TODO: check if we can fix this + return message_template.render(**answers) + prefix = answers["prefix"] scope = answers["scope"] subject = answers["subject"] @@ -166,7 +205,7 @@ def message(self, answers: ConventionalCommitsAnswers) -> str: # type: ignore[o return f"{prefix}{scope}: {subject}{body}{footer}" def example(self) -> str: - return ( + return self.custom_settings.get("example") or ( "fix: correct minor typos in code\n" "\n" "see the issue for details on the typos fixed\n" @@ -175,7 +214,7 @@ def example(self) -> str: ) def schema(self) -> str: - return ( + return self.custom_settings.get("schema") or ( "(): \n" "\n" "\n" @@ -184,6 +223,8 @@ def schema(self) -> str: ) def schema_pattern(self) -> str: + if schema_pattern := self.custom_settings.get("schema_pattern"): + return schema_pattern change_types = ( "build", "bump", @@ -209,6 +250,11 @@ def schema_pattern(self) -> str: ) def info(self) -> str: + if info_path := self.custom_settings.get("info_path"): + with open(info_path, encoding=self.config.settings["encoding"]) as f: + return f.read() + if info := self.custom_settings.get("info"): + return info dir_path = os.path.dirname(os.path.realpath(__file__)) filepath = os.path.join(dir_path, "conventional_commits_info.txt") with open(filepath, encoding=self.config.settings["encoding"]) as f: diff --git a/docs/customization.md b/docs/customization.md index df7717107..06568e52c 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -3,17 +3,11 @@ We have two different ways to do so. ## 1. Customize in configuration file -The basic steps are: - -1. Define your custom committing or bumping rules in the configuration file. -2. Declare `name = "cz_customize"` in your configuration file, or add `-n cz_customize` when running Commitizen. +Define your custom committing or bumping rules in the configuration file, changing behaviour of the default cz_conventional_commits commitizen. Example: ```toml title="pyproject.toml" -[tool.commitizen] -name = "cz_customize" - [tool.commitizen.customize] message_template = "{{change_type}}:{% if show_message %} {{message}}{% endif %}" example = "feature: this feature enable customize through config file" @@ -53,7 +47,6 @@ The equivalent example for a json config file: ```json title=".cz.json" { "commitizen": { - "name": "cz_customize", "customize": { "message_template": "{{change_type}}:{% if show_message %} {{message}}{% endif %}", "example": "feature: this feature enable customize through config file", @@ -108,7 +101,6 @@ And the correspondent example for a yaml file: ```yaml title=".cz.yaml" commitizen: - name: cz_customize customize: message_template: "{{change_type}}:{% if show_message %} {{message}}{% endif %}" example: 'feature: this feature enable customize through config file' diff --git a/tests/test_customized_cz_conventional_commits.py b/tests/test_customized_cz_conventional_commits.py new file mode 100644 index 000000000..e7aacd963 --- /dev/null +++ b/tests/test_customized_cz_conventional_commits.py @@ -0,0 +1,562 @@ +import pytest + +from commitizen.config import JsonConfig, TomlConfig, YAMLConfig +from commitizen.cz.conventional_commits import ConventionalCommitsCz + +TOML_STR = r""" + [tool.commitizen.customize] + message_template = "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example = "feature: this feature enables customization through a config file" + schema = ": " + schema_pattern = "(feature|bug fix):(\\s.*)" + commit_parser = "^(?Pfeature|bug fix):\\s(?P.*)?" + changelog_pattern = "^(feature|bug fix)?(!)?" + change_type_map = {"feature" = "Feat", "bug fix" = "Fix"} + + bump_pattern = "^(break|new|fix|hotfix)" + bump_map = {"break" = "MAJOR", "new" = "MINOR", "fix" = "PATCH", "hotfix" = "PATCH"} + change_type_order = ["perf", "BREAKING CHANGE", "feat", "fix", "refactor"] + info = "This is a customized cz." + + [[tool.commitizen.customize.questions]] + type = "list" + name = "change_type" + choices = [ + {value = "feature", name = "feature: A new feature."}, + {value = "bug fix", name = "bug fix: A bug fix."} + ] + message = "Select the type of change you are committing" + + [[tool.commitizen.customize.questions]] + type = "input" + name = "message" + message = "Body." + + [[tool.commitizen.customize.questions]] + type = "confirm" + name = "show_message" + message = "Do you want to add body message in commit?" +""" + +JSON_STR = r""" + { + "commitizen": { + "version": "1.0.0", + "version_files": [ + "commitizen/__version__.py", + "pyproject.toml" + ], + "customize": { + "message_template": "{{change_type}}:{% if show_message %} {{message}}{% endif %}", + "example": "feature: this feature enables customization through a config file", + "schema": ": ", + "schema_pattern": "(feature|bug fix):(\\s.*)", + "bump_pattern": "^(break|new|fix|hotfix)", + "bump_map": { + "break": "MAJOR", + "new": "MINOR", + "fix": "PATCH", + "hotfix": "PATCH" + }, + "commit_parser": "^(?Pfeature|bug fix):\\s(?P.*)?", + "changelog_pattern": "^(feature|bug fix)?(!)?", + "change_type_map": {"feature": "Feat", "bug fix": "Fix"}, + "change_type_order": ["perf", "BREAKING CHANGE", "feat", "fix", "refactor"], + "info": "This is a customized cz.", + "questions": [ + { + "type": "list", + "name": "change_type", + "choices": [ + { + "value": "feature", + "name": "feature: A new feature." + }, + { + "value": "bug fix", + "name": "bug fix: A bug fix." + } + ], + "message": "Select the type of change you are committing" + }, + { + "type": "input", + "name": "message", + "message": "Body." + }, + { + "type": "confirm", + "name": "show_message", + "message": "Do you want to add body message in commit?" + } + ] + } + } + } +""" + +YAML_STR = """ +commitizen: + version: 1.0.0 + version_files: + - commitizen/__version__.py + - pyproject.toml + customize: + message_template: "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example: 'feature: this feature enables customization through a config file' + schema: ": " + schema_pattern: "(feature|bug fix):(\\s.*)" + bump_pattern: "^(break|new|fix|hotfix)" + bump_map: + break: MAJOR + new: MINOR + fix: PATCH + hotfix: PATCH + change_type_order: ["perf", "BREAKING CHANGE", "feat", "fix", "refactor"] + info: This is a customized cz. + questions: + - type: list + name: change_type + choices: + - value: feature + name: 'feature: A new feature.' + - value: bug fix + name: 'bug fix: A bug fix.' + message: Select the type of change you are committing + - type: input + name: message + message: Body. + - type: confirm + name: show_message + message: Do you want to add body message in commit? +""" + +TOML_WITH_UNICODE = r""" + [tool.commitizen] + version = "1.0.0" + version_files = [ + "commitizen/__version__.py", + "pyproject.toml:version" + ] + [tool.commitizen.customize] + message_template = "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example = "✨ feature: this feature enables customization through a config file" + schema = ": " + schema_pattern = "(✨ feature|🐛 bug fix):(\\s.*)" + commit_parser = "^(?P✨ feature|🐛 bug fix):\\s(?P.*)?" + changelog_pattern = "^(✨ feature|🐛 bug fix)?(!)?" + change_type_map = {"✨ feature" = "Feat", "🐛 bug fix" = "Fix"} + bump_pattern = "^(✨ feat|🐛 bug fix)" + bump_map = {"break" = "MAJOR", "✨ feat" = "MINOR", "🐛 bug fix" = "MINOR"} + change_type_order = ["perf", "BREAKING CHANGE", "feat", "fix", "refactor"] + info = "This is a customized cz with emojis 🎉!" + [[tool.commitizen.customize.questions]] + type = "list" + name = "change_type" + choices = [ + {value = "✨ feature", name = "✨ feature: A new feature."}, + {value = "🐛 bug fix", name = "🐛 bug fix: A bug fix."} + ] + message = "Select the type of change you are committing" + [[tool.commitizen.customize.questions]] + type = "input" + name = "message" + message = "Body." + [[tool.commitizen.customize.questions]] + type = "confirm" + name = "show_message" + message = "Do you want to add body message in commit?" +""" + +JSON_WITH_UNICODE = r""" + { + "commitizen": { + "version": "1.0.0", + "version_files": [ + "commitizen/__version__.py", + "pyproject.toml:version" + ], + "customize": { + "message_template": "{{change_type}}:{% if show_message %} {{message}}{% endif %}", + "example": "✨ feature: this feature enables customization through a config file", + "schema": ": ", + "schema_pattern": "(✨ feature|🐛 bug fix):(\\s.*)", + "bump_pattern": "^(✨ feat|🐛 bug fix)", + "bump_map": { + "break": "MAJOR", + "✨ feat": "MINOR", + "🐛 bug fix": "MINOR" + }, + "commit_parser": "^(?P✨ feature|🐛 bug fix):\\s(?P.*)?", + "changelog_pattern": "^(✨ feature|🐛 bug fix)?(!)?", + "change_type_map": {"✨ feature": "Feat", "🐛 bug fix": "Fix"}, + "change_type_order": ["perf", "BREAKING CHANGE", "feat", "fix", "refactor"], + "info": "This is a customized cz with emojis 🎉!", + "questions": [ + { + "type": "list", + "name": "change_type", + "choices": [ + { + "value": "✨ feature", + "name": "✨ feature: A new feature." + }, + { + "value": "🐛 bug fix", + "name": "🐛 bug fix: A bug fix." + } + ], + "message": "Select the type of change you are committing" + }, + { + "type": "input", + "name": "message", + "message": "Body." + }, + { + "type": "confirm", + "name": "show_message", + "message": "Do you want to add body message in commit?" + } + ] + } + } + } +""" + +TOML_STR_INFO_PATH = """ + [tool.commitizen.customize] + message_template = "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example = "feature: this feature enables customization through a config file" + schema = ": " + bump_pattern = "^(break|new|fix|hotfix)" + bump_map = {"break" = "MAJOR", "new" = "MINOR", "fix" = "PATCH", "hotfix" = "PATCH"} + info_path = "info.txt" +""" + +JSON_STR_INFO_PATH = r""" + { + "commitizen": { + "customize": { + "message_template": "{{change_type}}:{% if show_message %} {{message}}{% endif %}", + "example": "feature: this feature enables customization through a config file", + "schema": ": ", + "bump_pattern": "^(break|new|fix|hotfix)", + "bump_map": { + "break": "MAJOR", + "new": "MINOR", + "fix": "PATCH", + "hotfix": "PATCH" + }, + "info_path": "info.txt" + } + } + } +""" + +YAML_STR_INFO_PATH = """ +commitizen: + customize: + message_template: "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example: 'feature: this feature enables customization through a config file' + schema: ": " + bump_pattern: "^(break|new|fix|hotfix)" + bump_map: + break: MAJOR + new: MINOR + fix: PATCH + hotfix: PATCH + info_path: info.txt +""" + +JSON_STR_WITHOUT_PATH = r""" + { + "commitizen": { + "customize": { + "message_template": "{{change_type}}:{% if show_message %} {{message}}{% endif %}", + "example": "feature: this feature enables customization through a config file", + "schema": ": ", + "bump_pattern": "^(break|new|fix|hotfix)", + "bump_map": { + "break": "MAJOR", + "new": "MINOR", + "fix": "PATCH", + "hotfix": "PATCH" + } + } + } + } +""" + +YAML_STR_WITHOUT_PATH = """ +commitizen: + customize: + message_template: "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example: 'feature: this feature enables customization through a config file' + schema: ": " + bump_pattern: "^(break|new|fix|hotfix)" + bump_map: + break: MAJOR + new: MINOR + fix: PATCH + hotfix: PATCH +""" + + +@pytest.fixture( + params=[ + TomlConfig(data=TOML_STR, path="not_exist.toml"), + JsonConfig(data=JSON_STR, path="not_exist.json"), + ] +) +def config(request): + """Parametrize the config fixture + + This fixture allow to test multiple config formats, + without add the builtin parametrize decorator + """ + return request.param + + +@pytest.fixture( + params=[ + TomlConfig(data=TOML_STR_INFO_PATH, path="not_exist.toml"), + JsonConfig(data=JSON_STR_INFO_PATH, path="not_exist.json"), + YAMLConfig(data=YAML_STR_INFO_PATH, path="not_exist.yaml"), + ] +) +def config_info(request): + return request.param + + +@pytest.fixture( + params=[ + TomlConfig(data=TOML_WITH_UNICODE, path="not_exist.toml"), + JsonConfig(data=JSON_WITH_UNICODE, path="not_exist.json"), + ] +) +def config_with_unicode(request): + return request.param + + +def test_bump_pattern(config): + cz = ConventionalCommitsCz(config) + assert cz.bump_pattern == "^(break|new|fix|hotfix)" + + +def test_bump_pattern_unicode(config_with_unicode): + cz = ConventionalCommitsCz(config_with_unicode) + assert cz.bump_pattern == "^(✨ feat|🐛 bug fix)" + + +def test_bump_map(config): + cz = ConventionalCommitsCz(config) + assert cz.bump_map == { + "break": "MAJOR", + "new": "MINOR", + "fix": "PATCH", + "hotfix": "PATCH", + } + + +def test_bump_map_unicode(config_with_unicode): + cz = ConventionalCommitsCz(config_with_unicode) + assert cz.bump_map == { + "break": "MAJOR", + "✨ feat": "MINOR", + "🐛 bug fix": "MINOR", + } + + +def test_change_type_order(config): + cz = ConventionalCommitsCz(config) + assert cz.change_type_order == [ + "perf", + "BREAKING CHANGE", + "feat", + "fix", + "refactor", + ] + + +def test_change_type_order_unicode(config_with_unicode): + cz = ConventionalCommitsCz(config_with_unicode) + assert cz.change_type_order == [ + "perf", + "BREAKING CHANGE", + "feat", + "fix", + "refactor", + ] + + +def test_questions(config): + cz = ConventionalCommitsCz(config) + questions = cz.questions() + expected_questions = [ + { + "type": "list", + "name": "change_type", + "choices": [ + {"value": "feature", "name": "feature: A new feature."}, + {"value": "bug fix", "name": "bug fix: A bug fix."}, + ], + "message": "Select the type of change you are committing", + }, + {"type": "input", "name": "message", "message": "Body."}, + { + "type": "confirm", + "name": "show_message", + "message": "Do you want to add body message in commit?", + }, + ] + assert list(questions) == expected_questions + + +def test_questions_unicode(config_with_unicode): + cz = ConventionalCommitsCz(config_with_unicode) + questions = cz.questions() + expected_questions = [ + { + "type": "list", + "name": "change_type", + "choices": [ + {"value": "✨ feature", "name": "✨ feature: A new feature."}, + {"value": "🐛 bug fix", "name": "🐛 bug fix: A bug fix."}, + ], + "message": "Select the type of change you are committing", + }, + {"type": "input", "name": "message", "message": "Body."}, + { + "type": "confirm", + "name": "show_message", + "message": "Do you want to add body message in commit?", + }, + ] + assert list(questions) == expected_questions + + +def test_answer(config): + cz = ConventionalCommitsCz(config) + answers = { + "change_type": "feature", + "message": "this feature enable customize through config file", + "show_message": True, + } + message = cz.message(answers) + assert message == "feature: this feature enable customize through config file" + + cz = ConventionalCommitsCz(config) + answers = { + "change_type": "feature", + "message": "this feature enable customize through config file", + "show_message": False, + } + message = cz.message(answers) + assert message == "feature:" + + +def test_answer_unicode(config_with_unicode): + cz = ConventionalCommitsCz(config_with_unicode) + answers = { + "change_type": "✨ feature", + "message": "this feature enables customization through a config file", + "show_message": True, + } + message = cz.message(answers) + assert ( + message + == "✨ feature: this feature enables customization through a config file" + ) + + cz = ConventionalCommitsCz(config_with_unicode) + answers = { + "change_type": "✨ feature", + "message": "this feature enables customization through a config file", + "show_message": False, + } + message = cz.message(answers) + assert message == "✨ feature:" + + +def test_example(config): + cz = ConventionalCommitsCz(config) + assert ( + "feature: this feature enables customization through a config file" + in cz.example() + ) + + +def test_example_unicode(config_with_unicode): + cz = ConventionalCommitsCz(config_with_unicode) + assert ( + "✨ feature: this feature enables customization through a config file" + in cz.example() + ) + + +def test_schema(config): + cz = ConventionalCommitsCz(config) + assert ": " in cz.schema() + + +def test_schema_pattern(config): + cz = ConventionalCommitsCz(config) + assert r"(feature|bug fix):(\s.*)" in cz.schema_pattern() + + +def test_schema_pattern_unicode(config_with_unicode): + cz = ConventionalCommitsCz(config_with_unicode) + assert r"(✨ feature|🐛 bug fix):(\s.*)" in cz.schema_pattern() + + +def test_info(config): + cz = ConventionalCommitsCz(config) + assert "This is a customized cz." in cz.info() + + +def test_info_unicode(config_with_unicode): + cz = ConventionalCommitsCz(config_with_unicode) + assert "This is a customized cz with emojis 🎉!" in cz.info() + + +def test_info_with_info_path(tmpdir, config_info): + with tmpdir.as_cwd(): + tmpfile = tmpdir.join("info.txt") + tmpfile.write("Test info") + + cz = ConventionalCommitsCz(config_info) + assert "Test info" in cz.info() + + +def test_commit_parser(config): + cz = ConventionalCommitsCz(config) + assert cz.commit_parser == "^(?Pfeature|bug fix):\\s(?P.*)?" + + +def test_commit_parser_unicode(config_with_unicode): + cz = ConventionalCommitsCz(config_with_unicode) + assert ( + cz.commit_parser + == "^(?P✨ feature|🐛 bug fix):\\s(?P.*)?" + ) + + +def test_changelog_pattern(config): + cz = ConventionalCommitsCz(config) + assert cz.changelog_pattern == "^(feature|bug fix)?(!)?" + + +def test_changelog_pattern_unicode(config_with_unicode): + cz = ConventionalCommitsCz(config_with_unicode) + assert cz.changelog_pattern == "^(✨ feature|🐛 bug fix)?(!)?" + + +def test_change_type_map(config): + cz = ConventionalCommitsCz(config) + assert cz.change_type_map == {"feature": "Feat", "bug fix": "Fix"} + + +def test_change_type_map_unicode(config_with_unicode): + cz = ConventionalCommitsCz(config_with_unicode) + assert cz.change_type_map == {"✨ feature": "Feat", "🐛 bug fix": "Fix"} From 3129cd693e0f292784835f21380c8c1982c1fc0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20=27ax=27=20H=C5=AFla?= Date: Thu, 7 Aug 2025 11:07:59 +0200 Subject: [PATCH 2/2] refactor(conventional-commits): remove string.Template usage as jinja2 is standard dependency --- .../conventional_commits.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/commitizen/cz/conventional_commits/conventional_commits.py b/commitizen/cz/conventional_commits/conventional_commits.py index 3bdd22e04..00f186724 100644 --- a/commitizen/cz/conventional_commits/conventional_commits.py +++ b/commitizen/cz/conventional_commits/conventional_commits.py @@ -1,6 +1,8 @@ import os from collections.abc import Iterable -from typing import TYPE_CHECKING, TypedDict +from typing import TypedDict + +from jinja2 import Template from commitizen import defaults from commitizen.config import BaseConfig @@ -8,14 +10,6 @@ from commitizen.cz.utils import multiple_line_breaker, required_validator from commitizen.question import CzQuestion -if TYPE_CHECKING: - from jinja2 import Template -else: - try: - from jinja2 import Template - except ImportError: - from string import Template - __all__ = ["ConventionalCommitsCz"] @@ -181,10 +175,7 @@ def questions(self) -> Iterable[CzQuestion]: def message(self, answers: ConventionalCommitsAnswers) -> str: # type: ignore[override] if _message_template := self.custom_settings.get("message_template"): - message_template = Template(_message_template) - if getattr(Template, "substitute", None): - return message_template.substitute(**answers) # type: ignore[attr-defined,no-any-return] # pragma: no cover # TODO: check if we can fix this - return message_template.render(**answers) + return Template(_message_template).render(**answers) prefix = answers["prefix"] scope = answers["scope"]