Merge "Add support for subsections"
This commit is contained in:
@ -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 = [
|
||||
@ -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,
|
||||
|
@ -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):
|
||||
|
||||
|
Reference in New Issue
Block a user