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

View file

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

View file

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

View file

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

View file

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