From 13116d5cd03608bdcc0904e5e3b1760187c3964b Mon Sep 17 00:00:00 2001 From: grahamhar Date: Mon, 26 Feb 2024 20:50:59 +0000 Subject: [PATCH 1/6] fix(changelog): handle custom tag_format in changelog generation When the tag_format does not follow the allowed schemas patterns then changlog generation fails. --- commitizen/changelog.py | 33 +++++++++++++++++------ commitizen/changelog_formats/base.py | 22 ++++++++++++++- commitizen/commands/changelog.py | 9 +++---- commitizen/providers/scm_provider.py | 6 +++++ docs/tutorials/monorepo_guidance.md | 40 ++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 14 deletions(-) create mode 100644 docs/tutorials/monorepo_guidance.md diff --git a/commitizen/changelog.py b/commitizen/changelog.py index 12d52f7b08..7da4c7a4df 100644 --- a/commitizen/changelog.py +++ b/commitizen/changelog.py @@ -93,16 +93,34 @@ def tag_included_in_changelog( return True -def get_version_tags(scheme: type[BaseVersion], tags: list[GitTag]) -> list[GitTag]: +def get_version_tags( + scheme: type[BaseVersion], tags: list[GitTag], tag_format: str +) -> list[GitTag]: valid_tags: list[GitTag] = [] + TAG_FORMAT_REGEXS = { + "$version": str(scheme.parser.pattern), + "$major": r"(?P\d+)", + "$minor": r"(?P\d+)", + "$patch": r"(?P\d+)", + "$prerelease": r"(?P\w+\d+)?", + "$devrelease": r"(?P\.dev\d+)?", + "${version}": str(scheme.parser.pattern), + "${major}": r"(?P\d+)", + "${minor}": r"(?P\d+)", + "${patch}": r"(?P\d+)", + "${prerelease}": r"(?P\w+\d+)?", + "${devrelease}": r"(?P\.dev\d+)?", + } + tag_format_regex = tag_format + for pattern, regex in TAG_FORMAT_REGEXS.items(): + tag_format_regex = tag_format_regex.replace(pattern, regex) for tag in tags: - try: - scheme(tag.name) - except InvalidVersion: - out.warn(f"InvalidVersion {tag}") - else: + if re.match(tag_format_regex, tag.name): valid_tags.append(tag) - + else: + out.warn( + f"InvalidVersion {tag.name} doesn't match configured tag format {tag_format}" + ) return valid_tags @@ -351,7 +369,6 @@ def get_oldest_and_newest_rev( oldest, newest = version.split("..") except ValueError: newest = version - newest_tag = normalize_tag(newest, tag_format=tag_format, scheme=scheme) oldest_tag = None diff --git a/commitizen/changelog_formats/base.py b/commitizen/changelog_formats/base.py index 7c802d63d4..807b3658cb 100644 --- a/commitizen/changelog_formats/base.py +++ b/commitizen/changelog_formats/base.py @@ -25,10 +25,30 @@ def __init__(self, config: BaseConfig): # See: https://bugs.python.org/issue44807 self.config = config self.encoding = self.config.settings["encoding"] + self.tag_format = self.config.settings.get("tag_format") @property def version_parser(self) -> Pattern: - return get_version_scheme(self.config).parser + version_regex = get_version_scheme(self.config).parser.pattern + if self.tag_format != "$version": + TAG_FORMAT_REGEXS = { + "$version": version_regex, + "$major": "(?P\d+)", + "$minor": "(?P\d+)", + "$patch": "(?P\d+)", + "$prerelease": "(?P\w+\d+)?", + "$devrelease": "(?P\.dev\d+)?", + "${version}": version_regex, + "${major}": "(?P\d+)", + "${minor}": "(?P\d+)", + "${patch}": "(?P\d+)", + "${prerelease}": "(?P\w+\d+)?", + "${devrelease}": "(?P\.dev\d+)?", + } + version_regex = self.tag_format + for pattern, regex in TAG_FORMAT_REGEXS.items(): + version_regex = version_regex.replace(pattern, regex) + return rf"{version_regex}" def get_metadata(self, filepath: str) -> Metadata: if not os.path.isfile(filepath): diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py index bda7a1844f..25e644aaef 100644 --- a/commitizen/commands/changelog.py +++ b/commitizen/commands/changelog.py @@ -168,8 +168,10 @@ def __call__(self): # Don't continue if no `file_name` specified. assert self.file_name - tags = changelog.get_version_tags(self.scheme, git.get_tags()) or [] - + tags = ( + changelog.get_version_tags(self.scheme, git.get_tags(), self.tag_format) + or [] + ) end_rev = "" if self.incremental: changelog_meta = self.changelog_format.get_metadata(self.file_name) @@ -182,7 +184,6 @@ def __call__(self): start_rev = self._find_incremental_rev( strip_local_version(latest_tag_version), tags ) - if self.rev_range: start_rev, end_rev = changelog.get_oldest_and_newest_rev( tags, @@ -190,13 +191,11 @@ def __call__(self): tag_format=self.tag_format, scheme=self.scheme, ) - commits = git.get_commits(start=start_rev, end=end_rev, args="--topo-order") if not commits and ( self.current_version is None or not self.current_version.is_prerelease ): raise NoCommitsFoundError("No commits found") - tree = changelog.generate_tree_from_commits( commits, tags, diff --git a/commitizen/providers/scm_provider.py b/commitizen/providers/scm_provider.py index 00df3e4153..26ca593d27 100644 --- a/commitizen/providers/scm_provider.py +++ b/commitizen/providers/scm_provider.py @@ -29,6 +29,12 @@ class ScmProvider(VersionProvider): "$patch": r"(?P\d+)", "$prerelease": r"(?P\w+\d+)?", "$devrelease": r"(?P\.dev\d+)?", + "${version}": r"(?P.+)", + "${major}": r"(?P\d+)", + "${minor}": r"(?P\d+)", + "${patch}": r"(?P\d+)", + "${prerelease}": r"(?P\w+\d+)?", + "${devrelease}": r"(?P\.dev\d+)?", } def _tag_format_matcher(self) -> Callable[[str], VersionProtocol | None]: diff --git a/docs/tutorials/monorepo_guidance.md b/docs/tutorials/monorepo_guidance.md new file mode 100644 index 0000000000..5bb334d725 --- /dev/null +++ b/docs/tutorials/monorepo_guidance.md @@ -0,0 +1,40 @@ +# Configuring commitizen in a monorepo + +This tutorial assumes the monorepo layout is designed with multiple components that can be released independently of each other, +some suggested layouts: + +``` +library-a + .cz.toml +library-b + .cz.toml +``` + +``` +src + library-b + .cz.toml + library-z + .cz.toml +``` + +Each component will have its own changelog, commits will need to use scopes so only relevant commits are included in the +appropriate change log for a given component. Example config and commit for `library-b` + +```toml +[tool.commitizen] +name = "cz_customize" +version = "0.0.0" +tag_format = "${version}-library-b" # the component name can be a prefix or suffix with or without a separator +update_changelog_on_bump = true + +[tool.commitizen.customize] +changelog_pattern = "^(feat|fix)\\(library-b\\)(!)?:" #the pattern on types can be a wild card or any types you wish to include +``` + +example commit message for the above + +`fix:(library-b) Some awesome message` + +If the above is followed and the `cz bump --changelog` is run in the directory containing the component the changelog +should be generated in the same directory with only commits scoped to the component. From 1d493aaa44c1b509432b243c264d2958a38c8489 Mon Sep 17 00:00:00 2001 From: grahamhar Date: Sat, 2 Mar 2024 14:03:36 +0000 Subject: [PATCH 2/6] test(changelog): handle custom tag_format in changelog generation --- tests/commands/test_changelog_command.py | 114 +++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/tests/commands/test_changelog_command.py b/tests/commands/test_changelog_command.py index 3d9a5c5c48..5c7a8a9faa 100644 --- a/tests/commands/test_changelog_command.py +++ b/tests/commands/test_changelog_command.py @@ -1523,6 +1523,120 @@ def test_changelog_template_extras_precedance( assert changelog.read_text() == "from-command - from-config - from-plugin" +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2021-06-11") +def test_changelog_only_tag_matching_tag_format_included_prefix( + mocker: MockFixture, + changelog_path: Path, + config_path: Path, +): + with open(config_path, "a", encoding="utf-8") as f: + f.write('\ntag_format = "custom${version}"\n') + create_file_and_commit("feat: new file") + git.tag("v0.2.0") + create_file_and_commit("feat: another new file") + git.tag("0.2.0") + git.tag("random0.2.0") + testargs = ["cz", "bump", "--changelog", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + create_file_and_commit("feat: another new file") + cli.main() + with open(changelog_path) as f: + out = f.read() + assert out.startswith("## custom0.3.0 (2021-06-11)") + assert "## v0.2.0 (2021-06-11)" not in out + assert "## 0.2.0 (2021-06-11)" not in out + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_only_tag_matching_tag_format_included_prefix_sep( + mocker: MockFixture, + changelog_path: Path, + config_path: Path, +): + with open(config_path, "a", encoding="utf-8") as f: + f.write('\ntag_format = "custom-${version}"\n') + create_file_and_commit("feat: new file") + git.tag("v0.2.0") + create_file_and_commit("feat: another new file") + git.tag("0.2.0") + git.tag("random0.2.0") + wait_for_tag() + testargs = ["cz", "bump", "--changelog", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + with open(changelog_path) as f: + out = f.read() + create_file_and_commit("feat: new version another new file") + create_file_and_commit("feat: new version some new file") + testargs = ["cz", "bump", "--changelog"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + with open(changelog_path) as f: + out = f.read() + assert out.startswith("## custom-0.3.0") + assert "## v0.2.0" not in out + assert "## 0.2.0" not in out + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2021-06-11") +def test_changelog_only_tag_matching_tag_format_included_suffix( + mocker: MockFixture, + changelog_path: Path, + config_path: Path, +): + with open(config_path, "a", encoding="utf-8") as f: + f.write('\ntag_format = "${version}custom"\n') + create_file_and_commit("feat: new file") + git.tag("v0.2.0") + create_file_and_commit("feat: another new file") + git.tag("0.2.0") + git.tag("random0.2.0") + testargs = ["cz", "bump", "--changelog", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + create_file_and_commit("feat: another new file") + cli.main() + wait_for_tag() + with open(changelog_path) as f: + out = f.read() + assert out.startswith("## 0.3.0custom (2021-06-11)") + assert "## v0.2.0 (2021-06-11)" not in out + assert "## 0.2.0 (2021-06-11)" not in out + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2021-06-11") +def test_changelog_only_tag_matching_tag_format_included_suffix_sep( + mocker: MockFixture, + changelog_path: Path, + config_path: Path, +): + with open(config_path, "a", encoding="utf-8") as f: + f.write('\ntag_format = "${version}-custom"\n') + create_file_and_commit("feat: new file") + git.tag("v0.2.0") + create_file_and_commit("feat: another new file") + git.tag("0.2.0") + git.tag("random0.2.0") + testargs = ["cz", "bump", "--changelog", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + create_file_and_commit("feat: another new file") + cli.main() + wait_for_tag() + with open(changelog_path) as f: + out = f.read() + assert out.startswith("## 0.3.0-custom (2021-06-11)") + assert "## v0.2.0 (2021-06-11)" not in out + assert "## 0.2.0 (2021-06-11)" not in out + + def test_changelog_template_extra_quotes( mocker: MockFixture, tmp_commitizen_project: Path, From 2e1c55381cad9db0400e121de8551c5ba4695743 Mon Sep 17 00:00:00 2001 From: grahamhar Date: Mon, 1 Apr 2024 18:38:40 +0100 Subject: [PATCH 3/6] fix(changelog): Handle tag format without version pattern --- commitizen/changelog.py | 4 +- commitizen/changelog_formats/asciidoc.py | 14 +++- commitizen/changelog_formats/base.py | 40 +++++------ commitizen/changelog_formats/markdown.py | 16 ++++- .../changelog_formats/restructuredtext.py | 24 +++++-- commitizen/changelog_formats/textile.py | 16 ++++- tests/test_changelog_format_asciidoc.py | 61 +++++++++++++++++ tests/test_changelog_format_markdown.py | 67 +++++++++++++++++++ .../test_changelog_format_restructuredtext.py | 66 ++++++++++++++++++ tests/test_changelog_format_textile.py | 55 +++++++++++++++ 10 files changed, 333 insertions(+), 30 deletions(-) diff --git a/commitizen/changelog.py b/commitizen/changelog.py index 7da4c7a4df..fc9d567002 100644 --- a/commitizen/changelog.py +++ b/commitizen/changelog.py @@ -98,13 +98,13 @@ def get_version_tags( ) -> list[GitTag]: valid_tags: list[GitTag] = [] TAG_FORMAT_REGEXS = { - "$version": str(scheme.parser.pattern), + "$version": scheme.parser.pattern, "$major": r"(?P\d+)", "$minor": r"(?P\d+)", "$patch": r"(?P\d+)", "$prerelease": r"(?P\w+\d+)?", "$devrelease": r"(?P\.dev\d+)?", - "${version}": str(scheme.parser.pattern), + "${version}": scheme.parser.pattern, "${major}": r"(?P\d+)", "${minor}": r"(?P\d+)", "${patch}": r"(?P\d+)", diff --git a/commitizen/changelog_formats/asciidoc.py b/commitizen/changelog_formats/asciidoc.py index d738926f6e..bca7464b06 100644 --- a/commitizen/changelog_formats/asciidoc.py +++ b/commitizen/changelog_formats/asciidoc.py @@ -18,7 +18,19 @@ def parse_version_from_title(self, line: str) -> str | None: matches = list(re.finditer(self.version_parser, m.group("title"))) if not matches: return None - return matches[-1].group("version") + if "version" in matches[-1].groupdict(): + return matches[-1].group("version") + partial_matches = matches[-1].groupdict() + try: + partial_version = f"{partial_matches['major']}.{partial_matches['minor']}.{partial_matches['patch']}" + except KeyError: + return None + + if partial_matches.get("prerelease"): + partial_version += f"-{partial_matches['prerelease']}" + if partial_matches.get("devrelease"): + partial_version += f"{partial_matches['devrelease']}" + return partial_version def parse_title_level(self, line: str) -> int | None: m = self.RE_TITLE.match(line) diff --git a/commitizen/changelog_formats/base.py b/commitizen/changelog_formats/base.py index 807b3658cb..8c41c7136a 100644 --- a/commitizen/changelog_formats/base.py +++ b/commitizen/changelog_formats/base.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import re from abc import ABCMeta from re import Pattern from typing import IO, Any, ClassVar @@ -25,30 +26,29 @@ def __init__(self, config: BaseConfig): # See: https://bugs.python.org/issue44807 self.config = config self.encoding = self.config.settings["encoding"] - self.tag_format = self.config.settings.get("tag_format") + self.tag_format = self.config.settings["tag_format"] @property def version_parser(self) -> Pattern: + tag_regex: str = self.tag_format version_regex = get_version_scheme(self.config).parser.pattern - if self.tag_format != "$version": - TAG_FORMAT_REGEXS = { - "$version": version_regex, - "$major": "(?P\d+)", - "$minor": "(?P\d+)", - "$patch": "(?P\d+)", - "$prerelease": "(?P\w+\d+)?", - "$devrelease": "(?P\.dev\d+)?", - "${version}": version_regex, - "${major}": "(?P\d+)", - "${minor}": "(?P\d+)", - "${patch}": "(?P\d+)", - "${prerelease}": "(?P\w+\d+)?", - "${devrelease}": "(?P\.dev\d+)?", - } - version_regex = self.tag_format - for pattern, regex in TAG_FORMAT_REGEXS.items(): - version_regex = version_regex.replace(pattern, regex) - return rf"{version_regex}" + TAG_FORMAT_REGEXS = { + "$version": version_regex, + "$major": r"(?P\d+)", + "$minor": r"(?P\d+)", + "$patch": r"(?P\d+)", + "$prerelease": r"(?P\w+\d+)?", + "$devrelease": r"(?P\.dev\d+)?", + "${version}": version_regex, + "${major}": r"(?P\d+)", + "${minor}": r"(?P\d+)", + "${patch}": r"(?P\d+)", + "${prerelease}": r"(?P\w+\d+)?", + "${devrelease}": r"(?P\.dev\d+)?", + } + for pattern, regex in TAG_FORMAT_REGEXS.items(): + tag_regex = tag_regex.replace(pattern, regex) + return re.compile(tag_regex) def get_metadata(self, filepath: str) -> Metadata: if not os.path.isfile(filepath): diff --git a/commitizen/changelog_formats/markdown.py b/commitizen/changelog_formats/markdown.py index a5a0f42de3..2e9aa23663 100644 --- a/commitizen/changelog_formats/markdown.py +++ b/commitizen/changelog_formats/markdown.py @@ -19,7 +19,21 @@ def parse_version_from_title(self, line: str) -> str | None: m = re.search(self.version_parser, m.group("title")) if not m: return None - return m.group("version") + if "version" in m.groupdict(): + return m.group("version") + matches = m.groupdict() + try: + partial_version = ( + f"{matches['major']}.{matches['minor']}.{matches['patch']}" + ) + except KeyError: + return None + + if matches.get("prerelease"): + partial_version += f"-{matches['prerelease']}" + if matches.get("devrelease"): + partial_version += f"{matches['devrelease']}" + return partial_version def parse_title_level(self, line: str) -> int | None: m = self.RE_TITLE.match(line) diff --git a/commitizen/changelog_formats/restructuredtext.py b/commitizen/changelog_formats/restructuredtext.py index 37acf81ef3..be07322c9b 100644 --- a/commitizen/changelog_formats/restructuredtext.py +++ b/commitizen/changelog_formats/restructuredtext.py @@ -46,7 +46,6 @@ def get_metadata_from_file(self, file: IO[Any]) -> Metadata: third = third.strip().lower() title: str | None = None kind: TitleKind | None = None - if self.is_overlined_title(first, second, third): title = second kind = (first[0], third[0]) @@ -67,10 +66,25 @@ def get_metadata_from_file(self, file: IO[Any]) -> Metadata: # Try to find the latest release done m = re.search(self.version_parser, title) if m: - version = m.group("version") - meta.latest_version = version - meta.latest_version_position = index - break # there's no need for more info + matches = m.groupdict() + if "version" in matches: + version = m.group("version") + meta.latest_version = version + meta.latest_version_position = index + break # there's no need for more info + try: + partial_version = ( + f"{matches['major']}.{matches['minor']}.{matches['patch']}" + ) + if matches.get("prerelease"): + partial_version += f"-{matches['prerelease']}" + if matches.get("devrelease"): + partial_version += f"{matches['devrelease']}" + meta.latest_version = partial_version + meta.latest_version_position = index + break + except KeyError: + pass if meta.unreleased_start is not None and meta.unreleased_end is None: meta.unreleased_end = ( meta.latest_version_position if meta.latest_version else index + 1 diff --git a/commitizen/changelog_formats/textile.py b/commitizen/changelog_formats/textile.py index 80118cdb3c..4f34b522fa 100644 --- a/commitizen/changelog_formats/textile.py +++ b/commitizen/changelog_formats/textile.py @@ -16,7 +16,21 @@ def parse_version_from_title(self, line: str) -> str | None: m = re.search(self.version_parser, line) if not m: return None - return m.group("version") + if "version" in m.groupdict(): + return m.group("version") + matches = m.groupdict() + try: + partial_version = ( + f"{matches['major']}.{matches['minor']}.{matches['patch']}" + ) + except KeyError: + return None + + if matches.get("prerelease"): + partial_version += f"-{matches['prerelease']}" + if matches.get("devrelease"): + partial_version += f"{matches['devrelease']}" + return partial_version def parse_title_level(self, line: str) -> int | None: m = self.RE_TITLE.match(line) diff --git a/tests/test_changelog_format_asciidoc.py b/tests/test_changelog_format_asciidoc.py index 89740d2147..0c5930df46 100644 --- a/tests/test_changelog_format_asciidoc.py +++ b/tests/test_changelog_format_asciidoc.py @@ -72,12 +72,42 @@ unreleased_start=1, ) +CHANGELOG_E = """ += Changelog + +All notable changes to this project will be documented in this file. + +The format is based on https://keepachangelog.com/en/1.0.0/[Keep a Changelog], +and this project adheres to https://semver.org/spec/v2.0.0.html[Semantic Versioning]. + +== [Unreleased] +* Start using "changelog" over "change log" since it's the common usage. + +== [{tag_formatted_version}] - 2017-06-20 +=== Added +* New visual identity by https://github.com/tylerfortune8[@tylerfortune8]. +* Version navigation. +""".strip() + +EXPECTED_E = Metadata( + latest_version="1.0.0", + latest_version_position=10, + unreleased_end=10, + unreleased_start=7, +) + @pytest.fixture def format(config: BaseConfig) -> AsciiDoc: return AsciiDoc(config) +@pytest.fixture +def format_with_tags(config: BaseConfig, request) -> AsciiDoc: + config.settings["tag_format"] = request.param + return AsciiDoc(config) + + VERSIONS_EXAMPLES = [ ("== [1.0.0] - 2017-06-20", "1.0.0"), ( @@ -135,3 +165,34 @@ def test_get_matadata( changelog.write_text(content) assert format.get_metadata(str(changelog)) == expected + + +@pytest.mark.parametrize( + "format_with_tags, tag_string, expected, ", + ( + pytest.param("${version}-example", "1.0.0-example", "1.0.0"), + pytest.param("${version}example", "1.0.0example", "1.0.0"), + pytest.param("example${version}", "example1.0.0", "1.0.0"), + pytest.param("example-${version}", "example-1.0.0", "1.0.0"), + pytest.param("example-${major}-${minor}-${patch}", "example-1-0-0", "1.0.0"), + pytest.param("example-${major}-${minor}", "example-1-0-0", None), + pytest.param( + "${major}-${minor}-${patch}-${prerelease}-example", + "1-0-0-rc1-example", + "1.0.0-rc1", + ), + pytest.param( + "${major}-${minor}-${patch}-${prerelease}${devrelease}-example", + "1-0-0-a1.dev1-example", + "1.0.0-a1.dev1", + ), + ), + indirect=["format_with_tags"], +) +def test_get_metadata_custom_tag_format( + tmp_path: Path, format_with_tags: AsciiDoc, tag_string: str, expected: Metadata +): + content = CHANGELOG_E.format(tag_formatted_version=tag_string) + changelog = tmp_path / format_with_tags.default_changelog_file + changelog.write_text(content) + assert format_with_tags.get_metadata(str(changelog)).latest_version == expected diff --git a/tests/test_changelog_format_markdown.py b/tests/test_changelog_format_markdown.py index ab7c65453c..52612b8e2b 100644 --- a/tests/test_changelog_format_markdown.py +++ b/tests/test_changelog_format_markdown.py @@ -72,12 +72,42 @@ unreleased_start=1, ) +CHANGELOG_E = """ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +- Start using "changelog" over "change log" since it's the common usage. + +## {tag_formatted_version} - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. +""".strip() + +EXPECTED_E = Metadata( + latest_version="1.0.0", + latest_version_position=10, + unreleased_end=10, + unreleased_start=7, +) + @pytest.fixture def format(config: BaseConfig) -> Markdown: return Markdown(config) +@pytest.fixture +def format_with_tags(config: BaseConfig, request) -> Markdown: + config.settings["tag_format"] = request.param + return Markdown(config) + + VERSIONS_EXAMPLES = [ ("## [1.0.0] - 2017-06-20", "1.0.0"), ( @@ -135,3 +165,40 @@ def test_get_matadata( changelog.write_text(content) assert format.get_metadata(str(changelog)) == expected + + +@pytest.mark.parametrize( + "format_with_tags, tag_string, expected, ", + ( + pytest.param("${version}-example", "1.0.0-example", "1.0.0"), + pytest.param("${version}example", "1.0.0example", "1.0.0"), + pytest.param("example${version}", "example1.0.0", "1.0.0"), + pytest.param("example-${version}", "example-1.0.0", "1.0.0"), + pytest.param("example-${major}-${minor}-${patch}", "example-1-0-0", "1.0.0"), + pytest.param("example-${major}-${minor}", "example-1-0-0", None), + pytest.param( + "${major}-${minor}-${patch}-${prerelease}-example", + "1-0-0-rc1-example", + "1.0.0-rc1", + ), + pytest.param( + "${major}-${minor}-${patch}-${prerelease}-example", + "1-0-0-a1-example", + "1.0.0-a1", + ), + pytest.param( + "${major}-${minor}-${patch}-${prerelease}${devrelease}-example", + "1-0-0-a1.dev1-example", + "1.0.0-a1.dev1", + ), + ), + indirect=["format_with_tags"], +) +def test_get_metadata_custom_tag_format( + tmp_path: Path, format_with_tags: Markdown, tag_string: str, expected: Metadata +): + content = CHANGELOG_E.format(tag_formatted_version=tag_string) + changelog = tmp_path / format_with_tags.default_changelog_file + changelog.write_text(content) + + assert format_with_tags.get_metadata(str(changelog)).latest_version == expected diff --git a/tests/test_changelog_format_restructuredtext.py b/tests/test_changelog_format_restructuredtext.py index 46a11ebcdf..11356ae28f 100644 --- a/tests/test_changelog_format_restructuredtext.py +++ b/tests/test_changelog_format_restructuredtext.py @@ -273,12 +273,39 @@ def case( """, ) +CHANGELOG = """ +Changelog + ######### + + All notable changes to this project will be documented in this file. + + The format is based on `Keep a Changelog `, + and this project adheres to `Semantic Versioning `. + + Unreleased + ========== + * Start using "changelog" over "change log" since it's the common usage. + + {tag_formatted_version} - 2017-06-20 + {underline} + Added + ----- + * New visual identity by `@tylerfortune8 `. + * Version navigation. +""".strip() + @pytest.fixture def format(config: BaseConfig) -> RestructuredText: return RestructuredText(config) +@pytest.fixture +def format_with_tags(config: BaseConfig, request) -> RestructuredText: + config.settings["tag_format"] = request.param + return RestructuredText(config) + + @pytest.mark.parametrize("content, expected", CASES) def test_get_matadata( tmp_path: Path, format: RestructuredText, content: str, expected: Metadata @@ -308,3 +335,42 @@ def test_is_overlined_title(format: RestructuredText, text: str, expected: bool) _, first, second, third = dedent(text).splitlines() assert format.is_overlined_title(first, second, third) is expected + + +@pytest.mark.parametrize( + "format_with_tags, tag_string, expected, ", + ( + pytest.param("${version}-example", "1.0.0-example", "1.0.0"), + pytest.param("${version}", "1.0.0", "1.0.0"), + pytest.param("${version}example", "1.0.0example", "1.0.0"), + pytest.param("example${version}", "example1.0.0", "1.0.0"), + pytest.param("example-${version}", "example-1.0.0", "1.0.0"), + pytest.param("example-${major}-${minor}-${patch}", "example-1-0-0", "1.0.0"), + pytest.param("example-${major}-${minor}", "example-1-0-0", None), + pytest.param( + "${major}-${minor}-${patch}-${prerelease}-example", + "1-0-0-rc1-example", + "1.0.0-rc1", + ), + pytest.param( + "${major}-${minor}-${patch}-${prerelease}${devrelease}-example", + "1-0-0-a1.dev1-example", + "1.0.0-a1.dev1", + ), + ), + indirect=["format_with_tags"], +) +def test_get_metadata_custom_tag_format( + tmp_path: Path, + format_with_tags: RestructuredText, + tag_string: str, + expected: Metadata, +): + content = CHANGELOG.format( + tag_formatted_version=tag_string, + underline="=" * len(tag_string) + "=============", + ) + changelog = tmp_path / format_with_tags.default_changelog_file + changelog.write_text(content) + + assert format_with_tags.get_metadata(str(changelog)).latest_version == expected diff --git a/tests/test_changelog_format_textile.py b/tests/test_changelog_format_textile.py index e382e1c746..3fac5c1756 100644 --- a/tests/test_changelog_format_textile.py +++ b/tests/test_changelog_format_textile.py @@ -72,12 +72,35 @@ unreleased_start=1, ) +CHANGELOG_E = """ +h1. Changelog + +All notable changes to this project will be documented in this file. + +The format is based on "Keep a Changelog":https://keepachangelog.com/en/1.0.0/, +and this project adheres to "Semantic Versioning":https://semver.org/spec/v2.0.0.html. + +h2. [Unreleased] +- Start using "changelog" over "change log" since it's the common usage. + +h2. [{tag_formatted_version}] - 2017-06-20 +h3. Added +* New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +* Version navigation. +""".strip() + @pytest.fixture def format(config: BaseConfig) -> Textile: return Textile(config) +@pytest.fixture +def format_with_tags(config: BaseConfig, request) -> Textile: + config.settings["tag_format"] = request.param + return Textile(config) + + VERSIONS_EXAMPLES = [ ("h2. [1.0.0] - 2017-06-20", "1.0.0"), ( @@ -135,3 +158,35 @@ def test_get_matadata( changelog.write_text(content) assert format.get_metadata(str(changelog)) == expected + + +@pytest.mark.parametrize( + "format_with_tags, tag_string, expected, ", + ( + pytest.param("${version}-example", "1.0.0-example", "1.0.0"), + pytest.param("${version}example", "1.0.0example", "1.0.0"), + pytest.param("example${version}", "example1.0.0", "1.0.0"), + pytest.param("example-${version}", "example-1.0.0", "1.0.0"), + pytest.param("example-${major}-${minor}-${patch}", "example-1-0-0", "1.0.0"), + pytest.param("example-${major}-${minor}", "example-1-0-0", None), + pytest.param( + "${major}-${minor}-${patch}-${prerelease}-example", + "1-0-0-rc1-example", + "1.0.0-rc1", + ), + pytest.param( + "${major}-${minor}-${patch}-${prerelease}${devrelease}-example", + "1-0-0-a1.dev1-example", + "1.0.0-a1.dev1", + ), + ), + indirect=["format_with_tags"], +) +def test_get_metadata_custom_tag_format( + tmp_path: Path, format_with_tags: Textile, tag_string: str, expected: Metadata +): + content = CHANGELOG_E.format(tag_formatted_version=tag_string) + changelog = tmp_path / format_with_tags.default_changelog_file + changelog.write_text(content) + + assert format_with_tags.get_metadata(str(changelog)).latest_version == expected From 97eb90cfec8ef3c79525be52d34e1dcf43b1baeb Mon Sep 17 00:00:00 2001 From: grahamhar Date: Sat, 20 Apr 2024 11:21:18 +0100 Subject: [PATCH 4/6] fix(changelog): Factorized TAG_FORMAT_REGEXES --- commitizen/changelog.py | 16 ++-------------- commitizen/changelog_formats/base.py | 16 ++-------------- commitizen/defaults.py | 17 +++++++++++++++++ commitizen/providers/scm_provider.py | 16 ++-------------- 4 files changed, 23 insertions(+), 42 deletions(-) diff --git a/commitizen/changelog.py b/commitizen/changelog.py index fc9d567002..32a66c47eb 100644 --- a/commitizen/changelog.py +++ b/commitizen/changelog.py @@ -44,6 +44,7 @@ from commitizen import out from commitizen.bump import normalize_tag from commitizen.cz.base import ChangelogReleaseHook +from commitizen.defaults import get_tag_regexes from commitizen.exceptions import InvalidConfigurationError, NoCommitsFoundError from commitizen.git import GitCommit, GitTag from commitizen.version_schemes import ( @@ -97,20 +98,7 @@ def get_version_tags( scheme: type[BaseVersion], tags: list[GitTag], tag_format: str ) -> list[GitTag]: valid_tags: list[GitTag] = [] - TAG_FORMAT_REGEXS = { - "$version": scheme.parser.pattern, - "$major": r"(?P\d+)", - "$minor": r"(?P\d+)", - "$patch": r"(?P\d+)", - "$prerelease": r"(?P\w+\d+)?", - "$devrelease": r"(?P\.dev\d+)?", - "${version}": scheme.parser.pattern, - "${major}": r"(?P\d+)", - "${minor}": r"(?P\d+)", - "${patch}": r"(?P\d+)", - "${prerelease}": r"(?P\w+\d+)?", - "${devrelease}": r"(?P\.dev\d+)?", - } + TAG_FORMAT_REGEXS = get_tag_regexes(scheme.parser.pattern) tag_format_regex = tag_format for pattern, regex in TAG_FORMAT_REGEXS.items(): tag_format_regex = tag_format_regex.replace(pattern, regex) diff --git a/commitizen/changelog_formats/base.py b/commitizen/changelog_formats/base.py index 8c41c7136a..415c404c91 100644 --- a/commitizen/changelog_formats/base.py +++ b/commitizen/changelog_formats/base.py @@ -9,6 +9,7 @@ from commitizen.changelog import Metadata from commitizen.config.base_config import BaseConfig from commitizen.version_schemes import get_version_scheme +from commitizen.defaults import get_tag_regexes from . import ChangelogFormat @@ -32,20 +33,7 @@ def __init__(self, config: BaseConfig): def version_parser(self) -> Pattern: tag_regex: str = self.tag_format version_regex = get_version_scheme(self.config).parser.pattern - TAG_FORMAT_REGEXS = { - "$version": version_regex, - "$major": r"(?P\d+)", - "$minor": r"(?P\d+)", - "$patch": r"(?P\d+)", - "$prerelease": r"(?P\w+\d+)?", - "$devrelease": r"(?P\.dev\d+)?", - "${version}": version_regex, - "${major}": r"(?P\d+)", - "${minor}": r"(?P\d+)", - "${patch}": r"(?P\d+)", - "${prerelease}": r"(?P\w+\d+)?", - "${devrelease}": r"(?P\.dev\d+)?", - } + TAG_FORMAT_REGEXS = get_tag_regexes(version_regex) for pattern, regex in TAG_FORMAT_REGEXS.items(): tag_regex = tag_regex.replace(pattern, regex) return re.compile(tag_regex) diff --git a/commitizen/defaults.py b/commitizen/defaults.py index e4363f4ab0..0f857f264a 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -132,3 +132,20 @@ class Settings(TypedDict, total=False): ) change_type_order = ["BREAKING CHANGE", "Feat", "Fix", "Refactor", "Perf"] bump_message = "bump: version $current_version → $new_version" + + +def get_tag_regexes(version_regex: str) -> dict[str | Any, str | Any]: + return { + "$version": version_regex, + "$major": r"(?P\d+)", + "$minor": r"(?P\d+)", + "$patch": r"(?P\d+)", + "$prerelease": r"(?P\w+\d+)?", + "$devrelease": r"(?P\.dev\d+)?", + "${version}": version_regex, + "${major}": r"(?P\d+)", + "${minor}": r"(?P\d+)", + "${patch}": r"(?P\d+)", + "${prerelease}": r"(?P\w+\d+)?", + "${devrelease}": r"(?P\.dev\d+)?", + } diff --git a/commitizen/providers/scm_provider.py b/commitizen/providers/scm_provider.py index 26ca593d27..33e470cfc6 100644 --- a/commitizen/providers/scm_provider.py +++ b/commitizen/providers/scm_provider.py @@ -3,6 +3,7 @@ import re from typing import Callable +from commitizen.defaults import get_tag_regexes from commitizen.git import get_tags from commitizen.providers.base_provider import VersionProvider from commitizen.version_schemes import ( @@ -22,20 +23,7 @@ class ScmProvider(VersionProvider): It is meant for `setuptools-scm` or any package manager `*-scm` provider. """ - TAG_FORMAT_REGEXS = { - "$version": r"(?P.+)", - "$major": r"(?P\d+)", - "$minor": r"(?P\d+)", - "$patch": r"(?P\d+)", - "$prerelease": r"(?P\w+\d+)?", - "$devrelease": r"(?P\.dev\d+)?", - "${version}": r"(?P.+)", - "${major}": r"(?P\d+)", - "${minor}": r"(?P\d+)", - "${patch}": r"(?P\d+)", - "${prerelease}": r"(?P\w+\d+)?", - "${devrelease}": r"(?P\.dev\d+)?", - } + TAG_FORMAT_REGEXS = get_tag_regexes(r"(?P.+)") def _tag_format_matcher(self) -> Callable[[str], VersionProtocol | None]: version_scheme = get_version_scheme(self.config) From e8fa7a25b4648cc04800eeb8d81ec2efd1e21f02 Mon Sep 17 00:00:00 2001 From: grahamhar Date: Sat, 20 Apr 2024 11:23:52 +0100 Subject: [PATCH 5/6] docs(bump): Document the use of tag_format variables with curly brackets --- docs/commands/bump.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/commands/bump.md b/docs/commands/bump.md index efb5b0881d..ed00193241 100644 --- a/docs/commands/bump.md +++ b/docs/commands/bump.md @@ -414,18 +414,24 @@ In your `pyproject.toml` or `.cz.toml` tag_format = "v$major.$minor.$patch$prerelease" ``` -The variables must be preceded by a `$` sign. Default is `$version`. +The variables must be preceded by a `$` sign and optionally can be wrapped in `{}` . Default is `$version`. Supported variables: -| Variable | Description | -| ------------- | ------------------------------------------- | -| `$version` | full generated version | -| `$major` | MAJOR increment | -| `$minor` | MINOR increment | -| `$patch` | PATCH increment | -| `$prerelease` | Prerelease (alpha, beta, release candidate) | -| `$devrelease` | Development release | +| Variable | Description | +|-----------------|---------------------------------------------| +| `$version` | full generated version | +| `$major` | MAJOR increment | +| `$minor` | MINOR increment | +| `$patch` | PATCH increment | +| `$prerelease` | Prerelease (alpha, beta, release candidate) | +| `$devrelease` | Development release | +| `${version}` | full generated version | +| `${major}` | MAJOR increment | +| `${minor}` | MINOR increment | +| `${patch}` | PATCH increment | +| `${prerelease}` | Prerelease (alpha, beta, release candidate) | +| `${devrelease}` | Development release | --- From 3d658617fb27cbfbf383e03739dba75cd54c56da Mon Sep 17 00:00:00 2001 From: Graham Hargreaves Date: Sat, 27 Apr 2024 12:45:29 +0100 Subject: [PATCH 6/6] refactor: Use format strings Co-authored-by: Wei Lee --- commitizen/changelog_formats/asciidoc.py | 4 ++-- commitizen/changelog_formats/base.py | 2 +- commitizen/changelog_formats/markdown.py | 4 ++-- .../changelog_formats/restructuredtext.py | 8 +++++-- commitizen/changelog_formats/textile.py | 17 ++++++++------ commitizen/defaults.py | 4 +++- docs/commands/bump.md | 22 +++++++------------ docs/tutorials/monorepo_guidance.md | 21 +++++++++--------- tests/commands/test_changelog_command.py | 2 ++ 9 files changed, 45 insertions(+), 39 deletions(-) diff --git a/commitizen/changelog_formats/asciidoc.py b/commitizen/changelog_formats/asciidoc.py index bca7464b06..6007a56d16 100644 --- a/commitizen/changelog_formats/asciidoc.py +++ b/commitizen/changelog_formats/asciidoc.py @@ -27,9 +27,9 @@ def parse_version_from_title(self, line: str) -> str | None: return None if partial_matches.get("prerelease"): - partial_version += f"-{partial_matches['prerelease']}" + partial_version = f"{partial_version}-{partial_matches['prerelease']}" if partial_matches.get("devrelease"): - partial_version += f"{partial_matches['devrelease']}" + partial_version = f"{partial_version}{partial_matches['devrelease']}" return partial_version def parse_title_level(self, line: str) -> int | None: diff --git a/commitizen/changelog_formats/base.py b/commitizen/changelog_formats/base.py index 415c404c91..53527a060c 100644 --- a/commitizen/changelog_formats/base.py +++ b/commitizen/changelog_formats/base.py @@ -8,8 +8,8 @@ from commitizen.changelog import Metadata from commitizen.config.base_config import BaseConfig -from commitizen.version_schemes import get_version_scheme from commitizen.defaults import get_tag_regexes +from commitizen.version_schemes import get_version_scheme from . import ChangelogFormat diff --git a/commitizen/changelog_formats/markdown.py b/commitizen/changelog_formats/markdown.py index 2e9aa23663..29c1cce54a 100644 --- a/commitizen/changelog_formats/markdown.py +++ b/commitizen/changelog_formats/markdown.py @@ -30,9 +30,9 @@ def parse_version_from_title(self, line: str) -> str | None: return None if matches.get("prerelease"): - partial_version += f"-{matches['prerelease']}" + partial_version = f"{partial_version}-{matches['prerelease']}" if matches.get("devrelease"): - partial_version += f"{matches['devrelease']}" + partial_version = f"{partial_version}{matches['devrelease']}" return partial_version def parse_title_level(self, line: str) -> int | None: diff --git a/commitizen/changelog_formats/restructuredtext.py b/commitizen/changelog_formats/restructuredtext.py index be07322c9b..09d032400c 100644 --- a/commitizen/changelog_formats/restructuredtext.py +++ b/commitizen/changelog_formats/restructuredtext.py @@ -77,9 +77,13 @@ def get_metadata_from_file(self, file: IO[Any]) -> Metadata: f"{matches['major']}.{matches['minor']}.{matches['patch']}" ) if matches.get("prerelease"): - partial_version += f"-{matches['prerelease']}" + partial_version = ( + f"{partial_version}-{matches['prerelease']}" + ) if matches.get("devrelease"): - partial_version += f"{matches['devrelease']}" + partial_version = ( + f"{partial_version}{matches['devrelease']}" + ) meta.latest_version = partial_version meta.latest_version_position = index break diff --git a/commitizen/changelog_formats/textile.py b/commitizen/changelog_formats/textile.py index 4f34b522fa..8750f0056c 100644 --- a/commitizen/changelog_formats/textile.py +++ b/commitizen/changelog_formats/textile.py @@ -19,17 +19,20 @@ def parse_version_from_title(self, line: str) -> str | None: if "version" in m.groupdict(): return m.group("version") matches = m.groupdict() - try: - partial_version = ( - f"{matches['major']}.{matches['minor']}.{matches['patch']}" - ) - except KeyError: + if not all( + [ + version_segment in matches + for version_segment in ("major", "minor", "patch") + ] + ): return None + partial_version = f"{matches['major']}.{matches['minor']}.{matches['patch']}" + if matches.get("prerelease"): - partial_version += f"-{matches['prerelease']}" + partial_version = f"{partial_version}-{matches['prerelease']}" if matches.get("devrelease"): - partial_version += f"{matches['devrelease']}" + partial_version = f"{partial_version}{matches['devrelease']}" return partial_version def parse_title_level(self, line: str) -> int | None: diff --git a/commitizen/defaults.py b/commitizen/defaults.py index 0f857f264a..2d092d5004 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -134,7 +134,9 @@ class Settings(TypedDict, total=False): bump_message = "bump: version $current_version → $new_version" -def get_tag_regexes(version_regex: str) -> dict[str | Any, str | Any]: +def get_tag_regexes( + version_regex: str, +) -> dict[str, str]: return { "$version": version_regex, "$major": r"(?P\d+)", diff --git a/docs/commands/bump.md b/docs/commands/bump.md index ed00193241..afb43230e4 100644 --- a/docs/commands/bump.md +++ b/docs/commands/bump.md @@ -418,20 +418,14 @@ The variables must be preceded by a `$` sign and optionally can be wrapped in `{ Supported variables: -| Variable | Description | -|-----------------|---------------------------------------------| -| `$version` | full generated version | -| `$major` | MAJOR increment | -| `$minor` | MINOR increment | -| `$patch` | PATCH increment | -| `$prerelease` | Prerelease (alpha, beta, release candidate) | -| `$devrelease` | Development release | -| `${version}` | full generated version | -| `${major}` | MAJOR increment | -| `${minor}` | MINOR increment | -| `${patch}` | PATCH increment | -| `${prerelease}` | Prerelease (alpha, beta, release candidate) | -| `${devrelease}` | Development release | +| Variable | Description | +|--------------------------------|---------------------------------------------| +| `$version`, `${version}` | full generated version | +| `$major`, `${major}` | MAJOR increment | +| `$minor`, `${minor}` | MINOR increment | +| `$patch`, `${patch}` | PATCH increment | +| `$prerelease`, `${prerelease}` | Prerelease (alpha, beta, release candidate) | +| `$devrelease`, ${devrelease}` | Development release | --- diff --git a/docs/tutorials/monorepo_guidance.md b/docs/tutorials/monorepo_guidance.md index 5bb334d725..c4345d6bc2 100644 --- a/docs/tutorials/monorepo_guidance.md +++ b/docs/tutorials/monorepo_guidance.md @@ -1,21 +1,22 @@ # Configuring commitizen in a monorepo -This tutorial assumes the monorepo layout is designed with multiple components that can be released independently of each other, -some suggested layouts: +This tutorial assumes the monorepo layout is designed with multiple components that can be released independently of each +other, it also assumes that conventional commits with scopes are in use. Some suggested layouts: ``` -library-a - .cz.toml -library-b - .cz.toml +. +├── library-b +│   └── .cz.toml +└── library-z + └── .cz.toml ``` ``` src - library-b - .cz.toml - library-z - .cz.toml +├── library-b +│   └── .cz.toml +└── library-z + └── .cz.toml ``` Each component will have its own changelog, commits will need to use scopes so only relevant commits are included in the diff --git a/tests/commands/test_changelog_command.py b/tests/commands/test_changelog_command.py index 5c7a8a9faa..bc0d6c6a28 100644 --- a/tests/commands/test_changelog_command.py +++ b/tests/commands/test_changelog_command.py @@ -1597,9 +1597,11 @@ def test_changelog_only_tag_matching_tag_format_included_suffix( git.tag("random0.2.0") testargs = ["cz", "bump", "--changelog", "--yes"] mocker.patch.object(sys, "argv", testargs) + # bump to 0.2.0custom cli.main() wait_for_tag() create_file_and_commit("feat: another new file") + # bump to 0.3.0custom cli.main() wait_for_tag() with open(changelog_path) as f: