|
14 | 14 |
|
15 | 15 | import asyncio |
16 | 16 | import logging |
| 17 | +import threading |
17 | 18 | from contextlib import asynccontextmanager |
18 | 19 | from types import SimpleNamespace |
19 | 20 | from typing import Any |
@@ -260,7 +261,95 @@ def _boom() -> None: |
260 | 261 |
|
261 | 262 | assert fake_llm_apis(hass) == {} |
262 | 263 | 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 |
| 265 | + |
| 266 | + |
| 267 | +class TestSchemaConversionCompatibility: |
| 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): |
| 289 | + schema = {"type": "object"} |
| 290 | + probatio = SimpleNamespace(from_openapi=lambda value: {"probatio": value}) |
| 291 | + |
| 292 | + def _import_module(name): |
| 293 | + if name == "voluptuous_openapi": |
| 294 | + raise ModuleNotFoundError( |
| 295 | + "No module named 'voluptuous_openapi'", |
| 296 | + name="voluptuous_openapi", |
| 297 | + ) |
| 298 | + assert name == "probatio" |
| 299 | + return probatio |
| 300 | + |
| 301 | + monkeypatch.setattr(llm_api.importlib, "import_module", _import_module) |
| 302 | + |
| 303 | + assert llm_api.convert_to_voluptuous(schema) == {"probatio": schema} |
| 304 | + |
| 305 | + def test_reraises_nested_module_not_found(self, monkeypatch): |
| 306 | + def _import_module(name): |
| 307 | + assert name == "voluptuous_openapi" |
| 308 | + raise ModuleNotFoundError( |
| 309 | + "No module named 'legacy_dependency'", |
| 310 | + name="legacy_dependency", |
| 311 | + ) |
| 312 | + |
| 313 | + monkeypatch.setattr(llm_api.importlib, "import_module", _import_module) |
| 314 | + |
| 315 | + with pytest.raises(ModuleNotFoundError, match="legacy_dependency"): |
| 316 | + llm_api.convert_to_voluptuous({"type": "object"}) |
| 317 | + |
| 318 | + async def test_converter_import_is_warmed_once_off_event_loop(self, monkeypatch): |
| 319 | + main_thread = threading.get_ident() |
| 320 | + imports: list[tuple[str, int]] = [] |
| 321 | + legacy = SimpleNamespace( |
| 322 | + convert_to_voluptuous=lambda value: {"voluptuous_openapi": value} |
| 323 | + ) |
| 324 | + |
| 325 | + def _import_module(name): |
| 326 | + imports.append((name, threading.get_ident())) |
| 327 | + if name.startswith("mcp."): |
| 328 | + return SimpleNamespace() |
| 329 | + if name == "voluptuous_openapi": |
| 330 | + return legacy |
| 331 | + if name == "probatio": |
| 332 | + raise ModuleNotFoundError("No module named 'probatio'", name="probatio") |
| 333 | + raise AssertionError(name) |
| 334 | + |
| 335 | + async def _executor(func, *args): |
| 336 | + return await asyncio.to_thread(func, *args) |
| 337 | + |
| 338 | + hass = _make_hass() |
| 339 | + hass.async_add_executor_job = AsyncMock(side_effect=_executor) |
| 340 | + monkeypatch.setattr(llm_api.importlib, "import_module", _import_module) |
| 341 | + |
| 342 | + assert await llm_api.async_probe_mcp_sdk(hass) |
| 343 | + schema = {"type": "object"} |
| 344 | + assert llm_api.convert_to_voluptuous(schema) == {"voluptuous_openapi": schema} |
| 345 | + assert llm_api.convert_to_voluptuous(schema) == {"voluptuous_openapi": schema} |
| 346 | + |
| 347 | + assert [name for name, _ in imports] == [ |
| 348 | + "mcp.client.session", |
| 349 | + "mcp.client.streamable_http", |
| 350 | + "voluptuous_openapi", |
| 351 | + ] |
| 352 | + assert all(thread_id != main_thread for _, thread_id in imports) |
264 | 353 |
|
265 | 354 |
|
266 | 355 | class TestFullModeInstance: |
|
0 commit comments