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

1"""Module that contains the command line application.""" 

2 

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`. 

13 

14from __future__ import annotations 

15 

16import argparse 

17import re 

18import sys 

19import warnings 

20from importlib import metadata 

21from pathlib import Path 

22from typing import Any, Literal, Pattern, Sequence, TextIO 

23 

24from appdirs import user_config_dir 

25from jinja2.exceptions import TemplateNotFound 

26 

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 

37 

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 

43 

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.""" 

57 

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} 

80 

81 

82class Templates(tuple): # (subclassing tuple) 

83 """Helper to pick a template on the command line.""" 

84 

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 

89 

90 

91def get_version() -> str: 

92 """Return the current `git-changelog` version. 

93 

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" 

101 

102 

103def _comma_separated_list(value: str) -> list[str]: 

104 return value.split(",") 

105 

106 

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 

121 

122 

123providers: dict[str, type[ProviderRefParser]] = { 

124 "github": GitHub, 

125 "gitlab": GitLab, 

126 "bitbucket": Bitbucket, 

127} 

128 

129 

130class _DebugInfo(argparse.Action): 

131 def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None: 

132 super().__init__(nargs=nargs, **kwargs) 

133 

134 def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 

135 debug.print_debug_info() 

136 sys.exit(0) 

137 

138 

139def get_parser() -> argparse.ArgumentParser: 

140 """Return the CLI argument parser. 

141 

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. 

153 

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. 

157 

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. 

162 

163 ### Conventions 

164 

165 {BasicConvention._format_sections_help()} 

166 {AngularConvention._format_sections_help()} 

167 {ConventionalCommitConvention._format_sections_help()} 

168 """, 

169 ), 

170 formatter_class=argparse.RawDescriptionHelpFormatter, 

171 ) 

172 

173 parser.add_argument( 

174 "repository", 

175 metavar="REPOSITORY", 

176 nargs="?", 

177 help="The repository path, relative or absolute. Default: current working directory.", 

178 ) 

179 

180 parser.add_argument( 

181 "--config-file", 

182 metavar="PATH", 

183 nargs="*", 

184 help="Configuration file(s).", 

185 ) 

186 

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 ) 

259 

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.") 

391 

392 return parser 

393 

394 

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 

401 

402 

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 

408 

409 

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. 

414 

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`][]. 

419 

420 Returns: 

421 A settings dictionary. Default settings if no config file is found or `config_file` is `None`. 

422 

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 

427 

428 for filename in config_file if isinstance(config_file, (list, tuple)) else [config_file]: 

429 _path = Path(filename) 

430 

431 if not _path.exists(): 

432 continue 

433 

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 ) 

441 

442 if not new_settings: # pyproject.toml did not have a git-changelog section 

443 continue 

444 

445 # Settings can have hyphens like in the CLI 

446 new_settings = {key.replace("-", "_"): value for key, value in new_settings.items()} 

447 

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 ) 

461 

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(",") 

469 

470 sections = [s.strip() for s in sections if isinstance(s, str) and s.strip()] 

471 

472 if sections: # toml doesn't store null/nil 

473 new_settings["sections"] = sections 

474 

475 project_config.update(new_settings) 

476 break 

477 

478 return project_config 

479 

480 

481def parse_settings(args: list[str] | None = None) -> dict: 

482 """Parse arguments and config files to build the final settings set. 

483 

484 Parameters: 

485 args: Arguments passed from the command line. 

486 

487 Returns: 

488 A dictionary with the final settings. 

489 """ 

490 parser = get_parser() 

491 opts = vars(parser.parse_args(args=args)) 

492 

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 } 

501 

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 

507 

508 jinja_context = explicit_opts_dict.pop("jinja_context", {}) 

509 

510 settings = read_config(config_file) 

511 

512 # CLI arguments override the config file settings 

513 settings.update(explicit_opts_dict) 

514 

515 # Merge jinja context, CLI values override config file values. 

516 settings["jinja_context"].update(jinja_context) 

517 

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) 

521 

522 return settings 

523 

524 

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. 

546 

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. 

549 

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. 

570 

571 Raises: 

572 ValueError: When some arguments are incompatible or missing. 

573 

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) 

586 

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 

589 

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

593 

594 # get provider 

595 provider_class = providers[provider] if provider else None 

596 

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" 

602 

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 ) 

616 

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) 

626 

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

632 

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 

637 

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 ) 

651 

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 ) 

661 

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] 

672 

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

676 

677 # overwrite output file 

678 else: 

679 rendered = jinja_template.render(changelog=changelog, jinja_context=jinja_context) 

680 

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) 

687 

688 return changelog, rendered 

689 

690 

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. 

697 

698 This will return the latest entry in the changelog. 

699 

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. 

704 

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 

727 

728 

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. 

736 

737 This will print the latest entry in the changelog. 

738 

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) 

752 

753 

754def main(args: list[str] | None = None) -> int: 

755 """Run the main program. 

756 

757 This function is executed when you type `git-changelog` or `python -m git_changelog`. 

758 

759 Arguments: 

760 args: Arguments passed from the command line. 

761 

762 Returns: 

763 An exit code. 

764 """ 

765 settings = parse_settings(args) 

766 

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 

775 

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 

783 

784 return 0