1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-19 06:50:15 +01:00

Migrate config-entry-unloading quality scale check from hassfest to pylint (#170720)

This commit is contained in:
Franck Nijhof
2026-05-14 22:06:12 +02:00
committed by GitHub
parent 8304f35734
commit bc0899ba10
4 changed files with 217 additions and 37 deletions
@@ -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))
+1 -4
View File
@@ -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),
@@ -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
@@ -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)