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.config_validation import make_entity_service_schema
from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent 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.restore_state import RestoreEntity
from homeassistant.helpers.script import ( from homeassistant.helpers.script import (
ATTR_CUR, ATTR_CUR,
@ -60,7 +65,7 @@ from homeassistant.loader import bind_hass
from homeassistant.util.async_ import create_eager_task from homeassistant.util.async_ import create_eager_task
from homeassistant.util.dt import parse_datetime from homeassistant.util.dt import parse_datetime
from .config import ScriptConfig from .config import ScriptConfig, ValidationStatus
from .const import ( from .const import (
ATTR_LAST_ACTION, ATTR_LAST_ACTION,
ATTR_LAST_TRIGGERED, ATTR_LAST_TRIGGERED,
@ -288,7 +293,8 @@ class ScriptEntityConfig:
key: str key: str
raw_blueprint_inputs: ConfigType | None raw_blueprint_inputs: ConfigType | None
raw_config: ConfigType | None raw_config: ConfigType | None
validation_failed: bool validation_error: str | None
validation_status: ValidationStatus
async def _prepare_script_config( async def _prepare_script_config(
@ -303,11 +309,17 @@ async def _prepare_script_config(
for key, config_block in conf.items(): for key, config_block in conf.items():
raw_config = cast(ScriptConfig, config_block).raw_config raw_config = cast(ScriptConfig, config_block).raw_config
raw_blueprint_inputs = cast(ScriptConfig, config_block).raw_blueprint_inputs 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( script_configs.append(
ScriptEntityConfig( 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] = [] entities: list[BaseScriptEntity] = []
for script_config in script_configs: for script_config in script_configs:
if script_config.validation_failed: if script_config.validation_status != ValidationStatus.OK:
entities.append( entities.append(
UnavailableScriptEntity( UnavailableScriptEntity(
script_config.key, script_config.key,
script_config.raw_config, script_config.raw_config,
cast(str, script_config.validation_error),
script_config.validation_status,
) )
) )
continue continue
@ -457,11 +471,15 @@ class UnavailableScriptEntity(BaseScriptEntity):
self, self,
key: str, key: str,
raw_config: ConfigType | None, raw_config: ConfigType | None,
validation_error: str,
validation_status: ValidationStatus,
) -> None: ) -> None:
"""Initialize a script entity.""" """Initialize a script entity."""
self._attr_name = raw_config.get(CONF_ALIAS, key) if raw_config else key self._attr_name = raw_config.get(CONF_ALIAS, key) if raw_config else key
self._attr_unique_id = key self._attr_unique_id = key
self.raw_config = raw_config self.raw_config = raw_config
self._validation_error = validation_error
self._validation_status = validation_status
@cached_property @cached_property
def referenced_labels(self) -> set[str]: def referenced_labels(self) -> set[str]:
@ -493,6 +511,31 @@ class UnavailableScriptEntity(BaseScriptEntity):
"""Return a set of referenced entities.""" """Return a set of referenced entities."""
return set() 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): class ScriptEntity(BaseScriptEntity, RestoreEntity):
"""Representation of a script entity.""" """Representation of a script entity."""

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from contextlib import suppress from contextlib import suppress
from enum import StrEnum
from typing import Any from typing import Any
import voluptuous as vol import voluptuous as vol
@ -118,6 +119,12 @@ async def _async_validate_config_item(
with suppress(ValueError): # Invalid config with suppress(ValueError): # Invalid config
raw_config = dict(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( def _log_invalid_script(
err: Exception, err: Exception,
script_name: str, script_name: str,
@ -133,7 +140,7 @@ async def _async_validate_config_item(
"Blueprint '%s' generated invalid script with inputs %s: %s", "Blueprint '%s' generated invalid script with inputs %s: %s",
blueprint_inputs.blueprint.name, blueprint_inputs.blueprint.name,
blueprint_inputs.inputs, blueprint_inputs.inputs,
humanize_error(data, err) if isinstance(err, vol.Invalid) else err, _humanize(err, data),
) )
return return
@ -141,17 +148,35 @@ async def _async_validate_config_item(
"%s %s and has been disabled: %s", "%s %s and has been disabled: %s",
script_name, script_name,
problem, problem,
humanize_error(data, err) if isinstance(err, vol.Invalid) else err, _humanize(err, data),
) )
return 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.""" """Try validating id, alias and description."""
minimal_config = _MINIMAL_SCRIPT_ENTITY_SCHEMA(config) minimal_config = _MINIMAL_SCRIPT_ENTITY_SCHEMA(config)
script_config = ScriptConfig(minimal_config) script_config = ScriptConfig(minimal_config)
script_config.raw_blueprint_inputs = raw_blueprint_inputs script_config.raw_blueprint_inputs = raw_blueprint_inputs
script_config.raw_config = raw_config script_config.raw_config = raw_config
script_config.validation_failed = True _set_validation_status(
script_config, validation_status, validation_error, config
)
return script_config return script_config
if is_blueprint_instance_config(config): if is_blueprint_instance_config(config):
@ -167,7 +192,7 @@ async def _async_validate_config_item(
) )
if raise_on_errors: if raise_on_errors:
raise raise
return _minimal_config() return _minimal_config(ValidationStatus.FAILED_BLUEPRINT, err, config)
raw_blueprint_inputs = blueprint_inputs.config_with_inputs raw_blueprint_inputs = blueprint_inputs.config_with_inputs
@ -184,7 +209,7 @@ async def _async_validate_config_item(
) )
if raise_on_errors: if raise_on_errors:
raise HomeAssistantError(err) from err 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}'" script_name = f"Script with object id '{object_id}'"
if isinstance(config, Mapping): 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) _log_invalid_script(err, script_name, "could not be validated", config)
if raise_on_errors: if raise_on_errors:
raise raise
return _minimal_config() return _minimal_config(ValidationStatus.FAILED_SCHEMA, err, config)
script_config = ScriptConfig(validated_config) script_config = ScriptConfig(validated_config)
script_config.raw_blueprint_inputs = raw_blueprint_inputs script_config.raw_blueprint_inputs = raw_blueprint_inputs
@ -217,22 +242,34 @@ async def _async_validate_config_item(
HomeAssistantError, HomeAssistantError,
) as err: ) as err:
_log_invalid_script( _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: if raise_on_errors:
raise raise
script_config.validation_failed = True _set_validation_status(
script_config, ValidationStatus.FAILED_SEQUENCE, err, validated_config
)
return script_config return script_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): class ScriptConfig(dict):
"""Dummy class to allow adding attributes.""" """Dummy class to allow adding attributes."""
raw_config: ConfigType | None = None raw_config: ConfigType | None = None
raw_blueprint_inputs: 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( 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", "title": "Script",
"entity_component": { "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": { "services": {
"reload": { "reload": {
"name": "[%key:common::action::reload%]", "name": "[%key:common::action::reload%]",

View File

@ -3,7 +3,7 @@
import asyncio import asyncio
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import Any
from unittest.mock import Mock, patch from unittest.mock import ANY, Mock, patch
import pytest import pytest
@ -47,11 +47,13 @@ import homeassistant.util.dt as dt_util
from tests.common import ( from tests.common import (
MockConfigEntry, MockConfigEntry,
MockUser,
async_fire_time_changed, async_fire_time_changed,
async_mock_service, async_mock_service,
mock_restore_cache, mock_restore_cache,
) )
from tests.components.logbook.common import MockRow, mock_humanify from tests.components.logbook.common import MockRow, mock_humanify
from tests.components.repairs import get_repairs
from tests.typing import WebSocketGenerator from tests.typing import WebSocketGenerator
ENTITY_ID = "script.test" ENTITY_ID = "script.test"
@ -252,13 +254,14 @@ async def test_bad_config_validation_critical(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("object_id", "broken_config", "problem", "details"), ("object_id", "broken_config", "problem", "details", "issue"),
[ [
( (
"bad_script", "bad_script",
{}, {},
"could not be validated", "could not be validated",
"required key not provided @ data['sequence']", "required key not provided @ data['sequence']",
"validation_failed_schema",
), ),
( (
"bad_script", "bad_script",
@ -270,18 +273,22 @@ async def test_bad_config_validation_critical(
"state": "blah", "state": "blah",
}, },
}, },
"failed to setup actions", "failed to setup sequence",
"Unknown entity registry entry abcdabcdabcdabcdabcdabcdabcdabcd.", "Unknown entity registry entry abcdabcdabcdabcdabcdabcdabcdabcd.",
"validation_failed_sequence",
), ),
], ],
) )
async def test_bad_config_validation( async def test_bad_config_validation(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
hass_admin_user: MockUser,
object_id, object_id,
broken_config, broken_config,
problem, problem,
details, details,
issue,
) -> None: ) -> None:
"""Test bad script configuration which can be detected during validation.""" """Test bad script configuration which can be detected during validation."""
assert await async_setup_component( 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 ( assert (
f"Script with alias 'bad_script' {problem} and has been disabled: {details}" f"Script with alias 'bad_script' {problem} and has been disabled: {details}"
in caplog.text 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 # Make sure both scripts are setup
assert set(hass.states.async_entity_ids("script")) == { 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 # The script failing validation should be unavailable
assert hass.states.get("script.bad_script").state == STATE_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"]) @pytest.mark.parametrize("running", ["no", "same", "different"])
async def test_reload_service(hass: HomeAssistant, running) -> None: 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" assert calls[1].data["entity_id"] == "script.custom_entity_id_2"
async def test_blueprint_automation( async def test_blueprint_script(hass: HomeAssistant, calls: list[ServiceCall]) -> None:
hass: HomeAssistant, calls: list[ServiceCall]
) -> None:
"""Test blueprint script.""" """Test blueprint script."""
assert await async_setup_component( assert await async_setup_component(
hass, hass,
@ -1623,6 +1664,7 @@ async def test_blueprint_automation(
) )
async def test_blueprint_script_bad_config( async def test_blueprint_script_bad_config(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
blueprint_inputs, blueprint_inputs,
problem, problem,
@ -1646,9 +1688,24 @@ async def test_blueprint_script_bad_config(
assert problem in caplog.text assert problem in caplog.text
assert details 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( async def test_blueprint_script_fails_substitution(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
"""Test blueprint script with bad inputs.""" """Test blueprint script with bad inputs."""
with patch( with patch(
@ -1677,6 +1734,18 @@ async def test_blueprint_script_fails_substitution(
in caplog.text 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}']) @pytest.mark.parametrize("response", [{"value": 5}, '{"value": 5}'])
async def test_responses(hass: HomeAssistant, response: Any) -> None: async def test_responses(hass: HomeAssistant, response: Any) -> None: