diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index dca09da6..8c9bbeb6 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -7,11 +7,21 @@ import os import re import textwrap +import unicodedata from collections import defaultdict from fnmatch import fnmatch from pathlib import Path -from typing import Any, DefaultDict, Iterable, Iterator, Mapping, NamedTuple, Sequence +from typing import ( + Any, + DefaultDict, + Iterable, + Iterator, + Mapping, + NamedTuple, + Sequence, + TypeAlias, +) from click import ClickException from jinja2 import Template @@ -19,6 +29,9 @@ from towncrier._settings.load import Config +UnderlineLengthType: TypeAlias = float | str | int | dict[Any, Any] | list[Any] + + # Returns issue, category and counter or (None, None, None) if the basename # could not be parsed or doesn't contain a valid category. def parse_newfragment_basename( @@ -203,6 +216,49 @@ def find_fragments( return content, fragment_files +def get_dict_length(obj: dict[UnderlineLengthType, UnderlineLengthType]) -> int: + """ + Gets the sum of the underline lengths for all keys and values in a dictionary. + """ + return sum( + get_underline_length(key) + get_underline_length(value) + for key, value in obj.items() + ) + + +def get_list_length(obj: list[UnderlineLengthType]) -> int: + """ + Gets the sum of the underline lengths for all items in a list. + """ + return sum(get_underline_length(item) for item in obj) + + +def get_string_length(text: str) -> int: + """ + Determines the amount of characters needed to underline a string. + """ + return sum( + 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1 for char in text + ) + + +def get_underline_length(obj: UnderlineLengthType) -> int: + """ + Given `obj` determine the underline length needed for the reStructuredText output. + + Particularly helps determine if an extra underline is needed for wide characters like emojis. + """ + if isinstance(obj, dict): + return get_dict_length(obj) + elif isinstance(obj, list): + return get_list_length(obj) + elif isinstance(obj, str): + return get_string_length(obj) + elif isinstance(obj, int) or isinstance(obj, float): + return len(str(obj)) + raise TypeError("Object must be a string, int, float, list, or dictionary.") + + def indent(text: str, prefix: str) -> str: """ Adds `prefix` to the beginning of non-empty lines in `text`. @@ -411,6 +467,7 @@ def get_indent(text: str) -> str: top_underline=top_underline, get_indent=get_indent, # simplify indentation in the jinja template. issues_by_category=issues_by_category, + get_underline_length=get_underline_length, # helps determine length for non-ascii chars ) for line in res.split("\n"): diff --git a/src/towncrier/build.py b/src/towncrier/build.py index 3d183925..d6eed7fd 100644 --- a/src/towncrier/build.py +++ b/src/towncrier/build.py @@ -19,7 +19,12 @@ from towncrier import _git -from ._builder import find_fragments, render_fragments, split_fragments +from ._builder import ( + find_fragments, + get_underline_length, + render_fragments, + split_fragments, +) from ._project import get_project_name, get_version from ._settings import ConfigError, config_option_help, load_config_from_options from ._writer import append_to_newsfile @@ -234,7 +239,7 @@ def __main( if is_markdown: parts = [top_line] else: - parts = [top_line, config.underlines[0] * len(top_line)] + parts = [top_line, config.underlines[0] * get_underline_length(top_line)] parts.append(rendered) content = "\n".join(parts) else: diff --git a/src/towncrier/newsfragments/626.bugfix.rst b/src/towncrier/newsfragments/626.bugfix.rst new file mode 100644 index 00000000..080af58e --- /dev/null +++ b/src/towncrier/newsfragments/626.bugfix.rst @@ -0,0 +1,2 @@ +For reStructuredText format, you can now use emojis in category names. +In previous versions, the generated underline was not matching the title width. diff --git a/src/towncrier/templates/default.rst b/src/towncrier/templates/default.rst index bee15720..9b196e79 100644 --- a/src/towncrier/templates/default.rst +++ b/src/towncrier/templates/default.rst @@ -1,29 +1,29 @@ {% if render_title %} {% if versiondata.name %} {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) -{{ top_underline * ((versiondata.name + versiondata.version + versiondata.date)|length + 4)}} +{{ top_underline * (get_underline_length(versiondata.name + versiondata.version + versiondata.date) + 4)}} {% else %} {{ versiondata.version }} ({{ versiondata.date }}) -{{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} +{{ top_underline * (get_underline_length(versiondata.version + versiondata.date) + 3)}} {% endif %} {% endif %} {% for section, _ in sections.items() %} {% set underline = underlines[0] %}{% if section %}{{section}} -{{ underline * section|length }}{% set underline = underlines[1] %} +{{ underline * get_underline_length(section) }}{% set underline = underlines[1] %} {% endif %} {% if sections[section] %} {% for category, val in definitions.items() if category in sections[section]%} {{ definitions[category]['name'] }} -{{ underline * definitions[category]['name']|length }} +{{ underline * get_underline_length(definitions[category]['name']) }} {% for text, values in sections[section][category].items() %} - {% if text %}{{ text }}{% if values %} ({{ values|join(', ') }}){% endif %}{% else %}{{ values|join(', ') }}{% endif %} {% endfor %} -{% if sections[section][category]|length == 0 %} +{% if get_underline_length(sections[section][category]) == 0 %} No significant changes. {% else %} diff --git a/src/towncrier/templates/hr-between-versions.rst b/src/towncrier/templates/hr-between-versions.rst index 455e571d..675196e0 100644 --- a/src/towncrier/templates/hr-between-versions.rst +++ b/src/towncrier/templates/hr-between-versions.rst @@ -1,22 +1,22 @@ {% if render_title %} {% if versiondata.name %} {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) -{{ top_underline * ((versiondata.name + versiondata.version + versiondata.date)|length + 4)}} +{{ top_underline * (get_underline_length(versiondata.name + versiondata.version + versiondata.date) + 4)}} {% else %} {{ versiondata.version }} ({{ versiondata.date }}) -{{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} +{{ top_underline * (get_underline_length(versiondata.version + versiondata.date) + 3)}} {% endif %} {% endif %} {% for section, _ in sections.items() %} {% set underline = underlines[0] %}{% if section %}{{section}} -{{ underline * section|length }}{% set underline = underlines[1] %} +{{ underline * get_underline_length(section) }}{% set underline = underlines[1] %} {% endif %} {% if sections[section] %} {% for category, val in definitions.items() if category in sections[section]%} {{ definitions[category]['name'] }} -{{ underline * definitions[category]['name']|length }} +{{ underline * get_underline_length(definitions[category]['name']) }} {% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category].items() %} @@ -28,7 +28,7 @@ - {{ sections[section][category]['']|join(', ') }} {% endif %} -{% if sections[section][category]|length == 0 %} +{% if get_underline_length(sections[section][category]) == 0 %} No significant changes. {% else %} diff --git a/src/towncrier/templates/single-file-no-bullets.rst b/src/towncrier/templates/single-file-no-bullets.rst index 5b83c515..7fbc34d0 100644 --- a/src/towncrier/templates/single-file-no-bullets.rst +++ b/src/towncrier/templates/single-file-no-bullets.rst @@ -1,22 +1,22 @@ {% if render_title %} {% if versiondata.name %} {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) -{{ top_underline * ((versiondata.name + versiondata.version + versiondata.date)|length + 4)}} +{{ top_underline * (get_underline_length(versiondata.name + versiondata.version + versiondata.date) + 4)}} {% else %} {{ versiondata.version }} ({{ versiondata.date }}) -{{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} +{{ top_underline * (get_underline_length(versiondata.version + versiondata.date) + 3)}} {% endif %} {% endif %} {% for section, _ in sections.items() %} {% set underline = underlines[0] %}{% if section %}{{section}} -{{ underline * section|length }}{% set underline = underlines[1] %} +{{ underline * get_underline_length(section) }}{% set underline = underlines[1] %} {% endif %} {% if sections[section] %} {% for category, val in definitions.items() if category in sections[section] %} {{ definitions[category]['name'] }} -{{ underline * definitions[category]['name']|length }} +{{ underline * get_underline_length(definitions[category]['name']) }} {% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category].items() %} @@ -28,7 +28,7 @@ - {{ sections[section][category]['']|join(', ') }} {% endif %} -{% if sections[section][category]|length == 0 %} +{% if get_underline_length(sections[section][category]) == 0 %} No significant changes. {% else %} diff --git a/src/towncrier/test/helpers.py b/src/towncrier/test/helpers.py index 77cea36d..b3727a1f 100644 --- a/src/towncrier/test/helpers.py +++ b/src/towncrier/test/helpers.py @@ -29,7 +29,7 @@ def write(path: str | Path, contents: str, dedent: bool = False) -> None: p.parent.mkdir(parents=True, exist_ok=True) if dedent: contents = textwrap.dedent(contents) - p.write_text(contents) + p.write_text(contents, encoding="utf-8") def read_pkg_resource(path: str) -> str: @@ -65,9 +65,9 @@ def setup_simple_project( config = "[tool.towncrier]\n" 'package = "foo"\n' + extra_config else: config = textwrap.dedent(config) - Path(pyproject_path).write_text(config) + Path(pyproject_path).write_text(config, encoding="utf-8") Path("foo").mkdir() - Path("foo/__init__.py").write_text('__version__ = "1.2.3"\n') + Path("foo/__init__.py").write_text('__version__ = "1.2.3"\n', encoding="utf-8") if mkdir_newsfragments: Path("foo/newsfragments").mkdir() diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index 0092d495..35df3fad 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -1398,6 +1398,62 @@ def test_default_start_string_markdown(self, runner): self.assertEqual(expected_output, output) + @with_project( + config=""" + [tool.towncrier] + name = "🍫 FooBar" + + [[tool.towncrier.type]] + directory = "bugfix" + name = "🐛 Bugfixes" + showcontent = true + """ + ) + def test_underline_length_unicode(self, runner): + """ + The news file can be generated for project names, + and categories that contains emoji in their names. + + This is a test for generating the RST section + underline to meet the docutils requirements. + """ + os.mkdir("newsfragments") + with open("newsfragments/321.bugfix", "w") as f: + f.write("Squashed a bug") + + result = runner.invoke( + _main, + [ + "--version=7.8.9", + "--date=01-01-2001", + "--draft", + ], + ) + + expected_output = dedent( + """\ + Loading template... + Finding news fragments... + Rendering news fragments... + Draft only -- nothing has been written. + What is seen below is what would be written. + + 🍫 FooBar 7.8.9 (01-01-2001) + ============================ + + 🐛 Bugfixes + ----------- + + - Squashed a bug (#321) + + + + """ + ) + + self.assertEqual(0, result.exit_code, result.output) + self.assertEqual(expected_output, result.output) + @with_project( config=""" [tool.towncrier] diff --git a/src/towncrier/test/test_builder.py b/src/towncrier/test/test_builder.py index 31108fdf..63bac4fd 100644 --- a/src/towncrier/test/test_builder.py +++ b/src/towncrier/test/test_builder.py @@ -5,7 +5,11 @@ from twisted.trial.unittest import TestCase -from .._builder import parse_newfragment_basename, render_fragments +from .._builder import ( + get_underline_length, + parse_newfragment_basename, + render_fragments, +) class TestParseNewsfragmentBasename(TestCase): @@ -41,6 +45,34 @@ def test_counter_with_extension(self): ("123", "feature", 1), ) + def test_get_underline_length_ascii(self): + """Determine underline size for normal ASCII strings.""" + assert get_underline_length("bugfixes") == 8 + + def test_get_underline_length_wide_character(self): + """Determine underline size for strings with wide characters.""" + assert get_underline_length("🐛 Bugfixes") == 11 + + def test_get_underline_length_list_of_lists(self): + """Determine underline size for lists.""" + assert get_underline_length([["a", "b"], ["c", "d"]]) == 4 + + def test_get_underline_length_dict_of_dicts(self): + """Determine underline size for dictionaries.""" + assert get_underline_length({"a": {"b": "c"}, "d": {"e": "f"}}) == 6 + + def test_get_underline_length_int(self): + """Determine underline size for integers.""" + assert get_underline_length(123) == 3 + + def test_get_underline_length_float(self): + """Determine underline size for floats.""" + assert get_underline_length(123.5) == 5 + + def test_get_underline_length_wrong_type(self): + """Determine underline size for wrong type.""" + self.assertRaises(TypeError, get_underline_length, None) + def test_ignores_extension(self): """File extensions are ignored.""" self.assertEqual(