Compare commits
88 Commits
0.3.3
...
d6da31b6ff
Author | SHA1 | Date | |
---|---|---|---|
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 |
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"
|
33
.github/workflows/python-publish.yml
vendored
@ -1,7 +1,7 @@
|
||||
# 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
|
||||
name: build
|
||||
on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
@ -9,13 +9,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [ 3.8, 3.9 ]
|
||||
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 ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v5.1.1
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@ -23,6 +24,7 @@ jobs:
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install flake8
|
||||
python -m pip install ${{ matrix.click-version }}
|
||||
|
||||
- name: Install module
|
||||
run: python -m pip install .
|
||||
@ -43,17 +45,18 @@ jobs:
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v5.1.1
|
||||
with:
|
||||
python-version: '3.x'
|
||||
python-version: '>=3.8'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install setuptools wheel twine
|
||||
python -m pip install setuptools wheel twine
|
||||
python -m pip install . # Self hosting!
|
||||
|
||||
- name: Install pypa/build
|
||||
run: python -m pip install build --user
|
||||
@ -61,7 +64,21 @@ jobs:
|
||||
- name: Build a binary wheel and source tarball
|
||||
run: python -m build --sdist --wheel --outdir dist/
|
||||
|
||||
- name: Get version name and body
|
||||
run: |
|
||||
echo "VERSION_TILE=$(yaclog show -n)" >> $GITHUB_ENV
|
||||
echo "$(yaclog show -mb)" >> RELEASE.md
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
|
||||
- name: Publish to Github
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/*
|
||||
name: ${{ env.VERSION_TITLE }}
|
||||
body_path: RELEASE.md
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.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
|
118
CHANGELOG.md
@ -2,22 +2,121 @@
|
||||
|
||||
All notable changes to this project will be documented in this file
|
||||
|
||||
## 0.3.3 - 2021-04-27
|
||||
## 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
|
||||
|
||||
### Changed
|
||||
### 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 links dict now contains version links. Modified version links will overwrite those in the table when writing to a file
|
||||
- Changelog object no longer errors when creating without a path.
|
||||
- `release` now resets lesser version values when incrementing
|
||||
- `release` now works with logs that have only unreleased changes
|
||||
|
||||
## 0.3.2 - 2021-04-24
|
||||
|
||||
## Version 0.3.2 - 2021-04-24
|
||||
|
||||
### Added
|
||||
|
||||
@ -32,7 +131,8 @@ All notable changes to this project will be documented in this file
|
||||
|
||||
- `release` and `entry` commands now work using empty changelogs.
|
||||
|
||||
## 0.3.1 - 2021-04-24
|
||||
|
||||
## Version 0.3.1 - 2021-04-24
|
||||
|
||||
### Added
|
||||
|
||||
@ -44,7 +144,8 @@ All notable changes to this project will be documented in this file
|
||||
- `tag` command for manipulating tags in the changelog
|
||||
- `release` command for creating releases
|
||||
|
||||
## 0.2.0 - 2021-04-19
|
||||
|
||||
## Version 0.2.0 - 2021-04-19
|
||||
|
||||
### Added
|
||||
|
||||
@ -57,7 +158,8 @@ All notable changes to this project will be documented in this file
|
||||
- Parser can now handle code blocks.
|
||||
- Parser can now handle setext-style headers and H2s not conforming to the schema.
|
||||
|
||||
## 0.1.0 - 2021-04-16
|
||||
|
||||
## Version 0.1.0 - 2021-04-16
|
||||
|
||||
First release
|
||||
|
||||
|
@ -1,4 +1,9 @@
|
||||
# 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
|
||||
|
||||

|
||||
@ -63,7 +68,7 @@ Unreleased
|
||||
Release the current version and make a git tag for it
|
||||
|
||||
```shell
|
||||
$ yaclog release --version 0.0.1 -c
|
||||
$ yaclog release 0.0.1 -c
|
||||
```
|
||||
```
|
||||
Renamed version "Unreleased" to "0.0.1".
|
||||
|
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
|
||||
```
|
87
docs/conf.py
Normal file
@ -0,0 +1,87 @@
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# This file only contains a selection of the most common options. For a full
|
||||
# list see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
from pkg_resources import get_distribution
|
||||
|
||||
# -- Path setup --------------------------------------------------------------
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
#
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.abspath('..'))
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = 'Yaclog'
|
||||
copyright = '2021, Andrew Cassidy'
|
||||
author = 'Andrew Cassidy'
|
||||
release = get_distribution('yaclog').version
|
||||
version = '.'.join(release.split('.')[:3])
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
'myst_parser',
|
||||
'sphinx_click',
|
||||
'sphinx_rtd_theme',
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.intersphinx',
|
||||
]
|
||||
|
||||
myst_heading_anchors = 2
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
# This pattern also affects html_static_path and html_extra_path.
|
||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
||||
|
||||
default_role = 'py:obj'
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
#
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
html_logo = 'docs_logo.png'
|
||||
html_favicon = 'favicon.ico'
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
html_css_files = ['css/custom.css']
|
||||
|
||||
# -- Options for Autodoc -----------------------------------------------------
|
||||
|
||||
add_module_names = False
|
||||
autodoc_docstring_signature = True
|
||||
autoclass_content = 'both'
|
||||
|
||||
autodoc_default_options = {
|
||||
'member-order': 'bysource',
|
||||
'undoc-members': True,
|
||||
}
|
||||
|
||||
# -- Options for Intersphinx -------------------------------------------------
|
||||
|
||||
# This config value contains the locations and names of other projects that
|
||||
# should be linked to in this documentation.
|
||||
|
||||
intersphinx_mapping = {
|
||||
'python': ('https://docs.python.org/3', None),
|
||||
'packaging': ('https://packaging.pypa.io/en/latest/', None),
|
||||
}
|
BIN
docs/docs_logo.png
Normal file
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".
|
||||
```
|
11
docs/handbook/index.md
Normal file
@ -0,0 +1,11 @@
|
||||
# Handbook
|
||||
|
||||
```{toctree}
|
||||
---
|
||||
maxdepth: 3
|
||||
---
|
||||
|
||||
getting_started
|
||||
changelog_files
|
||||
commands
|
||||
```
|
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,59 @@
|
||||
[build-system]
|
||||
requires = [
|
||||
"setuptools >= 35.0.2",
|
||||
"setuptools_scm[toml] >= 3.4",
|
||||
"setuptools>=64",
|
||||
"setuptools_scm>=8",
|
||||
"wheel"
|
||||
]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[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",
|
||||
]
|
||||
|
||||
[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"]
|
42
setup.cfg
@ -1,42 +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
|
||||
|
||||
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
|
||||
|
||||
project_urls =
|
||||
Source Code = https://github.com/drewcassidy/yaclog
|
||||
Changelog = https://github.com/drewcassidy/yaclog/blob/main/CHANGELOG.md
|
||||
|
||||
[options]
|
||||
install_requires =
|
||||
Click ~= 7.0
|
||||
GitPython >= 3
|
||||
packaging >= 20
|
||||
python_requires = >= 3.8
|
||||
packages = find:
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
yaclog = yaclog.cli.__main__:cli
|
||||
|
||||
[options.packages.find]
|
||||
exclude = tests.*
|
@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
import os.path
|
||||
import textwrap
|
||||
|
||||
import yaclog.changelog
|
||||
|
||||
log_segments = [
|
||||
@ -45,7 +46,8 @@ log_segments = [
|
||||
log_text = '\n\n'.join(log_segments)
|
||||
|
||||
log = yaclog.Changelog()
|
||||
log.header = '# Changelog\n\nThis changelog is for testing the parser, and has many things in it that might trip it up.'
|
||||
log.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()]
|
||||
|
||||
|
@ -1,9 +1,11 @@
|
||||
import datetime
|
||||
import os.path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import yaclog.changelog
|
||||
import yaclog
|
||||
from tests.common import log, log_segments, log_text
|
||||
from yaclog.changelog import VersionEntry
|
||||
|
||||
|
||||
class TestParser(unittest.TestCase):
|
||||
@ -20,9 +22,9 @@ class TestParser(unittest.TestCase):
|
||||
"""Test the log's path"""
|
||||
self.assertEqual(self.path, self.log.path)
|
||||
|
||||
def test_header(self):
|
||||
"""Test the header information at the top of the file"""
|
||||
self.assertEqual(log.header, self.log.header)
|
||||
def test_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"""
|
||||
@ -50,9 +52,9 @@ class TestWriter(unittest.TestCase):
|
||||
log.write(cls.path)
|
||||
with open(cls.path) as fd:
|
||||
cls.log_text = fd.read()
|
||||
cls.log_segments = [line for line in cls.log_text.split('\n\n') if line]
|
||||
cls.log_segments = [line.lstrip('\n') for line in cls.log_text.split('\n\n') if line]
|
||||
|
||||
def test_header(self):
|
||||
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])
|
||||
|
||||
@ -77,5 +79,88 @@ class TestWriter(unittest.TestCase):
|
||||
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()
|
||||
|
@ -1,14 +1,18 @@
|
||||
import unittest
|
||||
import os.path
|
||||
import git
|
||||
import unittest
|
||||
import traceback
|
||||
|
||||
import yaclog
|
||||
from yaclog.cli.__main__ import cli
|
||||
import git
|
||||
from click.testing import CliRunner
|
||||
|
||||
import yaclog.changelog
|
||||
from yaclog.cli.__main__ import cli
|
||||
|
||||
def check_result(runner, result, expected=0):
|
||||
runner.assertEqual(result.exit_code, expected, f'output: {result.output}\ntraceback: {result.exc_info}')
|
||||
|
||||
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):
|
||||
@ -24,6 +28,12 @@ class TestCreation(unittest.TestCase):
|
||||
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)
|
||||
|
||||
@ -52,7 +62,7 @@ class TestCreation(unittest.TestCase):
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(cli, ['show'])
|
||||
check_result(self, result, 1)
|
||||
check_result(self, result, False)
|
||||
self.assertIn('does not exist', result.output)
|
||||
|
||||
|
||||
@ -81,8 +91,7 @@ class TestTagging(unittest.TestCase):
|
||||
self.assertEqual(out_log.versions[1].tags, ['TAG2'])
|
||||
|
||||
result = runner.invoke(cli, ['tag', 'tag3', '0.8.0'])
|
||||
check_result(self, result, 2)
|
||||
self.assertIn('not found in changelog', result.output)
|
||||
check_result(self, result, False)
|
||||
|
||||
def test_tag_deletion(self):
|
||||
"""Test deleting tags from versions"""
|
||||
@ -102,15 +111,12 @@ class TestTagging(unittest.TestCase):
|
||||
in_log.write()
|
||||
|
||||
result = runner.invoke(cli, ['tag', '-d', 'tag2', '0.8.0'])
|
||||
check_result(self, result, 2)
|
||||
self.assertIn('not found in changelog', result.output)
|
||||
check_result(self, result, False)
|
||||
|
||||
result = runner.invoke(cli, ['tag', '-d', 'tag3', '0.9.0'])
|
||||
check_result(self, result, 2)
|
||||
self.assertIn('not found in version', result.output)
|
||||
check_result(self, result, False)
|
||||
|
||||
result = runner.invoke(cli, ['tag', '-d', 'tag1'])
|
||||
self.assertNotIn('not found in version', result.output)
|
||||
check_result(self, result)
|
||||
|
||||
out_log = yaclog.read(location)
|
||||
@ -118,7 +124,6 @@ class TestTagging(unittest.TestCase):
|
||||
self.assertEqual(out_log.versions[1].tags, ['TAG2'])
|
||||
|
||||
result = runner.invoke(cli, ['tag', '-d', 'tag2', '0.9.0'])
|
||||
self.assertNotIn('not found in version', result.output)
|
||||
check_result(self, result)
|
||||
|
||||
out_log = yaclog.read(location)
|
||||
@ -136,10 +141,9 @@ class TestRelease(unittest.TestCase):
|
||||
runner.invoke(cli, ['init']) # create the changelog
|
||||
runner.invoke(cli, ['entry', '-b', 'entry number 1'])
|
||||
|
||||
result = runner.invoke(cli, ['release', '--version', '1.0.0'])
|
||||
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('Unreleased', result.output)
|
||||
self.assertIn('1.0.0', result.output)
|
||||
|
||||
runner.invoke(cli, ['entry', '-b', 'entry number 2'])
|
||||
@ -147,15 +151,18 @@ class TestRelease(unittest.TestCase):
|
||||
result = runner.invoke(cli, ['release', '-p'])
|
||||
check_result(self, result)
|
||||
self.assertEqual(yaclog.read(location).versions[0].name, '1.0.1')
|
||||
self.assertIn('Unreleased', result.output)
|
||||
self.assertIn('1.0.1', result.output)
|
||||
|
||||
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('Unreleased', result.output)
|
||||
self.assertIn('1.1.0', result.output)
|
||||
|
||||
runner.invoke(cli, ['entry', '-b', 'entry number 4'])
|
||||
@ -163,9 +170,41 @@ class TestRelease(unittest.TestCase):
|
||||
result = runner.invoke(cli, ['release', '-M'])
|
||||
check_result(self, result)
|
||||
self.assertEqual(yaclog.read(location).versions[0].name, '2.0.0')
|
||||
self.assertIn('Unreleased', result.output)
|
||||
self.assertIn('2.0.0', result.output)
|
||||
|
||||
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()
|
||||
@ -182,13 +221,103 @@ class TestRelease(unittest.TestCase):
|
||||
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')
|
||||
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()
|
||||
|
@ -1,3 +1,8 @@
|
||||
"""
|
||||
Contains the `Changelog` class that represents a parsed changelog file that can be read from and written to
|
||||
disk as markdown, as well as the `VersionEntry` class that represents a single version within that changelog.
|
||||
"""
|
||||
|
||||
# yaclog: yet another changelog tool
|
||||
# Copyright (c) 2021. Andrew Cassidy
|
||||
#
|
||||
@ -14,82 +19,153 @@
|
||||
# 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
|
||||
from typing import List, Tuple, Optional
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
bullets = '+-*'
|
||||
brackets = '[]'
|
||||
import click # only for styling
|
||||
|
||||
code_regex = re.compile(r'^```')
|
||||
header_regex = re.compile(r'^(?P<hashes>#+)\s+(?P<contents>[^#]+)(?:\s+#+)?$')
|
||||
under1_regex = re.compile(r'^=+\s*$')
|
||||
under2_regex = re.compile(r'^-+\s*$')
|
||||
bullet_regex = re.compile(r'^[-+*]')
|
||||
linkid_regex = re.compile(r'^\[(?P<link_id>\S*)]:\s*(?P<link>.*)')
|
||||
|
||||
default_header = '# Changelog\n\nAll notable changes to this project will be documented in this file'
|
||||
|
||||
|
||||
def _strip_link(token):
|
||||
if link_literal := re.fullmatch(r'\[(.*?)]\((.*?)\)', token):
|
||||
# in the form [name](link)
|
||||
return link_literal[1], link_literal[2], None
|
||||
|
||||
if link_id := re.fullmatch(r'\[(.*?)]\[(.*?)]', token):
|
||||
# in the form [name][id] where id is hopefully linked somewhere else in the document
|
||||
return link_id[1], None, link_id[2].lower()
|
||||
|
||||
return token, None, None
|
||||
|
||||
|
||||
def _join_markdown(segments: List[str]) -> str:
|
||||
text: List[str] = []
|
||||
last_bullet = False
|
||||
for segment in segments:
|
||||
is_bullet = bullet_regex.match(segment)
|
||||
|
||||
if not is_bullet or not last_bullet:
|
||||
text.append('')
|
||||
|
||||
text.append(segment)
|
||||
|
||||
last_bullet = is_bullet
|
||||
|
||||
return '\n'.join(text).strip()
|
||||
import yaclog.markdown as markdown
|
||||
import yaclog.version
|
||||
|
||||
|
||||
class VersionEntry:
|
||||
def __init__(self):
|
||||
self.sections = {'': []}
|
||||
self.name: str = 'Unreleased'
|
||||
self.date: Optional[datetime.date] = None
|
||||
self.tags: List[str] = []
|
||||
self.link: Optional[str] = None
|
||||
self.link_id: Optional[str] = None
|
||||
self.line_no: int = -1
|
||||
"""
|
||||
A serialized representation of a single version entry in a `Changelog`,
|
||||
containing the changes made since the previous version
|
||||
"""
|
||||
|
||||
_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
|
||||
"""
|
||||
|
||||
def body(self, md: bool = True) -> str:
|
||||
segments = []
|
||||
|
||||
for section, entries in self.sections.items():
|
||||
if section:
|
||||
if md:
|
||||
segments.append(f'### {section.title()}')
|
||||
prefix = '### '
|
||||
title = section.title()
|
||||
else:
|
||||
segments.append(f'{section.upper()}:')
|
||||
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.append(_join_markdown(entries))
|
||||
segments += entries
|
||||
|
||||
return _join_markdown(segments)
|
||||
return markdown.join(segments)
|
||||
|
||||
def header(self, md: bool = True) -> str:
|
||||
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:
|
||||
segments.append('##')
|
||||
prefix = '## '
|
||||
else:
|
||||
prefix = ''
|
||||
|
||||
segments = []
|
||||
|
||||
if self.link and md:
|
||||
segments.append(f'[{self.name}]')
|
||||
@ -104,175 +180,221 @@ class VersionEntry:
|
||||
|
||||
segments += [f'[{t.upper()}]' for t in self.tags]
|
||||
|
||||
return ' '.join(segments)
|
||||
title = ' '.join(segments)
|
||||
|
||||
def text(self, md: bool = True) -> str:
|
||||
return self.header(md) + '\n\n' + self.body(md)
|
||||
if color:
|
||||
prefix = click.style(prefix, fg='bright_black')
|
||||
title = click.style(title, fg='blue', bold=True)
|
||||
|
||||
return prefix + title
|
||||
|
||||
def text(self, md: bool = True, color: bool = False) -> str:
|
||||
"""
|
||||
Get the version's contents as a string
|
||||
|
||||
:param md: Format headings as markdown
|
||||
:param color: Add color codes to the string for display in a terminal
|
||||
:return: The formatted version header and body
|
||||
"""
|
||||
|
||||
contents = self.header(md, color)
|
||||
body = self.body(md, color)
|
||||
if body:
|
||||
contents += '\n\n' + body
|
||||
return contents
|
||||
|
||||
@property
|
||||
def released(self) -> bool:
|
||||
"""Returns true if a PEP440 version number is present in the version name, and has no prerelease segments"""
|
||||
return yaclog.version.is_release(self.name)
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
"""Returns the PEP440 version number from the version name, or `None` if none is found"""
|
||||
return yaclog.version.extract_version(self.name)[0]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.header(False)
|
||||
|
||||
|
||||
class Changelog:
|
||||
def __init__(self, path=None):
|
||||
self.path: os.PathLike = path
|
||||
self.header: str = ''
|
||||
self.versions: List[VersionEntry] = []
|
||||
self.links = {}
|
||||
"""
|
||||
A serialized representation of a Markdown changelog made up of a preamble, multiple versions, and a link table.
|
||||
"""
|
||||
|
||||
if not path or not os.path.exists(path):
|
||||
self.header = default_header
|
||||
return
|
||||
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:
|
||||
lines = fp.readlines()
|
||||
tokens, links = markdown.tokenize(fp.read())
|
||||
|
||||
section = ''
|
||||
in_block = False
|
||||
in_code = False
|
||||
versions = []
|
||||
preamble_segments = []
|
||||
|
||||
segments: List[Tuple[int, List[str], str]] = []
|
||||
header_segments = []
|
||||
for token in tokens:
|
||||
text = '\n'.join(token.lines)
|
||||
|
||||
for line_no, line in enumerate(lines):
|
||||
if in_code:
|
||||
# this is the contents of a code block
|
||||
segments[-1][1].append(line)
|
||||
if code_regex.match(line):
|
||||
in_code = False
|
||||
in_block = False
|
||||
|
||||
elif code_regex.match(line):
|
||||
# this is the start of a code block
|
||||
in_code = True
|
||||
segments.append((line_no, [line], 'code'))
|
||||
|
||||
elif under1_regex.match(line) and in_block and len(segments[-1][1]) == 1 and segments[-1][2] == 'p':
|
||||
# this is an underline for a setext-style H1
|
||||
# ugly but it works
|
||||
last = segments.pop()
|
||||
segments.append((last[0], last[1] + [line], 'h1'))
|
||||
|
||||
elif under2_regex.match(line) and in_block and len(segments[-1][1]) == 1 and segments[-1][2] == 'p':
|
||||
# this is an underline for a setext-style H2
|
||||
# ugly but it works
|
||||
last = segments.pop()
|
||||
segments.append((last[0], last[1] + [line], 'h2'))
|
||||
|
||||
elif bullet_regex.match(line):
|
||||
in_block = True
|
||||
segments.append((line_no, [line], 'li'))
|
||||
|
||||
elif match := header_regex.match(line):
|
||||
# this is a header
|
||||
kind = f'h{len(match["hashes"])}'
|
||||
segments.append((line_no, [line], kind))
|
||||
in_block = False
|
||||
|
||||
elif match := linkid_regex.match(line):
|
||||
# this is a link definition in the form '[id]: link', so add it to the link table
|
||||
self.links[match['link_id'].lower()] = match['link']
|
||||
|
||||
elif line.isspace():
|
||||
# skip empty lines
|
||||
in_block = False
|
||||
|
||||
elif in_block:
|
||||
# this is a line to be added to a paragraph
|
||||
segments[-1][1].append(line)
|
||||
else:
|
||||
# this is a new paragraph
|
||||
in_block = True
|
||||
segments.append((line_no, [line], 'p'))
|
||||
|
||||
for segment in segments:
|
||||
text = ''.join(segment[1]).strip()
|
||||
|
||||
if segment[2] == 'h2':
|
||||
if token.kind == 'h2':
|
||||
# start of a version
|
||||
|
||||
slug = text.rstrip('-').strip('#').strip()
|
||||
split = slug.split()
|
||||
if '-' in split:
|
||||
split.remove('-')
|
||||
|
||||
version = VersionEntry()
|
||||
versions.append(VersionEntry.from_header(text, line_no=token.line_no))
|
||||
section = ''
|
||||
|
||||
version.name = slug
|
||||
version.line_no = segment[0]
|
||||
tags = []
|
||||
date = None
|
||||
|
||||
for word in split[1:]:
|
||||
if match := re.match(r'\d{4}-\d{2}-\d{2}', word):
|
||||
# date
|
||||
try:
|
||||
date = datetime.date.fromisoformat(match[0])
|
||||
except ValueError:
|
||||
break
|
||||
elif match := re.match(r'^\[(?P<tag>\S*)]', word):
|
||||
tags.append(match['tag'])
|
||||
else:
|
||||
break
|
||||
|
||||
else:
|
||||
# matches the schema
|
||||
version.name, version.link, version.link_id = _strip_link(split[0])
|
||||
version.date = date
|
||||
version.tags = tags
|
||||
|
||||
self.versions.append(version)
|
||||
|
||||
elif len(self.versions) == 0:
|
||||
elif len(versions) == 0:
|
||||
# we haven't encountered any version headers yet,
|
||||
# so its best to just add this line to the header string
|
||||
header_segments.append(text)
|
||||
# so its best to just add this line to the preamble
|
||||
preamble_segments.append(text)
|
||||
|
||||
elif segment[2] == 'h3':
|
||||
elif token.kind == 'h3':
|
||||
# start of a version section
|
||||
section = text.strip('#').strip()
|
||||
if section not in self.versions[-1].sections.keys():
|
||||
self.versions[-1].sections[section] = []
|
||||
if section not in versions[-1].sections.keys():
|
||||
versions[-1].sections[section] = []
|
||||
|
||||
else:
|
||||
# change log entry
|
||||
self.versions[-1].sections[section].append(text)
|
||||
versions[-1].sections[section].append(text)
|
||||
|
||||
# handle links
|
||||
for version in self.versions:
|
||||
for version in versions:
|
||||
if match := re.fullmatch(r'\[(.*)]', version.name):
|
||||
# ref-matched link
|
||||
link_id = match[1].lower()
|
||||
if link_id in self.links:
|
||||
version.link = self.links[link_id]
|
||||
if link_id in links:
|
||||
version.link = links[link_id]
|
||||
version.link_id = None
|
||||
version.name = match[1]
|
||||
|
||||
elif version.link_id in self.links:
|
||||
elif version.link_id in links:
|
||||
# id-matched link
|
||||
version.link = self.links[version.link_id]
|
||||
version.link = links[version.link_id]
|
||||
|
||||
# strip whitespace from header
|
||||
self.header = _join_markdown(header_segments)
|
||||
self.preamble = markdown.join(preamble_segments)
|
||||
self.versions = versions
|
||||
self.links = links
|
||||
|
||||
def write(self, path=None) -> None:
|
||||
"""
|
||||
Write a changelog to a Markdown file.
|
||||
|
||||
:param path: The changelog's path on disk. By default, :py:attr:`~Changelog.path` is used.
|
||||
"""
|
||||
|
||||
def write(self, path: os.PathLike = None):
|
||||
if path is None:
|
||||
# use the object path if none was provided
|
||||
path = self.path
|
||||
|
||||
segments = [self.header]
|
||||
segments = []
|
||||
|
||||
if self.preamble:
|
||||
segments.append(self.preamble)
|
||||
|
||||
v_links = {**self.links}
|
||||
|
||||
for version in self.versions:
|
||||
if version.link:
|
||||
v_links[version.name.lower()] = version.link
|
||||
|
||||
segments.append(version.text())
|
||||
segments.append(version.text() + '\n')
|
||||
|
||||
segments += [f'[{link_id}]: {link}' for link_id, link in v_links.items()]
|
||||
|
||||
text = _join_markdown(segments)
|
||||
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)
|
||||
|
@ -14,16 +14,17 @@
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import click
|
||||
import os.path
|
||||
import datetime
|
||||
import git
|
||||
import yaclog.cli.version_util
|
||||
from yaclog import Changelog
|
||||
import os.path
|
||||
|
||||
import click
|
||||
|
||||
import yaclog.version
|
||||
from yaclog.changelog import Changelog
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.option('--path', envvar='YACLOG_PATH', default='CHANGELOG.md', show_default=True,
|
||||
@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()
|
||||
@ -32,7 +33,7 @@ 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`.')
|
||||
raise click.FileError(f'Changelog file {path} does not exist. Create it by running yaclog init.')
|
||||
|
||||
ctx.obj = yaclog.read(path)
|
||||
|
||||
@ -46,62 +47,93 @@ def init(obj: Changelog):
|
||||
os.remove(obj.path)
|
||||
|
||||
yaclog.Changelog(obj.path).write()
|
||||
print(f'Created new changelog file at {obj.path}')
|
||||
click.echo(f'Created new changelog file at {obj.path}')
|
||||
|
||||
|
||||
@cli.command('format') # dont accidentally hide the `format` python builtin
|
||||
@cli.command('format') # don't accidentally hide the `format` python builtin
|
||||
@click.pass_obj
|
||||
def reformat(obj: Changelog):
|
||||
"""Reformat the changelog file."""
|
||||
obj.write()
|
||||
print(f'Reformatted changelog file at {obj.path}')
|
||||
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.argument('versions', type=str, nargs=-1)
|
||||
@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.argument('version_names', metavar='VERSIONS', type=str, nargs=-1)
|
||||
@click.pass_obj
|
||||
def show(obj: Changelog, all_versions, versions):
|
||||
"""Show the changes for VERSIONS.
|
||||
def show(obj: Changelog, all_versions, markdown, mode, version_names):
|
||||
"""
|
||||
Show the changes for VERSIONS.
|
||||
|
||||
VERSIONS is a list of versions to print. If not given, the most recent version is used.
|
||||
"""
|
||||
if all_versions:
|
||||
with open(obj.path, 'r') as fp:
|
||||
click.echo_via_pager(fp.read())
|
||||
else:
|
||||
if len(versions):
|
||||
v_list = []
|
||||
for v_name in versions:
|
||||
matches = [v for v in obj.versions if v.name == v_name]
|
||||
if len(matches) == 0:
|
||||
raise click.BadArgumentUsage(f'Version "{v_name}" not found in changelog.')
|
||||
v_list += matches
|
||||
else:
|
||||
v_list = [obj.versions[0]]
|
||||
|
||||
for v in v_list:
|
||||
click.echo(v.text(False))
|
||||
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'
|
||||
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.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.
|
||||
"""
|
||||
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:
|
||||
matches = [v for v in obj.versions if v.name == version_name]
|
||||
if len(matches) == 0:
|
||||
raise click.BadArgumentUsage(f'Version "{version_name}" not found in changelog.')
|
||||
version = matches[0]
|
||||
version = obj.get_version(version_name)
|
||||
else:
|
||||
version = obj.versions[0]
|
||||
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)
|
||||
@ -109,22 +141,20 @@ def tag(obj: Changelog, add, tag_name: str, version_name: str):
|
||||
try:
|
||||
version.tags.remove(tag_name)
|
||||
except ValueError:
|
||||
raise click.BadArgumentUsage(f'Tag "{tag_name}" not found in version "{version.name}".')
|
||||
raise click.BadArgumentUsage(f"Tag {tag_name} not found in version {version.name}.")
|
||||
|
||||
obj.write()
|
||||
|
||||
|
||||
@cli.command(short_help='Add entries to the changelog.')
|
||||
@click.option('--bullet', '-b', 'bullets', multiple=True, type=str,
|
||||
help='Bullet points to add. '
|
||||
'When multiple bullet points are provided, additional points are added as sub-points.')
|
||||
@click.option('--paragraph', '-p', 'paragraphs', multiple=True, type=str,
|
||||
help='Paragraphs to add')
|
||||
@click.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
|
||||
"""
|
||||
Add entries to SECTION in VERSION
|
||||
|
||||
SECTION is the name of the section to append to. If not given, entries will be uncategorized.
|
||||
|
||||
@ -133,111 +163,154 @@ def entry(obj: Changelog, bullets, paragraphs, section_name, version_name):
|
||||
"""
|
||||
|
||||
section_name = section_name.title()
|
||||
try:
|
||||
if version_name:
|
||||
matches = [v for v in obj.versions if v.name == version_name]
|
||||
if len(matches) == 0:
|
||||
raise click.BadArgumentUsage(f'Version "{version_name}" not found in changelog.')
|
||||
version = matches[0]
|
||||
version = obj.get_version(version_name)
|
||||
else:
|
||||
matches = [v for v in obj.versions if v.name.lower() == 'unreleased']
|
||||
if len(matches) == 0:
|
||||
version = yaclog.changelog.VersionEntry()
|
||||
obj.versions.insert(0, version)
|
||||
else:
|
||||
version = matches[0]
|
||||
version = obj.current_version(released=False, new_version=True)
|
||||
except KeyError as k:
|
||||
raise click.BadArgumentUsage(str(k))
|
||||
|
||||
if section_name not in version.sections.keys():
|
||||
version.sections[section_name] = []
|
||||
for p in paragraphs:
|
||||
version.add_entry(p, section_name)
|
||||
|
||||
section = version.sections[section_name]
|
||||
section += paragraphs
|
||||
|
||||
sub_bullet = False
|
||||
bullet_str = ''
|
||||
for bullet in bullets:
|
||||
bullet = bullet.strip()
|
||||
if bullet[0] not in ['-+*']:
|
||||
bullet = '- ' + bullet
|
||||
|
||||
if sub_bullet:
|
||||
bullet = '\n ' + bullet
|
||||
|
||||
bullet_str += bullet
|
||||
sub_bullet = True
|
||||
section.append(bullet_str)
|
||||
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('-v', '--version', 'v_flag', type=str, default=None, help='The new version number to use.')
|
||||
@click.option('-M', '--major', 'v_flag', flag_value='+M', help='Increment major version number.')
|
||||
@click.option('-m', '--minor', 'v_flag', flag_value='+m', help='Increment minor version number.')
|
||||
@click.option('-p', '--patch', 'v_flag', flag_value='+p', help='Increment patch number.')
|
||||
@click.option('-a', '--alpha', 'v_flag', flag_value='+a', help='Increment alpha version number.')
|
||||
@click.option('-b', '--beta', 'v_flag', flag_value='+b', help='Increment beta version number.')
|
||||
@click.option('-r', '--rc', 'v_flag', flag_value='+rc', help='Increment release candidate version number.')
|
||||
@click.option('-c', '--commit', is_flag=True, help='Create a git commit tagged with the new version number.')
|
||||
@click.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, v_flag, commit):
|
||||
"""Release versions in the changelog and increment their version numbers"""
|
||||
matches = [v for v in obj.versions if v.name.lower() != 'unreleased']
|
||||
if len(matches) == 0:
|
||||
version = '0.0.0'
|
||||
else:
|
||||
version = matches[0].name
|
||||
def release(obj: Changelog, version_name, rel_seg, pre_seg, commit, cargo, yes, new):
|
||||
"""
|
||||
Release VERSION, or a version incremented from the last release.
|
||||
|
||||
cur_version = obj.versions[0]
|
||||
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 v_flag:
|
||||
if v_flag[0] == '+':
|
||||
new_name = yaclog.cli.version_util.increment_version(version, v_flag)
|
||||
if version_name:
|
||||
new_name = version_name
|
||||
else:
|
||||
new_name = v_flag
|
||||
for v in obj.versions:
|
||||
if v.version is not None:
|
||||
new_name = v.name
|
||||
break
|
||||
else:
|
||||
new_name = '0.0.0'
|
||||
|
||||
if yaclog.cli.version_util.is_release(cur_version.name):
|
||||
click.confirm(f'Rename release version "{cur_version.name}" to "{new_name}"?', abort=True)
|
||||
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()
|
||||
print(f'Renamed version "{old_name}" to "{cur_version.name}".')
|
||||
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.')
|
||||
raise click.BadOptionUsage('commit', f'Directory {os.path.abspath(os.curdir)} is not a git repo')
|
||||
|
||||
repo.index.add(obj.path)
|
||||
|
||||
version_type = '' if yaclog.cli.version_util.is_release(cur_version.name) else 'non-release '
|
||||
if cargo:
|
||||
repo.index.add("Cargo.toml")
|
||||
|
||||
tracked = len(repo.index.diff(repo.head.commit))
|
||||
tracked_warning = 'Create tag'
|
||||
untracked = len(repo.index.diff(None))
|
||||
untracked_warning = ''
|
||||
untracked_plural = 's' if untracked > 1 else ''
|
||||
|
||||
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:
|
||||
untracked_warning = click.style(
|
||||
f' You have {untracked} untracked file{untracked_plural} that will not be included.',
|
||||
fg='red', bold=True)
|
||||
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:
|
||||
tracked_warning = 'Commit and create tag'
|
||||
|
||||
click.confirm(f'{tracked_warning} for {version_type}version {cur_version.name}?{untracked_warning}',
|
||||
abort=True)
|
||||
|
||||
if tracked > 0:
|
||||
commit = repo.index.commit(f'Version {cur_version.name}\n\n{cur_version.body()}')
|
||||
print(f'Created commit {repo.head.commit.hexsha[0:7]}')
|
||||
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
|
||||
|
||||
repo_tag = repo.create_tag(cur_version.name, ref=commit, message=cur_version.body(False))
|
||||
print(f'Created tag "{repo_tag.name}".')
|
||||
# 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__':
|
||||
|
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()
|
@ -1,83 +0,0 @@
|
||||
# yaclog: yet another changelog tool
|
||||
# Copyright (c) 2021. Andrew Cassidy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from packaging.version import Version, InvalidVersion
|
||||
|
||||
|
||||
def is_release(version: str) -> bool:
|
||||
try:
|
||||
v = Version(version)
|
||||
return not (v.is_devrelease or v.is_prerelease)
|
||||
except InvalidVersion:
|
||||
return False
|
||||
|
||||
|
||||
def increment_version(version: str, mode: str) -> str:
|
||||
v = Version(version)
|
||||
epoch = v.epoch
|
||||
release = v.release
|
||||
pre = v.pre
|
||||
post = v.post
|
||||
dev = v.dev
|
||||
local = v.local
|
||||
|
||||
if mode == '+M':
|
||||
release = (release[0] + 1,) + ((0,) * len(release[1:]))
|
||||
pre = post = dev = None
|
||||
elif mode == '+m':
|
||||
release = (release[0], release[1] + 1) + ((0,) * len(release[2:]))
|
||||
pre = post = dev = None
|
||||
elif mode == '+p':
|
||||
release = (release[0], release[1], release[2] + 1) + ((0,) * len(release[3:]))
|
||||
pre = post = dev = None
|
||||
elif mode in ['+a', '+b', '+rc']:
|
||||
if pre[0] == mode[1:]:
|
||||
pre = (mode[1:], pre[1] + 1)
|
||||
else:
|
||||
pre = (mode[1:], 0)
|
||||
else:
|
||||
raise IndexError(f'Unknown mode {mode}')
|
||||
|
||||
return join_version(epoch, release, pre, post, dev, local)
|
||||
|
||||
|
||||
def join_version(epoch, release, pre, post, dev, local) -> str:
|
||||
parts = []
|
||||
|
||||
# Epoch
|
||||
if epoch != 0:
|
||||
parts.append(f"{epoch}!")
|
||||
|
||||
# Release segment
|
||||
parts.append(".".join(str(x) for x in release))
|
||||
|
||||
# Pre-release
|
||||
if pre is not None:
|
||||
parts.append("".join(str(x) for x in pre))
|
||||
|
||||
# Post-release
|
||||
if post is not None:
|
||||
parts.append(f".post{post}")
|
||||
|
||||
# Development release
|
||||
if dev is not None:
|
||||
parts.append(f".dev{dev}")
|
||||
|
||||
# Local version segment
|
||||
if local is not None:
|
||||
parts.append(f"+{local}")
|
||||
|
||||
return "".join(parts)
|
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
|