Refactor frame.get_integration_frame (#101322)

This commit is contained in:
Erik Montnemery 2023-10-03 19:21:27 +02:00 committed by GitHub
parent 956098ae3a
commit ab2de18f8f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 163 additions and 60 deletions

View file

@ -132,24 +132,24 @@ def deprecated_function(
def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> None: def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> None:
logger = logging.getLogger(obj.__module__) logger = logging.getLogger(obj.__module__)
try: try:
_, integration, path = get_integration_frame() integration_frame = get_integration_frame()
if path == "custom_components/": if integration_frame.custom_integration:
logger.warning( logger.warning(
( (
"%s was called from %s, this is a deprecated %s. Use %s instead," "%s was called from %s, this is a deprecated %s. Use %s instead,"
" please report this to the maintainer of %s" " please report this to the maintainer of %s"
), ),
obj.__name__, obj.__name__,
integration, integration_frame.integration,
description, description,
replacement, replacement,
integration, integration_frame.integration,
) )
else: else:
logger.warning( logger.warning(
"%s was called from %s, this is a deprecated %s. Use %s instead", "%s was called from %s, this is a deprecated %s. Use %s instead",
obj.__name__, obj.__name__,
integration, integration_frame.integration,
description, description,
replacement, replacement,
) )

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass
import functools import functools
import logging import logging
from traceback import FrameSummary, extract_stack from traceback import FrameSummary, extract_stack
@ -18,9 +19,17 @@ _REPORTED_INTEGRATIONS: set[str] = set()
_CallableT = TypeVar("_CallableT", bound=Callable) _CallableT = TypeVar("_CallableT", bound=Callable)
def get_integration_frame( @dataclass
exclude_integrations: set | None = None, class IntegrationFrame:
) -> tuple[FrameSummary, str, str]: """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.""" """Return the frame, integration and integration path of the current stack frame."""
found_frame = None found_frame = None
if not exclude_integrations: if not exclude_integrations:
@ -46,7 +55,12 @@ def get_integration_frame(
if found_frame is None: if found_frame is None:
raise MissingIntegrationFrame raise MissingIntegrationFrame
return found_frame, integration, path return IntegrationFrame(
path == "custom_components/",
found_frame.filename[index:],
found_frame,
integration,
)
class MissingIntegrationFrame(HomeAssistantError): class MissingIntegrationFrame(HomeAssistantError):
@ -74,28 +88,26 @@ def report(
_LOGGER.warning(msg, stack_info=True) _LOGGER.warning(msg, stack_info=True)
return return
report_integration(what, integration_frame, level) _report_integration(what, integration_frame, level)
def report_integration( def _report_integration(
what: str, what: str,
integration_frame: tuple[FrameSummary, str, str], integration_frame: IntegrationFrame,
level: int = logging.WARNING, level: int = logging.WARNING,
) -> None: ) -> None:
"""Report incorrect usage in an integration. """Report incorrect usage in an integration.
Async friendly. Async friendly.
""" """
found_frame, integration, path = integration_frame found_frame = integration_frame.frame
# Keep track of integrations already reported to prevent flooding # Keep track of integrations already reported to prevent flooding
key = f"{found_frame.filename}:{found_frame.lineno}" key = f"{found_frame.filename}:{found_frame.lineno}"
if key in _REPORTED_INTEGRATIONS: if key in _REPORTED_INTEGRATIONS:
return return
_REPORTED_INTEGRATIONS.add(key) _REPORTED_INTEGRATIONS.add(key)
index = found_frame.filename.index(path) if integration_frame.custom_integration:
if path == "custom_components/":
extra = " to the custom integration author" extra = " to the custom integration author"
else: else:
extra = "" extra = ""
@ -108,8 +120,8 @@ def report_integration(
), ),
what, what,
extra, extra,
integration, integration_frame.integration,
found_frame.filename[index:], integration_frame.filename,
found_frame.lineno, found_frame.lineno,
(found_frame.line or "?").strip(), (found_frame.line or "?").strip(),
) )

View file

@ -1515,33 +1515,6 @@ async def recorder_mock(
return await async_setup_recorder_instance(hass, recorder_config) 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") @pytest.fixture(name="enable_bluetooth")
async def mock_enable_bluetooth( async def mock_enable_bluetooth(
hass: HomeAssistant, hass: HomeAssistant,

View file

@ -1,5 +1,5 @@
"""Test deprecation helpers.""" """Test deprecation helpers."""
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, Mock, patch
import pytest import pytest
@ -87,7 +87,10 @@ def test_config_get_deprecated_new(mock_get_logger) -> None:
def test_deprecated_function(caplog: pytest.LogCaptureFixture) -> 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") @deprecated_function("new_function")
def mock_deprecated_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" "mock_deprecated_function is a deprecated function. Use new_function instead"
in caplog.text 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
)

View file

@ -1,5 +1,6 @@
"""Test the frame helper.""" """Test the frame helper."""
from collections.abc import Generator
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import pytest import pytest
@ -7,15 +8,41 @@ import pytest
from homeassistant.helpers import frame 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( async def test_extract_frame_integration(
caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock
) -> None: ) -> None:
"""Test extracting the current frame from integration context.""" """Test extracting the current frame from integration context."""
found_frame, integration, path = frame.get_integration_frame() integration_frame = frame.get_integration_frame()
assert integration_frame == frame.IntegrationFrame(
assert integration == "hue" False, "homeassistant/components/hue/light.py", mock_integration_frame, "hue"
assert path == "homeassistant/components/" )
assert found_frame == mock_integration_frame
async def test_extract_frame_integration_with_excluded_integration( 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"} exclude_integrations={"zeroconf"}
) )
assert integration == "mdns" assert integration_frame == frame.IntegrationFrame(
assert path == "homeassistant/components/" False, "homeassistant/components/mdns/light.py", correct_frame, "mdns"
assert found_frame == correct_frame )
async def test_extract_frame_no_integration(caplog: pytest.LogCaptureFixture) -> None: 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() frame.get_integration_frame()
@pytest.mark.usefixtures("mock_integration_frame")
@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) @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.""" """Test to ensure a report is only written once to the log."""
what = "accessed hi instead of hello" what = "accessed hi instead of hello"
key = "/home/paulus/homeassistant/components/hue/light.py:23" 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) 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 key in frame._REPORTED_INTEGRATIONS
assert len(frame._REPORTED_INTEGRATIONS) == 1 assert len(frame._REPORTED_INTEGRATIONS) == 1
caplog.clear() caplog.clear()
frame.report(what, error_if_core=False) 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 key in frame._REPORTED_INTEGRATIONS
assert len(frame._REPORTED_INTEGRATIONS) == 1 assert len(frame._REPORTED_INTEGRATIONS) == 1