Remove support for installing Python dependencies in the config dir (#168155)

This commit is contained in:
Erik Montnemery
2026-10-01 15:08:48 +02:00
committed by GitHub
parent f13732d912
commit baa319aa2b
13 changed files with 52 additions and 247 deletions
+12 -13
View File
@@ -9,10 +9,21 @@ import threading
from .backup_restore import restore_backup
from .const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__
from .util.package import is_docker_env, is_virtual_env
FAULT_LOG_FILENAME = "home-assistant.log.fault"
def validate_environment() -> None:
"""Validate that Home Assistant is started from a container or a venv."""
if not is_virtual_env() and not is_docker_env():
print(
"Home Assistant must be run in a Python virtual environment or a container.",
file=sys.stderr,
)
sys.exit(1)
def validate_os() -> None:
"""Validate that Home Assistant is running in a supported operating system."""
if not sys.platform.startswith(("darwin", "linux")):
@@ -38,8 +49,6 @@ def ensure_config_path(config_dir: str) -> None:
"""Validate the configuration directory."""
from . import config as config_util # noqa: PLC0415
lib_dir = os.path.join(config_dir, "deps")
# Test if configuration directory exists
if not os.path.isdir(config_dir):
if config_dir != config_util.get_default_config_dir():
@@ -63,17 +72,6 @@ def ensure_config_path(config_dir: str) -> None:
)
sys.exit(1)
# Test if library directory exists
if not os.path.isdir(lib_dir):
try:
os.mkdir(lib_dir)
except OSError as ex:
print(
f"Fatal Error: Unable to create library directory {lib_dir}: {ex}",
file=sys.stderr,
)
sys.exit(1)
def get_arguments() -> argparse.Namespace:
"""Get parsed passed in arguments."""
@@ -166,6 +164,7 @@ def check_threads() -> None:
def main() -> int:
"""Start Home Assistant."""
validate_python()
validate_environment()
args = get_arguments()
+1 -15
View File
@@ -108,7 +108,7 @@ from .setup import (
from .util.async_ import create_eager_task
from .util.hass_dict import HassKey
from .util.logging import async_activate_log_queue_handler
from .util.package import async_get_user_site, is_docker_env, is_virtual_env
from .util.package import is_docker_env
from .util.system_info import is_official_image
with contextlib.suppress(ImportError):
@@ -363,9 +363,6 @@ async def async_setup_hass(
err,
)
else:
if not is_virtual_env():
await async_mount_local_lib_path(runtime_config.config_dir)
if hass.config.safe_mode:
_LOGGER.info("Starting in safe mode")
@@ -737,17 +734,6 @@ class _RotatingFileHandlerWithoutShouldRollOver(RotatingFileHandler):
return False
async def async_mount_local_lib_path(config_dir: str) -> str:
"""Add local library to Python Path.
This function is a coroutine.
"""
deps_dir = os.path.join(config_dir, "deps")
if (lib_dir := await async_get_user_site(deps_dir)) not in sys.path:
sys.path.insert(0, lib_dir)
return deps_dir
def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
"""Get domains of components to set up."""
# The common config section [homeassistant] could be filtered here,
-15
View File
@@ -11,7 +11,6 @@ import logging
import operator
import os
from pathlib import Path
import shutil
from types import ModuleType
from typing import TYPE_CHECKING, Any, Literal, overload
@@ -31,7 +30,6 @@ from .helpers.typing import ConfigType
from .loader import ComponentProtocol, Integration, IntegrationNotFound
from .requirements import RequirementsNotFound, async_get_integration_with_requirements
from .util.async_ import create_eager_task
from .util.package import is_docker_env
from .util.yaml import SECRET_YAML, Secrets, YamlTypeError, load_yaml_dict
from .util.yaml.objects import NodeStrClass
@@ -307,12 +305,6 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None:
version_obj = AwesomeVersion(conf_version)
if version_obj < AwesomeVersion("0.50"):
# 0.50 introduced persistent deps dir.
lib_path = hass.config.path("deps")
if os.path.isdir(lib_path):
shutil.rmtree(lib_path)
if version_obj < AwesomeVersion("0.92"):
# 0.92 moved google/tts.py to google_translate/tts.py
config_path = hass.config.path(YAML_CONFIG_FILE)
@@ -329,13 +321,6 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None:
except OSError:
_LOGGER.exception("Migrating to google_translate tts failed")
if version_obj < AwesomeVersion("0.94") and is_docker_env():
# In 0.94 we no longer install packages inside the deps folder when
# running inside a Docker container.
lib_path = hass.config.path("deps")
if os.path.isdir(lib_path):
shutil.rmtree(lib_path)
with open(version_path, "w", encoding="utf8") as outp:
outp.write(__version__)
+3 -7
View File
@@ -92,16 +92,12 @@ def async_clear_install_history(hass: HomeAssistant) -> None:
_async_get_manager(hass).install_failure_history.clear()
def pip_kwargs(config_dir: str | None) -> dict[str, Any]:
def pip_kwargs() -> dict[str, Any]:
"""Return keyword arguments for PIP install."""
is_docker = pkg_util.is_docker_env()
kwargs = {
return {
"constraints": os.path.join(os.path.dirname(__file__), CONSTRAINT_FILE),
"timeout": PIP_TIMEOUT,
}
if not (config_dir is None or pkg_util.is_virtual_env()) and not is_docker:
kwargs["target"] = os.path.join(config_dir, "deps")
return kwargs
def _install_with_retry(requirement: str, kwargs: dict[str, Any]) -> bool:
@@ -334,7 +330,7 @@ class RequirementsManager:
requirements: list[str],
) -> None:
"""Install a requirement and save failures."""
kwargs = pip_kwargs(self.hass.config.config_dir)
kwargs = pip_kwargs()
installed, failures = await self.hass.async_add_executor_job(
_install_requirements_if_missing, requirements, kwargs
)
+2 -24
View File
@@ -1,17 +1,12 @@
"""Home Assistant command line scripts."""
import argparse
import asyncio
from collections.abc import Sequence
import importlib
import logging
import os
import sys
from homeassistant.bootstrap import async_mount_local_lib_path
from homeassistant.config import get_default_config_dir
from homeassistant.requirements import pip_kwargs
from homeassistant.util.package import install_package, is_installed, is_virtual_env
from homeassistant.util.package import install_package, is_installed
# mypy: allow-untyped-defs, disallow-any-generics, no-warn-return-any
@@ -41,12 +36,7 @@ def run(args: list[str]) -> int:
script = importlib.import_module(f"homeassistant.scripts.{args[0]}")
config_dir = extract_config_dir()
if not is_virtual_env():
asyncio.run(async_mount_local_lib_path(config_dir))
_pip_kwargs = pip_kwargs(config_dir)
_pip_kwargs = pip_kwargs()
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
@@ -59,15 +49,3 @@ def run(args: list[str]) -> int:
return 1
return script.run(args[1:])
def extract_config_dir(args: Sequence[str] | None = None) -> str:
"""Extract the config dir from the arguments or get the default."""
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("-c", "--config", default=None)
parsed_args = parser.parse_known_args(args)[0]
return (
os.path.join(os.getcwd(), parsed_args.config)
if parsed_args.config
else get_default_config_dir()
)
+1 -1
View File
@@ -147,7 +147,7 @@ def run(script_args: list) -> int:
yaml_files = [
f
for f in glob(os.path.join(config_dir, "**/*.yaml"), recursive=True)
if not f.startswith(deps)
if not f.startswith(deps) # Avoid scanning legacy deps folder
]
for yfn in sorted(yaml_files):
-20
View File
@@ -223,26 +223,6 @@ def install_package(
return False
async def async_get_user_site(deps_dir: str) -> str:
"""Return user local library path.
This function is a coroutine.
"""
env = os.environ.copy()
env["PYTHONUSERBASE"] = os.path.abspath(deps_dir)
args = [sys.executable, "-m", "site", "--user-site"]
process = await asyncio.create_subprocess_exec(
*args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
env=env,
close_fds=False, # required for posix_spawn
)
stdout, _ = await process.communicate()
return stdout.decode().strip()
async def async_get_installed_packages() -> list[InstalledPackage]:
"""Return a list of installed packages and versions.
-15
View File
@@ -1,15 +0,0 @@
"""Test script init."""
from unittest.mock import patch
from homeassistant import scripts
@patch("homeassistant.scripts.get_default_config_dir", return_value="/default")
def test_config_per_platform(mock_def) -> None:
"""Test config per platform method."""
assert scripts.get_default_config_dir() == "/default"
assert scripts.extract_config_dir() == "/default"
assert scripts.extract_config_dir([""]) == "/default"
assert scripts.extract_config_dir(["-c", "/arg"]) == "/arg"
assert scripts.extract_config_dir(["--config", "/a"]) == "/a"
-45
View File
@@ -287,8 +287,6 @@ async def test_config_does_not_turn_off_debug(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_hass_config")
async def test_asyncio_debug_on_turns_hass_debug_on(
mock_enable_logging: AsyncMock,
mock_is_virtual_env: Mock,
mock_mount_local_lib_path: AsyncMock,
mock_ensure_config_exists: AsyncMock,
mock_process_ha_config_upgrade: Mock,
) -> None:
@@ -753,15 +751,6 @@ async def test_setup_after_deps_not_present(hass: HomeAssistant) -> None:
assert order == ["root", "second_dep"]
@pytest.fixture
def mock_is_virtual_env() -> Generator[Mock]:
"""Mock is_virtual_env."""
with patch(
"homeassistant.bootstrap.is_virtual_env", return_value=False
) as is_virtual_env:
yield is_virtual_env
@pytest.fixture
def mock_enable_logging() -> Generator[AsyncMock]:
"""Mock enable logging."""
@@ -769,15 +758,6 @@ def mock_enable_logging() -> Generator[AsyncMock]:
yield enable_logging
@pytest.fixture
def mock_mount_local_lib_path() -> Generator[AsyncMock]:
"""Mock enable logging."""
with patch(
"homeassistant.bootstrap.async_mount_local_lib_path"
) as mount_local_lib_path:
yield mount_local_lib_path
@pytest.fixture
def mock_process_ha_config_upgrade() -> Generator[Mock]:
"""Mock enable logging."""
@@ -800,8 +780,6 @@ def mock_ensure_config_exists() -> Generator[AsyncMock]:
@pytest.mark.usefixtures("mock_hass_config")
async def test_setup_hass(
mock_enable_logging: AsyncMock,
mock_is_virtual_env: Mock,
mock_mount_local_lib_path: AsyncMock,
mock_ensure_config_exists: AsyncMock,
mock_process_ha_config_upgrade: Mock,
caplog: pytest.LogCaptureFixture,
@@ -839,7 +817,6 @@ async def test_setup_hass(
log_file,
log_no_color,
)
assert len(mock_mount_local_lib_path.mock_calls) == 1
assert len(mock_ensure_config_exists.mock_calls) == 1
assert len(mock_process_ha_config_upgrade.mock_calls) == 1
@@ -853,8 +830,6 @@ async def test_setup_hass(
@pytest.mark.usefixtures("mock_hass_config")
async def test_setup_hass_takes_longer_than_log_slow_startup(
mock_enable_logging: AsyncMock,
mock_is_virtual_env: Mock,
mock_mount_local_lib_path: AsyncMock,
mock_ensure_config_exists: AsyncMock,
mock_process_ha_config_upgrade: Mock,
caplog: pytest.LogCaptureFixture,
@@ -894,8 +869,6 @@ async def test_setup_hass_takes_longer_than_log_slow_startup(
async def test_setup_hass_invalid_yaml(
mock_enable_logging: AsyncMock,
mock_is_virtual_env: Mock,
mock_mount_local_lib_path: AsyncMock,
mock_ensure_config_exists: AsyncMock,
mock_process_ha_config_upgrade: Mock,
) -> None:
@@ -916,13 +889,10 @@ async def test_setup_hass_invalid_yaml(
)
assert "recovery_mode" in hass.config.components
assert len(mock_mount_local_lib_path.mock_calls) == 0
async def test_setup_hass_config_dir_nonexistent(
mock_enable_logging: AsyncMock,
mock_is_virtual_env: Mock,
mock_mount_local_lib_path: AsyncMock,
mock_ensure_config_exists: AsyncMock,
mock_process_ha_config_upgrade: Mock,
) -> None:
@@ -947,8 +917,6 @@ async def test_setup_hass_config_dir_nonexistent(
async def test_setup_hass_recovery_mode(
mock_enable_logging: AsyncMock,
mock_is_virtual_env: Mock,
mock_mount_local_lib_path: AsyncMock,
mock_ensure_config_exists: AsyncMock,
mock_process_ha_config_upgrade: Mock,
) -> None:
@@ -978,7 +946,6 @@ async def test_setup_hass_recovery_mode(
mock_hass.assert_called_once()
assert "recovery_mode" in hass.config.components
assert len(mock_mount_local_lib_path.mock_calls) == 0
# Validate we didn't try to set up config entry.
assert "browser" not in hass.config.components
@@ -988,8 +955,6 @@ async def test_setup_hass_recovery_mode(
@pytest.mark.parametrize("domain", ["cloud", "backup"])
async def test_setup_hass_recovery_mode_with_failing_integration(
mock_enable_logging: AsyncMock,
mock_is_virtual_env: Mock,
mock_mount_local_lib_path: AsyncMock,
mock_ensure_config_exists: AsyncMock,
mock_process_ha_config_upgrade: Mock,
domain: str,
@@ -1018,8 +983,6 @@ async def test_setup_hass_recovery_mode_with_failing_integration(
@pytest.mark.usefixtures("mock_hass_config")
async def test_setup_hass_safe_mode(
mock_enable_logging: AsyncMock,
mock_is_virtual_env: Mock,
mock_mount_local_lib_path: AsyncMock,
mock_ensure_config_exists: AsyncMock,
mock_process_ha_config_upgrade: Mock,
caplog: pytest.LogCaptureFixture,
@@ -1053,8 +1016,6 @@ async def test_setup_hass_safe_mode(
@pytest.mark.usefixtures("mock_hass_config")
async def test_setup_hass_recovery_mode_and_safe_mode(
mock_enable_logging: AsyncMock,
mock_is_virtual_env: Mock,
mock_mount_local_lib_path: AsyncMock,
mock_ensure_config_exists: AsyncMock,
mock_process_ha_config_upgrade: Mock,
caplog: pytest.LogCaptureFixture,
@@ -1090,8 +1051,6 @@ async def test_setup_hass_recovery_mode_and_safe_mode(
async def test_storage_version_too_new_triggers_recovery_mode(
hass_storage: dict[str, Any],
mock_enable_logging: AsyncMock,
mock_is_virtual_env: Mock,
mock_mount_local_lib_path: AsyncMock,
mock_ensure_config_exists: AsyncMock,
mock_process_ha_config_upgrade: Mock,
caplog: pytest.LogCaptureFixture,
@@ -1129,8 +1088,6 @@ async def test_storage_version_too_new_triggers_recovery_mode(
@pytest.mark.usefixtures("mock_hass_config")
async def test_setup_hass_invalid_core_config(
mock_enable_logging: AsyncMock,
mock_is_virtual_env: Mock,
mock_mount_local_lib_path: AsyncMock,
mock_ensure_config_exists: AsyncMock,
mock_process_ha_config_upgrade: Mock,
) -> None:
@@ -1168,8 +1125,6 @@ async def test_setup_hass_invalid_core_config(
@pytest.mark.usefixtures("mock_hass_config")
async def test_setup_recovery_mode_if_no_frontend(
mock_enable_logging: AsyncMock,
mock_is_virtual_env: Mock,
mock_mount_local_lib_path: AsyncMock,
mock_ensure_config_exists: AsyncMock,
mock_process_ha_config_upgrade: Mock,
) -> None:
-46
View File
@@ -481,52 +481,6 @@ async def test_create_default_config_returns_none_if_write_error(
assert mock_print.called
@patch("homeassistant.config.shutil")
@patch("homeassistant.config.os")
@patch("homeassistant.config.is_docker_env", return_value=False)
def test_remove_lib_on_upgrade(
mock_docker, mock_os, mock_shutil, hass: HomeAssistant
) -> None:
"""Test removal of library on upgrade from before 0.50."""
ha_version = "0.49.0"
mock_os.path.isdir = mock.Mock(return_value=True)
mock_open = mock.mock_open()
with patch("homeassistant.config.open", mock_open, create=True):
opened_file = mock_open.return_value
opened_file.readline.return_value = ha_version
hass.config.path = mock.Mock()
config_util.process_ha_config_upgrade(hass)
hass_path = hass.config.path.return_value
assert mock_os.path.isdir.call_count == 1
assert mock_os.path.isdir.call_args == mock.call(hass_path)
assert mock_shutil.rmtree.call_count == 1
assert mock_shutil.rmtree.call_args == mock.call(hass_path)
@patch("homeassistant.config.shutil")
@patch("homeassistant.config.os")
@patch("homeassistant.config.is_docker_env", return_value=True)
def test_remove_lib_on_upgrade_94(
mock_docker, mock_os, mock_shutil, hass: HomeAssistant
) -> None:
"""Test removal of library on upgrade from before 0.94 and in Docker."""
ha_version = "0.93.0.dev0"
mock_os.path.isdir = mock.Mock(return_value=True)
mock_open = mock.mock_open()
with patch("homeassistant.config.open", mock_open, create=True):
opened_file = mock_open.return_value
opened_file.readline.return_value = ha_version
hass.config.path = mock.Mock()
config_util.process_ha_config_upgrade(hass)
hass_path = hass.config.path.return_value
assert mock_os.path.isdir.call_count == 1
assert mock_os.path.isdir.call_args == mock.call(hass_path)
assert mock_shutil.rmtree.call_count == 1
assert mock_shutil.rmtree.call_args == mock.call(hass_path)
def test_process_config_upgrade(hass: HomeAssistant) -> None:
"""Test update of version on upgrade."""
ha_version = "0.92.0"
+33
View File
@@ -13,6 +13,39 @@ _PYTHON_VERSION_ERROR = (
)
@patch("sys.exit")
@pytest.mark.parametrize(
("is_venv", "is_docker", "expected_exit_calls", "expected_stderr"),
[
(
False,
False,
[call(1)],
"Home Assistant must be run in a Python virtual environment or a container.\n",
),
(True, False, [], ""),
(False, True, [], ""),
(True, True, [], ""),
],
)
def test_validate_environment(
mock_exit: Mock,
is_venv: bool,
is_docker: bool,
expected_exit_calls: list[call],
expected_stderr: str,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Test validate Python environment."""
with (
patch("homeassistant.__main__.is_virtual_env", return_value=is_venv),
patch("homeassistant.__main__.is_docker_env", return_value=is_docker),
):
main.validate_environment()
assert mock_exit.call_args_list == expected_exit_calls
assert capsys.readouterr().err == expected_stderr
@patch("sys.exit")
@pytest.mark.parametrize(
("version_info", "expected_exit_calls", "expected_stderr"),
-23
View File
@@ -52,29 +52,6 @@ async def test_requirement_installed_in_venv(hass: HomeAssistant) -> None:
)
async def test_requirement_installed_in_deps(hass: HomeAssistant) -> None:
"""Test requirement installed in deps directory."""
with (
patch("os.path.dirname", return_value="ha_package_path"),
patch("homeassistant.util.package.is_virtual_env", return_value=False),
patch("homeassistant.util.package.is_docker_env", return_value=False),
patch(
"homeassistant.util.package.install_package", return_value=True
) as mock_install,
patch.dict(os.environ, env_without_wheel_links(), clear=True),
):
hass.config.skip_pip = False
mock_integration(hass, MockModule("comp", requirements=["package==0.0.1"]))
assert await setup.async_setup_component(hass, "comp", {})
assert "comp" in hass.config.components
assert mock_install.call_args == call(
"package==0.0.1",
target=hass.config.path("deps"),
constraints=os.path.join("ha_package_path", CONSTRAINT_FILE),
timeout=60,
)
async def test_install_existing_package(hass: HomeAssistant) -> None:
"""Test an install attempt on an existing package."""
with patch(
-23
View File
@@ -445,29 +445,6 @@ def test_install_constraint(mock_popen, mock_env_copy, mock_sys) -> None:
assert mock_popen.return_value.communicate.call_count == 1
async def test_async_get_user_site(mock_env_copy) -> None:
"""Test async get user site directory."""
deps_dir = "/deps_dir"
env = mock_env_copy()
env["PYTHONUSERBASE"] = os.path.abspath(deps_dir)
args = [sys.executable, "-m", "site", "--user-site"]
with patch(
"homeassistant.util.package.asyncio.create_subprocess_exec",
return_value=mock_async_subprocess(),
) as popen_mock:
ret = await package.async_get_user_site(deps_dir)
assert popen_mock.call_count == 1
assert popen_mock.call_args == call(
*args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
env=env,
close_fds=False,
)
assert ret == os.path.join(deps_dir, "lib_dir")
async def test_async_get_installed_packages() -> None:
"""Test async get installed packages."""
mock_output = (