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 @@ Build status Coverage Code style: black -Number of tests +Number of tests Number of downloads

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)']]" + )