29 Commits
0.1.0 ... 0.3.1

Author SHA1 Message Date
ebcb70c130 Version 0.3.1
### Added

- `yaclog` tool for manipulating changelogs from the command line
    - `init` command to make a new changelog
    - `format` command to reformat the changelog
    - `show` command to show changes from the changelog
    - `entry` command for manipulating entries in the changelog
    - `tag` command for manipulating tags in the changelog
    - `release` command for creating releases
2021-04-24 00:09:59 -07:00
c614363b5f Version 0.3.0
### Added

- `yaclog` tool for manipulating changelogs from the command line
    - `init` command to make a new changelog
    - `format` command to reformat the changelog
    - `show` command to show changes from the changelog
    - `entry` command for manipulating entries in the changelog
    - `tag` command for manipulating tags in the changelog
    - `release` command for creating releases
2021-04-24 00:02:04 -07:00
9ab1f74936 Fix file counting 2021-04-24 00:01:55 -07:00
74b6448ee1 Implement commit functionality 2021-04-23 23:57:53 -07:00
a82e455267 release command (without the commit feature) 2021-04-23 13:00:51 -07:00
41974dc953 entry and tag commands 2021-04-23 11:17:44 -07:00
5205e27fc8 Cleanup and remove implicit log creation 2021-04-23 02:18:07 -07:00
157f49839f yaclog show and yaclog format commands 2021-04-22 22:48:48 -07:00
6e75e15526 yaclog init command 2021-04-21 01:20:08 -07:00
e9a8e63c27 cleanup 2021-04-19 23:37:34 -07:00
983fff1471 Add to-string functions to version entries 2021-04-19 01:07:02 -07:00
39f6ede5f2 add date to changelog 2021-04-18 22:49:11 -07:00
ddfd96193d fix setup.cfg 2021-04-18 22:14:12 -07:00
dc5cc2ddd9 Release 0.2.0 2021-04-18 22:07:17 -07:00
57542e228e Update CHANGELOG.md 2021-04-18 22:04:22 -07:00
13ddc5a1f9 Gracefully handle H2s that dont match the schema 2021-04-18 22:01:31 -07:00
98c21e4078 Cleanup unused code 2021-04-18 20:10:03 -07:00
fb35ad3b29 Fix link parsing 2021-04-18 20:02:50 -07:00
849438a5f5 reworked 2-step parser that can handle setext headers 2021-04-18 18:45:39 -07:00
fa97e9154b Fix paragraph separation 2021-04-18 17:11:09 -07:00
f6f7a8b500 README formatting 2021-04-18 17:10:52 -07:00
7b694bc3c0 Add logo to readme 2021-04-18 17:01:21 -07:00
2e2a5834e6 Add logo 2021-04-18 16:57:55 -07:00
fbdd3f8971 code block support, kind-of 2021-04-18 12:36:27 -07:00
9ee8096e33 Improved parsing and add write() method
Tool now has round-trip accuracy with test file!
2021-04-18 03:02:33 -07:00
b5c4a1757e better version header formatting 2021-04-18 00:25:24 -07:00
1b263ad38f Copyright comment 2021-04-18 00:25:05 -07:00
a900679eb6 Fix python_requires tag 2021-04-17 21:53:16 -07:00
c999822bd0 Add changelog link 2021-04-17 00:29:13 -07:00
8 changed files with 579 additions and 89 deletions

View File

@ -1,9 +1,36 @@
# Changelog
All notable changes to this project will be documented in this file
## 0.3.1 - 2021-04-24
### Added
- `yaclog` tool for manipulating changelogs from the command line
- `init` command to make a new changelog
- `format` command to reformat the changelog
- `show` command to show changes from the changelog
- `entry` command for manipulating entries in the changelog
- `tag` command for manipulating tags in the changelog
- `release` command for creating releases
## 0.2.0 - 2021-04-19
### Added
- New yak log logo drawn by my sister
### Changed
- Updated package metadata
- Rewrote parser to use a 2-step method that is more flexible.
- Parser can now handle code blocks.
- Parser can now handle setext-style headers and H2s not conforming to the schema.
## 0.1.0 - 2021-04-16
First release
### Added
- `yaclog.read()` method to parse changelog files
- `yaclog.read()` method to parse changelog files

View File

@ -1,2 +1,6 @@
# yet-another-changelog
# Yaclog
Yet another changelog command line tool
![a yak who is a log](https://github.com/drewcassidy/yaclog/raw/main/logo.png)
*Logo by Erin Cassidy*

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -7,7 +7,6 @@ license = AGPLv3
license_file = LICENSE.md
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/drewcassidy/yet-another-changelog
keywords = changelog, commandline, markdown
classifiers =
@ -23,7 +22,14 @@ classifiers =
Topic :: Software Development :: Version Control :: Git
Topic :: Utilities
project_urls =
Source Code = https://github.com/drewcassidy/yaclog
Changelog = https://github.com/drewcassidy/yaclog/blob/main/CHANGELOG.md
[options]
install_requires = Click; GitPython
python_requires >= 3.8
install_requires =
Click ~= 7.0
GitPython >= 3
packaging >= 20
python_requires = >= 3.8
packages = find:

View File

@ -1,12 +1,36 @@
# 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/>.
import datetime
import os
import re
import string
from typing import List, Tuple, Optional
bullets = '+-*'
brackets = '[]'
code_regex = re.compile(r'^```')
header_regex = re.compile(r'^(?P<hashes>#+)\s+(?P<contents>[^#]+)(?:\s+#+)?$')
under1_regex = re.compile(r'^=+\s*$')
under2_regex = re.compile(r'^-+\s*$')
bullet_regex = re.compile(r'^[-+*]')
linkid_regex = re.compile(r'^\[(?P<link_id>\S*)]:\s*(?P<link>.*)')
default_header = '# Changelog\n\nAll notable changes to this project will be documented in this file'
def _strip_link(token):
if link_literal := re.fullmatch(r'\[(.*?)]\((.*?)\)', token):
@ -20,117 +44,238 @@ def _strip_link(token):
return token, None, None
def _join_markdown(segments: List[str]) -> str:
text: List[str] = []
last_bullet = False
for segment in segments:
is_bullet = bullet_regex.match(segment)
if not is_bullet or not last_bullet:
text.append('')
text.append(segment)
last_bullet = is_bullet
return '\n'.join(text).strip()
class VersionEntry:
def __init__(self):
self.sections = {'': []}
self.name: str = ''
self.name: str = 'Unreleased'
self.date: Optional[datetime.date] = None
self.tags: List[str] = []
self.link: str = ''
self.link_id: str = ''
self.line_no: int = -1
def body(self, md: bool = True) -> str:
segments = []
for section, entries in self.sections.items():
if section:
if md:
segments.append(f'### {section.title()}')
else:
segments.append(f'{section.upper()}:')
if len(entries) > 0:
segments.append(_join_markdown(entries))
return _join_markdown(segments)
def header(self, md: bool = True) -> str:
segments = []
if md:
segments.append('##')
if self.link and md:
segments.append(f'[{self.name}]')
else:
segments.append(self.name)
if self.date or len(self.tags) > 0:
segments.append('-')
if self.date:
segments.append(self.date.isoformat())
segments += [f'[{t.upper()}]' for t in self.tags]
return ' '.join(segments)
def text(self, md: bool = True) -> str:
return self.header(md) + '\n\n' + self.body(md)
def __str__(self) -> str:
if self.name.lower() == 'unreleased':
return f'## {self.name}'
date_str = self.date.isoformat() if self.date else 'UNKNOWN'
line = f'## {self.name} - {date_str}'
for tag in self.tags:
line += ' [' + tag.upper() + ']'
return line
return self.header(False)
class Changelog:
def __init__(self, path: os.PathLike):
self.path = path
self.header = ''
self.versions = []
def __init__(self, path: os.PathLike = None):
self.path: os.PathLike = path
self.header: str = ''
self.versions: List[VersionEntry] = []
self.links = {}
if not os.path.exists(path):
self.header = default_header
return
# Read file
with open(path, 'r') as fp:
# Read file
line = fp.readline()
while line and not line.startswith('##'):
self.header += line
line = fp.readline()
lines = fp.readlines()
version = None
section = ''
last_line = ''
section = ''
in_block = False
in_code = False
while line:
if line.isspace():
# skip empty lines
pass
elif match := re.fullmatch(
r'^##\s+(?P<name>\S*)(?:\s+-\s+(?P<date>\S+))?\s*?(?P<extra>.*?)\s*#*$', line):
# this is a version header in the form '## Name (- date) (tags*) (#*)'
version = VersionEntry()
section = ''
segments: List[Tuple[int, List[str], str]] = []
header_segments = []
version.name, version.link, version.link_id = _strip_link(match['name'])
for line_no, line in enumerate(lines):
if in_code:
# this is the contents of a code block
segments[-1][1].append(line)
if code_regex.match(line):
in_code = False
in_block = False
if match['date']:
elif code_regex.match(line):
# this is the start of a code block
in_code = True
segments.append((line_no, [line], 'code'))
elif under1_regex.match(line) and in_block and len(segments[-1][1]) == 1 and segments[-1][2] == 'p':
# this is an underline for a setext-style H1
# ugly but it works
last = segments.pop()
segments.append((last[0], last[1] + [line], 'h1'))
elif under2_regex.match(line) and in_block and len(segments[-1][1]) == 1 and segments[-1][2] == 'p':
# this is an underline for a setext-style H2
# ugly but it works
last = segments.pop()
segments.append((last[0], last[1] + [line], 'h2'))
elif bullet_regex.match(line):
in_block = True
segments.append((line_no, [line], 'li'))
elif match := header_regex.match(line):
# this is a header
kind = f'h{len(match["hashes"])}'
segments.append((line_no, [line], kind))
in_block = False
elif match := linkid_regex.match(line):
# this is a link definition in the form '[id]: link', so add it to the link table
self.links[match['link_id'].lower()] = match['link']
elif line.isspace():
# skip empty lines
in_block = False
elif in_block:
# this is a line to be added to a paragraph
segments[-1][1].append(line)
else:
# this is a new paragraph
in_block = True
segments.append((line_no, [line], 'p'))
for segment in segments:
text = ''.join(segment[1]).strip()
if segment[2] == 'h2':
# start of a version
slug = text.rstrip('-').strip('#').strip()
split = slug.split()
if '-' in split:
split.remove('-')
version = VersionEntry()
section = ''
version.name = slug
version.line_no = segment[0]
tags = []
date = []
for word in split[1:]:
if match := re.match(r'\d{4}-\d{2}-\d{2}', word):
# date
try:
version.date = datetime.date.fromisoformat(match['date'].strip(string.punctuation))
date = datetime.date.fromisoformat(match[0])
except ValueError:
version.date = None
if match['extra']:
version.tags = [s.strip('[]') for s in re.findall(r'\[.*?]', match['extra'])]
self.versions.append(version)
elif match := re.fullmatch(r'###\s+(\S*?)(\s+#*)?', line):
# this is a version section header in the form '### Name' or '### Name ###'
section = match[1].title()
if section not in version.sections.keys():
version.sections[section] = []
elif match := re.fullmatch(r'\[(\S*)]:\s*(\S*)\n', line):
# this is a link definition in the form '[id]: link', so add it to the link table
self.links[match[1].lower()] = match[2]
elif line[0] in bullets or last_line.isspace():
# bullet point or new paragraph
# bullet points are preserved since some people like to use '+', '-' or '*' for different things
version.sections[section].append(line.strip())
break
elif match := re.match(r'^\[(?P<tag>\S*)]', word):
tags.append(match['tag'])
else:
break
else:
# not a bullet point, and no whitespace on last line, so append to the last entry
version.sections[section][-1] += '\n' + line.strip()
# matches the schema
version.name, version.link, version.link_id = _strip_link(split[0])
version.date = date
version.tags = tags
last_line = line
line = fp.readline()
self.versions.append(version)
for version in self.versions:
# handle links
if match := re.fullmatch(r'\[(.*)]', version.name):
# ref-matched link
link_id = match[1].lower()
if link_id in self.links:
version.link = self.links.pop(link_id)
version.link_id = None
version.name = match[1]
elif len(self.versions) == 0:
# we haven't encountered any version headers yet,
# so its best to just add this line to the header string
header_segments.append(text)
elif version.link_id in self.links:
# id-matched link
version.link = self.links.pop(version.link_id)
elif segment[2] == 'h3':
# start of a version section
section = text.strip('#').strip()
if section not in self.versions[-1].sections.keys():
self.versions[-1].sections[section] = []
else:
# change log entry
self.versions[-1].sections[section].append(text)
def read_version_header(line: str) -> Tuple[str, datetime.date, List[str]]:
split = line.removeprefix('##').strip().split()
name = split[0]
date = datetime.date.fromisoformat(split[2]) if len(split) > 2 else None
tags = [s.removeprefix('[').removesuffix(']') for s in split[3:]]
# handle links
for version in self.versions:
if match := re.fullmatch(r'\[(.*)]', version.name):
# ref-matched link
link_id = match[1].lower()
if link_id in self.links:
version.link = self.links.pop(link_id)
version.link_id = None
version.name = match[1]
return name, date, tags
elif version.link_id in self.links:
# id-matched link
version.link = self.links.pop(version.link_id)
# strip whitespace from header
self.header = _join_markdown(header_segments)
def write_version_header(name: str, date: datetime.date, tags=None) -> str:
line = f'## {name} - {date.isoformat()}'
if tags:
for tag in tags:
line += ' [' + tag.upper() + ']'
def write(self, path: os.PathLike = None):
if path is None:
path = self.path
return line
v_links = {}
v_links.update(self.links)
segments = [self.header]
for version in self.versions:
if version.link:
v_links[version.name] = version.link
segments.append(version.text())
for link_id, link in v_links.items():
segments.append(f'[{link_id.lower()}]: {link}')
text = _join_markdown(segments)
with open(path, 'w') as fp:
fp.write(text)

0
yaclog/cli/__init__.py Normal file
View File

228
yaclog/cli/__main__.py Normal file
View File

@ -0,0 +1,228 @@
# 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/>.
import click
import os.path
import datetime
import git
import yaclog.cli.version_util
from yaclog import Changelog
@click.group()
@click.option('--path', envvar='YACLOG_PATH', default='CHANGELOG.md', show_default=True,
type=click.Path(dir_okay=False, writable=True, readable=True),
help='Location of the changelog file.')
@click.version_option()
@click.pass_context
def cli(ctx, path):
"""Manipulate markdown changelog files."""
if not (ctx.invoked_subcommand == 'init') and not os.path.exists(path):
# file does not exist and this isn't the init command
raise click.FileError(f'Changelog file {path} does not exist. Create it by running `yaclog init`.')
ctx.obj = yaclog.read(path)
@cli.command()
@click.pass_obj
def init(obj: Changelog):
"""Create a new changelog file."""
if os.path.exists(obj.path):
click.confirm(f'Changelog file {obj.path} already exists. Would you like to overwrite it?', abort=True)
os.remove(obj.path)
yaclog.Changelog(obj.path).write()
print(f'Created new changelog file at {obj.path}')
@cli.command('format') # dont accidentally hide the `format` python builtin
@click.pass_obj
def reformat(obj: Changelog):
"""Reformat the changelog file."""
obj.write()
print(f'Reformatted changelog file at {obj.path}')
@cli.command(short_help='Show changes from the changelog file')
@click.option('--all', '-a', 'all_versions', is_flag=True, help='Show the entire changelog.')
@click.argument('versions', type=str, nargs=-1)
@click.pass_obj
def show(obj: Changelog, all_versions, versions):
"""Show the changes for VERSIONS.
VERSIONS is a list of versions to print. If not given, the most recent version is used.
"""
if all_versions:
with open(obj.path, 'r') as fp:
click.echo_via_pager(fp.read())
else:
if len(versions):
v_list = []
for v_name in versions:
matches = [v for v in obj.versions if v.name == v_name]
if len(matches) == 0:
raise click.BadArgumentUsage(f'Version "{v_name}" not found in changelog.')
v_list += matches
else:
v_list = [obj.versions[0]]
for v in v_list:
click.echo(v.text(False))
@cli.command(short_help='Modify version tags')
@click.option('--add/--delete', '-a/-d', default=True, is_flag=True, help='Add or delete tags')
@click.argument('tag_name', metavar='tag', type=str)
@click.argument('version_name', metavar='version', type=str, required=False)
@click.pass_obj
def tag(obj: Changelog, add, tag_name: str, version_name: str):
"""Modify TAG on VERSION.
VERSION is the name of a version to add tags to. If not given, the most recent version is used.
"""
tag_name = tag_name.upper()
if version_name:
matches = [v for v in obj.versions if v.name == version_name]
if len(matches) == 0:
raise click.BadArgumentUsage(f'Version "{version_name}" not found in changelog.')
version = matches[0]
else:
version = obj.versions[0]
if add:
version.tags.append(tag_name)
else:
try:
version.tags.remove(tag_name)
except ValueError:
raise click.BadArgumentUsage(f'Tag "{tag_name}" not found in version "{version.name}".')
obj.write()
@cli.command(short_help='Add entries to the changelog.')
@click.option('--bullet', '-b', 'bullets', multiple=True, type=str,
help='Bullet points to add. '
'When multiple bullet points are provided, additional points are added as sub-points.')
@click.option('--paragraph', '-p', 'paragraphs', multiple=True, type=str,
help='Paragraphs to add')
@click.argument('section_name', metavar='SECTION', type=str, default='', required=False)
@click.argument('version_name', metavar='VERSION', type=str, default=None, required=False)
@click.pass_obj
def entry(obj: Changelog, bullets, paragraphs, section_name, version_name):
"""Add entries to SECTION in VERSION
SECTION is the name of the section to append to. If not given, entries will be uncategorized.
VERSION is the name of the version to append to. If not given, the most recent version will be used,
or a new 'Unreleased' version will be added if the most recent version has been released.
"""
section_name = section_name.title()
if version_name:
matches = [v for v in obj.versions if v.name == version_name]
if len(matches) == 0:
raise click.BadArgumentUsage(f'Version "{version_name}" not found in changelog.')
version = matches[0]
else:
version = obj.versions[0]
if version.name.lower() != 'unreleased':
version = yaclog.changelog.VersionEntry()
obj.versions.insert(0, version)
if section_name not in version.sections.keys():
version.sections[section_name] = []
section = version.sections[section_name]
section += paragraphs
sub_bullet = False
bullet_str = ''
for bullet in bullets:
bullet = bullet.strip()
if bullet[0] not in ['-+*']:
bullet = '- ' + bullet
if sub_bullet:
bullet = '\n ' + bullet
bullet_str += bullet
sub_bullet = True
section.append(bullet_str)
obj.write()
@cli.command()
@click.option('-v', '--version', 'v_flag', type=str, default=None, help='The new version number to use.')
@click.option('-M', '--major', 'v_flag', flag_value='+M', help='Increment major version number.')
@click.option('-m', '--minor', 'v_flag', flag_value='+m', help='Increment minor version number.')
@click.option('-p', '--patch', 'v_flag', flag_value='+p', help='Increment patch number.')
@click.option('-a', '--alpha', 'v_flag', flag_value='+a', help='Increment alpha version number.')
@click.option('-b', '--beta', 'v_flag', flag_value='+b', help='Increment beta version number.')
@click.option('-r', '--rc', 'v_flag', flag_value='+rc', help='Increment release candidate version number.')
@click.option('-c', '--commit', is_flag=True, help='Create a git commit tagged with the new version number.')
@click.pass_obj
def release(obj: Changelog, v_flag, commit):
"""Release versions in the changelog and increment their version numbers"""
version = [v for v in obj.versions if v.name.lower() != 'unreleased'][0]
cur_version = obj.versions[0]
old_name = cur_version.name
if v_flag:
if v_flag[0] == '+':
new_name = yaclog.cli.version_util.increment_version(version.name, v_flag)
else:
new_name = v_flag
if yaclog.cli.version_util.is_release(cur_version.name):
click.confirm(f'Rename release version "{cur_version.name}" to "{new_name}"?', abort=True)
cur_version.name = new_name
cur_version.date = datetime.datetime.utcnow().date()
obj.write()
print(f'Renamed version "{old_name}" to "{cur_version.name}".')
if commit:
repo = git.Repo(os.curdir)
if repo.bare:
raise click.BadOptionUsage('commit', f'Directory {os.path.abspath(os.curdir)} is not a git repo.')
repo.index.add(obj.path)
version_type = '' if yaclog.cli.version_util.is_release(cur_version.name) else 'non-release '
untracked = len(repo.index.diff(None))
untracked_warning = ''
untracked_plural = 's' if untracked > 1 else ''
if untracked > 0:
untracked_warning = click.style(
f' You have {untracked} untracked file{untracked_plural} that will not be committed.',
fg='red', bold=True)
click.confirm(f'Commit and create tag for {version_type}version {cur_version.name}?{untracked_warning}',
abort=True)
repo.index.commit(f'Version {cur_version.name}\n\n{cur_version.body()}')
repo.create_tag(cur_version.name, message=cur_version.body(False))
print(f'Created tag "{cur_version.name}".')
if __name__ == '__main__':
cli()

View File

@ -0,0 +1,80 @@
# 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 packaging.version import Version, InvalidVersion
def is_release(version: str) -> bool:
try:
v = Version(version)
return not (v.is_devrelease or v.is_prerelease)
except InvalidVersion:
return False
def increment_version(version: str, mode: str) -> str:
v = Version(version)
epoch = v.epoch
release = v.release
pre = v.pre
post = v.post
dev = v.dev
local = v.local
if mode == '+M':
release = (release[0] + 1,) + release[1:]
elif mode == '+m':
release = (release[0], release[1] + 1) + release[2:]
elif mode == '+p':
release = (release[0], release[1], release[2] + 1) + release[3:]
elif mode in ['+a', '+b', '+rc']:
if pre[0] == mode[1:]:
pre = (mode[1:], pre[1] + 1)
else:
pre = (mode[1:], 0)
else:
raise IndexError(f'Unknown mode {mode}')
return join_version(epoch, release, pre, post, dev, local)
def join_version(epoch, release, pre, post, dev, local) -> str:
parts = []
# Epoch
if epoch != 0:
parts.append(f"{epoch}!")
# Release segment
parts.append(".".join(str(x) for x in release))
# Pre-release
if pre is not None:
parts.append("".join(str(x) for x in pre))
# Post-release
if post is not None:
parts.append(f".post{post}")
# Development release
if dev is not None:
parts.append(f".dev{dev}")
# Local version segment
if local is not None:
parts.append(f"+{local}")
return "".join(parts)