From f17773233b3f9a2d19e54a2cf061dd5399bf1150 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 12 Jun 2023 10:26:02 +0200 Subject: [PATCH] Add check for integration config schema to hassfest (#93587) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- script/hassfest/__main__.py | 4 +- script/hassfest/config_schema.py | 108 +++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 script/hassfest/config_schema.py diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 87024619765..1c626ac3c5b 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -11,6 +11,7 @@ from . import ( bluetooth, codeowners, config_flow, + config_schema, coverage, dependencies, dhcp, @@ -32,6 +33,7 @@ INTEGRATION_PLUGINS = [ application_credentials, bluetooth, codeowners, + config_schema, dependencies, dhcp, json, @@ -43,7 +45,7 @@ INTEGRATION_PLUGINS = [ translations, usb, zeroconf, - config_flow, + config_flow, # This needs to run last, after translations are processed ] HASS_PLUGINS = [ coverage, diff --git a/script/hassfest/config_schema.py b/script/hassfest/config_schema.py new file mode 100644 index 00000000000..b794834161d --- /dev/null +++ b/script/hassfest/config_schema.py @@ -0,0 +1,108 @@ +"""Validate integrations which can be setup from YAML have config schemas.""" +from __future__ import annotations + +import ast + +from .model import Config, Integration + +CONFIG_SCHEMA_IGNORE = { + # Configuration under the homeassistant key is a special case, it's handled by + # conf_util.async_process_ha_core_config already during bootstrapping, not by + # a schema in the homeassistant integration. + "homeassistant", +} + + +def _has_assignment(module: ast.Module, name: str) -> bool: + """Test if the module assigns to a name.""" + for item in module.body: + if type(item) not in (ast.Assign, ast.AnnAssign, ast.AugAssign): + continue + if type(item) == ast.Assign: + for target in item.targets: + if target.id == name: + return True + continue + if item.target.id == name: + return True + return False + + +def _has_function( + module: ast.Module, _type: ast.AsyncFunctionDef | ast.FunctionDef, name: str +) -> bool: + """Test if the module defines a function.""" + for item in module.body: + if type(item) == _type and item.name == name: + return True + return False + + +def _has_import(module: ast.Module, name: str) -> bool: + """Test if the module imports to a name.""" + for item in module.body: + if type(item) not in (ast.Import, ast.ImportFrom): + continue + for alias in item.names: + if alias.asname == name or (alias.asname is None and alias.name == name): + return True + return False + + +def _validate_integration(config: Config, integration: Integration) -> None: + """Validate integration has has a configuration schema.""" + if integration.domain in CONFIG_SCHEMA_IGNORE: + return + + init_file = integration.path / "__init__.py" + + if not init_file.is_file(): + # Virtual integrations don't have any implementation + return + + init = ast.parse(init_file.read_text()) + + # No YAML Support + if not _has_function( + init, ast.AsyncFunctionDef, "async_setup" + ) and not _has_function(init, ast.FunctionDef, "setup"): + return + + # No schema + if ( + _has_assignment(init, "CONFIG_SCHEMA") + or _has_assignment(init, "PLATFORM_SCHEMA") + or _has_assignment(init, "PLATFORM_SCHEMA_BASE") + or _has_import(init, "CONFIG_SCHEMA") + or _has_import(init, "PLATFORM_SCHEMA") + or _has_import(init, "PLATFORM_SCHEMA_BASE") + ): + return + + config_file = integration.path / "config.py" + if config_file.is_file(): + config_module = ast.parse(config_file.read_text()) + if _has_function(config_module, ast.AsyncFunctionDef, "async_validate_config"): + return + + if config.specific_integrations: + notice_method = integration.add_warning + else: + notice_method = integration.add_error + + notice_method( + "config_schema", + "Integrations which implement 'async_setup' or 'setup' must define either " + "'CONFIG_SCHEMA', 'PLATFORM_SCHEMA' or 'PLATFORM_SCHEMA_BASE'. If the " + "integration has no configuration parameters, can only be set up from platforms" + " or can only be set up from config entries, one of the helpers " + "cv.empty_config_schema, cv.platform_only_config_schema or " + "cv.config_entry_only_config_schema can be used.", + ) + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Validate integrations have configuration schemas.""" + for domain in sorted(integrations): + integration = integrations[domain] + _validate_integration(config, integration)