71 Commits
0.2.0 ... 1.0.1

Author SHA1 Message Date
04a9c712f9 update changelog 2021-05-09 19:42:01 -07:00
d35b7fee83 Fixed broken header in new changelogs 2021-05-09 19:39:48 -07:00
38560702f4 Path metavar 2021-05-08 00:00:41 -07:00
2d1cc4ede4 Metavar capitalization 2021-05-07 23:59:44 -07:00
66bc8509e3 Version 1.0.0
### Changed

- API changes:
  - `header` attribute renamed to `preamble` to avoid confusion.
- improved version header parsing to be more robust and handle multi-word version names.
- improved version number incrementing in `release`.
  - can now handle other text surrounding a pep440-compliant version number, which will not be modified
  - can now handle pre-releases correctly. The version to increment is the most recent version in the log with a valid pep440 version number in it. 
  - Release increment and prerelease increments can be mixed, allowing e.g: `yaclog release -mr` to create a release candidate with in incremented minor version number.
- `release` base version is now an argument instead of an option, for consistency with other commands.

### Removed

- `entry` with multiple `-b` options no longer add sub bullet points, instead adding each bullet as its own line.

### Added

- Terminal output has color to distinguish version names/headers, sections, and git information.
- Extra newlines are added between versions to improve readability of the raw markdown file.
2021-05-07 14:52:28 -07:00
66baa96f44 Reflect last change in documentation 2021-05-07 14:29:49 -07:00
3676811f85 Change release version option to an argument 2021-05-07 13:57:01 -07:00
14430e6cd2 Add epub builds 2021-05-07 01:16:20 -07:00
affb8f8627 Configure rtd 2021-05-07 01:12:30 -07:00
dacacdc496 Document a change I made 2021-05-06 23:13:09 -07:00
a925a4e420 walk back last change slightly
preamble now contains the title, to allow for representing Jekyll front matter or any other information above the title
2021-05-06 23:09:34 -07:00
a230968736 header attribute on the changelog class has been split into title and preamble 2021-05-06 22:23:35 -07:00
4b11ab839d Add explanation of changelog file format 2021-05-05 23:03:14 -07:00
8db70fb75a Add doc building dependencies 2021-05-05 02:45:17 -07:00
ac3fb0ca2b Handbook section with usage information 2021-05-05 02:37:20 -07:00
36ab0930fe Add API documentation 2021-05-04 21:16:02 -07:00
f085f318b3 Add API documentation 2021-05-04 21:01:30 -07:00
5cc815d8b6 Styling and layout for docs 2021-05-04 19:39:56 -07:00
a8fab8149c Initialize docs 2021-05-04 13:32:48 -07:00
0d4ef5b733 Use click.echo to handle removing colors when unwanted 2021-05-03 20:58:09 -07:00
2317e04330 fix release 2021-05-03 20:57:02 -07:00
7747d8a328 show command now uses color 2021-05-03 20:50:04 -07:00
000228a836 entry with multiple -b options no longer add sub bullet points 2021-04-30 21:51:04 -07:00
7c638ad5fe Improved release version handling 2021-04-30 21:19:04 -07:00
8394fbfd94 Improve version number incrementing by rewriting version module 2021-04-30 01:52:06 -07:00
17a17fea41 Version entries now have line numbers again 2021-04-29 23:53:00 -07:00
2be155c1c0 Automatically create github releases
Self hosting!
2021-04-29 23:34:01 -07:00
c43fc25eae use API in command line tools 2021-04-29 22:59:09 -07:00
6734cd3b32 Fix imports AGAIN 2021-04-29 20:38:27 -07:00
d2b4d8addd Version header parsing unit tests 2021-04-29 20:35:14 -07:00
101a47eabb Improved version header parsing 2021-04-29 19:43:18 -07:00
e79b9f07db Fix more imports
I'll deuglify the code in a bit dont worry
2021-04-28 01:07:29 -07:00
21cb103cba Fix imports
I'll deuglify the code in a bit dont worry
2021-04-28 01:02:22 -07:00
5c379e2635 Version accessors on Changelog 2021-04-28 00:58:25 -07:00
c09df3a770 Refactor changelog class and make tokenizer seperate 2021-04-27 18:56:53 -07:00
1676b28f03 changelog module documentation 2021-04-26 22:28:09 -07:00
ae4a47d3f6 Changelog tweaks 2021-04-26 19:57:25 -07:00
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
39 changed files with 1978 additions and 197 deletions

View File

@ -1,15 +1,46 @@
# This workflow will upload a Python Package using Twine when a release is created
# 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 ]
name: build
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
@ -22,7 +53,8 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
python -m pip install setuptools wheel twine
python -m pip install . # Self hosting!
- name: Install pypa/build
run: python -m pip install build --user
@ -30,7 +62,21 @@ jobs:
- name: Build a binary wheel and source tarball
run: python -m build --sdist --wheel --outdir dist/
- name: Get version name and body
run: |
echo "VERSION_TILE=Version $(yaclog show -n)" >> $GITHUB_ENV
echo "$(yaclog show -mb)" >> RELEASE.md
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
- name: Publish to Github
uses: softprops/action-gh-release@v1
with:
files: dist/*
name: ${{ env.VERSION_TITLE }}
body_path: RELEASE.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

24
.readthedocs.yaml Normal file
View File

@ -0,0 +1,24 @@
# .readthedocs.yaml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: docs/conf.py
# Optionally build your docs in additional formats such as PDF
formats:
- pdf
- epub
# Optionally set the version of Python and requirements required to build your docs
python:
version: 3.8
install:
- method: pip
path: .
extra_requirements:
- docs

View File

@ -1,7 +1,83 @@
# Changelog
All notable changes to this project will be documented in this file
## 0.2.0
## 1.0.1 - 2021-05-10
### Fixed
- Fixed broken header in new changelogs
- Improved consistency in command documentation metavars
## 1.0.0 - 2021-05-07
### Changed
- API changes:
- `header` attribute renamed to `preamble` to avoid confusion.
- improved version header parsing to be more robust and handle multi-word version names.
- improved version number incrementing in `release`.
- can now handle other text surrounding a pep440-compliant version number, which will not be modified
- can now handle pre-releases correctly. The version to increment is the most recent version in the log with a valid pep440 version number in it.
- Release increment and prerelease increments can be mixed, allowing e.g: `yaclog release -mr` to create a release candidate with in incremented minor version number.
- `release` base version is now an argument instead of an option, for consistency with other commands.
### Removed
- `entry` with multiple `-b` options no longer add sub bullet points, instead adding each bullet as its own line.
### Added
- Terminal output has color to distinguish version names/headers, sections, and git information.
- Extra newlines are added between versions to improve readability of the raw markdown file.
## 0.3.3 - 2021-04-27
### Added
- Unit tests in the `tests` folder
### Fixed
- 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
@ -11,9 +87,9 @@ 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 code blocks.
- Parser can now handle setext-style headers and H2s not conforming to the schema.
## 0.1.0 - 2021-04-16
@ -21,4 +97,4 @@ First release
### Added
- `yaclog.read()` method to parse changelog files
- `yaclog.read()` method to parse changelog files

View File

@ -1,6 +1,78 @@
# Yaclog
# Yaclog
[![Documentation Status](https://readthedocs.org/projects/yaclog/badge/?version=latest)](https://yaclog.readthedocs.io/en/latest/?badge=latest)
[![Build Status](https://github.com/drewcassidy/yaclog/actions/workflows/python-publish.yml/badge.svg)](https://github.com/drewcassidy/yaclog/actions/workflows/python-publish.yml)
[![PyPI version](https://badge.fury.io/py/yaclog.svg)](https://badge.fury.io/py/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*
*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 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".
```

20
docs/Makefile Normal file
View File

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

22
docs/_static/css/custom.css vendored Normal file
View File

@ -0,0 +1,22 @@
/*
* 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/>.
*/
.rst-content .toctree-wrapper:not(:last-child) ul {
/*margin-bottom: 0;*/
/* make adjacent toctrees appear to merge */
}

BIN
docs/_static/icon-128.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
docs/_static/icon-16.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

BIN
docs/_static/icon-256.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
docs/_static/icon-32.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

BIN
docs/_static/icon-48.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 B

BIN
docs/_static/icon-64.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 764 B

10
docs/_templates/layout.html vendored Normal file
View File

@ -0,0 +1,10 @@
{% extends "!layout.html" %}
{% block extrahead %}
<link rel="icon" type="image/png" sizes="16x16" href="/_static/icon-16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/_static/icon-32.png">
<link rel="icon" type="image/png" sizes="48x48" href="/_static/icon-48.png">
<link rel="icon" type="image/png" sizes="64x64" href="/_static/icon-64.png">
<link rel="icon" type="image/png" sizes="128x128" href="/_static/icon-128.png">
<link rel="icon" type="image/png" sizes="256x256" href="/_static/icon-256.png">
{{ super() }}
{% endblock %}

2
docs/changelog.md Normal file
View File

@ -0,0 +1,2 @@
```{include} ../CHANGELOG.md
```

87
docs/conf.py Normal file
View File

@ -0,0 +1,87 @@
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
from pkg_resources import get_distribution
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import sys
sys.path.insert(0, os.path.abspath('..'))
# -- Project information -----------------------------------------------------
project = 'Yaclog'
copyright = '2021, Andrew Cassidy'
author = 'Andrew Cassidy'
release = get_distribution('yaclog').version
version = '.'.join(release.split('.')[:3])
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'myst_parser',
'sphinx_click',
'sphinx_rtd_theme',
'sphinx.ext.autodoc',
'sphinx.ext.intersphinx',
]
myst_heading_anchors = 2
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
default_role = 'py:obj'
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'sphinx_rtd_theme'
html_logo = 'docs_logo.png'
html_favicon = 'favicon.ico'
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
html_css_files = ['css/custom.css']
# -- Options for Autodoc -----------------------------------------------------
add_module_names = False
autodoc_docstring_signature = True
autoclass_content = 'both'
autodoc_default_options = {
'member-order': 'bysource',
'undoc-members': True,
}
# -- Options for Intersphinx -------------------------------------------------
# This config value contains the locations and names of other projects that
# should be linked to in this documentation.
intersphinx_mapping = {
'python': ('https://docs.python.org/3', None),
'packaging': ('https://packaging.pypa.io/en/latest/', None),
}

BIN
docs/docs_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
docs/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -0,0 +1,78 @@
# Changelog Files
Yaclog works on Markdown changelog files, using a machine-readable format based on what is proposed by [Keep a Changelog](https://keepachangelog.com). Changelog files can be created using the {command}`yaclog init` command.
## Preamble
The preamble is the text at the top of the file before any version information. It can contain the title, an explanation of the file's purpose, as well as any general machine-readable information you may want to include for use with other tools. Yaclog does not provide any ways to manipulate the front matter from the command line due to its open-ended nature.
## Versions
Version information begins with a header, which is an H2 containing the version's name, as well as optionally the date in ISO-8601 form, and any tag metadata. Some example version headers:
```markdown
## 1.0.0
```
```markdown
## 3.2.0 "Columbia" - 1981-07-20
```
```markdown
## Version 8.0.0rc1 1988-11-15 [PRERELEASE]
```
Version names should (but are not required to) include a version number in {pep}`440` format, which is a superset of [semantic versioning](https://semver.org). Versions can be incremented or renamed using the {command}`yaclog release` command.
## Entries
Entries are individual changes made since the previous version. They can be paragraphs, list items, or any markdown block element. Entries can be either uncategorized, or organized into sections using H3 headers. Entries can be added using the {command}`yaclog entry` command.
## Tags
Tags are additional metadata added to a version header, denoted by all-caps text surrounded in square brackets. Tags can be used to mark that a version is a prerelease, that it has been yanked for security reasons, or for marking compatibility with some other piece of software. Tags can be added and removed using the {command}`yaclog tag` command.
## Example
```markdown
# Changelog
All notable changes to this project will be documented in this file.
## 0.13.0 "Aquarius" - 1970-04-11 [YANKED]
Yanked due to issues with oxygen tanks, currently investigating
### Added
- Extra propellant in preparation for future versions
### Changed
- Replaced Ken Mattingly
- Stirred oxygen tanks
## 0.12.0 "Intrepid" - 1969-11-14
### Added
- New ALSEP package for surface science
- Color cameras
- Surface rendezvous with Surveyor 3
### Fixed
- 1201/1202 alarm distracting crew during landing
### Known Issues
- Lightning strike during launch: No effect on performance
## 0.11.0 "Eagle" - 1969-07-20
Initial stable release
### Changed
- Fully fueled lander to allow landing on the lunar surface
```

View File

@ -0,0 +1,7 @@
# Command Reference
```{eval-rst}
.. click:: yaclog.cli.__main__:cli
:prog: yaclog
:nested: full
```

View File

@ -0,0 +1,50 @@
# Getting Started
## Installation
Install and update using [pip](https://pip.pypa.io/en/stable/quickstart/):
```shell
$ pip install -U yaclog
```
## Usage
For detailed documentation on the {command}`yaclog` command and its subcommands see the {doc}`commands`.
### Example workflow
Create a new changelog in the current directory:
```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 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".
```

11
docs/handbook/index.md Normal file
View File

@ -0,0 +1,11 @@
# Handbook
```{toctree}
---
maxdepth: 3
---
getting_started
changelog_files
commands
```

34
docs/index.md Normal file
View File

@ -0,0 +1,34 @@
# Yaclog: Yet Another Commandline Changelog Tool
[![Documentation Status](https://readthedocs.org/projects/yaclog/badge/?version=latest)](https://yaclog.readthedocs.io/en/latest/?badge=latest)
[![Build Status](https://github.com/drewcassidy/yaclog/actions/workflows/python-publish.yml/badge.svg)](https://github.com/drewcassidy/yaclog/actions/workflows/python-publish.yml)
[![PyPI version](https://badge.fury.io/py/yaclog.svg)](https://badge.fury.io/py/yaclog)
Yaclog is a python library and command line tool to make it easier to keep track of changes to your projects.
It includes commands for appending new changes to a markdown changelog file, as well as releasing new versions
for deployment via git tags.
```{toctree}
---
maxdepth: 2
caption: Contents
---
handbook/index
reference/index
```
```{toctree}
---
maxdepth: 1
---
Changelog <changelog>
License <license>
```
## Indices and tables
* {ref}`genindex`
* {ref}`modindex`
* {ref}`search`

2
docs/license.md Normal file
View File

@ -0,0 +1,2 @@
```{include} ../LICENSE.md
```

35
docs/make.bat Normal file
View File

@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

View File

@ -0,0 +1,5 @@
Changelog Module
================
.. automodule:: yaclog.changelog
:members:

9
docs/reference/index.rst Normal file
View File

@ -0,0 +1,9 @@
API Reference
=============
.. toctree::
:maxdepth: 2
changelog.rst
markdown.rst
version.rst

View File

@ -0,0 +1,5 @@
Markdown Module
===============
.. automodule:: yaclog.markdown
:members:

View File

@ -0,0 +1,5 @@
Version Module
==============
.. automodule:: yaclog.version
:members:

View File

@ -10,7 +10,7 @@ long_description_content_type = text/markdown
keywords = changelog, commandline, markdown
classifiers =
Development Status :: 4 - Beta
Development Status :: 5 - Production/Stable
Intended Audience :: Developers
License :: OSI Approved :: GNU Affero General Public License v3
Operating System :: OS Independent
@ -23,10 +23,28 @@ classifiers =
Topic :: Utilities
project_urls =
Source Code = https://github.com/drewcassidy/yaclog
Source = https://github.com/drewcassidy/yaclog
Changelog = https://github.com/drewcassidy/yaclog/blob/main/CHANGELOG.md
Docs = https://yaclog.readthedocs.io/
[options]
install_requires = Click; GitPython
install_requires =
Click ~= 7.0
GitPython >= 3
packaging >= 20
python_requires = >= 3.8
packages = find:
[options.extras_require]
docs =
Sphinx >= 3.5
sphinx-click >= 2.7
sphinx-rtd-theme
myst-parser >= 0.14
[options.entry_points]
console_scripts =
yaclog = yaclog.cli.__main__:cli
[options.packages.find]
exclude = tests.*

0
tests/__init__.py Normal file
View File

81
tests/common.py Normal file
View File

@ -0,0 +1,81 @@
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.preamble = '# Changelog\n\n' \
'This 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'

166
tests/test_changelog.py Normal file
View File

@ -0,0 +1,166 @@
import datetime
import os.path
import tempfile
import unittest
import yaclog
from tests.common import log, log_segments, log_text
from yaclog.changelog import VersionEntry
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_preamble(self):
"""Test the preamble at the top of the file"""
self.assertEqual(log.preamble, self.log.preamble)
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.lstrip('\n') for line in cls.log_text.split('\n\n') if line]
def test_preamble(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])
class TestVersionEntry(unittest.TestCase):
def test_header_name(self):
"""Test reading version names from headers"""
headers = {
'short': ('## Test', 'Test'),
'with dash': ('## Test - ', 'Test'),
'multi word': ('## Very long version name 1.0.0', 'Very long version name 1.0.0'),
'with brackets': ('## [Test]', '[Test]'),
}
for c, t in headers.items():
h = t[0]
with self.subTest(c, h=h):
version = VersionEntry.from_header(h)
self.assertEqual(version.name, t[1])
self.assertEqual(version.tags, [])
self.assertIsNone(version.date)
self.assertIsNone(version.link)
self.assertIsNone(version.link_id)
def test_header_tags(self):
"""Test reading version tags from headers"""
headers = {
'no dash': ('## Test [Foo] [Bar]', 'Test', ['FOO', 'BAR']),
'with dash': ('## Test - [Foo] [Bar]', 'Test', ['FOO', 'BAR']),
'with brackets': ('## [Test] [Foo] [Bar]', '[Test]', ['FOO', 'BAR']),
'with brackets & dash': ('## [Test] - [Foo] [Bar]', '[Test]', ['FOO', 'BAR']),
}
for c, t in headers.items():
h = t[0]
with self.subTest(c, h=h):
version = VersionEntry.from_header(h)
self.assertEqual(version.name, t[1])
self.assertEqual(version.tags, t[2])
self.assertIsNone(version.date)
self.assertIsNone(version.link)
self.assertIsNone(version.link_id)
def test_header_date(self):
"""Test reading version dates from headers"""
headers = {
'no dash': ('## Test 1961-04-12', 'Test',
datetime.date.fromisoformat('1961-04-12'), []),
'with dash': ('## Test 1969-07-20', 'Test',
datetime.date.fromisoformat('1969-07-20'), []),
'two dates': ('## 1981-07-20 1988-11-15', '1981-07-20',
datetime.date.fromisoformat('1988-11-15'), []),
'single date': ('## 2020-05-30', '2020-05-30', None, []),
'with tags': ('## 1.0.0 - 2021-04-19 [Foo] [Bar]', '1.0.0',
datetime.date.fromisoformat('2021-04-19'), ['FOO', 'BAR']),
}
for c, t in headers.items():
h = t[0]
with self.subTest(c, h=h):
version = VersionEntry.from_header(h)
self.assertEqual(version.name, t[1])
self.assertEqual(version.date, t[2])
self.assertEqual(version.tags, t[3])
self.assertIsNone(version.link)
self.assertIsNone(version.link_id)
def test_header_noncompliant(self):
"""Test reading version that dont fit the schema, and should just be read as literals"""
headers = {
'no space between tags': 'Test [Foo][Bar]',
'text at end': 'Test [Foo] [Bar] Test',
'invalid date': 'Test - 9999-99-99',
}
for c, h in headers.items():
with self.subTest(c, h=h):
version = VersionEntry.from_header('## ' + h)
self.assertEqual(version.name, h)
self.assertEqual(version.tags, [])
self.assertIsNone(version.date)
self.assertIsNone(version.link)
self.assertIsNone(version.link_id)
if __name__ == '__main__':
unittest.main()

222
tests/test_cli.py Normal file
View File

@ -0,0 +1,222 @@
import os.path
import unittest
import traceback
import git
from click.testing import CliRunner
import yaclog.changelog
from yaclog.cli.__main__ import cli
def check_result(runner, result, success: bool = True):
runner.assertEqual((result.exit_code == 0), success,
f'\noutput: {result.output}\ntraceback: ' + ''.join(
traceback.format_exception(*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, 'r') as fp:
self.assertEqual('# Changelog\n', fp.readline())
self.assertEqual('\n', fp.readline())
self.assertEqual('All notable changes to this project will be documented in this file',
fp.readline().rstrip())
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, False)
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, False)
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, False)
result = runner.invoke(cli, ['tag', '-d', 'tag3', '0.9.0'])
check_result(self, result, False)
result = runner.invoke(cli, ['tag', '-d', 'tag1'])
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'])
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', '1.0.0'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '1.0.0')
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('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('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('2.0.0', result.output)
runner.invoke(cli, ['entry', '-b', 'entry number 5'])
result = runner.invoke(cli, ['release', '-Ma'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '3.0.0a1')
self.assertIn('3.0.0a1', result.output)
result = runner.invoke(cli, ['release', '-b'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '3.0.0b1')
self.assertIn('3.0.0b1', result.output)
result = runner.invoke(cli, ['release', '-r'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '3.0.0rc1')
self.assertIn('3.0.0rc1', result.output)
result = runner.invoke(cli, ['release', '-r'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '3.0.0rc2')
self.assertIn('3.0.0rc1', result.output)
result = runner.invoke(cli, ['release', '-f'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '3.0.0')
self.assertIn('3.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', '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

@ -1,3 +1,8 @@
"""
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.
"""
# yaclog: yet another changelog tool
# Copyright (c) 2021. Andrew Cassidy
#
@ -14,66 +19,158 @@
# 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
import datetime
import os
import re
import string
from typing import List, Tuple, Optional
from typing import List, Optional, Dict
bullets = '+-*'
brackets = '[]'
import click # only for styling
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>.*)')
def _strip_link(token):
if link_literal := re.fullmatch(r'\[(.*?)]\((.*?)\)', token):
# in the form [name](link)
return link_literal[1], link_literal[2], None
if link_id := re.fullmatch(r'\[(.*?)]\[(.*?)]', token):
# in the form [name][id] where id is hopefully linked somewhere else in the document
return link_id[1], None, link_id[2].lower()
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) and '\n' not in segment
if not is_bullet or not last_bullet:
text.append('')
text.append(segment)
last_bullet = is_bullet
return '\n'.join(text).strip()
import yaclog.markdown as markdown
import yaclog.version
class VersionEntry:
def __init__(self):
self.sections = {'': []}
self.name: str = ''
self.date: Optional[datetime.date] = None
self.tags: List[str] = []
self.link: str = ''
self.link_id: str = None
self.line_no: int = -1
"""
A serialized representation of a single version entry in a `Changelog`,
containing the changes made since the previous version
"""
def __str__(self) -> str:
if self.link:
segments = [f'[{self.name}]']
_header_regex = re.compile( # THE LANGUAGE OF THE GODS
r"##\s+(?P<name>.*?)(?:\s+-)?(?:\s+(?P<date>\d{4}-\d{2}-\d{2}))?(?P<tags>(?:\s+\[[^]]*?])*)\s*$")
_tag_regex = re.compile(r'\[(?P<tag>[^]]*?)]')
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):
"""
:param str name: The version's name
:param Optional[datetime.date] date: When the version was released
:param tags: The version's tags
:param link: The version's URL
:param link_id: The version's link ID
:param line_no: What line in the original file the version starts on
"""
self.name: str = name
"""The version's name"""
self.date: Optional[datetime.date] = date
"""When the version was released"""
self.tags: List[str] = tags if tags else []
"""The version's tags"""
self.link: Optional[str] = link
"""The version's URL"""
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
"""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"""
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."""
@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)
match = cls._header_regex.match(header)
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)
if match['tags']:
version.tags = [m['tag'].upper() for m in cls._tag_regex.finditer(match['tags'])]
return version
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)
def body(self, md: bool = True, color: bool = False) -> str:
"""
Get the version's body as a string
:param md: Format headings as markdown
:param color: Add color codes to the string for display in a terminal
:return: The formatted version body, without the version header
"""
segments = []
for section, entries in self.sections.items():
if section:
if md:
prefix = '### '
title = section.title()
else:
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:
segments += entries
return markdown.join(segments)
def header(self, md: bool = True, color: bool = False) -> str:
"""
Get the version's header as a string
:param md: Format headings as markdown
:param color: Add color codes to the string for display in a terminal
:return: The formatted version header
"""
if md:
prefix = '## '
else:
segments = [self.name]
prefix = ''
segments = []
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('-')
@ -83,176 +180,220 @@ class VersionEntry:
segments += [f'[{t.upper()}]' for t in self.tags]
return ' '.join(segments)
title = ' '.join(segments)
if color:
prefix = click.style(prefix, fg='bright_black')
title = click.style(title, fg='blue', bold=True)
return prefix + title
def text(self, md: bool = True, color: bool = False) -> str:
"""
Get the version's contents as a string
:param md: Format headings as markdown
:param color: Add color codes to the string for display in a terminal
:return: The formatted version header and body
"""
contents = self.header(md, color)
body = self.body(md, color)
if body:
contents += '\n\n' + body
return contents
@property
def released(self) -> bool:
"""Returns true if a PEP440 version number is present in the version name, and has no prerelease segments"""
return yaclog.version.is_release(self.name)
@property
def version(self):
"""Returns the PEP440 version number from the version name, or `None` if none is found"""
return yaclog.version.extract_version(self.name)[0]
def __str__(self) -> str:
return self.header(False)
class Changelog:
def __init__(self, path: os.PathLike):
self.path = path
self.header = ''
self.versions = []
"""
A serialized representation of a Markdown changelog made up of a preamble, multiple versions, and a link table.
"""
def __init__(self, path=None,
preamble: str = "# Changelog\n\nAll notable changes to this project will be documented in this file"):
"""
Contents will be automatically read from disk if the file exists
:param path: The changelog's path on disk.
:param str preamble: The changelog preamble to use if the file does not exist.
"""
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"""
self.links: Dict[str, str] = {}
"""Link definitions at the end of the changelog, as a dictionary of ``{id: url}``"""
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.
:param path: The changelog's path on disk. By default, :py:attr:`~Changelog.path` is used
"""
if not path:
# use the object path if none was provided
path = self.path
# Read file
with open(path, 'r') as fp:
self.lines = fp.readlines()
tokens, links = markdown.tokenize(fp.read())
section = ''
in_block = False
in_code = False
versions = []
preamble_segments = []
self.links = {}
for token in tokens:
text = '\n'.join(token.lines)
links = {}
segments: List[Tuple[int, List[str], str]] = []
header_segments = []
for line_no, line in enumerate(self.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
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
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':
if token.kind == 'h2':
# start of a version
slug = text.rstrip('-').strip('#').strip()
split = slug.split()
if '-' in split:
split.remove('-')
version = VersionEntry()
versions.append(VersionEntry.from_header(text, line_no=token.line_no))
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:
date = datetime.date.fromisoformat(match[0])
except ValueError:
break
elif match := re.match(r'^\[(?P<tag>\S*)]', word):
tags.append(match['tag'])
else:
break
else:
# matches the schema
version.name, version.link, version.link_id = _strip_link(split[0])
version.date = date
version.tags = tags
self.versions.append(version)
elif len(self.versions) == 0:
elif len(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)
# so its best to just add this line to the preamble
preamble_segments.append(text)
elif segment[2] == 'h3':
elif token.kind == 'h3':
# start of a version section
section = text.strip('#').strip()
if section not in self.versions[-1].sections.keys():
self.versions[-1].sections[section] = []
if section not in versions[-1].sections.keys():
versions[-1].sections[section] = []
else:
# change log entry
self.versions[-1].sections[section].append(text)
versions[-1].sections[section].append(text)
# handle links
for version in self.versions:
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.pop(link_id)
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.pop(version.link_id)
version.link = links[version.link_id]
# strip whitespace from header
self.header = _join_markdown(header_segments)
self.preamble = markdown.join(preamble_segments)
self.versions = versions
self.links = links
def write(self, path: os.PathLike = None):
def write(self, path=None) -> None:
"""
Write a changelog to a Markdown file.
:param path: The changelog's path on disk. By default, :py:attr:`~Changelog.path` is used.
"""
if path is None:
# use the object path if none was provided
path = self.path
v_links = {}
v_links.update(self.links)
segments = []
if self.preamble:
segments.append(self.preamble)
v_links = {**self.links}
for version in self.versions:
if version.link:
v_links[version.name.lower()] = version.link
segments.append(version.text() + '\n')
segments += [f'[{link_id}]: {link}' for link_id, link in v_links.items()]
text = markdown.join(segments)
with open(path, 'w') as fp:
fp.write(self.header)
fp.write('\n\n')
fp.write(text)
for version in self.versions:
fp.write(f'## {version}\n\n')
def add_version(self, index: int = 0, *args, **kwargs) -> VersionEntry:
"""
Add a new version to the changelog
if version.link:
v_links[version.name] = version.link
: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
for section in version.sections:
if section:
fp.write(f'### {section}\n\n')
def current_version(self, released: Optional[bool] = None, new_version: bool = False,
new_version_name: str = 'Unreleased') -> VersionEntry:
"""
Get the current version from the changelog
if len(version.sections[section]) > 0:
fp.write(_join_markdown(version.sections[section]))
fp.write('\n\n')
:param released: if the returned version should be a released version,
an unreleased version, or `None` to return the most recent
: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,
or `None` if ``new_version`` is disabled and none are found.
"""
for link_id, link in v_links.items():
fp.write(f'[{link_id.lower()}]: {link}\n')
# return the first version that matches `released`
for version in self.versions:
if version.released == released or released is None:
return version
# fallback if none are found
if new_version:
return self.add_version(name=new_version_name)
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')
def get_version(self, name: Optional[str] = None) -> VersionEntry:
"""
Get a version from the changelog by name
:param name: The name of the version to get, or `None` to return the most recent
:return: The first version with the selected name
"""
for version in self.versions:
if version.name == name or name is None:
return version
raise KeyError(f'Version {name} not found in changelog')
def __getitem__(self, item: str) -> VersionEntry:
return self.get_version(item)
def __len__(self) -> int:
return len(self.versions)

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

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

@ -0,0 +1,268 @@
# 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.path
import click
import git
import yaclog.version
from yaclog.changelog import Changelog
@click.group()
@click.option('--path', envvar='YACLOG_PATH', metavar='FILE', 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()
click.echo(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()
click.echo(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.option('--markdown/--txt', '-m/-t', default=False, help='Display as markdown or plain text.')
@click.option('--full', '-f', 'str_func', flag_value=lambda v, k: v.text(**k), default=True,
help='Show version header and body.')
@click.option('--name', '-n', 'str_func', flag_value=lambda v, k: v.name, help='Show only the version name')
@click.option('--body', '-b', 'str_func', flag_value=lambda v, k: v.body(**k), help='Show only the version body.')
@click.option('--header', '-h', 'str_func', flag_value=lambda v, k: v.preamble(**k),
help='Show only the version header.')
@click.argument('version_names', metavar='VERSIONS', type=str, nargs=-1)
@click.pass_obj
def show(obj: Changelog, all_versions, markdown, str_func, version_names):
"""
Show the changes for VERSIONS.
VERSIONS is a list of versions to print. If not given, the most recent version is used.
"""
try:
if all_versions:
versions = obj.versions
elif len(version_names) == 0:
versions = [obj.current_version()]
else:
versions = [obj.get_version(name) for name in version_names]
except KeyError as k:
raise click.BadArgumentUsage(k)
except ValueError as v:
raise click.ClickException(v)
kwargs = {'md': markdown, 'color': True}
for v in versions:
text = str_func(v, kwargs)
click.echo(text)
click.echo('\n')
@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()
try:
if version_name:
version = obj.get_version(version_name)
else:
version = obj.current_version()
except KeyError as k:
raise click.BadArgumentUsage(k)
except ValueError as v:
raise click.ClickException(v)
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', metavar='TEXT', multiple=True, type=str, help='Add a bullet point.')
@click.option('--paragraph', '-p', 'paragraphs', metavar='TEXT', multiple=True, type=str, help='Add a paragraph')
@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()
try:
if version_name:
version = obj.get_version(version_name)
else:
version = obj.current_version(released=False, new_version=True)
except KeyError as k:
raise click.BadArgumentUsage(k)
for p in paragraphs:
version.add_entry(p, section_name)
for b in bullets:
version.add_entry('- ' + b, section_name)
obj.write()
count = len(paragraphs) + len(bullets)
message = f"Created {count} {['entry', 'entries'][min(count - 1, 1)]}"
if section_name:
message += f" in section {click.style(section_name, fg='cyan')}"
if version.name.lower() != 'unreleased':
message += f" in version {click.style(version.name, fg='blue')}"
click.echo(message)
@cli.command(short_help='Release versions.')
@click.option('-M', '--major', 'rel_seg', flag_value=0, default=None, help='Increment major version number.')
@click.option('-m', '--minor', 'rel_seg', flag_value=1, help='Increment minor version number.')
@click.option('-p', '--patch', 'rel_seg', flag_value=2, help='Increment patch number.')
@click.option('-a', '--alpha', 'pre_seg', flag_value='a', default=None, help='Increment alpha version number.')
@click.option('-b', '--beta', 'pre_seg', flag_value='b', help='Increment beta version number.')
@click.option('-r', '--rc', 'pre_seg', flag_value='rc', help='Increment release candidate version number.')
@click.option('-f', '--full', 'pre_seg', flag_value='', help='Clear the prerelease value creating a full release.')
@click.option('-c', '--commit', is_flag=True,
help='Create a git commit tagged with the new version number. '
'If there are no changes to commit, the current commit will be tagged instead.')
@click.argument('version_name', metavar='VERSION', type=str, default=None, required=False)
@click.pass_obj
def release(obj: Changelog, version_name, rel_seg, pre_seg, commit):
"""
Release VERSION, or a version incremented from the last release.
VERSION is the name of the version to release. If VERSION is not provided but increment options are, then the most
recent valid PEP440 version number is used instead.
The most recent version in the log will be renamed (except by the --commit option) by using the VERSION as well as
any increment options. Increment options will always reset the later segments, and prerelease increments will clear
other kinds of prerelease.
"""
if rel_seg is None and pre_seg is None and not version_name and not commit:
click.echo('Nothing to release!')
raise click.Abort
cur_version = obj.current_version()
old_name = cur_version.name
if version_name:
new_name = version_name
else:
for v in obj.versions:
if v.version is not None:
new_name = v.name
break
else:
new_name = '0.0.0'
if rel_seg is not None or pre_seg is not None:
new_name = yaclog.version.increment_version(new_name, rel_seg, pre_seg)
if new_name != old_name:
if yaclog.version.is_release(old_name):
click.confirm(
f"Rename release version {click.style(old_name, fg='blue')} "
f"to {click.style(new_name, fg='blue')}?",
abort=True)
cur_version.name = new_name
cur_version.date = datetime.datetime.utcnow().date()
obj.write()
click.echo(f"Renamed {click.style(old_name, fg='blue')} to {click.style(new_name, fg='blue')}")
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)
tracked = len(repo.index.diff(repo.head.commit))
untracked = len(repo.index.diff(None))
message = [['Commit and create tag', 'Create tag'][min(tracked, 1)], 'for']
if not cur_version.released:
message.append('non-release')
message.append(f"version {click.style(new_name, fg='blue')}?")
if untracked > 0:
message.append(click.style(
f"You have {untracked} untracked file{'s'[:untracked]} that will not be included!",
fg='red', bold=True))
click.confirm(' '.join(message), abort=True)
if tracked > 0:
commit = repo.index.commit(f'Version {cur_version.name}\n\n{cur_version.body()}')
click.echo(f"Created commit {click.style(repo.head.commit.hexsha[0:7], fg='green')}")
else:
commit = repo.head.commit
repo_tag = repo.create_tag(cur_version.name, ref=commit, message=cur_version.body(False))
click.echo(f"Created tag {click.style(repo_tag.name, fg='green')}.")
if __name__ == '__main__':
cli()

165
yaclog/markdown.py Normal file
View File

@ -0,0 +1,165 @@
"""
Tools for parsing and manipulating markdown, including a very basic tokenizer.
"""
# 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 re
from typing import List
bullets = '+-*'
brackets = '[]'
code_regex = re.compile(r'^```')
header_regex = re.compile(r'^(?P<hashes>#+)\s+(?P<contents>[^#]+)(?:\s+#+)?$')
li_regex = re.compile(r'^[-+*] |\d+\. ')
numbered_regex = re.compile(r'^\d+\. ')
bullet_regex = re.compile(r'^[-+*] ')
link_id_regex = re.compile(r'^\[(?P<link_id>\S*)]:\s*(?P<link>.*)')
link_def_regex = re.compile(r'\[(?P<text>.*?)]\[(?P<link_id>.*?)]') # deferred link in the form [name][id]
link_lit_regex = re.compile(r'\[(?P<text>.*?)]\((?P<link>.*?)\)') # literal link in the form [name](url)
setext_h1_replace_regex = re.compile(r'(?<=\n)(?P<header>[^\n]+?)\n=+[ \t]*(?=\n)')
setext_h2_replace_regex = re.compile(r'(?<=\n)(?P<header>[^\n]+?)\n-+[ \t]*(?=\n)')
def strip_link(text):
"""
Parses and removes any links from the input string
:param text: An input string which may be a markdown link, either literal or an ID
:return: A tuple of (name, url, id). If the input is not a link, it is returned verbatim as the name.
"""
if link_lit := link_lit_regex.fullmatch(text):
# in the form [name](link)
return link_lit['text'], link_lit['link'], None
if link_def := link_def_regex.fullmatch(text):
# in the form [name][id] where id is hopefully linked somewhere else in the document
return link_def['text'], None, link_def['link_id'].lower()
return text, None, None
def join(segments: List[str]) -> str:
"""
Joins multiple lines of markdown by adding double newlines between them, or a single newline between list items
:param segments: A list of strings to join
:return: A joined markdown string
"""
text: List[str] = []
last_segment = ''
for segment in segments:
if bullet_regex.match(segment) and bullet_regex.match(last_segment):
pass
elif numbered_regex.match(segment) and numbered_regex.match(last_segment):
pass
else:
text.append('')
text.append(segment)
last_segment = segment
return '\n'.join(text).strip()
class Token:
"""A single tokenized block of markdown, consisting of one or more lines of text."""
def __init__(self, line_no: int, lines: List[str], kind: str):
self.line_no = line_no
"""Which line this block appears on in the original file"""
self.lines = lines
"""The lines of text making up this block"""
self.kind = kind
"""What kind of token this is. One of ``h[1-6]``, ``p``, ``li`` or ``code``"""
def __str__(self):
return f'{self.kind}: {self.lines}'
def tokenize(text: str):
"""
Tokenize a markdown string
The tokenizer is very basic, and only cares about the highest-level blocks
(Headers, top-level list items, links, code blocks, paragraphs).
:param text: input text to tokenize
:return: A list of tokens and a dictionary of links
"""
# convert setext-style headers
# The extra newline is to preserve line numbers
text = setext_h1_replace_regex.sub(r'# \g<header>\n', text)
text = setext_h2_replace_regex.sub(r'## \g<header>\n', text)
lines = text.split('\n')
tokens: List[Token] = []
links = {}
# state variables for parsing
block = None
for line_no, line in enumerate(lines):
if block == 'code':
# this is the contents of a code block
assert block == tokens[-1].kind, 'block state variable in invalid state!'
tokens[-1].lines.append(line)
if code_regex.match(line):
block = None
elif code_regex.match(line):
# this is the start of a code block
tokens.append(Token(line_no, [line], 'code'))
block = 'code'
elif li_regex.match(line):
# this is a list item
tokens.append(Token(line_no, [line], 'li'))
block = 'li'
elif match := header_regex.match(line):
# this is a header
kind = f'h{len(match["hashes"])}'
tokens.append(Token(line_no, [line], kind))
elif match := link_id_regex.match(line):
# this is a link definition in the form '[id]: link'
links[match['link_id'].lower()] = match['link']
block = None
elif not line or line.isspace():
# skip empty lines and reset block
block = None
elif block:
# this is a line to be added to a paragraph or list item
assert block == tokens[-1].kind, f'block state variable in invalid state! {block} != {tokens[-1].kind}'
tokens[-1].lines.append(line)
else:
# this is a new paragraph
tokens.append(Token(line_no, [line], 'p'))
block = 'p'
return tokens, links

120
yaclog/version.py Normal file
View File

@ -0,0 +1,120 @@
"""
Various helper functions for analyzing and manipulating :pep:`440` version numbers,
meant to augment the `packaging.version` module.
"""
# 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 re
from typing import Optional, Tuple
from packaging.version import Version, VERSION_PATTERN
version_regex = re.compile(VERSION_PATTERN, re.VERBOSE | re.IGNORECASE)
def extract_version(version_str: str) -> Tuple[Optional[Version], int, int]:
"""
Extracts a :pep:`440` version object from a string which may have other text
:param version_str: The input string to extract from
:return: A tuple of (version, start, end), where start and end are the span of the version in the original string
"""
match = version_regex.search(version_str)
if not match:
return None, -1, -1
return (Version(match[0]),) + match.span()
def increment_version(version_str: str, rel_seg: int = None, pre_seg: str = None) -> str:
"""
Increment the :pep:`440` version number in a string
:param version_str: The input string to manipulate
:param rel_seg: Which segment of the "release" value to increment, if any
:param pre_seg: Which kind of prerelease to use, if any. An empty string clears the prerelease field.
:return: The original string with the version number incremented
"""
v, *span = extract_version(version_str)
epoch = v.epoch
release = v.release
pre = v.pre
post = v.post
dev = v.dev
local = v.local
if rel_seg is not None:
if len(release) <= rel_seg:
release += (0,) * (1 + rel_seg - len(release))
release = release[0:rel_seg] + (release[rel_seg] + 1,) + (0,) * (len(release) - rel_seg - 1)
pre = None
if pre_seg is not None:
if pre_seg == '': # full release, clear prerelease field
pre = None
elif pre and pre[0] == pre_seg: # increment current prerelease type
pre = (pre_seg, pre[1] + 1)
else:
pre = (pre_seg, 1) # set prerelease field
new_v = join_version(epoch, release, pre, post, dev, local)
return version_str[0:span[0]] + new_v + version_str[span[1]:]
def join_version(epoch, release, pre, post, dev, local) -> str:
"""Join multiple segments of a :pep:`440` version"""
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)
def is_release(version_str: str) -> bool:
"""
Check if a version string is a release version
:param version_str: the input string to check
:return: True if the input contains a released :pep:`440` version,
or False if a prerelease version or no version is found
"""
v, *span = extract_version(version_str)
if v:
return not (v.is_devrelease or v.is_prerelease)
else:
return False