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,
entity_registry, entity_registry,
floor_registry, floor_registry,
frame,
issue_registry, issue_registry,
label_registry, label_registry,
recorder, recorder,
@ -441,9 +442,10 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
if DATA_REGISTRIES_LOADED in hass.data: if DATA_REGISTRIES_LOADED in hass.data:
return return
hass.data[DATA_REGISTRIES_LOADED] = None hass.data[DATA_REGISTRIES_LOADED] = None
translation.async_setup(hass)
entity.async_setup(hass) entity.async_setup(hass)
frame.async_setup(hass)
template.async_setup(hass) template.async_setup(hass)
translation.async_setup(hass)
await asyncio.gather( await asyncio.gather(
create_eager_task(get_internal_store_manager(hass).async_initialize()), create_eager_task(get_internal_store_manager(hass).async_initialize()),
create_eager_task(area_registry.async_load(hass)), create_eager_task(area_registry.async_load(hass)),

View File

@ -10,18 +10,20 @@ import functools
import linecache import linecache
import logging import logging
import sys import sys
import threading
from types import FrameType from types import FrameType
from typing import Any, cast from typing import Any, cast
from propcache.api import cached_property 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.exceptions import HomeAssistantError
from homeassistant.loader import ( from homeassistant.loader import (
Integration, Integration,
async_get_issue_integration, async_get_issue_integration,
async_suggest_report_issue, async_suggest_report_issue,
) )
from homeassistant.util.async_ import run_callback_threadsafe
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -29,6 +31,21 @@ _LOGGER = logging.getLogger(__name__)
_REPORTED_INTEGRATIONS: set[str] = set() _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) @dataclass(kw_only=True)
class IntegrationFrame: class IntegrationFrame:
"""Integration frame container.""" """Integration frame container."""
@ -204,14 +221,49 @@ def report_usage(
:param integration_domain: fallback for identifying the integration if the :param integration_domain: fallback for identifying the integration if the
frame is not found 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: try:
integration_frame = get_integration_frame( integration_frame = get_integration_frame(
exclude_integrations=exclude_integrations exclude_integrations=exclude_integrations
) )
except MissingIntegrationFrame as err: except MissingIntegrationFrame as err:
if integration := async_get_issue_integration( if integration := async_get_issue_integration(hass, integration_domain):
hass := async_get_hass_or_none(), integration_domain
):
_report_integration_domain( _report_integration_domain(
hass, hass,
what, what,
@ -240,6 +292,7 @@ def report_usage(
if integration_behavior is not ReportBehavior.IGNORE: if integration_behavior is not ReportBehavior.IGNORE:
_report_integration_frame( _report_integration_frame(
hass,
what, what,
breaks_in_ha_version, breaks_in_ha_version,
integration_frame, integration_frame,
@ -299,6 +352,7 @@ def _report_integration_domain(
def _report_integration_frame( def _report_integration_frame(
hass: HomeAssistant,
what: str, what: str,
breaks_in_ha_version: str | None, breaks_in_ha_version: str | None,
integration_frame: IntegrationFrame, integration_frame: IntegrationFrame,
@ -316,7 +370,7 @@ def _report_integration_frame(
_REPORTED_INTEGRATIONS.add(key) _REPORTED_INTEGRATIONS.add(key)
report_issue = async_suggest_report_issue( report_issue = async_suggest_report_issue(
async_get_hass_or_none(), hass,
integration_domain=integration_frame.integration, integration_domain=integration_frame.integration,
module=integration_frame.module, 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: def test_setup_invalid_config(config) -> None:
"""Test the history statistics sensor setup with invalid config.""" """Test the history statistics sensor setup with invalid config."""

View File

@ -1,5 +1,6 @@
"""Test pool.""" """Test pool."""
import asyncio
import threading import threading
import pytest import pytest
@ -8,6 +9,7 @@ from sqlalchemy.orm import sessionmaker
from homeassistant.components.recorder.const import DB_WORKER_PREFIX from homeassistant.components.recorder.const import DB_WORKER_PREFIX
from homeassistant.components.recorder.pool import RecorderPool from homeassistant.components.recorder.pool import RecorderPool
from homeassistant.core import HomeAssistant
async def test_recorder_pool_called_from_event_loop() -> None: 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() 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.""" """Test RecorderPool gives the same connection in the creating thread."""
recorder_and_worker_thread_ids: set[int] = set() recorder_and_worker_thread_ids: set[int] = set()
engine = create_engine( engine = create_engine(
@ -35,6 +39,8 @@ def test_recorder_pool(caplog: pytest.LogCaptureFixture) -> None:
connections = [] connections = []
add_thread = False add_thread = False
event = asyncio.Event()
def _get_connection_twice(): def _get_connection_twice():
if add_thread: if add_thread:
recorder_and_worker_thread_ids.add(threading.get_ident()) recorder_and_worker_thread_ids.add(threading.get_ident())
@ -48,33 +54,42 @@ def test_recorder_pool(caplog: pytest.LogCaptureFixture) -> None:
session = get_session() session = get_session()
connections.append(session.connection().connection.driver_connection) connections.append(session.connection().connection.driver_connection)
session.close() session.close()
hass.loop.call_soon_threadsafe(event.set)
caplog.clear() caplog.clear()
event.clear()
new_thread = threading.Thread(target=_get_connection_twice) new_thread = threading.Thread(target=_get_connection_twice)
new_thread.start() new_thread.start()
await event.wait()
new_thread.join() new_thread.join()
assert "accesses the database without the database executor" in caplog.text assert "accesses the database without the database executor" in caplog.text
assert connections[0] != connections[1] assert connections[0] != connections[1]
add_thread = True add_thread = True
caplog.clear() caplog.clear()
event.clear()
new_thread = threading.Thread(target=_get_connection_twice, name=DB_WORKER_PREFIX) new_thread = threading.Thread(target=_get_connection_twice, name=DB_WORKER_PREFIX)
new_thread.start() new_thread.start()
await event.wait()
new_thread.join() new_thread.join()
assert "accesses the database without the database executor" not in caplog.text assert "accesses the database without the database executor" not in caplog.text
assert connections[2] == connections[3] assert connections[2] == connections[3]
caplog.clear() caplog.clear()
event.clear()
new_thread = threading.Thread(target=_get_connection_twice, name="Recorder") new_thread = threading.Thread(target=_get_connection_twice, name="Recorder")
new_thread.start() new_thread.start()
await event.wait()
new_thread.join() new_thread.join()
assert "accesses the database without the database executor" not in caplog.text assert "accesses the database without the database executor" not in caplog.text
assert connections[4] == connections[5] assert connections[4] == connections[5]
shutdown = True shutdown = True
caplog.clear() caplog.clear()
event.clear()
new_thread = threading.Thread(target=_get_connection_twice, name=DB_WORKER_PREFIX) new_thread = threading.Thread(target=_get_connection_twice, name=DB_WORKER_PREFIX)
new_thread.start() new_thread.start()
await event.wait()
new_thread.join() new_thread.join()
assert "accesses the database without the database executor" not in caplog.text assert "accesses the database without the database executor" not in caplog.text
assert connections[6] != connections[7] assert connections[6] != connections[7]

View File

@ -84,6 +84,7 @@ from homeassistant.helpers import (
device_registry as dr, device_registry as dr,
entity_registry as er, entity_registry as er,
floor_registry as fr, floor_registry as fr,
frame,
issue_registry as ir, issue_registry as ir,
label_registry as lr, label_registry as lr,
recorder as recorder_helper, 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.""" """Reset the _Hass threading.local object for every test case."""
yield yield
ha._hass.__dict__.clear() ha._hass.__dict__.clear()
frame.async_setup(None)
@pytest.fixture(autouse=True, scope="session") @pytest.fixture(autouse=True, scope="session")
@ -599,6 +601,7 @@ async def hass(
async with async_test_home_assistant(loop, load_registries) as hass: async with async_test_home_assistant(loop, load_registries) as hass:
orig_exception_handler = loop.get_exception_handler() orig_exception_handler = loop.get_exception_handler()
loop.set_exception_handler(exc_handle) loop.set_exception_handler(exc_handle)
frame.async_setup(hass)
yield hass yield hass

View File

@ -110,7 +110,7 @@
# --- # ---
# name: test_report_usage_find_issue_tracker_other_thread[custom integration] # name: test_report_usage_find_issue_tracker_other_thread[custom integration]
list([ 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] # 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) assert not test(hass)
@pytest.mark.usefixtures("hass")
async def test_extract_entities() -> None: async def test_extract_entities() -> None:
"""Test extracting entities.""" """Test extracting entities."""
assert condition.async_extract_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: async def test_extract_devices() -> None:
"""Test extracting devices.""" """Test extracting devices."""
assert condition.async_extract_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) await hass.async_add_executor_job(schema, value)
@pytest.mark.usefixtures("hass")
def test_template_complex() -> None: def test_template_complex() -> None:
"""Test template_complex validator.""" """Test template_complex validator."""
schema = vol.Schema(cv.template_complex) schema = vol.Schema(cv.template_complex)
@ -1414,6 +1415,7 @@ def test_key_value_schemas() -> None:
schema({"mode": mode, "data": data}) schema({"mode": mode, "data": data})
@pytest.mark.usefixtures("hass")
def test_key_value_schemas_with_default() -> None: def test_key_value_schemas_with_default() -> None:
"""Test key value schemas.""" """Test key value schemas."""
schema = vol.Schema( 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: def test_script(caplog: pytest.LogCaptureFixture, config: dict, error: str) -> None:
"""Test script validation is user friendly.""" """Test script validation is user friendly."""
with pytest.raises(vol.Invalid, match=error): with pytest.raises(vol.Invalid, match=error):
@ -1570,6 +1573,7 @@ def test_language() -> None:
assert schema(value) assert schema(value)
@pytest.mark.usefixtures("hass")
def test_positive_time_period_template() -> None: def test_positive_time_period_template() -> None:
"""Test positive time period template validation.""" """Test positive time period template validation."""
schema = vol.Schema(cv.positive_time_period_template) 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" assert logger.name == "homeassistant.components.hue"
@pytest.mark.usefixtures("enable_custom_integrations") @pytest.mark.usefixtures("enable_custom_integrations", "hass")
async def test_extract_frame_resolve_module(hass: HomeAssistant) -> None: async def test_extract_frame_resolve_module() -> None:
"""Test extracting the current frame from integration context.""" """Test extracting the current frame from integration context."""
# pylint: disable-next=import-outside-toplevel # pylint: disable-next=import-outside-toplevel
from custom_components.test_integration_frame import call_get_integration_frame 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") @pytest.mark.usefixtures("enable_custom_integrations", "hass")
async def test_get_integration_logger_resolve_module(hass: HomeAssistant) -> None: async def test_get_integration_logger_resolve_module() -> None:
"""Test getting the logger from integration context.""" """Test getting the logger from integration context."""
# pylint: disable-next=import-outside-toplevel # pylint: disable-next=import-outside-toplevel
from custom_components.test_integration_frame import call_get_integration_logger 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( async def test_report_usage(
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
@ -254,6 +254,13 @@ async def test_report_usage(
assert reports == snapshot 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( @pytest.mark.parametrize(
"integration_frame_path", "integration_frame_path",
[ [
@ -370,8 +377,9 @@ async def test_report_usage_find_issue_tracker_other_thread(
@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) @patch.object(frame, "_REPORTED_INTEGRATIONS", set())
@pytest.mark.usefixtures("hass", "mock_integration_frame")
async def test_prevent_flooding( async def test_prevent_flooding(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock
) -> None: ) -> None:
"""Test to ensure a report is only written once to the log.""" """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()) @patch.object(frame, "_REPORTED_INTEGRATIONS", set())
@pytest.mark.usefixtures("hass", "mock_integration_frame")
async def test_breaks_in_ha_version( async def test_breaks_in_ha_version(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock
) -> None: ) -> None:
"""Test to ensure a report is only written once to the log.""" """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 assert expected_message in caplog.text
@pytest.mark.usefixtures("hass")
async def test_report_missing_integration_frame( async def test_report_missing_integration_frame(
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
@ -445,6 +455,7 @@ async def test_report_missing_integration_frame(
@pytest.mark.parametrize("run_count", [1, 2]) @pytest.mark.parametrize("run_count", [1, 2])
# Run this twice to make sure the flood check does not # Run this twice to make sure the flood check does not
# kick in when error_if_integration=True # kick in when error_if_integration=True
@pytest.mark.usefixtures("hass")
async def test_report_error_if_integration( async def test_report_error_if_integration(
caplog: pytest.LogCaptureFixture, run_count: int caplog: pytest.LogCaptureFixture, run_count: int
) -> None: ) -> 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( async def test_report(
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,

View File

@ -980,6 +980,7 @@ def test_datetime_selector_schema(schema, valid_selections, invalid_selections)
("schema", "valid_selections", "invalid_selections"), ("schema", "valid_selections", "invalid_selections"),
[({}, ("abc123", "{{ now() }}"), (None, "{{ incomplete }", "{% if True %}Hi!"))], [({}, ("abc123", "{{ now() }}"), (None, "{{ incomplete }", "{% if True %}Hi!"))],
) )
@pytest.mark.usefixtures("hass")
def test_template_selector_schema(schema, valid_selections, invalid_selections) -> None: def test_template_selector_schema(schema, valid_selections, invalid_selections) -> None:
"""Test template selector.""" """Test template selector."""
_test_selector("template", schema, valid_selections, invalid_selections) _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() template_obj.async_render_to_info()
@pytest.mark.usefixtures("hass")
def test_template_equality() -> None: def test_template_equality() -> None:
"""Test template comparison and hashing.""" """Test template comparison and hashing."""
template_one = template.Template("{{ template_one }}") template_one = template.Template("{{ template_one }}")
@ -5166,6 +5167,7 @@ def test_iif(hass: HomeAssistant) -> None:
assert tpl.async_render() == "no" assert tpl.async_render() == "no"
@pytest.mark.usefixtures("hass")
async def test_cache_garbage_collection() -> None: async def test_cache_garbage_collection() -> None:
"""Test caching a template.""" """Test caching a template."""
template_string = ( template_string = (

View File

@ -5995,7 +5995,7 @@ async def test_async_wait_component_startup(hass: HomeAssistant) -> None:
"integration_frame_path", "integration_frame_path",
["homeassistant/components/my_integration", "homeassistant.core"], ["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: async def test_options_flow_with_config_entry_core() -> None:
"""Test that OptionsFlowWithConfigEntry cannot be used in core.""" """Test that OptionsFlowWithConfigEntry cannot be used in core."""
entry = MockConfigEntry( 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.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()) @patch.object(frame, "_REPORTED_INTEGRATIONS", set())
async def test_options_flow_with_config_entry(caplog: pytest.LogCaptureFixture) -> None: async def test_options_flow_with_config_entry(caplog: pytest.LogCaptureFixture) -> None:
"""Test that OptionsFlowWithConfigEntry doesn't mutate entry options.""" """Test that OptionsFlowWithConfigEntry doesn't mutate entry options."""