From 6e5dcd8b8de2065c374afc30ebcfb95b29e245f9 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Wed, 29 May 2024 04:13:01 -0700 Subject: [PATCH] Support in blueprint schema for input sections (#110513) * initial commit for sections * updates * add description * fix test * rename collapsed key * New schema * update snapshots * Testing for sections * Validate no duplicate input keys across sections * rename all_inputs * Update homeassistant/components/blueprint/models.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/blueprint/const.py | 1 + homeassistant/components/blueprint/models.py | 13 +++-- homeassistant/components/blueprint/schemas.py | 50 ++++++++++++++++--- .../blueprint/snapshots/test_importer.ambr | 6 +-- tests/components/blueprint/test_models.py | 38 +++++++++----- tests/components/blueprint/test_schemas.py | 46 +++++++++++++++++ 6 files changed, 130 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/blueprint/const.py b/homeassistant/components/blueprint/const.py index 18433aa6ba6..ccbcd7a9d80 100644 --- a/homeassistant/components/blueprint/const.py +++ b/homeassistant/components/blueprint/const.py @@ -9,5 +9,6 @@ CONF_SOURCE_URL = "source_url" CONF_HOMEASSISTANT = "homeassistant" CONF_MIN_VERSION = "min_version" CONF_AUTHOR = "author" +CONF_COLLAPSED = "collapsed" DOMAIN = "blueprint" diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index 2475ccf8d14..414d4e55a9b 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -78,7 +78,7 @@ class Blueprint: self.domain = data_domain - missing = yaml.extract_inputs(data) - set(data[CONF_BLUEPRINT][CONF_INPUT]) + missing = yaml.extract_inputs(data) - set(self.inputs) if missing: raise InvalidBlueprint( @@ -95,8 +95,15 @@ class Blueprint: @property def inputs(self) -> dict[str, Any]: - """Return blueprint inputs.""" - return self.data[CONF_BLUEPRINT][CONF_INPUT] # type: ignore[no-any-return] + """Return flattened blueprint inputs.""" + inputs = {} + for key, value in self.data[CONF_BLUEPRINT][CONF_INPUT].items(): + if value and CONF_INPUT in value: + for key, value in value[CONF_INPUT].items(): + inputs[key] = value + else: + inputs[key] = value + return inputs @property def metadata(self) -> dict[str, Any]: diff --git a/homeassistant/components/blueprint/schemas.py b/homeassistant/components/blueprint/schemas.py index 390bb1ddc80..6aaa4091e07 100644 --- a/homeassistant/components/blueprint/schemas.py +++ b/homeassistant/components/blueprint/schemas.py @@ -8,6 +8,7 @@ from homeassistant.const import ( CONF_DEFAULT, CONF_DESCRIPTION, CONF_DOMAIN, + CONF_ICON, CONF_NAME, CONF_PATH, CONF_SELECTOR, @@ -18,6 +19,7 @@ from homeassistant.helpers import config_validation as cv, selector from .const import ( CONF_AUTHOR, CONF_BLUEPRINT, + CONF_COLLAPSED, CONF_HOMEASSISTANT, CONF_INPUT, CONF_MIN_VERSION, @@ -46,6 +48,23 @@ def version_validator(value: Any) -> str: return value +def unique_input_validator(inputs: Any) -> Any: + """Validate the inputs don't have duplicate keys under different sections.""" + all_inputs = set() + for key, value in inputs.items(): + if value and CONF_INPUT in value: + for key in value[CONF_INPUT]: + if key in all_inputs: + raise vol.Invalid(f"Duplicate use of input key {key} in blueprint.") + all_inputs.add(key) + else: + if key in all_inputs: + raise vol.Invalid(f"Duplicate use of input key {key} in blueprint.") + all_inputs.add(key) + + return inputs + + @callback def is_blueprint_config(config: Any) -> bool: """Return if it is a blueprint config.""" @@ -67,6 +86,21 @@ BLUEPRINT_INPUT_SCHEMA = vol.Schema( } ) +BLUEPRINT_INPUT_SECTION_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): str, + vol.Optional(CONF_ICON): str, + vol.Optional(CONF_DESCRIPTION): str, + vol.Optional(CONF_COLLAPSED): bool, + vol.Required(CONF_INPUT, default=dict): { + str: vol.Any( + None, + BLUEPRINT_INPUT_SCHEMA, + ) + }, + } +) + BLUEPRINT_SCHEMA = vol.Schema( { vol.Required(CONF_BLUEPRINT): vol.Schema( @@ -79,12 +113,16 @@ BLUEPRINT_SCHEMA = vol.Schema( vol.Optional(CONF_HOMEASSISTANT): { vol.Optional(CONF_MIN_VERSION): version_validator }, - vol.Optional(CONF_INPUT, default=dict): { - str: vol.Any( - None, - BLUEPRINT_INPUT_SCHEMA, - ) - }, + vol.Optional(CONF_INPUT, default=dict): vol.All( + { + str: vol.Any( + None, + BLUEPRINT_INPUT_SCHEMA, + BLUEPRINT_INPUT_SECTION_SCHEMA, + ) + }, + unique_input_validator, + ), } ), }, diff --git a/tests/components/blueprint/snapshots/test_importer.ambr b/tests/components/blueprint/snapshots/test_importer.ambr index 002d5204dc8..38cb3b485d4 100644 --- a/tests/components/blueprint/snapshots/test_importer.ambr +++ b/tests/components/blueprint/snapshots/test_importer.ambr @@ -1,6 +1,6 @@ # serializer version: 1 # name: test_extract_blueprint_from_community_topic - NodeDictClass({ + dict({ 'brightness': NodeDictClass({ 'default': 50, 'description': 'Brightness of the light(s) when turning on', @@ -97,7 +97,7 @@ }) # --- # name: test_fetch_blueprint_from_community_url - NodeDictClass({ + dict({ 'brightness': NodeDictClass({ 'default': 50, 'description': 'Brightness of the light(s) when turning on', @@ -194,7 +194,7 @@ }) # --- # name: test_fetch_blueprint_from_github_gist_url - NodeDictClass({ + dict({ 'light_entity': NodeDictClass({ 'name': 'Light', 'selector': dict({ diff --git a/tests/components/blueprint/test_models.py b/tests/components/blueprint/test_models.py index 96e72e2b4cc..ea811d8485b 100644 --- a/tests/components/blueprint/test_models.py +++ b/tests/components/blueprint/test_models.py @@ -26,24 +26,38 @@ def blueprint_1(): ) -@pytest.fixture -def blueprint_2(): +@pytest.fixture(params=[False, True]) +def blueprint_2(request): """Blueprint fixture with default inputs.""" - return models.Blueprint( - { - "blueprint": { - "name": "Hello", - "domain": "automation", - "source_url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml", + blueprint = { + "blueprint": { + "name": "Hello", + "domain": "automation", + "source_url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml", + "input": { + "test-input": {"name": "Name", "description": "Description"}, + "test-input-default": {"default": "test"}, + }, + }, + "example": Input("test-input"), + "example-default": Input("test-input-default"), + } + if request.param: + # Replace the inputs with inputs in sections. Test should otherwise behave the same. + blueprint["blueprint"]["input"] = { + "section-1": { + "name": "Section 1", "input": { "test-input": {"name": "Name", "description": "Description"}, - "test-input-default": {"default": "test"}, }, }, - "example": Input("test-input"), - "example-default": Input("test-input-default"), + "section-2": { + "input": { + "test-input-default": {"default": "test"}, + } + }, } - ) + return models.Blueprint(blueprint) @pytest.fixture diff --git a/tests/components/blueprint/test_schemas.py b/tests/components/blueprint/test_schemas.py index 0440a759f2f..70d599c9d01 100644 --- a/tests/components/blueprint/test_schemas.py +++ b/tests/components/blueprint/test_schemas.py @@ -52,6 +52,24 @@ _LOGGER = logging.getLogger(__name__) }, } }, + # With input sections + { + "blueprint": { + "name": "Test Name", + "domain": "automation", + "input": { + "section_a": { + "input": {"some_placeholder": None}, + }, + "section_b": { + "name": "Section", + "description": "A section with no inputs", + "input": {}, + }, + "some_placeholder_2": None, + }, + } + }, ], ) def test_blueprint_schema(blueprint) -> None: @@ -94,6 +112,34 @@ def test_blueprint_schema(blueprint) -> None: }, } }, + # Duplicate inputs in sections (1 of 2) + { + "blueprint": { + "name": "Test Name", + "domain": "automation", + "input": { + "section_a": { + "input": {"some_placeholder": None}, + }, + "section_b": { + "input": {"some_placeholder": None}, + }, + }, + } + }, + # Duplicate inputs in sections (2 of 2) + { + "blueprint": { + "name": "Test Name", + "domain": "automation", + "input": { + "section_a": { + "input": {"some_placeholder": None}, + }, + "some_placeholder": None, + }, + } + }, ], ) def test_blueprint_schema_invalid(blueprint) -> None: