mirror of
https://github.com/esphome/esphome.git
synced 2026-09-11 15:27:33 +00:00
script/setup and script/setup.bat implemented the same workflow and were kept in sync by hand, which had already drifted: the worktree hook fix in #18843 landed in the bash script only. Both are now thin wrappers around script/setup.py, so there is one implementation to maintain. The post-checkout hook also checks for a venv/Scripts layout before doing anything, so a branch switch on Windows can no longer clear a working virtual environment.
550 lines
18 KiB
Python
550 lines
18 KiB
Python
"""Tests for script/setup.py."""
|
|
|
|
import importlib.util
|
|
import os
|
|
from pathlib import Path
|
|
import runpy
|
|
import sys
|
|
from types import ModuleType
|
|
from unittest.mock import MagicMock, Mock, call, patch
|
|
|
|
import pytest
|
|
|
|
_SCRIPT = Path(__file__).parents[2] / "script" / "setup.py"
|
|
|
|
|
|
def _load_module() -> ModuleType:
|
|
spec = importlib.util.spec_from_file_location("script_setup", _SCRIPT)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
@pytest.fixture
|
|
def script_setup() -> ModuleType:
|
|
"""Fresh import of script/setup.py, isolated from other tests."""
|
|
return _load_module()
|
|
|
|
|
|
# --- bin_dir / venv_python / activate_hint -----------------------------------
|
|
|
|
|
|
def test_bin_dir_posix_layout(script_setup: ModuleType, tmp_path: Path) -> None:
|
|
"""The venv scheme resolves executables under bin/ on this host."""
|
|
assert script_setup.bin_dir(tmp_path) == tmp_path / "bin"
|
|
|
|
|
|
def test_venv_python_posix(script_setup: ModuleType, tmp_path: Path) -> None:
|
|
with patch.object(script_setup.os, "name", "posix"):
|
|
result = script_setup.venv_python(tmp_path)
|
|
assert result == tmp_path / "bin" / "python"
|
|
|
|
|
|
def test_venv_python_nt(script_setup: ModuleType, tmp_path: Path) -> None:
|
|
# A real Path cannot flip OS flavour mid-test (Python refuses to build a
|
|
# WindowsPath on a POSIX host), so bin_dir is stubbed with a MagicMock:
|
|
# only the "python.exe" vs "python" branch is under test here.
|
|
fake_bin = MagicMock()
|
|
with (
|
|
patch.object(script_setup, "bin_dir", return_value=fake_bin),
|
|
patch.object(script_setup.os, "name", "nt"),
|
|
):
|
|
script_setup.venv_python(tmp_path)
|
|
fake_bin.__truediv__.assert_called_once_with("python.exe")
|
|
|
|
|
|
def test_activate_hint_posix(script_setup: ModuleType) -> None:
|
|
with patch.object(script_setup.os, "name", "posix"):
|
|
hint = script_setup.activate_hint()
|
|
assert hint == "source venv/bin/activate"
|
|
|
|
|
|
def test_activate_hint_nt(script_setup: ModuleType) -> None:
|
|
# Same reasoning as test_venv_python_nt: stub bin_dir with a MagicMock so
|
|
# no real Path is built while os.name is patched to "nt".
|
|
fake_bin = MagicMock()
|
|
fake_bin.relative_to.return_value.__truediv__.return_value = (
|
|
"venv\\Scripts\\activate"
|
|
)
|
|
with (
|
|
patch.object(script_setup, "bin_dir", return_value=fake_bin),
|
|
patch.object(script_setup.os, "name", "nt"),
|
|
):
|
|
hint = script_setup.activate_hint()
|
|
# The nt branch returns str(activate) as-is, skipping the "source " prefix.
|
|
assert hint == "venv\\Scripts\\activate"
|
|
fake_bin.relative_to.assert_called_once_with(script_setup.ROOT)
|
|
fake_bin.relative_to.return_value.__truediv__.assert_called_once_with("activate")
|
|
|
|
|
|
# --- run -----------------------------------------------------------------
|
|
|
|
|
|
def test_run_success(script_setup: ModuleType) -> None:
|
|
with patch.object(
|
|
script_setup.subprocess, "run", return_value=Mock(returncode=0)
|
|
) as mock_run:
|
|
script_setup.run(["echo", "hi"])
|
|
mock_run.assert_called_once_with(
|
|
["echo", "hi"], cwd=script_setup.ROOT, env=None, check=False
|
|
)
|
|
|
|
|
|
def test_run_failure_raises_system_exit_with_code(
|
|
script_setup: ModuleType, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
with (
|
|
patch.object(script_setup.subprocess, "run", return_value=Mock(returncode=7)),
|
|
pytest.raises(SystemExit) as excinfo,
|
|
):
|
|
script_setup.run(["false"])
|
|
assert excinfo.value.code == 7
|
|
assert "Failed with exit code 7: false" in capsys.readouterr().err
|
|
|
|
|
|
# --- git_output ------------------------------------------------------------
|
|
|
|
|
|
def test_git_output_success_strips_stdout(script_setup: ModuleType) -> None:
|
|
with patch.object(
|
|
script_setup.subprocess,
|
|
"run",
|
|
return_value=Mock(returncode=0, stdout=" /repo/.git \n"),
|
|
) as mock_run:
|
|
result = script_setup.git_output("rev-parse", "--absolute-git-dir")
|
|
assert result == "/repo/.git"
|
|
mock_run.assert_called_once_with(
|
|
["git", "rev-parse", "--absolute-git-dir"],
|
|
cwd=script_setup.ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
|
|
def test_git_output_nonzero_returncode_is_empty(script_setup: ModuleType) -> None:
|
|
with patch.object(
|
|
script_setup.subprocess,
|
|
"run",
|
|
return_value=Mock(returncode=1, stdout="whatever"),
|
|
):
|
|
assert script_setup.git_output("status") == ""
|
|
|
|
|
|
def test_git_output_oserror_is_empty(script_setup: ModuleType) -> None:
|
|
with patch.object(script_setup.subprocess, "run", side_effect=OSError("no git")):
|
|
assert script_setup.git_output("status") == ""
|
|
|
|
|
|
# --- create_venv -----------------------------------------------------------
|
|
|
|
|
|
def test_create_venv_uses_uv_when_present(
|
|
script_setup: ModuleType, tmp_path: Path
|
|
) -> None:
|
|
venv = tmp_path / "venv"
|
|
with (
|
|
patch.object(script_setup.shutil, "which", return_value="/usr/bin/uv"),
|
|
patch.object(
|
|
script_setup.subprocess, "run", return_value=Mock(returncode=0)
|
|
) as mock_run,
|
|
):
|
|
script_setup.create_venv(venv)
|
|
mock_run.assert_called_once_with(
|
|
["/usr/bin/uv", "venv", "--clear", "--seed", str(venv)],
|
|
cwd=script_setup.ROOT,
|
|
env=None,
|
|
check=False,
|
|
)
|
|
|
|
|
|
def test_create_venv_falls_back_to_venv_module(
|
|
script_setup: ModuleType, tmp_path: Path
|
|
) -> None:
|
|
venv = tmp_path / "venv"
|
|
with (
|
|
patch.object(script_setup.shutil, "which", return_value=None),
|
|
patch.object(
|
|
script_setup.subprocess, "run", return_value=Mock(returncode=0)
|
|
) as mock_run,
|
|
):
|
|
script_setup.create_venv(venv)
|
|
mock_run.assert_called_once_with(
|
|
[sys.executable, "-m", "venv", "--clear", str(venv)],
|
|
cwd=script_setup.ROOT,
|
|
env=None,
|
|
check=False,
|
|
)
|
|
|
|
|
|
# --- venv_environment --------------------------------------------------------
|
|
|
|
|
|
def test_venv_environment_sets_virtual_env_and_prepends_path(
|
|
script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
venv = tmp_path / "venv"
|
|
monkeypatch.setenv("PYTHONHOME", "/somewhere")
|
|
monkeypatch.setenv("PATH", "/usr/bin:/bin")
|
|
env = script_setup.venv_environment(venv)
|
|
assert env["VIRTUAL_ENV"] == str(venv)
|
|
assert "PYTHONHOME" not in env
|
|
expected_prefix = str(script_setup.bin_dir(venv)) + os.pathsep
|
|
assert env["PATH"] == expected_prefix + "/usr/bin:/bin"
|
|
|
|
|
|
def test_venv_environment_path_fallback_when_unset(
|
|
script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
venv = tmp_path / "venv"
|
|
monkeypatch.delenv("PATH", raising=False)
|
|
env = script_setup.venv_environment(venv)
|
|
assert env["PATH"] == str(script_setup.bin_dir(venv)) + os.pathsep
|
|
|
|
|
|
# --- find_uv -----------------------------------------------------------------
|
|
|
|
|
|
def test_find_uv_found_immediately(script_setup: ModuleType, tmp_path: Path) -> None:
|
|
venv = tmp_path / "venv"
|
|
env = {"PATH": "/usr/bin"}
|
|
with (
|
|
patch.object(script_setup.shutil, "which", return_value="/usr/bin/uv"),
|
|
patch.object(script_setup.subprocess, "run") as mock_run,
|
|
):
|
|
result = script_setup.find_uv(venv, env)
|
|
assert result == "/usr/bin/uv"
|
|
mock_run.assert_not_called()
|
|
|
|
|
|
def test_find_uv_installed_then_found(script_setup: ModuleType, tmp_path: Path) -> None:
|
|
venv = tmp_path / "venv"
|
|
env = {"PATH": "/usr/bin"}
|
|
with (
|
|
patch.object(script_setup.shutil, "which", side_effect=[None, "/usr/bin/uv"]),
|
|
patch.object(
|
|
script_setup.subprocess, "run", return_value=Mock(returncode=0)
|
|
) as mock_run,
|
|
):
|
|
result = script_setup.find_uv(venv, env)
|
|
assert result == "/usr/bin/uv"
|
|
mock_run.assert_called_once_with(
|
|
[str(script_setup.venv_python(venv)), "-m", "pip", "install", "uv"],
|
|
cwd=script_setup.ROOT,
|
|
env=env,
|
|
check=False,
|
|
)
|
|
|
|
|
|
def test_find_uv_still_missing_raises_system_exit(
|
|
script_setup: ModuleType, tmp_path: Path
|
|
) -> None:
|
|
venv = tmp_path / "venv"
|
|
env = {"PATH": "/usr/bin"}
|
|
with (
|
|
patch.object(script_setup.shutil, "which", side_effect=[None, None]),
|
|
patch.object(script_setup.subprocess, "run", return_value=Mock(returncode=0)),
|
|
pytest.raises(SystemExit, match="uv could not be installed"),
|
|
):
|
|
script_setup.find_uv(venv, env)
|
|
|
|
|
|
# --- install_dependencies -----------------------------------------------------
|
|
|
|
|
|
def test_install_dependencies_installs_setuptools_then_project(
|
|
script_setup: ModuleType, tmp_path: Path
|
|
) -> None:
|
|
venv = tmp_path / "venv"
|
|
env = {"PATH": "/usr/bin"}
|
|
with (
|
|
patch.object(script_setup.shutil, "which", return_value="/usr/bin/uv"),
|
|
patch.object(
|
|
script_setup.subprocess, "run", return_value=Mock(returncode=0)
|
|
) as mock_run,
|
|
):
|
|
script_setup.install_dependencies(venv, env)
|
|
assert mock_run.call_args_list == [
|
|
call(
|
|
["/usr/bin/uv", "pip", "install", "setuptools", "wheel"],
|
|
cwd=script_setup.ROOT,
|
|
env=env,
|
|
check=False,
|
|
),
|
|
call(
|
|
[
|
|
"/usr/bin/uv",
|
|
"pip",
|
|
"install",
|
|
"-e",
|
|
".[dev,test]",
|
|
"--config-settings",
|
|
"editable_mode=compat",
|
|
],
|
|
cwd=script_setup.ROOT,
|
|
env=env,
|
|
check=False,
|
|
),
|
|
]
|
|
|
|
|
|
# --- install_git_hooks ---------------------------------------------------------
|
|
|
|
|
|
def _fake_git_output(git_dir: str, common_dir: str):
|
|
def _run(*args: str) -> str:
|
|
if "--absolute-git-dir" in args:
|
|
return git_dir
|
|
return common_dir
|
|
|
|
return _run
|
|
|
|
|
|
def test_install_git_hooks_returns_early_when_git_dir_empty(
|
|
script_setup: ModuleType,
|
|
) -> None:
|
|
env = {"PATH": "/usr/bin"}
|
|
with (
|
|
patch.object(
|
|
script_setup, "git_output", side_effect=_fake_git_output("", "/repo/.git")
|
|
),
|
|
patch.object(script_setup.subprocess, "run") as mock_run,
|
|
):
|
|
script_setup.install_git_hooks(env)
|
|
mock_run.assert_not_called()
|
|
|
|
|
|
def test_install_git_hooks_returns_early_when_common_dir_empty(
|
|
script_setup: ModuleType,
|
|
) -> None:
|
|
env = {"PATH": "/usr/bin"}
|
|
with (
|
|
patch.object(
|
|
script_setup, "git_output", side_effect=_fake_git_output("/repo/.git", "")
|
|
),
|
|
patch.object(script_setup.subprocess, "run") as mock_run,
|
|
):
|
|
script_setup.install_git_hooks(env)
|
|
mock_run.assert_not_called()
|
|
|
|
|
|
def test_install_git_hooks_returns_early_for_worktree(
|
|
script_setup: ModuleType,
|
|
) -> None:
|
|
"""A worktree's git-dir differs from the shared common-dir."""
|
|
env = {"PATH": "/usr/bin"}
|
|
with (
|
|
patch.object(
|
|
script_setup,
|
|
"git_output",
|
|
side_effect=_fake_git_output("/repo/.git/worktrees/wt", "/repo/.git"),
|
|
),
|
|
patch.object(script_setup.subprocess, "run") as mock_run,
|
|
):
|
|
script_setup.install_git_hooks(env)
|
|
mock_run.assert_not_called()
|
|
|
|
|
|
def test_install_git_hooks_missing_prek_raises_system_exit(
|
|
script_setup: ModuleType,
|
|
) -> None:
|
|
env = {"PATH": "/usr/bin"}
|
|
with (
|
|
patch.object(
|
|
script_setup,
|
|
"git_output",
|
|
side_effect=_fake_git_output("/repo/.git", "/repo/.git"),
|
|
),
|
|
patch.object(script_setup.shutil, "which", return_value=None),
|
|
patch.object(script_setup.subprocess, "run") as mock_run,
|
|
pytest.raises(SystemExit, match="prek was not installed"),
|
|
):
|
|
script_setup.install_git_hooks(env)
|
|
mock_run.assert_not_called()
|
|
|
|
|
|
def test_install_git_hooks_happy_path_installs_hook(
|
|
script_setup: ModuleType, tmp_path: Path
|
|
) -> None:
|
|
env = {"PATH": "/usr/bin"}
|
|
common_dir = tmp_path / "repo" / ".git"
|
|
hooks_dir = common_dir / "hooks"
|
|
hooks_dir.mkdir(parents=True)
|
|
source_hook = tmp_path / "post-checkout"
|
|
source_hook.write_text("#!/bin/sh\necho post-checkout\n")
|
|
|
|
with (
|
|
patch.object(script_setup, "POST_CHECKOUT_HOOK", source_hook),
|
|
patch.object(
|
|
script_setup,
|
|
"git_output",
|
|
side_effect=_fake_git_output(str(common_dir), str(common_dir)),
|
|
),
|
|
patch.object(script_setup.shutil, "which", return_value="/usr/bin/prek"),
|
|
patch.object(
|
|
script_setup.subprocess, "run", return_value=Mock(returncode=0)
|
|
) as mock_run,
|
|
):
|
|
script_setup.install_git_hooks(env)
|
|
|
|
mock_run.assert_called_once_with(
|
|
["/usr/bin/prek", "install", "--overwrite"],
|
|
cwd=script_setup.ROOT,
|
|
env=env,
|
|
check=False,
|
|
)
|
|
installed = hooks_dir / "post-checkout"
|
|
assert installed.read_text() == source_hook.read_text()
|
|
assert (installed.stat().st_mode & 0o777) == 0o755
|
|
|
|
|
|
def test_install_git_hooks_skips_copy_when_hooks_dir_missing(
|
|
script_setup: ModuleType, tmp_path: Path
|
|
) -> None:
|
|
"""The prek install still runs when the hooks directory does not exist."""
|
|
env = {"PATH": "/usr/bin"}
|
|
common_dir = tmp_path / "repo" / ".git"
|
|
common_dir.mkdir(parents=True) # no "hooks" subdirectory created
|
|
|
|
with (
|
|
patch.object(
|
|
script_setup,
|
|
"git_output",
|
|
side_effect=_fake_git_output(str(common_dir), str(common_dir)),
|
|
),
|
|
patch.object(script_setup.shutil, "which", return_value="/usr/bin/prek"),
|
|
patch.object(
|
|
script_setup.subprocess, "run", return_value=Mock(returncode=0)
|
|
) as mock_run,
|
|
):
|
|
script_setup.install_git_hooks(env)
|
|
|
|
mock_run.assert_called_once()
|
|
assert not (common_dir / "hooks").exists()
|
|
|
|
|
|
# --- report ------------------------------------------------------------------
|
|
|
|
|
|
def test_report_active_state(
|
|
script_setup: ModuleType, capsys: pytest.CaptureFixture
|
|
) -> None:
|
|
venv = Path("/opt/esphome-venv")
|
|
script_setup.report(script_setup.VENV_ACTIVE, venv)
|
|
out = capsys.readouterr().out
|
|
assert "Dependencies installed into the active virtual environment:" in out
|
|
assert str(venv) in out
|
|
assert "is already active in this shell" in out
|
|
|
|
|
|
def test_report_reused_state(
|
|
script_setup: ModuleType, capsys: pytest.CaptureFixture
|
|
) -> None:
|
|
script_setup.report(script_setup.VENV_REUSED, script_setup.DEFAULT_VENV)
|
|
out = capsys.readouterr().out
|
|
assert "Dependencies updated in the existing ./venv" in out
|
|
|
|
|
|
def test_report_created_state(
|
|
script_setup: ModuleType, capsys: pytest.CaptureFixture
|
|
) -> None:
|
|
script_setup.report(script_setup.VENV_CREATED, script_setup.DEFAULT_VENV)
|
|
out = capsys.readouterr().out
|
|
assert "Virtual environment created at ./venv" in out
|
|
|
|
|
|
# --- main --------------------------------------------------------------------
|
|
|
|
|
|
def test_main_raises_system_exit_when_python_too_old(
|
|
script_setup: ModuleType,
|
|
) -> None:
|
|
with (
|
|
patch.object(script_setup.sys, "version_info", (3, 11, 5)),
|
|
pytest.raises(SystemExit, match="ESPHome needs Python 3.12"),
|
|
):
|
|
script_setup.main()
|
|
|
|
|
|
def test_main_uses_active_virtual_env(
|
|
script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
active_venv = tmp_path / "active-venv"
|
|
monkeypatch.setenv("VIRTUAL_ENV", str(active_venv))
|
|
with (
|
|
patch.object(script_setup, "ROOT", tmp_path),
|
|
patch.object(script_setup, "create_venv") as mock_create_venv,
|
|
patch.object(script_setup, "install_dependencies") as mock_install_deps,
|
|
patch.object(script_setup, "install_git_hooks") as mock_install_hooks,
|
|
patch.object(script_setup, "report") as mock_report,
|
|
):
|
|
script_setup.main()
|
|
mock_create_venv.assert_not_called()
|
|
mock_install_deps.assert_called_once()
|
|
mock_install_hooks.assert_called_once()
|
|
mock_report.assert_called_once_with(script_setup.VENV_ACTIVE, active_venv)
|
|
assert (tmp_path / ".temp").is_dir()
|
|
|
|
|
|
def test_main_reuses_existing_venv(
|
|
script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
|
|
default_venv = tmp_path / "venv"
|
|
python_path = script_setup.venv_python(default_venv)
|
|
python_path.parent.mkdir(parents=True)
|
|
python_path.touch()
|
|
|
|
with (
|
|
patch.object(script_setup, "ROOT", tmp_path),
|
|
patch.object(script_setup, "DEFAULT_VENV", default_venv),
|
|
patch.object(script_setup, "create_venv") as mock_create_venv,
|
|
patch.object(script_setup, "install_dependencies") as mock_install_deps,
|
|
patch.object(script_setup, "install_git_hooks") as mock_install_hooks,
|
|
patch.object(script_setup, "report") as mock_report,
|
|
):
|
|
script_setup.main()
|
|
mock_create_venv.assert_not_called()
|
|
mock_install_deps.assert_called_once()
|
|
mock_install_hooks.assert_called_once()
|
|
mock_report.assert_called_once_with(script_setup.VENV_REUSED, default_venv)
|
|
assert (tmp_path / ".temp").is_dir()
|
|
|
|
|
|
def test_main_creates_new_venv(
|
|
script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
|
|
default_venv = tmp_path / "venv" # does not exist yet
|
|
|
|
with (
|
|
patch.object(script_setup, "ROOT", tmp_path),
|
|
patch.object(script_setup, "DEFAULT_VENV", default_venv),
|
|
patch.object(script_setup, "create_venv") as mock_create_venv,
|
|
patch.object(script_setup, "install_dependencies") as mock_install_deps,
|
|
patch.object(script_setup, "install_git_hooks") as mock_install_hooks,
|
|
patch.object(script_setup, "report") as mock_report,
|
|
):
|
|
script_setup.main()
|
|
mock_create_venv.assert_called_once_with(default_venv)
|
|
mock_install_deps.assert_called_once()
|
|
mock_install_hooks.assert_called_once()
|
|
mock_report.assert_called_once_with(script_setup.VENV_CREATED, default_venv)
|
|
assert (tmp_path / ".temp").is_dir()
|
|
|
|
|
|
def test_run_as_script_calls_main(tmp_path: Path) -> None:
|
|
"""The __main__ guard runs the whole flow, with every side effect stubbed."""
|
|
completed = Mock(returncode=0, stdout="")
|
|
with (
|
|
patch("subprocess.run", return_value=completed) as mock_run,
|
|
patch("shutil.which", return_value="/usr/bin/uv"),
|
|
patch("pathlib.Path.mkdir") as mock_mkdir,
|
|
patch.dict(os.environ, {"VIRTUAL_ENV": str(tmp_path / "env")}),
|
|
):
|
|
runpy.run_path(str(_SCRIPT), run_name="__main__")
|
|
|
|
# The dependency install ran, and git reported no hooks directory to touch.
|
|
assert mock_run.called
|
|
mock_mkdir.assert_called_once_with(exist_ok=True)
|