Compare commits

...

1 Commits

Author SHA1 Message Date
Franck Nijhof a028cacaee Add pylint plugin to verify reconfigure-flow quality scale claims 2026-05-13 16:46:44 +00:00
6 changed files with 355 additions and 0 deletions
+14
View File
@@ -98,6 +98,7 @@ Every check has a code following the
| `W7491` | [`hass-unique-id-ip-based`](#w7491-hass-unique-id-ip-based) | Unique ID should not be based on IP/hostname |
| `W7492` | [`hass-config-flow-polling-field`](#w7492-hass-config-flow-polling-field) | Config flow should not include polling interval fields |
| `W7493` | [`hass-config-flow-name-field`](#w7493-hass-config-flow-name-field) | Config flow should not include name fields |
| `W7483` | [`hass-reconfigure-flow-missing`](#w7496-hass-reconfigure-flow-missing) | Integration claiming reconfigure-flow must implement it |
## `hass_logger` checker
@@ -324,3 +325,16 @@ Config flow should not include a name field. Users should not set names
in config flows; they come automatically from the device or are set by
the integration.
## `hass_enforce_reconfigure_flow` checker
**Quality-scale-gated** (🥇 Gold): only fires for integrations whose
`quality_scale.yaml` marks `reconfigure-flow` as `done`.
### `W7483`: `hass-reconfigure-flow-missing`
Integration claims `reconfigure-flow: done` in `quality_scale.yaml` but
`config_flow.py` has no `async_step_reconfigure` method. Either implement
the reconfigure step or change the quality scale claim.
See the [reconfiguration-flow quality scale rule](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reconfiguration-flow).
@@ -0,0 +1,64 @@
"""Shared helpers for quality-scale-gated pylint checkers.
Provides utilities for reading and caching ``quality_scale.yaml`` files
from integration directories. Used by multiple checkers that verify
integrations back up their quality scale claims with actual code.
This module is NOT a pylint plugin itself (hence the ``_`` prefix).
"""
from pathlib import Path
from astroid import nodes
import yaml
_quality_scale_cache: dict[str, dict | None] = {}
def get_integration_dir(module: nodes.Module) -> str | None:
"""Return the integration directory if this module is inside one."""
if not module.file or module.file == "<?>":
return None
file_path = Path(module.file)
for parent in file_path.parents:
if parent.parent.name == "components":
return str(parent)
return None
def load_quality_scale(integration_dir: str) -> dict | None:
"""Load and cache quality_scale.yaml for an integration."""
if integration_dir in _quality_scale_cache:
return _quality_scale_cache[integration_dir]
qs_path = Path(integration_dir) / "quality_scale.yaml"
result: dict | None = None
if qs_path.exists():
try:
result = yaml.safe_load(qs_path.read_text())
except yaml.YAMLError:
return None
_quality_scale_cache[integration_dir] = result
return result
def quality_scale_rule_is_done(integration_dir: str, rule: str) -> bool:
"""Return True if a quality-scale rule is marked done."""
data = load_quality_scale(integration_dir)
if not data or not isinstance(data, dict):
return False
rules = data.get("rules", {})
if not isinstance(rules, dict):
return False
match rules.get(rule):
case "done":
return True
case {"status": "done"}:
return True
case _:
return False
@@ -0,0 +1,79 @@
"""Plugin for verifying reconfigure flow quality scale claims.
When an integration marks ``reconfigure-flow: done`` in its
``quality_scale.yaml``, its ``config_flow.py`` must define an
``async_step_reconfigure`` method on the config flow class.
This checker is **quality-scale-gated**: it only fires for integrations
whose ``quality_scale.yaml`` marks ``reconfigure-flow`` as ``done``.
"""
from _hass_quality_scale_helpers import get_integration_dir, quality_scale_rule_is_done
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
class HassEnforceReconfigureFlowChecker(BaseChecker):
"""Checker for missing reconfigure flow.
Only fires for integrations whose ``quality_scale.yaml`` marks
``reconfigure-flow`` as ``done``.
"""
name = "hass_enforce_reconfigure_flow"
priority = -1
msgs = {
"W7483": (
"Integration claims reconfigure-flow: done but config_flow.py "
"has no async_step_reconfigure method "
"(https://developers.home-assistant.io/docs/core/"
"integration-quality-scale/rules/reconfigure-flow)",
"hass-reconfigure-flow-missing",
"Used when quality_scale.yaml marks reconfigure-flow as done "
"but the config flow class does not implement "
"async_step_reconfigure. "
"See https://developers.home-assistant.io/docs/core/"
"integration-quality-scale/rules/reconfigure-flow",
),
}
options = ()
def visit_module(self, node: nodes.Module) -> None:
"""Check config_flow.py for async_step_reconfigure."""
root_name = node.name
if not root_name.startswith("homeassistant.components."):
return
parts = root_name.split(".")
current_module = parts[3] if len(parts) > 3 else ""
if current_module != "config_flow":
return
integration_dir = get_integration_dir(node)
if not integration_dir:
return
if not quality_scale_rule_is_done(integration_dir, "reconfigure-flow"):
return
if _has_step_method(node, "async_step_reconfigure"):
return
self.add_message("hass-reconfigure-flow-missing", node=node)
def _has_step_method(module: nodes.Module, method_name: str) -> bool:
"""Return True if any class in the module defines the given method."""
for child in module.body:
if not isinstance(child, nodes.ClassDef):
continue
for item in child.body:
if isinstance(item, nodes.FunctionDef) and item.name == method_name:
return True
return False
def register(linter: PyLinter) -> None:
"""Register the checker."""
linter.register_checker(HassEnforceReconfigureFlowChecker(linter))
+1
View File
@@ -125,6 +125,7 @@ load-plugins = [
"hass_enforce_config_flow_no_name",
"hass_enforce_config_flow_no_polling",
"hass_enforce_greek_micro_char",
"hass_enforce_reconfigure_flow",
"hass_enforce_runtime_data",
"hass_enforce_sorted_platforms",
"hass_enforce_super_call",
+29
View File
@@ -140,6 +140,35 @@ def decorator_checker_fixture(hass_decorator, linter) -> BaseChecker:
return type_hint_checker
@pytest.fixture(name="hass_quality_scale_helpers", scope="package")
def hass_quality_scale_helpers_fixture() -> ModuleType:
"""Fixture to load the quality scale helpers module."""
return _load_plugin_from_file(
"_hass_quality_scale_helpers",
"pylint/plugins/_hass_quality_scale_helpers.py",
)
@pytest.fixture(name="hass_enforce_reconfigure_flow", scope="package")
def hass_enforce_reconfigure_flow_fixture() -> ModuleType:
"""Fixture to the content for the reconfigure_flow check."""
return _load_plugin_from_file(
"hass_enforce_reconfigure_flow",
"pylint/plugins/hass_enforce_reconfigure_flow.py",
)
@pytest.fixture(name="enforce_reconfigure_flow_checker")
def enforce_reconfigure_flow_checker_fixture(
hass_quality_scale_helpers, hass_enforce_reconfigure_flow, linter
) -> BaseChecker:
"""Fixture to provide a reconfigure_flow checker."""
hass_quality_scale_helpers._quality_scale_cache.clear()
checker = hass_enforce_reconfigure_flow.HassEnforceReconfigureFlowChecker(linter)
checker.module = "homeassistant.components.pylint_test"
return checker
@pytest.fixture(name="hass_enforce_config_entry_unique_id_no_ip", scope="package")
def hass_enforce_config_entry_unique_id_no_ip_fixture() -> ModuleType:
"""Fixture to the content for the unique_id_no_ip check."""
@@ -0,0 +1,168 @@
"""Tests for pylint hass_enforce_reconfigure_flow plugin."""
from pathlib import Path
import astroid
from pylint.checkers import BaseChecker
from pylint.testutils.unittest_linter import UnittestLinter
from pylint.utils.ast_walker import ASTWalker
import pytest
import yaml
from . import assert_no_messages
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}))
@pytest.mark.parametrize(
("code", "module_name", "rules"),
[
pytest.param(
"""
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_reconfigure(self, user_input=None):
pass
""",
"homeassistant.components.test_int.config_flow",
{"reconfigure-flow": "done"},
id="has_reconfigure_step",
),
pytest.param(
"""
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
pass
""",
"homeassistant.components.test_int.config_flow",
None,
id="no_quality_scale",
),
pytest.param(
"""
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
pass
""",
"homeassistant.components.test_int.config_flow",
{"reconfigure-flow": {"status": "exempt", "comment": "reason"}},
id="rule_exempt",
),
pytest.param(
"""
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
pass
""",
"homeassistant.components.test_int.config_flow",
{"some-other-rule": "done"},
id="rule_absent",
),
pytest.param(
"""
async def async_setup_entry(hass, entry):
pass
""",
"homeassistant.components.test_int.sensor",
{"reconfigure-flow": "done"},
id="not_config_flow",
),
pytest.param(
"""
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
pass
""",
"some.other.module.config_flow",
None,
id="outside_components",
),
],
)
def test_enforce_reconfigure_flow(
linter: UnittestLinter,
enforce_reconfigure_flow_checker: BaseChecker,
tmp_path: Path,
code: str,
module_name: str,
rules: dict | None,
) -> None:
"""Good test cases."""
integration_dir = tmp_path / "homeassistant" / "components" / "test_int"
integration_dir.mkdir(parents=True)
config_flow_file = integration_dir / "config_flow.py"
config_flow_file.touch()
_create_quality_scale(integration_dir, rules)
root_node = astroid.parse(code, module_name)
root_node.file = str(config_flow_file)
walker = ASTWalker(linter)
walker.add_checker(enforce_reconfigure_flow_checker)
with assert_no_messages(linter):
walker.walk(root_node)
def test_enforce_reconfigure_flow_bad(
linter: UnittestLinter,
enforce_reconfigure_flow_checker: BaseChecker,
tmp_path: Path,
) -> None:
"""Bad test case: claims done but no async_step_reconfigure."""
integration_dir = tmp_path / "homeassistant" / "components" / "test_int"
integration_dir.mkdir(parents=True)
config_flow_file = integration_dir / "config_flow.py"
config_flow_file.touch()
_create_quality_scale(integration_dir, {"reconfigure-flow": "done"})
code = """
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
pass
"""
root_node = astroid.parse(code, "homeassistant.components.test_int.config_flow")
root_node.file = str(config_flow_file)
walker = ASTWalker(linter)
walker.add_checker(enforce_reconfigure_flow_checker)
walker.walk(root_node)
messages = linter.release_messages()
assert len(messages) == 1
assert messages[0].msg_id == "hass-reconfigure-flow-missing"
def test_enforce_reconfigure_flow_bad_status_done(
linter: UnittestLinter,
enforce_reconfigure_flow_checker: BaseChecker,
tmp_path: Path,
) -> None:
"""Bad test case: rule is {status: done} dict form."""
integration_dir = tmp_path / "homeassistant" / "components" / "test_int"
integration_dir.mkdir(parents=True)
config_flow_file = integration_dir / "config_flow.py"
config_flow_file.touch()
_create_quality_scale(
integration_dir,
{"reconfigure-flow": {"status": "done", "comment": "implemented"}},
)
code = """
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
pass
"""
root_node = astroid.parse(code, "homeassistant.components.test_int.config_flow")
root_node.file = str(config_flow_file)
walker = ASTWalker(linter)
walker.add_checker(enforce_reconfigure_flow_checker)
walker.walk(root_node)
messages = linter.release_messages()
assert len(messages) == 1
assert messages[0].msg_id == "hass-reconfigure-flow-missing"