Repository: twisted/towncrier (python)

Issue:
Markdown renderer should infer header level from title format
If you e.g. set `title_format = "#### {name} {version}"`, section headers will have the same header level as the title.

This shouldn’t be the case. Instead sections and section-less categories should be one header level below the title, and categories within sections one more.

Since specifying arbitrarily deep header sections in the `title_format` option, this should of course work by default and not require a template.

Example output:

```markdown
<!-- level(header) is defined by `title_format` ⇒ here level 4 -->
#### Foo 1.2.3

<!-- section-less categories are at level(header) + 1 ⇒ here level 5 -->
##### Features
- Added abc

<!-- sections are at level(header) + 1 too ⇒ here level 5 -->
##### Web

<!-- categories within sections are at level(header) + 2 ⇒ here level 6 -->
###### Bugfixes
- Fixed xyz
```


Pull request:
Fix Markdown headers
# Description

Fixes #695

<!-- add a short summary if necessary; mention issue numbers -->


# Checklist
<!-- add a "X" inside the brackets to confirm -->
* [x] Make sure changes are covered by existing or new tests.
* [x] For at least one Python version, make sure test pass on your local environment.
* [x] Create a file in `src/towncrier/newsfragments/`. Briefly describe your
  changes, with information useful to end users. Your change will be included in the public release notes.
* [x] Make sure all GitHub Actions checks are green (they are automatically checking all of the above).
* [x] Ensure `docs/tutorial.rst` is still up-to-date.
* [x] If you add new **CLI arguments** (or change the meaning of existing ones), make sure `docs/cli.rst` reflects those changes.
* [x] If you add new **configuration options** (or change the meaning of existing ones), make sure `docs/configuration.rst` reflects those changes.


Patch:
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 053daad..823300a 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -82,7 +82,7 @@ Top level keys
     When using reStructuredText, formatted titles are underlined using the ``underlines`` configuration.
     For titles, the first value from ``underlines`` is used to create the underline (which is inserted on the line following the title).
     If the template has an ``.md`` suffix, we assume we are looking at markdown format and the title is applied as, i.e. full control over the title format is given to the user.
-    This choice is made because setting the correct markdown header level automatically is non-trivial (mainly because you don't know what context the generated CHANGELOG entries are being written into).
+    The top header level is inferred from this, e.g. ``title_format = "(v{version})=\n### {version}"`` will render the title at level 3, categories at level 4, and so on.
 
 ``issue_format``
     A format string for rendering the issue/ticket number in newsfiles.
diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py
index 33ebe16..db4fe61 100644
--- a/src/towncrier/_builder.py
+++ b/src/towncrier/_builder.py
@@ -362,6 +362,7 @@ def render_fragments(
     top_underline: str = "=",
     all_bullets: bool = False,
     render_title: bool = True,
+    md_header_level: int = 1,
 ) -> str:
     """
     Render the fragments into a news file.
@@ -435,6 +436,7 @@ def render_fragments(
         top_underline=top_underline,
         get_indent=get_indent,  # simplify indentation in the jinja template.
         issues_by_category=issues_by_category,
+        header_prefix="#" * md_header_level,
     )
 
     for line in res.split("\n"):
diff --git a/src/towncrier/build.py b/src/towncrier/build.py
index e56c367..df4798c 100644
--- a/src/towncrier/build.py
+++ b/src/towncrier/build.py
@@ -8,6 +8,7 @@ Build a combined news file from news fragments.
 from __future__ import annotations
 
 import os
+import re
 import sys
 
 from datetime import date
@@ -213,6 +214,16 @@ def __main(
     # in the template.
     render_title = config.title_format == ""
 
+    # Add format-specific context to the template
+    md_header_level = 1
+    if is_markdown:
+        if config.title_format:
+            m = re.search(r"^#+(?=\s)", config.title_format, re.MULTILINE)
+            lvl = len(m[0]) if m else 0
+        else:  # TODO: derive from template or make configurable?
+            lvl = 1 if render_title else 0
+        md_header_level = lvl
+
     rendered = render_fragments(
         # The 0th underline is used for the top line
         template,
@@ -225,6 +236,7 @@ def __main(
         top_underline=config.underlines[0],
         all_bullets=config.all_bullets,
         render_title=render_title,
+        md_header_level=md_header_level,
     )
 
     if config.title_format:
diff --git a/src/towncrier/newsfragments/695.bugfix.rst b/src/towncrier/newsfragments/695.bugfix.rst
new file mode 100644
index 0000000..9388e32
--- /dev/null
+++ b/src/towncrier/newsfragments/695.bugfix.rst
@@ -0,0 +1,1 @@
+Markdown header level is correctly inferred from ``title_format``.
diff --git a/src/towncrier/templates/default.md b/src/towncrier/templates/default.md
index 4a5a07d..a6f1d1f 100644
--- a/src/towncrier/templates/default.md
+++ b/src/towncrier/templates/default.md
@@ -1,19 +1,19 @@
 {% if render_title %}
 {% if versiondata.name %}
-# {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }})
+{{header_prefix}} {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }})
 {% else %}
-# {{ versiondata.version }} ({{ versiondata.date }})
+{{header_prefix}} {{ versiondata.version }} ({{ versiondata.date }})
 {% endif %}
 {% endif %}
 {% for section, _ in sections.items() %}
 {% if section %}
 
-## {{section}}
+{{header_prefix}}# {{section}}
 {% endif %}
 
 {% if sections[section] %}
 {% for category, val in definitions.items() if category in sections[section] %}
-##{% if section %}#{% endif %} {{ definitions[category]['name'] }}
+{{header_prefix}}#{% if section %}#{% endif %} {{ definitions[category]['name'] }}
 
 {% for text, values in sections[section][category].items() %}
 - {{ text }}


Test patch:
diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py
index caf642b..1319c56 100644
--- a/src/towncrier/test/test_build.py
+++ b/src/towncrier/test/test_build.py
@@ -16,7 +16,7 @@ from twisted.trial.unittest import TestCase
 
 from .._shell import cli
 from ..build import _main
-from .helpers import read, read_pkg_resource, with_git_project, with_project, write
+from .helpers import read, with_git_project, with_project, write
 
 
 class TestCli(TestCase):
@@ -1086,7 +1086,7 @@ class TestCli(TestCase):
 
             [20-01-2001] CUSTOM RELEASE for FooBarBaz version 7.8.9
 
-            ## Features
+            # Features
 
             - Adds levitation (#123)
 
@@ -1104,7 +1104,6 @@ class TestCli(TestCase):
         package = "foo"
         filename = "NEWS.md"
         title_format = "### [{project_date}] CUSTOM RELEASE for {name} version {version}"
-        template = "custom_template.md"
         """
     )
     def test_markdown_injected_after_header(self, runner):
@@ -1136,13 +1135,6 @@ class TestCli(TestCase):
             dedent=True,
         )
 
-        default_template = read_pkg_resource("templates/default.md")
-        assert "##{%" in default_template
-        write(
-            "custom_template.md",
-            contents=default_template.replace("##{%", "####{%"),
-        )
-
         result = runner.invoke(_main, ["--date", "01-01-2001"], catch_exceptions=False)
 
         with open("foo/newsfragments/123.feature", "w") as f: