diff --git a/esphome/writer.py b/esphome/writer.py index 2b6ed929f0..c75c45daa4 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -1,4 +1,3 @@ -from contextlib import suppress import importlib import json import logging @@ -344,12 +343,21 @@ def copy_src_tree(): ): # 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 damage _LOGGER.warning("Replacing damaged build_info.json: %s", err) - with suppress(OSError): - build_info_json_path.unlink(missing_ok=True) + try: + build_info_json_path.unlink() + except OSError as unlink_err: + # The later write re-reads the file, so a kept damaged copy + # fails again with a misattributed error; name the real cause + _LOGGER.warning( + "Could not remove damaged build_info.json: %s", unlink_err + ) sources_changed = True # Write build_info header and JSON metadata diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 0551090e2c..6bf20e20c2 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -2143,6 +2143,56 @@ def test_copy_src_tree_regenerates_damaged_build_info( 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_missing_build_info_rebuilds_quietly( + mock_walk_files: MagicMock, + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """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 "damaged" not in caplog.text + + +@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 + + 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) + + with patch.object(Path, "unlink", fail_on_build_info): + _run_copy_src_tree() + assert "Could not remove damaged build_info.json" in caplog.text + assert json.loads(build_info_json_path.read_text())["config_hash"] == 0xDEADBEEF + + @patch("esphome.writer.CORE") @patch("esphome.writer.iter_components") @patch("esphome.writer.walk_files")