Skip to content

Commit 90aef6b

Browse files
committed
fix(component): respect Home Assistant skip_pip
1 parent 0b7fe0c commit 90aef6b

6 files changed

Lines changed: 230 additions & 4 deletions

File tree

custom_components/ha_mcp_tools/embedded_server.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,14 @@ async def _async_wait_for_pending_install(self) -> None:
675675

676676
async def _async_ensure_package(
677677
self, *, defer_mutations: bool = False
678+
) -> str | None:
679+
"""Use the externally managed package or ensure a managed install."""
680+
if self._hass.config.skip_pip:
681+
return await self._async_externally_managed_package_version()
682+
return await self._async_ensure_managed_package(defer_mutations=defer_mutations)
683+
684+
async def _async_ensure_managed_package(
685+
self, *, defer_mutations: bool = False
678686
) -> str | None:
679687
"""Ensure ``ha-mcp`` is importable, installing the pip spec if needed.
680688
@@ -845,6 +853,62 @@ async def _async_ensure_package(
845853
self._store_installed_spec()
846854
return version
847855

856+
async def _async_externally_managed_package_version(self) -> str:
857+
"""Validate and return the package supplied outside Home Assistant.
858+
859+
skip_pip means the surrounding system owns this interpreter. This
860+
path therefore performs metadata/importability reads only: it never
861+
calls Home Assistant's requirements manager, UV, or the config-entry
862+
marker writers used by manual and automatic installs.
863+
"""
864+
installed_version: str | None = await self._hass.async_add_executor_job(
865+
_installed_ha_mcp_version
866+
)
867+
stable_version: str | None = await self._hass.async_add_executor_job(
868+
_installed_dist_version, DIST_NAME_STABLE
869+
)
870+
dev_version: str | None = await self._hass.async_add_executor_job(
871+
_installed_dist_version, DIST_NAME_DEV
872+
)
873+
874+
if installed_version is None:
875+
raise EmbeddedServerError(
876+
"Home Assistant was started with skip_pip enabled, so HA-MCP "
877+
"will not install the externally managed server package. Use "
878+
"the system package manager to install ha-mcp "
879+
f"{MIN_EMBEDDED_SERVER_VERSION} or newer, then reload this "
880+
"integration.",
881+
kind="package",
882+
)
883+
884+
if stable_version is not None and dev_version is not None:
885+
raise EmbeddedServerError(
886+
f"Both {DIST_NAME_STABLE} {stable_version} and "
887+
f"{DIST_NAME_DEV} {dev_version} are installed while skip_pip "
888+
"is enabled. They share the ha_mcp import package, so HA-MCP "
889+
"cannot safely select one without modifying the environment. "
890+
"Use the system package manager to leave exactly one installed, "
891+
"then reload this integration.",
892+
kind="package",
893+
)
894+
895+
if not _is_compatible_embedded_version(installed_version):
896+
raise EmbeddedServerError(
897+
f"The externally managed ha-mcp {installed_version} is "
898+
"incompatible while skip_pip is enabled; this in-process "
899+
f"component requires {MIN_EMBEDDED_SERVER_VERSION} or newer. "
900+
"Upgrade it with the system package manager, then reload this "
901+
"integration.",
902+
kind="package",
903+
)
904+
905+
_LOGGER.info(
906+
"HA-MCP externally managed server package ready (version %s; "
907+
"skip_pip enabled)",
908+
installed_version,
909+
)
910+
return installed_version
911+
848912
async def _async_remove_legacy_target(
849913
self, target_dist: str, installed_version: str | None
850914
) -> None:

custom_components/ha_mcp_tools/embedded_setup.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,10 @@ async def async_maybe_auto_update(
608608
logged at debug and skipped; the next refresh retries. Genuine bugs
609609
propagate per the repo's no-silent-failure convention.
610610
"""
611+
if hass.config.skip_pip:
612+
# The system package manager owns ha-mcp; never reload to mutate it.
613+
return
614+
611615
if not bool(entry.options.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE)):
612616
# Auto-update turned off: stay on the currently-installed version.
613617
return

custom_components/ha_mcp_tools/update.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,10 @@ def latest_version(self) -> str | None:
109109

110110
@property
111111
def auto_update(self) -> bool:
112-
"""Reflect the entry's automatic-update option."""
113-
return bool(self._entry.options.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE))
112+
"""Reflect the effective automatic-update policy."""
113+
return not self.coordinator.hass.config.skip_pip and bool(
114+
self._entry.options.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE)
115+
)
114116

115117
@property
116118
def release_url(self) -> str | None:
@@ -126,8 +128,10 @@ def release_url(self) -> str | None:
126128

127129
@property
128130
def supported_features(self) -> UpdateEntityFeature:
129-
"""RELEASE_NOTES only on the stable channel — dev builds have no tags."""
130-
features = UpdateEntityFeature.INSTALL
131+
"""Expose install only when Home Assistant may manage the package."""
132+
features = UpdateEntityFeature(0)
133+
if not self.coordinator.hass.config.skip_pip:
134+
features |= UpdateEntityFeature.INSTALL
131135
data = self.coordinator.data
132136
if data is not None and data.dist != DIST_NAME_DEV:
133137
features |= UpdateEntityFeature.RELEASE_NOTES
@@ -257,6 +261,14 @@ async def async_install(
257261
that can still fail (review finding).
258262
"""
259263
data = self.coordinator.data
264+
if self.coordinator.hass.config.skip_pip:
265+
raise HomeAssistantError(
266+
"The HA-MCP server package is externally managed by the system "
267+
"package manager because Home Assistant was started with "
268+
"skip_pip. Install the update there, then reload this "
269+
"integration."
270+
)
271+
260272
target = version or self.latest_version
261273
if target is None:
262274
raise HomeAssistantError("No target version available to install.")

tests/src/unit/test_embedded_server.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565

6666
def _make_hass(tmp_path) -> MagicMock:
6767
hass = MagicMock(name="hass")
68+
hass.config.skip_pip = False
6869
hass.config.path = lambda sub: str(tmp_path / sub)
6970

7071
async def _executor(func, *args):
@@ -413,6 +414,95 @@ def test_explicit_override_wins_over_auto_update_off(self, tmp_path, monkeypatch
413414

414415

415416
class TestEnsurePackage:
417+
async def test_skip_pip_uses_compatible_externally_managed_package(
418+
self, tmp_path, monkeypatch
419+
):
420+
"""skip_pip must bypass every package mutation and preserve markers."""
421+
data = {
422+
DATA_SECRET_PATH: "/p",
423+
DATA_LAST_PIP_SPEC: "ha-mcp==7.11.0",
424+
DATA_PENDING_INSTALL_VERSION: "7.12.0",
425+
}
426+
mgr, hass, entry = _manager(
427+
tmp_path,
428+
options={OPT_PIP_SPEC: "ha-mcp==99.0.0"},
429+
data=data,
430+
)
431+
hass.config.skip_pip = True
432+
process = AsyncMock(side_effect=AssertionError("requirements mutation"))
433+
force_install = MagicMock(side_effect=AssertionError("package install"))
434+
uninstall = MagicMock(side_effect=AssertionError("package uninstall"))
435+
monkeypatch.setattr(es, "async_process_requirements", process)
436+
monkeypatch.setattr(es, "_force_install_package", force_install)
437+
monkeypatch.setattr(es, "_uninstall_distribution", uninstall)
438+
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.12.1")
439+
monkeypatch.setattr(
440+
es,
441+
"_installed_dist_version",
442+
lambda dist: "7.12.1" if dist == DIST_NAME_STABLE else None,
443+
)
444+
445+
version = await mgr._async_ensure_package()
446+
447+
assert version == "7.12.1"
448+
assert entry.data == data
449+
450+
async def test_skip_pip_reports_missing_externally_managed_package(
451+
self, tmp_path, monkeypatch
452+
):
453+
mgr, hass, _entry = _manager(tmp_path)
454+
hass.config.skip_pip = True
455+
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: None)
456+
monkeypatch.setattr(es, "_installed_dist_version", lambda _dist: None)
457+
458+
with pytest.raises(
459+
es.EmbeddedServerError,
460+
match=r"skip_pip.*system package manager.*7\.10\.0",
461+
) as exc_info:
462+
await mgr._async_ensure_package()
463+
464+
assert exc_info.value.kind == "package"
465+
466+
async def test_skip_pip_reports_incompatible_externally_managed_package(
467+
self, tmp_path, monkeypatch
468+
):
469+
mgr, hass, _entry = _manager(tmp_path)
470+
hass.config.skip_pip = True
471+
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.9.0")
472+
monkeypatch.setattr(
473+
es,
474+
"_installed_dist_version",
475+
lambda dist: "7.9.0" if dist == DIST_NAME_STABLE else None,
476+
)
477+
478+
with pytest.raises(
479+
es.EmbeddedServerError,
480+
match=r"externally managed ha-mcp 7\.9\.0.*7\.10\.0 or newer",
481+
) as exc_info:
482+
await mgr._async_ensure_package()
483+
484+
assert exc_info.value.kind == "package"
485+
486+
async def test_skip_pip_reports_ambiguous_externally_managed_packages(
487+
self, tmp_path, monkeypatch
488+
):
489+
mgr, hass, _entry = _manager(tmp_path)
490+
hass.config.skip_pip = True
491+
versions = {
492+
DIST_NAME_STABLE: "7.12.1",
493+
DIST_NAME_DEV: "7.13.0.dev1",
494+
}
495+
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.12.1")
496+
monkeypatch.setattr(es, "_installed_dist_version", versions.get)
497+
498+
with pytest.raises(
499+
es.EmbeddedServerError,
500+
match=r"Both ha-mcp 7\.12\.1 and ha-mcp-dev 7\.13\.0\.dev1",
501+
) as exc_info:
502+
await mgr._async_ensure_package()
503+
504+
assert exc_info.value.kind == "package"
505+
416506
async def test_fast_path_only_for_unchanged_override(self, tmp_path, monkeypatch):
417507
# The fast path is reserved for an explicit pip-spec override: an
418508
# unchanged, already-installed pin delegates the "already satisfied?"

tests/src/unit/test_embedded_setup.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
def _make_hass() -> MagicMock:
5959
hass = MagicMock(name="hass")
6060
hass.data = {}
61+
hass.config.skip_pip = False
6162

6263
def _update_entry(entry, *, data=None, **_kw):
6364
if data is not None:
@@ -1075,6 +1076,16 @@ async def test_auto_update_off_does_not_reload(self, monkeypatch):
10751076

10761077
hass.config_entries.async_reload.assert_not_awaited()
10771078

1079+
async def test_skip_pip_does_not_reload_or_set_marker(self, monkeypatch):
1080+
hass = _make_async_hass()
1081+
hass.config.skip_pip = True
1082+
entry = _make_entry(options={OPT_AUTO_UPDATE: True})
1083+
1084+
await esetup.async_maybe_auto_update(hass, entry, self._NEWER)
1085+
1086+
hass.config_entries.async_reload.assert_not_awaited()
1087+
assert DATA_PENDING_UPDATE_NOTIFY not in hass.data.get(DOMAIN, {})
1088+
10781089
async def test_override_does_not_reload(self, monkeypatch):
10791090
hass = _make_async_hass()
10801091
entry = _make_entry(options={OPT_PIP_SPEC: "ha-mcp==7.8.0"})

tests/src/unit/test_update.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def _make_coordinator(data=None) -> MagicMock:
3535
coordinator = MagicMock(name="coordinator")
3636
coordinator.data = data
3737
coordinator.hass = MagicMock(name="hass")
38+
coordinator.hass.config.skip_pip = False
3839
return coordinator
3940

4041

@@ -56,6 +57,7 @@ def _make_install_hass(*, bringup=None, installed_version=None) -> MagicMock:
5657
"""A hass wired for async_install's post-reload flow: awaits the bring-up
5758
task (if any) then reads the installed version via the executor."""
5859
hass = MagicMock(name="hass")
60+
hass.config.skip_pip = False
5961
hass.config_entries.async_reload = AsyncMock()
6062
hass.data = {DOMAIN: {DATA_BRINGUP_TASK: bringup}}
6163

@@ -110,6 +112,16 @@ def test_auto_update_reflects_entry_option(
110112
entity = _make_entity(entry=_make_entry(options=options))
111113
assert entity.auto_update is expected
112114

115+
def test_skip_pip_disables_auto_update(self):
116+
coordinator = _make_coordinator(_info())
117+
coordinator.hass.config.skip_pip = True
118+
entity = _make_entity(
119+
coordinator=coordinator,
120+
entry=_make_entry(options={OPT_AUTO_UPDATE: True}),
121+
)
122+
123+
assert entity.auto_update is False
124+
113125
def test_release_url_stable_channel(self):
114126
entity = _make_entity(
115127
coordinator=_make_coordinator(_info(latest="1.2.0", dist=DIST_NAME_STABLE))
@@ -152,6 +164,20 @@ def test_supported_features_install_only_when_coordinator_data_absent(self):
152164
entity = _make_entity(coordinator=_make_coordinator(None))
153165
assert entity.supported_features == upd.UpdateEntityFeature.INSTALL
154166

167+
@pytest.mark.parametrize(
168+
("dist", "expected"),
169+
[
170+
(DIST_NAME_STABLE, upd.UpdateEntityFeature.RELEASE_NOTES),
171+
(DIST_NAME_DEV, upd.UpdateEntityFeature(0)),
172+
],
173+
)
174+
def test_skip_pip_removes_install_feature(self, dist, expected):
175+
coordinator = _make_coordinator(_info(dist=dist))
176+
coordinator.hass.config.skip_pip = True
177+
entity = _make_entity(coordinator=coordinator)
178+
179+
assert entity.supported_features == expected
180+
155181

156182
class _FakeResp:
157183
def __init__(self, payload, *, raise_exc=None):
@@ -356,6 +382,25 @@ async def test_both_probes_failing_degrades_to_none(self, monkeypatch):
356382

357383

358384
class TestAsyncInstall:
385+
async def test_skip_pip_rejects_install_without_writing_or_reloading(self):
386+
entry = _make_entry(data={"existing": "kept"})
387+
hass = _make_install_hass(installed_version="1.2.0")
388+
hass.config.skip_pip = True
389+
coordinator = _make_coordinator(_info(latest="1.2.0"))
390+
entity = _make_entity(coordinator=coordinator, entry=entry)
391+
entity.hass = hass
392+
coordinator.hass.config.skip_pip = True
393+
394+
with pytest.raises(
395+
upd.HomeAssistantError,
396+
match="externally managed by the system package manager",
397+
):
398+
await entity.async_install("1.2.0", backup=False)
399+
400+
assert entry.data == {"existing": "kept"}
401+
hass.config_entries.async_update_entry.assert_not_called()
402+
hass.config_entries.async_reload.assert_not_awaited()
403+
359404
async def test_writes_pending_marker_and_reloads(self):
360405
entry = _make_entry(data={"existing": "kept"})
361406
hass = _make_install_hass(installed_version="1.2.0")

0 commit comments

Comments
 (0)