Merge remote-tracking branch 'origin/cv-sensitive-redact-sentinel' into integration

This commit is contained in:
J. Nick Koston
2026-05-26 23:51:13 -05:00
211 changed files with 4484 additions and 1257 deletions
@@ -2,6 +2,8 @@
from unittest.mock import patch
import pytest
from esphome.components.display import (
DisplayMetaData,
add_metadata,
@@ -74,8 +76,5 @@ def test_add_metadata_overwrites_existing():
def test_metadata_is_frozen():
"""Test that DisplayMetaData instances are immutable (frozen dataclass)."""
meta = DisplayMetaData(320, 240, True, False)
try:
with pytest.raises(AttributeError):
meta.width = 640
assert False, "Expected FrozenInstanceError"
except AttributeError:
pass
@@ -0,0 +1,71 @@
"""Tests for lvgl automation_schema lazy validate_automation build."""
from __future__ import annotations
from unittest.mock import patch
import esphome.components.lvgl # noqa: F401
from esphome.components.lvgl import schemas as lvgl_schemas
from esphome.components.lvgl.schemas import (
WIDGET_TYPES,
_lazy_validate_automation,
automation_schema,
)
from esphome.components.lvgl.widgets import WidgetType
from esphome.config_validation import GenerateID, declare_id
from esphome.const import CONF_TRIGGER_ID
from esphome.core.config import StartupTrigger
def _widget_type(name: str = "obj") -> WidgetType:
wt = WIDGET_TYPES.get(name)
assert wt is not None, f"widget type {name!r} not registered"
return wt
def _trigger_extra_schema() -> dict:
return {GenerateID(CONF_TRIGGER_ID): declare_id(StartupTrigger)}
def test_lazy_validator_defers_build_until_first_call() -> None:
with patch(
"esphome.components.lvgl.schemas.validate_automation",
wraps=lvgl_schemas.validate_automation,
) as va_mock:
validator = _lazy_validate_automation(_trigger_extra_schema())
assert va_mock.call_count == 0
validator({"then": []})
assert va_mock.call_count == 1
validator({"then": []})
assert va_mock.call_count == 1
def test_eager_build_when_schema_extraction_enabled() -> None:
with (
patch("esphome.components.lvgl.schemas.EnableSchemaExtraction", True),
patch(
"esphome.components.lvgl.schemas.validate_automation",
wraps=lvgl_schemas.validate_automation,
) as va_mock,
):
_lazy_validate_automation(_trigger_extra_schema())
assert va_mock.call_count == 1
def test_lazy_and_eager_produce_equivalent_validation() -> None:
extra = _trigger_extra_schema()
with patch("esphome.components.lvgl.schemas.EnableSchemaExtraction", True):
eager = _lazy_validate_automation(extra)
lazy = _lazy_validate_automation(_trigger_extra_schema())
sample = {"then": []}
assert lazy(sample) == eager(sample)
def test_automation_schema_uses_lazy_validators() -> None:
wt = _widget_type("obj")
with patch(
"esphome.components.lvgl.schemas.validate_automation",
wraps=lvgl_schemas.validate_automation,
) as va_mock:
automation_schema(wt.w_type)
assert va_mock.call_count == 0
@@ -0,0 +1,67 @@
"""Tests for obj_schema() memoization."""
from __future__ import annotations
from collections.abc import Generator
import pytest
import esphome.components.lvgl # noqa: F401
from esphome.components.lvgl import schemas as lvgl_schemas
from esphome.components.lvgl.schemas import WIDGET_TYPES, obj_schema
@pytest.fixture(autouse=True)
def _clear_obj_schema_cache() -> Generator[None]:
cache = getattr(lvgl_schemas, "_OBJ_SCHEMA_CACHE", None)
if cache is not None:
cache.clear()
yield
if cache is not None:
cache.clear()
def _widget_type(name: str = "obj"):
wt = WIDGET_TYPES.get(name)
assert wt is not None, f"widget type {name!r} not registered"
return wt
def test_same_widget_type_returns_same_schema() -> None:
wt = _widget_type("obj")
assert obj_schema(wt) is obj_schema(wt)
def test_different_widget_types_return_different_schemas() -> None:
assert obj_schema(_widget_type("obj")) is not obj_schema(_widget_type("label"))
def test_cache_is_populated_after_first_call() -> None:
wt = _widget_type("obj")
assert id(wt) not in lvgl_schemas._OBJ_SCHEMA_CACHE
obj_schema(wt)
assert id(wt) in lvgl_schemas._OBJ_SCHEMA_CACHE
def test_cached_schema_produces_equivalent_output() -> None:
wt = _widget_type("obj")
cached_result = obj_schema(wt)({})
lvgl_schemas._OBJ_SCHEMA_CACHE.clear()
fresh_result = obj_schema(wt)({})
assert cached_result == fresh_result
def test_id_recycling_is_caught_by_identity_guard() -> None:
wt = _widget_type("obj")
real_schema = obj_schema(wt)
cached_widget_type, _ = lvgl_schemas._OBJ_SCHEMA_CACHE[id(wt)]
sentinel_schema = object()
lvgl_schemas._OBJ_SCHEMA_CACHE[id(wt)] = (cached_widget_type, sentinel_schema)
assert obj_schema(wt) is sentinel_schema
other = _widget_type("label")
lvgl_schemas._OBJ_SCHEMA_CACHE[id(wt)] = (other, sentinel_schema)
rebuilt = obj_schema(wt)
assert rebuilt is not sentinel_schema
assert rebuilt is not real_schema
@@ -0,0 +1,236 @@
"""Tests for part_dict / obj_dict / part_schema / obj_schema mapping contracts.
These guard the dict-merge refactor: the dict helpers must keep returning the
same logical mapping as the chained-extend version produced, and the
corresponding Schema(...) wrappers must accept and reject the same configs.
"""
from __future__ import annotations
from collections.abc import Generator
import pytest
import voluptuous as vol
from esphome import config_validation as cv
import esphome.components.lvgl
from esphome.components.lvgl import (
_theme_schema,
defines as df,
schemas as lvgl_schemas,
)
from esphome.components.lvgl.schemas import (
ALIGN_TO_SCHEMA,
FLAG_SCHEMA,
FULL_STYLE_SCHEMA,
STATE_SCHEMA,
STYLE_SCHEMA,
WIDGET_TYPES,
automation_schema,
obj_dict,
obj_schema,
part_dict,
part_schema,
)
from esphome.components.lvgl.types import LvType
from esphome.components.lvgl.widgets import WidgetType
@pytest.fixture(autouse=True)
def _clear_obj_dict_cache() -> Generator[None]:
cache = getattr(lvgl_schemas, "_OBJ_DICT_CACHE", None)
if cache is not None:
cache.clear()
# The lazily-built theme schema is cached on _build_theme_schema; clear it
# too so each test starts from a clean slate.
build_theme = getattr(esphome.components.lvgl, "_build_theme_schema", None)
if build_theme is not None and hasattr(build_theme, "cache_clear"):
build_theme.cache_clear()
yield
if cache is not None:
cache.clear()
if build_theme is not None and hasattr(build_theme, "cache_clear"):
build_theme.cache_clear()
def _marker_names(mapping) -> set[str]:
"""Return the underlying string names of every voluptuous Marker key."""
names: set[str] = set()
for key in mapping:
if isinstance(key, vol.Marker):
schema = key.schema
if isinstance(schema, str):
names.add(schema)
return names
def _widget_type(name: str = "obj"):
wt = WIDGET_TYPES.get(name)
assert wt is not None, f"widget type {name!r} not registered"
return wt
def test_part_dict_includes_state_flag_and_part_keys() -> None:
parts = ("indicator", "knob")
keys = _marker_names(part_dict(parts))
assert {"indicator", "knob"} <= keys
assert _marker_names(STATE_SCHEMA.schema) <= keys
assert _marker_names(FLAG_SCHEMA.schema) <= keys
def test_obj_dict_extends_part_dict_with_align_automation_state_group() -> None:
wt = _widget_type("obj")
part_keys = _marker_names(part_dict(wt.parts))
obj_keys = _marker_names(obj_dict(wt))
assert part_keys <= obj_keys
assert _marker_names(ALIGN_TO_SCHEMA) <= obj_keys
assert _marker_names(automation_schema(wt.w_type)) <= obj_keys
assert {"state", "group"} <= obj_keys
def test_obj_dict_is_memoized_by_widget_type() -> None:
wt = _widget_type("obj")
first = obj_dict(wt)
second = obj_dict(wt)
assert first is second
# Different widget type, different dict.
assert obj_dict(_widget_type("label")) is not first
def test_part_schema_round_trips_known_state_and_part_settings() -> None:
schema = part_schema(("indicator",))
out = schema(
{
"bg_color": 0x112233,
"checked": {"bg_color": 0x445566},
"indicator": {"bg_color": 0x778899},
}
)
assert out["bg_color"] == 0x112233
assert out["checked"]["bg_color"] == 0x445566
assert out["indicator"]["bg_color"] == 0x778899
def test_part_schema_rejects_unknown_part() -> None:
schema = part_schema(("indicator",))
with pytest.raises(vol.Invalid):
schema({"definitely_not_a_part": {}})
@pytest.mark.parametrize("name", sorted(WIDGET_TYPES))
def test_obj_schema_accepts_empty_config_for_every_widget_type(name: str) -> None:
obj_schema(_widget_type(name))({})
def test_obj_schema_accepts_align_to_and_state_group() -> None:
schema = obj_schema(_widget_type("obj"))
out = schema(
{
df.CONF_ALIGN_TO: {
"id": "some_other_widget",
df.CONF_ALIGN: "TOP_LEFT",
},
"state": {"checked": True},
}
)
assert out[df.CONF_ALIGN_TO][df.CONF_ALIGN] == "LV_ALIGN_TOP_LEFT"
assert out["state"]["checked"] is True
def test_obj_schema_rejects_unknown_top_level_key() -> None:
with pytest.raises(vol.Invalid):
obj_schema(_widget_type("obj"))({"definitely_not_a_real_key": 1})
def test_part_schema_returns_cv_schema_for_extend_callers() -> None:
schema = part_schema(("indicator",))
extended = schema.extend({cv.Optional("extra_key"): cv.string})
out = extended({"extra_key": "value", "bg_color": 0xAABBCC})
assert out["extra_key"] == "value"
assert out["bg_color"] == 0xAABBCC
def test_obj_schema_returns_cv_schema_for_extend_callers() -> None:
schema = obj_schema(_widget_type("obj"))
extended = schema.extend({cv.Optional("extra_key"): cv.string})
extended({"extra_key": "value"})
@pytest.mark.parametrize(
"schema",
[STATE_SCHEMA, FLAG_SCHEMA, STYLE_SCHEMA, FULL_STYLE_SCHEMA],
)
def test_spread_sources_carry_no_extra_schemas(schema: cv.Schema) -> None:
# part_dict / obj_dict reach into .schema and rebuild via cv.Schema(...),
# which silently drops _extra_schemas and any non-default extra/required.
# Lock the invariant so a future add_extra() on these sources fails CI
# instead of quietly removing validation from part/obj/theme schemas.
assert not schema._extra_schemas
assert schema.extra is vol.PREVENT_EXTRA
assert schema.required is False
def test_theme_schema_merges_obj_dict_and_full_style_props() -> None:
# _theme_schema is the riskiest merge: obj_dict(w) and FULL_STYLE_SCHEMA.schema
# share many STYLE_SCHEMA marker instances. Exercise the merged schema
# end-to-end with one key from each side (a STATE_SCHEMA part from obj_dict
# and a FULL_STYLE-only property) to lock the behaviour against future
# regressions in either source.
out = _theme_schema(
{
df.CONF_DARK_MODE: True,
"obj": {
"bg_color": 0x112233,
"checked": {"bg_color": 0x445566},
df.CONF_PAD_ROW: 4,
df.CONF_GRID_CELL_X_ALIGN: "CENTER",
},
}
)
assert out[df.CONF_DARK_MODE] is True
obj_out = out["obj"]
assert obj_out["bg_color"] == 0x112233
assert obj_out["checked"]["bg_color"] == 0x445566
assert obj_out[df.CONF_PAD_ROW] == 4
assert obj_out[df.CONF_GRID_CELL_X_ALIGN] == "LV_GRID_ALIGN_CENTER"
def test_theme_schema_self_heals_when_a_widget_type_is_registered_later() -> None:
# _build_theme_schema is functools.cached on a snapshot of WIDGET_TYPES.
# any_widget_schema explicitly supports external components registering
# widgets lazily, and the device builder revalidates in-process, so a
# widget registered after first use must invalidate the cached snapshot.
_theme_schema({df.CONF_DARK_MODE: True}) # populate the cache
name = "test_self_heal_widget"
assert name not in WIDGET_TYPES
# is_mock=True skips registration side-effects; insert into WIDGET_TYPES
# manually so the next theme call sees the new entry.
WIDGET_TYPES[name] = WidgetType(name, LvType("test_fake_t"), (), is_mock=True)
try:
out = _theme_schema({df.CONF_DARK_MODE: False, name: {"bg_color": 0x010203}})
assert out[name]["bg_color"] == 0x010203
finally:
WIDGET_TYPES.pop(name, None)
@pytest.mark.parametrize(
"schema",
[STATE_SCHEMA, FLAG_SCHEMA, STYLE_SCHEMA, FULL_STYLE_SCHEMA],
)
def test_spread_sources_have_no_top_level_marker_defaults(schema: cv.Schema) -> None:
# _theme_schema merges obj_dict(w) with FULL_STYLE_SCHEMA.schema; on a key
# collision, dict-spread keeps the first source's marker (and its default)
# but the last source's value, whereas .extend() would take both from the
# later source. The two are equivalent today because the overlapping
# markers are the same instances (both derive from STYLE_SCHEMA) and none
# carry a top-level default. Lock that so a future divergent default would
# fail CI rather than silently drift the merged validation.
offenders = [
marker.schema
for marker in schema.schema
if isinstance(marker, vol.Optional) and marker.default is not vol.UNDEFINED
]
assert not offenders, f"top-level Optional with default: {offenders}"
@@ -510,15 +510,9 @@ def test_package_merge_by_missing_id() -> None:
],
}
error_raised = False
try:
with pytest.raises(cv.Invalid) as exc_info:
packages_pass(config)
assert False, "Expected validation error for missing ID"
except cv.Invalid as err:
error_raised = True
assert err.path == [CONF_SENSOR, 2]
assert error_raised
assert exc_info.value.path == [CONF_SENSOR, 2]
def test_package_list_remove_by_id() -> None:
+1
View File
@@ -0,0 +1 @@
"""Tests for the time component."""
+369
View File
@@ -0,0 +1,369 @@
"""Tests for time component – ha-timezone branch changes.
Covers:
- detect_tz() platform guard (returns None for unsupported platforms)
- detect_tz() result caching (avoids duplicate log messages)
- detect_tz() error paths (tzlocal None, tzdata missing)
- validate_tz() accepts/rejects POSIX timezone strings and IANA keys
- TIME_SCHEMA: timezone is now truly optional (was SplitDefault)
- homeassistant/time: USE_HOMEASSISTANT_TIMEZONE define emitted iff
CONF_TIMEZONE is absent from the config
"""
from __future__ import annotations
from unittest import mock
import pytest
from esphome.components.time import DOMAIN, TIME_SCHEMA, detect_tz, validate_tz
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_TIMEZONE,
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
Platform,
PlatformFramework,
)
from esphome.core import CORE, EsphomeError
from tests.component_tests.types import SetCoreConfigCallable
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# A minimal TZif v2/v3 file that encodes "EST5EDT" as the footer line.
# The binary content is not validated at this level – what matters is that
# _extract_tz_string() picks up the last-but-one newline-terminated line.
_FAKE_TZFILE = b"\x00" * 44 + b"TZif2\x00" * 1 + b"\n" + b"EST5EDT,M3.2.0,M11.1.0\n"
def _set_platform(platform: Platform) -> None:
"""Set CORE.data so that CORE.target_platform returns *platform*."""
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: platform,
KEY_TARGET_FRAMEWORK: "arduino",
}
# ---------------------------------------------------------------------------
# detect_tz – platform guard
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"platform_framework",
[
PlatformFramework.NRF52_ZEPHYR,
],
)
def test_detect_tz_returns_none_for_unsupported_platform(
platform_framework: PlatformFramework,
set_core_config: SetCoreConfigCallable,
) -> None:
"""detect_tz() must return None for platforms that do not support TZ auto-detection."""
set_core_config(platform_framework)
result = detect_tz()
assert result is None
@pytest.mark.parametrize(
"platform_framework",
[
PlatformFramework.ESP32_IDF,
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP8266_ARDUINO,
PlatformFramework.RP2040_ARDUINO,
PlatformFramework.BK72XX_ARDUINO,
PlatformFramework.RTL87XX_ARDUINO,
PlatformFramework.LN882X_ARDUINO,
PlatformFramework.HOST_NATIVE,
],
)
def test_detect_tz_calls_tzlocal_for_supported_platform(
platform_framework: PlatformFramework,
set_core_config: SetCoreConfigCallable,
) -> None:
"""detect_tz() must call tzlocal for every supported platform."""
set_core_config(platform_framework)
with (
mock.patch(
"esphome.components.time.tzlocal.get_localzone_name",
return_value="America/New_York",
),
mock.patch(
"esphome.components.time._load_tzdata",
return_value=_FAKE_TZFILE,
),
):
result = detect_tz()
assert result is not None
assert isinstance(result, str)
assert len(result) > 0
# ---------------------------------------------------------------------------
# detect_tz – caching
# ---------------------------------------------------------------------------
def test_detect_tz_caches_result(
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
) -> None:
"""detect_tz() must cache the TZ string after the first call so that
subsequent invocations (e.g. when multiple time platforms are configured)
skip tzlocal and avoid duplicate INFO messages."""
set_core_config(PlatformFramework.ESP32_IDF)
with (
mock.patch(
"esphome.components.time.tzlocal.get_localzone_name",
return_value="America/New_York",
) as mock_tz,
mock.patch(
"esphome.components.time._load_tzdata",
return_value=_FAKE_TZFILE,
) as mock_load,
):
first = detect_tz()
second = detect_tz()
assert first == second
# tzlocal and _load_tzdata must be called exactly once despite two detect_tz() calls
mock_tz.assert_called_once()
mock_load.assert_called_once()
def test_detect_tz_cache_stored_in_core_data(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The cached TZ string should be stored under CORE.data[DOMAIN][CONF_TIMEZONE]."""
set_core_config(PlatformFramework.ESP32_IDF)
with (
mock.patch(
"esphome.components.time.tzlocal.get_localzone_name",
return_value="Europe/London",
),
mock.patch(
"esphome.components.time._load_tzdata",
return_value=_FAKE_TZFILE,
),
):
result = detect_tz()
assert CORE.data.get(DOMAIN, {}).get(CONF_TIMEZONE) == result
def test_detect_tz_returns_pre_seeded_cache(
set_core_config: SetCoreConfigCallable,
) -> None:
"""If CORE.data already has a cached TZ string, detect_tz() must return it
without calling tzlocal at all."""
set_core_config(PlatformFramework.ESP32_IDF)
CORE.data[DOMAIN] = {CONF_TIMEZONE: "CET-1CEST,M3.5.0,M10.5.0/3"}
with mock.patch("esphome.components.time.tzlocal.get_localzone_name") as mock_tz:
result = detect_tz()
assert result == "CET-1CEST,M3.5.0,M10.5.0/3"
mock_tz.assert_not_called()
# ---------------------------------------------------------------------------
# detect_tz – error paths
# ---------------------------------------------------------------------------
def test_detect_tz_raises_when_tzlocal_returns_none(
set_core_config: SetCoreConfigCallable,
) -> None:
"""detect_tz() must raise EsphomeError when the local timezone cannot be determined."""
set_core_config(PlatformFramework.ESP32_IDF)
with (
mock.patch(
"esphome.components.time.tzlocal.get_localzone_name",
return_value=None,
),
pytest.raises(EsphomeError, match="Could not automatically determine timezone"),
):
detect_tz()
def test_detect_tz_raises_when_tzdata_not_found(
set_core_config: SetCoreConfigCallable,
) -> None:
"""detect_tz() must raise EsphomeError when tzdata has no entry for the IANA key."""
set_core_config(PlatformFramework.ESP32_IDF)
with (
mock.patch(
"esphome.components.time.tzlocal.get_localzone_name",
return_value="Antarctica/Troll",
),
mock.patch(
"esphome.components.time._load_tzdata",
return_value=None,
),
pytest.raises(EsphomeError, match="Could not automatically determine timezone"),
):
detect_tz()
# ---------------------------------------------------------------------------
# validate_tz
# ---------------------------------------------------------------------------
def test_validate_tz_accepts_valid_posix_string() -> None:
"""validate_tz() must accept a syntactically valid POSIX TZ string."""
result = validate_tz("UTC0")
assert result == "UTC0"
def test_validate_tz_accepts_posix_string_with_dst() -> None:
"""validate_tz() must accept a full POSIX TZ string with DST rules."""
tz = "EST5EDT,M3.2.0,M11.1.0"
result = validate_tz(tz)
assert result == tz
def test_validate_tz_accepts_iana_key_and_converts() -> None:
"""validate_tz() must accept an IANA timezone key and return the POSIX string."""
with mock.patch(
"esphome.components.time._load_tzdata",
return_value=_FAKE_TZFILE,
):
result = validate_tz("America/New_York")
# Should have been converted from IANA to POSIX via _extract_tz_string
assert result == "EST5EDT,M3.2.0,M11.1.0"
def test_validate_tz_rejects_invalid_posix_string() -> None:
"""validate_tz() must raise cv.Invalid for a malformed POSIX TZ string."""
with pytest.raises(cv.Invalid, match="Invalid POSIX timezone string"):
validate_tz("NOTAVALIDTZ!!!")
def test_validate_tz_accepts_empty_string() -> None:
"""An empty string is accepted by validate_tz() and signals 'disable timezone'."""
result = validate_tz("")
assert result == ""
# ---------------------------------------------------------------------------
# TIME_SCHEMA – timezone is now cv.Optional (no SplitDefault)
# ---------------------------------------------------------------------------
def test_time_schema_timezone_is_optional(
set_core_config: SetCoreConfigCallable,
) -> None:
"""TIME_SCHEMA must accept a config with no timezone key on a supported platform."""
set_core_config(PlatformFramework.ESP32_IDF)
# Should not raise
config = TIME_SCHEMA({})
assert CONF_TIMEZONE not in config
def test_time_schema_explicit_timezone_accepted(
set_core_config: SetCoreConfigCallable,
) -> None:
"""TIME_SCHEMA must accept an explicit valid POSIX timezone on Arduino/IDF."""
set_core_config(PlatformFramework.ESP32_IDF)
config = TIME_SCHEMA({CONF_TIMEZONE: "UTC0"})
assert config[CONF_TIMEZONE] == "UTC0"
def test_time_schema_explicit_empty_timezone_accepted(
set_core_config: SetCoreConfigCallable,
) -> None:
"""An empty timezone string (timezone-disable sentinel) must pass TIME_SCHEMA."""
set_core_config(PlatformFramework.ESP32_IDF)
config = TIME_SCHEMA({CONF_TIMEZONE: ""})
assert config[CONF_TIMEZONE] == ""
def test_time_schema_timezone_rejected_on_zephyr(
set_core_config: SetCoreConfigCallable,
) -> None:
"""TIME_SCHEMA must reject a timezone value on Zephyr with the framework error.
The platform check (cv.only_with_framework) must run BEFORE validate_tz so
that users receive an actionable "unsupported framework" message rather than a
confusing TZ-parsing error.
"""
set_core_config(PlatformFramework.NRF52_ZEPHYR)
with pytest.raises(cv.Invalid, match="only available with framework"):
TIME_SCHEMA({CONF_TIMEZONE: "UTC0"})
def test_time_schema_invalid_tz_on_zephyr_gives_framework_error(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Even a syntactically invalid TZ string must produce the framework error on Zephyr.
This specifically tests that cv.only_with_framework is evaluated before
validate_tz: if the order were reversed, an invalid POSIX string would
generate a misleading TZ-parsing error instead.
"""
set_core_config(PlatformFramework.NRF52_ZEPHYR)
with pytest.raises(cv.Invalid, match="only available with framework"):
TIME_SCHEMA({CONF_TIMEZONE: "NOTAVALIDTZ!!!"})
# ---------------------------------------------------------------------------
# homeassistant/time: USE_HOMEASSISTANT_TIMEZONE define
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_ha_cg():
"""Mock codegen functions used by homeassistant/time to_code."""
with (
mock.patch(
"esphome.components.homeassistant.time.cg.new_Pvariable",
return_value=mock.MagicMock(),
),
mock.patch(
"esphome.components.homeassistant.time.cg.add_define",
) as mock_add_define,
mock.patch(
"esphome.components.homeassistant.time.cg.register_component",
new_callable=mock.AsyncMock,
),
mock.patch(
"esphome.components.homeassistant.time.time_.register_time",
new_callable=mock.AsyncMock,
),
):
yield mock_add_define
@pytest.mark.asyncio
async def test_ha_time_defines_ha_timezone_when_no_explicit_tz(mock_ha_cg) -> None:
"""When CONF_TIMEZONE is absent from the config, to_code() must call
cg.add_define('USE_HOMEASSISTANT_TIMEZONE')."""
from esphome.components.homeassistant.time import to_code
await to_code({CONF_ID: mock.MagicMock()})
mock_ha_cg.assert_any_call("USE_HOMEASSISTANT_TIMEZONE")
@pytest.mark.asyncio
async def test_ha_time_no_ha_timezone_define_when_explicit_tz(mock_ha_cg) -> None:
"""When CONF_TIMEZONE is present in the config, to_code() must NOT call
cg.add_define('USE_HOMEASSISTANT_TIMEZONE')."""
from esphome.components.homeassistant.time import to_code
await to_code({CONF_ID: mock.MagicMock(), CONF_TIMEZONE: "UTC0"})
define_calls = [call.args[0] for call in mock_ha_cg.call_args_list]
assert "USE_HOMEASSISTANT_TIME" in define_calls
assert "USE_HOMEASSISTANT_TIMEZONE" not in define_calls
+14 -14
View File
@@ -120,12 +120,12 @@ api:
lambda: 'return condition;'
then:
- logger.log:
format: "Condition true, value: %d"
args: ['value']
format: "Condition true, value: %ld"
args: ['(long) value']
else:
- logger.log:
format: "Condition false, value: %d"
args: ['value']
format: "Condition false, value: %ld"
args: ['(long) value']
- logger.log: "After if/else"
# Test nested IfAction (multiple ContinuationAction instances)
- action: test_nested_if
@@ -171,8 +171,8 @@ api:
count: !lambda 'return count;'
then:
- logger.log:
format: "Repeat iteration: %d"
args: ['iteration']
format: "Repeat iteration: %lu"
args: ['(unsigned long) iteration']
- logger.log: "After repeat"
# Test combined continuations (if + while + repeat)
- action: test_combined_continuations
@@ -193,8 +193,8 @@ api:
lambda: 'return id(api_continuation_test_counter) > 0;'
then:
- logger.log:
format: "Combined: repeat=%d, while=%d"
args: ['iteration', 'id(api_continuation_test_counter)']
format: "Combined: repeat=%lu, while=%d"
args: ['(unsigned long) iteration', 'id(api_continuation_test_counter)']
- lambda: 'id(api_continuation_test_counter)--;'
else:
- logger.log: "Skipped loops"
@@ -208,8 +208,8 @@ api:
- api.respond:
success: true
- logger.log:
format: "Status response sent (call_id=%d)"
args: [call_id]
format: "Status response sent (call_id=%lu)"
args: ['(unsigned long) call_id']
- action: test_respond_status_error
variables:
@@ -229,8 +229,8 @@ api:
value: float
then:
- logger.log:
format: "Optional response (call_id=%d, return_response=%d)"
args: [call_id, return_response]
format: "Optional response (call_id=%lu, return_response=%lu)"
args: ['(unsigned long) call_id', '(unsigned long) return_response']
- api.respond:
data: !lambda |-
root["sensor"] = sensor_name;
@@ -264,8 +264,8 @@ api:
input: string
then:
- logger.log:
format: "Only response (call_id=%d)"
args: [call_id]
format: "Only response (call_id=%lu)"
args: ['(unsigned long) call_id']
- api.respond:
data: !lambda |-
root["input"] = input;
@@ -0,0 +1,11 @@
audio_file:
- id: test_audio
file:
type: local
path: $component_dir/test.wav
media_source:
- platform: audio_file
id: audio_file_source
# task_stack_in_psram: false must validate without a psram: component
task_stack_in_psram: false
@@ -1,6 +1,8 @@
<<: !include common.yaml
esp32_ble_tracker:
esp32_ble:
max_connections: 9
bluetooth_proxy:
@@ -3,6 +3,7 @@ esp32_hosted:
slot: 1
active_high: true
reset_pin: GPIO15
use_psram: true
cmd_pin: GPIO13
clk_pin: GPIO12
d0_pin: GPIO11
+2
View File
@@ -2,6 +2,8 @@ esphome:
debug_scheduler: true
platformio_options:
board_build.flash_mode: dio
build_flags:
- "-DESPHOME_TEST_BUILD_FLAG"
environment_variables:
TEST_ENV_VAR: "test_value"
BUILD_NUMBER: "12345"
+1
View File
@@ -1313,6 +1313,7 @@ lvgl:
width: 6
start_value: 0
end_value: 360
rounded: true
- id: page3
layout: Horizontal
pad_all: 6px
@@ -1,3 +1,6 @@
psram:
mode: quad
i2s_audio:
i2s_lrclk_pin: GPIO18
i2s_bclk_pin: GPIO19
@@ -12,6 +15,7 @@ microphone:
micro_wake_word:
microphone: echo_microphone
task_stack_in_psram: true
on_wake_word_detected:
- logger.log: "Wake word detected"
- micro_wake_word.stop:
@@ -0,0 +1,165 @@
#include "../common.h"
namespace esphome::mitsubishi_cn105::testing {
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeOffLeavesTraitsEmpty) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF);
EXPECT_FALSE(sut.traits().get_supports_swing_modes());
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeVerticalExposesOffAndVertical) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeHorizontalExposesOffAndHorizontal) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeBothExposesAllExpectedModes) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsVerticalSwingWhenSupported) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_VERTICAL);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsHorizontalSwingWhenSupported) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_HORIZONTAL);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsBothSwingWhenSupported) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsSwingOffWhenNoSwingActive) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_3;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesRemembersLastNonSwingPositions) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::RIGHT;
sut.apply_values_();
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4);
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4);
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT);
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesDoesNotOverwriteRememberedPositionWithUnknownValues) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.last_non_swing_vane_mode_ = MitsubishiCN105::VaneMode::POSITION_2;
sut.last_non_swing_wide_vane_mode_ = MitsubishiCN105::WideVaneMode::LEFT;
sut.status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::UNKNOWN;
sut.apply_values_();
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_2);
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::LEFT);
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedVerticalSwingState) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedHorizontalSwingState) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
}
} // namespace esphome::mitsubishi_cn105::testing
@@ -8,6 +8,7 @@
#include <vector>
#include "esphome/components/uart/uart_component.h"
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105.h"
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h"
namespace esphome::mitsubishi_cn105::testing {
@@ -44,6 +45,7 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 {
using MitsubishiCN105::State;
using MitsubishiCN105::UpdateFlag;
using MitsubishiCN105::state_;
using MitsubishiCN105::status_;
using MitsubishiCN105::operation_start_ms_;
using MitsubishiCN105::use_temperature_encoding_b_;
using MitsubishiCN105::set_wide_vane_high_bit_;
@@ -58,4 +60,13 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 {
void set_current_time(uint32_t ms) { test_loop_time_ms = ms; }
};
class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate {
public:
using MitsubishiCN105Climate::apply_values_;
using MitsubishiCN105Climate::last_non_swing_vane_mode_;
using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_;
MitsubishiCN105::Status &status() { return static_cast<TestableMitsubishiCN105 &>(this->hp_).status_; }
};
} // namespace esphome::mitsubishi_cn105::testing
@@ -3,6 +3,9 @@ climate:
id: ac
name: "AC Test"
uart_id: uart_bus
update_interval: 30s
current_temperature_min_interval: 120s
supported_swing_modes: BOTH
esphome:
on_boot:
+4
View File
@@ -16,8 +16,12 @@ speaker:
id: speaker_id
dac_type: external
i2s_dout_pin: ${dout_pin}
bits_per_sample: 32bit
channel: stereo
- platform: mixer
output_speaker: speaker_id
bits_per_sample: 32
num_channels: 2
source_speakers:
- id: source_speaker_1_id
- id: source_speaker_2_id
+40
View File
@@ -0,0 +1,40 @@
esphome:
on_boot:
then:
- router.speaker.switch_output:
id: router_id
target_speaker: speaker_b_id
# id omitted: auto-resolved since there's a single router instance
- router.speaker.switch_output:
target_speaker: !lambda return id(speaker_a_id);
i2s_audio:
i2s_lrclk_pin: ${a_lrclk_pin}
i2s_bclk_pin: ${a_bclk_pin}
speaker:
- platform: i2s_audio
id: speaker_a_id
dac_type: external
i2s_dout_pin: ${a_dout_pin}
sample_rate: 48000
bits_per_sample: 16bit
channel: stereo
- platform: i2s_audio
id: speaker_b_id
dac_type: external
i2s_dout_pin: ${b_dout_pin}
spdif_mode: true
use_apll: true
sample_rate: 48000
bits_per_sample: 16bit
channel: stereo
i2s_mode: primary
- platform: router
id: router_id
output_speakers:
- speaker_a_id
- speaker_b_id
sample_rate: 48000
bits_per_sample: 16
num_channels: 2
@@ -0,0 +1,7 @@
substitutions:
a_lrclk_pin: GPIO4
a_bclk_pin: GPIO5
a_dout_pin: GPIO14
b_dout_pin: GPIO19
<<: !include common.yaml
@@ -1,4 +1,5 @@
rp2040:
variant: rp2040
enable_full_printf: false
logger:
@@ -0,0 +1,6 @@
rp2040:
variant: rp2350
enable_full_printf: false
logger:
level: VERBOSE
+3 -7
View File
@@ -34,9 +34,7 @@ def test_get_base_frontend_path_dev_mode() -> None:
# The function uses Path.resolve() which resolves symlinks
# The actual function adds "/" to the path, so we simulate that
test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/"
expected = (
Path(os.getcwd()) / test_path_with_slash / "esphome_dashboard"
).resolve()
expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve()
assert result == expected
@@ -62,9 +60,7 @@ def test_get_base_frontend_path_dev_mode_relative_path() -> None:
# The function uses Path.resolve() which resolves symlinks
# The actual function adds "/" to the path, so we simulate that
test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/"
expected = (
Path(os.getcwd()) / test_path_with_slash / "esphome_dashboard"
).resolve()
expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve()
assert result == expected
assert result.is_absolute()
@@ -157,7 +153,7 @@ def test_load_file_path(tmp_path: Path) -> None:
test_file = tmp_path / "test.txt"
test_file.write_bytes(b"test content")
with open(test_file, "rb") as f:
with test_file.open("rb") as f:
content = f.read()
assert content == b"test content"
+5 -3
View File
@@ -79,7 +79,7 @@ def shared_platformio_cache() -> Generator[Path]:
lock_file = Path.home() / ".esphome-integration-tests-init.lock"
# Always acquire the lock to ensure cache is ready before proceeding
with open(lock_file, "w") as lock_fd:
with lock_file.open("w") as lock_fd:
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX)
# Check if the native platform is installed (the actual indicator of a populated cache)
@@ -407,8 +407,10 @@ async def wait_and_connect_api_client(
# Wait for connection with timeout
try:
await asyncio.wait_for(connected_future, timeout=timeout)
except TimeoutError:
raise TimeoutError(f"Failed to connect to API after {timeout} seconds")
except TimeoutError as err:
raise TimeoutError(
f"Failed to connect to API after {timeout} seconds"
) from err
if return_disconnect_event:
yield client, disconnect_event
@@ -43,7 +43,7 @@ async def test_gpio_expander_cache(
# ensure logs are in the expected order
log_order = [
(digital_read_hw_pattern, 0),
[(digital_read_cache_pattern, i) for i in range(0, 8)],
[(digital_read_cache_pattern, i) for i in range(8)],
(digital_read_hw_pattern, 8),
[(digital_read_cache_pattern, i) for i in range(8, 16)],
(digital_read_hw_pattern, 16),
@@ -68,7 +68,7 @@ async def test_gpio_expander_cache(
# uint16_t component tests (single bank of 16 pins)
(uint16_read_hw_pattern, 0), # First pin triggers hw read
[
(uint16_read_cache_pattern, i) for i in range(0, 16)
(uint16_read_cache_pattern, i) for i in range(16)
], # All 16 pins return via cache
# After cache reset
(uint16_read_hw_pattern, 5), # First read after reset triggers hw
+66 -3
View File
@@ -3,8 +3,11 @@
from __future__ import annotations
import ast
import importlib.util
from pathlib import Path
from esphome import config_validation as cv
SCRIPT_PATH = (
Path(__file__).resolve().parent.parent.parent
/ "script"
@@ -12,10 +15,16 @@ SCRIPT_PATH = (
)
def _load_script_module():
spec = importlib.util.spec_from_file_location("build_language_schema", SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _extract_sort_obj():
# build_language_schema.py runs argparse, loads every component, and
# calls build_schema() at import time, so a plain import isn't viable
# in a unit test. Pull just the pure helper out via AST instead.
# ``sort_obj`` is pure and self-contained; pulling it via AST avoids
# exercising the module-level component-loading state for these tests.
tree = ast.parse(SCRIPT_PATH.read_text())
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name == "sort_obj":
@@ -27,6 +36,7 @@ def _extract_sort_obj():
sort_obj = _extract_sort_obj()
_bls = _load_script_module()
def test_sort_obj_sorts_dict_keys() -> None:
@@ -96,3 +106,56 @@ def test_sort_obj_passes_through_scalars() -> None:
assert sort_obj(42) == 42
assert sort_obj(None) is None
assert sort_obj(True) is True
def test_convert_emits_explicit_sensitive_marker() -> None:
config_var: dict = {}
_bls.convert(cv.sensitive(cv.string), config_var, "/test")
assert config_var["sensitive"] is True
assert config_var["sensitive_source"] == "explicit"
assert config_var["type"] == "string"
def test_convert_keys_emits_heuristic_sensitive_marker() -> None:
converted: dict = {}
_bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root")
entry = converted["schema"]["config_vars"]["password"]
assert entry["sensitive"] is True
assert entry["sensitive_source"] == "heuristic"
assert entry["type"] == "string"
def test_convert_keys_explicit_beats_heuristic() -> None:
# Key name matches a fragment but the validator is explicitly wrapped;
# the explicit branch should win and emit ``sensitive_source: explicit``.
converted: dict = {}
_bls.convert_keys(
converted, {cv.Optional("password"): cv.sensitive(cv.string)}, "/root"
)
entry = converted["schema"]["config_vars"]["password"]
assert entry["sensitive"] is True
assert entry["sensitive_source"] == "explicit"
def test_convert_keys_no_heuristic_for_non_string_leaves() -> None:
# Even though the key contains a fragment, a non-string leaf must not
# be flagged. Prevents false positives on unrelated fields whose name
# happens to embed a substring like "token".
converted: dict = {}
_bls.convert_keys(converted, {cv.Optional("password"): cv.boolean}, "/root")
entry = converted["schema"]["config_vars"]["password"]
assert "sensitive" not in entry
assert "sensitive_source" not in entry
def test_convert_keys_no_marker_for_non_sensitive_field() -> None:
converted: dict = {}
_bls.convert_keys(converted, {cv.Optional("hostname"): cv.string}, "/root")
entry = converted["schema"]["config_vars"]["hostname"]
assert "sensitive" not in entry
assert "sensitive_source" not in entry
+2 -5
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import importlib.util
import json
import os
from pathlib import Path
import sys
from unittest.mock import patch
@@ -13,12 +12,10 @@ import pytest
# Load the script-under-test as `check_import_time` (it's a hyphenated path
# inside `script/` that mirrors the existing `determine_jobs` pattern).
script_dir = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "script")
)
script_dir = str((Path(__file__).parent / ".." / ".." / "script").resolve())
sys.path.insert(0, script_dir)
spec = importlib.util.spec_from_file_location(
"check_import_time", os.path.join(script_dir, "check_import_time.py")
"check_import_time", str(Path(script_dir) / "check_import_time.py")
)
check_import_time = importlib.util.module_from_spec(spec)
spec.loader.exec_module(check_import_time)
+2 -5
View File
@@ -3,7 +3,6 @@
from collections.abc import Generator
import importlib.util
import json
import os
from pathlib import Path
import sys
from unittest.mock import Mock, call, patch
@@ -11,9 +10,7 @@ from unittest.mock import Mock, call, patch
import pytest
# Add the script directory to Python path so we can import the module
script_dir = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "script")
)
script_dir = str((Path(__file__).parent / ".." / ".." / "script").resolve())
sys.path.insert(0, script_dir)
# Import helpers module for patching
@@ -22,7 +19,7 @@ import helpers # noqa: E402
import script.helpers # noqa: E402
spec = importlib.util.spec_from_file_location(
"determine_jobs", os.path.join(script_dir, "determine-jobs.py")
"determine_jobs", str(Path(script_dir) / "determine-jobs.py")
)
determine_jobs = importlib.util.module_from_spec(spec)
spec.loader.exec_module(determine_jobs)
+1 -3
View File
@@ -12,9 +12,7 @@ import pytest
from pytest import MonkeyPatch
# Add the script directory to Python path so we can import helpers
sys.path.insert(
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "script"))
)
sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve()))
import helpers # noqa: E402
+1 -4
View File
@@ -1,6 +1,5 @@
"""Unit tests for script/build_helpers.py manifest override and build helpers."""
import os
from pathlib import Path
import sys
import textwrap
@@ -9,9 +8,7 @@ from unittest.mock import MagicMock, patch
import pytest
# Add the script directory to Python path so we can import build_helpers
sys.path.insert(
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "script"))
)
sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve()))
import build_helpers # noqa: E402
@@ -4,6 +4,7 @@ esphome:
esp32:
board: esp32-c6-devkitc-1
flash_size: 8MB
framework:
type: esp-idf
+65 -2
View File
@@ -1,6 +1,11 @@
"""Tests for RP2040 component public helpers."""
"""Tests for RP2040 component public helpers and variant detection."""
from esphome.components.rp2040 import board_id_has_wifi
import pytest
from esphome.components.rp2040 import _detect_variant, board_id_has_wifi
from esphome.components.rp2040.const import VARIANT_RP2040, VARIANT_RP2350
import esphome.config_validation as cv
from esphome.const import CONF_BOARD, CONF_VARIANT
def test_board_id_has_wifi_for_known_wifi_board() -> None:
@@ -27,3 +32,61 @@ def test_board_id_has_wifi_for_unknown_board_returns_true() -> None:
"no CYW43" guard at compile time.
"""
assert board_id_has_wifi("not-a-real-board-id") is True
def test_detect_variant_derives_variant_from_board() -> None:
"""Board alone resolves to the matching variant."""
result = _detect_variant({CONF_BOARD: "rpipicow"})
assert result[CONF_BOARD] == "rpipicow"
assert result[CONF_VARIANT] == VARIANT_RP2040
def test_detect_variant_derives_variant_from_rp2350_board() -> None:
"""An RP2350 board resolves to ``RP2350``."""
result = _detect_variant({CONF_BOARD: "rpipico2"})
assert result[CONF_BOARD] == "rpipico2"
assert result[CONF_VARIANT] == VARIANT_RP2350
def test_detect_variant_only_picks_default_board_rp2040() -> None:
"""Variant alone picks Pico W as the canonical RP2040 board."""
result = _detect_variant({CONF_VARIANT: VARIANT_RP2040})
assert result[CONF_BOARD] == "rpipicow"
assert result[CONF_VARIANT] == VARIANT_RP2040
def test_detect_variant_only_picks_default_board_rp2350() -> None:
"""Variant alone picks Pico 2 W as the canonical RP2350 board."""
result = _detect_variant({CONF_VARIANT: VARIANT_RP2350})
assert result[CONF_BOARD] == "rpipico2w"
assert result[CONF_VARIANT] == VARIANT_RP2350
def test_detect_variant_matching_explicit_variant_passes() -> None:
"""Specifying both a board and the matching variant is allowed."""
result = _detect_variant({CONF_BOARD: "rpipico2", CONF_VARIANT: VARIANT_RP2350})
assert result[CONF_BOARD] == "rpipico2"
assert result[CONF_VARIANT] == VARIANT_RP2350
def test_detect_variant_mismatched_variant_raises() -> None:
"""Board/variant mismatch must be rejected and name the offending board."""
with pytest.raises(
cv.Invalid, match=r"does not match the selected board 'rpipicow'"
):
_detect_variant({CONF_BOARD: "rpipicow", CONF_VARIANT: VARIANT_RP2350})
def test_detect_variant_unknown_board_without_variant_raises() -> None:
"""Unknown board with no variant tells the user how to recover."""
with pytest.raises(cv.Invalid, match="please specify the chip variant"):
_detect_variant({CONF_BOARD: "not-a-real-board"})
def test_detect_variant_unknown_board_with_variant_passes() -> None:
"""Unknown board + explicit variant is accepted (with a warning)."""
result = _detect_variant(
{CONF_BOARD: "not-a-real-board", CONF_VARIANT: VARIANT_RP2040}
)
assert result[CONF_BOARD] == "not-a-real-board"
assert result[CONF_VARIANT] == VARIANT_RP2040
+2 -2
View File
@@ -70,11 +70,11 @@ def test_numeric_offset_slash() -> None:
def test_star() -> None:
assert _parse_cron_part("*", 0, 59, {}) == set(range(0, 60))
assert _parse_cron_part("*", 0, 59, {}) == set(range(60))
def test_question() -> None:
assert _parse_cron_part("?", 0, 59, {}) == set(range(0, 60))
assert _parse_cron_part("?", 0, 59, {}) == set(range(60))
def test_range() -> None:
+10 -10
View File
@@ -486,7 +486,7 @@ def test_preload_core_config_basic(setup_core: Path) -> None:
assert CONF_BUILD_PATH in config[CONF_ESPHOME]
# Verify default build path is "build/<device_name>"
build_path = config[CONF_ESPHOME][CONF_BUILD_PATH]
assert build_path.endswith(os.path.join("build", "test_device"))
assert build_path.endswith(str(Path("build") / "test_device"))
def test_preload_core_config_with_build_path(setup_core: Path) -> None:
@@ -523,7 +523,7 @@ def test_preload_core_config_env_build_path(setup_core: Path) -> None:
assert "test_device" in config[CONF_ESPHOME][CONF_BUILD_PATH]
# Verify it uses the env var path with device name appended
build_path = config[CONF_ESPHOME][CONF_BUILD_PATH]
expected_path = os.path.join("/env/build", "test_device")
expected_path = str(Path("/env/build") / "test_device")
assert build_path == expected_path or build_path == expected_path.replace(
"/", os.sep
)
@@ -739,7 +739,7 @@ async def test_add_includes_with_single_file(
"""Test add_includes copies a single header file to build directory."""
CORE.config_path = tmp_path / "config.yaml"
CORE.build_path = tmp_path / "build"
os.makedirs(CORE.build_path, exist_ok=True)
CORE.build_path.mkdir(parents=True, exist_ok=True)
# Create include file
include_file = tmp_path / "my_header.h"
@@ -769,7 +769,7 @@ async def test_add_includes_with_directory_unix(
"""Test add_includes copies all files from a directory on Unix."""
CORE.config_path = tmp_path / "config.yaml"
CORE.build_path = tmp_path / "build"
os.makedirs(CORE.build_path, exist_ok=True)
CORE.build_path.mkdir(parents=True, exist_ok=True)
# Create include directory with files
include_dir = tmp_path / "includes"
@@ -814,7 +814,7 @@ async def test_add_includes_with_directory_windows(
"""Test add_includes copies all files from a directory on Windows."""
CORE.config_path = tmp_path / "config.yaml"
CORE.build_path = tmp_path / "build"
os.makedirs(CORE.build_path, exist_ok=True)
CORE.build_path.mkdir(parents=True, exist_ok=True)
# Create include directory with files
include_dir = tmp_path / "includes"
@@ -856,7 +856,7 @@ async def test_add_includes_with_multiple_sources(
"""Test add_includes with multiple files and directories."""
CORE.config_path = tmp_path / "config.yaml"
CORE.build_path = tmp_path / "build"
os.makedirs(CORE.build_path, exist_ok=True)
CORE.build_path.mkdir(parents=True, exist_ok=True)
# Create various include sources
single_file = tmp_path / "single.h"
@@ -884,7 +884,7 @@ async def test_add_includes_empty_directory(
"""Test add_includes with an empty directory doesn't fail."""
CORE.config_path = tmp_path / "config.yaml"
CORE.build_path = tmp_path / "build"
os.makedirs(CORE.build_path, exist_ok=True)
CORE.build_path.mkdir(parents=True, exist_ok=True)
# Create empty directory
empty_dir = tmp_path / "empty"
@@ -906,7 +906,7 @@ async def test_add_includes_preserves_directory_structure_unix(
"""Test that add_includes preserves relative directory structure on Unix."""
CORE.config_path = tmp_path / "config.yaml"
CORE.build_path = tmp_path / "build"
os.makedirs(CORE.build_path, exist_ok=True)
CORE.build_path.mkdir(parents=True, exist_ok=True)
# Create nested directory structure
lib_dir = tmp_path / "lib"
@@ -940,7 +940,7 @@ async def test_add_includes_preserves_directory_structure_windows(
"""Test that add_includes preserves relative directory structure on Windows."""
CORE.config_path = tmp_path / "config.yaml"
CORE.build_path = tmp_path / "build"
os.makedirs(CORE.build_path, exist_ok=True)
CORE.build_path.mkdir(parents=True, exist_ok=True)
# Create nested directory structure
lib_dir = tmp_path / "lib"
@@ -973,7 +973,7 @@ async def test_add_includes_overwrites_existing_files(
"""Test that add_includes overwrites existing files in build directory."""
CORE.config_path = tmp_path / "config.yaml"
CORE.build_path = tmp_path / "build"
os.makedirs(CORE.build_path, exist_ok=True)
CORE.build_path.mkdir(parents=True, exist_ok=True)
# Create include file
include_file = tmp_path / "header.h"
@@ -67,7 +67,7 @@ def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> Non
configs = list(config.iter_component_configs(test_config))
assert len(configs) == 2
for domain, component, conf in configs:
for domain, _component, conf in configs:
assert domain == "switch"
assert "name" in conf
@@ -27,6 +27,7 @@ from esphome.const import (
SCHEDULER_DONT_RUN,
)
from esphome.core import CORE, HexInt, Lambda
from esphome.yaml_util import SensitiveStr
def test_check_not_templatable__invalid():
@@ -127,6 +128,85 @@ def test_string_string__invalid(value):
config_validation.string_strict(value)
def test_sensitive__default_delegates_to_string() -> None:
validator = config_validation.sensitive()
assert isinstance(validator, config_validation.SensitiveValidator)
assert validator.inner is config_validation.string
assert validator("hunter2") == "hunter2"
assert validator(42) == "42"
def test_sensitive__custom_inner_delegates_validation() -> None:
validator = config_validation.sensitive(config_validation.string_strict)
assert validator.inner is config_validation.string_strict
assert validator("abc") == "abc"
with pytest.raises(Invalid, match="Must be string, got"):
validator(123)
def test_sensitive__wraps_string_result_in_sensitive_str() -> None:
validator = config_validation.sensitive()
result = validator("hunter2")
assert isinstance(result, SensitiveStr)
assert isinstance(result, str)
assert result == "hunter2"
def test_sensitive__does_not_double_tag_already_sensitive() -> None:
# If the inner validator already returns a SensitiveStr (e.g., nested
# cv.sensitive wrappers), re-tagging is a no-op rather than a new
# SensitiveStr around the same value.
pre_tagged = SensitiveStr("hunter2")
def inner(_value):
return pre_tagged
validator = config_validation.sensitive(inner)
result = validator("anything")
assert result is pre_tagged
def test_sensitive__non_string_result_passes_through() -> None:
# If an inner validator returns something other than a string (e.g., a
# Lambda template), the sensitive wrapper must not coerce it.
sentinel = object()
def inner(_value):
return sentinel
validator = config_validation.sensitive(inner)
assert validator("anything") is sentinel
def test_sensitive__is_detectable_via_isinstance() -> None:
validator = config_validation.sensitive()
assert isinstance(validator, config_validation.SensitiveValidator)
def test_sensitive__repr_mirrors_inner() -> None:
# The schema dump dedups on ``repr(schema)``; mirroring the inner
# validator's repr keeps two ``cv.sensitive(cv.string)`` wrappers
# interchangeable for that purpose and avoids leaking the wrapper as
# noise in voluptuous error messages.
assert repr(config_validation.sensitive(config_validation.string)) == repr(
config_validation.string
)
assert repr(config_validation.sensitive(config_validation.string)) == repr(
config_validation.sensitive(config_validation.string)
)
def test_sensitive_key_fragments__covers_common_terms() -> None:
assert isinstance(config_validation.SENSITIVE_KEY_FRAGMENTS, frozenset)
for term in ("password", "passcode", "secret", "token", "api_key", "apikey", "psk"):
assert term in config_validation.SENSITIVE_KEY_FRAGMENTS
@given(
builds(
lambda v: "mdi:" + v,
+9 -4
View File
@@ -261,9 +261,14 @@ def test_check_library_data_invalid_platform(esp32_idf_core):
_check_library_data({"platforms": ["other"], "frameworks": "*"})
def test_check_library_data_invalid_framework(esp32_idf_core):
with pytest.raises(InvalidIDFComponent):
_check_library_data({"platforms": "*", "frameworks": ["other"]})
def test_check_library_data_invalid_framework(
esp32_idf_core: None, caplog: pytest.LogCaptureFixture
) -> None:
# Framework mismatch is a warning, not a hard skip: the library is still
# included so that PIO manifests that only list "arduino" (but actually
# compile under IDF) can be used without forking them.
_check_library_data({"name": "lib", "platforms": "*", "frameworks": ["other"]})
assert "do not include 'espidf'" in caplog.text
def test_extra_script_captures_libpath_libs_and_defines(tmp_path):
@@ -288,7 +293,7 @@ def test_extra_script_captures_libpath_libs_and_defines(tmp_path):
result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32")
assert result.libpath == [os.path.join("src", "esp32")]
assert result.libpath == [str(Path("src") / "esp32")]
assert result.libs == ["algobsec"]
assert ("BAR", "1") in result.cppdefines
assert "FOO" in result.cppdefines
+156
View File
@@ -0,0 +1,156 @@
"""Tests for esphome.espidf.framework helpers."""
# pylint: disable=protected-access
from pathlib import Path
from unittest.mock import patch
import pytest
from esphome.espidf.framework import _clone_idf_with_submodules, _parse_git_source
@pytest.mark.parametrize(
("source", "expected"),
[
# github:// shorthand
(
"github://espressif/esp-idf",
("https://github.com/espressif/esp-idf.git", None),
),
(
"github://espressif/esp-idf@master",
("https://github.com/espressif/esp-idf.git", "master"),
),
(
"github://espressif/esp-idf@release/v6.0",
("https://github.com/espressif/esp-idf.git", "release/v6.0"),
),
# explicit https://github.com/...git URL
(
"https://github.com/espressif/esp-idf.git",
("https://github.com/espressif/esp-idf.git", None),
),
(
"https://github.com/espressif/esp-idf.git@master",
("https://github.com/espressif/esp-idf.git", "master"),
),
(
"https://github.com/espressif/esp-idf.git@v6.0.1",
("https://github.com/espressif/esp-idf.git", "v6.0.1"),
),
# Tolerate a trailing ".git" on the shorthand so the user doesn't
# silently end up with a doubled "...esp-idf.git.git" URL.
(
"github://espressif/esp-idf.git",
("https://github.com/espressif/esp-idf.git", None),
),
(
"github://espressif/esp-idf.git@master",
("https://github.com/espressif/esp-idf.git", "master"),
),
],
)
def test_parse_git_source_recognized(
source: str, expected: tuple[str, str | None]
) -> None:
assert _parse_git_source(source) == expected
@pytest.mark.parametrize(
"source",
[
# archive URLs fall through to the existing download path
"https://github.com/espressif/esp-idf/archive/refs/heads/master.zip",
"https://dl.espressif.com/dl/esp-idf/v6.0.1/esp-idf-v6.0.1.zip",
"https://github.com/esphome-libs/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz",
# SSH and other git protocols are intentionally rejected — match
# external_components, which only recognizes github:// + structured
# dicts for these.
"git@github.com:espressif/esp-idf.git",
"ssh://git@github.com/espressif/esp-idf.git",
"git://github.com/espressif/esp-idf.git",
# non-GitHub .git URLs are intentionally rejected for the same reason
"https://gitlab.com/foo/bar.git",
"https://github.example.com/foo/bar.git",
],
)
def test_parse_git_source_rejected(source: str) -> None:
assert _parse_git_source(source) is None
def _make_idf_tree(framework_path: Path) -> None:
"""Create the minimum tree _clone_idf_with_submodules sanity-checks for."""
(framework_path / "tools").mkdir(parents=True)
(framework_path / "tools" / "idf_tools.py").write_text("# stub\n")
def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None:
framework_path = tmp_path / "idf"
framework_path.mkdir()
_make_idf_tree(framework_path)
with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock:
_clone_idf_with_submodules(
framework_path, "https://github.com/espressif/esp-idf.git", None
)
# No ref -> just clone + submodule update, no fetch/reset.
calls = [c.args[0] for c in run_git_command_mock.call_args_list]
assert calls[0] == [
"git",
"clone",
"--depth=1",
"--",
"https://github.com/espressif/esp-idf.git",
str(framework_path),
]
assert calls[-1][:5] == ["git", "submodule", "update", "--init", "--recursive"]
assert not any(c[1] == "fetch" for c in calls)
assert not any(c[1] == "reset" for c in calls)
def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None:
framework_path = tmp_path / "idf"
framework_path.mkdir()
_make_idf_tree(framework_path)
with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock:
_clone_idf_with_submodules(
framework_path,
"https://github.com/espressif/esp-idf.git",
"master",
)
calls = [c.args[0] for c in run_git_command_mock.call_args_list]
# clone, fetch ref, reset hard, submodule update
assert calls[0][:2] == ["git", "clone"]
assert calls[1] == [
"git",
"fetch",
"--depth=1",
"--",
"origin",
"master",
]
assert calls[2] == ["git", "reset", "--hard", "FETCH_HEAD"]
assert calls[3][:5] == ["git", "submodule", "update", "--init", "--recursive"]
def test_clone_idf_with_submodules_raises_when_tree_missing(
tmp_path: Path,
) -> None:
framework_path = tmp_path / "idf"
framework_path.mkdir()
# Deliberately do NOT call _make_idf_tree — simulate a clone that
# returned 0 but produced no tools/idf_tools.py.
with (
patch("esphome.git.run_git_command", return_value=""),
pytest.raises(RuntimeError, match="no usable ESP-IDF tree"),
):
_clone_idf_with_submodules(
framework_path,
"https://github.com/espressif/esp-idf.git",
None,
)
+2 -2
View File
@@ -120,7 +120,7 @@ def test_is_file_recent_with_old_file(setup_core: Path) -> None:
old_time = time.time() - 7200
mock_stat = MagicMock()
mock_stat.st_ctime = old_time
mock_stat.st_mtime = old_time
with patch.object(Path, "stat", return_value=mock_stat):
refresh = TimePeriod(seconds=3600)
@@ -147,7 +147,7 @@ def test_is_file_recent_with_zero_refresh(setup_core: Path) -> None:
# Mock stat to return a time 10 seconds ago
mock_stat = MagicMock()
mock_stat.st_ctime = time.time() - 10
mock_stat.st_mtime = time.time() - 10
with patch.object(Path, "stat", return_value=mock_stat):
refresh = TimePeriod(seconds=0)
result = external_files.is_file_recent(test_file, refresh)
+311 -10
View File
@@ -1,10 +1,10 @@
"""Tests for git.py module."""
from datetime import datetime, timedelta
import os
from pathlib import Path
import time
from typing import Any
from unittest.mock import Mock
from unittest.mock import Mock, patch
import pytest
@@ -34,9 +34,9 @@ def _setup_old_repo(repo_dir: Path, days_old: int = 2) -> None:
# Create FETCH_HEAD file with old timestamp
fetch_head = git_dir / "FETCH_HEAD"
fetch_head.write_text("test")
old_time = datetime.now() - timedelta(days=days_old)
old_time = time.time() - days_old * 86400
fetch_head.touch()
os.utime(fetch_head, (old_time.timestamp(), old_time.timestamp()))
os.utime(fetch_head, (old_time, old_time))
def _get_git_command_type(cmd: list[str]) -> str | None:
@@ -285,10 +285,10 @@ def test_clone_or_update_with_refresh_updates_old_repo(
# Create FETCH_HEAD file with old timestamp (2 days ago)
fetch_head = git_dir / "FETCH_HEAD"
fetch_head.write_text("test")
old_time = datetime.now() - timedelta(days=2)
old_time = time.time() - 2 * 86400
fetch_head.touch() # Create the file
# Set modification time to 2 days ago
os.utime(fetch_head, (old_time.timestamp(), old_time.timestamp()))
os.utime(fetch_head, (old_time, old_time))
# Mock git command responses
mock_run_git_command.return_value = "abc123" # SHA for rev-parse
@@ -333,10 +333,10 @@ def test_clone_or_update_with_refresh_skips_fresh_repo(
# Create FETCH_HEAD file with recent timestamp (1 hour ago)
fetch_head = git_dir / "FETCH_HEAD"
fetch_head.write_text("test")
recent_time = datetime.now() - timedelta(hours=1)
recent_time = time.time() - 3600
fetch_head.touch() # Create the file
# Set modification time to 1 hour ago
os.utime(fetch_head, (recent_time.timestamp(), recent_time.timestamp()))
os.utime(fetch_head, (recent_time, recent_time))
# Call with refresh=1d (1 day)
refresh = TimePeriodSeconds(days=1)
@@ -409,10 +409,10 @@ def test_clone_or_update_with_none_refresh_always_updates(
# Create FETCH_HEAD file with very recent timestamp (1 second ago)
fetch_head = git_dir / "FETCH_HEAD"
fetch_head.write_text("test")
recent_time = datetime.now() - timedelta(seconds=1)
recent_time = time.time() - 1
fetch_head.touch() # Create the file
# Set modification time to 1 second ago
os.utime(fetch_head, (recent_time.timestamp(), recent_time.timestamp()))
os.utime(fetch_head, (recent_time, recent_time))
# Mock git command responses
mock_run_git_command.return_value = "abc123" # SHA for rev-parse
@@ -1001,3 +1001,304 @@ def test_refresh_picks_up_new_remote_commits(
"--hard",
"old_sha",
]
def test_resolve_symlink_stub_returns_none_on_non_windows(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""On non-Windows, resolve_symlink_stub returns None without calling git."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
stub = repo_dir / "file.yaml"
stub.write_text("static/file.yaml")
with patch("esphome.git.sys.platform", "linux"):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result is None
mock_run_git_command.assert_not_called()
def test_resolve_symlink_stub_returns_target_for_mode_120000(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""A mode-120000 file is recognised as a stub; its target Path is returned."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
(repo_dir / "static").mkdir()
target = repo_dir / "static" / "real.yaml"
target.write_text("esphome:\n name: real\n")
stub = repo_dir / "real.yaml"
stub.write_text("static/real.yaml")
mock_run_git_command.return_value = "120000 abc123 0\treal.yaml"
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result == target.resolve()
# Stub file itself was not modified — only inspected.
assert stub.read_text() == "static/real.yaml"
def test_resolve_symlink_stub_resolves_relative_parent_paths(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Symlink targets with ``..`` segments resolve correctly within the repo."""
repo_dir = tmp_path / "repo"
(repo_dir / "subdir").mkdir(parents=True)
(repo_dir / "static").mkdir()
target = repo_dir / "static" / "shared.yaml"
target.write_text("shared content")
stub = repo_dir / "subdir" / "shared.yaml"
stub.write_text("../static/shared.yaml")
mock_run_git_command.return_value = "120000 abc123 0\tsubdir/shared.yaml"
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result == target.resolve()
def test_resolve_symlink_stub_refuses_escape_outside_repo(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""A symlink pointing outside the repository is not followed."""
outside = tmp_path / "outside.yaml"
outside.write_text("sensitive")
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
stub = repo_dir / "escape.yaml"
stub.write_text("../outside.yaml")
mock_run_git_command.return_value = "120000 abc123 0\tescape.yaml"
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result is None
def test_resolve_symlink_stub_returns_none_for_real_symlink(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""A real symlink already opens transparently, so the helper short-circuits.
Skipped on Windows where symlink creation requires
SeCreateSymbolicLinkPrivilege.
"""
if os.name == "nt":
pytest.skip("Requires symlink-creation privilege on Windows")
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
target = repo_dir / "real.yaml"
target.write_text("real content")
real_link = repo_dir / "link.yaml"
real_link.symlink_to("real.yaml")
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, real_link)
assert result is None
# No git call needed for real symlinks.
mock_run_git_command.assert_not_called()
def test_resolve_symlink_stub_returns_none_for_regular_file(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""A regular file (mode 100644) whose content looks path-shaped is not
followed."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
regular = repo_dir / "looks_like_path.txt"
regular.write_text("static/something.yaml")
mock_run_git_command.return_value = "100644 abc123 0\tlooks_like_path.txt"
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, regular)
assert result is None
def test_resolve_symlink_stub_returns_none_when_git_fails(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""If ``git ls-files`` fails (e.g. not a repo), the helper returns None."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
stub = repo_dir / "real.yaml"
stub.write_text("static/real.yaml")
mock_run_git_command.side_effect = GitCommandError("ls-files exploded")
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result is None
def test_resolve_symlink_stub_returns_none_for_non_utf8_content(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""A file whose bytes are not valid UTF-8 must not raise — return None."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
stub = repo_dir / "binary.bin"
stub.write_bytes(b"\xff\xfe\x00\xff")
mock_run_git_command.return_value = "120000 abc123 0\tbinary.bin"
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result is None
def test_resolve_symlink_stub_preserves_whitespace_in_target(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Only trailing CR/LF is stripped — internal whitespace is preserved."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
target_dir = repo_dir / "dir with spaces"
target_dir.mkdir()
target = target_dir / "real.yaml"
target.write_text("hello")
stub = repo_dir / "link.yaml"
# Trailing newline (as git's checkout may append) is stripped, but
# whitespace inside the target path itself must survive.
stub.write_bytes(b"dir with spaces/real.yaml\n")
mock_run_git_command.return_value = "120000 abc123 0\tlink.yaml"
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result == target.resolve()
def test_resolve_symlink_stub_returns_none_for_directory_target(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""A symlink pointing at a directory has no file content to load."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
(repo_dir / "dir_target").mkdir()
stub = repo_dir / "link_to_dir"
stub.write_text("dir_target")
mock_run_git_command.return_value = "120000 abc123 0\tlink_to_dir"
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result is None
def test_resolve_symlink_stub_returns_none_when_resolve_raises(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Path.resolve() raising (e.g. on a malformed target) must not propagate."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
stub = repo_dir / "broken.yaml"
stub.write_text("ignored")
mock_run_git_command.return_value = "120000 abc123 0\tbroken.yaml"
with (
patch("esphome.git.sys.platform", "win32"),
patch.object(Path, "resolve", side_effect=OSError("bad path")),
):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result is None
def test_resolve_symlink_stub_returns_none_when_file_missing(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""A file path that doesn't exist is rejected before git is consulted."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
missing = repo_dir / "ghost.yaml" # not created
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, missing)
assert result is None
mock_run_git_command.assert_not_called()
def test_resolve_symlink_stub_returns_none_when_path_outside_repo(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""A file path that isn't under repo_dir is rejected (ValueError from relative_to)."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
outside = tmp_path / "stray.yaml"
outside.write_text("something")
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, outside)
assert result is None
mock_run_git_command.assert_not_called()
def test_resolve_symlink_stub_returns_none_when_untracked(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Empty `git ls-files` output (untracked file) makes the helper return None."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
stub = repo_dir / "untracked.yaml"
stub.write_text("static/foo.yaml")
mock_run_git_command.return_value = ""
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result is None
def test_resolve_symlink_stub_returns_none_when_read_bytes_raises(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""An OSError from read_bytes() (e.g. file vanished mid-call) must not propagate."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
stub = repo_dir / "racy.yaml"
stub.write_text("static/racy.yaml")
mock_run_git_command.return_value = "120000 abc123 0\tracy.yaml"
with (
patch("esphome.git.sys.platform", "win32"),
patch.object(Path, "read_bytes", side_effect=OSError("vanished")),
):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result is None
+2 -1
View File
@@ -7,7 +7,7 @@ import stat
from unittest.mock import MagicMock, patch
from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr
from hypothesis import given
from hypothesis import given, settings
from hypothesis.strategies import ip_addresses
import pytest
@@ -151,6 +151,7 @@ def test_is_ip_address__invalid(host):
assert actual is False
@settings(deadline=None)
@given(value=ip_addresses(v=4).map(str))
def test_is_ip_address__valid(value):
actual = helpers.is_ip_address(value)
+134 -3
View File
@@ -11,7 +11,7 @@ from pathlib import Path
import re
import sys
import time
from typing import Any
from typing import Any, Self
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
@@ -22,6 +22,7 @@ from esphome.__main__ import (
Purpose,
_get_configured_xtal_freq,
_make_crystal_freq_callback,
_redact_with_legacy_fallback,
_resolve_network_devices,
_validate_bootloader_binary,
_validate_partition_table_binary,
@@ -29,6 +30,7 @@ from esphome.__main__ import (
command_analyze_memory,
command_bundle,
command_clean_all,
command_config,
command_config_hash,
command_rename,
command_run,
@@ -340,6 +342,135 @@ def mock_ram_strings_analyzer() -> Generator[Mock]:
yield mock_class
def test_redact_with_legacy_fallback__wraps_unmarked_field(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Unmarked sensitive-shaped fields are redacted; a deprecation warning
is emitted naming the field."""
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback("password: hunter2\n")
assert "password: \\033[8mhunter2\\033[28m" in out
assert any(
"password" in rec.message and "cv.sensitive" in rec.message
for rec in caplog.records
)
def test_redact_with_legacy_fallback__skips_already_wrapped(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Values already wrapped by the SensitiveStr representer don't trigger
the heuristic or the warning."""
wrapped = "password: \\033[8mhunter2\\033[28m\n"
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback(wrapped)
assert out == wrapped
assert not any("legacy substring" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__captures_full_field_name(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The warning names the actual field, not just the matched fragment."""
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
_redact_with_legacy_fallback("encryption_key: abc\n")
assert any("encryption_key" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__deduplicates_warnings(
caplog: pytest.LogCaptureFixture,
) -> None:
"""One warning per unique field name even if it appears many times."""
text = "password: a\npassword: b\npassword: c\n"
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
_redact_with_legacy_fallback(text)
password_warnings = [rec for rec in caplog.records if "'password'" in rec.message]
assert len(password_warnings) == 1
def test_redact_with_legacy_fallback__skips_lambda_values(
caplog: pytest.LogCaptureFixture,
) -> None:
"""``!lambda`` first line is structural, body is unreachable by a
single-line regex anyway, and tagged fields shouldn't trigger a warning."""
text = ' ssid: !lambda |-\n return "x";\n'
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback(text)
assert out == text
assert not any("legacy substring" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__skips_secret_references(
caplog: pytest.LogCaptureFixture,
) -> None:
"""``!secret name`` is the dumper's user-friendly representation; the
name isn't the secret, so wrapping it would clobber the round-trip."""
text = " password: !secret wifi_password\n"
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback(text)
assert out == text
assert not any("legacy substring" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__does_not_match_fragment_in_middle(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Fragment must end the field name; embedded matches like
``key_value_pair`` are unrelated to a sensitive key and must not be
redacted (matching the prior regex's scope)."""
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback("key_value_pair: abc\n")
assert "\\033[8m" not in out
assert not any("legacy substring" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Fragment must start the name or follow ``_``; ``monkey:`` shouldn't
fire a 'legacy heuristic' warning because there's no sensitive field
here — the user has nothing to migrate."""
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback("monkey: 1234\n")
assert "\\033[8m" not in out
assert not any("legacy substring" in rec.message for rec in caplog.records)
def test_command_config__invokes_legacy_fallback_when_redacting(
tmp_path: Path, capfd: CaptureFixture[str]
) -> None:
"""``command_config`` runs the legacy fallback on the dumped output when
``--show-secrets`` is off. Cover the wiring (not just the helper).
"""
setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}})
args = MockArgs()
args.show_secrets = False
result = command_config(args, {"wifi": {"password": "hunter2"}})
assert result == 0
output = capfd.readouterr().out
assert "\\033[8mhunter2\\033[28m" in output
def test_command_config__show_secrets_skips_redaction(
tmp_path: Path, capfd: CaptureFixture[str]
) -> None:
"""With ``--show-secrets`` the helper isn't invoked and the value
renders raw.
"""
setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}})
args = MockArgs()
args.show_secrets = True
result = command_config(args, {"wifi": {"password": "hunter2"}})
assert result == 0
output = capfd.readouterr().out
assert "hunter2" in output
assert "\\033[8m" not in output
def test_choose_upload_log_host_with_string_default() -> None:
"""Test with a single string default device."""
setup_core()
@@ -5110,11 +5241,11 @@ class MockSerial:
self.timeout = 0.1
self._is_open = False
def __enter__(self) -> MockSerial:
def __enter__(self) -> Self:
self._is_open = True
return self
def __exit__(self, *args: Any) -> None:
def __exit__(self, *args: object) -> None:
self._is_open = False
@property
+2 -2
View File
@@ -576,8 +576,8 @@ def test_esphome_storage_json_last_update_check_property() -> None:
assert result.hour == 10
assert result.minute == 30
# Test setter
new_date = datetime(2024, 2, 20, 15, 45, 30)
# Test setter — naive datetime matches the storage round-trip format.
new_date = datetime(2024, 2, 20, 15, 45, 30) # noqa: DTZ001
storage.last_update_check = new_date
assert storage.last_update_check_str == "2024-02-20T15:45:30"
+84 -2
View File
@@ -1,4 +1,3 @@
import glob
import logging
from pathlib import Path
from typing import Any
@@ -106,7 +105,7 @@ REMOTES = {
# Collect all input YAML files for test_substitutions_fixtures parametrized tests:
HERE = Path(__file__).parent
BASE_DIR = HERE / "fixtures" / "substitutions"
SOURCES = sorted(glob.glob(str(BASE_DIR / "*.input.yaml")))
SOURCES = sorted(str(p) for p in BASE_DIR.glob("*.input.yaml"))
assert SOURCES, f"test_substitutions_fixtures: No input YAML files found in {BASE_DIR}"
@@ -838,3 +837,86 @@ def test_include_vars_applied_to_lambda_value(tmp_path: Path) -> None:
assert isinstance(result["value"], Lambda)
assert result["value"].value == 'return "bar";'
@patch("esphome.git.resolve_symlink_stub")
@patch("esphome.git.clone_or_update")
def test_remote_package_symlink_stub_is_followed(
mock_clone_or_update: MagicMock,
mock_resolve_symlink_stub: MagicMock,
tmp_path: Path,
) -> None:
"""When a package YAML is a scalar (symlink stub) and resolve_symlink_stub
returns a target, the loader follows the target and uses its content."""
CORE.config_path = tmp_path / "test.yaml"
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
(repo_dir / "static").mkdir()
# Stub file: content is the target path string (simulating Windows behavior).
stub = repo_dir / "file1.yaml"
stub.write_text("static/file1.yaml")
# Real target with valid YAML mapping.
target = repo_dir / "static" / "file1.yaml"
target.write_text("substitutions:\n hello: world\n")
mock_clone_or_update.return_value = (repo_dir, None)
mock_resolve_symlink_stub.return_value = target
config: dict[str, Any] = {
"packages": {
"test_package": {
"url": "https://github.com/esphome/repo1",
"ref": "main",
"files": ["file1.yaml"],
}
}
}
# Must succeed (does not raise the helpful cv.Invalid) because the stub
# was followed and a valid mapping was loaded from the target.
do_packages_pass(config)
assert mock_resolve_symlink_stub.called
@patch("esphome.git.clone_or_update")
def test_remote_package_scalar_yaml_raises_helpful_error(
mock_clone_or_update: MagicMock, tmp_path: Path
) -> None:
"""A remote package YAML that is a top-level scalar (e.g. an unmaterialized
git symlink on Windows) raises a clear cv.Invalid, not AttributeError.
Regression test for the case where a repo containing a YAML symlink,
checked out on Windows without symlink privilege, lands as a short text
file containing the symlink target path. PyYAML parses that as a bare
string scalar; the package loader must reject it with a human-readable
error instead of dying inside ``.get()``.
"""
CORE.config_path = tmp_path / "test.yaml"
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
# Simulate the broken-symlink state: a YAML file whose entire content is
# the symlink target string. PyYAML parses this as a top-level scalar.
(repo_dir / "file1.yaml").write_text("static/file1.yaml")
mock_clone_or_update.return_value = (repo_dir, None)
config: dict[str, Any] = {
"packages": {
"test_package": {
"url": "https://github.com/esphome/repo1",
"ref": "main",
"files": ["file1.yaml"],
}
}
}
with pytest.raises(cv.Invalid) as exc_info:
do_packages_pass(config)
msg = str(exc_info.value)
assert "mapping at the top level" in msg
assert "file1.yaml" in msg
+4 -4
View File
@@ -1361,7 +1361,7 @@ def test_clean_build_handles_readonly_files(
# Create a read-only file (simulating git pack files on Windows)
readonly_file = git_dir / "pack-abc123.pack"
readonly_file.write_text("pack data")
os.chmod(readonly_file, stat.S_IRUSR) # Read-only
readonly_file.chmod(stat.S_IRUSR) # Read-only
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
@@ -1396,7 +1396,7 @@ def test_clean_all_handles_readonly_files(
subdir.mkdir()
readonly_file = subdir / "readonly.txt"
readonly_file.write_text("content")
os.chmod(readonly_file, stat.S_IRUSR) # Read-only
readonly_file.chmod(stat.S_IRUSR) # Read-only
# Verify file is read-only
assert not os.access(readonly_file, os.W_OK)
@@ -1425,7 +1425,7 @@ def test_clean_build_reraises_for_other_errors(
test_file.write_text("content")
# Make subdir read-only so files inside can't be deleted
os.chmod(subdir, stat.S_IRUSR | stat.S_IXUSR)
subdir.chmod(stat.S_IRUSR | stat.S_IXUSR)
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
@@ -1443,7 +1443,7 @@ def test_clean_build_reraises_for_other_errors(
clean_build()
finally:
# Cleanup - restore write permission so tmp_path cleanup works
os.chmod(subdir, stat.S_IRWXU)
subdir.chmod(stat.S_IRWXU)
# Tests for get_build_info()
+56 -1
View File
@@ -15,6 +15,7 @@ from esphome.yaml_util import (
DiscoveredYamlFiles,
ESPHomeDataBase,
ESPLiteralValue,
SensitiveStr,
discover_user_yaml_files,
force_load_include_files,
format_path,
@@ -907,7 +908,7 @@ def test_format_path_current_obj_without_location_falls_back_to_key():
"""An ESPHomeDataBase current_obj with no esp_range falls back to the key's location."""
class _NoRange(ESPHomeDataBase, str):
pass
__slots__ = ()
obj = _NoRange.__new__(_NoRange, "value")
str.__init__(obj)
@@ -1340,3 +1341,57 @@ def test_frontmatter_included_file_stored(tmp_path: Path) -> None:
assert main.resolve() not in core.CORE.frontmatter
# Included file's frontmatter is captured
assert core.CORE.frontmatter[inc.resolve()]["child_meta"] == "hello"
def test_sensitive_str__is_a_str_subclass() -> None:
value = SensitiveStr("hunter2")
assert isinstance(value, str)
assert value == "hunter2"
def test_dump__redacts_sensitive_str_by_default() -> None:
out = yaml_util.dump({"password": SensitiveStr("hunter2")})
assert "\\033[8mhunter2\\033[28m" in out
assert "hunter2" not in out.replace(
"\\033[8mhunter2\\033[28m", ""
) # the raw value is only present inside the wrap
def test_dump__show_secrets_emits_sensitive_str_raw() -> None:
out = yaml_util.dump({"password": SensitiveStr("hunter2")}, show_secrets=True)
assert "hunter2" in out
assert "\\033[8m" not in out
assert "\\033[28m" not in out
def test_dump__plain_str_is_not_redacted() -> None:
out = yaml_util.dump({"hostname": "myserver"})
assert "myserver" in out
assert "\\033[8m" not in out
def test_dump__secret_reference_wins_over_redaction() -> None:
# If the value also has an entry in _SECRET_VALUES (i.e., it was loaded
# via !secret), the dump should render it as !secret <name>, not as a
# redacted scalar. SensitiveStr layered on top must not change that.
value = SensitiveStr("hunter2")
yaml_util._SECRET_VALUES[str(value)] = "my_secret_name"
try:
out = yaml_util.dump({"password": value})
assert "!secret" in out
assert "my_secret_name" in out
assert "\\033[8m" not in out
finally:
yaml_util._SECRET_VALUES.clear()
def test_dump__redaction_flag_does_not_leak_between_calls() -> None:
# Per-call _Dumper subclass means show_secrets in one call doesn't
# affect another. Run them in both orders to catch any leakage.
redacted = yaml_util.dump({"password": SensitiveStr("hunter2")})
raw = yaml_util.dump({"password": SensitiveStr("hunter2")}, show_secrets=True)
redacted_again = yaml_util.dump({"password": SensitiveStr("hunter2")})
assert "\\033[8m" in redacted
assert "\\033[8m" not in raw
assert "\\033[8m" in redacted_again