mirror of
https://github.com/home-assistant/core.git
synced 2026-05-20 15:55:17 +02:00
Compare commits
1 Commits
dev
...
frenck-2026-0499
| Author | SHA1 | Date | |
|---|---|---|---|
| a028cacaee |
@@ -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))
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user