Compare commits
148 Commits
Author | SHA1 | Date | |
---|---|---|---|
b0d6288ae8 | |||
bc6d0e1886 | |||
76d2d55af8 | |||
fe3bd2f604 | |||
e701a33ce5 | |||
1f01bda2f4 | |||
629d931979 | |||
d2296fb926 | |||
80e35de136 | |||
d6da31b6ff | |||
d4f477a544 | |||
a392f09a51 | |||
683ccbf916 | |||
2a67f6edc7 | |||
c4be5d2420 | |||
30947769e1 | |||
6589a91d7e | |||
802633b9a7 | |||
47d4b595f8 | |||
fdf30bc14c | |||
72126c8dca | |||
0666f7f593 | |||
51e28e4ef0 | |||
a7cbacb687 | |||
3fa529a05c | |||
396960fae0 | |||
c661be05dc | |||
2f4124c0fc | |||
465b818ca2 | |||
32f20e677e | |||
8421d38164 | |||
c5030b6060 | |||
4ce3de25c7 | |||
6bc99c585b | |||
32abd7bc6b | |||
06df766f9f | |||
9b0ae90ee2 | |||
15e4d691f5 | |||
9a7e3da60d | |||
94f692e6c5 | |||
c7583388c6 | |||
fe9aa937d2 | |||
caa4560d6d | |||
03841ad07e | |||
aa2390312a | |||
07d76cdc09 | |||
8c79e158c8 | |||
21defeffce | |||
dccde1909b | |||
c25b780772 | |||
bf2e8f670f | |||
dc90731f3d | |||
5a6cb51d71 | |||
52fc36ab70 | |||
c696071b8f | |||
b0419dad80 | |||
2bfaa78053 | |||
524a1da4c6 | |||
acedf2b401 | |||
21b530c256 | |||
04a9c712f9 | |||
d35b7fee83 | |||
38560702f4 | |||
2d1cc4ede4 | |||
66bc8509e3 | |||
66baa96f44 | |||
3676811f85 | |||
14430e6cd2 | |||
affb8f8627 | |||
dacacdc496 | |||
a925a4e420 | |||
a230968736 | |||
4b11ab839d | |||
8db70fb75a | |||
ac3fb0ca2b | |||
36ab0930fe | |||
f085f318b3 | |||
5cc815d8b6 | |||
a8fab8149c | |||
0d4ef5b733 | |||
2317e04330 | |||
7747d8a328 | |||
000228a836 | |||
7c638ad5fe | |||
8394fbfd94 | |||
17a17fea41 | |||
2be155c1c0 | |||
c43fc25eae | |||
6734cd3b32 | |||
d2b4d8addd | |||
101a47eabb | |||
e79b9f07db | |||
21cb103cba | |||
5c379e2635 | |||
c09df3a770 | |||
1676b28f03 | |||
ae4a47d3f6 | |||
f56038d3c9 | |||
7b0eb4c78b | |||
32c09d82bd | |||
a443724d2b | |||
2ba414f121 | |||
140faccb69 | |||
08be02a49c | |||
3972786d82 | |||
ec9c785c3a | |||
daaf21ca8d | |||
0c11cf9ffc | |||
a13fa34c0c | |||
73a331f3e5 | |||
336421078c | |||
1c389038b4 | |||
53845bf20f | |||
ae681ae290 | |||
be78167b4b | |||
358942c858 | |||
82039ca074 | |||
a3ad83ec32 | |||
0bf63f1501 | |||
ebcb70c130 | |||
c614363b5f | |||
9ab1f74936 | |||
74b6448ee1 | |||
a82e455267 | |||
41974dc953 | |||
5205e27fc8 | |||
157f49839f | |||
6e75e15526 | |||
e9a8e63c27 | |||
983fff1471 | |||
39f6ede5f2 | |||
ddfd96193d | |||
dc5cc2ddd9 | |||
57542e228e | |||
13ddc5a1f9 | |||
98c21e4078 | |||
fb35ad3b29 | |||
849438a5f5 | |||
fa97e9154b | |||
f6f7a8b500 | |||
7b694bc3c0 | |||
2e2a5834e6 | |||
fbdd3f8971 | |||
9ee8096e33 | |||
b5c4a1757e | |||
1b263ad38f | |||
a900679eb6 | |||
c999822bd0 |
8
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# Set update schedule for GitHub Actions
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
# Check for updates to GitHub Actions every weekday
|
||||
interval: "daily"
|
73
.github/workflows/python-publish.yml
vendored
@ -1,28 +1,65 @@
|
||||
# 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", "3.10", "3.11", "3.12" ]
|
||||
click-version: [ "click~=8.0" ]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5.1.1
|
||||
with:
|
||||
python-version: '3.x'
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install setuptools wheel twine
|
||||
python -m pip install flake8
|
||||
python -m pip install ${{ matrix.click-version }}
|
||||
|
||||
- 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: Install module
|
||||
run: python -m pip install .
|
||||
|
||||
- name: Run Unit Tests
|
||||
run: python -m unittest -v
|
||||
|
||||
- name: Run Action
|
||||
id: yaclog-show
|
||||
uses: ./
|
||||
|
||||
deploy:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5.1.1
|
||||
with:
|
||||
python-version: '>=3.8'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install setuptools wheel twine
|
||||
|
||||
- name: Install pypa/build
|
||||
run: python -m pip install build --user
|
||||
@ -30,7 +67,21 @@ jobs:
|
||||
- name: Build a binary wheel and source tarball
|
||||
run: python -m build --sdist --wheel --outdir dist/
|
||||
|
||||
- name: Get version info
|
||||
id: yaclog-show
|
||||
uses: ./
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
|
||||
- name: Publish to Github
|
||||
run: |
|
||||
gh release create ${{ github.ref_name }} \
|
||||
--notes-file "${{ steps.yaclog-show.outputs.body_file }}" \
|
||||
--title "${{ steps.yaclog-show.outputs.name }}"
|
||||
|
||||
gh release upload ${{ github.ref_name }} dist/*
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
29
.readthedocs.yaml
Normal file
@ -0,0 +1,29 @@
|
||||
# .readthedocs.yaml
|
||||
# Read the Docs configuration file
|
||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||
|
||||
# Required
|
||||
version: 2
|
||||
|
||||
# Set the OS, Python version and other tools you might need
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.12"
|
||||
|
||||
# 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:
|
||||
install:
|
||||
- method: pip
|
||||
path: .
|
||||
extra_requirements:
|
||||
- docs
|
170
CHANGELOG.md
@ -1,9 +1,175 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file
|
||||
|
||||
## 0.1.0 - 2021-04-16
|
||||
## Version 1.4.0 - 2024-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- added a github action to the repository. The action can create new releases and fetch version information. For mor information see the "Github Actions" page in the handbook
|
||||
|
||||
|
||||
## Version 1.3.0 - 2024-08-08
|
||||
|
||||
### Added
|
||||
|
||||
- added a `--version` option to `yaclog show` that prints just the version number
|
||||
|
||||
### Changed
|
||||
|
||||
- removed support for Click 7 as a dependency
|
||||
|
||||
|
||||
## Version 1.2.0 - 2024-04-16
|
||||
|
||||
### Added
|
||||
|
||||
- added the `-s` option to `yaclog release` to increment arbitrary version segments
|
||||
- added the `-n` option to `yaclog release` to create a new release instead of releasing a new one
|
||||
- added the `-y` option to `yaclog release` to answer "yes" to all confirmation dialogs. Use with caution!
|
||||
|
||||
|
||||
## Version 1.1.2 - 2022-12-29
|
||||
|
||||
### Changed
|
||||
|
||||
- yaclog now only tries to use git when invoked with a command that needs it, meaning most sub commands can now be used on systems without git
|
||||
|
||||
|
||||
## Version 1.1.1 - 2022-08-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `yaclog release -C -c` not committing changes to cargo.toml
|
||||
|
||||
|
||||
## Version 1.1.0 - 2022-08-14
|
||||
|
||||
### Added
|
||||
|
||||
- Added a flag to update Rust Cargo.toml files when releasing a new version
|
||||
|
||||
|
||||
## Version 1.0.4 - 2022-04-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed tests folder being installed as a package
|
||||
|
||||
|
||||
## Version 1.0.3 - 2021-05-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `show` command not working with Click version 8
|
||||
- Fixed release message incorrectly stating if a commit will be created or not
|
||||
|
||||
|
||||
## Version 1.0.2 - 2021-05-12
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated to support Click version 8
|
||||
- Modified module documentation page titles to include a module role
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed tag names with spaces in versions
|
||||
|
||||
|
||||
## Version 1.0.1 - 2021-05-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed broken header in new changelogs
|
||||
- Improved consistency in command documentation metavars
|
||||
|
||||
|
||||
## Version 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.
|
||||
|
||||
|
||||
## Version 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
|
||||
|
||||
|
||||
## Version 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.
|
||||
|
||||
|
||||
## Version 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
|
||||
|
||||
|
||||
## Version 0.2.0 - 2021-04-19
|
||||
|
||||
### Added
|
||||
|
||||
- New yak log logo drawn by my sister
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated package metadata
|
||||
- Rewrote parser to use a 2-step method that is more flexible.
|
||||
- Parser can now handle code blocks.
|
||||
- Parser can now handle setext-style headers and H2s not conforming to the schema.
|
||||
|
||||
|
||||
## Version 0.1.0 - 2021-04-16
|
||||
|
||||
First release
|
||||
|
||||
### Added
|
||||
- `yaclog.read()` method to parse changelog files
|
||||
|
||||
- `yaclog.read()` method to parse changelog files
|
78
README.md
@ -1,2 +1,78 @@
|
||||
# yet-another-changelog
|
||||
# Yaclog
|
||||
|
||||
[](https://yaclog.readthedocs.io/en/latest/?badge=latest)
|
||||
[](https://github.com/drewcassidy/yaclog/actions/workflows/python-publish.yml)
|
||||
[](https://badge.fury.io/py/yaclog)
|
||||
|
||||
Yet another changelog command line tool
|
||||
|
||||

|
||||
|
||||
*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".
|
||||
```
|
||||
|
65
action.yaml
Normal file
@ -0,0 +1,65 @@
|
||||
name: Yaclog
|
||||
description: >
|
||||
Get version information from a changelog, and optionally create a new release.
|
||||
The `yaclog` command is made available for use in future steps.
|
||||
branding:
|
||||
icon: file-text
|
||||
color: orange
|
||||
|
||||
inputs:
|
||||
markdown:
|
||||
description: If outputs should be in markdown format or not
|
||||
default: 'true'
|
||||
|
||||
release:
|
||||
description: >
|
||||
Creates a new release and commits it if set. Directly passed to the arguments of `yaclog release`.
|
||||
Can be a version number or an increment tag like `--major`, `--minor`, or `--patch`.
|
||||
The resulting commit and tag will NOT be pushed back to the repo. You must add a step to do this yourself
|
||||
|
||||
outputs:
|
||||
name:
|
||||
description: "The current version name. For example, `Version 1.3.0`"
|
||||
value: ${{ steps.yaclog-show.outputs.name}}
|
||||
header:
|
||||
description: "The entire header for the current version. For example, `Version 1.3.0 - 2024-08-08`"
|
||||
value: ${{ steps.yaclog-show.outputs.header }}
|
||||
version:
|
||||
description: "The current version number. For example, `1.3.0`"
|
||||
value: ${{ steps.yaclog-show.outputs.version }}
|
||||
body_file:
|
||||
description: "Path to a temporary file containing the version body"
|
||||
value: ${{ steps.yaclog-show.outputs.body_file }}
|
||||
changelog:
|
||||
description: "Path to the entire changelog file."
|
||||
value: ${{ steps.yaclog-show.outputs.changelog }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- id: setup-python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
update-environment: 'false'
|
||||
|
||||
- name: Setup Yaclog
|
||||
shell: bash
|
||||
run: pipx install --python ${{ steps.setup-python.outputs.python-path }} ${{ github.action_path }}
|
||||
|
||||
- name: Create New Release
|
||||
shell: bash
|
||||
if: ${{ inputs.release }}
|
||||
run: yaclog release --yes --commit ${{ inputs.release }}
|
||||
|
||||
- name: Get Version Information
|
||||
id: yaclog-show
|
||||
shell: bash
|
||||
run: |
|
||||
yaclog show ---gh-actions ${{ inputs.markdown && '--markdown' }} >> "$GITHUB_OUTPUT"
|
||||
# output like so:
|
||||
# name=Version 1.3.0
|
||||
# header=Version 1.3.0 - 2024-08-08
|
||||
# version=1.3.0
|
||||
# body_file={path to file containing version body}
|
||||
# changelog={path to changelog}
|
20
docs/Makefile
Normal 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
@ -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
After Width: | Height: | Size: 1.7 KiB |
BIN
docs/_static/icon-16.png
vendored
Normal file
After Width: | Height: | Size: 394 B |
BIN
docs/_static/icon-256.png
vendored
Normal file
After Width: | Height: | Size: 4.0 KiB |
BIN
docs/_static/icon-32.png
vendored
Normal file
After Width: | Height: | Size: 586 B |
BIN
docs/_static/icon-48.png
vendored
Normal file
After Width: | Height: | Size: 578 B |
BIN
docs/_static/icon-64.png
vendored
Normal file
After Width: | Height: | Size: 764 B |
10
docs/_templates/layout.html
vendored
Normal 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
@ -0,0 +1,2 @@
|
||||
```{include} ../CHANGELOG.md
|
||||
```
|
100
docs/conf.py
Normal file
@ -0,0 +1,100 @@
|
||||
# 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 importlib.metadata import version
|
||||
|
||||
# -- 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 = version('yaclog')
|
||||
version = '.'.join(release.split('.')[:3])
|
||||
ref = version if len(release.split('.')) == 3 else 'main'
|
||||
|
||||
# -- 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',
|
||||
'sphinx_jinja'
|
||||
]
|
||||
|
||||
myst_heading_anchors = 2
|
||||
myst_enable_extensions = [
|
||||
"colon_fence"
|
||||
]
|
||||
|
||||
# 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),
|
||||
}
|
||||
|
||||
jinja_globals = {
|
||||
'version': version,
|
||||
'release': release,
|
||||
'ref': ref,
|
||||
}
|
BIN
docs/docs_logo.png
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
docs/favicon.ico
Normal file
After Width: | Height: | Size: 5.3 KiB |
78
docs/handbook/changelog_files.md
Normal 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
|
||||
```
|
7
docs/handbook/commands.md
Normal file
@ -0,0 +1,7 @@
|
||||
# Command Reference
|
||||
|
||||
```{eval-rst}
|
||||
.. click:: yaclog.cli.__main__:cli
|
||||
:prog: yaclog
|
||||
:nested: full
|
||||
```
|
50
docs/handbook/getting_started.md
Normal 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".
|
||||
```
|
141
docs/handbook/github_actions.md
Normal file
@ -0,0 +1,141 @@
|
||||
# Github Actions
|
||||
|
||||
Yaclog makes an action available for Github Actions and compatible CI systems.
|
||||
|
||||
## The Yaclog Action
|
||||
|
||||
To use the Yaclog action add the following to your workflow steps
|
||||
|
||||
````{jinja}
|
||||
```yaml
|
||||
- name: Get version info
|
||||
uses: drewcassidy/yaclog@{{ ref }}
|
||||
id: yaclog
|
||||
```
|
||||
````
|
||||
|
||||
### Inputs
|
||||
|
||||
```{confval} release
|
||||
:type: string
|
||||
|
||||
When set, creates a new release and commits it. Directly passed to the arguments of `yaclog release --yes --commit`.
|
||||
|
||||
Can be a version number or an increment tag like `--major`, `--minor`, or `--patch`.
|
||||
The resulting commit and tag will NOT be pushed back to the repo. You must add a step to do this yourself
|
||||
```
|
||||
|
||||
```{confval} markdown
|
||||
:type: boolean
|
||||
:default: true
|
||||
|
||||
If the output should be in markdown format or not. Equivalent to the `--markdown` flag
|
||||
```
|
||||
|
||||
### Outputs
|
||||
|
||||
```{confval} version
|
||||
The current version number, equivalent to the output of `yaclog show --version`. For example, `1.3.1`
|
||||
```
|
||||
|
||||
```{confval} name
|
||||
The most recent version name, equivalent to the output of `yaclog show --name`. For example, `Version 1.3.0`
|
||||
```
|
||||
|
||||
```{confval} header
|
||||
The entire header for the most recent version, equivalent to the output of `yaclog show --header`. For example, `Version 1.3.0 - 2024-08-08`
|
||||
```
|
||||
|
||||
```{confval} body_file
|
||||
The path to a temporary file containing the body of the most recent version. Contents equivalent to `yaclog show --body`
|
||||
```
|
||||
|
||||
```{confval} changelog
|
||||
The path to the changelog file. Usually `CHANGELOG.md` in the current directory.
|
||||
```
|
||||
|
||||
## Example Usage
|
||||
|
||||
### Get changelog information in your Build workflow
|
||||
|
||||
````{jinja}
|
||||
```yaml
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Mod Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: drewcassidy/yaclog@{{ ref }}
|
||||
id: yaclog
|
||||
|
||||
# Your build and test actions go here
|
||||
|
||||
- name: Publish to Github
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
run: |
|
||||
gh release create {{ '${{ github.ref_name }}' }} \
|
||||
--notes-file "{{ '${{ steps.yaclog.outputs.body_file }}' }}" \
|
||||
--title "{{ '${{ steps.yaclog.outputs.name }}' }}"
|
||||
env:
|
||||
GH_TOKEN: {{ '${{ github.token }}' }}
|
||||
|
||||
```
|
||||
````
|
||||
|
||||
### Workflow to make a new release
|
||||
|
||||
If you want to be able to create a new release for your project directly from the Github UI, you can make a new workflow
|
||||
you can dispatch directly.
|
||||
|
||||
Please note that this workflow does NOT create any releases in Github or any package managers. Instead, your normal build workflow should do this when it detects a push to a tag.
|
||||
|
||||
````{jinja}
|
||||
```yaml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release:
|
||||
description: 'type of release to use'
|
||||
required: true
|
||||
default: 'patch'
|
||||
type: choice
|
||||
options:
|
||||
- major
|
||||
- minor
|
||||
- patch
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
yaclog-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Mod Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Yaclog Release
|
||||
uses: drewcassidy/yaclog@{{ ref }}
|
||||
with:
|
||||
release: '--{{ '${{ inputs.release }}' }}'
|
||||
|
||||
- name: Push Changes
|
||||
run: |
|
||||
git config --global user.name "github-actions"
|
||||
git config --global user.email "github-actions@github.com"
|
||||
git push
|
||||
git push --tags
|
||||
env:
|
||||
GH_TOKEN: {{ '${{ github.token }}' }}
|
||||
```
|
||||
````
|
12
docs/handbook/index.md
Normal file
@ -0,0 +1,12 @@
|
||||
# Handbook
|
||||
|
||||
```{toctree}
|
||||
---
|
||||
maxdepth: 3
|
||||
---
|
||||
|
||||
getting_started
|
||||
changelog_files
|
||||
commands
|
||||
github_actions
|
||||
```
|
34
docs/index.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Yaclog: Yet Another Commandline Changelog Tool
|
||||
|
||||
[](https://yaclog.readthedocs.io/en/latest/?badge=latest)
|
||||
[](https://github.com/drewcassidy/yaclog/actions/workflows/python-publish.yml)
|
||||
[](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
@ -0,0 +1,2 @@
|
||||
```{include} ../LICENSE.md
|
||||
```
|
35
docs/make.bat
Normal 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
|
5
docs/reference/changelog.rst
Normal file
@ -0,0 +1,5 @@
|
||||
:py:mod:`changelog` Module
|
||||
==========================
|
||||
|
||||
.. automodule:: yaclog.changelog
|
||||
:members:
|
9
docs/reference/index.rst
Normal file
@ -0,0 +1,9 @@
|
||||
API Reference
|
||||
=============
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
changelog.rst
|
||||
markdown.rst
|
||||
version.rst
|
5
docs/reference/markdown.rst
Normal file
@ -0,0 +1,5 @@
|
||||
:py:mod:`markdown` Module
|
||||
=========================
|
||||
|
||||
.. automodule:: yaclog.markdown
|
||||
:members:
|
5
docs/reference/version.rst
Normal file
@ -0,0 +1,5 @@
|
||||
:py:mod:`version` Module
|
||||
========================
|
||||
|
||||
.. automodule:: yaclog.version
|
||||
:members:
|
@ -1,9 +1,60 @@
|
||||
[build-system]
|
||||
requires = [
|
||||
"setuptools >= 35.0.2",
|
||||
"setuptools_scm[toml] >= 3.4",
|
||||
"setuptools>=64",
|
||||
"setuptools_scm>=8",
|
||||
"wheel"
|
||||
]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools_scm]
|
||||
[project]
|
||||
name = "yaclog"
|
||||
description = "Yet another changelog CLI tool."
|
||||
readme = "README.md"
|
||||
authors = [{ name = "Andrew Cassidy", email = "drewcassidy@me.com" }]
|
||||
keywords = ["changelog", "commandline", "markdown"]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Topic :: Text Processing :: Markup :: Markdown",
|
||||
"Topic :: Software Development :: Version Control :: Git",
|
||||
"Topic :: Utilities"
|
||||
]
|
||||
|
||||
requires-python = ">= 3.8"
|
||||
dependencies = [
|
||||
"Click >= 8.0",
|
||||
"GitPython >= 3",
|
||||
"packaging >= 20",
|
||||
"tomlkit >= 0.11"
|
||||
|
||||
]
|
||||
dynamic = ["version"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
docs = [
|
||||
"Sphinx >= 3.5",
|
||||
"sphinx-click >= 2.7",
|
||||
"sphinx-rtd-theme",
|
||||
"myst-parser >= 0.14",
|
||||
"sphinx-jinja >=1.2.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
yaclog = "yaclog.cli.__main__:cli"
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/drewcassidy/yaclog"
|
||||
Changelog = "https://github.com/drewcassidy/yaclog/blob/main/CHANGELOG.md"
|
||||
Docs = "https://yaclog.readthedocs.io/"
|
||||
|
||||
[tool.setuptools_scm]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["yaclog"]
|
29
setup.cfg
@ -1,29 +0,0 @@
|
||||
[metadata]
|
||||
# until setuptools supports PEP621, this will have to do
|
||||
name = yaclog
|
||||
description = Yet another changelog CLI tool.
|
||||
author = Andrew Cassidy
|
||||
license = AGPLv3
|
||||
license_file = LICENSE.md
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown
|
||||
url = https://github.com/drewcassidy/yet-another-changelog
|
||||
|
||||
keywords = changelog, commandline, markdown
|
||||
classifiers =
|
||||
Development Status :: 4 - Beta
|
||||
Intended Audience :: Developers
|
||||
License :: OSI Approved :: GNU Affero General Public License v3
|
||||
Operating System :: OS Independent
|
||||
Programming Language :: Python :: 3 :: Only
|
||||
Programming Language :: Python :: 3
|
||||
Programming Language :: Python :: 3.8
|
||||
Programming Language :: Python :: 3.9
|
||||
Topic :: Text Processing :: Markup :: Markdown
|
||||
Topic :: Software Development :: Version Control :: Git
|
||||
Topic :: Utilities
|
||||
|
||||
[options]
|
||||
install_requires = Click; GitPython
|
||||
python_requires >= 3.8
|
||||
packages = find:
|
0
tests/__init__.py
Normal file
81
tests/common.py
Normal 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
@ -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()
|
323
tests/test_cli.py
Normal file
@ -0,0 +1,323 @@
|
||||
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)
|
||||
|
||||
result = runner.invoke(cli, ['release', '-y', '-s', 2])
|
||||
check_result(self, result)
|
||||
self.assertEqual(yaclog.read(location).versions[0].name, '1.0.2')
|
||||
self.assertIn('1.0.2', 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)
|
||||
|
||||
result = runner.invoke(cli, ['release', '-p', '-n'])
|
||||
check_result(self, result)
|
||||
self.assertEqual(yaclog.read(location).versions[0].name, '3.0.1')
|
||||
self.assertEqual(yaclog.read(location).versions[1].name, '3.0.0')
|
||||
self.assertIn('3.0.1', 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')
|
||||
|
||||
def test_cargo(self):
|
||||
"""Test updating cargo.toml files"""
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem():
|
||||
with open("Cargo.toml", "w") as fp:
|
||||
fp.write((
|
||||
'[package]\n'
|
||||
'name = "dummy"\n'
|
||||
'version = "0.3.4"\n'
|
||||
'authors = ["Andrew Cassidy <drewcassidy@me.com>"]\n'
|
||||
'description = "A dummy crate used for testing yaclog"\n'
|
||||
'keywords = ["does", "not", "exist"]\n'
|
||||
'edition = "2018"\n'
|
||||
))
|
||||
|
||||
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'])
|
||||
check_result(self, result)
|
||||
|
||||
with open("Cargo.toml", "r") as fp:
|
||||
self.assertIn('version = "1.0.0"', fp.read())
|
||||
# we're just going to trust tomlkit not to mangle everything else
|
||||
|
||||
|
||||
class TestShow(unittest.TestCase):
|
||||
|
||||
# noinspection PyShadowingNames
|
||||
def setUp(self):
|
||||
self.runner = CliRunner()
|
||||
self.location = 'CHANGELOG.md'
|
||||
|
||||
self.log = yaclog.Changelog()
|
||||
|
||||
self.log.add_version(name='1.0.0').add_entry('- entry number 1')
|
||||
self.log.add_version(name='Version 2.0.0').add_entry('- entry number 2', 'Added')
|
||||
self.log.add_version(name='Three Point Oh').add_entry('entry number 3')
|
||||
v = self.log.add_version(name='4.0.0 "Euclid"')
|
||||
v.add_entry('- entry number 4')
|
||||
v.add_entry('- entry number 5')
|
||||
v.tags.append('TAGGED')
|
||||
|
||||
self.modes = {
|
||||
'full': ([], lambda v, k: v.text(**k), '\n\n'),
|
||||
'name': (['-n'], lambda v, k: v.name, '\n'),
|
||||
'body': (['-b'], lambda v, k: v.body(**k), '\n\n'),
|
||||
'header': (['-h'], lambda v, k: v.header(**k), '\n'),
|
||||
}
|
||||
|
||||
def test_show_all(self):
|
||||
"""Test showing all version information"""
|
||||
|
||||
with self.runner.isolated_filesystem():
|
||||
self.log.write(self.location)
|
||||
|
||||
for mode, t in self.modes.items():
|
||||
with self.subTest(mode, flags=t[0]):
|
||||
check_result(self, result := self.runner.invoke(cli, ['show', '-a'] + t[0]))
|
||||
self.assertEqual(t[2].join([t[1](v, {'md': False}) for v in self.log.versions]),
|
||||
result.output.strip(), 'incorrect plaintext output')
|
||||
|
||||
check_result(self, result := self.runner.invoke(cli, ['show', '-am'] + t[0]))
|
||||
self.assertEqual(t[2].join([t[1](v, {'md': True}) for v in self.log.versions]),
|
||||
result.output.strip(), 'incorrect markdown output')
|
||||
|
||||
def test_show_version(self):
|
||||
with self.runner.isolated_filesystem():
|
||||
self.log.write(self.location)
|
||||
|
||||
for mode, t in self.modes.items():
|
||||
with self.subTest(mode, flags=t[0]):
|
||||
|
||||
for version in self.log.versions:
|
||||
check_result(self, result := self.runner.invoke(cli, ['show', version.name] + t[0]))
|
||||
self.assertEqual(t[1](version, {'md': False}),
|
||||
result.output.strip(), 'incorrect plaintext output')
|
||||
|
||||
check_result(self, result := self.runner.invoke(cli, ['show', version.name[-5:]] + t[0]))
|
||||
self.assertEqual(t[1](version, {'md': False}),
|
||||
result.output.strip(), 'incorrect plaintext output')
|
||||
|
||||
check_result(self, result := self.runner.invoke(cli, ['show', version.name, '-m'] + t[0]))
|
||||
self.assertEqual(t[1](version, {'md': True}),
|
||||
result.output.strip(), 'incorrect markdown output')
|
||||
|
||||
check_result(self, result := self.runner.invoke(cli, ['show', version.name[-5:], '-m'] + t[0]))
|
||||
self.assertEqual(t[1](version, {'md': True}),
|
||||
result.output.strip(), 'incorrect markdown output')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
@ -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
|
||||
|
@ -1,136 +1,400 @@
|
||||
"""
|
||||
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
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
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 = ''
|
||||
"""
|
||||
A serialized representation of a single version entry in a `Changelog`,
|
||||
containing the changes made since the previous version
|
||||
"""
|
||||
|
||||
_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:
|
||||
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('-')
|
||||
|
||||
if self.date:
|
||||
segments.append(self.date.isoformat())
|
||||
|
||||
segments += [f'[{t.upper()}]' for t in self.tags]
|
||||
|
||||
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:
|
||||
if self.name.lower() == 'unreleased':
|
||||
return f'## {self.name}'
|
||||
|
||||
date_str = self.date.isoformat() if self.date else 'UNKNOWN'
|
||||
line = f'## {self.name} - {date_str}'
|
||||
for tag in self.tags:
|
||||
line += ' [' + tag.upper() + ']'
|
||||
|
||||
return line
|
||||
return self.header(False)
|
||||
|
||||
|
||||
class Changelog:
|
||||
def __init__(self, path: os.PathLike):
|
||||
self.path = path
|
||||
self.header = ''
|
||||
self.versions = []
|
||||
self.links = {}
|
||||
"""
|
||||
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:
|
||||
# Read file
|
||||
line = fp.readline()
|
||||
while line and not line.startswith('##'):
|
||||
self.header += line
|
||||
line = fp.readline()
|
||||
tokens, links = markdown.tokenize(fp.read())
|
||||
|
||||
version = None
|
||||
section = ''
|
||||
last_line = ''
|
||||
section = ''
|
||||
versions = []
|
||||
preamble_segments = []
|
||||
|
||||
while line:
|
||||
if line.isspace():
|
||||
# skip empty lines
|
||||
pass
|
||||
elif match := re.fullmatch(
|
||||
r'^##\s+(?P<name>\S*)(?:\s+-\s+(?P<date>\S+))?\s*?(?P<extra>.*?)\s*#*$', line):
|
||||
# this is a version header in the form '## Name (- date) (tags*) (#*)'
|
||||
version = VersionEntry()
|
||||
section = ''
|
||||
for token in tokens:
|
||||
text = '\n'.join(token.lines)
|
||||
|
||||
version.name, version.link, version.link_id = _strip_link(match['name'])
|
||||
if token.kind == 'h2':
|
||||
# start of a version
|
||||
versions.append(VersionEntry.from_header(text, line_no=token.line_no))
|
||||
section = ''
|
||||
|
||||
if match['date']:
|
||||
try:
|
||||
version.date = datetime.date.fromisoformat(match['date'].strip(string.punctuation))
|
||||
except ValueError:
|
||||
version.date = None
|
||||
elif len(versions) == 0:
|
||||
# we haven't encountered any version headers yet,
|
||||
# so its best to just add this line to the preamble
|
||||
preamble_segments.append(text)
|
||||
|
||||
if match['extra']:
|
||||
version.tags = [s.strip('[]') for s in re.findall(r'\[.*?]', match['extra'])]
|
||||
elif token.kind == 'h3':
|
||||
# start of a version section
|
||||
section = text.strip('#').strip()
|
||||
if section not in versions[-1].sections.keys():
|
||||
versions[-1].sections[section] = []
|
||||
|
||||
self.versions.append(version)
|
||||
else:
|
||||
# change log entry
|
||||
versions[-1].sections[section].append(text)
|
||||
|
||||
elif match := re.fullmatch(r'###\s+(\S*?)(\s+#*)?', line):
|
||||
# this is a version section header in the form '### Name' or '### Name ###'
|
||||
section = match[1].title()
|
||||
if section not in version.sections.keys():
|
||||
version.sections[section] = []
|
||||
# handle links
|
||||
for version in versions:
|
||||
if match := re.fullmatch(r'\[(.*)]', version.name):
|
||||
# ref-matched link
|
||||
link_id = match[1].lower()
|
||||
if link_id in links:
|
||||
version.link = links[link_id]
|
||||
version.link_id = None
|
||||
version.name = match[1]
|
||||
|
||||
elif match := re.fullmatch(r'\[(\S*)]:\s*(\S*)\n', line):
|
||||
# this is a link definition in the form '[id]: link', so add it to the link table
|
||||
self.links[match[1].lower()] = match[2]
|
||||
elif version.link_id in links:
|
||||
# id-matched link
|
||||
version.link = links[version.link_id]
|
||||
|
||||
elif line[0] in bullets or last_line.isspace():
|
||||
# bullet point or new paragraph
|
||||
# bullet points are preserved since some people like to use '+', '-' or '*' for different things
|
||||
version.sections[section].append(line.strip())
|
||||
self.preamble = markdown.join(preamble_segments)
|
||||
self.versions = versions
|
||||
self.links = links
|
||||
|
||||
else:
|
||||
# not a bullet point, and no whitespace on last line, so append to the last entry
|
||||
version.sections[section][-1] += '\n' + line.strip()
|
||||
def write(self, path=None) -> None:
|
||||
"""
|
||||
Write a changelog to a Markdown file.
|
||||
|
||||
last_line = line
|
||||
line = fp.readline()
|
||||
:param path: The changelog's path on disk. By default, :py:attr:`~Changelog.path` is used.
|
||||
"""
|
||||
|
||||
for version in self.versions:
|
||||
# handle links
|
||||
if match := re.fullmatch(r'\[(.*)]', version.name):
|
||||
# ref-matched link
|
||||
link_id = match[1].lower()
|
||||
if link_id in self.links:
|
||||
version.link = self.links.pop(link_id)
|
||||
version.link_id = None
|
||||
version.name = match[1]
|
||||
if path is None:
|
||||
# use the object path if none was provided
|
||||
path = self.path
|
||||
|
||||
elif version.link_id in self.links:
|
||||
# id-matched link
|
||||
version.link = self.links.pop(version.link_id)
|
||||
segments = []
|
||||
|
||||
if self.preamble:
|
||||
segments.append(self.preamble)
|
||||
|
||||
def read_version_header(line: str) -> Tuple[str, datetime.date, List[str]]:
|
||||
split = line.removeprefix('##').strip().split()
|
||||
name = split[0]
|
||||
date = datetime.date.fromisoformat(split[2]) if len(split) > 2 else None
|
||||
tags = [s.removeprefix('[').removesuffix(']') for s in split[3:]]
|
||||
v_links = {**self.links}
|
||||
|
||||
return name, date, tags
|
||||
for version in self.versions:
|
||||
if version.link:
|
||||
v_links[version.name.lower()] = version.link
|
||||
|
||||
segments.append(version.text() + '\n')
|
||||
|
||||
def write_version_header(name: str, date: datetime.date, tags=None) -> str:
|
||||
line = f'## {name} - {date.isoformat()}'
|
||||
if tags:
|
||||
for tag in tags:
|
||||
line += ' [' + tag.upper() + ']'
|
||||
segments += [f'[{link_id}]: {link}' for link_id, link in v_links.items()]
|
||||
|
||||
return line
|
||||
text = markdown.join(segments)
|
||||
|
||||
with open(path, 'w') as fp:
|
||||
fp.write(text)
|
||||
|
||||
def add_version(self, index: int = 0, *args, **kwargs) -> VersionEntry:
|
||||
"""
|
||||
Add a new version to the changelog
|
||||
|
||||
: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
|
||||
|
||||
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
|
||||
|
||||
: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.
|
||||
"""
|
||||
|
||||
# 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.
|
||||
The first version with this value in its name is returned.
|
||||
:return: The first version with the selected name
|
||||
"""
|
||||
|
||||
for version in self.versions:
|
||||
if name in version.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
332
yaclog/cli/__main__.py
Normal file
@ -0,0 +1,332 @@
|
||||
# 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 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') # don't 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}')
|
||||
|
||||
|
||||
# noinspection PyShadowingNames
|
||||
@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', 'mode', flag_value='full', default=True,
|
||||
help='Show version header and body.')
|
||||
@click.option('--name', '-n', 'mode', flag_value='name',
|
||||
help='Show only the version name')
|
||||
@click.option('--body', '-b', 'mode', flag_value='body',
|
||||
help='Show only the version body.')
|
||||
@click.option('--header', '-h', 'mode', flag_value='header',
|
||||
help='Show only the version header.')
|
||||
@click.option('--version', '-v', 'mode', flag_value='version', help='Show only the version number. If the current version is unreleased, '
|
||||
'this is inferred by incrementing the patch number of the last released version')
|
||||
@click.option('---gh-actions', 'gh_actions', is_flag=True, hidden=True)
|
||||
@click.argument('version_names', metavar='VERSIONS', type=str, nargs=-1)
|
||||
@click.pass_obj
|
||||
def show(obj: Changelog, all_versions, markdown, mode, version_names, gh_actions):
|
||||
"""
|
||||
Show the changes for VERSIONS.
|
||||
|
||||
VERSIONS is a list of versions to print. If not given, the most recent version is used.
|
||||
"""
|
||||
|
||||
functions = {
|
||||
'full': (lambda v, k: v.text(**k)),
|
||||
'name': (lambda v, k: v.name),
|
||||
'body': (lambda v, k: v.body(**k)),
|
||||
'header': (lambda v, k: v.header(**k)),
|
||||
'version': (lambda v, k: str(v.version))
|
||||
}
|
||||
|
||||
str_func = functions[mode]
|
||||
kwargs = {'md': markdown, 'color': True}
|
||||
|
||||
try:
|
||||
if all_versions:
|
||||
versions = obj.versions
|
||||
elif len(version_names) == 0:
|
||||
versions = [obj.current_version()]
|
||||
if mode == 'version' and versions[0].name == 'Unreleased':
|
||||
latest = obj.current_version(released=True).version
|
||||
inferred = yaclog.version.increment_version(str(latest), 2, '')
|
||||
print(str(inferred))
|
||||
return
|
||||
else:
|
||||
versions = [obj.get_version(name) for name in version_names]
|
||||
except KeyError as k:
|
||||
raise click.BadArgumentUsage(str(k))
|
||||
except ValueError as v:
|
||||
raise click.ClickException(str(v))
|
||||
|
||||
sep = '\n\n' if mode == 'body' or mode == 'full' else '\n'
|
||||
|
||||
if gh_actions:
|
||||
import tempfile
|
||||
|
||||
all_modes = [ 'name', 'header', 'version' ]
|
||||
outputs = [f'{mode}={sep.join([functions[mode](v, kwargs) for v in versions])}' for mode in all_modes]
|
||||
click.echo('\n'.join(outputs))
|
||||
body_fd, body_file = tempfile.mkstemp(text=True)
|
||||
with os.fdopen(body_fd, 'w') as f:
|
||||
f.write(sep.join([functions['body'](v, kwargs) for v in versions]))
|
||||
click.echo(f'body_file={body_file}')
|
||||
click.echo(f'changelog={obj.path}')
|
||||
return
|
||||
|
||||
click.echo(sep.join([str_func(v, kwargs) for v in versions]))
|
||||
|
||||
|
||||
@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(str(k))
|
||||
except ValueError as v:
|
||||
raise click.ClickException(str(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(str(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, type=int, default=None,
|
||||
help='Increment major version number.')
|
||||
@click.option('-m', '--minor', 'rel_seg', flag_value=1, type=int,
|
||||
help='Increment minor version number.')
|
||||
@click.option('-p', '--patch', 'rel_seg', flag_value=2, type=int,
|
||||
help='Increment patch number.')
|
||||
@click.option('-s', '--segment', 'rel_seg', type=int,
|
||||
help='Increment nth segment of the version. For example, `--segment 2` is equivalent to `--patch`')
|
||||
@click.option('-a', '--alpha', 'pre_seg', flag_value='a', type=str, default=None,
|
||||
help='Increment alpha version number.')
|
||||
@click.option('-b', '--beta', 'pre_seg', flag_value='b', type=str,
|
||||
help='Increment beta version number.')
|
||||
@click.option('-r', '--rc', 'pre_seg', flag_value='rc', type=str,
|
||||
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.option('-C', '--cargo', '-🦀', is_flag=True,
|
||||
help='Update the version in a Rust cargo.toml manifest file.')
|
||||
@click.option('-y', '--yes', is_flag=True,
|
||||
help='Answer "yes" to all confirmation dialogs')
|
||||
@click.option('-n', '--new', is_flag=True,
|
||||
help = 'Create a new version instead of renaming an existing one')
|
||||
@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, cargo, yes, new):
|
||||
"""
|
||||
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 and not cargo:
|
||||
click.echo('Nothing to release!')
|
||||
raise click.Abort
|
||||
|
||||
if new:
|
||||
cur_version = obj.add_version()
|
||||
else:
|
||||
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) and not yes:
|
||||
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')}")
|
||||
|
||||
short_version, *_ = yaclog.version.extract_version(cur_version.name)
|
||||
if not short_version:
|
||||
short_version = cur_version.name.replace(' ', '-')
|
||||
|
||||
if cargo:
|
||||
from ..cli import cargo_toml
|
||||
cargo_toml.set_version("Cargo.toml", str(short_version))
|
||||
click.echo("Updated Cargo.toml")
|
||||
|
||||
if commit:
|
||||
import git
|
||||
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)
|
||||
|
||||
if cargo:
|
||||
repo.index.add("Cargo.toml")
|
||||
|
||||
tracked = len(repo.index.diff(repo.head.commit))
|
||||
untracked = len(repo.index.diff(None))
|
||||
|
||||
message = [['Create tag', 'Commit and 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))
|
||||
|
||||
if not yes:
|
||||
click.confirm(' '.join(message), abort=True)
|
||||
|
||||
if tracked > 0:
|
||||
commit = repo.index.commit(f'Release {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
|
||||
|
||||
# noinspection PyTypeChecker
|
||||
repo_tag = repo.create_tag(short_version, ref=commit, message=cur_version.body(False))
|
||||
click.echo(f"Created tag {click.style(repo_tag.name, fg='green')}.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
cli()
|
33
yaclog/cli/cargo_toml.py
Normal file
@ -0,0 +1,33 @@
|
||||
# yaclog: yet another changelog tool
|
||||
# Copyright (c) 2022. 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 tomlkit import dumps
|
||||
from tomlkit import parse
|
||||
|
||||
|
||||
def set_version(path, version):
|
||||
"""
|
||||
Set the version string in a cargo.toml file
|
||||
|
||||
:param path: path-like file location
|
||||
:param version: version string to overwrite with
|
||||
"""
|
||||
with open(path, 'r+') as fp:
|
||||
toml = parse(fp.read())
|
||||
toml['package']['version'] = version
|
||||
fp.seek(0)
|
||||
fp.write(dumps(toml))
|
||||
fp.truncate()
|
165
yaclog/markdown.py
Normal 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
@ -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
|