Fix uv installing in user site packages (#125808)

This commit is contained in:
Robert Resch 2024-09-15 14:53:45 +02:00 committed by GitHub
parent f80cc1a247
commit d9812f0d48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 153 additions and 17 deletions

View File

@ -8,6 +8,7 @@ from importlib.metadata import PackageNotFoundError, version
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
import site
from subprocess import PIPE, Popen from subprocess import PIPE, Popen
import sys import sys
from urllib.parse import urlparse from urllib.parse import urlparse
@ -83,6 +84,12 @@ def is_installed(requirement_str: str) -> bool:
return False return False
_UV_ENV_PYTHON_VARS = (
"UV_SYSTEM_PYTHON",
"UV_PYTHON",
)
def install_package( def install_package(
package: str, package: str,
upgrade: bool = True, upgrade: bool = True,
@ -96,7 +103,18 @@ def install_package(
""" """
_LOGGER.info("Attempting install of %s", package) _LOGGER.info("Attempting install of %s", package)
env = os.environ.copy() 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: if timeout:
env["HTTP_TIMEOUT"] = str(timeout) env["HTTP_TIMEOUT"] = str(timeout)
if upgrade: if upgrade:
@ -104,10 +122,20 @@ def install_package(
if constraints is not None: if constraints is not None:
args += ["--constraint", constraints] args += ["--constraint", constraints]
if target: if target:
assert not is_virtual_env() abs_target = os.path.abspath(target)
# This only works if not running in venv args += ["--target", abs_target]
args += ["--user"] elif (
env["PYTHONUSERBASE"] = os.path.abspath(target) 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) _LOGGER.debug("Running uv pip command: args=%s", args)
with Popen( with Popen(
args, args,

View File

@ -93,7 +93,15 @@ def test_install(mock_popen: MagicMock, mock_env_copy: MagicMock) -> None:
assert package.install_package(TEST_NEW_REQ, False) assert package.install_package(TEST_NEW_REQ, False)
assert mock_popen.call_count == 2 assert mock_popen.call_count == 2
assert mock_popen.mock_calls[0] == call( 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, stdin=PIPE,
stdout=PIPE, stdout=PIPE,
stderr=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 assert mock_popen.call_count == 2
env["HTTP_TIMEOUT"] = "10" env["HTTP_TIMEOUT"] = "10"
assert mock_popen.mock_calls[0] == call( 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, stdin=PIPE,
stdout=PIPE, stdout=PIPE,
stderr=PIPE, stderr=PIPE,
@ -134,6 +150,8 @@ def test_install_upgrade(mock_popen, mock_env_copy) -> None:
"install", "install",
"--quiet", "--quiet",
TEST_NEW_REQ, TEST_NEW_REQ,
"--index-strategy",
"unsafe-first-match",
"--upgrade", "--upgrade",
], ],
stdin=PIPE, 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 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.""" """Test an install with a target."""
target = "target_folder" target = "target_folder"
env = mock_env_copy() env = mock_env_copy()
env["PYTHONUSERBASE"] = os.path.abspath(target) abs_target = os.path.abspath(target)
mock_venv.return_value = False env["PYTHONUSERBASE"] = abs_target
mock_venv.return_value = is_venv
mock_sys.platform = "linux" mock_sys.platform = "linux"
args = [ args = [
"uv", "uv",
@ -158,7 +190,10 @@ def test_install_target(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None:
"install", "install",
"--quiet", "--quiet",
TEST_NEW_REQ, TEST_NEW_REQ,
"--user", "--index-strategy",
"unsafe-first-match",
"--target",
abs_target,
] ]
assert package.install_package(TEST_NEW_REQ, False, target=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 assert mock_popen.return_value.communicate.call_count == 1
@pytest.mark.usefixtures("mock_sys", "mock_popen", "mock_env_copy", "mock_venv") @pytest.mark.parametrize(
def test_install_target_venv() -> None: ("in_venv", "additional_env_vars"),
"""Test an install with a target in a virtual environment.""" [
target = "target_folder" (True, {}),
with pytest.raises(AssertionError): (False, {"UV_SYSTEM_PYTHON": "true"}),
package.install_package(TEST_NEW_REQ, False, target=target) (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") @pytest.mark.usefixtures("mock_sys", "mock_venv")
@ -202,6 +308,8 @@ def test_install_constraint(mock_popen, mock_env_copy) -> None:
"install", "install",
"--quiet", "--quiet",
TEST_NEW_REQ, TEST_NEW_REQ,
"--index-strategy",
"unsafe-first-match",
"--constraint", "--constraint",
constraints, constraints,
], ],