[core] Defer heavy module-scope imports in __main__, loader, and config (#15955)

This commit is contained in:
J. Nick Koston
2026-04-29 13:17:59 -05:00
committed by GitHub
parent ca3f7251d4
commit 985dba9332
5 changed files with 58 additions and 17 deletions
+25 -4
View File
@@ -21,7 +21,7 @@ import argcomplete
# Note: Do not import modules from esphome.components here, as this would
# cause them to be loaded before external components are processed, resulting
# in the built-in version being used instead of the external component one.
from esphome import const, writer, yaml_util
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 (
@@ -72,7 +72,12 @@ from esphome.util import (
run_external_process,
safe_print,
)
from esphome.zeroconf import discover_mdns_devices
# Keep expensive imports (zeroconf, writer, yaml_util, etc.) out of this
# module's top level. Every `esphome` invocation — including fast paths
# like `esphome version` — pays the cost of what's imported here before
# any command runs. Import inside the function that needs it instead.
# `script/check_import_time.py` enforces a budget in CI.
_LOGGER = logging.getLogger(__name__)
@@ -241,6 +246,8 @@ def _discover_mac_suffix_devices() -> list[str] | None:
"""
if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()):
return None
from esphome.zeroconf import discover_mdns_devices
_LOGGER.info("Discovering devices...")
if not (discovered := discover_mdns_devices(CORE.name)):
_LOGGER.warning(
@@ -660,7 +667,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
return 0
def wrap_to_code(name, comp):
def _wrap_to_code(name, comp, yaml_util):
coro = coroutine(comp.to_code)
@functools.wraps(comp.to_code)
@@ -680,6 +687,8 @@ def wrap_to_code(name, comp):
def write_cpp(config: ConfigType, native_idf: bool = False) -> int:
from esphome import writer
if not get_bool_env(ENV_NOGITIGNORE):
writer.write_gitignore()
@@ -691,17 +700,21 @@ def write_cpp(config: ConfigType, native_idf: bool = False) -> int:
def generate_cpp_contents(config: ConfigType) -> None:
from esphome import yaml_util
_LOGGER.info("Generating C++ source...")
for name, component, conf in iter_component_configs(CORE.config):
if component.to_code is not None:
coro = wrap_to_code(name, component)
coro = _wrap_to_code(name, component, yaml_util)
CORE.add_job(coro, conf)
CORE.flush_tasks()
def write_cpp_file(native_idf: bool = False) -> int:
from esphome import writer
code_s = indent(CORE.cpp_main_section)
writer.write_cpp(code_s)
@@ -1180,6 +1193,8 @@ def command_wizard(args: ArgsProtocol) -> int | None:
def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome import yaml_util
if not CORE.verbose:
config = strip_default_ids(config)
output = yaml_util.dump(config, args.show_secrets)
@@ -1321,6 +1336,8 @@ def command_clean_mqtt(args: ArgsProtocol, config: ConfigType) -> int | None:
def command_clean_all(args: ArgsProtocol) -> int | None:
from esphome import writer
try:
writer.clean_all(args.configuration)
except OSError as err:
@@ -1336,6 +1353,8 @@ def command_version(args: ArgsProtocol) -> int | None:
def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome import writer
try:
writer.clean_build()
except OSError as err:
@@ -1538,6 +1557,8 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome import yaml_util
new_name = args.name
for c in new_name:
if c not in ALLOWED_NAME_CHARS:
+8 -1
View File
@@ -25,7 +25,10 @@ from esphome.const import (
CONF_SUBSTITUTIONS,
)
from esphome.core import CORE, DocumentRange, EsphomeError
import esphome.core.config as core_config
# `esphome.core.config` is imported lazily at its two use sites below.
# It pulls in `esphome.automation` and `esphome.config_validation`, which
# dominate `esphome.__main__` startup cost when loaded eagerly here.
import esphome.final_validate as fv
from esphome.helpers import indent
from esphome.loader import ComponentManifest, get_component, get_platform
@@ -968,6 +971,8 @@ class CoreFinalValidateStep(ConfigValidationStep):
if result.errors:
return
import esphome.core.config as core_config
token = fv.full_config.set(result)
with result.catch_error([CONF_ESPHOME]):
if CONF_ESPHOME in result:
@@ -1073,6 +1078,8 @@ def validate_config(
return result
# 2. Load partial core config
import esphome.core.config as core_config
result[CONF_ESPHOME] = config[CONF_ESPHOME]
result.add_output_path([CONF_ESPHOME], CONF_ESPHOME)
try:
+20 -7
View File
@@ -9,14 +9,23 @@ import logging
from pathlib import Path
import sys
from types import ModuleType
from typing import Any
from typing import TYPE_CHECKING, Any
from esphome.const import SOURCE_FILE_EXTENSIONS
from esphome.core import CORE
import esphome.core.config
from esphome.cpp_generator import MockObjClass
from esphome.types import ConfigType
if TYPE_CHECKING:
from esphome.cpp_generator import MockObjClass
# `esphome.core.config` is imported lazily in `_lookup_module` when the
# "esphome" pseudo-component is first resolved. It pulls in
# `esphome.automation` and `esphome.config_validation`, which together
# dominate `esphome.__main__` startup cost when loaded eagerly.
# `esphome.cpp_generator` is similarly avoided at module scope; it pulls
# in `esphome.yaml_util` and is only needed for the `MockObjClass` type
# annotation, which is resolved lazily via `TYPE_CHECKING`.
_LOGGER = logging.getLogger(__name__)
@@ -94,7 +103,7 @@ class ComponentManifest:
return getattr(self.module, "CODEOWNERS", [])
@property
def instance_type(self) -> MockObjClass | None:
def instance_type(self) -> "MockObjClass | None":
return getattr(self.module, "INSTANCE_TYPE", None)
@property
@@ -213,6 +222,13 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None:
if domain in _COMPONENT_CACHE:
return _COMPONENT_CACHE[domain]
if domain == "esphome":
import esphome.core.config
manif = ComponentManifest(esphome.core.config, recursive_sources=True)
_COMPONENT_CACHE[domain] = manif
return manif
try:
module = importlib.import_module(f"esphome.components.{domain}")
except ImportError as e:
@@ -248,9 +264,6 @@ def get_platform(domain: str, platform: str) -> ComponentManifest | None:
_COMPONENT_CACHE: dict[str, ComponentManifest] = {}
CORE_COMPONENTS_PATH = (Path(__file__).parent / "components").resolve()
_COMPONENT_CACHE["esphome"] = ComponentManifest(
esphome.core.config, recursive_sources=True
)
def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> None:
+1 -1
View File
@@ -1,5 +1,5 @@
{
"target_module": "esphome.__main__",
"margin_pct": 15,
"cumulative_us": 123000
"cumulative_us": 91000
}
+4 -4
View File
@@ -2605,7 +2605,7 @@ def test_choose_upload_log_host_discovers_mac_suffix_devices(tmp_path: Path) ->
}
with (
patch(
"esphome.__main__.discover_mdns_devices", return_value=discovered
"esphome.zeroconf.discover_mdns_devices", return_value=discovered
) as mock_discover,
patch(
"esphome.__main__.choose_prompt", return_value="mydevice-abc123.local"
@@ -2653,7 +2653,7 @@ def test_choose_upload_log_host_mac_suffix_no_devices_found(
)
with (
patch("esphome.__main__.discover_mdns_devices", return_value={}),
patch("esphome.zeroconf.discover_mdns_devices", return_value={}),
caplog.at_level(logging.WARNING, logger="esphome.__main__"),
pytest.raises(EsphomeError),
):
@@ -2686,7 +2686,7 @@ def test_choose_upload_log_host_default_ota_discovers_mac_suffix(
"mydevice-def456.local": ["10.0.0.2"],
}
with patch(
"esphome.__main__.discover_mdns_devices", return_value=discovered
"esphome.zeroconf.discover_mdns_devices", return_value=discovered
) as mock_discover:
result = choose_upload_log_host(
default="OTA",
@@ -2715,7 +2715,7 @@ def test_choose_upload_log_host_default_ota_no_suffix_discovery(
name="mydevice",
)
with patch("esphome.__main__.discover_mdns_devices") as mock_discover:
with patch("esphome.zeroconf.discover_mdns_devices") as mock_discover:
result = choose_upload_log_host(
default="OTA",
check_default=None,