From 43f40c6f0e5df33abd9ca95f2e35f8b69c95f231 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 23 Nov 2025 19:14:46 +0100 Subject: [PATCH] Extract issue template functions into an issues Jinja2 extension (#157116) --- homeassistant/helpers/template/__init__.py | 32 +---- .../helpers/template/extensions/__init__.py | 2 + .../helpers/template/extensions/issues.py | 54 ++++++++ .../template/extensions/test_issues.py | 119 ++++++++++++++++++ tests/helpers/template/test_init.py | 118 +---------------- 5 files changed, 178 insertions(+), 147 deletions(-) create mode 100644 homeassistant/helpers/template/extensions/issues.py create mode 100644 tests/helpers/template/extensions/test_issues.py diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index 0e157bf8f9c..b54b462594b 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -55,11 +55,7 @@ from homeassistant.core import ( valid_entity_id, ) from homeassistant.exceptions import TemplateError -from homeassistant.helpers import ( - entity_registry as er, - issue_registry as ir, - location as loc_helper, -) +from homeassistant.helpers import entity_registry as er, location as loc_helper from homeassistant.helpers.singleton import singleton from homeassistant.helpers.translation import async_translate_state from homeassistant.helpers.typing import TemplateVarsType @@ -1223,25 +1219,6 @@ def config_entry_attr( return getattr(config_entry, attr_name) -def issues(hass: HomeAssistant) -> dict[tuple[str, str], dict[str, Any]]: - """Return all open issues.""" - current_issues = ir.async_get(hass).issues - # Use JSON for safe representation - return { - key: issue_entry.to_json() - for (key, issue_entry) in current_issues.items() - if issue_entry.active - } - - -def issue(hass: HomeAssistant, domain: str, issue_id: str) -> dict[str, Any] | None: - """Get issue by domain and issue_id.""" - result = ir.async_get(hass).async_get_issue(domain, issue_id) - if result: - return result.to_json() - return None - - def closest(hass: HomeAssistant, *args: Any) -> State | None: """Find closest entity. @@ -1896,6 +1873,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): ) self.add_extension("homeassistant.helpers.template.extensions.DeviceExtension") self.add_extension("homeassistant.helpers.template.extensions.FloorExtension") + self.add_extension("homeassistant.helpers.template.extensions.IssuesExtension") self.add_extension("homeassistant.helpers.template.extensions.LabelExtension") self.add_extension("homeassistant.helpers.template.extensions.MathExtension") self.add_extension("homeassistant.helpers.template.extensions.RegexExtension") @@ -1982,12 +1960,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["config_entry_id"] = hassfunction(config_entry_id) self.filters["config_entry_id"] = self.globals["config_entry_id"] - # Issue extensions - - self.globals["issues"] = hassfunction(issues) - self.globals["issue"] = hassfunction(issue) - self.filters["issue"] = self.globals["issue"] - if limited: # Only device_entities is available to limited templates, mark other # functions and filters as unsupported. diff --git a/homeassistant/helpers/template/extensions/__init__.py b/homeassistant/helpers/template/extensions/__init__.py index 8254efc6b14..588d5aaf38b 100644 --- a/homeassistant/helpers/template/extensions/__init__.py +++ b/homeassistant/helpers/template/extensions/__init__.py @@ -7,6 +7,7 @@ from .crypto import CryptoExtension from .datetime import DateTimeExtension from .devices import DeviceExtension from .floors import FloorExtension +from .issues import IssuesExtension from .labels import LabelExtension from .math import MathExtension from .regex import RegexExtension @@ -20,6 +21,7 @@ __all__ = [ "DateTimeExtension", "DeviceExtension", "FloorExtension", + "IssuesExtension", "LabelExtension", "MathExtension", "RegexExtension", diff --git a/homeassistant/helpers/template/extensions/issues.py b/homeassistant/helpers/template/extensions/issues.py new file mode 100644 index 00000000000..245650c5abb --- /dev/null +++ b/homeassistant/helpers/template/extensions/issues.py @@ -0,0 +1,54 @@ +"""Issue functions for Home Assistant templates.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.helpers import issue_registry as ir + +from .base import BaseTemplateExtension, TemplateFunction + +if TYPE_CHECKING: + from homeassistant.helpers.template import TemplateEnvironment + + +class IssuesExtension(BaseTemplateExtension): + """Extension for issue-related template functions.""" + + def __init__(self, environment: TemplateEnvironment) -> None: + """Initialize the issues extension.""" + super().__init__( + environment, + functions=[ + TemplateFunction( + "issues", + self.issues, + as_global=True, + requires_hass=True, + ), + TemplateFunction( + "issue", + self.issue, + as_global=True, + as_filter=True, + requires_hass=True, + ), + ], + ) + + def issues(self) -> dict[tuple[str, str], dict[str, Any]]: + """Return all open issues.""" + current_issues = ir.async_get(self.hass).issues + # Use JSON for safe representation + return { + key: issue_entry.to_json() + for (key, issue_entry) in current_issues.items() + if issue_entry.active + } + + def issue(self, domain: str, issue_id: str) -> dict[str, Any] | None: + """Get issue by domain and issue_id.""" + result = ir.async_get(self.hass).async_get_issue(domain, issue_id) + if result: + return result.to_json() + return None diff --git a/tests/helpers/template/extensions/test_issues.py b/tests/helpers/template/extensions/test_issues.py new file mode 100644 index 00000000000..69b3f4b7cf7 --- /dev/null +++ b/tests/helpers/template/extensions/test_issues.py @@ -0,0 +1,119 @@ +"""Test issue template functions.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.util import dt as dt_util + +from tests.helpers.template.helpers import assert_result_info, render_to_info + + +async def test_issues(hass: HomeAssistant, issue_registry: ir.IssueRegistry) -> None: + """Test issues function.""" + # Test no issues + info = render_to_info(hass, "{{ issues() }}") + assert_result_info(info, {}) + assert info.rate_limit is None + + # Test persistent issue + ir.async_create_issue( + hass, + "test", + "issue 1", + breaks_in_ha_version="2023.7", + is_fixable=True, + is_persistent=True, + learn_more_url="https://theuselessweb.com", + severity="error", + translation_key="abc_1234", + translation_placeholders={"abc": "123"}, + ) + await hass.async_block_till_done() + created_issue = issue_registry.async_get_issue("test", "issue 1") + info = render_to_info(hass, "{{ issues()['test', 'issue 1'] }}") + assert_result_info(info, created_issue.to_json()) + assert info.rate_limit is None + + # Test fixed issue + ir.async_delete_issue(hass, "test", "issue 1") + await hass.async_block_till_done() + info = render_to_info(hass, "{{ issues() }}") + assert_result_info(info, {}) + assert info.rate_limit is None + + issue = ir.IssueEntry( + active=False, + breaks_in_ha_version="2025.12", + created=dt_util.utcnow(), + data=None, + dismissed_version=None, + domain="test", + is_fixable=False, + is_persistent=False, + issue_domain="test", + issue_id="issue 2", + learn_more_url=None, + severity="warning", + translation_key="abc_1234", + translation_placeholders={"abc": "123"}, + ) + # Add non active issue + issue_registry.issues[("test", "issue 2")] = issue + # Test non active issue is omitted + issue_entry = issue_registry.async_get_issue("test", "issue 2") + assert issue_entry + issue_2_created = issue_entry.created + assert issue_entry and not issue_entry.active + info = render_to_info(hass, "{{ issues() }}") + assert_result_info(info, {}) + assert info.rate_limit is None + + # Load and activate the issue + ir.async_create_issue( + hass=hass, + breaks_in_ha_version="2025.12", + data=None, + domain="test", + is_fixable=False, + is_persistent=False, + issue_domain="test", + issue_id="issue 2", + learn_more_url=None, + severity="warning", + translation_key="abc_1234", + translation_placeholders={"abc": "123"}, + ) + activated_issue_entry = issue_registry.async_get_issue("test", "issue 2") + assert activated_issue_entry and activated_issue_entry.active + assert issue_2_created == activated_issue_entry.created + info = render_to_info(hass, "{{ issues()['test', 'issue 2'] }}") + assert_result_info(info, activated_issue_entry.to_json()) + assert info.rate_limit is None + + +async def test_issue(hass: HomeAssistant, issue_registry: ir.IssueRegistry) -> None: + """Test issue function.""" + # Test non existent issue + info = render_to_info(hass, "{{ issue('non_existent', 'issue') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test existing issue + ir.async_create_issue( + hass, + "test", + "issue 1", + breaks_in_ha_version="2023.7", + is_fixable=True, + is_persistent=True, + learn_more_url="https://theuselessweb.com", + severity="error", + translation_key="abc_1234", + translation_placeholders={"abc": "123"}, + ) + await hass.async_block_till_done() + created_issue = issue_registry.async_get_issue("test", "issue 1") + info = render_to_info(hass, "{{ issue('test', 'issue 1') }}") + assert_result_info(info, created_issue.to_json()) + assert info.rate_limit is None diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index ce2c08c90aa..8189115f539 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -33,13 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError -from homeassistant.helpers import ( - entity, - entity_registry as er, - issue_registry as ir, - template, - translation, -) +from homeassistant.helpers import entity, entity_registry as er, template, translation from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.json import json_dumps from homeassistant.helpers.template.render_info import ( @@ -1762,116 +1756,6 @@ async def test_config_entry_attr(hass: HomeAssistant) -> None: ) -async def test_issues(hass: HomeAssistant, issue_registry: ir.IssueRegistry) -> None: - """Test issues function.""" - # Test no issues - info = render_to_info(hass, "{{ issues() }}") - assert_result_info(info, {}) - assert info.rate_limit is None - - # Test persistent issue - ir.async_create_issue( - hass, - "test", - "issue 1", - breaks_in_ha_version="2023.7", - is_fixable=True, - is_persistent=True, - learn_more_url="https://theuselessweb.com", - severity="error", - translation_key="abc_1234", - translation_placeholders={"abc": "123"}, - ) - await hass.async_block_till_done() - created_issue = issue_registry.async_get_issue("test", "issue 1") - info = render_to_info(hass, "{{ issues()['test', 'issue 1'] }}") - assert_result_info(info, created_issue.to_json()) - assert info.rate_limit is None - - # Test fixed issue - ir.async_delete_issue(hass, "test", "issue 1") - await hass.async_block_till_done() - info = render_to_info(hass, "{{ issues() }}") - assert_result_info(info, {}) - assert info.rate_limit is None - - issue = ir.IssueEntry( - active=False, - breaks_in_ha_version="2025.12", - created=dt_util.utcnow(), - data=None, - dismissed_version=None, - domain="test", - is_fixable=False, - is_persistent=False, - issue_domain="test", - issue_id="issue 2", - learn_more_url=None, - severity="warning", - translation_key="abc_1234", - translation_placeholders={"abc": "123"}, - ) - # Add non active issue - issue_registry.issues[("test", "issue 2")] = issue - # Test non active issue is omitted - issue_entry = issue_registry.async_get_issue("test", "issue 2") - assert issue_entry - issue_2_created = issue_entry.created - assert issue_entry and not issue_entry.active - info = render_to_info(hass, "{{ issues() }}") - assert_result_info(info, {}) - assert info.rate_limit is None - - # Load and activate the issue - ir.async_create_issue( - hass=hass, - breaks_in_ha_version="2025.12", - data=None, - domain="test", - is_fixable=False, - is_persistent=False, - issue_domain="test", - issue_id="issue 2", - learn_more_url=None, - severity="warning", - translation_key="abc_1234", - translation_placeholders={"abc": "123"}, - ) - activated_issue_entry = issue_registry.async_get_issue("test", "issue 2") - assert activated_issue_entry and activated_issue_entry.active - assert issue_2_created == activated_issue_entry.created - info = render_to_info(hass, "{{ issues()['test', 'issue 2'] }}") - assert_result_info(info, activated_issue_entry.to_json()) - assert info.rate_limit is None - - -async def test_issue(hass: HomeAssistant, issue_registry: ir.IssueRegistry) -> None: - """Test issue function.""" - # Test non existent issue - info = render_to_info(hass, "{{ issue('non_existent', 'issue') }}") - assert_result_info(info, None) - assert info.rate_limit is None - - # Test existing issue - ir.async_create_issue( - hass, - "test", - "issue 1", - breaks_in_ha_version="2023.7", - is_fixable=True, - is_persistent=True, - learn_more_url="https://theuselessweb.com", - severity="error", - translation_key="abc_1234", - translation_placeholders={"abc": "123"}, - ) - await hass.async_block_till_done() - created_issue = issue_registry.async_get_issue("test", "issue 1") - info = render_to_info(hass, "{{ issue('test', 'issue 1') }}") - assert_result_info(info, created_issue.to_json()) - assert info.rate_limit is None - - def test_closest_function_to_coord(hass: HomeAssistant) -> None: """Test closest function to coord.""" hass.states.async_set(