diff --git a/esphome/writer.py b/esphome/writer.py index 866377d2f5..6f061c53de 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -342,7 +342,9 @@ def copy_src_tree(): or existing.get("esphome_version") != __version__ ): sources_changed = True - except (json.JSONDecodeError, KeyError, OSError): + except (json.JSONDecodeError, AttributeError, KeyError, OSError): + # AttributeError: valid JSON that is not an object (truncated + # or hand-edited) has no .get; treat it as stale like the rest 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 46e60ebd8e..b0de99e039 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -2214,6 +2214,56 @@ def test_copy_src_tree_handles_invalid_build_info_json( 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_non_dict_build_info_json( + mock_walk_files: MagicMock, + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Valid JSON that is not an object (no .get) is treated as stale.""" + # 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 invalid build_info.json + build_info_json_path = build_path / "build_info.json" + build_info_json_path.write_text("[]") + + # 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"), + 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 + + @patch("esphome.writer.CORE") @patch("esphome.writer.iter_components") @patch("esphome.writer.walk_files")