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 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,

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 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,
],