1
0
mirror of https://github.com/esphome/esphome.git synced 2026-02-08 00:31:58 +00:00

Add build info to image (#12425)

Co-authored-by: J. Nick Koston <nick+github@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
David Woodhouse
2025-12-18 00:06:52 +00:00
committed by GitHub
parent 2b337aa306
commit 9de7df7b5b
27 changed files with 1458 additions and 53 deletions

View File

@@ -1,6 +1,10 @@
"""Test writer module functionality."""
from collections.abc import Callable
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime
import json
import os
from pathlib import Path
import stat
@@ -20,6 +24,9 @@ from esphome.writer import (
clean_all,
clean_build,
clean_cmake_cache,
copy_src_tree,
generate_build_info_data_h,
get_build_info,
storage_should_clean,
update_storage_json,
write_cpp,
@@ -1165,3 +1172,721 @@ def test_clean_build_reraises_for_other_errors(
finally:
# Cleanup - restore write permission so tmp_path cleanup works
os.chmod(subdir, stat.S_IRWXU)
# Tests for get_build_info()
@patch("esphome.writer.CORE")
def test_get_build_info_new_build(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test get_build_info returns new build_time when no existing build_info.json."""
build_info_path = tmp_path / "build_info.json"
mock_core.relative_build_path.return_value = build_info_path
mock_core.config_hash = 0x12345678
config_hash, build_time, build_time_str = get_build_info()
assert config_hash == 0x12345678
assert isinstance(build_time, int)
assert build_time > 0
assert isinstance(build_time_str, str)
# Verify build_time_str format matches expected pattern
assert len(build_time_str) >= 19 # e.g., "2025-12-15 16:27:44 +0000"
@patch("esphome.writer.CORE")
def test_get_build_info_always_returns_current_time(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test get_build_info always returns current build_time."""
build_info_path = tmp_path / "build_info.json"
mock_core.relative_build_path.return_value = build_info_path
mock_core.config_hash = 0x12345678
# Create existing build_info.json with matching config_hash and version
existing_build_time = 1700000000
existing_build_time_str = "2023-11-14 22:13:20 +0000"
build_info_path.write_text(
json.dumps(
{
"config_hash": 0x12345678,
"build_time": existing_build_time,
"build_time_str": existing_build_time_str,
"esphome_version": "2025.1.0-dev",
}
)
)
with patch("esphome.writer.__version__", "2025.1.0-dev"):
config_hash, build_time, build_time_str = get_build_info()
assert config_hash == 0x12345678
# get_build_info now always returns current time
assert build_time != existing_build_time
assert build_time > existing_build_time
assert build_time_str != existing_build_time_str
@patch("esphome.writer.CORE")
def test_get_build_info_config_changed(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test get_build_info returns new build_time when config hash changed."""
build_info_path = tmp_path / "build_info.json"
mock_core.relative_build_path.return_value = build_info_path
mock_core.config_hash = 0xABCDEF00 # Different from existing
# Create existing build_info.json with different config_hash
existing_build_time = 1700000000
build_info_path.write_text(
json.dumps(
{
"config_hash": 0x12345678, # Different
"build_time": existing_build_time,
"build_time_str": "2023-11-14 22:13:20 +0000",
"esphome_version": "2025.1.0-dev",
}
)
)
with patch("esphome.writer.__version__", "2025.1.0-dev"):
config_hash, build_time, build_time_str = get_build_info()
assert config_hash == 0xABCDEF00
assert build_time != existing_build_time # New time generated
assert build_time > existing_build_time
@patch("esphome.writer.CORE")
def test_get_build_info_version_changed(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test get_build_info returns new build_time when ESPHome version changed."""
build_info_path = tmp_path / "build_info.json"
mock_core.relative_build_path.return_value = build_info_path
mock_core.config_hash = 0x12345678
# Create existing build_info.json with different version
existing_build_time = 1700000000
build_info_path.write_text(
json.dumps(
{
"config_hash": 0x12345678,
"build_time": existing_build_time,
"build_time_str": "2023-11-14 22:13:20 +0000",
"esphome_version": "2024.12.0", # Old version
}
)
)
with patch("esphome.writer.__version__", "2025.1.0-dev"): # New version
config_hash, build_time, build_time_str = get_build_info()
assert config_hash == 0x12345678
assert build_time != existing_build_time # New time generated
assert build_time > existing_build_time
@patch("esphome.writer.CORE")
def test_get_build_info_invalid_json(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test get_build_info handles invalid JSON gracefully."""
build_info_path = tmp_path / "build_info.json"
mock_core.relative_build_path.return_value = build_info_path
mock_core.config_hash = 0x12345678
# Create invalid JSON file
build_info_path.write_text("not valid json {{{")
config_hash, build_time, build_time_str = get_build_info()
assert config_hash == 0x12345678
assert isinstance(build_time, int)
assert build_time > 0
@patch("esphome.writer.CORE")
def test_get_build_info_missing_keys(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test get_build_info handles missing keys gracefully."""
build_info_path = tmp_path / "build_info.json"
mock_core.relative_build_path.return_value = build_info_path
mock_core.config_hash = 0x12345678
# Create JSON with missing keys
build_info_path.write_text(json.dumps({"config_hash": 0x12345678}))
with patch("esphome.writer.__version__", "2025.1.0-dev"):
config_hash, build_time, build_time_str = get_build_info()
assert config_hash == 0x12345678
assert isinstance(build_time, int)
assert build_time > 0
@patch("esphome.writer.CORE")
def test_get_build_info_build_time_str_format(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test get_build_info returns correctly formatted build_time_str."""
build_info_path = tmp_path / "build_info.json"
mock_core.relative_build_path.return_value = build_info_path
mock_core.config_hash = 0x12345678
config_hash, build_time, build_time_str = get_build_info()
# Verify the format matches "%Y-%m-%d %H:%M:%S %z"
# e.g., "2025-12-15 16:27:44 +0000"
parsed = datetime.strptime(build_time_str, "%Y-%m-%d %H:%M:%S %z")
assert parsed.year >= 2024
def test_generate_build_info_data_h_format() -> None:
"""Test generate_build_info_data_h produces correct header content."""
config_hash = 0x12345678
build_time = 1700000000
build_time_str = "2023-11-14 22:13:20 +0000"
result = generate_build_info_data_h(config_hash, build_time, build_time_str)
assert "#pragma once" in result
assert "#define ESPHOME_CONFIG_HASH 0x12345678U" in result
assert "#define ESPHOME_BUILD_TIME 1700000000" in result
assert 'ESPHOME_BUILD_TIME_STR[] = "2023-11-14 22:13:20 +0000"' in result
def test_generate_build_info_data_h_esp8266_progmem() -> None:
"""Test generate_build_info_data_h includes PROGMEM for ESP8266."""
result = generate_build_info_data_h(0xABCDEF01, 1700000000, "test")
# Should have ESP8266 PROGMEM conditional
assert "#ifdef USE_ESP8266" in result
assert "#include <pgmspace.h>" in result
assert "PROGMEM" in result
def test_generate_build_info_data_h_hash_formatting() -> None:
"""Test generate_build_info_data_h formats hash with leading zeros."""
# Test with small hash value that needs leading zeros
result = generate_build_info_data_h(0x00000001, 0, "test")
assert "#define ESPHOME_CONFIG_HASH 0x00000001U" in result
# Test with larger hash value
result = generate_build_info_data_h(0xFFFFFFFF, 0, "test")
assert "#define ESPHOME_CONFIG_HASH 0xffffffffU" in result
@patch("esphome.writer.CORE")
@patch("esphome.writer.iter_components")
@patch("esphome.writer.walk_files")
def test_copy_src_tree_writes_build_info_files(
mock_walk_files: MagicMock,
mock_iter_components: MagicMock,
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test copy_src_tree writes build_info_data.h and build_info.json."""
# 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 mock source files for defines.h and version.h
mock_defines_h = esphome_core_path / "defines.h"
mock_defines_h.write_text("// mock defines.h")
mock_version_h = esphome_core_path / "version.h"
mock_version_h.write_text("// mock version.h")
# Create mock FileResource that returns our temp files
@dataclass(frozen=True)
class MockFileResource:
package: str
resource: str
_path: Path
@contextmanager
def path(self):
yield self._path
# Create mock resources for defines.h and version.h (required by copy_src_tree)
mock_resources = [
MockFileResource(
package="esphome.core",
resource="defines.h",
_path=mock_defines_h,
),
MockFileResource(
package="esphome.core",
resource="version.h",
_path=mock_version_h,
),
]
# Create mock component with resources
mock_component = MagicMock()
mock_component.resources = mock_resources
# Setup mocks
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF
mock_core.target_platform = "test_platform"
mock_core.config = {}
mock_iter_components.return_value = [("core", mock_component)]
mock_walk_files.return_value = []
# Create mock module without copy_files attribute (causes AttributeError which is caught)
mock_module = MagicMock(spec=[]) # Empty spec = no copy_files attribute
with (
patch("esphome.writer.__version__", "2025.1.0-dev"),
patch("esphome.writer.importlib.import_module", return_value=mock_module),
):
copy_src_tree()
# Verify build_info_data.h was written
build_info_h_path = esphome_core_path / "build_info_data.h"
assert build_info_h_path.exists()
build_info_h_content = build_info_h_path.read_text()
assert "#define ESPHOME_CONFIG_HASH 0xdeadbeefU" in build_info_h_content
assert "#define ESPHOME_BUILD_TIME" in build_info_h_content
assert "ESPHOME_BUILD_TIME_STR" in build_info_h_content
# Verify build_info.json was written
build_info_json_path = build_path / "build_info.json"
assert build_info_json_path.exists()
build_info_json = json.loads(build_info_json_path.read_text())
assert build_info_json["config_hash"] == 0xDEADBEEF
assert "build_time" in build_info_json
assert "build_time_str" in build_info_json
assert build_info_json["esphome_version"] == "2025.1.0-dev"
@patch("esphome.writer.CORE")
@patch("esphome.writer.iter_components")
@patch("esphome.writer.walk_files")
def test_copy_src_tree_detects_config_hash_change(
mock_walk_files: MagicMock,
mock_iter_components: MagicMock,
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",
}
)
)
# 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 = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF # Different from existing
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()
new_content = build_info_h_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
@patch("esphome.writer.CORE")
@patch("esphome.writer.iter_components")
@patch("esphome.writer.walk_files")
def test_copy_src_tree_detects_version_change(
mock_walk_files: MagicMock,
mock_iter_components: MagicMock,
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
}
)
)
# 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 = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF
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()
new_json = json.loads(build_info_json_path.read_text())
assert new_json["esphome_version"] == "2025.1.0-dev"
@patch("esphome.writer.CORE")
@patch("esphome.writer.iter_components")
@patch("esphome.writer.walk_files")
def test_copy_src_tree_handles_invalid_build_info_json(
mock_walk_files: MagicMock,
mock_iter_components: MagicMock,
mock_core: MagicMock,
tmp_path: Path,
) -> 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()
# Create invalid build_info.json
build_info_json_path = build_path / "build_info.json"
build_info_json_path.write_text("invalid json {{{")
# 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 = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF
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")
def test_copy_src_tree_build_info_timestamp_behavior(
mock_walk_files: MagicMock,
mock_iter_components: MagicMock,
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test build_info behaviour: regenerated on change, preserved when unchanged."""
# Setup directory structure
src_path = tmp_path / "src"
src_path.mkdir()
esphome_core_path = src_path / "esphome" / "core"
esphome_core_path.mkdir(parents=True)
esphome_components_path = src_path / "esphome" / "components"
esphome_components_path.mkdir(parents=True)
build_path = tmp_path / "build"
build_path.mkdir()
# Create a source file
source_file = tmp_path / "source" / "test.cpp"
source_file.parent.mkdir()
source_file.write_text("// version 1")
# Create destination file in build tree
dest_file = esphome_components_path / "test.cpp"
# Create mock FileResource
@dataclass(frozen=True)
class MockFileResource:
package: str
resource: str
_path: Path
@contextmanager
def path(self):
yield self._path
mock_resources = [
MockFileResource(
package="esphome.components",
resource="test.cpp",
_path=source_file,
),
]
mock_component = MagicMock()
mock_component.resources = mock_resources
# Setup mocks
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF
mock_core.target_platform = "test_platform"
mock_core.config = {}
mock_iter_components.return_value = [("test", mock_component)]
build_info_json_path = build_path / "build_info.json"
# First run: initial setup, should create build_info
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()
# Manually set an old timestamp for testing
old_timestamp = 1700000000
old_timestamp_str = "2023-11-14 22:13:20 +0000"
build_info_json_path.write_text(
json.dumps(
{
"config_hash": 0xDEADBEEF,
"build_time": old_timestamp,
"build_time_str": old_timestamp_str,
"esphome_version": "2025.1.0-dev",
}
)
)
# Second run: no changes, should NOT regenerate build_info
mock_walk_files.return_value = [str(dest_file)]
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()
second_json = json.loads(build_info_json_path.read_text())
second_timestamp = second_json["build_time"]
# Verify timestamp was NOT changed
assert second_timestamp == old_timestamp, (
f"build_info should not be regenerated when no files change: "
f"{old_timestamp} != {second_timestamp}"
)
# Third run: change source file, should regenerate build_info with new timestamp
source_file.write_text("// version 2")
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()
third_json = json.loads(build_info_json_path.read_text())
third_timestamp = third_json["build_time"]
# Verify timestamp WAS changed
assert third_timestamp != old_timestamp, (
f"build_info should be regenerated when source file changes: "
f"{old_timestamp} == {third_timestamp}"
)
assert third_timestamp > old_timestamp
@patch("esphome.writer.CORE")
@patch("esphome.writer.iter_components")
@patch("esphome.writer.walk_files")
def test_copy_src_tree_detects_removed_source_file(
mock_walk_files: MagicMock,
mock_iter_components: MagicMock,
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test copy_src_tree detects when a non-generated source file is removed."""
# Setup directory structure
src_path = tmp_path / "src"
src_path.mkdir()
esphome_components_path = src_path / "esphome" / "components"
esphome_components_path.mkdir(parents=True)
build_path = tmp_path / "build"
build_path.mkdir()
# Create an existing source file in the build tree
existing_file = esphome_components_path / "test.cpp"
existing_file.write_text("// test file")
# Setup mocks - no components, so the file should be removed
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF
mock_core.target_platform = "test_platform"
mock_core.config = {}
mock_iter_components.return_value = [] # No components = file should be removed
mock_walk_files.return_value = [str(existing_file)]
# Create existing build_info.json
build_info_json_path = build_path / "build_info.json"
old_timestamp = 1700000000
build_info_json_path.write_text(
json.dumps(
{
"config_hash": 0xDEADBEEF,
"build_time": old_timestamp,
"build_time_str": "2023-11-14 22:13:20 +0000",
"esphome_version": "2025.1.0-dev",
}
)
)
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 file was removed
assert not existing_file.exists()
# Verify build_info was regenerated due to source file removal
new_json = json.loads(build_info_json_path.read_text())
assert new_json["build_time"] != old_timestamp
@patch("esphome.writer.CORE")
@patch("esphome.writer.iter_components")
@patch("esphome.writer.walk_files")
def test_copy_src_tree_ignores_removed_generated_file(
mock_walk_files: MagicMock,
mock_iter_components: MagicMock,
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test copy_src_tree doesn't mark sources_changed when only generated file removed."""
# 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_data.h (a generated file)
build_info_h = esphome_core_path / "build_info_data.h"
build_info_h.write_text("// old generated file")
# Setup mocks
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF
mock_core.target_platform = "test_platform"
mock_core.config = {}
mock_iter_components.return_value = []
# walk_files returns the generated file, but it's not in source_files_copy
mock_walk_files.return_value = [str(build_info_h)]
# Create existing build_info.json with old timestamp
build_info_json_path = build_path / "build_info.json"
old_timestamp = 1700000000
build_info_json_path.write_text(
json.dumps(
{
"config_hash": 0xDEADBEEF,
"build_time": old_timestamp,
"build_time_str": "2023-11-14 22:13:20 +0000",
"esphome_version": "2025.1.0-dev",
}
)
)
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_data.h was regenerated (not removed)
assert build_info_h.exists()
# Note: build_info.json will have a new timestamp because get_build_info()
# always returns current time. The key test is that the old build_info_data.h
# 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