Merge remote-tracking branch 'upstream/dev' into integration

# Conflicts:
#	esphome/__main__.py
#	esphome/components/api/client.py
#	esphome/components/bl0942/bl0942.cpp
#	esphome/components/cover/__init__.py
#	esphome/components/cover/automation.h
#	esphome/components/esphome/ota/ota_esphome.cpp
#	esphome/components/ota/ota_backend_esp_idf.cpp
#	esphome/components/ota/ota_partitions_esp_idf.cpp
#	esphome/espota2.py
#	tests/components/template/common-base.yaml
#	tests/unit_tests/components/api/test_client.py
#	tests/unit_tests/test_main.py
This commit is contained in:
J. Nick Koston
2026-05-06 16:47:31 -05:00
125 changed files with 4761 additions and 1254 deletions
+34 -1
View File
@@ -1065,7 +1065,40 @@ def convert_keys(converted, schema, path):
else:
converted["key_type"] = str(k)
if hasattr(k, "default") and str(k.default) != "...":
# ``cv.OnlyWith`` / ``cv.OnlyWithout`` expose ``default`` as
# a property that returns ``vol.UNDEFINED`` when the gating
# component isn't loaded — and at schema-generation time
# ``CORE.loaded_integrations`` is always empty, so the
# property never resolves. The unconditional default lives
# on ``_default``; expose it under a *new* per-class field
# (``default_with`` for ``OnlyWith``, ``default_without`` for
# ``OnlyWithout``) that bundles the value with the gating
# component(s). Pure addition to the bundle — old consumers
# that read only ``default`` see these fields as
# default-less (same as today, no regression where they used
# to fall back to a hard-coded UI default); new consumers
# opt-in to the gated fields and apply the default
# *conditionally* on which integrations the user has
# loaded. Without the gate info, an ethernet-only config on
# ``cv.OnlyWith(K, "wifi", default=True)`` would otherwise
# render ``True`` even though ESPHome itself wouldn't apply
# the default for that config.
if isinstance(k, (cv.OnlyWith, cv.OnlyWithout)):
default_value = k._default()
if default_value is not None:
components = (
list(k._component)
if isinstance(k._component, list)
else [k._component]
)
gate_field = (
"default_with" if isinstance(k, cv.OnlyWith) else "default_without"
)
result[gate_field] = {
"value": str(default_value),
"components": components,
}
elif hasattr(k, "default") and str(k.default) != "...":
default_value = k.default()
if default_value is not None:
result["default"] = str(default_value)
+1 -1
View File
@@ -250,7 +250,7 @@ def lint_ext_check(fname):
]
)
def lint_executable_bit(fname: Path) -> str | None:
ex = EXECUTABLE_BIT[str(fname)]
ex = EXECUTABLE_BIT[fname.as_posix()]
if ex != 100644:
return (
f"File has invalid executable bit {ex}. If running from a windows machine please "
+54
View File
@@ -10,6 +10,7 @@ what files have changed. It outputs JSON with the following structure:
"clang_tidy": true/false,
"clang_format": true/false,
"python_linters": true/false,
"device_builder": true/false,
"changed_components": ["component1", "component2", ...],
"component_test_count": 5,
"memory_impact": {
@@ -25,6 +26,7 @@ The CI workflow uses this information to:
- Skip or run clang-tidy (and whether to do a full scan)
- Skip or run clang-format
- Skip or run Python linters (ruff, flake8, pylint, pyupgrade)
- Skip or run downstream esphome/device-builder tests against the PR's Python code
- Determine which components to test individually
- Decide how to split component tests (if there are many)
- Run memory impact analysis whenever there are changed components (merged config), and also for core-only changes
@@ -440,6 +442,56 @@ def should_run_import_time(branch: str | None = None) -> bool:
return False
# Files outside esphome/**/*.py whose changes can affect the downstream
# device-builder build. requirements.txt / pyproject.toml change the runtime
# dependency graph that device-builder picks up when it installs esphome.
DEVICE_BUILDER_TRIGGER_FILES = frozenset(
{
"requirements.txt",
"pyproject.toml",
}
)
def should_run_device_builder(branch: str | None = None) -> bool:
"""Determine if downstream esphome/device-builder tests should run.
device-builder imports esphome as a library, so whenever the importable
Python surface, the runtime dependencies, or any non-C++ file packaged
with esphome (pyproject.toml has ``include-package-data = true``, so
things like esphome/idf_component.yml ship and can affect installs)
changes we re-run its test suite against the PR's code to catch
breakage we'd otherwise only see after a release.
Skipped on beta/release branches: those branches typically lag behind
device-builder@main, so a new device-builder API dependency would
falsely fail the run without reflecting any problem in the PR itself.
Args:
branch: Branch to compare against. If None, uses default.
Returns:
True if the device-builder downstream tests should run, False otherwise.
"""
target_branch = get_target_branch()
if target_branch and (
target_branch.startswith("release") or target_branch.startswith("beta")
):
return False
for file in changed_files(branch):
if file in DEVICE_BUILDER_TRIGGER_FILES:
return True
# Anything under esphome/ that isn't C++ source can change the
# importable / packaged surface device-builder consumes
# (Python sources, packaged YAML/JSON like idf_component.yml,
# etc.). C++ files only affect compiled firmware, not the
# Python install device-builder pulls in.
if file.startswith("esphome/") and not file.endswith(CPP_FILE_EXTENSIONS):
return True
return False
def determine_cpp_unit_tests(
branch: str | None = None,
) -> tuple[bool, list[str]]:
@@ -874,6 +926,7 @@ def main() -> None:
run_clang_format = should_run_clang_format(args.branch)
run_python_linters = should_run_python_linters(args.branch)
run_import_time = should_run_import_time(args.branch)
run_device_builder = should_run_device_builder(args.branch)
changed_cpp_file_count = count_changed_cpp_files(args.branch)
# Get changed components
@@ -1007,6 +1060,7 @@ def main() -> None:
"clang_format": run_clang_format,
"python_linters": run_python_linters,
"import_time": run_import_time,
"device_builder": run_device_builder,
"changed_components": changed_components,
"changed_components_with_tests": changed_components_with_tests,
"directly_changed_components_with_tests": list(directly_changed_with_tests),
+8 -1
View File
@@ -44,7 +44,14 @@ def find_and_activate_virtualenv():
def run_command():
# Execute the remaining arguments in the new environment
if len(sys.argv) > 1:
result = subprocess.run(sys.argv[1:], check=False, close_fds=False)
args = sys.argv[1:]
# Windows CreateProcess doesn't follow shebangs, so prepend the
# current interpreter when the entry is a .py script. Using
# sys.executable also pins the nested call to the same Python that
# ran us — no ambiguous PATH lookup for "python".
if args[0].endswith(".py"):
args = [sys.executable, *args]
result = subprocess.run(args, check=False, close_fds=False)
sys.exit(result.returncode)
else:
print(