From 09a3b1a3d1a849ab3f4e2451628cda70d2bf2d8e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 08:45:05 -0500 Subject: [PATCH] Address review: stable schema anchors, const.py home, validator hardening --- esphome/components/binary_sensor/__init__.py | 8 ++-- esphome/components/web_server/__init__.py | 18 ++++--- esphome/components/zigbee/__init__.py | 10 ++-- esphome/components/zigbee/const.py | 16 +++---- esphome/components/zigbee/const_zephyr.py | 4 +- esphome/const.py | 7 +++ esphome/core/entity_helpers.py | 26 ++++++----- script/ci-custom.py | 2 +- tests/unit_tests/core/test_entity_helpers.py | 49 ++++++++++++++++++-- 9 files changed, 93 insertions(+), 47 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 0f4b4fda91..ac23e99350 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -58,10 +58,8 @@ from esphome.const import ( DEVICE_CLASS_VIBRATION, DEVICE_CLASS_WINDOW, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority, entity_helpers from esphome.core.entity_helpers import ( - WEBSERVER_SORTING_SCHEMA, - ZIGBEE_BINARY_SENSOR_SCHEMA, entity_duplicate_validator, lazy_load_validator, mqtt_component_class, @@ -436,9 +434,9 @@ def validate_publish_initial_state(value): _BINARY_SENSOR_SCHEMA = ( - cv.ENTITY_BASE_SCHEMA.extend(WEBSERVER_SORTING_SCHEMA) + cv.ENTITY_BASE_SCHEMA.extend(entity_helpers.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMPONENT_SCHEMA) - .extend(ZIGBEE_BINARY_SENSOR_SCHEMA) + .extend(entity_helpers.ZIGBEE_BINARY_SENSOR_SCHEMA) .extend( { cv.GenerateID(): cv.declare_id(BinarySensor), diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 4ddf576368..4015356224 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -10,7 +10,11 @@ from esphome.components import web_server_base from esphome.components.logger import request_log_listener from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID import esphome.config_validation as cv -from esphome.const import ( + +# Re-exported so entity components and external components keep importing +# these from web_server; defined outside this package so entity base +# schemas do not need to import it. +from esphome.const import ( # noqa: F401 CONF_AUTH, CONF_COMPRESSION, CONF_CSS_INCLUDE, @@ -26,6 +30,8 @@ from esphome.const import ( CONF_OTA, CONF_PASSWORD, CONF_PORT, + CONF_SORTING_GROUP_ID, + CONF_SORTING_WEIGHT, CONF_TYPE, CONF_USERNAME, CONF_VERSION, @@ -39,15 +45,7 @@ from esphome.const import ( PLATFORM_RTL87XX, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority - -# Re-exported so entity components and external components keep importing -# these from web_server; defined in entity_helpers so entity base schemas -# do not need to import this package. -from esphome.core.entity_helpers import ( # noqa: F401 - CONF_SORTING_GROUP_ID, - CONF_SORTING_WEIGHT, - WEBSERVER_SORTING_SCHEMA, -) +from esphome.core.entity_helpers import WEBSERVER_SORTING_SCHEMA # noqa: F401 import esphome.final_validate as fv from esphome.types import ConfigType diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index b9e5ade9d1..9bee84a13f 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -14,11 +14,7 @@ from esphome.components.esp32.const import ( ) import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME, CONF_ON_START -from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import ( - ZIGBEE_BASE_ENTITY_SCHEMA, - ZIGBEE_BINARY_SENSOR_SCHEMA, -) +from esphome.core import CORE, CoroPriority, coroutine_with_priority, entity_helpers from esphome.types import ConfigType # CONF_ENDPOINT, CONF_MAX_EP_NUMBER, CONF_REPORT, CONF_USE_DEVICE_TYPE and @@ -62,8 +58,8 @@ CONFLICTS_WITH = ["openthread"] # Defined in esphome.core.entity_helpers so entity base schemas can reference # them without importing this package; re-exported here for existing consumers. -BASE_SCHEMA = ZIGBEE_BASE_ENTITY_SCHEMA -BINARY_SENSOR_SCHEMA = ZIGBEE_BINARY_SENSOR_SCHEMA +BASE_SCHEMA = entity_helpers.ZIGBEE_BASE_ENTITY_SCHEMA +BINARY_SENSOR_SCHEMA = entity_helpers.ZIGBEE_BINARY_SENSOR_SCHEMA SENSOR_SCHEMA = cv.Schema({}).extend(BASE_SCHEMA).extend(zephyr_sensor) SWITCH_SCHEMA = cv.Schema({}).extend(zephyr_switch) NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number) diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index 9ae7cb061b..3106fc93c3 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -1,7 +1,14 @@ from enum import IntEnum import esphome.codegen as cg -from esphome.const import ( + +# The entity schema keys and report enum live outside this package so +# entity base schemas can use them without importing it; re-imported here +# so zigbee code keeps its existing import paths. +from esphome.const import ( # noqa: F401 # pylint: disable=unused-import + CONF_ENDPOINT, + CONF_REPORT, + CONF_USE_DEVICE_TYPE, DEVICE_CLASS_CURRENT, DEVICE_CLASS_DURATION, DEVICE_CLASS_ENERGY, @@ -48,14 +55,7 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) - -# The entity schema keys and report enum live in esphome.core.entity_helpers -# so entity base schemas can use them without importing this package; -# re-imported here so zigbee code keeps its existing import paths. from esphome.core.entity_helpers import ( # noqa: F401 # pylint: disable=unused-import - CONF_ENDPOINT, - CONF_REPORT, - CONF_USE_DEVICE_TYPE, ZIGBEE_MAX_EP_NUMBER as CONF_MAX_EP_NUMBER, ZIGBEE_REPORT as REPORT, ) diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index d98cc2c9ea..34329cd6e8 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -1,6 +1,6 @@ -# These two schema keys live in esphome.core.entity_helpers so entity base +# These two schema keys live in esphome.components.const so entity base # schemas can use them without importing this package. -from esphome.core.entity_helpers import ( # noqa: F401 # pylint: disable=unused-import +from esphome.const import ( # noqa: F401 # pylint: disable=unused-import CONF_ZIGBEE_BINARY_SENSOR, CONF_ZIGBEE_ID, ) diff --git a/esphome/const.py b/esphome/const.py index 0dd948544f..cd1bf949af 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -387,6 +387,7 @@ CONF_ENABLE_PRIVATE_NETWORK_ACCESS = "enable_private_network_access" CONF_ENABLE_RRM = "enable_rrm" CONF_ENABLE_TIME = "enable_time" CONF_ENCRYPTION = "encryption" +CONF_ENDPOINT = "endpoint" CONF_ENERGY = "energy" CONF_ENTITY_CATEGORY = "entity_category" CONF_ENTITY_ID = "entity_id" @@ -885,6 +886,7 @@ CONF_REFERENCE_VOLTAGE = "reference_voltage" CONF_REFRESH = "refresh" CONF_RELABEL = "relabel" CONF_REPEAT = "repeat" +CONF_REPORT = "report" CONF_REPOSITORY = "repository" CONF_RESET = "reset" CONF_RESET_DURATION = "reset_duration" @@ -962,6 +964,8 @@ CONF_SLEEP_DURATION = "sleep_duration" CONF_SLEEP_PIN = "sleep_pin" CONF_SLEEP_WHEN_DONE = "sleep_when_done" CONF_SONY = "sony" +CONF_SORTING_GROUP_ID = "sorting_group_id" +CONF_SORTING_WEIGHT = "sorting_weight" CONF_SOURCE = "source" CONF_SOURCE_ID = "source_id" CONF_SPEAKER = "speaker" @@ -1093,6 +1097,7 @@ CONF_UPDATE_ON_BOOT = "update_on_boot" CONF_URL = "url" CONF_USE_ABBREVIATIONS = "use_abbreviations" CONF_USE_ADDRESS = "use_address" +CONF_USE_DEVICE_TYPE = "use_device_type" CONF_USE_DMA = "use_dma" CONF_USE_FAHRENHEIT = "use_fahrenheit" CONF_USERNAME = "username" @@ -1144,6 +1149,8 @@ CONF_Y = "y" CONF_Y_GRID = "y_grid" CONF_YEAR = "year" CONF_ZERO = "zero" +CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" +CONF_ZIGBEE_ID = "zigbee_id" TYPE_GIT = "git" TYPE_LOCAL = "local" diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index dc9bfb4e42..455b72196c 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -1,7 +1,8 @@ from collections.abc import Callable from dataclasses import dataclass, field import functools -import importlib +from importlib import import_module +from importlib.util import find_spec import logging import esphome.codegen as cg @@ -10,14 +11,21 @@ from esphome.const import ( CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, + CONF_ENDPOINT, CONF_ENTITY_CATEGORY, CONF_ICON, CONF_ID, CONF_INTERNAL, CONF_NAME, + CONF_REPORT, + CONF_SORTING_GROUP_ID, + CONF_SORTING_WEIGHT, CONF_UNIT_OF_MEASUREMENT, + CONF_USE_DEVICE_TYPE, CONF_WEB_SERVER, CONF_WEB_SERVER_ID, + CONF_ZIGBEE_BINARY_SENSOR, + CONF_ZIGBEE_ID, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.config import ( @@ -655,9 +663,6 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # owning integrations re-export the shared schema names and key strings so # each stays defined once. -CONF_SORTING_GROUP_ID = "sorting_group_id" -CONF_SORTING_WEIGHT = "sorting_weight" - _WebServer = cg.esphome_ns.namespace("web_server").class_( "WebServer", cg.Component, cg.Controller ) @@ -691,11 +696,6 @@ def mqtt_component_class(name: str) -> MockObjClass: return _mqtt_ns.class_(name, _MQTTComponent) -CONF_ENDPOINT = "endpoint" -CONF_REPORT = "report" -CONF_USE_DEVICE_TYPE = "use_device_type" -CONF_ZIGBEE_ID = "zigbee_id" -CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" ZIGBEE_MAX_EP_NUMBER = 239 _zigbee_ns = cg.esphome_ns.namespace("zigbee") @@ -753,11 +753,15 @@ def lazy_load_validator( component: str, name: str ) -> Callable[[ConfigType], ConfigType]: """Schema extra delegating to ``components..`` when loaded.""" + if find_spec(f"esphome.components.{component}") is None: + raise ValueError(f"No such component {component!r}") def validator(config: ConfigType) -> ConfigType: if component not in CORE.loaded_integrations: return config - module = importlib.import_module(f"esphome.components.{component}") - return getattr(module, name)(config) + module = import_module(f"esphome.components.{component}") + if (delegate := getattr(module, name, None)) is None: + raise ValueError(f"{component} has no validator {name!r}") + return delegate(config) return validator diff --git a/script/ci-custom.py b/script/ci-custom.py index 2d2da20995..4fbd5ec9fc 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -557,7 +557,7 @@ def lint_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1017 +CONST_PY_MAX_CONF = 1024 @lint_content_check(include=["esphome/const.py"]) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 5e047035e6..5a76761b3d 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -30,6 +30,7 @@ from esphome.core.entity_helpers import ( finalize_entity_strings, get_base_entity_object_id, lazy_load_validator, + mqtt_component_class, register_device_class, register_icon, register_unit_of_measurement, @@ -1268,9 +1269,7 @@ def test_lazy_load_validator_defers_import() -> None: original = CORE.loaded_integrations CORE.loaded_integrations = set() try: - with patch( - "esphome.core.entity_helpers.importlib.import_module" - ) as import_mock: + with patch("esphome.core.entity_helpers.import_module") as import_mock: assert validator(config) is config import_mock.assert_not_called() @@ -1282,3 +1281,47 @@ def test_lazy_load_validator_defers_import() -> None: delegate.assert_called_once_with(config) finally: CORE.loaded_integrations = original + + +def test_lazy_load_validator_rejects_unknown_component() -> None: + """A typo in the component name fails at schema construction.""" + with pytest.raises(ValueError, match="no_such_component"): + lazy_load_validator("no_such_component", "validate_binary_sensor") + + +def test_lazy_load_validator_names_missing_hook() -> None: + """A missing hook raises a clear error naming the component and hook.""" + validator = lazy_load_validator("zigbee", "no_such_hook") + + original = CORE.loaded_integrations + CORE.loaded_integrations = {"zigbee"} + try: + with ( + patch("esphome.core.entity_helpers.import_module") as import_mock, + pytest.raises(ValueError, match="no_such_hook"), + ): + del import_mock.return_value.no_such_hook + validator({}) + finally: + CORE.loaded_integrations = original + + +def test_integration_class_handles_match_owning_definitions() -> None: + """The cheap class handles must stay string-equal to the integrations' + own declarations, or use_id/declare_id resolution silently drifts.""" + from esphome.components import mqtt, web_server + from esphome.components.zigbee.const import ZigbeeComponent + from esphome.components.zigbee.zigbee_zephyr import ZigbeeBinarySensor + from esphome.core import entity_helpers + + mqtt_handle = mqtt_component_class("MQTTBinarySensorComponent") + assert str(mqtt_handle) == str(mqtt.MQTTBinarySensorComponent) + assert mqtt_handle.inherits_from(mqtt.MQTTComponent) + assert mqtt.MQTTBinarySensorComponent.inherits_from(entity_helpers._MQTTComponent) + + assert str(entity_helpers._WebServer) == str(web_server.WebServer) + assert entity_helpers._WebServer.inherits_from(web_server.WebServer) + assert web_server.WebServer.inherits_from(entity_helpers._WebServer) + + assert str(entity_helpers._ZigbeeComponent) == str(ZigbeeComponent) + assert str(entity_helpers._ZigbeeBinarySensor) == str(ZigbeeBinarySensor)