[core] Reduce memory footprint of esphome upload (#17684)

This commit is contained in:
J. Nick Koston
2026-07-21 12:21:12 -10:00
committed by GitHub
parent 1e4bf48ea3
commit 37be04f58e
5 changed files with 95 additions and 18 deletions
+11 -4
View File
@@ -16,14 +16,10 @@ import sys
import time import time
from typing import Protocol from typing import Protocol
import argcomplete
# Note: Do not import modules from esphome.components here, as this would # Note: Do not import modules from esphome.components here, as this would
# cause them to be loaded before external components are processed, resulting # cause them to be loaded before external components are processed, resulting
# in the built-in version being used instead of the external component one. # in the built-in version being used instead of the external component one.
from esphome import const from esphome import const
import esphome.codegen as cg
from esphome.config import iter_component_configs, read_config, strip_default_ids
from esphome.const import ( from esphome.const import (
ALLOWED_NAME_CHARS, ALLOWED_NAME_CHARS,
ARGUMENT_HELP_DEVICE, ARGUMENT_HELP_DEVICE,
@@ -704,6 +700,8 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
def _wrap_to_code(name, comp, yaml_util): def _wrap_to_code(name, comp, yaml_util):
import esphome.codegen as cg
coro = coroutine(comp.to_code) coro = coroutine(comp.to_code)
@functools.wraps(comp.to_code) @functools.wraps(comp.to_code)
@@ -739,6 +737,7 @@ def write_cpp(config: ConfigType) -> int:
def generate_cpp_contents(config: ConfigType) -> None: def generate_cpp_contents(config: ConfigType) -> None:
from esphome import yaml_util from esphome import yaml_util
from esphome.config import iter_component_configs
_LOGGER.info("Generating C++ source...") _LOGGER.info("Generating C++ source...")
@@ -1464,6 +1463,7 @@ def command_wizard(args: ArgsProtocol) -> int | None:
def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome import yaml_util from esphome import yaml_util
from esphome.config import strip_default_ids
if getattr(args, "no_defaults", False): if getattr(args, "no_defaults", False):
user_config = getattr(config, "user_config", None) user_config = getattr(config, "user_config", None)
@@ -2498,6 +2498,11 @@ def parse_args(argv):
# a deprecation warning). # a deprecation warning).
arguments = argv[1:] arguments = argv[1:]
# argcomplete only does anything when the shell-completion machinery
# invokes us with _ARGCOMPLETE set; skip the import otherwise.
if "_ARGCOMPLETE" in os.environ:
import argcomplete
argcomplete.autocomplete(parser) argcomplete.autocomplete(parser)
if len(arguments) > 0 and arguments[0] in SIMPLE_CONFIG_ACTIONS: if len(arguments) > 0 and arguments[0] in SIMPLE_CONFIG_ACTIONS:
@@ -2597,6 +2602,8 @@ def run_esphome(argv):
) )
if config is None: if config is None:
from esphome.config import read_config
config = read_config( config = read_config(
command_line_substitutions, command_line_substitutions,
skip_external_update=skip_external, skip_external_update=skip_external,
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"target_module": "esphome.__main__", "target_module": "esphome.__main__",
"margin_pct": 20, "margin_pct": 20,
"cumulative_us": 95000 "cumulative_us": 37200
} }
+8 -8
View File
@@ -220,7 +220,7 @@ def test_run_esphome_upload_and_logs_use_cache_when_fresh(
with ( with (
caplog.at_level("INFO", logger="esphome.__main__"), caplog.at_level("INFO", logger="esphome.__main__"),
patch("esphome.__main__.read_config") as mock_read, patch("esphome.config.read_config") as mock_read,
patch.dict("esphome.__main__.POST_CONFIG_ACTIONS", {command: _stub}), patch.dict("esphome.__main__.POST_CONFIG_ACTIONS", {command: _stub}),
): ):
assert run_esphome(["esphome", command, str(fresh_cache_files)]) == 0 assert run_esphome(["esphome", command, str(fresh_cache_files)]) == 0
@@ -242,7 +242,7 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache(
yaml_path.write_text("esphome:\n name: lite_test\n") yaml_path.write_text("esphome:\n name: lite_test\n")
with ( with (
patch("esphome.__main__.read_config", return_value=None) as mock_read, patch("esphome.config.read_config", return_value=None) as mock_read,
patch.dict( patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS", "esphome.__main__.POST_CONFIG_ACTIONS",
{command: lambda args, config: 0}, {command: lambda args, config: 0},
@@ -266,7 +266,7 @@ def test_run_esphome_upload_does_not_refresh_cache_without_sidecar(
with ( with (
patch( patch(
"esphome.__main__.read_config", "esphome.config.read_config",
return_value={"esphome": {"name": "lite_test"}}, return_value={"esphome": {"name": "lite_test"}},
), ),
patch("esphome.compiled_config.save_compiled_config") as mock_save, patch("esphome.compiled_config.save_compiled_config") as mock_save,
@@ -299,7 +299,7 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}} fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}}
with ( with (
patch("esphome.__main__.read_config", return_value=fresh_config), patch("esphome.config.read_config", return_value=fresh_config),
patch( patch(
"esphome.compiled_config.save_compiled_config", wraps=save_compiled_config "esphome.compiled_config.save_compiled_config", wraps=save_compiled_config
) as mock_save, ) as mock_save,
@@ -322,7 +322,7 @@ def test_run_esphome_upload_with_substitution_does_not_refresh_cache(
"""`-s` substitutions skip the cache on both read and write -- saving """`-s` substitutions skip the cache on both read and write -- saving
here would clobber the cache with a substitution-specific config.""" here would clobber the cache with a substitution-specific config."""
with ( with (
patch("esphome.__main__.read_config", return_value={"esphome": {}}), patch("esphome.config.read_config", return_value={"esphome": {}}),
patch("esphome.compiled_config.save_compiled_config") as mock_save, patch("esphome.compiled_config.save_compiled_config") as mock_save,
patch.dict( patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS", "esphome.__main__.POST_CONFIG_ACTIONS",
@@ -341,7 +341,7 @@ def test_run_esphome_compile_does_not_refresh_cache_via_fallback(
upload/logs fallback path -- the fallback save would skip the upload/logs fallback path -- the fallback save would skip the
storage_should_clean check.""" storage_should_clean check."""
with ( with (
patch("esphome.__main__.read_config", return_value={"esphome": {}}), patch("esphome.config.read_config", return_value={"esphome": {}}),
patch("esphome.compiled_config.save_compiled_config") as mock_save, patch("esphome.compiled_config.save_compiled_config") as mock_save,
patch.dict( patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS", "esphome.__main__.POST_CONFIG_ACTIONS",
@@ -360,7 +360,7 @@ def test_run_esphome_upload_with_substitution_skips_cache(
against the prior substitution set, so reusing it would silently against the prior substitution set, so reusing it would silently
ignore the override.""" ignore the override."""
with ( with (
patch("esphome.__main__.read_config", return_value=None) as mock_read, patch("esphome.config.read_config", return_value=None) as mock_read,
patch.dict( patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS", "esphome.__main__.POST_CONFIG_ACTIONS",
{"upload": lambda args, config: 0}, {"upload": lambda args, config: 0},
@@ -374,7 +374,7 @@ def test_run_esphome_upload_with_substitution_skips_cache(
def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None: def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None:
"""The compile subcommand always re-validates -- it's what writes the cache.""" """The compile subcommand always re-validates -- it's what writes the cache."""
with ( with (
patch("esphome.__main__.read_config", return_value=None) as mock_read, patch("esphome.config.read_config", return_value=None) as mock_read,
patch.dict( patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS", "esphome.__main__.POST_CONFIG_ACTIONS",
{"compile": lambda args, config: 0}, {"compile": lambda args, config: 0},
+53
View File
@@ -0,0 +1,53 @@
"""Guard the lazy-import contract of ``esphome.__main__``.
Every ``esphome`` invocation pays for whatever ``esphome.__main__``
imports at module level before the requested command runs. The
dashboard and device-builder spawn one ``esphome upload`` subprocess
per device, so keeping validation/codegen machinery out of the
top-level import directly lowers the RAM cost of each concurrent
upload (the upload/logs fast path in ``esphome.compiled_config``
never needs them).
``script/check_import_time.py`` budgets import *time* in CI; this
test pins down *which* heavy modules must stay out entirely.
"""
from __future__ import annotations
import subprocess
import sys
# Modules that must only load for the commands that actually use them
# (compile/config validation, shell completion), never from a bare
# ``import esphome.__main__``.
HEAVY_MODULES = (
"argcomplete",
"esphome.codegen",
"esphome.config",
"esphome.config_validation",
"esphome.cpp_generator",
"esphome.loader",
"voluptuous",
)
def test_main_module_does_not_import_heavy_modules() -> None:
"""A bare ``import esphome.__main__`` must not drag in validation/codegen."""
check = (
"import sys; import esphome.__main__; "
f"leaked = [m for m in {HEAVY_MODULES!r} if m in sys.modules]; "
"print(','.join(leaked))"
)
result = subprocess.run(
[sys.executable, "-c", check],
capture_output=True,
text=True,
check=True,
)
leaked = result.stdout.strip()
assert not leaked, (
f"esphome.__main__ imports heavy modules at top level: {leaked}. "
"Import them lazily inside the command that needs them instead; "
"every esphome invocation (including each parallel dashboard "
"upload subprocess) pays for top-level imports."
)
+21 -4
View File
@@ -618,7 +618,7 @@ def test_command_config__no_defaults_skips_strip_default_ids(
validated.user_config = {"sensor": [{"name": "x"}]} validated.user_config = {"sensor": [{"name": "x"}]}
with patch( with patch(
"esphome.__main__.strip_default_ids", side_effect=AssertionError "esphome.config.strip_default_ids", side_effect=AssertionError
) as mock_strip: ) as mock_strip:
result = command_config(args, validated) result = command_config(args, validated)
@@ -6201,7 +6201,7 @@ def test_run_esphome_bundle_detection(tmp_path: Path) -> None:
"esphome.bundle.prepare_bundle_for_compile", "esphome.bundle.prepare_bundle_for_compile",
return_value=extracted_yaml, return_value=extracted_yaml,
) as mock_prepare, ) as mock_prepare,
patch("esphome.__main__.read_config", return_value=None), patch("esphome.config.read_config", return_value=None),
): ):
result = run_esphome(["esphome", "compile", str(bundle_path)]) result = run_esphome(["esphome", "compile", str(bundle_path)])
@@ -6219,7 +6219,7 @@ def test_run_esphome_non_bundle_skips_extraction(tmp_path: Path) -> None:
with ( with (
patch("esphome.bundle.is_bundle_path", return_value=False) as mock_is_bundle, patch("esphome.bundle.is_bundle_path", return_value=False) as mock_is_bundle,
patch("esphome.bundle.prepare_bundle_for_compile") as mock_prepare, patch("esphome.bundle.prepare_bundle_for_compile") as mock_prepare,
patch("esphome.__main__.read_config", return_value=None), patch("esphome.config.read_config", return_value=None),
): ):
result = run_esphome(["esphome", "compile", str(yaml_file)]) result = run_esphome(["esphome", "compile", str(yaml_file)])
@@ -6247,7 +6247,7 @@ def test_run_esphome_skip_external_update_per_command(
yaml_file = tmp_path / "device.yaml" yaml_file = tmp_path / "device.yaml"
yaml_file.write_text("esphome:\n name: test\n") yaml_file.write_text("esphome:\n name: test\n")
with patch("esphome.__main__.read_config", return_value=None) as mock_read: with patch("esphome.config.read_config", return_value=None) as mock_read:
run_esphome(["esphome", command, str(yaml_file)]) run_esphome(["esphome", command, str(yaml_file)])
mock_read.assert_called_once() mock_read.assert_called_once()
@@ -6405,6 +6405,23 @@ def test_parse_args_logs_states() -> None:
assert args.states is True assert args.states is True
def test_parse_args_argcomplete_only_runs_when_completing() -> None:
"""Only import and invoke argcomplete when _ARGCOMPLETE is set.
The shell-completion machinery sets _ARGCOMPLETE when it invokes the
CLI; a normal invocation must skip the import entirely so every
esphome subprocess (e.g. parallel dashboard uploads) avoids paying
for it.
"""
fake_argcomplete = MagicMock()
with (
patch.dict(os.environ, {"_ARGCOMPLETE": "1"}),
patch.dict(sys.modules, {"argcomplete": fake_argcomplete}),
):
parse_args(["esphome", "version"])
fake_argcomplete.autocomplete.assert_called_once()
def test_should_subscribe_states_default() -> None: def test_should_subscribe_states_default() -> None:
"""Test that states are shown by default when nothing is set.""" """Test that states are shown by default when nothing is set."""
from esphome.__main__ import _should_subscribe_states from esphome.__main__ import _should_subscribe_states