Merge "Add support for subsections"
This commit is contained in:
@@ -21,6 +21,12 @@ features:
|
|||||||
fixes:
|
fixes:
|
||||||
- Use YAML lists to add multiple items to the same section.
|
- Use YAML lists to add multiple items to the same section.
|
||||||
- Another fix could be listed here.
|
- 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:
|
other:
|
||||||
- |
|
- |
|
||||||
This bullet item includes a paragraph and a nested list,
|
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 Any
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -33,10 +34,47 @@ class Opt(NamedTuple):
|
|||||||
class Section(NamedTuple):
|
class Section(NamedTuple):
|
||||||
name: str
|
name: str
|
||||||
title: str
|
title: str
|
||||||
|
section_level: int # 1 represents top-level; higher values are subsctions
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_raw_yaml(cls, val: List[List[str]]) -> List["Section"]:
|
def from_raw_yaml(
|
||||||
return [Section(*entry) for entry in val]
|
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 = [
|
_OPTIONS = [
|
||||||
@@ -181,6 +219,20 @@ _OPTIONS = [
|
|||||||
release notes, in the order in which the final report will
|
release notes, in the order in which the final report will
|
||||||
be generated. A prelude section will always be automatically
|
be generated. A prelude section will always be automatically
|
||||||
inserted before the first element of this list.
|
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,
|
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))
|
section.title, version_title, title, branch))
|
||||||
report.append('')
|
report.append('')
|
||||||
report.append(section.title)
|
report.append(section.title)
|
||||||
report.append('-' * len(section.title))
|
report.append(section.header_underline())
|
||||||
report.append('')
|
report.append('')
|
||||||
for n, fn, sha in notes:
|
for n, fn, sha in notes:
|
||||||
if show_source:
|
if show_source:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import os
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import fixtures
|
import fixtures
|
||||||
|
from testtools import ExpectedException
|
||||||
|
|
||||||
from reno import config
|
from reno import config
|
||||||
from reno.config import Section
|
from reno.config import Section
|
||||||
@@ -74,6 +75,39 @@ collapse_pre_releases: false
|
|||||||
expected = expected_options(notesdir='value2')
|
expected = expected_options(notesdir='value2')
|
||||||
self.assertEqual(expected, actual)
|
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):
|
def test_load_file_not_present(self):
|
||||||
missing = 'reno.config.Config._report_missing_config_files'
|
missing = 'reno.config.Config._report_missing_config_files'
|
||||||
with mock.patch(missing) as error_handler:
|
with mock.patch(missing) as error_handler:
|
||||||
|
|||||||
@@ -132,6 +132,9 @@ class TestFormatterCustomSections(TestFormatterBase):
|
|||||||
'api': [
|
'api': [
|
||||||
'This is the API change for the first feature.',
|
'This is the API change for the first feature.',
|
||||||
],
|
],
|
||||||
|
'features_subsubsection': [
|
||||||
|
'This is a subsubsection feature.',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
'note3': {
|
'note3': {
|
||||||
'api': [
|
'api': [
|
||||||
@@ -140,14 +143,19 @@ class TestFormatterCustomSections(TestFormatterBase):
|
|||||||
'features': [
|
'features': [
|
||||||
'This is the second feature.',
|
'This is the second feature.',
|
||||||
],
|
],
|
||||||
|
'features_subsection': [
|
||||||
|
'This is a subsection feature.',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(TestFormatterCustomSections, self).setUp()
|
super(TestFormatterCustomSections, self).setUp()
|
||||||
self.c.override(sections=[
|
self.c.override(sections=[
|
||||||
['api', 'API Changes'],
|
|
||||||
['features', 'New Features'],
|
['features', 'New Features'],
|
||||||
|
['features_subsection', 'Subsection', 2],
|
||||||
|
['features_subsubsection', 'Subsubsection', 3],
|
||||||
|
['api', 'API Changes'],
|
||||||
])
|
])
|
||||||
|
|
||||||
def test_custom_section_order(self):
|
def test_custom_section_order(self):
|
||||||
@@ -160,11 +168,38 @@ class TestFormatterCustomSections(TestFormatterBase):
|
|||||||
prelude_pos = result.index('This is the prelude.')
|
prelude_pos = result.index('This is the prelude.')
|
||||||
api_pos = result.index('API Changes')
|
api_pos = result.index('API Changes')
|
||||||
features_pos = result.index('New Features')
|
features_pos = result.index('New Features')
|
||||||
expected = [prelude_pos, api_pos, features_pos]
|
features_subsection_pos = result.index('Subsection')
|
||||||
actual = list(sorted([prelude_pos, features_pos, api_pos]))
|
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.assertEqual(expected, actual)
|
||||||
self.assertIn('.. _relnotes_1.0.0_API Changes:', result)
|
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):
|
class TestFormatterCustomUnreleaseTitle(TestFormatterBase):
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user