mirror of
https://github.com/home-assistant/core.git
synced 2026-06-16 08:52:53 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75ec9a9058 | |||
| 2434341e04 | |||
| 047edc035d | |||
| 8b5f27e016 | |||
| 5200a8131f |
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user