Raise repair issues when scripts can't be set up (#122087)
This commit is contained in:
parent
e2276458ed
commit
0927dd9090
4 changed files with 189 additions and 23 deletions
|
@ -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."""
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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%]",
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Add table
Reference in a new issue