mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[nrf52] Rebuild the Python env when its interpreter symlink dangles (#18540)
This commit is contained in:
@@ -62,6 +62,22 @@ def get_sdk_nrf_tools_path() -> Path:
|
|||||||
return path.resolve()
|
return path.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _needs_venv_rebuild(
|
||||||
|
env_python_path: Path, sentinel: Path, requirements_hash: str
|
||||||
|
) -> bool:
|
||||||
|
"""True when a penv must be (re)built.
|
||||||
|
|
||||||
|
Rebuild when the interpreter is not a regular file, which covers a
|
||||||
|
dangling symlink (a cached venv outliving a host interpreter upgrade)
|
||||||
|
and a corrupt restore, or when the sentinel is missing or stale.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
not env_python_path.is_file()
|
||||||
|
or not sentinel.exists()
|
||||||
|
or sentinel.read_text(encoding="utf-8") != requirements_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_python_env_path(version: str) -> Path:
|
def _get_python_env_path(version: str) -> Path:
|
||||||
return get_sdk_nrf_tools_path() / "penvs" / version
|
return get_sdk_nrf_tools_path() / "penvs" / version
|
||||||
|
|
||||||
@@ -198,10 +214,7 @@ def setup_platformio_python_env() -> None:
|
|||||||
+ "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode()
|
+ "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode()
|
||||||
+ f"python{sys.version_info.major}.{sys.version_info.minor}".encode()
|
+ f"python{sys.version_info.major}.{sys.version_info.minor}".encode()
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
if (
|
if _needs_venv_rebuild(env_python_path, sentinel, requirements_hash):
|
||||||
not sentinel.exists()
|
|
||||||
or sentinel.read_text(encoding="utf-8") != requirements_hash
|
|
||||||
):
|
|
||||||
rmdir(penv_path, msg="Clean up PlatformIO toolchain Python environment")
|
rmdir(penv_path, msg="Clean up PlatformIO toolchain Python environment")
|
||||||
|
|
||||||
create_venv(penv_path, msg="PlatformIO toolchain")
|
create_venv(penv_path, msg="PlatformIO toolchain")
|
||||||
@@ -250,10 +263,7 @@ def check_and_install() -> None:
|
|||||||
env_python_path = get_python_env_executable_path(python_env_path, "python")
|
env_python_path = get_python_env_executable_path(python_env_path, "python")
|
||||||
sentinel = python_env_path / ".ready"
|
sentinel = python_env_path / ".ready"
|
||||||
requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest()
|
requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest()
|
||||||
install_venv = (
|
install_venv = _needs_venv_rebuild(env_python_path, sentinel, requirements_hash)
|
||||||
not sentinel.exists()
|
|
||||||
or sentinel.read_text(encoding="utf-8") != requirements_hash
|
|
||||||
)
|
|
||||||
if install_venv:
|
if install_venv:
|
||||||
rmdir(python_env_path, msg=f"Clean up {version} Python environment")
|
rmdir(python_env_path, msg=f"Clean up {version} Python environment")
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from esphome.components.nrf52.framework import (
|
|||||||
_get_penv_site_packages,
|
_get_penv_site_packages,
|
||||||
_get_platformio_penv_path,
|
_get_platformio_penv_path,
|
||||||
_get_toolchain_platform_info,
|
_get_toolchain_platform_info,
|
||||||
|
_needs_venv_rebuild,
|
||||||
check_and_install,
|
check_and_install,
|
||||||
get_build_env,
|
get_build_env,
|
||||||
get_sdk_nrf_tools_path,
|
get_sdk_nrf_tools_path,
|
||||||
@@ -123,10 +124,19 @@ def mock_nrf52_ops():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _touch_penv_python(penv: Path) -> None:
|
||||||
|
"""Create the interpreter file so the rebuild gate sees a live venv."""
|
||||||
|
python = get_python_env_executable_path(penv, "python")
|
||||||
|
python.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
python.touch()
|
||||||
|
|
||||||
|
|
||||||
def _mark_venv_ready(python_env: Path) -> None:
|
def _mark_venv_ready(python_env: Path) -> None:
|
||||||
"""Write the venv sentinel with the current requirements hash."""
|
"""Write the venv sentinel with the current requirements hash and a
|
||||||
|
present interpreter so the rebuild gate passes."""
|
||||||
requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest()
|
requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest()
|
||||||
(python_env / ".ready").write_text(requirements_hash, encoding="utf-8")
|
(python_env / ".ready").write_text(requirements_hash, encoding="utf-8")
|
||||||
|
_touch_penv_python(python_env)
|
||||||
|
|
||||||
|
|
||||||
class TestCheckAndInstall:
|
class TestCheckAndInstall:
|
||||||
@@ -148,6 +158,23 @@ class TestCheckAndInstall:
|
|||||||
mock_nrf52_ops.download_from_mirrors.assert_not_called()
|
mock_nrf52_ops.download_from_mirrors.assert_not_called()
|
||||||
mock_nrf52_ops.archive_extract_all.assert_not_called()
|
mock_nrf52_ops.archive_extract_all.assert_not_called()
|
||||||
|
|
||||||
|
def test_missing_interpreter_rebuilds_venv(
|
||||||
|
self,
|
||||||
|
nrf52_dirs: SimpleNamespace,
|
||||||
|
mock_nrf52_ops: SimpleNamespace,
|
||||||
|
) -> None:
|
||||||
|
"""A valid sentinel must not mask a missing interpreter (a cached venv
|
||||||
|
restored after a host interpreter upgrade)."""
|
||||||
|
requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest()
|
||||||
|
(nrf52_dirs.python_env / ".ready").write_text(
|
||||||
|
requirements_hash, encoding="utf-8"
|
||||||
|
)
|
||||||
|
# no interpreter on disk
|
||||||
|
|
||||||
|
check_and_install()
|
||||||
|
|
||||||
|
mock_nrf52_ops.create_venv.assert_called_once()
|
||||||
|
|
||||||
def test_fresh_install_runs_all_steps(
|
def test_fresh_install_runs_all_steps(
|
||||||
self,
|
self,
|
||||||
nrf52_dirs: SimpleNamespace,
|
nrf52_dirs: SimpleNamespace,
|
||||||
@@ -348,6 +375,7 @@ class TestSetupPlatformioPythonEnv:
|
|||||||
(platformio_penv_dir / ".ready").write_text(
|
(platformio_penv_dir / ".ready").write_text(
|
||||||
_platformio_requirements_hash(), encoding="utf-8"
|
_platformio_requirements_hash(), encoding="utf-8"
|
||||||
)
|
)
|
||||||
|
_touch_penv_python(platformio_penv_dir)
|
||||||
|
|
||||||
with patch.dict(os.environ):
|
with patch.dict(os.environ):
|
||||||
setup_platformio_python_env()
|
setup_platformio_python_env()
|
||||||
@@ -392,6 +420,22 @@ class TestSetupPlatformioPythonEnv:
|
|||||||
|
|
||||||
assert not (platformio_penv_dir / ".ready").exists()
|
assert not (platformio_penv_dir / ".ready").exists()
|
||||||
|
|
||||||
|
def test_missing_interpreter_reinstalls(
|
||||||
|
self,
|
||||||
|
platformio_penv_dir: Path,
|
||||||
|
mock_nrf52_ops: SimpleNamespace,
|
||||||
|
) -> None:
|
||||||
|
"""A valid sentinel must not mask a missing interpreter."""
|
||||||
|
(platformio_penv_dir / ".ready").write_text(
|
||||||
|
_platformio_requirements_hash(), encoding="utf-8"
|
||||||
|
)
|
||||||
|
# no interpreter on disk
|
||||||
|
|
||||||
|
with patch.dict(os.environ):
|
||||||
|
setup_platformio_python_env()
|
||||||
|
|
||||||
|
mock_nrf52_ops.create_venv.assert_called_once()
|
||||||
|
|
||||||
def test_repeated_calls_do_not_duplicate_env_entries(
|
def test_repeated_calls_do_not_duplicate_env_entries(
|
||||||
self,
|
self,
|
||||||
platformio_penv_dir: Path,
|
platformio_penv_dir: Path,
|
||||||
@@ -401,6 +445,7 @@ class TestSetupPlatformioPythonEnv:
|
|||||||
(platformio_penv_dir / ".ready").write_text(
|
(platformio_penv_dir / ".ready").write_text(
|
||||||
_platformio_requirements_hash(), encoding="utf-8"
|
_platformio_requirements_hash(), encoding="utf-8"
|
||||||
)
|
)
|
||||||
|
_touch_penv_python(platformio_penv_dir)
|
||||||
site_packages = str(_get_penv_site_packages(platformio_penv_dir))
|
site_packages = str(_get_penv_site_packages(platformio_penv_dir))
|
||||||
bin_dir = str(
|
bin_dir = str(
|
||||||
get_python_env_executable_path(platformio_penv_dir, "python").parent
|
get_python_env_executable_path(platformio_penv_dir, "python").parent
|
||||||
@@ -422,6 +467,7 @@ class TestSetupPlatformioPythonEnv:
|
|||||||
(platformio_penv_dir / ".ready").write_text(
|
(platformio_penv_dir / ".ready").write_text(
|
||||||
_platformio_requirements_hash(), encoding="utf-8"
|
_platformio_requirements_hash(), encoding="utf-8"
|
||||||
)
|
)
|
||||||
|
_touch_penv_python(platformio_penv_dir)
|
||||||
site_packages = str(_get_penv_site_packages(platformio_penv_dir))
|
site_packages = str(_get_penv_site_packages(platformio_penv_dir))
|
||||||
|
|
||||||
with patch.dict(os.environ, {"PYTHONPATH": "/existing/path"}):
|
with patch.dict(os.environ, {"PYTHONPATH": "/existing/path"}):
|
||||||
@@ -531,3 +577,45 @@ def testget_tools_path_default_is_global_cache(
|
|||||||
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf"
|
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf"
|
||||||
).resolve()
|
).resolve()
|
||||||
assert get_sdk_nrf_tools_path() == expected
|
assert get_sdk_nrf_tools_path() == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_needs_venv_rebuild_gates(tmp_path: Path) -> None:
|
||||||
|
"""The shared penv gate rebuilds on any missing or stale piece."""
|
||||||
|
penv = tmp_path / "penv"
|
||||||
|
penv.mkdir()
|
||||||
|
python = penv / "python"
|
||||||
|
sentinel = penv / ".ready"
|
||||||
|
good_hash = "abc123"
|
||||||
|
|
||||||
|
# Nothing in place yet
|
||||||
|
assert _needs_venv_rebuild(python, sentinel, good_hash)
|
||||||
|
|
||||||
|
python.write_text("")
|
||||||
|
# Interpreter present but no sentinel
|
||||||
|
assert _needs_venv_rebuild(python, sentinel, good_hash)
|
||||||
|
|
||||||
|
sentinel.write_text(good_hash, encoding="utf-8")
|
||||||
|
# Everything in place
|
||||||
|
assert not _needs_venv_rebuild(python, sentinel, good_hash)
|
||||||
|
|
||||||
|
# Stale requirements hash
|
||||||
|
assert _needs_venv_rebuild(python, sentinel, "otherhash")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform == "win32", reason="symlink creation needs privileges on Windows"
|
||||||
|
)
|
||||||
|
def test_needs_venv_rebuild_on_dangling_interpreter_symlink(tmp_path: Path) -> None:
|
||||||
|
"""A cached venv restored after a host interpreter upgrade has a
|
||||||
|
bin/python symlink whose target is gone; the valid sentinel must not
|
||||||
|
mask it."""
|
||||||
|
penv = tmp_path / "penv"
|
||||||
|
penv.mkdir()
|
||||||
|
python = penv / "python"
|
||||||
|
sentinel = penv / ".ready"
|
||||||
|
sentinel.write_text("abc123", encoding="utf-8")
|
||||||
|
python.symlink_to(tmp_path / "hostedtoolcache" / "3.12.14" / "python3")
|
||||||
|
assert python.is_symlink()
|
||||||
|
assert not python.exists()
|
||||||
|
|
||||||
|
assert _needs_venv_rebuild(python, sentinel, "abc123")
|
||||||
|
|||||||
Reference in New Issue
Block a user