UI Schema / Addon options (#1441)

* UI schema for add-on options

* Fix lint

* Add tests

* Address comments
This commit is contained in:
Pascal Vizeli 2020-01-20 15:13:17 +01:00 committed by GitHub
parent b1e768f69e
commit f55c10914e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 214 additions and 4 deletions

1
API.md
View File

@ -506,6 +506,7 @@ Get all available addons.
"boot": "auto|manual", "boot": "auto|manual",
"build": "bool", "build": "bool",
"options": "{}", "options": "{}",
"schema": "{}|null",
"network": "{}|null", "network": "{}|null",
"network_description": "{}|null", "network_description": "{}|null",
"host_network": "bool", "host_network": "bool",

View File

@ -62,7 +62,7 @@ from ..const import (
SECURITY_PROFILE, SECURITY_PROFILE,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from .validate import RE_SERVICE, RE_VOLUME, validate_options from .validate import RE_SERVICE, RE_VOLUME, validate_options, schema_ui_options
Data = Dict[str, Any] Data = Dict[str, Any]
@ -485,6 +485,15 @@ class AddonModel(CoreSysAttributes):
return vol.Schema(dict) return vol.Schema(dict)
return vol.Schema(vol.All(dict, validate_options(self.coresys, raw_schema))) return vol.Schema(vol.All(dict, validate_options(self.coresys, raw_schema)))
@property
def schema_ui(self) -> Optional[List[Dict[str, Any]]]:
"""Create a UI schema for add-on options."""
raw_schema = self.data[ATTR_SCHEMA]
if isinstance(raw_schema, bool):
return None
return schema_ui_options(raw_schema)
def __eq__(self, other): def __eq__(self, other):
"""Compaired add-on objects.""" """Compaired add-on objects."""
if not isinstance(other, AddonModel): if not isinstance(other, AddonModel):

View File

@ -2,7 +2,7 @@
import logging import logging
import re import re
import secrets import secrets
from typing import Any, Dict from typing import Any, Dict, List
import uuid import uuid
import voluptuous as vol import voluptuous as vol
@ -109,6 +109,7 @@ V_STR = "str"
V_INT = "int" V_INT = "int"
V_FLOAT = "float" V_FLOAT = "float"
V_BOOL = "bool" V_BOOL = "bool"
V_PASSWORD = "password"
V_EMAIL = "email" V_EMAIL = "email"
V_URL = "url" V_URL = "url"
V_PORT = "port" V_PORT = "port"
@ -119,6 +120,7 @@ RE_SCHEMA_ELEMENT = re.compile(
r"^(?:" r"^(?:"
r"|bool|email|url|port" r"|bool|email|url|port"
r"|str(?:\((?P<s_min>\d+)?,(?P<s_max>\d+)?\))?" r"|str(?:\((?P<s_min>\d+)?,(?P<s_max>\d+)?\))?"
r"|password(?:\((?P<p_min>\d+)?,(?P<p_max>\d+)?\))?"
r"|int(?:\((?P<i_min>\d+)?,(?P<i_max>\d+)?\))?" r"|int(?:\((?P<i_min>\d+)?,(?P<i_max>\d+)?\))?"
r"|float(?:\((?P<f_min>[\d\.]+)?,(?P<f_max>[\d\.]+)?\))?" r"|float(?:\((?P<f_min>[\d\.]+)?,(?P<f_max>[\d\.]+)?\))?"
r"|match\((?P<match>.*)\)" r"|match\((?P<match>.*)\)"
@ -126,7 +128,16 @@ RE_SCHEMA_ELEMENT = re.compile(
r")\??$" r")\??$"
) )
_SCHEMA_LENGTH_PARTS = ("i_min", "i_max", "f_min", "f_max", "s_min", "s_max") _SCHEMA_LENGTH_PARTS = (
"i_min",
"i_max",
"f_min",
"f_max",
"s_min",
"s_max",
"p_min",
"p_max",
)
RE_DOCKER_IMAGE = re.compile(r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)$") RE_DOCKER_IMAGE = re.compile(r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)$")
RE_DOCKER_IMAGE_BUILD = re.compile( RE_DOCKER_IMAGE_BUILD = re.compile(
@ -375,7 +386,7 @@ def _single_validate(coresys: CoreSys, typ: str, value: Any, key: str):
if group_value: if group_value:
range_args[group_name[2:]] = float(group_value) range_args[group_name[2:]] = float(group_value)
if typ.startswith(V_STR): if typ.startswith(V_STR) or typ.startswith(V_PASSWORD):
return vol.All(str(value), vol.Range(**range_args))(value) return vol.All(str(value), vol.Range(**range_args))(value)
elif typ.startswith(V_INT): elif typ.startswith(V_INT):
return vol.All(vol.Coerce(int), vol.Range(**range_args))(value) return vol.All(vol.Coerce(int), vol.Range(**range_args))(value)
@ -441,3 +452,117 @@ def _check_missing_options(origin, exists, root):
if isinstance(origin[miss_opt], str) and origin[miss_opt].endswith("?"): if isinstance(origin[miss_opt], str) and origin[miss_opt].endswith("?"):
continue continue
raise vol.Invalid(f"Missing option {miss_opt} in {root}") raise vol.Invalid(f"Missing option {miss_opt} in {root}")
def schema_ui_options(raw_schema: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Generate UI schema."""
ui_schema = []
# read options
for key, value in raw_schema.items():
if isinstance(value, list):
# nested value list
_nested_ui_list(ui_schema, value, key)
elif isinstance(value, dict):
# nested value dict
_nested_ui_dict(ui_schema, value, key)
else:
# normal value
_single_ui_option(ui_schema, value, key)
return ui_schema
def _single_ui_option(
ui_schema: List[Dict[str, Any]], value: str, key: str, multiple: bool = False
) -> None:
"""Validate a single element."""
ui_node = {"name": key}
# If multible
if multiple:
ui_node["mutliple"] = True
# Parse extend data from type
match = RE_SCHEMA_ELEMENT.match(value)
# Prepare range
for group_name in _SCHEMA_LENGTH_PARTS:
group_value = match.group(group_name)
if not group_value:
continue
if group_name[2:] == "min":
ui_node["lengthMin"] = float(group_value)
elif group_name[2:] == "max":
ui_node["lengthMax"] = float(group_value)
# If required
if value.endswith("?"):
ui_node["optional"] = True
else:
ui_node["required"] = True
# Data types
if value.startswith(V_STR):
ui_node["type"] = "string"
elif value.startswith(V_PASSWORD):
ui_node["type"] = "string"
ui_node["format"] = "password"
elif value.startswith(V_INT):
ui_node["type"] = "integer"
elif value.startswith(V_FLOAT):
ui_node["type"] = "float"
elif value.startswith(V_BOOL):
ui_node["type"] = "boolean"
elif value.startswith(V_EMAIL):
ui_node["type"] = "string"
ui_node["format"] = "email"
elif value.startswith(V_URL):
ui_node["type"] = "string"
ui_node["format"] = "url"
elif value.startswith(V_PORT):
ui_node["type"] = "integer"
elif value.startswith(V_MATCH):
ui_node["type"] = "string"
elif value.startswith(V_LIST):
ui_node["type"] = "select"
ui_node["options"] = match.group("list").split("|")
ui_schema.append(ui_node)
def _nested_ui_list(
ui_schema: List[Dict[str, Any]], option_list: List[Any], key: str
) -> None:
"""UI nested list items."""
try:
element = option_list[0]
except IndexError:
_LOGGER.error("Invalid schema %s", key)
return
if isinstance(element, dict):
_nested_ui_dict(ui_schema, element, key, multiple=True)
else:
_single_ui_option(ui_schema, element, key, multiple=True)
def _nested_ui_dict(
ui_schema: List[Dict[str, Any]],
option_dict: Dict[str, Any],
key: str,
multiple: bool = False,
) -> None:
"""UI nested dict items."""
ui_node = {"name": key, "type": "schema", "optional": True, "multiple": multiple}
nested_schema = []
for c_key, c_value in option_dict.items():
# Nested?
if isinstance(c_value, list):
_nested_ui_list(nested_schema, c_value, c_key)
else:
_single_ui_option(nested_schema, c_value, c_key)
ui_node["schema"] = nested_schema
ui_schema.append(ui_node)

View File

@ -72,6 +72,7 @@ from ..const import (
ATTR_RATING, ATTR_RATING,
ATTR_REPOSITORIES, ATTR_REPOSITORIES,
ATTR_REPOSITORY, ATTR_REPOSITORY,
ATTR_SCHEMA,
ATTR_SERVICES, ATTR_SERVICES,
ATTR_SLUG, ATTR_SLUG,
ATTR_SOURCE, ATTR_SOURCE,
@ -200,6 +201,7 @@ class APIAddons(CoreSysAttributes):
ATTR_RATING: rating_security(addon), ATTR_RATING: rating_security(addon),
ATTR_BOOT: addon.boot, ATTR_BOOT: addon.boot,
ATTR_OPTIONS: addon.options, ATTR_OPTIONS: addon.options,
ATTR_SCHEMA: addon.schema_ui,
ATTR_ARCH: addon.supported_arch, ATTR_ARCH: addon.supported_arch,
ATTR_MACHINE: addon.supported_machine, ATTR_MACHINE: addon.supported_machine,
ATTR_HOMEASSISTANT: addon.homeassistant_version, ATTR_HOMEASSISTANT: addon.homeassistant_version,

View File

@ -0,0 +1,73 @@
"""Test add-ons schema to UI schema convertion."""
from hassio.addons.validate import schema_ui_options
def test_simple_schema():
"""Test with simple schema."""
assert schema_ui_options(
{"name": "str", "password": "password", "fires": "bool", "alias": "str?"}
) == [
{"name": "name", "required": True, "type": "string"},
{"format": "password", "name": "password", "required": True, "type": "string"},
{"name": "fires", "required": True, "type": "boolean"},
{"name": "alias", "optional": True, "type": "string"},
]
def test_group_schema():
"""Test with group schema."""
assert schema_ui_options(
{
"name": "str",
"password": "password",
"fires": "bool",
"alias": "str?",
"extended": {"name": "str", "data": ["str"], "path": "str?"},
}
) == [
{"name": "name", "required": True, "type": "string"},
{"format": "password", "name": "password", "required": True, "type": "string"},
{"name": "fires", "required": True, "type": "boolean"},
{"name": "alias", "optional": True, "type": "string"},
{
"multiple": False,
"name": "extended",
"optional": True,
"schema": [
{"name": "name", "required": True, "type": "string"},
{"mutliple": True, "name": "data", "required": True, "type": "string"},
{"name": "path", "optional": True, "type": "string"},
],
"type": "schema",
},
]
def test_group_list():
"""Test with group schema."""
assert schema_ui_options(
{
"name": "str",
"password": "password",
"fires": "bool",
"alias": "str?",
"extended": [{"name": "str", "data": ["str?"], "path": "str?"}],
}
) == [
{"name": "name", "required": True, "type": "string"},
{"format": "password", "name": "password", "required": True, "type": "string"},
{"name": "fires", "required": True, "type": "boolean"},
{"name": "alias", "optional": True, "type": "string"},
{
"multiple": True,
"name": "extended",
"optional": True,
"schema": [
{"name": "name", "required": True, "type": "string"},
{"mutliple": True, "name": "data", "optional": True, "type": "string"},
{"name": "path", "optional": True, "type": "string"},
],
"type": "schema",
},
]