Add initial blueprint support (#42469)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
Paulus Schoutsen 2020-11-02 15:00:13 +01:00 committed by GitHub
parent a84dc14569
commit 0fb587727c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 2144 additions and 22 deletions

View file

@ -64,6 +64,7 @@ homeassistant/components/bitcoin/* @fabaff
homeassistant/components/bizkaibus/* @UgaitzEtxebarria homeassistant/components/bizkaibus/* @UgaitzEtxebarria
homeassistant/components/blebox/* @gadgetmobile homeassistant/components/blebox/* @gadgetmobile
homeassistant/components/blink/* @fronzbot homeassistant/components/blink/* @fronzbot
homeassistant/components/blueprint/* @home-assistant/core
homeassistant/components/bmp280/* @belidzs homeassistant/components/bmp280/* @belidzs
homeassistant/components/bmw_connected_drive/* @gerard33 @rikroe homeassistant/components/bmw_connected_drive/* @gerard33 @rikroe
homeassistant/components/bond/* @prystupa homeassistant/components/bond/* @prystupa

View file

@ -1,9 +1,11 @@
"""Allow to set up simple automation rules via the config file.""" """Allow to set up simple automation rules via the config file."""
import logging 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 import voluptuous as vol
from voluptuous.humanize import humanize_error
from homeassistant.components import blueprint
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
ATTR_NAME, ATTR_NAME,
@ -47,6 +49,7 @@ from homeassistant.helpers.script import (
) )
from homeassistant.helpers.script_variables import ScriptVariables from homeassistant.helpers.script_variables import ScriptVariables
from homeassistant.helpers.service import async_register_admin_service 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.trigger import async_initialize_triggers
from homeassistant.helpers.typing import TemplateVarsType from homeassistant.helpers.typing import TemplateVarsType
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
@ -58,7 +61,7 @@ from homeassistant.util.dt import parse_datetime
DOMAIN = "automation" DOMAIN = "automation"
ENTITY_ID_FORMAT = DOMAIN + ".{}" ENTITY_ID_FORMAT = DOMAIN + ".{}"
GROUP_NAME_ALL_AUTOMATIONS = "all automations" DATA_BLUEPRINTS = "automation_blueprints"
CONF_DESCRIPTION = "description" CONF_DESCRIPTION = "description"
CONF_HIDE_ENTITY = "hide_entity" CONF_HIDE_ENTITY = "hide_entity"
@ -70,13 +73,9 @@ CONF_CONDITION_TYPE = "condition_type"
CONF_INITIAL_STATE = "initial_state" CONF_INITIAL_STATE = "initial_state"
CONF_SKIP_CONDITION = "skip_condition" CONF_SKIP_CONDITION = "skip_condition"
CONF_STOP_ACTIONS = "stop_actions" 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_INITIAL_STATE = True
DEFAULT_STOP_ACTIONS = 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 @bind_hass
def is_on(hass, entity_id): def is_on(hass, entity_id):
""" """
@ -221,6 +227,7 @@ async def async_setup(hass, config):
conf = await component.async_prepare_reload() conf = await component.async_prepare_reload()
if conf is None: if conf is None:
return return
async_get_blueprints(hass).async_reset_cache()
await _async_process_config(hass, conf, component) await _async_process_config(hass, conf, component)
hass.bus.async_fire(EVENT_AUTOMATION_RELOADED, context=service_call.context) hass.bus.async_fire(EVENT_AUTOMATION_RELOADED, context=service_call.context)
@ -506,7 +513,11 @@ class AutomationEntity(ToggleEntity, RestoreEntity):
return {CONF_ID: self._id} 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. """Process config and add automations.
This method is a coroutine. This method is a coroutine.
@ -514,9 +525,28 @@ async def _async_process_config(hass, config, component):
entities = [] entities = []
for config_key in extract_domain_configs(config, DOMAIN): 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): 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) automation_id = config_block.get(CONF_ID)
name = config_block.get(CONF_ALIAS) or f"{config_key} {list_no}" name = config_block.get(CONF_ALIAS) or f"{config_key} {list_no}"

View file

@ -3,6 +3,7 @@ import asyncio
import voluptuous as vol import voluptuous as vol
from homeassistant.components import blueprint
from homeassistant.components.device_automation.exceptions import ( from homeassistant.components.device_automation.exceptions import (
InvalidDeviceAutomationConfig, 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.helpers.trigger import async_validate_trigger_config
from homeassistant.loader import IntegrationNotFound 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: allow-untyped-calls, allow-untyped-defs
# mypy: no-check-untyped-defs, no-warn-return-any # 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): async def async_validate_config_item(hass, config, full_config=None):
"""Validate config item.""" """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 = PLATFORM_SCHEMA(config)
config[CONF_TRIGGER] = await async_validate_trigger_config( config[CONF_TRIGGER] = await async_validate_trigger_config(

View file

@ -2,6 +2,7 @@
"domain": "automation", "domain": "automation",
"name": "Automation", "name": "Automation",
"documentation": "https://www.home-assistant.io/integrations/automation", "documentation": "https://www.home-assistant.io/integrations/automation",
"dependencies": ["blueprint"],
"after_dependencies": [ "after_dependencies": [
"device_automation", "device_automation",
"webhook" "webhook"

View file

@ -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

View file

@ -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"

View file

@ -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))}",
)

View file

@ -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<topic>\d+)(?:/(?P<post>\d+)|)$"
)
COMMUNITY_CODE_BLOCK = re.compile(
r'<code class="lang-(?P<syntax>[a-z]+)">(?P<content>(?:.|\n)*)</code>', re.MULTILINE
)
GITHUB_FILE_PATTERN = re.compile(
r"^https://github.com/(?P<repository>.+)/blob/(?P<path>.+)$"
)
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")

View file

@ -0,0 +1,9 @@
{
"domain": "blueprint",
"name": "Blueprint",
"documentation": "https://www.home-assistant.io/integrations/blueprint",
"codeowners": [
"@home-assistant/core"
],
"quality_scale": "internal"
}

View file

@ -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

View file

@ -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,
)

View file

@ -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,
},
},
)

View file

@ -87,7 +87,7 @@ from homeassistant.helpers import (
template as template_helper, template as template_helper,
) )
from homeassistant.helpers.logging import KeywordStyleAdapter 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 import homeassistant.util.dt as dt_util
# pylint: disable=invalid-name # pylint: disable=invalid-name
@ -113,6 +113,17 @@ port = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535))
T = TypeVar("T") 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: # Adapted from:
# https://github.com/alecthomas/voluptuous/issues/115#issuecomment-144464666 # https://github.com/alecthomas/voluptuous/issues/115#issuecomment-144464666
def has_at_least_one_key(*keys: str) -> Callable: def has_at_least_one_key(*keys: str) -> Callable:

View file

@ -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

View file

@ -1,14 +1,17 @@
"""YAML utility functions.""" """YAML utility functions."""
from .const import _SECRET_NAMESPACE, SECRET_YAML from .const import _SECRET_NAMESPACE, SECRET_YAML
from .dumper import dump, save_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__ = [ __all__ = [
"SECRET_YAML", "SECRET_YAML",
"_SECRET_NAMESPACE", "_SECRET_NAMESPACE",
"Placeholder",
"dump", "dump",
"save_yaml", "save_yaml",
"clear_secret_cache", "clear_secret_cache",
"load_yaml", "load_yaml",
"secret_yaml", "secret_yaml",
"parse_yaml",
] ]

View file

@ -3,7 +3,7 @@ from collections import OrderedDict
import yaml import yaml
from .objects import NodeListClass from .objects import NodeListClass, Placeholder
# mypy: allow-untyped-calls, no-warn-return-any # mypy: allow-untyped-calls, no-warn-return-any
@ -60,3 +60,8 @@ yaml.SafeDumper.add_representer(
NodeListClass, NodeListClass,
lambda dumper, value: dumper.represent_sequence("tag:yaml.org,2002:seq", value), 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),
)

View file

@ -4,14 +4,14 @@ import fnmatch
import logging import logging
import os import os
import sys import sys
from typing import Dict, Iterator, List, TypeVar, Union, overload from typing import Dict, Iterator, List, TextIO, TypeVar, Union, overload
import yaml import yaml
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from .const import _SECRET_NAMESPACE, SECRET_YAML from .const import _SECRET_NAMESPACE, SECRET_YAML
from .objects import NodeListClass, NodeStrClass from .objects import NodeListClass, NodeStrClass, Placeholder
try: try:
import keyring import keyring
@ -56,17 +56,23 @@ def load_yaml(fname: str) -> JSON_TYPE:
"""Load a YAML file.""" """Load a YAML file."""
try: try:
with open(fname, encoding="utf-8") as conf_file: with open(fname, encoding="utf-8") as conf_file:
# If configuration file is empty YAML returns None return parse_yaml(conf_file)
# 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
except UnicodeDecodeError as exc: except UnicodeDecodeError as exc:
_LOGGER.error("Unable to read file %s: %s", fname, exc) _LOGGER.error("Unable to read file %s: %s", fname, exc)
raise HomeAssistantError(exc) from 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 @overload
def _add_reference( def _add_reference(
obj: Union[list, NodeListClass], loader: yaml.SafeLoader, node: yaml.nodes.Node 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( yaml.SafeLoader.add_constructor(
"!include_dir_merge_named", _include_dir_merge_named_yaml "!include_dir_merge_named", _include_dir_merge_named_yaml
) )
yaml.SafeLoader.add_constructor("!placeholder", Placeholder.from_node)

View file

@ -1,4 +1,7 @@
"""Custom yaml object types.""" """Custom yaml object types."""
from dataclasses import dataclass
import yaml
class NodeListClass(list): class NodeListClass(list):
@ -7,3 +10,15 @@ class NodeListClass(list):
class NodeStrClass(str): class NodeStrClass(str):
"""Wrapper class to be able to add attributes on a string.""" """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)

View file

@ -1232,3 +1232,25 @@ async def test_automation_variables(hass, caplog):
hass.bus.async_fire("test_event_3", {"break": 0}) hass.bus.async_fire("test_event_3", {"break": 0})
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(calls) == 3 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

View file

@ -0,0 +1 @@
"""Tests for the blueprint integration."""

View file

@ -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": '<code class="lang-yaml">invalid: yaml: 2</code>'}
]
}
},
)
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": '<code class="lang-php">invalid yaml + 2</code>'}
]
}
},
)
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",
}

View file

@ -0,0 +1 @@
"""Tests for the blueprint init."""

View file

@ -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}

View file

@ -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)

View file

@ -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",
},
},
}

View file

@ -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"
}
}
}

View file

@ -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]}
)

View file

@ -0,0 +1,8 @@
blueprint:
name: "In Folder Blueprint"
domain: automation
input:
trigger:
action:
trigger: !placeholder trigger
action: !placeholder action

View file

@ -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

View file

@ -461,3 +461,20 @@ def test_duplicate_key(caplog):
with patch_yaml_files(files): with patch_yaml_files(files):
load_yaml_config_file(YAML_CONFIG_FILE) load_yaml_config_file(YAML_CONFIG_FILE)
assert "contains duplicate key" in caplog.text 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