From 129b75961c697b78e7bd27574b58fe7ba72acd7c Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Tue, 8 Jun 2021 02:22:48 +0900 Subject: [PATCH 01/20] Removing side-effects: `add_release` -> `extract_release`. Signed-off-by: Samuel Giffard --- keepachangelog/_changelog.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/keepachangelog/_changelog.py b/keepachangelog/_changelog.py index 377a342..989e93d 100644 --- a/keepachangelog/_changelog.py +++ b/keepachangelog/_changelog.py @@ -1,6 +1,6 @@ import datetime import re -from typing import Dict, List, Optional, Iterable, Union +from typing import Dict, List, Optional, Iterable, Union, Tuple from keepachangelog._versioning import ( actual_version, @@ -14,7 +14,7 @@ def is_release(line: str) -> bool: return line.startswith("## ") -def add_release(changes: Dict[str, dict], line: str) -> dict: +def extract_release(line: str) -> Tuple[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] @@ -31,7 +31,7 @@ def add_release(changes: Dict[str, dict], line: str) -> dict: except InvalidSemanticVersion: pass - return changes.setdefault(version, {"metadata": metadata}) + return version, metadata def unlink(value: str) -> str: @@ -94,7 +94,8 @@ def _to_dict(change_log: Iterable[str], show_unreleased: bool) -> Dict[str, dict line = line.strip(" \n") if is_release(line): - current_release = add_release(changes, line) + version, metadata = extract_release(line) + current_release = changes.setdefault(version, {"metadata": metadata}) category = current_release.setdefault("uncategorized", []) elif is_category(line): category = add_category(current_release, line) @@ -180,7 +181,8 @@ def to_raw_dict(changelog_path: str) -> Dict[str, dict]: clean_line = line.strip(" \n") if is_release(clean_line): - current_release = add_release(changes, clean_line) + version, metadata = extract_release(clean_line) + current_release = changes.setdefault(version, {"metadata": metadata}) elif is_link(clean_line): link_match = link_pattern.fullmatch(clean_line) urls[link_match.group(1).lower()] = link_match.group(2) From a445c12a75cd609d7aef4e74fe3ee64a75f4e179 Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Tue, 8 Jun 2021 02:29:10 +0900 Subject: [PATCH 02/20] Removing side-effects: `add_category` -> `extract_category`. --- keepachangelog/_changelog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/keepachangelog/_changelog.py b/keepachangelog/_changelog.py index 989e93d..86b889b 100644 --- a/keepachangelog/_changelog.py +++ b/keepachangelog/_changelog.py @@ -49,9 +49,8 @@ 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, []) +def extract_category(line: str) -> str: + return line[4:].lower().strip(" ") # Link pattern should match lines like: "[1.2.3]: https://github.com/user/project/releases/tag/v0.0.1" @@ -98,7 +97,8 @@ def _to_dict(change_log: Iterable[str], show_unreleased: bool) -> Dict[str, dict current_release = changes.setdefault(version, {"metadata": metadata}) category = current_release.setdefault("uncategorized", []) elif is_category(line): - category = add_category(current_release, line) + category_name = extract_category(line) + category = current_release.setdefault(category_name, []) elif is_link(line): link_match = link_pattern.fullmatch(line) urls[link_match.group(1).lower()] = link_match.group(2) From 6eb6cae9a3b57dfe47469327dc8f75b61169c650 Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Tue, 8 Jun 2021 02:31:42 +0900 Subject: [PATCH 03/20] Removing side-effects: `add_information` -> `extract_information`. --- keepachangelog/_changelog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/keepachangelog/_changelog.py b/keepachangelog/_changelog.py index 86b889b..5b6cec6 100644 --- a/keepachangelog/_changelog.py +++ b/keepachangelog/_changelog.py @@ -61,8 +61,8 @@ 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(" -")) +def extract_information(line: str) -> str: + return line.lstrip(" *-").rstrip(" -") def to_dict( @@ -103,7 +103,7 @@ def _to_dict(change_log: Iterable[str], show_unreleased: bool) -> Dict[str, dict link_match = link_pattern.fullmatch(line) urls[link_match.group(1).lower()] = link_match.group(2) elif line: - add_information(category, line) + category.append(extract_information(line)) # Add url for each version (create version if not existing) for version, url in urls.items(): From 484fbad739ccd58de9ba4a91b33fb3980bc769e0 Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Tue, 8 Jun 2021 02:24:53 +0900 Subject: [PATCH 04/20] Renamed `unlink` -> `strip_link`. It was confusing. Name `unlink` should only be used for File System. --- keepachangelog/_changelog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/keepachangelog/_changelog.py b/keepachangelog/_changelog.py index 5b6cec6..9831255 100644 --- a/keepachangelog/_changelog.py +++ b/keepachangelog/_changelog.py @@ -23,7 +23,7 @@ def extract_release(line: str) -> Tuple[str, dict]: if " " in release_line else (release_line, None) ) - version = unlink(version) + version = strip_link(version) metadata = {"version": version, "release_date": extract_date(release_date)} try: @@ -34,7 +34,7 @@ def extract_release(line: str) -> Tuple[str, dict]: return version, metadata -def unlink(value: str) -> str: +def strip_link(value: str) -> str: return value.lstrip("[").rstrip("]") From fca4d8bc64229d3c248c8c6c76f055e7168b68a8 Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Tue, 8 Jun 2021 02:04:23 +0900 Subject: [PATCH 05/20] Added Dataclasses. Signed-off-by: Samuel Giffard --- keepachangelog/_changelog_dataclasses.py | 240 +++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 keepachangelog/_changelog_dataclasses.py diff --git a/keepachangelog/_changelog_dataclasses.py b/keepachangelog/_changelog_dataclasses.py new file mode 100644 index 0000000..bae7920 --- /dev/null +++ b/keepachangelog/_changelog_dataclasses.py @@ -0,0 +1,240 @@ +import dataclasses +import re +import string +from dataclasses import dataclass, field, fields +from datetime import date, datetime +from typing import List, Optional, Tuple, Any, Dict, Callable + +from keepachangelog._versioning import to_semantic, InvalidSemanticVersion + +DictFactoryCallable = Callable[[List[Tuple[str, Any]]], Dict[str, Any]] +UNRELEASED = "unreleased" + + +def is_release(line: str) -> bool: + return line.startswith("## ") + + +def is_category(line: str) -> bool: + return line.startswith("### ") + + +# Link pattern should match lines like: "[1.2.3]: https://github.com/user/project/releases/tag/v0.0.1" +link_pattern = re.compile(r"^\[(?P.*)\]: (?P.*)$") + + +def matches_link(line: str) -> re.Match: + return link_pattern.fullmatch(line) + + +@dataclass +class SemanticVersion: + major: int + minor: int + patch: int + prerelease: Optional[str] = None + buildmetadata: Optional[str] = None + + @classmethod + def from_version_string(cls, version_string: str) -> "SemanticVersion": + return cls(**to_semantic(version_string)) + + 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) -> Optional[Dict]: + if self.to_tuple() == (0, 0, 0, None, None): + return + return dataclasses.asdict(self) + + +@dataclass +class Metadata: + __RE_RELEASE = re.compile( + r"^## (?:\[(?P.*)]|\[(?P.*)] - (?P\d{4})-(?P\d{2})-(?P\d{2}))\s*$" + ) + version: str = UNRELEASED + release_date: Optional[date] = None + url: Optional[str] = None + + @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 + + def to_dict(self) -> dict: + out = { + "version": self.version.lower(), + } + if self.is_released: + if 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 + + def parse_release_line_best_effort(self, line: str) -> None: + """ + ## [1.0.1] - May 01, 2018 + ## 1.0.0 (2017-01-01) + """ + 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 + ] + version, *datelist = line[3:].strip().split(maxsplit=1) + self.version = version.strip(string.punctuation + string.whitespace) + if datelist: + datestring = datelist.pop().strip(string.punctuation + string.whitespace) + for accepted_format in accepted_formats: + try: + release_date = datetime.strptime(datestring, accepted_format).date() + except ValueError: + pass + else: + break + else: + release_date = datestring + 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"]) + ) + else: + self.version = groups["name"] + + @classmethod + def from_release_line(cls, line: str) -> "Metadata": + obj = cls() + obj.parse_release_line(line) + return obj + + +Note = str + + +class Category(List[Note]): + @staticmethod + def extract_information(line: str) -> str: + return line.lstrip(" *-").rstrip(" -") + + def streamline(self, line: str): + note: Note = Note(self.extract_information(line)) + if note: + self.append(note) + + +@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): + self.metadata = Metadata(**self.metadata) + for f in fields(self): + if f.type is not Category: + continue + if isinstance(getattr(self, f.name), dict): + setattr(self, f.name, Category(**getattr(self, f.name))) + + @property + def is_released(self): + return self.metadata.is_released + + def to_dict(self) -> dict: + out = { + "metadata": self.metadata.to_dict() + } + for f in fields(self): + if f.type is Category: + category = getattr(self, f.name) + if category: + out[f.name] = getattr(self, f.name) + 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): + self.__lines.append(line) + if is_release(line): + self.metadata.parse_release_line(line) + elif 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) + + 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 to_dict(self, *, show_unreleased:bool=False): + return {version.lower(): change.to_dict() for version, change in self.changes.items() if change.is_released or show_unreleased} + + 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 + if self.__active_change is not None: + self.__active_change.streamline(line) + else: + self.header.append(line) From 87c80c83cd1fba0fb18e4f24a716375df1f10247 Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Wed, 9 Jun 2021 20:53:04 +0900 Subject: [PATCH 06/20] Function `to_dict` uses dataclass internally. The behaviour is the same, except for a few cases with `semantic_version` that didn't make sense. I consider it an improvement. Another change is a smarter parsing of `release_date`. Signed-off-by: Samuel Giffard --- keepachangelog/_changelog.py | 53 +++++------------------- keepachangelog/_changelog_dataclasses.py | 16 ++++--- tests/test_changelog.py | 7 ++++ tests/test_changelog_empty_version.py | 7 ---- tests/test_changelog_unreleased.py | 7 ++++ tests/test_non_standard_changelog.py | 12 +++--- 6 files changed, 40 insertions(+), 62 deletions(-) diff --git a/keepachangelog/_changelog.py b/keepachangelog/_changelog.py index 9831255..45e9350 100644 --- a/keepachangelog/_changelog.py +++ b/keepachangelog/_changelog.py @@ -1,7 +1,9 @@ import datetime +import pathlib import re -from typing import Dict, List, Optional, Iterable, Union, Tuple +from typing import Dict, Optional, Iterable, Union, Tuple +from keepachangelog._changelog_dataclasses import Changelog from keepachangelog._versioning import ( actual_version, guess_unreleased_version, @@ -76,55 +78,20 @@ def to_dict( :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: + if "\n" in changelog_path: return _to_dict(changelog_path, show_unreleased) + path = pathlib.Path(changelog_path) + with open(path) as change_log: + return _to_dict(change_log, 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 = [] + changelog: Changelog = Changelog() for line in change_log: line = line.strip(" \n") + changelog.streamline(line) - if is_release(line): - version, metadata = extract_release(line) - current_release = changes.setdefault(version, {"metadata": metadata}) - category = current_release.setdefault("uncategorized", []) - elif is_category(line): - category_name = extract_category(line) - category = current_release.setdefault(category_name, []) - elif is_link(line): - link_match = link_pattern.fullmatch(line) - urls[link_match.group(1).lower()] = link_match.group(2) - elif line: - category.append(extract_information(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 - - # 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) - - # 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) - + changes = changelog.to_dict(show_unreleased=show_unreleased) return changes diff --git a/keepachangelog/_changelog_dataclasses.py b/keepachangelog/_changelog_dataclasses.py index bae7920..f265f23 100644 --- a/keepachangelog/_changelog_dataclasses.py +++ b/keepachangelog/_changelog_dataclasses.py @@ -59,7 +59,9 @@ class Metadata: @property def is_released(self): - return not self.version.lower() == UNRELEASED and (self.release_date is not None or self.url is not None) + 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): @@ -181,9 +183,7 @@ def is_released(self): return self.metadata.is_released def to_dict(self) -> dict: - out = { - "metadata": self.metadata.to_dict() - } + out = {"metadata": self.metadata.to_dict()} for f in fields(self): if f.type is Category: category = getattr(self, f.name) @@ -219,8 +219,12 @@ def __post_init__(self): temp_changes[key] = Change(**change) self.changes = temp_changes - def to_dict(self, *, show_unreleased:bool=False): - return {version.lower(): change.to_dict() for version, change in self.changes.items() if change.is_released or show_unreleased} + def to_dict(self, *, show_unreleased: bool = False): + return { + version.lower(): change.to_dict() + for version, change in self.changes.items() + if change.is_released or show_unreleased + } def streamline(self, line: str): link_match = matches_link(line) diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 961be91..e712a8b 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -126,6 +126,13 @@ def changelog(tmpdir): "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", } }, 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_unreleased.py b/tests/test_changelog_unreleased.py index 4f2044c..29d6a97 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": { diff --git a/tests/test_non_standard_changelog.py b/tests/test_non_standard_changelog.py index 25147da..1b081df 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, From 88ab3761d88fe16d513742334d9b0f7ce121efd9 Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Wed, 9 Jun 2021 21:42:18 +0900 Subject: [PATCH 07/20] Function `to_raw_dict` uses dataclass internally. It now properly keeps the empty lines and the case on `release_date`. The `semantic_version` bug is also fix (improved consistency). Signed-off-by: Samuel Giffard --- keepachangelog/_changelog.py | 55 ++++++++---------------- keepachangelog/_changelog_dataclasses.py | 51 ++++++++++++++-------- tests/test_changelog.py | 12 ++++++ tests/test_non_standard_changelog.py | 11 +++-- 4 files changed, 70 insertions(+), 59 deletions(-) diff --git a/keepachangelog/_changelog.py b/keepachangelog/_changelog.py index 45e9350..68505b0 100644 --- a/keepachangelog/_changelog.py +++ b/keepachangelog/_changelog.py @@ -77,21 +77,32 @@ 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. """ + return _to_dict_proxy(changelog_path, show_unreleased=show_unreleased, raw=False) + + +def _to_dict_proxy( + changelog_path: Union[str, Iterable[str]], + *, + show_unreleased: bool = False, + raw: bool = False, +) -> Dict[str, dict]: # Allow for changelog as a file path or as a context manager providing content if "\n" in changelog_path: - return _to_dict(changelog_path, show_unreleased) + return _to_dict(changelog_path, show_unreleased=show_unreleased, raw=raw) path = pathlib.Path(changelog_path) with open(path) as change_log: - return _to_dict(change_log, show_unreleased) + return _to_dict(change_log, show_unreleased=show_unreleased, raw=raw) -def _to_dict(change_log: Iterable[str], show_unreleased: bool) -> Dict[str, dict]: +def _to_dict( + change_log: Iterable[str], *, show_unreleased: bool, raw: bool +) -> Dict[str, dict]: changelog: Changelog = Changelog() for line in change_log: line = line.strip(" \n") changelog.streamline(line) - changes = changelog.to_dict(show_unreleased=show_unreleased) + changes = changelog.to_dict(show_unreleased=show_unreleased, raw=raw) return changes @@ -138,40 +149,8 @@ def from_dict(changes: Dict[str, dict]): 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): - version, metadata = extract_release(clean_line) - current_release = changes.setdefault(version, {"metadata": metadata}) - 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 +def to_raw_dict(changelog_path: str, *, show_unreleased=False) -> Dict[str, dict]: + return _to_dict_proxy(changelog_path, show_unreleased=show_unreleased, raw=True) def release(changelog_path: str, new_version: str = None) -> Optional[str]: diff --git a/keepachangelog/_changelog_dataclasses.py b/keepachangelog/_changelog_dataclasses.py index f265f23..13af4b0 100644 --- a/keepachangelog/_changelog_dataclasses.py +++ b/keepachangelog/_changelog_dataclasses.py @@ -51,10 +51,11 @@ def to_dict(self) -> Optional[Dict]: @dataclass class Metadata: __RE_RELEASE = re.compile( - r"^## (?:\[(?P.*)]|\[(?P.*)] - (?P\d{4})-(?P\d{2})-(?P\d{2}))\s*$" + 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 @property @@ -74,12 +75,14 @@ def semantic_version(self) -> Optional[SemanticVersion]: except InvalidSemanticVersion: return None - def to_dict(self) -> dict: + def to_dict(self, *, raw: bool = False) -> dict: out = { "version": self.version.lower(), } if self.is_released: - if self.release_date is not None: + 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 @@ -107,16 +110,20 @@ def parse_release_line_best_effort(self, line: str) -> None: version, *datelist = line[3:].strip().split(maxsplit=1) self.version = version.strip(string.punctuation + string.whitespace) if datelist: - datestring = datelist.pop().strip(string.punctuation + string.whitespace) + self.raw_release_date = datelist.pop().strip( + string.punctuation + string.whitespace + ) for accepted_format in accepted_formats: try: - release_date = datetime.strptime(datestring, accepted_format).date() + release_date = datetime.strptime( + self.raw_release_date, accepted_format + ).date() except ValueError: pass else: break else: - release_date = datestring + release_date = self.raw_release_date else: release_date = None self.release_date = release_date @@ -132,6 +139,7 @@ def parse_release_line(self, line: str) -> None: 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"] @@ -182,13 +190,19 @@ def __post_init__(self): def is_released(self): return self.metadata.is_released - def to_dict(self) -> dict: - out = {"metadata": self.metadata.to_dict()} - for f in fields(self): - if f.type is Category: - category = getattr(self, f.name) - if category: - out[f.name] = getattr(self, f.name) + 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 category: + out[f.name] = getattr(self, f.name) return out def parse_category_line(self, line: str): @@ -197,10 +211,11 @@ def parse_category_line(self, line: str): self.__active_category = getattr(self, category) def streamline(self, line: str): - self.__lines.append(line) if is_release(line): self.metadata.parse_release_line(line) - elif is_category(line): + return + self.__lines.append(line) + if is_category(line): self.parse_category_line(line) else: self.__active_category.streamline(line) @@ -219,9 +234,9 @@ def __post_init__(self): temp_changes[key] = Change(**change) self.changes = temp_changes - def to_dict(self, *, show_unreleased: bool = False): + def to_dict(self, *, show_unreleased: bool = False, raw: bool = False): return { - version.lower(): change.to_dict() + version.lower(): change.to_dict(raw=raw) for version, change in self.changes.items() if change.is_released or show_unreleased } @@ -238,7 +253,7 @@ def streamline(self, line: str): self.__active_change = Change() self.__active_change.streamline(line) self.changes[self.__active_change.metadata.version] = self.__active_change - if self.__active_change is not None: + elif self.__active_change is not None: self.__active_change.streamline(line) else: self.header.append(line) diff --git a/tests/test_changelog.py b/tests/test_changelog.py index e712a8b..ff1b73c 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -193,22 +193,27 @@ 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 @@ -248,6 +253,13 @@ def test_raw_changelog_with_versions_and_all_categories(changelog): "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", }, }, diff --git a/tests/test_non_standard_changelog.py b/tests/test_non_standard_changelog.py index 1b081df..99c9147 100644 --- a/tests/test_non_standard_changelog.py +++ b/tests/test_non_standard_changelog.py @@ -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, From c3e205d6f44a01e37bd9fb3a185a7910c15ded73 Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Wed, 9 Jun 2021 21:43:32 +0900 Subject: [PATCH 08/20] Changed function orders. Signed-off-by: Samuel Giffard --- keepachangelog/_changelog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/keepachangelog/_changelog.py b/keepachangelog/_changelog.py index 68505b0..5abdee2 100644 --- a/keepachangelog/_changelog.py +++ b/keepachangelog/_changelog.py @@ -80,6 +80,10 @@ def to_dict( return _to_dict_proxy(changelog_path, show_unreleased=show_unreleased, raw=False) +def to_raw_dict(changelog_path: str, *, show_unreleased=False) -> Dict[str, dict]: + return _to_dict_proxy(changelog_path, show_unreleased=show_unreleased, raw=True) + + def _to_dict_proxy( changelog_path: Union[str, Iterable[str]], *, @@ -149,10 +153,6 @@ def from_dict(changes: Dict[str, dict]): return content -def to_raw_dict(changelog_path: str, *, show_unreleased=False) -> Dict[str, dict]: - return _to_dict_proxy(changelog_path, show_unreleased=show_unreleased, raw=True) - - def release(changelog_path: str, new_version: str = None) -> Optional[str]: """ Release a new version based on changelog unreleased content. From cac2ec631624c8dc19c9fab75ced3c8f50d5830b Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Wed, 9 Jun 2021 22:37:19 +0900 Subject: [PATCH 09/20] Function `from_dict` uses dataclass internally. Note there is a weird quirk that uses `*` instead of `-` for the uncategorized bullet points. I kept this behaviour. But it could be fixed later (easy). Signed-off-by: Samuel Giffard --- keepachangelog/_changelog.py | 40 ++----------- keepachangelog/_changelog_dataclasses.py | 71 +++++++++++++++++++++++- keepachangelog/_versioning.py | 7 +++ 3 files changed, 79 insertions(+), 39 deletions(-) diff --git a/keepachangelog/_changelog.py b/keepachangelog/_changelog.py index 5abdee2..2287ce1 100644 --- a/keepachangelog/_changelog.py +++ b/keepachangelog/_changelog.py @@ -110,47 +110,15 @@ def _to_dict( return changes -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 + changelog: Changelog = Changelog(header=header.splitlines(), changes=changes) + return changelog.to_markdown() def release(changelog_path: str, new_version: str = None) -> Optional[str]: diff --git a/keepachangelog/_changelog_dataclasses.py b/keepachangelog/_changelog_dataclasses.py index 13af4b0..a85dd63 100644 --- a/keepachangelog/_changelog_dataclasses.py +++ b/keepachangelog/_changelog_dataclasses.py @@ -3,9 +3,13 @@ import string from dataclasses import dataclass, field, fields from datetime import date, datetime -from typing import List, Optional, Tuple, Any, Dict, Callable +from typing import List, Optional, Tuple, Any, Dict, Callable, Generator -from keepachangelog._versioning import to_semantic, InvalidSemanticVersion +from keepachangelog._versioning import ( + to_semantic, + InvalidSemanticVersion, + UnmatchingSemanticVersion, +) DictFactoryCallable = Callable[[List[Tuple[str, Any]]], Dict[str, Any]] UNRELEASED = "unreleased" @@ -47,6 +51,13 @@ def to_dict(self) -> Optional[Dict]: return return dataclasses.asdict(self) + def __str__(self): + return ( + f"{self.major}.{self.minor}.{self.patch}" + f"{'-' if self.prerelease is not None else ''}{self.prerelease}" + f"{'+' if self.buildmetadata is not None else ''}{self.buildmetadata}" + ) + @dataclass class Metadata: @@ -163,6 +174,9 @@ def streamline(self, line: str): if note: self.append(note) + def to_markdown(self, *, bullet: str = "-") -> str: + return "\n".join(f"{bullet} {note}" for note in self) + @dataclass class Change: @@ -179,6 +193,19 @@ 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(**self.metadata["semantic_version"]) + if "version" in self.metadata: + semver2 = SemanticVersion.from_version_string( + self.metadata["version"] + ) + if semver != semver2: + 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: @@ -190,6 +217,20 @@ def __post_init__(self): def is_released(self): return self.metadata.is_released + def to_markdown(self) -> str: + out = [] + if self.uncategorized: + out.append(self.uncategorized.to_markdown(bullet="*")) + out.append("") + for f in fields(self): + if f.type is Category and f.name != "uncategorized": + category: Category = getattr(self, f.name) + if category: + 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: @@ -202,7 +243,7 @@ def to_dict(self, *, raw=False) -> dict: if f.type is Category: category = getattr(self, f.name) if category: - out[f.name] = getattr(self, f.name) + out[f.name] = category return out def parse_category_line(self, line: str): @@ -234,6 +275,30 @@ def __post_init__(self): temp_changes[key] = Change(**change) self.changes = temp_changes + def links(self) -> Generator[List[Tuple[str, str]], None, None]: + for version, change in self.changes.items(): + yield version, change.metadata.url + + def to_markdown(self) -> str: + out = self.header[:] + [""] + for version, change in self.changes.items(): + 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() + if change_md: + 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 { version.lower(): change.to_dict(raw=raw) diff --git a/keepachangelog/_versioning.py b/keepachangelog/_versioning.py index 45dffc1..7159bd8 100644 --- a/keepachangelog/_versioning.py +++ b/keepachangelog/_versioning.py @@ -18,6 +18,13 @@ def __init__(self, version: str): ) +class UnmatchingSemanticVersion(Exception): + def __init__(self, version: str, semantic_version: dict): + super().__init__( + f"Semantic version {semantic_version} does not match version {version}." + ) + + def contains_breaking_changes(unreleased: dict) -> bool: return "removed" in unreleased or "changed" in unreleased From 1de44fe2b80c3fa587a9bfb3aebccf67e8bcd307 Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Thu, 10 Jun 2021 04:22:23 +0900 Subject: [PATCH 10/20] Uses `freezegun` for mocking datetimes. Signed-off-by: Samuel Giffard --- setup.py | 2 + tests/test_changelog_release.py | 66 +++++++++++++++------------------ 2 files changed, 31 insertions(+), 37 deletions(-) diff --git a/setup.py b/setup.py index 06f746c..bbb0f23 100644 --- a/setup.py +++ b/setup.py @@ -46,6 +46,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_changelog_release.py b/tests/test_changelog_release.py index 7053c5b..cbdf8af 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") @@ -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 ( @@ -700,7 +682,8 @@ def test_minor_digit_release(minor_digit_changelog, mock_date): ) -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,7 +711,8 @@ def test_patch_digit_release(patch_digit_changelog, mock_date): ) -def test_sorted_major_digit_semantic_release(major_digit_changelog, mock_date): +@freeze_time(_date_time_for_tests) +def test_sorted_major_digit_semantic_release(major_digit_changelog): assert keepachangelog.to_sorted_semantic( keepachangelog.to_raw_dict(major_digit_changelog) ) == [ @@ -755,7 +739,8 @@ def test_sorted_major_digit_semantic_release(major_digit_changelog, mock_date): ] -def test_sorted_minor_digit_semantic_release(minor_digit_changelog, mock_date): +@freeze_time(_date_time_for_tests) +def test_sorted_minor_digit_semantic_release(minor_digit_changelog): assert keepachangelog.to_sorted_semantic( keepachangelog.to_raw_dict(minor_digit_changelog) ) == [ @@ -782,7 +767,8 @@ def test_sorted_minor_digit_semantic_release(minor_digit_changelog, mock_date): ] -def test_sorted_patch_digit_semantic_release(patch_digit_changelog, mock_date): +@freeze_time(_date_time_for_tests) +def test_sorted_patch_digit_semantic_release(patch_digit_changelog): assert keepachangelog.to_sorted_semantic( keepachangelog.to_raw_dict(patch_digit_changelog) ) == [ @@ -809,7 +795,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 +844,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 +893,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 +934,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 +978,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 +1006,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" ) From 578736b491ee78743e366153fdde6fd5c6adabd5 Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Thu, 10 Jun 2021 04:25:04 +0900 Subject: [PATCH 11/20] Function `release` uses dataclass internally. The order in which the changes appear is now deterministic. There were some minor inconsistencies before. Signed-off-by: Samuel Giffard --- keepachangelog/_changelog.py | 105 +++----- keepachangelog/_changelog_dataclasses.py | 292 ++++++++++++++++++++--- tests/test_changelog_unreleased.py | 4 +- 3 files changed, 301 insertions(+), 100 deletions(-) diff --git a/keepachangelog/_changelog.py b/keepachangelog/_changelog.py index 2287ce1..351edf5 100644 --- a/keepachangelog/_changelog.py +++ b/keepachangelog/_changelog.py @@ -1,15 +1,9 @@ -import datetime import pathlib import re -from typing import Dict, Optional, Iterable, Union, Tuple +from typing import Dict, Optional, Iterable, Union, Tuple, Callable, Any -from keepachangelog._changelog_dataclasses import Changelog -from keepachangelog._versioning import ( - actual_version, - guess_unreleased_version, - to_semantic, - InvalidSemanticVersion, -) +from keepachangelog._changelog_dataclasses import Changelog, SemanticVersion +from keepachangelog._versioning import to_semantic, InvalidSemanticVersion def is_release(line: str) -> bool: @@ -77,35 +71,36 @@ 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. """ - return _to_dict_proxy(changelog_path, show_unreleased=show_unreleased, raw=False) + return _callback_proxy( + _to_dict, changelog_path, show_unreleased=show_unreleased, raw=False + ) def to_raw_dict(changelog_path: str, *, show_unreleased=False) -> Dict[str, dict]: - return _to_dict_proxy(changelog_path, show_unreleased=show_unreleased, raw=True) + return _callback_proxy( + _to_dict, changelog_path, show_unreleased=show_unreleased, raw=True + ) -def _to_dict_proxy( +def _callback_proxy( + callback: Callable[[Iterable[str], ...], Any], changelog_path: Union[str, Iterable[str]], - *, - show_unreleased: bool = False, - raw: bool = False, -) -> Dict[str, dict]: + *args, + **kwargs, +) -> Any: # Allow for changelog as a file path or as a context manager providing content if "\n" in changelog_path: - return _to_dict(changelog_path, show_unreleased=show_unreleased, raw=raw) + return callback(changelog_path, *args, **kwargs) path = pathlib.Path(changelog_path) with open(path) as change_log: - return _to_dict(change_log, show_unreleased=show_unreleased, raw=raw) + return callback(change_log, *args, **kwargs) def _to_dict( change_log: Iterable[str], *, show_unreleased: bool, raw: bool ) -> Dict[str, dict]: changelog: Changelog = Changelog() - for line in change_log: - line = line.strip(" \n") - changelog.streamline(line) - + changelog.streamlines(change_log) changes = changelog.to_dict(show_unreleased=show_unreleased, raw=raw) return changes @@ -129,52 +124,20 @@ 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 - - -def release_version( - changelog_path: str, current_version: Optional[str], new_version: str -): - unreleased_link_pattern = re.compile(r"^\[Unreleased\]: (.*)$", re.DOTALL) - lines = [] - with open(changelog_path) as change_log: - for line in change_log.readlines(): - # Move Unreleased section to new version - if re.fullmatch(r"^## \[Unreleased\].*$", line, re.DOTALL): - lines.append(line) - lines.append("\n") - lines.append( - f"## [{new_version}] - {datetime.date.today().isoformat()}\n" - ) - # Add new version link and update Unreleased link - elif unreleased_link_pattern.fullmatch(line): - unreleased_compare_pattern = re.fullmatch( - r"^.*/(.*)\.\.\.(\w*).*$", line, re.DOTALL - ) - # Unreleased link compare previous version to HEAD (unreleased tag) - if unreleased_compare_pattern: - new_unreleased_link = line.replace(current_version, new_version) - lines.append(new_unreleased_link) - current_tag = unreleased_compare_pattern.group(1) - unreleased_tag = unreleased_compare_pattern.group(2) - new_tag = current_tag.replace(current_version, new_version) - lines.append( - line.replace(new_version, current_version) - .replace(unreleased_tag, new_tag) - .replace("Unreleased", new_version) - ) - # Consider that there is no way to know how to create a link to compare versions - else: - lines.append(line) - lines.append(line.replace("Unreleased", new_version)) - else: - lines.append(line) - - with open(changelog_path, "wt") as change_log: - change_log.writelines(lines) + 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 _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 index a85dd63..4b8f768 100644 --- a/keepachangelog/_changelog_dataclasses.py +++ b/keepachangelog/_changelog_dataclasses.py @@ -1,9 +1,18 @@ -import dataclasses 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 +from typing import ( + List, + Optional, + Tuple, + Any, + Dict, + Callable, + Generator, + Iterable, + Union, +) from keepachangelog._versioning import ( to_semantic, @@ -14,6 +23,10 @@ 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.*)$") + def is_release(line: str) -> bool: return line.startswith("## ") @@ -23,39 +36,89 @@ def is_category(line: str) -> bool: return line.startswith("### ") -# Link pattern should match lines like: "[1.2.3]: https://github.com/user/project/releases/tag/v0.0.1" -link_pattern = re.compile(r"^\[(?P.*)\]: (?P.*)$") - - def matches_link(line: str) -> re.Match: - return link_pattern.fullmatch(line) + return RE_LINK_LINE.fullmatch(line) -@dataclass +@dataclass(eq=True, order=True) class SemanticVersion: - major: int - minor: int - patch: int - prerelease: Optional[str] = None - buildmetadata: Optional[str] = None + 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 initial_version(cls): + return cls.from_version_string("0.0.0") @classmethod def from_version_string(cls, version_string: str) -> "SemanticVersion": - return cls(**to_semantic(version_string)) + semver = 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( + self.to_dict(force=True) + | SemanticVersion(self.major + 1, 0, 0).to_dict(force=True) + ) + + def bump_minor(self): + return self.__class__.from_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( + 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( + 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) -> Optional[Dict]: - if self.to_tuple() == (0, 0, 0, None, None): + def to_dict(self, *, force=False) -> Optional[Dict]: + if self.to_tuple() == (0, 0, 0, None, None) and not force: return - return dataclasses.asdict(self) + 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"{'-' if self.prerelease is not None else ''}{self.prerelease}" - f"{'+' if self.buildmetadata is not None else ''}{self.buildmetadata}" + f"{f'-{self.prerelease}' if self.prerelease is not None else ''}" + f"{f'+{self.buildmetadata}' if self.buildmetadata is not None else ''}" ) @@ -86,6 +149,13 @@ def semantic_version(self) -> Optional[SemanticVersion]: 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(), @@ -194,7 +264,7 @@ def __post_init__(self): self.__active_category: Optional[Category] = self.uncategorized if isinstance(self.metadata, dict): if "semantic_version" in self.metadata: - semver = SemanticVersion(**self.metadata["semantic_version"]) + semver = SemanticVersion.from_dict(self.metadata["semantic_version"]) if "version" in self.metadata: semver2 = SemanticVersion.from_version_string( self.metadata["version"] @@ -217,7 +287,41 @@ def __post_init__(self): def is_released(self): return self.metadata.is_released - def to_markdown(self) -> str: + @property + def contains_breaking_changes(self) -> bool: + return bool(self.removed) or bool(self.changed) + + @property + def contains_only_bug_fixes(self): + return all( + [ + self.fixed, + not self.uncategorized, + not self.changed, + not self.added, + not self.security, + not self.deprecated, + not self.removed, + ] + ) + + @property + def is_empty(self): + return all( + [ + not self.fixed, + not self.uncategorized, + not self.changed, + not self.added, + not self.security, + not self.deprecated, + not self.removed, + ] + ) + + def to_markdown(self, *, raw=False) -> str: + if raw: + return "\n".join(self.__lines) out = [] if self.uncategorized: out.append(self.uncategorized.to_markdown(bullet="*")) @@ -267,6 +371,133 @@ 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 is None: + current = SemanticVersion.initial_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]: + 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=True + ) + + 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 = {} @@ -276,20 +507,22 @@ def __post_init__(self): self.changes = temp_changes def links(self) -> Generator[List[Tuple[str, str]], None, None]: - for version, change in self.changes.items(): + for version, change in self.sorted_changes: yield version, change.metadata.url - def to_markdown(self) -> str: - out = self.header[:] + [""] - for version, change in self.changes.items(): + def to_markdown(self, *, raw=False) -> str: + out = self.header[:] + if not raw: + out.append("") + for version, change in self.sorted_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() - if change_md: + change_md = change.to_markdown(raw=raw) + if change_md or not change.is_released: out.append(change_md) out += [ f"[{v.capitalize()}]: {link}" @@ -306,6 +539,11 @@ def to_dict(self, *, show_unreleased: bool = False, raw: bool = False): if change.is_released or show_unreleased } + 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: diff --git a/tests/test_changelog_unreleased.py b/tests/test_changelog_unreleased.py index 29d6a97..558da05 100644 --- a/tests/test_changelog_unreleased.py +++ b/tests/test_changelog_unreleased.py @@ -235,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) @@ -251,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 """ ) From 8667a764f2495532d3e3081d432a2b332c20a48a Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Thu, 10 Jun 2021 04:27:37 +0900 Subject: [PATCH 12/20] Removed unused symbols. Signed-off-by: Samuel Giffard --- keepachangelog/_changelog.py | 59 +----------------------------------- 1 file changed, 1 insertion(+), 58 deletions(-) diff --git a/keepachangelog/_changelog.py b/keepachangelog/_changelog.py index 351edf5..6b14d0d 100644 --- a/keepachangelog/_changelog.py +++ b/keepachangelog/_changelog.py @@ -1,64 +1,7 @@ import pathlib -import re -from typing import Dict, Optional, Iterable, Union, Tuple, Callable, Any +from typing import Dict, Optional, Iterable, Union, Callable, Any from keepachangelog._changelog_dataclasses import Changelog, SemanticVersion -from keepachangelog._versioning import to_semantic, InvalidSemanticVersion - - -def is_release(line: str) -> bool: - return line.startswith("## ") - - -def extract_release(line: str) -> Tuple[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 = strip_link(version) - - metadata = {"version": version, "release_date": extract_date(release_date)} - try: - metadata["semantic_version"] = to_semantic(version) - except InvalidSemanticVersion: - pass - - return version, metadata - - -def strip_link(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 extract_category(line: str) -> str: - return line[4:].lower().strip(" ") - - -# 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 extract_information(line: str) -> str: - return line.lstrip(" *-").rstrip(" -") def to_dict( From 067c59b5df103a06fc707b2a0cab236f9254afcc Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Thu, 10 Jun 2021 04:39:18 +0900 Subject: [PATCH 13/20] Removed more unused symbols. Moved `to_semantic` internally. Signed-off-by: Samuel Giffard --- keepachangelog/_changelog_dataclasses.py | 20 ++- keepachangelog/_versioning.py | 161 ----------------------- 2 files changed, 17 insertions(+), 164 deletions(-) diff --git a/keepachangelog/_changelog_dataclasses.py b/keepachangelog/_changelog_dataclasses.py index 4b8f768..19d30c8 100644 --- a/keepachangelog/_changelog_dataclasses.py +++ b/keepachangelog/_changelog_dataclasses.py @@ -15,7 +15,6 @@ ) from keepachangelog._versioning import ( - to_semantic, InvalidSemanticVersion, UnmatchingSemanticVersion, ) @@ -26,7 +25,9 @@ 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-]+)*))?$" +) def is_release(line: str) -> bool: return line.startswith("## ") @@ -58,13 +59,26 @@ 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() + 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 = to_semantic(version_string) + semver = cls.to_semantic(version_string) return cls.from_dict(semver) @classmethod diff --git a/keepachangelog/_versioning.py b/keepachangelog/_versioning.py index 7159bd8..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__( @@ -23,151 +10,3 @@ def __init__(self, version: str, semantic_version: dict): super().__init__( f"Semantic version {semantic_version} does not match version {version}." ) - - -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']}" From 5df39a083a10894568f580d508e98368c3c1985e Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Thu, 10 Jun 2021 04:40:29 +0900 Subject: [PATCH 14/20] Changed function orders. Signed-off-by: Samuel Giffard --- keepachangelog/_changelog.py | 46 ++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/keepachangelog/_changelog.py b/keepachangelog/_changelog.py index 6b14d0d..9d58cb6 100644 --- a/keepachangelog/_changelog.py +++ b/keepachangelog/_changelog.py @@ -25,29 +25,6 @@ def to_raw_dict(changelog_path: str, *, show_unreleased=False) -> Dict[str, dict ) -def _callback_proxy( - callback: Callable[[Iterable[str], ...], Any], - changelog_path: Union[str, Iterable[str]], - *args, - **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, *args, **kwargs) - path = pathlib.Path(changelog_path) - with open(path) as change_log: - return callback(change_log, *args, **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 from_dict(changes: Dict[str, dict]) -> str: header = """# Changelog All notable changes to this project will be documented in this file. @@ -74,6 +51,29 @@ def release(changelog_path: str, new_version: str = None) -> Optional[str]: return changelog.current_version_string +def _callback_proxy( + callback: Callable[[Iterable[str], ...], Any], + changelog_path: Union[str, Iterable[str]], + *args, + **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, *args, **kwargs) + path = pathlib.Path(changelog_path) + with open(path) as change_log: + return callback(change_log, *args, **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, changelog: Changelog, From dc6bc56393bb33c5a3b9486db48d75c379ae8c71 Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Thu, 10 Jun 2021 22:04:56 +0900 Subject: [PATCH 15/20] Added a bunch of tests. Signed-off-by: Samuel Giffard --- keepachangelog/_changelog_dataclasses.py | 55 +++++---- tests/test_change.py | 69 +++++++++++ ...changelog.py => test_changelog_generic.py} | 0 tests/test_metadata.py | 56 +++++++++ tests/test_semantic_version.py | 110 ++++++++++++++++++ 5 files changed, 269 insertions(+), 21 deletions(-) create mode 100644 tests/test_change.py rename tests/{test_changelog.py => test_changelog_generic.py} (100%) create mode 100644 tests/test_metadata.py create mode 100644 tests/test_semantic_version.py diff --git a/keepachangelog/_changelog_dataclasses.py b/keepachangelog/_changelog_dataclasses.py index 19d30c8..9120570 100644 --- a/keepachangelog/_changelog_dataclasses.py +++ b/keepachangelog/_changelog_dataclasses.py @@ -29,6 +29,7 @@ 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 is_release(line: str) -> bool: return line.startswith("## ") @@ -60,9 +61,9 @@ def prerelease(self, value): self.__prerelease_cmp = "1" if value is None else f"0{self.__prerelease}" @classmethod - def to_semantic(cls, version: Optional[str]) -> dict: + def to_semantic(cls, version: Optional[str] = "") -> dict: if not version: - return cls.initial_version().to_dict() + return cls.initial_version().to_dict(force=True) match = RE_SEMVER.fullmatch(version) if match: return { @@ -146,6 +147,13 @@ class Metadata: 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 ( @@ -187,11 +195,8 @@ def to_dict(self, *, raw: bool = False) -> dict: out["url"] = self.url return out - def parse_release_line_best_effort(self, line: str) -> None: - """ - ## [1.0.1] - May 01, 2018 - ## 1.0.0 (2017-01-01) - """ + @staticmethod + def parse_date(datestring: str) -> date: accepted_formats = [ "%Y-%m-%d", # 2020-10-09 "%d-%m-%Y", # 09-10-2020 @@ -202,23 +207,29 @@ def parse_release_line_best_effort(self, line: str) -> None: "%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 ) - for accepted_format in accepted_formats: - try: - release_date = datetime.strptime( - self.raw_release_date, accepted_format - ).date() - except ValueError: - pass - else: - break - else: - release_date = self.raw_release_date + release_date = self.parse_date(self.raw_release_date) else: release_date = None self.release_date = release_date @@ -283,7 +294,8 @@ def __post_init__(self): semver2 = SemanticVersion.from_version_string( self.metadata["version"] ) - if semver != semver2: + # 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"] ) @@ -294,8 +306,9 @@ def __post_init__(self): for f in fields(self): if f.type is not Category: continue - if isinstance(getattr(self, f.name), dict): - setattr(self, f.name, Category(**getattr(self, f.name))) + 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): diff --git a/tests/test_change.py b/tests/test_change.py new file mode 100644 index 0000000..e1fa2b5 --- /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 change.uncategorized == ["line1", "line2"] + assert isinstance(change.changed, Category) + assert change.changed == ["line3", "line4"] diff --git a/tests/test_changelog.py b/tests/test_changelog_generic.py similarity index 100% rename from tests/test_changelog.py rename to tests/test_changelog_generic.py 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_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, + } From 5a8985d8b80e42d9998a8b0f09c6c97ef5ff6d2d Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Thu, 10 Jun 2021 22:38:04 +0900 Subject: [PATCH 16/20] More tests. Now 100% coverage. I split them into 2 commits, to have git give a correct diff (because of file renaming). Signed-off-by: Samuel Giffard --- keepachangelog/_changelog_dataclasses.py | 2 - tests/test_changelog.py | 47 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 tests/test_changelog.py diff --git a/keepachangelog/_changelog_dataclasses.py b/keepachangelog/_changelog_dataclasses.py index 9120570..d30743b 100644 --- a/keepachangelog/_changelog_dataclasses.py +++ b/keepachangelog/_changelog_dataclasses.py @@ -429,8 +429,6 @@ def __latest_version(self) -> dict: @property def next_version(self) -> SemanticVersion: current = self.current_version - if current is None: - current = SemanticVersion.initial_version() if current.prerelease is not None: return current.release_version() unreleased_change = self.unreleased_unique diff --git a/tests/test_changelog.py b/tests/test_changelog.py new file mode 100644 index 0000000..de0314b --- /dev/null +++ b/tests/test_changelog.py @@ -0,0 +1,47 @@ +import textwrap + +import pytest + +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 + """ + ) + 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() From abb74ab0fa1f4e38e6a1e2227ca585e9a977b1d3 Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Wed, 16 Jun 2021 01:18:21 +0900 Subject: [PATCH 17/20] New features: multiline and sub-items. Multiline: Items can have several lines. Sub-items: Items can contain other items. Signed-off-by: Samuel Giffard --- keepachangelog/_changelog_dataclasses.py | 116 +++++-- keepachangelog/_tree.py | 190 +++++++++++ tests/test_category.py | 62 ++++ tests/test_change.py | 4 +- ...st_changelog_generic_multiline_subitems.py | 316 ++++++++++++++++++ tests/test_changelog_unreleased.py | 2 +- tests/test_tree.py | 78 +++++ 7 files changed, 735 insertions(+), 33 deletions(-) create mode 100644 keepachangelog/_tree.py create mode 100644 tests/test_category.py create mode 100644 tests/test_changelog_generic_multiline_subitems.py create mode 100644 tests/test_tree.py diff --git a/keepachangelog/_changelog_dataclasses.py b/keepachangelog/_changelog_dataclasses.py index d30743b..c66d6e7 100644 --- a/keepachangelog/_changelog_dataclasses.py +++ b/keepachangelog/_changelog_dataclasses.py @@ -14,6 +14,7 @@ Union, ) +from keepachangelog._tree import BulletTree, TextNode from keepachangelog._versioning import ( InvalidSemanticVersion, UnmatchingSemanticVersion, @@ -28,6 +29,7 @@ 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: @@ -256,21 +258,75 @@ def from_release_line(cls, line: str) -> "Metadata": return obj -Note = str +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 -class Category(List[Note]): @staticmethod - def extract_information(line: str) -> str: - return line.lstrip(" *-").rstrip(" -") + 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: Note = Note(self.extract_information(line)) - if note: - self.append(note) + """ + * 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: str = "-") -> str: - return "\n".join(f"{bullet} {note}" for note in self) + def to_markdown(self, *, bullet: Optional[str] = None) -> str: + return self.root.print(bullet=bullet) @dataclass @@ -308,7 +364,7 @@ def __post_init__(self): continue category = getattr(self, f.name) if isinstance(category, list) and not isinstance(category, Category): - setattr(self, f.name, Category(category)) + setattr(self, f.name, Category([category])) @property def is_released(self): @@ -316,19 +372,19 @@ def is_released(self): @property def contains_breaking_changes(self) -> bool: - return bool(self.removed) or bool(self.changed) + return not self.removed.is_empty or not self.changed.is_empty @property def contains_only_bug_fixes(self): return all( [ - self.fixed, - not self.uncategorized, - not self.changed, - not self.added, - not self.security, - not self.deprecated, - not self.removed, + 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, ] ) @@ -336,13 +392,13 @@ def contains_only_bug_fixes(self): def is_empty(self): return all( [ - not self.fixed, - not self.uncategorized, - not self.changed, - not self.added, - not self.security, - not self.deprecated, - not self.removed, + 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, ] ) @@ -350,13 +406,13 @@ def to_markdown(self, *, raw=False) -> str: if raw: return "\n".join(self.__lines) out = [] - if self.uncategorized: - out.append(self.uncategorized.to_markdown(bullet="*")) + 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 category: + if not category.is_empty: out.append(f"### {f.name.capitalize()}") out.append(category.to_markdown()) out.append("") @@ -373,8 +429,8 @@ def to_dict(self, *, raw=False) -> dict: for f in fields(self): if f.type is Category: category = getattr(self, f.name) - if category: - out[f.name] = category + if not category.is_empty: + out[f.name] = list(category) return out def parse_category_line(self, line: str): 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/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 index e1fa2b5..7fd94b1 100644 --- a/tests/test_change.py +++ b/tests/test_change.py @@ -64,6 +64,6 @@ def test_construction_with_category(self): changed=["line3", "line4"], ) assert isinstance(change.uncategorized, Category) - assert change.uncategorized == ["line1", "line2"] + assert list(change.uncategorized) == ["line1", "line2"] assert isinstance(change.changed, Category) - assert change.changed == ["line3", "line4"] + assert list(change.changed) == ["line3", "line4"] 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_unreleased.py b/tests/test_changelog_unreleased.py index 558da05..fc5110b 100644 --- a/tests/test_changelog_unreleased.py +++ b/tests/test_changelog_unreleased.py @@ -198,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. 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)']]" + ) From 054cd3a82c849c74d1f8dcdabea8d21217cf4880 Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Sun, 30 Jan 2022 18:35:41 +0100 Subject: [PATCH 18/20] Added `to_list` functionality, fixed some backward compatibility. (<3.8) Signed-off-by: Samuel Giffard --- keepachangelog/_changelog.py | 36 ++++++++++++--- keepachangelog/_changelog_dataclasses.py | 56 ++++++++++++++++-------- setup.py | 4 +- tests/test_changelog_release.py | 8 ++-- 4 files changed, 75 insertions(+), 29 deletions(-) diff --git a/keepachangelog/_changelog.py b/keepachangelog/_changelog.py index 9d58cb6..eb34154 100644 --- a/keepachangelog/_changelog.py +++ b/keepachangelog/_changelog.py @@ -1,7 +1,7 @@ import pathlib -from typing import Dict, Optional, Iterable, Union, Callable, Any +from typing import Dict, Optional, Iterable, Union, List, Tuple, Any -from keepachangelog._changelog_dataclasses import Changelog, SemanticVersion +from keepachangelog._changelog_dataclasses import Changelog, SemanticVersion, StreamlinesProtocol def to_dict( @@ -25,6 +25,22 @@ def to_raw_dict(changelog_path: str, *, show_unreleased=False) -> Dict[str, dict ) +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. + + :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]) -> str: header = """# Changelog All notable changes to this project will be documented in this file. @@ -52,17 +68,16 @@ def release(changelog_path: str, new_version: str = None) -> Optional[str]: def _callback_proxy( - callback: Callable[[Iterable[str], ...], Any], + callback: StreamlinesProtocol, changelog_path: Union[str, Iterable[str]], - *args, **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, *args, **kwargs) + return callback(changelog_path, **kwargs) path = pathlib.Path(changelog_path) with open(path) as change_log: - return callback(change_log, *args, **kwargs) + return callback(change_log, **kwargs) def _to_dict( @@ -74,6 +89,15 @@ def _to_dict( return changes +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 + + def _release_version( changelog_path: str, changelog: Changelog, diff --git a/keepachangelog/_changelog_dataclasses.py b/keepachangelog/_changelog_dataclasses.py index c66d6e7..bd4f11a 100644 --- a/keepachangelog/_changelog_dataclasses.py +++ b/keepachangelog/_changelog_dataclasses.py @@ -14,6 +14,11 @@ Union, ) +try: + from typing import Protocol +except ImportError: + from typing_extensions import Protocol # if Python > 3.8 + from keepachangelog._tree import BulletTree, TextNode from keepachangelog._versioning import ( InvalidSemanticVersion, @@ -44,6 +49,14 @@ def matches_link(line: str) -> re.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) @@ -93,28 +106,22 @@ def from_dict(cls, data: dict): def bump_major(self): return self.__class__.from_dict( - self.to_dict(force=True) - | SemanticVersion(self.major + 1, 0, 0).to_dict(force=True) + 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( - self.to_dict(force=True) - | SemanticVersion(self.major, self.minor + 1, 0).to_dict(force=True) + 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( - self.to_dict(force=True) - | SemanticVersion(self.major, self.minor, self.patch + 1).to_dict( - force=True - ) + 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( - self.to_dict(force=True) - | SemanticVersion(self.major, self.minor, self.patch).to_dict(force=True) + 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]]: @@ -516,12 +523,19 @@ def unreleased_unique(self) -> 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=True + released, key=lambda k: k[1].metadata.semantic_version_strict, reverse=reverse ) def release(self, new_version: Optional[SemanticVersion] = None) -> bool: @@ -588,14 +602,14 @@ def __post_init__(self): self.changes = temp_changes def links(self) -> Generator[List[Tuple[str, str]], None, None]: - for version, change in self.sorted_changes: + 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_changes: + 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}" @@ -614,11 +628,17 @@ def to_markdown(self, *, raw=False) -> str: return "\n".join(out) def to_dict(self, *, show_unreleased: bool = False, raw: bool = False): - return { - version.lower(): change.to_dict(raw=raw) - for version, change in self.changes.items() - if change.is_released or show_unreleased - } + 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: diff --git a/setup.py b/setup.py index bbb0f23..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 diff --git a/tests/test_changelog_release.py b/tests/test_changelog_release.py index cbdf8af..a4d79d1 100644 --- a/tests/test_changelog_release.py +++ b/tests/test_changelog_release.py @@ -438,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 """ @@ -671,11 +671,11 @@ def test_minor_digit_release(minor_digit_changelog): ### 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 """ From 796ac7d1e4fae169bab0b84fe7a201d6f1e77f44 Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Sun, 30 Jan 2022 18:36:32 +0100 Subject: [PATCH 19/20] Added simple `to_sorted_semantic`. Signed-off-by: Samuel Giffard --- keepachangelog/__init__.py | 3 +-- keepachangelog/_changelog.py | 20 ++++++++++++++++++++ tests/test_changelog_release.py | 12 +++--------- 3 files changed, 24 insertions(+), 11 deletions(-) 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 eb34154..34bf800 100644 --- a/keepachangelog/_changelog.py +++ b/keepachangelog/_changelog.py @@ -67,6 +67,26 @@ def release(changelog_path: str, new_version: str = None) -> Optional[str]: 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]], diff --git a/tests/test_changelog_release.py b/tests/test_changelog_release.py index a4d79d1..3f39dcb 100644 --- a/tests/test_changelog_release.py +++ b/tests/test_changelog_release.py @@ -713,9 +713,7 @@ def test_patch_digit_release(patch_digit_changelog): @freeze_time(_date_time_for_tests) def test_sorted_major_digit_semantic_release(major_digit_changelog): - assert keepachangelog.to_sorted_semantic( - keepachangelog.to_raw_dict(major_digit_changelog) - ) == [ + assert keepachangelog.to_sorted_semantic(major_digit_changelog, reverse=False) == [ ( "9.10.100", { @@ -741,9 +739,7 @@ def test_sorted_major_digit_semantic_release(major_digit_changelog): @freeze_time(_date_time_for_tests) def test_sorted_minor_digit_semantic_release(minor_digit_changelog): - assert keepachangelog.to_sorted_semantic( - keepachangelog.to_raw_dict(minor_digit_changelog) - ) == [ + assert keepachangelog.to_sorted_semantic(minor_digit_changelog, reverse=False) == [ ( "9.9.100", { @@ -769,9 +765,7 @@ def test_sorted_minor_digit_semantic_release(minor_digit_changelog): @freeze_time(_date_time_for_tests) def test_sorted_patch_digit_semantic_release(patch_digit_changelog): - assert keepachangelog.to_sorted_semantic( - keepachangelog.to_raw_dict(patch_digit_changelog) - ) == [ + assert keepachangelog.to_sorted_semantic(patch_digit_changelog, reverse=False) == [ ( "9.9.9", { From 3f07aecf990359181a8be844225f8daafdb871b7 Mon Sep 17 00:00:00 2001 From: Samuel Giffard Date: Sun, 30 Jan 2022 18:40:23 +0100 Subject: [PATCH 20/20] Bump version `2.0.0.dev3`. Updated `CHANGELOG` and `README`. Added pragma no cover to skip Version-specific lines. GitHub Action added on Pull Request. Signed-off-by: Samuel Giffard --- .github/workflows/test.yml | 2 +- CHANGELOG.md | 28 +++++++++++++++++++++++- README.md | 2 +- keepachangelog/_changelog_dataclasses.py | 7 +++--- keepachangelog/version.py | 2 +- 5 files changed, 34 insertions(+), 7 deletions(-) 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/_changelog_dataclasses.py b/keepachangelog/_changelog_dataclasses.py index bd4f11a..6d04790 100644 --- a/keepachangelog/_changelog_dataclasses.py +++ b/keepachangelog/_changelog_dataclasses.py @@ -12,11 +12,12 @@ Generator, Iterable, Union, + Match, ) -try: +try: # pragma: no cover from typing import Protocol -except ImportError: +except ImportError: # pragma: no cover from typing_extensions import Protocol # if Python > 3.8 from keepachangelog._tree import BulletTree, TextNode @@ -45,7 +46,7 @@ def is_category(line: str) -> bool: return line.startswith("### ") -def matches_link(line: str) -> re.Match: +def matches_link(line: str) -> Match: return RE_LINK_LINE.fullmatch(line) 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"