diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 08803aaded6..307a297272c 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -132,24 +132,24 @@ def deprecated_function( def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> None: logger = logging.getLogger(obj.__module__) try: - _, integration, path = get_integration_frame() - if path == "custom_components/": + integration_frame = get_integration_frame() + if integration_frame.custom_integration: logger.warning( ( "%s was called from %s, this is a deprecated %s. Use %s instead," " please report this to the maintainer of %s" ), obj.__name__, - integration, + integration_frame.integration, description, replacement, - integration, + integration_frame.integration, ) else: logger.warning( "%s was called from %s, this is a deprecated %s. Use %s instead", obj.__name__, - integration, + integration_frame.integration, description, replacement, ) diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 988db411a6b..084a781bf62 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable +from dataclasses import dataclass import functools import logging from traceback import FrameSummary, extract_stack @@ -18,9 +19,17 @@ _REPORTED_INTEGRATIONS: set[str] = set() _CallableT = TypeVar("_CallableT", bound=Callable) -def get_integration_frame( - exclude_integrations: set | None = None, -) -> tuple[FrameSummary, str, str]: +@dataclass +class IntegrationFrame: + """Integration frame container.""" + + custom_integration: bool + filename: str + frame: FrameSummary + integration: str + + +def get_integration_frame(exclude_integrations: set | None = None) -> IntegrationFrame: """Return the frame, integration and integration path of the current stack frame.""" found_frame = None if not exclude_integrations: @@ -46,7 +55,12 @@ def get_integration_frame( if found_frame is None: raise MissingIntegrationFrame - return found_frame, integration, path + return IntegrationFrame( + path == "custom_components/", + found_frame.filename[index:], + found_frame, + integration, + ) class MissingIntegrationFrame(HomeAssistantError): @@ -74,28 +88,26 @@ def report( _LOGGER.warning(msg, stack_info=True) return - report_integration(what, integration_frame, level) + _report_integration(what, integration_frame, level) -def report_integration( +def _report_integration( what: str, - integration_frame: tuple[FrameSummary, str, str], + integration_frame: IntegrationFrame, level: int = logging.WARNING, ) -> None: """Report incorrect usage in an integration. Async friendly. """ - found_frame, integration, path = integration_frame - + found_frame = integration_frame.frame # Keep track of integrations already reported to prevent flooding key = f"{found_frame.filename}:{found_frame.lineno}" if key in _REPORTED_INTEGRATIONS: return _REPORTED_INTEGRATIONS.add(key) - index = found_frame.filename.index(path) - if path == "custom_components/": + if integration_frame.custom_integration: extra = " to the custom integration author" else: extra = "" @@ -108,8 +120,8 @@ def report_integration( ), what, extra, - integration, - found_frame.filename[index:], + integration_frame.integration, + integration_frame.filename, found_frame.lineno, (found_frame.line or "?").strip(), ) diff --git a/tests/conftest.py b/tests/conftest.py index f743a2fe96a..015cae17205 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1515,33 +1515,6 @@ async def recorder_mock( return await async_setup_recorder_instance(hass, recorder_config) -@pytest.fixture -def mock_integration_frame() -> Generator[Mock, None, None]: - """Mock as if we're calling code from inside an integration.""" - correct_frame = Mock( - filename="/home/paulus/homeassistant/components/hue/light.py", - lineno="23", - line="self.light.is_on", - ) - with patch( - "homeassistant.helpers.frame.extract_stack", - return_value=[ - Mock( - filename="/home/paulus/homeassistant/core.py", - lineno="23", - line="do_something()", - ), - correct_frame, - Mock( - filename="/home/paulus/aiohue/lights.py", - lineno="2", - line="something()", - ), - ], - ): - yield correct_frame - - @pytest.fixture(name="enable_bluetooth") async def mock_enable_bluetooth( hass: HomeAssistant, diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 380801123b0..1128f7d43c6 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -1,5 +1,5 @@ """Test deprecation helpers.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch import pytest @@ -87,7 +87,10 @@ def test_config_get_deprecated_new(mock_get_logger) -> None: def test_deprecated_function(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated_function decorator.""" + """Test deprecated_function decorator. + + This tests the behavior when the calling integration is not known. + """ @deprecated_function("new_function") def mock_deprecated_function(): @@ -98,3 +101,82 @@ def test_deprecated_function(caplog: pytest.LogCaptureFixture) -> None: "mock_deprecated_function is a deprecated function. Use new_function instead" in caplog.text ) + + +def test_deprecated_function_called_from_built_in_integration( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test deprecated_function decorator. + + This tests the behavior when the calling integration is built-in. + """ + + @deprecated_function("new_function") + def mock_deprecated_function(): + pass + + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="await session.close()", + ), + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + mock_deprecated_function() + assert ( + "mock_deprecated_function was called from hue, this is a deprecated function. " + "Use new_function instead" in caplog.text + ) + + +def test_deprecated_function_called_from_custom_integration( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test deprecated_function decorator. + + This tests the behavior when the calling integration is custom. + """ + + @deprecated_function("new_function") + def mock_deprecated_function(): + pass + + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + Mock( + filename="/home/paulus/config/custom_components/hue/light.py", + lineno="23", + line="await session.close()", + ), + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + mock_deprecated_function() + assert ( + "mock_deprecated_function was called from hue, this is a deprecated function. " + "Use new_function instead, please report this to the maintainer of hue" + in caplog.text + ) diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 3086bebe09d..53d799a0400 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -1,5 +1,6 @@ """Test the frame helper.""" +from collections.abc import Generator from unittest.mock import Mock, patch import pytest @@ -7,15 +8,41 @@ import pytest from homeassistant.helpers import frame +@pytest.fixture +def mock_integration_frame() -> Generator[Mock, None, None]: + """Mock as if we're calling code from inside an integration.""" + correct_frame = Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="self.light.is_on", + ) + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + correct_frame, + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + yield correct_frame + + async def test_extract_frame_integration( caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock ) -> None: """Test extracting the current frame from integration context.""" - found_frame, integration, path = frame.get_integration_frame() - - assert integration == "hue" - assert path == "homeassistant/components/" - assert found_frame == mock_integration_frame + integration_frame = frame.get_integration_frame() + assert integration_frame == frame.IntegrationFrame( + False, "homeassistant/components/hue/light.py", mock_integration_frame, "hue" + ) async def test_extract_frame_integration_with_excluded_integration( @@ -48,13 +75,13 @@ async def test_extract_frame_integration_with_excluded_integration( ), ], ): - found_frame, integration, path = frame.get_integration_frame( + integration_frame = frame.get_integration_frame( exclude_integrations={"zeroconf"} ) - assert integration == "mdns" - assert path == "homeassistant/components/" - assert found_frame == correct_frame + assert integration_frame == frame.IntegrationFrame( + False, "homeassistant/components/mdns/light.py", correct_frame, "mdns" + ) async def test_extract_frame_no_integration(caplog: pytest.LogCaptureFixture) -> None: @@ -77,23 +104,32 @@ async def test_extract_frame_no_integration(caplog: pytest.LogCaptureFixture) -> frame.get_integration_frame() -@pytest.mark.usefixtures("mock_integration_frame") @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) -async def test_prevent_flooding(caplog: pytest.LogCaptureFixture) -> None: +async def test_prevent_flooding( + caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock +) -> None: """Test to ensure a report is only written once to the log.""" what = "accessed hi instead of hello" key = "/home/paulus/homeassistant/components/hue/light.py:23" + integration = "hue" + filename = "homeassistant/components/hue/light.py" + + expected_message = ( + f"Detected integration that {what}. Please report issue for {integration} using" + f" this method at {filename}, line " + f"{mock_integration_frame.lineno}: {mock_integration_frame.line}" + ) frame.report(what, error_if_core=False) - assert what in caplog.text + assert expected_message in caplog.text assert key in frame._REPORTED_INTEGRATIONS assert len(frame._REPORTED_INTEGRATIONS) == 1 caplog.clear() frame.report(what, error_if_core=False) - assert what not in caplog.text + assert expected_message not in caplog.text assert key in frame._REPORTED_INTEGRATIONS assert len(frame._REPORTED_INTEGRATIONS) == 1