diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9c42b63..ee3d6a7 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,5 +1,5 @@
repos:
- repo: https://github.com/psf/black
- rev: master
+ rev: 20.8b1
hooks:
- - id: black
\ No newline at end of file
+ - id: black
diff --git a/.travis.yml b/.travis.yml
index a4fe5a1..64f9fd5 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,6 +3,7 @@ python:
- "3.6"
- "3.7"
- "3.8"
+ - "3.9"
install:
- pip install .[testing]
script:
@@ -12,4 +13,4 @@ deploy:
username: __token__
edge: true
distributions: "sdist bdist_wheel"
- skip_existing: true
\ No newline at end of file
+ skip_existing: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2feb7af..56bfe58 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.5.0] - 2021-04-19
+### Added
+- `keepachangelog.release` function to guess new version number based on `Unreleased` section, update changelog and return new version number.
+- `keepachangelog.to_raw_dict` function returning a raw markdown description of the release under `raw` dict.
+
+### Fixed
+- Handle any category name.
+- Add more flexibility for release format.
+
+### Changed
+- `Unreleased` is now reported as lower cased `unreleased`.
+
## [0.4.0] - 2020-09-21
### Added
- `keepachangelog.flask_restx.add_changelog_endpoint` function to add a changelog endpoint to a [Flask-RestX](https://flask-restx.readthedocs.io/en/latest/) application.
@@ -30,7 +42,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Initial release.
-[Unreleased]: https://github.com/Colin-b/keepachangelog/compare/v0.4.0...HEAD
+[Unreleased]: https://github.com/Colin-b/keepachangelog/compare/v0.5.0...HEAD
+[0.5.0]: https://github.com/Colin-b/keepachangelog/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/Colin-b/keepachangelog/compare/v0.3.1...v0.4.0
[0.3.1]: https://github.com/Colin-b/keepachangelog/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/Colin-b/keepachangelog/compare/v0.2.0...v0.3.0
diff --git a/README.md b/README.md
index 571ea24..000265f 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,23 @@
-
Convert changelog into dict
+Manipulate keep a changelog files
-
-
+
+
-
+
-Convert changelog markdown file following [keep a changelog](https://keepachangelog.com/en/1.0.0/) format into python dict.
+* [Convert to dict](#convert-changelog-to-dict)
+* [Release a new version](#release)
+* [Add changelog retrieval REST API endpoint](#endpoint)
+ * [Starlette](#starlette)
+ * [Flask-RestX](#flask-restx)
+
+## Convert changelog to dict
+
+Convert changelog markdown file following [keep a changelog](https://keepachangelog.com/en/1.1.0/) format into python dict.
```python
import keepachangelog
@@ -55,7 +63,7 @@ For a markdown file with the following content:
# 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/),
+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).
## [Unreleased]
@@ -115,6 +123,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`show_unreleased` parameter can be specified in order to include `Unreleased` section information.
Note that `release_date` will be set to None in such as case.
+## Release
+
+You can create a new release by using `keepachangelog.release` function.
+
+```python
+import keepachangelog
+
+new_version = keepachangelog.release("path/to/CHANGELOG.md")
+```
+
+This will:
+* Guess the new version number and return it:
+ * `Removed` or `Changed` sections will be considered as breaking changes, thus incrementing the major version.
+ * If the only section is `Fixed`, only patch will be incremented.
+ * Otherwise, minor will be incremented.
+* Update changelog.
+ * Unreleased section content will be moved into a new section.
+ * `[Unreleased]` link will be updated.
+ * New link will be created corresponding to the new section (based on the format of the Unreleased link).
+
## Endpoint
### Starlette
diff --git a/keepachangelog/__init__.py b/keepachangelog/__init__.py
index 1ee1b48..31e7d59 100644
--- a/keepachangelog/__init__.py
+++ b/keepachangelog/__init__.py
@@ -1,2 +1,2 @@
from keepachangelog.version import __version__
-from keepachangelog._changelog import to_dict
+from keepachangelog._changelog import to_dict, to_raw_dict, release
diff --git a/keepachangelog/_changelog.py b/keepachangelog/_changelog.py
index 4629bd2..aa699f7 100644
--- a/keepachangelog/_changelog.py
+++ b/keepachangelog/_changelog.py
@@ -1,41 +1,50 @@
+import datetime
import re
-from typing import Dict, List
+from typing import Dict, List, Optional
-# Release pattern should match lines like: "## [0.0.1] - 2020-12-31" or ## [Unreleased]
-release_pattern = re.compile(r"^## \[(.*)\](?: - (.*))?$")
+from keepachangelog._versioning import guess_unreleased_version
-def is_release(line: str, show_unreleased: bool) -> bool:
- match = release_pattern.fullmatch(line)
- if match and (not show_unreleased and match.group(1) == "Unreleased"):
- return False
- return match is not None
+def is_release(line: str) -> bool:
+ return line.startswith("## ")
-def add_release(changes: Dict[str, dict], line: str) -> dict:
- release_info = release_pattern.fullmatch(line)
+def add_release(changes: Dict[str, dict], line: str, show_unreleased: bool) -> dict:
+ release_line = line[3:].lower().strip(" ")
+ # A release is separated by a space between version and release date
+ # Release pattern should match lines like: "[0.0.1] - 2020-12-31" or [Unreleased]
+ version, release_date = (
+ release_line.split(" ", maxsplit=1)
+ if " " in release_line
+ else (release_line, None)
+ )
+ if not show_unreleased and not release_date:
+ return {}
+ version = unlink(version)
return changes.setdefault(
- release_info.group(1),
- {"version": release_info.group(1), "release_date": release_info.group(2)},
+ version,
+ {"version": version, "release_date": extract_date(release_date)},
)
-categories = {
- "### Added": "added",
- "### Changed": "changed",
- "### Deprecated": "deprecated",
- "### Removed": "removed",
- "### Fixed": "fixed",
- "### Security": "security",
-}
+def unlink(value: str) -> str:
+ return value.lstrip("[").rstrip("]")
+
+
+def extract_date(date: str) -> str:
+ if not date:
+ return date
+
+ return date.lstrip(" -(").rstrip(" )")
def is_category(line: str) -> bool:
- return line in categories
+ return line.startswith("### ")
def add_category(release: dict, line: str) -> List[str]:
- return release.setdefault(categories[line], [])
+ category = line[4:].lower().strip(" ")
+ return release.setdefault(category, [])
# Link pattern should match lines like: "[1.2.3]: https://github.com/user/project/releases/tag/v0.0.1"
@@ -53,16 +62,82 @@ def add_information(category: List[str], line: str):
def to_dict(changelog_path: str, *, show_unreleased: bool = False) -> Dict[str, dict]:
changes = {}
with open(changelog_path) as change_log:
- release = {}
+ current_release = {}
category = []
for line in change_log:
line = line.strip(" \n")
- if is_release(line, show_unreleased):
- release = add_release(changes, line)
+ if is_release(line):
+ current_release = add_release(changes, line, show_unreleased)
elif is_category(line):
- category = add_category(release, line)
+ category = add_category(current_release, line)
elif is_information(line):
add_information(category, line)
return changes
+
+
+def to_raw_dict(changelog_path: str) -> Dict[str, dict]:
+ changes = {}
+ with open(changelog_path) as change_log:
+ current_release = {}
+ for line in change_log:
+ clean_line = line.strip(" \n")
+
+ if is_release(clean_line):
+ current_release = add_release(
+ changes, clean_line, show_unreleased=False
+ )
+ elif is_category(clean_line) or is_information(clean_line):
+ current_release["raw"] = current_release.get("raw", "") + line
+
+ return changes
+
+
+def release(changelog_path: str) -> str:
+ changelog = to_dict(changelog_path, show_unreleased=True)
+ current_version, new_version = guess_unreleased_version(changelog)
+ release_version(changelog_path, current_version, new_version)
+ return new_version
+
+
+def release_version(
+ changelog_path: str, current_version: Optional[str], new_version: str
+):
+ unreleased_link_pattern = re.compile(r"^\[Unreleased\]: (.*)$", re.DOTALL)
+ lines = []
+ with open(changelog_path) as change_log:
+ for line in change_log.readlines():
+ # Move Unreleased section to new version
+ if re.fullmatch(r"^## \[Unreleased\].*$", line, re.DOTALL):
+ lines.append(line)
+ lines.append("\n")
+ lines.append(
+ f"## [{new_version}] - {datetime.date.today().isoformat()}\n"
+ )
+ # Add new version link and update Unreleased link
+ elif unreleased_link_pattern.fullmatch(line):
+ unreleased_compare_pattern = re.fullmatch(
+ r"^.*/(.*)\.\.\.(\w*).*$", line, re.DOTALL
+ )
+ # Unreleased link compare previous version to HEAD (unreleased tag)
+ if unreleased_compare_pattern:
+ new_unreleased_link = line.replace(current_version, new_version)
+ lines.append(new_unreleased_link)
+ current_tag = unreleased_compare_pattern.group(1)
+ unreleased_tag = unreleased_compare_pattern.group(2)
+ new_tag = current_tag.replace(current_version, new_version)
+ lines.append(
+ line.replace(new_version, current_version)
+ .replace(unreleased_tag, new_tag)
+ .replace("Unreleased", new_version)
+ )
+ # Consider that there is no way to know how to create a link to compare versions
+ else:
+ lines.append(line)
+ lines.append(line.replace("Unreleased", new_version))
+ else:
+ lines.append(line)
+
+ with open(changelog_path, "wt") as change_log:
+ change_log.writelines(lines)
diff --git a/keepachangelog/_versioning.py b/keepachangelog/_versioning.py
new file mode 100644
index 0000000..be70fea
--- /dev/null
+++ b/keepachangelog/_versioning.py
@@ -0,0 +1,72 @@
+import re
+from typing import Tuple, Optional
+
+
+def contains_breaking_changes(unreleased: dict) -> bool:
+ return "removed" in unreleased or "changed" in unreleased
+
+
+def only_contains_bug_fixes(unreleased: dict) -> bool:
+ # unreleased contains at least 2 entries: version and release_date
+ return "fixed" in unreleased and len(unreleased) == 3
+
+
+def bump_major(version: str) -> str:
+ major, *_ = to_semantic(version)
+ return from_semantic(major + 1, 0, 0)
+
+
+def bump_minor(version: str) -> str:
+ major, minor, _ = to_semantic(version)
+ return from_semantic(major, minor + 1, 0)
+
+
+def bump_patch(version: str) -> str:
+ major, minor, patch = to_semantic(version)
+ return from_semantic(major, minor, patch + 1)
+
+
+def bump(unreleased: dict, version: str) -> str:
+ if contains_breaking_changes(unreleased):
+ return bump_major(version)
+ if only_contains_bug_fixes(unreleased):
+ return bump_patch(version)
+ return bump_minor(version)
+
+
+def actual_version(changelog: dict) -> Optional[str]:
+ versions = sorted(changelog.keys())
+ current_version = versions.pop() if versions else None
+ while "unreleased" == current_version:
+ current_version = versions.pop() if versions else None
+ return current_version
+
+
+def guess_unreleased_version(changelog: dict) -> Tuple[Optional[str], str]:
+ unreleased = changelog.get("unreleased", {})
+ if not unreleased or len(unreleased) < 3:
+ raise Exception(
+ "Release content must be provided within changelog Unreleased section."
+ )
+
+ version = actual_version(changelog)
+ return version, bump(unreleased, version)
+
+
+# Semantic versioning pattern should match version like 1.2.3"
+version_pattern = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
+
+
+def to_semantic(version: Optional[str]) -> Tuple[int, int, int]:
+ if not version:
+ return 0, 0, 0
+
+ match = version_pattern.fullmatch(version)
+ if match:
+ return int(match.group(1)), int(match.group(2)), int(match.group(3))
+
+ raise Exception(f"{version} is not following semantic versioning.")
+
+
+def from_semantic(major: int, minor: int, patch: int) -> str:
+ return f"{major}.{minor}.{patch}"
diff --git a/keepachangelog/version.py b/keepachangelog/version.py
index 151e68f..c4b9ff7 100644
--- a/keepachangelog/version.py
+++ b/keepachangelog/version.py
@@ -3,4 +3,4 @@
# Major should be incremented in case there is a breaking change. (eg: 2.5.8 -> 3.0.0)
# Minor should be incremented in case there is an enhancement. (eg: 2.5.8 -> 2.6.0)
# Patch should be incremented in case there is a bug fix. (eg: 2.5.8 -> 2.5.9)
-__version__ = "0.4.0"
+__version__ = "0.5.0"
diff --git a/setup.py b/setup.py
index 8931555..13df3fb 100644
--- a/setup.py
+++ b/setup.py
@@ -15,7 +15,7 @@
maintainer="Colin Bounouar",
maintainer_email="colin.bounouar.dev@gmail.com",
url="https://colin-b.github.io/keepachangelog/",
- description="Convert changelog into dict.",
+ description="Manipulate keep a changelog files.",
long_description=long_description,
long_description_content_type="text/markdown",
download_url="https://pypi.org/project/keepachangelog/",
@@ -30,6 +30,7 @@
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
+ "Programming Language :: Python :: 3.9",
"Topic :: Software Development :: Build Tools",
],
keywords=["changelog", "CHANGELOG.md", "markdown"],
diff --git a/tests/test_changelog.py b/tests/test_changelog.py
index 798fbaa..eaa991e 100644
--- a/tests/test_changelog.py
+++ b/tests/test_changelog.py
@@ -119,3 +119,63 @@ def test_changelog_with_versions_and_all_categories(changelog):
"version": "1.0.0",
},
}
+
+
+def test_raw_changelog_with_versions_and_all_categories(changelog):
+ assert keepachangelog.to_raw_dict(changelog) == {
+ "1.2.0": {
+ "raw": """### Changed
+- Release note 1.
+- Release note 2.
+### Added
+- Enhancement 1
+- sub enhancement 1
+- sub enhancement 2
+- Enhancement 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
+""",
+ "release_date": "2018-06-01",
+ "version": "1.2.0",
+ },
+ "1.1.0": {
+ "raw": """### Changed
+- Enhancement 1 (1.1.0)
+- sub enhancement 1
+- sub enhancement 2
+- Enhancement 2 (1.1.0)
+""",
+ "release_date": "2018-05-31",
+ "version": "1.1.0",
+ },
+ "1.0.1": {
+ "raw": """### Fixed
+- Bug fix 1 (1.0.1)
+- sub bug 1
+- sub bug 2
+- Bug fix 2 (1.0.1)
+""",
+ "release_date": "2018-05-31",
+ "version": "1.0.1",
+ },
+ "1.0.0": {
+ "raw": """### Deprecated
+- Known issue 1 (1.0.0)
+- Known issue 2 (1.0.0)
+""",
+ "release_date": "2017-04-10",
+ "version": "1.0.0",
+ },
+ }
diff --git a/tests/test_changelog_no_version.py b/tests/test_changelog_no_version.py
index 9c41a47..b88cbc2 100644
--- a/tests/test_changelog_no_version.py
+++ b/tests/test_changelog_no_version.py
@@ -17,3 +17,7 @@ def changelog(tmpdir):
def test_changelog_without_versions(changelog):
assert keepachangelog.to_dict(changelog) == {}
+
+
+def test_raw_changelog_without_versions(changelog):
+ assert keepachangelog.to_raw_dict(changelog) == {}
diff --git a/tests/test_changelog_release.py b/tests/test_changelog_release.py
new file mode 100644
index 0000000..95e63eb
--- /dev/null
+++ b/tests/test_changelog_release.py
@@ -0,0 +1,695 @@
+import datetime
+import os
+import os.path
+
+import pytest
+
+import keepachangelog
+import keepachangelog._changelog
+
+_date_time_for_tests = datetime.datetime(2021, 3, 19, 15, 5, 5, 663979)
+
+
+class DateTimeModuleMock:
+ class DateTimeMock(datetime.datetime):
+ @classmethod
+ def now(cls, tz=None):
+ return _date_time_for_tests.replace(tzinfo=tz)
+
+ class DateMock(datetime.date):
+ @classmethod
+ def today(cls):
+ return _date_time_for_tests.date()
+
+ timedelta = datetime.timedelta
+ timezone = datetime.timezone
+ datetime = DateTimeMock
+ date = DateMock
+
+
+@pytest.fixture
+def mock_date(monkeypatch):
+ monkeypatch.setattr(keepachangelog._changelog, "datetime", DateTimeModuleMock)
+
+
+@pytest.fixture
+def major_changelog(tmpdir):
+ changelog_file_path = os.path.join(tmpdir, "MAJOR_CHANGELOG.md")
+ with open(changelog_file_path, "wt") as file:
+ file.write(
+ """# 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]
+### Changed
+- Release note 1.
+* Release note 2.
+
+### Added
+- Enhancement 1
+ - sub enhancement 1
+ * sub enhancement 2
+- Enhancement 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
+
+## [1.1.0] - 2018-05-31
+### Changed
+- Enhancement 1 (1.1.0)
+- sub *enhancement 1*
+- sub enhancement 2
+- Enhancement 2 (1.1.0)
+
+## [1.0.1] - 2018-05-31
+### Fixed
+- Bug fix 1 (1.0.1)
+- sub bug 1
+- sub bug 2
+- Bug fix 2 (1.0.1)
+
+## [1.0.0] - 2017-04-10
+### Deprecated
+- Known issue 1 (1.0.0)
+- Known issue 2 (1.0.0)
+
+[Unreleased]: https://github.test_url/test_project/compare/v1.1.0...HEAD
+[1.1.0]: https://github.test_url/test_project/compare/v1.0.1...v1.1.0
+[1.0.1]: https://github.test_url/test_project/compare/v1.0.0...v1.0.1
+[1.0.0]: https://github.test_url/test_project/releases/tag/v1.0.0
+"""
+ )
+ return changelog_file_path
+
+
+@pytest.fixture
+def minor_changelog(tmpdir):
+ changelog_file_path = os.path.join(tmpdir, "MINOR_CHANGELOG.md")
+ with open(changelog_file_path, "wt") as file:
+ file.write(
+ """# 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]
+### Added
+- Enhancement 1
+ - sub enhancement 1
+ * sub enhancement 2
+- Enhancement 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
+
+## [1.1.0] - 2018-05-31
+### Changed
+- Enhancement 1 (1.1.0)
+- sub *enhancement 1*
+- sub enhancement 2
+- Enhancement 2 (1.1.0)
+
+## [1.0.1] - 2018-05-31
+### Fixed
+- Bug fix 1 (1.0.1)
+- sub bug 1
+- sub bug 2
+- Bug fix 2 (1.0.1)
+
+## [1.0.0] - 2017-04-10
+### Deprecated
+- Known issue 1 (1.0.0)
+- Known issue 2 (1.0.0)
+
+[Unreleased]: https://github.test_url/test_project/compare/v1.1.0...HEAD
+[1.1.0]: https://github.test_url/test_project/compare/v1.0.1...v1.1.0
+[1.0.1]: https://github.test_url/test_project/compare/v1.0.0...v1.0.1
+[1.0.0]: https://github.test_url/test_project/releases/tag/v1.0.0
+"""
+ )
+ return changelog_file_path
+
+
+@pytest.fixture
+def patch_changelog(tmpdir):
+ changelog_file_path = os.path.join(tmpdir, "PATCH_CHANGELOG.md")
+ with open(changelog_file_path, "wt") as file:
+ file.write(
+ """# 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]
+### Fixed
+- Bug fix 1
+ - sub bug 1
+ * sub bug 2
+- Bug fix 2
+
+## [1.1.0] - 2018-05-31
+### Changed
+- Enhancement 1 (1.1.0)
+- sub *enhancement 1*
+- sub enhancement 2
+- Enhancement 2 (1.1.0)
+
+## [1.0.1] - 2018-05-31
+### Fixed
+- Bug fix 1 (1.0.1)
+- sub bug 1
+- sub bug 2
+- Bug fix 2 (1.0.1)
+
+## [1.0.0] - 2017-04-10
+### Deprecated
+- Known issue 1 (1.0.0)
+- Known issue 2 (1.0.0)
+
+[Unreleased]: https://github.test_url/test_project/compare/v1.1.0...HEAD
+[1.1.0]: https://github.test_url/test_project/compare/v1.0.1...v1.1.0
+[1.0.1]: https://github.test_url/test_project/compare/v1.0.0...v1.0.1
+[1.0.0]: https://github.test_url/test_project/releases/tag/v1.0.0
+"""
+ )
+ return changelog_file_path
+
+
+@pytest.fixture
+def first_major_changelog(tmpdir):
+ changelog_file_path = os.path.join(tmpdir, "FIRST_MAJOR_CHANGELOG.md")
+ with open(changelog_file_path, "wt") as file:
+ file.write(
+ """# 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]
+### Changed
+- Release note 1.
+* Release note 2.
+
+### Added
+- Enhancement 1
+ - sub enhancement 1
+ * sub enhancement 2
+- Enhancement 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
+
+[Unreleased]: https://github.test_url/test_project
+"""
+ )
+ return changelog_file_path
+
+
+@pytest.fixture
+def first_minor_changelog(tmpdir):
+ changelog_file_path = os.path.join(tmpdir, "FIRST_MINOR_CHANGELOG.md")
+ with open(changelog_file_path, "wt") as file:
+ file.write(
+ """# 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]
+### Added
+- Enhancement 1
+ - sub enhancement 1
+ * sub enhancement 2
+- Enhancement 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
+
+[Unreleased]: https://github.test_url/test_project
+"""
+ )
+ return changelog_file_path
+
+
+@pytest.fixture
+def first_patch_changelog(tmpdir):
+ changelog_file_path = os.path.join(tmpdir, "FIRST_PATCH_CHANGELOG.md")
+ with open(changelog_file_path, "wt") as file:
+ file.write(
+ """# 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]
+### Fixed
+- Bug fix 1
+ - sub bug 1
+ * sub bug 2
+- Bug fix 2
+
+[Unreleased]: https://github.test_url/test_project
+"""
+ )
+ return changelog_file_path
+
+
+@pytest.fixture
+def empty_unreleased_changelog(tmpdir):
+ changelog_file_path = os.path.join(tmpdir, "EMPTY_UNRELEASED_CHANGELOG.md")
+ with open(changelog_file_path, "wt") as file:
+ file.write(
+ """# 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]
+
+[Unreleased]: https://github.test_url/test_project
+"""
+ )
+ return changelog_file_path
+
+
+@pytest.fixture
+def empty_changelog(tmpdir):
+ changelog_file_path = os.path.join(tmpdir, "EMPTY_CHANGELOG.md")
+ with open(changelog_file_path, "wt") as file:
+ file.write(
+ """# 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).
+"""
+ )
+ return changelog_file_path
+
+
+@pytest.fixture
+def non_semantic_changelog(tmpdir):
+ changelog_file_path = os.path.join(tmpdir, "NON_SEMANTIC_CHANGELOG.md")
+ with open(changelog_file_path, "wt") as file:
+ file.write(
+ """# 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]
+### Changed
+- Enhancement 1 (1.1.0)
+
+## [20180531] - 2018-05-31
+### Changed
+- Enhancement 1 (1.1.0)
+- sub *enhancement 1*
+- sub enhancement 2
+- Enhancement 2 (1.1.0)
+
+[Unreleased]: https://github.test_url/test_project/compare/v20180531...HEAD
+[20180531]: https://github.test_url/test_project/releases/tag/v20180531
+"""
+ )
+ return changelog_file_path
+
+
+def test_major_release(major_changelog, mock_date):
+ assert keepachangelog.release(major_changelog) == "2.0.0"
+ with open(major_changelog) as file:
+ assert (
+ file.read()
+ == """# 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]
+
+## [2.0.0] - 2021-03-19
+### Changed
+- Release note 1.
+* Release note 2.
+
+### Added
+- Enhancement 1
+ - sub enhancement 1
+ * sub enhancement 2
+- Enhancement 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
+
+## [1.1.0] - 2018-05-31
+### Changed
+- Enhancement 1 (1.1.0)
+- sub *enhancement 1*
+- sub enhancement 2
+- Enhancement 2 (1.1.0)
+
+## [1.0.1] - 2018-05-31
+### Fixed
+- Bug fix 1 (1.0.1)
+- sub bug 1
+- sub bug 2
+- Bug fix 2 (1.0.1)
+
+## [1.0.0] - 2017-04-10
+### Deprecated
+- Known issue 1 (1.0.0)
+- Known issue 2 (1.0.0)
+
+[Unreleased]: https://github.test_url/test_project/compare/v2.0.0...HEAD
+[2.0.0]: https://github.test_url/test_project/compare/v1.1.0...v2.0.0
+[1.1.0]: https://github.test_url/test_project/compare/v1.0.1...v1.1.0
+[1.0.1]: https://github.test_url/test_project/compare/v1.0.0...v1.0.1
+[1.0.0]: https://github.test_url/test_project/releases/tag/v1.0.0
+"""
+ )
+
+
+def test_minor_release(minor_changelog, mock_date):
+ assert keepachangelog.release(minor_changelog) == "1.2.0"
+ with open(minor_changelog) as file:
+ assert (
+ file.read()
+ == """# 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]
+
+## [1.2.0] - 2021-03-19
+### Added
+- Enhancement 1
+ - sub enhancement 1
+ * sub enhancement 2
+- Enhancement 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
+
+## [1.1.0] - 2018-05-31
+### Changed
+- Enhancement 1 (1.1.0)
+- sub *enhancement 1*
+- sub enhancement 2
+- Enhancement 2 (1.1.0)
+
+## [1.0.1] - 2018-05-31
+### Fixed
+- Bug fix 1 (1.0.1)
+- sub bug 1
+- sub bug 2
+- Bug fix 2 (1.0.1)
+
+## [1.0.0] - 2017-04-10
+### Deprecated
+- Known issue 1 (1.0.0)
+- Known issue 2 (1.0.0)
+
+[Unreleased]: https://github.test_url/test_project/compare/v1.2.0...HEAD
+[1.2.0]: https://github.test_url/test_project/compare/v1.1.0...v1.2.0
+[1.1.0]: https://github.test_url/test_project/compare/v1.0.1...v1.1.0
+[1.0.1]: https://github.test_url/test_project/compare/v1.0.0...v1.0.1
+[1.0.0]: https://github.test_url/test_project/releases/tag/v1.0.0
+"""
+ )
+
+
+def test_patch_release(patch_changelog, mock_date):
+ assert keepachangelog.release(patch_changelog) == "1.1.1"
+ with open(patch_changelog) as file:
+ assert (
+ file.read()
+ == """# 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]
+
+## [1.1.1] - 2021-03-19
+### Fixed
+- Bug fix 1
+ - sub bug 1
+ * sub bug 2
+- Bug fix 2
+
+## [1.1.0] - 2018-05-31
+### Changed
+- Enhancement 1 (1.1.0)
+- sub *enhancement 1*
+- sub enhancement 2
+- Enhancement 2 (1.1.0)
+
+## [1.0.1] - 2018-05-31
+### Fixed
+- Bug fix 1 (1.0.1)
+- sub bug 1
+- sub bug 2
+- Bug fix 2 (1.0.1)
+
+## [1.0.0] - 2017-04-10
+### Deprecated
+- Known issue 1 (1.0.0)
+- Known issue 2 (1.0.0)
+
+[Unreleased]: https://github.test_url/test_project/compare/v1.1.1...HEAD
+[1.1.1]: https://github.test_url/test_project/compare/v1.1.0...v1.1.1
+[1.1.0]: https://github.test_url/test_project/compare/v1.0.1...v1.1.0
+[1.0.1]: https://github.test_url/test_project/compare/v1.0.0...v1.0.1
+[1.0.0]: https://github.test_url/test_project/releases/tag/v1.0.0
+"""
+ )
+
+
+def test_first_major_release(first_major_changelog, mock_date):
+ assert keepachangelog.release(first_major_changelog) == "1.0.0"
+ with open(first_major_changelog) as file:
+ assert (
+ file.read()
+ == """# 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]
+
+## [1.0.0] - 2021-03-19
+### Changed
+- Release note 1.
+* Release note 2.
+
+### Added
+- Enhancement 1
+ - sub enhancement 1
+ * sub enhancement 2
+- Enhancement 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
+
+[Unreleased]: https://github.test_url/test_project
+[1.0.0]: https://github.test_url/test_project
+"""
+ )
+
+
+def test_first_minor_release(first_minor_changelog, mock_date):
+ assert keepachangelog.release(first_minor_changelog) == "0.1.0"
+ with open(first_minor_changelog) as file:
+ assert (
+ file.read()
+ == """# 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]
+
+## [0.1.0] - 2021-03-19
+### Added
+- Enhancement 1
+ - sub enhancement 1
+ * sub enhancement 2
+- Enhancement 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
+
+[Unreleased]: https://github.test_url/test_project
+[0.1.0]: https://github.test_url/test_project
+"""
+ )
+
+
+def test_first_patch_release(first_patch_changelog, mock_date):
+ assert keepachangelog.release(first_patch_changelog) == "0.0.1"
+ with open(first_patch_changelog) as file:
+ assert (
+ file.read()
+ == """# 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]
+
+## [0.0.1] - 2021-03-19
+### Fixed
+- Bug fix 1
+ - sub bug 1
+ * sub bug 2
+- Bug fix 2
+
+[Unreleased]: https://github.test_url/test_project
+[0.0.1]: https://github.test_url/test_project
+"""
+ )
+
+
+def test_empty_release(empty_changelog):
+ with pytest.raises(Exception) as exception_info:
+ keepachangelog.release(empty_changelog)
+ assert (
+ str(exception_info.value)
+ == "Release content must be provided within changelog Unreleased section."
+ )
+
+
+def test_empty_unreleased_release(empty_unreleased_changelog):
+ with pytest.raises(Exception) as exception_info:
+ keepachangelog.release(empty_unreleased_changelog)
+ assert (
+ str(exception_info.value)
+ == "Release content must be provided within changelog Unreleased section."
+ )
+
+
+def test_non_semantic_release(non_semantic_changelog):
+ with pytest.raises(Exception) as exception_info:
+ keepachangelog.release(non_semantic_changelog)
+ assert str(exception_info.value) == "20180531 is not following semantic versioning."
diff --git a/tests/test_changelog_unreleased.py b/tests/test_changelog_unreleased.py
index 76950a9..1495ebe 100644
--- a/tests/test_changelog_unreleased.py
+++ b/tests/test_changelog_unreleased.py
@@ -76,8 +76,8 @@ def changelog(tmpdir):
def test_changelog_with_versions_and_all_categories(changelog):
assert keepachangelog.to_dict(changelog, show_unreleased=True) == {
- "Unreleased": {
- "version": "Unreleased",
+ "unreleased": {
+ "version": "unreleased",
"release_date": None,
"changed": ["Release note 1.", "Release note 2."],
"added": [
diff --git a/tests/test_non_standard_changelog.py b/tests/test_non_standard_changelog.py
new file mode 100644
index 0000000..d5c9b90
--- /dev/null
+++ b/tests/test_non_standard_changelog.py
@@ -0,0 +1,222 @@
+import os
+import os.path
+
+import pytest
+
+import keepachangelog
+
+
+@pytest.fixture
+def changelog(tmpdir):
+ changelog_file_path = os.path.join(tmpdir, "CHANGELOG.md")
+ with open(changelog_file_path, "wt") as file:
+ file.write(
+ """# 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).
+
+## Master
+
+## 1.2.0 (August 28, 2019)
+### Changed
+- Release note 1.
+- Release note 2.
+
+### Added
+- Enhancement 1
+- sub enhancement 1
+- sub enhancement 2
+- Enhancement 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
+
+## [1.1.0] (May 03, 2018)
+### Changed
+- Enhancement 1 (1.1.0)
+- sub enhancement 1
+- sub enhancement 2
+- Enhancement 2 (1.1.0)
+
+## [1.0.1] - May 01, 2018
+### Fixed
+- Bug fix 1 (1.0.1)
+- sub bug 1
+- sub bug 2
+- Bug fix 2 (1.0.1)
+
+## 1.0.0 (2017-01-01)
+### Deprecated
+- Known issue 1 (1.0.0)
+- Known issue 2 (1.0.0)
+"""
+ )
+ return changelog_file_path
+
+
+def test_changelog_with_versions_and_all_categories(changelog):
+ assert keepachangelog.to_dict(changelog) == {
+ "1.2.0": {
+ "added": [
+ "Enhancement 1",
+ "sub enhancement 1",
+ "sub enhancement 2",
+ "Enhancement 2",
+ ],
+ "changed": ["Release note 1.", "Release note 2."],
+ "deprecated": ["Deprecated feature 1", "Future removal 2"],
+ "fixed": ["Bug fix 1", "sub bug 1", "sub bug 2", "Bug fix 2"],
+ "release_date": "august 28, 2019",
+ "removed": ["Deprecated feature 2", "Future removal 1"],
+ "security": ["Known issue 1", "Known issue 2"],
+ "version": "1.2.0",
+ },
+ "1.1.0": {
+ "changed": [
+ "Enhancement 1 (1.1.0)",
+ "sub enhancement 1",
+ "sub enhancement 2",
+ "Enhancement 2 (1.1.0)",
+ ],
+ "release_date": "may 03, 2018",
+ "version": "1.1.0",
+ },
+ "1.0.1": {
+ "fixed": [
+ "Bug fix 1 (1.0.1)",
+ "sub bug 1",
+ "sub bug 2",
+ "Bug fix 2 (1.0.1)",
+ ],
+ "release_date": "may 01, 2018",
+ "version": "1.0.1",
+ },
+ "1.0.0": {
+ "deprecated": ["Known issue 1 (1.0.0)", "Known issue 2 (1.0.0)"],
+ "release_date": "2017-01-01",
+ "version": "1.0.0",
+ },
+ }
+
+
+def test_changelog_with_unreleased_versions_and_all_categories(changelog):
+ assert keepachangelog.to_dict(changelog, show_unreleased=True) == {
+ "master": {"release_date": None, "version": "master"},
+ "1.2.0": {
+ "added": [
+ "Enhancement 1",
+ "sub enhancement 1",
+ "sub enhancement 2",
+ "Enhancement 2",
+ ],
+ "changed": ["Release note 1.", "Release note 2."],
+ "deprecated": ["Deprecated feature 1", "Future removal 2"],
+ "fixed": ["Bug fix 1", "sub bug 1", "sub bug 2", "Bug fix 2"],
+ "release_date": "august 28, 2019",
+ "removed": ["Deprecated feature 2", "Future removal 1"],
+ "security": ["Known issue 1", "Known issue 2"],
+ "version": "1.2.0",
+ },
+ "1.1.0": {
+ "changed": [
+ "Enhancement 1 (1.1.0)",
+ "sub enhancement 1",
+ "sub enhancement 2",
+ "Enhancement 2 (1.1.0)",
+ ],
+ "release_date": "may 03, 2018",
+ "version": "1.1.0",
+ },
+ "1.0.1": {
+ "fixed": [
+ "Bug fix 1 (1.0.1)",
+ "sub bug 1",
+ "sub bug 2",
+ "Bug fix 2 (1.0.1)",
+ ],
+ "release_date": "may 01, 2018",
+ "version": "1.0.1",
+ },
+ "1.0.0": {
+ "deprecated": ["Known issue 1 (1.0.0)", "Known issue 2 (1.0.0)"],
+ "release_date": "2017-01-01",
+ "version": "1.0.0",
+ },
+ }
+
+
+def test_raw_changelog_with_versions_and_all_categories(changelog):
+ assert keepachangelog.to_raw_dict(changelog) == {
+ "1.2.0": {
+ "raw": """### Changed
+- Release note 1.
+- Release note 2.
+### Added
+- Enhancement 1
+- sub enhancement 1
+- sub enhancement 2
+- Enhancement 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
+""",
+ "release_date": "august 28, 2019",
+ "version": "1.2.0",
+ },
+ "1.1.0": {
+ "raw": """### Changed
+- Enhancement 1 (1.1.0)
+- sub enhancement 1
+- sub enhancement 2
+- Enhancement 2 (1.1.0)
+""",
+ "release_date": "may 03, 2018",
+ "version": "1.1.0",
+ },
+ "1.0.1": {
+ "raw": """### Fixed
+- Bug fix 1 (1.0.1)
+- sub bug 1
+- sub bug 2
+- Bug fix 2 (1.0.1)
+""",
+ "release_date": "may 01, 2018",
+ "version": "1.0.1",
+ },
+ "1.0.0": {
+ "raw": """### Deprecated
+- Known issue 1 (1.0.0)
+- Known issue 2 (1.0.0)
+""",
+ "release_date": "2017-01-01",
+ "version": "1.0.0",
+ },
+ }