From 0fb587727c9786c762bdeff3e798c59b687acdb2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 2 Nov 2020 15:00:13 +0100 Subject: [PATCH] Add initial blueprint support (#42469) Co-authored-by: Martin Hjelmare Co-authored-by: Franck Nijhof --- CODEOWNERS | 1 + .../components/automation/__init__.py | 50 +- homeassistant/components/automation/config.py | 14 +- .../components/automation/manifest.json | 1 + .../components/blueprint/__init__.py | 19 + homeassistant/components/blueprint/const.py | 9 + homeassistant/components/blueprint/errors.py | 80 ++ .../components/blueprint/importer.py | 177 ++++ .../components/blueprint/manifest.json | 9 + homeassistant/components/blueprint/models.py | 231 ++++++ homeassistant/components/blueprint/schemas.py | 57 ++ .../components/blueprint/websocket_api.py | 86 ++ homeassistant/helpers/config_validation.py | 13 +- homeassistant/helpers/placeholder.py | 53 ++ homeassistant/util/yaml/__init__.py | 5 +- homeassistant/util/yaml/dumper.py | 7 +- homeassistant/util/yaml/loader.py | 23 +- homeassistant/util/yaml/objects.py | 15 + tests/components/automation/test_init.py | 22 + tests/components/blueprint/__init__.py | 1 + tests/components/blueprint/test_importer.py | 137 +++ tests/components/blueprint/test_init.py | 1 + tests/components/blueprint/test_models.py | 154 ++++ tests/components/blueprint/test_schemas.py | 71 ++ .../blueprint/test_websocket_api.py | 82 ++ tests/fixtures/blueprint/community_post.json | 783 ++++++++++++++++++ tests/helpers/test_placeholder.py | 29 + .../in_folder/in_folder_blueprint.yaml | 8 + .../automation/test_event_service.yaml | 11 + tests/util/test_yaml.py | 17 + 30 files changed, 2144 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/blueprint/__init__.py create mode 100644 homeassistant/components/blueprint/const.py create mode 100644 homeassistant/components/blueprint/errors.py create mode 100644 homeassistant/components/blueprint/importer.py create mode 100644 homeassistant/components/blueprint/manifest.json create mode 100644 homeassistant/components/blueprint/models.py create mode 100644 homeassistant/components/blueprint/schemas.py create mode 100644 homeassistant/components/blueprint/websocket_api.py create mode 100644 homeassistant/helpers/placeholder.py create mode 100644 tests/components/blueprint/__init__.py create mode 100644 tests/components/blueprint/test_importer.py create mode 100644 tests/components/blueprint/test_init.py create mode 100644 tests/components/blueprint/test_models.py create mode 100644 tests/components/blueprint/test_schemas.py create mode 100644 tests/components/blueprint/test_websocket_api.py create mode 100644 tests/fixtures/blueprint/community_post.json create mode 100644 tests/helpers/test_placeholder.py create mode 100644 tests/testing_config/blueprints/automation/in_folder/in_folder_blueprint.yaml create mode 100644 tests/testing_config/blueprints/automation/test_event_service.yaml diff --git a/CODEOWNERS b/CODEOWNERS index af6929b2870..f8640070f9e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -64,6 +64,7 @@ homeassistant/components/bitcoin/* @fabaff homeassistant/components/bizkaibus/* @UgaitzEtxebarria homeassistant/components/blebox/* @gadgetmobile homeassistant/components/blink/* @fronzbot +homeassistant/components/blueprint/* @home-assistant/core homeassistant/components/bmp280/* @belidzs homeassistant/components/bmw_connected_drive/* @gerard33 @rikroe homeassistant/components/bond/* @prystupa diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index dff751956a7..0989ed43495 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -1,9 +1,11 @@ """Allow to set up simple automation rules via the config file.""" import logging -from typing import Any, Awaitable, Callable, List, Optional, Set, cast +from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Union, cast import voluptuous as vol +from voluptuous.humanize import humanize_error +from homeassistant.components import blueprint from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_NAME, @@ -47,6 +49,7 @@ from homeassistant.helpers.script import ( ) from homeassistant.helpers.script_variables import ScriptVariables from homeassistant.helpers.service import async_register_admin_service +from homeassistant.helpers.singleton import singleton from homeassistant.helpers.trigger import async_initialize_triggers from homeassistant.helpers.typing import TemplateVarsType from homeassistant.loader import bind_hass @@ -58,7 +61,7 @@ from homeassistant.util.dt import parse_datetime DOMAIN = "automation" ENTITY_ID_FORMAT = DOMAIN + ".{}" -GROUP_NAME_ALL_AUTOMATIONS = "all automations" +DATA_BLUEPRINTS = "automation_blueprints" CONF_DESCRIPTION = "description" CONF_HIDE_ENTITY = "hide_entity" @@ -70,13 +73,9 @@ CONF_CONDITION_TYPE = "condition_type" CONF_INITIAL_STATE = "initial_state" CONF_SKIP_CONDITION = "skip_condition" CONF_STOP_ACTIONS = "stop_actions" +CONF_BLUEPRINT = "blueprint" +CONF_INPUT = "input" -CONDITION_USE_TRIGGER_VALUES = "use_trigger_values" -CONDITION_TYPE_AND = "and" -CONDITION_TYPE_NOT = "not" -CONDITION_TYPE_OR = "or" - -DEFAULT_CONDITION_TYPE = CONDITION_TYPE_AND DEFAULT_INITIAL_STATE = True DEFAULT_STOP_ACTIONS = True @@ -114,6 +113,13 @@ PLATFORM_SCHEMA = vol.All( ) +@singleton(DATA_BLUEPRINTS) +@callback +def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints: # type: ignore + """Get automation blueprints.""" + return blueprint.DomainBlueprints(hass, DOMAIN, _LOGGER) # type: ignore + + @bind_hass def is_on(hass, entity_id): """ @@ -221,6 +227,7 @@ async def async_setup(hass, config): conf = await component.async_prepare_reload() if conf is None: return + async_get_blueprints(hass).async_reset_cache() await _async_process_config(hass, conf, component) hass.bus.async_fire(EVENT_AUTOMATION_RELOADED, context=service_call.context) @@ -506,7 +513,11 @@ class AutomationEntity(ToggleEntity, RestoreEntity): return {CONF_ID: self._id} -async def _async_process_config(hass, config, component): +async def _async_process_config( + hass: HomeAssistant, + config: Dict[str, Any], + component: EntityComponent, +) -> None: """Process config and add automations. This method is a coroutine. @@ -514,9 +525,28 @@ async def _async_process_config(hass, config, component): entities = [] for config_key in extract_domain_configs(config, DOMAIN): - conf = config[config_key] + conf: List[Union[Dict[str, Any], blueprint.BlueprintInputs]] = config[ # type: ignore + config_key + ] for list_no, config_block in enumerate(conf): + if isinstance(config_block, blueprint.BlueprintInputs): # type: ignore + blueprint_inputs = config_block + + try: + config_block = cast( + Dict[str, Any], + PLATFORM_SCHEMA(blueprint_inputs.async_substitute()), + ) + except vol.Invalid as err: + _LOGGER.error( + "Blueprint %s generated invalid automation with inputs %s: %s", + blueprint_inputs.blueprint.name, + blueprint_inputs.inputs, + humanize_error(config_block, err), + ) + continue + automation_id = config_block.get(CONF_ID) name = config_block.get(CONF_ALIAS) or f"{config_key} {list_no}" diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index 3a296178aeb..c5aa8a62a15 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -3,6 +3,7 @@ import asyncio import voluptuous as vol +from homeassistant.components import blueprint from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, ) @@ -14,7 +15,14 @@ from homeassistant.helpers.script import async_validate_actions_config from homeassistant.helpers.trigger import async_validate_trigger_config from homeassistant.loader import IntegrationNotFound -from . import CONF_ACTION, CONF_CONDITION, CONF_TRIGGER, DOMAIN, PLATFORM_SCHEMA +from . import ( + CONF_ACTION, + CONF_CONDITION, + CONF_TRIGGER, + DOMAIN, + PLATFORM_SCHEMA, + async_get_blueprints, +) # mypy: allow-untyped-calls, allow-untyped-defs # mypy: no-check-untyped-defs, no-warn-return-any @@ -22,6 +30,10 @@ from . import CONF_ACTION, CONF_CONDITION, CONF_TRIGGER, DOMAIN, PLATFORM_SCHEMA async def async_validate_config_item(hass, config, full_config=None): """Validate config item.""" + if blueprint.is_blueprint_instance_config(config): + blueprints = async_get_blueprints(hass) + return await blueprints.async_inputs_from_config(config) + config = PLATFORM_SCHEMA(config) config[CONF_TRIGGER] = await async_validate_trigger_config( diff --git a/homeassistant/components/automation/manifest.json b/homeassistant/components/automation/manifest.json index a8dc43844e0..2db56eb597f 100644 --- a/homeassistant/components/automation/manifest.json +++ b/homeassistant/components/automation/manifest.json @@ -2,6 +2,7 @@ "domain": "automation", "name": "Automation", "documentation": "https://www.home-assistant.io/integrations/automation", + "dependencies": ["blueprint"], "after_dependencies": [ "device_automation", "webhook" diff --git a/homeassistant/components/blueprint/__init__.py b/homeassistant/components/blueprint/__init__.py new file mode 100644 index 00000000000..12a6782065b --- /dev/null +++ b/homeassistant/components/blueprint/__init__.py @@ -0,0 +1,19 @@ +"""The blueprint integration.""" +from . import websocket_api +from .const import DOMAIN # noqa +from .errors import ( # noqa + BlueprintException, + BlueprintWithNameException, + FailedToLoad, + InvalidBlueprint, + InvalidBlueprintInputs, + MissingPlaceholder, +) +from .models import Blueprint, BlueprintInputs, DomainBlueprints # noqa +from .schemas import is_blueprint_instance_config # noqa + + +async def async_setup(hass, config): + """Set up the blueprint integration.""" + websocket_api.async_setup(hass) + return True diff --git a/homeassistant/components/blueprint/const.py b/homeassistant/components/blueprint/const.py new file mode 100644 index 00000000000..fe4ee5b7ce6 --- /dev/null +++ b/homeassistant/components/blueprint/const.py @@ -0,0 +1,9 @@ +"""Constants for the blueprint integration.""" +BLUEPRINT_FOLDER = "blueprints" + +CONF_BLUEPRINT = "blueprint" +CONF_USE_BLUEPRINT = "use_blueprint" +CONF_INPUT = "input" +CONF_SOURCE_URL = "source_url" + +DOMAIN = "blueprint" diff --git a/homeassistant/components/blueprint/errors.py b/homeassistant/components/blueprint/errors.py new file mode 100644 index 00000000000..dff65b5263d --- /dev/null +++ b/homeassistant/components/blueprint/errors.py @@ -0,0 +1,80 @@ +"""Blueprint errors.""" +from typing import Any, Iterable + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant.exceptions import HomeAssistantError + + +class BlueprintException(HomeAssistantError): + """Base exception for blueprint errors.""" + + def __init__(self, domain: str, msg: str) -> None: + """Initialize a blueprint exception.""" + super().__init__(msg) + self.domain = domain + + +class BlueprintWithNameException(BlueprintException): + """Base exception for blueprint errors.""" + + def __init__(self, domain: str, blueprint_name: str, msg: str) -> None: + """Initialize blueprint exception.""" + super().__init__(domain, msg) + self.blueprint_name = blueprint_name + + +class FailedToLoad(BlueprintWithNameException): + """When we failed to load the blueprint.""" + + def __init__(self, domain: str, blueprint_name: str, exc: Exception) -> None: + """Initialize blueprint exception.""" + super().__init__(domain, blueprint_name, f"Failed to load blueprint: {exc}") + + +class InvalidBlueprint(BlueprintWithNameException): + """When we encountered an invalid blueprint.""" + + def __init__( + self, + domain: str, + blueprint_name: str, + blueprint_data: Any, + msg_or_exc: vol.Invalid, + ): + """Initialize an invalid blueprint error.""" + if isinstance(msg_or_exc, vol.Invalid): + msg_or_exc = humanize_error(blueprint_data, msg_or_exc) + + super().__init__( + domain, + blueprint_name, + f"Invalid blueprint: {msg_or_exc}", + ) + self.blueprint_data = blueprint_data + + +class InvalidBlueprintInputs(BlueprintException): + """When we encountered invalid blueprint inputs.""" + + def __init__(self, domain: str, msg: str): + """Initialize an invalid blueprint inputs error.""" + super().__init__( + domain, + f"Invalid blueprint inputs: {msg}", + ) + + +class MissingPlaceholder(BlueprintWithNameException): + """When we miss a placeholder.""" + + def __init__( + self, domain: str, blueprint_name: str, placeholder_names: Iterable[str] + ) -> None: + """Initialize blueprint exception.""" + super().__init__( + domain, + blueprint_name, + f"Missing placeholder {', '.join(sorted(placeholder_names))}", + ) diff --git a/homeassistant/components/blueprint/importer.py b/homeassistant/components/blueprint/importer.py new file mode 100644 index 00000000000..0f229ec8a07 --- /dev/null +++ b/homeassistant/components/blueprint/importer.py @@ -0,0 +1,177 @@ +"""Import logic for blueprint.""" +from dataclasses import dataclass +import re +from typing import Optional + +import voluptuous as vol +import yarl + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.util import yaml + +from .models import Blueprint +from .schemas import is_blueprint_config + +COMMUNITY_TOPIC_PATTERN = re.compile( + r"^https://community.home-assistant.io/t/[a-z0-9-]+/(?P\d+)(?:/(?P\d+)|)$" +) + +COMMUNITY_CODE_BLOCK = re.compile( + r'(?P(?:.|\n)*)', re.MULTILINE +) + +GITHUB_FILE_PATTERN = re.compile( + r"^https://github.com/(?P.+)/blob/(?P.+)$" +) +GITHUB_RAW_FILE_PATTERN = re.compile(r"^https://raw.githubusercontent.com/") + +COMMUNITY_TOPIC_SCHEMA = vol.Schema( + { + "slug": str, + "title": str, + "post_stream": {"posts": [{"updated_at": cv.datetime, "cooked": str}]}, + }, + extra=vol.ALLOW_EXTRA, +) + + +@dataclass(frozen=True) +class ImportedBlueprint: + """Imported blueprint.""" + + url: str + suggested_filename: str + raw_data: str + blueprint: Blueprint + + +def _get_github_import_url(url: str) -> str: + """Convert a GitHub url to the raw content. + + Async friendly. + """ + match = GITHUB_RAW_FILE_PATTERN.match(url) + if match is not None: + return url + + match = GITHUB_FILE_PATTERN.match(url) + + if match is None: + raise ValueError("Not a GitHub file url") + + repo, path = match.groups() + + return f"https://raw.githubusercontent.com/{repo}/{path}" + + +def _get_community_post_import_url(url: str) -> str: + """Convert a forum post url to an import url. + + Async friendly. + """ + match = COMMUNITY_TOPIC_PATTERN.match(url) + if match is None: + raise ValueError("Not a topic url") + + _topic, post = match.groups() + + json_url = url + + if post is not None: + # Chop off post part, ie /2 + json_url = json_url[: -len(post) - 1] + + json_url += ".json" + + return json_url + + +def _extract_blueprint_from_community_topic( + url: str, + topic: dict, +) -> Optional[ImportedBlueprint]: + """Extract a blueprint from a community post JSON. + + Async friendly. + """ + block_content = None + blueprint = None + post = topic["post_stream"]["posts"][0] + + for match in COMMUNITY_CODE_BLOCK.finditer(post["cooked"]): + block_syntax, block_content = match.groups() + + if block_syntax not in ("auto", "yaml"): + continue + + block_content = block_content.strip() + + try: + data = yaml.parse_yaml(block_content) + except HomeAssistantError: + if block_syntax == "yaml": + raise + + continue + + if not is_blueprint_config(data): + continue + + blueprint = Blueprint(data) + break + + if blueprint is None: + return None + + return ImportedBlueprint(url, topic["slug"], block_content, blueprint) + + +async def fetch_blueprint_from_community_post( + hass: HomeAssistant, url: str +) -> Optional[ImportedBlueprint]: + """Get blueprints from a community post url. + + Method can raise aiohttp client exceptions, vol.Invalid. + + Caller needs to implement own timeout. + """ + import_url = _get_community_post_import_url(url) + session = aiohttp_client.async_get_clientsession(hass) + + resp = await session.get(import_url, raise_for_status=True) + json_resp = await resp.json() + json_resp = COMMUNITY_TOPIC_SCHEMA(json_resp) + return _extract_blueprint_from_community_topic(url, json_resp) + + +async def fetch_blueprint_from_github_url( + hass: HomeAssistant, url: str +) -> ImportedBlueprint: + """Get a blueprint from a github url.""" + import_url = _get_github_import_url(url) + session = aiohttp_client.async_get_clientsession(hass) + + resp = await session.get(import_url, raise_for_status=True) + raw_yaml = await resp.text() + data = yaml.parse_yaml(raw_yaml) + blueprint = Blueprint(data) + + parsed_import_url = yarl.URL(import_url) + suggested_filename = f"{parsed_import_url.parts[1]}-{parsed_import_url.parts[-1]}" + if suggested_filename.endswith(".yaml"): + suggested_filename = suggested_filename[:-5] + + return ImportedBlueprint(url, suggested_filename, raw_yaml, blueprint) + + +async def fetch_blueprint_from_url(hass: HomeAssistant, url: str) -> ImportedBlueprint: + """Get a blueprint from a url.""" + for func in (fetch_blueprint_from_community_post, fetch_blueprint_from_github_url): + try: + return await func(hass, url) + except ValueError: + pass + + raise HomeAssistantError("Unsupported url") diff --git a/homeassistant/components/blueprint/manifest.json b/homeassistant/components/blueprint/manifest.json new file mode 100644 index 00000000000..215d788ee6b --- /dev/null +++ b/homeassistant/components/blueprint/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "blueprint", + "name": "Blueprint", + "documentation": "https://www.home-assistant.io/integrations/blueprint", + "codeowners": [ + "@home-assistant/core" + ], + "quality_scale": "internal" +} diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py new file mode 100644 index 00000000000..06401a34d7d --- /dev/null +++ b/homeassistant/components/blueprint/models.py @@ -0,0 +1,231 @@ +"""Blueprint models.""" +import asyncio +import logging +import pathlib +from typing import Any, Dict, Optional, Union + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant.const import CONF_DOMAIN, CONF_NAME, CONF_PATH +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import placeholder +from homeassistant.util import yaml + +from .const import ( + BLUEPRINT_FOLDER, + CONF_BLUEPRINT, + CONF_INPUT, + CONF_SOURCE_URL, + CONF_USE_BLUEPRINT, + DOMAIN, +) +from .errors import ( + BlueprintException, + FailedToLoad, + InvalidBlueprint, + InvalidBlueprintInputs, + MissingPlaceholder, +) +from .schemas import BLUEPRINT_INSTANCE_FIELDS, BLUEPRINT_SCHEMA + + +class Blueprint: + """Blueprint of a configuration structure.""" + + def __init__( + self, + data: dict, + *, + path: Optional[str] = None, + expected_domain: Optional[str] = None, + ) -> None: + """Initialize a blueprint.""" + try: + data = self.data = BLUEPRINT_SCHEMA(data) + except vol.Invalid as err: + raise InvalidBlueprint(expected_domain, path, data, err) from err + + self.placeholders = placeholder.extract_placeholders(data) + + # In future, we will treat this as "incorrect" and allow to recover from this + data_domain = data[CONF_BLUEPRINT][CONF_DOMAIN] + if expected_domain is not None and data_domain != expected_domain: + raise InvalidBlueprint( + expected_domain, + path or self.name, + data, + f"Found incorrect blueprint type {data_domain}, expected {expected_domain}", + ) + + self.domain = data_domain + + missing = self.placeholders - set(data[CONF_BLUEPRINT].get(CONF_INPUT, {})) + + if missing: + raise InvalidBlueprint( + data_domain, + path or self.name, + data, + f"Missing input definition for {', '.join(missing)}", + ) + + @property + def name(self) -> str: + """Return blueprint name.""" + return self.data[CONF_BLUEPRINT][CONF_NAME] + + @property + def metadata(self) -> dict: + """Return blueprint metadata.""" + return self.data[CONF_BLUEPRINT] + + def update_metadata(self, *, source_url: Optional[str] = None) -> None: + """Update metadata.""" + if source_url is not None: + self.data[CONF_BLUEPRINT][CONF_SOURCE_URL] = source_url + + +class BlueprintInputs: + """Inputs for a blueprint.""" + + def __init__( + self, blueprint: Blueprint, config_with_inputs: Dict[str, Any] + ) -> None: + """Instantiate a blueprint inputs object.""" + self.blueprint = blueprint + self.config_with_inputs = config_with_inputs + + @property + def inputs(self): + """Return the inputs.""" + return self.config_with_inputs[CONF_USE_BLUEPRINT][CONF_INPUT] + + def validate(self) -> None: + """Validate the inputs.""" + missing = self.blueprint.placeholders - set(self.inputs) + + if missing: + raise MissingPlaceholder( + self.blueprint.domain, self.blueprint.name, missing + ) + + # In future we can see if entities are correct domain, areas exist etc + # using the new selector helper. + + @callback + def async_substitute(self) -> dict: + """Get the blueprint value with the inputs substituted.""" + processed = placeholder.substitute(self.blueprint.data, self.inputs) + combined = {**self.config_with_inputs, **processed} + # From config_with_inputs + combined.pop(CONF_USE_BLUEPRINT) + # From blueprint + combined.pop(CONF_BLUEPRINT) + return combined + + +class DomainBlueprints: + """Blueprints for a specific domain.""" + + def __init__( + self, + hass: HomeAssistant, + domain: str, + logger: logging.Logger, + ) -> None: + """Initialize a domain blueprints instance.""" + self.hass = hass + self.domain = domain + self.logger = logger + self._blueprints = {} + self._load_lock = asyncio.Lock() + + hass.data.setdefault(DOMAIN, {})[domain] = self + + @callback + def async_reset_cache(self) -> None: + """Reset the blueprint cache.""" + self._blueprints = {} + + def _load_blueprint(self, blueprint_path) -> Blueprint: + """Load a blueprint.""" + try: + blueprint_data = yaml.load_yaml( + self.hass.config.path(BLUEPRINT_FOLDER, self.domain, blueprint_path) + ) + except (HomeAssistantError, FileNotFoundError) as err: + raise FailedToLoad(self.domain, blueprint_path, err) from err + + return Blueprint( + blueprint_data, expected_domain=self.domain, path=blueprint_path + ) + + def _load_blueprints(self) -> Dict[str, Union[Blueprint, BlueprintException]]: + """Load all the blueprints.""" + blueprint_folder = pathlib.Path( + self.hass.config.path(BLUEPRINT_FOLDER, self.domain) + ) + results = {} + + for blueprint_path in blueprint_folder.glob("**/*.yaml"): + blueprint_path = str(blueprint_path.relative_to(blueprint_folder)) + if self._blueprints.get(blueprint_path) is None: + try: + self._blueprints[blueprint_path] = self._load_blueprint( + blueprint_path + ) + except BlueprintException as err: + self._blueprints[blueprint_path] = None + results[blueprint_path] = err + continue + + results[blueprint_path] = self._blueprints[blueprint_path] + + return results + + async def async_get_blueprints( + self, + ) -> Dict[str, Union[Blueprint, BlueprintException]]: + """Get all the blueprints.""" + async with self._load_lock: + return await self.hass.async_add_executor_job(self._load_blueprints) + + async def async_get_blueprint(self, blueprint_path: str) -> Blueprint: + """Get a blueprint.""" + if blueprint_path in self._blueprints: + return self._blueprints[blueprint_path] + + async with self._load_lock: + # Check it again + if blueprint_path in self._blueprints: + return self._blueprints[blueprint_path] + + try: + blueprint = await self.hass.async_add_executor_job( + self._load_blueprint, blueprint_path + ) + except Exception: + self._blueprints[blueprint_path] = None + raise + + self._blueprints[blueprint_path] = blueprint + return blueprint + + async def async_inputs_from_config( + self, config_with_blueprint: dict + ) -> BlueprintInputs: + """Process a blueprint config.""" + try: + config_with_blueprint = BLUEPRINT_INSTANCE_FIELDS(config_with_blueprint) + except vol.Invalid as err: + raise InvalidBlueprintInputs( + self.domain, humanize_error(config_with_blueprint, err) + ) from err + + bp_conf = config_with_blueprint[CONF_USE_BLUEPRINT] + blueprint = await self.async_get_blueprint(bp_conf[CONF_PATH]) + inputs = BlueprintInputs(blueprint, config_with_blueprint) + inputs.validate() + return inputs diff --git a/homeassistant/components/blueprint/schemas.py b/homeassistant/components/blueprint/schemas.py new file mode 100644 index 00000000000..275a2cf242a --- /dev/null +++ b/homeassistant/components/blueprint/schemas.py @@ -0,0 +1,57 @@ +"""Schemas for the blueprint integration.""" +from typing import Any + +import voluptuous as vol + +from homeassistant.const import CONF_DOMAIN, CONF_NAME, CONF_PATH +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv + +from .const import CONF_BLUEPRINT, CONF_INPUT, CONF_USE_BLUEPRINT + + +@callback +def is_blueprint_config(config: Any) -> bool: + """Return if it is a blueprint config.""" + return isinstance(config, dict) and CONF_BLUEPRINT in config + + +@callback +def is_blueprint_instance_config(config: Any) -> bool: + """Return if it is a blueprint instance config.""" + return isinstance(config, dict) and CONF_USE_BLUEPRINT in config + + +BLUEPRINT_SCHEMA = vol.Schema( + { + # No definition yet for the inputs. + vol.Required(CONF_BLUEPRINT): vol.Schema( + { + vol.Required(CONF_NAME): str, + vol.Required(CONF_DOMAIN): str, + vol.Optional(CONF_INPUT, default=dict): {str: None}, + } + ), + }, + extra=vol.ALLOW_EXTRA, +) + + +def validate_yaml_suffix(value: str) -> str: + """Validate value has a YAML suffix.""" + if not value.endswith(".yaml"): + raise vol.Invalid("Path needs to end in .yaml") + return value + + +BLUEPRINT_INSTANCE_FIELDS = vol.Schema( + { + vol.Required(CONF_USE_BLUEPRINT): vol.Schema( + { + vol.Required(CONF_PATH): vol.All(cv.path, validate_yaml_suffix), + vol.Required(CONF_INPUT): {str: cv.match_all}, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py new file mode 100644 index 00000000000..51bec0eb2a0 --- /dev/null +++ b/homeassistant/components/blueprint/websocket_api.py @@ -0,0 +1,86 @@ +"""Websocket API for blueprint.""" +import asyncio +import logging +from typing import Dict, Optional + +import async_timeout +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv + +from . import importer, models +from .const import DOMAIN + +_LOGGER = logging.getLogger(__package__) + + +@callback +def async_setup(hass: HomeAssistant): + """Set up the websocket API.""" + websocket_api.async_register_command(hass, ws_list_blueprints) + websocket_api.async_register_command(hass, ws_import_blueprint) + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "blueprint/list", + } +) +async def ws_list_blueprints(hass, connection, msg): + """List available blueprints.""" + domain_blueprints: Optional[Dict[str, models.DomainBlueprints]] = hass.data.get( + DOMAIN, {} + ) + results = {} + + for domain, domain_results in zip( + domain_blueprints, + await asyncio.gather( + *[db.async_get_blueprints() for db in domain_blueprints.values()] + ), + ): + for path, value in domain_results.items(): + if isinstance(value, models.Blueprint): + domain_results[path] = { + "metadata": value.metadata, + } + else: + domain_results[path] = {"error": str(value)} + + results[domain] = domain_results + + connection.send_result(msg["id"], results) + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "blueprint/import", + vol.Required("url"): cv.url, + } +) +async def ws_import_blueprint(hass, connection, msg): + """Import a blueprint.""" + async with async_timeout.timeout(10): + imported_blueprint = await importer.fetch_blueprint_from_url(hass, msg["url"]) + + if imported_blueprint is None: + connection.send_error( + msg["id"], websocket_api.ERR_NOT_SUPPORTED, "This url is not supported" + ) + return + + connection.send_result( + msg["id"], + { + "url": imported_blueprint.url, + "suggested_filename": imported_blueprint.suggested_filename, + "raw_data": imported_blueprint.raw_data, + "blueprint": { + "metadata": imported_blueprint.blueprint.metadata, + }, + }, + ) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index add101de5b0..e78717ac609 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -87,7 +87,7 @@ from homeassistant.helpers import ( template as template_helper, ) from homeassistant.helpers.logging import KeywordStyleAdapter -from homeassistant.util import slugify as util_slugify +from homeassistant.util import sanitize_path, slugify as util_slugify import homeassistant.util.dt as dt_util # pylint: disable=invalid-name @@ -113,6 +113,17 @@ port = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) T = TypeVar("T") +def path(value: Any) -> str: + """Validate it's a safe path.""" + if not isinstance(value, str): + raise vol.Invalid("Expected a string") + + if sanitize_path(value) != value: + raise vol.Invalid("Invalid path") + + return value + + # Adapted from: # https://github.com/alecthomas/voluptuous/issues/115#issuecomment-144464666 def has_at_least_one_key(*keys: str) -> Callable: diff --git a/homeassistant/helpers/placeholder.py b/homeassistant/helpers/placeholder.py new file mode 100644 index 00000000000..3da5eaba76f --- /dev/null +++ b/homeassistant/helpers/placeholder.py @@ -0,0 +1,53 @@ +"""Placeholder helpers.""" +from typing import Any, Dict, Set + +from homeassistant.util.yaml import Placeholder + + +class UndefinedSubstitution(Exception): + """Error raised when we find a substitution that is not defined.""" + + def __init__(self, placeholder: str) -> None: + """Initialize the undefined substitution exception.""" + super().__init__(f"No substitution found for placeholder {placeholder}") + self.placeholder = placeholder + + +def extract_placeholders(obj: Any) -> Set[str]: + """Extract placeholders from a structure.""" + found: Set[str] = set() + _extract_placeholders(obj, found) + return found + + +def _extract_placeholders(obj: Any, found: Set[str]) -> None: + """Extract placeholders from a structure.""" + if isinstance(obj, Placeholder): + found.add(obj.name) + return + + if isinstance(obj, list): + for val in obj: + _extract_placeholders(val, found) + return + + if isinstance(obj, dict): + for val in obj.values(): + _extract_placeholders(val, found) + return + + +def substitute(obj: Any, substitutions: Dict[str, Any]) -> Any: + """Substitute values.""" + if isinstance(obj, Placeholder): + if obj.name not in substitutions: + raise UndefinedSubstitution(obj.name) + return substitutions[obj.name] + + if isinstance(obj, list): + return [substitute(val, substitutions) for val in obj] + + if isinstance(obj, dict): + return {key: substitute(val, substitutions) for key, val in obj.items()} + + return obj diff --git a/homeassistant/util/yaml/__init__.py b/homeassistant/util/yaml/__init__.py index 106bdbf8ef5..bb6eb122de5 100644 --- a/homeassistant/util/yaml/__init__.py +++ b/homeassistant/util/yaml/__init__.py @@ -1,14 +1,17 @@ """YAML utility functions.""" from .const import _SECRET_NAMESPACE, SECRET_YAML from .dumper import dump, save_yaml -from .loader import clear_secret_cache, load_yaml, secret_yaml +from .loader import clear_secret_cache, load_yaml, parse_yaml, secret_yaml +from .objects import Placeholder __all__ = [ "SECRET_YAML", "_SECRET_NAMESPACE", + "Placeholder", "dump", "save_yaml", "clear_secret_cache", "load_yaml", "secret_yaml", + "parse_yaml", ] diff --git a/homeassistant/util/yaml/dumper.py b/homeassistant/util/yaml/dumper.py index 3d11e943a91..6834323ed72 100644 --- a/homeassistant/util/yaml/dumper.py +++ b/homeassistant/util/yaml/dumper.py @@ -3,7 +3,7 @@ from collections import OrderedDict import yaml -from .objects import NodeListClass +from .objects import NodeListClass, Placeholder # mypy: allow-untyped-calls, no-warn-return-any @@ -60,3 +60,8 @@ yaml.SafeDumper.add_representer( NodeListClass, lambda dumper, value: dumper.represent_sequence("tag:yaml.org,2002:seq", value), ) + +yaml.SafeDumper.add_representer( + Placeholder, + lambda dumper, value: dumper.represent_scalar("!placeholder", value.name), +) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 7e954f21e1a..c9e191db5de 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -4,14 +4,14 @@ import fnmatch import logging import os import sys -from typing import Dict, Iterator, List, TypeVar, Union, overload +from typing import Dict, Iterator, List, TextIO, TypeVar, Union, overload import yaml from homeassistant.exceptions import HomeAssistantError from .const import _SECRET_NAMESPACE, SECRET_YAML -from .objects import NodeListClass, NodeStrClass +from .objects import NodeListClass, NodeStrClass, Placeholder try: import keyring @@ -56,17 +56,23 @@ def load_yaml(fname: str) -> JSON_TYPE: """Load a YAML file.""" try: with open(fname, encoding="utf-8") as conf_file: - # If configuration file is empty YAML returns None - # We convert that to an empty dict - return yaml.load(conf_file, Loader=SafeLineLoader) or OrderedDict() - except yaml.YAMLError as exc: - _LOGGER.error(str(exc)) - raise HomeAssistantError(exc) from exc + return parse_yaml(conf_file) except UnicodeDecodeError as exc: _LOGGER.error("Unable to read file %s: %s", fname, exc) raise HomeAssistantError(exc) from exc +def parse_yaml(content: Union[str, TextIO]) -> JSON_TYPE: + """Load a YAML file.""" + try: + # If configuration file is empty YAML returns None + # We convert that to an empty dict + return yaml.load(content, Loader=SafeLineLoader) or OrderedDict() + except yaml.YAMLError as exc: + _LOGGER.error(str(exc)) + raise HomeAssistantError(exc) from exc + + @overload def _add_reference( obj: Union[list, NodeListClass], loader: yaml.SafeLoader, node: yaml.nodes.Node @@ -325,3 +331,4 @@ yaml.SafeLoader.add_constructor("!include_dir_named", _include_dir_named_yaml) yaml.SafeLoader.add_constructor( "!include_dir_merge_named", _include_dir_merge_named_yaml ) +yaml.SafeLoader.add_constructor("!placeholder", Placeholder.from_node) diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py index cae957740e4..c3f4c0ff140 100644 --- a/homeassistant/util/yaml/objects.py +++ b/homeassistant/util/yaml/objects.py @@ -1,4 +1,7 @@ """Custom yaml object types.""" +from dataclasses import dataclass + +import yaml class NodeListClass(list): @@ -7,3 +10,15 @@ class NodeListClass(list): class NodeStrClass(str): """Wrapper class to be able to add attributes on a string.""" + + +@dataclass(frozen=True) +class Placeholder: + """A placeholder that should be substituted.""" + + name: str + + @classmethod + def from_node(cls, loader: yaml.Loader, node: yaml.nodes.Node) -> "Placeholder": + """Create a new placeholder from a node.""" + return cls(node.value) diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 1cdcfc11dfb..95667d9a690 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1232,3 +1232,25 @@ async def test_automation_variables(hass, caplog): hass.bus.async_fire("test_event_3", {"break": 0}) await hass.async_block_till_done() assert len(calls) == 3 + + +async def test_blueprint_automation(hass, calls): + """Test blueprint automation.""" + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "use_blueprint": { + "path": "test_event_service.yaml", + "input": { + "trigger_event": "blueprint_event", + "service_to_call": "test.automation", + }, + } + } + }, + ) + hass.bus.async_fire("blueprint_event") + await hass.async_block_till_done() + assert len(calls) == 1 diff --git a/tests/components/blueprint/__init__.py b/tests/components/blueprint/__init__.py new file mode 100644 index 00000000000..b67346b1c2f --- /dev/null +++ b/tests/components/blueprint/__init__.py @@ -0,0 +1 @@ +"""Tests for the blueprint integration.""" diff --git a/tests/components/blueprint/test_importer.py b/tests/components/blueprint/test_importer.py new file mode 100644 index 00000000000..263f82ab230 --- /dev/null +++ b/tests/components/blueprint/test_importer.py @@ -0,0 +1,137 @@ +"""Test blueprint importing.""" +import json +from pathlib import Path + +import pytest + +from homeassistant.components.blueprint import importer +from homeassistant.exceptions import HomeAssistantError + +from tests.common import load_fixture + + +@pytest.fixture(scope="session") +def community_post(): + """Topic JSON with a codeblock marked as auto syntax.""" + return load_fixture("blueprint/community_post.json") + + +def test_get_community_post_import_url(): + """Test variations of generating import forum url.""" + assert ( + importer._get_community_post_import_url( + "https://community.home-assistant.io/t/test-topic/123" + ) + == "https://community.home-assistant.io/t/test-topic/123.json" + ) + + assert ( + importer._get_community_post_import_url( + "https://community.home-assistant.io/t/test-topic/123/2" + ) + == "https://community.home-assistant.io/t/test-topic/123.json" + ) + + +def test_get_github_import_url(): + """Test getting github import url.""" + assert ( + importer._get_github_import_url( + "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml" + ) + == "https://raw.githubusercontent.com/balloob/home-assistant-config/main/blueprints/automation/motion_light.yaml" + ) + + assert ( + importer._get_github_import_url( + "https://raw.githubusercontent.com/balloob/home-assistant-config/main/blueprints/automation/motion_light.yaml" + ) + == "https://raw.githubusercontent.com/balloob/home-assistant-config/main/blueprints/automation/motion_light.yaml" + ) + + +def test_extract_blueprint_from_community_topic(community_post): + """Test extracting blueprint.""" + imported_blueprint = importer._extract_blueprint_from_community_topic( + "http://example.com", json.loads(community_post) + ) + assert imported_blueprint is not None + assert imported_blueprint.url == "http://example.com" + assert imported_blueprint.blueprint.domain == "automation" + assert imported_blueprint.blueprint.placeholders == { + "service_to_call", + "trigger_event", + } + + +def test_extract_blueprint_from_community_topic_invalid_yaml(): + """Test extracting blueprint with invalid YAML.""" + with pytest.raises(HomeAssistantError): + importer._extract_blueprint_from_community_topic( + "http://example.com", + { + "post_stream": { + "posts": [ + {"cooked": 'invalid: yaml: 2'} + ] + } + }, + ) + + +def test__extract_blueprint_from_community_topic_wrong_lang(): + """Test extracting blueprint with invalid YAML.""" + assert ( + importer._extract_blueprint_from_community_topic( + "http://example.com", + { + "post_stream": { + "posts": [ + {"cooked": 'invalid yaml + 2'} + ] + } + }, + ) + is None + ) + + +async def test_fetch_blueprint_from_community_url(hass, aioclient_mock, community_post): + """Test fetching blueprint from url.""" + aioclient_mock.get( + "https://community.home-assistant.io/t/test-topic/123.json", text=community_post + ) + imported_blueprint = await importer.fetch_blueprint_from_url( + hass, "https://community.home-assistant.io/t/test-topic/123/2" + ) + assert isinstance(imported_blueprint, importer.ImportedBlueprint) + assert imported_blueprint.blueprint.domain == "automation" + assert imported_blueprint.blueprint.placeholders == { + "service_to_call", + "trigger_event", + } + + +@pytest.mark.parametrize( + "url", + ( + "https://raw.githubusercontent.com/balloob/home-assistant-config/main/blueprints/automation/motion_light.yaml", + "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml", + ), +) +async def test_fetch_blueprint_from_github_url(hass, aioclient_mock, url): + """Test fetching blueprint from url.""" + aioclient_mock.get( + "https://raw.githubusercontent.com/balloob/home-assistant-config/main/blueprints/automation/motion_light.yaml", + text=Path( + hass.config.path("blueprints/automation/test_event_service.yaml") + ).read_text(), + ) + + imported_blueprint = await importer.fetch_blueprint_from_url(hass, url) + assert isinstance(imported_blueprint, importer.ImportedBlueprint) + assert imported_blueprint.blueprint.domain == "automation" + assert imported_blueprint.blueprint.placeholders == { + "service_to_call", + "trigger_event", + } diff --git a/tests/components/blueprint/test_init.py b/tests/components/blueprint/test_init.py new file mode 100644 index 00000000000..5552d0625b0 --- /dev/null +++ b/tests/components/blueprint/test_init.py @@ -0,0 +1 @@ +"""Tests for the blueprint init.""" diff --git a/tests/components/blueprint/test_models.py b/tests/components/blueprint/test_models.py new file mode 100644 index 00000000000..56fe13599d7 --- /dev/null +++ b/tests/components/blueprint/test_models.py @@ -0,0 +1,154 @@ +"""Test blueprint models.""" +import logging + +import pytest + +from homeassistant.components.blueprint import errors, models +from homeassistant.util.yaml import Placeholder + +from tests.async_mock import patch + + +@pytest.fixture +def blueprint_1(): + """Blueprint fixture.""" + return models.Blueprint( + { + "blueprint": { + "name": "Hello", + "domain": "automation", + "input": {"test-placeholder": None}, + }, + "example": Placeholder("test-placeholder"), + } + ) + + +@pytest.fixture +def domain_bps(hass): + """Domain blueprints fixture.""" + return models.DomainBlueprints(hass, "automation", logging.getLogger(__name__)) + + +def test_blueprint_model_init(): + """Test constructor validation.""" + with pytest.raises(errors.InvalidBlueprint): + models.Blueprint({}) + + with pytest.raises(errors.InvalidBlueprint): + models.Blueprint( + {"blueprint": {"name": "Hello", "domain": "automation"}}, + expected_domain="not-automation", + ) + + with pytest.raises(errors.InvalidBlueprint): + models.Blueprint( + { + "blueprint": { + "name": "Hello", + "domain": "automation", + "input": {"something": None}, + }, + "trigger": {"platform": Placeholder("non-existing")}, + } + ) + + +def test_blueprint_properties(blueprint_1): + """Test properties.""" + assert blueprint_1.metadata == { + "name": "Hello", + "domain": "automation", + "input": {"test-placeholder": None}, + } + assert blueprint_1.domain == "automation" + assert blueprint_1.name == "Hello" + assert blueprint_1.placeholders == {"test-placeholder"} + + +def test_blueprint_update_metadata(): + """Test properties.""" + bp = models.Blueprint( + { + "blueprint": { + "name": "Hello", + "domain": "automation", + }, + } + ) + + bp.update_metadata(source_url="http://bla.com") + assert bp.metadata["source_url"] == "http://bla.com" + + +def test_blueprint_inputs(blueprint_1): + """Test blueprint inputs.""" + inputs = models.BlueprintInputs( + blueprint_1, + {"use_blueprint": {"path": "bla", "input": {"test-placeholder": 1}}}, + ) + inputs.validate() + assert inputs.inputs == {"test-placeholder": 1} + assert inputs.async_substitute() == {"example": 1} + + +def test_blueprint_inputs_validation(blueprint_1): + """Test blueprint input validation.""" + inputs = models.BlueprintInputs( + blueprint_1, + {"use_blueprint": {"path": "bla", "input": {"non-existing-placeholder": 1}}}, + ) + with pytest.raises(errors.MissingPlaceholder): + inputs.validate() + + +async def test_domain_blueprints_get_blueprint_errors(hass, domain_bps): + """Test domain blueprints.""" + assert hass.data["blueprint"]["automation"] is domain_bps + + with pytest.raises(errors.FailedToLoad), patch( + "homeassistant.util.yaml.load_yaml", side_effect=FileNotFoundError + ): + await domain_bps.async_get_blueprint("non-existing-path") + + with patch( + "homeassistant.util.yaml.load_yaml", return_value={"blueprint": "invalid"} + ): + assert await domain_bps.async_get_blueprint("non-existing-path") is None + + +async def test_domain_blueprints_caching(domain_bps): + """Test domain blueprints cache blueprints.""" + obj = object() + with patch.object(domain_bps, "_load_blueprint", return_value=obj): + assert await domain_bps.async_get_blueprint("something") is obj + + # Now we hit cache + assert await domain_bps.async_get_blueprint("something") is obj + + obj_2 = object() + domain_bps.async_reset_cache() + + # Now we call this method again. + with patch.object(domain_bps, "_load_blueprint", return_value=obj_2): + assert await domain_bps.async_get_blueprint("something") is obj_2 + + +async def test_domain_blueprints_inputs_from_config(domain_bps, blueprint_1): + """Test DomainBlueprints.async_inputs_from_config.""" + with pytest.raises(errors.InvalidBlueprintInputs): + await domain_bps.async_inputs_from_config({"not-referencing": "use_blueprint"}) + + with pytest.raises(errors.MissingPlaceholder), patch.object( + domain_bps, "async_get_blueprint", return_value=blueprint_1 + ): + await domain_bps.async_inputs_from_config( + {"use_blueprint": {"path": "bla.yaml", "input": {}}} + ) + + with patch.object(domain_bps, "async_get_blueprint", return_value=blueprint_1): + inputs = await domain_bps.async_inputs_from_config( + {"use_blueprint": {"path": "bla.yaml", "input": {"test-placeholder": None}}} + ) + assert inputs.blueprint is blueprint_1 + assert inputs.inputs == {"test-placeholder": None} diff --git a/tests/components/blueprint/test_schemas.py b/tests/components/blueprint/test_schemas.py new file mode 100644 index 00000000000..bf50bdad975 --- /dev/null +++ b/tests/components/blueprint/test_schemas.py @@ -0,0 +1,71 @@ +"""Test schemas.""" +import logging + +import pytest +import voluptuous as vol + +from homeassistant.components.blueprint import schemas + +_LOGGER = logging.getLogger(__name__) + + +@pytest.mark.parametrize( + "blueprint", + ( + # Test allow extra + { + "trigger": "Test allow extra", + "blueprint": {"name": "Test Name", "domain": "automation"}, + }, + # Bare minimum + {"blueprint": {"name": "Test Name", "domain": "automation"}}, + # Empty triggers + {"blueprint": {"name": "Test Name", "domain": "automation", "input": {}}}, + # No definition of input + { + "blueprint": { + "name": "Test Name", + "domain": "automation", + "input": { + "some_placeholder": None, + }, + } + }, + ), +) +def test_blueprint_schema(blueprint): + """Test different schemas.""" + try: + schemas.BLUEPRINT_SCHEMA(blueprint) + except vol.Invalid: + _LOGGER.exception("%s", blueprint) + assert False, "Expected schema to be valid" + + +@pytest.mark.parametrize( + "blueprint", + ( + # no domain + {"blueprint": {}}, + # non existing key in blueprint + { + "blueprint": { + "name": "Example name", + "domain": "automation", + "non_existing": None, + } + }, + # non existing key in input + { + "blueprint": { + "name": "Example name", + "domain": "automation", + "input": {"some_placeholder": {"non_existing": "bla"}}, + } + }, + ), +) +def test_blueprint_schema_invalid(blueprint): + """Test different schemas.""" + with pytest.raises(vol.Invalid): + schemas.BLUEPRINT_SCHEMA(blueprint) diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py new file mode 100644 index 00000000000..54fcc0a5891 --- /dev/null +++ b/tests/components/blueprint/test_websocket_api.py @@ -0,0 +1,82 @@ +"""Test websocket API.""" +from pathlib import Path + +import pytest + +from homeassistant.components import automation +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +async def setup_bp(hass): + """Fixture to set up the blueprint component.""" + assert await async_setup_component(hass, "blueprint", {}) + + # Trigger registration of automation blueprints + automation.async_get_blueprints(hass) + + +async def test_list_blueprints(hass, hass_ws_client): + """Test listing blueprints.""" + client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "blueprint/list"}) + + msg = await client.receive_json() + + assert msg["id"] == 5 + assert msg["success"] + blueprints = msg["result"] + assert blueprints.get("automation") == { + "test_event_service.yaml": { + "metadata": { + "domain": "automation", + "input": {"service_to_call": None, "trigger_event": None}, + "name": "Call service based on event", + }, + }, + "in_folder/in_folder_blueprint.yaml": { + "metadata": { + "domain": "automation", + "input": {"action": None, "trigger": None}, + "name": "In Folder Blueprint", + } + }, + } + + +async def test_import_blueprint(hass, aioclient_mock, hass_ws_client): + """Test listing blueprints.""" + raw_data = Path( + hass.config.path("blueprints/automation/test_event_service.yaml") + ).read_text() + + aioclient_mock.get( + "https://raw.githubusercontent.com/balloob/home-assistant-config/main/blueprints/automation/motion_light.yaml", + text=raw_data, + ) + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 5, + "type": "blueprint/import", + "url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml", + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 5 + assert msg["success"] + assert msg["result"] == { + "suggested_filename": "balloob-motion_light", + "url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml", + "raw_data": raw_data, + "blueprint": { + "metadata": { + "domain": "automation", + "input": {"service_to_call": None, "trigger_event": None}, + "name": "Call service based on event", + }, + }, + } diff --git a/tests/fixtures/blueprint/community_post.json b/tests/fixtures/blueprint/community_post.json new file mode 100644 index 00000000000..28684ec65f7 --- /dev/null +++ b/tests/fixtures/blueprint/community_post.json @@ -0,0 +1,783 @@ +{ + "post_stream": { + "posts": [ + { + "id": 1144853, + "name": "Paulus Schoutsen", + "username": "balloob", + "avatar_template": "/user_avatar/community.home-assistant.io/balloob/{size}/21956_2.png", + "created_at": "2020-10-16T12:20:12.688Z", + "cooked": "\u003cp\u003ehere a test topic.\u003cbr\u003e\nhere a test topic.\u003cbr\u003e\nhere a test topic.\u003cbr\u003e\nhere a test topic.\u003c/p\u003e\n\u003ch1\u003eBlock without syntax\u003c/h1\u003e\n\u003cpre\u003e\u003ccode class=\"lang-auto\"\u003eblueprint:\n domain: automation\n name: Example Blueprint from post\n input:\n trigger_event:\n service_to_call:\ntrigger:\n platform: event\n event_type: !placeholder trigger_event\naction:\n service: !placeholder service_to_call\n\u003c/code\u003e\u003c/pre\u003e", + "post_number": 1, + "post_type": 1, + "updated_at": "2020-10-20T08:24:14.189Z", + "reply_count": 0, + "reply_to_post_number": null, + "quote_count": 0, + "incoming_link_count": 0, + "reads": 2, + "readers_count": 1, + "score": 0.4, + "yours": true, + "topic_id": 236133, + "topic_slug": "test-topic", + "display_username": "Paulus Schoutsen", + "primary_group_name": null, + "primary_group_flair_url": null, + "primary_group_flair_bg_color": null, + "primary_group_flair_color": null, + "version": 2, + "can_edit": true, + "can_delete": false, + "can_recover": false, + "can_wiki": true, + "read": true, + "user_title": "Founder of Home Assistant", + "title_is_group": false, + "actions_summary": [ + { + "id": 3, + "can_act": true + }, + { + "id": 4, + "can_act": true + }, + { + "id": 8, + "can_act": true + }, + { + "id": 7, + "can_act": true + } + ], + "moderator": true, + "admin": true, + "staff": true, + "user_id": 3, + "hidden": false, + "trust_level": 2, + "deleted_at": null, + "user_deleted": false, + "edit_reason": null, + "can_view_edit_history": true, + "wiki": false, + "reviewable_id": 0, + "reviewable_score_count": 0, + "reviewable_score_pending_count": 0, + "user_created_at": "2016-03-30T07:50:25.541Z", + "user_date_of_birth": null, + "user_signature": null, + "can_accept_answer": false, + "can_unaccept_answer": false, + "accepted_answer": false + }, + { + "id": 1144854, + "name": "Paulus Schoutsen", + "username": "balloob", + "avatar_template": "/user_avatar/community.home-assistant.io/balloob/{size}/21956_2.png", + "created_at": "2020-10-16T12:20:17.535Z", + "cooked": "", + "post_number": 2, + "post_type": 3, + "updated_at": "2020-10-16T12:20:17.535Z", + "reply_count": 0, + "reply_to_post_number": null, + "quote_count": 0, + "incoming_link_count": 1, + "reads": 2, + "readers_count": 1, + "score": 5.4, + "yours": true, + "topic_id": 236133, + "topic_slug": "test-topic", + "display_username": "Paulus Schoutsen", + "primary_group_name": null, + "primary_group_flair_url": null, + "primary_group_flair_bg_color": null, + "primary_group_flair_color": null, + "version": 1, + "can_edit": true, + "can_delete": true, + "can_recover": false, + "can_wiki": true, + "read": true, + "user_title": "Founder of Home Assistant", + "title_is_group": false, + "actions_summary": [ + { + "id": 3, + "can_act": true + }, + { + "id": 4, + "can_act": true + }, + { + "id": 8, + "can_act": true + }, + { + "id": 7, + "can_act": true + } + ], + "moderator": true, + "admin": true, + "staff": true, + "user_id": 3, + "hidden": false, + "trust_level": 2, + "deleted_at": null, + "user_deleted": false, + "edit_reason": null, + "can_view_edit_history": true, + "wiki": false, + "action_code": "visible.disabled", + "reviewable_id": 0, + "reviewable_score_count": 0, + "reviewable_score_pending_count": 0, + "user_created_at": "2016-03-30T07:50:25.541Z", + "user_date_of_birth": null, + "user_signature": null, + "can_accept_answer": false, + "can_unaccept_answer": false, + "accepted_answer": false + }, + { + "id": 1144872, + "name": "Paulus Schoutsen", + "username": "balloob", + "avatar_template": "/user_avatar/community.home-assistant.io/balloob/{size}/21956_2.png", + "created_at": "2020-10-16T12:27:53.926Z", + "cooked": "\u003cp\u003eTest reply!\u003c/p\u003e", + "post_number": 3, + "post_type": 1, + "updated_at": "2020-10-16T12:27:53.926Z", + "reply_count": 0, + "reply_to_post_number": null, + "quote_count": 0, + "incoming_link_count": 0, + "reads": 2, + "readers_count": 1, + "score": 0.4, + "yours": true, + "topic_id": 236133, + "topic_slug": "test-topic", + "display_username": "Paulus Schoutsen", + "primary_group_name": null, + "primary_group_flair_url": null, + "primary_group_flair_bg_color": null, + "primary_group_flair_color": null, + "version": 1, + "can_edit": true, + "can_delete": true, + "can_recover": false, + "can_wiki": true, + "read": true, + "user_title": "Founder of Home Assistant", + "title_is_group": false, + "actions_summary": [ + { + "id": 3, + "can_act": true + }, + { + "id": 4, + "can_act": true + }, + { + "id": 8, + "can_act": true + }, + { + "id": 7, + "can_act": true + } + ], + "moderator": true, + "admin": true, + "staff": true, + "user_id": 3, + "hidden": false, + "trust_level": 2, + "deleted_at": null, + "user_deleted": false, + "edit_reason": null, + "can_view_edit_history": true, + "wiki": false, + "reviewable_id": 0, + "reviewable_score_count": 0, + "reviewable_score_pending_count": 0, + "user_created_at": "2016-03-30T07:50:25.541Z", + "user_date_of_birth": null, + "user_signature": null, + "can_accept_answer": false, + "can_unaccept_answer": false, + "accepted_answer": false + } + ], + "stream": [ + 1144853, + 1144854, + 1144872 + ] + }, + "timeline_lookup": [ + [ + 1, + 3 + ] + ], + "suggested_topics": [ + { + "id": 17750, + "title": "Tutorial: Creating your first add-on", + "fancy_title": "Tutorial: Creating your first add-on", + "slug": "tutorial-creating-your-first-add-on", + "posts_count": 26, + "reply_count": 14, + "highest_post_number": 27, + "image_url": null, + "created_at": "2017-05-14T07:51:33.946Z", + "last_posted_at": "2020-07-28T11:29:27.892Z", + "bumped": true, + "bumped_at": "2020-07-28T11:29:27.892Z", + "archetype": "regular", + "unseen": false, + "last_read_post_number": 18, + "unread": 7, + "new_posts": 2, + "pinned": false, + "unpinned": null, + "visible": true, + "closed": false, + "archived": false, + "notification_level": 2, + "bookmarked": false, + "liked": false, + "thumbnails": null, + "tags": [], + "like_count": 9, + "views": 4355, + "category_id": 25, + "featured_link": null, + "has_accepted_answer": false, + "posters": [ + { + "extras": null, + "description": "Original Poster", + "user": { + "id": 3, + "username": "balloob", + "name": "Paulus Schoutsen", + "avatar_template": "/user_avatar/community.home-assistant.io/balloob/{size}/21956_2.png" + } + }, + { + "extras": null, + "description": "Frequent Poster", + "user": { + "id": 9852, + "username": "JSCSJSCS", + "name": "", + "avatar_template": "/user_avatar/community.home-assistant.io/jscsjscs/{size}/38256_2.png" + } + }, + { + "extras": null, + "description": "Frequent Poster", + "user": { + "id": 11494, + "username": "so3n", + "name": "", + "avatar_template": "/user_avatar/community.home-assistant.io/so3n/{size}/46007_2.png" + } + }, + { + "extras": null, + "description": "Frequent Poster", + "user": { + "id": 9094, + "username": "IoTnerd", + "name": "Balázs Suhajda", + "avatar_template": "/user_avatar/community.home-assistant.io/iotnerd/{size}/33526_2.png" + } + }, + { + "extras": "latest", + "description": "Most Recent Poster", + "user": { + "id": 73134, + "username": "diord", + "name": "", + "avatar_template": "/letter_avatar/diord/{size}/5_70a404e2c8e633b245e797a566d32dc7.png" + } + } + ] + }, + { + "id": 65981, + "title": "Lovelace: Button card", + "fancy_title": "Lovelace: Button card", + "slug": "lovelace-button-card", + "posts_count": 4608, + "reply_count": 3522, + "highest_post_number": 4691, + "image_url": null, + "created_at": "2018-08-28T00:18:19.312Z", + "last_posted_at": "2020-10-20T07:33:29.523Z", + "bumped": true, + "bumped_at": "2020-10-20T07:33:29.523Z", + "archetype": "regular", + "unseen": false, + "last_read_post_number": 1938, + "unread": 369, + "new_posts": 2384, + "pinned": false, + "unpinned": null, + "visible": true, + "closed": false, + "archived": false, + "notification_level": 2, + "bookmarked": false, + "liked": false, + "thumbnails": null, + "tags": [], + "like_count": 1700, + "views": 184752, + "category_id": 34, + "featured_link": null, + "has_accepted_answer": false, + "posters": [ + { + "extras": null, + "description": "Original Poster", + "user": { + "id": 25984, + "username": "kuuji", + "name": "Alexandre", + "avatar_template": "/user_avatar/community.home-assistant.io/kuuji/{size}/41093_2.png" + } + }, + { + "extras": null, + "description": "Frequent Poster", + "user": { + "id": 2019, + "username": "iantrich", + "name": "Ian", + "avatar_template": "/user_avatar/community.home-assistant.io/iantrich/{size}/154042_2.png" + } + }, + { + "extras": null, + "description": "Frequent Poster", + "user": { + "id": 33228, + "username": "jimz011", + "name": "Jim", + "avatar_template": "/user_avatar/community.home-assistant.io/jimz011/{size}/62413_2.png" + } + }, + { + "extras": null, + "description": "Frequent Poster", + "user": { + "id": 4931, + "username": "petro", + "name": "Petro", + "avatar_template": "/user_avatar/community.home-assistant.io/petro/{size}/47791_2.png" + } + }, + { + "extras": "latest", + "description": "Most Recent Poster", + "user": { + "id": 26227, + "username": "RomRider", + "name": "", + "avatar_template": "/user_avatar/community.home-assistant.io/romrider/{size}/41384_2.png" + } + } + ] + }, + { + "id": 10564, + "title": "Professional/Commercial Use?", + "fancy_title": "Professional/Commercial Use?", + "slug": "professional-commercial-use", + "posts_count": 54, + "reply_count": 37, + "highest_post_number": 54, + "image_url": null, + "created_at": "2017-01-27T05:01:57.453Z", + "last_posted_at": "2020-10-20T07:03:57.895Z", + "bumped": true, + "bumped_at": "2020-10-20T07:03:57.895Z", + "archetype": "regular", + "unseen": false, + "last_read_post_number": 7, + "unread": 0, + "new_posts": 47, + "pinned": false, + "unpinned": null, + "visible": true, + "closed": false, + "archived": false, + "notification_level": 2, + "bookmarked": false, + "liked": false, + "thumbnails": null, + "tags": [], + "like_count": 21, + "views": 10695, + "category_id": 17, + "featured_link": null, + "has_accepted_answer": false, + "posters": [ + { + "extras": null, + "description": "Original Poster", + "user": { + "id": 4758, + "username": "oobie11", + "name": "Bryan", + "avatar_template": "/user_avatar/community.home-assistant.io/oobie11/{size}/37858_2.png" + } + }, + { + "extras": null, + "description": "Frequent Poster", + "user": { + "id": 18386, + "username": "pitp2", + "name": "", + "avatar_template": "/letter_avatar/pitp2/{size}/5_70a404e2c8e633b245e797a566d32dc7.png" + } + }, + { + "extras": null, + "description": "Frequent Poster", + "user": { + "id": 23116, + "username": "jortegamx", + "name": "Jake", + "avatar_template": "/user_avatar/community.home-assistant.io/jortegamx/{size}/45515_2.png" + } + }, + { + "extras": null, + "description": "Frequent Poster", + "user": { + "id": 39038, + "username": "orif73", + "name": "orif73", + "avatar_template": "/letter_avatar/orif73/{size}/5_70a404e2c8e633b245e797a566d32dc7.png" + } + }, + { + "extras": "latest", + "description": "Most Recent Poster", + "user": { + "id": 41040, + "username": "devastator", + "name": "", + "avatar_template": "/letter_avatar/devastator/{size}/5_70a404e2c8e633b245e797a566d32dc7.png" + } + } + ] + }, + { + "id": 219480, + "title": "What the heck is with the 'latest state change' not being kept after restart?", + "fancy_title": "What the heck is with the \u0026lsquo;latest state change\u0026rsquo; not being kept after restart?", + "slug": "what-the-heck-is-with-the-latest-state-change-not-being-kept-after-restart", + "posts_count": 37, + "reply_count": 13, + "highest_post_number": 38, + "image_url": "https://community-assets.home-assistant.io/original/3X/3/4/349d096b209d40d5f424b64e970bcf360332cc7f.png", + "created_at": "2020-08-18T13:10:09.367Z", + "last_posted_at": "2020-10-20T00:32:07.312Z", + "bumped": true, + "bumped_at": "2020-10-20T00:32:07.312Z", + "archetype": "regular", + "unseen": false, + "last_read_post_number": 8, + "unread": 0, + "new_posts": 30, + "pinned": false, + "unpinned": null, + "visible": true, + "closed": false, + "archived": false, + "notification_level": 2, + "bookmarked": false, + "liked": false, + "thumbnails": [ + { + "max_width": null, + "max_height": null, + "width": 469, + "height": 59, + "url": "https://community-assets.home-assistant.io/original/3X/3/4/349d096b209d40d5f424b64e970bcf360332cc7f.png" + } + ], + "tags": [], + "like_count": 26, + "views": 1722, + "category_id": 52, + "featured_link": null, + "has_accepted_answer": false, + "posters": [ + { + "extras": null, + "description": "Original Poster", + "user": { + "id": 3124, + "username": "andriej", + "name": "", + "avatar_template": "/user_avatar/community.home-assistant.io/andriej/{size}/24457_2.png" + } + }, + { + "extras": null, + "description": "Frequent Poster", + "user": { + "id": 15052, + "username": "Misiu", + "name": "", + "avatar_template": "/user_avatar/community.home-assistant.io/misiu/{size}/20752_2.png" + } + }, + { + "extras": null, + "description": "Frequent Poster", + "user": { + "id": 4629, + "username": "lolouk44", + "name": "lolouk44", + "avatar_template": "/user_avatar/community.home-assistant.io/lolouk44/{size}/119845_2.png" + } + }, + { + "extras": null, + "description": "Frequent Poster", + "user": { + "id": 51736, + "username": "hmoffatt", + "name": "Hamish Moffatt", + "avatar_template": "/user_avatar/community.home-assistant.io/hmoffatt/{size}/88700_2.png" + } + }, + { + "extras": "latest", + "description": "Most Recent Poster", + "user": { + "id": 78711, + "username": "Astrosteve", + "name": "Steve", + "avatar_template": "/letter_avatar/astrosteve/{size}/5_70a404e2c8e633b245e797a566d32dc7.png" + } + } + ] + }, + { + "id": 162594, + "title": "A different take on designing a Lovelace UI", + "fancy_title": "A different take on designing a Lovelace UI", + "slug": "a-different-take-on-designing-a-lovelace-ui", + "posts_count": 641, + "reply_count": 425, + "highest_post_number": 654, + "image_url": null, + "created_at": "2020-01-11T23:09:25.207Z", + "last_posted_at": "2020-10-19T23:32:15.555Z", + "bumped": true, + "bumped_at": "2020-10-19T23:32:15.555Z", + "archetype": "regular", + "unseen": false, + "last_read_post_number": 7, + "unread": 32, + "new_posts": 615, + "pinned": false, + "unpinned": null, + "visible": true, + "closed": false, + "archived": false, + "notification_level": 2, + "bookmarked": false, + "liked": false, + "thumbnails": null, + "tags": [], + "like_count": 453, + "views": 68547, + "category_id": 9, + "featured_link": null, + "has_accepted_answer": false, + "posters": [ + { + "extras": null, + "description": "Original Poster", + "user": { + "id": 11256, + "username": "Mattias_Persson", + "name": "Mattias Persson", + "avatar_template": "/user_avatar/community.home-assistant.io/mattias_persson/{size}/14773_2.png" + } + }, + { + "extras": null, + "description": "Frequent Poster", + "user": { + "id": 27634, + "username": "Jason_hill", + "name": "Jason Hill", + "avatar_template": "/user_avatar/community.home-assistant.io/jason_hill/{size}/93218_2.png" + } + }, + { + "extras": null, + "description": "Frequent Poster", + "user": { + "id": 46782, + "username": "Martin_Pejstrup", + "name": "mpejstrup", + "avatar_template": "/user_avatar/community.home-assistant.io/martin_pejstrup/{size}/78412_2.png" + } + }, + { + "extras": null, + "description": "Frequent Poster", + "user": { + "id": 46841, + "username": "spudje", + "name": "", + "avatar_template": "/letter_avatar/spudje/{size}/5_70a404e2c8e633b245e797a566d32dc7.png" + } + }, + { + "extras": "latest", + "description": "Most Recent Poster", + "user": { + "id": 20924, + "username": "Diego_Santos", + "name": "Diego Santos", + "avatar_template": "/user_avatar/community.home-assistant.io/diego_santos/{size}/29096_2.png" + } + } + ] + } + ], + "tags": [], + "id": 236133, + "title": "Test Topic", + "fancy_title": "Test Topic", + "posts_count": 3, + "created_at": "2020-10-16T12:20:12.580Z", + "views": 13, + "reply_count": 0, + "like_count": 0, + "last_posted_at": "2020-10-16T12:27:53.926Z", + "visible": false, + "closed": false, + "archived": false, + "has_summary": false, + "archetype": "regular", + "slug": "test-topic", + "category_id": 1, + "word_count": 37, + "deleted_at": null, + "user_id": 3, + "featured_link": null, + "pinned_globally": false, + "pinned_at": null, + "pinned_until": null, + "image_url": null, + "draft": null, + "draft_key": "topic_236133", + "draft_sequence": 8, + "posted": true, + "unpinned": null, + "pinned": false, + "current_post_number": 1, + "highest_post_number": 3, + "last_read_post_number": 3, + "last_read_post_id": 1144872, + "deleted_by": null, + "has_deleted": false, + "actions_summary": [ + { + "id": 4, + "count": 0, + "hidden": false, + "can_act": true + }, + { + "id": 8, + "count": 0, + "hidden": false, + "can_act": true + }, + { + "id": 7, + "count": 0, + "hidden": false, + "can_act": true + } + ], + "chunk_size": 20, + "bookmarked": false, + "topic_timer": null, + "private_topic_timer": null, + "message_bus_last_id": 5, + "participant_count": 1, + "show_read_indicator": false, + "thumbnails": null, + "can_vote": false, + "vote_count": null, + "user_voted": false, + "details": { + "notification_level": 3, + "notifications_reason_id": 1, + "can_move_posts": true, + "can_edit": true, + "can_delete": true, + "can_remove_allowed_users": true, + "can_invite_to": true, + "can_invite_via_email": true, + "can_create_post": true, + "can_reply_as_new_topic": true, + "can_flag_topic": true, + "can_convert_topic": true, + "can_review_topic": true, + "can_remove_self_id": 3, + "participants": [ + { + "id": 3, + "username": "balloob", + "name": "Paulus Schoutsen", + "avatar_template": "/user_avatar/community.home-assistant.io/balloob/{size}/21956_2.png", + "post_count": 3, + "primary_group_name": null, + "primary_group_flair_url": null, + "primary_group_flair_color": null, + "primary_group_flair_bg_color": null + } + ], + "created_by": { + "id": 3, + "username": "balloob", + "name": "Paulus Schoutsen", + "avatar_template": "/user_avatar/community.home-assistant.io/balloob/{size}/21956_2.png" + }, + "last_poster": { + "id": 3, + "username": "balloob", + "name": "Paulus Schoutsen", + "avatar_template": "/user_avatar/community.home-assistant.io/balloob/{size}/21956_2.png" + } + } +} diff --git a/tests/helpers/test_placeholder.py b/tests/helpers/test_placeholder.py new file mode 100644 index 00000000000..d5978cd465a --- /dev/null +++ b/tests/helpers/test_placeholder.py @@ -0,0 +1,29 @@ +"""Test placeholders.""" +import pytest + +from homeassistant.helpers import placeholder +from homeassistant.util.yaml import Placeholder + + +def test_extract_placeholders(): + """Test extracting placeholders from data.""" + assert placeholder.extract_placeholders(Placeholder("hello")) == {"hello"} + assert placeholder.extract_placeholders( + {"info": [1, Placeholder("hello"), 2, Placeholder("world")]} + ) == {"hello", "world"} + + +def test_substitute(): + """Test we can substitute.""" + assert placeholder.substitute(Placeholder("hello"), {"hello": 5}) == 5 + + with pytest.raises(placeholder.UndefinedSubstitution): + placeholder.substitute(Placeholder("hello"), {}) + + assert ( + placeholder.substitute( + {"info": [1, Placeholder("hello"), 2, Placeholder("world")]}, + {"hello": 5, "world": 10}, + ) + == {"info": [1, 5, 2, 10]} + ) diff --git a/tests/testing_config/blueprints/automation/in_folder/in_folder_blueprint.yaml b/tests/testing_config/blueprints/automation/in_folder/in_folder_blueprint.yaml new file mode 100644 index 00000000000..c869e30c41e --- /dev/null +++ b/tests/testing_config/blueprints/automation/in_folder/in_folder_blueprint.yaml @@ -0,0 +1,8 @@ +blueprint: + name: "In Folder Blueprint" + domain: automation + input: + trigger: + action: +trigger: !placeholder trigger +action: !placeholder action diff --git a/tests/testing_config/blueprints/automation/test_event_service.yaml b/tests/testing_config/blueprints/automation/test_event_service.yaml new file mode 100644 index 00000000000..0e9479cd8c3 --- /dev/null +++ b/tests/testing_config/blueprints/automation/test_event_service.yaml @@ -0,0 +1,11 @@ +blueprint: + name: "Call service based on event" + domain: automation + input: + trigger_event: + service_to_call: +trigger: + platform: event + event_type: !placeholder trigger_event +action: + service: !placeholder service_to_call diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py index 96b6a86a27d..2e9d1b471ac 100644 --- a/tests/util/test_yaml.py +++ b/tests/util/test_yaml.py @@ -461,3 +461,20 @@ def test_duplicate_key(caplog): with patch_yaml_files(files): load_yaml_config_file(YAML_CONFIG_FILE) assert "contains duplicate key" in caplog.text + + +def test_placeholder_class(): + """Test placeholder class.""" + placeholder = yaml_loader.Placeholder("hello") + placeholder2 = yaml_loader.Placeholder("hello") + + assert placeholder.name == "hello" + assert placeholder == placeholder2 + + assert len({placeholder, placeholder2}) == 1 + + +def test_placeholder(): + """Test loading placeholders.""" + data = {"hello": yaml.Placeholder("test_name")} + assert yaml.parse_yaml(yaml.dump(data)) == data