Add new frame helper to better distinguish custom and core integrations (#130025)

* Add new frame helper to clarify options available

* Adjust

* Improve

* Use report_usage in core

* Add tests

* Use is/is not

Co-authored-by: J. Nick Koston <nick@koston.org>

* Use enum.auto()

---------

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
epenet 2024-11-07 18:23:35 +01:00 committed by GitHub
parent ee30520b57
commit a3b0909e3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 177 additions and 33 deletions

View file

@ -656,12 +656,12 @@ class HomeAssistant:
# late import to avoid circular imports # late import to avoid circular imports
from .helpers import frame # pylint: disable=import-outside-toplevel from .helpers import frame # pylint: disable=import-outside-toplevel
frame.report( frame.report_usage(
"calls `async_add_job`, which is deprecated and will be removed in Home " "calls `async_add_job`, which is deprecated and will be removed in Home "
"Assistant 2025.4; Please review " "Assistant 2025.4; Please review "
"https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job" "https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job"
" for replacement options", " for replacement options",
error_if_core=False, core_behavior=frame.ReportBehavior.LOG,
) )
if target is None: if target is None:
@ -712,12 +712,12 @@ class HomeAssistant:
# late import to avoid circular imports # late import to avoid circular imports
from .helpers import frame # pylint: disable=import-outside-toplevel from .helpers import frame # pylint: disable=import-outside-toplevel
frame.report( frame.report_usage(
"calls `async_add_hass_job`, which is deprecated and will be removed in Home " "calls `async_add_hass_job`, which is deprecated and will be removed in Home "
"Assistant 2025.5; Please review " "Assistant 2025.5; Please review "
"https://developers.home-assistant.io/blog/2024/04/07/deprecate_add_hass_job" "https://developers.home-assistant.io/blog/2024/04/07/deprecate_add_hass_job"
" for replacement options", " for replacement options",
error_if_core=False, core_behavior=frame.ReportBehavior.LOG,
) )
return self._async_add_hass_job(hassjob, *args, background=background) return self._async_add_hass_job(hassjob, *args, background=background)
@ -986,12 +986,12 @@ class HomeAssistant:
# late import to avoid circular imports # late import to avoid circular imports
from .helpers import frame # pylint: disable=import-outside-toplevel from .helpers import frame # pylint: disable=import-outside-toplevel
frame.report( frame.report_usage(
"calls `async_run_job`, which is deprecated and will be removed in Home " "calls `async_run_job`, which is deprecated and will be removed in Home "
"Assistant 2025.4; Please review " "Assistant 2025.4; Please review "
"https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job" "https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job"
" for replacement options", " for replacement options",
error_if_core=False, core_behavior=frame.ReportBehavior.LOG,
) )
if asyncio.iscoroutine(target): if asyncio.iscoroutine(target):
@ -1635,10 +1635,10 @@ class EventBus:
# late import to avoid circular imports # late import to avoid circular imports
from .helpers import frame # pylint: disable=import-outside-toplevel from .helpers import frame # pylint: disable=import-outside-toplevel
frame.report( frame.report_usage(
"calls `async_listen` with run_immediately, which is" "calls `async_listen` with run_immediately, which is"
" deprecated and will be removed in Home Assistant 2025.5", " deprecated and will be removed in Home Assistant 2025.5",
error_if_core=False, core_behavior=frame.ReportBehavior.LOG,
) )
if event_filter is not None and not is_callback_check_partial(event_filter): if event_filter is not None and not is_callback_check_partial(event_filter):
@ -1705,10 +1705,10 @@ class EventBus:
# late import to avoid circular imports # late import to avoid circular imports
from .helpers import frame # pylint: disable=import-outside-toplevel from .helpers import frame # pylint: disable=import-outside-toplevel
frame.report( frame.report_usage(
"calls `async_listen_once` with run_immediately, which is " "calls `async_listen_once` with run_immediately, which is "
"deprecated and will be removed in Home Assistant 2025.5", "deprecated and will be removed in Home Assistant 2025.5",
error_if_core=False, core_behavior=frame.ReportBehavior.LOG,
) )
one_time_listener: _OneTimeListener[_DataT] = _OneTimeListener( one_time_listener: _OneTimeListener[_DataT] = _OneTimeListener(

View file

@ -60,7 +60,7 @@ from .core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from .generated.currencies import HISTORIC_CURRENCIES from .generated.currencies import HISTORIC_CURRENCIES
from .helpers import config_validation as cv, issue_registry as ir from .helpers import config_validation as cv, issue_registry as ir
from .helpers.entity_values import EntityValues from .helpers.entity_values import EntityValues
from .helpers.frame import report from .helpers.frame import ReportBehavior, report_usage
from .helpers.storage import Store from .helpers.storage import Store
from .helpers.typing import UNDEFINED, UndefinedType from .helpers.typing import UNDEFINED, UndefinedType
from .util import dt as dt_util, location from .util import dt as dt_util, location
@ -695,11 +695,11 @@ class Config:
It will be removed in Home Assistant 2025.6. It will be removed in Home Assistant 2025.6.
""" """
report( report_usage(
"set the time zone using set_time_zone instead of async_set_time_zone" "set the time zone using set_time_zone instead of async_set_time_zone"
" which will stop working in Home Assistant 2025.6", " which will stop working in Home Assistant 2025.6",
error_if_core=True, core_integration_behavior=ReportBehavior.ERROR,
error_if_integration=True, custom_integration_behavior=ReportBehavior.ERROR,
) )
if time_zone := dt_util.get_time_zone(time_zone_str): if time_zone := dt_util.get_time_zone(time_zone_str):
self.time_zone = time_zone_str self.time_zone = time_zone_str

View file

@ -26,7 +26,7 @@ from .helpers.deprecation import (
check_if_deprecated_constant, check_if_deprecated_constant,
dir_with_deprecated_constants, dir_with_deprecated_constants,
) )
from .helpers.frame import report from .helpers.frame import ReportBehavior, report_usage
from .loader import async_suggest_report_issue from .loader import async_suggest_report_issue
from .util import uuid as uuid_util from .util import uuid as uuid_util
@ -530,12 +530,12 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
if not isinstance(result["type"], FlowResultType): if not isinstance(result["type"], FlowResultType):
result["type"] = FlowResultType(result["type"]) # type: ignore[unreachable] result["type"] = FlowResultType(result["type"]) # type: ignore[unreachable]
report( report_usage(
( (
"does not use FlowResultType enum for data entry flow result type. " "does not use FlowResultType enum for data entry flow result type. "
"This is deprecated and will stop working in Home Assistant 2025.1" "This is deprecated and will stop working in Home Assistant 2025.1"
), ),
error_if_core=False, core_behavior=ReportBehavior.LOG,
) )
if ( if (

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
import enum
import functools import functools
import linecache import linecache
import logging import logging
@ -144,24 +145,72 @@ def report(
If error_if_integration is True, raise instead of log if an integration is found If error_if_integration is True, raise instead of log if an integration is found
when unwinding the stack frame. when unwinding the stack frame.
""" """
core_behavior = ReportBehavior.ERROR if error_if_core else ReportBehavior.LOG
core_integration_behavior = (
ReportBehavior.ERROR if error_if_integration else ReportBehavior.LOG
)
custom_integration_behavior = core_integration_behavior
if log_custom_component_only:
if core_behavior is ReportBehavior.LOG:
core_behavior = ReportBehavior.IGNORE
if core_integration_behavior is ReportBehavior.LOG:
core_integration_behavior = ReportBehavior.IGNORE
report_usage(
what,
core_behavior=core_behavior,
core_integration_behavior=core_integration_behavior,
custom_integration_behavior=custom_integration_behavior,
exclude_integrations=exclude_integrations,
level=level,
)
class ReportBehavior(enum.Enum):
"""Enum for behavior on code usage."""
IGNORE = enum.auto()
"""Ignore the code usage."""
LOG = enum.auto()
"""Log the code usage."""
ERROR = enum.auto()
"""Raise an error on code usage."""
def report_usage(
what: str,
*,
core_behavior: ReportBehavior = ReportBehavior.ERROR,
core_integration_behavior: ReportBehavior = ReportBehavior.LOG,
custom_integration_behavior: ReportBehavior = ReportBehavior.LOG,
exclude_integrations: set[str] | None = None,
level: int = logging.WARNING,
) -> None:
"""Report incorrect code usage.
Similar to `report` but allows more fine-grained reporting.
"""
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:
msg = f"Detected code that {what}. Please report this issue." msg = f"Detected code that {what}. Please report this issue."
if error_if_core: if core_behavior is ReportBehavior.ERROR:
raise RuntimeError(msg) from err raise RuntimeError(msg) from err
if not log_custom_component_only: if core_behavior is ReportBehavior.LOG:
_LOGGER.warning(msg, stack_info=True) _LOGGER.warning(msg, stack_info=True)
return return
if ( integration_behavior = core_integration_behavior
error_if_integration if integration_frame.custom_integration:
or not log_custom_component_only integration_behavior = custom_integration_behavior
or integration_frame.custom_integration
): if integration_behavior is not ReportBehavior.IGNORE:
_report_integration(what, integration_frame, level, error_if_integration) _report_integration(
what, integration_frame, level, integration_behavior is ReportBehavior.ERROR
)
def _report_integration( def _report_integration(

View file

@ -1556,16 +1556,18 @@ class Components:
raise ImportError(f"Unable to load {comp_name}") raise ImportError(f"Unable to load {comp_name}")
# Local import to avoid circular dependencies # Local import to avoid circular dependencies
from .helpers.frame import report # pylint: disable=import-outside-toplevel # pylint: disable-next=import-outside-toplevel
from .helpers.frame import ReportBehavior, report_usage
report( report_usage(
( (
f"accesses hass.components.{comp_name}." f"accesses hass.components.{comp_name}."
" This is deprecated and will stop working in Home Assistant 2025.3, it" " This is deprecated and will stop working in Home Assistant 2025.3, it"
f" should be updated to import functions used from {comp_name} directly" f" should be updated to import functions used from {comp_name} directly"
), ),
error_if_core=False, core_behavior=ReportBehavior.IGNORE,
log_custom_component_only=True, core_integration_behavior=ReportBehavior.IGNORE,
custom_integration_behavior=ReportBehavior.LOG,
) )
wrapped = ModuleWrapper(self._hass, component) wrapped = ModuleWrapper(self._hass, component)
@ -1585,16 +1587,18 @@ class Helpers:
helper = importlib.import_module(f"homeassistant.helpers.{helper_name}") helper = importlib.import_module(f"homeassistant.helpers.{helper_name}")
# Local import to avoid circular dependencies # Local import to avoid circular dependencies
from .helpers.frame import report # pylint: disable=import-outside-toplevel # pylint: disable-next=import-outside-toplevel
from .helpers.frame import ReportBehavior, report_usage
report( report_usage(
( (
f"accesses hass.helpers.{helper_name}." f"accesses hass.helpers.{helper_name}."
" This is deprecated and will stop working in Home Assistant 2025.5, it" " This is deprecated and will stop working in Home Assistant 2025.5, it"
f" should be updated to import functions used from {helper_name} directly" f" should be updated to import functions used from {helper_name} directly"
), ),
error_if_core=False, core_behavior=ReportBehavior.IGNORE,
log_custom_component_only=True, core_integration_behavior=ReportBehavior.IGNORE,
custom_integration_behavior=ReportBehavior.LOG,
) )
wrapped = ModuleWrapper(self._hass, helper) wrapped = ModuleWrapper(self._hass, helper)

View file

@ -157,6 +157,97 @@ async def test_get_integration_logger_no_integration(
assert logger.name == __name__ assert logger.name == __name__
@pytest.mark.parametrize(
("integration_frame_path", "keywords", "expected_error", "expected_log"),
[
pytest.param(
"homeassistant/test_core",
{},
True,
0,
id="core default",
),
pytest.param(
"homeassistant/components/test_core_integration",
{},
False,
1,
id="core integration default",
),
pytest.param(
"custom_components/test_custom_integration",
{},
False,
1,
id="custom integration default",
),
pytest.param(
"custom_components/test_custom_integration",
{"custom_integration_behavior": frame.ReportBehavior.IGNORE},
False,
0,
id="custom integration ignore",
),
pytest.param(
"custom_components/test_custom_integration",
{"custom_integration_behavior": frame.ReportBehavior.ERROR},
True,
1,
id="custom integration error",
),
pytest.param(
"homeassistant/components/test_integration_frame",
{"core_integration_behavior": frame.ReportBehavior.IGNORE},
False,
0,
id="core_integration_behavior ignore",
),
pytest.param(
"homeassistant/components/test_integration_frame",
{"core_integration_behavior": frame.ReportBehavior.ERROR},
True,
1,
id="core_integration_behavior error",
),
pytest.param(
"homeassistant/test_integration_frame",
{"core_behavior": frame.ReportBehavior.IGNORE},
False,
0,
id="core_behavior ignore",
),
pytest.param(
"homeassistant/test_integration_frame",
{"core_behavior": frame.ReportBehavior.LOG},
False,
1,
id="core_behavior log",
),
],
)
@pytest.mark.usefixtures("mock_integration_frame")
async def test_report_usage(
caplog: pytest.LogCaptureFixture,
keywords: dict[str, Any],
expected_error: bool,
expected_log: int,
) -> None:
"""Test report."""
what = "test_report_string"
errored = False
try:
with patch.object(frame, "_REPORTED_INTEGRATIONS", set()):
frame.report_usage(what, **keywords)
except RuntimeError:
errored = True
assert errored == expected_error
assert caplog.text.count(what) == expected_log
@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) @patch.object(frame, "_REPORTED_INTEGRATIONS", set())
async def test_prevent_flooding( async def test_prevent_flooding(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock