mirror of
https://github.com/home-assistant/core.git
synced 2026-04-20 08:29:39 +02:00
Refactor template engine: Extract string functions into StringExtension (#152420)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
58
homeassistant/helpers/template/extensions/string.py
Normal file
58
homeassistant/helpers/template/extensions/string.py
Normal 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")
|
||||
164
tests/helpers/template/extensions/test_string.py
Normal file
164
tests/helpers/template/extensions/test_string.py
Normal 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"
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user