[core] Lazy-load zeroconf/writer/yaml_util in __main__

Every \`esphome\` CLI invocation pays the cost of whatever \`esphome/__main__.py\`
imports at module scope before the requested command even runs. This
moves three heavy imports into the functions that actually use them:

- \`esphome.zeroconf\` (discover_mdns_devices): only needed for the
  name_add_mac_suffix OTA discovery path.
- \`esphome.writer\`: only needed by compile/clean paths.
- \`esphome.yaml_util\`: only needed by codegen, config dump, and rename.

Local measurement drops from ~75ms to ~47ms (-37%) for a cold
\`python -c 'import esphome.__main__'\`. The zeroconf chain alone accounts
for most of the gain — the case that motivated the CI budget check.

Also adds a module-level note explaining the intent so future PRs don't
innocently promote these back to the top.

Retargets four test patches from \`esphome.__main__.discover_mdns_devices\`
to \`esphome.zeroconf.discover_mdns_devices\` now that the symbol is only
bound inside the function that calls it.
This commit is contained in:
J. Nick Koston
2026-04-23 14:30:03 -05:00
parent 99c80b7dfa
commit fa055b9e49
2 changed files with 27 additions and 6 deletions
+23 -2
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(
@@ -661,6 +668,8 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
def wrap_to_code(name, comp):
from esphome import yaml_util
coro = coroutine(comp.to_code)
@functools.wraps(comp.to_code)
@@ -680,6 +689,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()
@@ -702,6 +713,8 @@ def generate_cpp_contents(config: ConfigType) -> None:
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:
+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,