Merge remote-tracking branch 'origin/dev' into jesserockz-2026-627

# Conflicts:
#	script/git-hooks/post-checkout
This commit is contained in:
Jesse Hills
2026-09-08 20:06:19 +12:00
545 changed files with 19835 additions and 2912 deletions
+164 -1
View File
@@ -294,6 +294,9 @@ def highlight(s):
"esphome/components/socket/headers.h",
"esphome/core/defines.h",
"esphome/components/http_request/httplib.h",
# Shared C wire header (byte-identical with the co-processor firmware);
# these are protocol constants and constexpr is C++-only.
"esphome/components/esp32_hosted/esp_now_hosted_rpc.h",
],
)
def lint_no_defines(fname, match):
@@ -319,6 +322,154 @@ def lint_no_long_delays(fname, match):
)
# An if/else/for/while whose only body is an unbraced ESP_LOG*() call. When the build's compile-time
# log level drops that macro, the body expands to nothing and the compiler warns (-Wempty-body).
# clang-tidy's brace check does not catch these (ShortStatementLines allows short unbraced bodies), so
# this fills that gap. Matched against comment/string-masked content, so commented-out or quoted code
# is ignored. Both spellings are covered: core/log.h defines the uppercase ESP_LOG*() macros and
# the lowercase esph_log_*() ones, and both expand to nothing below their log level.
# 'for' allows ';' inside its parentheses (the classic C-style header); 'if'/'while' do not, so their
# condition cannot run past the statement it guards. The 'for' header permits one level of nested
# parens so it stays bounded to its own statement: without that, it can run past the loop body and
# latch onto a later ')', mis-reporting the line and skipping the '#' preprocessor check below.
ESP_LOG_NEEDS_BRACES_RE = re.compile(
r"(?:\bif\s*\([^{};]*\)|\bwhile\s*\([^{};]*\)|\bfor\s*\((?:[^{}()]|\([^{}()]*\))*\)|\belse\b)"
r"[ \t]*\n?[ \t]*(?:ESP_LOG[A-Z]*|esph_log_[a-z]+)\s*\(",
re.MULTILINE,
)
def _mask_cpp_comments_strings(s):
"""Return s with // and /* */ comments and string/char/raw-string literals blanked to spaces
(length and newlines preserved) so a regex only matches real code. Parentheses in real code are
kept, so callers can still balance them on the masked text."""
out = list(s)
i = 0
n = len(s)
while i < n:
c = s[i]
# Raw string literal: an optional encoding prefix, then R"delim( ... )delim". The body may
# contain quotes, //, /* and unbalanced parens, so it must be consumed as one unit.
if c == "R" and i + 1 < n and s[i + 1] == '"':
j = i + 2
delim = ""
while j < n and s[j] not in "( \t\r\n\\" and len(delim) < 16:
delim += s[j]
j += 1
if j < n and s[j] == "(":
closing = ")" + delim + '"'
end = s.find(closing, j + 1)
end = n if end == -1 else end + len(closing)
for k in range(i, end):
if s[k] != "\n":
out[k] = " "
i = end
continue
i += 1
elif c == "/" and i + 1 < n and s[i + 1] == "/":
while i < n and s[i] != "\n":
out[i] = " "
i += 1
elif c == "/" and i + 1 < n and s[i + 1] == "*":
out[i] = out[i + 1] = " "
i += 2
while i < n and not (s[i] == "*" and i + 1 < n and s[i + 1] == "/"):
if s[i] != "\n":
out[i] = " "
i += 1
if i < n:
out[i] = " "
if i + 1 < n:
out[i + 1] = " "
i += 2
# A "'" after an alphanumeric or '_' is a C++ digit separator (1'000), not a literal opener.
elif c == '"' or (
c == "'" and not (i and (s[i - 1].isalnum() or s[i - 1] == "_"))
):
quote = c
out[i] = " "
i += 1
while i < n:
if s[i] == "\\":
out[i] = " "
if i + 1 < n:
out[i + 1] = " "
i += 2
continue
if s[i] == quote:
out[i] = " "
i += 1
break
if s[i] != "\n":
out[i] = " "
i += 1
else:
i += 1
return "".join(out)
def _log_statement_end(masked, open_paren):
"""Index of the ';' ending the ESP_LOG call whose '(' is at open_paren, or None. Balanced on the
masked text so quotes/comments inside the arguments do not confuse the paren count."""
depth = 0
i = open_paren
n = len(masked)
while i < n:
ch = masked[i]
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
j = i + 1
while j < n and masked[j] != ";":
if not masked[j].isspace():
return None
j += 1
return j if j < n else None
i += 1
return None
@lint_content_check(include=cpp_include)
def lint_esp_log_needs_braces(fname, content):
# Cheap bailout: no log call means nothing to flag, and skips masking the file entirely.
if "ESP_LOG" not in content and "esph_log_" not in content:
return []
masked = _mask_cpp_comments_strings(content)
errors = []
for match in ESP_LOG_NEEDS_BRACES_RE.finditer(masked):
pos = match.start()
line_start = content.rfind("\n", 0, pos) + 1
# Skip preprocessor conditionals (#if/#else/#elif): not C++ control statements.
if content[line_start:pos].lstrip().startswith("#"):
continue
# A '// NOLINT' may sit at the end of the log line (where the message says to put it) or on the
# control-statement line, so scan the whole statement rather than only up to the ESP_LOG token.
stmt_end = _log_statement_end(masked, match.end() - 1)
nolint_end = (
content.find("\n", stmt_end) if stmt_end is not None else match.end()
)
if nolint_end == -1:
nolint_end = len(content)
if "NOLINT" in content[pos:nolint_end]:
continue
snippet = content[pos : match.end()].replace("\n", " ").strip()
errors.append(
(
content.count("\n", 0, pos) + 1,
pos - line_start + 1,
(
f"{highlight(snippet)} - an if/else/for/while body that is a single log "
"call must be wrapped in braces. When the log level compiles the macro out, the "
"body becomes empty and the compiler warns (-Wempty-body). Add { } around the "
"log call (or a '// NOLINT' comment if this is genuinely intended)."
),
)
)
return errors
@lint_content_check(
include=[
"esphome/const.py",
@@ -668,6 +819,10 @@ def lint_relative_py_import(fname: Path, line, col, content):
"esphome/components/host/helpers.cpp",
"esphome/components/zephyr/helpers.cpp",
"esphome/components/http_request/httplib.h",
# Global extern "C" esp_now_* linker symbols + shared C wire header;
# neither can live in a C++ namespace.
"esphome/components/esp32_hosted/esp_now_hosted.cpp",
"esphome/components/esp32_hosted/esp_now_hosted_rpc.h",
],
)
def lint_namespace(fname: Path, content: str) -> str | None:
@@ -693,7 +848,15 @@ def lint_esphome_h(fname, line, col, content):
)
@lint_content_check(include=["*.h"], exclude=["esphome/core/entity_types.h"])
@lint_content_check(
include=["*.h"],
exclude=[
"esphome/core/entity_types.h",
# Shared C wire header; uses a classic #ifndef guard for portability
# across the co-processor firmware repo it stays byte-identical with.
"esphome/components/esp32_hosted/esp_now_hosted_rpc.h",
],
)
def lint_pragma_once(fname, content):
if "#pragma once" not in content:
return (
+48 -9
View File
@@ -1,13 +1,10 @@
"""Files that affect clang-tidy results, and a content hash over them.
"""Files that affect clang-tidy results and the idedata built from them.
``CLANG_TIDY_GLOBAL_FILES`` (plus ``SDKCONFIG_DEFAULTS_PREFIX``) is the single
source of truth for which files influence clang-tidy output. A change to any of
them can surface warnings in source files a PR didn't touch, so:
* ``script/determine-jobs.py`` runs a full clang-tidy scan when one changes, and
* ``calculate_clang_tidy_hash()`` folds them into the idedata cache key used by
``script/helpers.py`` (a content hash, unlike an mtime check, stays correct
across git checkouts).
``CLANG_TIDY_GLOBAL_FILES`` (plus ``SDKCONFIG_DEFAULTS_PREFIX``) lists the files
that influence clang-tidy output; ``script/determine-jobs.py`` runs a full scan
when one changes. ``ESP_IDF_INFRA_TRIGGER_*`` lists the native ESP-IDF build
code. ``idedata_cache_hash()`` folds the right set into the idedata cache key
used by ``script/helpers.py`` and the CI cache action.
"""
from __future__ import annotations
@@ -31,6 +28,18 @@ CLANG_TIDY_GLOBAL_FILES = (
# this prefix at the repo root.
SDKCONFIG_DEFAULTS_PREFIX = "sdkconfig.defaults"
# Native ESP-IDF build infra: determine-jobs forces an esp32 compile when these
# change, and they feed the clang-tidy idedata cache key.
ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES = ("esphome/espidf/", "esphome/build_helpers/")
ESP_IDF_INFRA_TRIGGER_FILES = frozenset(
{
"esphome/build_gen/espidf.py",
"esphome/framework_helpers.py",
"esphome/platformio/library.py",
"esphome/platformio/extra_script.py",
}
)
def read_file_bytes(path: Path) -> bytes:
"""Read bytes from a file."""
@@ -66,3 +75,33 @@ def calculate_clang_tidy_hash(repo_root: Path | None = None) -> str:
hasher.update(read_file_bytes(path))
return hasher.hexdigest()
def calculate_idedata_cache_hash(repo_root: Path | None = None) -> str:
"""Clang-tidy hash plus the Python that generates the idedata."""
repo_root = _ensure_repo_root(repo_root)
hasher = hashlib.sha256()
hasher.update(calculate_clang_tidy_hash(repo_root).encode())
paths = {repo_root / name for name in ESP_IDF_INFRA_TRIGGER_FILES}
for prefix in ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES:
# .pyc files appear between the CI key computation and load_idedata's.
paths.update(
path
for path in (repo_root / prefix).rglob("*")
if "__pycache__" not in path.parts
)
for path in sorted(paths):
if path.is_file():
hasher.update(str(path.relative_to(repo_root)).encode())
hasher.update(read_file_bytes(path))
return hasher.hexdigest()
def idedata_cache_hash(environment: str, repo_root: Path | None = None) -> str:
"""Hash gating the cached idedata of one clang-tidy environment."""
if "esp32" in environment:
return calculate_idedata_cache_hash(repo_root)
return calculate_clang_tidy_hash(repo_root)
+59 -47
View File
@@ -53,16 +53,25 @@ from collections import Counter
from enum import StrEnum
from functools import cache
import json
import math
import os
from pathlib import Path
import statistics
import sys
from typing import Any
from clang_tidy_hash import CLANG_TIDY_GLOBAL_FILES, SDKCONFIG_DEFAULTS_PREFIX
from clang_tidy_hash import (
CLANG_TIDY_GLOBAL_FILES,
ESP_IDF_INFRA_TRIGGER_FILES,
ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES,
SDKCONFIG_DEFAULTS_PREFIX,
)
from helpers import (
CPP_FILE_EXTENSIONS,
ESPHOME_TESTS_COMPONENTS_PATH,
INTEGRATION_TESTS_PATH,
PYTHON_FILE_EXTENSIONS,
all_integration_test_files,
base_python_changed,
changed_files,
core_changed,
@@ -78,6 +87,8 @@ from helpers import (
get_target_branch,
git_ls_files,
is_validate_only_file,
load_integration_durations,
lpt_partition,
root_path,
)
from split_components_for_ci import create_intelligent_batches
@@ -91,24 +102,24 @@ CLANG_TIDY_SPLIT_THRESHOLD = 65
# Isolated components count as 10x, groupable components count as 1x
COMPONENT_TEST_BATCH_SIZE = 40
# Integration test bucketing: when more than the threshold tests are scheduled,
# fan out across this many parallel jobs. Below the threshold, a single job runs.
# Above the threshold, fan out across up to this many jobs, balanced by the
# recorded per-file durations. The target is serial junit-time weight per
# bucket, not wall time (calibrated with the conftest compile cap); it
# sizes the bucket count for small subsets.
INTEGRATION_TESTS_SPLIT_THRESHOLD = 10
INTEGRATION_TESTS_SPLIT_BUCKETS = 3
INTEGRATION_TESTS_SPLIT_BUCKETS = 5
INTEGRATION_TESTS_TARGET_BUCKET_WEIGHT = 360.0
def _split_list(items: list[str], n: int) -> list[list[str]]:
"""Split a list into n roughly-equal contiguous parts (matches script/clang-tidy)."""
k, m = divmod(len(items), n)
return [items[i * k + min(i, m) : (i + 1) * k + min(i + 1, m)] for i in range(n)]
def _all_integration_test_files() -> list[str]:
"""Return all integration test file paths, sorted, relative to repo root."""
return sorted(
str(p.relative_to(root_path))
for p in (Path(root_path) / "tests" / "integration").glob("test_*.py")
)
# platformio and aioesphomeapi (requirements.txt), the pytest stack
# (requirements_test.txt) and the fixture every session compiles; a change
# to any runs the full matrix
INTEGRATION_TESTS_TRIGGER_FILES = frozenset(
{
"requirements.txt",
"requirements_test.txt",
"tests/integration/fixtures/cache_init.yaml",
}
)
def _compute_integration_test_buckets(
@@ -117,7 +128,7 @@ def _compute_integration_test_buckets(
) -> tuple[bool, list[dict[str, Any]]]:
"""Compute (run_integration, buckets) from the determine_integration_tests result.
Pure function for unit testing — no I/O beyond `_all_integration_test_files`
Pure function for unit testing — no I/O beyond `all_integration_test_files`
when `integration_run_all` is set.
`buckets` is a list of `{name, tests}` dicts where `tests` is a JSON-friendly
@@ -125,7 +136,7 @@ def _compute_integration_test_buckets(
shell word-splitting / glob hazards.
"""
if integration_run_all:
files = _all_integration_test_files()
files = all_integration_test_files()
else:
files = sorted(integration_test_files)
@@ -136,12 +147,23 @@ def _compute_integration_test_buckets(
return False, []
if len(files) > INTEGRATION_TESTS_SPLIT_THRESHOLD:
parts = [
part for part in _split_list(files, INTEGRATION_TESTS_SPLIT_BUCKETS) if part
]
durations = load_integration_durations()
# Unrecorded files weigh the recording's median; with no recording a
# file weighs a whole bucket, which keeps the full fan-out
default = (
statistics.median(durations.values())
if durations
else INTEGRATION_TESTS_TARGET_BUCKET_WEIGHT
)
weights = {f: durations.get(f, default) for f in files}
count = min(
INTEGRATION_TESTS_SPLIT_BUCKETS,
math.ceil(sum(weights.values()) / INTEGRATION_TESTS_TARGET_BUCKET_WEIGHT),
)
# count <= SPLIT_BUCKETS < threshold < len(files): no group is empty
parts = [sorted(part) for part in lpt_partition(files, weights, count)]
buckets = [
{"name": f"{i + 1}/{len(parts)}", "tests": part}
for i, part in enumerate(parts)
{"name": f"{i + 1}/{count}", "tests": part} for i, part in enumerate(parts)
]
else:
buckets = [{"name": "1/1", "tests": files}]
@@ -216,12 +238,15 @@ def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[s
3. Integration test infrastructure files changed
- conftest.py, types.py, const.py, entity_utils.py, state_utils.py, etc.
4. A file in INTEGRATION_TESTS_TRIGGER_FILES changed
- The dependency pins and the session init fixture affect every test
Returns (run_all=False, [test_files...]) when:
4. Specific integration test files changed
5. Specific integration test files changed
- Only those specific test files are returned
5. Components used by integration tests (or their dependencies) changed
6. Components used by integration tests (or their dependencies) changed
- Only test files whose fixtures use the changed components are returned
Args:
@@ -239,12 +264,15 @@ def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[s
# If any core files changed, run all integration tests
return (True, [])
if any(f in INTEGRATION_TESTS_TRIGGER_FILES for f in files):
return (True, [])
# If infrastructure Python files changed (conftest, utils, etc.), run all tests
# Excludes test files (test_*.py), fixtures, and non-Python files (README.md)
if any(
f.startswith("tests/integration/")
f.startswith(INTEGRATION_TESTS_PATH)
and f.endswith(".py")
and not f.startswith("tests/integration/test_")
and not f.startswith(f"{INTEGRATION_TESTS_PATH}test_")
and "/fixtures/" not in f
for f in files
):
@@ -255,9 +283,9 @@ def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[s
fixture_to_test_files = get_fixture_to_test_files()
for f in files:
if f.startswith("tests/integration/test_") and f.endswith(".py"):
if f.startswith(f"{INTEGRATION_TESTS_PATH}test_") and f.endswith(".py"):
test_files.add(f)
elif f.startswith("tests/integration/fixtures/"):
elif f.startswith(f"{INTEGRATION_TESTS_PATH}fixtures/"):
if f.endswith(".yaml"):
# Fixture YAML changed - add corresponding test file(s)
test_files.update(fixture_to_test_files.get(Path(f).stem, ()))
@@ -524,23 +552,6 @@ def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool:
return False
# Native-build infra: changes under esphome/espidf/, the shared
# esphome/build_helpers/ package, or the modules the native ESP-IDF build
# imports affect every esp32 IDF build (now the default toolchain) but aren't
# components, so the component matrix wouldn't otherwise force any esp32
# compile. When they change we fold the `esp32` component into the matrix so
# the default native-IDF build path is still compiled on an infra-only PR.
ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES = ("esphome/espidf/", "esphome/build_helpers/")
ESP_IDF_INFRA_TRIGGER_FILES = frozenset(
{
"esphome/build_gen/espidf.py",
"esphome/framework_helpers.py",
"esphome/platformio/library.py",
"esphome/platformio/extra_script.py",
}
)
def _esp_idf_infra_changed(files: list[str]) -> bool:
"""Whether any changed file is ESP-IDF build/runner infrastructure."""
for file in files:
@@ -1410,6 +1421,7 @@ def main() -> None:
output: dict[str, Any] = {
"core_ci": run_core_ci,
"integration_tests": run_integration,
"integration_run_all": integration_run_all,
"integration_test_buckets": integration_test_buckets,
"clang_tidy": run_clang_tidy,
"clang_tidy_mode": clang_tidy_mode,
+48 -14
View File
@@ -16,23 +16,57 @@ top=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
[ -x "$top/venv/bin/python" ] && exit 0
[ -f "$top/venv/Scripts/python.exe" ] && exit 0
[ -f "$top/script/setup.py" ] || 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.
unset VIRTUAL_ENV
if [ -n "$py" ]; then
# Unquoted on purpose, as above.
$py "$top/script/setup.py"
else
"$top/script/setup"
fi
status=$?
# 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.
try_setup() {
"$@" -c "" >/dev/null 2>&1 || return 1
exec "$@" "$top/script/setup.py"
}
try_setup python3
try_setup python
try_setup py -3
exit 0
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
+69 -7
View File
@@ -43,6 +43,53 @@ ESPHOME_TESTS_COMPONENTS_PATH = "tests/components/"
# Tuple of component and test paths for efficient startswith checks
COMPONENT_AND_TESTS_PATHS = (ESPHOME_COMPONENTS_PATH, ESPHOME_TESTS_COMPONENTS_PATH)
# Integration tests path prefix
INTEGRATION_TESTS_PATH = "tests/integration/"
# Per-file integration test durations from CI junit output; shared by the
# reader (determine-jobs) and writer (update_integration_test_durations)
INTEGRATION_TEST_DURATIONS_FILE = "tests/integration/integration_test_durations.json"
def all_integration_test_files() -> list[str]:
"""Return all integration test file paths, sorted, relative to repo root."""
return sorted(
p.relative_to(root_path).as_posix()
for p in (Path(root_path) / "tests" / "integration").glob("test_*.py")
)
def load_integration_durations() -> dict[str, float]:
"""Return recorded per-file pytest durations in seconds; empty when unavailable."""
try:
raw = json.loads(
(Path(root_path) / INTEGRATION_TEST_DURATIONS_FILE).read_text()
)
if not isinstance(raw, dict):
print(
f"integration durations unavailable: expected an object, "
f"got {type(raw).__name__}",
file=sys.stderr,
)
return {}
except (OSError, ValueError) as err:
# The file ships in the repo; degrade to unweighted bucketing, loudly
print(f"integration durations unavailable: {err}", file=sys.stderr)
return {}
durations = {
key: seconds
for key, value in raw.items()
if isinstance(value, (int, float)) and (seconds := float(value)) > 0
}
if len(durations) != len(raw):
# One bad entry must not discard the whole recording
print(
f"dropped {len(raw) - len(durations)} invalid duration entries",
file=sys.stderr,
)
return durations
# Base bus components - these ARE the bus implementations and should not
# be flagged as needing migration since they are the platform/base components
BASE_BUS_COMPONENTS = {
@@ -809,17 +856,14 @@ def load_idedata(environment: str) -> dict[str, Any]:
start_time = time.time()
print(f"Loading IDE data for environment '{environment}'...")
# Reuse the clang-tidy input hash as the cache key: it already covers every
# file baked into the generated idedata (platformio.ini, sdkconfig.defaults,
# esphome/idf_component.yml), so this can't drift from that file list. A
# content hash -- unlike an mtime comparison -- stays correct across git
# checkouts, which don't preserve mtimes.
from clang_tidy_hash import calculate_clang_tidy_hash
# Content hash of the idedata inputs (data files and the generator code); a
# content hash, unlike mtimes, stays correct across git checkouts.
from clang_tidy_hash import idedata_cache_hash
temp_idedata = Path(temp_folder) / f"idedata-{environment}.json"
temp_hash = Path(temp_folder) / f"idedata-{environment}.hash"
cache_key = calculate_clang_tidy_hash()
cache_key = idedata_cache_hash(environment)
changed = (
not temp_idedata.is_file()
or not temp_hash.is_file()
@@ -1548,3 +1592,21 @@ def get_cpp_changed_components(files: list[str]) -> list[str]:
if file.startswith(ESPHOME_COMPONENTS_PATH):
affected.update(find_children_of_component(components_graph, component))
return sorted(c for c in affected if has_cpp_unit_tests(c, tests_dir))
def lpt_partition(
items: list[str], weights: dict[str, float], count: int
) -> list[list[str]]:
"""Partition items into `count` weight-balanced groups (LPT greedy).
Heaviest item first into the lightest group. Ties keep input order, so
pass pre-sorted items for deterministic output. script/clang-tidy's
split_list is the unweighted contiguous sibling.
"""
groups: list[list[str]] = [[] for _ in range(count)]
group_weights = [0.0] * count
for item in sorted(items, key=lambda i: -weights[i]):
lightest = min(range(count), key=group_weights.__getitem__)
groups[lightest].append(item)
group_weights[lightest] += weights[item]
return groups
+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())
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Merge CI junit output into tests/integration/integration_test_durations.json.
The integration-tests CI job uploads one junit XML artifact per bucket on
full matrix dev runs. Download a run's artifacts and merge the per file
durations into the recording used by script/determine-jobs.py:
gh run download <run-id> --repo esphome/esphome -p "junit-integration-*" -D /tmp/junit
script/update_integration_test_durations.py /tmp/junit
Missing files keep their previous recording and deleted files drop out; a
run covering under 90% of the test files aborts unless --allow-partial.
"""
from __future__ import annotations
import argparse
from collections import defaultdict
import json
from pathlib import Path
import sys
import xml.etree.ElementTree as ET
from helpers import (
INTEGRATION_TEST_DURATIONS_FILE,
INTEGRATION_TESTS_PATH,
all_integration_test_files,
load_integration_durations,
root_path,
)
DURATIONS_FILE = Path(root_path) / INTEGRATION_TEST_DURATIONS_FILE
MIN_COVERAGE = 0.9
# Exit code for the expected "run covers too few files" refusal, so the
# refresh workflow can move on to the next candidate run
EXIT_LOW_COVERAGE = 3
def collect_durations(junit_dir: Path, known_files: set[str]) -> dict[str, float]:
"""Sum junit testcase times per integration test file, in seconds."""
durations: defaultdict[str, float] = defaultdict(float)
unmatched = 0
xml_files = sorted(junit_dir.rglob("*.xml"))
if not xml_files:
raise SystemExit(f"no junit XML files found under {junit_dir}")
for xml_file in xml_files:
for testcase in ET.parse(xml_file).getroot().iter("testcase"):
# Skipped/errored testcases carry time="0"; recording them would
# overwrite a good previous duration
if any(
testcase.find(tag) is not None
for tag in ("skipped", "error", "failure")
):
continue
# classname is the dotted module plus any test class, e.g.
# tests.integration.test_x or tests.integration.test_x.TestFoo
parts = testcase.get("classname", "").split(".")
if parts[:2] != ["tests", "integration"] or len(parts) < 3:
unmatched += 1
continue
path = f"{INTEGRATION_TESTS_PATH}{parts[2]}.py"
if path not in known_files:
print(f"skipping unknown test module {path}", file=sys.stderr)
continue
durations[path] += float(testcase.get("time", "0"))
if unmatched:
# A junit naming change would otherwise shrink the recording silently
raise SystemExit(
f"{unmatched} testcases with unexpected classnames; the junit layout changed"
)
# An all-skipped file totals 0.0; let the merge keep its previous entry
return {k: v for k, v in durations.items() if v > 0}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"junit_dir", type=Path, help="directory containing downloaded junit XML files"
)
parser.add_argument(
"--allow-partial",
action="store_true",
help="merge a run covering under 90%% of the test files",
)
args = parser.parse_args()
on_disk = set(all_integration_test_files())
if not on_disk:
raise SystemExit("no integration test files found; wrong checkout root?")
collected = collect_durations(args.junit_dir, on_disk)
coverage = len(collected.keys() & on_disk) / len(on_disk)
if coverage < MIN_COVERAGE and not args.allow_partial:
print(
f"artifacts cover only {coverage:.0%} of {len(on_disk)} test files; "
"use a full matrix run or pass --allow-partial to merge anyway",
file=sys.stderr,
)
return EXIT_LOW_COVERAGE
# Validated load: a bad previous entry cannot survive the round trip, and
# an unreadable file aborts rather than being overwritten
previous = load_integration_durations()
if DURATIONS_FILE.is_file() and not previous:
raise SystemExit(f"{DURATIONS_FILE} is unreadable; refusing to overwrite it")
# New recordings win, absent files keep theirs, deleted files drop out
merged = {
path: collected.get(path, previous.get(path))
for path in sorted(on_disk)
if path in collected or path in previous
}
DURATIONS_FILE.write_text(
json.dumps({k: round(v, 2) for k, v in merged.items()}, indent=2) + "\n"
)
print(f"wrote {len(merged)} entries to {DURATIONS_FILE} ({coverage:.0%} fresh)")
return 0
if __name__ == "__main__":
sys.exit(main())