Skip to content

Commit 2ad5114

Browse files
committed
Merge remote-tracking branch 'upstream/master' into feat/issue-863-stdio-settings
# Conflicts: # tests/src/e2e/haos_only/test_manage_addon_modes.py
2 parents b98789e + 5d1d3f8 commit 2ad5114

4 files changed

Lines changed: 94 additions & 56 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,13 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
353353
- **[@w3z315](https://github.com/w3z315)** — Financial support via [GitHub Sponsors](https://github.com/sponsors/julienld). Thank you! ☕
354354
- **[@griffinmartin](https://github.com/griffinmartin)** — Added OpenCode (by Anomaly) as a selectable AI client in the setup wizard, with both stdio and streamable HTTP support.
355355
- **[@hhopke](https://github.com/hhopke)** — Fixed addon API calls to route through HA Core ingress proxy instead of direct container connections, fixing `ha_manage_addon` proxy mode on addon installs.
356+
- **[@tomwilkie](https://github.com/tomwilkie)** — JMESPath middleware exploration (#1147) whose review-time token-measurement data informed the design of #1199 and #1225.
357+
- **[@SealKan](https://github.com/SealKan)**`fields=`/`attribute_keys=` projection on six read-heavy tools (#1225), `ha_call_event` tool (#1239), and dashboards-list helper refactor (#1207).
358+
- **[@KarelTestSpecial](https://github.com/KarelTestSpecial)** — Cached YAML instance to prevent CPU spikes during bulk edits (#1371).
359+
- **[@corgan2222](https://github.com/corgan2222)** — HA brand assets for custom integration (#1317).
360+
- **[@drseanwing](https://github.com/drseanwing)** — Progress emission via FastMCP `Context` in long-running tools (#1124); tool-discovery / categorized-search docs (#1123).
361+
- **[@fnordpig](https://github.com/fnordpig)** — Config subentry support (#1393) and Assist pipeline management tool (#1392).
362+
- **[@paul43210](https://github.com/paul43210)**`array_patch` mode in `ha_manage_addon` for atomic GET-modify-POST (#1063).
356363

357364
---
358365

custom_components/ha_mcp_tools/yaml_rt.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,21 @@ def _register_ha_tags() -> None:
7474
_register_ha_tags()
7575

7676

77-
_THREAD_LOCAL = threading.local()
77+
def _build_yaml() -> YAML:
78+
"""Create a fresh round-trip YAML instance with HA tag support."""
79+
ry = YAML(typ="rt")
80+
ry.preserve_quotes = True
81+
return ry
82+
83+
84+
class _YAMLStorage(threading.local):
85+
"""Thread-local storage for ruamel.yaml instances."""
86+
87+
def __init__(self) -> None:
88+
self.yaml = _build_yaml()
89+
90+
91+
_STORAGE = _YAMLStorage()
7892

7993

8094
def make_yaml() -> YAML:
@@ -87,11 +101,11 @@ def make_yaml() -> YAML:
87101
Thread-local storage is used because ruamel.yaml instances are not
88102
thread-safe.
89103
"""
90-
if not hasattr(_THREAD_LOCAL, "yaml"):
91-
ry = YAML(typ="rt")
92-
ry.preserve_quotes = True
93-
_THREAD_LOCAL.yaml = ry
94-
return _THREAD_LOCAL.yaml
104+
try:
105+
return _STORAGE.yaml
106+
except AttributeError:
107+
_STORAGE.yaml = _build_yaml()
108+
return _STORAGE.yaml
95109

96110

97111
def yaml_dumps(ry: YAML, data: Any) -> str:

tests/src/e2e/haos_only/test_manage_addon_modes.py

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -46,22 +46,25 @@
4646
from __future__ import annotations
4747

4848
import asyncio
49+
import logging
4950
import time
5051
from typing import Any
5152

5253
import pytest
5354

5455
from ..utilities.assertions import parse_mcp_result, safe_call_tool
56+
from ..utilities.wait_helpers import _POLLING_TRANSIENT_ERRORS
57+
58+
logger = logging.getLogger(__name__)
5559

5660
pytestmark = [pytest.mark.haos_only]
5761

58-
# Node-RED's container takes 20–60s after install to leave "startup" and
59-
# enter "started". Any test whose contract requires the addon to actually
60-
# answer HTTP (i.e. asserts on ``status_code`` rather than tolerating a
61-
# structured error) must wait for it; otherwise CI flakes whenever the
62-
# runner is slow enough that the addon isn't ready by test-call time.
63-
# The bake installs Node-RED with ``start=True`` (build_image.py ADDONS),
64-
# so we only need to wait — never to start it ourselves.
62+
# Tests that assert strictly on ``status_code`` (with no fall-back error
63+
# branch) need the addon's container to have reached Supervisor's
64+
# ``started`` state — the bake installs every addon with ``start=True``,
65+
# but the container can take tens of seconds to leave its transient boot
66+
# phase, which is enough to flake the strict assertion. Timeout sized
67+
# for cache-cold runners; 2s poll matches sibling lifecycle helpers.
6568
_ADDON_RUNNING_TIMEOUT_S = 120.0
6669
_ADDON_RUNNING_POLL_S = 2.0
6770

@@ -109,31 +112,34 @@ async def _wait_addon_running(
109112
110113
Use this before any test that asserts on the HTTP/WS contract of an
111114
addon (rather than tolerating an addon-not-running structured
112-
error). ``ha_manage_addon`` short-circuits with
113-
``{"success": False, "error": {...}, "state": "startup"}`` when
114-
Supervisor reports the addon as anything other than ``started``;
115-
the bake installs addons with ``start=True`` but their containers
116-
can take 20-60s to leave the ``startup`` phase, which is enough to
117-
flake any strict-shape assertion. Mirrors the
118-
``wait_for_entity_registered`` discipline already mandated by
119-
AGENTS.md for tests that act on freshly created entities.
120-
121-
Transient ``ToolError`` from ``ha_get_addon`` (e.g. a momentary
122-
Supervisor 5xx during boot) is treated as "not ready yet" and the
123-
poll continues until the outer timeout. The deadline still fires;
124-
transient errors can't mask a wedged addon forever.
115+
error). When Supervisor reports the addon as anything other than
116+
``started``, ``ha_manage_addon`` raises ``ToolError`` from its
117+
running-state guard (``tools_addons.py`` "Verify add-on is running");
118+
the JSON-encoded error payload carries the observed transient state.
119+
The bake installs addons with ``start=True``, but their containers
120+
can take tens of seconds to reach ``started`` — long enough to
121+
flake any strict-shape assertion on the proxy path. Mirrors
122+
``_wait_for_state`` in ``test_addon_lifecycle.py`` (same private-
123+
sibling convention as ``_resolve_slug``).
124+
125+
Transient errors from ``ha_get_addon`` are caught via the project's
126+
canonical ``_POLLING_TRANSIENT_ERRORS`` tuple (see
127+
``tests/src/e2e/utilities/wait_helpers.py``) — the same discipline
128+
every other polling helper in the suite uses. Bugs (``TypeError`` /
129+
``AttributeError`` / ``KeyError`` / ``AssertionError``) propagate.
130+
The deadline still fires; transient errors can't mask a wedged
131+
addon forever.
125132
"""
126-
from fastmcp.exceptions import ToolError
127-
128133
deadline = time.monotonic() + timeout
129134
last_state: str | None = None
130135
while True:
131136
try:
132137
detail_raw = await mcp_client.call_tool("ha_get_addon", {"slug": slug})
133138
detail = parse_mcp_result(detail_raw).get("addon") or {}
134139
last_state = detail.get("state")
135-
except ToolError as e:
136-
last_state = f"<ToolError: {e!s}[:60]>"
140+
except _POLLING_TRANSIENT_ERRORS as e:
141+
logger.debug(f"⚠️ Transient error polling addon {slug!r}: {e}")
142+
last_state = f"<transient: {str(e)[:60]}>"
137143
if last_state == "started":
138144
return
139145
if time.monotonic() >= deadline:

tests/src/unit/test_yaml_rt_singleton.py

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44

55
import sys
66
import threading
7-
from unittest.mock import MagicMock, patch
7+
from io import StringIO
8+
from unittest.mock import MagicMock
89

910
import pytest
10-
from ruamel.yaml import YAML
1111

1212
# Mock Home Assistant imports so the package __init__ can be loaded.
1313
sys.modules["voluptuous"] = MagicMock()
@@ -22,31 +22,31 @@
2222
)
2323

2424
from custom_components.ha_mcp_tools.yaml_rt import ( # noqa: E402
25-
_THREAD_LOCAL,
25+
_STORAGE,
2626
make_yaml,
2727
)
2828

2929

3030
@pytest.fixture(autouse=True)
3131
def clear_thread_local():
3232
"""Ensure the thread-local storage is clean before each test."""
33-
if hasattr(_THREAD_LOCAL, "yaml"):
34-
del _THREAD_LOCAL.yaml
33+
if hasattr(_STORAGE, "yaml"):
34+
del _STORAGE.yaml
3535
yield
36-
if hasattr(_THREAD_LOCAL, "yaml"):
37-
del _THREAD_LOCAL.yaml
36+
if hasattr(_STORAGE, "yaml"):
37+
del _STORAGE.yaml
3838

3939

4040
def test_make_yaml_singleton_in_same_thread():
4141
"""Verify that make_yaml returns the same instance when called multiple times in one thread."""
42-
# We patch YAML constructor to count instantiations.
43-
# Since YAML is a class, we patch its __init__.
44-
with patch.object(YAML, "__init__", return_value=None) as mock_init:
45-
y1 = make_yaml()
46-
y2 = make_yaml()
42+
y1 = make_yaml()
43+
y2 = make_yaml()
4744

48-
assert y1 is y2
49-
assert mock_init.call_count == 1
45+
assert y1 is y2
46+
# Round-trip a quoted scalar to confirm preserve_quotes is set
47+
buf = StringIO()
48+
y1.dump({"key": '"quoted_value"'}, buf)
49+
assert '"quoted_value"' in buf.getvalue()
5050

5151

5252
def test_make_yaml_singleton_per_thread():
@@ -56,17 +56,28 @@ def test_make_yaml_singleton_per_thread():
5656
def worker(name):
5757
instances[name] = make_yaml()
5858

59-
with patch.object(YAML, "__init__", return_value=None) as mock_init:
60-
t1 = threading.Thread(target=worker, args=("t1",))
61-
t2 = threading.Thread(target=worker, args=("t2",))
59+
t1 = threading.Thread(target=worker, args=("t1",))
60+
t2 = threading.Thread(target=worker, args=("t2",))
6261

63-
t1.start()
64-
t1.join()
65-
t2.start()
66-
t2.join()
62+
t1.start()
63+
t1.join()
64+
t2.start()
65+
t2.join()
6766

68-
assert "t1" in instances
69-
assert "t2" in instances
70-
assert instances["t1"] is not instances["t2"]
71-
# One instantiation per thread
72-
assert mock_init.call_count == 2
67+
assert "t1" in instances
68+
assert "t2" in instances
69+
assert instances["t1"] is not instances["t2"]
70+
71+
72+
def test_make_yaml_rebuilds_after_storage_cleared():
73+
"""Verify that make_yaml rebuilds the instance after thread-local storage is cleared."""
74+
y1 = make_yaml()
75+
del _STORAGE.yaml
76+
y2 = make_yaml()
77+
78+
assert y2 is not y1
79+
assert y2.preserve_quotes is True
80+
# Round-trip a quoted scalar to confirm preserve_quotes actually took effect
81+
buf = StringIO()
82+
y2.dump({"key": '"quoted_value"'}, buf)
83+
assert '"quoted_value"' in buf.getvalue()

0 commit comments

Comments
 (0)