mirror of
https://github.com/home-assistant/core.git
synced 2025-07-24 05:37:44 +00:00
Make helpers.frame.report_usage work when called from any thread (#139836)
* Make helpers.frame.report_usage work when called from any thread * Address review comments, update tests * Add test * Update test * Update recorder test * Update tests
This commit is contained in:
parent
cfaf18f942
commit
cc5c8bf5e3
@ -81,6 +81,7 @@ from .helpers import (
|
||||
entity,
|
||||
entity_registry,
|
||||
floor_registry,
|
||||
frame,
|
||||
issue_registry,
|
||||
label_registry,
|
||||
recorder,
|
||||
@ -441,9 +442,10 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
|
||||
if DATA_REGISTRIES_LOADED in hass.data:
|
||||
return
|
||||
hass.data[DATA_REGISTRIES_LOADED] = None
|
||||
translation.async_setup(hass)
|
||||
entity.async_setup(hass)
|
||||
frame.async_setup(hass)
|
||||
template.async_setup(hass)
|
||||
translation.async_setup(hass)
|
||||
await asyncio.gather(
|
||||
create_eager_task(get_internal_store_manager(hass).async_initialize()),
|
||||
create_eager_task(area_registry.async_load(hass)),
|
||||
|
@ -10,18 +10,20 @@ import functools
|
||||
import linecache
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from types import FrameType
|
||||
from typing import Any, cast
|
||||
|
||||
from propcache.api import cached_property
|
||||
|
||||
from homeassistant.core import HomeAssistant, async_get_hass_or_none
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.loader import (
|
||||
Integration,
|
||||
async_get_issue_integration,
|
||||
async_suggest_report_issue,
|
||||
)
|
||||
from homeassistant.util.async_ import run_callback_threadsafe
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -29,6 +31,21 @@ _LOGGER = logging.getLogger(__name__)
|
||||
_REPORTED_INTEGRATIONS: set[str] = set()
|
||||
|
||||
|
||||
class _Hass:
|
||||
"""Container which makes a HomeAssistant instance available to frame helper."""
|
||||
|
||||
hass: HomeAssistant | None = None
|
||||
|
||||
|
||||
_hass = _Hass()
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup(hass: HomeAssistant) -> None:
|
||||
"""Set up the frame helper."""
|
||||
_hass.hass = hass
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class IntegrationFrame:
|
||||
"""Integration frame container."""
|
||||
@ -204,14 +221,49 @@ def report_usage(
|
||||
:param integration_domain: fallback for identifying the integration if the
|
||||
frame is not found
|
||||
"""
|
||||
if (hass := _hass.hass) is None:
|
||||
raise RuntimeError("Frame helper not set up")
|
||||
_report_usage_partial = functools.partial(
|
||||
_report_usage,
|
||||
hass,
|
||||
what,
|
||||
breaks_in_ha_version=breaks_in_ha_version,
|
||||
core_behavior=core_behavior,
|
||||
core_integration_behavior=core_integration_behavior,
|
||||
custom_integration_behavior=custom_integration_behavior,
|
||||
exclude_integrations=exclude_integrations,
|
||||
integration_domain=integration_domain,
|
||||
level=level,
|
||||
)
|
||||
if hass.loop_thread_id != threading.get_ident():
|
||||
future = run_callback_threadsafe(hass.loop, _report_usage_partial)
|
||||
future.result()
|
||||
return
|
||||
_report_usage_partial()
|
||||
|
||||
|
||||
def _report_usage(
|
||||
hass: HomeAssistant,
|
||||
what: str,
|
||||
*,
|
||||
breaks_in_ha_version: str | None,
|
||||
core_behavior: ReportBehavior,
|
||||
core_integration_behavior: ReportBehavior,
|
||||
custom_integration_behavior: ReportBehavior,
|
||||
exclude_integrations: set[str] | None,
|
||||
integration_domain: str | None,
|
||||
level: int,
|
||||
) -> None:
|
||||
"""Report incorrect code usage.
|
||||
|
||||
Must be called from the event loop.
|
||||
"""
|
||||
try:
|
||||
integration_frame = get_integration_frame(
|
||||
exclude_integrations=exclude_integrations
|
||||
)
|
||||
except MissingIntegrationFrame as err:
|
||||
if integration := async_get_issue_integration(
|
||||
hass := async_get_hass_or_none(), integration_domain
|
||||
):
|
||||
if integration := async_get_issue_integration(hass, integration_domain):
|
||||
_report_integration_domain(
|
||||
hass,
|
||||
what,
|
||||
@ -240,6 +292,7 @@ def report_usage(
|
||||
|
||||
if integration_behavior is not ReportBehavior.IGNORE:
|
||||
_report_integration_frame(
|
||||
hass,
|
||||
what,
|
||||
breaks_in_ha_version,
|
||||
integration_frame,
|
||||
@ -299,6 +352,7 @@ def _report_integration_domain(
|
||||
|
||||
|
||||
def _report_integration_frame(
|
||||
hass: HomeAssistant,
|
||||
what: str,
|
||||
breaks_in_ha_version: str | None,
|
||||
integration_frame: IntegrationFrame,
|
||||
@ -316,7 +370,7 @@ def _report_integration_frame(
|
||||
_REPORTED_INTEGRATIONS.add(key)
|
||||
|
||||
report_issue = async_suggest_report_issue(
|
||||
async_get_hass_or_none(),
|
||||
hass,
|
||||
integration_domain=integration_frame.integration,
|
||||
module=integration_frame.module,
|
||||
)
|
||||
|
@ -122,6 +122,7 @@ async def test_setup_multiple_states(
|
||||
},
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("hass")
|
||||
def test_setup_invalid_config(config) -> None:
|
||||
"""Test the history statistics sensor setup with invalid config."""
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""Test pool."""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
@ -8,6 +9,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from homeassistant.components.recorder.const import DB_WORKER_PREFIX
|
||||
from homeassistant.components.recorder.pool import RecorderPool
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
async def test_recorder_pool_called_from_event_loop() -> None:
|
||||
@ -22,7 +24,9 @@ async def test_recorder_pool_called_from_event_loop() -> None:
|
||||
sessionmaker(bind=engine)().connection()
|
||||
|
||||
|
||||
def test_recorder_pool(caplog: pytest.LogCaptureFixture) -> None:
|
||||
async def test_recorder_pool(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test RecorderPool gives the same connection in the creating thread."""
|
||||
recorder_and_worker_thread_ids: set[int] = set()
|
||||
engine = create_engine(
|
||||
@ -35,6 +39,8 @@ def test_recorder_pool(caplog: pytest.LogCaptureFixture) -> None:
|
||||
connections = []
|
||||
add_thread = False
|
||||
|
||||
event = asyncio.Event()
|
||||
|
||||
def _get_connection_twice():
|
||||
if add_thread:
|
||||
recorder_and_worker_thread_ids.add(threading.get_ident())
|
||||
@ -48,33 +54,42 @@ def test_recorder_pool(caplog: pytest.LogCaptureFixture) -> None:
|
||||
session = get_session()
|
||||
connections.append(session.connection().connection.driver_connection)
|
||||
session.close()
|
||||
hass.loop.call_soon_threadsafe(event.set)
|
||||
|
||||
caplog.clear()
|
||||
event.clear()
|
||||
new_thread = threading.Thread(target=_get_connection_twice)
|
||||
new_thread.start()
|
||||
await event.wait()
|
||||
new_thread.join()
|
||||
assert "accesses the database without the database executor" in caplog.text
|
||||
assert connections[0] != connections[1]
|
||||
|
||||
add_thread = True
|
||||
caplog.clear()
|
||||
event.clear()
|
||||
new_thread = threading.Thread(target=_get_connection_twice, name=DB_WORKER_PREFIX)
|
||||
new_thread.start()
|
||||
await event.wait()
|
||||
new_thread.join()
|
||||
assert "accesses the database without the database executor" not in caplog.text
|
||||
assert connections[2] == connections[3]
|
||||
|
||||
caplog.clear()
|
||||
event.clear()
|
||||
new_thread = threading.Thread(target=_get_connection_twice, name="Recorder")
|
||||
new_thread.start()
|
||||
await event.wait()
|
||||
new_thread.join()
|
||||
assert "accesses the database without the database executor" not in caplog.text
|
||||
assert connections[4] == connections[5]
|
||||
|
||||
shutdown = True
|
||||
caplog.clear()
|
||||
event.clear()
|
||||
new_thread = threading.Thread(target=_get_connection_twice, name=DB_WORKER_PREFIX)
|
||||
new_thread.start()
|
||||
await event.wait()
|
||||
new_thread.join()
|
||||
assert "accesses the database without the database executor" not in caplog.text
|
||||
assert connections[6] != connections[7]
|
||||
|
@ -84,6 +84,7 @@ from homeassistant.helpers import (
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
floor_registry as fr,
|
||||
frame,
|
||||
issue_registry as ir,
|
||||
label_registry as lr,
|
||||
recorder as recorder_helper,
|
||||
@ -433,6 +434,7 @@ def reset_hass_threading_local_object() -> Generator[None]:
|
||||
"""Reset the _Hass threading.local object for every test case."""
|
||||
yield
|
||||
ha._hass.__dict__.clear()
|
||||
frame.async_setup(None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
@ -599,6 +601,7 @@ async def hass(
|
||||
async with async_test_home_assistant(loop, load_registries) as hass:
|
||||
orig_exception_handler = loop.get_exception_handler()
|
||||
loop.set_exception_handler(exc_handle)
|
||||
frame.async_setup(hass)
|
||||
|
||||
yield hass
|
||||
|
||||
|
@ -110,7 +110,7 @@
|
||||
# ---
|
||||
# name: test_report_usage_find_issue_tracker_other_thread[custom integration]
|
||||
list([
|
||||
"Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_custom_integration' custom integration",
|
||||
"Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://blablabla.com",
|
||||
])
|
||||
# ---
|
||||
# name: test_report_usage_find_issue_tracker_other_thread[unknown custom integration]
|
||||
|
@ -2080,6 +2080,7 @@ async def test_multiple_zones(hass: HomeAssistant) -> None:
|
||||
assert not test(hass)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hass")
|
||||
async def test_extract_entities() -> None:
|
||||
"""Test extracting entities."""
|
||||
assert condition.async_extract_entities(
|
||||
@ -2153,6 +2154,7 @@ async def test_extract_entities() -> None:
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hass")
|
||||
async def test_extract_devices() -> None:
|
||||
"""Test extracting devices."""
|
||||
assert condition.async_extract_devices(
|
||||
|
@ -773,6 +773,7 @@ async def test_dynamic_template_no_hass(hass: HomeAssistant) -> None:
|
||||
await hass.async_add_executor_job(schema, value)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hass")
|
||||
def test_template_complex() -> None:
|
||||
"""Test template_complex validator."""
|
||||
schema = vol.Schema(cv.template_complex)
|
||||
@ -1414,6 +1415,7 @@ def test_key_value_schemas() -> None:
|
||||
schema({"mode": mode, "data": data})
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hass")
|
||||
def test_key_value_schemas_with_default() -> None:
|
||||
"""Test key value schemas."""
|
||||
schema = vol.Schema(
|
||||
@ -1492,6 +1494,7 @@ def test_key_value_schemas_with_default() -> None:
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("hass")
|
||||
def test_script(caplog: pytest.LogCaptureFixture, config: dict, error: str) -> None:
|
||||
"""Test script validation is user friendly."""
|
||||
with pytest.raises(vol.Invalid, match=error):
|
||||
@ -1570,6 +1573,7 @@ def test_language() -> None:
|
||||
assert schema(value)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hass")
|
||||
def test_positive_time_period_template() -> None:
|
||||
"""Test positive time period template validation."""
|
||||
schema = vol.Schema(cv.positive_time_period_template)
|
||||
|
@ -36,8 +36,8 @@ async def test_get_integration_logger(
|
||||
assert logger.name == "homeassistant.components.hue"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_custom_integrations")
|
||||
async def test_extract_frame_resolve_module(hass: HomeAssistant) -> None:
|
||||
@pytest.mark.usefixtures("enable_custom_integrations", "hass")
|
||||
async def test_extract_frame_resolve_module() -> None:
|
||||
"""Test extracting the current frame from integration context."""
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from custom_components.test_integration_frame import call_get_integration_frame
|
||||
@ -53,8 +53,8 @@ async def test_extract_frame_resolve_module(hass: HomeAssistant) -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_custom_integrations")
|
||||
async def test_get_integration_logger_resolve_module(hass: HomeAssistant) -> None:
|
||||
@pytest.mark.usefixtures("enable_custom_integrations", "hass")
|
||||
async def test_get_integration_logger_resolve_module() -> None:
|
||||
"""Test getting the logger from integration context."""
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from custom_components.test_integration_frame import call_get_integration_logger
|
||||
@ -228,7 +228,7 @@ async def test_get_integration_logger_no_integration(
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_integration_frame")
|
||||
@pytest.mark.usefixtures("hass", "mock_integration_frame")
|
||||
async def test_report_usage(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
snapshot: SnapshotAssertion,
|
||||
@ -254,6 +254,13 @@ async def test_report_usage(
|
||||
assert reports == snapshot
|
||||
|
||||
|
||||
async def test_report_usage_no_hass() -> None:
|
||||
"""Test report_usage when frame helper is not set up."""
|
||||
|
||||
with pytest.raises(RuntimeError, match="Frame helper not set up"):
|
||||
frame.report_usage("blablabla")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"integration_frame_path",
|
||||
[
|
||||
@ -370,8 +377,9 @@ async def test_report_usage_find_issue_tracker_other_thread(
|
||||
|
||||
|
||||
@patch.object(frame, "_REPORTED_INTEGRATIONS", set())
|
||||
@pytest.mark.usefixtures("hass", "mock_integration_frame")
|
||||
async def test_prevent_flooding(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock
|
||||
caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock
|
||||
) -> None:
|
||||
"""Test to ensure a report is only written once to the log."""
|
||||
|
||||
@ -401,8 +409,9 @@ async def test_prevent_flooding(
|
||||
|
||||
|
||||
@patch.object(frame, "_REPORTED_INTEGRATIONS", set())
|
||||
@pytest.mark.usefixtures("hass", "mock_integration_frame")
|
||||
async def test_breaks_in_ha_version(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock
|
||||
caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock
|
||||
) -> None:
|
||||
"""Test to ensure a report is only written once to the log."""
|
||||
|
||||
@ -422,6 +431,7 @@ async def test_breaks_in_ha_version(
|
||||
assert expected_message in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hass")
|
||||
async def test_report_missing_integration_frame(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
@ -445,6 +455,7 @@ async def test_report_missing_integration_frame(
|
||||
@pytest.mark.parametrize("run_count", [1, 2])
|
||||
# Run this twice to make sure the flood check does not
|
||||
# kick in when error_if_integration=True
|
||||
@pytest.mark.usefixtures("hass")
|
||||
async def test_report_error_if_integration(
|
||||
caplog: pytest.LogCaptureFixture, run_count: int
|
||||
) -> None:
|
||||
@ -545,7 +556,7 @@ async def test_report_error_if_integration(
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_integration_frame")
|
||||
@pytest.mark.usefixtures("hass", "mock_integration_frame")
|
||||
async def test_report(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
snapshot: SnapshotAssertion,
|
||||
|
@ -980,6 +980,7 @@ def test_datetime_selector_schema(schema, valid_selections, invalid_selections)
|
||||
("schema", "valid_selections", "invalid_selections"),
|
||||
[({}, ("abc123", "{{ now() }}"), (None, "{{ incomplete }", "{% if True %}Hi!"))],
|
||||
)
|
||||
@pytest.mark.usefixtures("hass")
|
||||
def test_template_selector_schema(schema, valid_selections, invalid_selections) -> None:
|
||||
"""Test template selector."""
|
||||
_test_selector("template", schema, valid_selections, invalid_selections)
|
||||
|
@ -149,6 +149,7 @@ async def test_template_render_info_collision(hass: HomeAssistant) -> None:
|
||||
template_obj.async_render_to_info()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hass")
|
||||
def test_template_equality() -> None:
|
||||
"""Test template comparison and hashing."""
|
||||
template_one = template.Template("{{ template_one }}")
|
||||
@ -5166,6 +5167,7 @@ def test_iif(hass: HomeAssistant) -> None:
|
||||
assert tpl.async_render() == "no"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hass")
|
||||
async def test_cache_garbage_collection() -> None:
|
||||
"""Test caching a template."""
|
||||
template_string = (
|
||||
|
@ -5995,7 +5995,7 @@ async def test_async_wait_component_startup(hass: HomeAssistant) -> None:
|
||||
"integration_frame_path",
|
||||
["homeassistant/components/my_integration", "homeassistant.core"],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_integration_frame")
|
||||
@pytest.mark.usefixtures("hass", "mock_integration_frame")
|
||||
async def test_options_flow_with_config_entry_core() -> None:
|
||||
"""Test that OptionsFlowWithConfigEntry cannot be used in core."""
|
||||
entry = MockConfigEntry(
|
||||
@ -6009,7 +6009,7 @@ async def test_options_flow_with_config_entry_core() -> None:
|
||||
|
||||
|
||||
@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"])
|
||||
@pytest.mark.usefixtures("mock_integration_frame")
|
||||
@pytest.mark.usefixtures("hass", "mock_integration_frame")
|
||||
@patch.object(frame, "_REPORTED_INTEGRATIONS", set())
|
||||
async def test_options_flow_with_config_entry(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Test that OptionsFlowWithConfigEntry doesn't mutate entry options."""
|
||||
|
Loading…
x
Reference in New Issue
Block a user