diff --git a/script/git-hooks/post-checkout b/script/git-hooks/post-checkout index 853c2b0352..73c1cb0f13 100755 --- a/script/git-hooks/post-checkout +++ b/script/git-hooks/post-checkout @@ -1,27 +1,49 @@ #!/bin/sh # Prepare the dev environment for a new checkout or worktree. # -# Installed into the git hooks directory by script/setup. Deliberately tiny and -# self-contained: it stays valid on branches where script/setup does not exist, -# and simply does nothing there. +# Installed into the git hooks directory by script/setup.py. Deliberately tiny +# and self-contained: it stays valid on branches where the setup script does not +# exist, and simply does nothing there. # $3 is 1 for a branch checkout, 0 for a file checkout. [ "$3" = "1" ] || exit 0 top=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 -# This also runs on ordinary branch switches, where there is nothing to do. +# This also runs on ordinary branch switches, where there is nothing to do. Both +# layouts are checked because git for Windows runs hooks under its own bundled +# shell, where the environment lives in venv/Scripts rather than venv/bin. [ -x "$top/venv/bin/python" ] && exit 0 -[ -x "$top/script/setup" ] || exit 0 +[ -f "$top/venv/Scripts/python.exe" ] && exit 0 + +# Branches from before the setup script moved to Python carry only the shell +# entry point, so whichever one the checked out branch has is used. +py= +if [ -f "$top/script/setup.py" ]; then + # The interpreter goes by different names across platforms, and on Windows + # "python3" is often a stub that opens the app store instead of running + # anything, so each candidate is tried before it is used. Doing nothing is the + # right outcome when none of them work. + for candidate in "python3" "python" "py -3"; do + # Unquoted on purpose: the launcher candidate is a command plus a flag. + if $candidate -c "" >/dev/null 2>&1; then + py=$candidate + break + fi + done + [ -n "$py" ] || exit 0 +elif ! [ -x "$top/script/setup" ]; then + exit 0 +fi # Every worktree shares the hooks directory of the checkout it was created -# from, and the script/setup run below is the one from whichever branch was just +# from, and the setup script run below is the one from whichever branch was just # checked out. Older branches install their own pre-commit hook without checking # for a worktree: that moves the shared hook aside as pre-commit.legacy and # replaces it with one tied to this worktree's virtual environment, so commits # break in every checkout. To rule that out, the hooks directory is copied -# before script/setup runs and put back exactly as it was afterwards, including -# removing any file script/setup added. +# before the setup script runs and put back exactly as it was afterwards, +# including removing any file the setup script added. hooks=$(git rev-parse --path-format=absolute --git-path hooks 2>/dev/null) || exit 0 snap=$(mktemp -d "$hooks/.post-checkout.XXXXXX") || exit 0 cp -p "$hooks"/* "$snap"/ 2>/dev/null @@ -29,7 +51,13 @@ cp -p "$hooks"/* "$snap"/ 2>/dev/null # Clear VIRTUAL_ENV so a checkout made from a shell with an environment already # activated still gets its own, rather than having the active one repointed at # this working tree. -env -u VIRTUAL_ENV "$top/script/setup" +unset VIRTUAL_ENV +if [ -n "$py" ]; then + # Unquoted on purpose, as above. + $py "$top/script/setup.py" +else + "$top/script/setup" +fi status=$? for f in "$hooks"/*; do diff --git a/script/setup b/script/setup index b96af6e8f3..91bcb88154 100755 --- a/script/setup +++ b/script/setup @@ -1,71 +1,7 @@ #!/usr/bin/env bash -# Set up ESPHome dev environment +# Set up ESPHome dev environment. +# +# The work is done by setup.py, which script/setup.bat also runs, so the Unix +# and Windows entry points share one implementation. -set -e - -cd "$(dirname "$0")/.." -if [ -n "$VIRTUAL_ENV" ]; then - # A virtual environment is already active (e.g. the devcontainer's pre-provisioned - # esphome-venv). Install into it rather than creating a ./venv in the workspace. - venv_state=active -elif [ -x venv/bin/python ]; then - # Reuse the environment from an earlier run, so this script can be run again - # at any time to pick up dependency changes. - venv_state=reused - source venv/bin/activate -else - venv_state=created - # --clear replaces a partial environment left behind by an interrupted run. - if [ -x "$(command -v uv)" ]; then - uv venv --clear --seed venv - else - python3 -m venv --clear venv - fi - source venv/bin/activate -fi - -if ! [ -x "$(command -v uv)" ]; then - python3 -m pip install uv -fi - -uv pip install setuptools wheel -uv pip install -e ".[dev,test]" --config-settings editable_mode=compat - -# A worktree shares one git hooks directory with the main checkout it was -# created from, so hooks are installed from the main checkout only. Installing -# from a worktree would point the shared hook at that worktree's virtual -# environment, breaking it for everyone once the worktree is removed. -git_dir="$(git rev-parse --absolute-git-dir 2>/dev/null || true)" -common_dir="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)" -if [ -n "$common_dir" ] && [ "$git_dir" = "$common_dir" ]; then - # --overwrite replaces any hook already in place. Without it, prek finds a - # previously installed pre-commit hook, moves it aside to - # .git/hooks/pre-commit.legacy and keeps calling it, so every commit would - # run both tools. - prek install --overwrite - - # Prepares the virtual environment for new checkouts and worktrees. Installed - # once here, it covers every worktree created from this checkout. - if [ -d "$common_dir/hooks" ]; then - cp script/git-hooks/post-checkout "$common_dir/hooks/post-checkout" - chmod +x "$common_dir/hooks/post-checkout" - fi -fi - -mkdir -p .temp - -echo -echo -case "$venv_state" in - created) - echo "Virtual environment created at ./venv. Run 'source venv/bin/activate' to use it." - ;; - reused) - echo "Dependencies updated in the existing ./venv. Run 'source venv/bin/activate' to use it." - ;; - active) - echo "Dependencies installed into the active virtual environment:" - echo " $VIRTUAL_ENV" - echo "It is already active in this shell, so no 'source venv/bin/activate' is needed." - ;; -esac +exec python3 "$(dirname "$0")/setup.py" "$@" diff --git a/script/setup.bat b/script/setup.bat index 809d05ae93..405121b139 100644 --- a/script/setup.bat +++ b/script/setup.bat @@ -1,28 +1 @@ -@echo off - -if defined VIRTUAL_ENV goto :install - -echo Starting the Virtual Environment -python -m venv venv -call venv/Scripts/activate -echo Running the Virtual Environment - -:install - -echo Installing required packages... - -python.exe -m pip install --upgrade pip - -pip3 install -r requirements.txt -r requirements_test.txt -r requirements_dev.txt -pip3 install setuptools wheel -pip3 install -e ".[dev,test]" --config-settings editable_mode=compat - -rem --overwrite replaces any hook already in place. Without it, prek finds a -rem previously installed pre-commit hook, moves it aside to -rem .git/hooks/pre-commit.legacy and keeps calling it, so every commit would -rem run both tools. -prek install --overwrite - -echo . -echo . -echo Virtual environment created. Run 'venv/Scripts/activate' to use it. +@python "%~dp0setup.py" %* diff --git a/script/setup.py b/script/setup.py new file mode 100755 index 0000000000..62129b8c05 --- /dev/null +++ b/script/setup.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Set up the ESPHome development environment. + +Shared implementation behind script/setup and script/setup.bat, so the Unix and +Windows entry points cannot drift apart. Uses only the standard library: it runs +before any dependency has been installed. +""" + +import os +from pathlib import Path +import shutil +import subprocess +import sys +import sysconfig + +MIN_PYTHON = (3, 12) + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_VENV = ROOT / "venv" +POST_CHECKOUT_HOOK = ROOT / "script" / "git-hooks" / "post-checkout" + +# State of the environment the dependencies end up in, used for the closing +# message. +VENV_ACTIVE = "active" +VENV_REUSED = "reused" +VENV_CREATED = "created" + + +def bin_dir(venv: Path) -> Path: + """Return the directory holding a virtual environment's executables. + + The "venv" scheme resolves to bin on Unix and Scripts on Windows, so the + layout does not have to be hardcoded here. + """ + base = str(venv) + return Path( + sysconfig.get_path("scripts", "venv", vars={"base": base, "platbase": base}) + ) + + +def venv_python(venv: Path) -> Path: + """Return the path to a virtual environment's interpreter.""" + name = "python.exe" if os.name == "nt" else "python" + return bin_dir(venv) / name + + +def run(command: list[str], env: dict[str, str] | None = None) -> None: + """Run a command, aborting the whole script if it fails.""" + print(f"+ {' '.join(command)}", flush=True) + result = subprocess.run(command, cwd=ROOT, env=env, check=False) + if result.returncode != 0: + # Some tools fail without printing anything, so name the step that broke. + print( + f"Failed with exit code {result.returncode}: {command[0]}", file=sys.stderr + ) + raise SystemExit(result.returncode) + + +def git_output(*args: str) -> str: + """Return the trimmed output of a git command, or "" if it cannot be run.""" + try: + result = subprocess.run( + ["git", *args], cwd=ROOT, capture_output=True, text=True, check=False + ) + except OSError: + # Git is not required to install the dependencies, only to install hooks. + return "" + if result.returncode != 0: + return "" + return result.stdout.strip() + + +def create_venv(venv: Path) -> None: + """Create a virtual environment, replacing anything already at the path.""" + # --clear replaces a partial environment left behind by an interrupted run. + if (uv := shutil.which("uv")) is not None: + run([uv, "venv", "--clear", "--seed", str(venv)]) + else: + run([sys.executable, "-m", "venv", "--clear", str(venv)]) + + +def venv_environment(venv: Path) -> dict[str, str]: + """Return the environment child processes need to target a virtual env. + + Equivalent to sourcing the environment's activate script: tools such as uv + and prek pick the environment up from VIRTUAL_ENV and PATH. + """ + env = dict(os.environ) + env["VIRTUAL_ENV"] = str(venv) + env.pop("PYTHONHOME", None) + path = str(bin_dir(venv)) + # An empty entry would be appended if PATH is unset, and on Unix that means + # the working directory is searched for executables. + if existing := env.get("PATH"): + path = os.pathsep.join([path, existing]) + env["PATH"] = path + return env + + +def find_uv(venv: Path, env: dict[str, str]) -> str: + """Return the path to uv, installing it into the environment if needed.""" + if (uv := shutil.which("uv", path=env["PATH"])) is not None: + return uv + run([str(venv_python(venv)), "-m", "pip", "install", "uv"], env=env) + if (uv := shutil.which("uv", path=env["PATH"])) is not None: + return uv + raise SystemExit("uv could not be installed, aborting.") + + +def install_dependencies(venv: Path, env: dict[str, str]) -> None: + """Install ESPHome and its development dependencies into the environment.""" + uv = find_uv(venv, env) + run([uv, "pip", "install", "setuptools", "wheel"], env=env) + # The dev and test extras pull in requirements_dev.txt and + # requirements_test.txt, and the package itself pulls in requirements.txt, + # so this single install covers every requirements file. + run( + [ + uv, + "pip", + "install", + "-e", + ".[dev,test]", + "--config-settings", + "editable_mode=compat", + ], + env=env, + ) + + +def install_git_hooks(env: dict[str, str]) -> None: + """Install the git hooks, but only when run from the main checkout. + + A worktree shares one git hooks directory with the main checkout it was + created from. Installing from a worktree would point the shared hook at that + worktree's virtual environment, breaking it for everyone once the worktree is + removed. + """ + git_dir = git_output("rev-parse", "--absolute-git-dir") + common_dir = git_output("rev-parse", "--path-format=absolute", "--git-common-dir") + if not git_dir or not common_dir or Path(git_dir) != Path(common_dir): + return + + prek = shutil.which("prek", path=env["PATH"]) + if prek is None: + raise SystemExit("prek was not installed, aborting.") + # --overwrite replaces any hook already in place. Without it, prek finds a + # previously installed pre-commit hook, moves it aside to + # .git/hooks/pre-commit.legacy and keeps calling it, so every commit would + # run both tools. + run([prek, "install", "--overwrite"], env=env) + + # Prepares the virtual environment for new checkouts and worktrees. Installed + # once here, it covers every worktree created from this checkout. + hooks_dir = Path(common_dir) / "hooks" + if hooks_dir.is_dir(): + installed = hooks_dir / "post-checkout" + shutil.copyfile(POST_CHECKOUT_HOOK, installed) + installed.chmod(0o755) + + +def activate_hint() -> str: + """Return the command that activates the environment this script creates.""" + activate = bin_dir(DEFAULT_VENV).relative_to(ROOT) / "activate" + if os.name == "nt": + return str(activate) + return f"source {activate.as_posix()}" + + +def report(state: str, venv: Path) -> None: + """Print the closing message for the environment that was set up.""" + location = f"./{DEFAULT_VENV.name}" + print() + print() + if state == VENV_ACTIVE: + print("Dependencies installed into the active virtual environment:") + print(f" {venv}") + print( + f"It is already active in this shell, so no '{activate_hint()}' is needed." + ) + elif state == VENV_REUSED: + print( + f"Dependencies updated in the existing {location}. " + f"Run '{activate_hint()}' to use it." + ) + else: + print( + f"Virtual environment created at {location}. " + f"Run '{activate_hint()}' to use it." + ) + + +def main() -> None: + """Set up the development environment.""" + if sys.version_info < MIN_PYTHON: + raise SystemExit( + f"ESPHome needs Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]} or newer, " + f"but this is Python {sys.version.split()[0]}." + ) + + # A virtual environment that is already active (for example the + # devcontainer's pre-provisioned esphome-venv) is installed into rather than + # creating a ./venv in the workspace. + if active := os.environ.get("VIRTUAL_ENV"): + state, venv = VENV_ACTIVE, Path(active) + elif venv_python(DEFAULT_VENV).is_file(): + # Reuse the environment from an earlier run, so this script can be run + # again at any time to pick up dependency changes. + state, venv = VENV_REUSED, DEFAULT_VENV + else: + state, venv = VENV_CREATED, DEFAULT_VENV + create_venv(venv) + + env = venv_environment(venv) + install_dependencies(venv, env) + install_git_hooks(env) + (ROOT / ".temp").mkdir(exist_ok=True) + report(state, venv) + + +if __name__ == "__main__": + main() diff --git a/tests/script/test_setup.py b/tests/script/test_setup.py new file mode 100644 index 0000000000..3e816c4b05 --- /dev/null +++ b/tests/script/test_setup.py @@ -0,0 +1,562 @@ +"""Tests for script/setup.py.""" + +import importlib.util +import os +from pathlib import Path, PurePosixPath, PureWindowsPath +import runpy +import sys +from types import ModuleType +from unittest.mock import 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_matches_host_layout(script_setup: ModuleType, tmp_path: Path) -> None: + """The venv scheme resolves to Scripts on Windows and bin everywhere else.""" + expected = "Scripts" if os.name == "nt" else "bin" + assert script_setup.bin_dir(tmp_path) == tmp_path / expected + + +# Both flavours are exercised on every host. Pure paths are used because a real +# Path refuses to change flavour: PosixPath cannot be built on Windows, and +# WindowsPath cannot be built on Unix. + + +def test_venv_python_posix(script_setup: ModuleType, tmp_path: Path) -> None: + with ( + patch.object( + script_setup, "bin_dir", return_value=PurePosixPath("/x/venv/bin") + ), + patch.object(script_setup.os, "name", "posix"), + ): + result = script_setup.venv_python(tmp_path) + assert result == PurePosixPath("/x/venv/bin/python") + + +def test_venv_python_nt(script_setup: ModuleType, tmp_path: Path) -> None: + with ( + patch.object( + script_setup, "bin_dir", return_value=PureWindowsPath(r"C:\x\venv\Scripts") + ), + patch.object(script_setup.os, "name", "nt"), + ): + result = script_setup.venv_python(tmp_path) + assert result == PureWindowsPath(r"C:\x\venv\Scripts\python.exe") + + +def test_activate_hint_posix(script_setup: ModuleType) -> None: + with ( + patch.object(script_setup, "ROOT", PurePosixPath("/x")), + patch.object( + script_setup, "bin_dir", return_value=PurePosixPath("/x/venv/bin") + ), + 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: + with ( + patch.object(script_setup, "ROOT", PureWindowsPath(r"C:\x")), + patch.object( + script_setup, "bin_dir", return_value=PureWindowsPath(r"C:\x\venv\Scripts") + ), + 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 == r"venv\Scripts\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) + # No trailing separator: an empty PATH entry means "search the cwd". + assert env["PATH"] == str(script_setup.bin_dir(venv)) + + +# --- 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() + if os.name != "nt": + # Windows has no POSIX permission bits for chmod to set. + 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)