From b3d9aa2d8459fd0bb30b8bbcf5f5ff8e35ac1937 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Aug 2026 18:46:43 -0500 Subject: [PATCH] [core] Log which source change triggered a rebuild (#18552) --- esphome/platformio/library.py | 13 +-- esphome/writer.py | 88 ++++++++++++++------- tests/unit_tests/test_platformio_library.py | 8 +- tests/unit_tests/test_writer.py | 36 +++++++++ 4 files changed, 107 insertions(+), 38 deletions(-) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index bf9c323b84..50e1408b13 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -55,8 +55,9 @@ DEFAULT_BUILD_SRC_DIRS = "src" DEFAULT_BUILD_INCLUDE_DIR = "include" DEFAULT_BUILD_FLAGS = [] # Suffix -> compiler kind (PlatformIO's CSUFFIXES/CXXSUFFIXES/ASSUFFIXES); -# "asm" merges SCons's AS and ASPP sets. Per CXXSUFFIXES .C/.C++ are C++ -# here, even where SCons demotes .C on case-insensitive filesystems. +# "aspp" is SCons's preprocessed-assembly set, "asm" its plain-assembler +# set (no preprocessor, defines, or includes). Per CXXSUFFIXES .C/.C++ are +# C++ here, even where SCons demotes .C on case-insensitive filesystems. SOURCE_KIND_FOR_SUFFIX: dict[str, str] = { ".c": "c", ".cpp": "cxx", @@ -65,10 +66,10 @@ SOURCE_KIND_FOR_SUFFIX: dict[str, str] = { ".c++": "cxx", ".C": "cxx", ".C++": "cxx", - ".S": "asm", - ".spp": "asm", - ".SPP": "asm", - ".sx": "asm", + ".S": "aspp", + ".spp": "aspp", + ".SPP": "aspp", + ".sx": "aspp", ".s": "asm", ".asm": "asm", ".ASM": "asm", diff --git a/esphome/writer.py b/esphome/writer.py index d614204603..85c0642774 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -288,11 +288,13 @@ def copy_src_tree(): # Source file removed, delete target p.unlink() if target not in generated_files: + _LOGGER.debug("Source removed: %s", target) sources_changed = True else: src_file = source_files_copy.pop(target) with src_file.path() as src_path: if copy_file_if_changed(src_path, p) and target not in generated_files: + _LOGGER.debug("Source changed: %s", target) sources_changed = True # Now copy new files @@ -303,21 +305,25 @@ def copy_src_tree(): copy_file_if_changed(src_path, dst_path) and target not in generated_files ): + _LOGGER.debug("Source added: %s", target) sources_changed = True # Finally copy defines if write_file_if_changed( CORE.relative_src_path("esphome", "core", "defines.h"), generate_defines_h() ): + _LOGGER.debug("Source changed: esphome/core/defines.h") sources_changed = True write_file_if_changed(CORE.relative_build_path("README.txt"), ESPHOME_README_TXT) if write_file_if_changed( CORE.relative_src_path("esphome.h"), ESPHOME_H_FORMAT.format(include_s) ): + _LOGGER.debug("Source changed: esphome.h") sources_changed = True if write_file_if_changed( CORE.relative_src_path("esphome", "core", "version.h"), generate_version_h() ): + _LOGGER.debug("Source changed: esphome/core/version.h") sources_changed = True # Generate new build_info files if needed @@ -332,35 +338,13 @@ def copy_src_tree(): # Defensively force a rebuild if the build_info files don't exist, or if # there was a config change which didn't actually cause a source change - if not build_info_data_h_path.exists() or not build_info_data_cpp_path.exists(): + if _build_info_stale( + build_info_data_h_path, + build_info_data_cpp_path, + build_info_json_path, + config_hash, + ): sources_changed = True - else: - try: - existing = json.loads(build_info_json_path.read_text(encoding="utf-8")) - 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 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 if sources_changed: @@ -414,6 +398,54 @@ def generate_version_h(): ) +def _build_info_stale( + h_path: Path, cpp_path: Path, json_path: Path, config_hash: int +) -> bool: + """Whether the build-info sources must regenerate (missing or stale).""" + if not h_path.exists() or not cpp_path.exists(): + _LOGGER.debug("Build info files missing; regenerating") + return True + try: + existing = json.loads(json_path.read_text(encoding="utf-8")) + except FileNotFoundError: + # An absent build_info.json is stale, not damaged; rebuild quietly + _LOGGER.debug("Build info JSON missing; regenerating") + return 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 + 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 + ) + return True + if not isinstance(existing, dict): + # Valid JSON that is not an object (truncated or hand-edited) is + # stale, not a traceback + _LOGGER.debug("Build info JSON malformed; regenerating") + return True + if ( + existing.get("config_hash") != config_hash + or existing.get("esphome_version") != __version__ + ): + _LOGGER.debug( + "Build info stale (config_hash %s -> %s, version %s -> %s)", + existing.get("config_hash"), + config_hash, + existing.get("esphome_version"), + __version__, + ) + return True + return False + + def get_build_info() -> tuple[int, int, str, str]: """Calculate build_info values from current config. diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 792d7dab61..ef24f99953 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -929,11 +929,11 @@ def test_split_flag_entry_non_string_is_clean() -> None: def test_source_kind_map_shape() -> None: - """The kind values the native compile rules key on, and the deliberate - AS/ASPP merge (.s and .S both map to asm).""" + """The kind values the native compile rules key on; the AS/ASPP split + matches SCons (.S preprocessed, .s plain assembler).""" - assert set(SOURCE_KIND_FOR_SUFFIX.values()) == {"c", "cxx", "asm"} + assert set(SOURCE_KIND_FOR_SUFFIX.values()) == {"c", "cxx", "asm", "aspp"} assert SOURCE_KIND_FOR_SUFFIX[".s"] == "asm" - assert SOURCE_KIND_FOR_SUFFIX[".S"] == "asm" + assert SOURCE_KIND_FOR_SUFFIX[".S"] == "aspp" assert SOURCE_KIND_FOR_SUFFIX[".c"] == "c" assert SOURCE_KIND_FOR_SUFFIX[".cpp"] == "cxx" diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index c73a5c5789..0a53dba9c2 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -2441,3 +2441,39 @@ def test_copy_src_tree_ignores_removed_generated_file( # file was removed and regenerated, not that it triggered sources_changed. new_json = json.loads(build_info_json_path.read_text()) assert new_json["config_hash"] == 0xDEADBEEF + + +@pytest.mark.parametrize( + ("case", "content", "expected"), + [ + ("files missing", None, True), + ("json missing", "ABSENT", True), + ("json unreadable", "not json", True), + # Valid JSON that is not an object is stale, not an AttributeError + ("json not an object", "[]", True), + ("hash mismatch", {"config_hash": 2, "esphome_version": "CURRENT"}, True), + ("version mismatch", {"config_hash": 1, "esphome_version": "0.0.0"}, True), + ("matching record", {"config_hash": 1, "esphome_version": "CURRENT"}, False), + ], +) +def test_build_info_stale_branches( + tmp_path: Path, case: str, content, expected: bool +) -> None: + """Missing files, an unreadable JSON, a hash or version mismatch each + regenerate; a matching record does not.""" + from esphome.const import __version__ + from esphome.writer import _build_info_stale + + h = tmp_path / "build_info_data.h" + cpp = tmp_path / "build_info_data.cpp" + info = tmp_path / "build_info.json" + if content is not None: + h.write_text("") + cpp.write_text("") + if isinstance(content, dict): + if content.get("esphome_version") == "CURRENT": + content["esphome_version"] = __version__ + info.write_text(json.dumps(content)) + elif isinstance(content, str) and content != "ABSENT": + info.write_text(content) + assert _build_info_stale(h, cpp, info, 1) is expected, case