mirror of
https://github.com/home-assistant/core.git
synced 2026-07-01 03:36:05 +01:00
eae809abd1
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
248 lines
8.3 KiB
Python
248 lines
8.3 KiB
Python
"""Functional utility functions for Home Assistant templates."""
|
|
|
|
from collections.abc import Callable
|
|
from copy import deepcopy
|
|
from operator import contains
|
|
import random
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import jinja2
|
|
from jinja2 import pass_context
|
|
|
|
from homeassistant.const import ATTR_ENTITY_ID
|
|
from homeassistant.core import ServiceResponse
|
|
|
|
from .base import BaseTemplateExtension, TemplateFunction
|
|
|
|
if TYPE_CHECKING:
|
|
from homeassistant.helpers.template import TemplateEnvironment
|
|
|
|
_SENTINEL = object()
|
|
|
|
|
|
class FunctionalExtension(BaseTemplateExtension):
|
|
"""Jinja2 extension for functional utility functions."""
|
|
|
|
def __init__(self, environment: TemplateEnvironment) -> None:
|
|
"""Initialize the functional extension."""
|
|
super().__init__(
|
|
environment,
|
|
functions=[
|
|
TemplateFunction(
|
|
"apply",
|
|
self.apply,
|
|
as_global=True,
|
|
as_filter=True,
|
|
as_test=True,
|
|
),
|
|
TemplateFunction(
|
|
"as_function",
|
|
self.as_function,
|
|
as_global=True,
|
|
as_filter=True,
|
|
),
|
|
TemplateFunction(
|
|
"iif",
|
|
self.iif,
|
|
as_global=True,
|
|
as_filter=True,
|
|
),
|
|
TemplateFunction(
|
|
"merge_response",
|
|
self.merge_response,
|
|
as_global=True,
|
|
),
|
|
TemplateFunction(
|
|
"combine",
|
|
self.combine,
|
|
as_global=True,
|
|
as_filter=True,
|
|
),
|
|
TemplateFunction(
|
|
"typeof",
|
|
self.typeof,
|
|
as_global=True,
|
|
as_filter=True,
|
|
),
|
|
TemplateFunction(
|
|
"is_defined",
|
|
self.fail_when_undefined,
|
|
as_filter=True,
|
|
),
|
|
TemplateFunction(
|
|
"random",
|
|
_random_every_time,
|
|
as_filter=True,
|
|
),
|
|
TemplateFunction(
|
|
"zip",
|
|
zip,
|
|
as_global=True,
|
|
),
|
|
TemplateFunction(
|
|
"ord",
|
|
ord,
|
|
as_filter=True,
|
|
),
|
|
TemplateFunction(
|
|
"contains",
|
|
contains,
|
|
as_filter=True,
|
|
as_test=True,
|
|
),
|
|
],
|
|
)
|
|
|
|
@staticmethod
|
|
def apply(value: Any, fn: Any, *args: Any, **kwargs: Any) -> Any:
|
|
"""Call the given callable with the provided arguments and keyword arguments."""
|
|
return fn(value, *args, **kwargs)
|
|
|
|
@staticmethod
|
|
def as_function(macro: jinja2.runtime.Macro) -> Callable[..., Any]:
|
|
"""Turn a macro with a 'returns' keyword argument into a function."""
|
|
|
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
return_value = None
|
|
|
|
def returns(value: Any) -> Any:
|
|
nonlocal return_value
|
|
return_value = value
|
|
return value
|
|
|
|
# Call the callable with the value and other args
|
|
macro(*args, **kwargs, returns=returns)
|
|
return return_value
|
|
|
|
# Remove "macro_" from the macro's name to avoid confusion
|
|
trimmed_name = macro.name.removeprefix("macro_")
|
|
|
|
wrapper.__name__ = trimmed_name
|
|
wrapper.__qualname__ = trimmed_name
|
|
return wrapper
|
|
|
|
@staticmethod
|
|
def iif(
|
|
value: Any,
|
|
if_true: Any = True,
|
|
if_false: Any = False,
|
|
if_none: Any = _SENTINEL,
|
|
) -> Any:
|
|
"""Immediate if function/filter that allow for common if/else constructs.
|
|
|
|
https://en.wikipedia.org/wiki/IIf
|
|
|
|
Examples:
|
|
{{ is_state("device_tracker.frenck", "home") | iif("yes", "no") }}
|
|
{{ iif(1==2, "yes", "no") }}
|
|
{{ (1 == 1) | iif("yes", "no") }}
|
|
"""
|
|
if value is None and if_none is not _SENTINEL:
|
|
return if_none
|
|
if bool(value):
|
|
return if_true
|
|
return if_false
|
|
|
|
@staticmethod
|
|
def merge_response(value: ServiceResponse) -> list[Any]:
|
|
"""Merge action responses into single list.
|
|
|
|
Checks that the input is a correct service response:
|
|
{
|
|
"entity_id": {str: dict[str, Any]},
|
|
}
|
|
If response is a single list, it will extend the list with the items
|
|
and add the entity_id and value_key to each dictionary for reference.
|
|
If response is a dictionary or multiple lists,
|
|
it will append the dictionary/lists to the list
|
|
and add the entity_id to each dictionary for reference.
|
|
"""
|
|
if not isinstance(value, dict):
|
|
raise TypeError("Response is not a dictionary")
|
|
if not value:
|
|
return []
|
|
|
|
is_single_list = False
|
|
response_items: list = []
|
|
input_service_response = deepcopy(value)
|
|
for entity_id, entity_response in input_service_response.items(): # pylint: disable=too-many-nested-blocks
|
|
if not isinstance(entity_response, dict):
|
|
raise TypeError("Response is not a dictionary")
|
|
for value_key, type_response in entity_response.items():
|
|
if len(entity_response) == 1 and isinstance(type_response, list):
|
|
is_single_list = True
|
|
for dict_in_list in type_response:
|
|
if isinstance(dict_in_list, dict):
|
|
if ATTR_ENTITY_ID in dict_in_list:
|
|
raise ValueError(
|
|
"Response dictionary already"
|
|
" contains key"
|
|
f" '{ATTR_ENTITY_ID}'"
|
|
)
|
|
dict_in_list[ATTR_ENTITY_ID] = entity_id
|
|
dict_in_list["value_key"] = value_key
|
|
response_items.extend(type_response)
|
|
else:
|
|
break
|
|
|
|
if not is_single_list:
|
|
_response = entity_response.copy()
|
|
if ATTR_ENTITY_ID in _response:
|
|
raise ValueError(
|
|
f"Response dictionary already contains key '{ATTR_ENTITY_ID}'"
|
|
)
|
|
_response[ATTR_ENTITY_ID] = entity_id
|
|
response_items.append(_response)
|
|
|
|
return response_items
|
|
|
|
@staticmethod
|
|
def combine(*args: Any, recursive: bool = False) -> dict[Any, Any]:
|
|
"""Combine multiple dictionaries into one."""
|
|
if not args:
|
|
raise TypeError("combine expected at least 1 argument, got 0")
|
|
|
|
result: dict[Any, Any] = {}
|
|
for arg in args:
|
|
if not isinstance(arg, dict):
|
|
raise TypeError(f"combine expected a dict, got {type(arg).__name__}")
|
|
|
|
if recursive:
|
|
for key, value in arg.items():
|
|
if (
|
|
key in result
|
|
and isinstance(result[key], dict)
|
|
and isinstance(value, dict)
|
|
):
|
|
result[key] = FunctionalExtension.combine(
|
|
result[key], value, recursive=True
|
|
)
|
|
else:
|
|
result[key] = value
|
|
else:
|
|
result |= arg
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def typeof(value: Any) -> Any:
|
|
"""Return the type of value passed to debug types."""
|
|
return value.__class__.__name__
|
|
|
|
@staticmethod
|
|
def fail_when_undefined(value: Any) -> Any:
|
|
"""Filter to force a failure when the value is undefined."""
|
|
if isinstance(value, jinja2.Undefined):
|
|
value()
|
|
return value
|
|
|
|
|
|
@pass_context
|
|
def _random_every_time(context: Any, values: Any) -> Any:
|
|
"""Choose a random value.
|
|
|
|
Unlike Jinja's random filter,
|
|
this is context-dependent to avoid caching the chosen value.
|
|
"""
|
|
return random.choice(values)
|