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:
Erik Montnemery 2025-03-05 19:37:34 +01:00 committed by GitHub
parent cfaf18f942
commit cc5c8bf5e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 113 additions and 18 deletions

View File

@ -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)),

View File

@ -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,
)

View File

@ -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."""

View File

@ -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]

View File

@ -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

View File

@ -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]

View File

@ -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(

View File

@ -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)

View File

@ -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,

View File

@ -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)

View File

@ -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 = (

View File

@ -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."""