Merge branch 'api-null-string-defaults' into api-decode-without-vtable

This commit is contained in:
J. Nick Koston
2026-09-10 19:23:47 -05:00
140 changed files with 4425 additions and 1845 deletions
+58 -6
View File
@@ -1,20 +1,72 @@
#!/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 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 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
# 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.
exec 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
[ -e "$snap/${f##*/}" ] || rm -f "$f"
done
# Files are moved rather than copied so a hook that is still running, such as
# this one, is swapped out atomically instead of being rewritten in place.
for f in "$snap"/*; do
cmp -s "$f" "$hooks/${f##*/}" 2>/dev/null || mv -f "$f" "$hooks/${f##*/}"
done
rm -rf "$snap"
exit $status
+17
View File
@@ -1104,6 +1104,10 @@ def get_components_per_integration_fixture() -> dict[str, set[str]]:
_TEST_FUNC_RE = re.compile(r"async def (test_\w+)")
# Any usage form (decorator, pytestmark assignment or list element); only
# test_*.py files are scanned, so the marker docs elsewhere cannot false-hit
_SHARED_YAML_USE_RE = re.compile(r"\bmark\.shared_yaml")
_SHARED_YAML_ARG_RE = re.compile(r"\(\s*[\"'](\w+)[\"']\s*\)")
@cache
@@ -1123,6 +1127,19 @@ def get_fixture_to_test_files() -> dict[str, frozenset[str]]:
for func in _TEST_FUNC_RE.findall(content):
base_name = func.replace("test_", "").partition("[")[0]
result.setdefault(base_name, set()).add(rel_path)
# Shared fixtures are named by marker, not by a test function; each
# decorator must carry a string literal or its fixture would silently
# map to no tests
for use in _SHARED_YAML_USE_RE.finditer(content):
arg = _SHARED_YAML_ARG_RE.match(content, use.end())
if arg is None:
line = content.count("\n", 0, use.start()) + 1
raise ValueError(
f"{rel_path}:{line}: shared_yaml marker must take a "
"single-line string literal so CI test selection can map "
"its fixture"
)
result.setdefault(arg.group(1), set()).add(rel_path)
return {k: frozenset(v) for k, v in result.items()}
+5 -69
View File
@@ -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" "$@"
+1 -28
View File
@@ -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" %*
+222
View File
@@ -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()
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Keep pre-commit hook revs in sync with the requirements files.
Dependabot only bumps the ``package==version`` pins in ``requirements*.txt``.
Some of those tools are pinned a second time as hook ``rev`` values in
``.pre-commit-config.yaml``. This script treats the requirements files as
the source of truth and rewrites the revs to match, editing the config
through yamlrocks so comments and layout survive.
Run without arguments to apply the changes in place, or with ``--check`` to
only report drift (exit status 1 when anything is out of sync).
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from pathlib import Path
import re
import sys
from typing import Any
import yamlrocks
REPO_ROOT = Path(__file__).resolve().parent.parent
PRECOMMIT_CONFIG = ".pre-commit-config.yaml"
class SyncError(Exception):
"""A pin could not be located in a requirements file or the config."""
@dataclass(frozen=True)
class SyncTarget:
"""A requirements pin and the pre-commit repo whose rev mirrors it."""
package: str
requirements_file: str
repo: str
SYNC_TARGETS: tuple[SyncTarget, ...] = (
SyncTarget(
"ruff", "requirements_test.txt", "https://github.com/astral-sh/ruff-pre-commit"
),
SyncTarget("flake8", "requirements_test.txt", "https://github.com/PyCQA/flake8"),
SyncTarget(
"pyupgrade", "requirements_test.txt", "https://github.com/asottile/pyupgrade"
),
SyncTarget(
"clang-format",
"requirements_dev.txt",
"https://github.com/pre-commit/mirrors-clang-format",
),
SyncTarget(
"yamllint",
"requirements_dev.txt",
"https://github.com/adrienverge/yamllint.git",
),
)
def read_requirement_version(requirements: str, package: str) -> str | None:
"""Return the ``==`` pin for ``package`` or None when it is not pinned."""
pattern = re.compile(
rf"^{re.escape(package)}==(?P<version>[^\s#]+)",
re.MULTILINE | re.IGNORECASE,
)
match = pattern.search(requirements)
return match.group("version") if match else None
def find_repo_entry(doc: Any, repo: str) -> Any:
"""Return the single ``- repo:`` block for ``repo`` in a pre-commit doc."""
try:
entries = [entry for entry in doc["repos"] if entry["repo"] == repo]
except KeyError as err:
raise SyncError(f"malformed pre-commit config, missing key {err}") from None
if len(entries) != 1:
raise SyncError(
f"expected exactly one block for repo {repo}, found {len(entries)}"
)
return entries[0]
def current_rev(entry: Any, repo: str) -> tuple[str, str]:
"""Split the block's rev into its tag prefix (``v`` or empty) and version."""
if "rev" not in entry:
raise SyncError(f"repo {repo} has no rev")
rev = entry["rev"]
if not isinstance(rev, str):
# A rev such as ``1.0`` parses as a number and cannot be compared or
# rewritten safely; quote it in the config instead.
raise SyncError(f"rev of repo {repo} is not a string: {rev!r}")
prefix = "v" if rev.startswith("v") else ""
return prefix, rev.removeprefix("v")
def sync(root: Path, *, write: bool) -> list[str]:
"""Bring every hook rev in line with its requirements pin.
Returns one description per rev that was (or, when ``write`` is False,
would be) changed. Raises SyncError when a pin cannot be found, which
means SYNC_TARGETS has gone stale and needs updating by hand.
"""
config_path = root / PRECOMMIT_CONFIG
doc = yamlrocks.loads(config_path.read_bytes(), option=yamlrocks.OPT_ROUND_TRIP)
requirements: dict[str, str] = {}
changes: list[str] = []
for target in SYNC_TARGETS:
if target.requirements_file not in requirements:
requirements[target.requirements_file] = (
root / target.requirements_file
).read_text()
version = read_requirement_version(
requirements[target.requirements_file], target.package
)
if version is None:
raise SyncError(
f"{target.requirements_file}: no '{target.package}==' pin found"
)
entry = find_repo_entry(doc, target.repo)
prefix, current = current_rev(entry, target.repo)
if current == version:
continue
changes.append(f"{target.package}: {current} -> {version}")
entry["rev"] = f"{prefix}{version}"
if changes and write:
config_path.write_bytes(doc.to_yaml())
return changes
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--check",
action="store_true",
help="report drift without modifying any file; exit 1 if out of sync",
)
parser.add_argument(
"--root",
type=Path,
default=REPO_ROOT,
help="repository checkout to operate on (default: this checkout)",
)
args = parser.parse_args(argv)
try:
changes = sync(args.root, write=not args.check)
except SyncError as err:
print(f"error: {err}", file=sys.stderr)
return 1
for change in changes:
print(change)
if args.check and changes:
return 1
return 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())