Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add utility function to calculate underline size for category names #645

Open
wants to merge 13 commits into
base: trunk
Choose a base branch
from
59 changes: 58 additions & 1 deletion src/towncrier/_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,31 @@
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

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(
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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"):
Expand Down
9 changes: 7 additions & 2 deletions src/towncrier/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/towncrier/newsfragments/626.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 5 additions & 5 deletions src/towncrier/templates/default.rst
Original file line number Diff line number Diff line change
@@ -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 %}
Expand Down
10 changes: 5 additions & 5 deletions src/towncrier/templates/hr-between-versions.rst
Original file line number Diff line number Diff line change
@@ -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)}}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One idea is a function like this

-{{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }})
-{{ top_underline * (get_underline_length(versiondata.name + versiondata.version + versiondata.date) + 4)}}
+{{ top_section(versiondata.name, versiondata.version) }}

this function will join the arguments with a single space, and will generate 2 lines.

This looks to me less error prone

just an idea

{% 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() %}
Expand All @@ -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 %}
Expand Down
10 changes: 5 additions & 5 deletions src/towncrier/templates/single-file-no-bullets.rst
Original file line number Diff line number Diff line change
@@ -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() %}
Expand All @@ -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 %}
Expand Down
6 changes: 3 additions & 3 deletions src/towncrier/test/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
56 changes: 56 additions & 0 deletions src/towncrier/test/test_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
34 changes: 33 additions & 1 deletion src/towncrier/test/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand Down
Loading