diff --git a/esphome/__main__.py b/esphome/__main__.py index 8c80dab90af..781bcd62885 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -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: diff --git a/esphome/config.py b/esphome/config.py index 6eb67af58b3..79d0d2b02ba 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -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: diff --git a/esphome/loader.py b/esphome/loader.py index 2405fa6f884..d50554f8c93 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -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: diff --git a/script/import_time_budget.json b/script/import_time_budget.json index 1e656dc9776..af3aa835113 100644 --- a/script/import_time_budget.json +++ b/script/import_time_budget.json @@ -1,5 +1,5 @@ { "target_module": "esphome.__main__", "margin_pct": 15, - "cumulative_us": 123000 + "cumulative_us": 91000 } diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 8ec9e70cf85..fb8f206a1d2 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -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,