Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand All @@ -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
37 changes: 37 additions & 0 deletions docs/customization/newsfile.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,40 @@ If your news file is in Markdown (e.g. ``NEWS.md``), use the following comment i
.. code-block:: html

<!-- towncrier release notes start -->


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...
17 changes: 7 additions & 10 deletions src/towncrier/_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
75 changes: 48 additions & 27 deletions src/towncrier/_settings/fragment_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,40 @@

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:
"""Base class to load fragment types."""

__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", {})
Expand All @@ -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

Expand All @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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
22 changes: 16 additions & 6 deletions src/towncrier/_settings/load.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 = ""
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)

Expand Down
12 changes: 12 additions & 0 deletions src/towncrier/newsfragments/697.feature.2.rst
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions src/towncrier/newsfragments/697.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion src/towncrier/templates/default.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}

Expand Down
4 changes: 3 additions & 1 deletion src/towncrier/templates/default.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}

Expand Down
Loading