diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 4d87e51badc..3796bf35cd7 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -8,6 +8,7 @@ from importlib.metadata import PackageNotFoundError, version import logging import os from pathlib import Path +import site from subprocess import PIPE, Popen import sys from urllib.parse import urlparse @@ -83,6 +84,12 @@ def is_installed(requirement_str: str) -> bool: return False +_UV_ENV_PYTHON_VARS = ( + "UV_SYSTEM_PYTHON", + "UV_PYTHON", +) + + def install_package( package: str, upgrade: bool = True, @@ -96,7 +103,18 @@ def install_package( """ _LOGGER.info("Attempting install of %s", package) env = os.environ.copy() - args = ["uv", "pip", "install", "--quiet", package] + args = [ + "uv", + "pip", + "install", + "--quiet", + package, + # We need to use unsafe-first-match for custom components + # which can use a different version of a package than the one + # we have built the wheel for. + "--index-strategy", + "unsafe-first-match", + ] if timeout: env["HTTP_TIMEOUT"] = str(timeout) if upgrade: @@ -104,10 +122,20 @@ def install_package( if constraints is not None: args += ["--constraint", constraints] if target: - assert not is_virtual_env() - # This only works if not running in venv - args += ["--user"] - env["PYTHONUSERBASE"] = os.path.abspath(target) + abs_target = os.path.abspath(target) + args += ["--target", abs_target] + elif ( + not is_virtual_env() + and not (any(var in env for var in _UV_ENV_PYTHON_VARS)) + and (abs_target := site.getusersitepackages()) + ): + # Pip compatibility + # Uv has currently no support for --user + # See https://github.com/astral-sh/uv/issues/2077 + # Using workaround to install to site-packages + # https://github.com/astral-sh/uv/issues/2077#issuecomment-2150406001 + args += ["--python", sys.executable, "--target", abs_target] + _LOGGER.debug("Running uv pip command: args=%s", args) with Popen( args, diff --git a/tests/util/test_package.py b/tests/util/test_package.py index 72600f94890..59a02bff838 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -93,7 +93,15 @@ def test_install(mock_popen: MagicMock, mock_env_copy: MagicMock) -> None: assert package.install_package(TEST_NEW_REQ, False) assert mock_popen.call_count == 2 assert mock_popen.mock_calls[0] == call( - ["uv", "pip", "install", "--quiet", TEST_NEW_REQ], + [ + "uv", + "pip", + "install", + "--quiet", + TEST_NEW_REQ, + "--index-strategy", + "unsafe-first-match", + ], stdin=PIPE, stdout=PIPE, stderr=PIPE, @@ -111,7 +119,15 @@ def test_install_with_timeout(mock_popen: MagicMock, mock_env_copy: MagicMock) - assert mock_popen.call_count == 2 env["HTTP_TIMEOUT"] = "10" assert mock_popen.mock_calls[0] == call( - ["uv", "pip", "install", "--quiet", TEST_NEW_REQ], + [ + "uv", + "pip", + "install", + "--quiet", + TEST_NEW_REQ, + "--index-strategy", + "unsafe-first-match", + ], stdin=PIPE, stdout=PIPE, stderr=PIPE, @@ -134,6 +150,8 @@ def test_install_upgrade(mock_popen, mock_env_copy) -> None: "install", "--quiet", TEST_NEW_REQ, + "--index-strategy", + "unsafe-first-match", "--upgrade", ], stdin=PIPE, @@ -145,12 +163,26 @@ def test_install_upgrade(mock_popen, mock_env_copy) -> None: assert mock_popen.return_value.communicate.call_count == 1 -def test_install_target(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: +@pytest.mark.parametrize( + "is_venv", + [ + True, + False, + ], +) +def test_install_target( + mock_sys: MagicMock, + mock_popen: MagicMock, + mock_env_copy: MagicMock, + mock_venv: MagicMock, + is_venv: bool, +) -> None: """Test an install with a target.""" target = "target_folder" env = mock_env_copy() - env["PYTHONUSERBASE"] = os.path.abspath(target) - mock_venv.return_value = False + abs_target = os.path.abspath(target) + env["PYTHONUSERBASE"] = abs_target + mock_venv.return_value = is_venv mock_sys.platform = "linux" args = [ "uv", @@ -158,7 +190,10 @@ def test_install_target(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: "install", "--quiet", TEST_NEW_REQ, - "--user", + "--index-strategy", + "unsafe-first-match", + "--target", + abs_target, ] assert package.install_package(TEST_NEW_REQ, False, target=target) @@ -169,12 +204,83 @@ def test_install_target(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: assert mock_popen.return_value.communicate.call_count == 1 -@pytest.mark.usefixtures("mock_sys", "mock_popen", "mock_env_copy", "mock_venv") -def test_install_target_venv() -> None: - """Test an install with a target in a virtual environment.""" - target = "target_folder" - with pytest.raises(AssertionError): - package.install_package(TEST_NEW_REQ, False, target=target) +@pytest.mark.parametrize( + ("in_venv", "additional_env_vars"), + [ + (True, {}), + (False, {"UV_SYSTEM_PYTHON": "true"}), + (False, {"UV_PYTHON": "python3"}), + (False, {"UV_SYSTEM_PYTHON": "true", "UV_PYTHON": "python3"}), + ], + ids=["in_venv", "UV_SYSTEM_PYTHON", "UV_PYTHON", "UV_SYSTEM_PYTHON and UV_PYTHON"], +) +def test_install_pip_compatibility_no_workaround( + mock_sys: MagicMock, + mock_popen: MagicMock, + mock_env_copy: MagicMock, + mock_venv: MagicMock, + in_venv: bool, + additional_env_vars: dict[str, str], +) -> None: + """Test install will not use pip fallback.""" + env = mock_env_copy() + env.update(additional_env_vars) + mock_venv.return_value = in_venv + mock_sys.platform = "linux" + args = [ + "uv", + "pip", + "install", + "--quiet", + TEST_NEW_REQ, + "--index-strategy", + "unsafe-first-match", + ] + + assert package.install_package(TEST_NEW_REQ, False) + assert mock_popen.call_count == 2 + assert mock_popen.mock_calls[0] == call( + args, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env, close_fds=False + ) + assert mock_popen.return_value.communicate.call_count == 1 + + +def test_install_pip_compatibility_use_workaround( + mock_sys: MagicMock, + mock_popen: MagicMock, + mock_env_copy: MagicMock, + mock_venv: MagicMock, +) -> None: + """Test install will use pip compatibility fallback.""" + env = mock_env_copy() + mock_venv.return_value = False + mock_sys.platform = "linux" + python = "python3" + mock_sys.executable = python + site_dir = "/site_dir" + args = [ + "uv", + "pip", + "install", + "--quiet", + TEST_NEW_REQ, + "--index-strategy", + "unsafe-first-match", + "--python", + python, + "--target", + site_dir, + ] + + with patch("homeassistant.util.package.site", autospec=True) as site_mock: + site_mock.getusersitepackages.return_value = site_dir + assert package.install_package(TEST_NEW_REQ, False) + + assert mock_popen.call_count == 2 + assert mock_popen.mock_calls[0] == call( + args, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env, close_fds=False + ) + assert mock_popen.return_value.communicate.call_count == 1 @pytest.mark.usefixtures("mock_sys", "mock_venv") @@ -202,6 +308,8 @@ def test_install_constraint(mock_popen, mock_env_copy) -> None: "install", "--quiet", TEST_NEW_REQ, + "--index-strategy", + "unsafe-first-match", "--constraint", constraints, ],