diff --git a/docs/configuration.rst b/docs/configuration.rst index 053daad1..4e1f4019 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,16 @@ Each table within this array has the following mandatory 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: .. code-block:: toml - [tool.towncrier] [[tool.towncrier.type]] name = "Deprecations" @@ -330,3 +341,19 @@ For example: name = "Dependency Changes" showcontent = true check = false + +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: + +.. code-block:: toml + + [tool.towncrier] + [[tool.towncrier.type]] + name = "" + directory = "highlight" + all_bullets = true + check = false + + [[tool.towncrier.type]] + use_default_types = true diff --git a/docs/customization/newsfile.rst b/docs/customization/newsfile.rst index 03b97a3a..c600a172 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 = "highlight" + all_bullets = false + check = false + + [[tool.towncrier.type]] + use_default_types = true + +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 ``+.highlight.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... 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..edbb848f 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,28 @@ class BaseFragmentTypesLoader: __metaclass__ = abc.ABCMeta - def __init__(self, config: Mapping[str, Any]): + _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 + 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,31 +44,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 = { - # 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, Mapping[str, Any]]: + def load(self) -> Mapping[str, CategoryType]: """Load default types.""" return self._default_types @@ -68,14 +75,24 @@ class ArrayFragmentTypesLoader(BaseFragmentTypesLoader): name = "Deprecations" showcontent = true + Use a type that only contains ``use_default_types = true`` to + insert the default fragment types:: + + ... + [[tool.towncrier.type]] + use_default_types = true """ - 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: + if type_config == {"use_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) @@ -84,6 +101,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 +133,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 +149,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/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/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_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 f46db2b2..81d4392c 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: @@ -370,6 +372,7 @@ def test_custom_types_as_tables_array_deprecated(self): """ ) config = load_config(project_dir) + assert config expected = [ ( "foo", @@ -377,6 +380,7 @@ def test_custom_types_as_tables_array_deprecated(self): "name": "Foo", "showcontent": False, "check": True, + "all_bullets": True, }, ), ( @@ -385,6 +389,7 @@ def test_custom_types_as_tables_array_deprecated(self): "name": "Spam", "showcontent": True, "check": True, + "all_bullets": True, }, ), ( @@ -393,6 +398,7 @@ def test_custom_types_as_tables_array_deprecated(self): "name": "Automatic", "showcontent": True, "check": False, + "all_bullets": True, }, ), ] @@ -400,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 `use_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]] + use_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 @@ -421,26 +529,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