diff --git a/examples/notes/add-complex-example-6b5927c246456896.yaml b/examples/notes/add-complex-example-6b5927c246456896.yaml index 24c31e1..382a1ca 100644 --- a/examples/notes/add-complex-example-6b5927c246456896.yaml +++ b/examples/notes/add-complex-example-6b5927c246456896.yaml @@ -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, diff --git a/releasenotes/notes/support-subsections-583600c47b3c7d49.yaml b/releasenotes/notes/support-subsections-583600c47b3c7d49.yaml new file mode 100644 index 0000000..31aa2c8 --- /dev/null +++ b/releasenotes/notes/support-subsections-583600c47b3c7d49.yaml @@ -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``. diff --git a/reno/config.py b/reno/config.py index 63c39eb..3ab2f80 100644 --- a/reno/config.py +++ b/reno/config.py @@ -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, diff --git a/reno/formatter.py b/reno/formatter.py index 39cb292..6eb6c26 100644 --- a/reno/formatter.py +++ b/reno/formatter.py @@ -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: diff --git a/reno/tests/test_config.py b/reno/tests/test_config.py index 889b816..018a9e2 100644 --- a/reno/tests/test_config.py +++ b/reno/tests/test_config.py @@ -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: diff --git a/reno/tests/test_formatter.py b/reno/tests/test_formatter.py index ee6e713..73bfaee 100644 --- a/reno/tests/test_formatter.py +++ b/reno/tests/test_formatter.py @@ -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):