diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index dca09da6..33ebe167 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -328,6 +328,29 @@ def render_issue(issue_format: str | None, issue: str) -> str: return issue_format.format(issue=issue) +def append_newlines_if_trailing_code_block(text: str) -> str: + """ + Appends two newlines to a text string if it ends with a code block. + + Used by `render_fragments` to avoid appending link to issue number into the code block. + """ + # Search for the existence of a code block at the end. We do this by searching for: + # 1. start of code block: two ":", followed by two newlines + # 2. any number of indented, or empty, lines (or the code block would end) + # 3. one line of indented text w/o a trailing newline (because the string is stripped) + # 4. end of the string. + indented_text = r" [ \t]+[^\n]*" + empty_or_indented_text_lines = f"(({indented_text})?\n)*" + regex = r"::\n\n" + empty_or_indented_text_lines + indented_text + "$" + if re.search(regex, text): + # We insert one space, the default template inserts another, which results + # in the correct indentation given default bullet indentation. + # Non-default templates with different indentation will likely encounter issues + # if they have trailing code blocks. + return text + "\n\n " + return text + + def render_fragments( template: str, issue_format: str | None, @@ -381,6 +404,7 @@ def render_fragments( # for the template, after formatting each issue number categories = {} for text, issues in entries: + text = append_newlines_if_trailing_code_block(text) rendered = [render_issue(issue_format, i) for i in issues] categories[text] = rendered diff --git a/src/towncrier/newsfragments/614.bugfix.rst b/src/towncrier/newsfragments/614.bugfix.rst new file mode 100644 index 00000000..f8b12a6e --- /dev/null +++ b/src/towncrier/newsfragments/614.bugfix.rst @@ -0,0 +1 @@ +Multi-line newsfragments that ends with a code block will now have a newline inserted before appending the link to the issue, to avoid breaking formatting. diff --git a/src/towncrier/test/test_format.py b/src/towncrier/test/test_format.py index a1efefb5..7be2877e 100644 --- a/src/towncrier/test/test_format.py +++ b/src/towncrier/test/test_format.py @@ -462,3 +462,74 @@ def test_line_wrapping_disabled(self): versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, ) self.assertEqual(output, expected_output) + + def test_trailing_block(self) -> None: + """ + Make sure a newline gets inserted before appending the issue number, if the + newsfragment ends with an indented block. + """ + + fragments = { + "": { + ( + "1", + "feature", + 0, + ): ( + "this fragment has a trailing code block::\n\n" + " def foo(): ...\n\n" + " \n" + " def bar(): ..." + ), + ( + "2", + "feature", + 0, + ): ( + "this block is not trailing::\n\n" + " def foo(): ...\n" + " def bar(): ...\n\n" + "so we can append the issue number directly after this" + ), + } + } + # the line with 3 spaces (and nothing else) is stripped + expected_output = """MyProject 1.0 (never) +===================== + +Features +-------- + +- this fragment has a trailing code block:: + + def foo(): ... + + + def bar(): ... + + (#1) +- this block is not trailing:: + + def foo(): ... + def bar(): ... + + so we can append the issue number directly after this (#2) + + +""" + + definitions = { + "feature": {"name": "Features", "showcontent": True}, + } + template = read_pkg_resource("templates/default.rst") + fragments_split = split_fragments(fragments, definitions) + output = render_fragments( + template, + None, + fragments_split, + definitions, + ["-", "~"], + wrap=True, + versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, + ) + self.assertEqual(output, expected_output)