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:
epenet 2024-11-27 18:17:53 +01:00 committed by GitHub
parent a6cb6fd239
commit e04b6f0cd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 137 additions and 71 deletions

View File

@ -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)

View 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).
"""

View File

@ -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