Refactor template engine: Extract string functions into StringExtension (#152420)

This commit is contained in:
Franck Nijhof
2025-09-16 12:15:43 +02:00
committed by GitHub
parent 0f372f4b47
commit ca6289a576
5 changed files with 232 additions and 86 deletions

View File

@@ -31,7 +31,6 @@ from typing import (
cast,
overload,
)
from urllib.parse import urlencode as urllib_urlencode
import weakref
from awesomeversion import AwesomeVersion
@@ -82,12 +81,7 @@ from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.translation import async_translate_state
from homeassistant.helpers.typing import TemplateVarsType
from homeassistant.loader import bind_hass
from homeassistant.util import (
convert,
dt as dt_util,
location as location_util,
slugify as slugify_util,
)
from homeassistant.util import convert, dt as dt_util, location as location_util
from homeassistant.util.async_ import run_callback_threadsafe
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
@@ -2327,16 +2321,6 @@ def from_hex(value: str) -> bytes:
return bytes.fromhex(value)
def ordinal(value):
"""Perform ordinal conversion."""
suffixes = ["th", "st", "nd", "rd"] + ["th"] * 6 # codespell:ignore nd
return str(value) + (
suffixes[(int(str(value)[-1])) % 10]
if int(str(value)[-2:]) % 100 not in range(11, 14)
else "th"
)
def from_json(value, default=_SENTINEL):
"""Convert a JSON string to an object."""
try:
@@ -2483,16 +2467,6 @@ def time_until(hass: HomeAssistant, value: Any | datetime, precision: int = 1) -
return dt_util.get_time_remaining(value, precision)
def urlencode(value):
"""Urlencode dictionary and return as UTF-8 string."""
return urllib_urlencode(value).encode("utf-8")
def slugify(value, separator="_"):
"""Convert a string into a slug, such as what is used for entity ids."""
return slugify_util(value, separator=separator)
def iif(
value: Any, if_true: Any = True, if_false: Any = False, if_none: Any = _SENTINEL
) -> Any:
@@ -2789,6 +2763,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.add_extension("homeassistant.helpers.template.extensions.CryptoExtension")
self.add_extension("homeassistant.helpers.template.extensions.MathExtension")
self.add_extension("homeassistant.helpers.template.extensions.RegexExtension")
self.add_extension("homeassistant.helpers.template.extensions.StringExtension")
self.globals["as_datetime"] = as_datetime
self.globals["as_function"] = as_function
@@ -2808,7 +2783,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.globals["pack"] = struct_pack
self.globals["set"] = _to_set
self.globals["shuffle"] = shuffle
self.globals["slugify"] = slugify
self.globals["strptime"] = strptime
self.globals["symmetric_difference"] = symmetric_difference
self.globals["timedelta"] = timedelta
@@ -2816,7 +2790,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.globals["typeof"] = typeof
self.globals["union"] = union
self.globals["unpack"] = struct_unpack
self.globals["urlencode"] = urlencode
self.globals["version"] = version
self.globals["zip"] = zip
@@ -2842,12 +2815,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.filters["is_number"] = is_number
self.filters["multiply"] = multiply
self.filters["ord"] = ord
self.filters["ordinal"] = ordinal
self.filters["pack"] = struct_pack
self.filters["random"] = random_every_time
self.filters["round"] = forgiving_round
self.filters["shuffle"] = shuffle
self.filters["slugify"] = slugify
self.filters["symmetric_difference"] = symmetric_difference
self.filters["timestamp_custom"] = timestamp_custom
self.filters["timestamp_local"] = timestamp_local

View File

@@ -4,5 +4,12 @@ from .base64 import Base64Extension
from .crypto import CryptoExtension
from .math import MathExtension
from .regex import RegexExtension
from .string import StringExtension
__all__ = ["Base64Extension", "CryptoExtension", "MathExtension", "RegexExtension"]
__all__ = [
"Base64Extension",
"CryptoExtension",
"MathExtension",
"RegexExtension",
"StringExtension",
]

View File

@@ -0,0 +1,58 @@
"""Jinja2 extension for string processing functions."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode as urllib_urlencode
from homeassistant.util import slugify as slugify_util
from .base import BaseTemplateExtension, TemplateFunction
if TYPE_CHECKING:
from homeassistant.helpers.template import TemplateEnvironment
class StringExtension(BaseTemplateExtension):
"""Jinja2 extension for string processing functions."""
def __init__(self, environment: TemplateEnvironment) -> None:
"""Initialize the string extension."""
super().__init__(
environment,
functions=[
TemplateFunction(
"ordinal",
self.ordinal,
as_filter=True,
),
TemplateFunction(
"slugify",
self.slugify,
as_global=True,
as_filter=True,
),
TemplateFunction(
"urlencode",
self.urlencode,
as_global=True,
),
],
)
def ordinal(self, value: Any) -> str:
"""Perform ordinal conversion."""
suffixes = ["th", "st", "nd", "rd"] + ["th"] * 6 # codespell:ignore nd
return str(value) + (
suffixes[(int(str(value)[-1])) % 10]
if int(str(value)[-2:]) % 100 not in range(11, 14)
else "th"
)
def slugify(self, value: Any, separator: str = "_") -> str:
"""Convert a string into a slug, such as what is used for entity ids."""
return slugify_util(str(value), separator=separator)
def urlencode(self, value: Any) -> bytes:
"""Urlencode dictionary and return as UTF-8 string."""
return urllib_urlencode(value).encode("utf-8")

View File

@@ -0,0 +1,164 @@
"""Test string template extension."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import template
def test_ordinal(hass: HomeAssistant) -> None:
"""Test the ordinal filter."""
tests = [
(1, "1st"),
(2, "2nd"),
(3, "3rd"),
(4, "4th"),
(5, "5th"),
(12, "12th"),
(100, "100th"),
(101, "101st"),
]
for value, expected in tests:
assert (
template.Template(f"{{{{ {value} | ordinal }}}}", hass).async_render()
== expected
)
def test_slugify(hass: HomeAssistant) -> None:
"""Test the slugify filter."""
# Test as global function
assert (
template.Template('{{ slugify("Home Assistant") }}', hass).async_render()
== "home_assistant"
)
# Test as filter
assert (
template.Template('{{ "Home Assistant" | slugify }}', hass).async_render()
== "home_assistant"
)
# Test with custom separator as global
assert (
template.Template('{{ slugify("Home Assistant", "-") }}', hass).async_render()
== "home-assistant"
)
# Test with custom separator as filter
assert (
template.Template('{{ "Home Assistant" | slugify("-") }}', hass).async_render()
== "home-assistant"
)
def test_urlencode(hass: HomeAssistant) -> None:
"""Test the urlencode method."""
# Test with dictionary
tpl = template.Template(
"{% set dict = {'foo': 'x&y', 'bar': 42} %}{{ dict | urlencode }}",
hass,
)
assert tpl.async_render() == "foo=x%26y&bar=42"
# Test with string
tpl = template.Template(
"{% set string = 'the quick brown fox = true' %}{{ string | urlencode }}",
hass,
)
assert tpl.async_render() == "the%20quick%20brown%20fox%20%3D%20true"
def test_string_functions_with_non_string_input(hass: HomeAssistant) -> None:
"""Test string functions with non-string input (automatic conversion)."""
# Test ordinal with integer
assert template.Template("{{ 42 | ordinal }}", hass).async_render() == "42nd"
# Test slugify with integer - Note: Jinja2 may return integer for simple cases
result = template.Template("{{ 123 | slugify }}", hass).async_render()
# Accept either string or integer result for simple numeric cases
assert result in ["123", 123]
def test_ordinal_edge_cases(hass: HomeAssistant) -> None:
"""Test ordinal function with edge cases."""
# Test teens (11th, 12th, 13th should all be 'th')
teens_tests = [
(11, "11th"),
(12, "12th"),
(13, "13th"),
(111, "111th"),
(112, "112th"),
(113, "113th"),
]
for value, expected in teens_tests:
assert (
template.Template(f"{{{{ {value} | ordinal }}}}", hass).async_render()
== expected
)
# Test other numbers ending in 1, 2, 3
other_tests = [
(21, "21st"),
(22, "22nd"),
(23, "23rd"),
(121, "121st"),
(122, "122nd"),
(123, "123rd"),
]
for value, expected in other_tests:
assert (
template.Template(f"{{{{ {value} | ordinal }}}}", hass).async_render()
== expected
)
def test_slugify_various_separators(hass: HomeAssistant) -> None:
"""Test slugify with various separators."""
test_cases = [
("Hello World", "_", "hello_world"),
("Hello World", "-", "hello-world"),
("Hello World", ".", "hello.world"),
("Hello-World_Test", "~", "hello~world~test"),
]
for text, separator, expected in test_cases:
# Test as global function
assert (
template.Template(
f'{{{{ slugify("{text}", "{separator}") }}}}', hass
).async_render()
== expected
)
# Test as filter
assert (
template.Template(
f'{{{{ "{text}" | slugify("{separator}") }}}}', hass
).async_render()
== expected
)
def test_urlencode_various_types(hass: HomeAssistant) -> None:
"""Test urlencode with various data types."""
# Test with nested dictionary values
tpl = template.Template(
"{% set data = {'key': 'value with spaces', 'num': 123} %}{{ data | urlencode }}",
hass,
)
result = tpl.async_render()
# URL encoding can have different order, so check both parts are present
# Note: urllib.parse.urlencode uses + for spaces in form data
assert "key=value+with+spaces" in result
assert "num=123" in result
# Test with special characters
tpl = template.Template(
"{% set data = {'special': 'a+b=c&d'} %}{{ data | urlencode }}",
hass,
)
assert tpl.async_render() == "special=a%2Bb%3Dc%26d"

View File

@@ -1202,46 +1202,6 @@ def test_from_hex(hass: HomeAssistant) -> None:
)
def test_slugify(hass: HomeAssistant) -> None:
"""Test the slugify filter."""
assert (
template.Template('{{ slugify("Home Assistant") }}', hass).async_render()
== "home_assistant"
)
assert (
template.Template('{{ "Home Assistant" | slugify }}', hass).async_render()
== "home_assistant"
)
assert (
template.Template('{{ slugify("Home Assistant", "-") }}', hass).async_render()
== "home-assistant"
)
assert (
template.Template('{{ "Home Assistant" | slugify("-") }}', hass).async_render()
== "home-assistant"
)
def test_ordinal(hass: HomeAssistant) -> None:
"""Test the ordinal filter."""
tests = [
(1, "1st"),
(2, "2nd"),
(3, "3rd"),
(4, "4th"),
(5, "5th"),
(12, "12th"),
(100, "100th"),
(101, "101st"),
]
for value, expected in tests:
assert (
template.Template(f"{{{{ {value} | ordinal }}}}", hass).async_render()
== expected
)
def test_timestamp_utc(hass: HomeAssistant) -> None:
"""Test the timestamps to local filter."""
now = dt_util.utcnow()
@@ -4495,20 +4455,6 @@ def test_render_complex_handling_non_template_values(hass: HomeAssistant) -> Non
) == {True: 1, False: 2}
def test_urlencode(hass: HomeAssistant) -> None:
"""Test the urlencode method."""
tpl = template.Template(
"{% set dict = {'foo': 'x&y', 'bar': 42} %}{{ dict | urlencode }}",
hass,
)
assert tpl.async_render() == "foo=x%26y&bar=42"
tpl = template.Template(
"{% set string = 'the quick brown fox = true' %}{{ string | urlencode }}",
hass,
)
assert tpl.async_render() == "the%20quick%20brown%20fox%20%3D%20true"
def test_as_timedelta(hass: HomeAssistant) -> None:
"""Test the as_timedelta function/filter."""
tpl = template.Template("{{ as_timedelta('PT10M') }}", hass)