Compare commits

...

6 Commits

Author SHA1 Message Date
Erik d8e425e002 Improve test 2026-05-13 11:42:04 +02:00
Erik 6e03333da6 Add test 2026-05-13 11:05:53 +02:00
Erik 00b8e67639 Remove fixtures patching removed functionality 2026-05-13 10:51:35 +02:00
Erik 03a7590d20 Remove tests 2026-05-13 10:46:26 +02:00
Erik 5ffcd04ccb Merge remote-tracking branch 'upstream/dev' into remove_deps_support 2026-05-13 10:43:37 +02:00
Erik 65f073ca15 Remove support for installing Python dependencies in the config dir 2026-04-14 09:05:43 +02:00
10 changed files with 55 additions and 174 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
@@ -106,7 +106,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):
@@ -351,9 +351,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")
@@ -702,17 +699,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
@@ -30,7 +29,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
@@ -306,12 +304,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)
@@ -328,13 +320,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 -8
View File
@@ -9,10 +9,9 @@ import os
import sys
from homeassistant import runner
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
@@ -42,12 +41,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)
+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):
-45
View File
@@ -220,8 +220,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:
@@ -684,15 +682,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."""
@@ -700,15 +689,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."""
@@ -731,8 +711,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,
@@ -770,7 +748,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
@@ -784,8 +761,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,
@@ -825,8 +800,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:
@@ -847,13 +820,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:
@@ -878,8 +848,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:
@@ -909,7 +877,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
@@ -919,8 +886,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,
@@ -949,8 +914,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,
@@ -984,8 +947,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,
@@ -1021,8 +982,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,
@@ -1060,8 +1019,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:
@@ -1099,8 +1056,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
@@ -477,52 +477,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"
+36 -1
View File
@@ -1,11 +1,46 @@
"""Test methods in __main__."""
from unittest.mock import PropertyMock, patch
from unittest.mock import Mock, PropertyMock, call, patch
import pytest
from homeassistant import __main__ as main
from homeassistant.const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE
@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")
def test_validate_python(mock_exit) -> None:
"""Test validate Python version method."""
-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(