329 files

This commit is contained in:
Home Assistant Version Control
2026-08-06 13:56:25 +00:00
parent 0df89406fa
commit 7afe7add1d
330 changed files with 13098 additions and 5942 deletions
+341 -36
View File
@@ -32,6 +32,7 @@ import importlib.metadata
import importlib.util
import logging
import os
import site
import subprocess
import sys
import threading
@@ -39,6 +40,7 @@ from contextlib import suppress
from datetime import timedelta
from functools import partial
from typing import TYPE_CHECKING, Any, Literal
from urllib.parse import urlparse
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
@@ -49,7 +51,7 @@ from homeassistant.requirements import (
async_process_requirements,
pip_kwargs,
)
from homeassistant.util.package import install_package
from homeassistant.util.package import is_virtual_env
from packaging.requirements import InvalidRequirement, Requirement
from packaging.utils import canonicalize_name
from packaging.version import InvalidVersion, Version
@@ -133,6 +135,11 @@ _PIP_INSTALL_TIMEOUT_SECONDS = 300
# Uninstall just removes files/metadata, so it is quick; cap it so a wedged
# subprocess can never tie up an executor thread indefinitely.
_PIP_UNINSTALL_TIMEOUT_SECONDS = 120
# Upper bound for ONE uv install attempt. Generous (a cold ARM wheel build
# is slow but finite) and bounded, so a wedged uv cannot hold the
# process-wide tracked-install slot — and with it the next bring-up —
# forever. The extra-index fallback can spend this twice.
_UV_INSTALL_TIMEOUT_SECONDS = 1800
# How long a bring-up waits for an install job orphaned by a CANCELLED
# previous bring-up before giving up: asyncio cancellation detaches the
@@ -651,23 +658,28 @@ class EmbeddedServerManager:
With auto-update on (the default) both channels install their
distribution UNPINNED, so every entry reload / HA restart must pick up
the newest build. Such a spec ALWAYS takes the force-install path
(``upgrade=True``, bypassing the requirements manager's is-installed
shortcut) — that is what makes the channel auto-update. This runs in a
(``--upgrade-package <dist>``, bypassing the requirements manager's
is-installed shortcut) — that is what makes the channel auto-update,
and scoping the upgrade to our own distribution is what keeps it
from replacing packages Home Assistant ships (#2135/#2146). This runs in a
background task, so it never blocks HA startup, and uv no-ops quickly
when the newest build is already installed.
Fast path: reserved for a STABLE spec — an explicit pip-spec override (a
version pin or tarball URL) or a channel with auto-update turned OFF
Fast path: reserved for a stable INDEX spec — an explicit pip-spec
override that is a version pin, or a channel with auto-update turned OFF
(which pins to the installed version, see :meth:`_resolve_pip_spec`).
When that spec matches the one last installed and the package imports,
delegate the "already satisfied?" decision to Home Assistant's
requirements manager; a pinned spec does not move, so there is nothing to
upgrade to. A CHANGED spec (a new override, a cleared override, a
upgrade to. A URL override (a tarball or ``file://`` wheel) is
deliberately EXCLUDED: HA's is-installed check cannot verify a URL
requirement, so delegating one always reaches its bare ``--upgrade``
install — see the comment on ``spec_is_stable`` below. A CHANGED spec (a new override, a cleared override, a
toggled auto-update, a channel switch) falls through to the
force-install path below — and additionally uninstalls the replaced
distribution first (:meth:`_async_remove_replaced_source`), because
``upgrade=True`` alone decides by version and a changed SOURCE can keep
the version string (issue #1914).
the upgrade flag alone decides by version and a changed SOURCE can
keep the version string (issue #1914).
On a channel switch the other channel's distribution is uninstalled first
(:meth:`_async_remove_conflicting_dist`): ``ha-mcp`` and ``ha-mcp-dev``
@@ -737,7 +749,20 @@ class EmbeddedServerManager:
# A "stable" spec (an explicit override, or a channel pinned because
# auto-update is off) is eligible for the fast path; an unpinned
# auto-updating channel never is.
spec_is_stable = bool(self._pip_spec_override) or not self._auto_update
# A URL spec is never eligible, however stable it looks. The fast
# path delegates to HA's requirements manager, and
# homeassistant.util.package.is_installed() returns False for ANY
# requirement carrying a URL ("we cannot verify versions, so let the
# package manager handle it"), so async_process_requirements always
# reaches install_package(), whose upgrade default appends a bare
# --upgrade. That re-resolves the whole graph and replaces packages
# HA only floors — the #2135/#2146 tear, on every restart. Routing
# URL specs to the force path costs a scoped --reinstall-package of
# OUR distribution only, which is the install HA would have done
# anyway, minus the stomp.
spec_is_stable = (
bool(self._pip_spec_override) or not self._auto_update
) and not _spec_is_url_requirement(self._pip_spec)
fast_path_ok = (
spec_is_stable
and stored_spec == self._pip_spec
@@ -773,9 +798,10 @@ class EmbeddedServerManager:
raise EmbeddedServerError(
f"The installer left installed ha-mcp {version}, but this "
f"in-process component requires {MIN_EMBEDDED_SERVER_VERSION} "
"or newer. Review resolver details logged under "
"homeassistant.util.package, correct the package conflict, and "
"reload this integration.",
"or newer. Review the installer output logged under "
"custom_components.ha_mcp_tools.embedded_server (or, for an "
"index spec taking the fast path, homeassistant.util.package), "
"correct the package conflict, and reload this integration.",
kind="package",
)
_LOGGER.info("HA-MCP in-process server package ready (version %s)", version)
@@ -865,7 +891,8 @@ class EmbeddedServerManager:
Returns None for an override that names an unknown distribution or
does not parse as a requirement at all (a direct URL): the installer
re-fetches and rebuilds URL requirements under ``upgrade=True``
reinstalls a named URL requirement outright
(``--reinstall-package``, see :func:`_force_install_package`)
regardless of the installed version, so a URL install is already
real and nothing needs removing.
"""
@@ -885,7 +912,7 @@ class EmbeddedServerManager:
) -> None:
"""Uninstall the replaced distribution when the requested source changed.
The forced install that follows relies on ``upgrade=True``, and the
The forced install that follows relies on its upgrade flag, and the
installer decides "already satisfied" by VERSION alone — but a source
change can keep the version string. A PR branch's committed
``project.version`` equals the release it branched from (only release
@@ -903,7 +930,7 @@ class EmbeddedServerManager:
Skipped when nothing is installed, when the last-installed spec is
unknown (nothing to compare: first install, or entry data predating
the spec tracking), when the spec is unchanged (the routine
reload/restart path, where ``upgrade=True`` alone is correct and an
reload/restart path, where the upgrade flag alone is correct and an
uninstall would churn — and briefly break — a healthy install on
every restart), when the new spec is a direct URL (always installs
for real), when the named distribution is not installed (e.g. a
@@ -928,6 +955,19 @@ class EmbeddedServerManager:
return
if stored_spec == self._pip_spec:
return
if _spec_is_url_requirement(self._pip_spec):
# A URL spec is reinstalled outright (--reinstall-package, see
# _scoped_install_flags), so the install cannot be skipped as
# "already satisfied" and there is nothing for this uninstall to
# unblock. Removing first would only delete the working build
# BEFORE the new URL is fetched, so a failed fetch (bad path,
# network, moved tarball) leaves no server installed at all —
# and it reopens the uninstall-then-extract window on our own
# package. _replaced_dist_name() already declines for a BARE
# url; a NAMED one ("ha-mcp @ file:///…", the shape the config
# flow and the e2e lane use) parses fine and would fall through
# to the removal below without this.
return
replaced_dist = self._replaced_dist_name()
if replaced_dist is None:
return
@@ -936,20 +976,27 @@ class EmbeddedServerManager:
# code on disk came from the index too, so "already satisfied by
# version" is the truth, not the #1914 lie.
return
pinned = _exact_pinned_version(self._pip_spec)
if pinned is not None:
try:
version_moves = Version(pinned) != Version(installed_version)
except InvalidVersion:
version_moves = False # unprovable — keep the uninstall
if version_moves:
# The new pin cannot be satisfied by the installed version, so
# the forced install is guaranteed to be real without any
# uninstall — and keeping the working build in place preserves
# it as the fallback if that install fails (e.g. offline).
return
if not await self._hass.async_add_executor_job(_dist_installed, replaced_dist):
return
# Compare the pin against the version of the distribution actually
# being replaced, not the caller's ``installed_version``: that one is
# read from whichever dist provides ``ha_mcp`` and is read BEFORE
# _async_remove_conflicting_dist() runs, so on a cross-channel switch
# it can describe the other channel's dist — or one already
# uninstalled. Comparing against it could report "the pin moved" for a
# target that is in fact already at the pinned version, skip this
# uninstall, and let the install no-op as satisfied (#1914).
replaced_version = await self._hass.async_add_executor_job(
_installed_dist_version, replaced_dist
)
if replaced_version is not None and _pin_moves_off_installed(
self._pip_spec, replaced_version
):
# The new pin cannot be satisfied by the installed version, so the
# forced install is guaranteed to be real without any uninstall —
# and keeping the working build in place preserves it as the
# fallback if that install fails (e.g. offline).
return
_LOGGER.info(
"The requested server source changed (%r -> %r); removing the "
"installed %r first so the reinstall cannot be skipped as "
@@ -992,23 +1039,36 @@ class EmbeddedServerManager:
Mirrors how ``homeassistant.requirements`` builds its pip invocation
(HA's own constraints file + ``config/deps`` target where applicable) so
the resolver honors Home Assistant's constraints, then installs with
``upgrade=True`` and a generous per-download timeout.
the resolver honors Home Assistant's constraints, with one deliberate
difference from ``install_package(upgrade=True)``: that maps to uv's
EAGER ``--upgrade``, which re-resolves the whole dependency graph to
the newest allowed versions and replaces packages Home Assistant
already ships even when the installed version satisfies our spec —
exactly how the image's websockets kept getting force-replaced
(#2135/#2146). ``--upgrade-package`` scopes the upgrade to ha-mcp's
own distribution: the server still auto-updates, every other
installed package is kept whenever it satisfies the resolution.
"""
kwargs = pip_kwargs(self._hass.config.config_dir)
kwargs["timeout"] = max(
int(kwargs.get("timeout") or 0), _PIP_INSTALL_TIMEOUT_SECONDS
)
timeout = max(int(kwargs.get("timeout") or 0), _PIP_INSTALL_TIMEOUT_SECONDS)
installed = await self._async_run_tracked_install_job(
partial(install_package, self._pip_spec, upgrade=True, **kwargs)
partial(
_force_install_package,
self._pip_spec,
channel_dist=dist_for_channel(self._channel),
constraints=kwargs.get("constraints"),
target=kwargs.get("target"),
timeout=timeout,
)
)
if not installed:
raise EmbeddedServerError(
f"Could not install the server ({self._pip_spec!r}). The "
f"in-process server requires ha-mcp "
f"{MIN_EMBEDDED_SERVER_VERSION} or newer and Home Assistant "
f"{MIN_EMBEDDED_HOME_ASSISTANT_VERSION} or newer. Resolver "
"details are logged under homeassistant.util.package.",
f"{MIN_EMBEDDED_HOME_ASSISTANT_VERSION} or newer. The "
"installer's output is logged under "
"custom_components.ha_mcp_tools.embedded_server.",
kind="package",
)
@@ -1380,7 +1440,16 @@ class EmbeddedServerManager:
port=self._port,
timeout_graceful_shutdown=2,
lifespan="on",
ws="websockets-sansio",
# HTTP-ONLY listener, so no WebSocket protocol is loaded. uvicorn
# resolves its ``ws`` class EAGERLY in Config.load(), and
# "websockets-sansio" imports the SHARED websockets package —
# the unowned, tearable copy ha-mcp vendors its own copy to stay
# clear of (#2135/#2146). With that setting a torn shared install
# crashed this server at listener startup no matter what the
# client imports. "none" resolves to None and imports nothing;
# the MCP app serves Streamable HTTP and registers no WebSocket
# route. Pinned by tests/src/unit/test_vendored_websockets.py.
ws="none",
# Leave Home Assistant's logging untouched — do not let uvicorn
# reconfigure the root logger from this thread.
log_config=None,
@@ -1919,6 +1988,242 @@ def _uninstall_distribution(dist_name: str, *, target: str | None = None) -> boo
return True
def _force_install_package(
spec: str,
*,
channel_dist: str | None,
constraints: str | None,
target: str | None,
timeout: int | None,
) -> bool:
"""Install ``spec``, touching ONLY our own distribution (blocking).
Mirrors ``homeassistant.util.package.install_package``'s uv invocation
(index strategy, constraints, target, the uv --user workaround, and the
HTTP_TIMEOUT env) but never its eager ``--upgrade``, which re-resolves
EVERY dependency to the newest allowed version and replaces packages the
Home Assistant image already ships (#2135/#2146). The replacement flag
is chosen per spec shape by :func:`_scoped_install_flags`;
``channel_dist`` is the distribution the active channel installs, used
to scope a bare URL that names none of its own.
"""
env = os.environ.copy()
if timeout:
env["HTTP_TIMEOUT"] = str(timeout)
args = _uv_install_args(
spec,
channel_dist=channel_dist,
constraints=constraints,
target=target,
env=env,
)
_LOGGER.info("Installing the in-process server package: %s", spec)
stderr = _run_uv_install(args, env)
if stderr is None:
return True
# install_package's extra-index fallback, mirrored: uv treats a failing
# extra index as FATAL where pip merely skips it, so a wheels-index
# outage would otherwise fail a bring-up that PyPI could satisfy on its
# own. When the error names an extra-index host, retry with that host
# dropped. Matched on host because wheel files may live outside the
# index path. The warning names the failing HOSTS rather than the
# configured URLs (which can carry credentials); uv's own stderr is
# included as-is, exactly as install_package logs it.
extra_urls = env.get("UV_EXTRA_INDEX_URL", "").split()
failing = {
url: host for url in extra_urls if (host := _url_host(url)) and host in stderr
}
if failing:
_LOGGER.warning(
"Could not install %r using extra index host %s: %s; retrying without it",
spec,
", ".join(failing.values()),
stderr,
)
retry_env = env.copy()
if remaining := [url for url in extra_urls if url not in failing]:
retry_env["UV_EXTRA_INDEX_URL"] = " ".join(remaining)
else:
del retry_env["UV_EXTRA_INDEX_URL"]
stderr = _run_uv_install(args, retry_env)
if stderr is None:
return True
_LOGGER.error("Could not install %r: %s", spec, stderr)
return False
def _scoped_install_flags(spec: str, channel_dist: str | None) -> list[str]:
"""Return the uv flag that scopes this install to OUR distribution.
Never a bare ``--upgrade``: that re-resolves the whole graph and
replaces packages Home Assistant ships (#2135/#2146). Which scoped flag
is right depends on the SPEC SHAPE, not on which distribution it names:
* A URL requirement must be REINSTALLED. Measured on uv 0.11.33 (the
version CI pins), re-running an unchanged ``name @ file://…`` spec
reports "Checked 1 package" under both no flag and
``--upgrade-package`` — it installs nothing — while
``--reinstall-package`` replaces it. Auditing-and-skipping would keep
the OLD code running while the bring-up logs success (the #1914
shape), and it is exactly the "a URL install is always real"
guarantee that ``_replaced_dist_name`` and
``_async_remove_replaced_source`` skip their uninstall on.
* An index requirement only needs an UPGRADE, scoped to the
distribution the spec itself names. Force-reinstalling one instead
would reopen the non-atomic uninstall-then-extract window this PR
exists to close, on every bring-up, for a spec that never needed it.
A bare URL names no distribution of its own, and the channel's dist is
the wrong guess: a repository tarball installs as ``ha-mcp`` whatever
channel is selected (see :meth:`_replaced_dist_name`), so on the dev
channel scoping to ``ha-mcp-dev`` would name a package the URL does not
provide — uv would report success while leaving the real ``ha-mcp``
un-refreshed, and a mutable URL (a branch tarball, a rebuilt artifact)
keeps its version string, so nothing else would catch it. Both known
dists are therefore named: reinstalling one that is not installed is a
harmless no-op for uv (verified: exit 0, package still installed from
the URL).
"""
try:
requirement = Requirement(spec)
except InvalidRequirement:
candidates = [channel_dist] if channel_dist else []
candidates += [DIST_NAME_STABLE, DIST_NAME_DEV]
flags: list[str] = []
for dist in dict.fromkeys(candidates): # ordered, deduplicated
flags += ["--reinstall-package", dist]
return flags
if requirement.url is not None:
return ["--reinstall-package", requirement.name]
return ["--upgrade-package", requirement.name]
def _url_host(url: str) -> str | None:
"""Host of ``url``, or None when it cannot be parsed.
``urlparse().hostname`` RAISES on a malformed URL (``ValueError:
Invalid IPv6 URL`` for an unclosed bracket), and the one caller runs on
the INSTALL-FAILURE path — where an operator's typo'd
``UV_EXTRA_INDEX_URL`` entry would replace uv's real stderr with a
traceback from the error handler.
"""
try:
return urlparse(url).hostname
except ValueError:
return None
def _pin_moves_off_installed(spec: str, installed_version: str) -> bool:
"""True when an exact-pin ``spec`` CANNOT be satisfied by what's installed.
Asks the pin's own specifier rather than comparing parsed versions: PEP
440 ``==1.0`` matches an installed ``1.0+local``, while
``Version("1.0") != Version("1.0+local")`` is True. A version comparison
would therefore call that pin "moved", skip the caller's uninstall, and
let the installer no-op it as already satisfied — the #1914 shape.
False when the spec is not an exact pin, and False whenever the answer
is unprovable (unparseable requirement or version): "unknown" must not
be mistaken for "guaranteed to move", since the caller skips its
uninstall on a True.
"""
if _exact_pinned_version(spec) is None:
return False
try:
requirement = Requirement(spec)
# Validate the installed version explicitly: SpecifierSet.contains()
# answers False for an unparseable version rather than raising, and
# False here would invert to "moved" — the unsafe direction.
Version(installed_version)
except (InvalidRequirement, InvalidVersion):
return False
if requirement.marker is not None and not requirement.marker.evaluate():
# The requirement does not apply to this interpreter, so the
# installer will skip it entirely — the pin cannot make the install
# real, whatever version it names.
return False
return not requirement.specifier.contains(installed_version, prereleases=True)
def _spec_is_url_requirement(spec: str) -> bool:
"""True when ``spec`` installs from a URL rather than an index.
Same shape test :func:`_scoped_install_flags` routes on, and for the
same reason — an unparseable spec is treated as URL-ish so it takes the
conservative path.
"""
try:
return Requirement(spec).url is not None
except InvalidRequirement:
return True
def _uv_install_args(
spec: str,
*,
channel_dist: str | None,
constraints: str | None,
target: str | None,
env: dict[str, str],
) -> list[str]:
"""Build the ``uv pip install`` argv (mirrors install_package's shape)."""
args = [
sys.executable,
"-m",
"uv",
"pip",
"install",
"--quiet",
spec,
# Mirrors install_package: custom components may need a different
# version of a package than the one HA built wheels for.
"--index-strategy",
"unsafe-first-match",
]
args += _scoped_install_flags(spec, channel_dist)
if constraints is not None:
args += ["--constraint", constraints]
if target:
args += ["--target", os.path.abspath(target)]
elif (
not is_virtual_env()
# install_package's _UV_ENV_PYTHON_VARS, mirrored: an explicit uv
# python selection means uv already installs to the right place.
and not any(var in env for var in ("UV_SYSTEM_PYTHON", "UV_PYTHON"))
and (user_site := site.getusersitepackages())
):
# uv has no --user (astral-sh/uv#2077); install_package's workaround.
args += ["--python", sys.executable, "--target", os.path.abspath(user_site)]
return args
def _run_uv_install(args: list[str], env: dict[str, str]) -> str | None:
"""Run one uv install attempt; return None on success, else its stderr.
Bounded by ``_UV_INSTALL_TIMEOUT_SECONDS``: this runs inside the
process-wide tracked-install slot, and the extra-index fallback can run
it twice, so a wedged uv would otherwise pin an executor thread (and
block the next bring-up) with no upper bound. The budget is deliberately
generous — a cold ARM wheel build is slow but finite.
"""
try:
result = subprocess.run(
args,
capture_output=True,
text=True,
check=False,
env=env,
timeout=_UV_INSTALL_TIMEOUT_SECONDS,
)
except (OSError, subprocess.SubprocessError) as err:
return f"{type(err).__name__}: {err}"
if result.returncode != 0:
return (result.stderr or "").strip() or f"exit code {result.returncode}"
return None
def _exact_pinned_version(spec: str) -> str | None:
"""Return the version of an exact ``==``/``===`` single-clause pin, or None.