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:
parent
919210c386
commit
ee73c9409e
@ -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,
|
||||
|
12
releasenotes/notes/support-subsections-583600c47b3c7d49.yaml
Normal file
12
releasenotes/notes/support-subsections-583600c47b3c7d49.yaml
Normal 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``.
|
@ -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,
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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):
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user