Raise repair issues when scripts can't be set up (#122087)

This commit is contained in:
Erik Montnemery 2024-07-18 08:34:41 +02:00 committed by GitHub
parent e2276458ed
commit 0927dd9090
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 189 additions and 23 deletions

View file

@ -43,6 +43,11 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.config_validation import make_entity_service_schema
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.script import (
ATTR_CUR,
@ -60,7 +65,7 @@ from homeassistant.loader import bind_hass
from homeassistant.util.async_ import create_eager_task
from homeassistant.util.dt import parse_datetime
from .config import ScriptConfig
from .config import ScriptConfig, ValidationStatus
from .const import (
ATTR_LAST_ACTION,
ATTR_LAST_TRIGGERED,
@ -288,7 +293,8 @@ class ScriptEntityConfig:
key: str
raw_blueprint_inputs: ConfigType | None
raw_config: ConfigType | None
validation_failed: bool
validation_error: str | None
validation_status: ValidationStatus
async def _prepare_script_config(
@ -303,11 +309,17 @@ async def _prepare_script_config(
for key, config_block in conf.items():
raw_config = cast(ScriptConfig, config_block).raw_config
raw_blueprint_inputs = cast(ScriptConfig, config_block).raw_blueprint_inputs
validation_failed = cast(ScriptConfig, config_block).validation_failed
validation_error = cast(ScriptConfig, config_block).validation_error
validation_status = cast(ScriptConfig, config_block).validation_status
script_configs.append(
ScriptEntityConfig(
config_block, key, raw_blueprint_inputs, raw_config, validation_failed
config_block,
key,
raw_blueprint_inputs,
raw_config,
validation_error,
validation_status,
)
)
@ -321,11 +333,13 @@ async def _create_script_entities(
entities: list[BaseScriptEntity] = []
for script_config in script_configs:
if script_config.validation_failed:
if script_config.validation_status != ValidationStatus.OK:
entities.append(
UnavailableScriptEntity(
script_config.key,
script_config.raw_config,
cast(str, script_config.validation_error),
script_config.validation_status,
)
)
continue
@ -457,11 +471,15 @@ class UnavailableScriptEntity(BaseScriptEntity):
self,
key: str,
raw_config: ConfigType | None,
validation_error: str,
validation_status: ValidationStatus,
) -> None:
"""Initialize a script entity."""
self._attr_name = raw_config.get(CONF_ALIAS, key) if raw_config else key
self._attr_unique_id = key
self.raw_config = raw_config
self._validation_error = validation_error
self._validation_status = validation_status
@cached_property
def referenced_labels(self) -> set[str]:
@ -493,6 +511,31 @@ class UnavailableScriptEntity(BaseScriptEntity):
"""Return a set of referenced entities."""
return set()
async def async_added_to_hass(self) -> None:
"""Create a repair issue to notify the user the automation has errors."""
await super().async_added_to_hass()
async_create_issue(
self.hass,
DOMAIN,
f"{self.entity_id}_validation_{self._validation_status}",
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key=f"validation_{self._validation_status}",
translation_placeholders={
"edit": f"/config/script/edit/{self.unique_id}",
"entity_id": self.entity_id,
"error": self._validation_error,
"name": self._attr_name or self.entity_id,
},
)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
async_delete_issue(
self.hass, DOMAIN, f"{self.entity_id}_validation_{self._validation_status}"
)
class ScriptEntity(BaseScriptEntity, RestoreEntity):
"""Representation of a script entity."""

View file

@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Mapping
from contextlib import suppress
from enum import StrEnum
from typing import Any
import voluptuous as vol
@ -118,6 +119,12 @@ async def _async_validate_config_item(
with suppress(ValueError): # Invalid config
raw_config = dict(config)
def _humanize(err: Exception, data: Any) -> str:
"""Humanize vol.Invalid, stringify other exceptions."""
if isinstance(err, vol.Invalid):
return humanize_error(data, err)
return str(err)
def _log_invalid_script(
err: Exception,
script_name: str,
@ -133,7 +140,7 @@ async def _async_validate_config_item(
"Blueprint '%s' generated invalid script with inputs %s: %s",
blueprint_inputs.blueprint.name,
blueprint_inputs.inputs,
humanize_error(data, err) if isinstance(err, vol.Invalid) else err,
_humanize(err, data),
)
return
@ -141,17 +148,35 @@ async def _async_validate_config_item(
"%s %s and has been disabled: %s",
script_name,
problem,
humanize_error(data, err) if isinstance(err, vol.Invalid) else err,
_humanize(err, data),
)
return
def _minimal_config() -> ScriptConfig:
def _set_validation_status(
script_config: ScriptConfig,
validation_status: ValidationStatus,
validation_error: Exception,
config: ConfigType,
) -> None:
"""Set validation status."""
if uses_blueprint:
validation_status = ValidationStatus.FAILED_BLUEPRINT
script_config.validation_status = validation_status
script_config.validation_error = _humanize(validation_error, config)
def _minimal_config(
validation_status: ValidationStatus,
validation_error: Exception,
config: ConfigType,
) -> ScriptConfig:
"""Try validating id, alias and description."""
minimal_config = _MINIMAL_SCRIPT_ENTITY_SCHEMA(config)
script_config = ScriptConfig(minimal_config)
script_config.raw_blueprint_inputs = raw_blueprint_inputs
script_config.raw_config = raw_config
script_config.validation_failed = True
_set_validation_status(
script_config, validation_status, validation_error, config
)
return script_config
if is_blueprint_instance_config(config):
@ -167,7 +192,7 @@ async def _async_validate_config_item(
)
if raise_on_errors:
raise
return _minimal_config()
return _minimal_config(ValidationStatus.FAILED_BLUEPRINT, err, config)
raw_blueprint_inputs = blueprint_inputs.config_with_inputs
@ -184,7 +209,7 @@ async def _async_validate_config_item(
)
if raise_on_errors:
raise HomeAssistantError(err) from err
return _minimal_config()
return _minimal_config(ValidationStatus.FAILED_BLUEPRINT, err, config)
script_name = f"Script with object id '{object_id}'"
if isinstance(config, Mapping):
@ -202,7 +227,7 @@ async def _async_validate_config_item(
_log_invalid_script(err, script_name, "could not be validated", config)
if raise_on_errors:
raise
return _minimal_config()
return _minimal_config(ValidationStatus.FAILED_SCHEMA, err, config)
script_config = ScriptConfig(validated_config)
script_config.raw_blueprint_inputs = raw_blueprint_inputs
@ -217,22 +242,34 @@ async def _async_validate_config_item(
HomeAssistantError,
) as err:
_log_invalid_script(
err, script_name, "failed to setup actions", validated_config
err, script_name, "failed to setup sequence", validated_config
)
if raise_on_errors:
raise
script_config.validation_failed = True
_set_validation_status(
script_config, ValidationStatus.FAILED_SEQUENCE, err, validated_config
)
return script_config
return script_config
class ValidationStatus(StrEnum):
"""What was changed in a config entry."""
FAILED_BLUEPRINT = "failed_blueprint"
FAILED_SCHEMA = "failed_schema"
FAILED_SEQUENCE = "failed_sequence"
OK = "ok"
class ScriptConfig(dict):
"""Dummy class to allow adding attributes."""
raw_config: ConfigType | None = None
raw_blueprint_inputs: ConfigType | None = None
validation_failed: bool = False
validation_status: ValidationStatus = ValidationStatus.OK
validation_error: str | None = None
async def _try_async_validate_config_item(

View file

@ -1,4 +1,7 @@
{
"common": {
"validation_failed_title": "Script {name} failed to set up"
},
"title": "Script",
"entity_component": {
"_": {
@ -32,6 +35,20 @@
}
}
},
"issues": {
"validation_failed_blueprint": {
"title": "[%key:component::script::common::validation_failed_title%]",
"description": "The blueprinted script \"{name}\" (`{entity_id}`) failed to set up.\n\nError:`{error}`.\n\nTo fix this error, [edit the script]({edit}) to correct it, then save and reload the script configuration."
},
"validation_failed_schema": {
"title": "[%key:component::script::common::validation_failed_title%]",
"description": "The script \"{name}\" (`{entity_id}`) is not active because the configuration has errors.\n\nError:`{error}`.\n\nTo fix this error, [edit the script]({edit}) to correct it, then save and reload the script configuration."
},
"validation_failed_sequence": {
"title": "[%key:component::script::common::validation_failed_title%]",
"description": "The script \"{name}\" (`{entity_id}`) is not active because its sequence could not be set up.\n\nError:`{error}`.\n\nTo fix this error, [edit the script]({edit}) to correct it, then save and reload the script configuration."
}
},
"services": {
"reload": {
"name": "[%key:common::action::reload%]",

View file

@ -3,7 +3,7 @@
import asyncio
from datetime import timedelta
from typing import Any
from unittest.mock import Mock, patch
from unittest.mock import ANY, Mock, patch
import pytest
@ -47,11 +47,13 @@ import homeassistant.util.dt as dt_util
from tests.common import (
MockConfigEntry,
MockUser,
async_fire_time_changed,
async_mock_service,
mock_restore_cache,
)
from tests.components.logbook.common import MockRow, mock_humanify
from tests.components.repairs import get_repairs
from tests.typing import WebSocketGenerator
ENTITY_ID = "script.test"
@ -252,13 +254,14 @@ async def test_bad_config_validation_critical(
@pytest.mark.parametrize(
("object_id", "broken_config", "problem", "details"),
("object_id", "broken_config", "problem", "details", "issue"),
[
(
"bad_script",
{},
"could not be validated",
"required key not provided @ data['sequence']",
"validation_failed_schema",
),
(
"bad_script",
@ -270,18 +273,22 @@ async def test_bad_config_validation_critical(
"state": "blah",
},
},
"failed to setup actions",
"failed to setup sequence",
"Unknown entity registry entry abcdabcdabcdabcdabcdabcdabcdabcd.",
"validation_failed_sequence",
),
],
)
async def test_bad_config_validation(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture,
hass_admin_user: MockUser,
object_id,
broken_config,
problem,
details,
issue,
) -> None:
"""Test bad script configuration which can be detected during validation."""
assert await async_setup_component(
@ -301,11 +308,22 @@ async def test_bad_config_validation(
},
)
# Check we get the expected error message
# Check we get the expected error message and issue
assert (
f"Script with alias 'bad_script' {problem} and has been disabled: {details}"
in caplog.text
)
issues = await get_repairs(hass, hass_ws_client)
assert len(issues) == 1
assert issues[0]["issue_id"] == f"script.bad_script_{issue}"
assert issues[0]["translation_key"] == issue
assert issues[0]["translation_placeholders"] == {
"edit": "/config/script/edit/bad_script",
"entity_id": "script.bad_script",
"error": ANY,
"name": "bad_script",
}
assert issues[0]["translation_placeholders"]["error"].startswith(details)
# Make sure both scripts are setup
assert set(hass.states.async_entity_ids("script")) == {
@ -315,6 +333,31 @@ async def test_bad_config_validation(
# The script failing validation should be unavailable
assert hass.states.get("script.bad_script").state == STATE_UNAVAILABLE
# Reloading the automation with fixed config should clear the issue
with patch(
"homeassistant.config.load_yaml_config_file",
autospec=True,
return_value={
script.DOMAIN: {
object_id: {
"alias": "bad_script",
"sequence": {
"service": "test.automation",
"entity_id": "hello.world",
},
},
}
},
):
await hass.services.async_call(
script.DOMAIN,
SERVICE_RELOAD,
context=Context(user_id=hass_admin_user.id),
blocking=True,
)
issues = await get_repairs(hass, hass_ws_client)
assert len(issues) == 0
@pytest.mark.parametrize("running", ["no", "same", "different"])
async def test_reload_service(hass: HomeAssistant, running) -> None:
@ -1563,9 +1606,7 @@ async def test_script_service_changed_entity_id(
assert calls[1].data["entity_id"] == "script.custom_entity_id_2"
async def test_blueprint_automation(
hass: HomeAssistant, calls: list[ServiceCall]
) -> None:
async def test_blueprint_script(hass: HomeAssistant, calls: list[ServiceCall]) -> None:
"""Test blueprint script."""
assert await async_setup_component(
hass,
@ -1623,6 +1664,7 @@ async def test_blueprint_automation(
)
async def test_blueprint_script_bad_config(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture,
blueprint_inputs,
problem,
@ -1646,9 +1688,24 @@ async def test_blueprint_script_bad_config(
assert problem in caplog.text
assert details in caplog.text
issues = await get_repairs(hass, hass_ws_client)
assert len(issues) == 1
issue = "validation_failed_blueprint"
assert issues[0]["issue_id"] == f"script.test_script_{issue}"
assert issues[0]["translation_key"] == issue
assert issues[0]["translation_placeholders"] == {
"edit": "/config/script/edit/test_script",
"entity_id": "script.test_script",
"error": ANY,
"name": "test_script",
}
assert issues[0]["translation_placeholders"]["error"].startswith(details)
async def test_blueprint_script_fails_substitution(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test blueprint script with bad inputs."""
with patch(
@ -1677,6 +1734,18 @@ async def test_blueprint_script_fails_substitution(
in caplog.text
)
issues = await get_repairs(hass, hass_ws_client)
assert len(issues) == 1
issue = "validation_failed_blueprint"
assert issues[0]["issue_id"] == f"script.test_script_{issue}"
assert issues[0]["translation_key"] == issue
assert issues[0]["translation_placeholders"] == {
"edit": "/config/script/edit/test_script",
"entity_id": "script.test_script",
"error": "No substitution found for input blah",
"name": "test_script",
}
@pytest.mark.parametrize("response", [{"value": 5}, '{"value": 5}'])
async def test_responses(hass: HomeAssistant, response: Any) -> None: