diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 21be2e5d664a6b..8860db69b7916a 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -421,6 +421,7 @@ def __init__( self._entry = entry self.entity_description = description + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/flo/switch.py b/homeassistant/components/flo/switch.py index 18a4341db577f6..4456732d125fc8 100644 --- a/homeassistant/components/flo/switch.py +++ b/homeassistant/components/flo/switch.py @@ -100,6 +100,7 @@ def async_update_state(self) -> None: self._attr_is_on = self._device.last_known_valve_state == "open" self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove(self._device.async_add_listener(self.async_update_state)) diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 833c1812ddbdfe..18fe1cd0a6988c 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -311,6 +311,7 @@ async def _async_force_refresh_state(self) -> None: await self.async_update() self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 825ca140f148e6..330e5dddfa5817 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -136,6 +136,7 @@ def native_value(self) -> int: """Get the current value in percentage.""" return self.entity_description.native_value_fn(self._shade) + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 513c8dbd8b03b5..2eeb633921446e 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -192,6 +192,7 @@ def extra_state_attributes(self) -> dict[str, Any] | None: if v is not None } + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index b1899100dd4491..1a160024a65e48 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -262,6 +262,7 @@ def target_value(self) -> Any: """Return the target value.""" return None if self.target is None else self.target.value + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Subscribe to the node control change events. diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index 8467cba9e6a4ed..de64741ba3a295 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -156,6 +156,7 @@ def __init__( self._attr_name = description.name # Override super self._change_handler: EventListener = None + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Subscribe to the node control change events.""" self._change_handler = self._node.isy.nodes.status_events.subscribe( diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py index 5ddba1e2e86355..388788d3dea922 100644 --- a/homeassistant/components/livisi/entity.py +++ b/homeassistant/components/livisi/entity.py @@ -64,6 +64,7 @@ def __init__( ) super().__init__(coordinator) + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callback for reachability.""" self.async_on_remove( diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index 334590c0e65c61..da7d6106796cc0 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -63,6 +63,7 @@ def is_on(self): """Return the brightness of the light.""" return self._device["status"] == OCCUPANCY_GROUP_OCCUPIED + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callbacks.""" self._smartbridge.add_occupancy_subscriber( diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index b96e03e7eb4297..fd6db8f0c6047a 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -352,6 +352,7 @@ def _handle_event(self, event): """Domain specific event handler.""" self._state = event["value"] + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register update callback.""" # Remove temporary bogus entity_id if added diff --git a/homeassistant/components/risco/entity.py b/homeassistant/components/risco/entity.py index 7f8e3be698b53f..f8869d75d4b30b 100644 --- a/homeassistant/components/risco/entity.py +++ b/homeassistant/components/risco/entity.py @@ -35,6 +35,7 @@ def _refresh_from_coordinator(self) -> None: self._get_data_from_coordinator() self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/risco/sensor.py b/homeassistant/components/risco/sensor.py index bb416b8c55098e..b196723afbe0ea 100644 --- a/homeassistant/components/risco/sensor.py +++ b/homeassistant/components/risco/sensor.py @@ -86,6 +86,7 @@ def __init__( self._attr_name = f"Risco {self.coordinator.risco.site_name} {name} Events" self._attr_device_class = SensorDeviceClass.TIMESTAMP + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self._entity_registry = er.async_get(self.hass) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 69dc6cb934011a..5afa5f8b7270a2 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -332,6 +332,7 @@ def __init__(self, coordinator: ShellyBlockCoordinator, block: Block) -> None: ) self._attr_unique_id = f"{coordinator.mac}-{block.description}" + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to HASS.""" self.async_on_remove(self.coordinator.async_add_listener(self._update_callback)) @@ -375,6 +376,7 @@ def status(self) -> dict: """Device status by entity key.""" return cast(dict, self.coordinator.device.status[self.key]) + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to HASS.""" self.async_on_remove(self.coordinator.async_add_listener(self._update_callback)) diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index d237daf01caccc..84ad68fabc3628 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -73,6 +73,7 @@ def _state_update(self): self._attr_native_value = self.meter.reading self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self): """Subscribe to updates.""" self.async_on_remove(self.coordinator.async_add_listener(self._state_update)) diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index 0e373e1a44fabf..00296f3108c04b 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -99,6 +99,7 @@ def _handle_position_update(self, event: dict[str, Any]) -> None: self._attr_available = True self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" if not self._client.subscribed: diff --git a/pylint/plugins/hass_enforce_super_call.py b/pylint/plugins/hass_enforce_super_call.py new file mode 100644 index 00000000000000..db4b2d4a5d7e59 --- /dev/null +++ b/pylint/plugins/hass_enforce_super_call.py @@ -0,0 +1,79 @@ +"""Plugin for checking super calls.""" +from __future__ import annotations + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.interfaces import INFERENCE +from pylint.lint import PyLinter + +METHODS = { + "async_added_to_hass", +} + + +class HassEnforceSuperCallChecker(BaseChecker): # type: ignore[misc] + """Checker for super calls.""" + + name = "hass_enforce_super_call" + priority = -1 + msgs = { + "W7441": ( + "Missing call to: super().%s", + "hass-missing-super-call", + "Used when method should call its parent implementation.", + ), + } + options = () + + def visit_functiondef( + self, node: nodes.FunctionDef | nodes.AsyncFunctionDef + ) -> None: + """Check for super calls in method body.""" + if node.name not in METHODS: + return + + assert node.parent + parent = node.parent.frame() + if not isinstance(parent, nodes.ClassDef): + return + + # Check function body for super call + for child_node in node.body: + while isinstance(child_node, (nodes.Expr, nodes.Await, nodes.Return)): + child_node = child_node.value + match child_node: + case nodes.Call( + func=nodes.Attribute( + expr=nodes.Call(func=nodes.Name(name="super")), + attrname=node.name, + ), + ): + return + + # Check for non-empty base implementation + found_base_implementation = False + for base in parent.ancestors(): + for method in base.mymethods(): + if method.name != node.name: + continue + if method.body and not ( + len(method.body) == 1 and isinstance(method.body[0], nodes.Pass) + ): + found_base_implementation = True + break + + if found_base_implementation: + self.add_message( + "hass-missing-super-call", + node=node, + args=(node.name,), + confidence=INFERENCE, + ) + break + + visit_asyncfunctiondef = visit_functiondef + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassEnforceSuperCallChecker(linter)) diff --git a/pyproject.toml b/pyproject.toml index a50ef040927558..7dfd584c59818f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,7 @@ init-hook = """\ load-plugins = [ "pylint.extensions.code_style", "pylint.extensions.typing", + "hass_enforce_super_call", "hass_enforce_type_hints", "hass_inheritance", "hass_imports", diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index 4a53f686c5ae16..03f637a646fd46 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -11,13 +11,11 @@ BASE_PATH = Path(__file__).parents[2] -@pytest.fixture(name="hass_enforce_type_hints", scope="session") -def hass_enforce_type_hints_fixture() -> ModuleType: - """Fixture to provide a requests mocker.""" - module_name = "hass_enforce_type_hints" +def _load_plugin_from_file(module_name: str, file: str) -> ModuleType: + """Load plugin from file path.""" spec = spec_from_file_location( module_name, - str(BASE_PATH.joinpath("pylint/plugins/hass_enforce_type_hints.py")), + str(BASE_PATH.joinpath(file)), ) assert spec and spec.loader @@ -27,6 +25,15 @@ def hass_enforce_type_hints_fixture() -> ModuleType: return module +@pytest.fixture(name="hass_enforce_type_hints", scope="session") +def hass_enforce_type_hints_fixture() -> ModuleType: + """Fixture to provide a requests mocker.""" + return _load_plugin_from_file( + "hass_enforce_type_hints", + "pylint/plugins/hass_enforce_type_hints.py", + ) + + @pytest.fixture(name="linter") def linter_fixture() -> UnittestLinter: """Fixture to provide a requests mocker.""" @@ -44,16 +51,10 @@ def type_hint_checker_fixture(hass_enforce_type_hints, linter) -> BaseChecker: @pytest.fixture(name="hass_imports", scope="session") def hass_imports_fixture() -> ModuleType: """Fixture to provide a requests mocker.""" - module_name = "hass_imports" - spec = spec_from_file_location( - module_name, str(BASE_PATH.joinpath("pylint/plugins/hass_imports.py")) + return _load_plugin_from_file( + "hass_imports", + "pylint/plugins/hass_imports.py", ) - assert spec and spec.loader - - module = module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) - return module @pytest.fixture(name="imports_checker") @@ -62,3 +63,20 @@ def imports_checker_fixture(hass_imports, linter) -> BaseChecker: type_hint_checker = hass_imports.HassImportsFormatChecker(linter) type_hint_checker.module = "homeassistant.components.pylint_test" return type_hint_checker + + +@pytest.fixture(name="hass_enforce_super_call", scope="session") +def hass_enforce_super_call_fixture() -> ModuleType: + """Fixture to provide a requests mocker.""" + return _load_plugin_from_file( + "hass_enforce_super_call", + "pylint/plugins/hass_enforce_super_call.py", + ) + + +@pytest.fixture(name="super_call_checker") +def super_call_checker_fixture(hass_enforce_super_call, linter) -> BaseChecker: + """Fixture to provide a requests mocker.""" + super_call_checker = hass_enforce_super_call.HassEnforceSuperCallChecker(linter) + super_call_checker.module = "homeassistant.components.pylint_test" + return super_call_checker diff --git a/tests/pylint/test_enforce_super_call.py b/tests/pylint/test_enforce_super_call.py new file mode 100644 index 00000000000000..5e2861b1c74c4e --- /dev/null +++ b/tests/pylint/test_enforce_super_call.py @@ -0,0 +1,221 @@ +"""Tests for pylint hass_enforce_super_call plugin.""" +from __future__ import annotations + +from types import ModuleType +from unittest.mock import patch + +import astroid +from pylint.checkers import BaseChecker +from pylint.interfaces import INFERENCE +from pylint.testutils import MessageTest +from pylint.testutils.unittest_linter import UnittestLinter +from pylint.utils.ast_walker import ASTWalker +import pytest + +from . import assert_adds_messages, assert_no_messages + + +@pytest.mark.parametrize( + "code", + [ + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + pass + """, + id="no_parent", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + \"\"\"Some docstring.\"\"\" + + class Child(Entity): + async def async_added_to_hass(self) -> None: + x = 2 + """, + id="empty_parent_implementation", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + \"\"\"Some docstring.\"\"\" + pass + + class Child(Entity): + async def async_added_to_hass(self) -> None: + x = 2 + """, + id="empty_parent_implementation2", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + """, + id="correct_super_call", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_added_to_hass(self) -> None: + return await super().async_added_to_hass() + """, + id="super_call_in_return", + ), + pytest.param( + """ + class Entity: + def added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + def added_to_hass(self) -> None: + super().added_to_hass() + """, + id="super_call_not_async", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + \"\"\"\"\"\" + + class Coordinator: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity, Coordinator): + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + """, + id="multiple_inheritance", + ), + pytest.param( + """ + async def async_added_to_hass() -> None: + x = 2 + """, + id="not_a_method", + ), + ], +) +def test_enforce_super_call( + linter: UnittestLinter, + hass_enforce_super_call: ModuleType, + super_call_checker: BaseChecker, + code: str, +) -> None: + """Good test cases.""" + root_node = astroid.parse(code, "homeassistant.components.pylint_test") + walker = ASTWalker(linter) + walker.add_checker(super_call_checker) + + with patch.object( + hass_enforce_super_call, "METHODS", new={"added_to_hass", "async_added_to_hass"} + ), assert_no_messages(linter): + walker.walk(root_node) + + +@pytest.mark.parametrize( + ("code", "node_idx"), + [ + pytest.param( + """ + class Entity: + def added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + def added_to_hass(self) -> None: + x = 3 + """, + 1, + id="no_super_call", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_added_to_hass(self) -> None: + x = 3 + """, + 1, + id="no_super_call_async", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_added_to_hass(self) -> None: + await Entity.async_added_to_hass() + """, + 1, + id="explicit_call_to_base_implementation", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + \"\"\"\"\"\" + + class Coordinator: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity, Coordinator): + async def async_added_to_hass(self) -> None: + x = 3 + """, + 2, + id="multiple_inheritance", + ), + ], +) +def test_enforce_super_call_bad( + linter: UnittestLinter, + hass_enforce_super_call: ModuleType, + super_call_checker: BaseChecker, + code: str, + node_idx: int, +) -> None: + """Bad test cases.""" + root_node = astroid.parse(code, "homeassistant.components.pylint_test") + walker = ASTWalker(linter) + walker.add_checker(super_call_checker) + node = root_node.body[node_idx].body[0] + + with patch.object( + hass_enforce_super_call, "METHODS", new={"added_to_hass", "async_added_to_hass"} + ), assert_adds_messages( + linter, + MessageTest( + msg_id="hass-missing-super-call", + node=node, + line=node.lineno, + args=(node.name,), + col_offset=node.col_offset, + end_line=node.position.end_lineno, + end_col_offset=node.position.end_col_offset, + confidence=INFERENCE, + ), + ): + walker.walk(root_node)