mirror of
https://github.com/esphome/esphome.git
synced 2026-08-24 15:16:20 +00:00
Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter
This commit is contained in:
@@ -22,6 +22,7 @@ from esphome.components.esp32 import (
|
||||
request_bluetooth,
|
||||
)
|
||||
from esphome.components.esp32.const import VARIANT_ESP32C2
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ENABLE_ON_BOOT,
|
||||
@@ -637,3 +638,10 @@ async def ble_disable_to_code(
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
return cg.new_Pvariable(action_id, template_arg)
|
||||
|
||||
|
||||
# ble_advertising.cpp is fully #ifdef'd on USE_ESP32_BLE_ADVERTISING, set
|
||||
# when advertising is enabled here or by esp32_ble_server / esp32_ble_beacon.
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{"ble_advertising.cpp": "USE_ESP32_BLE_ADVERTISING"}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import audio, esp32, speaker
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BITS_PER_SAMPLE,
|
||||
@@ -261,3 +262,13 @@ async def to_code(config: ConfigType) -> None:
|
||||
if config[CONF_TIMEOUT] != CONF_NEVER:
|
||||
cg.add(var.set_timeout(config[CONF_TIMEOUT]))
|
||||
cg.add(var.set_buffer_duration(config[CONF_BUFFER_DURATION]))
|
||||
|
||||
|
||||
# The SPDIF encoder and speaker are fully #ifdef'd on USE_I2S_AUDIO_SPDIF_MODE,
|
||||
# set only when spdif_mode is enabled.
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{
|
||||
"spdif_encoder.cpp": "USE_I2S_AUDIO_SPDIF_MODE",
|
||||
"i2s_audio_spdif.cpp": "USE_I2S_AUDIO_SPDIF_MODE",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import esphome.automation as auto
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import mqtt, power_supply, web_server
|
||||
from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BLUE,
|
||||
@@ -557,3 +558,10 @@ async def new_light(config, *args):
|
||||
@coroutine_with_priority(CoroPriority.CORE)
|
||||
async def to_code(config):
|
||||
cg.add_global(light_ns.using)
|
||||
|
||||
|
||||
# light_json_schema.cpp is only used by mqtt and web_server, which both
|
||||
# auto load json; USE_JSON alone is too broad since other components load it.
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{"light_json_schema.cpp": ("USE_MQTT", "USE_WEBSERVER")}
|
||||
)
|
||||
|
||||
@@ -8,7 +8,10 @@ from esphome.components.esp32 import (
|
||||
idf_version,
|
||||
include_builtin_idf_component,
|
||||
)
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
from esphome.config_helpers import (
|
||||
filter_source_files_from_defines,
|
||||
filter_source_files_from_platform,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_AVAILABILITY,
|
||||
@@ -640,7 +643,7 @@ async def mqtt_disable_to_code(config, action_id, template_arg, args):
|
||||
return cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
_platform_filter = filter_source_files_from_platform(
|
||||
{
|
||||
"mqtt_backend_esp32.cpp": {
|
||||
PlatformFramework.ESP32_ARDUINO,
|
||||
@@ -648,3 +651,34 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Each entity file is fully #ifdef'd on the USE_<ENTITY> define the core
|
||||
# emits for entity platforms present in the config.
|
||||
_define_filter = filter_source_files_from_defines(
|
||||
{
|
||||
"mqtt_alarm_control_panel.cpp": "USE_ALARM_CONTROL_PANEL",
|
||||
"mqtt_binary_sensor.cpp": "USE_BINARY_SENSOR",
|
||||
"mqtt_button.cpp": "USE_BUTTON",
|
||||
"mqtt_climate.cpp": "USE_CLIMATE",
|
||||
"mqtt_cover.cpp": "USE_COVER",
|
||||
"mqtt_date.cpp": "USE_DATETIME_DATE",
|
||||
"mqtt_datetime.cpp": "USE_DATETIME_DATETIME",
|
||||
"mqtt_event.cpp": "USE_EVENT",
|
||||
"mqtt_fan.cpp": "USE_FAN",
|
||||
"mqtt_light.cpp": "USE_LIGHT",
|
||||
"mqtt_lock.cpp": "USE_LOCK",
|
||||
"mqtt_number.cpp": "USE_NUMBER",
|
||||
"mqtt_select.cpp": "USE_SELECT",
|
||||
"mqtt_sensor.cpp": "USE_SENSOR",
|
||||
"mqtt_switch.cpp": "USE_SWITCH",
|
||||
"mqtt_text.cpp": "USE_TEXT",
|
||||
"mqtt_text_sensor.cpp": "USE_TEXT_SENSOR",
|
||||
"mqtt_time.cpp": "USE_DATETIME_TIME",
|
||||
"mqtt_update.cpp": "USE_UPDATE",
|
||||
"mqtt_valve.cpp": "USE_VALVE",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def FILTER_SOURCE_FILES() -> list[str]:
|
||||
return _platform_filter() + _define_filter()
|
||||
|
||||
@@ -5,7 +5,10 @@ import re
|
||||
from esphome import automation, pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
from esphome.config_helpers import (
|
||||
filter_source_files_from_defines,
|
||||
filter_source_files_from_platform,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_AFTER,
|
||||
@@ -521,7 +524,7 @@ async def final_step():
|
||||
cg.add_define("USE_UART_WAKE_LOOP_ON_RX")
|
||||
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
_platform_filter = filter_source_files_from_platform(
|
||||
{
|
||||
"uart_component_esp_idf.cpp": {
|
||||
PlatformFramework.ESP32_IDF,
|
||||
@@ -537,3 +540,13 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# uart_debugger.cpp is fully #ifdef'd on USE_UART_DEBUGGER, set only when a
|
||||
# debug block is configured.
|
||||
_define_filter = filter_source_files_from_defines(
|
||||
{"uart_debugger.cpp": "USE_UART_DEBUGGER"}
|
||||
)
|
||||
|
||||
|
||||
def FILTER_SOURCE_FILES() -> list[str]:
|
||||
return _platform_filter() + _define_filter()
|
||||
|
||||
@@ -233,6 +233,26 @@ bool str_contains_ignore_case_fallback(const char *haystack, const char *needle)
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
// _P mirror of str_contains_ignore_case_fallback above; host tests cover only the fallback,
|
||||
// so keep the two bodies in sync.
|
||||
bool str_contains_ignore_case_p(const char *haystack, PGM_P needle) {
|
||||
if (haystack == nullptr || needle == nullptr) {
|
||||
return false;
|
||||
}
|
||||
const size_t needle_len = strlen_P(needle);
|
||||
if (needle_len == 0) {
|
||||
return true;
|
||||
}
|
||||
for (const char *p = haystack; *p != '\0'; p++) {
|
||||
if (strncasecmp_P(p, needle, needle_len) == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
#endif // USE_ESP8266
|
||||
|
||||
// str_truncate, str_until, str_lower_case, str_upper_case, str_snake_case moved to alloc_helpers.cpp
|
||||
char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) {
|
||||
if (buffer_size == 0) {
|
||||
|
||||
+11
-1
@@ -984,6 +984,15 @@ inline bool str_endswith_ignore_case(const std::string &str, const char *suffix)
|
||||
/// Fallback implementation for case insensitive substring comparison.
|
||||
bool str_contains_ignore_case_fallback(const char *haystack, const char *needle);
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
/// ESP8266 internal implementation reading the needle from flash — prefer the
|
||||
/// `str_contains_ignore_case` macro which wraps needle literals with PSTR() automatically.
|
||||
bool str_contains_ignore_case_p(const char *haystack, PGM_P needle);
|
||||
/// Case-insensitive check if needle string is contained in haystack (no heap allocation).
|
||||
/// On ESP8266 the needle is wrapped with PSTR() so it stays in flash, which requires it to be
|
||||
/// a string literal; a runtime needle needs str_contains_ignore_case_p behind #ifdef USE_ESP8266.
|
||||
#define str_contains_ignore_case(haystack, needle) str_contains_ignore_case_p(haystack, PSTR(needle))
|
||||
#else
|
||||
/// Case-insensitive check if needle string is contained in haystack (no heap allocation).
|
||||
inline bool str_contains_ignore_case(const char *haystack, const char *needle) {
|
||||
if (!needle || !haystack) {
|
||||
@@ -991,7 +1000,7 @@ inline bool str_contains_ignore_case(const char *haystack, const char *needle) {
|
||||
}
|
||||
|
||||
// strcasestr is a GNU extension: newlib only declares it when _GNU_SOURCE is set.
|
||||
// ESP32/ESP8266/host builds get it from their framework or from g++ on Linux;
|
||||
// ESP32/host builds get it from their framework or from g++ on Linux;
|
||||
// LibreTiny, RP2 and Zephyr do not, so they use the hand-rolled fallback.
|
||||
#if defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ZEPHYR)
|
||||
return str_contains_ignore_case_fallback(haystack, needle);
|
||||
@@ -999,6 +1008,7 @@ inline bool str_contains_ignore_case(const char *haystack, const char *needle) {
|
||||
return strcasestr(haystack, needle) != nullptr;
|
||||
#endif // defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ZEPHYR)
|
||||
}
|
||||
#endif // USE_ESP8266
|
||||
|
||||
// str_truncate moved to alloc_helpers.h - remove this include before 2026.11.0
|
||||
|
||||
|
||||
+11
-2
@@ -31,6 +31,13 @@ SockAddr = IPv4SockAddr | IPv6SockAddr
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# cv.boolean's closed spelling tables, shared with the env-knob parsing below
|
||||
TRUTHY_BOOL_STRINGS = frozenset({"true", "yes", "on", "enable"})
|
||||
FALSY_BOOL_STRINGS = frozenset({"false", "no", "off", "disable"})
|
||||
# cv.boolean's spelling tables plus the 1/0 env convention
|
||||
TRUTHY_ENV_STRINGS = TRUTHY_BOOL_STRINGS | {"1"}
|
||||
FALSY_ENV_STRINGS = FALSY_BOOL_STRINGS | {"0"}
|
||||
|
||||
IS_MACOS = platform.system() == "Darwin"
|
||||
IS_WINDOWS = platform.system() == "Windows"
|
||||
IS_LINUX = platform.system() == "Linux"
|
||||
@@ -395,12 +402,14 @@ def sort_ip_addresses(address_list: list[str]) -> list[str]:
|
||||
|
||||
|
||||
def get_bool_env(var, default=False):
|
||||
"""Read a boolean env var: the ``cv.boolean`` spellings plus ``1``/``0``;
|
||||
anything else falls through to ``bool(value)``."""
|
||||
value = os.getenv(var, default)
|
||||
if isinstance(value, str):
|
||||
value = value.lower()
|
||||
if value in ["1", "true"]:
|
||||
if value in TRUTHY_ENV_STRINGS:
|
||||
return True
|
||||
if value in ["0", "false"]:
|
||||
if value in FALSY_ENV_STRINGS:
|
||||
return False
|
||||
return bool(value)
|
||||
|
||||
|
||||
+19
-2
@@ -337,12 +337,29 @@ def copy_src_tree():
|
||||
else:
|
||||
try:
|
||||
existing = json.loads(build_info_json_path.read_text(encoding="utf-8"))
|
||||
if (
|
||||
if not isinstance(existing, dict) or (
|
||||
existing.get("config_hash") != config_hash
|
||||
or existing.get("esphome_version") != __version__
|
||||
):
|
||||
# Non-object JSON is stale like every other damage case
|
||||
sources_changed = True
|
||||
except (json.JSONDecodeError, KeyError, OSError):
|
||||
except FileNotFoundError:
|
||||
# An absent build_info.json is stale, not damaged; rebuild quietly
|
||||
sources_changed = True
|
||||
except (ValueError, OSError) as err:
|
||||
# ValueError covers both JSONDecodeError and UnicodeDecodeError;
|
||||
# unlink so the regenerating write never re-reads the bad copy.
|
||||
# "Unreadable" not "damaged": EACCES/EISDIR land here too
|
||||
_LOGGER.warning("Regenerating unreadable build_info.json: %s", err)
|
||||
try:
|
||||
# missing_ok: a concurrent clean may have removed it already
|
||||
build_info_json_path.unlink(missing_ok=True)
|
||||
except OSError as unlink_err:
|
||||
# The later write re-reads the file, so a kept unreadable copy
|
||||
# fails again with a misattributed error; name the real cause
|
||||
_LOGGER.warning(
|
||||
"Could not remove unreadable build_info.json: %s", unlink_err
|
||||
)
|
||||
sources_changed = True
|
||||
|
||||
# Write build_info header and JSON metadata
|
||||
|
||||
@@ -121,7 +121,7 @@ TEST(StringContainsIgnoreCaseTest, MiscNotMatching) {
|
||||
|
||||
TEST(StringContainsIgnoreCaseTest, FallbackMatchesLibc) {
|
||||
const char *haystack = "Hello World";
|
||||
for (const char *needle : {"", "Hello", "hELLO", "Hell", "world", "Heaven", "Hello!", "d"}) {
|
||||
for (const char *needle : {"", "Hello", "hELLO", "HELLO", "Hell", "world", "World", "Heaven", "Hello!", "d"}) {
|
||||
EXPECT_EQ(str_contains_ignore_case_fallback(haystack, needle), str_contains_ignore_case(haystack, needle))
|
||||
<< "needle: " << needle;
|
||||
}
|
||||
|
||||
@@ -171,6 +171,13 @@ def test_is_ip_address__valid(value):
|
||||
("FOO", "fAlSe", True, False),
|
||||
("FOO", "Yes", False, True),
|
||||
("FOO", "123", False, True),
|
||||
# cv.boolean's spellings; falsy rows use default=True on purpose
|
||||
("FOO", "on", False, True),
|
||||
("FOO", "enable", False, True),
|
||||
("FOO", "no", True, False),
|
||||
("FOO", "off", True, False),
|
||||
("FOO", "OFF", True, False),
|
||||
("FOO", "Disable", True, False),
|
||||
),
|
||||
)
|
||||
def test_get_bool_env(monkeypatch, var, value, default, expected):
|
||||
|
||||
+111
-132
@@ -2041,6 +2041,38 @@ def test_copy_src_tree_writes_build_info_files(
|
||||
assert build_info_json["esphome_version"] == "2025.1.0-dev"
|
||||
|
||||
|
||||
def _setup_build_info_mocks(
|
||||
mock_core: MagicMock,
|
||||
mock_iter_components: MagicMock,
|
||||
mock_walk_files: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> Path:
|
||||
"""Point CORE at tmp_path and return the build_info.json path."""
|
||||
src_path = tmp_path / "src"
|
||||
(src_path / "esphome" / "core").mkdir(parents=True)
|
||||
build_path = tmp_path / "build"
|
||||
build_path.mkdir()
|
||||
mock_core.relative_src_path.side_effect = src_path.joinpath
|
||||
mock_core.relative_build_path.side_effect = build_path.joinpath
|
||||
mock_core.defines = []
|
||||
mock_core.config_hash = 0xDEADBEEF
|
||||
mock_core.comment = ""
|
||||
mock_core.target_platform = "test_platform"
|
||||
mock_core.config = {}
|
||||
mock_iter_components.return_value = []
|
||||
mock_walk_files.return_value = []
|
||||
return build_path / "build_info.json"
|
||||
|
||||
|
||||
def _run_copy_src_tree(version: str = "2025.1.0-dev") -> None:
|
||||
with (
|
||||
patch("esphome.writer.__version__", version),
|
||||
patch("esphome.writer.importlib.import_module") as mock_import,
|
||||
):
|
||||
mock_import.side_effect = AttributeError
|
||||
copy_src_tree()
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
@patch("esphome.writer.iter_components")
|
||||
@patch("esphome.writer.walk_files")
|
||||
@@ -2050,59 +2082,17 @@ def test_copy_src_tree_detects_config_hash_change(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test copy_src_tree detects when config_hash changes."""
|
||||
# Setup directory structure
|
||||
src_path = tmp_path / "src"
|
||||
src_path.mkdir()
|
||||
esphome_core_path = src_path / "esphome" / "core"
|
||||
esphome_core_path.mkdir(parents=True)
|
||||
build_path = tmp_path / "build"
|
||||
build_path.mkdir()
|
||||
|
||||
# Create existing build_info.json with different config_hash
|
||||
build_info_json_path = build_path / "build_info.json"
|
||||
build_info_json_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"config_hash": 0x12345678, # Different from current
|
||||
"build_time": 1700000000,
|
||||
"build_time_str": "2023-11-14 22:13:20 +0000",
|
||||
"esphome_version": "2025.1.0-dev",
|
||||
}
|
||||
)
|
||||
"""A changed config_hash regenerates build_info after a steady-state run."""
|
||||
build_info_json_path = _setup_build_info_mocks(
|
||||
mock_core, mock_iter_components, mock_walk_files, tmp_path
|
||||
)
|
||||
_run_copy_src_tree()
|
||||
assert json.loads(build_info_json_path.read_text())["config_hash"] == 0xDEADBEEF
|
||||
|
||||
# Create existing build_info_data.h
|
||||
build_info_h_path = esphome_core_path / "build_info_data.h"
|
||||
build_info_h_path.write_text("// old build_info_data.h")
|
||||
|
||||
# Setup mocks
|
||||
mock_core.relative_src_path.side_effect = src_path.joinpath
|
||||
mock_core.relative_build_path.side_effect = build_path.joinpath
|
||||
mock_core.defines = []
|
||||
mock_core.config_hash = 0xDEADBEEF # Different from existing
|
||||
mock_core.comment = ""
|
||||
mock_core.target_platform = "test_platform"
|
||||
mock_core.config = {}
|
||||
mock_iter_components.return_value = []
|
||||
mock_walk_files.return_value = []
|
||||
|
||||
with (
|
||||
patch("esphome.writer.__version__", "2025.1.0-dev"),
|
||||
patch("esphome.writer.importlib.import_module") as mock_import,
|
||||
):
|
||||
mock_import.side_effect = AttributeError
|
||||
copy_src_tree()
|
||||
|
||||
# Verify build_info files were updated due to config_hash change
|
||||
assert build_info_h_path.exists()
|
||||
build_info_cpp_path = esphome_core_path / "build_info_data.cpp"
|
||||
assert build_info_cpp_path.exists()
|
||||
new_content = build_info_cpp_path.read_text()
|
||||
assert "0xdeadbeef" in new_content.lower()
|
||||
|
||||
new_json = json.loads(build_info_json_path.read_text())
|
||||
assert new_json["config_hash"] == 0xDEADBEEF
|
||||
# Second run only regenerates if the hash comparison detects the change
|
||||
mock_core.config_hash = 0xC0FFEE
|
||||
_run_copy_src_tree()
|
||||
assert json.loads(build_info_json_path.read_text())["config_hash"] == 0xC0FFEE
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
@@ -2114,104 +2104,93 @@ def test_copy_src_tree_detects_version_change(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test copy_src_tree detects when esphome_version changes."""
|
||||
# Setup directory structure
|
||||
src_path = tmp_path / "src"
|
||||
src_path.mkdir()
|
||||
esphome_core_path = src_path / "esphome" / "core"
|
||||
esphome_core_path.mkdir(parents=True)
|
||||
build_path = tmp_path / "build"
|
||||
build_path.mkdir()
|
||||
|
||||
# Create existing build_info.json with different version
|
||||
build_info_json_path = build_path / "build_info.json"
|
||||
build_info_json_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"config_hash": 0xDEADBEEF,
|
||||
"build_time": 1700000000,
|
||||
"build_time_str": "2023-11-14 22:13:20 +0000",
|
||||
"esphome_version": "2024.12.0", # Old version
|
||||
}
|
||||
)
|
||||
"""A changed esphome_version regenerates build_info after a steady-state run."""
|
||||
build_info_json_path = _setup_build_info_mocks(
|
||||
mock_core, mock_iter_components, mock_walk_files, tmp_path
|
||||
)
|
||||
|
||||
# Create existing build_info_data.h
|
||||
build_info_h_path = esphome_core_path / "build_info_data.h"
|
||||
build_info_h_path.write_text("// old build_info_data.h")
|
||||
|
||||
# Setup mocks
|
||||
mock_core.relative_src_path.side_effect = src_path.joinpath
|
||||
mock_core.relative_build_path.side_effect = build_path.joinpath
|
||||
mock_core.defines = []
|
||||
mock_core.config_hash = 0xDEADBEEF
|
||||
mock_core.comment = ""
|
||||
mock_core.target_platform = "test_platform"
|
||||
mock_core.config = {}
|
||||
mock_iter_components.return_value = []
|
||||
mock_walk_files.return_value = []
|
||||
|
||||
with (
|
||||
patch("esphome.writer.__version__", "2025.1.0-dev"), # New version
|
||||
patch("esphome.writer.importlib.import_module") as mock_import,
|
||||
):
|
||||
mock_import.side_effect = AttributeError
|
||||
copy_src_tree()
|
||||
|
||||
# Verify build_info files were updated due to version change
|
||||
assert build_info_h_path.exists()
|
||||
# Pin version.h so only the build_info comparison can see the bump
|
||||
with patch("esphome.writer.generate_version_h", return_value="// version.h\n"):
|
||||
_run_copy_src_tree(version="2024.12.0")
|
||||
_run_copy_src_tree(version="2025.1.0-dev")
|
||||
new_json = json.loads(build_info_json_path.read_text())
|
||||
assert new_json["esphome_version"] == "2025.1.0-dev"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"damage",
|
||||
(b"invalid json {{{", b"[]", b'\xff{"config_hash": 1}'),
|
||||
ids=("invalid-json", "non-object", "non-utf8"),
|
||||
)
|
||||
@patch("esphome.writer.CORE")
|
||||
@patch("esphome.writer.iter_components")
|
||||
@patch("esphome.writer.walk_files")
|
||||
def test_copy_src_tree_regenerates_damaged_build_info(
|
||||
mock_walk_files: MagicMock,
|
||||
mock_iter_components: MagicMock,
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
damage: bytes,
|
||||
) -> None:
|
||||
"""A damaged build_info.json reads as stale and is regenerated, not left in place."""
|
||||
build_info_json_path = _setup_build_info_mocks(
|
||||
mock_core, mock_iter_components, mock_walk_files, tmp_path
|
||||
)
|
||||
_run_copy_src_tree()
|
||||
build_info_json_path.write_bytes(damage)
|
||||
# Second run only rewrites the file if the damage branch fires
|
||||
_run_copy_src_tree()
|
||||
new_json = json.loads(build_info_json_path.read_text())
|
||||
assert new_json["config_hash"] == 0xDEADBEEF
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
@patch("esphome.writer.iter_components")
|
||||
@patch("esphome.writer.walk_files")
|
||||
def test_copy_src_tree_handles_invalid_build_info_json(
|
||||
def test_copy_src_tree_missing_build_info_rebuilds_quietly(
|
||||
mock_walk_files: MagicMock,
|
||||
mock_iter_components: MagicMock,
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test copy_src_tree handles invalid build_info.json gracefully."""
|
||||
# Setup directory structure
|
||||
src_path = tmp_path / "src"
|
||||
src_path.mkdir()
|
||||
esphome_core_path = src_path / "esphome" / "core"
|
||||
esphome_core_path.mkdir(parents=True)
|
||||
build_path = tmp_path / "build"
|
||||
build_path.mkdir()
|
||||
"""An absent build_info.json regenerates without claiming damage."""
|
||||
build_info_json_path = _setup_build_info_mocks(
|
||||
mock_core, mock_iter_components, mock_walk_files, tmp_path
|
||||
)
|
||||
_run_copy_src_tree()
|
||||
build_info_json_path.unlink()
|
||||
_run_copy_src_tree()
|
||||
assert json.loads(build_info_json_path.read_text())["config_hash"] == 0xDEADBEEF
|
||||
assert "unreadable" not in caplog.text
|
||||
|
||||
# Create invalid build_info.json
|
||||
build_info_json_path = build_path / "build_info.json"
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
@patch("esphome.writer.iter_components")
|
||||
@patch("esphome.writer.walk_files")
|
||||
def test_copy_src_tree_unremovable_damaged_build_info_is_logged(
|
||||
mock_walk_files: MagicMock,
|
||||
mock_iter_components: MagicMock,
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A failed unlink of the damaged file names the real cause."""
|
||||
build_info_json_path = _setup_build_info_mocks(
|
||||
mock_core, mock_iter_components, mock_walk_files, tmp_path
|
||||
)
|
||||
_run_copy_src_tree()
|
||||
build_info_json_path.write_text("invalid json {{{")
|
||||
real_unlink = Path.unlink
|
||||
|
||||
# Create existing build_info_data.h
|
||||
build_info_h_path = esphome_core_path / "build_info_data.h"
|
||||
build_info_h_path.write_text("// old build_info_data.h")
|
||||
def fail_on_build_info(self: Path, missing_ok: bool = False) -> None:
|
||||
if self.name == "build_info.json":
|
||||
raise OSError("simulated EACCES")
|
||||
real_unlink(self, missing_ok=missing_ok)
|
||||
|
||||
# Setup mocks
|
||||
mock_core.relative_src_path.side_effect = src_path.joinpath
|
||||
mock_core.relative_build_path.side_effect = build_path.joinpath
|
||||
mock_core.defines = []
|
||||
mock_core.config_hash = 0xDEADBEEF
|
||||
mock_core.comment = ""
|
||||
mock_core.target_platform = "test_platform"
|
||||
mock_core.config = {}
|
||||
mock_iter_components.return_value = []
|
||||
mock_walk_files.return_value = []
|
||||
|
||||
with (
|
||||
patch("esphome.writer.__version__", "2025.1.0-dev"),
|
||||
patch("esphome.writer.importlib.import_module") as mock_import,
|
||||
):
|
||||
mock_import.side_effect = AttributeError
|
||||
copy_src_tree()
|
||||
|
||||
# Verify build_info files were created despite invalid JSON
|
||||
assert build_info_h_path.exists()
|
||||
new_json = json.loads(build_info_json_path.read_text())
|
||||
assert new_json["config_hash"] == 0xDEADBEEF
|
||||
with patch.object(Path, "unlink", fail_on_build_info):
|
||||
_run_copy_src_tree()
|
||||
assert "Could not remove unreadable build_info.json" in caplog.text
|
||||
assert json.loads(build_info_json_path.read_text())["config_hash"] == 0xDEADBEEF
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
|
||||
Reference in New Issue
Block a user