1
0
mirror of https://github.com/esphome/esphome.git synced 2025-10-31 15:12:06 +00:00

Merge branch 'dev' into platformio_cache_tests

This commit is contained in:
J. Nick Koston
2025-10-19 16:14:16 -10:00
committed by GitHub
8 changed files with 471 additions and 46 deletions

View File

@@ -28,20 +28,23 @@ jobs:
run: | run: |
# Get PR details by searching for PR with matching head SHA # Get PR details by searching for PR with matching head SHA
# The workflow_run.pull_requests field is often empty for forks # The workflow_run.pull_requests field is often empty for forks
# Use paginate to handle repos with many open PRs
head_sha="${{ github.event.workflow_run.head_sha }}" head_sha="${{ github.event.workflow_run.head_sha }}"
pr_data=$(gh api "/repos/${{ github.repository }}/commits/$head_sha/pulls" \ pr_data=$(gh api --paginate "/repos/${{ github.repository }}/pulls" \
--jq '.[0] | {number: .number, base_ref: .base.ref}') --jq ".[] | select(.head.sha == \"$head_sha\") | {number: .number, base_ref: .base.ref}" \
if [ -z "$pr_data" ] || [ "$pr_data" == "null" ]; then | head -n 1)
if [ -z "$pr_data" ]; then
echo "No PR found for SHA $head_sha, skipping" echo "No PR found for SHA $head_sha, skipping"
echo "skip=true" >> $GITHUB_OUTPUT echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
pr_number=$(echo "$pr_data" | jq -r '.number') pr_number=$(echo "$pr_data" | jq -r '.number')
base_ref=$(echo "$pr_data" | jq -r '.base_ref') base_ref=$(echo "$pr_data" | jq -r '.base_ref')
echo "pr_number=$pr_number" >> $GITHUB_OUTPUT echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
echo "base_ref=$base_ref" >> $GITHUB_OUTPUT echo "base_ref=$base_ref" >> "$GITHUB_OUTPUT"
echo "Found PR #$pr_number targeting base branch: $base_ref" echo "Found PR #$pr_number targeting base branch: $base_ref"
- name: Check out code from base repository - name: Check out code from base repository
@@ -87,9 +90,9 @@ jobs:
if: steps.pr.outputs.skip != 'true' if: steps.pr.outputs.skip != 'true'
run: | run: |
if [ -f ./memory-analysis/memory-analysis-target.json ] && [ -f ./memory-analysis/memory-analysis-pr.json ]; then if [ -f ./memory-analysis/memory-analysis-target.json ] && [ -f ./memory-analysis/memory-analysis-pr.json ]; then
echo "found=true" >> $GITHUB_OUTPUT echo "found=true" >> "$GITHUB_OUTPUT"
else else
echo "found=false" >> $GITHUB_OUTPUT echo "found=false" >> "$GITHUB_OUTPUT"
echo "Memory analysis artifacts not found, skipping comment" echo "Memory analysis artifacts not found, skipping comment"
fi fi

View File

@@ -62,6 +62,40 @@ from esphome.util import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Special non-component keys that appear in configs
_NON_COMPONENT_KEYS = frozenset(
{
CONF_ESPHOME,
"substitutions",
"packages",
"globals",
"external_components",
"<<",
}
)
def detect_external_components(config: ConfigType) -> set[str]:
"""Detect external/custom components in the configuration.
External components are those that appear in the config but are not
part of ESPHome's built-in components and are not special config keys.
Args:
config: The ESPHome configuration dictionary
Returns:
A set of external component names
"""
from esphome.analyze_memory.helpers import get_esphome_components
builtin_components = get_esphome_components()
return {
key
for key in config
if key not in builtin_components and key not in _NON_COMPONENT_KEYS
}
class ArgsProtocol(Protocol): class ArgsProtocol(Protocol):
device: list[str] | None device: list[str] | None
@@ -892,6 +926,54 @@ def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
return 0 return 0
def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
"""Analyze memory usage by component.
This command compiles the configuration and performs memory analysis.
Compilation is fast if sources haven't changed (just relinking).
"""
from esphome import platformio_api
from esphome.analyze_memory.cli import MemoryAnalyzerCLI
# Always compile to ensure fresh data (fast if no changes - just relinks)
exit_code = write_cpp(config)
if exit_code != 0:
return exit_code
exit_code = compile_program(args, config)
if exit_code != 0:
return exit_code
_LOGGER.info("Successfully compiled program.")
# Get idedata for analysis
idedata = platformio_api.get_idedata(config)
if idedata is None:
_LOGGER.error("Failed to get IDE data for memory analysis")
return 1
firmware_elf = Path(idedata.firmware_elf_path)
# Extract external components from config
external_components = detect_external_components(config)
_LOGGER.debug("Detected external components: %s", external_components)
# Perform memory analysis
_LOGGER.info("Analyzing memory usage...")
analyzer = MemoryAnalyzerCLI(
str(firmware_elf),
idedata.objdump_path,
idedata.readelf_path,
external_components,
)
analyzer.analyze()
# Generate and display report
report = analyzer.generate_report()
print()
print(report)
return 0
def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
new_name = args.name new_name = args.name
for c in new_name: for c in new_name:
@@ -1007,6 +1089,7 @@ POST_CONFIG_ACTIONS = {
"idedata": command_idedata, "idedata": command_idedata,
"rename": command_rename, "rename": command_rename,
"discover": command_discover, "discover": command_discover,
"analyze-memory": command_analyze_memory,
} }
SIMPLE_CONFIG_ACTIONS = [ SIMPLE_CONFIG_ACTIONS = [
@@ -1292,6 +1375,14 @@ def parse_args(argv):
) )
parser_rename.add_argument("name", help="The new name for the device.", type=str) parser_rename.add_argument("name", help="The new name for the device.", type=str)
parser_analyze_memory = subparsers.add_parser(
"analyze-memory",
help="Analyze memory usage by component.",
)
parser_analyze_memory.add_argument(
"configuration", help="Your YAML configuration file(s).", nargs="+"
)
# Keep backward compatibility with the old command line format of # Keep backward compatibility with the old command line format of
# esphome <config> <command>. # esphome <config> <command>.
# #

View File

@@ -6,7 +6,7 @@ import esphome.config_validation as cv
from esphome.const import CONF_SUBSTITUTIONS, VALID_SUBSTITUTIONS_CHARACTERS from esphome.const import CONF_SUBSTITUTIONS, VALID_SUBSTITUTIONS_CHARACTERS
from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, make_data_base from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, make_data_base
from .jinja import Jinja, JinjaStr, TemplateError, TemplateRuntimeError, has_jinja from .jinja import Jinja, JinjaError, JinjaStr, has_jinja
CODEOWNERS = ["@esphome/core"] CODEOWNERS = ["@esphome/core"]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -57,17 +57,12 @@ def _expand_jinja(value, orig_value, path, jinja, ignore_missing):
"->".join(str(x) for x in path), "->".join(str(x) for x in path),
err.message, err.message,
) )
except ( except JinjaError as err:
TemplateError,
TemplateRuntimeError,
RuntimeError,
ArithmeticError,
AttributeError,
TypeError,
) as err:
raise cv.Invalid( raise cv.Invalid(
f"{type(err).__name__} Error evaluating jinja expression '{value}': {str(err)}." f"{err.error_name()} Error evaluating jinja expression '{value}': {str(err.parent())}."
f" See {'->'.join(str(x) for x in path)}", f"\nEvaluation stack: (most recent evaluation last)\n{err.stack_trace_str()}"
f"\nRelevant context:\n{err.context_trace_str()}"
f"\nSee {'->'.join(str(x) for x in path)}",
path, path,
) )
return value return value

View File

@@ -6,6 +6,8 @@ import re
import jinja2 as jinja import jinja2 as jinja
from jinja2.sandbox import SandboxedEnvironment from jinja2.sandbox import SandboxedEnvironment
from esphome.yaml_util import ESPLiteralValue
TemplateError = jinja.TemplateError TemplateError = jinja.TemplateError
TemplateSyntaxError = jinja.TemplateSyntaxError TemplateSyntaxError = jinja.TemplateSyntaxError
TemplateRuntimeError = jinja.TemplateRuntimeError TemplateRuntimeError = jinja.TemplateRuntimeError
@@ -26,18 +28,20 @@ def has_jinja(st):
return detect_jinja_re.search(st) is not None return detect_jinja_re.search(st) is not None
# SAFE_GLOBAL_FUNCTIONS defines a allowlist of built-in functions that are considered safe to expose # SAFE_GLOBALS defines a allowlist of built-in functions or modules that are considered safe to expose
# in Jinja templates or other sandboxed evaluation contexts. Only functions that do not allow # in Jinja templates or other sandboxed evaluation contexts. Only functions that do not allow
# arbitrary code execution, file access, or other security risks are included. # arbitrary code execution, file access, or other security risks are included.
# #
# The following functions are considered safe: # The following functions are considered safe:
# - math: The entire math module is injected, allowing access to mathematical functions like sin, cos, sqrt, etc.
# - ord: Converts a character to its Unicode code point integer. # - ord: Converts a character to its Unicode code point integer.
# - chr: Converts an integer to its corresponding Unicode character. # - chr: Converts an integer to its corresponding Unicode character.
# - len: Returns the length of a sequence or collection. # - len: Returns the length of a sequence or collection.
# #
# These functions were chosen because they are pure, have no side effects, and do not provide access # These functions were chosen because they are pure, have no side effects, and do not provide access
# to the file system, environment, or other potentially sensitive resources. # to the file system, environment, or other potentially sensitive resources.
SAFE_GLOBAL_FUNCTIONS = { SAFE_GLOBALS = {
"math": math, # Inject entire math module
"ord": ord, "ord": ord,
"chr": chr, "chr": chr,
"len": len, "len": len,
@@ -56,22 +60,62 @@ class JinjaStr(str):
later in the main substitutions pass. later in the main substitutions pass.
""" """
Undefined = object()
def __new__(cls, value: str, upvalues=None): def __new__(cls, value: str, upvalues=None):
obj = super().__new__(cls, value) if isinstance(value, JinjaStr):
obj.upvalues = upvalues or {} base = str(value)
merged = {**value.upvalues, **(upvalues or {})}
else:
base = value
merged = dict(upvalues or {})
obj = super().__new__(cls, base)
obj.upvalues = merged
obj.result = JinjaStr.Undefined
return obj return obj
def __init__(self, value: str, upvalues=None):
self.upvalues = upvalues or {} class JinjaError(Exception):
def __init__(self, context_trace: dict, expr: str):
self.context_trace = context_trace
self.eval_stack = [expr]
def parent(self):
return self.__context__
def error_name(self):
return type(self.parent()).__name__
def context_trace_str(self):
return "\n".join(
f" {k} = {repr(v)} ({type(v).__name__})"
for k, v in self.context_trace.items()
)
def stack_trace_str(self):
return "\n".join(
f" {len(self.eval_stack) - i}: {expr}{i == 0 and ' <-- ' + self.error_name() or ''}"
for i, expr in enumerate(self.eval_stack)
)
class Jinja: class TrackerContext(jinja.runtime.Context):
def resolve_or_missing(self, key):
val = super().resolve_or_missing(key)
if isinstance(val, JinjaStr):
self.environment.context_trace[key] = val
val, _ = self.environment.expand(val)
self.environment.context_trace[key] = val
return val
class Jinja(SandboxedEnvironment):
""" """
Wraps a Jinja environment Wraps a Jinja environment
""" """
def __init__(self, context_vars): def __init__(self, context_vars):
self.env = SandboxedEnvironment( super().__init__(
trim_blocks=True, trim_blocks=True,
lstrip_blocks=True, lstrip_blocks=True,
block_start_string="<%", block_start_string="<%",
@@ -82,13 +126,20 @@ class Jinja:
variable_end_string="}", variable_end_string="}",
undefined=jinja.StrictUndefined, undefined=jinja.StrictUndefined,
) )
self.env.add_extension("jinja2.ext.do") self.context_class = TrackerContext
self.env.globals["math"] = math # Inject entire math module self.add_extension("jinja2.ext.do")
self.context_trace = {}
self.context_vars = {**context_vars} self.context_vars = {**context_vars}
self.env.globals = { for k, v in self.context_vars.items():
**self.env.globals, if isinstance(v, ESPLiteralValue):
continue
if isinstance(v, str) and not isinstance(v, JinjaStr) and has_jinja(v):
self.context_vars[k] = JinjaStr(v, self.context_vars)
self.globals = {
**self.globals,
**self.context_vars, **self.context_vars,
**SAFE_GLOBAL_FUNCTIONS, **SAFE_GLOBALS,
} }
def safe_eval(self, expr): def safe_eval(self, expr):
@@ -110,23 +161,43 @@ class Jinja:
result = None result = None
override_vars = {} override_vars = {}
if isinstance(content_str, JinjaStr): if isinstance(content_str, JinjaStr):
if content_str.result is not JinjaStr.Undefined:
return content_str.result, None
# If `value` is already a JinjaStr, it means we are trying to evaluate it again # If `value` is already a JinjaStr, it means we are trying to evaluate it again
# in a parent pass. # in a parent pass.
# Hopefully, all required variables are visible now. # Hopefully, all required variables are visible now.
override_vars = content_str.upvalues override_vars = content_str.upvalues
old_trace = self.context_trace
self.context_trace = {}
try: try:
template = self.env.from_string(content_str) template = self.from_string(content_str)
result = self.safe_eval(template.render(override_vars)) result = self.safe_eval(template.render(override_vars))
if isinstance(result, Undefined): if isinstance(result, Undefined):
# This happens when the expression is simply an undefined variable. Jinja does not print("" + result) # force a UndefinedError exception
# raise an exception, instead we get "Undefined".
# Trigger an UndefinedError exception so we skip to below.
print("" + result)
except (TemplateSyntaxError, UndefinedError) as err: except (TemplateSyntaxError, UndefinedError) as err:
# `content_str` contains a Jinja expression that refers to a variable that is undefined # `content_str` contains a Jinja expression that refers to a variable that is undefined
# in this scope. Perhaps it refers to a root substitution that is not visible yet. # in this scope. Perhaps it refers to a root substitution that is not visible yet.
# Therefore, return the original `content_str` as a JinjaStr, which contains the variables # Therefore, return `content_str` as a JinjaStr, which contains the variables
# that are actually visible to it at this point to postpone evaluation. # that are actually visible to it at this point to postpone evaluation.
return JinjaStr(content_str, {**self.context_vars, **override_vars}), err return JinjaStr(content_str, {**self.context_vars, **override_vars}), err
except JinjaError as err:
err.context_trace = {**self.context_trace, **err.context_trace}
err.eval_stack.append(content_str)
raise err
except (
TemplateError,
TemplateRuntimeError,
RuntimeError,
ArithmeticError,
AttributeError,
TypeError,
) as err:
raise JinjaError(self.context_trace, content_str) from err
finally:
self.context_trace = old_trace
if isinstance(content_str, JinjaStr):
content_str.result = result
return result, None return result, None

View File

@@ -273,6 +273,8 @@
#ifdef USE_NRF52 #ifdef USE_NRF52
#define USE_NRF52_DFU #define USE_NRF52_DFU
#define USE_SOFTDEVICE_ID 7
#define USE_SOFTDEVICE_VERSION 1
#endif #endif
// Disabled feature flags // Disabled feature flags

View File

@@ -8,6 +8,7 @@ substitutions:
area: 25 area: 25
numberOne: 1 numberOne: 1
var1: 79 var1: 79
double_width: 14
test_list: test_list:
- The area is 56 - The area is 56
- 56 - 56
@@ -25,3 +26,4 @@ test_list:
- ord("a") = 97 - ord("a") = 97
- chr(97) = a - chr(97) = a
- len([1,2,3]) = 3 - len([1,2,3]) = 3
- width = 7, double_width = 14

View File

@@ -8,6 +8,7 @@ substitutions:
area: 25 area: 25
numberOne: 1 numberOne: 1
var1: 79 var1: 79
double_width: ${width * 2}
test_list: test_list:
- "The area is ${width * height}" - "The area is ${width * height}"
@@ -23,3 +24,4 @@ test_list:
- ord("a") = ${ ord("a") } - ord("a") = ${ ord("a") }
- chr(97) = ${ chr(97) } - chr(97) = ${ chr(97) }
- len([1,2,3]) = ${ len([1,2,3]) } - len([1,2,3]) = ${ len([1,2,3]) }
- width = ${width}, double_width = ${double_width}

View File

@@ -17,10 +17,12 @@ from esphome import platformio_api
from esphome.__main__ import ( from esphome.__main__ import (
Purpose, Purpose,
choose_upload_log_host, choose_upload_log_host,
command_analyze_memory,
command_clean_all, command_clean_all,
command_rename, command_rename,
command_update_all, command_update_all,
command_wizard, command_wizard,
detect_external_components,
get_port_type, get_port_type,
has_ip_address, has_ip_address,
has_mqtt, has_mqtt,
@@ -226,13 +228,47 @@ def mock_run_external_process() -> Generator[Mock]:
@pytest.fixture @pytest.fixture
def mock_run_external_command() -> Generator[Mock]: def mock_run_external_command_main() -> Generator[Mock]:
"""Mock run_external_command for testing.""" """Mock run_external_command in __main__ module (different from platformio_api)."""
with patch("esphome.__main__.run_external_command") as mock: with patch("esphome.__main__.run_external_command") as mock:
mock.return_value = 0 # Default to success mock.return_value = 0 # Default to success
yield mock yield mock
@pytest.fixture
def mock_write_cpp() -> Generator[Mock]:
"""Mock write_cpp for testing."""
with patch("esphome.__main__.write_cpp") as mock:
mock.return_value = 0 # Default to success
yield mock
@pytest.fixture
def mock_compile_program() -> Generator[Mock]:
"""Mock compile_program for testing."""
with patch("esphome.__main__.compile_program") as mock:
mock.return_value = 0 # Default to success
yield mock
@pytest.fixture
def mock_get_esphome_components() -> Generator[Mock]:
"""Mock get_esphome_components for testing."""
with patch("esphome.analyze_memory.helpers.get_esphome_components") as mock:
mock.return_value = {"logger", "api", "ota"}
yield mock
@pytest.fixture
def mock_memory_analyzer_cli() -> Generator[Mock]:
"""Mock MemoryAnalyzerCLI for testing."""
with patch("esphome.analyze_memory.cli.MemoryAnalyzerCLI") as mock_class:
mock_analyzer = MagicMock()
mock_analyzer.generate_report.return_value = "Mock Memory Report"
mock_class.return_value = mock_analyzer
yield mock_class
def test_choose_upload_log_host_with_string_default() -> None: def test_choose_upload_log_host_with_string_default() -> None:
"""Test with a single string default device.""" """Test with a single string default device."""
setup_core() setup_core()
@@ -839,7 +875,7 @@ def test_upload_program_serial_esp8266_with_file(
def test_upload_using_esptool_path_conversion( def test_upload_using_esptool_path_conversion(
tmp_path: Path, tmp_path: Path,
mock_run_external_command: Mock, mock_run_external_command_main: Mock,
mock_get_idedata: Mock, mock_get_idedata: Mock,
) -> None: ) -> None:
"""Test upload_using_esptool properly converts Path objects to strings for esptool. """Test upload_using_esptool properly converts Path objects to strings for esptool.
@@ -875,10 +911,10 @@ def test_upload_using_esptool_path_conversion(
assert result == 0 assert result == 0
# Verify that run_external_command was called # Verify that run_external_command was called
assert mock_run_external_command.call_count == 1 assert mock_run_external_command_main.call_count == 1
# Get the actual call arguments # Get the actual call arguments
call_args = mock_run_external_command.call_args[0] call_args = mock_run_external_command_main.call_args[0]
# The first argument should be esptool.main function, # The first argument should be esptool.main function,
# followed by the command arguments # followed by the command arguments
@@ -917,7 +953,7 @@ def test_upload_using_esptool_path_conversion(
def test_upload_using_esptool_with_file_path( def test_upload_using_esptool_with_file_path(
tmp_path: Path, tmp_path: Path,
mock_run_external_command: Mock, mock_run_external_command_main: Mock,
) -> None: ) -> None:
"""Test upload_using_esptool with a custom file that's a Path object.""" """Test upload_using_esptool with a custom file that's a Path object."""
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test") setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test")
@@ -934,10 +970,10 @@ def test_upload_using_esptool_with_file_path(
assert result == 0 assert result == 0
# Verify that run_external_command was called # Verify that run_external_command was called
mock_run_external_command.assert_called_once() mock_run_external_command_main.assert_called_once()
# Get the actual call arguments # Get the actual call arguments
call_args = mock_run_external_command.call_args[0] call_args = mock_run_external_command_main.call_args[0]
cmd_list = list(call_args[1:]) # Skip the esptool.main function cmd_list = list(call_args[1:]) # Skip the esptool.main function
# Find the firmware path in the command # Find the firmware path in the command
@@ -2273,3 +2309,226 @@ def test_show_logs_api_mqtt_timeout_fallback(
# Verify run_logs was called with only the static IP (MQTT failed) # Verify run_logs was called with only the static IP (MQTT failed)
mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.100"]) mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.100"])
def test_detect_external_components_no_external(
mock_get_esphome_components: Mock,
) -> None:
"""Test detect_external_components with no external components."""
config = {
CONF_ESPHOME: {CONF_NAME: "test_device"},
"logger": {},
"api": {},
}
result = detect_external_components(config)
assert result == set()
mock_get_esphome_components.assert_called_once()
def test_detect_external_components_with_external(
mock_get_esphome_components: Mock,
) -> None:
"""Test detect_external_components detects external components."""
config = {
CONF_ESPHOME: {CONF_NAME: "test_device"},
"logger": {}, # Built-in
"api": {}, # Built-in
"my_custom_sensor": {}, # External
"another_custom": {}, # External
"external_components": [], # Special key, not a component
"substitutions": {}, # Special key, not a component
}
result = detect_external_components(config)
assert result == {"my_custom_sensor", "another_custom"}
mock_get_esphome_components.assert_called_once()
def test_detect_external_components_filters_special_keys(
mock_get_esphome_components: Mock,
) -> None:
"""Test detect_external_components filters out special config keys."""
config = {
CONF_ESPHOME: {CONF_NAME: "test_device"},
"substitutions": {"key": "value"},
"packages": {},
"globals": [],
"external_components": [],
"<<": {}, # YAML merge key
}
result = detect_external_components(config)
assert result == set()
mock_get_esphome_components.assert_called_once()
def test_command_analyze_memory_success(
tmp_path: Path,
capfd: CaptureFixture[str],
mock_write_cpp: Mock,
mock_compile_program: Mock,
mock_get_idedata: Mock,
mock_get_esphome_components: Mock,
mock_memory_analyzer_cli: Mock,
) -> None:
"""Test command_analyze_memory with successful compilation and analysis."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
# Create firmware.elf file
firmware_path = (
tmp_path / ".esphome" / "build" / "test_device" / ".pioenvs" / "test_device"
)
firmware_path.mkdir(parents=True, exist_ok=True)
firmware_elf = firmware_path / "firmware.elf"
firmware_elf.write_text("mock elf file")
# Mock idedata
mock_idedata_obj = MagicMock(spec=platformio_api.IDEData)
mock_idedata_obj.firmware_elf_path = str(firmware_elf)
mock_idedata_obj.objdump_path = "/path/to/objdump"
mock_idedata_obj.readelf_path = "/path/to/readelf"
mock_get_idedata.return_value = mock_idedata_obj
config = {
CONF_ESPHOME: {CONF_NAME: "test_device"},
"logger": {},
}
args = MockArgs()
result = command_analyze_memory(args, config)
assert result == 0
# Verify compilation was done
mock_write_cpp.assert_called_once_with(config)
mock_compile_program.assert_called_once_with(args, config)
# Verify analyzer was created with correct parameters
mock_memory_analyzer_cli.assert_called_once_with(
str(firmware_elf),
"/path/to/objdump",
"/path/to/readelf",
set(), # No external components
)
# Verify analysis was run
mock_analyzer = mock_memory_analyzer_cli.return_value
mock_analyzer.analyze.assert_called_once()
mock_analyzer.generate_report.assert_called_once()
# Verify report was printed
captured = capfd.readouterr()
assert "Mock Memory Report" in captured.out
def test_command_analyze_memory_with_external_components(
tmp_path: Path,
mock_write_cpp: Mock,
mock_compile_program: Mock,
mock_get_idedata: Mock,
mock_get_esphome_components: Mock,
mock_memory_analyzer_cli: Mock,
) -> None:
"""Test command_analyze_memory detects external components."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
# Create firmware.elf file
firmware_path = (
tmp_path / ".esphome" / "build" / "test_device" / ".pioenvs" / "test_device"
)
firmware_path.mkdir(parents=True, exist_ok=True)
firmware_elf = firmware_path / "firmware.elf"
firmware_elf.write_text("mock elf file")
# Mock idedata
mock_idedata_obj = MagicMock(spec=platformio_api.IDEData)
mock_idedata_obj.firmware_elf_path = str(firmware_elf)
mock_idedata_obj.objdump_path = "/path/to/objdump"
mock_idedata_obj.readelf_path = "/path/to/readelf"
mock_get_idedata.return_value = mock_idedata_obj
config = {
CONF_ESPHOME: {CONF_NAME: "test_device"},
"logger": {},
"my_custom_component": {"param": "value"}, # External component
"external_components": [{"source": "github://user/repo"}], # Not a component
}
args = MockArgs()
result = command_analyze_memory(args, config)
assert result == 0
# Verify analyzer was created with external components detected
mock_memory_analyzer_cli.assert_called_once_with(
str(firmware_elf),
"/path/to/objdump",
"/path/to/readelf",
{"my_custom_component"}, # External component detected
)
def test_command_analyze_memory_write_cpp_fails(
tmp_path: Path,
mock_write_cpp: Mock,
) -> None:
"""Test command_analyze_memory when write_cpp fails."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
args = MockArgs()
mock_write_cpp.return_value = 1 # Failure
result = command_analyze_memory(args, config)
assert result == 1
mock_write_cpp.assert_called_once_with(config)
def test_command_analyze_memory_compile_fails(
tmp_path: Path,
mock_write_cpp: Mock,
mock_compile_program: Mock,
) -> None:
"""Test command_analyze_memory when compilation fails."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
args = MockArgs()
mock_compile_program.return_value = 1 # Compilation failed
result = command_analyze_memory(args, config)
assert result == 1
mock_write_cpp.assert_called_once_with(config)
mock_compile_program.assert_called_once_with(args, config)
def test_command_analyze_memory_no_idedata(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
mock_write_cpp: Mock,
mock_compile_program: Mock,
mock_get_idedata: Mock,
) -> None:
"""Test command_analyze_memory when idedata cannot be retrieved."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
args = MockArgs()
mock_get_idedata.return_value = None # Failed to get idedata
with caplog.at_level(logging.ERROR):
result = command_analyze_memory(args, config)
assert result == 1
assert "Failed to get IDE data for memory analysis" in caplog.text