From 1c7080d5c5692e517e5eface7c13f4bf97ccba60 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 11 Nov 2020 23:32:46 +0100 Subject: [PATCH] Add save and delete WS commands to blueprints (#42907) Co-authored-by: Paulus Schoutsen --- homeassistant/components/blueprint/const.py | 1 + homeassistant/components/blueprint/errors.py | 8 + homeassistant/components/blueprint/models.py | 39 +++++ homeassistant/components/blueprint/schemas.py | 24 ++- .../components/blueprint/websocket_api.py | 116 +++++++++++-- tests/components/blueprint/test_models.py | 24 ++- .../blueprint/test_websocket_api.py | 160 +++++++++++++++++- 7 files changed, 349 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/blueprint/const.py b/homeassistant/components/blueprint/const.py index fe4ee5b7ce6..d9e3839f026 100644 --- a/homeassistant/components/blueprint/const.py +++ b/homeassistant/components/blueprint/const.py @@ -5,5 +5,6 @@ CONF_BLUEPRINT = "blueprint" CONF_USE_BLUEPRINT = "use_blueprint" CONF_INPUT = "input" CONF_SOURCE_URL = "source_url" +CONF_DESCRIPTION = "description" DOMAIN = "blueprint" diff --git a/homeassistant/components/blueprint/errors.py b/homeassistant/components/blueprint/errors.py index dff65b5263d..4a12fde1c26 100644 --- a/homeassistant/components/blueprint/errors.py +++ b/homeassistant/components/blueprint/errors.py @@ -78,3 +78,11 @@ class MissingPlaceholder(BlueprintWithNameException): blueprint_name, f"Missing placeholder {', '.join(sorted(placeholder_names))}", ) + + +class FileAlreadyExists(BlueprintWithNameException): + """Error when file already exists.""" + + def __init__(self, domain: str, blueprint_name: str) -> None: + """Initialize blueprint exception.""" + super().__init__(domain, blueprint_name, "Blueprint already exists") diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index 06401a34d7d..1681b4ffd31 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -24,6 +24,7 @@ from .const import ( from .errors import ( BlueprintException, FailedToLoad, + FileAlreadyExists, InvalidBlueprint, InvalidBlueprintInputs, MissingPlaceholder, @@ -86,6 +87,10 @@ class Blueprint: if source_url is not None: self.data[CONF_BLUEPRINT][CONF_SOURCE_URL] = source_url + def yaml(self) -> str: + """Dump blueprint as YAML.""" + return yaml.dump(self.data) + class BlueprintInputs: """Inputs for a blueprint.""" @@ -229,3 +234,37 @@ class DomainBlueprints: inputs = BlueprintInputs(blueprint, config_with_blueprint) inputs.validate() return inputs + + async def async_remove_blueprint(self, blueprint_path: str) -> None: + """Remove a blueprint file.""" + path = pathlib.Path( + self.hass.config.path(BLUEPRINT_FOLDER, self.domain, blueprint_path) + ) + + await self.hass.async_add_executor_job(path.unlink) + self._blueprints[blueprint_path] = None + + def _create_file(self, blueprint: Blueprint, blueprint_path: str) -> None: + """Create blueprint file.""" + + path = pathlib.Path( + self.hass.config.path(BLUEPRINT_FOLDER, self.domain, blueprint_path) + ) + if path.exists(): + raise FileAlreadyExists(self.domain, blueprint_path) + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(blueprint.yaml()) + + async def async_add_blueprint( + self, blueprint: Blueprint, blueprint_path: str + ) -> None: + """Add a blueprint.""" + if not blueprint_path.endswith(".yaml"): + blueprint_path = f"{blueprint_path}.yaml" + + await self.hass.async_add_executor_job( + self._create_file, blueprint, blueprint_path + ) + + self._blueprints[blueprint_path] = blueprint diff --git a/homeassistant/components/blueprint/schemas.py b/homeassistant/components/blueprint/schemas.py index 275a2cf242a..e04bc99e4b7 100644 --- a/homeassistant/components/blueprint/schemas.py +++ b/homeassistant/components/blueprint/schemas.py @@ -7,7 +7,13 @@ 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 +from .const import ( + CONF_BLUEPRINT, + CONF_DESCRIPTION, + CONF_INPUT, + CONF_SOURCE_URL, + CONF_USE_BLUEPRINT, +) @callback @@ -22,14 +28,26 @@ def is_blueprint_instance_config(config: Any) -> bool: return isinstance(config, dict) and CONF_USE_BLUEPRINT in config +BLUEPRINT_INPUT_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): str, + vol.Optional(CONF_DESCRIPTION): str, + } +) + 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}, + vol.Optional(CONF_SOURCE_URL): cv.url, + vol.Optional(CONF_INPUT, default=dict): { + str: vol.Any( + None, + BLUEPRINT_INPUT_SCHEMA, + ) + }, } ), }, diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py index 51bec0eb2a0..88aa00788be 100644 --- a/homeassistant/components/blueprint/websocket_api.py +++ b/homeassistant/components/blueprint/websocket_api.py @@ -1,5 +1,4 @@ """Websocket API for blueprint.""" -import asyncio import logging from typing import Dict, Optional @@ -8,10 +7,13 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv +from homeassistant.util import yaml from . import importer, models from .const import DOMAIN +from .errors import FileAlreadyExists _LOGGER = logging.getLogger(__package__) @@ -21,12 +23,15 @@ 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_register_command(hass, ws_save_blueprint) + websocket_api.async_register_command(hass, ws_delete_blueprint) @websocket_api.async_response @websocket_api.websocket_command( { vol.Required("type"): "blueprint/list", + vol.Required("domain"): cv.string, } ) async def ws_list_blueprints(hass, connection, msg): @@ -36,21 +41,19 @@ async def ws_list_blueprints(hass, connection, msg): ) 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)} + if msg["domain"] not in domain_blueprints: + connection.send_result(msg["id"], results) + return - results[domain] = domain_results + domain_results = await domain_blueprints[msg["domain"]].async_get_blueprints() + + for path, value in domain_results.items(): + if isinstance(value, models.Blueprint): + results[path] = { + "metadata": value.metadata, + } + else: + results[path] = {"error": str(value)} connection.send_result(msg["id"], results) @@ -84,3 +87,86 @@ async def ws_import_blueprint(hass, connection, msg): }, }, ) + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "blueprint/save", + vol.Required("domain"): cv.string, + vol.Required("path"): cv.path, + vol.Required("yaml"): cv.string, + vol.Optional("source_url"): cv.url, + } +) +async def ws_save_blueprint(hass, connection, msg): + """Save a blueprint.""" + + path = msg["path"] + domain = msg["domain"] + + domain_blueprints: Optional[Dict[str, models.DomainBlueprints]] = hass.data.get( + DOMAIN, {} + ) + + if domain not in domain_blueprints: + connection.send_error( + msg["id"], websocket_api.ERR_INVALID_FORMAT, "Unsupported domain" + ) + + try: + blueprint = models.Blueprint( + yaml.parse_yaml(msg["yaml"]), expected_domain=domain + ) + if "source_url" in msg: + blueprint.update_metadata(source_url=msg["source_url"]) + except HomeAssistantError as err: + connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err)) + return + + try: + await domain_blueprints[domain].async_add_blueprint(blueprint, path) + except FileAlreadyExists: + connection.send_error(msg["id"], "already_exists", "File already exists") + return + except OSError as err: + connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err)) + return + + connection.send_result( + msg["id"], + ) + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "blueprint/delete", + vol.Required("domain"): cv.string, + vol.Required("path"): cv.path, + } +) +async def ws_delete_blueprint(hass, connection, msg): + """Delete a blueprint.""" + + path = msg["path"] + domain = msg["domain"] + + domain_blueprints: Optional[Dict[str, models.DomainBlueprints]] = hass.data.get( + DOMAIN, {} + ) + + if domain not in domain_blueprints: + connection.send_error( + msg["id"], websocket_api.ERR_INVALID_FORMAT, "Unsupported domain" + ) + + try: + await domain_blueprints[domain].async_remove_blueprint(path) + except OSError as err: + connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err)) + return + + connection.send_result( + msg["id"], + ) diff --git a/tests/components/blueprint/test_models.py b/tests/components/blueprint/test_models.py index 56fe13599d7..c66ebcfceb6 100644 --- a/tests/components/blueprint/test_models.py +++ b/tests/components/blueprint/test_models.py @@ -17,7 +17,10 @@ def blueprint_1(): "blueprint": { "name": "Hello", "domain": "automation", - "input": {"test-placeholder": None}, + "source_url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml", + "input": { + "test-placeholder": {"name": "Name", "description": "Description"} + }, }, "example": Placeholder("test-placeholder"), } @@ -59,7 +62,8 @@ def test_blueprint_properties(blueprint_1): assert blueprint_1.metadata == { "name": "Hello", "domain": "automation", - "input": {"test-placeholder": None}, + "source_url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml", + "input": {"test-placeholder": {"name": "Name", "description": "Description"}}, } assert blueprint_1.domain == "automation" assert blueprint_1.name == "Hello" @@ -152,3 +156,19 @@ async def test_domain_blueprints_inputs_from_config(domain_bps, blueprint_1): ) assert inputs.blueprint is blueprint_1 assert inputs.inputs == {"test-placeholder": None} + + +async def test_domain_blueprints_add_blueprint(domain_bps, blueprint_1): + """Test DomainBlueprints.async_add_blueprint.""" + with patch.object(domain_bps, "_create_file") as create_file_mock: + # Should add extension when not present. + await domain_bps.async_add_blueprint(blueprint_1, "something") + assert create_file_mock.call_args[0][1] == ("something.yaml") + + await domain_bps.async_add_blueprint(blueprint_1, "something2.yaml") + assert create_file_mock.call_args[0][1] == ("something2.yaml") + + # Should be in cache. + with patch.object(domain_bps, "_load_blueprint") as mock_load: + assert await domain_bps.async_get_blueprint("something.yaml") == blueprint_1 + assert not mock_load.mock_calls diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py index 54fcc0a5891..2459b014c7b 100644 --- a/tests/components/blueprint/test_websocket_api.py +++ b/tests/components/blueprint/test_websocket_api.py @@ -6,6 +6,8 @@ import pytest from homeassistant.components import automation from homeassistant.setup import async_setup_component +from tests.async_mock import Mock, patch + @pytest.fixture(autouse=True) async def setup_bp(hass): @@ -19,14 +21,14 @@ async def setup_bp(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"}) + await client.send_json({"id": 5, "type": "blueprint/list", "domain": "automation"}) msg = await client.receive_json() assert msg["id"] == 5 assert msg["success"] blueprints = msg["result"] - assert blueprints.get("automation") == { + assert blueprints == { "test_event_service.yaml": { "metadata": { "domain": "automation", @@ -44,8 +46,23 @@ async def test_list_blueprints(hass, hass_ws_client): } -async def test_import_blueprint(hass, aioclient_mock, hass_ws_client): +async def test_list_blueprints_non_existing_domain(hass, hass_ws_client): """Test listing blueprints.""" + client = await hass_ws_client(hass) + await client.send_json( + {"id": 5, "type": "blueprint/list", "domain": "not_existsing"} + ) + + msg = await client.receive_json() + + assert msg["id"] == 5 + assert msg["success"] + blueprints = msg["result"] + assert blueprints == {} + + +async def test_import_blueprint(hass, aioclient_mock, hass_ws_client): + """Test importing blueprints.""" raw_data = Path( hass.config.path("blueprints/automation/test_event_service.yaml") ).read_text() @@ -80,3 +97,140 @@ async def test_import_blueprint(hass, aioclient_mock, hass_ws_client): }, }, } + + +async def test_save_blueprint(hass, aioclient_mock, hass_ws_client): + """Test saving blueprints.""" + raw_data = Path( + hass.config.path("blueprints/automation/test_event_service.yaml") + ).read_text() + + with patch("pathlib.Path.write_text") as write_mock: + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 6, + "type": "blueprint/save", + "path": "test_save", + "yaml": raw_data, + "domain": "automation", + "source_url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml", + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 6 + assert msg["success"] + assert write_mock.mock_calls + assert write_mock.call_args[0] == ( + "blueprint:\n name: Call service based on event\n domain: automation\n input:\n trigger_event:\n service_to_call:\n source_url: https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\ntrigger:\n platform: event\n event_type: !placeholder 'trigger_event'\naction:\n service: !placeholder 'service_to_call'\n", + ) + + +async def test_save_existing_file(hass, aioclient_mock, hass_ws_client): + """Test saving blueprints.""" + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 7, + "type": "blueprint/save", + "path": "test_event_service", + "yaml": 'blueprint: {name: "name", domain: "automation"}', + "domain": "automation", + "source_url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml", + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 7 + assert not msg["success"] + assert msg["error"] == {"code": "already_exists", "message": "File already exists"} + + +async def test_save_file_error(hass, aioclient_mock, hass_ws_client): + """Test saving blueprints with OS error.""" + with patch("pathlib.Path.write_text", side_effect=OSError): + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 8, + "type": "blueprint/save", + "path": "test_save", + "yaml": "raw_data", + "domain": "automation", + "source_url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml", + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 8 + assert not msg["success"] + + +async def test_save_invalid_blueprint(hass, aioclient_mock, hass_ws_client): + """Test saving invalid blueprints.""" + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 8, + "type": "blueprint/save", + "path": "test_wrong", + "yaml": "wrong_blueprint", + "domain": "automation", + "source_url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml", + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 8 + assert not msg["success"] + assert msg["error"] == { + "code": "invalid_format", + "message": "Invalid blueprint: expected a dictionary. Got 'wrong_blueprint'", + } + + +async def test_delete_blueprint(hass, aioclient_mock, hass_ws_client): + """Test deleting blueprints.""" + + with patch("pathlib.Path.unlink", return_value=Mock()) as unlink_mock: + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 9, + "type": "blueprint/delete", + "path": "test_delete", + "domain": "automation", + } + ) + + msg = await client.receive_json() + + assert unlink_mock.mock_calls + assert msg["id"] == 9 + assert msg["success"] + + +async def test_delete_non_exist_file_blueprint(hass, aioclient_mock, hass_ws_client): + """Test deleting non existing blueprints.""" + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 9, + "type": "blueprint/delete", + "path": "none_existing", + "domain": "automation", + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 9 + assert not msg["success"]