From bbf0a5e235a4fe548c52fd282fd1eca7e348e757 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Sep 2026 12:58:55 +0200 Subject: [PATCH] Add home_assistant_light_color_mode pylint checker (#180922) --- pylint/plugins/README.md | 39 ++ .../checkers/light_color_mode.py | 288 +++++++++++ .../helpers/entity_class.py | 6 + tests/pylint/test_light_color_mode.py | 451 ++++++++++++++++++ 4 files changed, 784 insertions(+) create mode 100644 pylint/plugins/pylint_home_assistant/checkers/light_color_mode.py create mode 100644 tests/pylint/test_light_color_mode.py diff --git a/pylint/plugins/README.md b/pylint/plugins/README.md index 63ce62dd5f32..25f080ab97bc 100644 --- a/pylint/plugins/README.md +++ b/pylint/plugins/README.md @@ -140,6 +140,8 @@ Every check has a code following the | `W7433` | [`home-assistant-missing-test-before-configure`](#w7433-home-assistant-missing-test-before-configure) | Config flow should test the connection before creating an entry | | `W7434` | [`home-assistant-config-flow-menu-missing-step`](#w7434-home-assistant-config-flow-menu-missing-step) | `async_show_menu` option has no matching `async_step_*` method | | `W7435` | [`home-assistant-json-fixture`](#w7435-home-assistant-json-fixture) | Use a JSON fixture helper instead of parsing a loaded fixture | +| `W7436` | [`home-assistant-light-missing-color-mode`](#w7436-home-assistant-light-missing-color-mode) | Light entity sets supported color modes but does not report a `color_mode` | +| `W7437` | [`home-assistant-light-missing-supported-color-modes`](#w7437-home-assistant-light-missing-supported-color-modes) | Light entity reports a `color_mode` but does not set supported color modes | ## `home_assistant_logger` checker @@ -1003,3 +1005,40 @@ Use the dedicated helper that loads and parses in one step instead: data = load_json_object_fixture("data.json", DOMAIN) data = await async_load_json_object_fixture(hass, "data.json", DOMAIN) ``` + + +## `home_assistant_light_color_mode` checker + +A `LightEntity` must report **both** `supported_color_modes` and a current +`color_mode`; setting one without the other raises `HomeAssistantError` at +runtime. These two checks flag each half of that inconsistency. A light +that sets *neither* is deliberately not flagged: the realistic both-missing +class is an abstract base, so flagging it would false-positive; the tradeoff +is that a concrete both-missing light, which also raises, is not caught. + +A value is considered *provided* by a class when its effective declaration, +resolved in MRO order and excluding `LightEntity`'s own `None` defaults, is a +non-`None` class-body `_attr_...` assignment, a `self._attr_... = ...` +assignment in a method body, or a property/method override of the public +name. Subclass shadowing is respected: a subclass that assigns the +`_attr_...` to `None` nullifies a non-`None` value inherited from an +ancestor. Mixin/abstract bases that are subclassed by another class in the +same module are exempted, on the assumption that the concrete subclass is the +runtime entity (and may supply the missing half). + +### `W7436`: `home-assistant-light-missing-color-mode` + +The light provides `supported_color_modes` but no `color_mode`. At runtime +`LightEntity.state_attributes` raises `HomeAssistantError` ("does not report a +color mode") whenever the light is on and `color_mode` is `None` -- there is +no inference of the mode from a single supported mode, so this holds even for +lights that support only `ONOFF` or `BRIGHTNESS`. Set `_attr_color_mode` or +override the `color_mode` property. + +### `W7437`: `home-assistant-light-missing-supported-color-modes` + +The light provides `color_mode` but no `supported_color_modes`. At runtime +`LightEntity._light_internal_supported_color_modes` raises `HomeAssistantError` +("does not set supported color modes") from both `state_attributes` and +`capability_attributes` whenever `supported_color_modes` is `None`. Set +`_attr_supported_color_modes` or override the `supported_color_modes` property. diff --git a/pylint/plugins/pylint_home_assistant/checkers/light_color_mode.py b/pylint/plugins/pylint_home_assistant/checkers/light_color_mode.py new file mode 100644 index 000000000000..3951cf0d296d --- /dev/null +++ b/pylint/plugins/pylint_home_assistant/checkers/light_color_mode.py @@ -0,0 +1,288 @@ +"""Checker for light entities that report only one of the color-mode attributes. + +A ``LightEntity`` must report **both** ``supported_color_modes`` and +a current ``color_mode``; setting one without the other raises +``HomeAssistantError`` at runtime. These two checks flag each half of that +inconsistency. A light that sets *neither* is deliberately not flagged: the +realistic both-missing class is an abstract base (which the concrete +subclass completes), so flagging it would produce false positives — the +tradeoff is that a concrete both-missing light, which also raises at +runtime, is not caught. + +A value is considered *provided* by a class when its effective declaration, +resolved in MRO order and excluding ``LightEntity``'s own ``None`` defaults, +is one of: + +- a non-``None`` class-body ``_attr_...`` assignment, +- a ``self._attr_... = ...`` assignment in a method body, or +- a property/method override of the public name. + +Subclass shadowing is respected: a subclass that assigns the ``_attr_...`` +to ``None`` nullifies a non-``None`` value inherited from an ancestor, so +the pair is treated as unset from that subclass down. + +Mixin/abstract bases that are subclassed by another class in the same +module are exempted, on the assumption that the concrete subclass is the +runtime entity (and may supply the missing half itself). + +``W7436`` (``home-assistant-light-missing-color-mode``) +------------------------------------------------------- +Fires when ``supported_color_modes`` is provided but ``color_mode`` is not. +At runtime ``LightEntity.state_attributes`` raises ``HomeAssistantError`` +("does not report a color mode") whenever the light is on and +``color_mode`` is ``None`` — there is no inference of the mode from a +single supported mode, so this holds even for lights that support only +``ONOFF`` or ``BRIGHTNESS``. + +``W7437`` (``home-assistant-light-missing-supported-color-modes``) +------------------------------------------------------------------ +Fires when ``color_mode`` is provided but ``supported_color_modes`` is not. +At runtime ``LightEntity._light_internal_supported_color_modes`` raises +``HomeAssistantError`` ("does not set supported color modes") from both +``state_attributes`` and ``capability_attributes`` whenever +``supported_color_modes`` is ``None``. + +Known limitations: + +- A base defined in one module whose missing half is only supplied by + subclasses in a *different* module is flagged, because the subclasses are + not visible when the base's module is linted. Suppress with a + ``# pylint: disable=...`` on the base. +- A property override is treated as providing the value regardless of what + it returns; a property that returns ``None`` at runtime is the + integration's responsibility. +""" + +import astroid +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + +from pylint_home_assistant.helpers.ast_utils import extended_ancestors +from pylint_home_assistant.helpers.entity_class import ( + LIGHT_ENTITY_QNAME, + collect_same_module_ancestor_qnames, + inherits_from_light_entity, +) +from pylint_home_assistant.helpers.module_info import is_integration_module + +_SUPPORTED_ATTR = "_attr_supported_color_modes" +_SUPPORTED_PROPERTY = "supported_color_modes" +_COLOR_MODE_ATTR = "_attr_color_mode" +_COLOR_MODE_PROPERTY = "color_mode" + + +def _is_non_none_value(value: nodes.NodeNG | None) -> bool: + """Return True if the AST value is present and not a literal ``None``.""" + if value is None: + return False + return not (isinstance(value, nodes.Const) and value.value is None) + + +def _is_self_attr_target(target: nodes.NodeNG, attr_name: str) -> bool: + """Return True if *target* is ``self.``.""" + match target: + case nodes.AssignAttr(attrname=name, expr=nodes.Name(name="self")) if ( + name == attr_name + ): + return True + return False + + +def _class_body_attr_state(class_node: nodes.ClassDef, attr_name: str) -> bool | None: + """Return the effect of the class body's final assignment to *attr_name*. + + ``True`` if the last source-order assignment sets a non-``None`` value, + ``False`` if it sets a literal ``None``, or ``None`` if the class body + does not assign *attr_name* at all. Later assignments win, matching + Python's class-body evaluation, so ``x = ColorMode.HS`` followed by + ``x = None`` resolves to ``False``. Annotation-only statements (``x: T`` + with no value) are not assignments and are ignored. + """ + state: bool | None = None + for item in class_node.body: + match item: + case nodes.AnnAssign(target=nodes.AssignName(name=name), value=value) if ( + name == attr_name and value is not None + ): + state = _is_non_none_value(value) + case nodes.Assign(targets=targets, value=value) if any( + isinstance(t, nodes.AssignName) and t.name == attr_name for t in targets + ): + state = _is_non_none_value(value) + return state + + +def _method_sets_self_attr(class_node: nodes.ClassDef, attr_name: str) -> bool: + """Return True if any method assigns ``self. = ``. + + The assignment need not be unconditional: any assignment means the class + reports the attribute and must therefore also report the paired value. + Assignments in nested functions or classes (which have their own + ``self``) are ignored — only the method's own scope counts. + """ + for method in class_node.body: + if not isinstance(method, nodes.FunctionDef | nodes.AsyncFunctionDef): + continue + for stmt in method.nodes_of_class((nodes.Assign, nodes.AnnAssign)): + if stmt.scope() is not method: + continue + match stmt: + case nodes.Assign(targets=targets, value=value): + target_list = list(targets) + case nodes.AnnAssign(target=target, value=value): + target_list = [target] + case _: + continue + if _is_non_none_value(value) and any( + _is_self_attr_target(t, attr_name) for t in target_list + ): + return True + return False + + +def _class_defines_method(class_node: nodes.ClassDef, method_name: str) -> bool: + """Return True if the class body overrides *method_name* (property/method).""" + return any( + isinstance(item, nodes.FunctionDef | nodes.AsyncFunctionDef) + and item.name == method_name + for item in class_node.body + ) + + +def _class_declaration( + class_node: nodes.ClassDef, attr_name: str, property_name: str +) -> bool | None: + """Return this class's *effective* declaration for the attr/property. + + ``True`` if the class provides a value, ``False`` if it nullifies an + inherited value (class-body ``_attr_... = None``), or ``None`` if the + class does not declare the pair at all. ``LightEntity`` declares the + ``None`` defaults, so it resolves to ``False`` — reaching it means no + subclass provided a value. + + Precedence within the class follows runtime resolution: a + ``property``/method override or a non-``None`` ``self._attr_...`` + assignment wins over a class-body ``_attr_... = None``. + """ + if class_node.qname() == LIGHT_ENTITY_QNAME: + return False + if _class_defines_method(class_node, property_name): + return True + if _method_sets_self_attr(class_node, attr_name): + return True + return _class_body_attr_state(class_node, attr_name) + + +def _mro(class_node: nodes.ClassDef) -> list[nodes.ClassDef]: + """Return the class's MRO, falling back to a DFS ancestor walk.""" + try: + return class_node.mro() # type: ignore[no-any-return] + except astroid.exceptions.MroError: + return [class_node, *extended_ancestors(class_node)] + + +def _provides_effective( + class_node: nodes.ClassDef, attr_name: str, property_name: str +) -> bool: + """Return True if the effective value for *class_node* is provided. + + Walks the MRO most-derived first and returns the first class that + declares the pair, so a subclass ``_attr_... = None`` shadows a + non-``None`` value set by an ancestor. + """ + for klass in _mro(class_node): + decl = _class_declaration(klass, attr_name, property_name) + if decl is not None: + return decl + return False + + +class HassLightColorModeChecker(BaseChecker): + """Flag light entities that report only one of the color-mode attributes.""" + + name = "home_assistant_light_color_mode" + priority = -1 + msgs = { + "W7436": ( + ( + "Light entity class `%s` reports supported color modes but " + "does not report a color mode; set `_attr_color_mode` or " + "override the `color_mode` property" + ), + "home-assistant-light-missing-color-mode", + ( + "Used when a LightEntity subclass provides " + "supported_color_modes (via _attr_supported_color_modes or a " + "supported_color_modes override) but neither sets " + "_attr_color_mode nor overrides the color_mode property. Such " + "a light raises HomeAssistantError at runtime because it does " + "not report a color mode when turned on." + ), + ), + "W7437": ( + ( + "Light entity class `%s` reports a color mode but does not " + "report supported color modes; set " + "`_attr_supported_color_modes` or override the " + "`supported_color_modes` property" + ), + "home-assistant-light-missing-supported-color-modes", + ( + "Used when a LightEntity subclass provides color_mode (via " + "_attr_color_mode or a color_mode override) but neither sets " + "_attr_supported_color_modes nor overrides the " + "supported_color_modes property. Such a light raises " + "HomeAssistantError at runtime because it does not set " + "supported color modes." + ), + ), + } + options = () + + _check_module: bool + _subclassed_qnames: set[str] + + def visit_module(self, node: nodes.Module) -> None: + """Cache per-module state.""" + self._check_module = is_integration_module(node.name) + self._subclassed_qnames = ( + collect_same_module_ancestor_qnames(node) if self._check_module else set() + ) + + def visit_classdef(self, node: nodes.ClassDef) -> None: + """Flag light entities reporting only one of the color-mode attributes.""" + if not self._check_module: + return + # Skip mixin / abstract bases: another class in the same module + # inherits from this one, so this class is not the runtime entity. + if node.qname() in self._subclassed_qnames: + return + if not inherits_from_light_entity(node): + return + provides_supported = _provides_effective( + node, _SUPPORTED_ATTR, _SUPPORTED_PROPERTY + ) + provides_color_mode = _provides_effective( + node, _COLOR_MODE_ATTR, _COLOR_MODE_PROPERTY + ) + # Only the XOR is flagged: reporting both is correct, and reporting + # neither is skipped to avoid false positives on abstract bases (a + # concrete both-missing light also raises but is not caught). + if provides_supported and not provides_color_mode: + self.add_message( + "home-assistant-light-missing-color-mode", + node=node, + args=(node.name,), + ) + elif provides_color_mode and not provides_supported: + self.add_message( + "home-assistant-light-missing-supported-color-modes", + node=node, + args=(node.name,), + ) + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassLightColorModeChecker(linter)) diff --git a/pylint/plugins/pylint_home_assistant/helpers/entity_class.py b/pylint/plugins/pylint_home_assistant/helpers/entity_class.py index c35dceefa89c..9e50c6f046a6 100644 --- a/pylint/plugins/pylint_home_assistant/helpers/entity_class.py +++ b/pylint/plugins/pylint_home_assistant/helpers/entity_class.py @@ -5,6 +5,7 @@ from astroid import nodes from .ast_utils import extended_ancestors ENTITY_QNAME = "homeassistant.helpers.entity.Entity" +LIGHT_ENTITY_QNAME = "homeassistant.components.light.LightEntity" def inherits_from_entity(class_node: nodes.ClassDef) -> bool: @@ -12,6 +13,11 @@ def inherits_from_entity(class_node: nodes.ClassDef) -> bool: return any(a.qname() == ENTITY_QNAME for a in extended_ancestors(class_node)) +def inherits_from_light_entity(class_node: nodes.ClassDef) -> bool: + """Return True if class inherits from ``LightEntity``.""" + return any(a.qname() == LIGHT_ENTITY_QNAME for a in extended_ancestors(class_node)) + + def collect_same_module_ancestor_qnames(module: nodes.Module) -> set[str]: """Return qnames of every class used as an ancestor in *module*. diff --git a/tests/pylint/test_light_color_mode.py b/tests/pylint/test_light_color_mode.py new file mode 100644 index 000000000000..1bd96b039625 --- /dev/null +++ b/tests/pylint/test_light_color_mode.py @@ -0,0 +1,451 @@ +"""Tests for the light_color_mode pylint checker.""" + +import json +from pathlib import Path + +import astroid +from astroid import nodes +from pylint.testutils import MessageTest, UnittestLinter +from pylint_home_assistant.checkers.light_color_mode import HassLightColorModeChecker +import pytest + +from . import assert_adds_messages, assert_no_messages, walk_checker + +_MISSING_COLOR_MODE = "home-assistant-light-missing-color-mode" +_MISSING_SUPPORTED = "home-assistant-light-missing-supported-color-modes" + + +@pytest.fixture(name="checker") +def checker_fixture(linter: UnittestLinter) -> HassLightColorModeChecker: + """Fixture to provide the W7436 + W7437 checker.""" + return HassLightColorModeChecker(linter) + + +def _make_integration(tmp_path: Path, *, domain: str = "test_integration") -> Path: + """Create a fake integration directory under components/.""" + integration_dir = tmp_path / "homeassistant" / "components" / "test_integration" + integration_dir.mkdir(parents=True) + (integration_dir / "manifest.json").write_text(json.dumps({"domain": domain})) + return integration_dir + + +def _parse( + code: str, + integration_dir: Path, + module_name: str = "homeassistant.components.test_integration.light", + file_name: str = "light.py", +) -> nodes.Module: + """Parse code as a module of the integration with .file set.""" + root_node = astroid.parse(code, module_name) + root_node.file = str(integration_dir / file_name) + return root_node + + +def _find_class(root_node: nodes.Module, name: str) -> nodes.ClassDef: + """Return the ClassDef named *name*.""" + for class_node in root_node.nodes_of_class(nodes.ClassDef): + if class_node.name == name: + return class_node + raise AssertionError(f"no class named {name} found") + + +def _expect(class_node: nodes.ClassDef, msg_id: str) -> MessageTest: + """Build the expected MessageTest for a flagged class.""" + pos = class_node.position + return MessageTest( + msg_id=msg_id, + node=class_node, + line=pos.lineno, + col_offset=pos.col_offset, + end_line=pos.end_lineno, + end_col_offset=pos.end_col_offset, + args=(class_node.name,), + ) + + +@pytest.mark.parametrize( + ("code", "class_name"), + [ + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + _attr_supported_color_modes = {ColorMode.ONOFF} +""", + "MyLight", + id="class_body_supported_no_color_mode", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + def __init__(self, status) -> None: + modes = {ColorMode.HS} + self._attr_supported_color_modes = modes +""", + "MyLight", + id="self_assign_supported_no_color_mode", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + @property + def supported_color_modes(self): + return {ColorMode.HS} +""", + "MyLight", + id="supported_property_override_no_color_mode", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} + _attr_color_mode = None +""", + "MyLight", + id="color_mode_explicitly_none", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} + _attr_color_mode = ColorMode.HS + _attr_color_mode = None +""", + "MyLight", + id="color_mode_reassigned_none_wins", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} + + def _factory(self): + class _Inner: + def run(self): + self._attr_color_mode = ColorMode.HS + return _Inner +""", + "MyLight", + id="color_mode_only_in_nested_class_scope", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyBaseLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} + +class MyLight(MyBaseLight): + pass +""", + "MyLight", + id="supported_inherited_color_mode_missing", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyBaseLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} + _attr_color_mode = ColorMode.HS + +class MyLight(MyBaseLight): + _attr_color_mode = None +""", + "MyLight", + id="subclass_nullifies_inherited_color_mode", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyBaseLight(LightEntity): + pass + +class MyColorLight(MyBaseLight): + def __init__(self, status) -> None: + self._attr_supported_color_modes = {ColorMode.HS} + + @property + def color_mode(self): + return ColorMode.HS + +class MySwitchLight(MyBaseLight): + def __init__(self, status) -> None: + self._attr_supported_color_modes = {ColorMode.ONOFF} +""", + "MySwitchLight", + id="two_subclasses_only_offender", + ), + ], +) +def test_fires_w7436( + linter: UnittestLinter, + checker: HassLightColorModeChecker, + tmp_path: Path, + code: str, + class_name: str, +) -> None: + """W7436 fires when supported color modes are set but no color mode is reported.""" + integration_dir = _make_integration(tmp_path) + + root_node = _parse(code, integration_dir) + class_node = _find_class(root_node, class_name) + with assert_adds_messages(linter, _expect(class_node, _MISSING_COLOR_MODE)): + walk_checker(linter, checker, root_node) + + +@pytest.mark.parametrize( + ("code", "class_name"), + [ + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + _attr_color_mode = ColorMode.ONOFF +""", + "MyLight", + id="class_body_color_mode_no_supported", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + def __init__(self, status) -> None: + self._attr_color_mode = ColorMode.HS +""", + "MyLight", + id="self_assign_color_mode_no_supported", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + @property + def color_mode(self): + return ColorMode.HS +""", + "MyLight", + id="color_mode_property_override_no_supported", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyBaseLight(LightEntity): + _attr_color_mode = ColorMode.HS + +class MyLight(MyBaseLight): + pass +""", + "MyLight", + id="color_mode_inherited_supported_missing", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyBaseLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} + _attr_color_mode = ColorMode.HS + +class MyLight(MyBaseLight): + _attr_supported_color_modes = None +""", + "MyLight", + id="subclass_nullifies_inherited_supported", + ), + ], +) +def test_fires_w7437( + linter: UnittestLinter, + checker: HassLightColorModeChecker, + tmp_path: Path, + code: str, + class_name: str, +) -> None: + """W7437 fires when a color mode is reported but no supported modes are set.""" + integration_dir = _make_integration(tmp_path) + + root_node = _parse(code, integration_dir) + class_node = _find_class(root_node, class_name) + with assert_adds_messages(linter, _expect(class_node, _MISSING_SUPPORTED)): + walk_checker(linter, checker, root_node) + + +@pytest.mark.parametrize( + "code", + [ + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_color_mode = ColorMode.ONOFF +""", + id="both_class_attrs", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} + + @property + def color_mode(self): + return ColorMode.HS +""", + id="supported_attr_and_color_mode_property", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + def __init__(self, status) -> None: + self._attr_supported_color_modes = {ColorMode.HS} + self._attr_color_mode = ColorMode.HS +""", + id="both_self_assigned", + ), + pytest.param( + """ +from homeassistant.components.light import LightEntity + +class MyLight(LightEntity): + @property + def is_on(self) -> bool: + return True +""", + id="neither_set", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + _attr_supported_color_modes: set[ColorMode] | None = None +""", + id="supported_explicitly_none", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyBaseLight(LightEntity): + _attr_color_mode = ColorMode.HS + +class MyLight(MyBaseLight): + _attr_supported_color_modes = {ColorMode.HS} +""", + id="color_mode_from_base_supported_from_subclass", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyBaseLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} + +class MyLight(MyBaseLight): + _attr_color_mode = ColorMode.HS +""", + id="supported_from_base_color_mode_from_subclass", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyBaseLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} + _attr_color_mode = ColorMode.HS + +class MyLight(MyBaseLight): + _attr_color_mode = None + + def __init__(self) -> None: + self._attr_color_mode = ColorMode.HS +""", + id="subclass_reassigns_inherited_color_mode_via_init", + ), + ], +) +def test_good( + linter: UnittestLinter, + checker: HassLightColorModeChecker, + tmp_path: Path, + code: str, +) -> None: + """No message when a light reports both halves, or neither. + + Covers the both-reported cases (directly or via inheritance, including a + runtime ``self._attr_...`` assignment that wins over a class-body + ``None``) and the both-missing case (legacy/abstract, deliberately not + flagged). + """ + integration_dir = _make_integration(tmp_path) + + root_node = _parse(code, integration_dir) + with assert_no_messages(linter): + walk_checker(linter, checker, root_node) + + +@pytest.mark.parametrize( + ("code", "module_name", "file_name"), + [ + pytest.param( + """ +from homeassistant.components.light import ColorMode + +class NotALight: + _attr_supported_color_modes = {ColorMode.HS} +""", + "homeassistant.components.test_integration.light", + "light.py", + id="non_light_entity_class", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} +""", + "not_homeassistant.something.light", + "light.py", + id="module_outside_integration", + ), + ], +) +def test_out_of_scope_ignored( + linter: UnittestLinter, + checker: HassLightColorModeChecker, + tmp_path: Path, + code: str, + module_name: str, + file_name: str, +) -> None: + """W7436 doesn't fire for classes/modules outside the rule's scope.""" + integration_dir = _make_integration(tmp_path) + + root_node = _parse( + code, integration_dir, module_name=module_name, file_name=file_name + ) + with assert_no_messages(linter): + walk_checker(linter, checker, root_node)