329 files
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user