Add support for subsections

Subsections will appear underneath their top-level section, in
the order defined in the config file. They will use a ^ or ~
underline rather than -.

Users define subsections by adding a number to the end
of the section entry, either 2 or 3. It will be a subsection
of the parent section above it. This syntax is simpler than others
considered, like nesting lists inside a top-level section. It also
makes it easy for us to support deeper levels of nesting if we'd like.

Like normal sections, in users' notes files, they will use the
ID they define for each subsection.

Change-Id: Ib7b4781de81e19d11a7acb863204067c0946827b
Story: 2010375
This commit is contained in:
Eric Arellano 2023-02-07 15:59:28 -07:00
parent 919210c386
commit ee73c9409e
No known key found for this signature in database
GPG Key ID: 33ED4DFA7E582A86
6 changed files with 145 additions and 6 deletions

View File

@ -21,6 +21,12 @@ features:
fixes:
- Use YAML lists to add multiple items to the same section.
- Another fix could be listed here.
fixes_command_line:
- |
This is a subsection. It requires setting `sections` in
config with an entry underneath `['fixes', 'Bug Fixes']` like
`['fixes_command_line', 'Command Line', 2]`. The `2` at the end
indicates that the entry is a subsection header.
other:
- |
This bullet item includes a paragraph and a nested list,

View File

@ -0,0 +1,12 @@
---
features:
- |
Reno now allows specifying subsections, which will appear under
their top-level sections like "Features". This allows you to group
related changes together better.
To enable, set the ``sections`` value in config. Add subsections directly
underneath their top-level section, with the number ``2`` or ``3`` after
to indicate its nesting level. For example,
``["features_command_line", "Command Line", 2]``. Then, in your release note
files, use the ID of the subsection, e.g. ``features_command_line``.

View File

@ -16,6 +16,7 @@ import textwrap
from typing import Any
from typing import List
from typing import NamedTuple
from typing import Union
import yaml
@ -33,10 +34,47 @@ class Opt(NamedTuple):
class Section(NamedTuple):
name: str
title: str
section_level: int # 1 represents top-level; higher values are subsctions
@classmethod
def from_raw_yaml(cls, val: List[List[str]]) -> List["Section"]:
return [Section(*entry) for entry in val]
def from_raw_yaml(
cls, val: List[List[Union[str, int]]]
) -> List["Section"]:
result = []
for entry in val:
if len(entry) == 2:
section_level = 1
elif len(entry) == 3:
section_level = entry[2]
if (
not isinstance(section_level, int)
or section_level < 1
or section_level > 3
):
raise ValueError(
"The third argument for each entry in the `sections` "
"option config must be an integer between 1 and 3."
f"Invalid entry: {entry}"
)
else:
raise ValueError(
"Each entry in the `sections` option config must be a "
f"list with 2 or 3 values. Invalid entry: {entry}"
)
result.append(
Section(
name=entry[0], title=entry[1], section_level=section_level
)
)
return result
def header_underline(self) -> str:
symbol = {
1: "-",
2: "^",
3: "~",
}[self.section_level]
return symbol * len(self.title)
_OPTIONS = [
@ -164,6 +202,20 @@ _OPTIONS = [
release notes, in the order in which the final report will
be generated. A prelude section will always be automatically
inserted before the first element of this list.
You can optionally include a number from 1 to 3 at the
end of the list to mark the entry as a subsection. By default,
each section has the number 1, which represents a top-level
section. Use 2 and 3 for subsections and subsubsections.
For example, ``['features', 'New Features', 1]``,
``['features_command_line', 'Command Line', 2]``,
and ``['features_command_line_ios', 'iOS', 3]``. The order of this
option matters; define subsections right after their ancestor
sections.
Warning: you should check that ``semver_major``, ``semver_minor``,
and ``semver_patch`` includes the relevant section names,
including subsections.
""")),
Opt('prelude_section_name', defaults.PRELUDE_SECTION_NAME,

View File

@ -106,7 +106,7 @@ def format_report(loader, config, versions_to_include, title=None,
section.title, version_title, title, branch))
report.append('')
report.append(section.title)
report.append('-' * len(section.title))
report.append(section.header_underline())
report.append('')
for n, fn, sha in notes:
if show_source:

View File

@ -16,6 +16,7 @@ import os
from unittest import mock
import fixtures
from testtools import ExpectedException
from reno import config
from reno.config import Section
@ -74,6 +75,39 @@ collapse_pre_releases: false
expected = expected_options(notesdir='value2')
self.assertEqual(expected, actual)
def test_override_sections_with_subsections(self):
c = config.Config(self.tempdir.path)
c.override(
sections=[
["features", "Features"],
["features_sub", "Sub", 2],
["features_subsub", "Subsub", 3],
["bugs", "Bugs"],
["bugs_sub", "Sub", 2],
["documentation", "Documentation", 1]
],
)
actual = c.options
expected = expected_options(
sections=[
Section("features", "Features", section_level=1),
Section("features_sub", "Sub", section_level=2),
Section("features_subsub", "Subsub", section_level=3),
Section("bugs", "Bugs", section_level=1),
Section("bugs_sub", "Sub", section_level=2),
Section("documentation", "Documentation", section_level=1),
]
)
self.assertEqual(expected, actual)
# Also check data validation.
with ExpectedException(ValueError):
c.override(sections=[["features"]])
with ExpectedException(ValueError):
c.override(sections=[["features", "Features", 0]])
with ExpectedException(ValueError):
c.override(sections=[["features", "Features", 5]])
def test_load_file_not_present(self):
missing = 'reno.config.Config._report_missing_config_files'
with mock.patch(missing) as error_handler:

View File

@ -132,6 +132,9 @@ class TestFormatterCustomSections(TestFormatterBase):
'api': [
'This is the API change for the first feature.',
],
'features_subsubsection': [
'This is a subsubsection feature.',
],
},
'note3': {
'api': [
@ -140,14 +143,19 @@ class TestFormatterCustomSections(TestFormatterBase):
'features': [
'This is the second feature.',
],
'features_subsection': [
'This is a subsection feature.',
],
},
}
def setUp(self):
super(TestFormatterCustomSections, self).setUp()
self.c.override(sections=[
['api', 'API Changes'],
['features', 'New Features'],
['features_subsection', 'Subsection', 2],
['features_subsubsection', 'Subsubsection', 3],
['api', 'API Changes'],
])
def test_custom_section_order(self):
@ -160,11 +168,38 @@ class TestFormatterCustomSections(TestFormatterBase):
prelude_pos = result.index('This is the prelude.')
api_pos = result.index('API Changes')
features_pos = result.index('New Features')
expected = [prelude_pos, api_pos, features_pos]
actual = list(sorted([prelude_pos, features_pos, api_pos]))
features_subsection_pos = result.index('Subsection')
features_subsubsection_pos = result.index('Subsubsection')
expected = [
prelude_pos,
features_pos,
features_subsection_pos,
features_subsubsection_pos,
api_pos,
]
actual = sorted(expected)
self.assertEqual(expected, actual)
self.assertIn('.. _relnotes_1.0.0_API Changes:', result)
def test_header_underlines(self):
result = formatter.format_report(
loader=self.ldr,
config=self.c,
versions_to_include=self.versions,
title=None,
).splitlines()
def assert_header(header: str, char: str) -> None:
pos = next(
i for i, line in enumerate(result) if line.strip() == header
)
expected = char * len(header)
self.assertEqual(result[pos + 1].strip(), expected)
assert_header("New Features", "-")
assert_header("Subsection", "^")
assert_header("Subsubsection", "~")
class TestFormatterCustomUnreleaseTitle(TestFormatterBase):