diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 506a15e6c925..8a7772fcc27d 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -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() diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index bb07500f82ef..965067cedda8 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -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, diff --git a/homeassistant/config.py b/homeassistant/config.py index a71c97e0b2b8..d7111c582486 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -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__) diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 96bc21610b77..6ff5df7738a1 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -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 ) diff --git a/homeassistant/scripts/__init__.py b/homeassistant/scripts/__init__.py index d784cfce7cb2..f56343529d81 100644 --- a/homeassistant/scripts/__init__.py +++ b/homeassistant/scripts/__init__.py @@ -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() - ) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index a043c4e0d2a9..71e4fe67da98 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -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): diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index d5a4c8ac778f..b6e6088f69e3 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -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. diff --git a/tests/scripts/test_init.py b/tests/scripts/test_init.py deleted file mode 100644 index 002ade2baeff..000000000000 --- a/tests/scripts/test_init.py +++ /dev/null @@ -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" diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 2158ce8345af..24fb6ef4897c 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -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: diff --git a/tests/test_config.py b/tests/test_config.py index 7345a3d03d28..9e7d2a3c74cf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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" diff --git a/tests/test_main.py b/tests/test_main.py index c8df894cc169..12b7bbb77852 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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"), diff --git a/tests/test_requirements.py b/tests/test_requirements.py index cd968e05b8ff..1a9a59c72e72 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -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( diff --git a/tests/util/test_package.py b/tests/util/test_package.py index 0b3e1de70fcf..07da6932ecdf 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -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 = (