110 Commits

Author SHA1 Message Date
1f01bda2f4 dogfood own action 2024-08-20 00:28:48 -07:00
629d931979 Don't trample over existing python installs 2024-08-20 00:28:38 -07:00
d2296fb926 name and description 2024-08-19 23:31:42 -07:00
80e35de136 Add github action for getting version info and making releases 2024-08-19 23:30:45 -07:00
d6da31b6ff Release Version 1.3.0
### Added

- added a `--version` option to `yaclog show` that prints just the version number

### Changed

- removed support for Click 7 as a dependency
2024-08-08 00:51:31 -07:00
d4f477a544 Update build dependencies 2024-08-08 00:50:37 -07:00
a392f09a51 drop support for Click 7.0 2024-08-08 00:50:37 -07:00
683ccbf916 Bump actions/setup-python from 5.1.0 to 5.1.1
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.1.0 to 5.1.1.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5.1.0...v5.1.1)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-08 00:44:10 -07:00
2a67f6edc7 Infer version number for unreleased versions
increment the patch number. not always whats intended but at least produces something unique
2024-08-08 00:43:16 -07:00
c4be5d2420 add "--version" option to yaclog show 2024-08-08 00:30:18 -07:00
30947769e1 Readthedocs broke their dang config schema? 2024-04-16 00:08:25 -07:00
6589a91d7e Update CI workflow some more 2024-04-16 00:03:35 -07:00
802633b9a7 Update CI workflow 2024-04-16 00:02:09 -07:00
47d4b595f8 Release Version 1.2.0
### 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!
2024-04-15 23:49:30 -07:00
fdf30bc14c also make -y apply to git commit confirmations 2024-04-15 23:49:14 -07:00
72126c8dca Add -s, -n, and -y options to release 2024-04-15 23:45:24 -07:00
0666f7f593 Release Version 1.1.2
### 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
2022-12-29 00:34:58 -08:00
51e28e4ef0 cleanup 2022-12-29 00:34:27 -08:00
a7cbacb687 Update changelog 2022-12-28 20:49:58 -08:00
3fa529a05c Bump actions/setup-python from 4.3.1 to 4.4.0
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4.3.1 to 4.4.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v4.3.1...v4.4.0)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-12-27 20:26:05 -08:00
396960fae0 Only import toml and gitpython when necessary
Allows for using most commands on platforms without git installed (like the a-shell app on ios where gitpython doesnt quite work)
2022-12-27 20:21:18 -08:00
c661be05dc Bump actions/setup-python from 4.2.0 to 4.3.1
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4.2.0 to 4.3.1.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v4.2.0...v4.3.1)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-12-12 23:34:48 -08:00
2f4124c0fc Test with python 3.11 2022-12-12 23:33:48 -08:00
465b818ca2 Release Version 1.1.1
### Fixed

- Fixed `yaclog release -C -c` not committing changes to cargo.toml
2022-08-14 17:39:12 -07:00
32f20e677e Release Version 1.1.0
### Added

- Added a flag to update Rust Cargo.toml files when releasing a new version
2022-08-13 20:03:24 -07:00
8421d38164 Add cargo.toml support
Added a flag to update Rust Cargo.toml files when releasing a new version
2022-08-13 20:02:45 -07:00
c5030b6060 Bump actions/setup-python from 4.0.0 to 4.2.0
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4.0.0 to 4.2.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v4.0.0...v4.2.0)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-08-04 23:57:53 -07:00
4ce3de25c7 Bump actions/setup-python from 3.1.2 to 4.0.0
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 3.1.2 to 4.0.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v3.1.2...v4.0.0)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-09 23:15:47 -07:00
6bc99c585b Merge pull request #3 from drewcassidy/dependabot/github_actions/actions/setup-python-3.1.2
Bump actions/setup-python from 3.1.1 to 3.1.2
2022-04-12 18:24:29 -07:00
32abd7bc6b Bump actions/setup-python from 3.1.1 to 3.1.2
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 3.1.1 to 3.1.2.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v3.1.1...v3.1.2)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-12 11:18:19 +00:00
06df766f9f Remove license metadata (troves are fine)
oops: https://cybre.space/@cinebox/108095181597001366
2022-04-08 00:13:06 -07:00
9b0ae90ee2 Release Version 1.0.4
### Fixed

- Fixed tests folder being installed as a package
2022-04-08 00:02:03 -07:00
15e4d691f5 Add python 3.10 classifier 2022-04-08 00:01:45 -07:00
9a7e3da60d Stop gh actions from parsing "3.10" is a number 2022-04-07 23:53:19 -07:00
94f692e6c5 Run tests on Python 3.10 2022-04-07 23:47:51 -07:00
c7583388c6 Merge pull request #2 from drewcassidy/dependabot/github_actions/actions/setup-python-3.1.1
Bump actions/setup-python from 2 to 3.1.1
2022-04-07 23:45:34 -07:00
fe9aa937d2 Merge pull request #1 from drewcassidy/dependabot/github_actions/actions/checkout-3
Bump actions/checkout from 2 to 3
2022-04-07 23:44:59 -07:00
caa4560d6d Bump actions/setup-python from 2 to 3.1.1
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 2 to 3.1.1.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v2...v3.1.1)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-08 06:43:41 +00:00
03841ad07e Bump actions/checkout from 2 to 3
Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-08 06:43:38 +00:00
aa2390312a Enable dependabot 2022-04-07 23:43:17 -07:00
07d76cdc09 Update changelog 2022-04-07 23:35:37 -07:00
8c79e158c8 Slim package finding now that I understand it 2022-04-07 23:33:39 -07:00
21defeffce Migrate to PEP621 2022-04-07 00:10:16 -07:00
dccde1909b Release Version 1.0.3
### Fixed

- Fixed `show` command not working with Click version 8
- Fixed release message incorrectly stating if a commit will be created or not
2021-05-11 22:27:07 -07:00
c25b780772 Run tests with multiple versions of click
will be removed once click 8.0.0 reaches critical mass I guess?
2021-05-11 22:21:09 -07:00
bf2e8f670f Fix release messages 2021-05-11 22:13:50 -07:00
dc90731f3d Fix show command 2021-05-11 22:10:13 -07:00
5a6cb51d71 Release Version 1.0.2
### 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
2021-05-11 19:33:56 -07:00
52fc36ab70 Version Version 1.0.2
### 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
2021-05-11 19:26:46 -07:00
c696071b8f Fix last commit
gdi pycharm please stop doing that
2021-05-11 19:25:03 -07:00
b0419dad80 Fix tagging with spaces in version names 2021-05-11 19:23:21 -07:00
2bfaa78053 Version Version 1.0.2
### Changed

- Updated to support Click version 8
- Modified module documentation page titles to include a module role
2021-05-11 19:19:50 -07:00
524a1da4c6 Add 'Version' prefix to log 2021-05-11 19:18:54 -07:00
acedf2b401 Update docs 2021-05-11 19:12:31 -07:00
21b530c256 Update to support Click 8 2021-05-11 19:04:31 -07:00
04a9c712f9 update changelog 2021-05-09 19:42:01 -07:00
d35b7fee83 Fixed broken header in new changelogs 2021-05-09 19:39:48 -07:00
38560702f4 Path metavar 2021-05-08 00:00:41 -07:00
2d1cc4ede4 Metavar capitalization 2021-05-07 23:59:44 -07:00
66bc8509e3 Version 1.0.0
### Changed

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

### Removed

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

### Added

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

- Unit tests in the `tests` folder

### Changed

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

8
.github/dependabot.yml vendored Normal file
View 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"

View File

@ -1,28 +1,61 @@
# This workflow will upload a Python Package using Twine when a release is created
# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
name: Upload Python Package
on:
release:
types: [ published ]
name: build
on: [ push, pull_request ]
jobs:
deploy:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ]
click-version: [ "click~=8.0" ]
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5.1.1
with:
python-version: '3.x'
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
python -m pip install flake8
python -m pip install ${{ matrix.click-version }}
- name: Install module
run: python -m pip install .
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Run unit tests
run: python -m unittest -v
deploy:
# needs: test
runs-on: ubuntu-latest
# if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5.1.1
with:
python-version: '>=3.8'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install setuptools wheel twine
- name: Install pypa/build
run: python -m pip install build --user
@ -30,7 +63,20 @@ jobs:
- name: Build a binary wheel and source tarball
run: python -m build --sdist --wheel --outdir dist/
- name: Get version info
id: yaclog-show
uses: ./
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
- name: Publish to Github
uses: softprops/action-gh-release@v2
with:
files: dist/*
name: ${{ steps.yaclog-show.outputs.name }}
body_path: ${{ steps.yaclog-show.outputs.body_file }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

29
.readthedocs.yaml Normal file
View 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

View File

@ -2,7 +2,121 @@
All notable changes to this project will be documented in this file
## 0.3.2 - 2021-04-24
## Version 1.3.0 - 2024-08-08
### Added
- added a `--version` option to `yaclog show` that prints just the version number
### Changed
- removed support for Click 7 as a dependency
## Version 1.2.0 - 2024-04-16
### Added
- added the `-s` option to `yaclog release` to increment arbitrary version segments
- added the `-n` option to `yaclog release` to create a new release instead of releasing a new one
- added the `-y` option to `yaclog release` to answer "yes" to all confirmation dialogs. Use with caution!
## Version 1.1.2 - 2022-12-29
### Changed
- yaclog now only tries to use git when invoked with a command that needs it, meaning most sub commands can now be used on systems without git
## Version 1.1.1 - 2022-08-15
### Fixed
- Fixed `yaclog release -C -c` not committing changes to cargo.toml
## Version 1.1.0 - 2022-08-14
### Added
- Added a flag to update Rust Cargo.toml files when releasing a new version
## Version 1.0.4 - 2022-04-08
### Fixed
- Fixed tests folder being installed as a package
## Version 1.0.3 - 2021-05-12
### Fixed
- Fixed `show` command not working with Click version 8
- Fixed release message incorrectly stating if a commit will be created or not
## Version 1.0.2 - 2021-05-12
### Changed
- Updated to support Click version 8
- Modified module documentation page titles to include a module role
### Fixed
- Fixed tag names with spaces in versions
## Version 1.0.1 - 2021-05-10
### Fixed
- Fixed broken header in new changelogs
- Improved consistency in command documentation metavars
## Version 1.0.0 - 2021-05-07
### Changed
- API changes:
- `header` attribute renamed to `preamble` to avoid confusion.
- improved version header parsing to be more robust and handle multi-word version names.
- improved version number incrementing in `release`.
- can now handle other text surrounding a pep440-compliant version number, which will not be modified
- can now handle pre-releases correctly. The version to increment is the most recent version in the log with a valid pep440 version number in it.
- Release increment and prerelease increments can be mixed, allowing e.g: `yaclog release -mr` to create a release candidate with in incremented minor version number.
- `release` base version is now an argument instead of an option, for consistency with other commands.
### Removed
- `entry` with multiple `-b` options no longer add sub bullet points, instead adding each bullet as its own line.
### Added
- Terminal output has color to distinguish version names/headers, sections, and git information.
- Extra newlines are added between versions to improve readability of the raw markdown file.
## Version 0.3.3 - 2021-04-27
### Added
- Unit tests in the `tests` folder
### Fixed
- Default links and dates in VersionEntry are now consistently `None`
- Changelog links dict now contains version links. Modified version links will overwrite those in the table when writing to a file
- Changelog object no longer errors when creating without a path.
- `release` now resets lesser version values when incrementing
- `release` now works with logs that have only unreleased changes
## Version 0.3.2 - 2021-04-24
### Added
@ -17,19 +131,21 @@ 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
- `yaclog` tool for manipulating changelogs from the command line
- `init` command to make a new changelog
- `format` command to reformat the changelog
- `show` command to show changes from the changelog
- `entry` command for manipulating entries in the changelog
- `tag` command for manipulating tags in the changelog
- `release` command for creating releases
- `init` command to make a new changelog
- `format` command to reformat the changelog
- `show` command to show changes from the changelog
- `entry` command for manipulating entries in the changelog
- `tag` command for manipulating tags in the changelog
- `release` command for creating releases
## 0.2.0 - 2021-04-19
## Version 0.2.0 - 2021-04-19
### Added
@ -39,10 +155,11 @@ All notable changes to this project will be documented in this file
- Updated package metadata
- Rewrote parser to use a 2-step method that is more flexible.
- Parser can now handle code blocks.
- Parser can now handle setext-style headers and H2s not conforming to the schema.
- Parser can now handle code blocks.
- Parser can now handle setext-style headers and H2s not conforming to the schema.
## 0.1.0 - 2021-04-16
## Version 0.1.0 - 2021-04-16
First release

View File

@ -1,4 +1,9 @@
# Yaclog
# Yaclog
[![Documentation Status](https://readthedocs.org/projects/yaclog/badge/?version=latest)](https://yaclog.readthedocs.io/en/latest/?badge=latest)
[![Build Status](https://github.com/drewcassidy/yaclog/actions/workflows/python-publish.yml/badge.svg)](https://github.com/drewcassidy/yaclog/actions/workflows/python-publish.yml)
[![PyPI version](https://badge.fury.io/py/yaclog.svg)](https://badge.fury.io/py/yaclog)
Yet another changelog command line tool
![a yak who is a log](https://github.com/drewcassidy/yaclog/raw/main/logo.png)
@ -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".

87
action.yaml Normal file
View File

@ -0,0 +1,87 @@
name: 'Yaclog'
description: 'Get version information from a changelog, and optionally create a new release'
branding:
icon: file-text
color: blue
inputs:
markdown:
description: If outputs should be in markdown format or not
default: 'true'
release:
description: >
Creates a new release and commits it if set. Directly passed to the arguments of `yaclog release`.
Can be a version number or an increment tag like `--major`, `--minor`, or `--patch`.
The resulting commit and tag will be pushed back to the repo, but the workflow must have write permissions.
Add
```yaml
permissions:
contents: write
```
to your workflow to allow this.
outputs:
name:
description: "The current version name. For example, `Version 1.3.0`"
value: ${{ steps.yaclog-show.outputs.name}}
header:
description: "The entire header for the current version. For example, `Version 1.3.0 - 2024-08-08`"
value: ${{ steps.yaclog-show.outputs.header }}
version:
description: "The current version number. For example, `1.3.0`"
value: ${{ steps.yaclog-show.outputs.version }}
body_file:
description: "Path to a temporary file containing the version body"
value: ${{ steps.yaclog-show.outputs.body_file }}
changelog:
description: "Path to the entire changelog file."
value: ${{ steps.yaclog-show.outputs.changelog }}
runs:
using: "composite"
steps:
- id: setup-python
uses: actions/setup-python@v5
with:
python-version: '3.10'
update-environment: 'false'
- name: Setup Yaclog
shell: bash
run: |
${{ steps.setup-python.outputs.python-path }} -m pip install ${{ github.action_path }}
# now make it available in $PATH
# this whole rigamarole is because we are using a python root that isnt in $PATH as to not step over other actions
mkdir -p "$GITHUB_ACTION_PATH/bin"
echo '${{ steps.setup-python.outputs.python-path }} -m yaclog.cli $@' > "$GITHUB_ACTION_PATH/bin/yaclog"
chmod +x "$GITHUB_ACTION_PATH/bin/yaclog"
echo "$GITHUB_ACTION_PATH/bin" > "$GITHUB_PATH"
- name: Create New Release
shell: bash
if: ${{ inputs.release }}
run: yaclog release --yes --commit ${{ inputs.release }}
- name: Get Version Information
id: yaclog-show
shell: bash
run: |
yaclog show ---gh-actions ${{ inputs.markdown && '--markdown' }} >> "$GITHUB_OUTPUT"
# output like so:
# name=Version 1.3.0
# header=Version 1.3.0 - 2024-08-08
# version=1.3.0
# body_file={path to file containing version body}
# changelog={path to changelog}
- name: Push Changes
if: ${{ inputs.release }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
git config --global user.name "github-actions"
git config --global user.email "github-actions@github.com"
git push
git push --tags

20
docs/Makefile Normal file
View File

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

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

@ -0,0 +1,22 @@
/*
* yaclog: yet another changelog tool
* Copyright (c) 2021. Andrew Cassidy
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
.rst-content .toctree-wrapper:not(:last-child) ul {
/*margin-bottom: 0;*/
/* make adjacent toctrees appear to merge */
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 764 B

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

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

2
docs/changelog.md Normal file
View File

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

87
docs/conf.py Normal file
View File

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

BIN
docs/docs_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
docs/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

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

View File

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

View File

@ -0,0 +1,50 @@
# Getting Started
## Installation
Install and update using [pip](https://pip.pypa.io/en/stable/quickstart/):
```shell
$ pip install -U yaclog
```
## Usage
For detailed documentation on the {command}`yaclog` command and its subcommands see the {doc}`commands`.
### Example workflow
Create a new changelog in the current directory:
```shell
$ yaclog init
```
Add some new entries to the "Added" section of the current unreleased version:
```shell
$ yaclog entry -b 'Introduced some more bugs'
$ yaclog entry -b 'Introduced some more features'
```
Show the current version:
```shell
$ yaclog show
```
```
Unreleased
- Introduced some more bugs
- Introduced some more features
```
Release the current version and make a git tag for it
```shell
$ yaclog release 0.0.1 -c
```
```
Renamed version "Unreleased" to "0.0.1".
Commit and create tag for version 0.0.1? [y/N]: y
Created commit a7b6789
Created tag "0.0.1".
```

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

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

34
docs/index.md Normal file
View File

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

2
docs/license.md Normal file
View File

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

35
docs/make.bat Normal file
View File

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

View File

@ -0,0 +1,5 @@
:py:mod:`changelog` Module
==========================
.. automodule:: yaclog.changelog
:members:

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

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

View File

@ -0,0 +1,5 @@
:py:mod:`markdown` Module
=========================
.. automodule:: yaclog.markdown
:members:

View File

@ -0,0 +1,5 @@
:py:mod:`version` Module
========================
.. automodule:: yaclog.version
:members:

View File

@ -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"
[tool.setuptools_scm]
[project]
name = "yaclog"
description = "Yet another changelog CLI tool."
readme = "README.md"
authors = [{ name = "Andrew Cassidy", email = "drewcassidy@me.com" }]
keywords = ["changelog", "commandline", "markdown"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Affero General Public License v3",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Topic :: Text Processing :: Markup :: Markdown",
"Topic :: Software Development :: Version Control :: Git",
"Topic :: Utilities"
]
requires-python = ">= 3.8"
dependencies = [
"Click >= 8.0",
"GitPython >= 3",
"packaging >= 20",
"tomlkit >= 0.11"
]
dynamic = ["version"]
[project.optional-dependencies]
docs = [
"Sphinx >= 3.5",
"sphinx-click >= 2.7",
"sphinx-rtd-theme",
"myst-parser >= 0.14",
]
[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"]

View File

@ -1,39 +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

0
tests/__init__.py Normal file
View File

81
tests/common.py Normal file
View File

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

166
tests/test_changelog.py Normal file
View File

@ -0,0 +1,166 @@
import datetime
import os.path
import tempfile
import unittest
import yaclog
from tests.common import log, log_segments, log_text
from yaclog.changelog import VersionEntry
class TestParser(unittest.TestCase):
@classmethod
def setUpClass(cls):
with tempfile.TemporaryDirectory() as td:
cls.path = os.path.join(td, 'changelog.md')
with open(cls.path, 'w') as fd:
fd.write(log_text)
cls.log = yaclog.read(cls.path)
def test_path(self):
"""Test the log's path"""
self.assertEqual(self.path, self.log.path)
def test_preamble(self):
"""Test the preamble at the top of the file"""
self.assertEqual(log.preamble, self.log.preamble)
def test_links(self):
"""Test the links at the end of the file"""
self.assertEqual({'fullversion': 'http://endless.horse', **log.links}, self.log.links)
def test_versions(self):
"""Test the version headers"""
for i in range(len(self.log.versions)):
self.assertEqual(log.versions[i].name, self.log.versions[i].name)
self.assertEqual(log.versions[i].link, self.log.versions[i].link)
self.assertEqual(log.versions[i].date, self.log.versions[i].date)
self.assertEqual(log.versions[i].tags, self.log.versions[i].tags)
def test_entries(self):
"""Test the change entries"""
self.assertEqual(log.versions[0].sections, self.log.versions[0].sections)
class TestWriter(unittest.TestCase):
@classmethod
def setUpClass(cls):
with tempfile.TemporaryDirectory() as td:
cls.path = os.path.join(td, 'changelog.md')
log.write(cls.path)
with open(cls.path) as fd:
cls.log_text = fd.read()
cls.log_segments = [line.lstrip('\n') for line in cls.log_text.split('\n\n') if line]
def test_preamble(self):
"""Test the header information at the top of the file"""
self.assertEqual(log_segments[0:2], self.log_segments[0:2])
def test_links(self):
"""Test the links at the end of the file"""
self.assertEqual(
{'[fullversion]: http://endless.horse', '[id]: http://www.koalastothemax.com'},
set(self.log_segments[16:18]))
def test_versions(self):
"""Test the version headers"""
self.assertEqual('## [Tests]', self.log_segments[2])
self.assertEqual('## [FullVersion] - 1969-07-20 [TAG1] [TAG2]', self.log_segments[14])
self.assertEqual('## Long Version Name', self.log_segments[15])
def test_entries(self):
"""Test the change entries"""
self.assertEqual(log_segments[3], self.log_segments[3])
self.assertEqual('### Bullet Points', self.log_segments[4])
self.assertEqual(log_segments[5], self.log_segments[5])
self.assertEqual('### Blocks', self.log_segments[6])
self.assertEqual(log_segments[7:14], self.log_segments[7:14])
class TestVersionEntry(unittest.TestCase):
def test_header_name(self):
"""Test reading version names from headers"""
headers = {
'short': ('## Test', 'Test'),
'with dash': ('## Test - ', 'Test'),
'multi word': ('## Very long version name 1.0.0', 'Very long version name 1.0.0'),
'with brackets': ('## [Test]', '[Test]'),
}
for c, t in headers.items():
h = t[0]
with self.subTest(c, h=h):
version = VersionEntry.from_header(h)
self.assertEqual(version.name, t[1])
self.assertEqual(version.tags, [])
self.assertIsNone(version.date)
self.assertIsNone(version.link)
self.assertIsNone(version.link_id)
def test_header_tags(self):
"""Test reading version tags from headers"""
headers = {
'no dash': ('## Test [Foo] [Bar]', 'Test', ['FOO', 'BAR']),
'with dash': ('## Test - [Foo] [Bar]', 'Test', ['FOO', 'BAR']),
'with brackets': ('## [Test] [Foo] [Bar]', '[Test]', ['FOO', 'BAR']),
'with brackets & dash': ('## [Test] - [Foo] [Bar]', '[Test]', ['FOO', 'BAR']),
}
for c, t in headers.items():
h = t[0]
with self.subTest(c, h=h):
version = VersionEntry.from_header(h)
self.assertEqual(version.name, t[1])
self.assertEqual(version.tags, t[2])
self.assertIsNone(version.date)
self.assertIsNone(version.link)
self.assertIsNone(version.link_id)
def test_header_date(self):
"""Test reading version dates from headers"""
headers = {
'no dash': ('## Test 1961-04-12', 'Test',
datetime.date.fromisoformat('1961-04-12'), []),
'with dash': ('## Test 1969-07-20', 'Test',
datetime.date.fromisoformat('1969-07-20'), []),
'two dates': ('## 1981-07-20 1988-11-15', '1981-07-20',
datetime.date.fromisoformat('1988-11-15'), []),
'single date': ('## 2020-05-30', '2020-05-30', None, []),
'with tags': ('## 1.0.0 - 2021-04-19 [Foo] [Bar]', '1.0.0',
datetime.date.fromisoformat('2021-04-19'), ['FOO', 'BAR']),
}
for c, t in headers.items():
h = t[0]
with self.subTest(c, h=h):
version = VersionEntry.from_header(h)
self.assertEqual(version.name, t[1])
self.assertEqual(version.date, t[2])
self.assertEqual(version.tags, t[3])
self.assertIsNone(version.link)
self.assertIsNone(version.link_id)
def test_header_noncompliant(self):
"""Test reading version that dont fit the schema, and should just be read as literals"""
headers = {
'no space between tags': 'Test [Foo][Bar]',
'text at end': 'Test [Foo] [Bar] Test',
'invalid date': 'Test - 9999-99-99',
}
for c, h in headers.items():
with self.subTest(c, h=h):
version = VersionEntry.from_header('## ' + h)
self.assertEqual(version.name, h)
self.assertEqual(version.tags, [])
self.assertIsNone(version.date)
self.assertIsNone(version.link)
self.assertIsNone(version.link_id)
if __name__ == '__main__':
unittest.main()

323
tests/test_cli.py Normal file
View File

@ -0,0 +1,323 @@
import os.path
import unittest
import traceback
import git
from click.testing import CliRunner
import yaclog.changelog
from yaclog.cli.__main__ import cli
def check_result(runner, result, success: bool = True):
runner.assertEqual((result.exit_code == 0), success,
f'\noutput: {result.output}\ntraceback: ' + ''.join(
traceback.format_exception(*result.exc_info)))
class TestCreation(unittest.TestCase):
def test_init(self):
"""Test creating and overwriting a changelog"""
runner = CliRunner()
location = 'CHANGELOG.md'
err_str = 'THIS FILE WILL BE OVERWRITTEN'
with runner.isolated_filesystem():
result = runner.invoke(cli, ['init'])
check_result(self, result)
self.assertTrue(os.path.exists(os.path.abspath(location)), 'yaclog init did not create a file')
self.assertIn(location, result.output, "yaclog init did not echo the file's correct location")
with open(location, 'r') as fp:
self.assertEqual('# Changelog\n', fp.readline())
self.assertEqual('\n', fp.readline())
self.assertEqual('All notable changes to this project will be documented in this file',
fp.readline().rstrip())
with open(location, 'w') as fp:
fp.write(err_str)
result = runner.invoke(cli, ['init'], input='y\n')
check_result(self, result)
self.assertTrue(os.path.exists(os.path.abspath(location)), 'file no longer exists after overwrite')
self.assertIn(location, result.output, "yaclog init did not echo the file's correct location")
with open(location, 'r') as fp:
self.assertNotEqual(fp.read(), err_str, 'file was not overwritten')
def test_init_path(self):
"""Test creating a changelog with a non-default filename"""
runner = CliRunner()
location = 'A different file.md'
with runner.isolated_filesystem():
result = runner.invoke(cli, ['--path', location, 'init'])
check_result(self, result)
self.assertTrue(os.path.exists(os.path.abspath(location)), 'yaclog init did not create a file')
self.assertIn(location, result.output, "yaclog init did not echo the file's correct location")
def test_does_not_exist(self):
"""Test if an error is thrown when the file does not exist"""
runner = CliRunner()
with runner.isolated_filesystem():
result = runner.invoke(cli, ['show'])
check_result(self, result, False)
self.assertIn('does not exist', result.output)
class TestTagging(unittest.TestCase):
def test_tag_addition(self):
"""Test adding tags to versions"""
runner = CliRunner()
location = 'CHANGELOG.md'
with runner.isolated_filesystem():
in_log = yaclog.Changelog(location)
in_log.versions = [yaclog.changelog.VersionEntry(), yaclog.changelog.VersionEntry()]
in_log.versions[0].name = '1.0.0'
in_log.versions[1].name = '0.9.0'
in_log.write()
result = runner.invoke(cli, ['tag', 'tag1'])
check_result(self, result)
result = runner.invoke(cli, ['tag', 'tag2', '0.9.0'])
check_result(self, result)
out_log = yaclog.read(location)
self.assertEqual(out_log.versions[0].tags, ['TAG1'])
self.assertEqual(out_log.versions[1].tags, ['TAG2'])
result = runner.invoke(cli, ['tag', 'tag3', '0.8.0'])
check_result(self, result, False)
def test_tag_deletion(self):
"""Test deleting tags from versions"""
runner = CliRunner()
location = 'CHANGELOG.md'
with runner.isolated_filesystem():
in_log = yaclog.Changelog(location)
in_log.versions = [None, None]
in_log.versions = [yaclog.changelog.VersionEntry(), yaclog.changelog.VersionEntry()]
in_log.versions[0].name = '1.0.0'
in_log.versions[0].tags = ['TAG1']
in_log.versions[1].name = '0.9.0'
in_log.versions[1].tags = ['TAG2']
in_log.write()
result = runner.invoke(cli, ['tag', '-d', 'tag2', '0.8.0'])
check_result(self, result, False)
result = runner.invoke(cli, ['tag', '-d', 'tag3', '0.9.0'])
check_result(self, result, False)
result = runner.invoke(cli, ['tag', '-d', 'tag1'])
check_result(self, result)
out_log = yaclog.read(location)
self.assertEqual(out_log.versions[0].tags, [])
self.assertEqual(out_log.versions[1].tags, ['TAG2'])
result = runner.invoke(cli, ['tag', '-d', 'tag2', '0.9.0'])
check_result(self, result)
out_log = yaclog.read(location)
self.assertEqual(out_log.versions[0].tags, [])
self.assertEqual(out_log.versions[1].tags, [])
class TestRelease(unittest.TestCase):
def test_increment(self):
"""Test version incrementing on release"""
runner = CliRunner()
location = 'CHANGELOG.md'
with runner.isolated_filesystem():
runner.invoke(cli, ['init']) # create the changelog
runner.invoke(cli, ['entry', '-b', 'entry number 1'])
result = runner.invoke(cli, ['release', '1.0.0'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '1.0.0')
self.assertIn('1.0.0', result.output)
runner.invoke(cli, ['entry', '-b', 'entry number 2'])
result = runner.invoke(cli, ['release', '-p'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '1.0.1')
self.assertIn('1.0.1', result.output)
result = runner.invoke(cli, ['release', '-y', '-s', 2])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '1.0.2')
self.assertIn('1.0.2', result.output)
runner.invoke(cli, ['entry', '-b', 'entry number 3'])
result = runner.invoke(cli, ['release', '-m'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '1.1.0')
self.assertIn('1.1.0', result.output)
runner.invoke(cli, ['entry', '-b', 'entry number 4'])
result = runner.invoke(cli, ['release', '-M'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '2.0.0')
self.assertIn('2.0.0', result.output)
runner.invoke(cli, ['entry', '-b', 'entry number 5'])
result = runner.invoke(cli, ['release', '-Ma'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '3.0.0a1')
self.assertIn('3.0.0a1', result.output)
result = runner.invoke(cli, ['release', '-b'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '3.0.0b1')
self.assertIn('3.0.0b1', result.output)
result = runner.invoke(cli, ['release', '-r'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '3.0.0rc1')
self.assertIn('3.0.0rc1', result.output)
result = runner.invoke(cli, ['release', '-r'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '3.0.0rc2')
self.assertIn('3.0.0rc1', result.output)
result = runner.invoke(cli, ['release', '-f'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '3.0.0')
self.assertIn('3.0.0', result.output)
result = runner.invoke(cli, ['release', '-p', '-n'])
check_result(self, result)
self.assertEqual(yaclog.read(location).versions[0].name, '3.0.1')
self.assertEqual(yaclog.read(location).versions[1].name, '3.0.0')
self.assertIn('3.0.1', result.output)
def test_commit(self):
"""Test committing and tagging releases"""
runner = CliRunner()
with runner.isolated_filesystem():
repo = git.Repo.init(os.path.join(os.curdir, 'testing'))
os.chdir('testing')
repo.index.commit('initial commit')
with repo.config_writer() as cw:
cw.set_value('user', 'email', 'unit-tester@example.com')
cw.set_value('user', 'name', 'unit-tester')
runner.invoke(cli, ['init']) # create the changelog
runner.invoke(cli, ['entry', '-b', 'entry number 1'])
result = runner.invoke(cli, ['release', 'Version 1.0.0', '-c'], input='y\n')
check_result(self, result)
self.assertIn('Created commit', result.output)
self.assertIn('Created tag', result.output)
self.assertIn(repo.head.commit.hexsha[0:7], result.output)
self.assertEqual(repo.tags[0].name, '1.0.0')
def test_cargo(self):
"""Test updating cargo.toml files"""
runner = CliRunner()
with runner.isolated_filesystem():
with open("Cargo.toml", "w") as fp:
fp.write((
'[package]\n'
'name = "dummy"\n'
'version = "0.3.4"\n'
'authors = ["Andrew Cassidy <drewcassidy@me.com>"]\n'
'description = "A dummy crate used for testing yaclog"\n'
'keywords = ["does", "not", "exist"]\n'
'edition = "2018"\n'
))
runner.invoke(cli, ['init']) # create the changelog
runner.invoke(cli, ['entry', '-b', 'entry number 1'])
result = runner.invoke(cli, ['release', 'Version 1.0.0', '-C'])
check_result(self, result)
with open("Cargo.toml", "r") as fp:
self.assertIn('version = "1.0.0"', fp.read())
# we're just going to trust tomlkit not to mangle everything else
class TestShow(unittest.TestCase):
# noinspection PyShadowingNames
def setUp(self):
self.runner = CliRunner()
self.location = 'CHANGELOG.md'
self.log = yaclog.Changelog()
self.log.add_version(name='1.0.0').add_entry('- entry number 1')
self.log.add_version(name='Version 2.0.0').add_entry('- entry number 2', 'Added')
self.log.add_version(name='Three Point Oh').add_entry('entry number 3')
v = self.log.add_version(name='4.0.0 "Euclid"')
v.add_entry('- entry number 4')
v.add_entry('- entry number 5')
v.tags.append('TAGGED')
self.modes = {
'full': ([], lambda v, k: v.text(**k), '\n\n'),
'name': (['-n'], lambda v, k: v.name, '\n'),
'body': (['-b'], lambda v, k: v.body(**k), '\n\n'),
'header': (['-h'], lambda v, k: v.header(**k), '\n'),
}
def test_show_all(self):
"""Test showing all version information"""
with self.runner.isolated_filesystem():
self.log.write(self.location)
for mode, t in self.modes.items():
with self.subTest(mode, flags=t[0]):
check_result(self, result := self.runner.invoke(cli, ['show', '-a'] + t[0]))
self.assertEqual(t[2].join([t[1](v, {'md': False}) for v in self.log.versions]),
result.output.strip(), 'incorrect plaintext output')
check_result(self, result := self.runner.invoke(cli, ['show', '-am'] + t[0]))
self.assertEqual(t[2].join([t[1](v, {'md': True}) for v in self.log.versions]),
result.output.strip(), 'incorrect markdown output')
def test_show_version(self):
with self.runner.isolated_filesystem():
self.log.write(self.location)
for mode, t in self.modes.items():
with self.subTest(mode, flags=t[0]):
for version in self.log.versions:
check_result(self, result := self.runner.invoke(cli, ['show', version.name] + t[0]))
self.assertEqual(t[1](version, {'md': False}),
result.output.strip(), 'incorrect plaintext output')
check_result(self, result := self.runner.invoke(cli, ['show', version.name[-5:]] + t[0]))
self.assertEqual(t[1](version, {'md': False}),
result.output.strip(), 'incorrect plaintext output')
check_result(self, result := self.runner.invoke(cli, ['show', version.name, '-m'] + t[0]))
self.assertEqual(t[1](version, {'md': True}),
result.output.strip(), 'incorrect markdown output')
check_result(self, result := self.runner.invoke(cli, ['show', version.name[-5:], '-m'] + t[0]))
self.assertEqual(t[1](version, {'md': True}),
result.output.strip(), 'incorrect markdown output')
if __name__ == '__main__':
unittest.main()

View File

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

View File

@ -1,3 +1,8 @@
"""
Contains the `Changelog` class that represents a parsed changelog file that can be read from and written to
disk as markdown, as well as the `VersionEntry` class that represents a single version within that changelog.
"""
# yaclog: yet another changelog tool
# Copyright (c) 2021. Andrew Cassidy
#
@ -14,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: str = ''
self.link_id: str = ''
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,178 +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: os.PathLike = 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 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 = []
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.pop(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.pop(version.link_id)
version.link = links[version.link_id]
# strip whitespace from header
self.header = _join_markdown(header_segments)
self.preamble = markdown.join(preamble_segments)
self.versions = versions
self.links = links
def write(self, path=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
v_links = {}
v_links.update(self.links)
segments = []
segments = [self.header]
if self.preamble:
segments.append(self.preamble)
v_links = {**self.links}
for version in self.versions:
if version.link:
v_links[version.name] = version.link
v_links[version.name.lower()] = version.link
segments.append(version.text())
segments.append(version.text() + '\n')
for link_id, link in v_links.items():
segments.append(f'[{link_id.lower()}]: {link}')
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)

View File

@ -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,108 @@ 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.option('---gh-actions', 'gh_actions', is_flag=True, hidden=True)
@click.argument('version_names', metavar='VERSIONS', type=str, nargs=-1)
@click.pass_obj
def show(obj: Changelog, all_versions, versions):
"""Show the changes for VERSIONS.
def show(obj: Changelog, all_versions, markdown, mode, version_names, gh_actions):
"""
Show the changes for VERSIONS.
VERSIONS is a list of versions to print. If not given, the most recent version is used.
"""
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'
if gh_actions:
import tempfile
all_modes = [ 'name', 'header', 'version' ]
outputs = [f'{mode}={sep.join([functions[mode](v, kwargs) for v in versions])}' for mode in all_modes]
click.echo('\n'.join(outputs))
body_fd, body_file = tempfile.mkstemp(text=True)
with os.fdopen(body_fd, 'w') as f:
f.write(sep.join([functions['body'](v, kwargs) for v in versions]))
click.echo(f'body_file={body_file}')
click.echo(f'changelog={obj.path}')
return
click.echo(sep.join([str_func(v, kwargs) for v in versions]))
@cli.command(short_help='Modify version tags')
@click.option('--add/--delete', '-a/-d', default=True, is_flag=True, help='Add or delete tags')
@click.argument('tag_name', metavar='tag', type=str)
@click.argument('version_name', metavar='version', type=str, required=False)
@click.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()
if version_name:
matches = [v for v in obj.versions if v.name == version_name]
if len(matches) == 0:
raise click.BadArgumentUsage(f'Version "{version_name}" not found in changelog.')
version = matches[0]
else:
version = obj.versions[0]
try:
if version_name:
version = obj.get_version(version_name)
else:
version = obj.current_version()
except KeyError as k:
raise click.BadArgumentUsage(str(k))
except ValueError as v:
raise click.ClickException(str(v))
if add:
version.tags.append(tag_name)
@ -109,22 +156,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,104 +178,154 @@ def entry(obj: Changelog, bullets, paragraphs, section_name, version_name):
"""
section_name = section_name.title()
if version_name:
matches = [v for v in obj.versions if v.name == version_name]
if len(matches) == 0:
raise click.BadArgumentUsage(f'Version "{version_name}" not found in changelog.')
version = matches[0]
else:
matches = [v for v in obj.versions if v.name.lower() == 'unreleased']
if len(matches) == 0:
version = yaclog.changelog.VersionEntry()
obj.versions.insert(0, version)
try:
if version_name:
version = obj.get_version(version_name)
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"""
version = [v for v in obj.versions if v.name.lower() != 'unreleased'][0]
cur_version = obj.versions[0]
def release(obj: Changelog, version_name, rel_seg, pre_seg, commit, cargo, yes, new):
"""
Release VERSION, or a version incremented from the last release.
VERSION is the name of the version to release. If VERSION is not provided but increment options are, then the most
recent valid PEP440 version number is used instead.
The most recent version in the log will be renamed (except by the --commit option) by using the VERSION as well as
any increment options. Increment options will always reset the later segments, and prerelease increments will clear
other kinds of prerelease.
"""
if rel_seg is None and pre_seg is None and not version_name and not commit and not cargo:
click.echo('Nothing to release!')
raise click.Abort
if new:
cur_version = obj.add_version()
else:
cur_version = obj.current_version()
old_name = cur_version.name
if v_flag:
if v_flag[0] == '+':
new_name = yaclog.cli.version_util.increment_version(version.name, v_flag)
if version_name:
new_name = version_name
else:
for v in obj.versions:
if v.version is not None:
new_name = v.name
break
else:
new_name = v_flag
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'
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
click.confirm(f'{tracked_warning} for {version_type}version {cur_version.name}?{untracked_warning}',
abort=True)
if tracked > 0:
repo.index.commit(f'Version {cur_version.name}\n\n{cur_version.body()}')
print(f'Created commit {repo.head.commit.hexsha[0:7]}')
repo.create_tag(cur_version.name, message=cur_version.body(False))
print(f'Created tag "{cur_version.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
View 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()

View File

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

165
yaclog/markdown.py Normal file
View File

@ -0,0 +1,165 @@
"""
Tools for parsing and manipulating markdown, including a very basic tokenizer.
"""
# yaclog: yet another changelog tool
# Copyright (c) 2021. Andrew Cassidy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import re
from typing import List
bullets = '+-*'
brackets = '[]'
code_regex = re.compile(r'^```')
header_regex = re.compile(r'^(?P<hashes>#+)\s+(?P<contents>[^#]+)(?:\s+#+)?$')
li_regex = re.compile(r'^[-+*] |\d+\. ')
numbered_regex = re.compile(r'^\d+\. ')
bullet_regex = re.compile(r'^[-+*] ')
link_id_regex = re.compile(r'^\[(?P<link_id>\S*)]:\s*(?P<link>.*)')
link_def_regex = re.compile(r'\[(?P<text>.*?)]\[(?P<link_id>.*?)]') # deferred link in the form [name][id]
link_lit_regex = re.compile(r'\[(?P<text>.*?)]\((?P<link>.*?)\)') # literal link in the form [name](url)
setext_h1_replace_regex = re.compile(r'(?<=\n)(?P<header>[^\n]+?)\n=+[ \t]*(?=\n)')
setext_h2_replace_regex = re.compile(r'(?<=\n)(?P<header>[^\n]+?)\n-+[ \t]*(?=\n)')
def strip_link(text):
"""
Parses and removes any links from the input string
:param text: An input string which may be a markdown link, either literal or an ID
:return: A tuple of (name, url, id). If the input is not a link, it is returned verbatim as the name.
"""
if link_lit := link_lit_regex.fullmatch(text):
# in the form [name](link)
return link_lit['text'], link_lit['link'], None
if link_def := link_def_regex.fullmatch(text):
# in the form [name][id] where id is hopefully linked somewhere else in the document
return link_def['text'], None, link_def['link_id'].lower()
return text, None, None
def join(segments: List[str]) -> str:
"""
Joins multiple lines of markdown by adding double newlines between them, or a single newline between list items
:param segments: A list of strings to join
:return: A joined markdown string
"""
text: List[str] = []
last_segment = ''
for segment in segments:
if bullet_regex.match(segment) and bullet_regex.match(last_segment):
pass
elif numbered_regex.match(segment) and numbered_regex.match(last_segment):
pass
else:
text.append('')
text.append(segment)
last_segment = segment
return '\n'.join(text).strip()
class Token:
"""A single tokenized block of markdown, consisting of one or more lines of text."""
def __init__(self, line_no: int, lines: List[str], kind: str):
self.line_no = line_no
"""Which line this block appears on in the original file"""
self.lines = lines
"""The lines of text making up this block"""
self.kind = kind
"""What kind of token this is. One of ``h[1-6]``, ``p``, ``li`` or ``code``"""
def __str__(self):
return f'{self.kind}: {self.lines}'
def tokenize(text: str):
"""
Tokenize a markdown string
The tokenizer is very basic, and only cares about the highest-level blocks
(Headers, top-level list items, links, code blocks, paragraphs).
:param text: input text to tokenize
:return: A list of tokens and a dictionary of links
"""
# convert setext-style headers
# The extra newline is to preserve line numbers
text = setext_h1_replace_regex.sub(r'# \g<header>\n', text)
text = setext_h2_replace_regex.sub(r'## \g<header>\n', text)
lines = text.split('\n')
tokens: List[Token] = []
links = {}
# state variables for parsing
block = None
for line_no, line in enumerate(lines):
if block == 'code':
# this is the contents of a code block
assert block == tokens[-1].kind, 'block state variable in invalid state!'
tokens[-1].lines.append(line)
if code_regex.match(line):
block = None
elif code_regex.match(line):
# this is the start of a code block
tokens.append(Token(line_no, [line], 'code'))
block = 'code'
elif li_regex.match(line):
# this is a list item
tokens.append(Token(line_no, [line], 'li'))
block = 'li'
elif match := header_regex.match(line):
# this is a header
kind = f'h{len(match["hashes"])}'
tokens.append(Token(line_no, [line], kind))
elif match := link_id_regex.match(line):
# this is a link definition in the form '[id]: link'
links[match['link_id'].lower()] = match['link']
block = None
elif not line or line.isspace():
# skip empty lines and reset block
block = None
elif block:
# this is a line to be added to a paragraph or list item
assert block == tokens[-1].kind, f'block state variable in invalid state! {block} != {tokens[-1].kind}'
tokens[-1].lines.append(line)
else:
# this is a new paragraph
tokens.append(Token(line_no, [line], 'p'))
block = 'p'
return tokens, links

120
yaclog/version.py Normal file
View File

@ -0,0 +1,120 @@
"""
Various helper functions for analyzing and manipulating :pep:`440` version numbers,
meant to augment the `packaging.version` module.
"""
# yaclog: yet another changelog tool
# Copyright (c) 2021. Andrew Cassidy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import re
from typing import Optional, Tuple
from packaging.version import Version, VERSION_PATTERN
version_regex = re.compile(VERSION_PATTERN, re.VERBOSE | re.IGNORECASE)
def extract_version(version_str: str) -> Tuple[Optional[Version], int, int]:
"""
Extracts a :pep:`440` version object from a string which may have other text
:param version_str: The input string to extract from
:return: A tuple of (version, start, end), where start and end are the span of the version in the original string
"""
match = version_regex.search(version_str)
if not match:
return None, -1, -1
return (Version(match[0]),) + match.span()
def increment_version(version_str: str, rel_seg: int = None, pre_seg: str = None) -> str:
"""
Increment the :pep:`440` version number in a string
:param version_str: The input string to manipulate
:param rel_seg: Which segment of the "release" value to increment, if any
:param pre_seg: Which kind of prerelease to use, if any. An empty string clears the prerelease field.
:return: The original string with the version number incremented
"""
v, *span = extract_version(version_str)
epoch = v.epoch
release = v.release
pre = v.pre
post = v.post
dev = v.dev
local = v.local
if rel_seg is not None:
if len(release) <= rel_seg:
release += (0,) * (1 + rel_seg - len(release))
release = release[0:rel_seg] + (release[rel_seg] + 1,) + (0,) * (len(release) - rel_seg - 1)
pre = None
if pre_seg is not None:
if pre_seg == '': # full release, clear prerelease field
pre = None
elif pre and pre[0] == pre_seg: # increment current prerelease type
pre = (pre_seg, pre[1] + 1)
else:
pre = (pre_seg, 1) # set prerelease field
new_v = join_version(epoch, release, pre, post, dev, local)
return version_str[0:span[0]] + new_v + version_str[span[1]:]
def join_version(epoch, release, pre, post, dev, local) -> str:
"""Join multiple segments of a :pep:`440` version"""
parts = []
# Epoch
if epoch != 0:
parts.append(f"{epoch}!")
# Release segment
parts.append(".".join(str(x) for x in release))
# Pre-release
if pre is not None:
parts.append("".join(str(x) for x in pre))
# Post-release
if post is not None:
parts.append(f".post{post}")
# Development release
if dev is not None:
parts.append(f".dev{dev}")
# Local version segment
if local is not None:
parts.append(f"+{local}")
return "".join(parts)
def is_release(version_str: str) -> bool:
"""
Check if a version string is a release version
:param version_str: the input string to check
:return: True if the input contains a released :pep:`440` version,
or False if a prerelease version or no version is found
"""
v, *span = extract_version(version_str)
if v:
return not (v.is_devrelease or v.is_prerelease)
else:
return False