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 <erik@montnemery.com>
This commit is contained in:
karwosts 2024-05-29 04:13:01 -07:00 committed by GitHub
parent d83ab7bb04
commit 6e5dcd8b8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 130 additions and 24 deletions

View File

@ -9,5 +9,6 @@ CONF_SOURCE_URL = "source_url"
CONF_HOMEASSISTANT = "homeassistant" CONF_HOMEASSISTANT = "homeassistant"
CONF_MIN_VERSION = "min_version" CONF_MIN_VERSION = "min_version"
CONF_AUTHOR = "author" CONF_AUTHOR = "author"
CONF_COLLAPSED = "collapsed"
DOMAIN = "blueprint" DOMAIN = "blueprint"

View File

@ -78,7 +78,7 @@ class Blueprint:
self.domain = data_domain 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: if missing:
raise InvalidBlueprint( raise InvalidBlueprint(
@ -95,8 +95,15 @@ class Blueprint:
@property @property
def inputs(self) -> dict[str, Any]: def inputs(self) -> dict[str, Any]:
"""Return blueprint inputs.""" """Return flattened blueprint inputs."""
return self.data[CONF_BLUEPRINT][CONF_INPUT] # type: ignore[no-any-return] 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 @property
def metadata(self) -> dict[str, Any]: def metadata(self) -> dict[str, Any]:

View File

@ -8,6 +8,7 @@ from homeassistant.const import (
CONF_DEFAULT, CONF_DEFAULT,
CONF_DESCRIPTION, CONF_DESCRIPTION,
CONF_DOMAIN, CONF_DOMAIN,
CONF_ICON,
CONF_NAME, CONF_NAME,
CONF_PATH, CONF_PATH,
CONF_SELECTOR, CONF_SELECTOR,
@ -18,6 +19,7 @@ from homeassistant.helpers import config_validation as cv, selector
from .const import ( from .const import (
CONF_AUTHOR, CONF_AUTHOR,
CONF_BLUEPRINT, CONF_BLUEPRINT,
CONF_COLLAPSED,
CONF_HOMEASSISTANT, CONF_HOMEASSISTANT,
CONF_INPUT, CONF_INPUT,
CONF_MIN_VERSION, CONF_MIN_VERSION,
@ -46,6 +48,23 @@ def version_validator(value: Any) -> str:
return value 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 @callback
def is_blueprint_config(config: Any) -> bool: def is_blueprint_config(config: Any) -> bool:
"""Return if it is a blueprint config.""" """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( BLUEPRINT_SCHEMA = vol.Schema(
{ {
vol.Required(CONF_BLUEPRINT): vol.Schema( vol.Required(CONF_BLUEPRINT): vol.Schema(
@ -79,12 +113,16 @@ BLUEPRINT_SCHEMA = vol.Schema(
vol.Optional(CONF_HOMEASSISTANT): { vol.Optional(CONF_HOMEASSISTANT): {
vol.Optional(CONF_MIN_VERSION): version_validator vol.Optional(CONF_MIN_VERSION): version_validator
}, },
vol.Optional(CONF_INPUT, default=dict): { vol.Optional(CONF_INPUT, default=dict): vol.All(
str: vol.Any( {
None, str: vol.Any(
BLUEPRINT_INPUT_SCHEMA, None,
) BLUEPRINT_INPUT_SCHEMA,
}, BLUEPRINT_INPUT_SECTION_SCHEMA,
)
},
unique_input_validator,
),
} }
), ),
}, },

View File

@ -1,6 +1,6 @@
# serializer version: 1 # serializer version: 1
# name: test_extract_blueprint_from_community_topic # name: test_extract_blueprint_from_community_topic
NodeDictClass({ dict({
'brightness': NodeDictClass({ 'brightness': NodeDictClass({
'default': 50, 'default': 50,
'description': 'Brightness of the light(s) when turning on', 'description': 'Brightness of the light(s) when turning on',
@ -97,7 +97,7 @@
}) })
# --- # ---
# name: test_fetch_blueprint_from_community_url # name: test_fetch_blueprint_from_community_url
NodeDictClass({ dict({
'brightness': NodeDictClass({ 'brightness': NodeDictClass({
'default': 50, 'default': 50,
'description': 'Brightness of the light(s) when turning on', 'description': 'Brightness of the light(s) when turning on',
@ -194,7 +194,7 @@
}) })
# --- # ---
# name: test_fetch_blueprint_from_github_gist_url # name: test_fetch_blueprint_from_github_gist_url
NodeDictClass({ dict({
'light_entity': NodeDictClass({ 'light_entity': NodeDictClass({
'name': 'Light', 'name': 'Light',
'selector': dict({ 'selector': dict({

View File

@ -26,24 +26,38 @@ def blueprint_1():
) )
@pytest.fixture @pytest.fixture(params=[False, True])
def blueprint_2(): def blueprint_2(request):
"""Blueprint fixture with default inputs.""" """Blueprint fixture with default inputs."""
return models.Blueprint( blueprint = {
{ "blueprint": {
"blueprint": { "name": "Hello",
"name": "Hello", "domain": "automation",
"domain": "automation", "source_url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml",
"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": { "input": {
"test-input": {"name": "Name", "description": "Description"}, "test-input": {"name": "Name", "description": "Description"},
"test-input-default": {"default": "test"},
}, },
}, },
"example": Input("test-input"), "section-2": {
"example-default": Input("test-input-default"), "input": {
"test-input-default": {"default": "test"},
}
},
} }
) return models.Blueprint(blueprint)
@pytest.fixture @pytest.fixture

View File

@ -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: 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: def test_blueprint_schema_invalid(blueprint) -> None: