Merge branch 'dev' into codex/pr15983-idf-reproducible

This commit is contained in:
J. Nick Koston
2026-04-26 04:01:08 -05:00
committed by GitHub
27 changed files with 625 additions and 22 deletions
@@ -1491,3 +1491,133 @@ def test_substitute_package_definition_includes_source_location(tmp_path: Path)
line, col = int(match.group(1)), int(match.group(2))
assert line == 2, f"expected 1-based line 2, got {line} (err={err!r})"
assert col >= 1, f"expected 1-based column ≥ 1, got {col} (err={err!r})"
def test_substitute_package_definition_vars_preserved_literally() -> None:
"""``vars:`` blocks in remote-package files are not substituted prematurely.
Variable references inside ``vars:`` may resolve to substitutions
contributed by sibling packages that have not yet been loaded, so they
must be passed through untouched and resolved later by the package YAML.
"""
pkg = {
CONF_URL: "https://github.com/esphome/non-existant-repo",
CONF_REF: "main",
CONF_FILES: [
{
CONF_PATH: "common/somefile.yaml",
CONF_VARS: {"pin": "${PIN}"},
},
],
}
# Note: PIN is intentionally NOT in the context — it is meant to
# be resolved later, when the package YAML is processed.
result = _substitute_package_definition(pkg, ContextVars())
assert result[CONF_FILES][0][CONF_VARS] == {"pin": "${PIN}"}
def test_substitute_package_definition_other_fields_still_substituted() -> None:
"""Marking ``vars:`` literal does not stop substitution of url/ref/path."""
ctx = ContextVars({"branch": "release", "org": "esphome"})
pkg = {
CONF_URL: "https://github.com/${org}/firmware",
CONF_REF: "${branch}",
CONF_FILES: [
{
CONF_PATH: "common/sensor.yaml",
CONF_VARS: {"pin": "${PIN}"},
},
],
}
result = _substitute_package_definition(pkg, ctx)
assert result[CONF_URL] == "https://github.com/esphome/firmware"
assert result[CONF_REF] == "release"
# vars passed through unchanged
assert result[CONF_FILES][0][CONF_VARS] == {"pin": "${PIN}"}
def test_substitute_package_definition_without_vars_unaffected() -> None:
"""Files entries without a ``vars:`` block continue to work."""
ctx = ContextVars({"branch": "main"})
pkg = {
CONF_URL: "https://github.com/esphome/firmware",
CONF_REF: "${branch}",
CONF_FILES: [
{CONF_PATH: "file1.yaml"},
"file2.yaml",
],
}
result = _substitute_package_definition(pkg, ctx)
assert result[CONF_REF] == "main"
assert result[CONF_FILES][0] == {CONF_PATH: "file1.yaml"}
assert result[CONF_FILES][1] == "file2.yaml"
@patch("esphome.yaml_util.load_yaml")
@patch("pathlib.Path.is_file")
@patch("esphome.git.clone_or_update")
def test_remote_package_vars_resolved_against_sibling_package_substitutions(
mock_clone_or_update, mock_is_file, mock_load_yaml
) -> None:
"""A ``vars:`` reference in one remote package can resolve to a
substitution defined in a sibling remote package.
A higher-priority package declares ``substitutions:`` (e.g. ``SENSOR_PIN: 5``) and a
lower-priority package's ``files: -> vars:`` references that substitution.
Because packages are processed highest-priority first and ``vars:`` is now
preserved literally during package-definition processing, the substitution
is resolved correctly when the package YAML itself is loaded.
"""
mock_clone_or_update.return_value = (Path("/tmp/noexists"), MagicMock())
mock_is_file.return_value = True
# Two YAML files mocked from the "remote" repo:
# - platform.yaml exports a substitution ``SENSOR_PIN``
# - sensor.yaml uses ``${pin}`` (which is bound from ``vars:`` to
# ``${SENSOR_PIN}`` and resolved against the merged substitutions).
mock_load_yaml.side_effect = [
# Order matches reverse-priority traversal (highest priority first).
OrderedDict(
{
CONF_SUBSTITUTIONS: {"SENSOR_PIN": "GPIO5"},
}
),
OrderedDict(
{
CONF_SENSOR: [
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_1,
"pin": "${pin}",
}
],
}
),
]
config = {
CONF_PACKAGES: {
"special_sensor": {
CONF_URL: "https://github.com/esphome/non-existant-repo",
CONF_FILES: [
{
CONF_PATH: "sensor.yaml",
CONF_VARS: {"pin": "${SENSOR_PIN}"},
},
],
CONF_REFRESH: "1d",
},
"platform": {
CONF_URL: "https://github.com/esphome/non-existant-repo",
CONF_FILES: ["platform.yaml"],
CONF_REFRESH: "1d",
},
}
}
actual = packages_pass(config)
assert actual[CONF_SENSOR][0]["pin"] == "GPIO5"
@@ -0,0 +1,18 @@
remote_receiver:
id: rf_receiver
pin: ${rx_pin}
# Test radio_frequency platform with receiver
radio_frequency:
# RF 900MHz receiver
- platform: ir_rf_proxy
id: rf_900_rx
name: "RF 900 Receiver"
frequency: 900 MHz
remote_receiver_id: rf_receiver
# RF receiver (no frequency specified)
- platform: ir_rf_proxy
id: rf_rx
name: "RF Receiver"
remote_receiver_id: rf_receiver
@@ -0,0 +1,19 @@
remote_transmitter:
id: rf_transmitter
pin: ${tx_pin}
carrier_duty_percent: 100%
# Test radio_frequency platform with transmitter
radio_frequency:
# RF 433MHz transmitter
- platform: ir_rf_proxy
id: rf_433_tx
name: "RF 433 Transmitter"
frequency: 433 MHz
remote_transmitter_id: rf_transmitter
# RF transmitter (no frequency specified)
- platform: ir_rf_proxy
id: rf_tx
name: "RF Transmitter"
remote_transmitter_id: rf_transmitter
@@ -0,0 +1,7 @@
network:
wifi:
ssid: MySSID
password: password1
api:
@@ -0,0 +1,8 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
packages:
common: !include common.yaml
rx: !include common-rx.yaml
tx: !include common-tx.yaml
@@ -0,0 +1,8 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
packages:
common: !include common.yaml
rx: !include common-rx.yaml
tx: !include common-tx.yaml
@@ -0,0 +1,8 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
packages:
common: !include common.yaml
rx: !include common-rx.yaml
tx: !include common-tx.yaml
@@ -0,0 +1,8 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
packages:
common: !include common.yaml
rx: !include common-rx.yaml
tx: !include common-tx.yaml
+5
View File
@@ -29,3 +29,8 @@ output:
rtttl:
output: rtttl_output
on_finished_playback:
- then:
- logger.log: "Playback finished 1"
- then:
- logger.log: "Playback finished 2"
+58
View File
@@ -1744,6 +1744,64 @@ def test_proc_on_exit_skips_when_already_closed() -> None:
handler.close.assert_not_called()
@pytest.mark.asyncio
async def test_esphome_logs_handler_appends_no_states_when_set() -> None:
"""Test --no-states is appended when no_states is truthy in the message."""
handler = Mock(spec=web_server.EsphomeLogsHandler)
handler.build_device_command = AsyncMock(
return_value=["esphome", "logs", "device.yaml", "--device", "OTA"]
)
json_message = {
"configuration": "device.yaml",
"port": "OTA",
"no_states": True,
}
cmd = await web_server.EsphomeLogsHandler.build_command(handler, json_message)
assert cmd == [
"esphome",
"logs",
"device.yaml",
"--device",
"OTA",
"--no-states",
]
handler.build_device_command.assert_awaited_once_with(["logs"], json_message)
@pytest.mark.asyncio
async def test_esphome_logs_handler_omits_no_states_when_missing() -> None:
"""Test --no-states is not added when no_states is absent from the message."""
handler = Mock(spec=web_server.EsphomeLogsHandler)
handler.build_device_command = AsyncMock(
return_value=["esphome", "logs", "device.yaml", "--device", "OTA"]
)
cmd = await web_server.EsphomeLogsHandler.build_command(
handler, {"configuration": "device.yaml", "port": "OTA"}
)
assert "--no-states" not in cmd
assert cmd == ["esphome", "logs", "device.yaml", "--device", "OTA"]
@pytest.mark.asyncio
async def test_esphome_logs_handler_omits_no_states_when_false() -> None:
"""Test --no-states is not added when no_states is explicitly False."""
handler = Mock(spec=web_server.EsphomeLogsHandler)
handler.build_device_command = AsyncMock(
return_value=["esphome", "logs", "device.yaml", "--device", "OTA"]
)
cmd = await web_server.EsphomeLogsHandler.build_command(
handler,
{"configuration": "device.yaml", "port": "OTA", "no_states": False},
)
assert "--no-states" not in cmd
def _make_auth_handler(auth_header: str | None = None) -> Mock:
"""Create a mock handler with the given Authorization header."""
handler = Mock()
+16
View File
@@ -1842,6 +1842,22 @@ def test_should_run_benchmarks_core_header_change() -> None:
assert determine_jobs.should_run_benchmarks() is True
def test_should_run_benchmarks_host_platform_change() -> None:
"""Test benchmarks trigger on host platform changes.
Benchmarks build and run on the host platform, so changes to its
millis()/micros()/etc. implementations affect every benchmark.
"""
for host_file in [
"esphome/components/host/core.cpp",
"esphome/components/host/__init__.py",
]:
with patch.object(determine_jobs, "changed_files", return_value=[host_file]):
assert determine_jobs.should_run_benchmarks() is True, (
f"Expected benchmarks to run for {host_file}"
)
def test_should_run_benchmarks_benchmark_infra_change() -> None:
"""Test benchmarks trigger on benchmark infrastructure changes."""
for infra_file in [
+61 -1
View File
@@ -11,7 +11,13 @@ from esphome.config_helpers import Extend, Remove
import esphome.config_validation as cv
from esphome.core import DocumentLocation, DocumentRange, EsphomeError
from esphome.util import OrderedDict
from esphome.yaml_util import ESPHomeDataBase, format_path, make_data_base
from esphome.yaml_util import (
ESPHomeDataBase,
ESPLiteralValue,
format_path,
make_data_base,
make_literal,
)
@pytest.fixture(autouse=True)
@@ -891,3 +897,57 @@ def test_format_path_empty_path_with_located_current_obj():
obj = _located("${var}", "main.yaml", 0, 0)
result = format_path([], obj)
assert result == "In: in main.yaml 1:1"
def test_make_literal_wraps_dict() -> None:
"""A dict is wrapped so it becomes an ESPLiteralValue instance."""
value = {"key": "${var}"}
result = make_literal(value)
assert isinstance(result, ESPLiteralValue)
assert isinstance(result, dict)
assert result == {"key": "${var}"}
def test_make_literal_wraps_list() -> None:
"""A list is wrapped so it becomes an ESPLiteralValue instance."""
value = ["${var}", "plain"]
result = make_literal(value)
assert isinstance(result, ESPLiteralValue)
assert isinstance(result, list)
assert result == ["${var}", "plain"]
def test_make_literal_wraps_string() -> None:
"""A string is wrapped so it becomes an ESPLiteralValue instance."""
result = make_literal("${var}")
assert isinstance(result, ESPLiteralValue)
assert result == "${var}"
def test_make_literal_returns_already_wrapped_value_unchanged() -> None:
"""Wrapping a value that is already an ESPLiteralValue returns it as-is."""
value = make_literal({"key": "value"})
assert isinstance(value, ESPLiteralValue)
result = make_literal(value)
assert result is value
def test_make_literal_returns_none_unchanged() -> None:
"""Values whose class cannot be augmented (e.g. ``None``) are returned as-is."""
result = make_literal(None)
assert result is None
def test_make_literal_blocks_substitution() -> None:
"""A value wrapped with make_literal is skipped by the substitution pass."""
value = make_literal({"pin": "${PIN}"})
result = substitutions.substitute(
value,
path=[],
parent_context=substitutions.ContextVars(),
strict_undefined=False,
)
# The literal block must remain untouched, even though the variable is
# undefined in the context.
assert result == {"pin": "${PIN}"}
assert isinstance(result, ESPLiteralValue)