Skip to content

Commit

Permalink
feat: add configuration files
Browse files Browse the repository at this point in the history
Permit *git-changelog* be configured permanently via config files.

Resolves: pawamoy#54.
  • Loading branch information
oesteban committed Aug 18, 2023
1 parent 1551b24 commit c3d0a06
Show file tree
Hide file tree
Showing 2 changed files with 187 additions and 24 deletions.
60 changes: 60 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,66 @@ This is useful when you don't tell *git-changelog* to bump the latest version:
you will have an "Unreleased" section that is overwritten and updated
each time you update your changelog in-place.

## 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 = '<!-- insertion marker -->'
output = 'output.log'
parse-refs = false
parse-trailers = false
repository = '.'
sections = ''
template = 'angular'
version-regex = '^## \[(?P<version>v?[^\]]+)'
```

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 = 'atom'
in-place = false
marker-line = '<!-- insertion marker -->'
output = 'output.log'
parse-refs = false
parse-trailers = false
repository = '.'
sections = ''
template = 'keepachangelog'
version-regex = '^## \[(?P<version>v?[^\]]+)'
```

[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/
Expand Down
151 changes: 127 additions & 24 deletions src/git_changelog/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import re
import sys
from importlib import metadata
import toml
from pathlib import Path
from typing import Pattern, TextIO

from jinja2.exceptions import TemplateNotFound
Expand All @@ -30,9 +32,30 @@
ConventionalCommitConvention,
)

DEFAULT_CONFIG_FILES = [
"pyproject.toml",
".git-changelog.toml",
"config/git-changelog.toml",
".config/git-changelog.toml",
str(Path.home() / ".config" / "git-changelog.toml"),
]

DEFAULT_VERSION_REGEX = r"^## \[(?P<version>v?[^\]]+)"
DEFAULT_MARKER_LINE = "<!-- insertion marker -->"
CONVENTIONS = ("angular", "atom", "conventional", "basic")
DEFAULT_SETTINGS = {
"bump_latest": False,
"convention": "basic",
"in_place": False,
"marker_line": DEFAULT_MARKER_LINE,
"output": sys.stdout,
"parse_refs": False,
"parse_trailers": False,
"repository": ".",
"sections": None,
"template": "keepachangelog",
"version_regex": DEFAULT_VERSION_REGEX,
}


class Templates(tuple): # (subclassing tuple)
Expand Down Expand Up @@ -96,16 +119,21 @@ def get_parser() -> argparse.ArgumentParser:
"repository",
metavar="REPOSITORY",
nargs="?",
default=".",
help="The repository path, relative or absolute.",
)

parser.add_argument(
"--config-file",
metavar="PATH",
nargs="*",
help="Configuration file(s).",
)

parser.add_argument(
"-b",
"--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. "
"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.",
Expand All @@ -122,7 +150,6 @@ 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. "
Expand All @@ -136,7 +163,6 @@ def get_parser() -> argparse.ArgumentParser:
"--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. ",
Expand All @@ -147,7 +173,6 @@ 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, "
Expand All @@ -159,15 +184,13 @@ def get_parser() -> argparse.ArgumentParser:
"--output",
action="store",
dest="output",
default=sys.stdout,
help="Output to given file. Default: stdout.",
)
parser.add_argument(
"-r",
"--parse-refs",
action="store_true",
dest="parse_refs",
default=False,
help="Parse provider-specific references in commit messages (GitHub/GitLab issues, PRs, etc.).",
)
parser.add_argument(
Expand All @@ -176,7 +199,6 @@ def get_parser() -> argparse.ArgumentParser:
"--commit-style",
"--convention",
choices=CONVENTIONS,
default="basic",
dest="convention",
help="The commit convention to match against. Default: basic.",
)
Expand All @@ -185,7 +207,6 @@ def get_parser() -> argparse.ArgumentParser:
"--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.",
Expand All @@ -194,7 +215,6 @@ def get_parser() -> argparse.ArgumentParser:
"-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".',
Expand All @@ -204,7 +224,6 @@ def get_parser() -> argparse.ArgumentParser:
"--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.",
)
Expand Down Expand Up @@ -233,6 +252,82 @@ 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


class _Sentinel:
pass


def main(args: list[str] | None = None) -> int:
"""Run the main program.
Expand All @@ -247,20 +342,28 @@ def main(args: list[str] | None = None) -> int:
parser = get_parser()
opts = parser.parse_args(args=args)

# 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)

try:
build_and_render(
repository=opts.repository,
template=opts.template,
convention=opts.convention,
parse_refs=opts.parse_refs,
parse_trailers=opts.parse_trailers,
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,
)
build_and_render(**settings)
except ValueError as error:
print(f"git-changelog: {error}", file=sys.stderr)
return 1
Expand Down

0 comments on commit c3d0a06

Please sign in to comment.