diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 92f9266859bd3c..81e09c20c64296 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==40.2.0", + "aioesphomeapi==41.0.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index dc69c33cd5d467..aa9e66d3841a31 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], + "quality_scale": "bronze", "requirements": ["opower==0.15.4"] } diff --git a/homeassistant/components/opower/quality_scale.yaml b/homeassistant/components/opower/quality_scale.yaml new file mode 100644 index 00000000000000..77b97763db514d --- /dev/null +++ b/homeassistant/components/opower/quality_scale.yaml @@ -0,0 +1,79 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration does not provide any service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: The integration does not provide any service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: The integration does not subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: The integration does not provide any service actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: The integration does not have an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: + status: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: The integration does not support discovery. + discovery: + status: exempt + comment: The integration does not support discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: No custom icons are defined; icons from device classes are sufficient. + reconfiguration-flow: + status: exempt + comment: The integration has no user-configurable options that are not authentication-related. + repair-issues: done + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index 357a16c73404ea..e7fea5018fa8e3 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -18,7 +18,6 @@ import pathlib import random import re -import statistics from struct import error as StructError, pack, unpack_from import sys from types import CodeType, TracebackType @@ -37,7 +36,7 @@ from awesomeversion import AwesomeVersion import jinja2 -from jinja2 import pass_context, pass_environment, pass_eval_context +from jinja2 import pass_context, pass_eval_context from jinja2.runtime import AsyncLoopContext, LoopContext from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace @@ -2047,121 +2046,11 @@ def returns(value): return wrapper -def logarithm(value, base=math.e, default=_SENTINEL): - """Filter and function to get logarithm of the value with a specific base.""" - try: - base_float = float(base) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("log", base) - return default - try: - value_float = float(value) - return math.log(value_float, base_float) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("log", value) - return default - - -def sine(value, default=_SENTINEL): - """Filter and function to get sine of the value.""" - try: - return math.sin(float(value)) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("sin", value) - return default - - -def cosine(value, default=_SENTINEL): - """Filter and function to get cosine of the value.""" - try: - return math.cos(float(value)) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("cos", value) - return default - - -def tangent(value, default=_SENTINEL): - """Filter and function to get tangent of the value.""" - try: - return math.tan(float(value)) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("tan", value) - return default - - -def arc_sine(value, default=_SENTINEL): - """Filter and function to get arc sine of the value.""" - try: - return math.asin(float(value)) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("asin", value) - return default - - -def arc_cosine(value, default=_SENTINEL): - """Filter and function to get arc cosine of the value.""" - try: - return math.acos(float(value)) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("acos", value) - return default - - -def arc_tangent(value, default=_SENTINEL): - """Filter and function to get arc tangent of the value.""" - try: - return math.atan(float(value)) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("atan", value) - return default - - -def arc_tangent2(*args, default=_SENTINEL): - """Filter and function to calculate four quadrant arc tangent of y / x. - - The parameters to atan2 may be passed either in an iterable or as separate arguments - The default value may be passed either as a positional or in a keyword argument - """ - try: - if 1 <= len(args) <= 2 and isinstance(args[0], (list, tuple)): - if len(args) == 2 and default is _SENTINEL: - # Default value passed as a positional argument - default = args[1] - args = args[0] - elif len(args) == 3 and default is _SENTINEL: - # Default value passed as a positional argument - default = args[2] - - return math.atan2(float(args[0]), float(args[1])) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("atan2", args) - return default - - def version(value): """Filter and function to get version object of the value.""" return AwesomeVersion(value) -def square_root(value, default=_SENTINEL): - """Filter and function to get square root of the value.""" - try: - return math.sqrt(float(value)) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("sqrt", value) - return default - - def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True, default=_SENTINEL): """Filter to convert given timestamp to format.""" try: @@ -2315,118 +2204,6 @@ def fail_when_undefined(value): return value -def min_max_from_filter(builtin_filter: Any, name: str) -> Any: - """Convert a built-in min/max Jinja filter to a global function. - - The parameters may be passed as an iterable or as separate arguments. - """ - - @pass_environment - @wraps(builtin_filter) - def wrapper(environment: jinja2.Environment, *args: Any, **kwargs: Any) -> Any: - if len(args) == 0: - raise TypeError(f"{name} expected at least 1 argument, got 0") - - if len(args) == 1: - if isinstance(args[0], Iterable): - return builtin_filter(environment, args[0], **kwargs) - - raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") - - return builtin_filter(environment, args, **kwargs) - - return pass_environment(wrapper) - - -def average(*args: Any, default: Any = _SENTINEL) -> Any: - """Filter and function to calculate the arithmetic mean. - - Calculates of an iterable or of two or more arguments. - - The parameters may be passed as an iterable or as separate arguments. - """ - if len(args) == 0: - raise TypeError("average expected at least 1 argument, got 0") - - # If first argument is iterable and more than 1 argument provided but not a named - # default, then use 2nd argument as default. - if isinstance(args[0], Iterable): - average_list = args[0] - if len(args) > 1 and default is _SENTINEL: - default = args[1] - elif len(args) == 1: - raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") - else: - average_list = args - - try: - return statistics.fmean(average_list) - except (TypeError, statistics.StatisticsError): - if default is _SENTINEL: - raise_no_default("average", args) - return default - - -def median(*args: Any, default: Any = _SENTINEL) -> Any: - """Filter and function to calculate the median. - - Calculates median of an iterable of two or more arguments. - - The parameters may be passed as an iterable or as separate arguments. - """ - if len(args) == 0: - raise TypeError("median expected at least 1 argument, got 0") - - # If first argument is a list or tuple and more than 1 argument provided but not a named - # default, then use 2nd argument as default. - if isinstance(args[0], Iterable): - median_list = args[0] - if len(args) > 1 and default is _SENTINEL: - default = args[1] - elif len(args) == 1: - raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") - else: - median_list = args - - try: - return statistics.median(median_list) - except (TypeError, statistics.StatisticsError): - if default is _SENTINEL: - raise_no_default("median", args) - return default - - -def statistical_mode(*args: Any, default: Any = _SENTINEL) -> Any: - """Filter and function to calculate the statistical mode. - - Calculates mode of an iterable of two or more arguments. - - The parameters may be passed as an iterable or as separate arguments. - """ - if not args: - raise TypeError("statistical_mode expected at least 1 argument, got 0") - - # If first argument is a list or tuple and more than 1 argument provided but not a named - # default, then use 2nd argument as default. - if len(args) == 1 and isinstance(args[0], Iterable): - mode_list = args[0] - elif isinstance(args[0], list | tuple): - mode_list = args[0] - if len(args) > 1 and default is _SENTINEL: - default = args[1] - elif len(args) == 1: - raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") - else: - mode_list = args - - try: - return statistics.mode(mode_list) - except (TypeError, statistics.StatisticsError): - if default is _SENTINEL: - raise_no_default("statistical_mode", args) - return default - - def forgiving_float(value, default=_SENTINEL): """Try to convert value to a float.""" try: @@ -2549,21 +2326,6 @@ def regex_findall(value, find="", ignorecase=False): return _regex_cache(find, flags).findall(value) -def bitwise_and(first_value, second_value): - """Perform a bitwise and operation.""" - return first_value & second_value - - -def bitwise_or(first_value, second_value): - """Perform a bitwise or operation.""" - return first_value | second_value - - -def bitwise_xor(first_value, second_value): - """Perform a bitwise xor operation.""" - return first_value ^ second_value - - def struct_pack(value: Any | None, format_string: str) -> bytes | None: """Pack an object into a bytes object.""" try: @@ -3065,45 +2827,29 @@ def __init__( self.add_extension("jinja2.ext.do") self.add_extension("homeassistant.helpers.template.extensions.Base64Extension") self.add_extension("homeassistant.helpers.template.extensions.CryptoExtension") + self.add_extension("homeassistant.helpers.template.extensions.MathExtension") - self.globals["acos"] = arc_cosine self.globals["as_datetime"] = as_datetime self.globals["as_function"] = as_function self.globals["as_local"] = dt_util.as_local self.globals["as_timedelta"] = as_timedelta self.globals["as_timestamp"] = forgiving_as_timestamp - self.globals["asin"] = arc_sine - self.globals["atan"] = arc_tangent - self.globals["atan2"] = arc_tangent2 - self.globals["average"] = average self.globals["bool"] = forgiving_boolean self.globals["combine"] = combine - self.globals["cos"] = cosine self.globals["difference"] = difference - self.globals["e"] = math.e self.globals["flatten"] = flatten self.globals["float"] = forgiving_float self.globals["iif"] = iif self.globals["int"] = forgiving_int self.globals["intersect"] = intersect self.globals["is_number"] = is_number - self.globals["log"] = logarithm - self.globals["max"] = min_max_from_filter(self.filters["max"], "max") - self.globals["median"] = median self.globals["merge_response"] = merge_response - self.globals["min"] = min_max_from_filter(self.filters["min"], "min") self.globals["pack"] = struct_pack - self.globals["pi"] = math.pi self.globals["set"] = _to_set self.globals["shuffle"] = shuffle - self.globals["sin"] = sine self.globals["slugify"] = slugify - self.globals["sqrt"] = square_root - self.globals["statistical_mode"] = statistical_mode self.globals["strptime"] = strptime self.globals["symmetric_difference"] = symmetric_difference - self.globals["tan"] = tangent - self.globals["tau"] = math.pi * 2 self.globals["timedelta"] = timedelta self.globals["tuple"] = _to_tuple self.globals["typeof"] = typeof @@ -3113,7 +2859,6 @@ def __init__( self.globals["version"] = version self.globals["zip"] = zip - self.filters["acos"] = arc_cosine self.filters["add"] = add self.filters["apply"] = apply self.filters["as_datetime"] = as_datetime @@ -3121,17 +2866,9 @@ def __init__( self.filters["as_local"] = dt_util.as_local self.filters["as_timedelta"] = as_timedelta self.filters["as_timestamp"] = forgiving_as_timestamp - self.filters["asin"] = arc_sine - self.filters["atan"] = arc_tangent - self.filters["atan2"] = arc_tangent2 - self.filters["average"] = average - self.filters["bitwise_and"] = bitwise_and - self.filters["bitwise_or"] = bitwise_or - self.filters["bitwise_xor"] = bitwise_xor self.filters["bool"] = forgiving_boolean self.filters["combine"] = combine self.filters["contains"] = contains - self.filters["cos"] = cosine self.filters["difference"] = difference self.filters["flatten"] = flatten self.filters["float"] = forgiving_float_filter @@ -3142,8 +2879,6 @@ def __init__( self.filters["intersect"] = intersect self.filters["is_defined"] = fail_when_undefined self.filters["is_number"] = is_number - self.filters["log"] = logarithm - self.filters["median"] = median self.filters["multiply"] = multiply self.filters["ord"] = ord self.filters["ordinal"] = ordinal @@ -3156,12 +2891,8 @@ def __init__( self.filters["regex_search"] = regex_search self.filters["round"] = forgiving_round self.filters["shuffle"] = shuffle - self.filters["sin"] = sine self.filters["slugify"] = slugify - self.filters["sqrt"] = square_root - self.filters["statistical_mode"] = statistical_mode self.filters["symmetric_difference"] = symmetric_difference - self.filters["tan"] = tangent self.filters["timestamp_custom"] = timestamp_custom self.filters["timestamp_local"] = timestamp_local self.filters["timestamp_utc"] = timestamp_utc diff --git a/homeassistant/helpers/template/extensions/__init__.py b/homeassistant/helpers/template/extensions/__init__.py index d1ed7e093faf99..29c65103d3ce9c 100644 --- a/homeassistant/helpers/template/extensions/__init__.py +++ b/homeassistant/helpers/template/extensions/__init__.py @@ -2,5 +2,6 @@ from .base64 import Base64Extension from .crypto import CryptoExtension +from .math import MathExtension -__all__ = ["Base64Extension", "CryptoExtension"] +__all__ = ["Base64Extension", "CryptoExtension", "MathExtension"] diff --git a/homeassistant/helpers/template/extensions/base.py b/homeassistant/helpers/template/extensions/base.py index 142e9e77d5ecf4..87a3625bdbb3a9 100644 --- a/homeassistant/helpers/template/extensions/base.py +++ b/homeassistant/helpers/template/extensions/base.py @@ -19,7 +19,7 @@ class TemplateFunction: """Definition for a template function, filter, or test.""" name: str - func: Callable[..., Any] + func: Callable[..., Any] | Any as_global: bool = False as_filter: bool = False as_test: bool = False diff --git a/homeassistant/helpers/template/extensions/math.py b/homeassistant/helpers/template/extensions/math.py new file mode 100644 index 00000000000000..ac64de50a476ea --- /dev/null +++ b/homeassistant/helpers/template/extensions/math.py @@ -0,0 +1,338 @@ +"""Mathematical and statistical functions for Home Assistant templates.""" + +from __future__ import annotations + +from collections.abc import Iterable +from functools import wraps +import math +import statistics +from typing import TYPE_CHECKING, Any, NoReturn + +import jinja2 +from jinja2 import pass_environment + +from homeassistant.helpers.template import template_cv + +from .base import BaseTemplateExtension, TemplateFunction + +if TYPE_CHECKING: + from homeassistant.helpers.template import TemplateEnvironment + +# Sentinel object for default parameter +_SENTINEL = object() + + +def raise_no_default(function: str, value: Any) -> NoReturn: + """Log warning if no default is specified.""" + template, action = template_cv.get() or ("", "rendering or compiling") + raise ValueError( + f"Template error: {function} got invalid input '{value}' when {action} template" + f" '{template}' but no default was specified" + ) + + +class MathExtension(BaseTemplateExtension): + """Jinja2 extension for mathematical and statistical functions.""" + + def __init__(self, environment: TemplateEnvironment) -> None: + """Initialize the math extension.""" + super().__init__( + environment, + functions=[ + # Math constants (as globals only) - these are values, not functions + TemplateFunction("e", math.e, as_global=True), + TemplateFunction("pi", math.pi, as_global=True), + TemplateFunction("tau", math.pi * 2, as_global=True), + # Trigonometric functions (as globals and filters) + TemplateFunction("sin", self.sine, as_global=True, as_filter=True), + TemplateFunction("cos", self.cosine, as_global=True, as_filter=True), + TemplateFunction("tan", self.tangent, as_global=True, as_filter=True), + TemplateFunction("asin", self.arc_sine, as_global=True, as_filter=True), + TemplateFunction( + "acos", self.arc_cosine, as_global=True, as_filter=True + ), + TemplateFunction( + "atan", self.arc_tangent, as_global=True, as_filter=True + ), + TemplateFunction( + "atan2", self.arc_tangent2, as_global=True, as_filter=True + ), + # Advanced math functions (as globals and filters) + TemplateFunction("log", self.logarithm, as_global=True, as_filter=True), + TemplateFunction( + "sqrt", self.square_root, as_global=True, as_filter=True + ), + # Statistical functions (as globals and filters) + TemplateFunction( + "average", self.average, as_global=True, as_filter=True + ), + TemplateFunction("median", self.median, as_global=True, as_filter=True), + TemplateFunction( + "statistical_mode", + self.statistical_mode, + as_global=True, + as_filter=True, + ), + # Min/Max functions (as globals only) + TemplateFunction("min", self.min_max_min, as_global=True), + TemplateFunction("max", self.min_max_max, as_global=True), + # Bitwise operations (as globals and filters) + TemplateFunction( + "bitwise_and", self.bitwise_and, as_global=True, as_filter=True + ), + TemplateFunction( + "bitwise_or", self.bitwise_or, as_global=True, as_filter=True + ), + TemplateFunction( + "bitwise_xor", self.bitwise_xor, as_global=True, as_filter=True + ), + ], + ) + + @staticmethod + def logarithm(value: Any, base: Any = math.e, default: Any = _SENTINEL) -> Any: + """Filter and function to get logarithm of the value with a specific base.""" + try: + base_float = float(base) + except (ValueError, TypeError): + if default is _SENTINEL: + raise_no_default("log", base) + return default + try: + value_float = float(value) + return math.log(value_float, base_float) + except (ValueError, TypeError): + if default is _SENTINEL: + raise_no_default("log", value) + return default + + @staticmethod + def sine(value: Any, default: Any = _SENTINEL) -> Any: + """Filter and function to get sine of the value.""" + try: + return math.sin(float(value)) + except (ValueError, TypeError): + if default is _SENTINEL: + raise_no_default("sin", value) + return default + + @staticmethod + def cosine(value: Any, default: Any = _SENTINEL) -> Any: + """Filter and function to get cosine of the value.""" + try: + return math.cos(float(value)) + except (ValueError, TypeError): + if default is _SENTINEL: + raise_no_default("cos", value) + return default + + @staticmethod + def tangent(value: Any, default: Any = _SENTINEL) -> Any: + """Filter and function to get tangent of the value.""" + try: + return math.tan(float(value)) + except (ValueError, TypeError): + if default is _SENTINEL: + raise_no_default("tan", value) + return default + + @staticmethod + def arc_sine(value: Any, default: Any = _SENTINEL) -> Any: + """Filter and function to get arc sine of the value.""" + try: + return math.asin(float(value)) + except (ValueError, TypeError): + if default is _SENTINEL: + raise_no_default("asin", value) + return default + + @staticmethod + def arc_cosine(value: Any, default: Any = _SENTINEL) -> Any: + """Filter and function to get arc cosine of the value.""" + try: + return math.acos(float(value)) + except (ValueError, TypeError): + if default is _SENTINEL: + raise_no_default("acos", value) + return default + + @staticmethod + def arc_tangent(value: Any, default: Any = _SENTINEL) -> Any: + """Filter and function to get arc tangent of the value.""" + try: + return math.atan(float(value)) + except (ValueError, TypeError): + if default is _SENTINEL: + raise_no_default("atan", value) + return default + + @staticmethod + def arc_tangent2(*args: Any, default: Any = _SENTINEL) -> Any: + """Filter and function to calculate four quadrant arc tangent of y / x. + + The parameters to atan2 may be passed either in an iterable or as separate arguments + The default value may be passed either as a positional or in a keyword argument + """ + try: + if 1 <= len(args) <= 2 and isinstance(args[0], (list, tuple)): + if len(args) == 2 and default is _SENTINEL: + # Default value passed as a positional argument + default = args[1] + args = tuple(args[0]) + elif len(args) == 3 and default is _SENTINEL: + # Default value passed as a positional argument + default = args[2] + + return math.atan2(float(args[0]), float(args[1])) + except (ValueError, TypeError): + if default is _SENTINEL: + raise_no_default("atan2", args) + return default + + @staticmethod + def square_root(value: Any, default: Any = _SENTINEL) -> Any: + """Filter and function to get square root of the value.""" + try: + return math.sqrt(float(value)) + except (ValueError, TypeError): + if default is _SENTINEL: + raise_no_default("sqrt", value) + return default + + @staticmethod + def average(*args: Any, default: Any = _SENTINEL) -> Any: + """Filter and function to calculate the arithmetic mean. + + Calculates of an iterable or of two or more arguments. + + The parameters may be passed as an iterable or as separate arguments. + """ + if len(args) == 0: + raise TypeError("average expected at least 1 argument, got 0") + + # If first argument is iterable and more than 1 argument provided but not a named + # default, then use 2nd argument as default. + if isinstance(args[0], Iterable): + average_list = args[0] + if len(args) > 1 and default is _SENTINEL: + default = args[1] + elif len(args) == 1: + raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") + else: + average_list = args + + try: + return statistics.fmean(average_list) + except (TypeError, statistics.StatisticsError): + if default is _SENTINEL: + raise_no_default("average", args) + return default + + @staticmethod + def median(*args: Any, default: Any = _SENTINEL) -> Any: + """Filter and function to calculate the median. + + Calculates median of an iterable of two or more arguments. + + The parameters may be passed as an iterable or as separate arguments. + """ + if len(args) == 0: + raise TypeError("median expected at least 1 argument, got 0") + + # If first argument is a list or tuple and more than 1 argument provided but not a named + # default, then use 2nd argument as default. + if isinstance(args[0], Iterable): + median_list = args[0] + if len(args) > 1 and default is _SENTINEL: + default = args[1] + elif len(args) == 1: + raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") + else: + median_list = args + + try: + return statistics.median(median_list) + except (TypeError, statistics.StatisticsError): + if default is _SENTINEL: + raise_no_default("median", args) + return default + + @staticmethod + def statistical_mode(*args: Any, default: Any = _SENTINEL) -> Any: + """Filter and function to calculate the statistical mode. + + Calculates mode of an iterable of two or more arguments. + + The parameters may be passed as an iterable or as separate arguments. + """ + if not args: + raise TypeError("statistical_mode expected at least 1 argument, got 0") + + # If first argument is a list or tuple and more than 1 argument provided but not a named + # default, then use 2nd argument as default. + if len(args) == 1 and isinstance(args[0], Iterable): + mode_list = args[0] + elif isinstance(args[0], list | tuple): + mode_list = args[0] + if len(args) > 1 and default is _SENTINEL: + default = args[1] + elif len(args) == 1: + raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") + else: + mode_list = args + + try: + return statistics.mode(mode_list) + except (TypeError, statistics.StatisticsError): + if default is _SENTINEL: + raise_no_default("statistical_mode", args) + return default + + def min_max_from_filter(self, builtin_filter: Any, name: str) -> Any: + """Convert a built-in min/max Jinja filter to a global function. + + The parameters may be passed as an iterable or as separate arguments. + """ + + @pass_environment + @wraps(builtin_filter) + def wrapper(environment: jinja2.Environment, *args: Any, **kwargs: Any) -> Any: + if len(args) == 0: + raise TypeError(f"{name} expected at least 1 argument, got 0") + + if len(args) == 1: + if isinstance(args[0], Iterable): + return builtin_filter(environment, args[0], **kwargs) + + raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") + + return builtin_filter(environment, args, **kwargs) + + return pass_environment(wrapper) + + def min_max_min(self, *args: Any, **kwargs: Any) -> Any: + """Min function using built-in filter.""" + return self.min_max_from_filter(self.environment.filters["min"], "min")( + self.environment, *args, **kwargs + ) + + def min_max_max(self, *args: Any, **kwargs: Any) -> Any: + """Max function using built-in filter.""" + return self.min_max_from_filter(self.environment.filters["max"], "max")( + self.environment, *args, **kwargs + ) + + @staticmethod + def bitwise_and(first_value: Any, second_value: Any) -> Any: + """Perform a bitwise and operation.""" + return first_value & second_value + + @staticmethod + def bitwise_or(first_value: Any, second_value: Any) -> Any: + """Perform a bitwise or operation.""" + return first_value | second_value + + @staticmethod + def bitwise_xor(first_value: Any, second_value: Any) -> Any: + """Perform a bitwise xor operation.""" + return first_value ^ second_value diff --git a/requirements_all.txt b/requirements_all.txt index e8bc69e54bfd74..1414463a6919f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==40.2.0 +aioesphomeapi==41.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42467e066dc552..0f3f2997c6965d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==40.2.0 +aioesphomeapi==41.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index a3a0f9d6facb06..978cea6f627c61 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -736,7 +736,6 @@ class Rule: "openuv", "openweathermap", "opnsense", - "opower", "opple", "oralb", "oru", @@ -1777,7 +1776,6 @@ class Rule: "openuv", "openweathermap", "opnsense", - "opower", "opple", "oralb", "oru", diff --git a/tests/helpers/template/extensions/test_math.py b/tests/helpers/template/extensions/test_math.py new file mode 100644 index 00000000000000..5a8730951811b1 --- /dev/null +++ b/tests/helpers/template/extensions/test_math.py @@ -0,0 +1,393 @@ +"""Test mathematical and statistical functions for Home Assistant templates.""" + +from __future__ import annotations + +import math + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template + + +def render(hass: HomeAssistant, template_str: str) -> str: + """Render template and return result.""" + return template.Template(template_str, hass).async_render() + + +def test_math_constants(hass: HomeAssistant) -> None: + """Test math constants.""" + assert render(hass, "{{ e }}") == math.e + assert render(hass, "{{ pi }}") == math.pi + assert render(hass, "{{ tau }}") == math.pi * 2 + + +def test_logarithm(hass: HomeAssistant) -> None: + """Test logarithm.""" + tests = [ + (4, 2, 2.0), + (1000, 10, 3.0), + (math.e, "", 1.0), # The "" means the default base (e) will be used + ] + + for value, base, expected in tests: + assert ( + template.Template( + f"{{{{ {value} | log({base}) | round(1) }}}}", hass + ).async_render() + == expected + ) + + assert ( + template.Template( + f"{{{{ log({value}, {base}) | round(1) }}}}", hass + ).async_render() + == expected + ) + + # Test handling of invalid input + with pytest.raises(TemplateError): + template.Template("{{ invalid | log(_) }}", hass).async_render() + with pytest.raises(TemplateError): + template.Template("{{ log(invalid, _) }}", hass).async_render() + with pytest.raises(TemplateError): + template.Template("{{ 10 | log(invalid) }}", hass).async_render() + with pytest.raises(TemplateError): + template.Template("{{ log(10, invalid) }}", hass).async_render() + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | log(10, 1) }}") == 1 + assert render(hass, "{{ 'no_number' | log(10, default=1) }}") == 1 + assert render(hass, "{{ log('no_number', 10, 1) }}") == 1 + assert render(hass, "{{ log('no_number', 10, default=1) }}") == 1 + assert render(hass, "{{ log(0, 10, 1) }}") == 1 + assert render(hass, "{{ log(0, 10, default=1) }}") == 1 + + +def test_sine(hass: HomeAssistant) -> None: + """Test sine.""" + tests = [ + (0, 0.0), + (math.pi / 2, 1.0), + (math.pi, 0.0), + (math.pi * 1.5, -1.0), + (math.pi / 10, 0.309), + ] + + for value, expected in tests: + assert ( + template.Template( + f"{{{{ {value} | sin | round(3) }}}}", hass + ).async_render() + == expected + ) + assert render(hass, f"{{{{ sin({value}) | round(3) }}}}") == expected + + # Test handling of invalid input + with pytest.raises(TemplateError): + template.Template("{{ 'duck' | sin }}", hass).async_render() + with pytest.raises(TemplateError): + template.Template("{{ invalid | sin('duck') }}", hass).async_render() + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | sin(1) }}") == 1 + assert render(hass, "{{ 'no_number' | sin(default=1) }}") == 1 + assert render(hass, "{{ sin('no_number', 1) }}") == 1 + assert render(hass, "{{ sin('no_number', default=1) }}") == 1 + + +def test_cosine(hass: HomeAssistant) -> None: + """Test cosine.""" + tests = [ + (0, 1.0), + (math.pi / 2, 0.0), + (math.pi, -1.0), + (math.pi * 1.5, 0.0), + (math.pi / 3, 0.5), + ] + + for value, expected in tests: + assert ( + template.Template( + f"{{{{ {value} | cos | round(3) }}}}", hass + ).async_render() + == expected + ) + assert render(hass, f"{{{{ cos({value}) | round(3) }}}}") == expected + + # Test handling of invalid input + with pytest.raises(TemplateError): + template.Template("{{ 'duck' | cos }}", hass).async_render() + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | cos(1) }}") == 1 + assert render(hass, "{{ 'no_number' | cos(default=1) }}") == 1 + assert render(hass, "{{ cos('no_number', 1) }}") == 1 + assert render(hass, "{{ cos('no_number', default=1) }}") == 1 + + +def test_tangent(hass: HomeAssistant) -> None: + """Test tangent.""" + tests = [ + (0, 0.0), + (math.pi / 4, 1.0), + (math.pi, 0.0), + (math.pi / 6, 0.577), + ] + + for value, expected in tests: + assert ( + template.Template( + f"{{{{ {value} | tan | round(3) }}}}", hass + ).async_render() + == expected + ) + assert render(hass, f"{{{{ tan({value}) | round(3) }}}}") == expected + + # Test handling of invalid input + with pytest.raises(TemplateError): + template.Template("{{ 'duck' | tan }}", hass).async_render() + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | tan(1) }}") == 1 + assert render(hass, "{{ 'no_number' | tan(default=1) }}") == 1 + assert render(hass, "{{ tan('no_number', 1) }}") == 1 + assert render(hass, "{{ tan('no_number', default=1) }}") == 1 + + +def test_square_root(hass: HomeAssistant) -> None: + """Test square root.""" + tests = [ + (0, 0.0), + (1, 1.0), + (4, 2.0), + (9, 3.0), + (16, 4.0), + (0.25, 0.5), + ] + + for value, expected in tests: + assert ( + template.Template(f"{{{{ {value} | sqrt }}}}", hass).async_render() + == expected + ) + assert render(hass, f"{{{{ sqrt({value}) }}}}") == expected + + # Test handling of invalid input + with pytest.raises(TemplateError): + template.Template("{{ 'duck' | sqrt }}", hass).async_render() + with pytest.raises(TemplateError): + template.Template("{{ -1 | sqrt }}", hass).async_render() + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | sqrt(1) }}") == 1 + assert render(hass, "{{ 'no_number' | sqrt(default=1) }}") == 1 + assert render(hass, "{{ sqrt('no_number', 1) }}") == 1 + assert render(hass, "{{ sqrt('no_number', default=1) }}") == 1 + assert render(hass, "{{ sqrt(-1, 1) }}") == 1 + assert render(hass, "{{ sqrt(-1, default=1) }}") == 1 + + +def test_arc_functions(hass: HomeAssistant) -> None: + """Test arc trigonometric functions.""" + # Test arc sine + assert render(hass, "{{ asin(0.5) | round(3) }}") == round(math.asin(0.5), 3) + assert render(hass, "{{ 0.5 | asin | round(3) }}") == round(math.asin(0.5), 3) + + # Test arc cosine + assert render(hass, "{{ acos(0.5) | round(3) }}") == round(math.acos(0.5), 3) + assert render(hass, "{{ 0.5 | acos | round(3) }}") == round(math.acos(0.5), 3) + + # Test arc tangent + assert render(hass, "{{ atan(1) | round(3) }}") == round(math.atan(1), 3) + assert render(hass, "{{ 1 | atan | round(3) }}") == round(math.atan(1), 3) + + # Test atan2 + assert render(hass, "{{ atan2(1, 1) | round(3) }}") == round(math.atan2(1, 1), 3) + assert render(hass, "{{ atan2([1, 1]) | round(3) }}") == round(math.atan2(1, 1), 3) + + # Test invalid input handling + with pytest.raises(TemplateError): + render(hass, "{{ asin(2) }}") # Outside domain [-1, 1] + + # Test default values + assert render(hass, "{{ asin(2, 1) }}") == 1 + assert render(hass, "{{ acos(2, 1) }}") == 1 + assert render(hass, "{{ atan('invalid', 1) }}") == 1 + assert render(hass, "{{ atan2('invalid', 1, 1) }}") == 1 + + +def test_average(hass: HomeAssistant) -> None: + """Test the average function.""" + assert template.Template("{{ average([1, 2, 3]) }}", hass).async_render() == 2 + assert template.Template("{{ average(1, 2, 3) }}", hass).async_render() == 2 + + # Testing of default values + assert template.Template("{{ average([1, 2, 3], -1) }}", hass).async_render() == 2 + assert template.Template("{{ average([], -1) }}", hass).async_render() == -1 + assert template.Template("{{ average([], default=-1) }}", hass).async_render() == -1 + assert ( + template.Template("{{ average([], 5, default=-1) }}", hass).async_render() == -1 + ) + assert ( + template.Template("{{ average(1, 'a', 3, default=-1) }}", hass).async_render() + == -1 + ) + + with pytest.raises(TemplateError): + template.Template("{{ average() }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ average([]) }}", hass).async_render() + + +def test_median(hass: HomeAssistant) -> None: + """Test the median function.""" + assert template.Template("{{ median([1, 2, 3]) }}", hass).async_render() == 2 + assert template.Template("{{ median([1, 2, 3, 4]) }}", hass).async_render() == 2.5 + assert template.Template("{{ median(1, 2, 3) }}", hass).async_render() == 2 + + # Testing of default values + assert template.Template("{{ median([1, 2, 3], -1) }}", hass).async_render() == 2 + assert template.Template("{{ median([], -1) }}", hass).async_render() == -1 + assert template.Template("{{ median([], default=-1) }}", hass).async_render() == -1 + + with pytest.raises(TemplateError): + template.Template("{{ median() }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ median([]) }}", hass).async_render() + + +def test_statistical_mode(hass: HomeAssistant) -> None: + """Test the statistical mode function.""" + assert ( + template.Template("{{ statistical_mode([1, 1, 2, 3]) }}", hass).async_render() + == 1 + ) + assert ( + template.Template("{{ statistical_mode(1, 1, 2, 3) }}", hass).async_render() + == 1 + ) + + # Testing of default values + assert ( + template.Template("{{ statistical_mode([1, 1, 2], -1) }}", hass).async_render() + == 1 + ) + assert ( + template.Template("{{ statistical_mode([], -1) }}", hass).async_render() == -1 + ) + assert ( + template.Template("{{ statistical_mode([], default=-1) }}", hass).async_render() + == -1 + ) + + with pytest.raises(TemplateError): + template.Template("{{ statistical_mode() }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ statistical_mode([]) }}", hass).async_render() + + +def test_min_max_functions(hass: HomeAssistant) -> None: + """Test min and max functions.""" + # Test min function + assert template.Template("{{ min([1, 2, 3]) }}", hass).async_render() == 1 + assert template.Template("{{ min(1, 2, 3) }}", hass).async_render() == 1 + + # Test max function + assert template.Template("{{ max([1, 2, 3]) }}", hass).async_render() == 3 + assert template.Template("{{ max(1, 2, 3) }}", hass).async_render() == 3 + + # Test error handling + with pytest.raises(TemplateError): + template.Template("{{ min() }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ max() }}", hass).async_render() + + +def test_bitwise_and(hass: HomeAssistant) -> None: + """Test bitwise and.""" + assert template.Template("{{ bitwise_and(8, 2) }}", hass).async_render() == 0 + assert template.Template("{{ bitwise_and(10, 2) }}", hass).async_render() == 2 + assert template.Template("{{ bitwise_and(8, 8) }}", hass).async_render() == 8 + + +def test_bitwise_or(hass: HomeAssistant) -> None: + """Test bitwise or.""" + assert template.Template("{{ bitwise_or(8, 2) }}", hass).async_render() == 10 + assert template.Template("{{ bitwise_or(8, 8) }}", hass).async_render() == 8 + assert template.Template("{{ bitwise_or(10, 2) }}", hass).async_render() == 10 + + +def test_bitwise_xor(hass: HomeAssistant) -> None: + """Test bitwise xor.""" + assert template.Template("{{ bitwise_xor(8, 2) }}", hass).async_render() == 10 + assert template.Template("{{ bitwise_xor(8, 8) }}", hass).async_render() == 0 + assert template.Template("{{ bitwise_xor(10, 2) }}", hass).async_render() == 8 + + +@pytest.mark.parametrize( + "attribute", + [ + "a", + "b", + "c", + ], +) +def test_min_max_attribute(hass: HomeAssistant, attribute) -> None: + """Test the min and max filters with attribute.""" + hass.states.async_set( + "test.object", + "test", + { + "objects": [ + { + "a": 1, + "b": 2, + "c": 3, + }, + { + "a": 2, + "b": 1, + "c": 2, + }, + { + "a": 3, + "b": 3, + "c": 1, + }, + ], + }, + ) + assert ( + template.Template( + f"{{{{ (state_attr('test.object', 'objects') | min(attribute='{attribute}'))['{attribute}']}}}}", + hass, + ).async_render() + == 1 + ) + assert ( + template.Template( + f"{{{{ (min(state_attr('test.object', 'objects'), attribute='{attribute}'))['{attribute}']}}}}", + hass, + ).async_render() + == 1 + ) + assert ( + template.Template( + f"{{{{ (state_attr('test.object', 'objects') | max(attribute='{attribute}'))['{attribute}']}}}}", + hass, + ).async_render() + == 3 + ) + assert ( + template.Template( + f"{{{{ (max(state_attr('test.object', 'objects'), attribute='{attribute}'))['{attribute}']}}}}", + hass, + ).async_render() + == 3 + ) diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 6d4c27123fc266..2de40457353fed 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -862,338 +862,6 @@ def test_as_function_no_arguments(hass: HomeAssistant) -> None: ) -def test_logarithm(hass: HomeAssistant) -> None: - """Test logarithm.""" - tests = [ - (4, 2, 2.0), - (1000, 10, 3.0), - (math.e, "", 1.0), # The "" means the default base (e) will be used - ] - - for value, base, expected in tests: - assert ( - template.Template( - f"{{{{ {value} | log({base}) | round(1) }}}}", hass - ).async_render() - == expected - ) - - assert ( - template.Template( - f"{{{{ log({value}, {base}) | round(1) }}}}", hass - ).async_render() - == expected - ) - - # Test handling of invalid input - with pytest.raises(TemplateError): - template.Template("{{ invalid | log(_) }}", hass).async_render() - with pytest.raises(TemplateError): - template.Template("{{ log(invalid, _) }}", hass).async_render() - with pytest.raises(TemplateError): - template.Template("{{ 10 | log(invalid) }}", hass).async_render() - with pytest.raises(TemplateError): - template.Template("{{ log(10, invalid) }}", hass).async_render() - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | log(10, 1) }}") == 1 - assert render(hass, "{{ 'no_number' | log(10, default=1) }}") == 1 - assert render(hass, "{{ log('no_number', 10, 1) }}") == 1 - assert render(hass, "{{ log('no_number', 10, default=1) }}") == 1 - assert render(hass, "{{ log(0, 10, 1) }}") == 1 - assert render(hass, "{{ log(0, 10, default=1) }}") == 1 - - -def test_sine(hass: HomeAssistant) -> None: - """Test sine.""" - tests = [ - (0, 0.0), - (math.pi / 2, 1.0), - (math.pi, 0.0), - (math.pi * 1.5, -1.0), - (math.pi / 10, 0.309), - ] - - for value, expected in tests: - assert ( - template.Template( - f"{{{{ {value} | sin | round(3) }}}}", hass - ).async_render() - == expected - ) - assert render(hass, f"{{{{ sin({value}) | round(3) }}}}") == expected - - # Test handling of invalid input - with pytest.raises(TemplateError): - template.Template("{{ 'duck' | sin }}", hass).async_render() - with pytest.raises(TemplateError): - template.Template("{{ invalid | sin('duck') }}", hass).async_render() - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | sin(1) }}") == 1 - assert render(hass, "{{ 'no_number' | sin(default=1) }}") == 1 - assert render(hass, "{{ sin('no_number', 1) }}") == 1 - assert render(hass, "{{ sin('no_number', default=1) }}") == 1 - - -def test_cos(hass: HomeAssistant) -> None: - """Test cosine.""" - tests = [ - (0, 1.0), - (math.pi / 2, 0.0), - (math.pi, -1.0), - (math.pi * 1.5, -0.0), - (math.pi / 10, 0.951), - ] - - for value, expected in tests: - assert ( - template.Template( - f"{{{{ {value} | cos | round(3) }}}}", hass - ).async_render() - == expected - ) - assert render(hass, f"{{{{ cos({value}) | round(3) }}}}") == expected - - # Test handling of invalid input - with pytest.raises(TemplateError): - template.Template("{{ 'error' | cos }}", hass).async_render() - with pytest.raises(TemplateError): - template.Template("{{ invalid | cos('error') }}", hass).async_render() - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | cos(1) }}") == 1 - assert render(hass, "{{ 'no_number' | cos(default=1) }}") == 1 - assert render(hass, "{{ cos('no_number', 1) }}") == 1 - assert render(hass, "{{ cos('no_number', default=1) }}") == 1 - - -def test_tan(hass: HomeAssistant) -> None: - """Test tangent.""" - tests = [ - (0, 0.0), - (math.pi, -0.0), - (math.pi / 180 * 45, 1.0), - (math.pi / 180 * 90, "1.633123935319537e+16"), - (math.pi / 180 * 135, -1.0), - ] - - for value, expected in tests: - assert ( - template.Template( - f"{{{{ {value} | tan | round(3) }}}}", hass - ).async_render() - == expected - ) - assert render(hass, f"{{{{ tan({value}) | round(3) }}}}") == expected - - # Test handling of invalid input - with pytest.raises(TemplateError): - template.Template("{{ 'error' | tan }}", hass).async_render() - with pytest.raises(TemplateError): - template.Template("{{ invalid | tan('error') }}", hass).async_render() - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | tan(1) }}") == 1 - assert render(hass, "{{ 'no_number' | tan(default=1) }}") == 1 - assert render(hass, "{{ tan('no_number', 1) }}") == 1 - assert render(hass, "{{ tan('no_number', default=1) }}") == 1 - - -def test_sqrt(hass: HomeAssistant) -> None: - """Test square root.""" - tests = [ - (0, 0.0), - (1, 1.0), - (2, 1.414), - (10, 3.162), - (100, 10.0), - ] - - for value, expected in tests: - assert ( - template.Template( - f"{{{{ {value} | sqrt | round(3) }}}}", hass - ).async_render() - == expected - ) - assert render(hass, f"{{{{ sqrt({value}) | round(3) }}}}") == expected - - # Test handling of invalid input - with pytest.raises(TemplateError): - template.Template("{{ 'error' | sqrt }}", hass).async_render() - with pytest.raises(TemplateError): - template.Template("{{ invalid | sqrt('error') }}", hass).async_render() - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | sqrt(1) }}") == 1 - assert render(hass, "{{ 'no_number' | sqrt(default=1) }}") == 1 - assert render(hass, "{{ sqrt('no_number', 1) }}") == 1 - assert render(hass, "{{ sqrt('no_number', default=1) }}") == 1 - - -def test_arc_sine(hass: HomeAssistant) -> None: - """Test arcus sine.""" - tests = [ - (-1.0, -1.571), - (-0.5, -0.524), - (0.0, 0.0), - (0.5, 0.524), - (1.0, 1.571), - ] - - for value, expected in tests: - assert ( - template.Template( - f"{{{{ {value} | asin | round(3) }}}}", hass - ).async_render() - == expected - ) - assert render(hass, f"{{{{ asin({value}) | round(3) }}}}") == expected - - # Test handling of invalid input - invalid_tests = [ - -2.0, # value error - 2.0, # value error - '"error"', - ] - - for value in invalid_tests: - with pytest.raises(TemplateError): - template.Template( - f"{{{{ {value} | asin | round(3) }}}}", hass - ).async_render() - with pytest.raises(TemplateError): - assert render(hass, f"{{{{ asin({value}) | round(3) }}}}") - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | asin(1) }}") == 1 - assert render(hass, "{{ 'no_number' | asin(default=1) }}") == 1 - assert render(hass, "{{ asin('no_number', 1) }}") == 1 - assert render(hass, "{{ asin('no_number', default=1) }}") == 1 - - -def test_arc_cos(hass: HomeAssistant) -> None: - """Test arcus cosine.""" - tests = [ - (-1.0, 3.142), - (-0.5, 2.094), - (0.0, 1.571), - (0.5, 1.047), - (1.0, 0.0), - ] - - for value, expected in tests: - assert ( - template.Template( - f"{{{{ {value} | acos | round(3) }}}}", hass - ).async_render() - == expected - ) - assert render(hass, f"{{{{ acos({value}) | round(3) }}}}") == expected - - # Test handling of invalid input - invalid_tests = [ - -2.0, # value error - 2.0, # value error - '"error"', - ] - - for value in invalid_tests: - with pytest.raises(TemplateError): - template.Template( - f"{{{{ {value} | acos | round(3) }}}}", hass - ).async_render() - with pytest.raises(TemplateError): - assert render(hass, f"{{{{ acos({value}) | round(3) }}}}") - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | acos(1) }}") == 1 - assert render(hass, "{{ 'no_number' | acos(default=1) }}") == 1 - assert render(hass, "{{ acos('no_number', 1) }}") == 1 - assert render(hass, "{{ acos('no_number', default=1) }}") == 1 - - -def test_arc_tan(hass: HomeAssistant) -> None: - """Test arcus tangent.""" - tests = [ - (-10.0, -1.471), - (-2.0, -1.107), - (-1.0, -0.785), - (-0.5, -0.464), - (0.0, 0.0), - (0.5, 0.464), - (1.0, 0.785), - (2.0, 1.107), - (10.0, 1.471), - ] - - for value, expected in tests: - assert ( - template.Template( - f"{{{{ {value} | atan | round(3) }}}}", hass - ).async_render() - == expected - ) - assert render(hass, f"{{{{ atan({value}) | round(3) }}}}") == expected - - # Test handling of invalid input - with pytest.raises(TemplateError): - template.Template("{{ 'error' | atan }}", hass).async_render() - with pytest.raises(TemplateError): - template.Template("{{ invalid | atan('error') }}", hass).async_render() - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | atan(1) }}") == 1 - assert render(hass, "{{ 'no_number' | atan(default=1) }}") == 1 - assert render(hass, "{{ atan('no_number', 1) }}") == 1 - assert render(hass, "{{ atan('no_number', default=1) }}") == 1 - - -def test_arc_tan2(hass: HomeAssistant) -> None: - """Test two parameter version of arcus tangent.""" - tests = [ - (-10.0, -10.0, -2.356), - (-10.0, 0.0, -1.571), - (-10.0, 10.0, -0.785), - (0.0, -10.0, 3.142), - (0.0, 0.0, 0.0), - (0.0, 10.0, 0.0), - (10.0, -10.0, 2.356), - (10.0, 0.0, 1.571), - (10.0, 10.0, 0.785), - (-4.0, 3.0, -0.927), - (-1.0, 2.0, -0.464), - (2.0, 1.0, 1.107), - ] - - for y, x, expected in tests: - assert ( - template.Template( - f"{{{{ ({y}, {x}) | atan2 | round(3) }}}}", hass - ).async_render() - == expected - ) - assert ( - template.Template( - f"{{{{ atan2({y}, {x}) | round(3) }}}}", hass - ).async_render() - == expected - ) - - # Test handling of invalid input - with pytest.raises(TemplateError): - template.Template("{{ ('duck', 'goose') | atan2 }}", hass).async_render() - with pytest.raises(TemplateError): - template.Template("{{ atan2('duck', 'goose') }}", hass).async_render() - - # Test handling of default return value - assert render(hass, "{{ ('duck', 'goose') | atan2(1) }}") == 1 - assert render(hass, "{{ ('duck', 'goose') | atan2(default=1) }}") == 1 - assert render(hass, "{{ atan2('duck', 'goose', 1) }}") == 1 - assert render(hass, "{{ atan2('duck', 'goose', default=1) }}") == 1 - - def test_strptime(hass: HomeAssistant) -> None: """Test the parse timestamp method.""" tests = [ @@ -1521,211 +1189,6 @@ def test_from_json(hass: HomeAssistant) -> None: assert actual_result == expected_result -def test_average(hass: HomeAssistant) -> None: - """Test the average filter.""" - assert template.Template("{{ [1, 2, 3] | average }}", hass).async_render() == 2 - assert template.Template("{{ average([1, 2, 3]) }}", hass).async_render() == 2 - assert template.Template("{{ average(1, 2, 3) }}", hass).async_render() == 2 - - # Testing of default values - assert template.Template("{{ average([1, 2, 3], -1) }}", hass).async_render() == 2 - assert template.Template("{{ average([], -1) }}", hass).async_render() == -1 - assert template.Template("{{ average([], default=-1) }}", hass).async_render() == -1 - assert ( - template.Template("{{ average([], 5, default=-1) }}", hass).async_render() == -1 - ) - assert ( - template.Template("{{ average(1, 'a', 3, default=-1) }}", hass).async_render() - == -1 - ) - - with pytest.raises(TemplateError): - template.Template("{{ 1 | average }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ average() }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ average([]) }}", hass).async_render() - - -def test_median(hass: HomeAssistant) -> None: - """Test the median filter.""" - assert template.Template("{{ [1, 3, 2] | median }}", hass).async_render() == 2 - assert template.Template("{{ median([1, 3, 2, 4]) }}", hass).async_render() == 2.5 - assert template.Template("{{ median(1, 3, 2) }}", hass).async_render() == 2 - assert template.Template("{{ median('cdeba') }}", hass).async_render() == "c" - - # Testing of default values - assert template.Template("{{ median([1, 2, 3], -1) }}", hass).async_render() == 2 - assert template.Template("{{ median([], -1) }}", hass).async_render() == -1 - assert template.Template("{{ median([], default=-1) }}", hass).async_render() == -1 - assert template.Template("{{ median('abcd', -1) }}", hass).async_render() == -1 - assert ( - template.Template("{{ median([], 5, default=-1) }}", hass).async_render() == -1 - ) - assert ( - template.Template("{{ median(1, 'a', 3, default=-1) }}", hass).async_render() - == -1 - ) - - with pytest.raises(TemplateError): - template.Template("{{ 1 | median }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ median() }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ median([]) }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ median('abcd') }}", hass).async_render() - - -def test_statistical_mode(hass: HomeAssistant) -> None: - """Test the mode filter.""" - assert ( - template.Template("{{ [1, 2, 2, 3] | statistical_mode }}", hass).async_render() - == 2 - ) - assert ( - template.Template("{{ statistical_mode([1, 2, 3]) }}", hass).async_render() == 1 - ) - assert ( - template.Template( - "{{ statistical_mode('hello', 'bye', 'hello') }}", hass - ).async_render() - == "hello" - ) - assert ( - template.Template("{{ statistical_mode('banana') }}", hass).async_render() - == "a" - ) - - # Testing of default values - assert ( - template.Template("{{ statistical_mode([1, 2, 3], -1) }}", hass).async_render() - == 1 - ) - assert ( - template.Template("{{ statistical_mode([], -1) }}", hass).async_render() == -1 - ) - assert ( - template.Template("{{ statistical_mode([], default=-1) }}", hass).async_render() - == -1 - ) - assert ( - template.Template( - "{{ statistical_mode([], 5, default=-1) }}", hass - ).async_render() - == -1 - ) - - with pytest.raises(TemplateError): - template.Template("{{ 1 | statistical_mode }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ statistical_mode() }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ statistical_mode([]) }}", hass).async_render() - - -def test_min(hass: HomeAssistant) -> None: - """Test the min filter.""" - assert template.Template("{{ [1, 2, 3] | min }}", hass).async_render() == 1 - assert template.Template("{{ min([1, 2, 3]) }}", hass).async_render() == 1 - assert template.Template("{{ min(1, 2, 3) }}", hass).async_render() == 1 - - with pytest.raises(TemplateError): - template.Template("{{ 1 | min }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ min() }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ min(1) }}", hass).async_render() - - -def test_max(hass: HomeAssistant) -> None: - """Test the max filter.""" - assert template.Template("{{ [1, 2, 3] | max }}", hass).async_render() == 3 - assert template.Template("{{ max([1, 2, 3]) }}", hass).async_render() == 3 - assert template.Template("{{ max(1, 2, 3) }}", hass).async_render() == 3 - - with pytest.raises(TemplateError): - template.Template("{{ 1 | max }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ max() }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ max(1) }}", hass).async_render() - - -@pytest.mark.parametrize( - "attribute", - [ - "a", - "b", - "c", - ], -) -def test_min_max_attribute(hass: HomeAssistant, attribute) -> None: - """Test the min and max filters with attribute.""" - hass.states.async_set( - "test.object", - "test", - { - "objects": [ - { - "a": 1, - "b": 2, - "c": 3, - }, - { - "a": 2, - "b": 1, - "c": 2, - }, - { - "a": 3, - "b": 3, - "c": 1, - }, - ], - }, - ) - assert ( - template.Template( - f"{{{{ (state_attr('test.object', 'objects') | min(attribute='{attribute}'))['{attribute}']}}}}", - hass, - ).async_render() - == 1 - ) - assert ( - template.Template( - f"{{{{ (min(state_attr('test.object', 'objects'), attribute='{attribute}'))['{attribute}']}}}}", - hass, - ).async_render() - == 1 - ) - assert ( - template.Template( - f"{{{{ (state_attr('test.object', 'objects') | max(attribute='{attribute}'))['{attribute}']}}}}", - hass, - ).async_render() - == 3 - ) - assert ( - template.Template( - f"{{{{ (max(state_attr('test.object', 'objects'), attribute='{attribute}'))['{attribute}']}}}}", - hass, - ).async_render() - == 3 - ) - - def test_ord(hass: HomeAssistant) -> None: """Test the ord filter.""" assert template.Template('{{ "d" | ord }}', hass).async_render() == 100 @@ -3079,72 +2542,6 @@ def test_regex_findall_index(hass: HomeAssistant) -> None: assert tpl.async_render() == "LHR" -def test_bitwise_and(hass: HomeAssistant) -> None: - """Test bitwise_and method.""" - tpl = template.Template( - """ -{{ 8 | bitwise_and(8) }} - """, - hass, - ) - assert tpl.async_render() == 8 & 8 - tpl = template.Template( - """ -{{ 10 | bitwise_and(2) }} - """, - hass, - ) - assert tpl.async_render() == 10 & 2 - tpl = template.Template( - """ -{{ 8 | bitwise_and(2) }} - """, - hass, - ) - assert tpl.async_render() == 8 & 2 - - -def test_bitwise_or(hass: HomeAssistant) -> None: - """Test bitwise_or method.""" - tpl = template.Template( - """ -{{ 8 | bitwise_or(8) }} - """, - hass, - ) - assert tpl.async_render() == 8 | 8 - tpl = template.Template( - """ -{{ 10 | bitwise_or(2) }} - """, - hass, - ) - assert tpl.async_render() == 10 | 2 - tpl = template.Template( - """ -{{ 8 | bitwise_or(2) }} - """, - hass, - ) - assert tpl.async_render() == 8 | 2 - - -@pytest.mark.parametrize( - ("value", "xor_value", "expected"), - [(8, 8, 0), (10, 2, 8), (0x8000, 0xFAFA, 31482), (True, False, 1), (True, True, 0)], -) -def test_bitwise_xor( - hass: HomeAssistant, value: Any, xor_value: Any, expected: int -) -> None: - """Test bitwise_xor method.""" - assert ( - template.Template("{{ value | bitwise_xor(xor_value) }}", hass).async_render( - {"value": value, "xor_value": xor_value} - ) - == expected - ) - - def test_pack(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: """Test struct pack method.""" diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index c81c4dcd5cf392..5e31469f813648 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -572,7 +572,10 @@ async def test_register_entity_service_non_entity_service_schema( vol.Any(vol.Schema({"some": str})), ) ): - expected_message = f"The test_domain.hello_{idx} service registers an entity service with a non entity service schema" + expected_message = ( + f"The test_domain.hello_{idx} service registers " + "an entity service with a non entity service schema" + ) with pytest.raises(HomeAssistantError, match=expected_message): component.async_register_entity_service(f"hello_{idx}", schema, Mock()) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index e973de0d2b4600..9f4b6a83c805ca 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -1892,7 +1892,10 @@ async def test_register_entity_service_non_entity_service_schema( vol.Any(vol.Schema({"some": str})), ) ): - expected_message = f"The mock_platform.hello_{idx} service registers an entity service with a non entity service schema" + expected_message = ( + f"The mock_platform.hello_{idx} service registers " + "an entity service with a non entity service schema" + ) with pytest.raises(HomeAssistantError, match=expected_message): entity_platform.async_register_entity_service( f"hello_{idx}", schema, Mock()