From 43ba053030f6dd817a663f4bc85482f6347188c1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 20 Nov 2020 15:24:42 +0100 Subject: [PATCH] Add support for checking minimum HA version (#43350) --- homeassistant/components/blueprint/const.py | 2 + homeassistant/components/blueprint/models.py | 26 +++++++++-- homeassistant/components/blueprint/schemas.py | 27 +++++++++++- .../components/blueprint/websocket_api.py | 1 + homeassistant/helpers/selector.py | 5 +++ tests/components/blueprint/test_models.py | 30 ++++++++++++- tests/components/blueprint/test_schemas.py | 43 +++++++++++++++++++ .../blueprint/test_websocket_api.py | 1 + 8 files changed, 130 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/blueprint/const.py b/homeassistant/components/blueprint/const.py index d9e3839f026..60df20dda36 100644 --- a/homeassistant/components/blueprint/const.py +++ b/homeassistant/components/blueprint/const.py @@ -6,5 +6,7 @@ CONF_USE_BLUEPRINT = "use_blueprint" CONF_INPUT = "input" CONF_SOURCE_URL = "source_url" CONF_DESCRIPTION = "description" +CONF_HOMEASSISTANT = "homeassistant" +CONF_MIN_VERSION = "min_version" DOMAIN = "blueprint" diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index 1681b4ffd31..48390d0bd23 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -2,12 +2,13 @@ import asyncio import logging import pathlib -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, List, Optional, Union +from pkg_resources import parse_version import voluptuous as vol from voluptuous.humanize import humanize_error -from homeassistant.const import CONF_DOMAIN, CONF_NAME, CONF_PATH +from homeassistant.const import CONF_DOMAIN, CONF_NAME, CONF_PATH, __version__ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import placeholder @@ -16,7 +17,9 @@ from homeassistant.util import yaml from .const import ( BLUEPRINT_FOLDER, CONF_BLUEPRINT, + CONF_HOMEASSISTANT, CONF_INPUT, + CONF_MIN_VERSION, CONF_SOURCE_URL, CONF_USE_BLUEPRINT, DOMAIN, @@ -62,7 +65,7 @@ class Blueprint: self.domain = data_domain - missing = self.placeholders - set(data[CONF_BLUEPRINT].get(CONF_INPUT, {})) + missing = self.placeholders - set(data[CONF_BLUEPRINT][CONF_INPUT]) if missing: raise InvalidBlueprint( @@ -91,6 +94,23 @@ class Blueprint: """Dump blueprint as YAML.""" return yaml.dump(self.data) + @callback + def validate(self) -> Optional[List[str]]: + """Test if the Home Assistant installation supports this blueprint. + + Return list of errors if not valid. + """ + errors = [] + metadata = self.metadata + min_version = metadata.get(CONF_HOMEASSISTANT, {}).get(CONF_MIN_VERSION) + + if min_version is not None and parse_version(__version__) < parse_version( + min_version + ): + errors.append(f"Requires at least Home Assistant {min_version}") + + return errors or None + class BlueprintInputs: """Inputs for a blueprint.""" diff --git a/homeassistant/components/blueprint/schemas.py b/homeassistant/components/blueprint/schemas.py index b734476b85a..ed78b4d2b42 100644 --- a/homeassistant/components/blueprint/schemas.py +++ b/homeassistant/components/blueprint/schemas.py @@ -10,12 +10,34 @@ from homeassistant.helpers import config_validation as cv, selector from .const import ( CONF_BLUEPRINT, CONF_DESCRIPTION, + CONF_HOMEASSISTANT, CONF_INPUT, + CONF_MIN_VERSION, CONF_SOURCE_URL, CONF_USE_BLUEPRINT, ) +def version_validator(value): + """Validate a Home Assistant version.""" + if not isinstance(value, str): + raise vol.Invalid("Version needs to be a string") + + parts = value.split(".") + + if len(parts) != 3: + raise vol.Invalid("Version needs to be formatted as {major}.{minor}.{patch}") + + try: + parts = [int(p) for p in parts] + except ValueError: + raise vol.Invalid( + "Major, minor and patch version needs to be an integer" + ) from None + + return value + + @callback def is_blueprint_config(config: Any) -> bool: """Return if it is a blueprint config.""" @@ -43,6 +65,9 @@ BLUEPRINT_SCHEMA = vol.Schema( vol.Required(CONF_NAME): str, vol.Required(CONF_DOMAIN): str, vol.Optional(CONF_SOURCE_URL): cv.url, + vol.Optional(CONF_HOMEASSISTANT): { + vol.Optional(CONF_MIN_VERSION): version_validator + }, vol.Optional(CONF_INPUT, default=dict): { str: vol.Any( None, @@ -68,7 +93,7 @@ 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}, + vol.Required(CONF_INPUT, default=dict): {str: cv.match_all}, } ) }, diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py index 88aa00788be..1e6971d9bc8 100644 --- a/homeassistant/components/blueprint/websocket_api.py +++ b/homeassistant/components/blueprint/websocket_api.py @@ -85,6 +85,7 @@ async def ws_import_blueprint(hass, connection, msg): "blueprint": { "metadata": imported_blueprint.blueprint.metadata, }, + "validation_errors": imported_blueprint.blueprint.validate(), }, ) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index b00ae972c8e..5bf70f01bfa 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -40,7 +40,9 @@ class EntitySelector(Selector): CONFIG_SCHEMA = vol.Schema( { + # Integration that provided the entity vol.Optional("integration"): str, + # Domain the entity belongs to vol.Optional("domain"): str, } ) @@ -52,8 +54,11 @@ class DeviceSelector(Selector): CONFIG_SCHEMA = vol.Schema( { + # Integration linked to it with a config entry vol.Optional("integration"): str, + # Manufacturer of device vol.Optional("manufacturer"): str, + # Model of device vol.Optional("model"): str, } ) diff --git a/tests/components/blueprint/test_models.py b/tests/components/blueprint/test_models.py index c66ebcfceb6..48fbf617fa1 100644 --- a/tests/components/blueprint/test_models.py +++ b/tests/components/blueprint/test_models.py @@ -71,7 +71,7 @@ def test_blueprint_properties(blueprint_1): def test_blueprint_update_metadata(): - """Test properties.""" + """Test update metadata.""" bp = models.Blueprint( { "blueprint": { @@ -85,6 +85,34 @@ def test_blueprint_update_metadata(): assert bp.metadata["source_url"] == "http://bla.com" +def test_blueprint_validate(): + """Test validate blueprint.""" + assert ( + models.Blueprint( + { + "blueprint": { + "name": "Hello", + "domain": "automation", + }, + } + ).validate() + is None + ) + + assert ( + models.Blueprint( + { + "blueprint": { + "name": "Hello", + "domain": "automation", + "homeassistant": {"min_version": "100000.0.0"}, + }, + } + ).validate() + == ["Requires at least Home Assistant 100000.0.0"] + ) + + def test_blueprint_inputs(blueprint_1): """Test blueprint inputs.""" inputs = models.BlueprintInputs( diff --git a/tests/components/blueprint/test_schemas.py b/tests/components/blueprint/test_schemas.py index bf50bdad975..7c91c1e4117 100644 --- a/tests/components/blueprint/test_schemas.py +++ b/tests/components/blueprint/test_schemas.py @@ -31,6 +31,26 @@ _LOGGER = logging.getLogger(__name__) }, } }, + # With selector + { + "blueprint": { + "name": "Test Name", + "domain": "automation", + "input": { + "some_placeholder": {"selector": {"entity": {}}}, + }, + } + }, + # With min version + { + "blueprint": { + "name": "Test Name", + "domain": "automation", + "homeassistant": { + "min_version": "1000000.0.0", + }, + } + }, ), ) def test_blueprint_schema(blueprint): @@ -63,9 +83,32 @@ def test_blueprint_schema(blueprint): "input": {"some_placeholder": {"non_existing": "bla"}}, } }, + # Invalid version + { + "blueprint": { + "name": "Test Name", + "domain": "automation", + "homeassistant": { + "min_version": "1000000.invalid.0", + }, + } + }, ), ) def test_blueprint_schema_invalid(blueprint): """Test different schemas.""" with pytest.raises(vol.Invalid): schemas.BLUEPRINT_SCHEMA(blueprint) + + +@pytest.mark.parametrize( + "bp_instance", + ( + {"path": "hello.yaml"}, + {"path": "hello.yaml", "input": {}}, + {"path": "hello.yaml", "input": {"hello": None}}, + ), +) +def test_blueprint_instance_fields(bp_instance): + """Test blueprint instance fields.""" + schemas.BLUEPRINT_INSTANCE_FIELDS({"use_blueprint": bp_instance}) diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py index 2459b014c7b..c948494cca0 100644 --- a/tests/components/blueprint/test_websocket_api.py +++ b/tests/components/blueprint/test_websocket_api.py @@ -96,6 +96,7 @@ async def test_import_blueprint(hass, aioclient_mock, hass_ws_client): "name": "Call service based on event", }, }, + "validation_errors": None, }