mirror of
https://github.com/esphome/esphome.git
synced 2026-09-11 15:27:33 +00:00
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()
|