[core] Lint duplicate UNIT_/CONF_ constants against both const homes (#19422)

This commit is contained in:
Jesse Hills
2026-09-24 14:21:32 +01:00
committed by GitHub
parent 22177680c4
commit b0c9a7d5bd
15 changed files with 157 additions and 24 deletions
+1 -2
View File
@@ -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,
+1
View File
@@ -61,3 +61,4 @@ ICON_SOLAR_POWER = "mdi:solar-power"
KEY_METADATA = "metadata"
UNIT_AMPERE_HOUR = "Ah"
UNIT_COUNTS = "#"
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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")
-2
View File
@@ -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"
+1 -1
View File
@@ -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")
+3 -3
View File
@@ -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_(
+1 -1
View File
@@ -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"
+67 -7
View File
@@ -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.
+76
View File
@@ -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 ---