mirror of
https://github.com/esphome/esphome.git
synced 2026-08-29 01:03:29 +00:00
[time] Defer aioesphomeapi import to speed up config validation (#17214)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user