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/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/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/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/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/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
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": [],
},
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"
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(