diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 6b653be..9174dc3 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,6 +1,6 @@
name: Test
-on: [push]
+on: [push, pull_request]
jobs:
build:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2d0d1cb..b7d987c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [2.0.0.dev3] - 2022-01-30
+### Added
+- The parsing engine is now Object-oriented and more flexible.
+- Three new features:
+ - **Multiline:** Items ( <-- such as this one) can now
+ be written on several lines.
+ - **Sub-items:** Items can now have sub-items.
+ - Of any (reasonable) depth.
+ - **To list:** Using `to_list` instead of `to_dict` makes it possible to sort it easily.
+
+### Changed
+- Release dates are parsed (several formats are supported).
+ When `to_dict()`, a unique format is chosen.
+- When `to_raw_dict()`, release dates are kept "as-is"
+ (and `.lower()` is no longer applied)
+
+### Fixed
+- Change ordering is now deterministic
+- When `to_raw_dict()`, empty lines are preserved.
+- When semantic version exists, it is produced. Before, if
+ depended on some bugs (it was produced sometimes and not
+ other times, such as when the change was empty or if there
+ was only the link, something like that...).
+ This is now more consistent.
+
## [2.0.0.dev2] - 2021-08-04
### Fixed
- `keepachangelog.release` will now properly bump version in case the number of digit to compare was previously increased (such as if version 9 and 10 existed).
@@ -84,7 +109,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/v2.0.0.dev2...HEAD
+[Unreleased]: https://github.com/Colin-b/keepachangelog/compare/v2.0.0.dev3...HEAD
+[2.0.0.dev3]: https://github.com/Colin-b/keepachangelog/compare/v2.0.0.dev2...v2.0.0.dev3
[2.0.0.dev2]: https://github.com/Colin-b/keepachangelog/compare/v2.0.0.dev1...v2.0.0.dev2
[2.0.0.dev1]: https://github.com/Colin-b/keepachangelog/compare/v2.0.0.dev0...v2.0.0.dev1
[2.0.0.dev0]: https://github.com/Colin-b/keepachangelog/compare/v1.0.0...v2.0.0.dev0
diff --git a/README.md b/README.md
index 1ea8fc3..2d852c4 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
-
+
diff --git a/keepachangelog/__init__.py b/keepachangelog/__init__.py
index 8d3641a..5b2360d 100644
--- a/keepachangelog/__init__.py
+++ b/keepachangelog/__init__.py
@@ -1,3 +1,2 @@
from keepachangelog.version import __version__
-from keepachangelog._changelog import to_dict, to_raw_dict, release, from_dict
-from keepachangelog._versioning import to_sorted_semantic
+from keepachangelog._changelog import to_dict, to_raw_dict, release, from_dict, to_sorted_semantic, to_list
diff --git a/keepachangelog/_changelog.py b/keepachangelog/_changelog.py
index 377a342..34bf800 100644
--- a/keepachangelog/_changelog.py
+++ b/keepachangelog/_changelog.py
@@ -1,69 +1,7 @@
-import datetime
-import re
-from typing import Dict, List, Optional, Iterable, Union
+import pathlib
+from typing import Dict, Optional, Iterable, Union, List, Tuple, Any
-from keepachangelog._versioning import (
- actual_version,
- guess_unreleased_version,
- to_semantic,
- InvalidSemanticVersion,
-)
-
-
-def is_release(line: str) -> bool:
- return line.startswith("## ")
-
-
-def add_release(changes: Dict[str, dict], line: str) -> 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)
- )
- version = unlink(version)
-
- metadata = {"version": version, "release_date": extract_date(release_date)}
- try:
- metadata["semantic_version"] = to_semantic(version)
- except InvalidSemanticVersion:
- pass
-
- return changes.setdefault(version, {"metadata": metadata})
-
-
-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.startswith("### ")
-
-
-def add_category(release: dict, line: str) -> List[str]:
- 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"
-link_pattern = re.compile(r"^\[(.*)\]: (.*)$")
-
-
-def is_link(line: str) -> bool:
- return link_pattern.fullmatch(line) is not None
-
-
-def add_information(category: List[str], line: str):
- category.append(line.lstrip(" *-").rstrip(" -"))
+from keepachangelog._changelog_dataclasses import Changelog, SemanticVersion, StreamlinesProtocol
def to_dict(
@@ -76,133 +14,42 @@ def to_dict(
:param show_unreleased: Add unreleased section (if any) to the resulting dictionary.
:return python dict containing version as key and related changes as value.
"""
- # Allow for changelog as a file path or as a context manager providing content
- try:
- with open(changelog_path) as change_log:
- return _to_dict(change_log, show_unreleased)
- except TypeError:
- return _to_dict(changelog_path, show_unreleased)
-
-
-def _to_dict(change_log: Iterable[str], show_unreleased: bool) -> Dict[str, dict]:
- changes = {}
- # As URLs can be defined before actual usage, maintain a separate dict
- urls = {}
- current_release = {}
- category = []
- for line in change_log:
- line = line.strip(" \n")
-
- if is_release(line):
- current_release = add_release(changes, line)
- category = current_release.setdefault("uncategorized", [])
- elif is_category(line):
- category = add_category(current_release, line)
- elif is_link(line):
- link_match = link_pattern.fullmatch(line)
- urls[link_match.group(1).lower()] = link_match.group(2)
- elif line:
- add_information(category, line)
+ return _callback_proxy(
+ _to_dict, changelog_path, show_unreleased=show_unreleased, raw=False
+ )
- # Add url for each version (create version if not existing)
- for version, url in urls.items():
- changes.setdefault(version, {"metadata": {"version": version}})["metadata"][
- "url"
- ] = url
- # Avoid empty uncategorized
- unreleased_version = None
- for version, current_release in changes.items():
- metadata = current_release["metadata"]
- if not current_release.get("uncategorized"):
- current_release.pop("uncategorized", None)
+def to_raw_dict(changelog_path: str, *, show_unreleased=False) -> Dict[str, dict]:
+ return _callback_proxy(
+ _to_dict, changelog_path, show_unreleased=show_unreleased, raw=True
+ )
- # If there is an empty release date, it identify the unreleased section
- if ("release_date" in metadata) and not metadata["release_date"]:
- unreleased_version = version
- if not show_unreleased:
- changes.pop(unreleased_version, None)
+def to_list(
+ changelog_path: Union[str, Iterable[str]], *, show_unreleased: bool = False, reverse: bool = True
+) -> List[Tuple[str, dict]]:
+ """
+ Convert changelog markdown file following keep a changelog format into python list.
- return changes
+ :param changelog_path: Path to the changelog file, or context manager providing iteration on lines.
+ :param show_unreleased: Add unreleased section (if any) to the resulting dictionary.
+ :param reverse: None: no sort. True: ascending order. False: descending order.
+ :return python list of tuples containing version and related changes.
+ """
+ return _callback_proxy(
+ _to_list, changelog_path, show_unreleased=show_unreleased, raw=True, reverse=reverse
+ )
-def from_dict(changes: Dict[str, dict]):
- content = """# Changelog
+def from_dict(changes: Dict[str, dict]) -> str:
+ header = """# 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n"""
- for current_release in changes.values():
- metadata = current_release["metadata"]
- content += f"\n## [{metadata['version'].capitalize()}]"
-
- if metadata.get("release_date"):
- content += f" - {metadata['release_date']}"
-
- uncategorized = current_release.get("uncategorized", [])
- for category_content in uncategorized:
- content += f"\n* {category_content}"
- if uncategorized:
- content += "\n"
-
- for category_name, category_content in current_release.items():
- if category_name in ["metadata", "uncategorized"]:
- continue
-
- content += f"\n### {category_name.capitalize()}"
-
- for categorized in category_content:
- content += f"\n- {categorized}"
-
- content += "\n"
-
- content += "\n"
-
- for current_release in changes.values():
- metadata = current_release["metadata"]
- if not metadata.get("url"):
- continue
-
- content += f"[{metadata['version'].capitalize()}]: {metadata['url']}\n"
-
- return content
-
-
-def to_raw_dict(changelog_path: str) -> Dict[str, dict]:
- changes = {}
- # As URLs can be defined before actual usage, maintain a separate dict
- urls = {}
- 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)
- elif is_link(clean_line):
- link_match = link_pattern.fullmatch(clean_line)
- urls[link_match.group(1).lower()] = link_match.group(2)
- elif clean_line:
- current_release["raw"] = current_release.get("raw", "") + line
-
- # Add url for each version (create version if not existing)
- for version, url in urls.items():
- changes.setdefault(version, {"metadata": {"version": version}})["metadata"][
- "url"
- ] = url
-
- unreleased_version = None
- for version, current_release in changes.items():
- metadata = current_release["metadata"]
- # If there is an empty release date, it identify the unreleased section
- if ("release_date" in metadata) and not metadata["release_date"]:
- unreleased_version = version
-
- changes.pop(unreleased_version, None)
-
- return changes
+ changelog: Changelog = Changelog(header=header.splitlines(), changes=changes)
+ return changelog.to_markdown()
def release(changelog_path: str, new_version: str = None) -> Optional[str]:
@@ -213,52 +60,71 @@ def release(changelog_path: str, new_version: str = None) -> Optional[str]:
:param new_version: The new version to use instead of trying to guess one.
:return: The new version, None if there was no change to release.
"""
- changelog = to_dict(changelog_path, show_unreleased=True)
- current_version, current_semantic_version = actual_version(changelog)
- if not new_version:
- new_version = guess_unreleased_version(changelog, current_semantic_version)
- if new_version:
- release_version(changelog_path, current_version, new_version)
- return new_version
+ changelog: Changelog = Changelog()
+ _callback_proxy(changelog.streamlines, changelog_path)
+ success = _release_version(changelog_path, changelog, new_version)
+ if success:
+ return changelog.current_version_string
+
+
+def to_sorted_semantic(
+ changelog_path: Union[str, Iterable[str]], *, reverse: bool = True
+) -> List[Tuple[str, dict]]:
+ """
+ Convert changelog markdown file following keep a changelog format into a sorted list of semantic versions.
+ Note: unreleased is not considered as a semantic version and will thus be removed from the resulting versions.
+
+ :param changelog_path: Path to the changelog file, or context manager providing iteration on lines.
+ :param reverse: None: no sort. True: ascending order. False: descending order.
+ :return: An ordered (first element is the oldest version, last element is the newest (highest)) list of versions.
+ Each version is represented as a 2-tuple: first one is the string version, second one is a dictionary containing:
+ 'major', 'minor', 'patch', 'prerelease', 'buildmetadata' keys.
+ """
+ changelog = to_list(changelog_path, show_unreleased=False, reverse=reverse)
+ return [
+ (version, changelog_dict['metadata']['semantic_version'])
+ for version, changelog_dict in changelog
+ ]
+
+
+def _callback_proxy(
+ callback: StreamlinesProtocol,
+ changelog_path: Union[str, Iterable[str]],
+ **kwargs,
+) -> Any:
+ # Allow for changelog as a file path or as a context manager providing content
+ if "\n" in changelog_path:
+ return callback(changelog_path, **kwargs)
+ path = pathlib.Path(changelog_path)
+ with open(path) as change_log:
+ return callback(change_log, **kwargs)
+
+
+def _to_dict(
+ change_log: Iterable[str], *, show_unreleased: bool, raw: bool
+) -> Dict[str, dict]:
+ changelog: Changelog = Changelog()
+ changelog.streamlines(change_log)
+ changes = changelog.to_dict(show_unreleased=show_unreleased, raw=raw)
+ return changes
-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)
+def _to_list(
+ change_log: Iterable[str], *, show_unreleased: bool, raw: bool, reverse: bool
+) -> List[Tuple[str, dict]]:
+ changelog: Changelog = Changelog()
+ changelog.streamlines(change_log)
+ changes = changelog.to_list(show_unreleased=show_unreleased, raw=raw, reverse=reverse)
+ return changes
+
- with open(changelog_path, "wt") as change_log:
- change_log.writelines(lines)
+def _release_version(
+ changelog_path: str,
+ changelog: Changelog,
+ new_version: Optional[SemanticVersion] = None,
+) -> bool:
+ success = changelog.release(new_version)
+ if success:
+ with open(changelog_path, "wt") as change_log:
+ change_log.writelines(changelog.to_markdown(raw=True))
+ return success
diff --git a/keepachangelog/_changelog_dataclasses.py b/keepachangelog/_changelog_dataclasses.py
new file mode 100644
index 0000000..6d04790
--- /dev/null
+++ b/keepachangelog/_changelog_dataclasses.py
@@ -0,0 +1,664 @@
+import re
+import string
+from dataclasses import dataclass, field, fields
+from datetime import date, datetime
+from typing import (
+ List,
+ Optional,
+ Tuple,
+ Any,
+ Dict,
+ Callable,
+ Generator,
+ Iterable,
+ Union,
+ Match,
+)
+
+try: # pragma: no cover
+ from typing import Protocol
+except ImportError: # pragma: no cover
+ from typing_extensions import Protocol # if Python > 3.8
+
+from keepachangelog._tree import BulletTree, TextNode
+from keepachangelog._versioning import (
+ InvalidSemanticVersion,
+ UnmatchingSemanticVersion,
+)
+
+DictFactoryCallable = Callable[[List[Tuple[str, Any]]], Dict[str, Any]]
+UNRELEASED = "unreleased"
+
+RE_URL = re.compile(r"^.*/(?P.*)\.\.\.(?P\w*).*$", re.DOTALL)
+# Link pattern should match lines like: "[1.2.3]: https://github.com/user/project/releases/tag/v0.0.1"
+RE_LINK_LINE = re.compile(r"^\[(?P.*)\]: (?P.*)$")
+RE_SEMVER = re.compile(
+ r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:[-\.]?(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
+)
+RE_NOTE_LINE = re.compile(r"^(?P\s*(?P[-*]?)\s*)(?P.*)\s*$")
+
+
+def is_release(line: str) -> bool:
+ return line.startswith("## ")
+
+
+def is_category(line: str) -> bool:
+ return line.startswith("### ")
+
+
+def matches_link(line: str) -> Match:
+ return RE_LINK_LINE.fullmatch(line)
+
+
+class StreamlineProtocol(Protocol):
+ def __call__(self, line: str) -> None: ...
+
+
+class StreamlinesProtocol(Protocol):
+ def __call__(self, lines: Iterable[str], **kwargs) -> None: ...
+
+
+@dataclass(eq=True, order=True)
+class SemanticVersion:
+ major: int = field(compare=True)
+ minor: int = field(compare=True)
+ patch: int = field(compare=True)
+ __prerelease: Optional[str] = field(default=None, compare=False)
+ __prerelease_cmp: str = field(default="1", compare=True)
+ buildmetadata: Optional[str] = field(default=None, compare=False)
+
+ @property
+ def prerelease(self):
+ return self.__prerelease
+
+ @prerelease.setter
+ def prerelease(self, value):
+ self.__prerelease = value
+ self.__prerelease_cmp = "1" if value is None else f"0{self.__prerelease}"
+
+ @classmethod
+ def to_semantic(cls, version: Optional[str] = "") -> dict:
+ if not version:
+ return cls.initial_version().to_dict(force=True)
+ match = RE_SEMVER.fullmatch(version)
+ if match:
+ return {
+ key: int(value) if key in ("major", "minor", "patch") else value
+ for key, value in match.groupdict().items()
+ }
+
+ raise InvalidSemanticVersion(version)
+
+ @classmethod
+ def initial_version(cls):
+ return cls.from_version_string("0.0.0")
+
+ @classmethod
+ def from_version_string(cls, version_string: str) -> "SemanticVersion":
+ semver = cls.to_semantic(version_string)
+ return cls.from_dict(semver)
+
+ @classmethod
+ def from_dict(cls, data: dict):
+ prerelease = data.pop("prerelease")
+ obj = cls(**data)
+ obj.prerelease = prerelease
+ return obj
+
+ def bump_major(self):
+ return self.__class__.from_dict(
+ dict(self.to_dict(force=True), **SemanticVersion(self.major + 1, 0, 0).to_dict(force=True))
+ )
+
+ def bump_minor(self):
+ return self.__class__.from_dict(
+ dict(self.to_dict(force=True), **SemanticVersion(self.major, self.minor + 1, 0).to_dict(force=True))
+ )
+
+ def bump_patch(self):
+ return self.__class__.from_dict(
+ dict(self.to_dict(force=True), **SemanticVersion(self.major, self.minor, self.patch + 1).to_dict(force=True))
+ )
+
+ def release_version(self):
+ return self.__class__.from_dict(
+ dict(self.to_dict(force=True), **SemanticVersion(self.major, self.minor, self.patch).to_dict(force=True))
+ )
+
+ def to_tuple(self) -> Tuple[int, int, int, Optional[str], Optional[str]]:
+ return self.major, self.minor, self.patch, self.prerelease, self.buildmetadata
+
+ def to_dict(self, *, force=False) -> Optional[Dict]:
+ if self.to_tuple() == (0, 0, 0, None, None) and not force:
+ return
+ return {
+ "major": self.major,
+ "minor": self.minor,
+ "patch": self.patch,
+ "prerelease": self.prerelease,
+ "buildmetadata": self.buildmetadata,
+ }
+
+ def __str__(self):
+ return (
+ f"{self.major}.{self.minor}.{self.patch}"
+ f"{f'-{self.prerelease}' if self.prerelease is not None else ''}"
+ f"{f'+{self.buildmetadata}' if self.buildmetadata is not None else ''}"
+ )
+
+
+@dataclass
+class Metadata:
+ __RE_RELEASE = re.compile(
+ r"^## (?:\[(?P.*)]|\[(?P.*)] - (?P(?P\d{4})-(?P\d{2})-(?P\d{2})))\s*$"
+ )
+ version: str = UNRELEASED
+ release_date: Optional[date] = None
+ raw_release_date: Optional[str] = None
+ url: Optional[str] = None
+
+ def __post_init__(self):
+ if isinstance(self.release_date, str):
+ self.raw_release_date = self.release_date
+ self.release_date = None
+ if self.raw_release_date is not None and self.release_date is None:
+ self.release_date = self.parse_date(self.raw_release_date)
+
+ @property
+ def is_released(self):
+ return not self.version.lower() == UNRELEASED and (
+ self.release_date is not None or self.url is not None
+ )
+
+ @property
+ def is_named_version(self):
+ return self.version and not self.semantic_version
+
+ @property
+ def semantic_version(self) -> Optional[SemanticVersion]:
+ try:
+ return SemanticVersion.from_version_string(self.version)
+ except InvalidSemanticVersion:
+ return None
+
+ @property
+ def semantic_version_strict(self) -> SemanticVersion:
+ try:
+ return SemanticVersion.from_version_string(self.version)
+ except InvalidSemanticVersion:
+ return SemanticVersion(0, 0, -1)
+
+ def to_dict(self, *, raw: bool = False) -> dict:
+ out = {
+ "version": self.version.lower(),
+ }
+ if self.is_released:
+ if raw and self.raw_release_date is not None:
+ out["release_date"] = self.raw_release_date
+ elif self.release_date is not None:
+ out["release_date"] = self.release_date.strftime("%Y-%m-%d")
+ if self.is_named_version:
+ out["release_date"] = None
+ if self.version.strip() and self.semantic_version is not None:
+ out["semantic_version"] = self.semantic_version.to_dict()
+ if self.url is not None:
+ out["url"] = self.url
+ return out
+
+ @staticmethod
+ def parse_date(datestring: str) -> date:
+ accepted_formats = [
+ "%Y-%m-%d", # 2020-10-09
+ "%d-%m-%Y", # 09-10-2020
+ "%Y/%m/%d", # 2020/10/09
+ "%d/%m/%Y", # 09/10/2020
+ "%b %d, %Y", # Oct 9, 2020
+ "%B %d, %Y", # October 9, 2020
+ "%b %d %Y", # Oct 9 2020
+ "%B %d %Y", # October 9 2020
+ ]
+ for accepted_format in accepted_formats:
+ try:
+ dateobj = datetime.strptime(datestring, accepted_format).date()
+ except ValueError:
+ pass
+ else:
+ break
+ else:
+ dateobj = datestring
+ return dateobj
+
+ def parse_release_line_best_effort(self, line: str) -> None:
+ """
+ ## [1.0.1] - May 01, 2018
+ ## 1.0.0 (2017-01-01)
+ """
+ version, *datelist = line[3:].strip().split(maxsplit=1)
+ self.version = version.strip(string.punctuation + string.whitespace)
+ if datelist:
+ self.raw_release_date = datelist.pop().strip(
+ string.punctuation + string.whitespace
+ )
+ release_date = self.parse_date(self.raw_release_date)
+ else:
+ release_date = None
+ self.release_date = release_date
+
+ def parse_release_line(self, line: str) -> None:
+ match = self.__RE_RELEASE.match(line)
+ if match is None:
+ return self.parse_release_line_best_effort(line)
+ groups = match.groupdict()
+ has_version: bool = groups["version"] is not None
+ if has_version:
+ self.version = groups["version"]
+ self.release_date = date(
+ int(groups["year"]), int(groups["month"]), int(groups["day"])
+ )
+ self.raw_release_date = groups["raw_date"]
+ else:
+ self.version = groups["name"]
+
+ @classmethod
+ def from_release_line(cls, line: str) -> "Metadata":
+ obj = cls()
+ obj.parse_release_line(line)
+ return obj
+
+
+class Category:
+ def __init__(self, seq: list = None):
+ self.root: BulletTree = BulletTree.treeify(seq if seq is not None else [])
+
+ def __iter__(self):
+ yield from self.root
+
+ @property
+ def is_empty(self) -> bool:
+ return not self.root
+
+ @staticmethod
+ def extract_information(line: str) -> Tuple[str, str, str]:
+ match = RE_NOTE_LINE.match(line)
+ if match is None: # pragma: no cover
+ raise ValueError(
+ "Looks like an implementation error. Could not parse: %s", line
+ )
+ groups = match.groupdict()
+ return groups["indentation"], groups["bullet"], groups["data"]
+
+ def streamline(self, line: str):
+ """
+ * note 1 l1
+ note 1 l2
+
+ note 1 l4
+ - note 1.1 l1
+ - note 1.2 l1
+ note 1.2 l2
+ - note 2
+ * note 3
+ """
+ indentation, bullet, data = self.extract_information(line)
+ if bullet:
+ if self.is_empty:
+ self.root.new_child_node(
+ BulletTree(
+ [TextNode([data])], bullet=bullet, indent=len(indentation)
+ )
+ )
+ else:
+ last: BulletTree = self.root.last_non_textnode
+ last_indent: int = last.indent
+ if len(indentation) > last_indent:
+ last.new_child_node(
+ BulletTree(
+ [TextNode([data])], bullet=bullet, indent=len(indentation)
+ )
+ )
+ else:
+ node = last
+ while node.indent != len(indentation):
+ node = node.parent
+
+ node.parent.new_child_node(
+ BulletTree(
+ [TextNode([data])], bullet=bullet, indent=len(indentation)
+ )
+ )
+ elif self.is_empty:
+ if line.strip():
+ raise ValueError("Initial line should start with a bullet point!", line)
+ else:
+ text_node: TextNode = self.root.last
+ text_node.append(data)
+
+ def to_markdown(self, *, bullet: Optional[str] = None) -> str:
+ return self.root.print(bullet=bullet)
+
+
+@dataclass
+class Change:
+ metadata: Metadata = field(default_factory=Metadata)
+ uncategorized: Category = field(default_factory=Category)
+ changed: Category = field(default_factory=Category)
+ added: Category = field(default_factory=Category)
+ fixed: Category = field(default_factory=Category)
+ security: Category = field(default_factory=Category)
+ deprecated: Category = field(default_factory=Category)
+ removed: Category = field(default_factory=Category)
+
+ def __post_init__(self):
+ self.__lines: List[str] = []
+ self.__active_category: Optional[Category] = self.uncategorized
+ if isinstance(self.metadata, dict):
+ if "semantic_version" in self.metadata:
+ semver = SemanticVersion.from_dict(self.metadata["semantic_version"])
+ if "version" in self.metadata:
+ semver2 = SemanticVersion.from_version_string(
+ self.metadata["version"]
+ )
+ # to_tuple() because we want them to be exactly equal, even buildmetadata
+ if semver.to_tuple() != semver2.to_tuple():
+ raise UnmatchingSemanticVersion(
+ self.metadata["version"], self.metadata["semantic_version"]
+ )
+ else:
+ self.metadata["version"] = str(semver)
+ self.metadata.pop("semantic_version")
+ self.metadata = Metadata(**self.metadata)
+ for f in fields(self):
+ if f.type is not Category:
+ continue
+ category = getattr(self, f.name)
+ if isinstance(category, list) and not isinstance(category, Category):
+ setattr(self, f.name, Category([category]))
+
+ @property
+ def is_released(self):
+ return self.metadata.is_released
+
+ @property
+ def contains_breaking_changes(self) -> bool:
+ return not self.removed.is_empty or not self.changed.is_empty
+
+ @property
+ def contains_only_bug_fixes(self):
+ return all(
+ [
+ not self.fixed.is_empty,
+ self.uncategorized.is_empty,
+ self.changed.is_empty,
+ self.added.is_empty,
+ self.security.is_empty,
+ self.deprecated.is_empty,
+ self.removed.is_empty,
+ ]
+ )
+
+ @property
+ def is_empty(self):
+ return all(
+ [
+ self.fixed.is_empty,
+ self.uncategorized.is_empty,
+ self.changed.is_empty,
+ self.added.is_empty,
+ self.security.is_empty,
+ self.deprecated.is_empty,
+ self.removed.is_empty,
+ ]
+ )
+
+ def to_markdown(self, *, raw=False) -> str:
+ if raw:
+ return "\n".join(self.__lines)
+ out = []
+ if not self.uncategorized.is_empty:
+ out.append(self.uncategorized.to_markdown())
+ out.append("")
+ for f in fields(self):
+ if f.type is Category and f.name != "uncategorized":
+ category: Category = getattr(self, f.name)
+ if not category.is_empty:
+ out.append(f"### {f.name.capitalize()}")
+ out.append(category.to_markdown())
+ out.append("")
+ return "\n".join(out)
+
+ def to_dict(self, *, raw=False) -> dict:
+ out = {"metadata": self.metadata.to_dict(raw=raw)}
+ if raw:
+ if self.__lines:
+ out["raw"] = "\n".join(self.__lines)
+ if not out["raw"].endswith("\n"):
+ out["raw"] += "\n"
+ else:
+ for f in fields(self):
+ if f.type is Category:
+ category = getattr(self, f.name)
+ if not category.is_empty:
+ out[f.name] = list(category)
+ return out
+
+ def parse_category_line(self, line: str):
+ category = line[4:].lower().strip(" ")
+ if hasattr(self, category):
+ self.__active_category = getattr(self, category)
+
+ def streamline(self, line: str):
+ if is_release(line):
+ self.metadata.parse_release_line(line)
+ return
+ self.__lines.append(line)
+ if is_category(line):
+ self.parse_category_line(line)
+ else:
+ self.__active_category.streamline(line)
+
+
+@dataclass
+class Changelog:
+ header: List[str] = field(default_factory=list)
+ changes: Dict[str, Change] = field(default_factory=dict)
+
+ @property
+ def current_version(self) -> Union[SemanticVersion, str]:
+ maxver = self.__latest_version()
+ return (
+ (maxver["semver"] or maxver["version"])
+ if maxver["is_released"]
+ else maxver["semver_s"]
+ )
+
+ @property
+ def current_version_string(self) -> str:
+ maxver = self.__latest_version()
+ return maxver["version"]
+
+ def __latest_version(self) -> dict:
+ return max(
+ (
+ {
+ "semver": change.metadata.semantic_version,
+ "semver_s": change.metadata.semantic_version_strict,
+ "version": change.metadata.version,
+ "is_released": change.is_released,
+ }
+ for change in self.changes.values()
+ ),
+ key=lambda version: (version["is_released"], version["semver_s"]),
+ )
+
+ @property
+ def next_version(self) -> SemanticVersion:
+ current = self.current_version
+ if current.prerelease is not None:
+ return current.release_version()
+ unreleased_change = self.unreleased_unique
+ if (
+ len(self.changes) == 1
+ and unreleased_change is list(self.changes.values())[0]
+ ):
+ current = SemanticVersion.initial_version()
+ if unreleased_change.contains_breaking_changes:
+ return current.bump_major()
+ if unreleased_change.contains_only_bug_fixes:
+ return current.bump_patch()
+ return current.bump_minor()
+
+ @property
+ def unreleased(self) -> List[Change]:
+ unreleased_changes = []
+ for change in self.changes.values():
+ if not change.is_released:
+ unreleased_changes.append(change)
+ return unreleased_changes
+
+ @property
+ def unreleased_unique(self) -> Change:
+ unreleased_changes = self.unreleased
+ if len(unreleased_changes) > 1:
+ raise AttributeError("There are several unreleased sections!")
+ return unreleased_changes.pop() if unreleased_changes else Change()
+
+ @property
+ def sorted_changes(self) -> Generator[Tuple[str, Change], None, None]:
+ yield from self._sorted_changes(reverse=False)
+
+ @property
+ def sorted_reversed_changes(self) -> Generator[Tuple[str, Change], None, None]:
+ yield from self._sorted_changes(reverse=True)
+
+ def _sorted_changes(self, reverse: bool) -> Generator[Tuple[str, Change], None, None]:
+ for version, change in self.changes.items():
+ if not change.is_released:
+ yield version, change
+ released = ((v, c) for v, c in self.changes.items() if c.is_released)
+ yield from sorted(
+ released, key=lambda k: k[1].metadata.semantic_version_strict, reverse=reverse
+ )
+
+ def release(self, new_version: Optional[SemanticVersion] = None) -> bool:
+ unreleased_change = self.unreleased_unique
+ if unreleased_change.is_empty:
+ return False
+ current_version = self.current_version
+ if isinstance(current_version, str):
+ raise InvalidSemanticVersion(current_version)
+ if new_version is None:
+ new_version = self.next_version
+ return self.__release_unreleased_change(
+ unreleased_change, current_version, new_version
+ )
+
+ def __release_unreleased_change(
+ self,
+ unreleased: Change,
+ current_version: SemanticVersion,
+ new_version: SemanticVersion,
+ ) -> bool:
+ self.wipe_unreleased_references()
+ un_metadata = unreleased.metadata
+ un_metadata.version = str(new_version)
+ un_metadata.release_date = date.today()
+ old_url = un_metadata.url
+ self.__update_url(unreleased, current_version, new_version)
+ self.changes[str(new_version)] = unreleased
+
+ if old_url is not None:
+ self.unreleased_unique.metadata.url = old_url.replace(
+ str(current_version), str(new_version)
+ )
+ return True
+
+ @staticmethod
+ def __update_url(
+ change: Change,
+ current_version: SemanticVersion,
+ new_version: SemanticVersion,
+ ) -> None:
+ old_url = change.metadata.url
+ if old_url is not None:
+ match_old_url = RE_URL.fullmatch(old_url)
+ if match_old_url is not None:
+ groups = match_old_url.groupdict()
+ current_tag = groups["current_tag"]
+ new_tag = current_tag.replace(str(current_version), str(new_version))
+ released_url = old_url.replace(groups["un_tag"], new_tag)
+ change.metadata.url = released_url
+
+ def wipe_unreleased_references(self) -> None:
+ """Wipe all information from unreleased versions."""
+ for version in self.changes.keys():
+ if not self.changes[version].is_released:
+ self.changes[version] = Change(metadata=Metadata(version=version))
+
+ def __post_init__(self):
+ self.__active_change: Optional[Change] = None
+ temp_changes = {}
+ for key, change in self.changes.items():
+ if isinstance(change, dict):
+ temp_changes[key] = Change(**change)
+ self.changes = temp_changes
+
+ def links(self) -> Generator[List[Tuple[str, str]], None, None]:
+ for version, change in self.sorted_reversed_changes:
+ yield version, change.metadata.url
+
+ def to_markdown(self, *, raw=False) -> str:
+ out = self.header[:]
+ if not raw:
+ out.append("")
+ for version, change in self.sorted_reversed_changes:
+ if change.metadata.release_date is not None:
+ out.append(
+ f"## [{version.capitalize()}] - {change.metadata.release_date}"
+ )
+ else:
+ out.append(f"## [{version.capitalize()}]")
+ change_md = change.to_markdown(raw=raw)
+ if change_md or not change.is_released:
+ out.append(change_md)
+ out += [
+ f"[{v.capitalize()}]: {link}"
+ for v, link in self.links()
+ if link is not None
+ ]
+ out.append("")
+ return "\n".join(out)
+
+ def to_dict(self, *, show_unreleased: bool = False, raw: bool = False):
+ return dict(self.iter_changes(show_unreleased=show_unreleased, raw=raw))
+
+ def to_list(self, *, show_unreleased: bool = False, raw: bool = False, reverse: bool = None):
+ return list(self.iter_changes(show_unreleased=show_unreleased, raw=raw, reverse=reverse))
+
+ def iter_changes(self, *, show_unreleased: bool = False, raw: bool = False, reverse: bool = None):
+ changes = self.changes.items() if reverse is None else self.sorted_reversed_changes if reverse else self.sorted_changes
+
+ for version, change in changes:
+ if change.is_released or show_unreleased:
+ yield version.lower(), change.to_dict(raw=raw)
+
+ def streamlines(self, lines: Iterable[str]):
+ for line in lines:
+ line = line.strip("\n")
+ self.streamline(line)
+
+ def streamline(self, line: str):
+ link_match = matches_link(line)
+ if link_match is not None:
+ groups = link_match.groupdict()
+ self.changes.setdefault(
+ groups["version"], Change(Metadata(version=groups["version"]))
+ ).metadata.url = groups["url"]
+ return
+ if is_release(line):
+ self.__active_change = Change()
+ self.__active_change.streamline(line)
+ self.changes[self.__active_change.metadata.version] = self.__active_change
+ elif self.__active_change is not None:
+ self.__active_change.streamline(line)
+ else:
+ self.header.append(line)
diff --git a/keepachangelog/_tree.py b/keepachangelog/_tree.py
new file mode 100644
index 0000000..1d1b704
--- /dev/null
+++ b/keepachangelog/_tree.py
@@ -0,0 +1,190 @@
+from abc import ABC, abstractmethod
+from typing import Union, List, Optional, Iterator, TypeVar, Generic, Sequence
+
+T = TypeVar("T", bound="Tree")
+
+
+class Printable(ABC):
+ """Abstract Printable class.
+
+ Printable classes define a `print` function with an
+ optional `depth` parameter."""
+
+ @abstractmethod
+ def print(self, depth: int = 0) -> str:
+ """Prints the object.
+
+ :param depth: May be used to know the relative depth of this
+ object.
+ """
+ pass # pragma: no cover
+
+
+class TextNode(List[str], Printable):
+ """A text node.
+
+ A Leaf in a Tree. It can contain a `list[str]` to represent
+ a multiline string.
+ """
+
+ def __init__(self, seq: Sequence[str] = (), parent: "Tree" = None):
+ super(TextNode, self).__init__(seq)
+ self.__parent = parent
+
+ def print(self, depth: int = 1, *, indent: int = 2, bullet: str = "-") -> str:
+ if not self:
+ return ""
+ if depth < 1:
+ raise ValueError("A %s cannot be root!", self.__class__.__name__)
+ # let's allow for indent=1 but not encourage
+ indent = indent if indent > 0 else 2
+ initial = (
+ f"{' ' * ((depth - 1 ) * indent)}"
+ f"{' ' * (indent - (2 if indent > 1 else 1))}"
+ f"{bullet}{' ' if indent > 1 else ''}"
+ )
+ subsequent = " " * (depth * indent)
+ lines = [f"{initial}{self[0]}"] + [f"{subsequent}{el}" for el in self[1:]]
+ return "\n".join(lines)
+
+
+NodeType = Union[T, TextNode]
+
+
+class Tree(Generic[T], Printable, Sequence):
+ """A printable Tree."""
+
+ def __init__(
+ self,
+ children: Optional[List[NodeType]] = None,
+ *,
+ parent: T = None,
+ ):
+ self.__parent = parent
+ self.__children: List[NodeType] = [] if children is None else children
+
+ def __iter__(self) -> Iterator:
+ for child in self.__children:
+ if isinstance(child, Tree):
+ yield from child
+ elif isinstance(child, TextNode):
+ yield "\n".join(child).rstrip()
+
+ def __getitem__(self, index: int) -> NodeType:
+ return self.__children[index]
+
+ def __len__(self):
+ return len(self.__children)
+
+ def __repr__(self) -> str:
+ return f"{self.type()}[{', '.join(repr(c) for c in self.__children)}]"
+
+ def __str__(self) -> str:
+ return self.print()
+
+ @property
+ def parent(self) -> T:
+ return self.__parent
+
+ @property
+ def children(self) -> List[NodeType]:
+ return self.__children
+
+ @property
+ def is_root(self) -> bool:
+ return self.__parent is None
+
+ @property
+ def last(self) -> NodeType:
+ if isinstance(self[-1], Tree):
+ return self[-1].last
+ elif isinstance(self[-1], TextNode):
+ return self[-1]
+
+ @property
+ def last_non_textnode(self) -> T:
+ if isinstance(self[-1], Tree):
+ return self[-1].last_non_textnode
+ elif isinstance(self[-1], TextNode):
+ return self
+
+ @classmethod
+ def treeify(cls, data: list) -> T:
+ """Transforms a `list` into a `Tree`.
+
+ >>> Tree.treeify([])
+ Root[]
+
+ >>> Tree.treeify([[]])
+ Root[Node[]]
+
+ >>> Tree.treeify([[[]]])
+ Root[Node[Node[]]]
+
+ >>> Tree.treeify([[], []])
+ Root[Node[], Node[]]
+
+ >>> Tree.treeify([["item 1 (L1)\\nitem1 (L2)"], ["item 2", ["item 2.1", "item 2.2"]]])
+ Root[Node[['item 1 (L1)', 'item1 (L2)']], Node[['item 2'], Node[['item 2.1'], ['item 2.2']]]]
+ """
+ root = cls()
+ for el in data:
+ if isinstance(el, list):
+ root.new_child_node(cls.treeify(el))
+ elif isinstance(el, str):
+ root.new_child_node(TextNode(el.splitlines()))
+ return root
+
+ def new_child_node(self, child: NodeType) -> None:
+ """Adds a child node to the Tree."""
+ self.__children.append(child)
+ child.__parent = self
+
+ def type(self) -> str:
+ """The "type" of the current part of the Tree.
+
+ :return: Either "Root" or "Node"."""
+ return "Root" if self.is_root else "Node"
+
+ def print(self, depth: int = 0) -> str:
+ out = []
+ for child in self.__children:
+ if isinstance(child, Tree):
+ out.append(child.print(depth + 1))
+ elif isinstance(child, TextNode):
+ out.append(child.print(depth))
+ return "\n".join(out)
+
+
+class BulletTree(Tree["BulletTree"]):
+ """A printable Tree that accepts some extra formatting options."""
+
+ def __init__(
+ self,
+ children: Optional[List[NodeType]] = None,
+ *,
+ parent: "Tree" = None,
+ bullet: str = "-",
+ indent: int = 2,
+ ):
+ super(BulletTree, self).__init__(children, parent=parent)
+ self.__bullet = bullet
+ self.__indent = indent
+
+ @property
+ def bullet(self):
+ return self.__bullet
+
+ @property
+ def indent(self):
+ return self.__indent
+
+ def print(self, depth: int = 0, *, bullet: Optional[str] = None) -> str:
+ bullet = self.__bullet if bullet is None else bullet
+ out = []
+ for child in self.children:
+ if isinstance(child, Tree):
+ out.append(child.print(depth + 1))
+ elif isinstance(child, TextNode):
+ out.append(child.print(depth, indent=self.__indent, bullet=bullet))
+ return "\n".join(out)
diff --git a/keepachangelog/_versioning.py b/keepachangelog/_versioning.py
index 45dffc1..6843640 100644
--- a/keepachangelog/_versioning.py
+++ b/keepachangelog/_versioning.py
@@ -1,16 +1,3 @@
-import re
-from functools import cmp_to_key
-from typing import Tuple, Optional, Iterable, List
-
-initial_semantic_version = {
- "major": 0,
- "minor": 0,
- "patch": 0,
- "prerelease": None,
- "buildmetadata": None,
-}
-
-
class InvalidSemanticVersion(Exception):
def __init__(self, version: str):
super().__init__(
@@ -18,149 +5,8 @@ def __init__(self, version: str):
)
-def contains_breaking_changes(unreleased: dict) -> bool:
- return "removed" in unreleased or "changed" in unreleased
-
-
-def only_contains_bug_fixes(unreleased: dict) -> bool:
- return ["fixed"] == list(unreleased)
-
-
-def bump_major(semantic_version: dict):
- semantic_version["major"] += 1
- semantic_version["minor"] = 0
- semantic_version["patch"] = 0
- semantic_version["prerelease"] = None
- semantic_version["buildmetadata"] = None
-
-
-def bump_minor(semantic_version: dict) -> str:
- semantic_version["minor"] += 1
- semantic_version["patch"] = 0
- semantic_version["prerelease"] = None
- semantic_version["buildmetadata"] = None
-
-
-def bump_patch(semantic_version: dict) -> str:
- semantic_version["patch"] += 1
- semantic_version["prerelease"] = None
- semantic_version["buildmetadata"] = None
-
-
-def bump(unreleased: dict, semantic_version: dict) -> dict:
- if semantic_version["prerelease"]:
- semantic_version["prerelease"] = None
- semantic_version["buildmetadata"] = None
- elif contains_breaking_changes(unreleased):
- bump_major(semantic_version)
- elif only_contains_bug_fixes(unreleased):
- bump_patch(semantic_version)
- else:
- bump_minor(semantic_version)
- return semantic_version
-
-
-def _compare(first_version: str, second_version: str) -> int:
- if first_version > second_version:
- return 1
-
- if first_version < second_version:
- return -1
-
- return 0
-
-
-def semantic_order(
- first_version: Tuple[str, dict], second_version: Tuple[str, dict]
-) -> int:
- _, semantic_first_version = first_version
- _, semantic_second_version = second_version
-
- major_difference = _compare(
- semantic_first_version["major"], semantic_second_version["major"]
- )
- if major_difference:
- return major_difference
-
- minor_difference = _compare(
- semantic_first_version["minor"], semantic_second_version["minor"]
- )
- if minor_difference:
- return minor_difference
-
- patch_difference = _compare(
- semantic_first_version["patch"], semantic_second_version["patch"]
- )
- if patch_difference:
- return patch_difference
-
- # Ensure release is "bigger than" pre-release
- pre_release_difference = _compare(
- f"0{semantic_first_version['prerelease']}"
- if semantic_first_version["prerelease"]
- else "1",
- f"0{semantic_second_version['prerelease']}"
- if semantic_second_version["prerelease"]
- else "1",
- )
-
- return pre_release_difference
-
-
-def actual_version(changelog: dict) -> Tuple[Optional[str], dict]:
- versions = to_sorted_semantic(changelog.keys())
- return versions.pop() if versions else (None, initial_semantic_version.copy())
-
-
-def to_sorted_semantic(versions: Iterable[str]) -> List[Tuple[str, dict]]:
- """
- Convert a list of string semantic versions to a sorted list of semantic versions.
- Note: unreleased is not considered as a semantic version and will thus be removed from the resulting versions.
-
- :param versions: un-ordered list of semantic versions (as string). Can contains unreleased.
- :return: An ordered (first element is the oldest version, last element is the newest (highest)) list of versions.
- Each version is represented as a 2-tuple: first one is the string version, second one is a dictionary containing:
- 'major', 'minor', 'patch', 'prerelease', 'buildmetadata' keys.
- """
- return sorted(
- [
- (version, to_semantic(version))
- for version in versions
- if version != "unreleased"
- ],
- key=cmp_to_key(semantic_order),
- )
-
-
-def guess_unreleased_version(
- changelog: dict, current_semantic_version: dict
-) -> Optional[str]:
- unreleased = changelog.get("unreleased", {})
- # Only keep user provided entries
- unreleased = unreleased.copy()
- unreleased.pop("metadata", None)
- if unreleased:
- return from_semantic(bump(unreleased, current_semantic_version))
-
-
-semantic_versioning = re.compile(
- r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:[-\.]?(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
-)
-
-
-def to_semantic(version: Optional[str]) -> dict:
- if not version:
- return initial_semantic_version.copy()
-
- match = semantic_versioning.fullmatch(version)
- if match:
- return {
- key: int(value) if key in ("major", "minor", "patch") else value
- for key, value in match.groupdict().items()
- }
-
- raise InvalidSemanticVersion(version)
-
-
-def from_semantic(semantic_version: dict) -> str:
- return f"{semantic_version['major']}.{semantic_version['minor']}.{semantic_version['patch']}"
+class UnmatchingSemanticVersion(Exception):
+ def __init__(self, version: str, semantic_version: dict):
+ super().__init__(
+ f"Semantic version {semantic_version} does not match version {version}."
+ )
diff --git a/keepachangelog/version.py b/keepachangelog/version.py
index a4a8380..15ef87a 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__ = "2.0.0.dev2"
+__version__ = "2.0.0.dev3"
diff --git a/setup.py b/setup.py
index 06f746c..56a3711 100644
--- a/setup.py
+++ b/setup.py
@@ -36,7 +36,9 @@
],
keywords=["changelog", "CHANGELOG.md", "markdown"],
packages=find_packages(exclude=["tests*"]),
- install_requires=[],
+ install_requires=[
+ 'typing-extensions == 4.0.1; python_version < "3.8.0"',
+ ],
extras_require={
"testing": [
# Used to check starlette endpoint
@@ -46,6 +48,8 @@
"flask-restx==0.5.*",
# Used to check coverage
"pytest-cov==3.*",
+ # For clean datetime mock
+ "freezegun==1.1.0",
]
},
python_requires=">=3.6",
diff --git a/tests/test_category.py b/tests/test_category.py
new file mode 100644
index 0000000..9fa5bff
--- /dev/null
+++ b/tests/test_category.py
@@ -0,0 +1,62 @@
+import textwrap
+
+import pytest
+
+from keepachangelog._changelog_dataclasses import Category
+
+
+class TestCategory:
+ def test_no_bullet_bad(self):
+ lines = textwrap.dedent(
+ """
+ note 1
+ note 2
+ """
+ ).splitlines()
+ category = Category()
+ with pytest.raises(ValueError):
+ for line in lines:
+ category.streamline(line)
+
+ def test_complex(self):
+ lines = textwrap.dedent(
+ """
+ * note 1 l1
+ note 1 l2
+
+ note 1 l4
+ - note 1.1 l1
+ - note 1.2 l1
+ note 1.2 l2
+ - note 2
+ * note 3
+ """
+ ).splitlines()
+ category = Category()
+ for line in lines:
+ category.streamline(line)
+ assert category
+
+ # Should look like:
+ #
+ # Root[
+ # Node[TextNode[""]]
+ # Node[
+ # TextNode["note 1 l1", "note 1 l2", "", "note 1 l4"],
+ # Node[TextNode["note 1.1 l1"]],
+ # Node[TextNode["note 1.2 l1", "note 1.2 l2"]],
+ # ],
+ # Node[TextNode["note 2"]],
+ # Node[TextNode["note 3"]],
+ # ]
+
+ assert category.root[0].bullet == "*"
+ assert category.root[0][0] == ["note 1 l1", "note 1 l2", "", "note 1 l4"]
+ assert category.root[0][1].bullet == "-"
+ assert category.root[0][1][0] == ["note 1.1 l1"]
+ assert category.root[0][2].bullet == "-"
+ assert category.root[0][2][0] == ["note 1.2 l1", "note 1.2 l2"]
+ assert category.root[1].bullet == "-"
+ assert category.root[1][0] == ["note 2"]
+ assert category.root[2].bullet == "*"
+ assert category.root[2][0] == ["note 3"]
diff --git a/tests/test_change.py b/tests/test_change.py
new file mode 100644
index 0000000..7fd94b1
--- /dev/null
+++ b/tests/test_change.py
@@ -0,0 +1,69 @@
+from datetime import date
+
+import pytest
+
+from keepachangelog._changelog_dataclasses import Change, Category
+from keepachangelog._versioning import UnmatchingSemanticVersion
+
+
+class TestChange:
+ def test_construction_with_metadata_as_dict(self):
+ change = Change(metadata={"version": "1.0.0", "release_date": "2018-5-1"})
+ assert change.metadata.version == "1.0.0"
+ assert str(change.metadata.semantic_version) == "1.0.0"
+ assert change.metadata.release_date == date(2018, 5, 1)
+ assert change.metadata.raw_release_date == "2018-5-1"
+
+ def test_construction_with_metadata_as_dict_bad_semver(self):
+ with pytest.raises(UnmatchingSemanticVersion):
+ change = Change(
+ metadata={
+ "version": "1.0.0",
+ "semantic_version": {
+ "major": 1,
+ "minor": 2,
+ "patch": 3,
+ "prerelease": None,
+ "buildmetadata": None,
+ },
+ }
+ )
+
+ def test_construction_with_metadata_as_dict_bad_semver_diff_bmd(self):
+ with pytest.raises(UnmatchingSemanticVersion):
+ change = Change(
+ metadata={
+ "version": "1.0.0+linux",
+ "semantic_version": {
+ "major": 1,
+ "minor": 0,
+ "patch": 0,
+ "prerelease": None,
+ "buildmetadata": "win64",
+ },
+ }
+ )
+
+ def test_construction_with_metadata_as_dict_only_semver(self):
+ change = Change(
+ metadata={
+ "semantic_version": {
+ "major": 1,
+ "minor": 0,
+ "patch": 0,
+ "prerelease": None,
+ "buildmetadata": "win64",
+ },
+ }
+ )
+ assert change.metadata.version == "1.0.0+win64"
+
+ def test_construction_with_category(self):
+ change = Change(
+ uncategorized=["line1", "line2"],
+ changed=["line3", "line4"],
+ )
+ assert isinstance(change.uncategorized, Category)
+ assert list(change.uncategorized) == ["line1", "line2"]
+ assert isinstance(change.changed, Category)
+ assert list(change.changed) == ["line3", "line4"]
diff --git a/tests/test_changelog.py b/tests/test_changelog.py
index 961be91..de0314b 100644
--- a/tests/test_changelog.py
+++ b/tests/test_changelog.py
@@ -1,285 +1,47 @@
-import io
-import os
-import os.path
+import textwrap
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).
-
-## [Unreleased]
-
-## [1.2.0] - 2018-06-01
-### 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.2...v1.1.0
-[1.0.2]: https://github.test_url/test_project/compare/v1.0.1...v1.0.2
-[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
-"""
+from keepachangelog._changelog_dataclasses import Changelog, SemanticVersion
+
+
+class TestChangelog:
+ def test_several_unreleased(self):
+ md = textwrap.dedent(
+ """
+ ## master
+ - line 1
+ ## develop
+ ### changed
+ - line 2
+ ## [1.0.0] 2018-5-1
+ - line 3
+ """
)
- return changelog_file_path
-
-
-changelog_as_dict = {
- "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"],
- "removed": ["Deprecated feature 2", "Future removal 1"],
- "security": ["Known issue 1", "Known issue 2"],
- "metadata": {
- "release_date": "2018-06-01",
- "version": "1.2.0",
- "semantic_version": {
- "buildmetadata": None,
- "major": 1,
- "minor": 2,
- "patch": 0,
- "prerelease": None,
- },
- },
- },
- "1.1.0": {
- "changed": [
- "Enhancement 1 (1.1.0)",
- "sub enhancement 1",
- "sub enhancement 2",
- "Enhancement 2 (1.1.0)",
- ],
- "metadata": {
- "release_date": "2018-05-31",
- "version": "1.1.0",
- "semantic_version": {
- "buildmetadata": None,
- "major": 1,
- "minor": 1,
- "patch": 0,
- "prerelease": None,
- },
- "url": "https://github.test_url/test_project/compare/v1.0.2...v1.1.0",
- },
- },
- "1.0.2": {
- "metadata": {
- "version": "1.0.2",
- "url": "https://github.test_url/test_project/compare/v1.0.1...v1.0.2",
- }
- },
- "1.0.1": {
- "fixed": [
- "Bug fix 1 (1.0.1)",
- "sub bug 1",
- "sub bug 2",
- "Bug fix 2 (1.0.1)",
- ],
- "metadata": {
- "release_date": "2018-05-31",
- "version": "1.0.1",
- "semantic_version": {
- "buildmetadata": None,
- "major": 1,
- "minor": 0,
- "patch": 1,
- "prerelease": None,
- },
- "url": "https://github.test_url/test_project/compare/v1.0.0...v1.0.1",
- },
- },
- "1.0.0": {
- "deprecated": ["Known issue 1 (1.0.0)", "Known issue 2 (1.0.0)"],
- "metadata": {
- "release_date": "2017-04-10",
- "version": "1.0.0",
- "semantic_version": {
- "buildmetadata": None,
- "major": 1,
- "minor": 0,
- "patch": 0,
- "prerelease": None,
- },
- "url": "https://github.test_url/test_project/releases/tag/v1.0.0",
- },
- },
-}
-
-
-def test_changelog_with_versions_and_all_categories(changelog):
- assert keepachangelog.to_dict(changelog) == changelog_as_dict
-
-
-def test_changelog_with_versions_and_all_categories_as_file_reader(changelog):
- with io.StringIO(open(changelog).read()) as file_reader:
- assert keepachangelog.to_dict(file_reader) == changelog_as_dict
-
- # Assert that file reader is not closed
- file_reader.seek(0)
- assert keepachangelog.to_dict(file_reader) == changelog_as_dict
-
-
-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
-""",
- "metadata": {
- "release_date": "2018-06-01",
- "version": "1.2.0",
- "semantic_version": {
- "buildmetadata": None,
- "major": 1,
- "minor": 2,
- "patch": 0,
- "prerelease": None,
- },
- },
- },
- "1.1.0": {
- "raw": """### Changed
-- Enhancement 1 (1.1.0)
-- sub enhancement 1
-- sub enhancement 2
-- Enhancement 2 (1.1.0)
-""",
- "metadata": {
- "release_date": "2018-05-31",
- "version": "1.1.0",
- "semantic_version": {
- "buildmetadata": None,
- "major": 1,
- "minor": 1,
- "patch": 0,
- "prerelease": None,
- },
- "url": "https://github.test_url/test_project/compare/v1.0.2...v1.1.0",
- },
- },
- "1.0.2": {
- "metadata": {
- "version": "1.0.2",
- "url": "https://github.test_url/test_project/compare/v1.0.1...v1.0.2",
- },
- },
- "1.0.1": {
- "raw": """### Fixed
-- Bug fix 1 (1.0.1)
-- sub bug 1
-- sub bug 2
-- Bug fix 2 (1.0.1)
-""",
- "metadata": {
- "release_date": "2018-05-31",
- "version": "1.0.1",
- "semantic_version": {
- "buildmetadata": None,
- "major": 1,
- "minor": 0,
- "patch": 1,
- "prerelease": None,
- },
- "url": "https://github.test_url/test_project/compare/v1.0.0...v1.0.1",
- },
- },
- "1.0.0": {
- "raw": """### Deprecated
-- Known issue 1 (1.0.0)
-- Known issue 2 (1.0.0)
-""",
- "metadata": {
- "release_date": "2017-04-10",
- "version": "1.0.0",
- "semantic_version": {
- "buildmetadata": None,
- "major": 1,
- "minor": 0,
- "patch": 0,
- "prerelease": None,
- },
- "url": "https://github.test_url/test_project/releases/tag/v1.0.0",
- },
- },
- }
+ changelog = Changelog()
+ changelog.streamlines(md.splitlines())
+ assert len(changelog.unreleased) == 2
+ assert len(changelog.changes) == 3
+ with pytest.raises(AttributeError):
+ un = changelog.unreleased_unique
+
+ def test_released_no_version(self):
+ md = textwrap.dedent(
+ """
+ ## [] 2018-5-1
+ - line 1
+ """
+ )
+ changelog = Changelog()
+ changelog.streamlines(md.splitlines())
+ assert len(changelog.unreleased) == 0
+ assert len(changelog.changes) == 1
+ change = list(changelog.changes.values())[0]
+ assert change.is_released
+ assert not change.is_empty
+ assert (
+ change.metadata.semantic_version
+ == change.metadata.semantic_version_strict
+ == SemanticVersion.initial_version()
+ )
+ assert changelog.current_version == SemanticVersion.initial_version()
diff --git a/tests/test_changelog_empty_version.py b/tests/test_changelog_empty_version.py
index b2dc644..3849650 100644
--- a/tests/test_changelog_empty_version.py
+++ b/tests/test_changelog_empty_version.py
@@ -57,13 +57,6 @@ def test_changelog_with_empty_version(changelog):
"metadata": {
"release_date": "2018-06-01",
"version": "",
- "semantic_version": {
- "buildmetadata": None,
- "major": 0,
- "minor": 0,
- "patch": 0,
- "prerelease": None,
- },
},
},
}
diff --git a/tests/test_changelog_generic.py b/tests/test_changelog_generic.py
new file mode 100644
index 0000000..ff1b73c
--- /dev/null
+++ b/tests/test_changelog_generic.py
@@ -0,0 +1,304 @@
+import io
+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).
+
+## [Unreleased]
+
+## [1.2.0] - 2018-06-01
+### 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.2...v1.1.0
+[1.0.2]: https://github.test_url/test_project/compare/v1.0.1...v1.0.2
+[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
+
+
+changelog_as_dict = {
+ "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"],
+ "removed": ["Deprecated feature 2", "Future removal 1"],
+ "security": ["Known issue 1", "Known issue 2"],
+ "metadata": {
+ "release_date": "2018-06-01",
+ "version": "1.2.0",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 2,
+ "patch": 0,
+ "prerelease": None,
+ },
+ },
+ },
+ "1.1.0": {
+ "changed": [
+ "Enhancement 1 (1.1.0)",
+ "sub enhancement 1",
+ "sub enhancement 2",
+ "Enhancement 2 (1.1.0)",
+ ],
+ "metadata": {
+ "release_date": "2018-05-31",
+ "version": "1.1.0",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 1,
+ "patch": 0,
+ "prerelease": None,
+ },
+ "url": "https://github.test_url/test_project/compare/v1.0.2...v1.1.0",
+ },
+ },
+ "1.0.2": {
+ "metadata": {
+ "version": "1.0.2",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 0,
+ "patch": 2,
+ "prerelease": None,
+ },
+ "url": "https://github.test_url/test_project/compare/v1.0.1...v1.0.2",
+ }
+ },
+ "1.0.1": {
+ "fixed": [
+ "Bug fix 1 (1.0.1)",
+ "sub bug 1",
+ "sub bug 2",
+ "Bug fix 2 (1.0.1)",
+ ],
+ "metadata": {
+ "release_date": "2018-05-31",
+ "version": "1.0.1",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 0,
+ "patch": 1,
+ "prerelease": None,
+ },
+ "url": "https://github.test_url/test_project/compare/v1.0.0...v1.0.1",
+ },
+ },
+ "1.0.0": {
+ "deprecated": ["Known issue 1 (1.0.0)", "Known issue 2 (1.0.0)"],
+ "metadata": {
+ "release_date": "2017-04-10",
+ "version": "1.0.0",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 0,
+ "patch": 0,
+ "prerelease": None,
+ },
+ "url": "https://github.test_url/test_project/releases/tag/v1.0.0",
+ },
+ },
+}
+
+
+def test_changelog_with_versions_and_all_categories(changelog):
+ assert keepachangelog.to_dict(changelog) == changelog_as_dict
+
+
+def test_changelog_with_versions_and_all_categories_as_file_reader(changelog):
+ with io.StringIO(open(changelog).read()) as file_reader:
+ assert keepachangelog.to_dict(file_reader) == changelog_as_dict
+
+ # Assert that file reader is not closed
+ file_reader.seek(0)
+ assert keepachangelog.to_dict(file_reader) == changelog_as_dict
+
+
+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
+""",
+ "metadata": {
+ "release_date": "2018-06-01",
+ "version": "1.2.0",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 2,
+ "patch": 0,
+ "prerelease": None,
+ },
+ },
+ },
+ "1.1.0": {
+ "raw": """### Changed
+- Enhancement 1 (1.1.0)
+- sub enhancement 1
+- sub enhancement 2
+- Enhancement 2 (1.1.0)
+""",
+ "metadata": {
+ "release_date": "2018-05-31",
+ "version": "1.1.0",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 1,
+ "patch": 0,
+ "prerelease": None,
+ },
+ "url": "https://github.test_url/test_project/compare/v1.0.2...v1.1.0",
+ },
+ },
+ "1.0.2": {
+ "metadata": {
+ "version": "1.0.2",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 0,
+ "patch": 2,
+ "prerelease": None,
+ },
+ "url": "https://github.test_url/test_project/compare/v1.0.1...v1.0.2",
+ },
+ },
+ "1.0.1": {
+ "raw": """### Fixed
+- Bug fix 1 (1.0.1)
+- sub bug 1
+- sub bug 2
+- Bug fix 2 (1.0.1)
+""",
+ "metadata": {
+ "release_date": "2018-05-31",
+ "version": "1.0.1",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 0,
+ "patch": 1,
+ "prerelease": None,
+ },
+ "url": "https://github.test_url/test_project/compare/v1.0.0...v1.0.1",
+ },
+ },
+ "1.0.0": {
+ "raw": """### Deprecated
+- Known issue 1 (1.0.0)
+- Known issue 2 (1.0.0)
+""",
+ "metadata": {
+ "release_date": "2017-04-10",
+ "version": "1.0.0",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 0,
+ "patch": 0,
+ "prerelease": None,
+ },
+ "url": "https://github.test_url/test_project/releases/tag/v1.0.0",
+ },
+ },
+ }
diff --git a/tests/test_changelog_generic_multiline_subitems.py b/tests/test_changelog_generic_multiline_subitems.py
new file mode 100644
index 0000000..520407a
--- /dev/null
+++ b/tests/test_changelog_generic_multiline_subitems.py
@@ -0,0 +1,316 @@
+import io
+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).
+
+## [Unreleased]
+
+## [1.2.0] - 2018-06-01
+### Changed
+- Release note 1.
+- Release note 2.
+
+### Added
+- Enhancement 1
+ Takes several lines
+ To explain
+
+ even empty lines
+ - 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.2...v1.1.0
+[1.0.2]: https://github.test_url/test_project/compare/v1.0.1...v1.0.2
+[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
+
+
+changelog_as_dict = {
+ "1.2.0": {
+ "added": [
+ """Enhancement 1
+Takes several lines
+To explain
+
+even empty lines""",
+ "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"],
+ "removed": ["Deprecated feature 2", "Future removal 1"],
+ "security": ["Known issue 1", "Known issue 2"],
+ "metadata": {
+ "release_date": "2018-06-01",
+ "version": "1.2.0",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 2,
+ "patch": 0,
+ "prerelease": None,
+ },
+ },
+ },
+ "1.1.0": {
+ "changed": [
+ "Enhancement 1 (1.1.0)",
+ "sub enhancement 1",
+ "sub enhancement 2",
+ "Enhancement 2 (1.1.0)",
+ ],
+ "metadata": {
+ "release_date": "2018-05-31",
+ "version": "1.1.0",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 1,
+ "patch": 0,
+ "prerelease": None,
+ },
+ "url": "https://github.test_url/test_project/compare/v1.0.2...v1.1.0",
+ },
+ },
+ "1.0.2": {
+ "metadata": {
+ "version": "1.0.2",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 0,
+ "patch": 2,
+ "prerelease": None,
+ },
+ "url": "https://github.test_url/test_project/compare/v1.0.1...v1.0.2",
+ }
+ },
+ "1.0.1": {
+ "fixed": [
+ "Bug fix 1 (1.0.1)",
+ "sub bug 1",
+ "sub bug 2",
+ "Bug fix 2 (1.0.1)",
+ ],
+ "metadata": {
+ "release_date": "2018-05-31",
+ "version": "1.0.1",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 0,
+ "patch": 1,
+ "prerelease": None,
+ },
+ "url": "https://github.test_url/test_project/compare/v1.0.0...v1.0.1",
+ },
+ },
+ "1.0.0": {
+ "deprecated": ["Known issue 1 (1.0.0)", "Known issue 2 (1.0.0)"],
+ "metadata": {
+ "release_date": "2017-04-10",
+ "version": "1.0.0",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 0,
+ "patch": 0,
+ "prerelease": None,
+ },
+ "url": "https://github.test_url/test_project/releases/tag/v1.0.0",
+ },
+ },
+}
+
+
+def test_changelog_with_versions_and_all_categories(changelog):
+ assert keepachangelog.to_dict(changelog) == changelog_as_dict
+
+
+def test_changelog_with_versions_and_all_categories_as_file_reader(changelog):
+ with io.StringIO(open(changelog).read()) as file_reader:
+ assert keepachangelog.to_dict(file_reader) == changelog_as_dict
+
+ # Assert that file reader is not closed
+ file_reader.seek(0)
+ assert keepachangelog.to_dict(file_reader) == changelog_as_dict
+
+
+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
+ Takes several lines
+ To explain
+
+ even empty lines
+ - 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
+""",
+ "metadata": {
+ "release_date": "2018-06-01",
+ "version": "1.2.0",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 2,
+ "patch": 0,
+ "prerelease": None,
+ },
+ },
+ },
+ "1.1.0": {
+ "raw": """### Changed
+- Enhancement 1 (1.1.0)
+ - sub enhancement 1
+ - sub enhancement 2
+- Enhancement 2 (1.1.0)
+""",
+ "metadata": {
+ "release_date": "2018-05-31",
+ "version": "1.1.0",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 1,
+ "patch": 0,
+ "prerelease": None,
+ },
+ "url": "https://github.test_url/test_project/compare/v1.0.2...v1.1.0",
+ },
+ },
+ "1.0.2": {
+ "metadata": {
+ "version": "1.0.2",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 0,
+ "patch": 2,
+ "prerelease": None,
+ },
+ "url": "https://github.test_url/test_project/compare/v1.0.1...v1.0.2",
+ },
+ },
+ "1.0.1": {
+ "raw": """### Fixed
+- Bug fix 1 (1.0.1)
+ - sub bug 1
+ - sub bug 2
+- Bug fix 2 (1.0.1)
+""",
+ "metadata": {
+ "release_date": "2018-05-31",
+ "version": "1.0.1",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 0,
+ "patch": 1,
+ "prerelease": None,
+ },
+ "url": "https://github.test_url/test_project/compare/v1.0.0...v1.0.1",
+ },
+ },
+ "1.0.0": {
+ "raw": """### Deprecated
+- Known issue 1 (1.0.0)
+- Known issue 2 (1.0.0)
+""",
+ "metadata": {
+ "release_date": "2017-04-10",
+ "version": "1.0.0",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 0,
+ "patch": 0,
+ "prerelease": None,
+ },
+ "url": "https://github.test_url/test_project/releases/tag/v1.0.0",
+ },
+ },
+ }
diff --git a/tests/test_changelog_release.py b/tests/test_changelog_release.py
index 7053c5b..3f39dcb 100644
--- a/tests/test_changelog_release.py
+++ b/tests/test_changelog_release.py
@@ -3,6 +3,7 @@
import os.path
import pytest
+from freezegun import freeze_time
import keepachangelog
import keepachangelog._changelog
@@ -10,28 +11,6 @@
_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")
@@ -459,11 +438,11 @@ def minor_digit_changelog(tmpdir):
### Added
- Enhancement
-## [9.9.100] - 2018-05-31
+## [9.10.90] - 2018-05-31
### Added
- Enhancement
-## [9.10.90] - 2018-05-31
+## [9.9.100] - 2018-05-31
### Added
- Enhancement
"""
@@ -497,8 +476,8 @@ def patch_digit_changelog(tmpdir):
)
return changelog_file_path
-
-def test_major_release(major_changelog, mock_date):
+@freeze_time(_date_time_for_tests)
+def test_major_release(major_changelog):
assert keepachangelog.release(major_changelog) == "2.0.0"
with open(major_changelog) as file:
assert (
@@ -575,7 +554,8 @@ def test_major_release(major_changelog, mock_date):
)
-def test_minor_release(minor_changelog, mock_date):
+@freeze_time(_date_time_for_tests)
+def test_minor_release(minor_changelog):
assert keepachangelog.release(minor_changelog) == "1.2.0"
with open(minor_changelog) as file:
assert (
@@ -644,7 +624,8 @@ def test_minor_release(minor_changelog, mock_date):
)
-def test_major_digit_release(major_digit_changelog, mock_date):
+@freeze_time(_date_time_for_tests)
+def test_major_digit_release(major_digit_changelog):
assert keepachangelog.release(major_digit_changelog) == "11.0.0"
with open(major_digit_changelog) as file:
assert (
@@ -672,7 +653,8 @@ def test_major_digit_release(major_digit_changelog, mock_date):
)
-def test_minor_digit_release(minor_digit_changelog, mock_date):
+@freeze_time(_date_time_for_tests)
+def test_minor_digit_release(minor_digit_changelog):
assert keepachangelog.release(minor_digit_changelog) == "9.11.0"
with open(minor_digit_changelog) as file:
assert (
@@ -689,18 +671,19 @@ def test_minor_digit_release(minor_digit_changelog, mock_date):
### Added
- Enhancement
-## [9.9.100] - 2018-05-31
+## [9.10.90] - 2018-05-31
### Added
- Enhancement
-## [9.10.90] - 2018-05-31
+## [9.9.100] - 2018-05-31
### Added
- Enhancement
"""
)
-def test_patch_digit_release(patch_digit_changelog, mock_date):
+@freeze_time(_date_time_for_tests)
+def test_patch_digit_release(patch_digit_changelog):
assert keepachangelog.release(patch_digit_changelog) == "9.9.11"
with open(patch_digit_changelog) as file:
assert (
@@ -728,10 +711,9 @@ def test_patch_digit_release(patch_digit_changelog, mock_date):
)
-def test_sorted_major_digit_semantic_release(major_digit_changelog, mock_date):
- assert keepachangelog.to_sorted_semantic(
- keepachangelog.to_raw_dict(major_digit_changelog)
- ) == [
+@freeze_time(_date_time_for_tests)
+def test_sorted_major_digit_semantic_release(major_digit_changelog):
+ assert keepachangelog.to_sorted_semantic(major_digit_changelog, reverse=False) == [
(
"9.10.100",
{
@@ -755,10 +737,9 @@ def test_sorted_major_digit_semantic_release(major_digit_changelog, mock_date):
]
-def test_sorted_minor_digit_semantic_release(minor_digit_changelog, mock_date):
- assert keepachangelog.to_sorted_semantic(
- keepachangelog.to_raw_dict(minor_digit_changelog)
- ) == [
+@freeze_time(_date_time_for_tests)
+def test_sorted_minor_digit_semantic_release(minor_digit_changelog):
+ assert keepachangelog.to_sorted_semantic(minor_digit_changelog, reverse=False) == [
(
"9.9.100",
{
@@ -782,10 +763,9 @@ def test_sorted_minor_digit_semantic_release(minor_digit_changelog, mock_date):
]
-def test_sorted_patch_digit_semantic_release(patch_digit_changelog, mock_date):
- assert keepachangelog.to_sorted_semantic(
- keepachangelog.to_raw_dict(patch_digit_changelog)
- ) == [
+@freeze_time(_date_time_for_tests)
+def test_sorted_patch_digit_semantic_release(patch_digit_changelog):
+ assert keepachangelog.to_sorted_semantic(patch_digit_changelog, reverse=False) == [
(
"9.9.9",
{
@@ -809,7 +789,8 @@ def test_sorted_patch_digit_semantic_release(patch_digit_changelog, mock_date):
]
-def test_patch_release(patch_changelog, mock_date):
+@freeze_time(_date_time_for_tests)
+def test_patch_release(patch_changelog):
assert keepachangelog.release(patch_changelog) == "1.1.1"
with open(patch_changelog) as file:
assert (
@@ -857,7 +838,8 @@ def test_patch_release(patch_changelog, mock_date):
)
-def test_first_major_release(first_major_changelog, mock_date):
+@freeze_time(_date_time_for_tests)
+def test_first_major_release(first_major_changelog):
assert keepachangelog.release(first_major_changelog) == "1.0.0"
with open(first_major_changelog) as file:
assert (
@@ -905,7 +887,8 @@ def test_first_major_release(first_major_changelog, mock_date):
)
-def test_first_minor_release(first_minor_changelog, mock_date):
+@freeze_time(_date_time_for_tests)
+def test_first_minor_release(first_minor_changelog):
assert keepachangelog.release(first_minor_changelog) == "0.1.0"
with open(first_minor_changelog) as file:
assert (
@@ -945,7 +928,8 @@ def test_first_minor_release(first_minor_changelog, mock_date):
)
-def test_first_patch_release(first_patch_changelog, mock_date):
+@freeze_time(_date_time_for_tests)
+def test_first_patch_release(first_patch_changelog):
assert keepachangelog.release(first_patch_changelog) == "0.0.1"
with open(first_patch_changelog) as file:
assert (
@@ -988,7 +972,8 @@ def test_non_semantic_release(non_semantic_changelog):
)
-def test_first_stable_release(unstable_changelog, mock_date):
+@freeze_time(_date_time_for_tests)
+def test_first_stable_release(unstable_changelog):
assert keepachangelog.release(unstable_changelog) == "2.5.0"
with open(unstable_changelog) as file:
assert (
@@ -1015,7 +1000,8 @@ def test_first_stable_release(unstable_changelog, mock_date):
)
-def test_custom_release(unstable_changelog, mock_date):
+@freeze_time(_date_time_for_tests)
+def test_custom_release(unstable_changelog):
assert (
keepachangelog.release(unstable_changelog, new_version="2.5.0b52") == "2.5.0b52"
)
diff --git a/tests/test_changelog_unreleased.py b/tests/test_changelog_unreleased.py
index 4f2044c..fc5110b 100644
--- a/tests/test_changelog_unreleased.py
+++ b/tests/test_changelog_unreleased.py
@@ -125,6 +125,13 @@ def test_changelog_with_versions_and_all_categories(changelog):
"metadata": {
"url": "https://github.test_url/test_project/compare/v1.0.1...v1.0.2",
"version": "1.0.2",
+ "semantic_version": {
+ "buildmetadata": None,
+ "major": 1,
+ "minor": 0,
+ "patch": 2,
+ "prerelease": None,
+ },
},
},
"1.0.1": {
@@ -191,7 +198,7 @@ def test_changelog_from_dict(changelog):
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
-* Release note 0.
+- Release note 0.
### Changed
- Release note 1.
@@ -228,6 +235,7 @@ def test_changelog_from_dict(changelog):
- sub enhancement 2
- Enhancement 2 (1.1.0)
+## [1.0.2]
## [1.0.1] - 2018-05-31
### Fixed
- Bug fix 1 (1.0.1)
@@ -244,11 +252,10 @@ def test_changelog_from_dict(changelog):
### Added
- First release
-## [1.0.2]
[Unreleased]: https://github.test_url/test_project/compare/v1.1.0...HEAD
[1.1.0]: https://github.test_url/test_project/compare/v1.0.2...v1.1.0
+[1.0.2]: https://github.test_url/test_project/compare/v1.0.1...v1.0.2
[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
-[1.0.2]: https://github.test_url/test_project/compare/v1.0.1...v1.0.2
"""
)
diff --git a/tests/test_metadata.py b/tests/test_metadata.py
new file mode 100644
index 0000000..e009110
--- /dev/null
+++ b/tests/test_metadata.py
@@ -0,0 +1,56 @@
+from datetime import date
+
+import pytest
+
+from keepachangelog._changelog_dataclasses import Metadata
+
+
+class TestMetadata:
+ @pytest.mark.parametrize(
+ ["line", "version", "raw_release_date", "release_date"],
+ [
+ pytest.param(
+ "## [1.2.3] - 2018-05-01", "1.2.3", "2018-05-01", date(2018, 5, 1)
+ ),
+ pytest.param(
+ "## [1.2.3] - 01-05-2018", "1.2.3", "01-05-2018", date(2018, 5, 1)
+ ),
+ pytest.param(
+ "## [1.2.3] - 2018/05/01", "1.2.3", "2018/05/01", date(2018, 5, 1)
+ ),
+ pytest.param(
+ "## [1.2.3] - 01/05/2018", "1.2.3", "01/05/2018", date(2018, 5, 1)
+ ),
+ pytest.param(
+ "## [1.2.3] - May 01, 2018", "1.2.3", "May 01, 2018", date(2018, 5, 1)
+ ),
+ pytest.param(
+ "## [1.2.3] - May 01, 2018", "1.2.3", "May 01, 2018", date(2018, 5, 1)
+ ),
+ pytest.param(
+ "## [1.2.3] - May 01 2018", "1.2.3", "May 01 2018", date(2018, 5, 1)
+ ),
+ pytest.param(
+ "## [1.2.3] - May 01 2018", "1.2.3", "May 01 2018", date(2018, 5, 1)
+ ),
+ pytest.param(
+ "## [1.2.3] - 1er mai 2018", "1.2.3", "1er mai 2018", "1er mai 2018"
+ ),
+ pytest.param(
+ "## 1.2.3 May 01 2018", "1.2.3", "May 01 2018", date(2018, 5, 1)
+ ),
+ pytest.param(
+ "## 1.2.3 (May 01 2018)", "1.2.3", "May 01 2018", date(2018, 5, 1)
+ ),
+ pytest.param(
+ "## [1.2.3] (May 01 2018)", "1.2.3", "May 01 2018", date(2018, 5, 1)
+ ),
+ pytest.param("## [master]", "master", None, None),
+ pytest.param("## Develop", "Develop", None, None),
+ ],
+ )
+ def test_parse_release_line(self, line, version, raw_release_date, release_date):
+ metadata = Metadata.from_release_line(line)
+ assert metadata.version == version
+ assert metadata.release_date == release_date
+ assert metadata.raw_release_date == raw_release_date
diff --git a/tests/test_non_standard_changelog.py b/tests/test_non_standard_changelog.py
index 25147da..99c9147 100644
--- a/tests/test_non_standard_changelog.py
+++ b/tests/test_non_standard_changelog.py
@@ -86,7 +86,7 @@ def test_changelog_with_versions_and_all_categories(changelog):
"removed": ["Deprecated feature 2", "Future removal 1"],
"security": ["Known issue 1", "Known issue 2"],
"metadata": {
- "release_date": "august 28, 2019",
+ "release_date": "2019-08-28",
"version": "1.2.0",
"semantic_version": {
"buildmetadata": None,
@@ -105,7 +105,7 @@ def test_changelog_with_versions_and_all_categories(changelog):
"Enhancement 2 (1.1.0)",
],
"metadata": {
- "release_date": "may 03, 2018",
+ "release_date": "2018-05-03",
"version": "1.1.0",
"semantic_version": {
"buildmetadata": None,
@@ -124,7 +124,7 @@ def test_changelog_with_versions_and_all_categories(changelog):
"Bug fix 2 (1.0.1)",
],
"metadata": {
- "release_date": "may 01, 2018",
+ "release_date": "2018-05-01",
"version": "1.0.1",
"semantic_version": {
"buildmetadata": None,
@@ -170,7 +170,7 @@ def test_changelog_with_unreleased_versions_and_all_categories(changelog):
"removed": ["Deprecated feature 2", "Future removal 1"],
"security": ["Known issue 1", "Known issue 2"],
"metadata": {
- "release_date": "august 28, 2019",
+ "release_date": "2019-08-28",
"version": "1.2.0",
"semantic_version": {
"buildmetadata": None,
@@ -189,7 +189,7 @@ def test_changelog_with_unreleased_versions_and_all_categories(changelog):
"Enhancement 2 (1.1.0)",
],
"metadata": {
- "release_date": "may 03, 2018",
+ "release_date": "2018-05-03",
"version": "1.1.0",
"semantic_version": {
"buildmetadata": None,
@@ -208,7 +208,7 @@ def test_changelog_with_unreleased_versions_and_all_categories(changelog):
"Bug fix 2 (1.0.1)",
],
"metadata": {
- "release_date": "may 01, 2018",
+ "release_date": "2018-05-01",
"version": "1.0.1",
"semantic_version": {
"buildmetadata": None,
@@ -242,28 +242,33 @@ def test_raw_changelog_with_versions_and_all_categories(changelog):
"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
""",
"metadata": {
- "release_date": "august 28, 2019",
+ "release_date": "August 28, 2019",
"version": "1.2.0",
"semantic_version": {
"buildmetadata": None,
@@ -282,7 +287,7 @@ def test_raw_changelog_with_versions_and_all_categories(changelog):
- Enhancement 2 (1.1.0)
""",
"metadata": {
- "release_date": "may 03, 2018",
+ "release_date": "May 03, 2018",
"version": "1.1.0",
"semantic_version": {
"buildmetadata": None,
@@ -301,7 +306,7 @@ def test_raw_changelog_with_versions_and_all_categories(changelog):
- Bug fix 2 (1.0.1)
""",
"metadata": {
- "release_date": "may 01, 2018",
+ "release_date": "May 01, 2018",
"version": "1.0.1",
"semantic_version": {
"buildmetadata": None,
diff --git a/tests/test_semantic_version.py b/tests/test_semantic_version.py
new file mode 100644
index 0000000..c14edf0
--- /dev/null
+++ b/tests/test_semantic_version.py
@@ -0,0 +1,110 @@
+import pytest
+from keepachangelog._changelog_dataclasses import SemanticVersion
+from keepachangelog._versioning import InvalidSemanticVersion
+
+
+@pytest.fixture
+def initial_semantic_version() -> SemanticVersion:
+ return SemanticVersion.initial_version()
+
+
+@pytest.fixture
+def initial_semantic_dict(initial_semantic_version: SemanticVersion) -> dict:
+ return initial_semantic_version.to_dict(force=True)
+
+
+class TestToSemantic:
+ def test_empty_str(self, initial_semantic_dict):
+ assert SemanticVersion.to_semantic("") == initial_semantic_dict
+
+ def test_default(self, initial_semantic_dict):
+ assert SemanticVersion.to_semantic() == initial_semantic_dict
+
+ def test_none(self, initial_semantic_dict):
+ assert SemanticVersion.to_semantic(None) == initial_semantic_dict
+
+
+class TestFromVersionString:
+ @pytest.mark.parametrize(
+ ["version_string", "expected_tuple"],
+ [
+ pytest.param("1.2.3", (1, 2, 3, None, None), id="Ma.Mi.Pa"),
+ pytest.param("1.2.3b1", (1, 2, 3, "b1", None), id="Ma.Mi.PaPr"),
+ pytest.param("1.2.3-b1", (1, 2, 3, "b1", None), id="Ma.Mi.Pa-Pr"),
+ pytest.param("1.2.3.b1", (1, 2, 3, "b1", None), id="Ma.Mi.Pa.Pr"),
+ pytest.param("1.2.3+42", (1, 2, 3, None, "42"), id="Ma.Mi.Pa+BMD"),
+ pytest.param("1.2.3b1+42", (1, 2, 3, "b1", "42"), id="Ma.Mi.PaPr+BMD"),
+ pytest.param("1.2.3-b1+42", (1, 2, 3, "b1", "42"), id="Ma.Mi.Pa-Pr+BMD"),
+ pytest.param(
+ "1.2.3.b1.42", (1, 2, 3, "b1.42", None), id="Ma.Mi.Pa.Pr1.Pr2"
+ ),
+ pytest.param(
+ "1.2.3.4.8.15+16.23.42",
+ (1, 2, 3, "4.8.15", "16.23.42"),
+ id="Ma.Mi.Pa.Pr1.Pr2.Pr3+BMD1.BMD2.BMD3",
+ ),
+ ],
+ )
+ def test_valid(self, version_string, expected_tuple):
+ assert (
+ SemanticVersion.from_version_string(version_string).to_tuple()
+ == expected_tuple
+ )
+
+ @pytest.mark.parametrize(
+ ["version_string"],
+ [
+ pytest.param("1", id="Ma"),
+ pytest.param("1.2", id="Ma.Mi"),
+ pytest.param("a.2.2", id="MaS.Mi.Pa"),
+ pytest.param("1.b.3", id="Ma.MiS.Pa"),
+ pytest.param("1.2.c", id="Ma.Mi.PaS"),
+ pytest.param("1.2-alpha", id="Ma.Mi-Pr"),
+ pytest.param("1.2-alpha+dev", id="Ma.Mi-Pr+BMD"),
+ pytest.param("1.2+dev", id="Ma.Mi+BMD"),
+ ],
+ )
+ def test_invalid(self, version_string):
+ with pytest.raises(InvalidSemanticVersion):
+ SemanticVersion.from_version_string(version_string).to_tuple()
+
+ @pytest.mark.parametrize(
+ ["ver_low", "ver_high"],
+ [
+ pytest.param("1.0.0", "1.0.1", id="patch"),
+ pytest.param("1.0.0", "1.1.0", id="minor"),
+ pytest.param("1.0.0", "2.0.0", id="major"),
+ pytest.param("1.0.0-dev", "1.0.0", id="prerelease-release"),
+ pytest.param("1.0.0-dev1", "1.0.0-dev2", id="prerelease-prerelease"),
+ pytest.param("1.0.0-dev1+2", "1.0.0-dev2+1", id="pre1.BMD-pre2.BMD"),
+ ],
+ )
+ def test_ordering_less(self, ver_low, ver_high):
+ semver_low = SemanticVersion.from_version_string(ver_low)
+ semver_high = SemanticVersion.from_version_string(ver_high)
+ assert semver_low < semver_high
+
+ @pytest.mark.parametrize(
+ ["version1", "version2"],
+ [
+ pytest.param("1.0.0", "1.0.0", id="same"),
+ pytest.param("1.0.0+1", "1.0.0+2", id="BMD-BMD"),
+ pytest.param("1.0.0+posix", "1.0.0+win64", id="BMD_os-BMD_os"),
+ ],
+ )
+ def test_ordering_equal(self, version1, version2):
+ semver1 = SemanticVersion.from_version_string(version1)
+ semver2 = SemanticVersion.from_version_string(version2)
+ assert semver1 == semver2
+
+ def test_to_dict(self):
+ assert SemanticVersion(0, 0, 0).to_dict() is None
+
+ def test_to_dict_force(self):
+ assert SemanticVersion(0, 0, 0).to_dict(force=True) == {
+ "major": 0,
+ "minor": 0,
+ "patch": 0,
+ "prerelease": None,
+ "buildmetadata": None,
+ }
diff --git a/tests/test_tree.py b/tests/test_tree.py
new file mode 100644
index 0000000..da9095a
--- /dev/null
+++ b/tests/test_tree.py
@@ -0,0 +1,78 @@
+import textwrap
+
+import pytest
+from keepachangelog._tree import Tree, TextNode
+
+
+class TestTextNode:
+ def test_empty(self):
+ assert TextNode().print() == ""
+
+ def test_root_bad(self):
+ with pytest.raises(ValueError):
+ TextNode([""]).print(depth=0)
+
+ def test_single_line_default(self):
+ assert TextNode(["line 1"]).print() == "- line 1"
+
+ def test_single_line_bullet(self):
+ assert TextNode(["line 1"]).print(bullet="*") == "* line 1"
+
+ def test_single_line_bullet_depth1_indent1(self):
+ assert TextNode(["line 1"]).print(indent=1, bullet="*") == "*line 1"
+
+ def test_single_line_bullet_depth1_indent4(self):
+ assert TextNode(["line 1"]).print(depth=1, indent=4, bullet="*") == " * line 1"
+
+ def test_single_line_bullet_depth2_indent4(self):
+ assert (
+ TextNode(["line 1"]).print(depth=2, indent=4, bullet="*")
+ == " * line 1"
+ )
+
+ def test_single_line_bullet_depth2_indent2(self):
+ assert TextNode(["line 1"]).print(depth=2, indent=2, bullet="*") == " * line 1"
+
+
+class TestTree:
+ @pytest.mark.parametrize(
+ ["data", "expected_repr", "expected_str"],
+ [
+ pytest.param([], "Root[]", "", id="empty"),
+ pytest.param([[]], "Root[Node[]]", "", id="single_node"),
+ pytest.param([[[]]], "Root[Node[Node[]]]", "", id="two_nested_nodes"),
+ pytest.param([[], []], "Root[Node[], Node[]]", "\n", id="two_nodes"),
+ pytest.param(
+ [["item 1 (L1)\nitem 1 (L2)"], ["item 2", ["item 2.1", "item 2.2"]]],
+ "Root[Node[['item 1 (L1)', 'item 1 (L2)']], Node[['item 2'], Node[['item 2.1'], ['item 2.2']]]]",
+ textwrap.dedent(
+ """\
+ - item 1 (L1)
+ item 1 (L2)
+ - item 2
+ - item 2.1
+ - item 2.2"""
+ ),
+ id="complex",
+ ),
+ ],
+ )
+ def test_repr(self, data: list, expected_repr, expected_str: str):
+ assert repr(Tree.treeify(data)) == expected_repr
+ assert str(Tree.treeify(data)) == expected_str
+
+ def test_repr_root(self):
+ assert (
+ repr(Tree.treeify(["item 1 (L1)\nitem 1 (L2)"]))
+ == "Root[['item 1 (L1)', 'item 1 (L2)']]"
+ )
+
+ def test_repr_non_root(self):
+ assert (
+ repr(
+ Tree.treeify(
+ [["item 1 (L1)\nitem 1 (L2)"], ["item 2", ["item 2.1", "item 2.2"]]]
+ )[0]
+ )
+ == "Node[['item 1 (L1)', 'item 1 (L2)']]"
+ )