diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index d21a1ba73a1..01827fce4a6 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -126,7 +126,7 @@ jobs: env: UV_PRERELEASE: allow run: | - python3 -m pip install "$(grep '^uv' < requirements_test.txt)" + python3 -m pip install "$(grep '^uv' < requirements.txt)" uv pip install packaging tomli uv pip install . python3 script/version_bump.py nightly --set-nightly-version "${{ needs.init.outputs.version }}" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 84ee815c087..45e7ec77a8e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -252,7 +252,7 @@ jobs: python -m venv venv . venv/bin/activate python --version - pip install "$(grep '^uv' < requirements_test.txt)" + pip install "$(grep '^uv' < requirements.txt)" uv pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit @@ -476,7 +476,7 @@ jobs: - name: Generate partial uv restore key id: generate-uv-key run: | - uv_version=$(cat requirements_test.txt | grep uv | cut -d '=' -f 3) + uv_version=$(cat requirements.txt | grep uv | cut -d '=' -f 3) echo "version=${uv_version}" >> $GITHUB_OUTPUT echo "key=uv-${{ env.UV_CACHE_VERSION }}-${uv_version}-${{ env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT @@ -525,7 +525,7 @@ jobs: python -m venv venv . venv/bin/activate python --version - pip install "$(grep '^uv' < requirements_test.txt)" + pip install "$(grep '^uv' < requirements.txt)" uv pip install -U "pip>=21.3.1" setuptools wheel uv pip install -r requirements.txt python -m script.gen_requirements_all ci diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 20dd2054c6e..2ba72411330 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -46,7 +46,7 @@ jobs: python -m venv venv . venv/bin/activate python --version - pip install "$(grep '^uv' < requirements_test.txt)" + pip install "$(grep '^uv' < requirements.txt)" uv pip install -r requirements.txt - name: Get information diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d87ccf93aa7..98a4eecb641 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -83,7 +83,7 @@ repos: pass_filenames: false language: script types: [text] - files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements_test.txt)$ + files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$ - id: hassfest-metadata name: hassfest-metadata entry: script/run-in-env.sh python3 -m script.hassfest -p metadata diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8731a0158b7..a416eab6506 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -42,7 +42,6 @@ orjson==3.10.7 packaging>=23.1 paho-mqtt==1.6.1 Pillow==10.4.0 -pip>=21.3.1 psutil-home-assistant==0.0.1 PyJWT==2.9.0 pymicro-vad==1.0.1 @@ -59,6 +58,7 @@ SQLAlchemy==2.0.31 typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 +uv==0.4.8 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 067bf5ff36d..4d87e51badc 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -94,12 +94,11 @@ def install_package( Return boolean if install successful. """ - # Not using 'import pip; pip.main([])' because it breaks the logger _LOGGER.info("Attempting install of %s", package) env = os.environ.copy() - args = [sys.executable, "-m", "pip", "install", "--quiet", package] + args = ["uv", "pip", "install", "--quiet", package] if timeout: - args += ["--timeout", str(timeout)] + env["HTTP_TIMEOUT"] = str(timeout) if upgrade: args.append("--upgrade") if constraints is not None: @@ -109,7 +108,7 @@ def install_package( # This only works if not running in venv args += ["--user"] env["PYTHONUSERBASE"] = os.path.abspath(target) - _LOGGER.debug("Running pip command: args=%s", args) + _LOGGER.debug("Running uv pip command: args=%s", args) with Popen( args, stdin=PIPE, diff --git a/pyproject.toml b/pyproject.toml index ac362b92483..e0f427454b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,6 @@ dependencies = [ "pyOpenSSL==24.2.1", "orjson==3.10.7", "packaging>=23.1", - "pip>=21.3.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", "PyYAML==6.0.2", @@ -66,6 +65,7 @@ dependencies = [ # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", + "uv==0.4.8", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", diff --git a/requirements.txt b/requirements.txt index 2a46b3170d1..eb39a94559a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,6 @@ Pillow==10.4.0 pyOpenSSL==24.2.1 orjson==3.10.7 packaging>=23.1 -pip>=21.3.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 PyYAML==6.0.2 @@ -38,6 +37,7 @@ SQLAlchemy==2.0.31 typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 +uv==0.4.8 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 diff --git a/requirements_test.txt b/requirements_test.txt index 6869cc12e11..7579a654d40 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -51,4 +51,3 @@ types-pytz==2024.1.0.20240417 types-PyYAML==6.0.12.20240311 types-requests==2.31.0.3 types-xmltodict==0.13.0.3 -uv==0.4.8 diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 5809ea4afa0..bcafbdb53c0 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -172,8 +172,9 @@ def _generate_files(config: Config) -> list[File]: + 10 ) * 1000 - package_versions = _get_package_versions( - Path("requirements_test.txt"), {"pipdeptree", "tqdm", "uv"} + package_versions = _get_package_versions(Path("requirements.txt"), {"uv"}) + package_versions |= _get_package_versions( + Path("requirements_test.txt"), {"pipdeptree", "tqdm"} ) package_versions |= _get_package_versions( Path("requirements_test_pre_commit.txt"), {"ruff"} diff --git a/tests/util/test_package.py b/tests/util/test_package.py index 2ead327bf10..72600f94890 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -1,12 +1,13 @@ """Test Home Assistant package util methods.""" import asyncio +from collections.abc import Generator from importlib.metadata import metadata import logging import os from subprocess import PIPE import sys -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, Mock, call, patch import pytest @@ -24,7 +25,7 @@ TEST_ZIP_REQ = "file://{}#{}".format( @pytest.fixture -def mock_sys(): +def mock_sys() -> Generator[MagicMock]: """Mock sys.""" with patch("homeassistant.util.package.sys", spec=object) as sys_mock: sys_mock.executable = "python3" @@ -32,19 +33,19 @@ def mock_sys(): @pytest.fixture -def deps_dir(): +def deps_dir() -> str: """Return path to deps directory.""" return os.path.abspath("/deps_dir") @pytest.fixture -def lib_dir(deps_dir): +def lib_dir(deps_dir) -> str: """Return path to lib directory.""" return os.path.join(deps_dir, "lib_dir") @pytest.fixture -def mock_popen(lib_dir): +def mock_popen(lib_dir) -> Generator[MagicMock]: """Return a Popen mock.""" with patch("homeassistant.util.package.Popen") as popen_mock: popen_mock.return_value.__enter__ = popen_mock @@ -57,7 +58,7 @@ def mock_popen(lib_dir): @pytest.fixture -def mock_env_copy(): +def mock_env_copy() -> Generator[Mock]: """Mock os.environ.copy.""" with patch("homeassistant.util.package.os.environ.copy") as env_copy: env_copy.return_value = {} @@ -65,14 +66,14 @@ def mock_env_copy(): @pytest.fixture -def mock_venv(): +def mock_venv() -> Generator[MagicMock]: """Mock homeassistant.util.package.is_virtual_env.""" with patch("homeassistant.util.package.is_virtual_env") as mock: mock.return_value = True yield mock -def mock_async_subprocess(): +def mock_async_subprocess() -> Generator[MagicMock]: """Return an async Popen mock.""" async_popen = MagicMock() @@ -85,13 +86,14 @@ def mock_async_subprocess(): return async_popen -def test_install(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: +@pytest.mark.usefixtures("mock_sys", "mock_venv") +def test_install(mock_popen: MagicMock, mock_env_copy: MagicMock) -> None: """Test an install attempt on a package that doesn't exist.""" env = mock_env_copy() assert package.install_package(TEST_NEW_REQ, False) assert mock_popen.call_count == 2 assert mock_popen.mock_calls[0] == call( - [mock_sys.executable, "-m", "pip", "install", "--quiet", TEST_NEW_REQ], + ["uv", "pip", "install", "--quiet", TEST_NEW_REQ], stdin=PIPE, stdout=PIPE, stderr=PIPE, @@ -101,15 +103,33 @@ def test_install(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: assert mock_popen.return_value.communicate.call_count == 1 -def test_install_upgrade(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: +@pytest.mark.usefixtures("mock_sys", "mock_venv") +def test_install_with_timeout(mock_popen: MagicMock, mock_env_copy: MagicMock) -> None: + """Test an install attempt on a package that doesn't exist with a timeout set.""" + env = mock_env_copy() + assert package.install_package(TEST_NEW_REQ, False, timeout=10) + assert mock_popen.call_count == 2 + env["HTTP_TIMEOUT"] = "10" + assert mock_popen.mock_calls[0] == call( + ["uv", "pip", "install", "--quiet", TEST_NEW_REQ], + 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") +def test_install_upgrade(mock_popen, mock_env_copy) -> None: """Test an upgrade attempt on a package.""" env = mock_env_copy() assert package.install_package(TEST_NEW_REQ) assert mock_popen.call_count == 2 assert mock_popen.mock_calls[0] == call( [ - mock_sys.executable, - "-m", + "uv", "pip", "install", "--quiet", @@ -133,8 +153,7 @@ def test_install_target(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: mock_venv.return_value = False mock_sys.platform = "linux" args = [ - mock_sys.executable, - "-m", + "uv", "pip", "install", "--quiet", @@ -150,16 +169,16 @@ def test_install_target(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: assert mock_popen.return_value.communicate.call_count == 1 -def test_install_target_venv(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: +@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) -def test_install_error( - caplog: pytest.LogCaptureFixture, mock_sys, mock_popen, mock_venv -) -> None: +@pytest.mark.usefixtures("mock_sys", "mock_venv") +def test_install_error(caplog: pytest.LogCaptureFixture, mock_popen) -> None: """Test an install that errors out.""" caplog.set_level(logging.WARNING) mock_popen.return_value.returncode = 1 @@ -169,7 +188,8 @@ def test_install_error( assert record.levelname == "ERROR" -def test_install_constraint(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: +@pytest.mark.usefixtures("mock_sys", "mock_venv") +def test_install_constraint(mock_popen, mock_env_copy) -> None: """Test install with constraint file on not installed package.""" env = mock_env_copy() constraints = "constraints_file.txt" @@ -177,8 +197,7 @@ def test_install_constraint(mock_sys, mock_popen, mock_env_copy, mock_venv) -> N assert mock_popen.call_count == 2 assert mock_popen.mock_calls[0] == call( [ - mock_sys.executable, - "-m", + "uv", "pip", "install", "--quiet",