From ee73c9409e4bbceec565a42c7090a72455b1dcfc Mon Sep 17 00:00:00 2001 From: Eric Arellano Date: Tue, 7 Feb 2023 15:59:28 -0700 Subject: [PATCH] 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 --- .../add-complex-example-6b5927c246456896.yaml | 6 ++ .../support-subsections-583600c47b3c7d49.yaml | 12 ++++ reno/config.py | 56 ++++++++++++++++++- reno/formatter.py | 2 +- reno/tests/test_config.py | 34 +++++++++++ reno/tests/test_formatter.py | 41 +++++++++++++- 6 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 releasenotes/notes/support-subsections-583600c47b3c7d49.yaml 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):