Skip to content

Commit f2e045e

Browse files
committed
fix: isolate best-effort locale failures
1 parent f291906 commit f2e045e

5 files changed

Lines changed: 245 additions & 47 deletions

File tree

custom_components/ha_mcp_tools/llm_api.py

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,10 @@
4949
import asyncio
5050
import importlib
5151
import logging
52-
from collections.abc import AsyncIterator, Iterable
52+
from collections.abc import AsyncIterator, Callable, Iterable
5353
from contextlib import asynccontextmanager
5454
from dataclasses import dataclass
55+
from functools import cache
5556
from typing import TYPE_CHECKING, Any, cast
5657

5758
import voluptuous as vol
@@ -128,18 +129,22 @@
128129
_SEARCH_RESULT_LIMIT = 8
129130

130131

131-
def convert_to_voluptuous(schema: Any) -> vol.Schema:
132-
"""Convert an OpenAPI schema on stable and Probatio-based HA Core."""
132+
@cache
133+
def _schema_converter() -> Callable[[Any], Any]:
134+
"""Resolve the Core-provided schema converter once, off the event loop."""
133135
try:
134-
probatio = importlib.import_module("probatio")
136+
legacy = importlib.import_module("voluptuous_openapi")
135137
except ModuleNotFoundError as err:
136-
if err.name != "probatio":
138+
if err.name != "voluptuous_openapi":
137139
raise
138-
legacy = importlib.import_module("voluptuous_openapi")
139-
converter = legacy.convert_to_voluptuous
140-
else:
141-
converter = probatio.from_openapi
142-
return cast(vol.Schema, converter(schema))
140+
probatio = importlib.import_module("probatio")
141+
return probatio.from_openapi
142+
return legacy.convert_to_voluptuous
143+
144+
145+
def convert_to_voluptuous(schema: Any) -> vol.Schema:
146+
"""Convert an OpenAPI schema on stable and Probatio-based HA Core."""
147+
return cast(vol.Schema, _schema_converter()(schema))
143148

144149

145150
# Used when the server's initialize result carries no instructions (it always
@@ -213,14 +218,15 @@ def _is_transport_failure(err: BaseException) -> bool:
213218

214219

215220
def _import_mcp_sdk() -> None:
216-
"""Import the mcp client SDK modules (blocking; run on the executor).
221+
"""Import lazy LLM dependencies (blocking; run on the executor).
217222
218-
Raises ImportError when the SDK is not importable — the caller decides
219-
whether that skips registration (SDK missing entirely) or surfaces as a
220-
conversation error.
223+
Raises ImportError when the MCP SDK or Core's schema converter is not
224+
importable — the caller decides whether that skips registration or
225+
surfaces as a conversation error.
221226
"""
222227
importlib.import_module("mcp.client.session")
223228
importlib.import_module("mcp.client.streamable_http")
229+
_schema_converter()
224230

225231

226232
async def async_probe_mcp_sdk(hass: HomeAssistant) -> bool:
@@ -229,8 +235,8 @@ async def async_probe_mcp_sdk(hass: HomeAssistant) -> bool:
229235
await hass.async_add_executor_job(_import_mcp_sdk)
230236
except ImportError as err:
231237
_LOGGER.warning(
232-
"The installed server package provides no importable 'mcp' client "
233-
"SDK (%s); the conversation-agent LLM API will not be available",
238+
"A required LLM dependency is not importable (%s); the "
239+
"conversation-agent LLM API will not be available",
234240
err,
235241
)
236242
return False

src/ha_mcp/settings_ui/_i18n.py

Lines changed: 91 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,89 @@ def _warn_best_effort_catalog(locale: str, path: Path, exc: Exception) -> None:
134134
_LOGGER.warning("Skipping best-effort locale %s from %s: %s", locale, path, exc)
135135

136136

137+
def _warn_best_effort_entry(locale: str, entry: str, exc: Exception) -> None:
138+
_LOGGER.warning(
139+
"Ignoring invalid best-effort locale %s %s; using English fallback: %s",
140+
locale,
141+
entry,
142+
exc,
143+
)
144+
145+
146+
def _catalog_fragment(
147+
catalog: dict[str, Any],
148+
*,
149+
messages: dict[str, str] | None = None,
150+
tools: dict[str, dict[str, str]] | None = None,
151+
) -> dict[str, Any]:
152+
"""Return a minimal catalog used to validate one translated entry."""
153+
return {
154+
"meta": catalog["meta"],
155+
"messages": messages or {},
156+
"tool_groups": {},
157+
"tools": tools or {},
158+
}
159+
160+
161+
def _sanitize_best_effort_catalog(
162+
locale: str,
163+
catalog: dict[str, Any],
164+
english: dict[str, Any],
165+
settings_html: Path,
166+
) -> dict[str, Any]:
167+
"""Drop only invalid translated entries so the rest of a locale survives."""
168+
sanitized = {
169+
"meta": dict(catalog["meta"]),
170+
"messages": dict(catalog["messages"]),
171+
"tool_groups": dict(catalog["tool_groups"]),
172+
"tools": {
173+
tool_name: dict(fields) for tool_name, fields in catalog["tools"].items()
174+
},
175+
}
176+
known_panels = _known_panels(settings_html)
177+
178+
for key, translated in tuple(sanitized["messages"].items()):
179+
english_messages = (
180+
{key: english["messages"][key]} if key in english["messages"] else {}
181+
)
182+
candidate = {
183+
DEFAULT_LOCALE: _catalog_fragment(english, messages=english_messages),
184+
locale: _catalog_fragment(sanitized, messages={key: translated}),
185+
}
186+
try:
187+
_validate_placeholder_parity(candidate)
188+
_validate_inline_markup(candidate)
189+
_validate_panel_links(candidate, settings_html, known_panels=known_panels)
190+
except ValueError as exc:
191+
del sanitized["messages"][key]
192+
_warn_best_effort_entry(locale, f"message {key!r}", exc)
193+
194+
for tool_name, translated_tool in tuple(sanitized["tools"].items()):
195+
for field, translated in tuple(translated_tool.items()):
196+
english_field = english["tools"].get(tool_name, {}).get(field)
197+
english_tools = (
198+
{tool_name: {field: english_field}} if english_field is not None else {}
199+
)
200+
candidate = {
201+
DEFAULT_LOCALE: _catalog_fragment(english, tools=english_tools),
202+
locale: _catalog_fragment(
203+
sanitized,
204+
tools={tool_name: {field: translated}},
205+
),
206+
}
207+
try:
208+
_validate_placeholder_parity(candidate)
209+
except ValueError as exc:
210+
del translated_tool[field]
211+
_warn_best_effort_entry(
212+
locale, f"tool {tool_name!r} field {field!r}", exc
213+
)
214+
if not translated_tool:
215+
del sanitized["tools"][tool_name]
216+
217+
return sanitized
218+
219+
137220
def load_catalogs(
138221
directory: Path = LOCALES_DIR, settings_html: Path = _SETTINGS_HTML
139222
) -> dict[str, dict[str, Any]]:
@@ -174,17 +257,9 @@ def load_catalogs(
174257
_validate_panel_links(strict_catalogs, settings_html)
175258

176259
for locale in sorted(set(catalogs) - set(strict_catalogs)):
177-
candidate = {
178-
DEFAULT_LOCALE: catalogs[DEFAULT_LOCALE],
179-
locale: catalogs[locale],
180-
}
181-
try:
182-
_validate_placeholder_parity(candidate)
183-
_validate_inline_markup(candidate)
184-
_validate_panel_links(candidate, settings_html)
185-
except ValueError as exc:
186-
_warn_best_effort_catalog(locale, directory / f"{locale}.json", exc)
187-
del catalogs[locale]
260+
catalogs[locale] = _sanitize_best_effort_catalog(
261+
locale, catalogs[locale], catalogs[DEFAULT_LOCALE], settings_html
262+
)
188263
return catalogs
189264

190265

@@ -272,7 +347,10 @@ def _known_panels(settings_html: Path = _SETTINGS_HTML) -> set[str]:
272347

273348

274349
def _validate_panel_links(
275-
catalogs: dict[str, dict[str, Any]], settings_html: Path = _SETTINGS_HTML
350+
catalogs: dict[str, dict[str, Any]],
351+
settings_html: Path = _SETTINGS_HTML,
352+
*,
353+
known_panels: set[str] | None = None,
276354
) -> None:
277355
"""Reject cross-panel links that point at a tab which does not exist.
278356
@@ -285,7 +363,7 @@ def _validate_panel_links(
285363
and group labels go through ``escapeHtml``, so a link written there shows
286364
as visible garbled markup rather than a dead link — wrong, but not silent.
287365
"""
288-
panels = _known_panels(settings_html)
366+
panels = known_panels if known_panels is not None else _known_panels(settings_html)
289367
english_messages = catalogs[DEFAULT_LOCALE]["messages"]
290368
for locale, catalog in catalogs.items():
291369
for key, value in catalog["messages"].items():

tests/src/unit/test_llm_api.py

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import asyncio
1616
import logging
17+
import threading
1718
from contextlib import asynccontextmanager
1819
from types import SimpleNamespace
1920
from typing import Any
@@ -260,38 +261,83 @@ def _boom() -> None:
260261

261262
assert fake_llm_apis(hass) == {}
262263
assert DATA_LLM_API_UNSUB not in hass.data.get(DOMAIN, {})
263-
assert "no importable 'mcp' client SDK" in caplog.text
264+
assert "required LLM dependency is not importable" in caplog.text
264265

265266

266267
class TestSchemaConversionCompatibility:
267-
def test_prefers_probatio_when_home_assistant_provides_it(self, monkeypatch):
268+
@pytest.fixture(autouse=True)
269+
def _clear_schema_converter_cache(self):
270+
llm_api._schema_converter.cache_clear()
271+
yield
272+
llm_api._schema_converter.cache_clear()
273+
274+
def test_prefers_stable_core_converter_when_available(self, monkeypatch):
275+
schema = {"type": "object"}
276+
legacy = SimpleNamespace(
277+
convert_to_voluptuous=lambda value: {"voluptuous_openapi": value}
278+
)
279+
280+
def _import_module(name):
281+
assert name == "voluptuous_openapi"
282+
return legacy
283+
284+
monkeypatch.setattr(llm_api.importlib, "import_module", _import_module)
285+
286+
assert llm_api.convert_to_voluptuous(schema) == {"voluptuous_openapi": schema}
287+
288+
def test_falls_back_to_probatio_on_newer_core(self, monkeypatch):
268289
schema = {"type": "object"}
269290
probatio = SimpleNamespace(from_openapi=lambda value: {"probatio": value})
270291

271292
def _import_module(name):
293+
if name == "voluptuous_openapi":
294+
raise ModuleNotFoundError(
295+
"No module named 'voluptuous_openapi'",
296+
name="voluptuous_openapi",
297+
)
272298
assert name == "probatio"
273299
return probatio
274300

275301
monkeypatch.setattr(llm_api.importlib, "import_module", _import_module)
276302

277303
assert llm_api.convert_to_voluptuous(schema) == {"probatio": schema}
278304

279-
def test_falls_back_to_voluptuous_openapi_on_stable_core(self, monkeypatch):
280-
schema = {"type": "object"}
305+
async def test_converter_import_is_warmed_once_off_event_loop(self, monkeypatch):
306+
main_thread = threading.get_ident()
307+
imports: list[tuple[str, int]] = []
281308
legacy = SimpleNamespace(
282309
convert_to_voluptuous=lambda value: {"voluptuous_openapi": value}
283310
)
284311

285312
def _import_module(name):
313+
imports.append((name, threading.get_ident()))
314+
if name.startswith("mcp."):
315+
return SimpleNamespace()
316+
if name == "voluptuous_openapi":
317+
return legacy
286318
if name == "probatio":
287319
raise ModuleNotFoundError("No module named 'probatio'", name="probatio")
288-
assert name == "voluptuous_openapi"
289-
return legacy
320+
raise AssertionError(name)
321+
322+
async def _executor(func, *args):
323+
return await asyncio.to_thread(func, *args)
290324

325+
hass = _make_hass()
326+
hass.async_add_executor_job = AsyncMock(side_effect=_executor)
291327
monkeypatch.setattr(llm_api.importlib, "import_module", _import_module)
292328

329+
assert await llm_api.async_probe_mcp_sdk(hass)
330+
schema = {"type": "object"}
331+
assert llm_api.convert_to_voluptuous(schema) == {"voluptuous_openapi": schema}
293332
assert llm_api.convert_to_voluptuous(schema) == {"voluptuous_openapi": schema}
294333

334+
assert [name for name, _ in imports] == [
335+
"mcp.client.session",
336+
"mcp.client.streamable_http",
337+
"voluptuous_openapi",
338+
]
339+
assert all(thread_id != main_thread for _, thread_id in imports)
340+
295341

296342
class TestFullModeInstance:
297343
async def test_lists_exposed_tools_with_converted_schemas_and_prompt(

tests/src/unit/test_locale_parity.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import subprocess
4242
import sys
4343
import tempfile
44+
import warnings
4445
from collections import Counter
4546
from functools import cache
4647
from pathlib import Path, PurePosixPath
@@ -394,6 +395,7 @@ def test_every_locale_ships_on_every_surface() -> None:
394395
)
395396

396397

398+
@pytest.mark.filterwarnings("always:best-effort locale")
397399
def test_best_effort_locale_surface_problems_are_warning_only() -> None:
398400
issues: list[str] = []
399401
locale_lines = [
@@ -431,9 +433,10 @@ def test_best_effort_locale_surface_problems_are_warning_only() -> None:
431433
issues.append(f"invalid {path.relative_to(_REPO_ROOT)}: {exc}")
432434

433435
if issues:
434-
print(
435-
"::warning::best-effort locale audit: " + "; ".join(issues),
436-
file=sys.stderr,
436+
warnings.warn(
437+
"best-effort locale audit: " + "; ".join(issues),
438+
pytest.PytestWarning,
439+
stacklevel=1,
437440
)
438441

439442

@@ -857,11 +860,6 @@ def _non_english_settings_locales() -> list[str]:
857860
)
858861

859862

860-
def test_best_effort_locales_are_outside_hard_content_gates() -> None:
861-
assert BEST_EFFORT_LOCALES.isdisjoint(_translated_component_locales())
862-
assert BEST_EFFORT_LOCALES.isdisjoint(_non_english_settings_locales())
863-
864-
865863
@cache
866864
def _renderable_groups_and_tools() -> tuple[frozenset[str], frozenset[str]]:
867865
"""The group headings and tool names the settings UI can actually show.

0 commit comments

Comments
 (0)