Jinja filter and function for median and statistical_mode (#105554)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
Martijn van der Pol 2023-12-27 15:14:20 +01:00 committed by GitHub
parent ed3ea5e5f4
commit a823edf1c2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 150 additions and 0 deletions

View file

@ -1908,6 +1908,66 @@ def average(*args: Any, default: Any = _SENTINEL) -> Any:
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:
@ -2390,6 +2450,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.filters["from_json"] = from_json
self.filters["is_defined"] = fail_when_undefined
self.filters["average"] = average
self.filters["median"] = median
self.filters["statistical_mode"] = statistical_mode
self.filters["random"] = random_every_time
self.filters["base64_encode"] = base64_encode
self.filters["base64_decode"] = base64_decode
@ -2412,6 +2474,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.filters["bool"] = forgiving_boolean
self.filters["version"] = version
self.filters["contains"] = contains
self.filters["median"] = median
self.filters["statistical_mode"] = statistical_mode
self.globals["log"] = logarithm
self.globals["sin"] = sine
self.globals["cos"] = cosine
@ -2433,6 +2497,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.globals["strptime"] = strptime
self.globals["urlencode"] = urlencode
self.globals["average"] = average
self.globals["median"] = median
self.globals["statistical_mode"] = statistical_mode
self.globals["max"] = min_max_from_filter(self.filters["max"], "max")
self.globals["min"] = min_max_from_filter(self.filters["min"], "min")
self.globals["is_number"] = is_number
@ -2445,6 +2511,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.globals["iif"] = iif
self.globals["bool"] = forgiving_boolean
self.globals["version"] = version
self.globals["median"] = median
self.globals["statistical_mode"] = statistical_mode
self.tests["is_number"] = is_number
self.tests["list"] = _is_list
self.tests["set"] = _is_set

View file

@ -1318,6 +1318,88 @@ def test_average(hass: HomeAssistant) -> None:
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