From c34af4be86a63d6948b5f55af041602244cca9da Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 17 Sep 2025 00:47:00 +0200 Subject: [PATCH 1/4] Add active built-in and custom integrations to Cloud support package (#152452) --- homeassistant/components/cloud/http_api.py | 109 ++++++++ .../cloud/snapshots/test_http_api.ambr | 188 ++++++++++++++ tests/components/cloud/test_http_api.py | 234 ++++++++++++++++++ 3 files changed, 531 insertions(+) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 49e4af9e3e50d8..4a8a569a5a6bc0 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -37,6 +37,10 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.loader import ( + async_get_custom_components, + async_get_loaded_integration, +) from homeassistant.util.location import async_detect_location_info from .alexa_config import entity_supported as entity_supported_by_alexa @@ -431,6 +435,79 @@ class DownloadSupportPackageView(HomeAssistantView): url = "/api/cloud/support_package" name = "api:cloud:support_package" + async def _get_integration_info(self, hass: HomeAssistant) -> dict[str, Any]: + """Collect information about active and custom integrations.""" + # Get loaded components from hass.config.components + loaded_components = hass.config.components.copy() + + # Get custom integrations + custom_domains = set() + with suppress(Exception): + custom_domains = set(await async_get_custom_components(hass)) + + # Separate built-in and custom integrations + builtin_integrations = [] + custom_integrations = [] + + for domain in sorted(loaded_components): + try: + integration = async_get_loaded_integration(hass, domain) + except Exception: # noqa: BLE001 + # Broad exception catch for robustness in support package + # generation. If we can't get integration info, + # just add the domain + if domain in custom_domains: + custom_integrations.append( + { + "domain": domain, + "name": "Unknown", + "version": "Unknown", + "documentation": "Unknown", + } + ) + else: + builtin_integrations.append( + { + "domain": domain, + "name": "Unknown", + } + ) + else: + if domain in custom_domains: + # This is a custom integration + # include version and documentation link + version = ( + str(integration.version) if integration.version else "Unknown" + ) + if not (documentation := integration.documentation): + documentation = "Unknown" + + custom_integrations.append( + { + "domain": domain, + "name": integration.name, + "version": version, + "documentation": documentation, + } + ) + else: + # This is a built-in integration. + # No version needed, as it is always the same as the + # Home Assistant version + builtin_integrations.append( + { + "domain": domain, + "name": integration.name, + } + ) + + return { + "builtin_count": len(builtin_integrations), + "builtin_integrations": builtin_integrations, + "custom_count": len(custom_integrations), + "custom_integrations": custom_integrations, + } + async def _generate_markdown( self, hass: HomeAssistant, @@ -453,6 +530,38 @@ def get_domain_table_markdown(domain_info: dict[str, Any]) -> str: markdown = "## System Information\n\n" markdown += get_domain_table_markdown(hass_info) + # Add integration information + try: + integration_info = await self._get_integration_info(hass) + except Exception: # noqa: BLE001 + # Broad exception catch for robustness in support package generation + # If there's any error getting integration info, just note it + markdown += "## Active integrations\n\n" + markdown += "Unable to collect integration information\n\n" + else: + markdown += "## Active Integrations\n\n" + markdown += f"Built-in integrations: {integration_info['builtin_count']}\n" + markdown += f"Custom integrations: {integration_info['custom_count']}\n\n" + + # Built-in integrations + if integration_info["builtin_integrations"]: + markdown += "
Built-in integrations\n\n" + markdown += "Domain | Name\n" + markdown += "--- | ---\n" + for integration in integration_info["builtin_integrations"]: + markdown += f"{integration['domain']} | {integration['name']}\n" + markdown += "\n
\n\n" + + # Custom integrations + if integration_info["custom_integrations"]: + markdown += "
Custom integrations\n\n" + markdown += "Domain | Name | Version | Documentation\n" + markdown += "--- | --- | --- | ---\n" + for integration in integration_info["custom_integrations"]: + doc_url = integration.get("documentation") or "N/A" + markdown += f"{integration['domain']} | {integration['name']} | {integration['version']} | {doc_url}\n" + markdown += "\n
\n\n" + for domain, domain_info in domains_info.items(): domain_info_md = get_domain_table_markdown(domain_info) markdown += ( diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr index 52c544dc541e53..9e1f68e23f8676 100644 --- a/tests/components/cloud/snapshots/test_http_api.ambr +++ b/tests/components/cloud/snapshots/test_http_api.ambr @@ -19,6 +19,193 @@ timezone | US/Pacific config_dir | config + ## Active Integrations + + Built-in integrations: 15 + Custom integrations: 1 + +
Built-in integrations + + Domain | Name + --- | --- + auth | Auth + binary_sensor | Binary Sensor + cloud | Home Assistant Cloud + cloud.binary_sensor | Unknown + cloud.stt | Unknown + cloud.tts | Unknown + ffmpeg | FFmpeg + homeassistant | Home Assistant Core Integration + http | HTTP + mock_no_info_integration | mock_no_info_integration + repairs | Repairs + stt | Speech-to-text (STT) + system_health | System Health + tts | Text-to-speech (TTS) + webhook | Webhook + +
+ +
Custom integrations + + Domain | Name | Version | Documentation + --- | --- | --- | --- + test | Test Components | 1.2.3 | http://example.com + +
+ +
mock_no_info_integration + + No information available +
+ +
cloud + + logged_in | True + --- | --- + subscription_expiration | 2025-01-17T11:19:31+00:00 + relayer_connected | True + relayer_region | xx-earth-616 + remote_enabled | True + remote_connected | False + alexa_enabled | True + google_enabled | False + cloud_ice_servers_enabled | True + remote_server | us-west-1 + certificate_status | ready + instance_id | 12345678901234567890 + can_reach_cert_server | Exception: Unexpected exception + can_reach_cloud_auth | Failed: unreachable + can_reach_cloud | ok + +
+ + ## Full logs + +
Logs + + ```logs + 2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] Hass nabucasa log + 2025-02-10 12:00:00.000 WARNING (MainThread) [snitun.utils.aiohttp_client] Snitun log + 2025-02-10 12:00:00.000 ERROR (MainThread) [homeassistant.components.cloud.client] Cloud log + ``` + +
+ + ''' +# --- +# name: test_download_support_package_custom_components_error + ''' + ## System Information + + version | core-2025.2.0 + --- | --- + installation_type | Home Assistant Core + dev | False + hassio | False + docker | False + container_arch | None + user | hass + virtualenv | False + python_version | 3.13.1 + os_name | Linux + os_version | 6.12.9 + arch | x86_64 + timezone | US/Pacific + config_dir | config + + ## Active Integrations + + Built-in integrations: 15 + Custom integrations: 0 + +
Built-in integrations + + Domain | Name + --- | --- + auth | Auth + binary_sensor | Binary Sensor + cloud | Home Assistant Cloud + cloud.binary_sensor | Unknown + cloud.stt | Unknown + cloud.tts | Unknown + ffmpeg | FFmpeg + homeassistant | Home Assistant Core Integration + http | HTTP + mock_no_info_integration | mock_no_info_integration + repairs | Repairs + stt | Speech-to-text (STT) + system_health | System Health + tts | Text-to-speech (TTS) + webhook | Webhook + +
+ +
mock_no_info_integration + + No information available +
+ +
cloud + + logged_in | True + --- | --- + subscription_expiration | 2025-01-17T11:19:31+00:00 + relayer_connected | True + relayer_region | xx-earth-616 + remote_enabled | True + remote_connected | False + alexa_enabled | True + google_enabled | False + cloud_ice_servers_enabled | True + remote_server | us-west-1 + certificate_status | ready + instance_id | 12345678901234567890 + can_reach_cert_server | Exception: Unexpected exception + can_reach_cloud_auth | Failed: unreachable + can_reach_cloud | ok + +
+ + ## Full logs + +
Logs + + ```logs + 2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] This message will be dropped since this test patches MAX_RECORDS + 2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] Hass nabucasa log + 2025-02-10 12:00:00.000 WARNING (MainThread) [snitun.utils.aiohttp_client] Snitun log + 2025-02-10 12:00:00.000 ERROR (MainThread) [homeassistant.components.cloud.client] Cloud log + ``` + +
+ + ''' +# --- +# name: test_download_support_package_integration_load_error + ''' + ## System Information + + version | core-2025.2.0 + --- | --- + installation_type | Home Assistant Core + dev | False + hassio | False + docker | False + container_arch | None + user | hass + virtualenv | False + python_version | 3.13.1 + os_name | Linux + os_version | 6.12.9 + arch | x86_64 + timezone | US/Pacific + config_dir | config + + ## Active integrations + + Unable to collect integration information +
mock_no_info_integration No information available @@ -50,6 +237,7 @@
Logs ```logs + 2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] This message will be dropped since this test patches MAX_RECORDS 2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] Hass nabucasa log 2025-02-10 12:00:00.000 WARNING (MainThread) [snitun.utils.aiohttp_client] Snitun log 2025-02-10 12:00:00.000 ERROR (MainThread) [homeassistant.components.cloud.client] Cloud log diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 96927477b0a151..5256ff8a5099db 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -36,6 +36,7 @@ from homeassistant.components.websocket_api import ERR_INVALID_FORMAT from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er +from homeassistant.loader import async_get_loaded_integration from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.location import LocationInfo @@ -1840,6 +1841,7 @@ async def test_logout_view_dispatch_event( @patch("homeassistant.components.cloud.helpers.FixedSizeQueueLogHandler.MAX_RECORDS", 3) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_download_support_package( hass: HomeAssistant, cloud: MagicMock, @@ -1875,6 +1877,9 @@ async def mock_empty_info(hass: HomeAssistant) -> dict[str, Any]: ) hass.config.components.add("mock_no_info_integration") + # Add mock custom integration for testing + hass.config.components.add("test") # This is a custom integration from the fixture + assert await async_setup_component(hass, "system_health", {}) with patch("uuid.UUID.hex", new_callable=PropertyMock) as hexmock: @@ -1947,3 +1952,232 @@ async def mock_empty_info(hass: HomeAssistant) -> dict[str, Any]: req = await cloud_client.get("/api/cloud/support_package") assert req.status == HTTPStatus.OK assert await req.text() == snapshot + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_download_support_package_custom_components_error( + hass: HomeAssistant, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test download support package when async_get_custom_components fails.""" + + aioclient_mock.get("https://cloud.bla.com/status", text="") + aioclient_mock.get( + "https://cert-server/directory", exc=Exception("Unexpected exception") + ) + aioclient_mock.get( + "https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json", + exc=aiohttp.ClientError, + ) + + def async_register_mock_platform( + hass: HomeAssistant, register: system_health.SystemHealthRegistration + ) -> None: + async def mock_empty_info(hass: HomeAssistant) -> dict[str, Any]: + return {} + + register.async_register_info(mock_empty_info, "/config/mock_integration") + + mock_platform( + hass, + "mock_no_info_integration.system_health", + MagicMock(async_register=async_register_mock_platform), + ) + hass.config.components.add("mock_no_info_integration") + + assert await async_setup_component(hass, "system_health", {}) + + with patch("uuid.UUID.hex", new_callable=PropertyMock) as hexmock: + hexmock.return_value = "12345678901234567890" + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "user_pool_id": "AAAA", + "region": "us-east-1", + "acme_server": "cert-server", + "relayer_server": "cloud.bla.com", + }, + }, + ) + await hass.async_block_till_done() + + await cloud.login("test-user", "test-pass") + + cloud.remote.snitun_server = "us-west-1" + cloud.remote.certificate_status = CertificateStatus.READY + cloud.expiration_date = dt_util.parse_datetime("2025-01-17T11:19:31.0+00:00") + + await cloud.client.async_system_message({"region": "xx-earth-616"}) + await set_cloud_prefs( + { + "alexa_enabled": True, + "google_enabled": False, + "remote_enabled": True, + "cloud_ice_servers_enabled": True, + } + ) + + now = dt_util.utcnow() + tz = now.astimezone().tzinfo + freezer.move_to(datetime.datetime(2025, 2, 10, 12, 0, 0, tzinfo=tz)) + logging.getLogger("hass_nabucasa.iot").info( + "This message will be dropped since this test patches MAX_RECORDS" + ) + logging.getLogger("hass_nabucasa.iot").info("Hass nabucasa log") + logging.getLogger("snitun.utils.aiohttp_client").warning("Snitun log") + logging.getLogger("homeassistant.components.cloud.client").error("Cloud log") + freezer.move_to(now) + + cloud_client = await hass_client() + with ( + patch.object(hass.config, "config_dir", new="config"), + patch( + "homeassistant.components.homeassistant.system_health.system_info.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Core", + "version": "2025.2.0", + "dev": False, + "hassio": False, + "virtualenv": False, + "python_version": "3.13.1", + "docker": False, + "container_arch": None, + "arch": "x86_64", + "timezone": "US/Pacific", + "os_name": "Linux", + "os_version": "6.12.9", + "user": "hass", + }, + ), + patch( + "homeassistant.components.cloud.http_api.async_get_custom_components", + side_effect=Exception("Custom components error"), + ), + ): + req = await cloud_client.get("/api/cloud/support_package") + assert req.status == HTTPStatus.OK + assert await req.text() == snapshot + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_download_support_package_integration_load_error( + hass: HomeAssistant, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test download support package when async_get_loaded_integration fails.""" + + aioclient_mock.get("https://cloud.bla.com/status", text="") + aioclient_mock.get( + "https://cert-server/directory", exc=Exception("Unexpected exception") + ) + aioclient_mock.get( + "https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json", + exc=aiohttp.ClientError, + ) + + def async_register_mock_platform( + hass: HomeAssistant, register: system_health.SystemHealthRegistration + ) -> None: + async def mock_empty_info(hass: HomeAssistant) -> dict[str, Any]: + return {} + + register.async_register_info(mock_empty_info, "/config/mock_integration") + + mock_platform( + hass, + "mock_no_info_integration.system_health", + MagicMock(async_register=async_register_mock_platform), + ) + hass.config.components.add("mock_no_info_integration") + # Add a component that will fail to load integration info + hass.config.components.add("test") # This is a custom integration from the fixture + hass.config.components.add("failing_integration") + + assert await async_setup_component(hass, "system_health", {}) + + with patch("uuid.UUID.hex", new_callable=PropertyMock) as hexmock: + hexmock.return_value = "12345678901234567890" + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "user_pool_id": "AAAA", + "region": "us-east-1", + "acme_server": "cert-server", + "relayer_server": "cloud.bla.com", + }, + }, + ) + await hass.async_block_till_done() + + await cloud.login("test-user", "test-pass") + + cloud.remote.snitun_server = "us-west-1" + cloud.remote.certificate_status = CertificateStatus.READY + cloud.expiration_date = dt_util.parse_datetime("2025-01-17T11:19:31.0+00:00") + + await cloud.client.async_system_message({"region": "xx-earth-616"}) + await set_cloud_prefs( + { + "alexa_enabled": True, + "google_enabled": False, + "remote_enabled": True, + "cloud_ice_servers_enabled": True, + } + ) + + now = dt_util.utcnow() + tz = now.astimezone().tzinfo + freezer.move_to(datetime.datetime(2025, 2, 10, 12, 0, 0, tzinfo=tz)) + logging.getLogger("hass_nabucasa.iot").info( + "This message will be dropped since this test patches MAX_RECORDS" + ) + logging.getLogger("hass_nabucasa.iot").info("Hass nabucasa log") + logging.getLogger("snitun.utils.aiohttp_client").warning("Snitun log") + logging.getLogger("homeassistant.components.cloud.client").error("Cloud log") + freezer.move_to(now) + + cloud_client = await hass_client() + with ( + patch.object(hass.config, "config_dir", new="config"), + patch( + "homeassistant.components.homeassistant.system_health.system_info.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Core", + "version": "2025.2.0", + "dev": False, + "hassio": False, + "virtualenv": False, + "python_version": "3.13.1", + "docker": False, + "container_arch": None, + "arch": "x86_64", + "timezone": "US/Pacific", + "os_name": "Linux", + "os_version": "6.12.9", + "user": "hass", + }, + ), + patch( + "homeassistant.components.cloud.http_api.async_get_loaded_integration", + side_effect=lambda hass, domain: Exception("Integration load error") + if domain == "failing_integration" + else async_get_loaded_integration(hass, domain), + ), + ): + req = await cloud_client.get("/api/cloud/support_package") + assert req.status == HTTPStatus.OK + assert await req.text() == snapshot From 4a4c124181dd517f494815e5a009c7965b9d9000 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 17 Sep 2025 00:48:50 +0200 Subject: [PATCH 2/4] Refactor template engine: Extract collection & data structure functions into CollectionExtension (#152446) --- homeassistant/helpers/template/__init__.py | 135 +---- .../helpers/template/extensions/__init__.py | 2 + .../helpers/template/extensions/collection.py | 191 +++++++ .../template/extensions/test_collection.py | 357 +++++++++++++ tests/helpers/template/test_init.py | 501 ++---------------- 5 files changed, 588 insertions(+), 598 deletions(-) create mode 100644 homeassistant/helpers/template/extensions/collection.py create mode 100644 tests/helpers/template/extensions/test_collection.py diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index b876cb2c6aed71..4d9581444dda66 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -5,7 +5,7 @@ from ast import literal_eval import asyncio import collections.abc -from collections.abc import Callable, Generator, Iterable, MutableSequence +from collections.abc import Callable, Generator, Iterable from contextlib import AbstractContextManager from contextvars import ContextVar from copy import deepcopy @@ -2245,31 +2245,6 @@ def is_number(value): return True -def _is_list(value: Any) -> bool: - """Return whether a value is a list.""" - return isinstance(value, list) - - -def _is_set(value: Any) -> bool: - """Return whether a value is a set.""" - return isinstance(value, set) - - -def _is_tuple(value: Any) -> bool: - """Return whether a value is a tuple.""" - return isinstance(value, tuple) - - -def _to_set(value: Any) -> set[Any]: - """Convert value to set.""" - return set(value) - - -def _to_tuple(value): - """Convert value to tuple.""" - return tuple(value) - - def _is_datetime(value: Any) -> bool: """Return whether a value is a datetime.""" return isinstance(value, datetime) @@ -2487,98 +2462,11 @@ def iif( return if_false -def shuffle(*args: Any, seed: Any = None) -> MutableSequence[Any]: - """Shuffle a list, either with a seed or without.""" - if not args: - raise TypeError("shuffle expected at least 1 argument, got 0") - - # If first argument is iterable and more than 1 argument provided - # but not a named seed, then use 2nd argument as seed. - if isinstance(args[0], Iterable): - items = list(args[0]) - if len(args) > 1 and seed is None: - seed = args[1] - elif len(args) == 1: - raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") - else: - items = list(args) - - if seed: - r = random.Random(seed) - r.shuffle(items) - else: - random.shuffle(items) - return items - - def typeof(value: Any) -> Any: """Return the type of value passed to debug types.""" return value.__class__.__name__ -def flatten(value: Iterable[Any], levels: int | None = None) -> list[Any]: - """Flattens list of lists.""" - if not isinstance(value, Iterable) or isinstance(value, str): - raise TypeError(f"flatten expected a list, got {type(value).__name__}") - - flattened: list[Any] = [] - for item in value: - if isinstance(item, Iterable) and not isinstance(item, str): - if levels is None: - flattened.extend(flatten(item)) - elif levels >= 1: - flattened.extend(flatten(item, levels=(levels - 1))) - else: - flattened.append(item) - else: - flattened.append(item) - return flattened - - -def intersect(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: - """Return the common elements between two lists.""" - if not isinstance(value, Iterable) or isinstance(value, str): - raise TypeError(f"intersect expected a list, got {type(value).__name__}") - if not isinstance(other, Iterable) or isinstance(other, str): - raise TypeError(f"intersect expected a list, got {type(other).__name__}") - - return list(set(value) & set(other)) - - -def difference(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: - """Return elements in first list that are not in second list.""" - if not isinstance(value, Iterable) or isinstance(value, str): - raise TypeError(f"difference expected a list, got {type(value).__name__}") - if not isinstance(other, Iterable) or isinstance(other, str): - raise TypeError(f"difference expected a list, got {type(other).__name__}") - - return list(set(value) - set(other)) - - -def union(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: - """Return all unique elements from both lists combined.""" - if not isinstance(value, Iterable) or isinstance(value, str): - raise TypeError(f"union expected a list, got {type(value).__name__}") - if not isinstance(other, Iterable) or isinstance(other, str): - raise TypeError(f"union expected a list, got {type(other).__name__}") - - return list(set(value) | set(other)) - - -def symmetric_difference(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: - """Return elements that are in either list but not in both.""" - if not isinstance(value, Iterable) or isinstance(value, str): - raise TypeError( - f"symmetric_difference expected a list, got {type(value).__name__}" - ) - if not isinstance(other, Iterable) or isinstance(other, str): - raise TypeError( - f"symmetric_difference expected a list, got {type(other).__name__}" - ) - - return list(set(value) ^ set(other)) - - def combine(*args: Any, recursive: bool = False) -> dict[Any, Any]: """Combine multiple dictionaries into one.""" if not args: @@ -2760,11 +2648,15 @@ def __init__( self.add_extension("jinja2.ext.loopcontrols") self.add_extension("jinja2.ext.do") self.add_extension("homeassistant.helpers.template.extensions.Base64Extension") + self.add_extension( + "homeassistant.helpers.template.extensions.CollectionExtension" + ) self.add_extension("homeassistant.helpers.template.extensions.CryptoExtension") self.add_extension("homeassistant.helpers.template.extensions.MathExtension") self.add_extension("homeassistant.helpers.template.extensions.RegexExtension") self.add_extension("homeassistant.helpers.template.extensions.StringExtension") + self.globals["apply"] = apply self.globals["as_datetime"] = as_datetime self.globals["as_function"] = as_function self.globals["as_local"] = dt_util.as_local @@ -2772,23 +2664,15 @@ def __init__( self.globals["as_timestamp"] = forgiving_as_timestamp self.globals["bool"] = forgiving_boolean self.globals["combine"] = combine - self.globals["difference"] = difference - 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["merge_response"] = merge_response self.globals["pack"] = struct_pack - self.globals["set"] = _to_set - self.globals["shuffle"] = shuffle self.globals["strptime"] = strptime - self.globals["symmetric_difference"] = symmetric_difference self.globals["timedelta"] = timedelta - self.globals["tuple"] = _to_tuple self.globals["typeof"] = typeof - self.globals["union"] = union self.globals["unpack"] = struct_unpack self.globals["version"] = version self.globals["zip"] = zip @@ -2803,14 +2687,11 @@ def __init__( self.filters["bool"] = forgiving_boolean self.filters["combine"] = combine self.filters["contains"] = contains - self.filters["difference"] = difference - self.filters["flatten"] = flatten self.filters["float"] = forgiving_float_filter self.filters["from_json"] = from_json self.filters["from_hex"] = from_hex self.filters["iif"] = iif self.filters["int"] = forgiving_int_filter - self.filters["intersect"] = intersect self.filters["is_defined"] = fail_when_undefined self.filters["is_number"] = is_number self.filters["multiply"] = multiply @@ -2818,14 +2699,11 @@ def __init__( self.filters["pack"] = struct_pack self.filters["random"] = random_every_time self.filters["round"] = forgiving_round - self.filters["shuffle"] = shuffle - self.filters["symmetric_difference"] = symmetric_difference self.filters["timestamp_custom"] = timestamp_custom self.filters["timestamp_local"] = timestamp_local self.filters["timestamp_utc"] = timestamp_utc self.filters["to_json"] = to_json self.filters["typeof"] = typeof - self.filters["union"] = union self.filters["unpack"] = struct_unpack self.filters["version"] = version @@ -2833,10 +2711,7 @@ def __init__( self.tests["contains"] = contains self.tests["datetime"] = _is_datetime self.tests["is_number"] = is_number - self.tests["list"] = _is_list - self.tests["set"] = _is_set self.tests["string_like"] = _is_string_like - self.tests["tuple"] = _is_tuple if hass is None: return diff --git a/homeassistant/helpers/template/extensions/__init__.py b/homeassistant/helpers/template/extensions/__init__.py index b6bb7fb8ad9425..80a4c1d46f6733 100644 --- a/homeassistant/helpers/template/extensions/__init__.py +++ b/homeassistant/helpers/template/extensions/__init__.py @@ -1,6 +1,7 @@ """Home Assistant template extensions.""" from .base64 import Base64Extension +from .collection import CollectionExtension from .crypto import CryptoExtension from .math import MathExtension from .regex import RegexExtension @@ -8,6 +9,7 @@ __all__ = [ "Base64Extension", + "CollectionExtension", "CryptoExtension", "MathExtension", "RegexExtension", diff --git a/homeassistant/helpers/template/extensions/collection.py b/homeassistant/helpers/template/extensions/collection.py new file mode 100644 index 00000000000000..b0f3313dc81077 --- /dev/null +++ b/homeassistant/helpers/template/extensions/collection.py @@ -0,0 +1,191 @@ +"""Collection and data structure functions for Home Assistant templates.""" + +from __future__ import annotations + +from collections.abc import Iterable, MutableSequence +import random +from typing import TYPE_CHECKING, Any + +from .base import BaseTemplateExtension, TemplateFunction + +if TYPE_CHECKING: + from homeassistant.helpers.template import TemplateEnvironment + + +class CollectionExtension(BaseTemplateExtension): + """Extension for collection and data structure operations.""" + + def __init__(self, environment: TemplateEnvironment) -> None: + """Initialize the collection extension.""" + super().__init__( + environment, + functions=[ + TemplateFunction( + "flatten", + self.flatten, + as_global=True, + as_filter=True, + ), + TemplateFunction( + "shuffle", + self.shuffle, + as_global=True, + as_filter=True, + ), + # Set operations + TemplateFunction( + "intersect", + self.intersect, + as_global=True, + as_filter=True, + ), + TemplateFunction( + "difference", + self.difference, + as_global=True, + as_filter=True, + ), + TemplateFunction( + "union", + self.union, + as_global=True, + as_filter=True, + ), + TemplateFunction( + "symmetric_difference", + self.symmetric_difference, + as_global=True, + as_filter=True, + ), + # Type conversion functions + TemplateFunction( + "set", + self.to_set, + as_global=True, + ), + TemplateFunction( + "tuple", + self.to_tuple, + as_global=True, + ), + # Type checking functions (tests) + TemplateFunction( + "list", + self.is_list, + as_test=True, + ), + TemplateFunction( + "set", + self.is_set, + as_test=True, + ), + TemplateFunction( + "tuple", + self.is_tuple, + as_test=True, + ), + ], + ) + + def flatten(self, value: Iterable[Any], levels: int | None = None) -> list[Any]: + """Flatten list of lists.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError(f"flatten expected a list, got {type(value).__name__}") + + flattened: list[Any] = [] + for item in value: + if isinstance(item, Iterable) and not isinstance(item, str): + if levels is None: + flattened.extend(self.flatten(item)) + elif levels >= 1: + flattened.extend(self.flatten(item, levels=(levels - 1))) + else: + flattened.append(item) + else: + flattened.append(item) + return flattened + + def shuffle(self, *args: Any, seed: Any = None) -> MutableSequence[Any]: + """Shuffle a list, either with a seed or without.""" + if not args: + raise TypeError("shuffle expected at least 1 argument, got 0") + + # If first argument is iterable and more than 1 argument provided + # but not a named seed, then use 2nd argument as seed. + if isinstance(args[0], Iterable) and not isinstance(args[0], str): + items = list(args[0]) + if len(args) > 1 and seed is None: + seed = args[1] + elif len(args) == 1: + raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") + else: + items = list(args) + + if seed: + r = random.Random(seed) + r.shuffle(items) + else: + random.shuffle(items) + return items + + def intersect(self, value: Iterable[Any], other: Iterable[Any]) -> list[Any]: + """Return the common elements between two lists.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError(f"intersect expected a list, got {type(value).__name__}") + if not isinstance(other, Iterable) or isinstance(other, str): + raise TypeError(f"intersect expected a list, got {type(other).__name__}") + + return list(set(value) & set(other)) + + def difference(self, value: Iterable[Any], other: Iterable[Any]) -> list[Any]: + """Return elements in first list that are not in second list.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError(f"difference expected a list, got {type(value).__name__}") + if not isinstance(other, Iterable) or isinstance(other, str): + raise TypeError(f"difference expected a list, got {type(other).__name__}") + + return list(set(value) - set(other)) + + def union(self, value: Iterable[Any], other: Iterable[Any]) -> list[Any]: + """Return all unique elements from both lists combined.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError(f"union expected a list, got {type(value).__name__}") + if not isinstance(other, Iterable) or isinstance(other, str): + raise TypeError(f"union expected a list, got {type(other).__name__}") + + return list(set(value) | set(other)) + + def symmetric_difference( + self, value: Iterable[Any], other: Iterable[Any] + ) -> list[Any]: + """Return elements that are in either list but not in both.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError( + f"symmetric_difference expected a list, got {type(value).__name__}" + ) + if not isinstance(other, Iterable) or isinstance(other, str): + raise TypeError( + f"symmetric_difference expected a list, got {type(other).__name__}" + ) + + return list(set(value) ^ set(other)) + + def to_set(self, value: Any) -> set[Any]: + """Convert value to set.""" + return set(value) + + def to_tuple(self, value: Any) -> tuple[Any, ...]: + """Convert value to tuple.""" + return tuple(value) + + def is_list(self, value: Any) -> bool: + """Return whether a value is a list.""" + return isinstance(value, list) + + def is_set(self, value: Any) -> bool: + """Return whether a value is a set.""" + return isinstance(value, set) + + def is_tuple(self, value: Any) -> bool: + """Return whether a value is a tuple.""" + return isinstance(value, tuple) diff --git a/tests/helpers/template/extensions/test_collection.py b/tests/helpers/template/extensions/test_collection.py new file mode 100644 index 00000000000000..88cdb00dd194c6 --- /dev/null +++ b/tests/helpers/template/extensions/test_collection.py @@ -0,0 +1,357 @@ +"""Test collection extension.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2, 3], True), + ({"a": 1}, False), + ({1, 2, 3}, False), + ((1, 2, 3), False), + ("abc", False), + ("", False), + (5, False), + (None, False), + ({"foo": "bar", "baz": "qux"}, False), + ], +) +def test_is_list(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test list test.""" + assert ( + template.Template("{{ value is list }}", hass).async_render({"value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2, 3], False), + ({"a": 1}, False), + ({1, 2, 3}, True), + ((1, 2, 3), False), + ("abc", False), + ("", False), + (5, False), + (None, False), + ({"foo": "bar", "baz": "qux"}, False), + ], +) +def test_is_set(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test set test.""" + assert ( + template.Template("{{ value is set }}", hass).async_render({"value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2, 3], False), + ({"a": 1}, False), + ({1, 2, 3}, False), + ((1, 2, 3), True), + ("abc", False), + ("", False), + (5, False), + (None, False), + ({"foo": "bar", "baz": "qux"}, False), + ], +) +def test_is_tuple(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test tuple test.""" + assert ( + template.Template("{{ value is tuple }}", hass).async_render({"value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2, 3], {"expected0": {1, 2, 3}}), + ({"a": 1}, {"expected1": {"a"}}), + ({1, 2, 3}, {"expected2": {1, 2, 3}}), + ((1, 2, 3), {"expected3": {1, 2, 3}}), + ("abc", {"expected4": {"a", "b", "c"}}), + ("", {"expected5": set()}), + (range(3), {"expected6": {0, 1, 2}}), + ({"foo": "bar", "baz": "qux"}, {"expected7": {"foo", "baz"}}), + ], +) +def test_set(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test set conversion.""" + assert ( + template.Template("{{ set(value) }}", hass).async_render({"value": value}) + == list(expected.values())[0] + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2, 3], {"expected0": (1, 2, 3)}), + ({"a": 1}, {"expected1": ("a",)}), + ({1, 2, 3}, {"expected2": (1, 2, 3)}), # Note: set order is not guaranteed + ((1, 2, 3), {"expected3": (1, 2, 3)}), + ("abc", {"expected4": ("a", "b", "c")}), + ("", {"expected5": ()}), + (range(3), {"expected6": (0, 1, 2)}), + ({"foo": "bar", "baz": "qux"}, {"expected7": ("foo", "baz")}), + ], +) +def test_tuple(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test tuple conversion.""" + result = template.Template("{{ tuple(value) }}", hass).async_render( + {"value": value} + ) + expected_value = list(expected.values())[0] + if isinstance(value, set): # Sets don't have predictable order + assert set(result) == set(expected_value) + else: + assert result == expected_value + + +@pytest.mark.parametrize( + ("cola", "colb", "expected"), + [ + ([1, 2], [3, 4], [(1, 3), (2, 4)]), + ([1, 2], [3, 4, 5], [(1, 3), (2, 4)]), + ([1, 2, 3, 4], [3, 4], [(1, 3), (2, 4)]), + ], +) +def test_zip(hass: HomeAssistant, cola, colb, expected) -> None: + """Test zip.""" + assert ( + template.Template("{{ zip(cola, colb) | list }}", hass).async_render( + {"cola": cola, "colb": colb} + ) + == expected + ) + assert ( + template.Template( + "[{% for a, b in zip(cola, colb) %}({{a}}, {{b}}), {% endfor %}]", hass + ).async_render({"cola": cola, "colb": colb}) + == expected + ) + + +@pytest.mark.parametrize( + ("col", "expected"), + [ + ([(1, 3), (2, 4)], [(1, 2), (3, 4)]), + (["ax", "by", "cz"], [("a", "b", "c"), ("x", "y", "z")]), + ], +) +def test_unzip(hass: HomeAssistant, col, expected) -> None: + """Test unzipping using zip.""" + assert ( + template.Template("{{ zip(*col) | list }}", hass).async_render({"col": col}) + == expected + ) + assert ( + template.Template( + "{% set a, b = zip(*col) %}[{{a}}, {{b}}]", hass + ).async_render({"col": col}) + == expected + ) + + +def test_shuffle(hass: HomeAssistant) -> None: + """Test shuffle.""" + # Test basic shuffle + result = template.Template("{{ shuffle([1, 2, 3, 4, 5]) }}", hass).async_render() + assert len(result) == 5 + assert set(result) == {1, 2, 3, 4, 5} + + # Test shuffle with seed + result1 = template.Template( + "{{ shuffle([1, 2, 3, 4, 5], seed=42) }}", hass + ).async_render() + result2 = template.Template( + "{{ shuffle([1, 2, 3, 4, 5], seed=42) }}", hass + ).async_render() + assert result1 == result2 # Same seed should give same result + + # Test shuffle with different seed + result3 = template.Template( + "{{ shuffle([1, 2, 3, 4, 5], seed=123) }}", hass + ).async_render() + # Different seeds should usually give different results + # (but we can't guarantee it for small lists) + assert len(result3) == 5 + assert set(result3) == {1, 2, 3, 4, 5} + + +def test_flatten(hass: HomeAssistant) -> None: + """Test flatten.""" + # Test basic flattening + assert template.Template( + "{{ flatten([[1, 2], [3, 4]]) }}", hass + ).async_render() == [1, 2, 3, 4] + + # Test nested flattening + assert template.Template( + "{{ flatten([[[1, 2], [3, 4]], [[5, 6], [7, 8]]]) }}", hass + ).async_render() == [1, 2, 3, 4, 5, 6, 7, 8] + + # Test flattening with levels + assert template.Template( + "{{ flatten([[[1, 2], [3, 4]], [[5, 6], [7, 8]]], levels=1) }}", hass + ).async_render() == [[1, 2], [3, 4], [5, 6], [7, 8]] + + # Test mixed types + assert template.Template( + "{{ flatten([[1, 'a'], [2, 'b']]) }}", hass + ).async_render() == [1, "a", 2, "b"] + + # Test empty list + assert template.Template("{{ flatten([]) }}", hass).async_render() == [] + + # Test single level + assert template.Template("{{ flatten([1, 2, 3]) }}", hass).async_render() == [ + 1, + 2, + 3, + ] + + +def test_intersect(hass: HomeAssistant) -> None: + """Test intersect.""" + # Test basic intersection + result = template.Template( + "{{ [1, 2, 3, 4] | intersect([3, 4, 5, 6]) | sort }}", hass + ).async_render() + assert result == [3, 4] + + # Test no intersection + result = template.Template("{{ [1, 2] | intersect([3, 4]) }}", hass).async_render() + assert result == [] + + # Test string intersection + result = template.Template( + "{{ ['a', 'b', 'c'] | intersect(['b', 'c', 'd']) | sort }}", hass + ).async_render() + assert result == ["b", "c"] + + # Test empty list intersection + result = template.Template("{{ [] | intersect([1, 2, 3]) }}", hass).async_render() + assert result == [] + + +def test_difference(hass: HomeAssistant) -> None: + """Test difference.""" + # Test basic difference + result = template.Template( + "{{ [1, 2, 3, 4] | difference([3, 4, 5, 6]) | sort }}", hass + ).async_render() + assert result == [1, 2] + + # Test no difference + result = template.Template( + "{{ [1, 2] | difference([1, 2, 3, 4]) }}", hass + ).async_render() + assert result == [] + + # Test string difference + result = template.Template( + "{{ ['a', 'b', 'c'] | difference(['b', 'c', 'd']) | sort }}", hass + ).async_render() + assert result == ["a"] + + # Test empty list difference + result = template.Template("{{ [] | difference([1, 2, 3]) }}", hass).async_render() + assert result == [] + + +def test_union(hass: HomeAssistant) -> None: + """Test union.""" + # Test basic union + result = template.Template( + "{{ [1, 2, 3] | union([3, 4, 5]) | sort }}", hass + ).async_render() + assert result == [1, 2, 3, 4, 5] + + # Test string union + result = template.Template( + "{{ ['a', 'b'] | union(['b', 'c']) | sort }}", hass + ).async_render() + assert result == ["a", "b", "c"] + + # Test empty list union + result = template.Template( + "{{ [] | union([1, 2, 3]) | sort }}", hass + ).async_render() + assert result == [1, 2, 3] + + # Test duplicate elements + result = template.Template( + "{{ [1, 1, 2, 2] | union([2, 2, 3, 3]) | sort }}", hass + ).async_render() + assert result == [1, 2, 3] + + +def test_symmetric_difference(hass: HomeAssistant) -> None: + """Test symmetric_difference.""" + # Test basic symmetric difference + result = template.Template( + "{{ [1, 2, 3, 4] | symmetric_difference([3, 4, 5, 6]) | sort }}", hass + ).async_render() + assert result == [1, 2, 5, 6] + + # Test no symmetric difference (identical sets) + result = template.Template( + "{{ [1, 2, 3] | symmetric_difference([1, 2, 3]) }}", hass + ).async_render() + assert result == [] + + # Test string symmetric difference + result = template.Template( + "{{ ['a', 'b', 'c'] | symmetric_difference(['b', 'c', 'd']) | sort }}", hass + ).async_render() + assert result == ["a", "d"] + + # Test empty list symmetric difference + result = template.Template( + "{{ [] | symmetric_difference([1, 2, 3]) | sort }}", hass + ).async_render() + assert result == [1, 2, 3] + + +def test_collection_functions_as_tests(hass: HomeAssistant) -> None: + """Test that type checking functions work as tests.""" + # Test various type checking functions + assert template.Template("{{ [1,2,3] is list }}", hass).async_render() + assert template.Template("{{ set([1,2,3]) is set }}", hass).async_render() + assert template.Template("{{ (1,2,3) is tuple }}", hass).async_render() + + +def test_collection_error_handling(hass: HomeAssistant) -> None: + """Test error handling in collection functions.""" + + # Test flatten with non-iterable + with pytest.raises(TemplateError, match="flatten expected a list"): + template.Template("{{ flatten(123) }}", hass).async_render() + + # Test intersect with non-iterable + with pytest.raises(TemplateError, match="intersect expected a list"): + template.Template("{{ [1, 2] | intersect(123) }}", hass).async_render() + + # Test difference with non-iterable + with pytest.raises(TemplateError, match="difference expected a list"): + template.Template("{{ [1, 2] | difference(123) }}", hass).async_render() + + # Test shuffle with no arguments + with pytest.raises(TemplateError, match="shuffle expected at least 1 argument"): + template.Template("{{ shuffle() }}", hass).async_render() diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 77191af52593e9..d6df489e84215b 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -15,7 +15,6 @@ from freezegun import freeze_time import orjson import pytest -from pytest_unordered import unordered from syrupy.assertion import SnapshotAssertion import voluptuous as vol @@ -514,114 +513,6 @@ def test_isnumber(hass: HomeAssistant, value, expected) -> None: ) -@pytest.mark.parametrize( - ("value", "expected"), - [ - ([1, 2], True), - ({1, 2}, False), - ({"a": 1, "b": 2}, False), - (ReadOnlyDict({"a": 1, "b": 2}), False), - (MappingProxyType({"a": 1, "b": 2}), False), - ("abc", False), - (b"abc", False), - ((1, 2), False), - (datetime(2024, 1, 1, 0, 0, 0), False), - ], -) -def test_is_list(hass: HomeAssistant, value: Any, expected: bool) -> None: - """Test is list.""" - assert ( - template.Template("{{ value is list }}", hass).async_render({"value": value}) - == expected - ) - - -@pytest.mark.parametrize( - ("value", "expected"), - [ - ([1, 2], False), - ({1, 2}, True), - ({"a": 1, "b": 2}, False), - (ReadOnlyDict({"a": 1, "b": 2}), False), - (MappingProxyType({"a": 1, "b": 2}), False), - ("abc", False), - (b"abc", False), - ((1, 2), False), - (datetime(2024, 1, 1, 0, 0, 0), False), - ], -) -def test_is_set(hass: HomeAssistant, value: Any, expected: bool) -> None: - """Test is set.""" - assert ( - template.Template("{{ value is set }}", hass).async_render({"value": value}) - == expected - ) - - -@pytest.mark.parametrize( - ("value", "expected"), - [ - ([1, 2], False), - ({1, 2}, False), - ({"a": 1, "b": 2}, False), - (ReadOnlyDict({"a": 1, "b": 2}), False), - (MappingProxyType({"a": 1, "b": 2}), False), - ("abc", False), - (b"abc", False), - ((1, 2), True), - (datetime(2024, 1, 1, 0, 0, 0), False), - ], -) -def test_is_tuple(hass: HomeAssistant, value: Any, expected: bool) -> None: - """Test is tuple.""" - assert ( - template.Template("{{ value is tuple }}", hass).async_render({"value": value}) - == expected - ) - - -@pytest.mark.parametrize( - ("value", "expected"), - [ - ([1, 2], {1, 2}), - ({1, 2}, {1, 2}), - ({"a": 1, "b": 2}, {"a", "b"}), - (ReadOnlyDict({"a": 1, "b": 2}), {"a", "b"}), - (MappingProxyType({"a": 1, "b": 2}), {"a", "b"}), - ("abc", {"a", "b", "c"}), - (b"abc", {97, 98, 99}), - ((1, 2), {1, 2}), - ], -) -def test_set(hass: HomeAssistant, value: Any, expected: bool) -> None: - """Test convert to set function.""" - assert ( - template.Template("{{ set(value) }}", hass).async_render({"value": value}) - == expected - ) - - -@pytest.mark.parametrize( - ("value", "expected"), - [ - ([1, 2], (1, 2)), - ({1, 2}, (1, 2)), - ({"a": 1, "b": 2}, ("a", "b")), - (ReadOnlyDict({"a": 1, "b": 2}), ("a", "b")), - (MappingProxyType({"a": 1, "b": 2}), ("a", "b")), - ("abc", ("a", "b", "c")), - (b"abc", (97, 98, 99)), - ((1, 2), (1, 2)), - ], -) -def test_tuple(hass: HomeAssistant, value: Any, expected: bool) -> None: - """Test convert to tuple function.""" - assert ( - template.Template("{{ tuple(value) }}", hass).async_render({"value": value}) - == expected - ) - - def test_converting_datetime_to_iterable(hass: HomeAssistant) -> None: """Test converting a datetime to an iterable raises an error.""" dt_ = datetime(2020, 1, 1, 0, 0, 0) @@ -655,30 +546,6 @@ def test_is_datetime(hass: HomeAssistant, value, expected) -> None: ) -@pytest.mark.parametrize( - ("value", "expected"), - [ - ([1, 2], False), - ({1, 2}, False), - ({"a": 1, "b": 2}, False), - (ReadOnlyDict({"a": 1, "b": 2}), False), - (MappingProxyType({"a": 1, "b": 2}), False), - ("abc", True), - (b"abc", True), - ((1, 2), False), - (datetime(2024, 1, 1, 0, 0, 0), False), - ], -) -def test_is_string_like(hass: HomeAssistant, value, expected) -> None: - """Test is string_like.""" - assert ( - template.Template("{{ value is string_like }}", hass).async_render( - {"value": value} - ) - == expected - ) - - def test_rounding_value(hass: HomeAssistant) -> None: """Test rounding value.""" hass.states.async_set("sensor.temperature", 12.78) @@ -795,37 +662,46 @@ def test_apply(hass: HomeAssistant) -> None: def test_apply_macro_with_arguments(hass: HomeAssistant) -> None: """Test apply macro with positional, named, and mixed arguments.""" # Test macro with positional arguments - assert template.Template( - """ - {%- macro greet(name, greeting) -%} - {{ greeting }}, {{ name }}! - {%- endmacro %} - {{ ["Alice", "Bob"] | map('apply', greet, "Hello") | list }} + assert ( + template.Template( + """ + {%- macro add_numbers(a, b, c) -%} + {{ a + b + c }} + {%- endmacro -%} + {{ apply(5, add_numbers, 10, 15) }} """, - hass, - ).async_render() == ["Hello, Alice!", "Hello, Bob!"] + hass, + ).async_render() + == 30 + ) # Test macro with named arguments - assert template.Template( - """ - {%- macro greet(name, greeting="Hi") -%} + assert ( + template.Template( + """ + {%- macro greet(name, greeting="Hello") -%} {{ greeting }}, {{ name }}! - {%- endmacro %} - {{ ["Alice", "Bob"] | map('apply', greet, greeting="Hello") | list }} + {%- endmacro -%} + {{ apply("World", greet, greeting="Hi") }} """, - hass, - ).async_render() == ["Hello, Alice!", "Hello, Bob!"] + hass, + ).async_render() + == "Hi, World!" + ) - # Test macro with mixed positional and named arguments - assert template.Template( - """ - {%- macro greet(name, separator, greeting="Hi") -%} - {{ greeting }}{{separator}} {{ name }}! - {%- endmacro %} - {{ ["Alice", "Bob"] | map('apply', greet, "," , greeting="Hey") | list }} + # Test macro with mixed arguments + assert ( + template.Template( + """ + {%- macro format_message(prefix, name, suffix="!") -%} + {{ prefix }} {{ name }}{{ suffix }} + {%- endmacro -%} + {{ apply("Welcome", format_message, "John", suffix="...") }} """, - hass, - ).async_render() == ["Hey, Alice!", "Hey, Bob!"] + hass, + ).async_render() + == "Welcome John..." + ) def test_as_function(hass: HomeAssistant) -> None: @@ -5695,51 +5571,6 @@ async def test_template_thread_safety_checks(hass: HomeAssistant) -> None: assert template_obj.async_render_to_info().result() == 23 -@pytest.mark.parametrize( - ("cola", "colb", "expected"), - [ - ([1, 2], [3, 4], [(1, 3), (2, 4)]), - ([1, 2], [3, 4, 5], [(1, 3), (2, 4)]), - ([1, 2, 3, 4], [3, 4], [(1, 3), (2, 4)]), - ], -) -def test_zip(hass: HomeAssistant, cola, colb, expected) -> None: - """Test zip.""" - assert ( - template.Template("{{ zip(cola, colb) | list }}", hass).async_render( - {"cola": cola, "colb": colb} - ) - == expected - ) - assert ( - template.Template( - "[{% for a, b in zip(cola, colb) %}({{a}}, {{b}}), {% endfor %}]", hass - ).async_render({"cola": cola, "colb": colb}) - == expected - ) - - -@pytest.mark.parametrize( - ("col", "expected"), - [ - ([(1, 3), (2, 4)], [(1, 2), (3, 4)]), - (["ax", "by", "cz"], [("a", "b", "c"), ("x", "y", "z")]), - ], -) -def test_unzip(hass: HomeAssistant, col, expected) -> None: - """Test unzipping using zip.""" - assert ( - template.Template("{{ zip(*col) | list }}", hass).async_render({"col": col}) - == expected - ) - assert ( - template.Template( - "{% set a, b = zip(*col) %}[{{a}}, {{b}}]", hass - ).async_render({"col": col}) - == expected - ) - - def test_template_output_exceeds_maximum_size(hass: HomeAssistant) -> None: """Test template output exceeds maximum size.""" tpl = template.Template("{{ 'a' * 1024 * 257 }}", hass) @@ -6040,57 +5871,6 @@ async def test_merge_response_not_mutate_original_object( assert tpl.async_render() -def test_shuffle(hass: HomeAssistant) -> None: - """Test the shuffle function and filter.""" - assert list( - template.Template("{{ [1, 2, 3] | shuffle }}", hass).async_render() - ) == unordered([1, 2, 3]) - - assert list( - template.Template("{{ shuffle([1, 2, 3]) }}", hass).async_render() - ) == unordered([1, 2, 3]) - - assert list( - template.Template("{{ shuffle(1, 2, 3) }}", hass).async_render() - ) == unordered([1, 2, 3]) - - assert list(template.Template("{{ shuffle([]) }}", hass).async_render()) == [] - - assert list(template.Template("{{ [] | shuffle }}", hass).async_render()) == [] - - # Testing using seed - assert list( - template.Template("{{ shuffle([1, 2, 3], 'seed') }}", hass).async_render() - ) == [2, 3, 1] - - assert list( - template.Template( - "{{ shuffle([1, 2, 3], seed='seed') }}", - hass, - ).async_render() - ) == [2, 3, 1] - - assert list( - template.Template( - "{{ [1, 2, 3] | shuffle('seed') }}", - hass, - ).async_render() - ) == [2, 3, 1] - - assert list( - template.Template( - "{{ [1, 2, 3] | shuffle(seed='seed') }}", - hass, - ).async_render() - ) == [2, 3, 1] - - with pytest.raises(TemplateError): - template.Template("{{ 1 | shuffle }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ shuffle() }}", hass).async_render() - - def test_typeof(hass: HomeAssistant) -> None: """Test the typeof debug filter/function.""" assert template.Template("{{ True | typeof }}", hass).async_render() == "bool" @@ -6118,221 +5898,6 @@ def test_typeof(hass: HomeAssistant) -> None: ) -def test_flatten(hass: HomeAssistant) -> None: - """Test the flatten function and filter.""" - assert template.Template( - "{{ flatten([1, [2, [3]], 4, [5 , 6]]) }}", hass - ).async_render() == [1, 2, 3, 4, 5, 6] - - assert template.Template( - "{{ [1, [2, [3]], 4, [5 , 6]] | flatten }}", hass - ).async_render() == [1, 2, 3, 4, 5, 6] - - assert template.Template( - "{{ flatten([1, [2, [3]], 4, [5 , 6]], 1) }}", hass - ).async_render() == [1, 2, [3], 4, 5, 6] - - assert template.Template( - "{{ flatten([1, [2, [3]], 4, [5 , 6]], levels=1) }}", hass - ).async_render() == [1, 2, [3], 4, 5, 6] - - assert template.Template( - "{{ [1, [2, [3]], 4, [5 , 6]] | flatten(1) }}", hass - ).async_render() == [1, 2, [3], 4, 5, 6] - - assert template.Template( - "{{ [1, [2, [3]], 4, [5 , 6]] | flatten(levels=1) }}", hass - ).async_render() == [1, 2, [3], 4, 5, 6] - - assert template.Template("{{ flatten([]) }}", hass).async_render() == [] - - assert template.Template("{{ [] | flatten }}", hass).async_render() == [] - - with pytest.raises(TemplateError): - template.Template("{{ 'string' | flatten }}", hass).async_render() - - with pytest.raises(TemplateError): - template.Template("{{ flatten() }}", hass).async_render() - - -def test_intersect(hass: HomeAssistant) -> None: - """Test the intersect function and filter.""" - assert list( - template.Template( - "{{ intersect([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", hass - ).async_render() - ) == unordered([1, 2, 3, 4, 5]) - - assert list( - template.Template( - "{{ [1, 2, 5, 3, 4, 10] | intersect([1, 2, 3, 4, 5, 11, 99]) }}", hass - ).async_render() - ) == unordered([1, 2, 3, 4, 5]) - - assert list( - template.Template( - "{{ intersect(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass - ).async_render() - ) == unordered(["b", "c"]) - - assert list( - template.Template( - "{{ ['a', 'b', 'c'] | intersect(['b', 'c', 'd']) }}", hass - ).async_render() - ) == unordered(["b", "c"]) - - assert ( - template.Template("{{ intersect([], [1, 2, 3]) }}", hass).async_render() == [] - ) - - assert ( - template.Template("{{ [] | intersect([1, 2, 3]) }}", hass).async_render() == [] - ) - - with pytest.raises(TemplateError, match="intersect expected a list, got str"): - template.Template("{{ 'string' | intersect([1, 2, 3]) }}", hass).async_render() - - with pytest.raises(TemplateError, match="intersect expected a list, got str"): - template.Template("{{ [1, 2, 3] | intersect('string') }}", hass).async_render() - - -def test_difference(hass: HomeAssistant) -> None: - """Test the difference function and filter.""" - assert list( - template.Template( - "{{ difference([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", hass - ).async_render() - ) == [10] - - assert list( - template.Template( - "{{ [1, 2, 5, 3, 4, 10] | difference([1, 2, 3, 4, 5, 11, 99]) }}", hass - ).async_render() - ) == [10] - - assert list( - template.Template( - "{{ difference(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass - ).async_render() - ) == ["a"] - - assert list( - template.Template( - "{{ ['a', 'b', 'c'] | difference(['b', 'c', 'd']) }}", hass - ).async_render() - ) == ["a"] - - assert ( - template.Template("{{ difference([], [1, 2, 3]) }}", hass).async_render() == [] - ) - - assert ( - template.Template("{{ [] | difference([1, 2, 3]) }}", hass).async_render() == [] - ) - - with pytest.raises(TemplateError, match="difference expected a list, got str"): - template.Template("{{ 'string' | difference([1, 2, 3]) }}", hass).async_render() - - with pytest.raises(TemplateError, match="difference expected a list, got str"): - template.Template("{{ [1, 2, 3] | difference('string') }}", hass).async_render() - - -def test_union(hass: HomeAssistant) -> None: - """Test the union function and filter.""" - assert list( - template.Template( - "{{ union([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", hass - ).async_render() - ) == unordered([1, 2, 3, 4, 5, 10, 11, 99]) - - assert list( - template.Template( - "{{ [1, 2, 5, 3, 4, 10] | union([1, 2, 3, 4, 5, 11, 99]) }}", hass - ).async_render() - ) == unordered([1, 2, 3, 4, 5, 10, 11, 99]) - - assert list( - template.Template( - "{{ union(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass - ).async_render() - ) == unordered(["a", "b", "c", "d"]) - - assert list( - template.Template( - "{{ ['a', 'b', 'c'] | union(['b', 'c', 'd']) }}", hass - ).async_render() - ) == unordered(["a", "b", "c", "d"]) - - assert list( - template.Template("{{ union([], [1, 2, 3]) }}", hass).async_render() - ) == unordered([1, 2, 3]) - - assert list( - template.Template("{{ [] | union([1, 2, 3]) }}", hass).async_render() - ) == unordered([1, 2, 3]) - - with pytest.raises(TemplateError, match="union expected a list, got str"): - template.Template("{{ 'string' | union([1, 2, 3]) }}", hass).async_render() - - with pytest.raises(TemplateError, match="union expected a list, got str"): - template.Template("{{ [1, 2, 3] | union('string') }}", hass).async_render() - - -def test_symmetric_difference(hass: HomeAssistant) -> None: - """Test the symmetric_difference function and filter.""" - assert list( - template.Template( - "{{ symmetric_difference([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", - hass, - ).async_render() - ) == unordered([10, 11, 99]) - - assert list( - template.Template( - "{{ [1, 2, 5, 3, 4, 10] | symmetric_difference([1, 2, 3, 4, 5, 11, 99]) }}", - hass, - ).async_render() - ) == unordered([10, 11, 99]) - - assert list( - template.Template( - "{{ symmetric_difference(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass - ).async_render() - ) == unordered(["a", "d"]) - - assert list( - template.Template( - "{{ ['a', 'b', 'c'] | symmetric_difference(['b', 'c', 'd']) }}", hass - ).async_render() - ) == unordered(["a", "d"]) - - assert list( - template.Template( - "{{ symmetric_difference([], [1, 2, 3]) }}", hass - ).async_render() - ) == unordered([1, 2, 3]) - - assert list( - template.Template( - "{{ [] | symmetric_difference([1, 2, 3]) }}", hass - ).async_render() - ) == unordered([1, 2, 3]) - - with pytest.raises( - TemplateError, match="symmetric_difference expected a list, got str" - ): - template.Template( - "{{ 'string' | symmetric_difference([1, 2, 3]) }}", hass - ).async_render() - - with pytest.raises( - TemplateError, match="symmetric_difference expected a list, got str" - ): - template.Template( - "{{ [1, 2, 3] | symmetric_difference('string') }}", hass - ).async_render() - - def test_combine(hass: HomeAssistant) -> None: """Test combine filter and function.""" assert template.Template( From d67ec7593a1517f7a9623e473c72022f6fbdb5a1 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 16 Sep 2025 16:02:35 -0700 Subject: [PATCH 3/4] Add diagnostics to history_stats (#152460) --- .../components/history_stats/diagnostics.py | 23 +++++++++++++++ .../history_stats/test_diagnostics.py | 28 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 homeassistant/components/history_stats/diagnostics.py create mode 100644 tests/components/history_stats/test_diagnostics.py diff --git a/homeassistant/components/history_stats/diagnostics.py b/homeassistant/components/history_stats/diagnostics.py new file mode 100644 index 00000000000000..045e37d49b96e6 --- /dev/null +++ b/homeassistant/components/history_stats/diagnostics.py @@ -0,0 +1,23 @@ +"""Diagnostics support for history_stats.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + registry = er.async_get(hass) + entities = registry.entities.get_entries_for_config_entry_id(config_entry.entry_id) + + return { + "config_entry": config_entry.as_dict(), + "entity": [entity.extended_dict for entity in entities], + } diff --git a/tests/components/history_stats/test_diagnostics.py b/tests/components/history_stats/test_diagnostics.py new file mode 100644 index 00000000000000..8ca68b1622e496 --- /dev/null +++ b/tests/components/history_stats/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests for derivative diagnostics.""" + +import pytest + +from homeassistant.components.history_stats.const import DEFAULT_NAME, DOMAIN +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.core import HomeAssistant + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("recorder_mock") +async def test_diagnostics( + hass: HomeAssistant, hass_client: ClientSessionGenerator, loaded_entry +) -> None: + """Test diagnostics for config entry.""" + + result = await get_diagnostics_for_config_entry(hass, hass_client, loaded_entry) + + assert isinstance(result, dict) + assert result["config_entry"]["domain"] == DOMAIN + assert result["config_entry"]["options"][CONF_NAME] == DEFAULT_NAME + assert ( + result["config_entry"]["options"][CONF_ENTITY_ID] + == "binary_sensor.test_monitored" + ) + assert result["entity"][0]["entity_id"] == "sensor.unnamed_statistics" From 1598c4ebe8e9743e9e3b7f99ca47355c4b2c4709 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 16 Sep 2025 20:18:54 -0400 Subject: [PATCH 4/4] Bump aioesphomeapi to 41.1.0 (#152461) Co-authored-by: J. Nick Koston --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/snapshots/test_diagnostics.ambr | 1 + tests/components/esphome/test_diagnostics.py | 1 + 5 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 81e09c20c64296..22dde4f4ec6736 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==41.0.0", + "aioesphomeapi==41.1.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 2970034e851b7d..6bef49d343fb74 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==41.0.0 +aioesphomeapi==41.1.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 70c3d25d49b89c..1693b3e22921d2 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==41.0.0 +aioesphomeapi==41.1.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index 6b7a1c64c9fd67..8ff30160a0192e 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -109,6 +109,7 @@ 'uses_password': False, 'voice_assistant_feature_flags': 0, 'webserver_port': 0, + 'zwave_proxy_feature_flags': 0, }), 'services': list([ ]), diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index ebfe15d562f467..ca0b7ff4c555b5 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -146,6 +146,7 @@ async def test_diagnostics_with_bluetooth( "legacy_voice_assistant_version": 0, "voice_assistant_feature_flags": 0, "webserver_port": 0, + "zwave_proxy_feature_flags": 0, }, "services": [], },