34 Commits
0.2.0 ... 0.3.3

Author SHA1 Message Date
f56038d3c9 Version 0.3.3
### Added

- Unit tests in the `tests` folder

### Changed

- Default links and dates in VersionEntry are now consistently `None`
- Changelog links dict now contains version links. 
  Modified version links will overwrite those in the table when writing to a file
- Changelog object no longer errors when creating without a path.
- `release` now resets lesser version values when incrementing
- `release` now works with logs that have only unreleased changes
2021-04-26 19:53:50 -07:00
7b0eb4c78b Tests cleanup 2021-04-26 19:53:28 -07:00
32c09d82bd Consolidate output checking and fix git error 2021-04-25 23:18:44 -07:00
a443724d2b Even better test error logging 2021-04-25 23:09:27 -07:00
2ba414f121 Better test error logging 2021-04-25 23:05:16 -07:00
140faccb69 Attempt to fix tagging error? 2021-04-25 22:59:13 -07:00
08be02a49c More CLI tests 2021-04-25 22:47:55 -07:00
3972786d82 Fix release command and empty logs 2021-04-25 22:20:42 -07:00
ec9c785c3a Fix version incrementing 2021-04-25 22:19:14 -07:00
daaf21ca8d Cleanup 2021-04-25 19:54:00 -07:00
0c11cf9ffc Add tests for log writer 2021-04-25 19:51:12 -07:00
a13fa34c0c Update changelog 2021-04-25 19:28:50 -07:00
73a331f3e5 Use correct test runner 2021-04-25 15:58:38 -07:00
336421078c Run tests in CI 2021-04-25 15:56:23 -07:00
1c389038b4 Delete test changelog 2021-04-25 15:45:22 -07:00
53845bf20f Rewrite tests 2021-04-25 15:41:51 -07:00
ae681ae290 Bring changelog up to date 2021-04-25 02:24:22 -07:00
be78167b4b Add unit tests for parser 2021-04-25 02:16:19 -07:00
358942c858 Version 0.3.2
### Added

- Readme file now has installation and usage instructions.
- yaclog command entry point added to setup.cfg.

### Changed

- `release -c` will no longer create empty commits, and will use the current commit instead.

### Fixed

- `release` and `entry` commands now work using empty changelogs.
2021-04-24 14:01:47 -07:00
82039ca074 formatting 2021-04-24 13:59:56 -07:00
a3ad83ec32 Bug fixes and readme 2021-04-24 02:58:59 -07:00
0bf63f1501 release -c will no longer create empty commits 2021-04-24 02:23:15 -07:00
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
13 changed files with 909 additions and 58 deletions

View File

@ -2,14 +2,45 @@
# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
name: Upload Python Package
on:
release:
types: [ published ]
on: [ push, pull_request ]
jobs:
deploy:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ 3.8, 3.9 ]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8
- name: Install module
run: python -m pip install .
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Run unit tests
run: python -m unittest -v
deploy:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
steps:
- uses: actions/checkout@v2

View File

@ -1,7 +1,50 @@
# Changelog
All notable changes to this project will be documented in this file
## 0.2.0
## 0.3.3 - 2021-04-27
### Added
- Unit tests in the `tests` folder
### Changed
- Default links and dates in VersionEntry are now consistently `None`
- Changelog links dict now contains version links.
Modified version links will overwrite those in the table when writing to a file
- Changelog object no longer errors when creating without a path.
- `release` now resets lesser version values when incrementing
- `release` now works with logs that have only unreleased changes
## 0.3.2 - 2021-04-24
### Added
- Readme file now has installation and usage instructions.
- yaclog command entry point added to setup.cfg.
### Changed
- `release -c` will no longer create empty commits, and will use the current commit instead.
### Fixed
- `release` and `entry` commands now work using empty changelogs.
## 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
@ -12,8 +55,7 @@ All notable changes to this project will be documented in this file
- 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.
- Parser can now handle setext-style headers and H2s not conforming to the schema.
## 0.1.0 - 2021-04-16
@ -21,4 +63,4 @@ First release
### Added
- `yaclog.read()` method to parse changelog files
- `yaclog.read()` method to parse changelog files

View File

@ -3,4 +3,71 @@ 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*
*Logo by Erin Cassidy*
## Installation
Install and update using [pip](https://pip.pypa.io/en/stable/quickstart/):
```shell
$ pip install -U yaclog
```
## Usage
For usage from the command line, yaclog provides the `yaclog` command:
```
Usage: yaclog [OPTIONS] COMMAND [ARGS]...
Manipulate markdown changelog files.
Options:
--path FILE Location of the changelog file. [default: CHANGELOG.md]
--version Show the version and exit.
--help Show this message and exit.
Commands:
entry Add entries to the changelog.
format Reformat the changelog file.
init Create a new changelog file.
release Release versions.
show Show changes from the changelog file
tag Modify version tags
```
### Example workflow
Create a new changelog:
```shell
$ yaclog init
```
Add some new entries to the "Added" section of the current unreleased version:
```shell
$ yaclog entry -b 'Introduced some more bugs'
$ yaclog entry -b 'Introduced some more features'
```
Show the current version:
```shell
$ yaclog show
```
```
Unreleased
- Introduced some more bugs
- Introduced some more features
```
Release the current version and make a git tag for it
```shell
$ yaclog release --version 0.0.1 -c
```
```
Renamed version "Unreleased" to "0.0.1".
Commit and create tag for version 0.0.1? [y/N]: y
Created commit a7b6789
Created tag "0.0.1".
```

View File

@ -27,6 +27,16 @@ project_urls =
Changelog = https://github.com/drewcassidy/yaclog/blob/main/CHANGELOG.md
[options]
install_requires = Click; GitPython
install_requires =
Click ~= 7.0
GitPython >= 3
packaging >= 20
python_requires = >= 3.8
packages = find:
[options.entry_points]
console_scripts =
yaclog = yaclog.cli.__main__:cli
[options.packages.find]
exclude = tests.*

0
tests/__init__.py Normal file
View File

79
tests/common.py Normal file
View File

@ -0,0 +1,79 @@
import datetime
import os.path
import textwrap
import yaclog.changelog
log_segments = [
'# Changelog',
'This changelog is for testing the parser, and has many things in it that might trip it up.',
'## [Tests]', # 2
'- bullet point with no section',
'### Bullet Points', # 4
textwrap.dedent('''\
- bullet point dash
* bullet point star
+ bullet point plus
- sub point 1
- sub point 2
- sub point 3'''),
'### Blocks ##', # 6
'#### This is an H4',
'##### This is an H5',
'###### This is an H6',
'- this is a bullet point\nit spans many lines',
'This is\na paragraph\nit spans many lines',
'```python\nthis is some example code\nit spans many lines\n```',
'> this is a block quote\nit spans many lines',
'[FullVersion] - 1969-07-20 [TAG1] [TAG2]\n-----', # 14
'## Long Version Name', # 15
'[fullVersion]: http://endless.horse\n[id]: http://www.koalastothemax.com'
]
log_text = '\n\n'.join(log_segments)
log = yaclog.Changelog()
log.header = '# Changelog\n\nThis changelog is for testing the parser, and has many things in it that might trip it up.'
log.links = {'id': 'http://www.koalastothemax.com'}
log.versions = [yaclog.changelog.VersionEntry(), yaclog.changelog.VersionEntry(), yaclog.changelog.VersionEntry()]
log.versions[0].name = '[Tests]'
log.versions[0].sections = {
'': ['- bullet point with no section'],
'Bullet Points': [
'- bullet point dash',
'* bullet point star',
'+ bullet point plus\n - sub point 1\n - sub point 2\n - sub point 3'],
'Blocks': [
'#### This is an H4',
'##### This is an H5',
'###### This is an H6',
'- this is a bullet point\nit spans many lines',
'This is\na paragraph\nit spans many lines',
'```python\nthis is some example code\nit spans many lines\n```',
'> this is a block quote\nit spans many lines',
]
}
log.versions[1].name = 'FullVersion'
log.versions[1].link = 'http://endless.horse'
log.versions[1].tags = ['TAG1', 'TAG2']
log.versions[1].date = datetime.date.fromisoformat('1969-07-20')
log.versions[2].name = 'Long Version Name'

81
tests/test_changelog.py Normal file
View File

@ -0,0 +1,81 @@
import os.path
import tempfile
import unittest
import yaclog.changelog
from tests.common import log, log_segments, log_text
class TestParser(unittest.TestCase):
@classmethod
def setUpClass(cls):
with tempfile.TemporaryDirectory() as td:
cls.path = os.path.join(td, 'changelog.md')
with open(cls.path, 'w') as fd:
fd.write(log_text)
cls.log = yaclog.read(cls.path)
def test_path(self):
"""Test the log's path"""
self.assertEqual(self.path, self.log.path)
def test_header(self):
"""Test the header information at the top of the file"""
self.assertEqual(log.header, self.log.header)
def test_links(self):
"""Test the links at the end of the file"""
self.assertEqual({'fullversion': 'http://endless.horse', **log.links}, self.log.links)
def test_versions(self):
"""Test the version headers"""
for i in range(len(self.log.versions)):
self.assertEqual(log.versions[i].name, self.log.versions[i].name)
self.assertEqual(log.versions[i].link, self.log.versions[i].link)
self.assertEqual(log.versions[i].date, self.log.versions[i].date)
self.assertEqual(log.versions[i].tags, self.log.versions[i].tags)
def test_entries(self):
"""Test the change entries"""
self.assertEqual(log.versions[0].sections, self.log.versions[0].sections)
class TestWriter(unittest.TestCase):
@classmethod
def setUpClass(cls):
with tempfile.TemporaryDirectory() as td:
cls.path = os.path.join(td, 'changelog.md')
log.write(cls.path)
with open(cls.path) as fd:
cls.log_text = fd.read()
cls.log_segments = [line for line in cls.log_text.split('\n\n') if line]
def test_header(self):
"""Test the header information at the top of the file"""
self.assertEqual(log_segments[0:2], self.log_segments[0:2])
def test_links(self):
"""Test the links at the end of the file"""
self.assertEqual(
{'[fullversion]: http://endless.horse', '[id]: http://www.koalastothemax.com'},
set(self.log_segments[16:18]))
def test_versions(self):
"""Test the version headers"""
self.assertEqual('## [Tests]', self.log_segments[2])
self.assertEqual('## [FullVersion] - 1969-07-20 [TAG1] [TAG2]', self.log_segments[14])
self.assertEqual('## Long Version Name', self.log_segments[15])
def test_entries(self):
"""Test the change entries"""
self.assertEqual(log_segments[3], self.log_segments[3])
self.assertEqual('### Bullet Points', self.log_segments[4])
self.assertEqual(log_segments[5], self.log_segments[5])
self.assertEqual('### Blocks', self.log_segments[6])
self.assertEqual(log_segments[7:14], self.log_segments[7:14])
if __name__ == '__main__':
unittest.main()

194
tests/test_cli.py Normal file
View File

@ -0,0 +1,194 @@
import unittest
import os.path
import git
import yaclog
from yaclog.cli.__main__ import cli
from click.testing import CliRunner
def check_result(runner, result, expected=0):
runner.assertEqual(result.exit_code, expected, f'output: {result.output}\ntraceback: {result.exc_info}')
class TestCreation(unittest.TestCase):
def test_init(self):
"""Test creating and overwriting a changelog"""
runner = CliRunner()
location = 'CHANGELOG.md'
err_str = 'THIS FILE WILL BE OVERWRITTEN'
with runner.isolated_filesystem():
result = runner.invoke(cli, ['init'])
check_result(self, result)
self.assertTrue(os.path.exists(os.path.abspath(location)), 'yaclog init did not create a file')
self.assertIn(location, result.output, "yaclog init did not echo the file's correct location")
with open(location, 'w') as fp:
fp.write(err_str)
result = runner.invoke(cli, ['init'], input='y\n')
check_result(self, result)
self.assertTrue(os.path.exists(os.path.abspath(location)), 'file no longer exists after overwrite')
self.assertIn(location, result.output, "yaclog init did not echo the file's correct location")
with open(location, 'r') as fp:
self.assertNotEqual(fp.read(), err_str, 'file was not overwritten')
def test_init_path(self):
"""Test creating a changelog with a non-default filename"""
runner = CliRunner()
location = 'A different file.md'
with runner.isolated_filesystem():
result = runner.invoke(cli, ['--path', location, 'init'])
check_result(self, result)
self.assertTrue(os.path.exists(os.path.abspath(location)), 'yaclog init did not create a file')
self.assertIn(location, result.output, "yaclog init did not echo the file's correct location")
def test_does_not_exist(self):
"""Test if an error is thrown when the file does not exist"""
runner = CliRunner()
with runner.isolated_filesystem():
result = runner.invoke(cli, ['show'])
check_result(self, result, 1)
self.assertIn('does not exist', result.output)
class TestTagging(unittest.TestCase):
def test_tag_addition(self):
"""Test adding tags to versions"""
runner = CliRunner()
location = 'CHANGELOG.md'
with runner.isolated_filesystem():
in_log = yaclog.Changelog(location)
in_log.versions = [yaclog.changelog.VersionEntry(), yaclog.changelog.VersionEntry()]
in_log.versions[0].name = '1.0.0'
in_log.versions[1].name = '0.9.0'
in_log.write()
result = runner.invoke(cli, ['tag', 'tag1'])
check_result(self, result)
result = runner.invoke(cli, ['tag', 'tag2', '0.9.0'])
check_result(self, result)
out_log = yaclog.read(location)
self.assertEqual(out_log.versions[0].tags, ['TAG1'])
self.assertEqual(out_log.versions[1].tags, ['TAG2'])
result = runner.invoke(cli, ['tag', 'tag3', '0.8.0'])
check_result(self, result, 2)
self.assertIn('not found in changelog', result.output)
def test_tag_deletion(self):
"""Test deleting tags from versions"""
runner = CliRunner()
location = 'CHANGELOG.md'
with runner.isolated_filesystem():
in_log = yaclog.Changelog(location)
in_log.versions = [None, None]
in_log.versions = [yaclog.changelog.VersionEntry(), yaclog.changelog.VersionEntry()]
in_log.versions[0].name = '1.0.0'
in_log.versions[0].tags = ['TAG1']
in_log.versions[1].name = '0.9.0'
in_log.versions[1].tags = ['TAG2']
in_log.write()
result = runner.invoke(cli, ['tag', '-d', 'tag2', '0.8.0'])
check_result(self, result, 2)
self.assertIn('not found in changelog', result.output)
result = runner.invoke(cli, ['tag', '-d', 'tag3', '0.9.0'])
check_result(self, result, 2)
self.assertIn('not found in version', result.output)
result = runner.invoke(cli, ['tag', '-d', 'tag1'])
self.assertNotIn('not found in version', result.output)
check_result(self, result)
out_log = yaclog.read(location)
self.assertEqual(out_log.versions[0].tags, [])
self.assertEqual(out_log.versions[1].tags, ['TAG2'])
result = runner.invoke(cli, ['tag', '-d', 'tag2', '0.9.0'])
self.assertNotIn('not found in version', result.output)
check_result(self, result)
out_log = yaclog.read(location)
self.assertEqual(out_log.versions[0].tags, [])
self.assertEqual(out_log.versions[1].tags, [])
class TestRelease(unittest.TestCase):
def test_increment(self):
"""Test version incrementing on release"""
runner = CliRunner()
location = 'CHANGELOG.md'
with runner.isolated_filesystem():
runner.invoke(cli, ['init']) # create the changelog
runner.invoke(cli, ['entry', '-b', 'entry number 1'])
result = runner.invoke(cli, ['release', '--version', '1.0.0'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '1.0.0')
self.assertIn('Unreleased', result.output)
self.assertIn('1.0.0', result.output)
runner.invoke(cli, ['entry', '-b', 'entry number 2'])
result = runner.invoke(cli, ['release', '-p'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '1.0.1')
self.assertIn('Unreleased', result.output)
self.assertIn('1.0.1', result.output)
runner.invoke(cli, ['entry', '-b', 'entry number 3'])
result = runner.invoke(cli, ['release', '-m'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '1.1.0')
self.assertIn('Unreleased', result.output)
self.assertIn('1.1.0', result.output)
runner.invoke(cli, ['entry', '-b', 'entry number 4'])
result = runner.invoke(cli, ['release', '-M'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '2.0.0')
self.assertIn('Unreleased', result.output)
self.assertIn('2.0.0', result.output)
def test_commit(self):
"""Test committing and tagging releases"""
runner = CliRunner()
with runner.isolated_filesystem():
repo = git.Repo.init(os.path.join(os.curdir, 'testing'))
os.chdir('testing')
repo.index.commit('initial commit')
with repo.config_writer() as cw:
cw.set_value('user', 'email', 'unit-tester@example.com')
cw.set_value('user', 'name', 'unit-tester')
runner.invoke(cli, ['init']) # create the changelog
runner.invoke(cli, ['entry', '-b', 'entry number 1'])
result = runner.invoke(cli, ['release', '--version', '1.0.0', '-c'], input='y\n')
check_result(self, result)
self.assertIn('Created commit', result.output)
self.assertIn('Created tag', result.output)
self.assertIn(repo.head.commit.hexsha[0:7], result.output)
self.assertEqual(repo.tags[0].name, '1.0.0')
if __name__ == '__main__':
unittest.main()

View File

@ -2,7 +2,7 @@ import os
from yaclog.changelog import Changelog
def read(path: os.PathLike):
def read(path):
"""
Create a new Changelog object from the given path
:param path: a path to a markdown changelog file

View File

@ -17,7 +17,6 @@
import datetime
import os
import re
import string
from typing import List, Tuple, Optional
bullets = '+-*'
@ -30,6 +29,8 @@ 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):
@ -47,7 +48,7 @@ def _join_markdown(segments: List[str]) -> str:
text: List[str] = []
last_bullet = False
for segment in segments:
is_bullet = bullet_regex.match(segment) and '\n' not in segment
is_bullet = bullet_regex.match(segment)
if not is_bullet or not last_bullet:
text.append('')
@ -62,18 +63,38 @@ def _join_markdown(segments: List[str]) -> str:
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 = None
self.link: Optional[str] = None
self.link_id: Optional[str] = None
self.line_no: int = -1
def __str__(self) -> str:
if self.link:
segments = [f'[{self.name}]']
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 = [self.name]
segments.append(self.name)
if self.date or len(self.tags) > 0:
segments.append('-')
@ -85,28 +106,36 @@ class VersionEntry:
return ' '.join(segments)
def text(self, md: bool = True) -> str:
return self.header(md) + '\n\n' + self.body(md)
def __str__(self) -> str:
return self.header(False)
class Changelog:
def __init__(self, path: os.PathLike):
self.path = path
self.header = ''
self.versions = []
def __init__(self, path=None):
self.path: os.PathLike = path
self.header: str = ''
self.versions: List[VersionEntry] = []
self.links = {}
if not path or not os.path.exists(path):
self.header = default_header
return
# Read file
with open(path, 'r') as fp:
self.lines = fp.readlines()
lines = fp.readlines()
section = ''
in_block = False
in_code = False
self.links = {}
links = {}
segments: List[Tuple[int, List[str], str]] = []
header_segments = []
for line_no, line in enumerate(self.lines):
for line_no, line in enumerate(lines):
if in_code:
# this is the contents of a code block
segments[-1][1].append(line)
@ -143,7 +172,7 @@ class Changelog:
elif match := linkid_regex.match(line):
# this is a link definition in the form '[id]: link', so add it to the link table
links[match['link_id'].lower()] = match['link']
self.links[match['link_id'].lower()] = match['link']
elif line.isspace():
# skip empty lines
@ -174,7 +203,7 @@ class Changelog:
version.name = slug
version.line_no = segment[0]
tags = []
date = []
date = None
for word in split[1:]:
if match := re.match(r'\d{4}-\d{2}-\d{2}', word):
@ -216,43 +245,34 @@ class Changelog:
if match := re.fullmatch(r'\[(.*)]', version.name):
# ref-matched link
link_id = match[1].lower()
if link_id in links:
version.link = links.pop(link_id)
if link_id in self.links:
version.link = self.links[link_id]
version.link_id = None
version.name = match[1]
elif version.link_id in links:
elif version.link_id in self.links:
# id-matched link
version.link = links.pop(version.link_id)
version.link = self.links[version.link_id]
# strip whitespace from header
self.header = _join_markdown(header_segments)
self.links = links
def write(self, path: os.PathLike = None):
if path is None:
path = self.path
v_links = {}
v_links.update(self.links)
segments = [self.header]
v_links = {**self.links}
for version in self.versions:
if version.link:
v_links[version.name.lower()] = version.link
segments.append(version.text())
segments += [f'[{link_id}]: {link}' for link_id, link in v_links.items()]
text = _join_markdown(segments)
with open(path, 'w') as fp:
fp.write(self.header)
fp.write('\n\n')
for version in self.versions:
fp.write(f'## {version}\n\n')
if version.link:
v_links[version.name] = version.link
for section in version.sections:
if section:
fp.write(f'### {section}\n\n')
if len(version.sections[section]) > 0:
fp.write(_join_markdown(version.sections[section]))
fp.write('\n\n')
for link_id, link in v_links.items():
fp.write(f'[{link_id.lower()}]: {link}\n')
fp.write(text)

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

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

@ -0,0 +1,244 @@
# 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:
matches = [v for v in obj.versions if v.name.lower() == 'unreleased']
if len(matches) == 0:
version = yaclog.changelog.VersionEntry()
obj.versions.insert(0, version)
else:
version = matches[0]
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(short_help='Release versions.')
@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"""
matches = [v for v in obj.versions if v.name.lower() != 'unreleased']
if len(matches) == 0:
version = '0.0.0'
else:
version = matches[0].name
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, 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 '
tracked = len(repo.index.diff(repo.head.commit))
tracked_warning = 'Create tag'
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 included.',
fg='red', bold=True)
if tracked > 0:
tracked_warning = 'Commit and create tag'
click.confirm(f'{tracked_warning} for {version_type}version {cur_version.name}?{untracked_warning}',
abort=True)
if tracked > 0:
commit = repo.index.commit(f'Version {cur_version.name}\n\n{cur_version.body()}')
print(f'Created commit {repo.head.commit.hexsha[0:7]}')
else:
commit = repo.head.commit
repo_tag = repo.create_tag(cur_version.name, ref=commit, message=cur_version.body(False))
print(f'Created tag "{repo_tag.name}".')
if __name__ == '__main__':
cli()

View File

@ -0,0 +1,83 @@
# 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,) + ((0,) * len(release[1:]))
pre = post = dev = None
elif mode == '+m':
release = (release[0], release[1] + 1) + ((0,) * len(release[2:]))
pre = post = dev = None
elif mode == '+p':
release = (release[0], release[1], release[2] + 1) + ((0,) * len(release[3:]))
pre = post = dev = None
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)