From d69df24b916eea32278d7902e6a0ce41ad34d3f8 Mon Sep 17 00:00:00 2001 From: Christoph Gringmuth Date: Fri, 6 Mar 2026 15:27:13 +0100 Subject: [PATCH] feat: Don't overwrite header information anymore Colin-b/keepachangelog#33 --- keepachangelog/_changelog.py | 32 ++++++++++- keepachangelog/_versioning.py | 2 +- tests/conftest.py | 12 ++++ tests/test_changelog.py | 7 ++- tests/test_changelog_different_header.py | 73 ++++++++++++++++++++++++ tests/test_changelog_empty_version.py | 4 +- tests/test_changelog_no_added.py | 4 +- tests/test_changelog_no_category.py | 4 +- tests/test_changelog_no_changed.py | 4 +- tests/test_changelog_no_deprecated.py | 4 +- tests/test_changelog_no_fixed.py | 4 +- tests/test_changelog_no_removed.py | 4 +- tests/test_changelog_no_security.py | 4 +- tests/test_changelog_no_version.py | 4 +- tests/test_changelog_unreleased.py | 7 ++- tests/test_flask_restx.py | 10 ++++ tests/test_non_standard_changelog.py | 5 +- tests/test_starlette.py | 10 ++++ 18 files changed, 166 insertions(+), 28 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_changelog_different_header.py diff --git a/keepachangelog/_changelog.py b/keepachangelog/_changelog.py index 34ccf29..f64cd5c 100644 --- a/keepachangelog/_changelog.py +++ b/keepachangelog/_changelog.py @@ -11,6 +11,10 @@ ) +def is_header(line: str) -> bool: + return line.startswith("# ") + + def is_release(line: str) -> bool: return line.startswith("## ") @@ -91,10 +95,17 @@ def _to_dict(change_log: Iterable[str], show_unreleased: bool) -> dict[str, dict urls = {} current_release = {} category = [] + header = {} + is_header_text = False for line in change_log: line = line.strip(" \n") - if is_release(line): + if is_header(line): + header = changes.setdefault("header", {"title": "", "text": []}) + header["title"] = line.lstrip("#").strip(" ") + is_header_text = True # consider everything until next section as header + elif is_release(line): + is_header_text = False # next section started current_release = add_release(changes, line) category = current_release.setdefault("uncategorized", []) elif is_category(line): @@ -102,6 +113,8 @@ def _to_dict(change_log: Iterable[str], show_unreleased: bool) -> dict[str, dict elif is_link(line): link_match = link_pattern.fullmatch(line) urls[link_match.group(1).lower()] = link_match.group(2) + elif is_header_text: + header["text"].append(line) elif line: add_information(category, line) @@ -114,6 +127,8 @@ def _to_dict(change_log: Iterable[str], show_unreleased: bool) -> dict[str, dict # Avoid empty uncategorized unreleased_version = None for version, current_release in changes.items(): + if version == "header": + continue metadata = current_release["metadata"] if not current_release.get("uncategorized"): current_release.pop("uncategorized", None) @@ -135,7 +150,15 @@ def from_dict(changes: dict[str, dict]) -> str: The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n""" - for current_release in changes.values(): + if "header" in changes.keys(): + header_title = changes["header"]["title"] + header_text = "\n".join(changes["header"]["text"]) + content = f"# {header_title}\n{header_text}" + + for key, val in changes.items(): + if key == "header": # ignore header + continue + current_release = val metadata = current_release["metadata"] content += f"\n## [{metadata['version'].capitalize()}]" @@ -160,7 +183,10 @@ def from_dict(changes: dict[str, dict]) -> str: content += "\n" urls_content = [] - for current_release in changes.values(): + for key, val in changes.items(): + if key == "header": + continue + current_release = val metadata = current_release["metadata"] if not metadata.get("url"): continue diff --git a/keepachangelog/_versioning.py b/keepachangelog/_versioning.py index 66a495c..5b1939f 100644 --- a/keepachangelog/_versioning.py +++ b/keepachangelog/_versioning.py @@ -135,7 +135,7 @@ def to_sorted_semantic(versions: Iterable[str]) -> list[tuple[str, dict]]: [ (version, to_semantic(version)) for version in versions - if version != "unreleased" + if version not in ["unreleased", "header"] ], key=cmp_to_key(semantic_order), ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2813271 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +import keepachangelog + + +def to_dict_ignore_header(changelog, **kwargs): + mapping = keepachangelog.to_dict(changelog, **kwargs) + return dict_ignore_header(mapping) if mapping else {} + + +def dict_ignore_header(mapping): + mapping = mapping.copy() + mapping.pop("header") + return mapping diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 52f4b75..42656ce 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -5,6 +5,7 @@ import pytest import keepachangelog +from tests.conftest import to_dict_ignore_header @pytest.fixture @@ -168,17 +169,17 @@ def changelog(tmpdir): def test_changelog_with_versions_and_all_categories(changelog): - assert keepachangelog.to_dict(changelog) == changelog_as_dict + assert to_dict_ignore_header(changelog) == changelog_as_dict def test_changelog_with_versions_and_all_categories_as_file_reader(changelog): with open(changelog, encoding="utf-8") as file_reader: with io.StringIO(file_reader.read()) as memory_reader: - assert keepachangelog.to_dict(memory_reader) == changelog_as_dict + assert to_dict_ignore_header(memory_reader) == changelog_as_dict # Assert that file reader is not closed memory_reader.seek(0) - assert keepachangelog.to_dict(memory_reader) == changelog_as_dict + assert to_dict_ignore_header(memory_reader) == changelog_as_dict def test_raw_changelog_with_versions_and_all_categories(changelog): diff --git a/tests/test_changelog_different_header.py b/tests/test_changelog_different_header.py new file mode 100644 index 0000000..3897915 --- /dev/null +++ b/tests/test_changelog_different_header.py @@ -0,0 +1,73 @@ +import os +import os.path + +import pytest + +import keepachangelog + + +content = """# Header Title +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor +in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. + +## [Unreleased] +### Added +- Enhancement 1 +- sub enhancement 1 + +## [1.2.0] - 2018-06-01 +### Added +- Cool feature + +### Changed +- Release note 1. +- Release note 2. + +### Fixed +- Bug fix 1 +- sub bug 1 +- sub bug 2 +- Bug fix 2 + +### Security +- Known issue 1 +- Known issue 2 + +### Deprecated +- Deprecated feature 1 +- Future removal 2 + +### Removed +- Deprecated feature 2 +- Future removal 1 +""" + + +@pytest.fixture +def changelog(tmpdir): + changelog_file_path = os.path.join(tmpdir, "CHANGELOG.md") + with open(changelog_file_path, "wt") as file: + file.write(content) + return changelog_file_path + + +def test_changelog_with_different_header_to_dict(changelog): + assert keepachangelog.to_dict(changelog)["header"] == { + "title": "Header Title", + "text": [ + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor", + "in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.", + "", + ], + } + + +def test_changelog_with_different_header_from_dict(changelog): + assert ( + keepachangelog.from_dict( + keepachangelog.to_dict(changelog, show_unreleased=True) + ) + == content + ) diff --git a/tests/test_changelog_empty_version.py b/tests/test_changelog_empty_version.py index b2dc644..7fb0e79 100644 --- a/tests/test_changelog_empty_version.py +++ b/tests/test_changelog_empty_version.py @@ -3,7 +3,7 @@ import pytest -import keepachangelog +from tests.conftest import to_dict_ignore_header @pytest.fixture @@ -47,7 +47,7 @@ def changelog(tmpdir): def test_changelog_with_empty_version(changelog): - assert keepachangelog.to_dict(changelog) == { + assert to_dict_ignore_header(changelog) == { "": { "changed": ["Release note 1.", "Release note 2."], "deprecated": ["Deprecated feature 1", "Future removal 2"], diff --git a/tests/test_changelog_no_added.py b/tests/test_changelog_no_added.py index 271dfcb..7647a5a 100644 --- a/tests/test_changelog_no_added.py +++ b/tests/test_changelog_no_added.py @@ -3,7 +3,7 @@ import pytest -import keepachangelog +from tests.conftest import to_dict_ignore_header @pytest.fixture @@ -66,7 +66,7 @@ def changelog(tmpdir): def test_changelog_with_versions_and_no_added(changelog): - assert keepachangelog.to_dict(changelog) == { + assert to_dict_ignore_header(changelog) == { "1.2.0": { "changed": ["Release note 1.", "Release note 2."], "deprecated": ["Deprecated feature 1", "Future removal 2"], diff --git a/tests/test_changelog_no_category.py b/tests/test_changelog_no_category.py index 3ac2667..90851f5 100644 --- a/tests/test_changelog_no_category.py +++ b/tests/test_changelog_no_category.py @@ -3,7 +3,7 @@ import pytest -import keepachangelog +from tests.conftest import to_dict_ignore_header @pytest.fixture @@ -46,7 +46,7 @@ def changelog(tmpdir): def test_changelog_without_category(changelog): - assert keepachangelog.to_dict(changelog) == { + assert to_dict_ignore_header(changelog) == { "1.2.0": { "uncategorized": ["Release note 1.", "Release note 2."], "fixed": ["Bug fix 1", "sub bug 1", "sub bug 2", "Bug fix 2"], diff --git a/tests/test_changelog_no_changed.py b/tests/test_changelog_no_changed.py index 4bf6550..727ee30 100644 --- a/tests/test_changelog_no_changed.py +++ b/tests/test_changelog_no_changed.py @@ -3,7 +3,7 @@ import pytest -import keepachangelog +from tests.conftest import to_dict_ignore_header @pytest.fixture @@ -63,7 +63,7 @@ def changelog(tmpdir): def test_changelog_with_versions_and_no_changed(changelog): - assert keepachangelog.to_dict(changelog) == { + assert to_dict_ignore_header(changelog) == { "1.2.0": { "added": [ "Enhancement 1", diff --git a/tests/test_changelog_no_deprecated.py b/tests/test_changelog_no_deprecated.py index cfaa9e9..5bcbbf1 100644 --- a/tests/test_changelog_no_deprecated.py +++ b/tests/test_changelog_no_deprecated.py @@ -3,7 +3,7 @@ import pytest -import keepachangelog +from tests.conftest import to_dict_ignore_header @pytest.fixture @@ -65,7 +65,7 @@ def changelog(tmpdir): def test_changelog_with_versions_and_no_deprecated(changelog): - assert keepachangelog.to_dict(changelog) == { + assert to_dict_ignore_header(changelog) == { "1.2.0": { "added": [ "Enhancement 1", diff --git a/tests/test_changelog_no_fixed.py b/tests/test_changelog_no_fixed.py index 2f1fa93..eda84b7 100644 --- a/tests/test_changelog_no_fixed.py +++ b/tests/test_changelog_no_fixed.py @@ -3,7 +3,7 @@ import pytest -import keepachangelog +from tests.conftest import to_dict_ignore_header @pytest.fixture @@ -66,7 +66,7 @@ def changelog(tmpdir): def test_changelog_with_versions_and_no_fixed(changelog): - assert keepachangelog.to_dict(changelog) == { + assert to_dict_ignore_header(changelog) == { "1.2.0": { "added": [ "Enhancement 1", diff --git a/tests/test_changelog_no_removed.py b/tests/test_changelog_no_removed.py index be1fde1..e215282 100644 --- a/tests/test_changelog_no_removed.py +++ b/tests/test_changelog_no_removed.py @@ -3,7 +3,7 @@ import pytest -import keepachangelog +from tests.conftest import to_dict_ignore_header @pytest.fixture @@ -68,7 +68,7 @@ def changelog(tmpdir): def test_changelog_with_versions_and_no_removed(changelog): - assert keepachangelog.to_dict(changelog) == { + assert to_dict_ignore_header(changelog) == { "1.2.0": { "added": [ "Enhancement 1", diff --git a/tests/test_changelog_no_security.py b/tests/test_changelog_no_security.py index 1fc21d3..8c9c8d7 100644 --- a/tests/test_changelog_no_security.py +++ b/tests/test_changelog_no_security.py @@ -3,7 +3,7 @@ import pytest -import keepachangelog +from tests.conftest import to_dict_ignore_header @pytest.fixture @@ -68,7 +68,7 @@ def changelog(tmpdir): def test_changelog_with_versions_and_no_security(changelog): - assert keepachangelog.to_dict(changelog) == { + assert to_dict_ignore_header(changelog) == { "1.2.0": { "added": [ "Enhancement 1", diff --git a/tests/test_changelog_no_version.py b/tests/test_changelog_no_version.py index b88cbc2..dcb01ab 100644 --- a/tests/test_changelog_no_version.py +++ b/tests/test_changelog_no_version.py @@ -5,6 +5,8 @@ import keepachangelog +from tests.conftest import to_dict_ignore_header + @pytest.fixture def changelog(tmpdir): @@ -16,7 +18,7 @@ def changelog(tmpdir): def test_changelog_without_versions(changelog): - assert keepachangelog.to_dict(changelog) == {} + assert to_dict_ignore_header(changelog) == {} def test_raw_changelog_without_versions(changelog): diff --git a/tests/test_changelog_unreleased.py b/tests/test_changelog_unreleased.py index 4f2044c..9c6ecf1 100644 --- a/tests/test_changelog_unreleased.py +++ b/tests/test_changelog_unreleased.py @@ -5,6 +5,8 @@ import keepachangelog +from tests.conftest import to_dict_ignore_header + @pytest.fixture def changelog(tmpdir): @@ -81,7 +83,7 @@ def changelog(tmpdir): def test_changelog_with_versions_and_all_categories(changelog): - assert keepachangelog.to_dict(changelog, show_unreleased=True) == { + assert to_dict_ignore_header(changelog, show_unreleased=True) == { "unreleased": { "changed": ["Release note 1.", "Release note 2."], "added": [ @@ -181,13 +183,14 @@ def test_changelog_with_versions_and_all_categories(changelog): def test_changelog_from_dict(changelog): releases = keepachangelog.to_dict(changelog, show_unreleased=True) + print(keepachangelog.from_dict(releases)) assert ( keepachangelog.from_dict(releases) == """# 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.1.0/), +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] diff --git a/tests/test_flask_restx.py b/tests/test_flask_restx.py index 06a7e57..9287ba6 100644 --- a/tests/test_flask_restx.py +++ b/tests/test_flask_restx.py @@ -78,6 +78,16 @@ def test_changelog_endpoint_with_file(tmpdir): response = client.get("/changelog") assert response.status_code == 200 assert response.json == { + "header": { + "title": "Changelog", + "text": [ + "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).", + "", + ], + }, "1.1.0": { "changed": [ "Enhancement 1 (1.1.0)", diff --git a/tests/test_non_standard_changelog.py b/tests/test_non_standard_changelog.py index 25147da..4266f05 100644 --- a/tests/test_non_standard_changelog.py +++ b/tests/test_non_standard_changelog.py @@ -4,6 +4,7 @@ import pytest import keepachangelog +from tests.conftest import to_dict_ignore_header @pytest.fixture @@ -72,7 +73,7 @@ def changelog(tmpdir): def test_changelog_with_versions_and_all_categories(changelog): - assert keepachangelog.to_dict(changelog) == { + assert to_dict_ignore_header(changelog) == { "1.2.0": { "added": [ "Enhancement 1", @@ -153,7 +154,7 @@ def test_changelog_with_versions_and_all_categories(changelog): def test_changelog_with_unreleased_versions_and_all_categories(changelog): - assert keepachangelog.to_dict(changelog, show_unreleased=True) == { + assert to_dict_ignore_header(changelog, show_unreleased=True) == { "master": { "metadata": {"release_date": None, "version": "master"}, }, diff --git a/tests/test_starlette.py b/tests/test_starlette.py index 8018397..7c79646 100644 --- a/tests/test_starlette.py +++ b/tests/test_starlette.py @@ -80,6 +80,16 @@ def test_changelog_endpoint_with_file(tmpdir): response = client.get("/changelog") assert response.status_code == 200 assert response.json() == { + "header": { + "title": "Changelog", + "text": [ + "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).", + "", + ], + }, "1.1.0": { "changed": [ "Enhancement 1 (1.1.0)",