From 6b9944ec88388932d2724e0e38e3393e8047dcb9 Mon Sep 17 00:00:00 2001 From: Jonathan Kempson Date: Mon, 13 Apr 2026 22:00:24 +0100 Subject: [PATCH 1/2] perf: skip inspect.stack() in _sub_stack when no placeholders present --- appdaemon/adapi.py | 23 +++++++++----- tests/unit/test_sub_stack.py | 59 ++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 tests/unit/test_sub_stack.py diff --git a/appdaemon/adapi.py b/appdaemon/adapi.py index b74166932..90d023efe 100644 --- a/appdaemon/adapi.py +++ b/appdaemon/adapi.py @@ -101,14 +101,21 @@ def __init__(self, ad: AppDaemon, config_model: "AppConfig"): @staticmethod def _sub_stack(msg): # If msg is a data structure of some type, don't sub - if isinstance(msg, str): - stack = inspect.stack() - if msg.find("__module__") != -1: - msg = msg.replace("__module__", stack[2][1]) - if msg.find("__line__") != -1: - msg = msg.replace("__line__", str(stack[2][2])) - if msg.find("__function__") != -1: - msg = msg.replace("__function__", stack[2][3]) + if not isinstance(msg, str): + return msg + # Fast path: avoid the expensive inspect.stack() call (which walks + # every frame and reads source lines via linecache) when no + # placeholders are present. The vast majority of log calls do not + # use __module__/__line__/__function__ substitution. + if "__module__" not in msg and "__line__" not in msg and "__function__" not in msg: + return msg + stack = inspect.stack() + if "__module__" in msg: + msg = msg.replace("__module__", stack[2][1]) + if "__line__" in msg: + msg = msg.replace("__line__", str(stack[2][2])) + if "__function__" in msg: + msg = msg.replace("__function__", stack[2][3]) return msg def _get_namespace(self, **kwargs): diff --git a/tests/unit/test_sub_stack.py b/tests/unit/test_sub_stack.py new file mode 100644 index 000000000..a13f07edb --- /dev/null +++ b/tests/unit/test_sub_stack.py @@ -0,0 +1,59 @@ +import pytest +from appdaemon.adapi import ADAPI + +pytestmark = [ + pytest.mark.ci, + pytest.mark.unit, +] + + +def _log(msg): + """Stand-in for ADAPI.log/error/etc. + + ``_sub_stack`` reads ``stack[2]`` — two frames above itself — because it is + designed to be called from inside a logging wrapper. Tests therefore need + an intermediate frame so the resolved caller is deterministic. + """ + return ADAPI._sub_stack(msg) + + +def test_non_string_returned_unchanged(): + payload = {"key": "value"} + assert ADAPI._sub_stack(payload) is payload + assert ADAPI._sub_stack(123) == 123 + assert ADAPI._sub_stack(None) is None + + +def test_plain_string_returned_unchanged(): + msg = "nothing special here" + assert _log(msg) == msg + + +def test_module_placeholder_substituted(): + result = _log("called from __module__") + assert "__module__" not in result + assert result.startswith("called from ") + + +def test_line_placeholder_substituted(): + result = _log("line=__line__") + assert "__line__" not in result + assert result.startswith("line=") + assert result[len("line=") :].isdigit() + + +def test_function_placeholder_substituted(): + def outer_caller(): + return _log("in __function__") + + assert outer_caller() == "in outer_caller" + + +def test_multiple_placeholders_in_same_message(): + def outer_caller(): + return _log("__function__ at __line__") + + result = outer_caller() + assert "__function__" not in result + assert "__line__" not in result + assert result.startswith("outer_caller at ") From c0fd663ec75d81888c306504d6a172f0270742ec Mon Sep 17 00:00:00 2001 From: Jonathan Kempson Date: Tue, 21 Apr 2026 18:55:15 +0100 Subject: [PATCH 2/2] perf: skip deepcopy in process_event when no /stream subscribers --- appdaemon/events.py | 9 +++ tests/unit/test_event_stream_shortcircuit.py | 66 ++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/unit/test_event_stream_shortcircuit.py diff --git a/appdaemon/events.py b/appdaemon/events.py index d73924b02..f3328b454 100644 --- a/appdaemon/events.py +++ b/appdaemon/events.py @@ -264,6 +264,15 @@ async def process_event(self, namespace: str, data: dict[str, Any]): # if self.AD.http is not None: + # Short-circuit when nobody is subscribed to /stream. The + # deepcopy below exists only to feed stream_update, and + # ADStream.process_event is itself a no-op when handlers + # is empty. When no clients are connected this branch was + # the dominant CPU cost in our deployment. + stream = getattr(self.AD.http, "stream", None) + if stream is not None and not stream.handlers: + return + if data["event_type"] == "state_changed": if data["data"]["new_state"] == data["data"]["old_state"]: # Nothing changed so don't send diff --git a/tests/unit/test_event_stream_shortcircuit.py b/tests/unit/test_event_stream_shortcircuit.py new file mode 100644 index 000000000..95d597b68 --- /dev/null +++ b/tests/unit/test_event_stream_shortcircuit.py @@ -0,0 +1,66 @@ +"""Regression tests for the /stream short-circuit in Events.process_event. + +When no clients are subscribed to /stream the deepcopy + stream_update +work is pure waste — ADStream.process_event already returns immediately +when ``handlers`` is empty. The fix returns before the deepcopy runs. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from appdaemon.events import Events + +pytestmark = [ + pytest.mark.ci, + pytest.mark.unit, + pytest.mark.asyncio, +] + + +def _make_events(handlers): + """Build a minimally-mocked Events with a fake http.stream.handlers.""" + ad = MagicMock() + ad.stopping = False + ad.sched = None + ad.apps_enabled = False + ad.logging = MagicMock() + ad.logging.get_child.return_value = MagicMock() + ad.logging.has_log_callback = AsyncMock(return_value=False) + ad.http = MagicMock() + ad.http.stream.handlers = handlers + ad.http.stream_update = AsyncMock() + ad.state = MagicMock() + ad.state.set_state_simple = AsyncMock() + ad.state.process_state_callbacks = AsyncMock() + return Events(ad), ad + + +def _state_changed_event(): + return { + "event_type": "state_changed", + "data": { + "entity_id": "sensor.x", + "new_state": {"state": "on"}, + "old_state": {"state": "off"}, + }, + } + + +async def test_stream_update_skipped_when_no_handlers(): + events, ad = _make_events(handlers={}) + await events.process_event("default", _state_changed_event()) + ad.http.stream_update.assert_not_called() + + +async def test_stream_update_called_when_handler_present(): + events, ad = _make_events(handlers={"client-1": object()}) + await events.process_event("default", _state_changed_event()) + ad.http.stream_update.assert_called_once() + + +async def test_no_op_when_http_disabled(): + events, ad = _make_events(handlers={"client-1": object()}) + ad.http = None + # Should not raise even though stream is unreachable. + await events.process_event("default", _state_changed_event())