Coverage for src/git_changelog/cli.py: 83.33%
228 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-02 00:26 +0200
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-02 00:26 +0200
1"""Module that contains the command line application."""
3# Why does this file exist, and why not put this in `__main__`?
4#
5# You might be tempted to import things from `__main__` later,
6# but that will cause problems: the code will get executed twice:
7#
8# - When you run `python -m git_changelog` python will execute
9# `__main__.py` as a script. That means there won't be any
10# `git_changelog.__main__` in `sys.modules`.
11# - When you import `__main__` it will get executed again (as a module) because
12# there's no `git_changelog.__main__` in `sys.modules`.
14from __future__ import annotations
16import argparse
17import re
18import sys
19import warnings
20from importlib import metadata
21from pathlib import Path
22from typing import Any, Literal, Pattern, Sequence, TextIO
24from appdirs import user_config_dir
25from jinja2.exceptions import TemplateNotFound
27from git_changelog import debug, templates
28from git_changelog.build import Changelog, Version
29from git_changelog.commit import (
30 AngularConvention,
31 BasicConvention,
32 CommitConvention,
33 ConventionalCommitConvention,
34)
35from git_changelog.providers import Bitbucket, GitHub, GitLab, ProviderRefParser
36from git_changelog.versioning import bump_pep440, bump_semver
38# TODO: Remove once support for Python 3.10 is dropped.
39if sys.version_info >= (3, 11):
40 import tomllib
41else:
42 import tomli as tomllib
44DEFAULT_VERSIONING = "semver"
45DEFAULT_VERSION_REGEX = r"^## \[(?P<version>v?[^\]]+)"
46DEFAULT_MARKER_LINE = "<!-- insertion marker -->"
47DEFAULT_CHANGELOG_FILE = "CHANGELOG.md"
48CONVENTIONS = ("angular", "conventional", "basic")
49DEFAULT_CONFIG_FILES = [
50 "pyproject.toml",
51 ".git-changelog.toml",
52 "config/git-changelog.toml",
53 ".config/git-changelog.toml",
54 str(Path(user_config_dir()) / "git-changelog.toml"),
55]
56"""Default configuration files read by git-changelog."""
58DEFAULT_SETTINGS: dict[str, Any] = {
59 "bump": None,
60 "bump_latest": None,
61 "convention": "basic",
62 "filter_commits": None,
63 "in_place": False,
64 "input": DEFAULT_CHANGELOG_FILE,
65 "marker_line": DEFAULT_MARKER_LINE,
66 "omit_empty_versions": False,
67 "output": sys.stdout,
68 "parse_refs": False,
69 "parse_trailers": False,
70 "provider": None,
71 "release_notes": False,
72 "repository": ".",
73 "sections": None,
74 "template": "keepachangelog",
75 "jinja_context": {},
76 "version_regex": DEFAULT_VERSION_REGEX,
77 "versioning": DEFAULT_VERSIONING,
78 "zerover": True,
79}
82class Templates(tuple): # (subclassing tuple)
83 """Helper to pick a template on the command line."""
85 def __contains__(self, item: object) -> bool:
86 if isinstance(item, str): 86 ↛ 88line 86 didn't jump to line 88, because the condition on line 86 was never false
87 return item.startswith("path:") or super().__contains__(item)
88 return False
91def get_version() -> str:
92 """Return the current `git-changelog` version.
94 Returns:
95 The current `git-changelog` version.
96 """
97 try:
98 return metadata.version("git-changelog")
99 except metadata.PackageNotFoundError:
100 return "0.0.0"
103def _comma_separated_list(value: str) -> list[str]:
104 return value.split(",")
107class _ParseDictAction(argparse.Action):
108 def __call__(
109 self,
110 parser: argparse.ArgumentParser, # noqa: ARG002
111 namespace: argparse.Namespace,
112 values: str | Sequence[Any] | None,
113 option_string: str | Sequence[Any] | None = None, # noqa: ARG002
114 ):
115 attribute = getattr(namespace, self.dest)
116 if not isinstance(attribute, dict):
117 setattr(namespace, self.dest, {})
118 if isinstance(values, str): 118 ↛ exitline 118 didn't return from function '__call__', because the condition on line 118 was never false
119 key, value = values.split("=", 1)
120 getattr(namespace, self.dest)[key] = value
123providers: dict[str, type[ProviderRefParser]] = {
124 "github": GitHub,
125 "gitlab": GitLab,
126 "bitbucket": Bitbucket,
127}
130class _DebugInfo(argparse.Action):
131 def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None:
132 super().__init__(nargs=nargs, **kwargs)
134 def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
135 debug.print_debug_info()
136 sys.exit(0)
139def get_parser() -> argparse.ArgumentParser:
140 """Return the CLI argument parser.
142 Returns:
143 An argparse parser.
144 """
145 parser = argparse.ArgumentParser(
146 add_help=False,
147 prog="git-changelog",
148 description=re.sub(
149 r"\n *",
150 "\n",
151 f"""
152 Automatic Changelog generator using Jinja2 templates.
154 This tool parses your commit messages to extract useful data
155 that is then rendered using Jinja2 templates, for example to
156 a changelog file formatted in Markdown.
158 Each Git tag will be treated as a version of your project.
159 Each version contains a set of commits, and will be an entry
160 in your changelog. Commits in each version will be grouped
161 by sections, depending on the commit convention you follow.
163 ### Conventions
165 {BasicConvention._format_sections_help()}
166 {AngularConvention._format_sections_help()}
167 {ConventionalCommitConvention._format_sections_help()}
168 """,
169 ),
170 formatter_class=argparse.RawDescriptionHelpFormatter,
171 )
173 parser.add_argument(
174 "repository",
175 metavar="REPOSITORY",
176 nargs="?",
177 help="The repository path, relative or absolute. Default: current working directory.",
178 )
180 parser.add_argument(
181 "--config-file",
182 metavar="PATH",
183 nargs="*",
184 help="Configuration file(s).",
185 )
187 parser.add_argument(
188 "-b",
189 "--bump-latest",
190 action="store_true",
191 dest="bump_latest",
192 help="Deprecated, use --bump=auto instead. "
193 "Guess the new latest version by bumping the previous one based on the set of unreleased commits. "
194 "For example, if a commit contains breaking changes, bump the major number (or the minor number for 0.x versions). "
195 "Else if there are new features, bump the minor number. Else just bump the patch number. "
196 "Default: unset (false).",
197 )
198 parser.add_argument(
199 "-B",
200 "--bump",
201 action="store",
202 dest="bump",
203 metavar="VERSION",
204 help="Specify the bump from latest version for the set of unreleased commits. "
205 "Can be one of `auto`, `major`, `minor`, `patch` or a valid SemVer version (eg. 1.2.3). "
206 "For both SemVer and PEP 440 versioning schemes (see -n), `auto` will bump the major number "
207 "if a commit contains breaking changes (or the minor number for 0.x versions, see -Z), "
208 "else the minor number if there are new features, else the patch number. "
209 "Default: unset (false).",
210 )
211 parser.add_argument(
212 "-n",
213 "--versioning",
214 action="store",
215 dest="versioning",
216 metavar="SCHEME",
217 help="Versioning scheme to use when bumping and comparing versions. "
218 "The selected scheme will impact the values accepted by the `--bump` option. "
219 "Supported: `pep440`, `semver`. PEP 440 provides the following bump strategies: `auto`, "
220 f"`{'`, `'.join(part for part in bump_pep440.strategies if '+' not in part)}`. "
221 "Values `auto`, `major`, `minor`, `micro` can be suffixed with one of `+alpha`, `+beta`, `+candidate`, "
222 "and/or `+dev`. Values `alpha`, `beta` and `candidate` can be suffixed with `+dev`. "
223 "Examples: `auto+alpha`, `major+beta+dev`, `micro+dev`, `candidate+dev`, etc.. "
224 "SemVer provides the following bump strategies: `auto`, "
225 f"`{'`, `'.join(bump_semver.strategies)}`. "
226 "See the docs for more information. Default: unset (`semver`).",
227 )
228 parser.add_argument(
229 "-h",
230 "--help",
231 action="help",
232 default=argparse.SUPPRESS,
233 help="Show this help message and exit.",
234 )
235 parser.add_argument(
236 "-i",
237 "--in-place",
238 action="store_true",
239 dest="in_place",
240 help="Insert new entries (versions missing from changelog) in-place. "
241 "An output file must be specified. With custom templates, "
242 "you can pass two additional arguments: `--version-regex` and `--marker-line`. "
243 "When writing in-place, an `in_place` variable "
244 "will be injected in the Jinja context, "
245 "allowing to adapt the generated contents "
246 "(for example to skip changelog headers or footers). Default: unset (false).",
247 )
248 parser.add_argument(
249 "-g",
250 "--version-regex",
251 action="store",
252 metavar="REGEX",
253 dest="version_regex",
254 help="A regular expression to match versions in the existing changelog "
255 "(used to find the latest release) when writing in-place. "
256 "The regular expression must be a Python regex with a `version` named group. "
257 f"Default: `{DEFAULT_VERSION_REGEX}`.",
258 )
260 parser.add_argument(
261 "-m",
262 "--marker-line",
263 action="store",
264 metavar="MARKER",
265 dest="marker_line",
266 help="A marker line at which to insert new entries "
267 "(versions missing from changelog). "
268 "If two marker lines are present in the changelog, "
269 "the contents between those two lines will be overwritten "
270 "(useful to update an 'Unreleased' entry for example). "
271 f"Default: `{DEFAULT_MARKER_LINE}`.",
272 )
273 parser.add_argument(
274 "-o",
275 "--output",
276 action="store",
277 metavar="FILE",
278 dest="output",
279 help="Output to given file. Default: standard output.",
280 )
281 parser.add_argument(
282 "-p",
283 "--provider",
284 metavar="PROVIDER",
285 dest="provider",
286 choices=providers.keys(),
287 help="Explicitly specify the repository provider. Default: unset.",
288 )
289 parser.add_argument(
290 "-r",
291 "--parse-refs",
292 action="store_true",
293 dest="parse_refs",
294 help="Parse provider-specific references in commit messages (GitHub/GitLab/Bitbucket "
295 "issues, PRs, etc.). Default: unset (false).",
296 )
297 parser.add_argument(
298 "-R",
299 "--release-notes",
300 action="store_true",
301 dest="release_notes",
302 help="Output release notes to stdout based on the last entry in the changelog. Default: unset (false).",
303 )
304 parser.add_argument(
305 "-I",
306 "--input",
307 metavar="FILE",
308 dest="input",
309 help=f"Read from given file when creating release notes. Default: `{DEFAULT_CHANGELOG_FILE}`.",
310 )
311 parser.add_argument(
312 "-c",
313 "--convention",
314 "--commit-style",
315 "--style",
316 metavar="CONVENTION",
317 choices=CONVENTIONS,
318 dest="convention",
319 help=f"The commit convention to match against. Default: `{DEFAULT_SETTINGS['convention']}`.",
320 )
321 parser.add_argument(
322 "-s",
323 "--sections",
324 action="store",
325 type=_comma_separated_list,
326 metavar="SECTIONS",
327 dest="sections",
328 help="A comma-separated list of sections to render. "
329 "See the available sections for each supported convention in the description. "
330 "Default: unset (None).",
331 )
332 parser.add_argument(
333 "-t",
334 "--template",
335 choices=Templates(("angular", "keepachangelog")),
336 metavar="TEMPLATE",
337 dest="template",
338 help="The Jinja2 template to use. Prefix it with `path:` to specify the path "
339 "to a Jinja templated file. "
340 f"Default: `{DEFAULT_SETTINGS['template']}`.",
341 )
342 parser.add_argument(
343 "-T",
344 "--trailers",
345 "--git-trailers",
346 action="store_true",
347 dest="parse_trailers",
348 help="Parse Git trailers in the commit message. "
349 "See https://git-scm.com/docs/git-interpret-trailers. Default: unset (false).",
350 )
351 parser.add_argument(
352 "-E",
353 "--omit-empty-versions",
354 action="store_true",
355 dest="omit_empty_versions",
356 help="Omit empty versions from the output. Default: unset (false).",
357 )
358 parser.add_argument(
359 "-Z",
360 "--no-zerover",
361 action="store_false",
362 dest="zerover",
363 help="By default, breaking changes on a 0.x don't bump the major version, maintaining it at 0. "
364 "With this option, a breaking change will bump a 0.x version to 1.0.",
365 )
366 parser.add_argument(
367 "-F",
368 "--filter-commits",
369 action="store",
370 metavar="RANGE",
371 dest="filter_commits",
372 help="The Git revision-range filter to use (e.g. `v1.2.0..`). Default: no filter.",
373 )
374 parser.add_argument(
375 "-j",
376 "--jinja-context",
377 action=_ParseDictAction,
378 metavar="KEY=VALUE",
379 dest="jinja_context",
380 help="Pass additional key/value pairs to the template. Option can be used multiple times. "
381 "The key/value pairs are accessible as 'jinja_context' in the template.",
382 )
383 parser.add_argument(
384 "-V",
385 "--version",
386 action="version",
387 version=f"%(prog)s {debug.get_version()}",
388 help="Show the current version of the program and exit.",
389 )
390 parser.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.")
392 return parser
395def _latest(lines: list[str], regex: Pattern) -> str | None:
396 for line in lines: 396 ↛ 400line 396 didn't jump to line 400, because the loop on line 396 didn't complete
397 match = regex.search(line)
398 if match:
399 return match.groupdict()["version"]
400 return None
403def _unreleased(versions: list[Version], last_release: str) -> list[Version]:
404 for index, version in enumerate(versions): 404 ↛ 407line 404 didn't jump to line 407, because the loop on line 404 didn't complete
405 if version.tag == last_release:
406 return versions[:index]
407 return versions
410def read_config(
411 config_file: Sequence[str | Path] | str | Path | None = DEFAULT_CONFIG_FILES,
412) -> dict:
413 """Find config files and initialize settings with the one of highest priority.
415 Parameters:
416 config_file: A path or list of paths to configuration file(s); or `None` to
417 disable config file settings. Default: a list of paths given by
418 [`git_changelog.cli.DEFAULT_CONFIG_FILES`][].
420 Returns:
421 A settings dictionary. Default settings if no config file is found or `config_file` is `None`.
423 """
424 project_config = DEFAULT_SETTINGS.copy()
425 if config_file is None: # Unset config file 425 ↛ 426line 425 didn't jump to line 426, because the condition on line 425 was never true
426 return project_config
428 for filename in config_file if isinstance(config_file, (list, tuple)) else [config_file]:
429 _path = Path(filename)
431 if not _path.exists():
432 continue
434 with _path.open("rb") as file:
435 new_settings = tomllib.load(file)
436 if _path.name == "pyproject.toml":
437 new_settings = new_settings.get("tool", {}).get("git-changelog", {}) or new_settings.get(
438 "tool.git-changelog",
439 {},
440 )
442 if not new_settings: # pyproject.toml did not have a git-changelog section
443 continue
445 # Settings can have hyphens like in the CLI
446 new_settings = {key.replace("-", "_"): value for key, value in new_settings.items()}
448 # TODO: remove at some point
449 if "bump_latest" in new_settings:
450 _opt_value = new_settings["bump_latest"]
451 _suggestion = (
452 "remove it from the config file" if not _opt_value else "set `bump = 'auto'` in the config file instead"
453 )
454 warnings.warn(
455 f"`bump-latest = {str(_opt_value).lower()}` option found "
456 f"in config file ({_path.absolute()}). This option will be removed in the future. "
457 f"To achieve the same result, please {_suggestion}.",
458 FutureWarning,
459 stacklevel=1,
460 )
462 # Massage found values to meet expectations
463 # Parse sections
464 if "sections" in new_settings:
465 # Remove "sections" from dict, only restore if the list is valid
466 sections = new_settings.pop("sections", None)
467 if isinstance(sections, str):
468 sections = sections.split(",")
470 sections = [s.strip() for s in sections if isinstance(s, str) and s.strip()]
472 if sections: # toml doesn't store null/nil
473 new_settings["sections"] = sections
475 project_config.update(new_settings)
476 break
478 return project_config
481def parse_settings(args: list[str] | None = None) -> dict:
482 """Parse arguments and config files to build the final settings set.
484 Parameters:
485 args: Arguments passed from the command line.
487 Returns:
488 A dictionary with the final settings.
489 """
490 parser = get_parser()
491 opts = vars(parser.parse_args(args=args))
493 # Determine which arguments were explicitly set with the CLI
494 sentinel = object()
495 sentinel_ns = argparse.Namespace(**{key: sentinel for key in opts})
496 explicit_opts_dict = {
497 key: opts.get(key, None)
498 for key, value in vars(parser.parse_args(namespace=sentinel_ns, args=args)).items()
499 if value is not sentinel
500 }
502 config_file = explicit_opts_dict.pop("config_file", DEFAULT_CONFIG_FILES)
503 if str(config_file).strip().lower() in ("no", "none", "off", "false", "0", ""): 503 ↛ 504line 503 didn't jump to line 504, because the condition on line 503 was never true
504 config_file = None
505 elif str(config_file).strip().lower() in ("yes", "default", "on", "true", "1"): 505 ↛ 506line 505 didn't jump to line 506, because the condition on line 505 was never true
506 config_file = DEFAULT_CONFIG_FILES
508 jinja_context = explicit_opts_dict.pop("jinja_context", {})
510 settings = read_config(config_file)
512 # CLI arguments override the config file settings
513 settings.update(explicit_opts_dict)
515 # Merge jinja context, CLI values override config file values.
516 settings["jinja_context"].update(jinja_context)
518 # TODO: remove at some point
519 if "bump_latest" in explicit_opts_dict:
520 warnings.warn("`--bump-latest` is deprecated in favor of `--bump=auto`", FutureWarning, stacklevel=1)
522 return settings
525def build_and_render(
526 repository: str,
527 template: str,
528 convention: str | CommitConvention,
529 parse_refs: bool = False, # noqa: FBT001,FBT002
530 parse_trailers: bool = False, # noqa: FBT001,FBT002
531 sections: list[str] | None = None,
532 in_place: bool = False, # noqa: FBT001,FBT002
533 output: str | TextIO | None = None,
534 version_regex: str = DEFAULT_VERSION_REGEX,
535 marker_line: str = DEFAULT_MARKER_LINE,
536 bump_latest: bool = False, # noqa: FBT001,FBT002
537 omit_empty_versions: bool = False, # noqa: FBT001,FBT002
538 provider: str | None = None,
539 bump: str | None = None,
540 zerover: bool = True, # noqa: FBT001,FBT002
541 filter_commits: str | None = None,
542 jinja_context: dict[str, Any] | None = None,
543 versioning: Literal["pep440", "semver"] = "semver",
544) -> tuple[Changelog, str]:
545 """Build a changelog and render it.
547 This function returns the changelog instance and the rendered contents,
548 but also updates the specified output file (side-effect) or writes to stdout.
550 Parameters:
551 repository: Path to a local repository.
552 template: Name of a builtin template, or path to a custom template (prefixed with `path:`).
553 convention: Name of a commit message style/convention.
554 parse_refs: Whether to parse provider-specific references (GitHub/GitLab issues, PRs, etc.).
555 parse_trailers: Whether to parse Git trailers.
556 sections: Sections to render (features, bug fixes, etc.).
557 in_place: Whether to update the changelog in-place.
558 output: Output/changelog file.
559 version_regex: Regular expression to match versions in an existing changelog file.
560 marker_line: Marker line used to insert contents in an existing changelog.
561 bump_latest: Deprecated, use --bump=auto instead.
562 Whether to try and bump the latest version to guess the new one.
563 omit_empty_versions: Whether to omit empty versions from the output.
564 provider: Provider class used by this repository.
565 bump: Whether to try and bump to a given version.
566 zerover: Keep major version at zero, even for breaking changes.
567 filter_commits: The Git revision-range used to filter commits in git-log.
568 jinja_context: Key/value pairs passed to the Jinja template.
569 versioning: Versioning scheme to use when grouping commits and bumping versions.
571 Raises:
572 ValueError: When some arguments are incompatible or missing.
574 Returns:
575 The built changelog and the rendered contents.
576 """
577 # get template
578 if template.startswith("path:"):
579 path = template.replace("path:", "", 1)
580 try:
581 jinja_template = templates.get_custom_template(path)
582 except TemplateNotFound as error:
583 raise ValueError(f"No such file: {path}") from error
584 else:
585 jinja_template = templates.get_template(template)
587 if output is None: 587 ↛ 588line 587 didn't jump to line 588, because the condition on line 587 was never true
588 output = sys.stdout
590 # handle misconfiguration early
591 if in_place and output is sys.stdout: 591 ↛ 592line 591 didn't jump to line 592, because the condition on line 591 was never true
592 raise ValueError("Cannot write in-place to stdout")
594 # get provider
595 provider_class = providers[provider] if provider else None
597 # TODO: remove at some point
598 if bump_latest: 598 ↛ 599line 598 didn't jump to line 599, because the condition on line 598 was never true
599 warnings.warn("`bump_latest=True` is deprecated in favor of `bump='auto'`", DeprecationWarning, stacklevel=1)
600 if bump is None:
601 bump = "auto"
603 # build data
604 changelog = Changelog(
605 repository,
606 provider=provider_class,
607 convention=convention,
608 parse_provider_refs=parse_refs,
609 parse_trailers=parse_trailers,
610 sections=sections,
611 bump=bump,
612 zerover=zerover,
613 filter_commits=filter_commits,
614 versioning=versioning,
615 )
617 # remove empty versions from changelog data
618 if omit_empty_versions: 618 ↛ 619line 618 didn't jump to line 619, because the condition on line 618 was never true
619 section_set = set(changelog.sections)
620 empty_versions = [
621 version for version in changelog.versions_list if section_set.isdisjoint(version.sections_dict.keys())
622 ]
623 for version in empty_versions:
624 changelog.versions_list.remove(version)
625 changelog.versions_dict.pop(version.tag)
627 # render new entries in-place
628 if in_place:
629 # read current changelog lines
630 with open(output) as changelog_file: # type: ignore[arg-type]
631 lines = changelog_file.read().splitlines()
633 # prepare version regex and marker line
634 if template in {"angular", "keepachangelog"}: 634 ↛ 639line 634 didn't jump to line 639, because the condition on line 634 was never false
635 version_regex = DEFAULT_VERSION_REGEX
636 marker_line = DEFAULT_MARKER_LINE
638 # only keep new entries (missing from changelog)
639 last_released = _latest(lines, re.compile(version_regex))
640 if last_released: 640 ↛ 653line 640 didn't jump to line 653
641 # check if the latest version is already in the changelog
642 if last_released in [
643 changelog.versions_list[0].tag,
644 changelog.versions_list[0].planned_tag,
645 ]:
646 raise ValueError(f"Version {last_released} already in changelog")
647 changelog.versions_list = _unreleased(
648 changelog.versions_list,
649 last_released,
650 )
652 # render new entries
653 rendered = (
654 jinja_template.render(
655 changelog=changelog,
656 jinja_context=jinja_context,
657 in_place=True,
658 ).rstrip("\n")
659 + "\n"
660 )
662 # find marker line(s) in current changelog
663 marker = lines.index(marker_line)
664 try:
665 marker2 = lines[marker + 1 :].index(marker_line)
666 except ValueError:
667 # apply new entries at marker line
668 lines[marker] = rendered
669 else:
670 # apply new entries between marker lines
671 lines[marker : marker + marker2 + 2] = [rendered]
673 # write back updated changelog lines
674 with open(output, "w") as changelog_file: # type: ignore[arg-type]
675 changelog_file.write("\n".join(lines).rstrip("\n") + "\n")
677 # overwrite output file
678 else:
679 rendered = jinja_template.render(changelog=changelog, jinja_context=jinja_context)
681 # write result in specified output
682 if output is sys.stdout:
683 sys.stdout.write(rendered)
684 else:
685 with open(output, "w") as stream: # type: ignore[arg-type]
686 stream.write(rendered)
688 return changelog, rendered
691def get_release_notes(
692 input_file: str | Path = "CHANGELOG.md",
693 version_regex: str = DEFAULT_VERSION_REGEX,
694 marker_line: str = DEFAULT_MARKER_LINE,
695) -> str:
696 """Get release notes from existing changelog.
698 This will return the latest entry in the changelog.
700 Parameters:
701 input_file: The changelog to read from.
702 version_regex: A regular expression to match version entries.
703 marker_line: The insertion marker line in the changelog.
705 Returns:
706 The latest changelog entry.
707 """
708 release_notes = []
709 found_marker = False
710 found_version = False
711 with open(input_file) as changelog:
712 for line in changelog: 712 ↛ 711line 712 didn't jump to line 711
713 line = line.strip() # noqa: PLW2901
714 if not found_marker:
715 if line == marker_line:
716 found_marker = True
717 continue
718 if re.search(version_regex, line):
719 if found_version:
720 break
721 found_version = True
722 release_notes.append(line)
723 result = "\n".join(release_notes).strip()
724 if result.endswith(marker_line): 724 ↛ 726line 724 didn't jump to line 726, because the condition on line 724 was never false
725 result = result[: -len(marker_line)].strip()
726 return result
729def output_release_notes(
730 input_file: str = "CHANGELOG.md",
731 version_regex: str = DEFAULT_VERSION_REGEX,
732 marker_line: str = DEFAULT_MARKER_LINE,
733 output_file: str | TextIO | None = None,
734) -> None:
735 """Print release notes from existing changelog.
737 This will print the latest entry in the changelog.
739 Parameters:
740 input_file: The changelog to read from.
741 version_regex: A regular expression to match version entries.
742 marker_line: The insertion marker line in the changelog.
743 output_file: Where to print/write the release notes.
744 """
745 output_file = output_file or sys.stdout
746 release_notes = get_release_notes(input_file, version_regex, marker_line)
747 try:
748 output_file.write(release_notes) # type: ignore[union-attr]
749 except AttributeError:
750 with open(output_file, "w") as file: # type: ignore[arg-type]
751 file.write(release_notes)
754def main(args: list[str] | None = None) -> int:
755 """Run the main program.
757 This function is executed when you type `git-changelog` or `python -m git_changelog`.
759 Arguments:
760 args: Arguments passed from the command line.
762 Returns:
763 An exit code.
764 """
765 settings = parse_settings(args)
767 if settings.pop("release_notes"): 767 ↛ 768line 767 didn't jump to line 768, because the condition on line 767 was never true
768 output_release_notes(
769 input_file=settings["input"],
770 version_regex=settings["version_regex"],
771 marker_line=settings["marker_line"],
772 output_file=None, # force writing to stdout
773 )
774 return 0
776 # --input is not necessary anymore
777 settings.pop("input", None)
778 try:
779 build_and_render(**settings)
780 except ValueError as error:
781 print(f"git-changelog: {error}", file=sys.stderr)
782 return 1
784 return 0