mirror of
https://github.com/home-assistant/core.git
synced 2025-07-17 10:17:09 +00:00
Add quality scale hassfest check for config-entry-unload (#131720)
* Add dataclass to hassfest quality_scale * Add basic check for config-entry-unloading * Future-proof with a list of errors
This commit is contained in:
parent
a6cb6fd239
commit
e04b6f0cd8
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from voluptuous.humanize import humanize_error
|
from voluptuous.humanize import humanize_error
|
||||||
|
|
||||||
@ -10,72 +12,88 @@ from homeassistant.exceptions import HomeAssistantError
|
|||||||
from homeassistant.util.yaml import load_yaml_dict
|
from homeassistant.util.yaml import load_yaml_dict
|
||||||
|
|
||||||
from .model import Config, Integration, ScaledQualityScaleTiers
|
from .model import Config, Integration, ScaledQualityScaleTiers
|
||||||
|
from .quality_scale_validation import RuleValidationProtocol, config_entry_unloading
|
||||||
|
|
||||||
QUALITY_SCALE_TIERS = {value.name.lower(): value for value in ScaledQualityScaleTiers}
|
QUALITY_SCALE_TIERS = {value.name.lower(): value for value in ScaledQualityScaleTiers}
|
||||||
|
|
||||||
RULES = {
|
|
||||||
ScaledQualityScaleTiers.BRONZE: [
|
@dataclass
|
||||||
"action-setup",
|
class Rule:
|
||||||
"appropriate-polling",
|
"""Quality scale rules."""
|
||||||
"brands",
|
|
||||||
"common-modules",
|
name: str
|
||||||
"config-flow",
|
tier: ScaledQualityScaleTiers
|
||||||
"config-flow-test-coverage",
|
validator: RuleValidationProtocol | None = None
|
||||||
"dependency-transparency",
|
|
||||||
"docs-actions",
|
|
||||||
"docs-high-level-description",
|
ALL_RULES = [
|
||||||
"docs-installation-instructions",
|
# BRONZE
|
||||||
"docs-removal-instructions",
|
Rule("action-setup", ScaledQualityScaleTiers.BRONZE),
|
||||||
"entity-event-setup",
|
Rule("appropriate-polling", ScaledQualityScaleTiers.BRONZE),
|
||||||
"entity-unique-id",
|
Rule("brands", ScaledQualityScaleTiers.BRONZE),
|
||||||
"has-entity-name",
|
Rule("common-modules", ScaledQualityScaleTiers.BRONZE),
|
||||||
"runtime-data",
|
Rule("config-flow", ScaledQualityScaleTiers.BRONZE),
|
||||||
"test-before-configure",
|
Rule("config-flow-test-coverage", ScaledQualityScaleTiers.BRONZE),
|
||||||
"test-before-setup",
|
Rule("dependency-transparency", ScaledQualityScaleTiers.BRONZE),
|
||||||
"unique-config-entry",
|
Rule("docs-actions", ScaledQualityScaleTiers.BRONZE),
|
||||||
],
|
Rule("docs-high-level-description", ScaledQualityScaleTiers.BRONZE),
|
||||||
ScaledQualityScaleTiers.SILVER: [
|
Rule("docs-installation-instructions", ScaledQualityScaleTiers.BRONZE),
|
||||||
"action-exceptions",
|
Rule("docs-removal-instructions", ScaledQualityScaleTiers.BRONZE),
|
||||||
"config-entry-unloading",
|
Rule("entity-event-setup", ScaledQualityScaleTiers.BRONZE),
|
||||||
"docs-configuration-parameters",
|
Rule("entity-unique-id", ScaledQualityScaleTiers.BRONZE),
|
||||||
"docs-installation-parameters",
|
Rule("has-entity-name", ScaledQualityScaleTiers.BRONZE),
|
||||||
"entity-unavailable",
|
Rule("runtime-data", ScaledQualityScaleTiers.BRONZE),
|
||||||
"integration-owner",
|
Rule("test-before-configure", ScaledQualityScaleTiers.BRONZE),
|
||||||
"log-when-unavailable",
|
Rule("test-before-setup", ScaledQualityScaleTiers.BRONZE),
|
||||||
"parallel-updates",
|
Rule("unique-config-entry", ScaledQualityScaleTiers.BRONZE),
|
||||||
"reauthentication-flow",
|
# SILVER
|
||||||
"test-coverage",
|
Rule("action-exceptions", ScaledQualityScaleTiers.SILVER),
|
||||||
],
|
Rule(
|
||||||
ScaledQualityScaleTiers.GOLD: [
|
"config-entry-unloading", ScaledQualityScaleTiers.SILVER, config_entry_unloading
|
||||||
"devices",
|
),
|
||||||
"diagnostics",
|
Rule("docs-configuration-parameters", ScaledQualityScaleTiers.SILVER),
|
||||||
"discovery",
|
Rule("docs-installation-parameters", ScaledQualityScaleTiers.SILVER),
|
||||||
"discovery-update-info",
|
Rule("entity-unavailable", ScaledQualityScaleTiers.SILVER),
|
||||||
"docs-data-update",
|
Rule("integration-owner", ScaledQualityScaleTiers.SILVER),
|
||||||
"docs-examples",
|
Rule("log-when-unavailable", ScaledQualityScaleTiers.SILVER),
|
||||||
"docs-known-limitations",
|
Rule("parallel-updates", ScaledQualityScaleTiers.SILVER),
|
||||||
"docs-supported-devices",
|
Rule("reauthentication-flow", ScaledQualityScaleTiers.SILVER),
|
||||||
"docs-supported-functions",
|
Rule("test-coverage", ScaledQualityScaleTiers.SILVER),
|
||||||
"docs-troubleshooting",
|
# GOLD: [
|
||||||
"docs-use-cases",
|
Rule("devices", ScaledQualityScaleTiers.GOLD),
|
||||||
"dynamic-devices",
|
Rule("diagnostics", ScaledQualityScaleTiers.GOLD),
|
||||||
"entity-category",
|
Rule("discovery", ScaledQualityScaleTiers.GOLD),
|
||||||
"entity-device-class",
|
Rule("discovery-update-info", ScaledQualityScaleTiers.GOLD),
|
||||||
"entity-disabled-by-default",
|
Rule("docs-data-update", ScaledQualityScaleTiers.GOLD),
|
||||||
"entity-translations",
|
Rule("docs-examples", ScaledQualityScaleTiers.GOLD),
|
||||||
"exception-translations",
|
Rule("docs-known-limitations", ScaledQualityScaleTiers.GOLD),
|
||||||
"icon-translations",
|
Rule("docs-supported-devices", ScaledQualityScaleTiers.GOLD),
|
||||||
"reconfiguration-flow",
|
Rule("docs-supported-functions", ScaledQualityScaleTiers.GOLD),
|
||||||
"repair-issues",
|
Rule("docs-troubleshooting", ScaledQualityScaleTiers.GOLD),
|
||||||
"stale-devices",
|
Rule("docs-use-cases", ScaledQualityScaleTiers.GOLD),
|
||||||
],
|
Rule("dynamic-devices", ScaledQualityScaleTiers.GOLD),
|
||||||
ScaledQualityScaleTiers.PLATINUM: [
|
Rule("entity-category", ScaledQualityScaleTiers.GOLD),
|
||||||
"async-dependency",
|
Rule("entity-device-class", ScaledQualityScaleTiers.GOLD),
|
||||||
"inject-websession",
|
Rule("entity-disabled-by-default", ScaledQualityScaleTiers.GOLD),
|
||||||
"strict-typing",
|
Rule("entity-translations", ScaledQualityScaleTiers.GOLD),
|
||||||
],
|
Rule("exception-translations", ScaledQualityScaleTiers.GOLD),
|
||||||
|
Rule("icon-translations", ScaledQualityScaleTiers.GOLD),
|
||||||
|
Rule("reconfiguration-flow", ScaledQualityScaleTiers.GOLD),
|
||||||
|
Rule("repair-issues", ScaledQualityScaleTiers.GOLD),
|
||||||
|
Rule("stale-devices", ScaledQualityScaleTiers.GOLD),
|
||||||
|
# PLATINUM
|
||||||
|
Rule("async-dependency", ScaledQualityScaleTiers.PLATINUM),
|
||||||
|
Rule("inject-websession", ScaledQualityScaleTiers.PLATINUM),
|
||||||
|
Rule("strict-typing", ScaledQualityScaleTiers.PLATINUM),
|
||||||
|
]
|
||||||
|
|
||||||
|
SCALE_RULES = {
|
||||||
|
tier: [rule.name for rule in ALL_RULES if rule.tier == tier]
|
||||||
|
for tier in ScaledQualityScaleTiers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VALIDATORS = {rule.name: rule.validator for rule in ALL_RULES if rule.validator}
|
||||||
|
|
||||||
INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
|
INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
|
||||||
"abode",
|
"abode",
|
||||||
"accuweather",
|
"accuweather",
|
||||||
@ -1244,7 +1262,7 @@ SCHEMA = vol.Schema(
|
|||||||
{
|
{
|
||||||
vol.Required("rules"): vol.Schema(
|
vol.Required("rules"): vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(rule): vol.Any(
|
vol.Optional(rule.name): vol.Any(
|
||||||
vol.In(["todo", "done"]),
|
vol.In(["todo", "done"]),
|
||||||
vol.Schema(
|
vol.Schema(
|
||||||
{
|
{
|
||||||
@ -1259,8 +1277,7 @@ SCHEMA = vol.Schema(
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for tier_list in RULES.values()
|
for rule in ALL_RULES
|
||||||
for rule in tier_list
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1327,21 +1344,29 @@ def validate_iqs_file(config: Config, integration: Integration) -> None:
|
|||||||
"quality_scale", f"Invalid {name}: {humanize_error(data, err)}"
|
"quality_scale", f"Invalid {name}: {humanize_error(data, err)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if declared_quality_scale is None:
|
rules_met = set[str]()
|
||||||
return
|
|
||||||
|
|
||||||
rules_met = set()
|
|
||||||
for rule_name, rule_value in data.get("rules", {}).items():
|
for rule_name, rule_value in data.get("rules", {}).items():
|
||||||
status = rule_value["status"] if isinstance(rule_value, dict) else rule_value
|
status = rule_value["status"] if isinstance(rule_value, dict) else rule_value
|
||||||
if status in {"done", "exempt"}:
|
if status not in {"done", "exempt"}:
|
||||||
rules_met.add(rule_name)
|
continue
|
||||||
|
rules_met.add(rule_name)
|
||||||
|
if (
|
||||||
|
status == "done"
|
||||||
|
and (validator := VALIDATORS.get(rule_name))
|
||||||
|
and (errors := validator.validate(integration))
|
||||||
|
):
|
||||||
|
for error in errors:
|
||||||
|
integration.add_error("quality_scale", f"[{rule_name}] {error}")
|
||||||
|
|
||||||
# An integration must have all the necessary rules for the declared
|
# An integration must have all the necessary rules for the declared
|
||||||
# quality scale, and all the rules below.
|
# quality scale, and all the rules below.
|
||||||
|
if declared_quality_scale is None:
|
||||||
|
return
|
||||||
|
|
||||||
for scale in ScaledQualityScaleTiers:
|
for scale in ScaledQualityScaleTiers:
|
||||||
if scale > declared_quality_scale:
|
if scale > declared_quality_scale:
|
||||||
break
|
break
|
||||||
required_rules = set(RULES[scale])
|
required_rules = set(SCALE_RULES[scale])
|
||||||
if missing_rules := (required_rules - rules_met):
|
if missing_rules := (required_rules - rules_met):
|
||||||
friendly_rule_str = "\n".join(
|
friendly_rule_str = "\n".join(
|
||||||
f" {rule}: todo" for rule in sorted(missing_rules)
|
f" {rule}: todo" for rule in sorted(missing_rules)
|
||||||
|
15
script/hassfest/quality_scale_validation/__init__.py
Normal file
15
script/hassfest/quality_scale_validation/__init__.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
"""Integration quality scale rules."""
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from script.hassfest.model import Integration
|
||||||
|
|
||||||
|
|
||||||
|
class RuleValidationProtocol(Protocol):
|
||||||
|
"""Protocol for rule validation."""
|
||||||
|
|
||||||
|
def validate(self, integration: Integration) -> list[str] | None:
|
||||||
|
"""Validate a quality scale rule.
|
||||||
|
|
||||||
|
Returns error (if any).
|
||||||
|
"""
|
@ -0,0 +1,26 @@
|
|||||||
|
"""Enforce that the integration implements entry unloading."""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
|
||||||
|
from script.hassfest.model import Integration
|
||||||
|
|
||||||
|
|
||||||
|
def _has_async_function(module: ast.Module, name: str) -> bool:
|
||||||
|
"""Test if the module defines a function."""
|
||||||
|
return any(
|
||||||
|
type(item) is ast.AsyncFunctionDef and item.name == name for item in module.body
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate(integration: Integration) -> list[str] | None:
|
||||||
|
"""Validate that the integration has a config flow."""
|
||||||
|
|
||||||
|
init_file = integration.path / "__init__.py"
|
||||||
|
init = ast.parse(init_file.read_text())
|
||||||
|
|
||||||
|
if not _has_async_function(init, "async_unload_entry"):
|
||||||
|
return [
|
||||||
|
"Integration does not support config entry unloading "
|
||||||
|
"(is missing `async_unload_entry` in __init__.py)"
|
||||||
|
]
|
||||||
|
return None
|
Loading…
x
Reference in New Issue
Block a user