Skip to content

Commit af67a35

Browse files
authored
Extend base jinja2 extension with hass requirement and tests (home-assistant#156403)
1 parent dd34d45 commit af67a35

File tree

2 files changed

+309
-0
lines changed

2 files changed

+309
-0
lines changed

homeassistant/helpers/template/extensions/base.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from jinja2.parser import Parser
1212

1313
if TYPE_CHECKING:
14+
from homeassistant.core import HomeAssistant
1415
from homeassistant.helpers.template import TemplateEnvironment
1516

1617

@@ -26,6 +27,7 @@ class TemplateFunction:
2627
limited_ok: bool = (
2728
True # Whether this function is available in limited environments
2829
)
30+
requires_hass: bool = False # Whether this function requires hass to be available
2931

3032

3133
class BaseTemplateExtension(Extension):
@@ -44,6 +46,10 @@ def __init__(
4446

4547
if functions:
4648
for template_func in functions:
49+
# Skip functions that require hass when hass is not available
50+
if template_func.requires_hass and self.environment.hass is None:
51+
continue
52+
4753
# Skip functions not allowed in limited environments
4854
if self.environment.limited and not template_func.limited_ok:
4955
continue
@@ -55,6 +61,24 @@ def __init__(
5561
if template_func.as_test:
5662
environment.tests[template_func.name] = template_func.func
5763

64+
@property
65+
def hass(self) -> HomeAssistant:
66+
"""Return the Home Assistant instance.
67+
68+
This property should only be used in extensions that have functions
69+
marked with requires_hass=True, as it assumes hass is not None.
70+
71+
Raises:
72+
RuntimeError: If hass is not available in the environment.
73+
"""
74+
if self.environment.hass is None:
75+
raise RuntimeError(
76+
"Home Assistant instance is not available. "
77+
"This property should only be used in extensions with "
78+
"functions marked requires_hass=True."
79+
)
80+
return self.environment.hass
81+
5882
def parse(self, parser: Parser) -> Node | list[Node]:
5983
"""Required by Jinja2 Extension base class."""
6084
return []
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
"""Test base template extension."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from homeassistant.helpers.template import TemplateEnvironment
8+
from homeassistant.helpers.template.extensions.base import (
9+
BaseTemplateExtension,
10+
TemplateFunction,
11+
)
12+
13+
14+
def test_hass_property_raises_when_hass_is_none() -> None:
15+
"""Test that accessing hass property raises RuntimeError when hass is None."""
16+
# Create an environment without hass
17+
env = TemplateEnvironment(None)
18+
19+
# Create a simple extension
20+
extension = BaseTemplateExtension(env)
21+
22+
# Accessing hass property should raise RuntimeError
23+
with pytest.raises(
24+
RuntimeError,
25+
match=(
26+
"Home Assistant instance is not available. "
27+
"This property should only be used in extensions with "
28+
"functions marked requires_hass=True."
29+
),
30+
):
31+
_ = extension.hass
32+
33+
34+
def test_requires_hass_functions_not_registered_without_hass() -> None:
35+
"""Test that functions requiring hass are not registered when hass is None."""
36+
# Create an environment without hass
37+
env = TemplateEnvironment(None)
38+
39+
# Create a test function
40+
def test_func() -> str:
41+
return "test"
42+
43+
# Create extension with a function that requires hass
44+
extension = BaseTemplateExtension(
45+
env,
46+
functions=[
47+
TemplateFunction(
48+
"test_func",
49+
test_func,
50+
as_global=True,
51+
requires_hass=True,
52+
),
53+
],
54+
)
55+
56+
# Function should not be registered
57+
assert "test_func" not in env.globals
58+
assert extension is not None # Extension is created but function not registered
59+
60+
61+
def test_requires_hass_false_functions_registered_without_hass() -> None:
62+
"""Test that functions not requiring hass are registered even when hass is None."""
63+
# Create an environment without hass
64+
env = TemplateEnvironment(None)
65+
66+
# Create a test function
67+
def test_func() -> str:
68+
return "test"
69+
70+
# Create extension with a function that does not require hass
71+
extension = BaseTemplateExtension(
72+
env,
73+
functions=[
74+
TemplateFunction(
75+
"test_func",
76+
test_func,
77+
as_global=True,
78+
requires_hass=False, # Explicitly False (default)
79+
),
80+
],
81+
)
82+
83+
# Function should be registered
84+
assert "test_func" in env.globals
85+
assert extension is not None
86+
87+
88+
def test_limited_ok_functions_not_registered_in_limited_env() -> None:
89+
"""Test that functions with limited_ok=False are not registered in limited env."""
90+
# Create a limited environment without hass
91+
env = TemplateEnvironment(None, limited=True)
92+
93+
# Create test functions
94+
def allowed_func() -> str:
95+
return "allowed"
96+
97+
def restricted_func() -> str:
98+
return "restricted"
99+
100+
# Create extension with both types of functions
101+
extension = BaseTemplateExtension(
102+
env,
103+
functions=[
104+
TemplateFunction(
105+
"allowed_func",
106+
allowed_func,
107+
as_global=True,
108+
limited_ok=True, # Allowed in limited environments
109+
),
110+
TemplateFunction(
111+
"restricted_func",
112+
restricted_func,
113+
as_global=True,
114+
limited_ok=False, # Not allowed in limited environments
115+
),
116+
],
117+
)
118+
119+
# Only the allowed function should be registered
120+
assert "allowed_func" in env.globals
121+
assert "restricted_func" not in env.globals
122+
assert extension is not None
123+
124+
125+
def test_limited_ok_true_functions_registered_in_limited_env() -> None:
126+
"""Test that functions with limited_ok=True are registered in limited env."""
127+
# Create a limited environment without hass
128+
env = TemplateEnvironment(None, limited=True)
129+
130+
# Create a test function
131+
def test_func() -> str:
132+
return "test"
133+
134+
# Create extension with a function allowed in limited environments
135+
extension = BaseTemplateExtension(
136+
env,
137+
functions=[
138+
TemplateFunction(
139+
"test_func",
140+
test_func,
141+
as_global=True,
142+
limited_ok=True, # Default is True
143+
),
144+
],
145+
)
146+
147+
# Function should be registered
148+
assert "test_func" in env.globals
149+
assert extension is not None
150+
151+
152+
def test_function_registered_as_global() -> None:
153+
"""Test that functions can be registered as globals."""
154+
env = TemplateEnvironment(None)
155+
156+
def test_func() -> str:
157+
return "global"
158+
159+
extension = BaseTemplateExtension(
160+
env,
161+
functions=[
162+
TemplateFunction(
163+
"test_func",
164+
test_func,
165+
as_global=True,
166+
),
167+
],
168+
)
169+
170+
# Function should be registered as a global
171+
assert "test_func" in env.globals
172+
assert env.globals["test_func"] is test_func
173+
assert extension is not None
174+
175+
176+
def test_function_registered_as_filter() -> None:
177+
"""Test that functions can be registered as filters."""
178+
env = TemplateEnvironment(None)
179+
180+
def test_filter(value: str) -> str:
181+
return f"filtered_{value}"
182+
183+
extension = BaseTemplateExtension(
184+
env,
185+
functions=[
186+
TemplateFunction(
187+
"test_filter",
188+
test_filter,
189+
as_filter=True,
190+
),
191+
],
192+
)
193+
194+
# Function should be registered as a filter
195+
assert "test_filter" in env.filters
196+
assert env.filters["test_filter"] is test_filter
197+
# Should not be in globals since as_global=False
198+
assert "test_filter" not in env.globals
199+
assert extension is not None
200+
201+
202+
def test_function_registered_as_test() -> None:
203+
"""Test that functions can be registered as tests."""
204+
env = TemplateEnvironment(None)
205+
206+
def test_check(value: str) -> bool:
207+
return value == "test"
208+
209+
extension = BaseTemplateExtension(
210+
env,
211+
functions=[
212+
TemplateFunction(
213+
"test_check",
214+
test_check,
215+
as_test=True,
216+
),
217+
],
218+
)
219+
220+
# Function should be registered as a test
221+
assert "test_check" in env.tests
222+
assert env.tests["test_check"] is test_check
223+
# Should not be in globals or filters
224+
assert "test_check" not in env.globals
225+
assert "test_check" not in env.filters
226+
assert extension is not None
227+
228+
229+
def test_function_registered_as_multiple_types() -> None:
230+
"""Test that functions can be registered as multiple types simultaneously."""
231+
env = TemplateEnvironment(None)
232+
233+
def multi_func(value: str = "default") -> str:
234+
return f"multi_{value}"
235+
236+
extension = BaseTemplateExtension(
237+
env,
238+
functions=[
239+
TemplateFunction(
240+
"multi_func",
241+
multi_func,
242+
as_global=True,
243+
as_filter=True,
244+
as_test=True,
245+
),
246+
],
247+
)
248+
249+
# Function should be registered in all three places
250+
assert "multi_func" in env.globals
251+
assert env.globals["multi_func"] is multi_func
252+
assert "multi_func" in env.filters
253+
assert env.filters["multi_func"] is multi_func
254+
assert "multi_func" in env.tests
255+
assert env.tests["multi_func"] is multi_func
256+
assert extension is not None
257+
258+
259+
def test_multiple_functions_registered() -> None:
260+
"""Test that multiple functions can be registered at once."""
261+
env = TemplateEnvironment(None)
262+
263+
def func1() -> str:
264+
return "one"
265+
266+
def func2() -> str:
267+
return "two"
268+
269+
def func3() -> str:
270+
return "three"
271+
272+
extension = BaseTemplateExtension(
273+
env,
274+
functions=[
275+
TemplateFunction("func1", func1, as_global=True),
276+
TemplateFunction("func2", func2, as_filter=True),
277+
TemplateFunction("func3", func3, as_test=True),
278+
],
279+
)
280+
281+
# All functions should be registered in their respective places
282+
assert "func1" in env.globals
283+
assert "func2" in env.filters
284+
assert "func3" in env.tests
285+
assert extension is not None

0 commit comments

Comments
 (0)