yaclog/yaclog/changelog.py

401 lines
14 KiB
Python
Raw Normal View History

2021-05-05 04:01:30 +00:00
"""
Contains the `Changelog` class that represents a parsed changelog file that can be read from and written to
disk as markdown, as well as the `VersionEntry` class that represents a single version within that changelog.
2021-05-05 04:01:30 +00:00
"""
2021-04-18 07:25:05 +00:00
# yaclog: yet another changelog tool
# Copyright (c) 2021. Andrew Cassidy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
2021-04-16 04:14:37 +00:00
import datetime
import os
import re
from typing import List, Optional, Dict
2021-04-16 04:14:37 +00:00
2021-05-04 03:50:04 +00:00
import click # only for styling
2021-04-28 07:58:25 +00:00
import yaclog.markdown as markdown
import yaclog.version
2021-04-16 04:14:37 +00:00
class VersionEntry:
"""
A serialized representation of a single version entry in a `Changelog`,
containing the changes made since the previous version
"""
2021-04-27 05:28:09 +00:00
2021-05-05 04:01:30 +00:00
_header_regex = re.compile( # THE LANGUAGE OF THE GODS
2021-04-30 02:43:18 +00:00
r"##\s+(?P<name>.*?)(?:\s+-)?(?:\s+(?P<date>\d{4}-\d{2}-\d{2}))?(?P<tags>(?:\s+\[[^]]*?])*)\s*$")
2021-05-05 04:01:30 +00:00
_tag_regex = re.compile(r'\[(?P<tag>[^]]*?)]')
2021-04-30 02:43:18 +00:00
2021-04-27 05:28:09 +00:00
def __init__(self, name: str = 'Unreleased',
date: Optional[datetime.date] = None, tags: Optional[List[str]] = None,
link: Optional[str] = None, link_id: Optional[str] = None, line_no: Optional[int] = None):
2021-04-27 05:28:09 +00:00
"""
:param str name: The version's name
2021-05-05 04:01:30 +00:00
:param Optional[datetime.date] date: When the version was released
2021-04-27 05:28:09 +00:00
:param tags: The version's tags
:param link: The version's URL
:param link_id: The version's link ID
2021-05-05 04:01:30 +00:00
:param line_no: What line in the original file the version starts on
2021-04-27 05:28:09 +00:00
"""
self.name: str = name
"""The version's name"""
2021-04-27 05:28:09 +00:00
self.date: Optional[datetime.date] = date
2021-04-30 02:43:18 +00:00
"""When the version was released"""
2021-04-27 05:28:09 +00:00
self.tags: List[str] = tags if tags else []
"""The version's tags"""
2021-04-27 05:28:09 +00:00
self.link: Optional[str] = link
"""The version's URL"""
2021-04-27 05:28:09 +00:00
self.link_id: Optional[str] = link_id
"""The version's link ID, uses the version name by default when writing"""
self.line_no: Optional[int] = line_no
2021-05-05 04:01:30 +00:00
"""What line the version occurs at in the file, or `None` if the version was not read from a file.
This is not guaranteed to be correct after the changelog has been modified,
and it has no effect on the written file"""
2021-04-27 05:28:09 +00:00
self.sections: Dict[str, List[str]] = {'': []}
"""The dictionary of change entries in the version, organized by section.
Uncategorized changes have a section of an empty string."""
2021-04-27 05:28:09 +00:00
2021-04-30 02:43:18 +00:00
@classmethod
def from_header(cls, header: str, line_no: Optional[int] = None) -> VersionEntry:
"""
Create a new version entry from a markdown header
:param header: A markdown header to parse
:param line_no: Line number the header is on
:return: a new VersionEntry with the header's information
"""
version = cls(line_no=line_no)
2021-04-30 02:43:18 +00:00
2021-05-05 04:01:30 +00:00
match = cls._header_regex.match(header)
2021-04-30 02:43:18 +00:00
assert match, f'failed to parse version header: "{header}"'
version.name, version.link, version.link_id = markdown.strip_link(match['name'])
if match['date']:
try:
version.date = datetime.date.fromisoformat(match['date'])
except ValueError:
return cls(name=header.lstrip('#').strip(), line_no=line_no)
2021-04-30 02:43:18 +00:00
if match['tags']:
2021-05-05 04:01:30 +00:00
version.tags = [m['tag'].upper() for m in cls._tag_regex.finditer(match['tags'])]
2021-04-30 02:43:18 +00:00
return version
2021-04-27 05:28:09 +00:00
def add_entry(self, contents: str, section: str = '') -> None:
"""
Add a new entry to the version
:param contents: The contents string to add
:param section: Which section to add to.
"""
section = section.title()
if section not in self.sections.keys():
self.sections[section] = []
self.sections[section].append(contents)
2021-04-16 04:14:37 +00:00
2021-05-04 03:50:04 +00:00
def body(self, md: bool = True, color: bool = False) -> str:
2021-04-27 05:28:09 +00:00
"""
Get the version's body as a string
2021-05-04 03:50:04 +00:00
:param md: Format headings as markdown
:param color: Add color codes to the string for display in a terminal
2021-04-27 05:28:09 +00:00
:return: The formatted version body, without the version header
"""
segments = []
for section, entries in self.sections.items():
if section:
if md:
2021-05-04 03:50:04 +00:00
prefix = '### '
title = section.title()
else:
2021-05-04 03:50:04 +00:00
prefix = ''
title = section.upper()
if color:
prefix = click.style(prefix, fg='bright_black')
title = click.style(title, fg='cyan', bold=True)
segments.append(prefix + title)
if len(entries) > 0:
2021-04-27 05:28:09 +00:00
segments += entries
return markdown.join(segments)
2021-05-04 03:50:04 +00:00
def header(self, md: bool = True, color: bool = False) -> str:
2021-04-27 05:28:09 +00:00
"""
Get the version's header as a string
2021-05-04 03:50:04 +00:00
:param md: Format headings as markdown
:param color: Add color codes to the string for display in a terminal
2021-04-27 05:28:09 +00:00
:return: The formatted version header
"""
if md:
2021-05-04 03:50:04 +00:00
prefix = '## '
else:
prefix = ''
segments = []
if self.link and md:
segments.append(f'[{self.name}]')
else:
segments.append(self.name)
2021-04-16 04:14:37 +00:00
if self.date or len(self.tags) > 0:
segments.append('-')
2021-04-18 07:25:24 +00:00
if self.date:
segments.append(self.date.isoformat())
2021-04-16 04:14:37 +00:00
2021-04-18 07:25:24 +00:00
segments += [f'[{t.upper()}]' for t in self.tags]
2021-05-04 03:50:04 +00:00
title = ' '.join(segments)
if color:
prefix = click.style(prefix, fg='bright_black')
title = click.style(title, fg='blue', bold=True)
return prefix + title
2021-04-16 04:14:37 +00:00
2021-05-04 03:50:04 +00:00
def text(self, md: bool = True, color: bool = False) -> str:
2021-04-27 05:28:09 +00:00
"""
Get the version's contents as a string
2021-05-04 03:50:04 +00:00
:param md: Format headings as markdown
:param color: Add color codes to the string for display in a terminal
2021-04-27 05:28:09 +00:00
:return: The formatted version header and body
"""
2021-05-04 03:50:04 +00:00
contents = self.header(md, color)
body = self.body(md, color)
2021-04-27 05:28:09 +00:00
if body:
contents += '\n\n' + body
return contents
2021-04-28 07:58:25 +00:00
@property
2021-05-05 04:01:30 +00:00
def released(self) -> bool:
"""Returns true if a PEP440 version number is present in the version name, and has no prerelease segments"""
2021-04-28 07:58:25 +00:00
return yaclog.version.is_release(self.name)
2021-05-01 04:19:04 +00:00
@property
def version(self):
2021-05-05 04:01:30 +00:00
"""Returns the PEP440 version number from the version name, or `None` if none is found"""
2021-05-01 04:19:04 +00:00
return yaclog.version.extract_version(self.name)[0]
def __str__(self) -> str:
return self.header(False)
2021-04-16 04:14:37 +00:00
class Changelog:
"""
A serialized representation of a Markdown changelog made up of a preamble, multiple versions, and a link table.
"""
def __init__(self, path=None,
2021-05-10 02:39:48 +00:00
preamble: str = "# Changelog\n\nAll notable changes to this project will be documented in this file"):
2021-04-27 05:28:09 +00:00
"""
2021-05-05 04:01:30 +00:00
Contents will be automatically read from disk if the file exists
2021-04-27 05:28:09 +00:00
:param path: The changelog's path on disk.
:param str preamble: The changelog preamble to use if the file does not exist.
2021-04-27 05:28:09 +00:00
"""
self.path = os.path.abspath(path) if path else None
"""The path of the changelog's file on disk"""
self.preamble: str = preamble
"""Any text at the top of the changelog before any version information, including the title.
It can contain the title, an explanation of the file's purpose, as well as any general machine-readable
information for use with other tools."""
self.versions: List[VersionEntry] = []
"""A list of versions in the changelog, with the most recent version first"""
2021-05-05 04:01:30 +00:00
self.links: Dict[str, str] = {}
"""Link definitions at the end of the changelog, as a dictionary of ``{id: url}``"""
2021-04-21 08:20:08 +00:00
2021-04-27 05:28:09 +00:00
if path and os.path.exists(path):
self.read()
def read(self, path=None) -> None:
"""
Read a markdown changelog file from disk. The object's contents will be overwritten by the file contents if
reading is successful.
2021-04-27 05:28:09 +00:00
:param path: The changelog's path on disk. By default, :py:attr:`~Changelog.path` is used
2021-04-27 05:28:09 +00:00
"""
if not path:
# use the object path if none was provided
path = self.path
2021-04-16 04:14:37 +00:00
# Read file
2021-04-16 04:14:37 +00:00
with open(path, 'r') as fp:
tokens, links = markdown.tokenize(fp.read())
section = ''
versions = []
preamble_segments = []
for token in tokens:
text = '\n'.join(token.lines)
if token.kind == 'h2':
# start of a version
versions.append(VersionEntry.from_header(text, line_no=token.line_no))
section = ''
elif len(versions) == 0:
# we haven't encountered any version headers yet,
# so its best to just add this line to the preamble
preamble_segments.append(text)
elif token.kind == 'h3':
# start of a version section
section = text.strip('#').strip()
if section not in versions[-1].sections.keys():
versions[-1].sections[section] = []
else:
# change log entry
versions[-1].sections[section].append(text)
# handle links
for version in versions:
if match := re.fullmatch(r'\[(.*)]', version.name):
# ref-matched link
link_id = match[1].lower()
if link_id in links:
version.link = links[link_id]
version.link_id = None
version.name = match[1]
elif version.link_id in links:
# id-matched link
version.link = links[version.link_id]
self.preamble = markdown.join(preamble_segments)
self.versions = versions
self.links = links
def write(self, path=None) -> None:
2021-04-27 05:28:09 +00:00
"""
Write a changelog to a Markdown file.
2021-04-27 05:28:09 +00:00
:param path: The changelog's path on disk. By default, :py:attr:`~Changelog.path` is used.
"""
if path is None:
2021-04-27 05:28:09 +00:00
# use the object path if none was provided
path = self.path
segments = []
if self.preamble:
segments.append(self.preamble)
2021-04-25 09:16:19 +00:00
v_links = {**self.links}
2021-04-24 06:57:53 +00:00
for version in self.versions:
if version.link:
2021-04-25 22:41:51 +00:00
v_links[version.name.lower()] = version.link
2021-04-16 04:14:37 +00:00
segments.append(version.text() + '\n')
2021-04-16 04:14:37 +00:00
2021-04-25 22:41:51 +00:00
segments += [f'[{link_id}]: {link}' for link_id, link in v_links.items()]
text = markdown.join(segments)
2021-04-16 04:14:37 +00:00
2021-04-24 06:57:53 +00:00
with open(path, 'w') as fp:
fp.write(text)
def add_version(self, index: int = 0, *args, **kwargs) -> VersionEntry:
2021-04-28 07:58:25 +00:00
"""
Add a new version to the changelog
2021-04-28 07:58:25 +00:00
:param index: Where to add the new version in the log. Defaults to the top
:param args: args to forward to the :py:class:`VersionEntry` constructor
:param kwargs: kwargs to forward to the :py:class:`VersionEntry` constructor
:return: The created entry
"""
self.versions.insert(index, version := VersionEntry(*args, **kwargs))
return version
2021-04-30 05:59:09 +00:00
def current_version(self, released: Optional[bool] = None, new_version: bool = False,
new_version_name: str = 'Unreleased') -> VersionEntry:
2021-04-28 07:58:25 +00:00
"""
Get the current version from the changelog
2021-04-28 07:58:25 +00:00
:param released: if the returned version should be a released version,
2021-05-05 04:01:30 +00:00
an unreleased version, or `None` to return the most recent
2021-04-30 05:59:09 +00:00
:param new_version: if a new version should be created if none exist.
:param new_version_name: The name of the version to create if there
are no matches and ``new_version`` is True.
:return: The current version matching the criteria,
2021-05-05 04:01:30 +00:00
or `None` if ``new_version`` is disabled and none are found.
2021-04-28 07:58:25 +00:00
"""
2021-04-30 02:43:18 +00:00
# return the first version that matches `released`
for version in self.versions:
if version.released == released or released is None:
return version
2021-04-28 07:58:25 +00:00
# fallback if none are found
2021-04-30 05:59:09 +00:00
if new_version:
return self.add_version(name=new_version_name)
2021-04-30 05:59:09 +00:00
else:
if released is not None:
raise ValueError(f'Changelog has no current version matching released={released}')
else:
raise ValueError('Changelog has no current version')
2021-04-28 07:58:25 +00:00
def get_version(self, name: Optional[str] = None) -> VersionEntry:
"""
2021-05-12 05:10:13 +00:00
Get a version from the changelog by name.
2021-04-28 07:58:25 +00:00
2021-05-12 05:10:13 +00:00
:param name: The name of the version to get, or `None` to return the most recent.
The first version with this value in its name is returned.
2021-04-28 07:58:25 +00:00
:return: The first version with the selected name
"""
for version in self.versions:
2021-05-12 05:10:13 +00:00
if name in version.name or name is None:
2021-04-28 07:58:25 +00:00
return version
2021-04-30 05:59:09 +00:00
raise KeyError(f'Version {name} not found in changelog')
2021-04-28 07:58:25 +00:00
def __getitem__(self, item: str) -> VersionEntry:
return self.get_version(item)
def __len__(self) -> int:
return len(self.versions)