From c6ee5d6ee40b1d8bdd154f8cfdfa982adf56a5f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Goudout?= Date: Mon, 21 Aug 2023 14:10:24 +0200 Subject: [PATCH 01/21] feat: Add bump option (CLI, library) allowing to specify an exact version to bump to, as well as `auto`, `major`, `minor` or `patch` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #38: https://github.com/pawamoy/git-changelog/issues/38 PR #41: https://github.com/pawamoy/git-changelog/pull/41 Co-authored-by: Timothée Mazzucotelli --- README.md | 11 +- docs/usage.md | 96 +++++++++++++- src/git_changelog/build.py | 72 ++++++----- src/git_changelog/cli.py | 258 ++++++++++++++++++++++++++++--------- tests/test_end_to_end.py | 12 +- 5 files changed, 351 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 47faedb..0a15fcf 100644 --- a/README.md +++ b/README.md @@ -137,13 +137,22 @@ positional arguments: REPOSITORY The repository path, relative or absolute. Default: . options: - -b, --bump-latest Guess the new latest version by bumping the previous + -b, --bump-latest Deprecated, use --bump=auto instead. + Guess the new latest version by bumping the previous one based on the set of unreleased commits. For example, if a commit contains breaking changes, bump the major number (or the minor number for 0.x versions). Else if there are new features, bump the minor number. Else just bump the patch number. Default: False. + --bump VERSION Specify the bump from latest version for the set + of unreleased commits. Can be one of 'auto', + 'major', 'minor', 'patch' or a valid semver version + (eg. 1.2.3). With 'auto', if a commit contains breaking + changes, bump the major number (or the minor number + for 0.x versions), else if there are new features, + bump the minor number, else just bump the patch number. + Default: None. -h, --help Show this help message and exit. -i, --in-place Insert new entries (versions missing from changelog) in-place. An output file must be specified. With diff --git a/docs/usage.md b/docs/usage.md index e7b9a77..d91a6ec 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -69,7 +69,7 @@ build_and_render( parse_trailers=True, parse_refs=False, sections=("build", "deps", "feat", "fix", "refactor"), - bump_latest=True, + bump="auto", in_place=True, ) ``` @@ -220,10 +220,39 @@ will bump the MINOR part of the latest tag. Other types will bump the PATCH part Commits containing breaking changes will bump the MAJOR part, unless MAJOR is 0, in which case they'll only bump the MINOR part. -To tell *git-changelog* to try and guess the new version, use the `-b` or `--bump-latest` CLI option: +To tell *git-changelog* to try and guess the new version, use the `--bump=auto` CLI option: ```bash -git-changelog --bump +git-changelog --bump auto +``` + +You can also specify a version to bump to directly: + +```bash +git-changelog --bump 2.3.1 +``` + +Or which part of the version to bump, resetting +numbers on its right to 0: + +```bash +git-changelog --bump major # 1.2.3 -> 2.0.0 +git-changelog --bump minor # 1.2.3 -> 1.3.0 +git-changelog --bump patch # 1.2.3 -> 1.2.4 +``` + +Note that the major number won't be bumped if the latest version is 0.x. +Instead, the minor number will be bumped: + +```bash +git-changelog --bump major # 0.1.2 -> 0.2.0, same as minor because 0.x +git-changelog --bump minor # 0.1.2 -> 0.2.0 +``` + +In that case, when you are ready to bump to 1.0.0, just pass this version as value: + +```bash +git-changelog --bump 1.0.0 ``` ## Parse additional information in commit messages @@ -409,6 +438,67 @@ Other options can be used to help *git-changelog* retrieving the latest entry from your changelog: `--version-regex` and `--marker-line`. + +## Configuration files + +Project-wise, permanent configuration of *git-changelog* is possible. +By default, *git-changelog* will search for the existence a suitable configuration +in the ``pyproject.toml`` file or otherwise, the following configuration files +in this particular order: + * ``.git-changelog.toml`` + * ``config/git-changelog.toml`` + * ``.config/git-changelog.toml`` + * ``~/.config/git-changelog.toml`` + +The use of a configuration file can be disabled or overridden with the ``--config-file`` +option. +To disable the configuration file, pass ``no``, ``None``, ``false``, or ``0``: + +```bash +git-changelog --config-file no +``` + +To override the configuration file, pass the path to the new file: + +```bash +git-changelog --config-file $HOME/.custom-git-changelog-config +``` + +The configuration file must be written in TOML language, and may take values +for most of the command line options: + +```toml +bump-latest = false +convention = 'basic' +in-place = false +marker-line = '' +output = 'output.log' +parse-refs = false +parse-trailers = false +repository = '.' +sections = '' +template = 'angular' +version-regex = '^## \[(?Pv?[^\]]+)' +``` + +In the case of configuring *git-changelog* within ``pyproject.toml``, these +settings must be found in the appropriate section: + +```toml +[tool.git-changelog] +bump-latest = false +convention = 'conventional' +in-place = false +marker-line = '' +output = 'output.log' +parse-refs = false +parse-trailers = false +repository = '.' +sections = '' +template = 'keepachangelog' +version-regex = '^## \[(?Pv?[^\]]+)' +``` + [keepachangelog]: https://keepachangelog.com/en/1.0.0/ [conventional-commit]: https://www.conventionalcommits.org/en/v1.0.0-beta.4/ [jinja]: https://jinja.palletsprojects.com/en/3.1.x/ diff --git a/src/git_changelog/build.py b/src/git_changelog/build.py index 71613de..5a5aae7 100644 --- a/src/git_changelog/build.py +++ b/src/git_changelog/build.py @@ -6,9 +6,9 @@ import os import re import sys -from contextlib import suppress +import warnings from subprocess import check_output -from typing import TYPE_CHECKING, ClassVar, Type, Union +from typing import TYPE_CHECKING, ClassVar, Literal, Type, Union from semver import VersionInfo @@ -27,7 +27,7 @@ ConventionType = Union[str, CommitConvention, Type[CommitConvention]] -def bump(version: str, part: str = "patch") -> str: +def bump(version: str, part: Literal["major", "minor", "patch"] = "patch") -> str: """Bump a version. Arguments: @@ -169,6 +169,7 @@ def __init__( parse_trailers: bool = False, sections: list[str] | None = None, bump_latest: bool = False, + bump: str | None = None, ): """Initialization method. @@ -179,7 +180,8 @@ def __init__( parse_provider_refs: Whether to parse provider-specific references in the commit messages. parse_trailers: Whether to parse Git trailers in the commit messages. sections: The sections to render (features, bug fixes, etc.). - bump_latest: Whether to try and bump latest version to guess new one. + bump_latest: Deprecated, use `bump="auto"` instead. Whether to try and bump latest version to guess new one. + bump: Whether to try and bump to a given version. """ self.repository: str | Path = repository self.parse_provider_refs: bool = parse_provider_refs @@ -236,9 +238,17 @@ def __init__( self.versions_list = v_list self.versions_dict = v_dict - # try to guess the new version by bumping the latest one + # TODO: remove at some point if bump_latest: - self._bump_latest() + warnings.warn( + "`bump_latest=True` is deprecated in favor of `bump='auto'`", + DeprecationWarning, + stacklevel=1, + ) + if bump is None: + bump = "auto" + if bump: + self._bump(bump) # fix a single, initial version to 0.1.0 self._fix_single_version() @@ -383,33 +393,35 @@ def _group_commits_by_version( ) return versions_list, versions_dict - def _bump_latest(self) -> None: - # guess the next version number based on last version and recent commits + def _bump(self, version: str) -> None: last_version = self.versions_list[0] if not last_version.tag and last_version.previous_version: last_tag = last_version.previous_version.tag - major = minor = False - for commit in last_version.commits: - if commit.convention["is_major"]: - major = True - break - if commit.convention["is_minor"]: - minor = True - # never fail on non-semver versions - with suppress(ValueError): - if major: - planned_tag = bump(last_tag, "major") - elif minor: - planned_tag = bump(last_tag, "minor") - else: - planned_tag = bump(last_tag, "patch") - last_version.planned_tag = planned_tag - if self.provider: - last_version.url = self.provider.get_tag_url(tag=planned_tag) - last_version.compare_url = self.provider.get_compare_url( - base=last_version.previous_version.tag, - target=last_version.planned_tag, - ) + if version == "auto": + # guess the next version number based on last version and recent commits + version = "patch" + for commit in last_version.commits: + if commit.convention["is_major"]: + version = "major" + break + if commit.convention["is_minor"]: + version = "minor" + if version in {"major", "minor", "patch"}: + # bump version (don't fail on non-semver versions) + try: + last_version.planned_tag = bump(last_tag, version) # type: ignore[arg-type] + except ValueError: + return + else: + # user specified version + last_version.planned_tag = version + # update URLs + if self.provider: + last_version.url = self.provider.get_tag_url(tag=last_version.planned_tag) + last_version.compare_url = self.provider.get_compare_url( + base=last_version.previous_version.tag, + target=last_version.planned_tag, + ) def _fix_single_version(self) -> None: last_version = self.versions_list[0] diff --git a/src/git_changelog/cli.py b/src/git_changelog/cli.py index 72382ca..4fb7fa6 100644 --- a/src/git_changelog/cli.py +++ b/src/git_changelog/cli.py @@ -16,8 +16,11 @@ import argparse import re import sys +import warnings +import toml +from pathlib import Path from importlib import metadata -from typing import TYPE_CHECKING, Pattern, TextIO +from typing import Pattern, TextIO from jinja2.exceptions import TemplateNotFound @@ -31,12 +34,36 @@ ) from git_changelog.providers import Bitbucket, GitHub, GitLab, ProviderRefParser -if TYPE_CHECKING: - from pathlib import Path - DEFAULT_VERSION_REGEX = r"^## \[(?Pv?[^\]]+)" DEFAULT_MARKER_LINE = "" +DEFAULT_CHANGELOG_FILE = "CHANGELOG.md" CONVENTIONS = ("angular", "conventional", "basic") +DEFAULT_CONFIG_FILES = [ + "pyproject.toml", + ".git-changelog.toml", + "config/git-changelog.toml", + ".config/git-changelog.toml", + str(Path.home() / ".config" / "git-changelog.toml"), +] + +DEFAULT_SETTINGS = { + "bump": None, + "bump_latest": None, + "convention": "basic", + "in_place": False, + "input": DEFAULT_CHANGELOG_FILE, + "marker_line": DEFAULT_MARKER_LINE, + "omit_empty_versions": False, + "output": sys.stdout, + "parse_refs": False, + "parse_trailers": False, + "provider": None, + "release_notes": False, + "repository": ".", + "sections": None, + "template": "keepachangelog", + "version_regex": DEFAULT_VERSION_REGEX, +} class Templates(tuple): # (subclassing tuple) @@ -107,8 +134,14 @@ def get_parser() -> argparse.ArgumentParser: "repository", metavar="REPOSITORY", nargs="?", - default=".", - help="The repository path, relative or absolute. Default: %(default)s.", + help="The repository path, relative or absolute. Default: current working directory.", + ) + + parser.add_argument( + "--config-file", + metavar="PATH", + nargs="*", + help="Configuration file(s).", ) parser.add_argument( @@ -116,10 +149,23 @@ def get_parser() -> argparse.ArgumentParser: "--bump-latest", action="store_true", dest="bump_latest", - default=False, - help="Guess the new latest version by bumping the previous one based on the set of unreleased commits. " + help="Deprecated, use --bump=auto instead. " + "Guess the new latest version by bumping the previous one based on the set of unreleased commits. " "For example, if a commit contains breaking changes, bump the major number (or the minor number for 0.x versions). " - "Else if there are new features, bump the minor number. Else just bump the patch number. Default: %(default)s.", + "Else if there are new features, bump the minor number. Else just bump the patch number. " + "Default: unset (false).", + ) + parser.add_argument( + "-B", + "--bump", + action="store", + dest="bump", + metavar="VERSION", + help="Specify the bump from latest version for the set of unreleased commits. " + "Can be one of 'auto', 'major', 'minor', 'patch' or a valid semver version (eg. 1.2.3). " + "With 'auto', if a commit contains breaking changes, bump the major number (or the minor number for 0.x versions), " + "else if there are new features, bump the minor number, else just bump the patch number. " + "Default: unset.", ) parser.add_argument( "-h", @@ -133,24 +179,23 @@ def get_parser() -> argparse.ArgumentParser: "--in-place", action="store_true", dest="in_place", - default=False, help="Insert new entries (versions missing from changelog) in-place. " "An output file must be specified. With custom templates, " "you can pass two additional arguments: --version-regex and --marker-line. " "When writing in-place, an 'in_place' variable " "will be injected in the Jinja context, " "allowing to adapt the generated contents " - "(for example to skip changelog headers or footers). Default: %(default)s.", + "(for example to skip changelog headers or footers). Default: unset (false).", ) parser.add_argument( "-g", "--version-regex", action="store", dest="version_regex", - default=DEFAULT_VERSION_REGEX, help="A regular expression to match versions in the existing changelog " "(used to find the latest release) when writing in-place. " - "The regular expression must be a Python regex with a 'version' named group. Default: %(default)s.", + "The regular expression must be a Python regex with a 'version' named group. " + f"Default: '{DEFAULT_VERSION_REGEX}'.", ) parser.add_argument( @@ -158,51 +203,49 @@ def get_parser() -> argparse.ArgumentParser: "--marker-line", action="store", dest="marker_line", - default=DEFAULT_MARKER_LINE, help="A marker line at which to insert new entries " "(versions missing from changelog). " "If two marker lines are present in the changelog, " "the contents between those two lines will be overwritten " - "(useful to update an 'Unreleased' entry for example). Default: %(default)s.", + "(useful to update an 'Unreleased' entry for example). " + f"Default: '{DEFAULT_MARKER_LINE}'.", ) parser.add_argument( "-o", "--output", action="store", dest="output", - default=sys.stdout, - help="Output to given file. Default: stdout.", + help="Output to given file. Default: standard output.", ) parser.add_argument( "-p", "--provider", dest="provider", - default=None, choices=providers.keys(), - help="Explicitly specify the repository provider. Default: %(default)s.", + help="Explicitly specify the repository provider. Default: unset.", ) parser.add_argument( "-r", "--parse-refs", action="store_true", dest="parse_refs", - default=False, - help="Parse provider-specific references in commit messages (GitHub/GitLab/Bitbucket issues, PRs, etc.). Default: %(default)s.", + help="Parse provider-specific references in commit messages (GitHub/GitLab/Bitbucket " + "issues, PRs, etc.). Default: unset (false).", ) parser.add_argument( "-R", "--release-notes", action="store_true", dest="release_notes", - default=False, - help="Output release notes to stdout based on the last entry in the changelog. Default: %(default)s.", + help="Output release notes to stdout based on the last entry in the changelog. " + "Default: unset (false).", ) parser.add_argument( "-I", "--input", dest="input", - default="CHANGELOG.md", - help="Read from given file when creating release notes. Default: %(default)s.", + help="Read from given file when creating release notes. " + f"Default: '{DEFAULT_CHANGELOG_FILE}'.", ) parser.add_argument( "-c", @@ -210,45 +253,44 @@ def get_parser() -> argparse.ArgumentParser: "--commit-style", "--convention", choices=CONVENTIONS, - default="basic", dest="convention", - help="The commit convention to match against. Default: %(default)s.", + help="The commit convention to match against. " + f"Default: '{DEFAULT_SETTINGS['convention']}'.", ) parser.add_argument( "-s", "--sections", action="store", type=_comma_separated_list, - default=None, dest="sections", help="A comma-separated list of sections to render. " - "See the available sections for each supported convention in the description. Default: %(default)s.", + "See the available sections for each supported convention in the description. " + "Default: unset (None).", ) parser.add_argument( "-t", "--template", choices=Templates(("angular", "keepachangelog")), - default="keepachangelog", dest="template", - help='The Jinja2 template to use. Prefix with "path:" to specify the path ' - 'to a directory containing a file named "changelog.md". Default: %(default)s.', + help="The Jinja2 template to use. Prefix it with \"path:\" to specify the path " + "to a directory containing a file named \"changelog.md\". " + f"Default: '{DEFAULT_SETTINGS['template']}'.", ) parser.add_argument( "-T", "--trailers", "--git-trailers", action="store_true", - default=False, dest="parse_trailers", - help="Parse Git trailers in the commit message. See https://git-scm.com/docs/git-interpret-trailers. Default: %(default)s.", + help="Parse Git trailers in the commit message. " + "See https://git-scm.com/docs/git-interpret-trailers. Default: unset (false).", ) parser.add_argument( "-E", "--omit-empty-versions", action="store_true", - default=False, dest="omit_empty_versions", - help="Omit empty versions from the output. Default: %(default)s.", + help="Omit empty versions from the output. Default: unset (false).", ) parser.add_argument( "-v", @@ -275,6 +317,78 @@ def _unreleased(versions: list[Version], last_release: str) -> list[Version]: return versions +def read_config( + config_file: str | list[str] | None = DEFAULT_CONFIG_FILES +) -> dict: + """ + Find config files and initialize settings with the one of highest priority. + + Arguments: + config_file: A path or list of paths to configuration file(s); or ``None`` to + disable config file settings. Default: ``pyproject.toml`` and ``.git-changelogrc`` + at the current working directory. + + Returns: + A settings dictionary. Default settings if no config file is found or ``config_file`` + is ``None``. + + """ + + project_config = DEFAULT_SETTINGS.copy() + if config_file is None: # Unset config file + return project_config + + config_file = [config_file] if isinstance(config_file, str) else config_file + + for filename in config_file: + _path = Path(filename) + + if not _path.exists(): + continue + + new_settings = toml.load(_path) + if _path.name == "pyproject.toml": + new_settings = new_settings.get("tool", {}).get("git-changelog", {}) + + if not new_settings: # Likely, pyproject.toml did not have a git-changelog section + continue + + # Settings can have hyphens like in the CLI + new_settings = { + key.replace("-", "_"): value for key, value in new_settings.items() + } + + # Massage found values to meet expectations + # Parse sections + if "sections" in new_settings and new_settings["sections"] is not None: + sections = new_settings["sections"] + if isinstance(sections, str): + sections = [s.strip() for s in sections.split(",")] + + new_settings["sections"] = [ + s.strip() for s in sections if s and s.strip() != "none" + ] or None + + # Convert boolean values + new_settings = { + key: True if ( + isinstance(value, str) + and value.strip().lower() in ("yes", "on", "true", "1", "") + ) else value for key, value in new_settings.items() + } + new_settings = { + key: False if ( + isinstance(value, str) + and value.strip().lower() in ("no", "none", "off", "false", "0") + ) else value for key, value in new_settings.items() + } + + project_config.update(new_settings) + break + + return project_config + + def build_and_render( repository: str, template: str, @@ -289,6 +403,7 @@ def build_and_render( bump_latest: bool = False, # noqa: FBT001,FBT002 omit_empty_versions: bool = False, # noqa: FBT001,FBT002 provider: str | None = None, + bump: str | None = None, ) -> tuple[Changelog, str]: """Build a changelog and render it. @@ -306,9 +421,11 @@ def build_and_render( output: Output/changelog file. version_regex: Regular expression to match versions in an existing changelog file. marker_line: Marker line used to insert contents in an existing changelog. - bump_latest: Whether to try and bump the latest version to guess the new one. + bump_latest: Deprecated, use --bump=auto instead. + Whether to try and bump the latest version to guess the new one. omit_empty_versions: Whether to omit empty versions from the output. provider: Provider class used by this repository. + bump: Whether to try and bump to a given version. Raises: ValueError: When some arguments are incompatible or missing. @@ -344,7 +461,7 @@ def build_and_render( parse_provider_refs=parse_refs, parse_trailers=parse_trailers, sections=sections, - bump_latest=bump_latest, + bump=bump, ) # remove empty versions from changelog data @@ -476,6 +593,10 @@ def output_release_notes( file.write(release_notes) +class _Sentinel: + pass + + def main(args: list[str] | None = None) -> int: """Run the main program. @@ -490,31 +611,52 @@ def main(args: list[str] | None = None) -> int: parser = get_parser() opts = parser.parse_args(args=args) - if opts.release_notes: + # Determine which arguments were explicitly set with the CLI + sentinel = _Sentinel() + sentinel_ns = argparse.Namespace(**{key: sentinel for key in vars(opts)}) + parser.parse_args(namespace=sentinel_ns) + explicit_opts_dict = { + key: value for key, value in vars(sentinel_ns).items() + if value is not sentinel + } + + config_file = explicit_opts_dict.pop("config_file", DEFAULT_CONFIG_FILES) + if str(config_file).strip().lower() in ("no", "none", "off", "false", "0", ""): + config_file = None + elif str(config_file).strip().lower() in ("yes", "default", "on", "true", "1"): + config_file = DEFAULT_CONFIG_FILES + + settings = read_config(config_file) + + # CLI arguments override the config file settings + settings.update(explicit_opts_dict) + + # TODO: remove at some point + _bump_latest = settings.pop("bump_latest", None) + if _bump_latest is not None: + warnings.warn( + "`--bump-latest` is deprecated in favor of `--bump auto`", + FutureWarning, + stacklevel=1, + ) + + # If `--bump-latest` is `True`, set `--bump auto` + if _bump_latest and settings.get("bump", None) is None: + settings["bump"] = "auto" + + if settings.pop("release_notes"): output_release_notes( - input_file=opts.input, - version_regex=opts.version_regex, - marker_line=opts.marker_line, - output_file=opts.output, + input_file=settings["input"], + version_regex=settings["version_regex"], + marker_line=settings["marker_line"], + output_file=settings["output"], ) return 0 + # --input is not necessary anymore + settings.pop("input", None) try: - build_and_render( - repository=opts.repository, - template=opts.template, - convention=opts.convention, - parse_refs=opts.parse_refs, - parse_trailers=opts.parse_trailers, - provider=opts.provider, - sections=opts.sections, - in_place=opts.in_place, - output=opts.output, - version_regex=opts.version_regex, - marker_line=opts.marker_line, - bump_latest=opts.bump_latest, - omit_empty_versions=opts.omit_empty_versions, - ) + build_and_render(**settings) except ValueError as error: print(f"git-changelog: {error}", file=sys.stderr) return 1 diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 88dc072..25c12d5 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -77,7 +77,7 @@ def test_bumping_latest(repo: Path) -> None: Parameters: repo: Path to a temporary repository. """ - changelog = Changelog(repo, convention=AngularConvention, bump_latest=True) + changelog = Changelog(repo, convention=AngularConvention, bump="auto") # features, no breaking changes: minor bumped assert changelog.versions_list[0].planned_tag is not None assert changelog.versions_list[0].planned_tag.lstrip("v") == bump( @@ -94,7 +94,7 @@ def test_not_bumping_latest(repo: Path) -> None: Parameters: repo: Path to a temporary repository. """ - changelog = Changelog(repo, convention=AngularConvention, bump_latest=False) + changelog = Changelog(repo, convention=AngularConvention, bump=None) assert changelog.versions_list[0].planned_tag is None rendered = KEEP_A_CHANGELOG.render(changelog=changelog) assert "Unreleased" in rendered @@ -124,7 +124,7 @@ def test_rendering_in_place(repo: Path, tmp_path: Path) -> None: _, rendered = build_and_render( str(repo), convention="angular", - bump_latest=False, + bump=None, output=output.as_posix(), template="keepachangelog", ) @@ -136,7 +136,7 @@ def test_rendering_in_place(repo: Path, tmp_path: Path) -> None: build_and_render( str(repo), convention="angular", - bump_latest=True, + bump="auto", output=output.as_posix(), template="keepachangelog", in_place=True, @@ -159,7 +159,7 @@ def test_no_duplicate_rendering(repo: Path, tmp_path: Path) -> None: _, rendered = build_and_render( str(repo), convention="angular", - bump_latest=True, + bump="auto", output=output.as_posix(), template="keepachangelog", ) @@ -178,7 +178,7 @@ def test_no_duplicate_rendering(repo: Path, tmp_path: Path) -> None: build_and_render( str(repo), convention="angular", - bump_latest=True, + bump="auto", output=output.as_posix(), template="keepachangelog", in_place=True, From 1b3b0dbbfd5232d5a95f38b5a83a6ed15ef013bf Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Fri, 18 Aug 2023 12:28:00 +0200 Subject: [PATCH 02/21] sty: run ruff --fix --- src/git_changelog/cli.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/git_changelog/cli.py b/src/git_changelog/cli.py index 4fb7fa6..1cb076c 100644 --- a/src/git_changelog/cli.py +++ b/src/git_changelog/cli.py @@ -17,11 +17,11 @@ import re import sys import warnings -import toml -from pathlib import Path from importlib import metadata +from pathlib import Path from typing import Pattern, TextIO +import toml from jinja2.exceptions import TemplateNotFound from git_changelog import templates @@ -272,8 +272,8 @@ def get_parser() -> argparse.ArgumentParser: "--template", choices=Templates(("angular", "keepachangelog")), dest="template", - help="The Jinja2 template to use. Prefix it with \"path:\" to specify the path " - "to a directory containing a file named \"changelog.md\". " + help="The Jinja2 template to use. Prefix it with 'path:'' to specify the path " + "to a directory containing a file named 'changelog.md'. " f"Default: '{DEFAULT_SETTINGS['template']}'.", ) parser.add_argument( @@ -318,10 +318,9 @@ def _unreleased(versions: list[Version], last_release: str) -> list[Version]: def read_config( - config_file: str | list[str] | None = DEFAULT_CONFIG_FILES + config_file: str | list[str] | None = DEFAULT_CONFIG_FILES, ) -> dict: - """ - Find config files and initialize settings with the one of highest priority. + """Find config files and initialize settings with the one of highest priority. Arguments: config_file: A path or list of paths to configuration file(s); or ``None`` to @@ -333,7 +332,6 @@ def read_config( is ``None``. """ - project_config = DEFAULT_SETTINGS.copy() if config_file is None: # Unset config file return project_config From d7246044fef9e09c7999ca724cb594d8a1baabbd Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Fri, 18 Aug 2023 12:30:23 +0200 Subject: [PATCH 03/21] fix: move toml as a dependency --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1495db4..b264d9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ classifiers = [ dependencies = [ "Jinja2>=2.10,<4", "semver>=2.13", + "toml>=0.10", ] [project.urls] @@ -77,7 +78,6 @@ docs = [ "mkdocs-material>=7.3", "mkdocs-minify-plugin>=0.6.4", "mkdocstrings[python]>=0.18", - "toml>=0.10", ] maintain = [ "black>=23.1", From 159bea8dbf286fba4200eb9086f15c3709aabbd8 Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Mon, 21 Aug 2023 09:56:54 +0200 Subject: [PATCH 04/21] fix: replace double backticks with single --- docs/usage.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index d91a6ec..5030385 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -443,16 +443,16 @@ and `--marker-line`. Project-wise, permanent configuration of *git-changelog* is possible. By default, *git-changelog* will search for the existence a suitable configuration -in the ``pyproject.toml`` file or otherwise, the following configuration files +in the `pyproject.toml` file or otherwise, the following configuration files in this particular order: - * ``.git-changelog.toml`` - * ``config/git-changelog.toml`` - * ``.config/git-changelog.toml`` - * ``~/.config/git-changelog.toml`` + * `.git-changelog.toml` + * `config/git-changelog.toml` + * `.config/git-changelog.toml` + * `~/.config/git-changelog.toml` -The use of a configuration file can be disabled or overridden with the ``--config-file`` +The use of a configuration file can be disabled or overridden with the `--config-file` option. -To disable the configuration file, pass ``no``, ``None``, ``false``, or ``0``: +To disable the configuration file, pass `no`, `None`, `false`, or `0`: ```bash git-changelog --config-file no @@ -481,7 +481,7 @@ template = 'angular' version-regex = '^## \[(?Pv?[^\]]+)' ``` -In the case of configuring *git-changelog* within ``pyproject.toml``, these +In the case of configuring *git-changelog* within `pyproject.toml`, these settings must be found in the appropriate section: ```toml From d2a93872a5a98f2406f954b0425a0d97cd1585b9 Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Mon, 21 Aug 2023 10:00:37 +0200 Subject: [PATCH 05/21] fix(docs): revise docstring default value --- src/git_changelog/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/git_changelog/cli.py b/src/git_changelog/cli.py index 1cb076c..ac5aece 100644 --- a/src/git_changelog/cli.py +++ b/src/git_changelog/cli.py @@ -324,8 +324,8 @@ def read_config( Arguments: config_file: A path or list of paths to configuration file(s); or ``None`` to - disable config file settings. Default: ``pyproject.toml`` and ``.git-changelogrc`` - at the current working directory. + disable config file settings. Default: a list of paths given by + :obj:`~git_changelog.cli.DEFAULT_CONFIG_FILES`. Returns: A settings dictionary. Default settings if no config file is found or ``config_file`` From d91a7647a878ad947e815a6142e2a70ecfea3de1 Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Mon, 21 Aug 2023 10:03:52 +0200 Subject: [PATCH 06/21] fix: revise config file reading function --- src/git_changelog/cli.py | 25 +++++----------- tests/test_cli.py | 64 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/src/git_changelog/cli.py b/src/git_changelog/cli.py index ac5aece..9d9dbeb 100644 --- a/src/git_changelog/cli.py +++ b/src/git_changelog/cli.py @@ -318,7 +318,7 @@ def _unreleased(versions: list[Version], last_release: str) -> list[Version]: def read_config( - config_file: str | list[str] | None = DEFAULT_CONFIG_FILES, + config_file: str | Path | list[str | Path] | None = DEFAULT_CONFIG_FILES, ) -> dict: """Find config files and initialize settings with the one of highest priority. @@ -336,7 +336,7 @@ def read_config( if config_file is None: # Unset config file return project_config - config_file = [config_file] if isinstance(config_file, str) else config_file + config_file = config_file if isinstance(config_file, (list, tuple)) else [config_file] for filename in config_file: _path = Path(filename) @@ -346,7 +346,10 @@ def read_config( new_settings = toml.load(_path) if _path.name == "pyproject.toml": - new_settings = new_settings.get("tool", {}).get("git-changelog", {}) + new_settings = ( + new_settings.get("tool", {}).get("git-changelog", {}) + or new_settings.get("tool.git-changelog", {}) + ) if not new_settings: # Likely, pyproject.toml did not have a git-changelog section continue @@ -364,23 +367,9 @@ def read_config( sections = [s.strip() for s in sections.split(",")] new_settings["sections"] = [ - s.strip() for s in sections if s and s.strip() != "none" + s.strip() for s in sections if s.strip() and s.strip() != "none" ] or None - # Convert boolean values - new_settings = { - key: True if ( - isinstance(value, str) - and value.strip().lower() in ("yes", "on", "true", "1", "") - ) else value for key, value in new_settings.items() - } - new_settings = { - key: False if ( - isinstance(value, str) - and value.strip().lower() in ("no", "none", "off", "false", "0") - ) else value for key, value in new_settings.items() - } - project_config.update(new_settings) break diff --git a/tests/test_cli.py b/tests/test_cli.py index 42a974e..c5caabe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,9 +2,11 @@ from __future__ import annotations +import os from typing import TYPE_CHECKING import pytest +import toml from git_changelog import cli @@ -50,3 +52,65 @@ def test_passing_repository_and_sections(tmp_path: Path, args: tuple[str]) -> No """ ch = tmp_path.joinpath("ch.md") assert cli.main([*args, "-o", ch.as_posix(), "-c", "angular"]) == 0 + + +@pytest.mark.parametrize("is_pyproject", [True, False, None]) +@pytest.mark.parametrize(("sections", "sections_value"), [ + (None, None), + ("none", None), + ("force-null", None), + ("", None), + ("none, none, none", None), + ("a, b, ", ["a", "b"]), + ("a, , ", ["a"]), + ("a, b, c", ["a", "b", "c"]), + (["a", "b", "c"], ["a", "b", "c"]), +]) +@pytest.mark.parametrize("parse_refs", [None, False, True]) +def test_config_reading( + tmp_path: Path, + is_pyproject: bool | None, + sections: str | list[str] | None, + sections_value: list | None, + parse_refs: bool | None, +) -> None: + """Check settings files are correctly interpreted. + + Parameters: + tmp_path: A temporary path to write the settings file into. + is_pyproject: controls whether a ``pyproject.toml`` (``True``), + a ``.git-changelog.toml`` (``False``) or a custom file (``None``) is being tested. + sections: A ``sections`` config to override defaults. + sections_falue: The expectation for ``sections`` after reading the config file. + parse_refs: A explicit override of the ``parse_refs`` of the config (if boolean) + or skip writing the override into the test config file (``None``). + """ + os.chdir(tmp_path) + + config_content = {} + + if sections is not None: + config_content["sections"] = None if sections == "force-null" else sections + + if parse_refs is not None: + config_content["parse_refs"] = parse_refs + + config_fname = "custom-file.toml" if is_pyproject is None else ".git-changelog.toml" + config_fname = "pyproject.toml" if is_pyproject else config_fname + (tmp_path / config_fname).write_text( + toml.dumps( + config_content if not is_pyproject + else {"tool": {"git-changelog": config_content}}, + ), + ) + + settings = ( + cli.read_config(tmp_path / config_fname) if config_fname == "custom-file.toml" + else cli.read_config() + ) + + ground_truth = cli.DEFAULT_SETTINGS.copy() + ground_truth["sections"] = sections_value + ground_truth["parse_refs"] = bool(parse_refs) + + assert settings == ground_truth From 612d3ed080dc857c10e3cfcdebb01348e7b25688 Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Mon, 21 Aug 2023 11:03:25 +0200 Subject: [PATCH 07/21] fix(docs): several documentation issues --- docs/usage.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 5030385..2541324 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -452,7 +452,7 @@ in this particular order: The use of a configuration file can be disabled or overridden with the `--config-file` option. -To disable the configuration file, pass `no`, `None`, `false`, or `0`: +To disable the configuration file, pass `no`, `none`, `false`, `off`, `0` or empty string (`''`): ```bash git-changelog --config-file no @@ -476,7 +476,7 @@ output = 'output.log' parse-refs = false parse-trailers = false repository = '.' -sections = '' +sections = 'none' template = 'angular' version-regex = '^## \[(?Pv?[^\]]+)' ``` @@ -494,7 +494,7 @@ output = 'output.log' parse-refs = false parse-trailers = false repository = '.' -sections = '' +sections = 'fix,maint' template = 'keepachangelog' version-regex = '^## \[(?Pv?[^\]]+)' ``` From e8ecd9b02cda74afcb68c919536c1d3c6bcea3f7 Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Mon, 21 Aug 2023 11:11:18 +0200 Subject: [PATCH 08/21] feat: infer config file depending on platform with appdirs --- docs/usage.md | 5 ++++- pyproject.toml | 1 + src/git_changelog/cli.py | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 2541324..80f94da 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -448,8 +448,11 @@ in this particular order: * `.git-changelog.toml` * `config/git-changelog.toml` * `.config/git-changelog.toml` - * `~/.config/git-changelog.toml` + * `/git-changelog.toml` +In the last case (`/git-changelog.toml`), the `` +is platform-dependent and will be automatically inferred from your settings. +In Unix systems, this will typically point at `$HOME/.config/git-changelog.toml`. The use of a configuration file can be disabled or overridden with the `--config-file` option. To disable the configuration file, pass `no`, `none`, `false`, `off`, `0` or empty string (`''`): diff --git a/pyproject.toml b/pyproject.toml index b264d9d..a2dbeda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ + "appdirs", "Jinja2>=2.10,<4", "semver>=2.13", "toml>=0.10", diff --git a/src/git_changelog/cli.py b/src/git_changelog/cli.py index 9d9dbeb..c923f2b 100644 --- a/src/git_changelog/cli.py +++ b/src/git_changelog/cli.py @@ -22,6 +22,7 @@ from typing import Pattern, TextIO import toml +from appdirs import user_config_dir from jinja2.exceptions import TemplateNotFound from git_changelog import templates @@ -43,7 +44,7 @@ ".git-changelog.toml", "config/git-changelog.toml", ".config/git-changelog.toml", - str(Path.home() / ".config" / "git-changelog.toml"), + str(Path(user_config_dir()) / "git-changelog.toml"), ] DEFAULT_SETTINGS = { From 389bd92afca730ebc10cd5b178c4726223dfb1b2 Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Mon, 21 Aug 2023 14:49:16 +0200 Subject: [PATCH 09/21] fix: remove _Sentinel class --- src/git_changelog/cli.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/git_changelog/cli.py b/src/git_changelog/cli.py index c923f2b..7f94470 100644 --- a/src/git_changelog/cli.py +++ b/src/git_changelog/cli.py @@ -581,10 +581,6 @@ def output_release_notes( file.write(release_notes) -class _Sentinel: - pass - - def main(args: list[str] | None = None) -> int: """Run the main program. @@ -600,7 +596,7 @@ def main(args: list[str] | None = None) -> int: opts = parser.parse_args(args=args) # Determine which arguments were explicitly set with the CLI - sentinel = _Sentinel() + sentinel = object() sentinel_ns = argparse.Namespace(**{key: sentinel for key in vars(opts)}) parser.parse_args(namespace=sentinel_ns) explicit_opts_dict = { From f36d1fb5b1f6928ee2380eb35abd4bddd2416efd Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Mon, 21 Aug 2023 14:58:54 +0200 Subject: [PATCH 10/21] fix: do not interpret ``None`` in config files This commit addresses [this comment (#55)](https://github.com/pawamoy/git-changelog/pull/55#discussion_r1299985309). Please note that this will disallow unsetting options if a hierarchy of config files were to be implemented at some point. --- docs/usage.md | 2 +- src/git_changelog/cli.py | 18 +++++++++++------- tests/test_cli.py | 8 +++++--- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 80f94da..ca97f76 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -479,7 +479,7 @@ output = 'output.log' parse-refs = false parse-trailers = false repository = '.' -sections = 'none' +sections = ['fix', 'maint'] template = 'angular' version-regex = '^## \[(?Pv?[^\]]+)' ``` diff --git a/src/git_changelog/cli.py b/src/git_changelog/cli.py index 7f94470..ad820bd 100644 --- a/src/git_changelog/cli.py +++ b/src/git_changelog/cli.py @@ -352,7 +352,7 @@ def read_config( or new_settings.get("tool.git-changelog", {}) ) - if not new_settings: # Likely, pyproject.toml did not have a git-changelog section + if not new_settings: # pyproject.toml did not have a git-changelog section continue # Settings can have hyphens like in the CLI @@ -362,14 +362,18 @@ def read_config( # Massage found values to meet expectations # Parse sections - if "sections" in new_settings and new_settings["sections"] is not None: - sections = new_settings["sections"] + if "sections" in new_settings: + # Remove "sections" from dict, only restore if the list is valid + sections = new_settings.pop("sections", None) if isinstance(sections, str): - sections = [s.strip() for s in sections.split(",")] + sections = sections.split(",") - new_settings["sections"] = [ - s.strip() for s in sections if s.strip() and s.strip() != "none" - ] or None + sections = [ + s.strip() for s in sections if isinstance(s, str) and s.strip() + ] + + if sections: # toml doesn't store null/nil + new_settings["sections"] = sections project_config.update(new_settings) break diff --git a/tests/test_cli.py b/tests/test_cli.py index c5caabe..60022d7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -57,14 +57,16 @@ def test_passing_repository_and_sections(tmp_path: Path, args: tuple[str]) -> No @pytest.mark.parametrize("is_pyproject", [True, False, None]) @pytest.mark.parametrize(("sections", "sections_value"), [ (None, None), - ("none", None), - ("force-null", None), ("", None), - ("none, none, none", None), + (",,", None), + ("force-null", None), ("a, b, ", ["a", "b"]), ("a, , ", ["a"]), ("a, b, c", ["a", "b", "c"]), (["a", "b", "c"], ["a", "b", "c"]), + # Uncomment if None/null is once allowed as a value + # ("none", None), + # ("none, none, none", None), ]) @pytest.mark.parametrize("parse_refs", [None, False, True]) def test_config_reading( From 89a1af30e592cbdef08853ad8682a90b7c6e2424 Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Mon, 21 Aug 2023 15:06:49 +0200 Subject: [PATCH 11/21] sty: roll back multiline string --- src/git_changelog/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/git_changelog/cli.py b/src/git_changelog/cli.py index ad820bd..25f7809 100644 --- a/src/git_changelog/cli.py +++ b/src/git_changelog/cli.py @@ -166,7 +166,7 @@ def get_parser() -> argparse.ArgumentParser: "Can be one of 'auto', 'major', 'minor', 'patch' or a valid semver version (eg. 1.2.3). " "With 'auto', if a commit contains breaking changes, bump the major number (or the minor number for 0.x versions), " "else if there are new features, bump the minor number, else just bump the patch number. " - "Default: unset.", + "Default: unset (false).", ) parser.add_argument( "-h", From d97d033461b1dbe71fd5d6f410c13ddd43afd91d Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Mon, 21 Aug 2023 16:01:07 +0200 Subject: [PATCH 12/21] maint: pacify mypy type checking --- src/git_changelog/cli.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/git_changelog/cli.py b/src/git_changelog/cli.py index 52826e3..f514b0b 100644 --- a/src/git_changelog/cli.py +++ b/src/git_changelog/cli.py @@ -19,7 +19,7 @@ import warnings from importlib import metadata from pathlib import Path -from typing import Pattern, TextIO +from typing import Pattern, TextIO, Sequence import toml from appdirs import user_config_dir @@ -331,7 +331,7 @@ def _unreleased(versions: list[Version], last_release: str) -> list[Version]: def read_config( - config_file: str | Path | list[str | Path] | None = DEFAULT_CONFIG_FILES, + config_file: Sequence[str | Path] | str | Path | None = DEFAULT_CONFIG_FILES, ) -> dict: """Find config files and initialize settings with the one of highest priority. @@ -349,9 +349,7 @@ def read_config( if config_file is None: # Unset config file return project_config - config_file = config_file if isinstance(config_file, (list, tuple)) else [config_file] - - for filename in config_file: + for filename in config_file if isinstance(config_file, (list, tuple)) else [config_file]: _path = Path(filename) if not _path.exists(): From 4990bdd8638ff3b52dd303a6dd06627571765dfc Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Mon, 21 Aug 2023 16:19:48 +0200 Subject: [PATCH 13/21] fix: sloppy merge leftovers --- src/git_changelog/cli.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/git_changelog/cli.py b/src/git_changelog/cli.py index f514b0b..92134dc 100644 --- a/src/git_changelog/cli.py +++ b/src/git_changelog/cli.py @@ -168,18 +168,6 @@ def get_parser() -> argparse.ArgumentParser: "else if there are new features, bump the minor number, else just bump the patch number. " "Default: unset (false).", ) - parser.add_argument( - "-B", - "--bump", - action="store", - dest="bump", - metavar="VERSION", - default=None, - help="Specify the bump from latest version for the set of unreleased commits. " - "Can be one of 'auto', 'major', 'minor', 'patch' or a valid semver version (eg. 1.2.3). " - "With 'auto', if a commit contains breaking changes, bump the major number (or the minor number for 0.x versions), " - "else if there are new features, bump the minor number, else just bump the patch number. Default: %(default)s.", - ) parser.add_argument( "-h", "--help", @@ -455,12 +443,6 @@ def build_and_render( # get provider provider_class = providers[provider] if provider else None - # TODO: remove at some point - if bump_latest: - warnings.warn("`bump_latest=True` is deprecated in favor of `bump='auto'`", DeprecationWarning, stacklevel=1) - if bump is None: - bump = "auto" - # build data changelog = Changelog( repository, From cbaf5e8b455f4236fc8c70ebf308c54360378081 Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Mon, 21 Aug 2023 17:14:04 +0200 Subject: [PATCH 14/21] fix: the second parser also needs the arguments --- src/git_changelog/cli.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/git_changelog/cli.py b/src/git_changelog/cli.py index 92134dc..d9ffb85 100644 --- a/src/git_changelog/cli.py +++ b/src/git_changelog/cli.py @@ -595,14 +595,14 @@ def main(args: list[str] | None = None) -> int: An exit code. """ parser = get_parser() - opts = parser.parse_args(args=args) + opts = vars(parser.parse_args(args=args)) # Determine which arguments were explicitly set with the CLI sentinel = object() - sentinel_ns = argparse.Namespace(**{key: sentinel for key in vars(opts)}) - parser.parse_args(namespace=sentinel_ns) + sentinel_ns = argparse.Namespace(**{key: sentinel for key in opts.keys()}) explicit_opts_dict = { - key: value for key, value in vars(sentinel_ns).items() + key: opts.get(key, None) + for key, value in vars(parser.parse_args(namespace=sentinel_ns, args=args)).items() if value is not sentinel } From 20d20e2cee9c6651a77ca9531f901682c8a8f31c Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Mon, 21 Aug 2023 17:19:22 +0200 Subject: [PATCH 15/21] fix: roll deprecation warning back --- src/git_changelog/cli.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/git_changelog/cli.py b/src/git_changelog/cli.py index d9ffb85..3444e2c 100644 --- a/src/git_changelog/cli.py +++ b/src/git_changelog/cli.py @@ -443,6 +443,12 @@ def build_and_render( # get provider provider_class = providers[provider] if provider else None + # TODO: remove at some point + if bump_latest: + warnings.warn("`bump_latest=True` is deprecated in favor of `bump='auto'`", DeprecationWarning, stacklevel=1) + if bump is None: + bump = "auto" + # build data changelog = Changelog( repository, @@ -617,19 +623,6 @@ def main(args: list[str] | None = None) -> int: # CLI arguments override the config file settings settings.update(explicit_opts_dict) - # TODO: remove at some point - _bump_latest = settings.pop("bump_latest", None) - if _bump_latest is not None: - warnings.warn( - "`--bump-latest` is deprecated in favor of `--bump auto`", - FutureWarning, - stacklevel=1, - ) - - # If `--bump-latest` is `True`, set `--bump auto` - if _bump_latest and settings.get("bump", None) is None: - settings["bump"] = "auto" - if settings.pop("release_notes"): output_release_notes( input_file=settings["input"], From eec730b097fb0f8eb7ec25fa657b78e2fb66b555 Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Mon, 21 Aug 2023 18:53:05 +0200 Subject: [PATCH 16/21] fix: better handling of warnings in deprecation of --bump-latest --- src/git_changelog/cli.py | 78 ++++++++++++++++++++++++++++------------ tests/test_cli.py | 49 +++++++++++++++++++++++-- 2 files changed, 103 insertions(+), 24 deletions(-) diff --git a/src/git_changelog/cli.py b/src/git_changelog/cli.py index 3444e2c..c7dc387 100644 --- a/src/git_changelog/cli.py +++ b/src/git_changelog/cli.py @@ -358,6 +358,21 @@ def read_config( key.replace("-", "_"): value for key, value in new_settings.items() } + # TODO: remove at some point + if "bump_latest" in new_settings: + _opt_value = new_settings['bump_latest'] + _suggestion = ( + "remove it from the config file" if not _opt_value + else "set `bump = 'auto'` in the config file instead" + ) + warnings.warn( + f"`bump-latest = {str(_opt_value).lower()}` option found " + f"in config file ({_path.absolute()}). This option will be removed in the future. " + f"To achieve the same result, please {_suggestion}.", + FutureWarning, + stacklevel=1, + ) + # Massage found values to meet expectations # Parse sections if "sections" in new_settings: @@ -379,6 +394,46 @@ def read_config( return project_config +def parse_settings(args: list[str] | None = None) -> dict: + """Parse arguments and config files to build the final settings set. + + Arguments: + args: Arguments passed from the command line. + + Returns: + A dictionary with the final settings. + """ + + parser = get_parser() + opts = vars(parser.parse_args(args=args)) + + # Determine which arguments were explicitly set with the CLI + sentinel = object() + sentinel_ns = argparse.Namespace(**{key: sentinel for key in opts.keys()}) + explicit_opts_dict = { + key: opts.get(key, None) + for key, value in vars(parser.parse_args(namespace=sentinel_ns, args=args)).items() + if value is not sentinel + } + + config_file = explicit_opts_dict.pop("config_file", DEFAULT_CONFIG_FILES) + if str(config_file).strip().lower() in ("no", "none", "off", "false", "0", ""): + config_file = None + elif str(config_file).strip().lower() in ("yes", "default", "on", "true", "1"): + config_file = DEFAULT_CONFIG_FILES + + settings = read_config(config_file) + + # CLI arguments override the config file settings + settings.update(explicit_opts_dict) + + # TODO: remove at some point + if "bump_latest" in explicit_opts_dict: + warnings.warn("`--bump-latest` is deprecated in favor of `--bump=auto`", FutureWarning, stacklevel=1) + + return settings + + def build_and_render( repository: str, template: str, @@ -600,28 +655,7 @@ def main(args: list[str] | None = None) -> int: Returns: An exit code. """ - parser = get_parser() - opts = vars(parser.parse_args(args=args)) - - # Determine which arguments were explicitly set with the CLI - sentinel = object() - sentinel_ns = argparse.Namespace(**{key: sentinel for key in opts.keys()}) - explicit_opts_dict = { - key: opts.get(key, None) - for key, value in vars(parser.parse_args(namespace=sentinel_ns, args=args)).items() - if value is not sentinel - } - - config_file = explicit_opts_dict.pop("config_file", DEFAULT_CONFIG_FILES) - if str(config_file).strip().lower() in ("no", "none", "off", "false", "0", ""): - config_file = None - elif str(config_file).strip().lower() in ("yes", "default", "on", "true", "1"): - config_file = DEFAULT_CONFIG_FILES - - settings = read_config(config_file) - - # CLI arguments override the config file settings - settings.update(explicit_opts_dict) + settings = parse_settings(args) if settings.pop("release_notes"): output_release_notes( diff --git a/tests/test_cli.py b/tests/test_cli.py index 60022d7..a9851fe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,6 +14,8 @@ from pathlib import Path + + def test_main() -> None: """Basic CLI test.""" assert cli.main([]) == 0 @@ -35,7 +37,6 @@ def test_get_version() -> None: """Get self version.""" assert cli.get_version() - @pytest.mark.parametrize( "args", [ @@ -51,7 +52,12 @@ def test_passing_repository_and_sections(tmp_path: Path, args: tuple[str]) -> No args: Command line arguments. """ ch = tmp_path.joinpath("ch.md") - assert cli.main([*args, "-o", ch.as_posix(), "-c", "angular"]) == 0 + parsed_settings = cli.parse_settings([*args, "-o", ch.as_posix(), "-c", "angular"]) + + assert parsed_settings["output"] == str(ch.as_posix()) + assert parsed_settings["sections"] == ["feat", "fix"] + assert parsed_settings["repository"] == "." + assert parsed_settings["convention"] == "angular" @pytest.mark.parametrize("is_pyproject", [True, False, None]) @@ -116,3 +122,42 @@ def test_config_reading( ground_truth["parse_refs"] = bool(parse_refs) assert settings == ground_truth + + +@pytest.mark.parametrize("value", [None, False, True]) +def test_settings_warning( + tmp_path: Path, + value: bool, +) -> None: + """Check warning on bump_latest. + + Parameters: + tmp_path: A temporary path to write the settings file into. + """ + + os.chdir(tmp_path) + + args = [] + if value is not None: + (tmp_path / ".git-changelog.toml").write_text( + toml.dumps({"bump_latest": value}) + ) + else: + args = ["--bump-latest"] + + with pytest.warns(FutureWarning) as record: + cli.parse_settings(args) + + solution = "is deprecated in favor of" # Warning comes from CLI parsing. + if value is not None: # Warning is issued when parsing the config file. + solution = "remove" if not value else "auto" + + assert len(record) == 1 + assert solution in str(record[0].message) + + # If setting is in config file AND passed by CLI, two FutureWarnings are issued. + if (tmp_path / ".git-changelog.toml").exists(): + with pytest.warns(FutureWarning) as record: + cli.parse_settings(["--bump-latest"]) + + assert len(record) == 2 From 3f38e6a40d1bb8525b6b9255a2351a5080dea7fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Mon, 21 Aug 2023 23:27:40 +0200 Subject: [PATCH 17/21] Update tests/test_cli.py --- tests/test_cli.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index a9851fe..65c4f25 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -86,12 +86,12 @@ def test_config_reading( Parameters: tmp_path: A temporary path to write the settings file into. - is_pyproject: controls whether a ``pyproject.toml`` (``True``), - a ``.git-changelog.toml`` (``False``) or a custom file (``None``) is being tested. - sections: A ``sections`` config to override defaults. - sections_falue: The expectation for ``sections`` after reading the config file. - parse_refs: A explicit override of the ``parse_refs`` of the config (if boolean) - or skip writing the override into the test config file (``None``). + is_pyproject: Controls whether a `pyproject.toml` (`True`), + a `.git-changelog.toml` (`False`) or a custom file (`None`) is being tested. + sections: A `sections` config to override defaults. + sections_value: The expectation for `sections` after reading the config file. + parse_refs: An explicit override of the `parse_refs` of the config (if boolean) + or skip writing the override into the test config file (`None`). """ os.chdir(tmp_path) From 21599f772432080c207e526671b81e3de0bfe268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Mon, 21 Aug 2023 23:28:28 +0200 Subject: [PATCH 18/21] Apply suggestions from code review --- docs/usage.md | 37 +++++++++++++++++++------------------ src/git_changelog/cli.py | 11 +++++------ 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index ba7fbbe..4f6f2c2 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -445,10 +445,11 @@ Project-wise, permanent configuration of *git-changelog* is possible. By default, *git-changelog* will search for the existence a suitable configuration in the `pyproject.toml` file or otherwise, the following configuration files in this particular order: - * `.git-changelog.toml` - * `config/git-changelog.toml` - * `.config/git-changelog.toml` - * `/git-changelog.toml` + +- `.git-changelog.toml` +- `config/git-changelog.toml` +- `.config/git-changelog.toml` +- `/git-changelog.toml` In the last case (`/git-changelog.toml`), the `` is platform-dependent and will be automatically inferred from your settings. @@ -472,16 +473,16 @@ for most of the command line options: ```toml bump = "auto" -convention = 'basic' +convention = "basic" in-place = false -marker-line = '' -output = 'output.log' +marker-line = "" +output = "output.log" parse-refs = false parse-trailers = false -repository = '.' -sections = ['fix', 'maint'] -template = 'angular' -version-regex = '^## \[(?Pv?[^\]]+)' +repository = "." +sections = ["fix", "maint"] +template = "angular" +version-regex = "^## \[(?Pv?[^\]]+)" ``` In the case of configuring *git-changelog* within `pyproject.toml`, these @@ -490,16 +491,16 @@ settings must be found in the appropriate section: ```toml [tool.git-changelog] bump = "minor" -convention = 'conventional' +convention = "conventional" in-place = false -marker-line = '' -output = 'output.log' +marker-line = "" +output = "output.log" parse-refs = false parse-trailers = false -repository = '.' -sections = 'fix,maint' -template = 'keepachangelog' -version-regex = '^## \[(?Pv?[^\]]+)' +repository = "." +sections = "fix,maint" +template = "keepachangelog" +version-regex = "^## \[(?Pv?[^\]]+)" ``` [keepachangelog]: https://keepachangelog.com/en/1.0.0/ diff --git a/src/git_changelog/cli.py b/src/git_changelog/cli.py index c7dc387..d4d383f 100644 --- a/src/git_changelog/cli.py +++ b/src/git_changelog/cli.py @@ -323,14 +323,13 @@ def read_config( ) -> dict: """Find config files and initialize settings with the one of highest priority. - Arguments: - config_file: A path or list of paths to configuration file(s); or ``None`` to + Parameters: + config_file: A path or list of paths to configuration file(s); or `None` to disable config file settings. Default: a list of paths given by - :obj:`~git_changelog.cli.DEFAULT_CONFIG_FILES`. + [`git_changelog.cli.DEFAULT_CONFIG_FILES`][]. Returns: - A settings dictionary. Default settings if no config file is found or ``config_file`` - is ``None``. + A settings dictionary. Default settings if no config file is found or `config_file` is `None`. """ project_config = DEFAULT_SETTINGS.copy() @@ -397,7 +396,7 @@ def read_config( def parse_settings(args: list[str] | None = None) -> dict: """Parse arguments and config files to build the final settings set. - Arguments: + Parameters: args: Arguments passed from the command line. Returns: From cefec7fd1e090609109a97600ae6603f67defd16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Mon, 21 Aug 2023 23:45:55 +0200 Subject: [PATCH 19/21] tests: Restore CWD after changing it --- tests/test_cli.py | 107 ++++++++++++++++++++++++++-------------------- 1 file changed, 60 insertions(+), 47 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 65c4f25..a2a50b3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,6 +4,7 @@ import os from typing import TYPE_CHECKING +import sys import pytest import toml @@ -14,6 +15,20 @@ from pathlib import Path +if sys.version_info >= (3, 11): + from contextlib import chdir +else: + # TODO: remove once support for Python 3.10 is dropped + from contextlib import contextmanager + + @contextmanager + def chdir(path: str) -> Iterator[None]: # noqa: D103 + old_wd = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(old_wd) def test_main() -> None: @@ -93,35 +108,34 @@ def test_config_reading( parse_refs: An explicit override of the `parse_refs` of the config (if boolean) or skip writing the override into the test config file (`None`). """ - os.chdir(tmp_path) - - config_content = {} - - if sections is not None: - config_content["sections"] = None if sections == "force-null" else sections - - if parse_refs is not None: - config_content["parse_refs"] = parse_refs - - config_fname = "custom-file.toml" if is_pyproject is None else ".git-changelog.toml" - config_fname = "pyproject.toml" if is_pyproject else config_fname - (tmp_path / config_fname).write_text( - toml.dumps( - config_content if not is_pyproject - else {"tool": {"git-changelog": config_content}}, - ), - ) + with chdir(tmp_path): + config_content = {} + + if sections is not None: + config_content["sections"] = None if sections == "force-null" else sections + + if parse_refs is not None: + config_content["parse_refs"] = parse_refs + + config_fname = "custom-file.toml" if is_pyproject is None else ".git-changelog.toml" + config_fname = "pyproject.toml" if is_pyproject else config_fname + (tmp_path / config_fname).write_text( + toml.dumps( + config_content if not is_pyproject + else {"tool": {"git-changelog": config_content}}, + ), + ) - settings = ( - cli.read_config(tmp_path / config_fname) if config_fname == "custom-file.toml" - else cli.read_config() - ) + settings = ( + cli.read_config(tmp_path / config_fname) if config_fname == "custom-file.toml" + else cli.read_config() + ) - ground_truth = cli.DEFAULT_SETTINGS.copy() - ground_truth["sections"] = sections_value - ground_truth["parse_refs"] = bool(parse_refs) + ground_truth = cli.DEFAULT_SETTINGS.copy() + ground_truth["sections"] = sections_value + ground_truth["parse_refs"] = bool(parse_refs) - assert settings == ground_truth + assert settings == ground_truth @pytest.mark.parametrize("value", [None, False, True]) @@ -135,29 +149,28 @@ def test_settings_warning( tmp_path: A temporary path to write the settings file into. """ - os.chdir(tmp_path) - - args = [] - if value is not None: - (tmp_path / ".git-changelog.toml").write_text( - toml.dumps({"bump_latest": value}) - ) - else: - args = ["--bump-latest"] + with chdir(tmp_path): + args = [] + if value is not None: + (tmp_path / ".git-changelog.toml").write_text( + toml.dumps({"bump_latest": value}) + ) + else: + args = ["--bump-latest"] - with pytest.warns(FutureWarning) as record: - cli.parse_settings(args) + with pytest.warns(FutureWarning) as record: + cli.parse_settings(args) - solution = "is deprecated in favor of" # Warning comes from CLI parsing. - if value is not None: # Warning is issued when parsing the config file. - solution = "remove" if not value else "auto" + solution = "is deprecated in favor of" # Warning comes from CLI parsing. + if value is not None: # Warning is issued when parsing the config file. + solution = "remove" if not value else "auto" - assert len(record) == 1 - assert solution in str(record[0].message) + assert len(record) == 1 + assert solution in str(record[0].message) - # If setting is in config file AND passed by CLI, two FutureWarnings are issued. - if (tmp_path / ".git-changelog.toml").exists(): - with pytest.warns(FutureWarning) as record: - cli.parse_settings(["--bump-latest"]) + # If setting is in config file AND passed by CLI, two FutureWarnings are issued. + if (tmp_path / ".git-changelog.toml").exists(): + with pytest.warns(FutureWarning) as record: + cli.parse_settings(["--bump-latest"]) - assert len(record) == 2 + assert len(record) == 2 From 481baca77e4c2895b2d7e0199e9c420536206bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Mon, 21 Aug 2023 23:55:31 +0200 Subject: [PATCH 20/21] style: Format --- src/git_changelog/cli.py | 34 ++++++++++---------------- tests/test_cli.py | 53 ++++++++++++++++++++-------------------- 2 files changed, 39 insertions(+), 48 deletions(-) diff --git a/src/git_changelog/cli.py b/src/git_changelog/cli.py index d4d383f..b72e231 100644 --- a/src/git_changelog/cli.py +++ b/src/git_changelog/cli.py @@ -19,7 +19,7 @@ import warnings from importlib import metadata from pathlib import Path -from typing import Pattern, TextIO, Sequence +from typing import Pattern, Sequence, TextIO import toml from appdirs import user_config_dir @@ -46,6 +46,7 @@ ".config/git-changelog.toml", str(Path(user_config_dir()) / "git-changelog.toml"), ] +"""Default configuration files read by git-changelog.""" DEFAULT_SETTINGS = { "bump": None, @@ -238,15 +239,13 @@ def get_parser() -> argparse.ArgumentParser: "--release-notes", action="store_true", dest="release_notes", - help="Output release notes to stdout based on the last entry in the changelog. " - "Default: unset (false).", + help="Output release notes to stdout based on the last entry in the changelog. Default: unset (false).", ) parser.add_argument( "-I", "--input", dest="input", - help="Read from given file when creating release notes. " - f"Default: '{DEFAULT_CHANGELOG_FILE}'.", + help=f"Read from given file when creating release notes. Default: '{DEFAULT_CHANGELOG_FILE}'.", ) parser.add_argument( "-c", @@ -255,8 +254,7 @@ def get_parser() -> argparse.ArgumentParser: "--convention", choices=CONVENTIONS, dest="convention", - help="The commit convention to match against. " - f"Default: '{DEFAULT_SETTINGS['convention']}'.", + help=f"The commit convention to match against. Default: '{DEFAULT_SETTINGS['convention']}'.", ) parser.add_argument( "-s", @@ -344,25 +342,22 @@ def read_config( new_settings = toml.load(_path) if _path.name == "pyproject.toml": - new_settings = ( - new_settings.get("tool", {}).get("git-changelog", {}) - or new_settings.get("tool.git-changelog", {}) + new_settings = new_settings.get("tool", {}).get("git-changelog", {}) or new_settings.get( + "tool.git-changelog", + {}, ) if not new_settings: # pyproject.toml did not have a git-changelog section continue # Settings can have hyphens like in the CLI - new_settings = { - key.replace("-", "_"): value for key, value in new_settings.items() - } + new_settings = {key.replace("-", "_"): value for key, value in new_settings.items()} # TODO: remove at some point if "bump_latest" in new_settings: - _opt_value = new_settings['bump_latest'] + _opt_value = new_settings["bump_latest"] _suggestion = ( - "remove it from the config file" if not _opt_value - else "set `bump = 'auto'` in the config file instead" + "remove it from the config file" if not _opt_value else "set `bump = 'auto'` in the config file instead" ) warnings.warn( f"`bump-latest = {str(_opt_value).lower()}` option found " @@ -380,9 +375,7 @@ def read_config( if isinstance(sections, str): sections = sections.split(",") - sections = [ - s.strip() for s in sections if isinstance(s, str) and s.strip() - ] + sections = [s.strip() for s in sections if isinstance(s, str) and s.strip()] if sections: # toml doesn't store null/nil new_settings["sections"] = sections @@ -402,13 +395,12 @@ def parse_settings(args: list[str] | None = None) -> dict: Returns: A dictionary with the final settings. """ - parser = get_parser() opts = vars(parser.parse_args(args=args)) # Determine which arguments were explicitly set with the CLI sentinel = object() - sentinel_ns = argparse.Namespace(**{key: sentinel for key in opts.keys()}) + sentinel_ns = argparse.Namespace(**{key: sentinel for key in opts}) explicit_opts_dict = { key: opts.get(key, None) for key, value in vars(parser.parse_args(namespace=sentinel_ns, args=args)).items() diff --git a/tests/test_cli.py b/tests/test_cli.py index a2a50b3..e39056e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,8 +3,8 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING import sys +from typing import TYPE_CHECKING, Any, Iterator import pytest import toml @@ -52,6 +52,7 @@ def test_get_version() -> None: """Get self version.""" assert cli.get_version() + @pytest.mark.parametrize( "args", [ @@ -76,19 +77,22 @@ def test_passing_repository_and_sections(tmp_path: Path, args: tuple[str]) -> No @pytest.mark.parametrize("is_pyproject", [True, False, None]) -@pytest.mark.parametrize(("sections", "sections_value"), [ - (None, None), - ("", None), - (",,", None), - ("force-null", None), - ("a, b, ", ["a", "b"]), - ("a, , ", ["a"]), - ("a, b, c", ["a", "b", "c"]), - (["a", "b", "c"], ["a", "b", "c"]), - # Uncomment if None/null is once allowed as a value - # ("none", None), - # ("none, none, none", None), -]) +@pytest.mark.parametrize( + ("sections", "sections_value"), + [ + (None, None), + ("", None), + (",,", None), + ("force-null", None), + ("a, b, ", ["a", "b"]), + ("a, , ", ["a"]), + ("a, b, c", ["a", "b", "c"]), + (["a", "b", "c"], ["a", "b", "c"]), + # Uncomment if None/null is once allowed as a value + # ("none", None), + # ("none, none, none", None), + ], +) @pytest.mark.parametrize("parse_refs", [None, False, True]) def test_config_reading( tmp_path: Path, @@ -108,8 +112,8 @@ def test_config_reading( parse_refs: An explicit override of the `parse_refs` of the config (if boolean) or skip writing the override into the test config file (`None`). """ - with chdir(tmp_path): - config_content = {} + with chdir(str(tmp_path)): + config_content: dict[str, Any] = {} if sections is not None: config_content["sections"] = None if sections == "force-null" else sections @@ -121,17 +125,13 @@ def test_config_reading( config_fname = "pyproject.toml" if is_pyproject else config_fname (tmp_path / config_fname).write_text( toml.dumps( - config_content if not is_pyproject - else {"tool": {"git-changelog": config_content}}, + config_content if not is_pyproject else {"tool": {"git-changelog": config_content}}, ), ) - settings = ( - cli.read_config(tmp_path / config_fname) if config_fname == "custom-file.toml" - else cli.read_config() - ) + settings = cli.read_config(tmp_path / config_fname) if config_fname == "custom-file.toml" else cli.read_config() - ground_truth = cli.DEFAULT_SETTINGS.copy() + ground_truth: dict[str, Any] = cli.DEFAULT_SETTINGS.copy() ground_truth["sections"] = sections_value ground_truth["parse_refs"] = bool(parse_refs) @@ -148,12 +148,11 @@ def test_settings_warning( Parameters: tmp_path: A temporary path to write the settings file into. """ - - with chdir(tmp_path): - args = [] + with chdir(str(tmp_path)): + args: list[str] = [] if value is not None: (tmp_path / ".git-changelog.toml").write_text( - toml.dumps({"bump_latest": value}) + toml.dumps({"bump_latest": value}), ) else: args = ["--bump-latest"] From 204b308ccd0a7f8cea831295a8b6a316ab93f16d Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Tue, 22 Aug 2023 08:55:54 +0200 Subject: [PATCH 21/21] fix(docs): version regex in usage examples --- docs/usage.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 4f6f2c2..180bfd2 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -482,7 +482,7 @@ parse-trailers = false repository = "." sections = ["fix", "maint"] template = "angular" -version-regex = "^## \[(?Pv?[^\]]+)" +version-regex = "^## \\\\[(?Pv?[^\\\\]]+)" ``` In the case of configuring *git-changelog* within `pyproject.toml`, these @@ -500,7 +500,7 @@ parse-trailers = false repository = "." sections = "fix,maint" template = "keepachangelog" -version-regex = "^## \[(?Pv?[^\]]+)" +version-regex = "^## \\\\[(?Pv?[^\\\\]]+)" ``` [keepachangelog]: https://keepachangelog.com/en/1.0.0/