Merge branch 'dev' into component-8byte-optimization

This commit is contained in:
J. Nick Koston
2026-03-29 08:25:57 -10:00
committed by GitHub
337 changed files with 7663 additions and 4481 deletions
+18
View File
@@ -248,6 +248,24 @@ def test_area_id_hash_collision(
)
def test_area_singular_hash_collision(
yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str]
) -> None:
"""Test that area hash collisions between singular area: and areas: list are detected."""
result = load_config_from_fixture(
yaml_file, "area_singular_hash_collision.yaml", FIXTURES_DIR
)
assert result is None
captured = capsys.readouterr()
assert (
"Area ID 'd6ka' with hash 3082558663 collides with existing area ID 'test_2258'"
in captured.out
)
# Error path should point to 'areas' (where the colliding entry is), not 'area'
assert "areas" in captured.out
def test_device_duplicate_id(
yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str]
) -> None:
@@ -0,0 +1,10 @@
esphome:
name: test
area:
id: test_2258
name: "Area 1"
areas:
- id: d6ka
name: "Area 2"
host:
@@ -38,3 +38,20 @@ test_list:
- '{ 79, 82 }'
- a: 15 should be 15, overridden from command line
b: 20 should stay as 20, not overridden
- aa:
- 1
- 2
- 3
- 4
- 5
- 6
bb:
- 7
- 8
- 9
- aa:
x: 1
y: 3
z: 4
bb:
w: 5
@@ -44,3 +44,13 @@ test_list:
- '{ ${position.x}, ${position.y} }'
- a: ${a} should be 15, overridden from command line
b: ${b} should stay as 20, not overridden
# Test merging lists when substituted keys resolve to an existing key
- ${ "aa" }: [1, 2, 3]
${ "a" + "a" }: [4, 5, 6]
${ "bb" }: [7, 8, 9]
# Test merging dicts when substituted keys resolve to an existing key
- ${ "aa" }: {"x": 1, "y": 2}
${ "a" + "a" }: {"y": 3, "z": 4}
${ "bb" }: {"w": 5}
@@ -9,6 +9,11 @@ substitutions:
numberOne: 1
var1: 79
double_width: 14
double_height: 16
y: ${x}
x: ${y}
b: 79
c: 80
test_list:
- The area is 56
- 56
@@ -27,3 +32,4 @@ test_list:
- chr(97) = a
- len([1,2,3]) = 3
- width = 7, double_width = 14
- a = ${a}
@@ -1,4 +1,7 @@
substitutions:
y: ${x} # Circular reference, expect to pass unresolved.
x: ${y} # Circular reference, expect to pass unresolved.
double_height: ${height * 2}
width: 7
height: 8
enabled: true
@@ -9,6 +12,8 @@ substitutions:
numberOne: 1
var1: 79
double_width: ${width * 2}
c: ${b+1}
b: ${undefined_variable | default(79) }
test_list:
- "The area is ${width * height}"
@@ -25,3 +30,4 @@ test_list:
- chr(97) = ${ chr(97) }
- len([1,2,3]) = ${ len([1,2,3]) }
- width = ${width}, double_width = ${double_width}
- a = ${a}
@@ -1,7 +1,3 @@
substitutions:
x: 10
y: 20
z: 30
values_from_repo1_main:
- package_name: package1
x: 3
@@ -28,3 +24,20 @@ values_from_repo1_main:
y: 20
z: 5
volume: 1000
- package_name: package6
x: 12
y: 13
z: 5
volume: 780
- package_name: default
x: 10
y: 20
z: 5
volume: 1000
substitutions:
x: 10
y: 20
z: 30
my_repo: repo1
my_file: file1
my_ref: main
@@ -2,16 +2,26 @@ substitutions:
x: 10
y: 20
z: 30
my_repo: default_repo
my_file: default_file
my_ref: main
# The following key is only used by the test framework
# to simulate command line substitutions
command_line_substitutions:
my_repo: repo1
my_file: file1
packages:
package1:
url: https://github.com/esphome/repo1
ref: main
files:
- path: file1.yaml
vars:
package_name: package1
x: 3
y: 4
ref: main
package2: !include # a package that just includes the given remote package
file: remote_package_proxy.yaml
vars:
@@ -41,3 +51,13 @@ packages:
repo: repo1
file: file1.yaml
ref: main
package6:
url: https://github.com/esphome/${my_repo}
ref: ${my_ref}
files:
- path: ${my_file + ".yaml"}
vars:
package_name: package6
x: 12
y: 13
package7: github://esphome/${my_repo}/${my_file + ".yaml"}@${my_ref}
@@ -0,0 +1,44 @@
fancy_component: &id001
- id: component9
value: 9
some_component:
- id: component1
value: 1
- id: component2
value: 2
- id: component3
value: 3
- id: component4
value: 4
- id: component5
value: 79
power: 200
- id: component6
value: 6
- id: component7
value: 7
switch: &id002
- platform: gpio
id: switch1
pin: 12
- platform: gpio
id: switch2
pin: 13
display:
- platform: ili9xxx
dimensions:
width: 100
height: 480
substitutions:
extended_component: component5
package_options:
alternative_package:
alternative_component:
- id: component8
value: 8
fancy_package:
fancy_component: *id001
pin: 12
some_switches: *id002
package_selection: fancy_package
fancy_subst: 42
@@ -0,0 +1,63 @@
substitutions:
package_options:
alternative_package:
alternative_component:
- id: component8
value: 8
fancy_package:
substitutions:
fancy_subst: 42
fancy_component:
- id: component9
value: 9
pin: 12
some_switches:
- platform: gpio
id: switch1
pin: ${pin}
- platform: gpio
id: switch2
pin: ${pin+1}
package_selection: fancy_package
packages:
- ${ package_options[package_selection] }
- some_component:
- id: component1
value: 1
- some_component:
- id: component2
value: 2
- switch: ${ some_switches }
- packages:
package_with_defaults: !include
file: display.yaml
vars:
native_width: 100
high_dpi: false
my_package:
packages:
- packages:
special_package:
substitutions:
extended_component: component5
some_component:
- id: component3
value: 3
some_component:
- id: component4
value: 4
- id: !extend ${ extended_component }
power: 200
value: 79
some_component:
- id: component5
value: 5
some_component:
- id: component6
value: 6
- id: component7
value: 7
@@ -0,0 +1,49 @@
substitutions:
a: 10
b: 20
x: 79
test_list:
- level1:
a: 10
b: 20
c: 10
d: 20
e: ${e}
f: ${f}
g: ${g}
h: ${h}
i: ${i}
j: ${j}
x: 80
y: 40
level2:
- level2:
a: 10
b: 20
c: 10
d: 20
e: 20
f: 40
g: ${g}
h: ${h}
i: ${i}
j: ${j}
x: 81
y: 40
level3:
- level3:
a: 10
b: 20
c: 10
d: 20
e: 20
f: 40
g: 100
h: 200
i: 30
j: ${undefined_variable}
x: 82
y: 40
- a: 10
b: 20
x: 79
@@ -0,0 +1,16 @@
substitutions:
a: 10
b: 20
x: 79
test_list:
- !include
file: level1_package.yaml
vars:
x: ${x+1}
y: ${d*2}
c: ${a}
d: ${b}
- a: ${a}
b: ${b}
x: ${x}
@@ -0,0 +1,5 @@
values:
- var1: $var1
- a: 10
- b: B-default
- c: The value of C is 79
@@ -0,0 +1,7 @@
# Test that include_vars with vars works even when there are no substitutions key defined.
packages:
- !include
file: inc1.yaml
vars:
a: 10
c: 79
@@ -0,0 +1,69 @@
substitutions:
a: from base config
b: from package3
c: from nested package4
nested_package:
nested_package_test_list:
- a: from base config
- b: from package3
- c: from nested package4
package1:
package1_test_list:
- a: from base config
- b: from package3
- c: from nested package4
package2:
package2_test_list:
- a: from package2 vars
- b: from package3
- c: from nested package4
package3:
package3_test_list:
- a: from base config
- b: from package3
- c: from nested package4
package4:
packages:
- nested_package_test_list:
- a: from base config
- b: from package3
- c: from nested package4
package_map:
package1:
package1_test_list:
- a: from base config
- b: from package3
- c: from nested package4
package2:
package2_test_list:
- a: from package2 vars
- b: from package3
- c: from nested package4
package3: &id001
package3_test_list:
- a: from base config
- b: from package3
- c: from nested package4
selected_package_number: 3
selected_package_name: package3
selected_package: *id001
base_test_list:
- a: from base config
- b: from package3
- c: from nested package4
package1_test_list:
- a: from base config
- b: from package3
- c: from nested package4
package2_test_list:
- a: from package2 vars
- b: from package3
- c: from nested package4
package3_test_list:
- a: from base config
- b: from package3
- c: from nested package4
nested_package_test_list:
- a: from base config
- b: from package3
- c: from nested package4
@@ -0,0 +1,62 @@
command_line_substitutions:
selected_package_number: 3
substitutions:
a: from base config
package1: &p1
substitutions:
a: from package1
b: from package1
c: from package1
package1_test_list:
- a: ${ a }
- b: ${ b }
- c: ${ c }
package2: &p2 !include
file: package2.yaml
vars:
a: from package2 vars
package3: &p3
substitutions:
a: from package3
b: from package3
c: from package3
package3_test_list:
- a: ${ a }
- b: ${ b }
- c: ${ c }
package4:
substitutions:
nested_package:
substitutions:
c: from nested package4
nested_package_test_list:
- a: ${ a }
- b: ${ b }
- c: ${ c }
packages:
- ${ nested_package }
package_map:
package1: *p1
package2: *p2
package3: *p3
selected_package_number: 2 # will be overridden by command line substitutions
selected_package_name: package${ selected_package_number }
selected_package: ${ package_map[selected_package_name] }
packages:
- ${ package1 }
- ${ package2 }
- ${ selected_package }
- ${ package4 }
base_test_list:
- a: ${ a }
- b: ${ b }
- c: ${ c }
@@ -0,0 +1,21 @@
# this file is included by 07-include_hierarchy.input.yaml
level1:
a: ${a} # top-level substitution
b: ${b} # top-level substitution
c: ${c} # from vars when including
d: ${d} # from vars when including
e: ${e} # undefined at this level
f: ${f} # undefined at this level
g: ${g} # undefined at this level
h: ${h} # undefined at this level
i: ${i} # undefined at this level
j: ${j} # undefined at this level
x: ${x} # from vars when including, calculated
y: ${y} # from vars when including, calculated
level2:
- !include
file: level2_package.yaml
vars:
e: ${c*2}
f: ${d*2}
x: ${x+1}
@@ -0,0 +1,21 @@
# this file is included by level1_package.yaml
level2:
a: ${a} # top-level substitution
b: ${b} # top-level substitution
c: ${c} # visible from level1 vars
d: ${d} # visible from level1 vars
e: ${e} # from vars when including
f: ${f} # from vars when including
g: ${g} # undefined at this level
h: ${h} # undefined at this level
i: ${i} # undefined at this level
j: ${j} # undefined at this level
x: ${x} # from vars when including, calculated
y: ${y} # from vars when including, calculated
level3:
- !include
file: level3_package.yaml
vars:
g: ${e*5}
h: ${f*5}
x: ${x+1}
@@ -0,0 +1,16 @@
# this file is included by level2_package.yaml
defaults:
i: 30
level3:
a: ${a} # top-level substitution
b: ${b} # top-level substitution
c: ${c} # visible from level1 vars
d: ${d} # visible from level1 vars
e: ${e} # visible from level2 vars
f: ${f} # visible from level2 vars
g: ${g} # from vars when including
h: ${h} # from vars when including
i: ${i} # Should take the default value of 30
j: ${undefined_variable} # Does not exist, should be output as-is
x: ${x} # from vars when including, calculated
y: ${y} # from vars when including, calculated
@@ -0,0 +1,10 @@
# included from 10-dynamic_packages.input.yaml
substitutions:
a: from package2 # must not override base config's a
# b not defined here, won't override package1's b
c: from package2 # will override package1's c
package2_test_list:
- a: ${ a }
- b: ${ b }
- c: ${ c }
+80 -1
View File
@@ -5,7 +5,13 @@ from unittest.mock import patch
import pytest
from esphome.automation import has_non_synchronous_actions
from esphome.automation import (
TriggerForwarder,
TriggerOnFalseForwarder,
TriggerOnTrueForwarder,
has_non_synchronous_actions,
)
from esphome.cpp_generator import MockObj, RawExpression
from esphome.util import RegistryEntry
@@ -175,3 +181,76 @@ def test_has_non_synchronous_actions_dict_input(
"""Direct dict input (single action)."""
assert has_non_synchronous_actions({"delay": "1s"}) is True
assert has_non_synchronous_actions({"logger.log": "hello"}) is False
def _build_forwarder(
automation_name: str,
args: list[tuple[str, str]],
forwarder: MockObj | None = None,
) -> str:
"""Build a trigger forwarder expression the same way build_callback_automation does.
Mirrors the forwarder selection logic in automation.build_callback_automation.
"""
import esphome.codegen as cg
obj = MockObj(automation_name, "->")
if forwarder is None:
arg_types = [RawExpression(t) for t, _ in args]
templ = (
cg.TemplateArguments(*arg_types) if arg_types else cg.TemplateArguments()
)
forwarder = TriggerForwarder.template(templ)
return f"{forwarder}{{{obj}}}"
def test_trigger_forwarder_no_args() -> None:
"""Button on_press: TriggerForwarder<> with no args."""
result = _build_forwarder("auto_1", [])
assert result == "TriggerForwarder<>{auto_1}"
def test_trigger_forwarder_single_float_arg() -> None:
"""Sensor on_value: TriggerForwarder<float>."""
result = _build_forwarder("auto_1", [("float", "x")])
assert result == "TriggerForwarder<float>{auto_1}"
def test_trigger_forwarder_single_bool_arg() -> None:
"""Switch on_state: TriggerForwarder<bool>."""
result = _build_forwarder("auto_1", [("bool", "x")])
assert result == "TriggerForwarder<bool>{auto_1}"
def test_trigger_forwarder_on_true() -> None:
"""Binary_sensor on_press / switch on_turn_on: TriggerOnTrueForwarder."""
result = _build_forwarder("auto_1", [], forwarder=TriggerOnTrueForwarder)
assert result == "TriggerOnTrueForwarder{auto_1}"
def test_trigger_forwarder_on_false() -> None:
"""Binary_sensor on_release / switch on_turn_off: TriggerOnFalseForwarder."""
result = _build_forwarder("auto_1", [], forwarder=TriggerOnFalseForwarder)
assert result == "TriggerOnFalseForwarder{auto_1}"
def test_trigger_forwarder_multiple_args() -> None:
"""Binary_sensor on_state_change: TriggerForwarder with two args."""
result = _build_forwarder(
"auto_1",
[("optional<bool>", "x_previous"), ("optional<bool>", "x")],
)
assert result == "TriggerForwarder<optional<bool>, optional<bool>>{auto_1}"
def test_trigger_forwarder_string_arg() -> None:
"""Text_sensor on_value: TriggerForwarder<std::string>."""
result = _build_forwarder("auto_1", [("std::string", "x")])
assert result == "TriggerForwarder<std::string>{auto_1}"
def test_trigger_forwarder_custom_type() -> None:
"""Custom forwarder type passed directly."""
custom = MockObj("MyForwarder", "")
result = _build_forwarder("auto_1", [], forwarder=custom)
assert result == "MyForwarder{auto_1}"
+41 -6
View File
@@ -1762,7 +1762,34 @@ def test_show_logs_api(
assert result == 0
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100", "192.168.1.101"]
CORE.config, ["192.168.1.100", "192.168.1.101"], subscribe_states=True
)
@patch("esphome.components.api.client.run_logs")
def test_show_logs_api_no_states(
mock_run_logs: Mock,
) -> None:
"""Test show_logs with --no-states flag."""
setup_core(
config={
"logger": {},
CONF_API: {},
CONF_MDNS: {CONF_DISABLED: False},
},
platform=PLATFORM_ESP32,
)
mock_run_logs.return_value = 0
args = MockArgs()
args.no_states = True
devices = ["192.168.1.100"]
result = show_logs(CORE.config, args, devices)
assert result == 0
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100"], subscribe_states=False
)
@@ -1788,7 +1815,9 @@ def test_show_logs_api_with_fqdn_mdns_disabled(
assert result == 0
# Should use the FQDN directly, not try MQTT lookup
mock_run_logs.assert_called_once_with(CORE.config, ["device.example.com"])
mock_run_logs.assert_called_once_with(
CORE.config, ["device.example.com"], subscribe_states=True
)
@patch("esphome.components.api.client.run_logs")
@@ -1816,7 +1845,9 @@ def test_show_logs_api_with_mqtt_fallback(
assert result == 0
mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client")
mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.200"])
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.200"], subscribe_states=True
)
@patch("esphome.mqtt.show_logs")
@@ -2746,7 +2777,7 @@ def test_show_logs_api_static_ip_with_mqttip(
# Verify run_logs was called with both IPs
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100", "192.168.2.50"]
CORE.config, ["192.168.1.100", "192.168.2.50"], subscribe_states=True
)
@@ -2782,7 +2813,9 @@ def test_show_logs_api_multiple_mqttip_resolves_once(
# Note: "MQTT" is a different magic string from "MQTTIP", but both trigger MQTT resolution
# The _resolve_network_devices helper filters out both after first resolution
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.2.50", "192.168.2.51", "192.168.1.100"]
CORE.config,
["192.168.2.50", "192.168.2.51", "192.168.1.100"],
subscribe_states=True,
)
@@ -2862,7 +2895,9 @@ def test_show_logs_api_mqtt_timeout_fallback(
mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client")
# Verify run_logs was called with only the static IP (MQTT failed)
mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.100"])
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100"], subscribe_states=True
)
def test_detect_external_components_no_external(
+264 -18
View File
@@ -10,9 +10,10 @@ from esphome import config as config_module, yaml_util
from esphome.components import substitutions
from esphome.components.packages import do_packages_pass, merge_packages
from esphome.config import resolve_extend_remove
from esphome.config_helpers import merge_config
from esphome.config_helpers import Extend, merge_config
import esphome.config_validation as cv
from esphome.const import CONF_SUBSTITUTIONS
from esphome.core import CORE
from esphome.core import CORE, Lambda
from esphome.util import OrderedDict
_LOGGER = logging.getLogger(__name__)
@@ -142,9 +143,11 @@ def test_substitutions_fixtures(
command_line_substitutions = config.pop("command_line_substitutions", None)
config = do_packages_pass(config)
config = do_packages_pass(
config, command_line_substitutions=command_line_substitutions
)
substitutions.do_substitution_pass(config, command_line_substitutions)
config = substitutions.do_substitution_pass(config, command_line_substitutions)
config = merge_packages(config)
@@ -206,7 +209,7 @@ def test_substitutions_with_command_line_maintains_ordered_dict() -> None:
command_line_subs = {"var2": "override", "var3": "new_value"}
# Call do_substitution_pass with command line substitutions
substitutions.do_substitution_pass(config, command_line_subs)
config = substitutions.do_substitution_pass(config, command_line_subs)
# Verify that config is still an OrderedDict
assert isinstance(config, OrderedDict), "Config should remain an OrderedDict"
@@ -234,7 +237,7 @@ def test_substitutions_without_command_line_maintains_ordered_dict() -> None:
config["other_key"] = "other_value"
# Call without command line substitutions
substitutions.do_substitution_pass(config, None)
config = substitutions.do_substitution_pass(config, None)
# Verify that config is still an OrderedDict
assert isinstance(config, OrderedDict), "Config should remain an OrderedDict"
@@ -268,7 +271,7 @@ def test_substitutions_after_merge_config_maintains_ordered_dict() -> None:
)
# Now try to run substitution pass on the merged config
substitutions.do_substitution_pass(merged_config, None)
merged_config = substitutions.do_substitution_pass(merged_config, None)
# Should not raise AttributeError
assert isinstance(merged_config, OrderedDict), (
@@ -279,7 +282,7 @@ def test_substitutions_after_merge_config_maintains_ordered_dict() -> None:
def test_validate_config_with_command_line_substitutions_maintains_ordered_dict(
tmp_path,
tmp_path: Path,
) -> None:
"""Test that validate_config preserves OrderedDict when merging command-line substitutions.
@@ -288,7 +291,7 @@ def test_validate_config_with_command_line_substitutions_maintains_ordered_dict(
"""
# Create a minimal valid config
test_config = OrderedDict()
test_config["esphome"] = {"name": "test_device", "platform": "ESP32"}
test_config["esphome"] = {"name": "test_device"}
test_config[CONF_SUBSTITUTIONS] = OrderedDict({"var1": "value1", "var2": "value2"})
test_config["esp32"] = {"board": "esp32dev"}
@@ -314,17 +317,11 @@ def test_validate_config_with_command_line_substitutions_maintains_ordered_dict(
assert result[CONF_SUBSTITUTIONS]["var3"] == "new_value"
def test_validate_config_without_command_line_substitutions_maintains_ordered_dict(
tmp_path,
) -> None:
"""Test that validate_config preserves OrderedDict without command-line substitutions.
This tests the code path in config.py where result[CONF_SUBSTITUTIONS] is set
using merge_dicts_ordered() when command_line_substitutions is None.
"""
def _get_test_minimal_valid_config(tmp_path: Path) -> OrderedDict:
"""Helper to create a minimal valid config for testing."""
# Create a minimal valid config
test_config = OrderedDict()
test_config["esphome"] = {"name": "test_device", "platform": "ESP32"}
test_config["esphome"] = {"name": "test_device"}
test_config[CONF_SUBSTITUTIONS] = OrderedDict({"var1": "value1", "var2": "value2"})
test_config["esp32"] = {"board": "esp32dev"}
@@ -332,6 +329,19 @@ def test_validate_config_without_command_line_substitutions_maintains_ordered_di
test_yaml = tmp_path / "test.yaml"
test_yaml.write_text("# test config")
CORE.config_path = test_yaml
return test_config
def test_validate_config_without_command_line_substitutions_maintains_ordered_dict(
tmp_path: Path,
) -> None:
"""Test that validate_config preserves OrderedDict without command-line substitutions.
This tests the code path in config.py where result[CONF_SUBSTITUTIONS] is set
using merge_dicts_ordered() when command_line_substitutions is None.
"""
test_config = _get_test_minimal_valid_config(tmp_path)
# Call validate_config without command line substitutions
result = config_module.validate_config(test_config, None)
@@ -384,3 +394,239 @@ def test_merge_config_preserves_ordered_dict() -> None:
assert not isinstance(result, OrderedDict), (
"dict + dict should not return OrderedDict"
)
def test_substitution_pass_error_gets_captured(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""vol.Invalid from do_substitution_pass is captured by validate_config."""
# Patch the target: in config_module.do_substitution_pass (NOT where it's defined)
def fake_do_substitution_pass(*args, **kwargs):
raise cv.Invalid("Error in do_substitutions_pass!!")
monkeypatch.setattr(
config_module, "do_substitution_pass", fake_do_substitution_pass
)
# Prepare minimal config + no CLI substitutions
config = _get_test_minimal_valid_config(tmp_path)
# Call the function under test
result = config_module.validate_config(config, None)
# Now assert that add_error was called with the vol.Invalid
assert "Error in do_substitutions_pass!!" in str(result.get_error_for_path([]))
@pytest.mark.parametrize(
"value", ["", " ", "1foo", "9VAR", "0abc", "$1foo", "$9VAR", "$0abc"]
)
def test_validate_substitution_key_empty_raises(value: str) -> None:
"""Empty (or all-whitespace) substitution keys are rejected."""
with pytest.raises(cv.Invalid):
substitutions.validate_substitution_key(value)
@pytest.mark.parametrize(
"input_value, expected_output",
[
("$FOO_bar9", "FOO_bar9"), # Valid key with leading '$'
("Foo_bar9", "Foo_bar9"), # Normal valid key
],
)
def test_validate_substitution_key_valid(
input_value: str, expected_output: str
) -> None:
"""Valid substitution keys are accepted with optional leading '$'."""
result = substitutions.validate_substitution_key(input_value)
assert result == expected_output
def test_circular_dependency_warnings(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Circular substitution references produce warnings naming the cause."""
config = OrderedDict(
{
CONF_SUBSTITUTIONS: OrderedDict({"x": "${y}", "y": "${x}"}),
"key": "value",
}
)
with caplog.at_level(logging.WARNING):
substitutions.do_substitution_pass(config)
assert "Could not resolve substitution variable 'x'" in caplog.text
assert "'y' is undefined" in caplog.text
assert "Could not resolve substitution variable 'y'" in caplog.text
assert "'x' is undefined" in caplog.text
# Verify path includes location
assert "substitutions->x" in caplog.text
assert "substitutions->y" in caplog.text
def test_missing_dependency_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A substitution referencing an undefined variable warns with the cause."""
config = OrderedDict(
{
CONF_SUBSTITUTIONS: OrderedDict({"a": "${missing}"}),
"key": "value",
}
)
with caplog.at_level(logging.WARNING):
substitutions.do_substitution_pass(config)
assert "Could not resolve substitution variable 'a'" in caplog.text
assert "'missing' is undefined" in caplog.text
assert "substitutions->a" in caplog.text
def test_undefined_variable_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A reference to an undefined variable in config values produces a warning."""
config = OrderedDict(
{
"key": "${undefined_var}",
}
)
with caplog.at_level(logging.WARNING):
substitutions.do_substitution_pass(config)
assert "'undefined_var' is undefined" in caplog.text
def test_password_field_warnings_suppressed(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Undefined variables in password fields should not produce warnings."""
config = OrderedDict(
{
"password": "${undefined_var}",
}
)
with caplog.at_level(logging.WARNING):
substitutions.do_substitution_pass(config)
assert caplog.text == ""
def test_config_context_unresolvable_warns(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Unresolvable vars in a ConfigContext produce warnings via push_context."""
inner = OrderedDict({"key": "${a}"})
yaml_util.add_context(inner, {"a": "${undefined}"})
config = OrderedDict({"items": [inner]})
with caplog.at_level(logging.WARNING):
substitutions.do_substitution_pass(config)
assert "Could not resolve substitution variable 'a'" in caplog.text
assert "'undefined' is undefined" in caplog.text
def test_non_string_substitution_value_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Undefined vars in non-string contexts (e.g. dict keys) produce warnings."""
config = OrderedDict(
{
"items": {"${undefined_key}": "value"},
}
)
with caplog.at_level(logging.WARNING):
substitutions.do_substitution_pass(config)
assert "'undefined_key' is undefined" in caplog.text
def test_lambda_substitution() -> None:
"""Substitution inside a Lambda value should be expanded."""
lam = Lambda("return ${var};")
config = OrderedDict(
{
CONF_SUBSTITUTIONS: OrderedDict({"var": "42"}),
"lambda": lam,
}
)
config = substitutions.do_substitution_pass(config)
assert config["lambda"].value == "return 42;"
def test_lambda_no_substitution_unchanged() -> None:
"""A Lambda with no variable references should not be mutated."""
lam = Lambda("return 1;")
original_value = lam.value
config = OrderedDict(
{
CONF_SUBSTITUTIONS: OrderedDict({"var": "42"}),
"lambda": lam,
}
)
config = substitutions.do_substitution_pass(config)
assert config["lambda"].value is original_value
def test_extend_substitution() -> None:
"""Substitution inside an Extend value should be expanded."""
ext = Extend("${component_id}")
config = OrderedDict(
{
CONF_SUBSTITUTIONS: OrderedDict({"component_id": "my_sensor"}),
"sensor": ext,
}
)
config = substitutions.do_substitution_pass(config)
assert config["sensor"].value == "my_sensor"
def test_substitute_does_not_mutate_input() -> None:
"""substitute() must return a new tree without modifying the original."""
inner_list = ["${var}", "static"]
inner_dict = OrderedDict({"key": "${var}"})
lam = Lambda("return ${var};")
config = OrderedDict(
{
"a_list": inner_list,
"a_dict": inner_dict,
"a_lambda": lam,
"plain": "${var}",
}
)
context = substitutions.ContextVars({"var": "replaced"})
result = substitutions.substitute(config, [], context, strict_undefined=True)
# Result has substitutions applied
assert result["plain"] == "replaced"
assert result["a_list"] == ["replaced", "static"]
assert result["a_dict"]["key"] == "replaced"
assert result["a_lambda"].value == "return replaced;"
# Original input is untouched
assert config["plain"] == "${var}"
assert inner_list == ["${var}", "static"]
assert inner_dict["key"] == "${var}"
assert lam.value == "return ${var};"
# Containers are new objects, not the originals
assert result["a_list"] is not inner_list
assert result["a_dict"] is not inner_dict
assert result["a_lambda"] is not lam
def test_do_substitution_pass_substitutions_must_be_mapping_from_config() -> None:
"""Non-mapping substitutions raises cv.Invalid."""
config = OrderedDict(
{
CONF_SUBSTITUTIONS: ["not", "a", "mapping"],
"other": "value",
}
)
with pytest.raises(
cv.Invalid, match="Substitutions must be a key to value mapping"
):
substitutions.do_substitution_pass(config)
+165
View File
@@ -866,6 +866,171 @@ def test_clean_all_with_yaml_file(
assert str(build_dir) in caplog.text
@patch("esphome.writer.CORE")
def test_clean_all_with_yaml_build_path(
mock_core: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test clean_all cleans absolute build_path specified in YAML config."""
config_dir = tmp_path / "config"
config_dir.mkdir()
# Create an absolute custom build path directory with contents
custom_build = tmp_path / "custom_build"
custom_build.mkdir()
(custom_build / "firmware.bin").write_text("x")
sub = custom_build / "subdir"
sub.mkdir()
(sub / "file.txt").write_text("x")
yaml_file = config_dir / "test.yaml"
# Absolute build_path: data_dir / absolute = absolute (Python Path behavior)
yaml_file.write_text(f"esphome:\n name: test\n build_path: {custom_build}\n")
# Also create the normal .esphome dir
build_dir = config_dir / ".esphome"
build_dir.mkdir()
(build_dir / "dummy.txt").write_text("x")
from esphome.writer import clean_all
with caplog.at_level("INFO"):
clean_all([str(yaml_file)])
# Both .esphome and custom build_path should be cleaned
assert build_dir.exists()
assert not (build_dir / "dummy.txt").exists()
assert custom_build.exists()
assert not (custom_build / "firmware.bin").exists()
assert not sub.exists()
@patch("esphome.writer.CORE")
def test_clean_all_with_yaml_parse_error(
mock_core: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test clean_all still cleans .esphome when YAML parse fails."""
config_dir = tmp_path / "config"
config_dir.mkdir()
yaml_file = config_dir / "test.yaml"
yaml_file.write_text("invalid: yaml: content: [")
build_dir = config_dir / ".esphome"
build_dir.mkdir()
(build_dir / "dummy.txt").write_text("x")
from esphome.writer import clean_all
with caplog.at_level("INFO"):
clean_all([str(yaml_file)])
# .esphome should still be cleaned despite YAML parse failure
assert build_dir.exists()
assert not (build_dir / "dummy.txt").exists()
@patch("esphome.writer.CORE")
def test_clean_all_with_env_build_path(
mock_core: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test clean_all cleans ESPHOME_BUILD_PATH directory."""
config_dir = tmp_path / "config"
config_dir.mkdir()
build_dir = config_dir / ".esphome"
build_dir.mkdir()
(build_dir / "dummy.txt").write_text("x")
# Create env build path directory
env_build = tmp_path / "env_build"
env_build.mkdir()
(env_build / "firmware.bin").write_text("x")
from esphome.writer import clean_all
with (
caplog.at_level("INFO"),
patch.dict(os.environ, {"ESPHOME_BUILD_PATH": str(env_build)}),
):
clean_all([str(config_dir)])
# Both should be cleaned
assert not (build_dir / "dummy.txt").exists()
assert env_build.exists()
assert not (env_build / "firmware.bin").exists()
@patch("esphome.writer.CORE")
def test_clean_all_ignores_empty_env_vars(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test clean_all ignores empty ESPHOME_BUILD_PATH/ESPHOME_DATA_DIR."""
config_dir = tmp_path / "config"
config_dir.mkdir()
# Create a file in cwd that must NOT be cleaned
marker = tmp_path / "important.txt"
marker.write_text("do not delete")
from esphome.writer import clean_all
with patch.dict(
os.environ,
{"ESPHOME_BUILD_PATH": "", "ESPHOME_DATA_DIR": ""},
):
clean_all([str(config_dir)])
# Empty env vars must not cause cwd to be cleaned
assert marker.exists()
@patch("esphome.writer.CORE")
def test_clean_all_no_args_with_esphome_dir(
mock_core: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test clean_all with no args cleans .esphome in cwd."""
esphome_dir = tmp_path / ".esphome"
esphome_dir.mkdir()
(esphome_dir / "dummy.txt").write_text("x")
from esphome.writer import clean_all
with (
caplog.at_level("INFO"),
patch("esphome.writer.Path.cwd", return_value=tmp_path),
):
clean_all([])
assert esphome_dir.exists()
assert not (esphome_dir / "dummy.txt").exists()
@patch("esphome.writer.CORE")
def test_clean_all_no_args_no_esphome_dir(
mock_core: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test clean_all with no args and no .esphome dir warns."""
from esphome.writer import clean_all
with (
caplog.at_level("WARNING"),
patch("esphome.writer.Path.cwd", return_value=tmp_path),
):
clean_all([])
assert "No configuration files specified" in caplog.text
@patch("esphome.writer.CORE")
def test_clean_all(
mock_core: MagicMock,
+29 -15
View File
@@ -25,7 +25,7 @@ def test_include_with_vars(fixture_path: Path) -> None:
yaml_file = fixture_path / "yaml_util" / "includetest.yaml"
actual = yaml_util.load_yaml(yaml_file)
substitutions.do_substitution_pass(actual, None)
actual = substitutions.do_substitution_pass(actual, None)
assert actual["esphome"]["name"] == "original"
assert actual["esphome"]["libraries"][0] == "Wire"
assert actual["esp8266"]["board"] == "nodemcu"
@@ -98,13 +98,15 @@ def test_construct_secret_missing(fixture_path: Path, tmp_path: Path) -> None:
"""Test that missing secrets raise proper errors."""
# Create a YAML file with a secret that doesn't exist
test_yaml = tmp_path / "test.yaml"
test_yaml.write_text("""
test_yaml.write_text(
"""
esphome:
name: test
wifi:
password: !secret nonexistent_secret
""")
"""
)
# Create an empty secrets file
secrets_yaml = tmp_path / "secrets.yaml"
@@ -118,10 +120,12 @@ def test_construct_secret_no_secrets_file(tmp_path: Path) -> None:
"""Test that missing secrets.yaml file raises proper error."""
# Create a YAML file with a secret but no secrets.yaml
test_yaml = tmp_path / "test.yaml"
test_yaml.write_text("""
test_yaml.write_text(
"""
wifi:
password: !secret some_secret
""")
"""
)
# Mock CORE.config_path to avoid NoneType error
with (
@@ -140,10 +144,12 @@ def test_construct_secret_fallback_to_main_config_dir(
subdir.mkdir()
test_yaml = subdir / "test.yaml"
test_yaml.write_text("""
test_yaml.write_text(
"""
wifi:
password: !secret test_secret
""")
"""
)
# Create secrets.yaml in the main directory
main_secrets = tmp_path / "secrets.yaml"
@@ -164,9 +170,11 @@ def test_construct_include_dir_named(fixture_path: Path, tmp_path: Path) -> None
# Create test YAML that uses include_dir_named
test_yaml = dst_dir / "test_include_named.yaml"
test_yaml.write_text("""
test_yaml.write_text(
"""
sensor: !include_dir_named named_dir
""")
"""
)
actual = yaml_util.load_yaml(test_yaml)
actual_sensor = actual["sensor"]
@@ -199,9 +207,11 @@ def test_construct_include_dir_named_empty_dir(tmp_path: Path) -> None:
empty_dir.mkdir()
test_yaml = tmp_path / "test.yaml"
test_yaml.write_text("""
test_yaml.write_text(
"""
sensor: !include_dir_named empty_dir
""")
"""
)
actual = yaml_util.load_yaml(test_yaml)
@@ -231,9 +241,11 @@ def test_construct_include_dir_named_with_dots(tmp_path: Path) -> None:
hidden_subfile.write_text("key: hidden_subfile_value")
test_yaml = tmp_path / "test.yaml"
test_yaml.write_text("""
test_yaml.write_text(
"""
test: !include_dir_named test_dir
""")
"""
)
actual = yaml_util.load_yaml(test_yaml)
@@ -255,9 +267,11 @@ def test_find_files_recursive(fixture_path: Path, tmp_path: Path) -> None:
# This indirectly tests _find_files by using include_dir_named
test_yaml = dst_dir / "test_include_recursive.yaml"
test_yaml.write_text("""
test_yaml.write_text(
"""
all_sensors: !include_dir_named named_dir
""")
"""
)
actual = yaml_util.load_yaml(test_yaml)