diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3f0e5e..1166857 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 244c9a4..a098713 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fe30c3b..ad037fe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ repos: - repo: https://github.com/psf/black - rev: 22.10.0 + rev: 22.12.0 hooks: - id: black diff --git a/CHANGELOG.md b/CHANGELOG.md index 954d599..d2f71f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.0.dev4] - 2022-12-22 +### Added +- Add a CLI to interact with `keepachangelog` API. (Thanks [Luca Faggianelli](https://github.com/lucafaggianelli)) + +### Changed +- Changelog file is now expected to be `utf-8` encoded when read. (Thanks [0x55aa](https://github.com/665465)) +- Changelog file is now `utf-8` encoded when written. + ## [2.0.0.dev3] - 2022-10-19 ### Fixed - `keepachangelog.from_dict` now returns a single new line at end of file in case no url could be found. (Thanks [rockstarr-programmerr](https://github.com/rockstarr-programmerr)) @@ -51,7 +59,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `keepachangelog.release` is now allowing to provide a custom new version thanks to the new `new_version` parameter. ### Fixed -- `keepachangelog.release` now allows `pre-release` and `build metadata` information as part of valid semantic version. As per [semantic versioning specifications](https://semver.org). +- `keepachangelog.release` now allows `pre-release` and `build metadata` information as part of valid semantic version. As per [semantic versioning specifications](https://semver.org). To ensure compatibility with some python specific versioning, `pre-release` is also handled as not being prefixed with `-`, or prefixed with `.`. - `keepachangelog.release` will now bump a pre-release version to a stable version. It was previously failing. @@ -91,7 +99,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release. -[Unreleased]: https://github.com/Colin-b/keepachangelog/compare/v2.0.0.dev3...HEAD +[Unreleased]: https://github.com/Colin-b/keepachangelog/compare/v2.0.0.dev4...HEAD +[2.0.0.dev4]: https://github.com/Colin-b/keepachangelog/compare/v2.0.0.dev3...v2.0.0.dev4 [2.0.0.dev3]: https://github.com/Colin-b/keepachangelog/compare/v2.0.0.dev2...v2.0.0.dev3 [2.0.0.dev2]: https://github.com/Colin-b/keepachangelog/compare/v2.0.0.dev1...v2.0.0.dev2 [2.0.0.dev1]: https://github.com/Colin-b/keepachangelog/compare/v2.0.0.dev0...v2.0.0.dev1 diff --git a/README.md b/README.md index 4fb90e9..d4dfdee 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Build status Coverage Code style: black -Number of tests +Number of tests Number of downloads

@@ -299,3 +299,37 @@ Note: [flask-restx](https://pypi.python.org/pypi/flask-restx) module must be ins ```sh python -m pip install keepachangelog ``` + +## Usage from command line + +`keepachangelog` can be used directly via command line: + +```sh +# Run it as a Python module +python -m keepachangelog --help +# or as a shell command +keepachangelog --help + +# usage: keepachangelog [-h] [-v] {show,release} ... +# +# Manipulate keep a changelog files +# +# options: +# -h, --help show this help message and exit +# -v, --version show program's version number and exit +# +# commands: +# {show,release} +# show Show the content of a release from the changelog +# release Create a new release in the changelog +# +# Examples: +# +# keepachangelog show 1.0.0 +# keepachangelog show 1.0.0 --raw +# keepachangelog show 1.0.0 path/to/CHANGELOG.md +# +# keepachangelog release +# keepachangelog release 1.0.1 +# keepachangelog release 1.0.1 -f path/to/CHANGELOG.md +``` diff --git a/keepachangelog/__main__.py b/keepachangelog/__main__.py new file mode 100644 index 0000000..834b2e9 --- /dev/null +++ b/keepachangelog/__main__.py @@ -0,0 +1,131 @@ +from typing import List +import argparse + +import keepachangelog +from keepachangelog.version import __version__ + + +def _format_change_section(change_type: str, changes: List[str]): + body = "".join([f" - {change}\r\n" for change in changes]) + + return f"""{change_type.capitalize()} +{body}""" + + +def _command_show(args): + output = None + + if args.raw: + changelog = keepachangelog.to_raw_dict(args.file) + else: + changelog = keepachangelog.to_dict(args.file) + + content = changelog.get(args.release) + + if args.raw: + output = content["raw"] + else: + output = "\n".join( + [ + _format_change_section(change_type, changes) + for change_type, changes in content.items() + if change_type != "metadata" + ] + ) + + print(output) + + +def _command_release(args): + new_version = keepachangelog.release(args.file, args.release) + + if new_version: + print(new_version) + + +def _parse_args(cmdline: List[str]): + class CustomFormatter( + argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter + ): + pass + + parser = argparse.ArgumentParser( + prog="keepachangelog", + description="Manipulate keep a changelog files", + epilog=""" +Examples: + + keepachangelog show 1.0.0 + keepachangelog show 1.0.0 --raw + keepachangelog show 1.0.0 path/to/CHANGELOG.md + + keepachangelog release + keepachangelog release 1.0.1 + keepachangelog release 1.0.1 -f path/to/CHANGELOG.md +""", + formatter_class=CustomFormatter, + ) + + subparser = parser.add_subparsers(title="commands") + + # keepachangelog show + parser_show_help = "Show the content of a release from the changelog" + parser_show: argparse.ArgumentParser = subparser.add_parser( + "show", description=parser_show_help, help=parser_show_help + ) + parser_show.formatter_class = CustomFormatter + + parser_show.add_argument( + "release", type=str, help="The version to search in the changelog" + ) + parser_show.add_argument( + "file", + type=str, + nargs="?", + default="CHANGELOG.md", + help="The path to the changelog file", + ) + parser_show.add_argument( + "-r", "--raw", action="store_true", help="Show the raw markdown body" + ) + + parser_show.set_defaults(func=_command_show) + + # keepachangelog release + parser_release_help = "Create a new release in the changelog" + parser_release: argparse.ArgumentParser = subparser.add_parser( + "release", description=parser_release_help, help=parser_release_help + ) + parser_release.formatter_class = CustomFormatter + + parser_release.add_argument( + "release", + type=str, + nargs="?", + help="The version to add to the changelog. If not provided, a new version will be automatically generated based on the changes in the Unreleased section", + ) + parser_release.add_argument( + "-f", + "--file", + type=str, + required=False, + default="CHANGELOG.md", + help="The path to the changelog file", + ) + + parser_release.set_defaults(func=_command_release) + + parser.add_argument( + "-v", "--version", action="version", version=f"%(prog)s {__version__}" + ) + + return parser.parse_args(cmdline) + + +def main(cmdline: List[str] = None): + args = _parse_args(cmdline) + args.func(args) + + +if __name__ == "__main__": + main() # pragma: no cover diff --git a/keepachangelog/_changelog.py b/keepachangelog/_changelog.py index af4b2b1..e842630 100644 --- a/keepachangelog/_changelog.py +++ b/keepachangelog/_changelog.py @@ -78,7 +78,7 @@ def to_dict( """ # Allow for changelog as a file path or as a context manager providing content try: - with open(changelog_path) as change_log: + with open(changelog_path, encoding="utf-8") as change_log: return _to_dict(change_log, show_unreleased) except TypeError: return _to_dict(changelog_path, show_unreleased) @@ -177,7 +177,7 @@ def to_raw_dict(changelog_path: str) -> Dict[str, dict]: changes = {} # As URLs can be defined before actual usage, maintain a separate dict urls = {} - with open(changelog_path) as change_log: + with open(changelog_path, encoding="utf-8") as change_log: current_release = {} for line in change_log: clean_line = line.strip(" \n") @@ -230,7 +230,7 @@ def release_version( ): unreleased_link_pattern = re.compile(r"^\[Unreleased\]: (.*)$", re.DOTALL) lines = [] - with open(changelog_path) as change_log: + with open(changelog_path, encoding="utf-8") as change_log: for line in change_log.readlines(): # Move Unreleased section to new version if re.fullmatch(r"^## \[Unreleased\].*$", line, re.DOTALL): @@ -263,5 +263,5 @@ def release_version( else: lines.append(line) - with open(changelog_path, "wt") as change_log: + with open(changelog_path, mode="wt", encoding="utf-8") as change_log: change_log.writelines(lines) diff --git a/keepachangelog/version.py b/keepachangelog/version.py index 15ef87a..89c4624 100644 --- a/keepachangelog/version.py +++ b/keepachangelog/version.py @@ -3,4 +3,4 @@ # Major should be incremented in case there is a breaking change. (eg: 2.5.8 -> 3.0.0) # Minor should be incremented in case there is an enhancement. (eg: 2.5.8 -> 2.6.0) # Patch should be incremented in case there is a bug fix. (eg: 2.5.8 -> 2.5.9) -__version__ = "2.0.0.dev3" +__version__ = "2.0.0.dev4" diff --git a/setup.py b/setup.py index a93fb22..3999533 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,7 @@ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Topic :: Software Development :: Build Tools", ], keywords=["changelog", "CHANGELOG.md", "markdown"], @@ -40,11 +41,9 @@ "testing": [ # Used to check starlette endpoint "httpx==0.23.*", - "starlette==0.21.*", - # Flask-Restx is buggy for now - "werkzeug==2.1.2", + "starlette==0.23.*", # Used to check flask-restx endpoint - "flask-restx==0.5.*", + "flask-restx==1.*", # Used to check coverage "pytest-cov==4.*", ] @@ -56,4 +55,7 @@ "Issues": "https://github.com/Colin-b/keepachangelog/issues", }, platforms=["Windows", "Linux"], + entry_points={ + "console_scripts": ["keepachangelog=keepachangelog.__main__:main"], + }, ) diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 961be91..0c5bc01 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -10,7 +10,7 @@ @pytest.fixture def changelog(tmpdir): changelog_file_path = os.path.join(tmpdir, "CHANGELOG.md") - with open(changelog_file_path, "wt") as file: + with open(changelog_file_path, mode="wt", encoding="utf-8") as file: file.write( """# Changelog All notable changes to this project will be documented in this file. @@ -26,7 +26,7 @@ def changelog(tmpdir): - Release note 2. ### Added -- Enhancement 1 +- Enhancement 1 漢字 - sub enhancement 1 - sub enhancement 2 - Enhancement 2 @@ -81,7 +81,7 @@ def changelog(tmpdir): changelog_as_dict = { "1.2.0": { "added": [ - "Enhancement 1", + "Enhancement 1 漢字", "sub enhancement 1", "sub enhancement 2", "Enhancement 2", @@ -172,7 +172,7 @@ def test_changelog_with_versions_and_all_categories(changelog): def test_changelog_with_versions_and_all_categories_as_file_reader(changelog): - with io.StringIO(open(changelog).read()) as file_reader: + with io.StringIO(open(changelog, encoding="utf-8").read()) as file_reader: assert keepachangelog.to_dict(file_reader) == changelog_as_dict # Assert that file reader is not closed @@ -187,7 +187,7 @@ def test_raw_changelog_with_versions_and_all_categories(changelog): - Release note 1. - Release note 2. ### Added -- Enhancement 1 +- Enhancement 1 漢字 - sub enhancement 1 - sub enhancement 2 - Enhancement 2 diff --git a/tests/test_changelog_release.py b/tests/test_changelog_release.py index 7053c5b..ad1a23c 100644 --- a/tests/test_changelog_release.py +++ b/tests/test_changelog_release.py @@ -35,7 +35,7 @@ def mock_date(monkeypatch): @pytest.fixture def major_changelog(tmpdir): changelog_file_path = os.path.join(tmpdir, "MAJOR_CHANGELOG.md") - with open(changelog_file_path, "wt") as file: + with open(changelog_file_path, mode="wt", encoding="utf-8") as file: file.write( """# Changelog All notable changes to this project will be documented in this file. @@ -49,7 +49,7 @@ def major_changelog(tmpdir): * Release note 2. ### Added -- Enhancement 1 +- Enhancement 1 漢字 - sub enhancement 1 * sub enhancement 2 - Enhancement 2 @@ -500,7 +500,7 @@ def patch_digit_changelog(tmpdir): def test_major_release(major_changelog, mock_date): assert keepachangelog.release(major_changelog) == "2.0.0" - with open(major_changelog) as file: + with open(major_changelog, encoding="utf-8") as file: assert ( file.read() == """# Changelog @@ -517,7 +517,7 @@ def test_major_release(major_changelog, mock_date): * Release note 2. ### Added -- Enhancement 1 +- Enhancement 1 漢字 - sub enhancement 1 * sub enhancement 2 - Enhancement 2 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..e707a28 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,139 @@ +import os + +import pytest + +from keepachangelog.__main__ import main as cli +from keepachangelog.version import __version__ + + +@pytest.fixture +def changelog(tmpdir): + changelog_file_path = os.path.join(tmpdir, "CHANGELOG.md") + with open(changelog_file_path, mode="wt", encoding="utf-8") as file: + file.write( + """# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +### Changed +- Breaking change + +## [1.2.0] - 2018-06-01 +### Changed +- Release note 1. +- Release note 2. + +### Added +- Enhancement 1 +- sub enhancement 1 +- sub enhancement 2 +- Enhancement 2 + +### Fixed +- Bug fix 1 +- sub bug 1 +- sub bug 2 +- Bug fix 2 + +### Security +- Known issue 1 +- Known issue 2 + +### Deprecated +- Deprecated feature 1 +- Future removal 2 + +## [1.1.0] - 2018-05-31 +### Changed +- Enhancement 1 (1.1.0) +- sub enhancement 1 +- sub enhancement 2 +- Enhancement 2 (1.1.0) + +## [1.0.1] - 2018-05-31 +### Fixed +- Bug fix 1 (1.0.1) +- sub bug 1 +- sub bug 2 +- Bug fix 2 (1.0.1) + +## [1.0.0] - 2017-04-10 +### Deprecated +- Known issue 1 (1.0.0) 漢字 +- Known issue 2 (1.0.0) +""" + ) + return changelog_file_path + + +def test_print_help(changelog: str, capsys: pytest.CaptureFixture): + with pytest.raises(SystemExit) as exc: + cli(["--help"]) + + assert exc.value.args[0] == 0 + + captured = capsys.readouterr() + + assert captured.err == "" + assert "usage: keepachangelog [-h] [-v] {show,release} ..." in captured.out + + +def test_print_version(changelog: str, capsys: pytest.CaptureFixture): + with pytest.raises(SystemExit) as exc: + cli(["--version"]) + + assert exc.value.args[0] == 0 + + captured = capsys.readouterr() + + assert captured.err == "" + assert captured.out.strip() == f"keepachangelog {__version__}" + + +def test_show_release_pretty(changelog: str, capsys: pytest.CaptureFixture): + cli(["show", "1.0.0", changelog]) + + captured = capsys.readouterr() + + assert captured.err == "" + assert ( + captured.out.strip() + == "Deprecated\n - Known issue 1 (1.0.0) 漢字\r\n - Known issue 2 (1.0.0)" + ) + + +def test_show_release_raw(changelog: str, capsys: pytest.CaptureFixture): + cli(["show", "1.0.0", changelog, "--raw"]) + + captured = capsys.readouterr() + + assert captured.err == "" + assert ( + captured.out.strip() + == """### Deprecated +- Known issue 1 (1.0.0) 漢字 +- Known issue 2 (1.0.0)""" + ) + + +def test_create_release_automatic_version( + changelog: str, capsys: pytest.CaptureFixture +): + cli(["release", "-f", changelog]) + + captured = capsys.readouterr() + + assert captured.err == "" + assert captured.out.strip() == "2.0.0" + + +def test_create_release_specific_version(changelog: str, capsys: pytest.CaptureFixture): + cli(["release", "3.2.1", "-f", changelog]) + + captured = capsys.readouterr() + + assert captured.err == "" + assert captured.out.strip() == "3.2.1"