Merge "Add support for subsections"

This commit is contained in:
Zuul
2023-03-30 12:03:32 +00:00
committed by Gerrit Code Review
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 = [
@ -181,6 +219,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):