Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions custom_components/ha_mcp_tools/embedded_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,14 @@ async def _async_wait_for_pending_install(self) -> None:

async def _async_ensure_package(
self, *, defer_mutations: bool = False
) -> str | None:
"""Use the externally managed package or ensure a managed install."""
if self._hass.config.skip_pip:
return await self._async_externally_managed_package_version()
return await self._async_ensure_managed_package(defer_mutations=defer_mutations)

async def _async_ensure_managed_package(
self, *, defer_mutations: bool = False
) -> str | None:
"""Ensure ``ha-mcp`` is importable, installing the pip spec if needed.

Expand Down Expand Up @@ -845,6 +853,62 @@ async def _async_ensure_package(
self._store_installed_spec()
return version

async def _async_externally_managed_package_version(self) -> str:
"""Validate and return the package supplied outside Home Assistant.

skip_pip means the surrounding system owns this interpreter. This
path therefore performs metadata/importability reads only: it never
calls Home Assistant's requirements manager, UV, or the config-entry
marker writers used by manual and automatic installs.
"""
installed_version: str | None = await self._hass.async_add_executor_job(
_installed_ha_mcp_version
)
stable_version: str | None = await self._hass.async_add_executor_job(
_installed_dist_version, DIST_NAME_STABLE
)
dev_version: str | None = await self._hass.async_add_executor_job(
_installed_dist_version, DIST_NAME_DEV
)

if installed_version is None:
raise EmbeddedServerError(
"Home Assistant was started with skip_pip enabled, so HA-MCP "
"will not install the externally managed server package. Use "
"the system package manager to install ha-mcp "
f"{MIN_EMBEDDED_SERVER_VERSION} or newer, then reload this "
"integration.",
kind="package",
)

if stable_version is not None and dev_version is not None:
Comment thread
kingpanther13 marked this conversation as resolved.
raise EmbeddedServerError(
f"Both {DIST_NAME_STABLE} {stable_version} and "
f"{DIST_NAME_DEV} {dev_version} are installed while skip_pip "
"is enabled. They share the ha_mcp import package, so HA-MCP "
"cannot safely select one without modifying the environment. "
"Use the system package manager to leave exactly one installed, "
"then reload this integration.",
kind="package",
)

if not _is_compatible_embedded_version(installed_version):
raise EmbeddedServerError(
f"The externally managed ha-mcp {installed_version} is "
"incompatible while skip_pip is enabled; this in-process "
f"component requires {MIN_EMBEDDED_SERVER_VERSION} or newer. "
"Upgrade it with the system package manager, then reload this "
"integration.",
kind="package",
)

_LOGGER.info(
"HA-MCP externally managed server package ready (version %s; "
"skip_pip enabled)",
installed_version,
)
return installed_version

async def _async_remove_legacy_target(
self, target_dist: str, installed_version: str | None
) -> None:
Expand Down
4 changes: 4 additions & 0 deletions custom_components/ha_mcp_tools/embedded_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,10 @@ async def async_maybe_auto_update(
logged at debug and skipped; the next refresh retries. Genuine bugs
propagate per the repo's no-silent-failure convention.
"""
if hass.config.skip_pip:
# The system package manager owns ha-mcp; never reload to mutate it.
return

if not bool(entry.options.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE)):
# Auto-update turned off: stay on the currently-installed version.
return
Expand Down
20 changes: 16 additions & 4 deletions custom_components/ha_mcp_tools/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,10 @@ def latest_version(self) -> str | None:

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

@property
def release_url(self) -> str | None:
Expand All @@ -126,8 +128,10 @@ def release_url(self) -> str | None:

@property
def supported_features(self) -> UpdateEntityFeature:
"""RELEASE_NOTES only on the stable channel — dev builds have no tags."""
features = UpdateEntityFeature.INSTALL
"""Expose install only when Home Assistant may manage the package."""
features = UpdateEntityFeature(0)
if not self.coordinator.hass.config.skip_pip:
features |= UpdateEntityFeature.INSTALL
data = self.coordinator.data
if data is not None and data.dist != DIST_NAME_DEV:
features |= UpdateEntityFeature.RELEASE_NOTES
Expand Down Expand Up @@ -257,6 +261,14 @@ async def async_install(
that can still fail (review finding).
"""
data = self.coordinator.data
if self.coordinator.hass.config.skip_pip:
raise HomeAssistantError(
"The HA-MCP server package is externally managed by the system "
"package manager because Home Assistant was started with "
"skip_pip. Install the update there, then reload this "
"integration."
)

target = version or self.latest_version
if target is None:
raise HomeAssistantError("No target version available to install.")
Expand Down
90 changes: 90 additions & 0 deletions tests/src/unit/test_embedded_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@

def _make_hass(tmp_path) -> MagicMock:
hass = MagicMock(name="hass")
hass.config.skip_pip = False
hass.config.path = lambda sub: str(tmp_path / sub)

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


class TestEnsurePackage:
async def test_skip_pip_uses_compatible_externally_managed_package(
self, tmp_path, monkeypatch
):
"""skip_pip must bypass every package mutation and preserve markers."""
data = {
DATA_SECRET_PATH: "/p",
DATA_LAST_PIP_SPEC: "ha-mcp==7.11.0",
DATA_PENDING_INSTALL_VERSION: "7.12.0",
}
mgr, hass, entry = _manager(
tmp_path,
options={OPT_PIP_SPEC: "ha-mcp==99.0.0"},
data=data,
)
hass.config.skip_pip = True
process = AsyncMock(side_effect=AssertionError("requirements mutation"))
force_install = MagicMock(side_effect=AssertionError("package install"))
uninstall = MagicMock(side_effect=AssertionError("package uninstall"))
monkeypatch.setattr(es, "async_process_requirements", process)
monkeypatch.setattr(es, "_force_install_package", force_install)
monkeypatch.setattr(es, "_uninstall_distribution", uninstall)
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.12.1")
monkeypatch.setattr(
es,
"_installed_dist_version",
lambda dist: "7.12.1" if dist == DIST_NAME_STABLE else None,
)

version = await mgr._async_ensure_package()

assert version == "7.12.1"
assert entry.data == data

async def test_skip_pip_reports_missing_externally_managed_package(
self, tmp_path, monkeypatch
):
mgr, hass, _entry = _manager(tmp_path)
hass.config.skip_pip = True
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: None)
monkeypatch.setattr(es, "_installed_dist_version", lambda _dist: None)

with pytest.raises(
es.EmbeddedServerError,
match=r"skip_pip.*system package manager.*7\.10\.0",
) as exc_info:
await mgr._async_ensure_package()

assert exc_info.value.kind == "package"

async def test_skip_pip_reports_incompatible_externally_managed_package(
self, tmp_path, monkeypatch
):
mgr, hass, _entry = _manager(tmp_path)
hass.config.skip_pip = True
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.9.0")
monkeypatch.setattr(
es,
"_installed_dist_version",
lambda dist: "7.9.0" if dist == DIST_NAME_STABLE else None,
)

with pytest.raises(
es.EmbeddedServerError,
match=r"externally managed ha-mcp 7\.9\.0.*7\.10\.0 or newer",
) as exc_info:
await mgr._async_ensure_package()

assert exc_info.value.kind == "package"

async def test_skip_pip_reports_ambiguous_externally_managed_packages(
self, tmp_path, monkeypatch
):
mgr, hass, _entry = _manager(tmp_path)
hass.config.skip_pip = True
versions = {
DIST_NAME_STABLE: "7.12.1",
DIST_NAME_DEV: "7.13.0.dev1",
}
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.12.1")
monkeypatch.setattr(es, "_installed_dist_version", versions.get)

with pytest.raises(
es.EmbeddedServerError,
match=r"Both ha-mcp 7\.12\.1 and ha-mcp-dev 7\.13\.0\.dev1",
) as exc_info:
await mgr._async_ensure_package()

assert exc_info.value.kind == "package"

async def test_fast_path_only_for_unchanged_override(self, tmp_path, monkeypatch):
# The fast path is reserved for an explicit pip-spec override: an
# unchanged, already-installed pin delegates the "already satisfied?"
Expand Down
11 changes: 11 additions & 0 deletions tests/src/unit/test_embedded_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
def _make_hass() -> MagicMock:
hass = MagicMock(name="hass")
hass.data = {}
hass.config.skip_pip = False

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

hass.config_entries.async_reload.assert_not_awaited()

async def test_skip_pip_does_not_reload_or_set_marker(self, monkeypatch):
hass = _make_async_hass()
hass.config.skip_pip = True
entry = _make_entry(options={OPT_AUTO_UPDATE: True})

await esetup.async_maybe_auto_update(hass, entry, self._NEWER)

hass.config_entries.async_reload.assert_not_awaited()
assert DATA_PENDING_UPDATE_NOTIFY not in hass.data.get(DOMAIN, {})

async def test_override_does_not_reload(self, monkeypatch):
hass = _make_async_hass()
entry = _make_entry(options={OPT_PIP_SPEC: "ha-mcp==7.8.0"})
Expand Down
45 changes: 45 additions & 0 deletions tests/src/unit/test_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def _make_coordinator(data=None) -> MagicMock:
coordinator = MagicMock(name="coordinator")
coordinator.data = data
coordinator.hass = MagicMock(name="hass")
coordinator.hass.config.skip_pip = False
return coordinator


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

Expand Down Expand Up @@ -110,6 +112,16 @@ def test_auto_update_reflects_entry_option(
entity = _make_entity(entry=_make_entry(options=options))
assert entity.auto_update is expected

def test_skip_pip_disables_auto_update(self):
coordinator = _make_coordinator(_info())
coordinator.hass.config.skip_pip = True
entity = _make_entity(
coordinator=coordinator,
entry=_make_entry(options={OPT_AUTO_UPDATE: True}),
)

assert entity.auto_update is False

def test_release_url_stable_channel(self):
entity = _make_entity(
coordinator=_make_coordinator(_info(latest="1.2.0", dist=DIST_NAME_STABLE))
Expand Down Expand Up @@ -152,6 +164,20 @@ def test_supported_features_install_only_when_coordinator_data_absent(self):
entity = _make_entity(coordinator=_make_coordinator(None))
assert entity.supported_features == upd.UpdateEntityFeature.INSTALL

@pytest.mark.parametrize(
("dist", "expected"),
[
(DIST_NAME_STABLE, upd.UpdateEntityFeature.RELEASE_NOTES),
(DIST_NAME_DEV, upd.UpdateEntityFeature(0)),
],
)
def test_skip_pip_removes_install_feature(self, dist, expected):
coordinator = _make_coordinator(_info(dist=dist))
coordinator.hass.config.skip_pip = True
entity = _make_entity(coordinator=coordinator)

assert entity.supported_features == expected


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


class TestAsyncInstall:
async def test_skip_pip_rejects_install_without_writing_or_reloading(self):
entry = _make_entry(data={"existing": "kept"})
hass = _make_install_hass(installed_version="1.2.0")
hass.config.skip_pip = True
coordinator = _make_coordinator(_info(latest="1.2.0"))
entity = _make_entity(coordinator=coordinator, entry=entry)
entity.hass = hass
coordinator.hass.config.skip_pip = True

with pytest.raises(
upd.HomeAssistantError,
match="externally managed by the system package manager",
):
await entity.async_install("1.2.0", backup=False)

assert entry.data == {"existing": "kept"}
hass.config_entries.async_update_entry.assert_not_called()
hass.config_entries.async_reload.assert_not_awaited()

async def test_writes_pending_marker_and_reloads(self):
entry = _make_entry(data={"existing": "kept"})
hass = _make_install_hass(installed_version="1.2.0")
Expand Down
Loading