From 80a622184a3aded1fdbe1aaa5fb85104fee2b1cd Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Thu, 27 Mar 2025 16:02:15 +1300 Subject: [PATCH 1/6] Add support for 'all_bullets' configuration in fragment types --- docs/configuration.rst | 17 ++++++++ src/towncrier/_builder.py | 17 ++++---- src/towncrier/_settings/fragment_types.py | 41 ++++++++++++------- src/towncrier/_settings/load.py | 22 +++++++--- .../newsfragments/+8deeacbc.feature.rst | 1 + src/towncrier/templates/default.md | 4 +- src/towncrier/templates/default.rst | 4 +- src/towncrier/test/test_settings.py | 9 ++++ 8 files changed, 82 insertions(+), 33 deletions(-) create mode 100644 src/towncrier/newsfragments/+8deeacbc.feature.rst diff --git a/docs/configuration.rst b/docs/configuration.rst index 053daad1..056f04ba 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -255,6 +255,12 @@ These may include the following optional keys: ``true`` by default. +``all_bullets`` + + A boolean value indicating whether this fragment type should be rendered without bullets, overriding the global ``all_bullets`` configuration. + + Matches the global ``all_bullets`` configuration by default. + For example, if you want your custom fragment types to be ``["feat", "fix", "chore",]`` and you want all of them to use the default configuration except ``"chore"`` you can do it as follows: .. code-block:: toml @@ -312,11 +318,22 @@ Each table within this array has the following mandatory keys: ``true`` by default. +``nobullets`` + + A boolean value indicating whether this fragment type should be rendered without bullets. + + ``false`` by default. + For example: .. code-block:: toml [tool.towncrier] + [[tool.towncrier.type]] + name = "" + directory = "description" + nobullets = true + [[tool.towncrier.type]] name = "Deprecations" diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index 33ebe167..4e43a491 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -230,9 +230,9 @@ def split_fragments( section: dict[str, dict[str, list[str]]] = {} for (issue, category, counter), content in section_fragments.items(): - if all_bullets: - # By default all fragmetns are append by "-" automatically, - # and need to be indented because of that. + if definitions[category].get("all_bullets", all_bullets): + # By default all fragments are appended by "-" automatically, and need + # to be indented because of that. # (otherwise, assume they are formatted correctly) content = indent(content.strip(), " ")[2:] else: @@ -417,13 +417,10 @@ def render_fragments( done = [] def get_indent(text: str) -> str: - # If bullets are not assumed and we wrap, the subsequent - # indentation depends on whether or not this is a bullet point. - # (it is probably usually best to disable wrapping in that case) - if all_bullets or text[:2] == "- " or text[:2] == "* ": - return " " - elif text[:3] == "#. ": - return " " + # Subsequent indentation depends on whether or not this is a bullet point. + match = re.match(r"^([-*]|(\d+|#)\.) ", text) + if match: + return " " * len(match.group(0)) return "" res = jinja_template.render( diff --git a/src/towncrier/_settings/fragment_types.py b/src/towncrier/_settings/fragment_types.py index 60902185..207c165c 100644 --- a/src/towncrier/_settings/fragment_types.py +++ b/src/towncrier/_settings/fragment_types.py @@ -2,7 +2,11 @@ import abc -from typing import Any, Iterable, Mapping +from typing import TYPE_CHECKING, Any, Iterable, Mapping + + +if TYPE_CHECKING: + from .load import CategoryType, Config class BaseFragmentTypesLoader: @@ -10,12 +14,15 @@ class BaseFragmentTypesLoader: __metaclass__ = abc.ABCMeta - def __init__(self, config: Mapping[str, Any]): + def __init__(self, config: Mapping[str, Any], parsed_config: Config): """Initialize.""" self.config = config + self.parsed_config = parsed_config @classmethod - def factory(cls, config: Mapping[str, Any]) -> BaseFragmentTypesLoader: + def factory( + cls, config: Mapping[str, Any], parsed_config: Config + ) -> BaseFragmentTypesLoader: fragment_types_class: type[BaseFragmentTypesLoader] = DefaultFragmentTypesLoader fragment_types = config.get("fragment", {}) types_config = config.get("type", {}) @@ -24,18 +31,18 @@ def factory(cls, config: Mapping[str, Any]) -> BaseFragmentTypesLoader: elif types_config: fragment_types_class = ArrayFragmentTypesLoader - new = fragment_types_class(config) + new = fragment_types_class(config, parsed_config) return new @abc.abstractmethod - def load(self) -> Mapping[str, Mapping[str, Any]]: + def load(self) -> Mapping[str, CategoryType]: """Load fragment types.""" class DefaultFragmentTypesLoader(BaseFragmentTypesLoader): """Default towncrier's fragment types.""" - _default_types = { + _default_types: Mapping[str, CategoryType] = { # Keep in-sync with docs/tutorial.rst. "feature": {"name": "Features", "showcontent": True, "check": True}, "bugfix": {"name": "Bugfixes", "showcontent": True, "check": True}, @@ -48,7 +55,7 @@ class DefaultFragmentTypesLoader(BaseFragmentTypesLoader): "misc": {"name": "Misc", "showcontent": False, "check": True}, } - def load(self) -> Mapping[str, Mapping[str, Any]]: + def load(self) -> Mapping[str, CategoryType]: """Load default types.""" return self._default_types @@ -70,10 +77,10 @@ class ArrayFragmentTypesLoader(BaseFragmentTypesLoader): """ - def load(self) -> Mapping[str, Mapping[str, Any]]: + def load(self) -> Mapping[str, CategoryType]: """Load types from toml array of mappings.""" - types = {} + types: dict[str, CategoryType] = {} types_config = self.config["type"] for type_config in types_config: fragment_type_name = type_config["name"] @@ -84,6 +91,9 @@ def load(self) -> Mapping[str, Mapping[str, Any]]: "name": fragment_type_name, "showcontent": is_content_required, "check": check, + "all_bullets": type_config.get( + "all_bullets", self.parsed_config.all_bullets + ), } return types @@ -113,12 +123,12 @@ class TableFragmentTypesLoader(BaseFragmentTypesLoader): """ - def __init__(self, config: Mapping[str, Mapping[str, Any]]): + def __init__(self, *args: Any, **kwargs: Any): """Initialize.""" - self.config = config - self.fragment_options = config.get("fragment", {}) + super().__init__(*args, **kwargs) + self.fragment_options = self.config.get("fragment", {}) - def load(self) -> Mapping[str, Mapping[str, Any]]: + def load(self) -> Mapping[str, CategoryType]: """Load types from nested mapping.""" fragment_types: Iterable[str] = self.fragment_options.keys() fragment_types = sorted(fragment_types) @@ -129,16 +139,17 @@ def load(self) -> Mapping[str, Mapping[str, Any]]: types = dict(custom_types_sequence) return types - def _load_options(self, fragment_type: str) -> Mapping[str, Any]: + def _load_options(self, fragment_type: str) -> CategoryType: """Load fragment options.""" capitalized_fragment_type = fragment_type.capitalize() options = self.fragment_options.get(fragment_type, {}) fragment_description = options.get("name", capitalized_fragment_type) show_content = options.get("showcontent", True) check = options.get("check", True) - clean_fragment_options = { + clean_fragment_options: CategoryType = { "name": fragment_description, "showcontent": show_content, "check": check, + "all_bullets": options.get("all_bullets", self.parsed_config.all_bullets), } return clean_fragment_options diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py index 3a70a67a..2926b50e 100644 --- a/src/towncrier/_settings/load.py +++ b/src/towncrier/_settings/load.py @@ -11,7 +11,7 @@ from contextlib import ExitStack from pathlib import Path -from typing import Any, Literal, Mapping, Sequence +from typing import Any, Literal, Mapping, Sequence, TypedDict from click import ClickException @@ -33,10 +33,18 @@ re_resource_template = re.compile(r"[-\w.]+:[-\w.]+$") +class CategoryType(TypedDict, total=False): + name: str + directory: str + showcontent: bool + check: bool + all_bullets: bool + + @dataclasses.dataclass class Config: sections: Mapping[str, str] - types: Mapping[str, Mapping[str, Any]] + types: Mapping[str, CategoryType] template: str | tuple[str, str] start_string: str package: str = "" @@ -203,10 +211,6 @@ def parse_toml(base_path: str, config: Mapping[str, Any]) -> Config: sections[""] = "" parsed_data["sections"] = sections - # Process 'types'. - fragment_types_loader = ft.BaseFragmentTypesLoader.factory(config) - parsed_data["types"] = fragment_types_loader.load() - # Process 'template'. markdown_file = Path(config.get("filename", "")).suffix == ".md" template = config.get("template", "towncrier:default") @@ -242,6 +246,12 @@ def parse_toml(base_path: str, config: Mapping[str, Any]) -> Config: start_string = start_string_template.format("towncrier release notes start") parsed_data["start_string"] = start_string + # Process 'types'. + fragment_types_loader = ft.BaseFragmentTypesLoader.factory( + config, parsed_config=Config(types={}, **parsed_data) + ) + parsed_data["types"] = fragment_types_loader.load() + # Return the parsed config. return Config(**parsed_data) diff --git a/src/towncrier/newsfragments/+8deeacbc.feature.rst b/src/towncrier/newsfragments/+8deeacbc.feature.rst new file mode 100644 index 00000000..29230b4e --- /dev/null +++ b/src/towncrier/newsfragments/+8deeacbc.feature.rst @@ -0,0 +1 @@ +Categories can be marked as ``all_bullets`` so fragments in that specific category can override the global configuration. diff --git a/src/towncrier/templates/default.md b/src/towncrier/templates/default.md index 4a5a07d4..1558bfe1 100644 --- a/src/towncrier/templates/default.md +++ b/src/towncrier/templates/default.md @@ -13,10 +13,12 @@ {% if sections[section] %} {% for category, val in definitions.items() if category in sections[section] %} +{% if definitions[category]['name'] %} ##{% if section %}#{% endif %} {{ definitions[category]['name'] }} +{% endif %} {% for text, values in sections[section][category].items() %} -- {{ text }} +{% if "all_bullets" not in definitions[category] or definitions[category].all_bullets %}- {% endif %}{{ text }} {%- if values %} {% if "\n - " in text or '\n * ' in text %} diff --git a/src/towncrier/templates/default.rst b/src/towncrier/templates/default.rst index bee15720..17592e42 100644 --- a/src/towncrier/templates/default.rst +++ b/src/towncrier/templates/default.rst @@ -15,11 +15,13 @@ {% if sections[section] %} {% for category, val in definitions.items() if category in sections[section]%} +{% if definitions[category]['name'] %} {{ definitions[category]['name'] }} {{ underline * definitions[category]['name']|length }} +{% endif %} {% for text, values in sections[section][category].items() %} -- {% if text %}{{ text }}{% if values %} ({{ values|join(', ') }}){% endif %}{% else %}{{ values|join(', ') }}{% endif %} +{% if "all_bullets" not in definitions[category] or definitions[category].all_bullets %}- {% endif %}{% if text %}{{ text }}{% if values %} ({{ values|join(', ') }}){% endif %}{% else %}{{ values|join(', ') }}{% endif %} {% endfor %} diff --git a/src/towncrier/test/test_settings.py b/src/towncrier/test/test_settings.py index f46db2b2..6562c4ea 100644 --- a/src/towncrier/test/test_settings.py +++ b/src/towncrier/test/test_settings.py @@ -370,6 +370,7 @@ def test_custom_types_as_tables_array_deprecated(self): """ ) config = load_config(project_dir) + assert config expected = [ ( "foo", @@ -377,6 +378,7 @@ def test_custom_types_as_tables_array_deprecated(self): "name": "Foo", "showcontent": False, "check": True, + "all_bullets": True, }, ), ( @@ -385,6 +387,7 @@ def test_custom_types_as_tables_array_deprecated(self): "name": "Spam", "showcontent": True, "check": True, + "all_bullets": True, }, ), ( @@ -393,6 +396,7 @@ def test_custom_types_as_tables_array_deprecated(self): "name": "Automatic", "showcontent": True, "check": False, + "all_bullets": True, }, ), ] @@ -421,26 +425,31 @@ def test_custom_types_as_tables(self): """ ) config = load_config(project_dir) + assert config expected = { "chore": { "name": "Other Tasks", "showcontent": False, "check": True, + "all_bullets": True, }, "feat": { "name": "Feat", "showcontent": True, "check": True, + "all_bullets": True, }, "fix": { "name": "Fix", "showcontent": True, "check": True, + "all_bullets": True, }, "auto": { "name": "Automatic", "showcontent": True, "check": False, + "all_bullets": True, }, } actual = config.types From 916cf948c198453d8259168bc24ecf59b879e8b8 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Thu, 27 Mar 2025 16:43:28 +1300 Subject: [PATCH 2/6] Add documentation for custom section types and default section types --- docs/configuration.rst | 22 ++++++++--- docs/customization/newsfile.rst | 37 +++++++++++++++++++ src/towncrier/_settings/fragment_types.py | 36 +++++++++++------- .../newsfragments/+12a76291.feature.rst | 12 ++++++ 4 files changed, 88 insertions(+), 19 deletions(-) create mode 100644 src/towncrier/newsfragments/+12a76291.feature.rst diff --git a/docs/configuration.rst b/docs/configuration.rst index 056f04ba..b465fc1a 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -328,12 +328,6 @@ For example: .. code-block:: toml - [tool.towncrier] - [[tool.towncrier.type]] - name = "" - directory = "description" - nobullets = true - [[tool.towncrier.type]] name = "Deprecations" @@ -347,3 +341,19 @@ For example: name = "Dependency Changes" showcontent = true check = false + +To insert the default types into your configuration, use the ``default_types = true`` key. + +In this special case, no other keys should be set for that item. For example: + +.. code-block:: toml + + [tool.towncrier] + [[tool.towncrier.type]] + name = "" + directory = "description" + all_bullets = true + check = false + + [[tool.towncrier.type]] + default_types = true diff --git a/docs/customization/newsfile.rst b/docs/customization/newsfile.rst index 03b97a3a..604eecbe 100644 --- a/docs/customization/newsfile.rst +++ b/docs/customization/newsfile.rst @@ -38,3 +38,40 @@ If your news file is in Markdown (e.g. ``NEWS.md``), use the following comment i .. code-block:: html + + +Adding Content at the Start of the Next Release +----------------------------------------------- + +Using a custom section type, you can add content at the start of the next release. +Here's an example configuration to add a text-only section at the start of the next release: + +.. code-block:: toml + + [[tool.towncrier.type]] + name = "" + directory = "description" + all_bullets = false + check = false + + [[tool.towncrier.type]] + default_types = true + +Any fragments with a suffix of ``.description`` will be added to this description section. +The section has no visible name and no bullets, so the content of the fragments will be shown directly after the release title (or in the case of a more complex configuration with multiple sections, after the relevant section title). + +The output of the above configuration with a ``+.description.rst`` fragment containing "Happy new year!" (and other ``.feature`` fragments) will look something like this: + +.. code-block:: rst + + myproject 1.2.3 (2026-01-01) + ============================ + + + Happy new year! + + + Features + -------- + + - Added, etc... \ No newline at end of file diff --git a/src/towncrier/_settings/fragment_types.py b/src/towncrier/_settings/fragment_types.py index 207c165c..3464244b 100644 --- a/src/towncrier/_settings/fragment_types.py +++ b/src/towncrier/_settings/fragment_types.py @@ -14,6 +14,19 @@ class BaseFragmentTypesLoader: __metaclass__ = abc.ABCMeta + _default_types: Mapping[str, CategoryType] = { + # Keep in-sync with docs/tutorial.rst. + "feature": {"name": "Features", "showcontent": True, "check": True}, + "bugfix": {"name": "Bugfixes", "showcontent": True, "check": True}, + "doc": {"name": "Improved Documentation", "showcontent": True, "check": True}, + "removal": { + "name": "Deprecations and Removals", + "showcontent": True, + "check": True, + }, + "misc": {"name": "Misc", "showcontent": False, "check": True}, + } + def __init__(self, config: Mapping[str, Any], parsed_config: Config): """Initialize.""" self.config = config @@ -42,19 +55,6 @@ def load(self) -> Mapping[str, CategoryType]: class DefaultFragmentTypesLoader(BaseFragmentTypesLoader): """Default towncrier's fragment types.""" - _default_types: Mapping[str, CategoryType] = { - # Keep in-sync with docs/tutorial.rst. - "feature": {"name": "Features", "showcontent": True, "check": True}, - "bugfix": {"name": "Bugfixes", "showcontent": True, "check": True}, - "doc": {"name": "Improved Documentation", "showcontent": True, "check": True}, - "removal": { - "name": "Deprecations and Removals", - "showcontent": True, - "check": True, - }, - "misc": {"name": "Misc", "showcontent": False, "check": True}, - } - def load(self) -> Mapping[str, CategoryType]: """Load default types.""" return self._default_types @@ -75,6 +75,12 @@ class ArrayFragmentTypesLoader(BaseFragmentTypesLoader): name = "Deprecations" showcontent = true + Use a type that only contains ``default_types = true`` to + insert the default fragment types:: + + ... + [[tool.towncrier.type]] + default_types = true """ def load(self) -> Mapping[str, CategoryType]: @@ -83,6 +89,10 @@ def load(self) -> Mapping[str, CategoryType]: types: dict[str, CategoryType] = {} types_config = self.config["type"] for type_config in types_config: + if type_config == {"default_types": True}: + for type_name, type_options in self._default_types.items(): + types[type_name] = type_options + continue fragment_type_name = type_config["name"] directory = type_config.get("directory", fragment_type_name.lower()) is_content_required = type_config.get("showcontent", True) diff --git a/src/towncrier/newsfragments/+12a76291.feature.rst b/src/towncrier/newsfragments/+12a76291.feature.rst new file mode 100644 index 00000000..d3dc9c17 --- /dev/null +++ b/src/towncrier/newsfragments/+12a76291.feature.rst @@ -0,0 +1,12 @@ +Adding a type to configuration with just ``default_types = true`` will insert the default types rather than needing to duplicate them. + +For example, to add a new ``description`` type first, but keep all of the other default types:: + + [[tool.towncrier.type]] + name = "" + directory = "description" + all_bullets = false + check = false + + [[tool.towncrier.type]] + default_types = true From 6bc8a8119dd6095b8705df0becfb8c040e74a2c4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 31 Mar 2025 21:33:20 +0000 Subject: [PATCH 3/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/customization/newsfile.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/customization/newsfile.rst b/docs/customization/newsfile.rst index 604eecbe..0ccf28c5 100644 --- a/docs/customization/newsfile.rst +++ b/docs/customization/newsfile.rst @@ -74,4 +74,4 @@ The output of the above configuration with a ``+.description.rst`` fragment cont Features -------- - - Added, etc... \ No newline at end of file + - Added, etc... From ee0de0bafda2f205ca982da8bf298d592b2157f1 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Tue, 1 Apr 2025 16:24:45 +1300 Subject: [PATCH 4/6] Add tests --- src/towncrier/test/test_bullets.py | 462 ++++++++++++++++++++++++++++ src/towncrier/test/test_settings.py | 104 +++++++ 2 files changed, 566 insertions(+) create mode 100644 src/towncrier/test/test_bullets.py diff --git a/src/towncrier/test/test_bullets.py b/src/towncrier/test/test_bullets.py new file mode 100644 index 00000000..f820d62d --- /dev/null +++ b/src/towncrier/test/test_bullets.py @@ -0,0 +1,462 @@ +import os + +from textwrap import dedent + +from twisted.trial.unittest import TestCase + +from .._builder import render_fragments, split_fragments +from .._settings import load_config +from .helpers import read_pkg_resource, write + + +class BulletsTests(TestCase): + maxDiff = None + + def mktemp_project( + self, *, pyproject_toml: str = "", towncrier_toml: str = "" + ) -> str: + """ + Create a temporary directory with config files. + """ + project_dir = self.mktemp() + os.makedirs(project_dir) + + if pyproject_toml: + write( + os.path.join(project_dir, "pyproject.toml"), + pyproject_toml, + dedent=True, + ) + + if towncrier_toml: + write( + os.path.join(project_dir, "towncrier.toml"), + towncrier_toml, + dedent=True, + ) + + return project_dir + + def test_render_rst_all_bullets_true_default(self): + """ + Default behavior: all_bullets is True, RST renders with bullets. + """ + project_dir = self.mktemp_project( + pyproject_toml=""" + [tool.towncrier] + package = "foobar" + filename = "NEWS.rst" + # all_bullets defaults to true + [tool.towncrier.fragment.feat] + name = "Features" + [tool.towncrier.fragment.fix] + name = "Bugfixes" + """ + ) + config = load_config(project_dir) + assert config is not None # Assure type checker + template_resource_path = f"templates/{config.template[1]}" + template_content = read_pkg_resource(template_resource_path) + raw_fragments = { + "": { + ("1", "feat", 0): "Feature 1.", + ("2", "fix", 0): "Fix 2.", + }, + } + processed_fragments = split_fragments( + raw_fragments, config.types, config.all_bullets + ) + rendered = render_fragments( + template=template_content, + issue_format=config.issue_format, + fragments=processed_fragments, + definitions=config.types, + underlines=config.underlines[1:], + wrap=config.wrap, + versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, + all_bullets=config.all_bullets, + render_title=False, # Don't render title in fragment tests + ) + expected = """ +Features +-------- + +- Feature 1. (#1) + + +Bugfixes +-------- + +- Fix 2. (#2) + +""" + self.assertEqual(dedent(expected).strip(), rendered.strip()) + + def test_render_rst_all_bullets_false_global(self): + """ + Global all_bullets=false, RST renders without bullets. + """ + project_dir = self.mktemp_project( + pyproject_toml=""" + [tool.towncrier] + package = "foobar" + filename = "NEWS.rst" + all_bullets = false + [tool.towncrier.fragment.feat] + name = "Features" + [tool.towncrier.fragment.fix] + name = "Bugfixes" + """ + ) + config = load_config(project_dir) + assert config is not None # Assure type checker + template_resource_path = f"templates/{config.template[1]}" + template_content = read_pkg_resource(template_resource_path) + raw_fragments = { + "": { + ("1", "feat", 0): "Feature 1.", + ("2", "fix", 0): "Fix 2.", + }, + } + processed_fragments = split_fragments( + raw_fragments, config.types, config.all_bullets + ) + rendered = render_fragments( + template=template_content, + issue_format=config.issue_format, + fragments=processed_fragments, + definitions=config.types, + underlines=config.underlines[1:], + wrap=config.wrap, + versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, + all_bullets=config.all_bullets, + render_title=False, # Don't render title in fragment tests + ) + expected = """ +Features +-------- + +Feature 1. (#1) + + +Bugfixes +-------- + +Fix 2. (#2) + +""" + self.assertEqual(dedent(expected).strip(), rendered.strip()) + + def test_render_rst_all_bullets_override(self): + """ + Per-category all_bullets overrides global setting in RST. + """ + project_dir = self.mktemp_project( + pyproject_toml=""" + [tool.towncrier] + package = "foobar" + filename = "NEWS.rst" + all_bullets = false # Global false + [tool.towncrier.fragment.feat] + name = "Features" + all_bullets = true # Override to true + [tool.towncrier.fragment.fix] + name = "Bugfixes" + # Inherits global false + [tool.towncrier.fragment.fish] + name = "Docs" + all_bullets = false # Explicit false + """ + ) + config = load_config(project_dir) + assert config is not None # Assure type checker + template_resource_path = f"templates/{config.template[1]}" + template_content = read_pkg_resource(template_resource_path) + raw_fragments = { + "": { + ("1", "feat", 0): "Feature 1.", + ("2", "fix", 0): "Fix 2.", + ("3", "fish", 0): "Doc 3.", + }, + } + processed_fragments = split_fragments( + raw_fragments, config.types, config.all_bullets + ) + rendered = render_fragments( + template=template_content, + issue_format=config.issue_format, + fragments=processed_fragments, + definitions=config.types, + underlines=config.underlines[1:], + wrap=config.wrap, + versiondata={"name": "MyProject", "version": "1.0", "date": "2026-01-01"}, + all_bullets=config.all_bullets, + ) + # Note: Order is sorted based on alphabetical fragment names + expected = """ +MyProject 1.0 (2026-01-01) +========================== + +Features +-------- + +- Feature 1. (#1) + + +Docs +---- + +Doc 3. (#3) + + +Bugfixes +-------- + +Fix 2. (#2) + + +""" + self.assertEqual(dedent(expected).strip(), rendered.strip()) + + def test_render_md_all_bullets_true_default(self): + """ + Default behavior: all_bullets is True, Markdown renders with bullets. + """ + project_dir = self.mktemp_project( + pyproject_toml=""" + [tool.towncrier] + package = "foobar" + filename = "NEWS.md" # Triggers markdown template + # all_bullets defaults to true + [tool.towncrier.fragment.feat] + name = "Features" + [tool.towncrier.fragment.fix] + name = "Bugfixes" + """ + ) + config = load_config(project_dir) + assert config is not None # Assure type checker + template_resource_path = f"templates/{config.template[1]}" + template_content = read_pkg_resource(template_resource_path) + raw_fragments = { + "": { + ("1", "feat", 0): "Feature 1.", + ("2", "fix", 0): "Fix 2.", + }, + } + processed_fragments = split_fragments( + raw_fragments, config.types, config.all_bullets + ) + rendered = render_fragments( + template=template_content, + issue_format=config.issue_format, + fragments=processed_fragments, + definitions=config.types, + underlines=config.underlines[1:], + wrap=config.wrap, + versiondata={"name": "MyProject", "version": "1.0", "date": "never"}, + all_bullets=config.all_bullets, + render_title=False, # Don't render title in fragment tests + ) + expected = """ +## Features + +- Feature 1. (#1) + +## Bugfixes + +- Fix 2. (#2) + +""" + self.assertEqual(dedent(expected).strip(), rendered.strip()) + + def test_render_md_all_bullets_false_global(self): + """ + Global all_bullets=false, Markdown renders without bullets. + """ + project_dir = self.mktemp_project( + pyproject_toml=""" + [tool.towncrier] + package = "foobar" + filename = "NEWS.md" + all_bullets = false + [tool.towncrier.fragment.feat] + name = "Features" + [tool.towncrier.fragment.fix] + name = "Bugfixes" + """ + ) + config = load_config(project_dir) + assert config is not None # Assure type checker + template_resource_path = f"templates/{config.template[1]}" + template_content = read_pkg_resource(template_resource_path) + raw_fragments = { + "": { + ("1", "feat", 0): "Feature 1.", + ("2", "fix", 0): "Fix 2.", + }, + } + processed_fragments = split_fragments( + raw_fragments, config.types, config.all_bullets + ) + rendered = render_fragments( + template=template_content, + issue_format=config.issue_format, + fragments=processed_fragments, + definitions=config.types, + underlines=config.underlines[1:], + wrap=config.wrap, + versiondata={}, + all_bullets=config.all_bullets, + render_title=False, # Don't render title in fragment tests + ) + expected = """ +## Features + +Feature 1. (#1) + +## Bugfixes + +Fix 2. (#2) + +""" + self.assertEqual(dedent(expected).strip(), rendered.strip()) + + def test_render_md_all_bullets_override(self): + """ + Per-category all_bullets overrides global setting in Markdown. + """ + project_dir = self.mktemp_project( + pyproject_toml=""" + [tool.towncrier] + package = "foobar" + filename = "NEWS.md" + all_bullets = true # Global true + [tool.towncrier.fragment.feat] + name = "Features" + # Inherits global true + [tool.towncrier.fragment.fix] + name = "Bugfixes" + all_bullets = false # Override to false + [tool.towncrier.fragment.fish] + name = "Docs" + all_bullets = true # Explicit true + """ + ) + config = load_config(project_dir) + assert config is not None # Assure type checker + template_resource_path = f"templates/{config.template[1]}" + template_content = read_pkg_resource(template_resource_path) + raw_fragments = { + "": { + ("1", "feat", 0): "Feature 1.", + ("2", "fix", 0): "Fix 2.", + ("3", "fish", 0): "Doc 3.", + }, + } + processed_fragments = split_fragments( + raw_fragments, config.types, config.all_bullets + ) + rendered = render_fragments( + template=template_content, + issue_format=config.issue_format, + fragments=processed_fragments, + definitions=config.types, + underlines=config.underlines[1:], + wrap=config.wrap, + versiondata={}, # MD template might not need versiondata for title + all_bullets=config.all_bullets, + render_title=False, # Don't render title in fragment tests + ) + # Note: Order is sorted based on alphabetical fragment names + expected = """ +## Features + +- Feature 1. (#1) + +## Docs + +- Doc 3. (#3) + +## Bugfixes + +Fix 2. (#2) + +""" + self.assertEqual(dedent(expected).strip(), rendered.strip()) + + def test_render_rst_all_bullets_array_format(self): + """ + Per-category all_bullets overrides global setting using the array format. + """ + project_dir = self.mktemp_project( + pyproject_toml=""" + [tool.towncrier] + package = "foobar" + filename = "NEWS.rst" + all_bullets = false # Global false + + [[tool.towncrier.type]] + directory = "feat" + name = "Features" + all_bullets = true # Override to true + + [[tool.towncrier.type]] + directory = "fix" + name = "Bugfixes" + # Inherits global false + + [[tool.towncrier.type]] + directory = "docs" + name = "Documentation" + all_bullets = false # Explicit false + """ + ) + config = load_config(project_dir) + assert config is not None + template_resource_path = f"templates/{config.template[1]}" + template_content = read_pkg_resource(template_resource_path) + raw_fragments = { + "": { + ("1", "feat", 0): "Feature 1.", + ("2", "fix", 0): "Fix 2.", + ("3", "docs", 0): "Doc 3.", + }, + } + processed_fragments = split_fragments( + raw_fragments, config.types, config.all_bullets + ) + rendered = render_fragments( + template=template_content, + issue_format=config.issue_format, + fragments=processed_fragments, + definitions=config.types, + underlines=config.underlines[1:], + wrap=config.wrap, + versiondata={"name": "MyProject", "version": "1.0", "date": "2026-01-01"}, + all_bullets=config.all_bullets, + ) + # Order is sorted based on the order of the array + expected = """ +MyProject 1.0 (2026-01-01) +========================== + +Features +-------- + +- Feature 1. (#1) + + +Bugfixes +-------- + +Fix 2. (#2) + + +Documentation +------------- + +Doc 3. (#3) + +""" + self.assertEqual(dedent(expected).strip(), rendered.strip()) diff --git a/src/towncrier/test/test_settings.py b/src/towncrier/test/test_settings.py index 6562c4ea..88ec33ca 100644 --- a/src/towncrier/test/test_settings.py +++ b/src/towncrier/test/test_settings.py @@ -14,6 +14,8 @@ class TomlSettingsTests(TestCase): + maxDiff = None + def mktemp_project( self, *, pyproject_toml: str = "", towncrier_toml: str = "" ) -> str: @@ -404,6 +406,108 @@ def test_custom_types_as_tables_array_deprecated(self): actual = config.types self.assertDictEqual(expected, actual) + def test_custom_types_array_with_defaults(self): + """ + Custom fragment types defined using an array of tables can include + the default types by specifying `default_types = true`. + """ + project_dir = self.mktemp_project( + pyproject_toml=""" + [tool.towncrier] + package = "foobar" + + [[tool.towncrier.type]] + directory="custom" + name="Custom Type" + showcontent=false + + [[tool.towncrier.type]] + default_types = true + + [[tool.towncrier.type]] + directory="another" + name="Another Type" + showcontent=true + """ + ) + config = load_config(project_dir) + assert config + expected = { + "custom": { + "all_bullets": True, + "name": "Custom Type", + "showcontent": False, + "check": True, + }, + # Default types inserted here + "feature": {"name": "Features", "showcontent": True, "check": True}, + "bugfix": {"name": "Bugfixes", "showcontent": True, "check": True}, + "doc": { + "name": "Improved Documentation", + "showcontent": True, + "check": True, + }, + "removal": { + "name": "Deprecations and Removals", + "showcontent": True, + "check": True, + }, + "misc": {"name": "Misc", "showcontent": False, "check": True}, + # Another custom type after defaults + "another": { + "all_bullets": True, + "name": "Another Type", + "showcontent": True, + "check": True, + }, + } + + actual = config.types + self.assertDictEqual(expected, actual) + + # Check key ordering + self.assertEqual( + ["custom", "feature", "bugfix", "doc", "removal", "misc", "another"], + list(actual.keys()), + ) + + def test_custom_types_all_bullets_inheritance(self): + """ + Test that fragment types inherit the global all_bullets setting + when not explicitly set. + """ + project_dir = self.mktemp_project( + pyproject_toml=""" + [tool.towncrier] + package = "foobar" + all_bullets = false # Global setting + [tool.towncrier.fragment.feat] + name = "Features" + # No all_bullets here - should inherit false + [tool.towncrier.fragment.fix] + name = "Bugfixes" + all_bullets = true # Explicit override + """ + ) + config = load_config(project_dir) + assert config is not None + expected = { + "feat": { + "name": "Features", + "showcontent": True, + "check": True, + "all_bullets": False, # Inherited + }, + "fix": { + "name": "Bugfixes", + "showcontent": True, + "check": True, + "all_bullets": True, # Overridden + }, + } + actual = config.types + self.assertDictEqual(expected, actual) + def test_custom_types_as_tables(self): """ Custom fragment categories can be defined inside From ba4a19ff5b159bc8ffef4ae532dadb5971337b3e Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Mon, 7 Apr 2025 10:12:32 +1200 Subject: [PATCH 5/6] Fix docs reference to all_bullets --- docs/configuration.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index b465fc1a..1fff794c 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -318,11 +318,11 @@ Each table within this array has the following mandatory keys: ``true`` by default. -``nobullets`` +``all_bullets`` - A boolean value indicating whether this fragment type should be rendered without bullets. + A boolean value indicating whether this fragment type should be rendered without bullets, overriding the global ``all_bullets`` configuration. - ``false`` by default. + Matches the global ``all_bullets`` configuration by default. For example: From 554d92878960860e446508c02e00cac2e9174315 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Mon, 7 Apr 2025 10:36:53 +1200 Subject: [PATCH 6/6] Bikeshed some names --- docs/configuration.rst | 6 +++--- docs/customization/newsfile.rst | 8 ++++---- src/towncrier/_settings/fragment_types.py | 6 +++--- src/towncrier/newsfragments/+12a76291.feature.rst | 12 ------------ src/towncrier/newsfragments/+8deeacbc.feature.rst | 1 - src/towncrier/newsfragments/697.feature.2.rst | 12 ++++++++++++ src/towncrier/newsfragments/697.feature.rst | 3 +++ src/towncrier/test/test_settings.py | 4 ++-- 8 files changed, 27 insertions(+), 25 deletions(-) delete mode 100644 src/towncrier/newsfragments/+12a76291.feature.rst delete mode 100644 src/towncrier/newsfragments/+8deeacbc.feature.rst create mode 100644 src/towncrier/newsfragments/697.feature.2.rst create mode 100644 src/towncrier/newsfragments/697.feature.rst diff --git a/docs/configuration.rst b/docs/configuration.rst index 1fff794c..4e1f4019 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -342,7 +342,7 @@ For example: showcontent = true check = false -To insert the default types into your configuration, use the ``default_types = true`` key. +To insert the default types into your configuration, use the ``use_default_types = true`` key. In this special case, no other keys should be set for that item. For example: @@ -351,9 +351,9 @@ In this special case, no other keys should be set for that item. For example: [tool.towncrier] [[tool.towncrier.type]] name = "" - directory = "description" + directory = "highlight" all_bullets = true check = false [[tool.towncrier.type]] - default_types = true + use_default_types = true diff --git a/docs/customization/newsfile.rst b/docs/customization/newsfile.rst index 0ccf28c5..c600a172 100644 --- a/docs/customization/newsfile.rst +++ b/docs/customization/newsfile.rst @@ -50,17 +50,17 @@ Here's an example configuration to add a text-only section at the start of the n [[tool.towncrier.type]] name = "" - directory = "description" + directory = "highlight" all_bullets = false check = false [[tool.towncrier.type]] - default_types = true + use_default_types = true -Any fragments with a suffix of ``.description`` will be added to this description section. +Any fragments with a suffix of ``.highlight`` will be added to this highlight section which will be rendered as the first section after the release title (with no title of its own). The section has no visible name and no bullets, so the content of the fragments will be shown directly after the release title (or in the case of a more complex configuration with multiple sections, after the relevant section title). -The output of the above configuration with a ``+.description.rst`` fragment containing "Happy new year!" (and other ``.feature`` fragments) will look something like this: +The output of the above configuration with a ``+.highlight.rst`` fragment containing "Happy new year!" (and other ``.feature`` fragments) will look something like this: .. code-block:: rst diff --git a/src/towncrier/_settings/fragment_types.py b/src/towncrier/_settings/fragment_types.py index 3464244b..edbb848f 100644 --- a/src/towncrier/_settings/fragment_types.py +++ b/src/towncrier/_settings/fragment_types.py @@ -75,12 +75,12 @@ class ArrayFragmentTypesLoader(BaseFragmentTypesLoader): name = "Deprecations" showcontent = true - Use a type that only contains ``default_types = true`` to + Use a type that only contains ``use_default_types = true`` to insert the default fragment types:: ... [[tool.towncrier.type]] - default_types = true + use_default_types = true """ def load(self) -> Mapping[str, CategoryType]: @@ -89,7 +89,7 @@ def load(self) -> Mapping[str, CategoryType]: types: dict[str, CategoryType] = {} types_config = self.config["type"] for type_config in types_config: - if type_config == {"default_types": True}: + if type_config == {"use_default_types": True}: for type_name, type_options in self._default_types.items(): types[type_name] = type_options continue diff --git a/src/towncrier/newsfragments/+12a76291.feature.rst b/src/towncrier/newsfragments/+12a76291.feature.rst deleted file mode 100644 index d3dc9c17..00000000 --- a/src/towncrier/newsfragments/+12a76291.feature.rst +++ /dev/null @@ -1,12 +0,0 @@ -Adding a type to configuration with just ``default_types = true`` will insert the default types rather than needing to duplicate them. - -For example, to add a new ``description`` type first, but keep all of the other default types:: - - [[tool.towncrier.type]] - name = "" - directory = "description" - all_bullets = false - check = false - - [[tool.towncrier.type]] - default_types = true diff --git a/src/towncrier/newsfragments/+8deeacbc.feature.rst b/src/towncrier/newsfragments/+8deeacbc.feature.rst deleted file mode 100644 index 29230b4e..00000000 --- a/src/towncrier/newsfragments/+8deeacbc.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Categories can be marked as ``all_bullets`` so fragments in that specific category can override the global configuration. diff --git a/src/towncrier/newsfragments/697.feature.2.rst b/src/towncrier/newsfragments/697.feature.2.rst new file mode 100644 index 00000000..6a24d4b1 --- /dev/null +++ b/src/towncrier/newsfragments/697.feature.2.rst @@ -0,0 +1,12 @@ +Adding a type to configuration with just ``use_default_types = true`` will insert the default types rather than needing to duplicate them. + +For example, to add a new ``highlight`` type first, but keep all of the other default types:: + + [[tool.towncrier.type]] + name = "" + directory = "highlight" + all_bullets = false + check = false + + [[tool.towncrier.type]] + use_default_types = true diff --git a/src/towncrier/newsfragments/697.feature.rst b/src/towncrier/newsfragments/697.feature.rst new file mode 100644 index 00000000..76583131 --- /dev/null +++ b/src/towncrier/newsfragments/697.feature.rst @@ -0,0 +1,3 @@ +The default template now respects the ``all_bullets`` configuration setting. + +In addition, categories can be override the ``all_bullets`` setting so fragments in that specific category can override the global configuration. diff --git a/src/towncrier/test/test_settings.py b/src/towncrier/test/test_settings.py index 88ec33ca..81d4392c 100644 --- a/src/towncrier/test/test_settings.py +++ b/src/towncrier/test/test_settings.py @@ -409,7 +409,7 @@ def test_custom_types_as_tables_array_deprecated(self): def test_custom_types_array_with_defaults(self): """ Custom fragment types defined using an array of tables can include - the default types by specifying `default_types = true`. + the default types by specifying `use_default_types = true`. """ project_dir = self.mktemp_project( pyproject_toml=""" @@ -422,7 +422,7 @@ def test_custom_types_array_with_defaults(self): showcontent=false [[tool.towncrier.type]] - default_types = true + use_default_types = true [[tool.towncrier.type]] directory="another"