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:
parent
a84dc14569
commit
0fb587727c
30 changed files with 2144 additions and 22 deletions
|
@ -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
|
||||
|
|
|
@ -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}"
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
"domain": "automation",
|
||||
"name": "Automation",
|
||||
"documentation": "https://www.home-assistant.io/integrations/automation",
|
||||
"dependencies": ["blueprint"],
|
||||
"after_dependencies": [
|
||||
"device_automation",
|
||||
"webhook"
|
||||
|
|
19
homeassistant/components/blueprint/__init__.py
Normal file
19
homeassistant/components/blueprint/__init__.py
Normal 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
|
9
homeassistant/components/blueprint/const.py
Normal file
9
homeassistant/components/blueprint/const.py
Normal 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"
|
80
homeassistant/components/blueprint/errors.py
Normal file
80
homeassistant/components/blueprint/errors.py
Normal 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))}",
|
||||
)
|
177
homeassistant/components/blueprint/importer.py
Normal file
177
homeassistant/components/blueprint/importer.py
Normal 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")
|
9
homeassistant/components/blueprint/manifest.json
Normal file
9
homeassistant/components/blueprint/manifest.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"domain": "blueprint",
|
||||
"name": "Blueprint",
|
||||
"documentation": "https://www.home-assistant.io/integrations/blueprint",
|
||||
"codeowners": [
|
||||
"@home-assistant/core"
|
||||
],
|
||||
"quality_scale": "internal"
|
||||
}
|
231
homeassistant/components/blueprint/models.py
Normal file
231
homeassistant/components/blueprint/models.py
Normal 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
|
57
homeassistant/components/blueprint/schemas.py
Normal file
57
homeassistant/components/blueprint/schemas.py
Normal 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,
|
||||
)
|
86
homeassistant/components/blueprint/websocket_api.py
Normal file
86
homeassistant/components/blueprint/websocket_api.py
Normal 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,
|
||||
},
|
||||
},
|
||||
)
|
|
@ -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:
|
||||
|
|
53
homeassistant/helpers/placeholder.py
Normal file
53
homeassistant/helpers/placeholder.py
Normal 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
|
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
1
tests/components/blueprint/__init__.py
Normal file
1
tests/components/blueprint/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the blueprint integration."""
|
137
tests/components/blueprint/test_importer.py
Normal file
137
tests/components/blueprint/test_importer.py
Normal 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",
|
||||
}
|
1
tests/components/blueprint/test_init.py
Normal file
1
tests/components/blueprint/test_init.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the blueprint init."""
|
154
tests/components/blueprint/test_models.py
Normal file
154
tests/components/blueprint/test_models.py
Normal 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}
|
71
tests/components/blueprint/test_schemas.py
Normal file
71
tests/components/blueprint/test_schemas.py
Normal 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)
|
82
tests/components/blueprint/test_websocket_api.py
Normal file
82
tests/components/blueprint/test_websocket_api.py
Normal 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",
|
||||
},
|
||||
},
|
||||
}
|
783
tests/fixtures/blueprint/community_post.json
vendored
Normal file
783
tests/fixtures/blueprint/community_post.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
29
tests/helpers/test_placeholder.py
Normal file
29
tests/helpers/test_placeholder.py
Normal 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]}
|
||||
)
|
|
@ -0,0 +1,8 @@
|
|||
blueprint:
|
||||
name: "In Folder Blueprint"
|
||||
domain: automation
|
||||
input:
|
||||
trigger:
|
||||
action:
|
||||
trigger: !placeholder trigger
|
||||
action: !placeholder action
|
|
@ -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
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue