From b0c9a7d5bdfef10c4fc1fb3225f9896503cb3db6 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:21:32 +0100 Subject: [PATCH] [core] Lint duplicate UNIT_/CONF_ constants against both const homes (#19422) --- esphome/components/as7341/sensor.py | 3 +- esphome/components/const/__init__.py | 1 + .../components/esp32_ble_server/__init__.py | 3 +- esphome/components/growatt_solar/sensor.py | 2 +- esphome/components/havells_solar/sensor.py | 2 +- esphome/components/ina2xx_base/__init__.py | 2 +- esphome/components/ltr390/sensor.py | 2 +- esphome/components/ltr501/sensor.py | 2 +- esphome/components/ltr_als_ps/sensor.py | 2 +- esphome/components/lvgl/defines.py | 2 - esphome/components/ota/__init__.py | 2 +- esphome/components/selec_meter/sensor.py | 6 +- esphome/components/veml7700/sensor.py | 2 +- script/ci-custom.py | 74 ++++++++++++++++-- tests/script/test_ci_custom.py | 76 +++++++++++++++++++ 15 files changed, 157 insertions(+), 24 deletions(-) diff --git a/esphome/components/as7341/sensor.py b/esphome/components/as7341/sensor.py index f70c5e999f..3afab8422f 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_CLEAR, @@ -33,8 +34,6 @@ CONF_F7 = "f7" CONF_F8 = "f8" CONF_NIR = "nir" -UNIT_COUNTS = "#" - AS7341_GAIN = as7341_ns.enum("AS7341Gain") GAIN_OPTIONS = { "X0.5": AS7341_GAIN.AS7341_GAIN_0_5X, diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 256ab5c0a3..31b0a0c097 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -61,3 +61,4 @@ ICON_SOLAR_POWER = "mdi:solar-power" KEY_METADATA = "metadata" UNIT_AMPERE_HOUR = "Ah" +UNIT_COUNTS = "#" diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 924d11db2b..9355d58859 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -3,7 +3,7 @@ import encodings from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble -from esphome.components.const import CONF_MANUFACTURER +from esphome.components.const import CONF_DESCRIPTION, CONF_MANUFACTURER from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import BTLoggers, bt_uuid import esphome.config_validation as cv @@ -37,7 +37,6 @@ CONF_ADVERTISE = "advertise" CONF_APPEARANCE = "appearance" CONF_BROADCAST = "broadcast" CONF_CHARACTERISTICS = "characteristics" -CONF_DESCRIPTION = "description" CONF_DESCRIPTORS = "descriptors" CONF_ENDIANNESS = "endianness" CONF_FIRMWARE_VERSION = "firmware_version" diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index 2e2b218730..3728710b1f 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -22,6 +22,7 @@ from esphome.const import ( UNIT_AMPERE, UNIT_CELSIUS, UNIT_HERTZ, + UNIT_KILOWATT_HOURS, UNIT_VOLT, UNIT_WATT, ) @@ -33,7 +34,6 @@ CONF_TOTAL_GENERATION_TIME = "total_generation_time" CONF_TODAY_GENERATION_TIME = "today_generation_time" CONF_PV1 = "pv1" CONF_PV2 = "pv2" -UNIT_KILOWATT_HOURS = "kWh" UNIT_HOURS = "h" UNIT_KOHM = "kΩ" UNIT_MILLIAMPERE = "mA" diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index dcea1afd04..efb7af2c3c 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -23,6 +23,7 @@ from esphome.const import ( UNIT_AMPERE, UNIT_DEGREES, UNIT_HERTZ, + UNIT_KILOWATT_HOURS, UNIT_MINUTE, UNIT_VOLT, UNIT_VOLT_AMPS_REACTIVE, @@ -36,7 +37,6 @@ CONF_TOTAL_GENERATION_TIME = "total_generation_time" CONF_TODAY_GENERATION_TIME = "today_generation_time" CONF_PV1 = "pv1" CONF_PV2 = "pv2" -UNIT_KILOWATT_HOURS = "kWh" UNIT_HOURS = "h" UNIT_KOHM = "kΩ" UNIT_MILLIAMPERE = "mA" diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index 7bb589f0b1..474283ccce 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -24,6 +24,7 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_CELSIUS, + UNIT_MILLIVOLT, UNIT_VOLT, UNIT_WATT, UNIT_WATT_HOURS, @@ -44,7 +45,6 @@ CONF_TEMPERATURE_COEFFICIENT = "temperature_coefficient" CONF_RESET_ON_BOOT = "reset_on_boot" UNIT_COULOMB = "C" UNIT_JOULE = "J" -UNIT_MILLIVOLT = "mV" ina2xx_base_ns = cg.esphome_ns.namespace("ina2xx_base") INA2XX = ina2xx_base_ns.class_("INA2XX", cg.PollingComponent) diff --git a/esphome/components/ltr390/sensor.py b/esphome/components/ltr390/sensor.py index c3ac90ad11..6c25729bee 100644 --- a/esphome/components/ltr390/sensor.py +++ b/esphome/components/ltr390/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_AMBIENT_LIGHT, @@ -28,7 +29,6 @@ CONF_UV_INDEX = "uv_index" CONF_UV = "uv" CONF_WINDOW_CORRECTION_FACTOR = "window_correction_factor" -UNIT_COUNTS = "#" UNIT_UVI = "UVI" LTR390GAIN = ltr390_ns.enum("LTR390GAIN") diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index c2091a6336..e27a7c323e 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_ACTUAL_GAIN, @@ -42,7 +43,6 @@ CONF_PS_LOW_THRESHOLD = "ps_low_threshold" ICON_BRIGHTNESS_7 = "mdi:brightness-7" ICON_GAIN = "mdi:multiplication" ICON_PROXIMITY = "mdi:hand-wave-outline" -UNIT_COUNTS = "#" ltr501_ns = cg.esphome_ns.namespace("ltr501") diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index af09282e2d..7bd476156b 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_ACTUAL_GAIN, @@ -41,7 +42,6 @@ CONF_PS_LOW_THRESHOLD = "ps_low_threshold" ICON_BRIGHTNESS_7 = "mdi:brightness-7" ICON_GAIN = "mdi:multiplication" ICON_PROXIMITY = "mdi:hand-wave-outline" -UNIT_COUNTS = "#" ltr_als_ps_ns = cg.esphome_ns.namespace("ltr_als_ps") diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 73fc58736b..f601b4b57a 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -680,7 +680,6 @@ CONF_BODY = "body" CONF_BUTTONS = "buttons" CONF_CHANGE_RATE = "change_rate" CONF_CLOSE_BUTTON = "close_button" -CONF_COLOR_DEPTH = "color_depth" CONF_COLOR_END = "color_end" CONF_COLOR_START = "color_start" CONF_CONTAINER = "container" @@ -767,7 +766,6 @@ CONF_RESUME_ON_INPUT = "resume_on_input" CONF_RIGHT_BUTTON = "right_button" CONF_ROLLOVER = "rollover" CONF_ROOT_BACK_BTN = "root_back_btn" -CONF_ROWS = "rows" CONF_SCALE = "scale" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index a2e6953a16..6031a68489 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -1,5 +1,6 @@ from esphome import automation import esphome.codegen as cg +from esphome.components.const import CONF_ON_STATE_CHANGE from esphome.config_helpers import ( filter_source_files_from_defines, filter_source_files_from_platform, @@ -38,7 +39,6 @@ CONF_ON_ABORT = "on_abort" CONF_ON_BEGIN = "on_begin" CONF_ON_END = "on_end" CONF_ON_PROGRESS = "on_progress" -CONF_ON_STATE_CHANGE = "on_state_change" ota_ns = cg.esphome_ns.namespace("ota") diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index 0d4e0fafc5..04aef00d46 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -27,6 +27,9 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_HERTZ, + UNIT_KILOVOLT_AMPS_HOURS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOWATT_HOURS, UNIT_VOLT, UNIT_VOLT_AMPS, UNIT_VOLT_AMPS_REACTIVE, @@ -44,9 +47,6 @@ CONF_MAXIMUM_DEMAND_ACTIVE_POWER = "maximum_demand_active_power" CONF_MAXIMUM_DEMAND_REACTIVE_POWER = "maximum_demand_reactive_power" CONF_MAXIMUM_DEMAND_APPARENT_POWER = "maximum_demand_apparent_power" -UNIT_KILOWATT_HOURS = "kWh" -UNIT_KILOVOLT_AMPS_HOURS = "kVAh" -UNIT_KILOVOLT_AMPS_REACTIVE_HOURS = "kVARh" selec_meter_ns = cg.esphome_ns.namespace("selec_meter") SelecMeter = selec_meter_ns.class_( diff --git a/esphome/components/veml7700/sensor.py b/esphome/components/veml7700/sensor.py index 4afca4b868..02820505ed 100644 --- a/esphome/components/veml7700/sensor.py +++ b/esphome/components/veml7700/sensor.py @@ -2,6 +2,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_ACTUAL_GAIN, @@ -31,7 +32,6 @@ from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] -UNIT_COUNTS = "#" ICON_MULTIPLICATION = "mdi:multiplication" ICON_BRIGHTNESS_7 = "mdi:brightness-7" diff --git a/script/ci-custom.py b/script/ci-custom.py index 6672760b06..bdf7750ce8 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -529,21 +529,67 @@ def lint_conf_matches(fname, match): CONF_RE = r'^(CONF_[a-zA-Z0-9_]+)\s*=\s*[\'"].*?[\'"]\s*?$' with codecs.open("esphome/const.py", "r", encoding="utf-8") as const_f_handle: constants_content = const_f_handle.read() +with codecs.open( + "esphome/components/const/__init__.py", "r", encoding="utf-8" +) as component_const_f_handle: + component_constants_content = component_const_f_handle.read() + +# The two canonical homes for shared constants: esphome/const.py (core, frozen) and +# esphome/components/const/__init__.py (shared by components). A constant defined in +# either must be imported from there rather than redefined in a component. +CONST_HOMES = ["esphome/const.py", "esphome/components/const/__init__.py"] + CONSTANTS = [m.group(1) for m in re.finditer(CONF_RE, constants_content, re.MULTILINE)] +COMPONENT_CONSTANTS = [ + m.group(1) for m in re.finditer(CONF_RE, component_constants_content, re.MULTILINE) +] CONSTANTS_USES = collections.defaultdict(list) -@lint_re_check(CONF_RE, include=["*.py"], exclude=["esphome/const.py"]) +def _const_home_error(name, core_constants, component_constants): + """Return an error if the constant already lives in one of the canonical homes.""" + if name in core_constants: + return ( + f"Constant {highlight(name)} has already been defined in const.py - " + "please import the constant from const.py directly." + ) + if name in component_constants: + return ( + f"Constant {highlight(name)} has already been defined in " + "esphome/components/const/__init__.py - please import the constant from " + "esphome.components.const directly." + ) + return None + + +@lint_re_check(CONF_RE, include=["*.py"], exclude=CONST_HOMES) def lint_conf_from_const_py(fname, match): name = match.group(1) - if name not in CONSTANTS: + err = _const_home_error(name, CONSTANTS, COMPONENT_CONSTANTS) + if err is None: CONSTANTS_USES[name].append(fname) - return None - return ( - f"Constant {highlight(name)} has already been defined in const.py - " - "please import the constant from const.py directly." - ) + return err + + +UNIT_RE = r'^(UNIT_[a-zA-Z0-9_]+)\s*=\s*[\'"].*?[\'"]\s*?$' +UNIT_CONSTANTS = [ + m.group(1) for m in re.finditer(UNIT_RE, constants_content, re.MULTILINE) +] +COMPONENT_UNIT_CONSTANTS = [ + m.group(1) for m in re.finditer(UNIT_RE, component_constants_content, re.MULTILINE) +] + +UNIT_CONSTANTS_USES = collections.defaultdict(list) + + +@lint_re_check(UNIT_RE, include=["*.py"], exclude=CONST_HOMES) +def lint_unit_from_const_py(fname, match): + name = match.group(1) + err = _const_home_error(name, UNIT_CONSTANTS, COMPONENT_UNIT_CONSTANTS) + if err is None: + UNIT_CONSTANTS_USES[name].append(fname) + return err RAW_PIN_ACCESS_RE = ( @@ -711,6 +757,20 @@ def lint_constants_usage(): return errs +@lint_post_check +def lint_unit_constants_usage(): + errs = [] + for constant, uses in UNIT_CONSTANTS_USES.items(): + if len(uses) < 3: + continue + errs.append( + f"Constant {highlight(constant)} is defined in {len(uses)} files. Please move all definitions of the " + f"constant to esphome/components/const/__init__.py (Uses: {', '.join(str(u) for u in uses)}) in a separate PR. " + "See https://developers.esphome.io/contributing/code/#python" + ) + return errs + + # 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. diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index 5b4d4fe5c8..be2c98051f 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -151,6 +151,82 @@ def test_nolint_on_control_line_suppresses() -> None: assert not _lint("if (x) // NOLINT\n ESP_LOGD(t);\n") +# --- rule: UNIT_ constants must not be redefined (mirror of the CONF_ check) --- + +# Real UNIT_ constants that live in each canonical home. +UNIT_IN_CONST_PY = ci_custom.UNIT_CONSTANTS[0] +UNIT_IN_COMPONENT_CONST = ci_custom.COMPONENT_UNIT_CONSTANTS[0] + + +def _unit_def(fname: str, content: str) -> list: + return ci_custom.lint_unit_from_const_py(fname, content) + + +def test_unit_already_in_const_py_is_flagged() -> None: + errs = _unit_def("esphome/components/x/sensor.py", f'{UNIT_IN_CONST_PY} = "x"\n') + assert errs + assert "const.py" in errs[0][2] + + +def test_unit_already_in_component_const_is_flagged() -> None: + errs = _unit_def( + "esphome/components/x/sensor.py", f'{UNIT_IN_COMPONENT_CONST} = "x"\n' + ) + assert errs + assert "esphome.components.const" in errs[0][2] + + +def test_unit_not_in_const_py_is_tracked_not_flagged() -> None: + ci_custom.UNIT_CONSTANTS_USES.clear() + assert _unit_def("a.py", 'UNIT_FOO_BAR = "fb"\n') == [] + assert ci_custom.UNIT_CONSTANTS_USES["UNIT_FOO_BAR"] == ["a.py"] + + +def test_unit_defined_in_three_files_is_flagged() -> None: + ci_custom.UNIT_CONSTANTS_USES.clear() + for fname in ("a.py", "b.py", "c.py"): + _unit_def(fname, 'UNIT_FOO_BAR = "fb"\n') + errs = ci_custom.lint_unit_constants_usage() + assert any("UNIT_FOO_BAR" in e and "3 files" in e for e in errs) + + +def test_unit_defined_in_two_files_is_not_flagged() -> None: + ci_custom.UNIT_CONSTANTS_USES.clear() + for fname in ("a.py", "b.py"): + _unit_def(fname, 'UNIT_FOO_BAR = "fb"\n') + assert ci_custom.lint_unit_constants_usage() == [] + + +# --- same rule for CONF_, now also recognising the components/const home --- + +CONF_IN_CONST_PY = ci_custom.CONSTANTS[0] +CONF_IN_COMPONENT_CONST = ci_custom.COMPONENT_CONSTANTS[0] + + +def _conf_def(fname: str, content: str) -> list: + return ci_custom.lint_conf_from_const_py(fname, content) + + +def test_conf_already_in_const_py_is_flagged() -> None: + errs = _conf_def("esphome/components/x/sensor.py", f'{CONF_IN_CONST_PY} = "x"\n') + assert errs + assert "const.py" in errs[0][2] + + +def test_conf_already_in_component_const_is_flagged() -> None: + errs = _conf_def( + "esphome/components/x/sensor.py", f'{CONF_IN_COMPONENT_CONST} = "x"\n' + ) + assert errs + assert "esphome.components.const" in errs[0][2] + + +def test_conf_not_in_a_const_home_is_tracked_not_flagged() -> None: + ci_custom.CONSTANTS_USES.pop("CONF_FOO_BAR", None) + assert _conf_def("a.py", 'CONF_FOO_BAR = "foo_bar"\n') == [] + assert ci_custom.CONSTANTS_USES["CONF_FOO_BAR"] == ["a.py"] + + # --- ESP_LOG call scanner and bare-literal-ternary lint ---