mirror of
https://github.com/esphome/esphome.git
synced 2026-09-11 15:27:33 +00:00
The tests assumed a POSIX host, but pytest also runs on windows-latest. Both path flavours are now built with PurePosixPath and PureWindowsPath, which work on either host, the bin/Scripts layout is asserted against the host, and the executable bit is only checked where it exists. Also drops the trailing separator from PATH when PATH is unset, since an empty entry makes Unix search the working directory for executables.
223 lines
7.7 KiB
Python
Executable File
223 lines
7.7 KiB
Python
Executable File
#!/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()
|