From b783355c1002923ebe2990c6a78a009b504e6383 Mon Sep 17 00:00:00 2001 From: Jacob Gulan Date: Fri, 2 Aug 2024 16:33:49 -0400 Subject: [PATCH 01/13] add utility function to calculate underline size for category names --- src/towncrier/_builder.py | 22 +++++++++++++++++++ src/towncrier/newsfragments/626.bugfix.rst | 1 + src/towncrier/templates/default.rst | 2 +- .../templates/hr-between-versions.rst | 2 +- .../templates/single-file-no-bullets.rst | 2 +- src/towncrier/test/test_builder.py | 12 +++++++++- 6 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 src/towncrier/newsfragments/626.bugfix.rst diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index dca09da6..481cbe8c 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -7,6 +7,7 @@ import os import re import textwrap +import unicodedata from collections import defaultdict from fnmatch import fnmatch @@ -203,6 +204,22 @@ def find_fragments( return content, fragment_files +def get_underline_size(text: str) -> int: + """ + Given `text` determine the underline size needed for the reStructuredText output. + + Particularly helps determine if an extra underline is needed for wide characters like emojis. + """ + underline_size: int = 0 + for char in text: + if unicodedata.east_asian_width(char) in ("W", "F"): + underline_size += 2 + else: + underline_size += 1 + + return underline_size + + def indent(text: str, prefix: str) -> str: """ Adds `prefix` to the beginning of non-empty lines in `text`. @@ -246,6 +263,11 @@ def split_fragments( # it's recorded. content = "" + # Calculate the underline size for the name of the category. + definitions[category]["underline_size"] = get_underline_size( + definitions[category]["name"] + ) + texts = section.setdefault(category, {}) issues = texts.setdefault(content, []) diff --git a/src/towncrier/newsfragments/626.bugfix.rst b/src/towncrier/newsfragments/626.bugfix.rst new file mode 100644 index 00000000..e47790db --- /dev/null +++ b/src/towncrier/newsfragments/626.bugfix.rst @@ -0,0 +1 @@ +Fixed section names not having enough underline characters for emojis. diff --git a/src/towncrier/templates/default.rst b/src/towncrier/templates/default.rst index bee15720..1c19b333 100644 --- a/src/towncrier/templates/default.rst +++ b/src/towncrier/templates/default.rst @@ -16,7 +16,7 @@ {% if sections[section] %} {% for category, val in definitions.items() if category in sections[section]%} {{ definitions[category]['name'] }} -{{ underline * definitions[category]['name']|length }} +{{ underline * definitions[category]['underline_size'] }} {% for text, values in sections[section][category].items() %} - {% if text %}{{ text }}{% if values %} ({{ values|join(', ') }}){% endif %}{% else %}{{ values|join(', ') }}{% endif %} diff --git a/src/towncrier/templates/hr-between-versions.rst b/src/towncrier/templates/hr-between-versions.rst index 455e571d..dd484c63 100644 --- a/src/towncrier/templates/hr-between-versions.rst +++ b/src/towncrier/templates/hr-between-versions.rst @@ -16,7 +16,7 @@ {% if sections[section] %} {% for category, val in definitions.items() if category in sections[section]%} {{ definitions[category]['name'] }} -{{ underline * definitions[category]['name']|length }} +{{ underline * definitions[category]['underline_size'] }} {% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category].items() %} diff --git a/src/towncrier/templates/single-file-no-bullets.rst b/src/towncrier/templates/single-file-no-bullets.rst index 5b83c515..93a39a2b 100644 --- a/src/towncrier/templates/single-file-no-bullets.rst +++ b/src/towncrier/templates/single-file-no-bullets.rst @@ -16,7 +16,7 @@ {% for category, val in definitions.items() if category in sections[section] %} {{ definitions[category]['name'] }} -{{ underline * definitions[category]['name']|length }} +{{ underline * definitions[category]['underline_size'] }} {% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category].items() %} diff --git a/src/towncrier/test/test_builder.py b/src/towncrier/test/test_builder.py index 31108fdf..9aaded60 100644 --- a/src/towncrier/test/test_builder.py +++ b/src/towncrier/test/test_builder.py @@ -5,7 +5,7 @@ from twisted.trial.unittest import TestCase -from .._builder import parse_newfragment_basename, render_fragments +from .._builder import get_underline_size, parse_newfragment_basename, render_fragments class TestParseNewsfragmentBasename(TestCase): @@ -208,3 +208,13 @@ def test_ordering(self): - Added Fish """ ) + + +class TestUtilityFunctions(TestCase): + def test_get_underline_size_ascii(self): + """Determine underline size for normal ASCII strings.""" + assert get_underline_size("bugfixes") == 8 + + def test_get_underline_size_wide_character(self): + """Determine underline size for strings with wide characters.""" + assert get_underline_size("🐛 Bugfixes") == 11 From 745dec58a03a0d9850b7c56fa61d9a13f20bdea1 Mon Sep 17 00:00:00 2001 From: Jacob Gulan Date: Wed, 7 Aug 2024 15:23:40 -0400 Subject: [PATCH 02/13] add integration test --- src/towncrier/test/test_build.py | 52 ++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index 0092d495..848733f0 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -1398,6 +1398,58 @@ 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_size_unicode(self, runner): + """ + TODO: Add better description + """ + 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] From 5fbfbfdb4c4348f4e5981b730643ab008ccdebca Mon Sep 17 00:00:00 2001 From: Jacob Gulan Date: Wed, 7 Aug 2024 15:26:58 -0400 Subject: [PATCH 03/13] update underline size calc for jinja templates --- src/towncrier/_builder.py | 2 +- src/towncrier/build.py | 9 +++++++-- src/towncrier/templates/default.rst | 2 +- src/towncrier/templates/hr-between-versions.rst | 2 +- src/towncrier/templates/single-file-no-bullets.rst | 2 +- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index 481cbe8c..ce1f10fd 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -263,7 +263,6 @@ def split_fragments( # it's recorded. content = "" - # Calculate the underline size for the name of the category. definitions[category]["underline_size"] = get_underline_size( definitions[category]["name"] ) @@ -433,6 +432,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, + versiondata_name_underline_size=get_underline_size(versiondata.get("name", "")), ) for line in res.split("\n"): diff --git a/src/towncrier/build.py b/src/towncrier/build.py index 3d183925..c9c1c8f1 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_size, + 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_size(top_line)] parts.append(rendered) content = "\n".join(parts) else: diff --git a/src/towncrier/templates/default.rst b/src/towncrier/templates/default.rst index 1c19b333..f7185ee5 100644 --- a/src/towncrier/templates/default.rst +++ b/src/towncrier/templates/default.rst @@ -1,7 +1,7 @@ {% if render_title %} {% if versiondata.name %} {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) -{{ top_underline * ((versiondata.name + versiondata.version + versiondata.date)|length + 4)}} +{{ top_underline * ((versiondata.version + versiondata.date)|length + 4 + versiondata_name_underline_size)}} {% else %} {{ versiondata.version }} ({{ versiondata.date }}) {{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} diff --git a/src/towncrier/templates/hr-between-versions.rst b/src/towncrier/templates/hr-between-versions.rst index dd484c63..085ad03b 100644 --- a/src/towncrier/templates/hr-between-versions.rst +++ b/src/towncrier/templates/hr-between-versions.rst @@ -1,7 +1,7 @@ {% if render_title %} {% if versiondata.name %} {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) -{{ top_underline * ((versiondata.name + versiondata.version + versiondata.date)|length + 4)}} +{{ top_underline * ((versiondata.version + versiondata.date)|length + 4 + versiondata_name_underline_size)}} {% else %} {{ versiondata.version }} ({{ versiondata.date }}) {{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} diff --git a/src/towncrier/templates/single-file-no-bullets.rst b/src/towncrier/templates/single-file-no-bullets.rst index 93a39a2b..75b7535e 100644 --- a/src/towncrier/templates/single-file-no-bullets.rst +++ b/src/towncrier/templates/single-file-no-bullets.rst @@ -1,7 +1,7 @@ {% if render_title %} {% if versiondata.name %} {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) -{{ top_underline * ((versiondata.name + versiondata.version + versiondata.date)|length + 4)}} +{{ top_underline * ((versiondata.version + versiondata.date)|length + 4 + versiondata_name_underline_size)}} {% else %} {{ versiondata.version }} ({{ versiondata.date }}) {{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} From c45ea093ea3bfa99e86ceeb76e1138c3fb067ecd Mon Sep 17 00:00:00 2001 From: Jacob Gulan Date: Wed, 7 Aug 2024 15:28:07 -0400 Subject: [PATCH 04/13] update bugfix newsfragment --- src/towncrier/newsfragments/626.bugfix.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/towncrier/newsfragments/626.bugfix.rst b/src/towncrier/newsfragments/626.bugfix.rst index e47790db..080af58e 100644 --- a/src/towncrier/newsfragments/626.bugfix.rst +++ b/src/towncrier/newsfragments/626.bugfix.rst @@ -1 +1,2 @@ -Fixed section names not having enough underline characters for emojis. +For reStructuredText format, you can now use emojis in category names. +In previous versions, the generated underline was not matching the title width. From 8aed19732712d363b5e1c57c0c5bb953f7abcbb4 Mon Sep 17 00:00:00 2001 From: Jacob Gulan Date: Wed, 7 Aug 2024 15:31:38 -0400 Subject: [PATCH 05/13] consolidate unit tests --- src/towncrier/test/test_builder.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/towncrier/test/test_builder.py b/src/towncrier/test/test_builder.py index 9aaded60..c53152f9 100644 --- a/src/towncrier/test/test_builder.py +++ b/src/towncrier/test/test_builder.py @@ -41,6 +41,14 @@ def test_counter_with_extension(self): ("123", "feature", 1), ) + def test_get_underline_size_ascii(self): + """Determine underline size for normal ASCII strings.""" + assert get_underline_size("bugfixes") == 8 + + def test_get_underline_size_wide_character(self): + """Determine underline size for strings with wide characters.""" + assert get_underline_size("🐛 Bugfixes") == 11 + def test_ignores_extension(self): """File extensions are ignored.""" self.assertEqual( @@ -208,13 +216,3 @@ def test_ordering(self): - Added Fish """ ) - - -class TestUtilityFunctions(TestCase): - def test_get_underline_size_ascii(self): - """Determine underline size for normal ASCII strings.""" - assert get_underline_size("bugfixes") == 8 - - def test_get_underline_size_wide_character(self): - """Determine underline size for strings with wide characters.""" - assert get_underline_size("🐛 Bugfixes") == 11 From eb60d898097b42a80cfc65f944b899317dfea290 Mon Sep 17 00:00:00 2001 From: Jacob Gulan Date: Wed, 7 Aug 2024 18:51:20 -0400 Subject: [PATCH 06/13] update docstring --- src/towncrier/test/test_build.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index 848733f0..b4461a09 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -1411,7 +1411,11 @@ def test_default_start_string_markdown(self, runner): ) def test_underline_size_unicode(self, runner): """ - TODO: Add better description + 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: From bdbfed4d1c7985f52cb80d0b69c8f14f1c29fef8 Mon Sep 17 00:00:00 2001 From: Jacob Gulan Date: Wed, 7 Aug 2024 19:11:20 -0400 Subject: [PATCH 07/13] update write_text() to use utf-8 encoding --- src/towncrier/test/helpers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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() From 473411c7831cb93c0215f3328a4f471f3df9d91b Mon Sep 17 00:00:00 2001 From: Jacob Gulan Date: Thu, 8 Aug 2024 08:55:29 -0400 Subject: [PATCH 08/13] rename underline_size to underline_length --- src/towncrier/_builder.py | 18 ++++++++++-------- src/towncrier/build.py | 4 ++-- src/towncrier/templates/default.rst | 4 ++-- .../templates/hr-between-versions.rst | 4 ++-- .../templates/single-file-no-bullets.rst | 4 ++-- src/towncrier/test/test_build.py | 2 +- src/towncrier/test/test_builder.py | 14 +++++++++----- 7 files changed, 28 insertions(+), 22 deletions(-) diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index ce1f10fd..7fc2a642 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -204,20 +204,20 @@ def find_fragments( return content, fragment_files -def get_underline_size(text: str) -> int: +def get_underline_length(text: str) -> int: """ - Given `text` determine the underline size needed for the reStructuredText output. + Given `text` determine the underline length needed for the reStructuredText output. Particularly helps determine if an extra underline is needed for wide characters like emojis. """ - underline_size: int = 0 + underline_length: int = 0 for char in text: if unicodedata.east_asian_width(char) in ("W", "F"): - underline_size += 2 + underline_length += 2 else: - underline_size += 1 + underline_length += 1 - return underline_size + return underline_length def indent(text: str, prefix: str) -> str: @@ -263,7 +263,7 @@ def split_fragments( # it's recorded. content = "" - definitions[category]["underline_size"] = get_underline_size( + definitions[category]["underline_length"] = get_underline_length( definitions[category]["name"] ) @@ -432,7 +432,9 @@ 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, - versiondata_name_underline_size=get_underline_size(versiondata.get("name", "")), + versiondata_name_underline_length=get_underline_length( + versiondata.get("name", "") + ), ) for line in res.split("\n"): diff --git a/src/towncrier/build.py b/src/towncrier/build.py index c9c1c8f1..d6eed7fd 100644 --- a/src/towncrier/build.py +++ b/src/towncrier/build.py @@ -21,7 +21,7 @@ from ._builder import ( find_fragments, - get_underline_size, + get_underline_length, render_fragments, split_fragments, ) @@ -239,7 +239,7 @@ def __main( if is_markdown: parts = [top_line] else: - parts = [top_line, config.underlines[0] * get_underline_size(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/templates/default.rst b/src/towncrier/templates/default.rst index f7185ee5..b40dfab1 100644 --- a/src/towncrier/templates/default.rst +++ b/src/towncrier/templates/default.rst @@ -1,7 +1,7 @@ {% if render_title %} {% if versiondata.name %} {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) -{{ top_underline * ((versiondata.version + versiondata.date)|length + 4 + versiondata_name_underline_size)}} +{{ top_underline * ((versiondata.version + versiondata.date)|length + 4 + versiondata_name_underline_length)}} {% else %} {{ versiondata.version }} ({{ versiondata.date }}) {{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} @@ -16,7 +16,7 @@ {% if sections[section] %} {% for category, val in definitions.items() if category in sections[section]%} {{ definitions[category]['name'] }} -{{ underline * definitions[category]['underline_size'] }} +{{ underline * definitions[category]['underline_length'] }} {% for text, values in sections[section][category].items() %} - {% if text %}{{ text }}{% if values %} ({{ values|join(', ') }}){% endif %}{% else %}{{ values|join(', ') }}{% endif %} diff --git a/src/towncrier/templates/hr-between-versions.rst b/src/towncrier/templates/hr-between-versions.rst index 085ad03b..df95e91c 100644 --- a/src/towncrier/templates/hr-between-versions.rst +++ b/src/towncrier/templates/hr-between-versions.rst @@ -1,7 +1,7 @@ {% if render_title %} {% if versiondata.name %} {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) -{{ top_underline * ((versiondata.version + versiondata.date)|length + 4 + versiondata_name_underline_size)}} +{{ top_underline * ((versiondata.version + versiondata.date)|length + 4 + versiondata_name_underline_length)}} {% else %} {{ versiondata.version }} ({{ versiondata.date }}) {{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} @@ -16,7 +16,7 @@ {% if sections[section] %} {% for category, val in definitions.items() if category in sections[section]%} {{ definitions[category]['name'] }} -{{ underline * definitions[category]['underline_size'] }} +{{ underline * definitions[category]['underline_length'] }} {% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category].items() %} diff --git a/src/towncrier/templates/single-file-no-bullets.rst b/src/towncrier/templates/single-file-no-bullets.rst index 75b7535e..83af1a78 100644 --- a/src/towncrier/templates/single-file-no-bullets.rst +++ b/src/towncrier/templates/single-file-no-bullets.rst @@ -1,7 +1,7 @@ {% if render_title %} {% if versiondata.name %} {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) -{{ top_underline * ((versiondata.version + versiondata.date)|length + 4 + versiondata_name_underline_size)}} +{{ top_underline * ((versiondata.version + versiondata.date)|length + 4 + versiondata_name_underline_length)}} {% else %} {{ versiondata.version }} ({{ versiondata.date }}) {{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} @@ -16,7 +16,7 @@ {% for category, val in definitions.items() if category in sections[section] %} {{ definitions[category]['name'] }} -{{ underline * definitions[category]['underline_size'] }} +{{ underline * definitions[category]['underline_length'] }} {% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category].items() %} diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py index b4461a09..35df3fad 100644 --- a/src/towncrier/test/test_build.py +++ b/src/towncrier/test/test_build.py @@ -1409,7 +1409,7 @@ def test_default_start_string_markdown(self, runner): showcontent = true """ ) - def test_underline_size_unicode(self, runner): + def test_underline_length_unicode(self, runner): """ The news file can be generated for project names, and categories that contains emoji in their names. diff --git a/src/towncrier/test/test_builder.py b/src/towncrier/test/test_builder.py index c53152f9..5e50ff15 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 get_underline_size, parse_newfragment_basename, render_fragments +from .._builder import ( + get_underline_length, + parse_newfragment_basename, + render_fragments, +) class TestParseNewsfragmentBasename(TestCase): @@ -41,13 +45,13 @@ def test_counter_with_extension(self): ("123", "feature", 1), ) - def test_get_underline_size_ascii(self): + def test_get_underline_length_ascii(self): """Determine underline size for normal ASCII strings.""" - assert get_underline_size("bugfixes") == 8 + assert get_underline_length("bugfixes") == 8 - def test_get_underline_size_wide_character(self): + def test_get_underline_length_wide_character(self): """Determine underline size for strings with wide characters.""" - assert get_underline_size("🐛 Bugfixes") == 11 + assert get_underline_length("🐛 Bugfixes") == 11 def test_ignores_extension(self): """File extensions are ignored.""" From f236c3103a08e68f3491307ad00e4638f3480a22 Mon Sep 17 00:00:00 2001 From: Jacob Gulan Date: Thu, 8 Aug 2024 10:23:26 -0400 Subject: [PATCH 09/13] pass get_underline_length() function to jinja template --- src/towncrier/_builder.py | 53 +++++++++++++------ src/towncrier/templates/default.rst | 10 ++-- .../templates/hr-between-versions.rst | 10 ++-- .../templates/single-file-no-bullets.rst | 10 ++-- 4 files changed, 51 insertions(+), 32 deletions(-) diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index 7fc2a642..71f68e57 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -204,20 +204,45 @@ def find_fragments( return content, fragment_files -def get_underline_length(text: str) -> int: +def get_dict_length(obj: dict) -> int: """ - Given `text` determine the underline length needed for the reStructuredText output. + 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() + ) - Particularly helps determine if an extra underline is needed for wide characters like emojis. + +def get_list_length(obj: list) -> int: """ - underline_length: int = 0 - for char in text: - if unicodedata.east_asian_width(char) in ("W", "F"): - underline_length += 2 - else: - underline_length += 1 + 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 + ) - return underline_length + +def get_underline_length(obj: str | dict | list) -> 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) + raise ValueError("Object must be a string, list, or dictionary.") def indent(text: str, prefix: str) -> str: @@ -263,10 +288,6 @@ def split_fragments( # it's recorded. content = "" - definitions[category]["underline_length"] = get_underline_length( - definitions[category]["name"] - ) - texts = section.setdefault(category, {}) issues = texts.setdefault(content, []) @@ -432,9 +453,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, - versiondata_name_underline_length=get_underline_length( - versiondata.get("name", "") - ), + get_underline_length=get_underline_length, # helps determine length for non-ascii chars ) for line in res.split("\n"): diff --git a/src/towncrier/templates/default.rst b/src/towncrier/templates/default.rst index b40dfab1..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.version + versiondata.date)|length + 4 + versiondata_name_underline_length)}} +{{ 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]['underline_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 df95e91c..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.version + versiondata.date)|length + 4 + versiondata_name_underline_length)}} +{{ 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]['underline_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 83af1a78..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.version + versiondata.date)|length + 4 + versiondata_name_underline_length)}} +{{ 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]['underline_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 %} From 35052ab0fd6f441ddeb96952df976030dd9f9e1e Mon Sep 17 00:00:00 2001 From: Jacob Gulan Date: Thu, 8 Aug 2024 10:41:35 -0400 Subject: [PATCH 10/13] add more unit tests --- src/towncrier/test/test_builder.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/towncrier/test/test_builder.py b/src/towncrier/test/test_builder.py index 5e50ff15..63bac4fd 100644 --- a/src/towncrier/test/test_builder.py +++ b/src/towncrier/test/test_builder.py @@ -53,6 +53,26 @@ 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( From 185bd7a408db356165a6c271816242609be9ecb3 Mon Sep 17 00:00:00 2001 From: Jacob Gulan Date: Thu, 8 Aug 2024 10:43:50 -0400 Subject: [PATCH 11/13] appease mypy generic types --- src/towncrier/_builder.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index 71f68e57..6d8f5f22 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -20,6 +20,9 @@ from towncrier._settings.load import Config +UnderlineLengthType = float | str | int | dict[str, 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( @@ -204,7 +207,7 @@ def find_fragments( return content, fragment_files -def get_dict_length(obj: dict) -> int: +def get_dict_length(obj: dict[str, Any]) -> int: """ Gets the sum of the underline lengths for all keys and values in a dictionary. """ @@ -214,7 +217,7 @@ def get_dict_length(obj: dict) -> int: ) -def get_list_length(obj: list) -> int: +def get_list_length(obj: list[Any]) -> int: """ Gets the sum of the underline lengths for all items in a list. """ @@ -230,7 +233,7 @@ def get_string_length(text: str) -> int: ) -def get_underline_length(obj: str | dict | list) -> int: +def get_underline_length(obj: UnderlineLengthType) -> int: """ Given `obj` determine the underline length needed for the reStructuredText output. @@ -242,7 +245,9 @@ def get_underline_length(obj: str | dict | list) -> int: return get_list_length(obj) elif isinstance(obj, str): return get_string_length(obj) - raise ValueError("Object must be a string, list, or dictionary.") + 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: From 24b3cdb489fd9a0e5c0f332d4629f869c0cc1df1 Mon Sep 17 00:00:00 2001 From: Jacob Gulan Date: Thu, 8 Aug 2024 10:45:06 -0400 Subject: [PATCH 12/13] update typing for underline length --- src/towncrier/_builder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index 6d8f5f22..e31d128f 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -20,7 +20,7 @@ from towncrier._settings.load import Config -UnderlineLengthType = float | str | int | dict[str, Any] | list[Any] +UnderlineLengthType = float | str | int | dict[Any, Any] | list[Any] # Returns issue, category and counter or (None, None, None) if the basename @@ -207,7 +207,7 @@ def find_fragments( return content, fragment_files -def get_dict_length(obj: dict[str, Any]) -> int: +def get_dict_length(obj: dict[UnderlineLengthType, UnderlineLengthType]) -> int: """ Gets the sum of the underline lengths for all keys and values in a dictionary. """ @@ -217,7 +217,7 @@ def get_dict_length(obj: dict[str, Any]) -> int: ) -def get_list_length(obj: list[Any]) -> int: +def get_list_length(obj: list[UnderlineLengthType]) -> int: """ Gets the sum of the underline lengths for all items in a list. """ From fc414ed72497ff364387e40bff62ca67cbf20a7e Mon Sep 17 00:00:00 2001 From: Jacob Gulan Date: Thu, 8 Aug 2024 12:25:41 -0400 Subject: [PATCH 13/13] add TypeAlias --- src/towncrier/_builder.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py index e31d128f..8c9bbeb6 100644 --- a/src/towncrier/_builder.py +++ b/src/towncrier/_builder.py @@ -12,7 +12,16 @@ 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 @@ -20,7 +29,7 @@ from towncrier._settings.load import Config -UnderlineLengthType = float | str | int | dict[Any, Any] | list[Any] +UnderlineLengthType: TypeAlias = float | str | int | dict[Any, Any] | list[Any] # Returns issue, category and counter or (None, None, None) if the basename