Compare commits

...

5 Commits

Author SHA1 Message Date
Paul Bottein 75ec9a9058 Publish numeric sensor device classes as generated sensor.json 2026-06-15 18:14:30 +02:00
Erik Montnemery 2434341e04 Queue nested firing of events (#173519) 2026-06-15 17:27:16 +02:00
Franck Nijhof 047edc035d Skip literal_eval for template results that cannot be a Python literal (#173664)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-15 17:24:29 +02:00
epenet 8b5f27e016 Optimize module parsing in pylint imports checker (#173077) 2026-06-15 17:12:02 +02:00
Markus Adrario 5200a8131f Homee: QS examples done (#173543) 2026-06-15 17:11:02 +02:00
14 changed files with 301 additions and 66 deletions
+1 -1
View File
@@ -102,7 +102,7 @@ repos:
pass_filenames: false
language: script
types: [text]
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|homeassistant/components/sensor/const\.py|requirements.+\.txt)$
- id: hassfest-metadata
name: hassfest-metadata
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker
@@ -48,7 +48,7 @@ rules:
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: todo
+62 -2
View File
@@ -5,7 +5,7 @@ of entities and react to changes.
"""
import asyncio
from collections import UserDict, defaultdict
from collections import UserDict, defaultdict, deque
from collections.abc import (
Callable,
Collection,
@@ -1428,10 +1428,24 @@ def _verify_event_type_length_or_raise(event_type: EventType[_DataT] | str) -> N
raise MaxLengthExceeded(event_type, "event_type", MAX_LENGTH_EVENT_EVENT_TYPE)
# Maximum number of events event listeners may queue while a single top-level
# event is being dispatched, to guard against event listeners firing events in
# an endless loop.
_MAX_QUEUED_EVENT_DISPATCHES: Final = 10_000
class EventBus:
"""Allow the firing of and listening for events."""
__slots__ = ("_debug", "_hass", "_listeners", "_match_all_listeners")
__slots__ = (
"_debug",
"_dispatching",
"_event_queue",
"_hass",
"_listeners",
"_match_all_listeners",
"_queued_event_count",
)
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize a new event bus."""
@@ -1441,6 +1455,11 @@ class EventBus:
self._match_all_listeners: list[_FilterableJobType[Any]] = []
self._listeners[MATCH_ALL] = self._match_all_listeners
self._hass = hass
self._event_queue: deque[
tuple[EventType[Any] | str, Any, EventOrigin, Context | None, float]
] = deque()
self._dispatching = False
self._queued_event_count = 0
self._async_logging_changed()
self.async_listen(EVENT_LOGGING_CHANGED, self._async_logging_changed)
@@ -1520,6 +1539,47 @@ class EventBus:
"Bus:Handling %s", _event_repr(event_type, origin, event_data)
)
if self._dispatching:
# A nested fire is queued and dispatched after the current
# dispatch. The fire time is captured now since dispatch is
# deferred.
if self._queued_event_count >= _MAX_QUEUED_EVENT_DISPATCHES:
# Guard against event listeners firing events in an endless
# loop: stop queuing further events and raise so the firing
# listener's error handling kicks in. Events already queued
# are still dispatched.
raise HomeAssistantError(
f"Event {event_type} not fired: more than"
f" {_MAX_QUEUED_EVENT_DISPATCHES} events were queued by event"
" listeners while dispatching a single event; event listeners"
" are likely firing events in an endless loop"
)
self._queued_event_count += 1
self._event_queue.append(
(event_type, event_data, origin, context, time_fired or time.time())
)
return
self._dispatching = True
self._queued_event_count = 0
try:
self._async_dispatch(event_type, event_data, origin, context, time_fired)
event_queue = self._event_queue
while event_queue:
self._async_dispatch(*event_queue.popleft())
finally:
self._dispatching = False
@callback
def _async_dispatch(
self,
event_type: EventType[_DataT] | str,
event_data: _DataT | None,
origin: EventOrigin,
context: Context | None,
time_fired: float | None,
) -> None:
"""Dispatch an event to its listeners."""
listeners = self._listeners.get(event_type, EMPTY_LIST)
if event_type not in EVENTS_EXCLUDED_FROM_MATCH_ALL:
match_all_listeners = self._match_all_listeners
+61
View File
@@ -0,0 +1,61 @@
{
"numeric_device_classes": [
"absolute_humidity",
"apparent_power",
"aqi",
"area",
"atmospheric_pressure",
"battery",
"blood_glucose_concentration",
"carbon_dioxide",
"carbon_monoxide",
"conductivity",
"current",
"data_rate",
"data_size",
"distance",
"duration",
"energy",
"energy_distance",
"energy_storage",
"frequency",
"gas",
"humidity",
"illuminance",
"irradiance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"ph",
"pm1",
"pm10",
"pm25",
"pm4",
"power",
"power_factor",
"precipitation",
"precipitation_intensity",
"pressure",
"reactive_energy",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"temperature_delta",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"volume",
"volume_flow_rate",
"volume_storage",
"water",
"weight",
"wind_direction",
"wind_speed"
]
}
+14 -15
View File
@@ -230,9 +230,17 @@ RESULT_WRAPPERS[tuple] = TupleWrapper
@lru_cache(maxsize=EVAL_CACHE_SIZE)
def _cached_parse_result(render_result: str) -> Any:
def _parse_result(render_result: str) -> Any:
"""Parse a result and cache the result."""
result = literal_eval(render_result)
# lru_cache does not memoize raised exceptions. The most common template
# results, plain string states such as "on", "off" or "unavailable", are
# not Python literals, so literal_eval compiles and raises for them on
# every render. Catching here caches that outcome (return the original
# render) so the recompile only happens once per distinct result.
try:
result = literal_eval(render_result)
except ValueError, TypeError, SyntaxError, MemoryError:
return render_result
if type(result) in RESULT_WRAPPERS:
result = RESULT_WRAPPERS[type(result)](result, render_result=render_result)
@@ -343,7 +351,7 @@ class Template:
if self.is_static:
if not parse_result or (self.hass and self.hass.config.legacy_templates):
return self.template
return self._parse_result(self.template)
return _parse_result(self.template)
assert self.hass is not None, "hass variable not set on template"
return run_callback_threadsafe(
self.hass.loop,
@@ -372,7 +380,7 @@ class Template:
if self.is_static:
if not parse_result or (self.hass and self.hass.config.legacy_templates):
return self.template
return self._parse_result(self.template)
return _parse_result(self.template)
compiled = self._compiled or self._ensure_compiled(limited, strict, log_fn)
@@ -395,16 +403,7 @@ class Template:
if not parse_result or (self.hass and self.hass.config.legacy_templates):
return render_result
return self._parse_result(render_result)
def _parse_result(self, render_result: str) -> Any:
"""Parse the result."""
try:
return _cached_parse_result(render_result)
except ValueError, TypeError, SyntaxError, MemoryError:
pass
return render_result
return _parse_result(render_result)
async def async_render_will_timeout(
self,
@@ -571,7 +570,7 @@ class Template:
if not parse_result or (self.hass and self.hass.config.legacy_templates):
return render_result
return self._parse_result(render_result)
return _parse_result(render_result)
def _ensure_compiled(
self,
@@ -214,23 +214,21 @@ class HassImportsFormatChecker(BaseChecker):
}
options = ()
def __init__(self, linter: PyLinter) -> None:
"""Initialize the HassImportsFormatChecker."""
super().__init__(linter)
self.current_package: str | None = None
current_package: str
current_component: str | None
def visit_module(self, node: nodes.Module) -> None:
"""Determine current package."""
if node.package:
self.current_package = node.name
else:
self.current_package = node.name
if not node.package:
# Strip name of the current module
self.current_package = node.name[: node.name.rfind(".")]
parsed_module = parse_module(node.name, include_test=True)
self.current_component = parsed_module.domain if parsed_module else None
def visit_import(self, node: nodes.Import) -> None:
"""Check for improper `import _` invocations."""
if self.current_package is None:
return
for other_module, _alias in node.names:
if other_module.startswith(f"{self.current_package}."):
self.add_message("home-assistant-relative-import", node=node)
@@ -251,13 +249,10 @@ class HassImportsFormatChecker(BaseChecker):
self, current_package: str, node: nodes.ImportFrom
) -> None:
"""Check for improper 'from ._ import _' invocations."""
if not current_package.startswith(
("homeassistant.components.", "tests.components.")
):
if not (current_component := self.current_component):
return
split_package = current_package.split(".")
current_component = split_package[2]
self._check_for_constant_alias(node, current_component, current_component)
@@ -367,18 +362,11 @@ class HassImportsFormatChecker(BaseChecker):
def visit_importfrom(self, node: nodes.ImportFrom) -> None:
"""Check for improper 'from _ import _' invocations."""
if not self.current_package:
return
if node.level is not None:
self._visit_importfrom_relative(self.current_package, node)
return
# Cache current component
current_component: str | None = None
for root in ("homeassistant", "tests"):
if self.current_package.startswith(f"{root}.components."):
current_component = self.current_package.split(".")[2]
current_component = self.current_component
# Checks for hass-relative-import
if not self._check_for_relative_import(
self.current_package, node, current_component
@@ -5,6 +5,8 @@ import re
_INTEGRATION_ROOT = "homeassistant.components"
_INTEGRATION_ROOT_DOT = f"{_INTEGRATION_ROOT}."
_INTEGRATION_TEST_ROOT = "tests.components"
_INTEGRATION_TEST_ROOT_DOT = f"{_INTEGRATION_TEST_ROOT}."
_ROOT_SEGMENT_COUNT = _INTEGRATION_ROOT.count(".") + 1
_MODULE_REGEX: re.Pattern[str] = re.compile(
rf"^{re.escape(_INTEGRATION_ROOT)}\.\w+(\.\w+)?$"
@@ -26,14 +28,20 @@ class IntegrationModule:
"""
def parse_module(module_name: str) -> IntegrationModule | None:
def parse_module(
module_name: str, *, include_test: bool = False
) -> IntegrationModule | None:
"""Parse a dotted module name into integration parts.
Returns ``None`` if *module_name* is not under the integration root.
For deep sub-modules (e.g. ``homeassistant.components.hue.light.v2``),
``module`` is set to the first segment after the domain (``light``).
"""
if not module_name.startswith(_INTEGRATION_ROOT_DOT):
if module_name.startswith(_INTEGRATION_ROOT_DOT):
root = _INTEGRATION_ROOT
elif include_test and module_name.startswith(_INTEGRATION_TEST_ROOT_DOT):
root = _INTEGRATION_TEST_ROOT
else:
return None
parts = module_name.split(".")
@@ -42,13 +50,13 @@ def parse_module(module_name: str) -> IntegrationModule | None:
return None
if n == _ROOT_SEGMENT_COUNT + 1:
return IntegrationModule(
root=_INTEGRATION_ROOT,
root=root,
domain=parts[_ROOT_SEGMENT_COUNT],
module=None,
)
# n >= _ROOT_SEGMENT_COUNT + 2: domain.module[.submodule...]
return IntegrationModule(
root=_INTEGRATION_ROOT,
root=root,
domain=parts[_ROOT_SEGMENT_COUNT],
module=parts[_ROOT_SEGMENT_COUNT + 1],
)
+2
View File
@@ -29,6 +29,7 @@ from . import (
mypy_config,
quality_scale,
requirements,
sensor,
services,
ssdp,
translations,
@@ -69,6 +70,7 @@ HASS_PLUGINS = [
mdi_icons,
mypy_config,
metadata,
sensor,
]
ALL_PLUGIN_NAMES = [
+40
View File
@@ -0,0 +1,40 @@
"""Generate the sensor.json file."""
import json
from homeassistant.components.sensor.const import (
NON_NUMERIC_DEVICE_CLASSES,
SensorDeviceClass,
)
from .model import Config, Integration
PATH = "homeassistant/generated/sensor.json"
def _generate() -> str:
"""Generate the sensor data."""
numeric_device_classes = sorted(
device_class.value
for device_class in set(SensorDeviceClass) - NON_NUMERIC_DEVICE_CLASSES
)
return json.dumps({"numeric_device_classes": numeric_device_classes}, indent=2)
def validate(integrations: dict[str, Integration], config: Config) -> None:
"""Validate sensor.json."""
path = config.root / PATH
config.cache["sensor"] = content = _generate()
if path.read_text() != content + "\n":
config.add_error(
"sensor",
"File sensor.json is not up to date. Run python3 -m script.hassfest",
fixable=True,
)
def generate(integrations: dict[str, Integration], config: Config) -> None:
"""Generate sensor.json."""
path = config.root / PATH
path.write_text(f"{config.cache['sensor']}\n")
+6 -2
View File
@@ -157,8 +157,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed(
history_stats_config_entry.entry_id not in hass.config_entries.async_entry_ids()
)
# Check we got the expected events
assert events == ["remove"]
# Check we got the expected events: the helper entity's device link is
# cleared when the source device is removed (the helper entity belongs to
# the history_stats config entry, not the removed source config entry),
# then the helper entity is removed when the history_stats config entry is
# removed. Both registry actions are observed in fire order.
assert events == ["update", "remove"]
@pytest.mark.usefixtures("recorder_mock")
+6 -2
View File
@@ -145,8 +145,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed(
# Check that the statistics config entry is removed
assert statistics_config_entry.entry_id not in hass.config_entries.async_entry_ids()
# Check we got the expected events
assert events == ["remove"]
# Check we got the expected events: the helper entity's device link is
# cleared when the source device is removed (the helper entity belongs to
# the statistics config entry, not the removed source config entry), then
# the helper entity is removed when the statistics config entry is removed.
# Both registry actions are observed in fire order.
assert events == ["update", "remove"]
async def test_async_handle_source_entity_changes_source_entity_removed_shared_device(
+6 -2
View File
@@ -177,8 +177,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed(
# Check that the trend config entry is removed
assert trend_config_entry.entry_id not in hass.config_entries.async_entry_ids()
# Check we got the expected events
assert events == ["remove"]
# Check we got the expected events: the helper entity's device link is
# cleared when the source device is removed (the helper entity belongs to
# the trend config entry, not the removed source config entry), then the
# helper entity is removed when the trend config entry is removed. Both
# registry actions are observed in fire order.
assert events == ["update", "remove"]
async def test_async_handle_source_entity_changes_source_entity_removed_shared_device(
+14
View File
@@ -867,6 +867,20 @@ async def test_parse_result(hass: HomeAssistant) -> None:
# ("+48100200300", "+48100200300"), # phone number
("010", "010"),
("0011101.00100001010001", "0011101.00100001010001"),
# Plain string states are not Python literals and must be returned
# unchanged (literal_eval raises and the original render is used).
("on", "on"),
("off", "off"),
("unavailable", "unavailable"),
("home", "home"),
("standby", "standby"),
("True story", "True story"),
# Keyword and collection literals must still be parsed.
("True", True),
("False", False),
("None", None),
# "set()" is the only call expression literal_eval accepts (empty set).
("set()", set()),
):
assert render(hass, tpl) == result
+67 -16
View File
@@ -1336,22 +1336,14 @@ async def test_eventbus_listen_once_run_immediately_coro(hass: HomeAssistant) ->
async def test_eventbus_nested_fire_dispatch_order(hass: HomeAssistant) -> None:
"""Test dispatch order when a listener fires an event synchronously.
Event dispatch is reentrant: an event fired from within a synchronous
listener is dispatched immediately, nested inside the dispatch of the
outer event.
The implementation of event listeners is such that listeners are called
in the order they were registered
As a result, the order in which a listener observes the two
events depends on its registration position relative to the listener
which fires the nested event: listeners registered before it observe
fire order, listeners registered after it observe the nested event
first.
This test documents the current behavior rather than guarantees it: a
non-reentrant (queued) dispatch would make all listeners observe fire
order.
Event dispatch is however non-reentrant: an event fired from within a
synchronous listener is queued and dispatched after the dispatch of the
outer event completes. All listeners therefore observe events in fire
order, regardless of their registration position relative to the
listener which fires the nested event.
"""
observed_before: list[str] = []
observed_after: list[str] = []
@@ -1378,15 +1370,74 @@ async def test_eventbus_nested_fire_dispatch_order(hass: HomeAssistant) -> None:
hass.bus.async_fire("test_outer")
# Registered before the nesting listener: observes fire order.
# All listeners observe fire order, regardless of registration position
# relative to the nesting listener.
assert observed_before == ["test_outer", "test_nested"]
# Registered after the nesting listener: observes inverted order.
assert observed_after == ["test_nested", "test_outer"]
assert observed_after == ["test_outer", "test_nested"]
for unsub in unsubs:
unsub()
async def test_eventbus_nested_fire_endless_loop_guard(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that event listeners firing events in an endless loop are stopped.
A listener which unconditionally fires an event it also listens to would
keep the dispatch drain loop running forever. Once the per-dispatch queue
limit is reached, the bus stops queuing further events and raises in the
firing listener; the raise is caught and logged by the per-listener error
handling.
"""
calls: list[ha.Event] = []
@ha.callback
def refire(event: ha.Event) -> None:
calls.append(event)
hass.bus.async_fire("test_loop")
unsub = hass.bus.async_listen("test_loop", refire)
with patch.object(ha, "_MAX_QUEUED_EVENT_DISPATCHES", 10):
hass.bus.async_fire("test_loop")
# The top-level dispatch plus 10 queued dispatches; the next fire is
# rejected once the queue limit is reached. The firing listener raises,
# which the per-listener error handling catches and logs.
assert len(calls) == 11
assert "Error running job" in caplog.text
assert "are likely firing events in an endless loop" in caplog.text
unsub()
# The bus remains functional after the aborted dispatch
events = async_capture_events(hass, "test_after")
hass.bus.async_fire("test_after")
assert len(events) == 1
async def test_eventbus_fire_raises_when_queue_limit_reached(
hass: HomeAssistant,
) -> None:
"""Test a nested fire raises once the per-dispatch queue limit is reached.
A fire issued while an event is being dispatched is queued, but once the
limit is reached it is rejected with an error instead of being queued.
"""
# Simulate being in the middle of dispatching with the queue limit reached
hass.bus._dispatching = True
hass.bus._queued_event_count = ha._MAX_QUEUED_EVENT_DISPATCHES
try:
with pytest.raises(HomeAssistantError, match="endless loop"):
hass.bus.async_fire("test")
# The rejected event is not queued
assert len(hass.bus._event_queue) == 0
finally:
hass.bus._dispatching = False
hass.bus._queued_event_count = 0
async def test_eventbus_unsubscribe_listener(hass: HomeAssistant) -> None:
"""Test unsubscribe listener from returned function."""
calls = []