[time] Defer aioesphomeapi import to speed up config validation (#17214)

This commit is contained in:
Franck Nijhof
2026-06-25 15:34:44 -04:00
committed by GitHub
parent cc646b2213
commit 239211e521
2 changed files with 62 additions and 12 deletions
+20 -12
View File
@@ -1,11 +1,8 @@
import errno
import functools
from importlib import resources
import logging
from aioesphomeapi.posix_tz import (
DSTRuleType as PyDSTRuleType,
parse_posix_tz as parse_posix_tz_python,
)
import tzlocal
from esphome import automation
@@ -57,13 +54,20 @@ DSTRuleType_cpp = time_ns.enum("DSTRuleType", is_class=True)
DSTRule_cpp = time_ns.struct("DSTRule")
ParsedTimezone_cpp = time_ns.struct("ParsedTimezone")
# Map Python DSTRuleType enum values to C++ enum expressions
_DST_RULE_TYPE_MAP = {
PyDSTRuleType.NONE: DSTRuleType_cpp.NONE,
PyDSTRuleType.MONTH_WEEK_DAY: DSTRuleType_cpp.MONTH_WEEK_DAY,
PyDSTRuleType.JULIAN_NO_LEAP: DSTRuleType_cpp.JULIAN_NO_LEAP,
PyDSTRuleType.DAY_OF_YEAR: DSTRuleType_cpp.DAY_OF_YEAR,
}
# Map Python DSTRuleType enum values to C++ enum expressions. Built lazily to
# avoid importing aioesphomeapi (a heavy import) when the time component is only
# auto-loaded for its schema and never reaches code generation.
@functools.cache
def _dst_rule_type_map() -> dict:
from aioesphomeapi.posix_tz import DSTRuleType as PyDSTRuleType
return {
PyDSTRuleType.NONE: DSTRuleType_cpp.NONE,
PyDSTRuleType.MONTH_WEEK_DAY: DSTRuleType_cpp.MONTH_WEEK_DAY,
PyDSTRuleType.JULIAN_NO_LEAP: DSTRuleType_cpp.JULIAN_NO_LEAP,
PyDSTRuleType.DAY_OF_YEAR: DSTRuleType_cpp.DAY_OF_YEAR,
}
def _load_tzdata(iana_key: str) -> bytes | None:
@@ -317,6 +321,8 @@ def validate_tz(value: str) -> str:
# Validate that the POSIX TZ string is parseable (skip empty strings)
if value:
from aioesphomeapi.posix_tz import parse_posix_tz as parse_posix_tz_python
try:
parse_posix_tz_python(value)
except ValueError as e:
@@ -372,7 +378,7 @@ def _emit_dst_rule_fields(prefix, rule):
"""Emit field-by-field assignments for a DSTRule to avoid rodata struct blob."""
cg.add(cg.RawExpression(f"{prefix}.time_seconds = {rule.time_seconds}"))
cg.add(cg.RawExpression(f"{prefix}.day = {rule.day}"))
cg.add(cg.RawExpression(f"{prefix}.type = {_DST_RULE_TYPE_MAP[rule.type]}"))
cg.add(cg.RawExpression(f"{prefix}.type = {_dst_rule_type_map()[rule.type]}"))
cg.add(cg.RawExpression(f"{prefix}.month = {rule.month}"))
cg.add(cg.RawExpression(f"{prefix}.week = {rule.week}"))
cg.add(cg.RawExpression(f"{prefix}.day_of_week = {rule.day_of_week}"))
@@ -409,6 +415,8 @@ async def setup_time_core_(time_var, config):
cg.add(time_var.set_timezone(timezone))
else:
# Embedded: pre-parse at codegen time, emit struct directly
from aioesphomeapi.posix_tz import parse_posix_tz as parse_posix_tz_python
try:
parsed = parse_posix_tz_python(timezone)
_emit_parsed_timezone_fields(parsed)
+42
View File
@@ -1,6 +1,8 @@
"""Tests for time component cron expression parsing."""
import errno
import subprocess
import sys
from unittest.mock import MagicMock, patch
import pytest
@@ -143,3 +145,43 @@ def test_validate_tz_accepts_posix_string_when_read_bytes_raises_einval() -> Non
_mock_resources_with_error(OSError(errno.EINVAL, "Invalid argument")),
):
assert validate_tz("<+08>-8") == "<+08>-8"
def _modules_after(code: str) -> set[str]:
"""Run code in a fresh interpreter and return the imported module names.
A subprocess is required because the test process itself has already
imported aioesphomeapi via other tests, so sys.modules here is useless.
"""
result = subprocess.run(
[sys.executable, "-c", f"import sys\n{code}\nprint('\\n'.join(sys.modules))"],
capture_output=True,
text=True,
check=True,
)
return set(result.stdout.split())
def test_importing_time_does_not_import_aioesphomeapi() -> None:
"""Importing the time component must not drag in aioesphomeapi.
aioesphomeapi is a heavy import (it builds a large number of dataclasses at
import time). The time component is auto-loaded by many components, so
importing it for its schema during config validation must not pay that
cost. The import is deferred to the functions that actually need it.
"""
modules = _modules_after("import esphome.components.time")
assert "aioesphomeapi" not in modules
def test_validate_tz_imports_aioesphomeapi_lazily() -> None:
"""Validating a non-empty timezone is what triggers the lazy import.
Documents the boundary: the cost is only paid when a timezone is actually
validated, not merely by loading the component.
"""
modules = _modules_after(
"from esphome.components.time import validate_tz\n"
"validate_tz('EST5EDT,M3.2.0,M11.1.0')"
)
assert "aioesphomeapi" in modules