From 4333870590953b001e3a60340c2d6e4b1d7b675f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Aug 2026 13:43:48 -0500 Subject: [PATCH] [core] Use uv for the ESP-IDF Python environment when available (#18838) --- esphome/espidf/framework.py | 33 ++++++++++++++++------- tests/unit_tests/test_espidf_framework.py | 27 +++++++++++++++++++ 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 6c2a285360..9373b5f569 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -1053,15 +1053,28 @@ def _check_esp_idf_python_env_install( constraint_file_path, ) - cmd_pip_install = [ - str(env_python_path), - "-m", - "pip", - "install", - "--upgrade", - "--constraint", - constraint_file_path, - ] + # uv (much faster than pip) when available, e.g. in the docker image + if uv_path := shutil.which("uv"): + cmd_pip_install = [ + uv_path, + "pip", + "install", + "--python", + str(env_python_path), + "--upgrade", + "--constraint", + str(constraint_file_path), + ] + else: + cmd_pip_install = [ + str(env_python_path), + "-m", + "pip", + "install", + "--upgrade", + "--constraint", + str(constraint_file_path), + ] _LOGGER.info("Installing ESP-IDF %s Python dependencies ...", version) cmd = cmd_pip_install + [ @@ -1135,6 +1148,8 @@ def check_esp_idf_install( env = {} env["IDF_TOOLS_PATH"] = str(get_idf_tools_path()) env["IDF_PATH"] = "" + # uv defaults to 3 HTTP retries; match the pioarduino penv's bump to 10 + env["UV_HTTP_RETRIES"] = os.environ.get("UV_HTTP_RETRIES", "10") # An explicit ESPHOME_IDF_DEFAULT_TARGETS wins over the caller's # per-variant request (builder-image pre-warm); otherwise the caller's diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index afa4433aa1..3eeace9914 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -520,6 +520,33 @@ def test_check_esp_idf_install_feature_failure(espidf_mocks: SimpleNamespace) -> check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"]) +def test_python_deps_use_uv_when_available( + espidf_mocks: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + """The python env installs go through uv when on the PATH, pip otherwise.""" + monkeypatch.delenv("UV_HTTP_RETRIES", raising=False) + with patch( + "esphome.espidf.framework.shutil.which", + # Keyed on the name: the same which() also probes the default tools + side_effect=lambda name: "/usr/bin/uv" if name == "uv" else None, + ): + check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"]) + upgrade_call, feature_call = espidf_mocks.run_ok.call_args_list[1:3] + upgrade_cmd, feature_cmd = upgrade_call.args[0], feature_call.args[0] + assert upgrade_cmd[:3] == ["/usr/bin/uv", "pip", "install"] + assert "--python" in upgrade_cmd + assert feature_cmd[:3] == ["/usr/bin/uv", "pip", "install"] + assert upgrade_call.kwargs["env"]["UV_HTTP_RETRIES"] == "10" + + espidf_mocks.run_ok.reset_mock() + monkeypatch.setenv("UV_HTTP_RETRIES", "3") # an explicit user value wins + with patch("esphome.espidf.framework.shutil.which", return_value=None): + check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"]) + upgrade_call = espidf_mocks.run_ok.call_args_list[1] + assert upgrade_call.args[0][1:4] == ["-m", "pip", "install"] + assert upgrade_call.kwargs["env"]["UV_HTTP_RETRIES"] == "3" + + def _mark_installed() -> None: """Create the extracted marker and python-env interpreter so the install check takes the already-installed path rather than force-installing."""