From 138c250e8ea9adc2283f173319a249ebdcb523d8 Mon Sep 17 00:00:00 2001 From: Daniel Edgy Edgecombe Date: Fri, 23 Aug 2024 19:28:40 +0200 Subject: [PATCH] feat: Update post process to generate link using a link parser, and build body with a jinja template. (#48) closes #44 --- changelog_gen/cli/command.py | 6 +- changelog_gen/config.py | 51 +------- changelog_gen/extractor.py | 1 + changelog_gen/post_processor.py | 44 +++++-- changelog_gen/writer.py | 91 ++++++-------- pyproject.toml | 9 +- tests/cli/test_config.py | 15 ++- tests/cli/test_generate.py | 174 +++++++++++++++++--------- tests/test_config.py | 110 ++-------------- tests/test_post_processor.py | 112 +++++++++-------- tests/test_writer.py | 214 +++++++++++++------------------- 11 files changed, 366 insertions(+), 461 deletions(-) diff --git a/changelog_gen/cli/command.py b/changelog_gen/cli/command.py index b054c08..957c80d 100644 --- a/changelog_gen/cli/command.py +++ b/changelog_gen/cli/command.py @@ -321,8 +321,7 @@ def _gen( # noqa: PLR0913, C901, PLR0915 w = writer.new_writer(context, extension, dry_run=dry_run, change_template=cfg.change_template) - w.add_version(version_string) - w.consume(cfg.type_headers, changes) + w.consume(version_string, cfg.type_headers, changes) change_lines = create_with_editor(context, str(w), extension) if interactive else str(w) @@ -382,5 +381,4 @@ def release_hook(_context: Context, new_version: str) -> list[str]: context.error("httpx required to execute post process, install with `--extras post-process`.") return - unique_issues = [r.issue_ref.replace("#", "") for r in changes if r.issue_ref] - per_issue_post_process(context, post_process, sorted(unique_issues), str(new), dry_run=dry_run) + per_issue_post_process(context, post_process, changes, str(new), dry_run=dry_run) diff --git a/changelog_gen/config.py b/changelog_gen/config.py index ada8240..cdd32b2 100644 --- a/changelog_gen/config.py +++ b/changelog_gen/config.py @@ -37,11 +37,11 @@ class PostProcessConfig: """Post Processor configuration options.""" - url: str | None = None + link_parser: dict[str, str] | None = None verb: str = "POST" # The body to send as a post-processing command, # can have the entries: ::issue_ref::, ::version:: - body: str = '{"body": "Released on ::version::"}' + body_template: str = '{"body": "Released on {{ version }}"}' auth_type: str = "basic" # future proof config headers: dict | None = None # Name of an environment variable to use as HTTP Basic Auth parameters. @@ -154,24 +154,12 @@ def to_dict(self: Config) -> dict: @timer -def _process_overrides(overrides: dict) -> tuple[dict, PostProcessConfig | None]: +def _process_overrides(overrides: dict) -> dict: """Process provided overrides. Remove any unsupplied values (None). """ - post_process_url = overrides.pop("post_process_url", "") - post_process_auth_env = overrides.pop("post_process_auth_env", None) - - post_process = None - if post_process_url or post_process_auth_env: - post_process = PostProcessConfig( - url=post_process_url, - auth_env=post_process_auth_env, - ) - - overrides = {k: v for k, v in overrides.items() if v is not None} - - return overrides, post_process + return {k: v for k, v in overrides.items() if v is not None} @timer @@ -205,13 +193,13 @@ def check_deprecations(cfg: dict) -> None: # noqa: ARG001 @timer -def read(path: str = "pyproject.toml", **kwargs) -> Config: # noqa: C901 +def read(path: str = "pyproject.toml", **kwargs) -> Config: """Read configuration from local environment. Supported configuration locations (checked in order): * pyproject.toml """ - overrides, post_process = _process_overrides(kwargs) + overrides = _process_overrides(kwargs) cfg = {} pyproject = Path(path) @@ -223,37 +211,10 @@ def read(path: str = "pyproject.toml", **kwargs) -> Config: # noqa: C901 # parse pyproject cfg = _process_pyproject(pyproject) - if "post_process" not in cfg and post_process: - cfg["post_process"] = { - "url": post_process.url, - "auth_env": post_process.auth_env, - } - - if "post_process" in cfg and post_process: - cfg["post_process"]["url"] = post_process.url or cfg["post_process"].get("url") - cfg["post_process"]["auth_env"] = post_process.auth_env or cfg["post_process"].get("auth_env") - cfg.update(overrides) check_deprecations(cfg) # pragma: no mutate - for replace_key_path in [ - ("post_process", "url"), - ("post_process", "body"), - ]: - data, value = cfg, None - for key in replace_key_path: - value = data.get(key) - if key in data: - data = data[key] - - # check for non supported replace keys - supported = {"::issue_ref::", "::version::", "::commit_hash::", "::pull_ref::"} - unsupported = sorted(set(re.findall(r"(::.*?::)", str(value)) or []) - supported) - if unsupported: - msg = f"""Replace string(s) ('{"', '".join(unsupported)}') not supported.""" - raise errors.UnsupportedReplaceError(msg) - if cfg.get("post_process"): pp = cfg["post_process"] try: diff --git a/changelog_gen/extractor.py b/changelog_gen/extractor.py index 6a6a9f6..03e28cb 100644 --- a/changelog_gen/extractor.py +++ b/changelog_gen/extractor.py @@ -36,6 +36,7 @@ class Change: # noqa: D101 breaking: bool = False footers: list[Footer] = dataclasses.field(default_factory=list) links: list[Link] = dataclasses.field(default_factory=list) + rendered: str = "" # This is populated by the writer at run time def __lt__(self: t.Self, other: Change) -> bool: # noqa: D105 s = (not self.breaking, self.scope.lower() if self.scope else "zzz", self.issue_ref.lower()) diff --git a/changelog_gen/post_processor.py b/changelog_gen/post_processor.py index 56fbdd1..b77a030 100644 --- a/changelog_gen/post_processor.py +++ b/changelog_gen/post_processor.py @@ -1,17 +1,21 @@ from __future__ import annotations import os +import re import typing as t from http import HTTPStatus import httpx import typer +from jinja2 import BaseLoader, Environment +from changelog_gen.extractor import Link from changelog_gen.util import timer if t.TYPE_CHECKING: from changelog_gen.config import PostProcessConfig from changelog_gen.context import Context + from changelog_gen.extractor import Change class BearerAuth(httpx.Auth): @@ -56,39 +60,53 @@ def make_client(context: Context, cfg: PostProcessConfig) -> httpx.Client: ) +def _get_footer(change: Change, footer: str) -> str | None: + for footer_ in change.footers: + if footer_.footer.lower() == footer.lower(): + return footer_ + return None + + @timer def per_issue_post_process( context: Context, cfg: PostProcessConfig, - issue_refs: list[str], + changes: list[Change], version_tag: str, *, dry_run: bool = False, ) -> None: """Run post process for all provided issue references.""" - if not cfg.url: + link_parser, body_template = cfg.link_parser, cfg.body_template + if link_parser is None: return context.warning("Post processing:") client = make_client(context, cfg) - for issue in issue_refs: - url, body = cfg.url, cfg.body - for find, replace in [ - ("::issue_ref::", issue), - ("::version::", version_tag), - ]: - url = url.replace(find, replace) - body = body.replace(find, replace) + for change in changes: + link = None + + footer = _get_footer(change, link_parser["target"]) + if footer is None: + continue + + text_template = link_parser.get("text", "{0}") + link_template = link_parser["link"] + matches = re.findall(link_parser["pattern"], footer.value) + link = Link(text_template.format(matches[0]), link_template.format(matches[0])) + + rtemplate = Environment(loader=BaseLoader()).from_string(body_template) # noqa: S701 + body = rtemplate.render(link=link, change=change, version=version_tag) context.indent() if dry_run: - context.warning("Would request: %s %s %s", cfg.verb, url, body) + context.warning("Would request: %s %s %s", cfg.verb, link.link, body) else: - context.info("Request: %s %s", cfg.verb, url) + context.info("Request: %s %s", cfg.verb, link.link) r = client.request( method=cfg.verb, - url=url, + url=link.link, content=body, ) context.indent() diff --git a/changelog_gen/writer.py b/changelog_gen/writer.py index 9a92800..806c524 100644 --- a/changelog_gen/writer.py +++ b/changelog_gen/writer.py @@ -51,48 +51,33 @@ def __init__( self._change_template = change_template @timer - def add_version(self: t.Self, version: str) -> None: - """Add a version string to changelog file.""" - self._add_version(version) + def _render_change(self: t.Self, change: Change) -> str: + rtemplate = Environment(loader=BaseLoader()).from_string(self._change_template.replace("\n", "")) # noqa: S701 - @timer - def _add_version(self: t.Self, version: str) -> None: - raise NotImplementedError + return rtemplate.render(change=change) @timer - def consume(self: t.Self, type_headers: dict[str, str], changes: list[Change]) -> None: + def consume(self: t.Self, version_string: str, type_headers: dict[str, str], changes: list[Change]) -> None: """Process sections and generate changelog file entries.""" grouped_changes = defaultdict(list) for change in changes: + change.rendered = self._render_change(change) grouped_changes[change.header].append(change) + ordered_group_changes = {} for header in type_headers.values(): if header not in grouped_changes: continue # Remove processed headers to prevent rendering duplicate type -> header mappings changes_ = grouped_changes.pop(header) - self.add_section(header, changes_) + ordered_group_changes[header] = sorted(changes_) - @timer - def add_section(self: t.Self, header: str, changes: list[Change]) -> None: - """Add a section to changelog file.""" - self._add_section_header(header) - for change in sorted(changes): - self._add_section_line(change) - self._post_section() - - @timer - def _add_section_header(self: t.Self, header: str) -> None: - raise NotImplementedError + self._consume(version_string, ordered_group_changes) @timer - def _add_section_line(self: t.Self, change: Change) -> None: + def _consume(self: t.Self, version_string: str, group_changes: dict[str, list[Change]]) -> None: raise NotImplementedError - @timer - def _post_section(self: t.Self) -> None: - pass - @timer def __str__(self: t.Self) -> str: # noqa: D105 content = "\n".join(self.content) @@ -139,24 +124,21 @@ def __init__(self: t.Self, *args, **kwargs) -> None: ) @timer - def _add_version(self: t.Self, version: str) -> None: - self.content.extend([f"## {version}", ""]) + def _consume(self: t.Self, version_string: str, group_changes: dict[str, list[Change]]) -> None: + template = """## {{ version_string }} - @timer - def _add_section_header(self: t.Self, header: str) -> None: - self.content.extend([f"### {header}", ""]) +{% for header, changes in group_changes.items() -%} +### {{ header }} - @timer - def _add_section_line(self: t.Self, change: Change) -> None: - rtemplate = Environment(loader=BaseLoader()).from_string(self._change_template.replace("\n", "")) # noqa: S701 - - line = rtemplate.render(change=change) - - self.content.append(line) +{% for change in changes -%} +{{change.rendered}} +{% endfor %} +{% endfor %} +""" + rtemplate = Environment(loader=BaseLoader()).from_string(template) # noqa: S701 - @timer - def _post_section(self: t.Self) -> None: - self.content.append("") + content = rtemplate.render(group_changes=group_changes, version_string=version_string) + self.content = content.split("\n")[:-1] class RstWriter(BaseWriter): @@ -192,25 +174,34 @@ def links(self: t.Self) -> list[str]: return [f".. _`{ref}`: {link}" for ref, link in sorted(self._links.items())] @timer - def _add_version(self: t.Self, version: str) -> None: - self.content.extend([version, "=" * len(version), ""]) + def _consume(self: t.Self, version_string: str, group_changes: dict[str, list[Change]]) -> None: + template = """{{ version_string }} +{{ "=" * version_string|length }} - @timer - def _add_section_header(self: t.Self, header: str) -> None: - self.content.extend([header, "-" * len(header), ""]) +{% for header, changes in group_changes.items() -%} +{{ header }} +{{ "-" * header|length }} - @timer - def _add_section_line(self: t.Self, change: Change) -> None: - rtemplate = Environment(loader=BaseLoader()).from_string(self._change_template.replace("\n", "")) # noqa: S701 +{% for change in changes -%} +{{change.rendered}} - line = rtemplate.render(change=change) +{% endfor %} +{% endfor %} +""" + rtemplate = Environment(loader=BaseLoader()).from_string(template) # noqa: S701 - self.content.extend([line, ""]) + content = rtemplate.render(group_changes=group_changes, version_string=version_string) + self.content = content.split("\n")[:-2] + + @timer + def _render_change(self: t.Self, change: Change) -> str: + line = super()._render_change(change) for link in change.links: - line = f"{line} [`{link.text}`_]" self._links[link.text] = link.link + return line + @timer def write(self: t.Self) -> str: """Write contents to destination.""" diff --git a/pyproject.toml b/pyproject.toml index a90ddb4..3ace049 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,13 +73,18 @@ date_format = "- %Y-%m-%d" [[tool.changelog_gen.link_parsers]] target = "closes" -pattern = "#(\\d+)$" +pattern = '#(\d+)$' +link = "https =//github.com/NRWLDev/changelog-gen/issues/{0}" + +[[tool.changelog_gen.link_parsers]] +target = "fixes" +pattern = '#(\d+)$' link = "https =//github.com/NRWLDev/changelog-gen/issues/{0}" # Fall back in case a PR does not close an issue [[tool.changelog_gen.link_parsers]] target = "Refs" -pattern = "#(\\d+)$" +pattern = '#(\d+)$' link = "https =//github.com/NRWLDev/changelog-gen/issues/{0}" [[tool.changelog_gen.link_parsers]] diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index 3947a45..d799346 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -64,17 +64,22 @@ def test_config_displayed(cli_runner): def test_post_process_config_displayed(cli_runner, config_factory): - config_factory(post_process={"url": "http://localhost"}) + config_factory(post_process={"link_parser": {"target": "Refs", "link": "http://localhost"}}) result = cli_runner.invoke(["config"]) assert result.exit_code == 0 - assert result.output.strip().endswith(""" + assert result.output.endswith(""" [post_process] -url = 'http://localhost' verb = 'POST' -body = '{"body": "Released on ::version::"}' +body_template = '{"body": "Released on {{ version }}"}' auth_type = 'basic' +[post_process.link_parser] +target = 'Refs' +link = 'http://localhost' + [custom] -[files]""") +[files] + +""") diff --git a/tests/cli/test_generate.py b/tests/cli/test_generate.py index b7d2f01..63d4b4e 100644 --- a/tests/cli/test_generate.py +++ b/tests/cli/test_generate.py @@ -15,6 +15,7 @@ from changelog_gen.cli import command from changelog_gen.config import PostProcessConfig from changelog_gen.context import Context +from changelog_gen.extractor import Change, Footer @pytest.fixture(autouse=True) @@ -140,11 +141,13 @@ def _breaking_conventional_commits(commit_factory): def post_process_pyproject(cwd): p = cwd / "pyproject.toml" p.write_text( - """ + r""" [tool.changelog_gen] current_version = "0.0.0" commit = true -post_process.url = "https://my-api/::issue_ref::/release" +post_process.link_parser."target" = "Refs" +post_process.link_parser."pattern" = '#(\d+)$' +post_process.link_parser."link" = "https://my-api/{0}/release" post_process.auth_env = "MY_API_AUTH" """, ) @@ -619,15 +622,65 @@ def test_load_config( result = cli_runner.invoke(["generate"]) + changes = [ + Change( + header="Bug fixes", + description="Detail about 4", + commit_type="fix", + short_hash="short0", + commit_hash="commit-hash0", + scope="", + breaking=False, + footers=[Footer(footer="Refs", separator=": ", value="#4")], + links=[], + rendered="- Detail about 4", + ), + Change( + header="Features and Improvements", + description="Detail about 3", + commit_type="feat", + short_hash="short1", + commit_hash="commit-hash1", + scope="", + breaking=False, + footers=[Footer(footer="Refs", separator=": ", value="#3")], + links=[], + rendered="- Detail about 3", + ), + Change( + header="Features and Improvements", + description="Detail about 2", + commit_type="feat", + short_hash="short4", + commit_hash="commit-hash4", + scope="", + breaking=False, + footers=[Footer(footer="Refs", separator=": ", value="#2")], + links=[], + rendered="- Detail about 2", + ), + Change( + header="Bug fixes", + description="Detail about 1", + commit_type="fix", + short_hash="short5", + commit_hash="commit-hash5", + scope="", + breaking=False, + footers=[Footer(footer="Refs", separator=": ", value="#1")], + links=[], + rendered="- Detail about 1", + ), + ] assert result.exit_code == 0 assert post_process_mock.call_args_list == [ mock.call( FakeContext(), PostProcessConfig( - url="https://my-api/::issue_ref::/release", + link_parser={"target": "Refs", "pattern": r"#(\d+)$", "link": "https://my-api/{0}/release"}, auth_env="MY_API_AUTH", ), - ["1", "2", "3", "4"], + changes, "0.0.1", dry_run=False, ), @@ -653,7 +706,7 @@ def test_post_process_called( assert result.exit_code == 0 @pytest.mark.usefixtures("_conventional_commits", "changelog", "post_process_pyproject") - def test_generate_post_process_url( + def test_generate_dry_run( self, cli_runner, monkeypatch, @@ -662,70 +715,67 @@ def test_generate_post_process_url( post_process_mock = mock.MagicMock() monkeypatch.setattr(command, "per_issue_post_process", post_process_mock) - api_url = "https://my-api/::issue_ref::/comment" - result = cli_runner.invoke(["generate", "--post-process-url", api_url]) + result = cli_runner.invoke(["generate", "--dry-run"]) - assert result.exit_code == 0 - assert post_process_mock.call_args_list == [ - mock.call( - FakeContext(), - PostProcessConfig( - url=api_url, - auth_env="MY_API_AUTH", - ), - ["1", "2", "3", "4"], - "0.0.1", - dry_run=False, + changes = [ + Change( + header="Bug fixes", + description="Detail about 4", + commit_type="fix", + short_hash="short0", + commit_hash="commit-hash0", + scope="", + breaking=False, + footers=[Footer(footer="Refs", separator=": ", value="#4")], + links=[], + rendered="- Detail about 4", ), - ] - - @pytest.mark.usefixtures("_conventional_commits", "changelog", "post_process_pyproject") - def test_generate_post_process_auth_env( - self, - cli_runner, - monkeypatch, - ): - monkeypatch.setattr(typer, "confirm", mock.MagicMock(return_value=True)) - post_process_mock = mock.MagicMock() - monkeypatch.setattr(command, "per_issue_post_process", post_process_mock) - - result = cli_runner.invoke(["generate", "--post-process-auth-env", "OTHER_API_AUTH"]) - - assert result.exit_code == 0 - assert post_process_mock.call_args_list == [ - mock.call( - FakeContext(), - PostProcessConfig( - url="https://my-api/::issue_ref::/release", - auth_env="OTHER_API_AUTH", - ), - ["1", "2", "3", "4"], - "0.0.1", - dry_run=False, + Change( + header="Features and Improvements", + description="Detail about 3", + commit_type="feat", + short_hash="short1", + commit_hash="commit-hash1", + scope="", + breaking=False, + footers=[Footer(footer="Refs", separator=": ", value="#3")], + links=[], + rendered="- Detail about 3", + ), + Change( + header="Features and Improvements", + description="Detail about 2", + commit_type="feat", + short_hash="short4", + commit_hash="commit-hash4", + scope="", + breaking=False, + footers=[Footer(footer="Refs", separator=": ", value="#2")], + links=[], + rendered="- Detail about 2", + ), + Change( + header="Bug fixes", + description="Detail about 1", + commit_type="fix", + short_hash="short5", + commit_hash="commit-hash5", + scope="", + breaking=False, + footers=[Footer(footer="Refs", separator=": ", value="#1")], + links=[], + rendered="- Detail about 1", ), ] - - @pytest.mark.usefixtures("_conventional_commits", "changelog", "post_process_pyproject") - def test_generate_dry_run( - self, - cli_runner, - monkeypatch, - ): - monkeypatch.setattr(typer, "confirm", mock.MagicMock(return_value=True)) - post_process_mock = mock.MagicMock() - monkeypatch.setattr(command, "per_issue_post_process", post_process_mock) - - result = cli_runner.invoke(["generate", "--dry-run"]) - assert result.exit_code == 0 assert post_process_mock.call_args_list == [ mock.call( FakeContext(), PostProcessConfig( - url="https://my-api/::issue_ref::/release", + link_parser={"target": "Refs", "pattern": r"#(\d+)$", "link": "https://my-api/{0}/release"}, auth_env="MY_API_AUTH", ), - ["1", "2", "3", "4"], + changes, "0.0.1", dry_run=True, ), @@ -760,7 +810,7 @@ def test_using_config(self, cli_runner, config_factory, monkeypatch): r = cli_runner.invoke(["generate"]) assert r.exit_code == 0, r.output - assert writer_mock.add_version.call_args == mock.call("v0.0.1 on 2022-04-14") + assert writer_mock.consume.call_args[0][0] == "v0.0.1 on 2022-04-14" @pytest.mark.usefixtures("_conventional_commits", "changelog") def test_using_cli(self, cli_runner, monkeypatch): @@ -771,7 +821,7 @@ def test_using_cli(self, cli_runner, monkeypatch): r = cli_runner.invoke(["generate", "--date-format", "(%Y-%m-%d at %H:%M)"]) assert r.exit_code == 0, r.output - assert writer_mock.add_version.call_args == mock.call("v0.0.1 (2022-04-14 at 16:45)") + assert writer_mock.consume.call_args[0][0] == "v0.0.1 (2022-04-14 at 16:45)" @pytest.mark.usefixtures("_conventional_commits", "changelog") def test_override_config(self, cli_runner, config_factory, monkeypatch): @@ -784,7 +834,7 @@ def test_override_config(self, cli_runner, config_factory, monkeypatch): r = cli_runner.invoke(["generate", "--date-format", "(%Y-%m-%d at %H:%M)"]) assert r.exit_code == 0, r.output - assert writer_mock.add_version.call_args == mock.call("v0.0.1 (2022-04-14 at 16:45)") + assert writer_mock.consume.call_args[0][0] == "v0.0.1 (2022-04-14 at 16:45)" @pytest.mark.usefixtures("_conventional_commits", "changelog") def test_override_config_and_disable(self, cli_runner, config_factory, monkeypatch): @@ -797,7 +847,7 @@ def test_override_config_and_disable(self, cli_runner, config_factory, monkeypat r = cli_runner.invoke(["generate", "--date-format", ""]) assert r.exit_code == 0, r.output - assert writer_mock.add_version.call_args == mock.call("v0.0.1") + assert writer_mock.consume.call_args[0][0] == "v0.0.1" class TestCreateWithEditor: diff --git a/tests/test_config.py b/tests/test_config.py index ae4efc8..5663dcd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -195,13 +195,15 @@ def test_read_picks_up_no_post_process_config(self, config_factory): def test_read_picks_up_post_process_config_pyproject(self, config_factory): config_factory( - """ + r""" [tool.changelog_gen] current_version = "0.0.0" [tool.changelog_gen.post_process] -url = "https://fake_rest_api/::commit_hash::" +link_parser."target" = "Refs" +link_parser."pattern" = '#(\d+)$' +link_parser."link" = "https://fake_rest_api/{0}" verb = "PUT" -body = '{"issue": "::issue_ref::", "comment": "Released in ::version::"}' +body_template = '{"issue": "{{ link.text }}", "comment": "Released in {{ version }}"}' auth_env = "MY_API_AUTH" headers."content-type" = "application/json" """, @@ -209,93 +211,13 @@ def test_read_picks_up_post_process_config_pyproject(self, config_factory): c = config.read() assert c.post_process == config.PostProcessConfig( - url="https://fake_rest_api/::commit_hash::", + link_parser={"target": "Refs", "pattern": r"#(\d+)$", "link": "https://fake_rest_api/{0}"}, verb="PUT", - body='{"issue": "::issue_ref::", "comment": "Released in ::version::"}', + body_template='{"issue": "{{ link.text }}", "comment": "Released in {{ version }}"}', auth_env="MY_API_AUTH", headers={"content-type": "application/json"}, ) - @pytest.mark.parametrize( - "config_value", - [ - 'post_process.body = "::unexpected:: ::also-unexpected::"', - 'post_process.url = "::unexpected:: ::also-unexpected::"', - ], - ) - def test_read_picks_up_unexpected_replaces(self, config_factory, config_value): - config_factory( - f""" -[tool.changelog_gen] -current_version = "0.0.0" -{config_value} - """, - ) - - with pytest.raises(errors.UnsupportedReplaceError) as e: - config.read() - - assert str(e.value) == "Replace string(s) ('::also-unexpected::', '::unexpected::') not supported." - - def test_read_picks_up_post_process_override(self, config_factory): - config_factory( - """ -[tool.changelog_gen] -current_version = "0.0.0" -[tool.changelog_gen.post_process] -url = "https://initial/::issue_ref::" -auth_env = "INITIAL" -""", - ) - - c = config.read( - post_process_url="https://fake_rest_api/", - post_process_auth_env="MY_API_AUTH", - ) - assert c.post_process == config.PostProcessConfig( - url="https://fake_rest_api/", - auth_env="MY_API_AUTH", - ) - - def test_read_picks_up_post_process_override_no_config(self, config_factory): - config_factory( - """ -[tool.changelog_gen] -current_version = "0.0.0" -release = true -""", - ) - - c = config.read( - post_process_url="https://fake_rest_api/", - post_process_auth_env="MY_API_AUTH", - ) - assert c.post_process == config.PostProcessConfig( - url="https://fake_rest_api/", - auth_env="MY_API_AUTH", - ) - - @pytest.mark.parametrize(("url", "auth_env"), [("", "AUTH"), ("url", "")]) - def test_read_ignores_empty_post_process_override(self, config_factory, url, auth_env): - config_factory( - """ -[tool.changelog_gen] -current_version = "0.0.0" -[tool.changelog_gen.post_process] -url = "https://initial/::issue_ref::" -auth_env = "INITIAL" -""", - ) - - c = config.read( - post_process_url=url, - post_process_auth_env=auth_env, - ) - assert c.post_process == config.PostProcessConfig( - url=url or "https://initial/::issue_ref::", - auth_env=auth_env or "INITIAL", - ) - def test_read_rejects_unknown_fields(self, config_factory): config_factory( """ @@ -353,20 +275,6 @@ def test_read_overrides_pyproject(config_factory, key, value): assert getattr(c, key) == value -def test_process_overrides_no_post_process_values(): - _, post_process = config._process_overrides({}) - assert post_process is None - - -def test_process_overrides_extracts_post_process_values(): - overrides, post_process = config._process_overrides( - {"key": "value", "post_process_url": "url", "post_process_auth_env": "auth"}, - ) - assert overrides == {"key": "value"} - assert post_process.url == "url" - assert post_process.auth_env == "auth" - - def test_config_defaults(): c = config.Config(current_version="0.0.0") assert c.verbose == 0 @@ -431,10 +339,10 @@ def test_config_defaults(): def test_post_process_defaults(): pp = config.PostProcessConfig() assert pp.verb == "POST" - assert pp.body == '{"body": "Released on ::version::"}' + assert pp.body_template == '{"body": "Released on {{ version }}"}' assert pp.auth_type == "basic" for attr in [ - "url", + "link_parser", "headers", "auth_env", ]: diff --git a/tests/test_post_processor.py b/tests/test_post_processor.py index e63559a..6fe413f 100644 --- a/tests/test_post_processor.py +++ b/tests/test_post_processor.py @@ -4,10 +4,11 @@ import pytest import typer -httpx = pytest.importorskip("httpx") +from changelog_gen import post_processor +from changelog_gen.config import PostProcessConfig +from changelog_gen.extractor import Change, Footer -from changelog_gen import post_processor # noqa: E402 -from changelog_gen.config import PostProcessConfig # noqa: E402 +httpx = pytest.importorskip("httpx") def test_bearer_auth_flow(): @@ -87,13 +88,17 @@ def test_handle_bad_auth_gracefully(self, monkeypatch, env_value): class TestPerIssuePostPrequest: @pytest.mark.parametrize("cfg_verb", ["POST", "PUT", "GET"]) @pytest.mark.parametrize( - "issue_refs", + "changes", [ - ["1", "2", "3"], + [ + Change("header", "line1", "fix", footers=[Footer("Refs", ": ", "#1")]), + Change("header", "line1", "fix", footers=[Footer("Refs", ": ", "#2")]), + Change("header", "line3", "fix"), + ], [], ], ) - def test_one_client_regardless_of_issue_count(self, monkeypatch, httpx_mock, cfg_verb, issue_refs): + def test_one_client_regardless_of_issue_count(self, monkeypatch, httpx_mock, cfg_verb, changes): monkeypatch.setattr( post_processor, "make_client", @@ -101,17 +106,18 @@ def test_one_client_regardless_of_issue_count(self, monkeypatch, httpx_mock, cfg ) cfg = PostProcessConfig( verb=cfg_verb, - url="https://my-api.github.com/comments/::issue_ref::", + link_parser={"target": "Refs", "pattern": r"#(\d+)", "link": "https://my-api.github.com/comments/{0}"}, ) - for issue in issue_refs: - httpx_mock.add_response( - method=cfg_verb, - url=cfg.url.replace("::issue_ref::", issue), - status_code=HTTPStatus.OK, - ) + for change in changes: + if change.issue_ref: + httpx_mock.add_response( + method=cfg_verb, + url=f"https://my-api.github.com/comments/{change.issue_ref.replace('#', '')}", + status_code=HTTPStatus.OK, + ) ctx = mock.Mock() - post_processor.per_issue_post_process(ctx, cfg, issue_refs, "1.0.0") + post_processor.per_issue_post_process(ctx, cfg, changes, "1.0.0") assert post_processor.make_client.call_args_list == [ mock.call(ctx, cfg), @@ -119,32 +125,32 @@ def test_one_client_regardless_of_issue_count(self, monkeypatch, httpx_mock, cfg def test_handle_http_errors_gracefully(self, httpx_mock): ctx = mock.Mock() - issue_refs = ["1", "2", "3"] + changes = [ + Change("header", "line1", "fix", footers=[Footer("Refs", ": ", "#1")]), + Change("header", "line1", "fix", footers=[Footer("Refs", ": ", "#2")]), + Change("header", "line3", "fix"), + ] - cfg = PostProcessConfig(url="https://my-api.github.com/comments/::issue_ref::") + cfg = PostProcessConfig( + link_parser={"target": "Refs", "pattern": r"#(\d+)", "link": "https://my-api.github.com/comments/{0}"}, + ) - ep0 = cfg.url.replace("::issue_ref::", issue_refs[0]) + ep0 = "https://my-api.github.com/comments/1" httpx_mock.add_response( method="POST", url=ep0, status_code=HTTPStatus.OK, ) - ep1 = cfg.url.replace("::issue_ref::", issue_refs[1]) - not_found_txt = f"{issue_refs[1]} NOT FOUND" + ep1 = "https://my-api.github.com/comments/2" + not_found_txt = "2 NOT FOUND" httpx_mock.add_response( method="POST", url=ep1, status_code=HTTPStatus.NOT_FOUND, content=bytes(not_found_txt, "utf-8"), ) - ep2 = cfg.url.replace("::issue_ref::", issue_refs[2]) - httpx_mock.add_response( - method="POST", - url=ep2, - status_code=HTTPStatus.OK, - ) - post_processor.per_issue_post_process(ctx, cfg, issue_refs, "1.0.0") + post_processor.per_issue_post_process(ctx, cfg, changes, "1.0.0") assert ctx.error.call_args_list == [ mock.call("Post process request failed."), @@ -158,8 +164,6 @@ def test_handle_http_errors_gracefully(self, httpx_mock): mock.call("Response: %s", "OK"), mock.call("Request: %s %s", "POST", ep1), mock.call("Response: %s", "NOT_FOUND"), - mock.call("Request: %s %s", "POST", ep2), - mock.call("Response: %s", "OK"), ] @pytest.mark.parametrize("cfg_verb", ["POST", "PUT", "GET"]) @@ -169,7 +173,7 @@ def test_handle_http_errors_gracefully(self, httpx_mock): [ (None, '{"body": "Released on %s"}'), # send issue ref as an int without quotes - ('{"issue": ::issue_ref::, "version": "::version::"}', '{"issue": 1, "version": "%s"}'), + ('{"issue": {{ link.text }}, "version": "{{ version }}"}', '{"issue": 1, "version": "%s"}'), ], ) def test_body(self, cfg_verb, new_version, cfg_body, exp_body, httpx_mock): @@ -177,51 +181,54 @@ def test_body(self, cfg_verb, new_version, cfg_body, exp_body, httpx_mock): "verb": cfg_verb, } if cfg_body is not None: - kwargs["body"] = cfg_body + kwargs["body_template"] = cfg_body cfg = PostProcessConfig( - url="https://my-api.github.com/comments/::issue_ref::", + link_parser={"target": "Refs", "pattern": r"#(\d+)$", "link": "https://my_api.github.com/comments/{0}"}, **kwargs, ) httpx_mock.add_response( method=cfg_verb, - url=cfg.url.replace("::issue_ref::", "1"), + url="https://my_api.github.com/comments/1", status_code=HTTPStatus.OK, match_content=bytes(exp_body % new_version, "utf-8"), ) - post_processor.per_issue_post_process(mock.Mock(), cfg, ["1"], new_version) + changes = [ + Change("header", "line", "fix", footers=[Footer(footer="Refs", separator=": ", value="#1")]), + ] + post_processor.per_issue_post_process(mock.Mock(), cfg, changes, new_version) @pytest.mark.parametrize("cfg_verb", ["POST", "PUT", "GET"]) - @pytest.mark.parametrize( - "issue_refs", - [ - ["1", "2", "3"], - [], - ], - ) @pytest.mark.parametrize( ("cfg_body", "exp_body"), [ (None, '{"body": "Released on 3.2.1"}'), # send issue ref as an int without quotes - ('{"issue": ::issue_ref::, "version": "::version::"}', '{"issue": ::issue_ref::, "version": "3.2.1"}'), + ('{"issue": {{ link.text }}, "version": "{{ version }}"}', '{"issue": ::issue_ref::, "version": "3.2.1"}'), ], ) - def test_dry_run(self, cfg_verb, issue_refs, cfg_body, exp_body): - kwargs = {} + def test_dry_run(self, cfg_verb, cfg_body, exp_body): + kwargs = { + "verb": cfg_verb, + } if cfg_body is not None: - kwargs["body"] = cfg_body + kwargs["body_template"] = cfg_body cfg = PostProcessConfig( - url="https://my-api.github.com/comments/::issue_ref::", - verb=cfg_verb, + link_parser={"target": "Refs", "pattern": r"#(\d+)$", "link": "https://my_api.github.com/comments/{0}"}, **kwargs, ) + url = "https://my_api.github.com/comments/{0}" + changes = [ + Change("header", "line", "fix", footers=[Footer(footer="Refs", separator=": ", value="#1")]), + Change("header", "line2", "fix", footers=[Footer(footer="Refs", separator=": ", value="#2")]), + Change("header", "line3", "fix", footers=[Footer(footer="Authors", separator=": ", value="edgy")]), + ] ctx = mock.Mock() post_processor.per_issue_post_process( ctx, cfg, - issue_refs, + changes, "3.2.1", dry_run=True, ) @@ -234,19 +241,24 @@ def test_dry_run(self, cfg_verb, issue_refs, cfg_body, exp_body): mock.call( "Would request: %s %s %s", cfg_verb, - cfg.url.replace("::issue_ref::", issue), + url.format(issue), exp_body.replace("::issue_ref::", issue), ) - for issue in issue_refs + for issue in ["1", "2"] ] def test_no_url_ignored(self): cfg = PostProcessConfig() ctx = mock.Mock() + changes = [ + Change("header", "line", "fix", footers=[Footer(footer="Refs", separator=": ", value="#1")]), + Change("header", "line2", "fix", footers=[Footer(footer="Refs", separator=": ", value="#2")]), + Change("header", "line3", "fix", footers=[]), + ] post_processor.per_issue_post_process( ctx, cfg, - ["1", "2"], + changes, "3.2.1", dry_run=True, ) diff --git a/tests/test_writer.py b/tests/test_writer.py index ca48d42..aff1a4c 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -104,30 +104,17 @@ def test_base_methods_not_implemented(self, changelog, ctx): w = writer.BaseWriter(changelog, ctx) with pytest.raises(NotImplementedError): - w._add_section_header("header") + w._consume("version_string", {}) - with pytest.raises(NotImplementedError): - w._add_section_line(Change("header", "issue_ref", "description", "fix")) - - with pytest.raises(NotImplementedError): - w._add_version("0.0.0") + def test_consume(self, monkeypatch, changelog, ctx): + monkeypatch.setattr(writer.BaseWriter, "_consume", mock.Mock()) - def test_add_version(self, monkeypatch, changelog, ctx): - monkeypatch.setattr(writer.BaseWriter, "_add_version", mock.Mock()) w = writer.BaseWriter(changelog, ctx) + w._change_template = "" - w.add_version("0.0.0") - - assert w._add_version.call_args == mock.call("0.0.0") - - def test_add_section(self, monkeypatch, changelog, ctx): - monkeypatch.setattr(writer.BaseWriter, "_add_section_header", mock.Mock()) - monkeypatch.setattr(writer.BaseWriter, "_add_section_line", mock.Mock()) - - w = writer.BaseWriter(changelog, ctx) - - w.add_section( - "header", + w.consume( + "0.0.1", + {"header": "header"}, [ Change("header", "line1", "fix", breaking=True, footers=[Footer("Refs", ": ", "#1")]), Change( @@ -140,32 +127,31 @@ def test_add_section(self, monkeypatch, changelog, ctx): ], ) - assert w._add_section_header.call_args == mock.call("header") - assert w._add_section_line.call_args_list == [ - mock.call( - Change("header", "line1", "fix", breaking=True, footers=[Footer("Refs", ": ", "#1")]), - ), - mock.call( - Change("header", "line3", "fix", scope="config", footers=[Footer("Refs", ": ", "#3")]), - ), - mock.call( - Change( - "header", - "line2", - "fix", - footers=[Footer("Refs", ": ", "#2"), Footer("Authors", ": ", "(a, b)")], - ), - ), - ] + assert w._consume.call_args == mock.call( + "0.0.1", + { + "header": [ + Change("header", "line1", "fix", breaking=True, footers=[Footer("Refs", ": ", "#1")]), + Change("header", "line3", "fix", scope="config", footers=[Footer("Refs", ": ", "#3")]), + Change( + "header", + "line2", + "fix", + footers=[Footer("Refs", ": ", "#2"), Footer("Authors", ": ", "(a, b)")], + ), + ], + }, + ) - def test_add_section_sorting(self, monkeypatch, changelog, ctx): - monkeypatch.setattr(writer.BaseWriter, "_add_section_header", mock.Mock()) - monkeypatch.setattr(writer.BaseWriter, "_add_section_line", mock.Mock()) + def test_consume_sorting(self, monkeypatch, changelog, ctx): + monkeypatch.setattr(writer.BaseWriter, "_consume", mock.Mock()) w = writer.BaseWriter(changelog, ctx) + w._change_template = "" - w.add_section( - "header", + w.consume( + "0.0.1", + {"header": "header"}, [ Change("header", "line3", "fix", breaking=True, footers=[Footer("Refs", ": ", "#3")]), Change( @@ -178,23 +164,21 @@ def test_add_section_sorting(self, monkeypatch, changelog, ctx): ], ) - assert w._add_section_header.call_args == mock.call("header") - assert w._add_section_line.call_args_list == [ - mock.call( - Change("header", "line3", "fix", breaking=True, footers=[Footer("Refs", ": ", "#3")]), - ), - mock.call( - Change("header", "line1", "fix", scope="config", footers=[Footer("Refs", ": ", "#1")]), - ), - mock.call( - Change( - "header", - "line2", - "fix", - footers=[Footer("Refs", ": ", "#2"), Footer("Authors", ": ", "(a, b)")], - ), - ), - ] + assert w._consume.call_args == mock.call( + "0.0.1", + { + "header": [ + Change("header", "line3", "fix", breaking=True, footers=[Footer("Refs", ": ", "#3")]), + Change("header", "line1", "fix", scope="config", footers=[Footer("Refs", ": ", "#1")]), + Change( + "header", + "line2", + "fix", + footers=[Footer("Refs", ": ", "#2"), Footer("Authors", ": ", "(a, b)")], + ), + ], + }, + ) class TestMdWriter: @@ -242,41 +226,27 @@ def test_init_stores_existing_changelog(self, changelog_md, ctx): "", ] - def test_add_version(self, changelog_md, ctx): + def test_render_change(self, changelog_md, ctx): w = writer.MdWriter(changelog_md, ctx) - w._add_version("0.0.0") + line = w._render_change(Change("header", "line", "fix", footers=[Footer("Refs", ": ", "#1")])) - assert w.content == ["## 0.0.0", ""] + assert line == "- line" - def test_add_section_header(self, changelog_md, ctx): + def test_render_change_with_metadata(self, changelog_md, ctx): w = writer.MdWriter(changelog_md, ctx) - w._add_section_header("header") - - assert w.content == ["### header", ""] - - def test_add_section_line(self, changelog_md, ctx): - w = writer.MdWriter(changelog_md, ctx) - - w._add_section_line(Change("header", "line", "fix", footers=[Footer("Refs", ": ", "#1")])) - - assert w.content == ["- line"] - - def test_add_section_line_with_metadata(self, changelog_md, ctx): - w = writer.MdWriter(changelog_md, ctx) - - w._add_section_line( + line = w._render_change( Change("header", "line", "fix", scope="config", breaking=True, footers=[Footer("Authors", ": ", "(a, b)")]), ) - assert w.content == ["- (`config`) **Breaking** line (a, b)"] + assert line == "- (`config`) **Breaking** line (a, b)" - def test_add_section_line_with_links(self, changelog_md): + def test_render_change_with_links(self, changelog_md): ctx = Context(Config(current_version="0.0.0")) w = writer.MdWriter(changelog_md, ctx) - w._add_section_line( + line = w._render_change( Change( "header", "line", @@ -285,13 +255,13 @@ def test_add_section_line_with_links(self, changelog_md): ), ) - assert w.content == ["- line [[#1](http://url/issues/1)] [[1234567](http://url/commit/commit-hash)]"] + assert line == "- line [[#1](http://url/issues/1)] [[1234567](http://url/commit/commit-hash)]" def test_write_dry_run_doesnt_write_to_file(self, changelog_md, ctx): w = writer.MdWriter(changelog_md, ctx, dry_run=True) - w.add_version("0.0.1") - w.add_section( - "header", + w.consume( + "0.0.1", + {"header": "header"}, [ Change("header", "line1", "fix", footers=[Footer("Refs", ": ", "#1")]), Change("header", "line2", "fix", footers=[Footer("Refs", ": ", "#2")]), @@ -304,9 +274,9 @@ def test_write_dry_run_doesnt_write_to_file(self, changelog_md, ctx): def test_write(self, changelog_md, ctx): w = writer.MdWriter(changelog_md, ctx) - w.add_version("0.0.1") - w.add_section( - "header", + w.consume( + "0.0.1", + {"header": "header"}, [ Change("header", "line1", "fix", footers=[Footer("Refs", ": ", "#1")]), Change("header", "line2", "fix", footers=[Footer("Refs", ": ", "#2")]), @@ -344,9 +314,9 @@ def test_write_with_existing_content(self, changelog_md, ctx): ) w = writer.MdWriter(changelog_md, ctx) - w.add_version("0.0.2") - w.add_section( - "header", + w.consume( + "0.0.2", + {"header": "header"}, [ Change("header", "line4", "fix", footers=[Footer("Refs", ": ", "#4")]), Change("header", "line5", "fix", footers=[Footer("Refs", ": ", "#5")]), @@ -434,41 +404,27 @@ def test_init_stores_existing_changelog(self, changelog_rst, ctx): "", ] - def test_add_version(self, changelog_rst, ctx): + def test_render_change(self, changelog_rst, ctx): w = writer.RstWriter(changelog_rst, ctx) - w._add_version("0.0.0") + line = w._render_change(Change("header", "line", "fix", footers=[Footer("Refs", ": ", "#1")])) - assert w.content == ["0.0.0", "=====", ""] + assert line == "* line" - def test_add_section_header(self, changelog_rst, ctx): + def test_render_change_with_metadata(self, changelog_rst, ctx): w = writer.RstWriter(changelog_rst, ctx) - w._add_section_header("header") - - assert w.content == ["header", "------", ""] - - def test_add_section_line(self, changelog_rst, ctx): - w = writer.RstWriter(changelog_rst, ctx) - - w._add_section_line(Change("header", "line", "fix", footers=[Footer("Refs", ": ", "#1")])) - - assert w.content == ["* line", ""] - - def test_add_section_line_with_metadata(self, changelog_rst, ctx): - w = writer.RstWriter(changelog_rst, ctx) - - w._add_section_line( + line = w._render_change( Change("header", "line", "fix", scope="config", breaking=True, footers=[Footer("Authors", ": ", "(a, b)")]), ) - assert w.content == ["* (`config`) **Breaking** line (a, b)", ""] + assert line == "* (`config`) **Breaking** line (a, b)", "" - def test_add_section_line_with_links(self, changelog_rst): + def test_render_change_with_links(self, changelog_rst): ctx = Context(Config(current_version="0.0.0")) w = writer.RstWriter(changelog_rst, ctx) - w._add_section_line( + line = w._render_change( Change( "header", "line", @@ -477,17 +433,17 @@ def test_add_section_line_with_links(self, changelog_rst): ), ) - assert w.content == ["* line [`#1`_] [`1234567`_]", ""] + assert line == "* line [`#1`_] [`1234567`_]" assert w._links == {"#1": "http://url/issues/1", "1234567": "http://url/commit/commit-hash"} assert w.links == [".. _`#1`: http://url/issues/1", ".. _`1234567`: http://url/commit/commit-hash"] - def test_add_section_line_without_links(self, changelog_rst): + def test_render_change_without_links(self, changelog_rst): ctx = Context(Config(current_version="0.0.0")) w = writer.RstWriter(changelog_rst, ctx) - w._add_section_line(Change("header", "line", "fix")) + line = w._render_change(Change("header", "line", "fix")) - assert w.content == ["* line", ""] + assert line == "* line" assert w._links == {} assert w.links == [] @@ -495,9 +451,9 @@ def test_str_with_links(self, changelog_rst): ctx = Context(Config(current_version="0.0.0")) w = writer.RstWriter(changelog_rst, ctx) - w.add_version("0.0.1") - w.add_section( - "header", + w.consume( + "0.0.1", + {"header": "header"}, [ Change("header", "line1", "fix", links=[Link("#1", "http://url/issues/1")]), Change("header", "line2", "fix", links=[Link("#2", "http://url/issues/2")]), @@ -530,9 +486,9 @@ def test_str_with_links(self, changelog_rst): def test_write_dry_run_doesnt_write_to_file(self, changelog_rst, ctx): w = writer.RstWriter(changelog_rst, ctx, dry_run=True) - w.add_version("0.0.1") - w.add_section( - "header", + w.consume( + "0.0.1", + {"header": "header"}, [ Change("header", "line1", "fix", footers=[Footer("Refs", ": ", "#1")]), Change("header", "line2", "fix", footers=[Footer("Refs", ": ", "#2")]), @@ -551,9 +507,9 @@ def test_write_dry_run_doesnt_write_to_file(self, changelog_rst, ctx): def test_write(self, changelog_rst, ctx): w = writer.RstWriter(changelog_rst, ctx) - w.add_version("0.0.1") - w.add_section( - "header", + w.consume( + "0.0.1", + {"header": "header"}, [ Change("header", "line1", "fix", footers=[Footer("Refs", ": ", "#1")]), Change("header", "line2", "fix", footers=[Footer("Refs", ": ", "#2")]), @@ -604,9 +560,9 @@ def test_write_with_existing_content(self, changelog_rst): ctx = Context(Config(current_version="0.0.0")) w = writer.RstWriter(changelog_rst, ctx) - w.add_version("0.0.2") - w.add_section( - "header", + w.consume( + "0.0.2", + {"header": "header"}, [ Change("header", "line4", "fix", footers=[Footer("Refs", ": ", "#4")]), Change("header", "line5", "fix", footers=[Footer("Refs", ": ", "#5")]),