mirror of
https://github.com/home-assistant/core.git
synced 2026-09-03 12:02:16 +01:00
Add home_assistant_light_color_mode pylint checker (#180922)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.<attr_name>``."""
|
||||
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.<attr_name> = <non-None>``.
|
||||
|
||||
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))
|
||||
@@ -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*.
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user