diff --git a/pylint/plugins/pylint_home_assistant/checkers/quality_scale/config_entry_unloading.py b/pylint/plugins/pylint_home_assistant/checkers/quality_scale/config_entry_unloading.py new file mode 100644 index 00000000000..29aa75a922e --- /dev/null +++ b/pylint/plugins/pylint_home_assistant/checkers/quality_scale/config_entry_unloading.py @@ -0,0 +1,63 @@ +"""Checker for missing config entry unloading. + +**Quality-scale-gated** (Silver): only fires for integrations whose +``quality_scale.yaml`` marks ``config-entry-unloading`` as ``done``. + +The integration's ``__init__.py`` must implement ``async_unload_entry`` +so that config entries can be properly unloaded and resources cleaned up. + +https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/config-entry-unloading/ +""" + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + +from pylint_home_assistant.const import Module, QualityScaleRule +from pylint_home_assistant.helpers.module_info import get_module_platform +from pylint_home_assistant.helpers.quality_scale import quality_scale_rule_is_done + + +class ConfigEntryUnloadingChecker(BaseChecker): + """Checker for async_unload_entry in __init__ modules.""" + + name = "home_assistant_config_entry_unloading" + priority = -1 + msgs = { + "W7413": ( + "Integration should implement `async_unload_entry` " + "(https://developers.home-assistant.io/docs/core/" + "integration-quality-scale/rules/config-entry-unloading)", + "home-assistant-missing-config-entry-unloading", + "Used when an integration's __init__.py does not implement " + "async_unload_entry. This function is needed so config entries " + "can be properly unloaded and resources cleaned up.", + ), + } + options = () + + def visit_module(self, node: nodes.Module) -> None: + """Check that __init__ modules define async_unload_entry.""" + platform = get_module_platform(node.name) + if platform != Module.INIT: + return + + if not quality_scale_rule_is_done( + node, QualityScaleRule.CONFIG_ENTRY_UNLOADING + ): + return + + # Check top-level function definitions only (not nested) + for item in node.body: + if ( + isinstance(item, nodes.AsyncFunctionDef) + and item.name == "async_unload_entry" + ): + return + + self.add_message("home-assistant-missing-config-entry-unloading", node=node) + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(ConfigEntryUnloadingChecker(linter)) diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 382034f0bf3..c0ffacd5f0e 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -13,7 +13,6 @@ from .model import Config, Integration, IntegrationType, ScaledQualityScaleTiers from .quality_scale_validation import ( RuleValidationProtocol, action_setup, - config_entry_unloading, config_flow, discovery, reauthentication_flow, @@ -58,9 +57,7 @@ ALL_RULES = [ Rule("unique-config-entry", ScaledQualityScaleTiers.BRONZE, unique_config_entry), # SILVER Rule("action-exceptions", ScaledQualityScaleTiers.SILVER), - Rule( - "config-entry-unloading", ScaledQualityScaleTiers.SILVER, config_entry_unloading - ), + Rule("config-entry-unloading", ScaledQualityScaleTiers.SILVER), Rule("docs-configuration-parameters", ScaledQualityScaleTiers.SILVER), Rule("docs-installation-parameters", ScaledQualityScaleTiers.SILVER), Rule("entity-unavailable", ScaledQualityScaleTiers.SILVER), diff --git a/script/hassfest/quality_scale_validation/config_entry_unloading.py b/script/hassfest/quality_scale_validation/config_entry_unloading.py deleted file mode 100644 index 4874ddc4625..00000000000 --- a/script/hassfest/quality_scale_validation/config_entry_unloading.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Enforce that the integration implements entry unloading. - -https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/config-entry-unloading/ -""" - -import ast - -from script.hassfest import ast_parse_module -from script.hassfest.model import Config, Integration - - -def _has_unload_entry_function(module: ast.Module) -> bool: - """Test if the module defines `async_unload_entry` function.""" - return any( - type(item) is ast.AsyncFunctionDef and item.name == "async_unload_entry" - for item in module.body - ) - - -def validate( - config: Config, integration: Integration, *, rules_done: set[str] -) -> list[str] | None: - """Validate that the integration has a config flow.""" - - init_file = integration.path / "__init__.py" - init = ast_parse_module(init_file) - - if not _has_unload_entry_function(init): - return [ - "Integration does not support config entry unloading " - "(is missing `async_unload_entry` in __init__.py)" - ] - return None diff --git a/tests/pylint/quality_scale/test_config_entry_unloading.py b/tests/pylint/quality_scale/test_config_entry_unloading.py new file mode 100644 index 00000000000..b2374106fd1 --- /dev/null +++ b/tests/pylint/quality_scale/test_config_entry_unloading.py @@ -0,0 +1,153 @@ +"""Tests for the config entry unloading quality scale checker.""" + +from pathlib import Path + +import astroid +from pylint.testutils import MessageTest, UnittestLinter +from pylint.utils.ast_walker import ASTWalker +from pylint_home_assistant.checkers.quality_scale.config_entry_unloading import ( + ConfigEntryUnloadingChecker, +) +from pylint_home_assistant.helpers.quality_scale import clear_quality_scale_cache +import pytest +import yaml + +from tests.pylint import assert_adds_messages, assert_no_messages + + +@pytest.fixture(name="unloading_checker") +def unloading_checker_fixture(linter: UnittestLinter) -> ConfigEntryUnloadingChecker: + """Fixture to provide a config entry unloading checker.""" + clear_quality_scale_cache() + return ConfigEntryUnloadingChecker(linter) + + +def _create_quality_scale(integration_dir: Path, rules: dict | None = None) -> None: + """Create a quality_scale.yaml in the integration directory.""" + if rules is not None: + (integration_dir / "quality_scale.yaml").write_text(yaml.dump({"rules": rules})) + + +def _make_integration(tmp_path: Path) -> Path: + """Create a fake integration directory under components/.""" + integration_dir = tmp_path / "homeassistant" / "components" / "test_int" + integration_dir.mkdir(parents=True) + return integration_dir + + +def test_unload_entry_present( + linter: UnittestLinter, + unloading_checker: ConfigEntryUnloadingChecker, + tmp_path: Path, +) -> None: + """No warning when async_unload_entry is defined and rule is done.""" + integration_dir = _make_integration(tmp_path) + _create_quality_scale(integration_dir, {"config-entry-unloading": "done"}) + + root_node = astroid.parse( + """ +async def async_setup_entry(hass, entry): + pass + +async def async_unload_entry(hass, entry): + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +""", + "homeassistant.components.test_int", + ) + root_node.file = str(integration_dir / "__init__.py") + + walker = ASTWalker(linter) + walker.add_checker(unloading_checker) + + with assert_no_messages(linter): + walker.walk(root_node) + + +def test_unload_entry_missing_fires( + linter: UnittestLinter, + unloading_checker: ConfigEntryUnloadingChecker, + tmp_path: Path, +) -> None: + """Warning when async_unload_entry is missing and rule is done.""" + integration_dir = _make_integration(tmp_path) + _create_quality_scale(integration_dir, {"config-entry-unloading": "done"}) + + root_node = astroid.parse( + """ +async def async_setup_entry(hass, entry): + pass +""", + "homeassistant.components.test_int", + ) + root_node.file = str(integration_dir / "__init__.py") + + walker = ASTWalker(linter) + walker.add_checker(unloading_checker) + + with assert_adds_messages( + linter, + MessageTest( + msg_id="home-assistant-missing-config-entry-unloading", + node=root_node, + line=0, + col_offset=0, + ), + ): + walker.walk(root_node) + + +@pytest.mark.parametrize( + ("module_name", "rules"), + [ + pytest.param( + "homeassistant.components.test_int", + None, + id="no_quality_scale_file", + ), + pytest.param( + "homeassistant.components.test_int", + {"config-entry-unloading": "todo"}, + id="rule_todo", + ), + pytest.param( + "homeassistant.components.test_int", + {"config-entry-unloading": {"status": "exempt", "comment": "reason"}}, + id="rule_exempt", + ), + pytest.param( + "homeassistant.components.test_int.sensor", + {"config-entry-unloading": "done"}, + id="not_init_module", + ), + pytest.param( + "not_homeassistant.something", + {"config-entry-unloading": "done"}, + id="not_an_integration", + ), + ], +) +def test_unload_entry_not_fired( + linter: UnittestLinter, + unloading_checker: ConfigEntryUnloadingChecker, + tmp_path: Path, + module_name: str, + rules: dict | None, +) -> None: + """No warning when rule is not done or module is not __init__.""" + integration_dir = _make_integration(tmp_path) + _create_quality_scale(integration_dir, rules) + + root_node = astroid.parse( + """ +async def async_setup_entry(hass, entry): + pass +""", + module_name, + ) + root_node.file = str(integration_dir / "__init__.py") + + walker = ASTWalker(linter) + walker.add_checker(unloading_checker) + + with assert_no_messages(linter): + walker.walk(root_node)