mirror of
https://github.com/esphome/esphome.git
synced 2026-10-04 18:11:29 +00:00
Merge remote-tracking branch 'origin/dev' into jesserockz-2026-436
# Conflicts: # esphome/config_validation.py
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import string
|
||||
from unittest.mock import patch
|
||||
|
||||
from hypothesis import example, given, settings
|
||||
from hypothesis.strategies import builds, integers, ip_addresses, one_of, text
|
||||
@@ -45,6 +49,7 @@ from esphome.const import (
|
||||
TYPE_GIT,
|
||||
TYPE_LOCAL,
|
||||
Framework,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import (
|
||||
CORE,
|
||||
@@ -929,7 +934,7 @@ def test_string_no_slash__slash_replaced_with_warning(
|
||||
actual = cv.string_no_slash(value)
|
||||
assert actual == expected
|
||||
assert "reserved as a URL path separator" in caplog.text
|
||||
assert "will become an error in ESPHome 2026.7.0" in caplog.text
|
||||
assert "will become an error in ESPHome 2027.7.0" in caplog.text
|
||||
|
||||
|
||||
def test_string_no_slash__long_string_allowed() -> None:
|
||||
@@ -1465,9 +1470,41 @@ def test_version_parse_with_extra() -> None:
|
||||
assert version.extra == "dev20240101"
|
||||
|
||||
|
||||
def test_version_parse_invalid() -> None:
|
||||
def test_version_parse_without_patch() -> None:
|
||||
"""A two-part version parses with patch defaulting to 0, so framework
|
||||
shorthands like '6.0' and '6.0-rc1' are accepted."""
|
||||
version = cv.Version.parse("6.0")
|
||||
assert (version.major, version.minor, version.patch, version.extra) == (
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
"",
|
||||
)
|
||||
version = cv.Version.parse("6.0-rc1")
|
||||
assert (version.major, version.minor, version.patch, version.extra) == (
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
"rc1",
|
||||
)
|
||||
|
||||
|
||||
def test_version_parse_numeric_extra() -> None:
|
||||
"""Four-part versions keep the trailing component as extra (pioarduino
|
||||
packaging revisions, e.g. 5.5.3.1)."""
|
||||
version = cv.Version.parse("5.5.3.1")
|
||||
assert (version.major, version.minor, version.patch, version.extra) == (
|
||||
5,
|
||||
5,
|
||||
3,
|
||||
"1",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["not.a.version", "6", "a.b", ""])
|
||||
def test_version_parse_invalid(value: str) -> None:
|
||||
with pytest.raises(ValueError, match="Not a valid version number"):
|
||||
cv.Version.parse("not.a.version")
|
||||
cv.Version.parse(value)
|
||||
|
||||
|
||||
def test_version_is_beta() -> None:
|
||||
@@ -2423,6 +2460,58 @@ def test_one_of_string_and_space() -> None:
|
||||
assert cv.one_of("a_b", string=True, space="_")("a b") == "a_b"
|
||||
|
||||
|
||||
def test_one_of_string_and_underscore() -> None:
|
||||
assert cv.one_of("a-b", string=True, underscore="-")("a_b") == "a-b"
|
||||
assert cv.one_of("a-b", string=True, underscore="-")("a-b") == "a-b"
|
||||
|
||||
|
||||
def test_one_of_string_lower_space_and_underscore() -> None:
|
||||
validator = cv.one_of("output-mode", lower=True, space="-", underscore="-")
|
||||
assert validator("output_mode") == "output-mode"
|
||||
assert validator("OUTPUT_MODE") == "output-mode"
|
||||
assert validator("output mode") == "output-mode"
|
||||
assert validator("output-mode") == "output-mode"
|
||||
|
||||
|
||||
def test_one_of_string_underscore_unknown() -> None:
|
||||
with pytest.raises(Invalid):
|
||||
cv.one_of("a-b", string=True, underscore="-")("c_d")
|
||||
|
||||
|
||||
def test_one_of_string_underscore_default_unchanged() -> None:
|
||||
with pytest.raises(Invalid):
|
||||
cv.one_of("a-b", string=True)("a_b")
|
||||
|
||||
|
||||
def test_one_of_string_and_hyphen() -> None:
|
||||
assert cv.one_of("a_b", string=True, hyphen="_")("a-b") == "a_b"
|
||||
assert cv.one_of("a_b", string=True, hyphen="_")("a_b") == "a_b"
|
||||
|
||||
|
||||
def test_one_of_string_lower_space_and_hyphen() -> None:
|
||||
validator = cv.one_of("output_mode", lower=True, space="_", hyphen="_")
|
||||
assert validator("output-mode") == "output_mode"
|
||||
assert validator("OUTPUT-MODE") == "output_mode"
|
||||
assert validator("output mode") == "output_mode"
|
||||
assert validator("output_mode") == "output_mode"
|
||||
|
||||
|
||||
def test_one_of_string_hyphen_unknown() -> None:
|
||||
with pytest.raises(Invalid):
|
||||
cv.one_of("a_b", string=True, hyphen="_")("c-d")
|
||||
|
||||
|
||||
def test_one_of_string_hyphen_default_unchanged() -> None:
|
||||
with pytest.raises(Invalid):
|
||||
cv.one_of("a_b", string=True)("a-b")
|
||||
|
||||
|
||||
def test_one_of_string_underscore_hyphen_swap_no_cascade() -> None:
|
||||
validator = cv.one_of("a-b", "a_b", string=True, underscore="-", hyphen="_")
|
||||
assert validator("a_b") == "a-b"
|
||||
assert validator("a-b") == "a_b"
|
||||
|
||||
|
||||
def test_one_of_int() -> None:
|
||||
assert cv.one_of(1, 2, int=True)("2") == 2
|
||||
|
||||
@@ -2461,6 +2550,20 @@ def test_enum_valid() -> None:
|
||||
assert result.enum_value == 10
|
||||
|
||||
|
||||
def test_enum_valid_with_underscore() -> None:
|
||||
mapping = {"a-b": 1}
|
||||
result = cv.enum(mapping, string=True, underscore="-")("a_b")
|
||||
assert result == "a-b"
|
||||
assert result.enum_value == 1
|
||||
|
||||
|
||||
def test_enum_valid_with_hyphen() -> None:
|
||||
mapping = {"a_b": 1}
|
||||
result = cv.enum(mapping, string=True, hyphen="_")("a-b")
|
||||
assert result == "a_b"
|
||||
assert result.enum_value == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# lambda_ / returning_lambda
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2491,6 +2594,52 @@ def test_returning_lambda_no_return() -> None:
|
||||
cv.returning_lambda(Lambda("int x = 5;"))
|
||||
|
||||
|
||||
def test_returning_lambda_return_only_in_comment() -> None:
|
||||
with pytest.raises(Invalid, match="return statement"):
|
||||
cv.returning_lambda(Lambda("// return 5;\nint x = 5;"))
|
||||
|
||||
|
||||
def test_returning_lambda_missing_semicolon_is_accepted() -> None:
|
||||
"""A forgotten semicolon is left for the C++ compiler to report."""
|
||||
assert isinstance(cv.returning_lambda(Lambda("return x")), Lambda)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("return 5;", True),
|
||||
("if (x) { return x; } return 0;", True),
|
||||
("if (x) return 1; else return 0;", True),
|
||||
("switch (x) { case 0: return 1; }", True),
|
||||
# a semicolon means code: any return keyword counts
|
||||
("return not x;", True),
|
||||
("return a and b;", True),
|
||||
("please return the sensor; then wait", True),
|
||||
# a forgotten semicolon is still lambda source; the compiler reports it
|
||||
("return id(x).state", True),
|
||||
("return x", True),
|
||||
("return 5", True),
|
||||
("return not x", True),
|
||||
# accepted: a one-word tail is indistinguishable from 'return x'
|
||||
("return soon", True),
|
||||
("Alert: return home", True),
|
||||
("static value", False),
|
||||
("no returns here", False),
|
||||
("the_return_value", False),
|
||||
# without a semicolon, prose is not lambda source
|
||||
("please return the item", False),
|
||||
("return to sender", False),
|
||||
("return a and b", False),
|
||||
# return only inside a comment is not a return statement
|
||||
("// return 5;\nint x = 5;", False),
|
||||
("/* return 5; */ int x = 5;", False),
|
||||
("return 5; // done", True),
|
||||
],
|
||||
)
|
||||
def test_looks_like_returning_lambda(value: str, expected: bool) -> None:
|
||||
assert cv.looks_like_returning_lambda(value) is expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dimensions
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2855,11 +3004,63 @@ def test_require_esphome_version_ok() -> None:
|
||||
assert cv.require_esphome_version(1, 0, 0)("test") == "test"
|
||||
|
||||
|
||||
def test_require_esphome_version_accepts_version_object() -> None:
|
||||
"""The Version form matches require_framework_version's style."""
|
||||
assert cv.require_esphome_version(cv.Version(1, 0, 0))("test") == "test"
|
||||
with pytest.raises(Invalid, match="at least ESPHome version 9999.0.0"):
|
||||
cv.require_esphome_version(cv.Version(9999, 0, 0))("test")
|
||||
|
||||
|
||||
def test_require_esphome_version_partial_ints_fail_at_call_site() -> None:
|
||||
"""Missing ints raise immediately instead of a TypeError inside the validator."""
|
||||
with pytest.raises(ValueError, match="needs a Version or"):
|
||||
cv.require_esphome_version(2026, 8)
|
||||
with pytest.raises(ValueError, match="needs a Version or"):
|
||||
cv.require_esphome_version(2026)
|
||||
|
||||
|
||||
def test_require_esphome_version_too_old() -> None:
|
||||
with pytest.raises(Invalid, match="at least ESPHome version 9999.0.0"):
|
||||
cv.require_esphome_version(9999, 0, 0)("test")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("current", ["2026.8.0", "2026.8.0b1", "2026.8.0-dev20260801"])
|
||||
def test_require_esphome_version_prerelease_of_required_passes(current: str) -> None:
|
||||
"""A dev or beta build of the required version satisfies it.
|
||||
|
||||
Pins the behavior of the old tuple comparison that dropped the
|
||||
suffix, now expressed through Version ordering where the extra field
|
||||
only breaks ties upward.
|
||||
"""
|
||||
with patch.object(cv, "ESPHOME_VERSION", current):
|
||||
assert cv.require_esphome_version(2026, 8, 0)("test") == "test"
|
||||
|
||||
|
||||
def test_require_esphome_version_older_prerelease_fails() -> None:
|
||||
with (
|
||||
patch.object(cv, "ESPHOME_VERSION", "2026.7.0-dev20260701"),
|
||||
pytest.raises(Invalid, match="at least ESPHome version 2026.8.0"),
|
||||
):
|
||||
cv.require_esphome_version(2026, 8, 0)("test")
|
||||
|
||||
|
||||
def test_parse_esphome_version_deprecated_shim(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The removed helper still works for external components and warns."""
|
||||
from esphome import const, util
|
||||
|
||||
with (
|
||||
patch.object(const, "__version__", "2026.9.0-dev"),
|
||||
caplog.at_level(logging.WARNING),
|
||||
):
|
||||
assert cv.parse_esphome_version() == (2026, 9, 0)
|
||||
assert cv.parse_esphome_version() < (9999, 0, 0)
|
||||
assert "parse_esphome_version() is deprecated" in caplog.text
|
||||
# Both historical import paths resolve to the same function
|
||||
assert cv.parse_esphome_version is util.parse_esphome_version
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# suppress_invalid / validate_source_shorthand / rename_key
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2909,3 +3110,178 @@ def test_rename_key_present() -> None:
|
||||
|
||||
def test_rename_key_absent() -> None:
|
||||
assert cv.rename_key("old", "new")({"other": 5}) == {"other": 5}
|
||||
|
||||
|
||||
def test_rename_key_no_removed_in_is_silent(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.config_validation"):
|
||||
assert cv.rename_key("old", "new")({"old": 5}) == {"new": 5}
|
||||
assert not caplog.records
|
||||
|
||||
|
||||
def test_rename_key_removed_in_renames_and_warns(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.config_validation"):
|
||||
result = cv.rename_key("old", "new", removed_in="2026.8.0")({"old": 5})
|
||||
assert result == {"new": 5}
|
||||
assert "'old' is deprecated, use 'new'. Will be removed in 2026.8.0" in caplog.text
|
||||
|
||||
|
||||
def test_rename_key_removed_in_absent_key_no_warning(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.config_validation"):
|
||||
result = cv.rename_key("old", "new", removed_in="2026.8.0")({"other": 5})
|
||||
assert result == {"other": 5}
|
||||
assert not caplog.records
|
||||
|
||||
|
||||
def test_rename_key_removed_in_with_component_prefixes_warning(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.config_validation"):
|
||||
result = cv.rename_key(
|
||||
"old", "new", removed_in="2026.8.0", component="my_component"
|
||||
)({"old": 5})
|
||||
assert result == {"new": 5}
|
||||
assert (
|
||||
"[my_component] 'old' is deprecated, use 'new'. Will be removed in 2026.8.0"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
|
||||
def test_rename_key_both_keys_rejected() -> None:
|
||||
with pytest.raises(Invalid, match="Cannot specify more than one of"):
|
||||
cv.rename_key("old", "new")({"old": 5, "new": 6})
|
||||
|
||||
|
||||
def test_rename_key_both_keys_rejected_with_removed_in(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with (
|
||||
caplog.at_level(logging.WARNING, logger="esphome.config_validation"),
|
||||
pytest.raises(Invalid, match="Cannot specify more than one of"),
|
||||
):
|
||||
cv.rename_key("old", "new", removed_in="2026.8.0")({"old": 5, "new": 6})
|
||||
assert not caplog.records
|
||||
|
||||
|
||||
def test_file__existing_relative_path(setup_core: Path) -> None:
|
||||
(setup_core / "partitions.csv").write_text("csv\n")
|
||||
|
||||
assert cv.file_("partitions.csv") == setup_core / "partitions.csv"
|
||||
|
||||
|
||||
def test_file__missing_raises(setup_core: Path) -> None:
|
||||
with pytest.raises(Invalid, match="Could not find file"):
|
||||
cv.file_("partitions.csv")
|
||||
|
||||
|
||||
def test_file__remaps_bundle_absolute_path(setup_core: Path) -> None:
|
||||
"""A stale absolute path in an extracted bundle resolves to the bundled copy."""
|
||||
manifest = {
|
||||
"manifest_version": 1,
|
||||
"config_filename": "test.yaml",
|
||||
"config_dir": "/original/config",
|
||||
}
|
||||
(setup_core / "manifest.json").write_text(json.dumps(manifest))
|
||||
(setup_core / "partitions.csv").write_text("csv\n")
|
||||
|
||||
assert cv.file_("/original/config/partitions.csv") == setup_core / "partitions.csv"
|
||||
|
||||
|
||||
def test_file__missing_absolute_path_without_bundle(setup_core: Path) -> None:
|
||||
with pytest.raises(Invalid, match="Could not find file"):
|
||||
cv.file_("/original/config/partitions.csv")
|
||||
|
||||
|
||||
def test_file__remaps_windows_bundle_absolute_path(setup_core: Path) -> None:
|
||||
"""A bundle created on Windows resolves on a host with another layout."""
|
||||
manifest = {
|
||||
"manifest_version": 1,
|
||||
"config_filename": "test.yaml",
|
||||
"config_dir": "C:\\Users\\nick\\esphome",
|
||||
}
|
||||
(setup_core / "manifest.json").write_text(json.dumps(manifest))
|
||||
(setup_core / "partitions.csv").write_text("csv\n")
|
||||
|
||||
result = cv.file_("C:\\Users\\nick\\esphome\\partitions.csv")
|
||||
|
||||
assert result == setup_core / "partitions.csv"
|
||||
|
||||
|
||||
def test_directory_remaps_bundle_absolute_path(setup_core: Path) -> None:
|
||||
"""A stale absolute directory in an extracted bundle resolves to the bundled copy."""
|
||||
manifest = {
|
||||
"manifest_version": 1,
|
||||
"config_filename": "test.yaml",
|
||||
"config_dir": "/original/config",
|
||||
}
|
||||
(setup_core / "manifest.json").write_text(json.dumps(manifest))
|
||||
(setup_core / "headers").mkdir()
|
||||
|
||||
assert cv.directory("/original/config/headers") == setup_core / "headers"
|
||||
|
||||
|
||||
def test_directory_missing_raises(setup_core: Path) -> None:
|
||||
with pytest.raises(Invalid, match="Could not find directory"):
|
||||
cv.directory("/original/config/headers")
|
||||
|
||||
|
||||
def test_file__remapped_path_is_directory_raises(setup_core: Path) -> None:
|
||||
"""A remapped path that is a directory still fails file validation."""
|
||||
manifest = {
|
||||
"manifest_version": 1,
|
||||
"config_filename": "test.yaml",
|
||||
"config_dir": "/original/config",
|
||||
}
|
||||
(setup_core / "manifest.json").write_text(json.dumps(manifest))
|
||||
(setup_core / "headers").mkdir()
|
||||
|
||||
with pytest.raises(Invalid, match="is not a file"):
|
||||
cv.file_("/original/config/headers")
|
||||
|
||||
|
||||
def test_require_platformio_toolchain() -> None:
|
||||
"""Platforms with only the PlatformIO backend reject other toolchains."""
|
||||
validator = cv.require_platformio_toolchain("RP2")
|
||||
CORE.toolchain = None
|
||||
config: dict = {}
|
||||
assert validator(config) is config
|
||||
assert CORE.toolchain == Toolchain.PLATFORMIO
|
||||
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(Invalid, match="Unsupported toolchain 'arduino' for RP2"):
|
||||
validator(config)
|
||||
|
||||
|
||||
def test_check_supported_toolchain_unresolved_is_an_ordering_bug() -> None:
|
||||
"""Calling the check before resolution fails naming the ordering bug,
|
||||
not a user-facing unsupported-toolchain error."""
|
||||
CORE.toolchain = None
|
||||
with pytest.raises(Invalid, match="not resolved before RP2 validation"):
|
||||
cv._check_supported_toolchain("RP2", (Toolchain.PLATFORMIO,))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "minimal_config"),
|
||||
[
|
||||
("host", {}),
|
||||
("rp2", {"board": "rpipicow"}),
|
||||
("bk72xx", {"board": "generic-bk7231n-qfn32-tuya"}),
|
||||
("rtl87xx", {"board": "generic-rtl8710bn-2mb-788k"}),
|
||||
("ln882x", {"board": "generic-ln882h"}),
|
||||
# The legacy stub platform must reject too, not just the chip families
|
||||
("libretiny", {}),
|
||||
],
|
||||
)
|
||||
def test_every_platformio_only_platform_rejects_arduino_toolchain(
|
||||
platform: str, minimal_config: dict
|
||||
) -> None:
|
||||
"""A platform that cannot serve a CLI toolchain rejects it at validation."""
|
||||
module = importlib.import_module(f"esphome.components.{platform}")
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(Invalid, match="Unsupported toolchain 'arduino'"):
|
||||
module.CONFIG_SCHEMA(dict(minimal_config))
|
||||
|
||||
Reference in New Issue
Block a user