Skip to content

Commit ac9947d

Browse files
authored
Allow deeply nested dicts and lists in addon config schemas (#6171)
* Allow arbitrarily nested addon config schemas * Disallow lists directly nested in another list in addon schema * Handle arbitrarily nested addon schemas in UiOptions class * Handle arbitrarily nested addon schemas in AddonOptions class * Add tests for addon config schemas * Add tests for addon option validation
1 parent 2e22e1e commit ac9947d

File tree

4 files changed

+208
-56
lines changed

4 files changed

+208
-56
lines changed

supervisor/addons/options.py

Lines changed: 42 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,7 @@ def __call__(self, struct):
9393

9494
typ = self.raw_schema[key]
9595
try:
96-
if isinstance(typ, list):
97-
# nested value list
98-
options[key] = self._nested_validate_list(typ[0], value, key)
99-
elif isinstance(typ, dict):
100-
# nested value dict
101-
options[key] = self._nested_validate_dict(typ, value, key)
102-
else:
103-
# normal value
104-
options[key] = self._single_validate(typ, value, key)
96+
options[key] = self._validate_element(typ, value, key)
10597
except (IndexError, KeyError):
10698
raise vol.Invalid(
10799
f"Type error for option '{key}' in {self._name} ({self._slug})"
@@ -111,7 +103,20 @@ def __call__(self, struct):
111103
return options
112104

113105
# pylint: disable=no-value-for-parameter
114-
def _single_validate(self, typ: str, value: Any, key: str):
106+
def _validate_element(self, typ: Any, value: Any, key: str) -> Any:
107+
"""Validate a value against a type specification."""
108+
if isinstance(typ, list):
109+
# nested value list
110+
return self._nested_validate_list(typ[0], value, key)
111+
elif isinstance(typ, dict):
112+
# nested value dict
113+
return self._nested_validate_dict(typ, value, key)
114+
else:
115+
# normal value
116+
return self._single_validate(typ, value, key)
117+
118+
# pylint: disable=no-value-for-parameter
119+
def _single_validate(self, typ: str, value: Any, key: str) -> Any:
115120
"""Validate a single element."""
116121
# if required argument
117122
if value is None:
@@ -188,7 +193,9 @@ def _single_validate(self, typ: str, value: Any, key: str):
188193
f"Fatal error for option '{key}' with type '{typ}' in {self._name} ({self._slug})"
189194
) from None
190195

191-
def _nested_validate_list(self, typ: Any, data_list: list[Any], key: str):
196+
def _nested_validate_list(
197+
self, typ: Any, data_list: list[Any], key: str
198+
) -> list[Any]:
192199
"""Validate nested items."""
193200
options = []
194201

@@ -201,17 +208,13 @@ def _nested_validate_list(self, typ: Any, data_list: list[Any], key: str):
201208
# Process list
202209
for element in data_list:
203210
# Nested?
204-
if isinstance(typ, dict):
205-
c_options = self._nested_validate_dict(typ, element, key)
206-
options.append(c_options)
207-
else:
208-
options.append(self._single_validate(typ, element, key))
211+
options.append(self._validate_element(typ, element, key))
209212

210213
return options
211214

212215
def _nested_validate_dict(
213216
self, typ: dict[Any, Any], data_dict: dict[Any, Any], key: str
214-
):
217+
) -> dict[Any, Any]:
215218
"""Validate nested items."""
216219
options = {}
217220

@@ -231,12 +234,7 @@ def _nested_validate_dict(
231234
continue
232235

233236
# Nested?
234-
if isinstance(typ[c_key], list):
235-
options[c_key] = self._nested_validate_list(
236-
typ[c_key][0], c_value, c_key
237-
)
238-
else:
239-
options[c_key] = self._single_validate(typ[c_key], c_value, c_key)
237+
options[c_key] = self._validate_element(typ[c_key], c_value, c_key)
240238

241239
self._check_missing_options(typ, options, key)
242240
return options
@@ -274,18 +272,28 @@ def __call__(self, raw_schema: dict[str, Any]) -> list[dict[str, Any]]:
274272

275273
# read options
276274
for key, value in raw_schema.items():
277-
if isinstance(value, list):
278-
# nested value list
279-
self._nested_ui_list(ui_schema, value, key)
280-
elif isinstance(value, dict):
281-
# nested value dict
282-
self._nested_ui_dict(ui_schema, value, key)
283-
else:
284-
# normal value
285-
self._single_ui_option(ui_schema, value, key)
275+
self._ui_schema_element(ui_schema, value, key)
286276

287277
return ui_schema
288278

279+
def _ui_schema_element(
280+
self,
281+
ui_schema: list[dict[str, Any]],
282+
value: str,
283+
key: str,
284+
multiple: bool = False,
285+
):
286+
if isinstance(value, list):
287+
# nested value list
288+
assert not multiple
289+
self._nested_ui_list(ui_schema, value, key)
290+
elif isinstance(value, dict):
291+
# nested value dict
292+
self._nested_ui_dict(ui_schema, value, key, multiple)
293+
else:
294+
# normal value
295+
self._single_ui_option(ui_schema, value, key, multiple)
296+
289297
def _single_ui_option(
290298
self,
291299
ui_schema: list[dict[str, Any]],
@@ -377,10 +385,7 @@ def _nested_ui_list(
377385
_LOGGER.error("Invalid schema %s", key)
378386
return
379387

380-
if isinstance(element, dict):
381-
self._nested_ui_dict(ui_schema, element, key, multiple=True)
382-
else:
383-
self._single_ui_option(ui_schema, element, key, multiple=True)
388+
self._ui_schema_element(ui_schema, element, key, multiple=True)
384389

385390
def _nested_ui_dict(
386391
self,
@@ -399,11 +404,7 @@ def _nested_ui_dict(
399404

400405
nested_schema: list[dict[str, Any]] = []
401406
for c_key, c_value in option_dict.items():
402-
# Nested?
403-
if isinstance(c_value, list):
404-
self._nested_ui_list(nested_schema, c_value, c_key)
405-
else:
406-
self._single_ui_option(nested_schema, c_value, c_key)
407+
self._ui_schema_element(nested_schema, c_value, c_key)
407408

408409
ui_node["schema"] = nested_schema
409410
ui_schema.append(ui_node)

supervisor/addons/validate.py

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,19 @@
137137
r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)(:[\.\-\w{}]+)?$"
138138
)
139139

140-
SCHEMA_ELEMENT = vol.Match(RE_SCHEMA_ELEMENT)
140+
SCHEMA_ELEMENT = vol.Schema(
141+
vol.Any(
142+
vol.Match(RE_SCHEMA_ELEMENT),
143+
[
144+
# A list may not directly contain another list
145+
vol.Any(
146+
vol.Match(RE_SCHEMA_ELEMENT),
147+
{str: vol.Self},
148+
)
149+
],
150+
{str: vol.Self},
151+
)
152+
)
141153

142154
RE_MACHINE = re.compile(
143155
r"^!?(?:"
@@ -406,20 +418,7 @@ def _migrate(config: dict[str, Any]):
406418
vol.Optional(ATTR_CODENOTARY): vol.Email(),
407419
vol.Optional(ATTR_OPTIONS, default={}): dict,
408420
vol.Optional(ATTR_SCHEMA, default={}): vol.Any(
409-
vol.Schema(
410-
{
411-
str: vol.Any(
412-
SCHEMA_ELEMENT,
413-
[
414-
vol.Any(
415-
SCHEMA_ELEMENT,
416-
{str: vol.Any(SCHEMA_ELEMENT, [SCHEMA_ELEMENT])},
417-
)
418-
],
419-
vol.Schema({str: vol.Any(SCHEMA_ELEMENT, [SCHEMA_ELEMENT])}),
420-
)
421-
}
422-
),
421+
vol.Schema({str: SCHEMA_ELEMENT}),
423422
False,
424423
),
425424
vol.Optional(ATTR_IMAGE): docker_image,

tests/addons/test_config.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,3 +325,97 @@ def test_valid_slug():
325325
config["slug"] = "complemento telefónico"
326326
with pytest.raises(vol.Invalid):
327327
assert vd.SCHEMA_ADDON_CONFIG(config)
328+
329+
330+
def test_valid_schema():
331+
"""Test valid and invalid addon slugs."""
332+
config = load_json_fixture("basic-addon-config.json")
333+
334+
# Basic types
335+
config["schema"] = {
336+
"bool_basic": "bool",
337+
"mail_basic": "email",
338+
"url_basic": "url",
339+
"port_basic": "port",
340+
"match_basic": "match(.*@.*)",
341+
"list_basic": "list(option1|option2|option3)",
342+
# device
343+
"device_basic": "device",
344+
"device_filter": "device(subsystem=tty)",
345+
# str
346+
"str_basic": "str",
347+
"str_basic2": "str(,)",
348+
"str_min": "str(5,)",
349+
"str_max": "str(,10)",
350+
"str_minmax": "str(5,10)",
351+
# password
352+
"password_basic": "password",
353+
"password_basic2": "password(,)",
354+
"password_min": "password(5,)",
355+
"password_max": "password(,10)",
356+
"password_minmax": "password(5,10)",
357+
# int
358+
"int_basic": "int",
359+
"int_basic2": "int(,)",
360+
"int_min": "int(5,)",
361+
"int_max": "int(,10)",
362+
"int_minmax": "int(5,10)",
363+
# float
364+
"float_basic": "float",
365+
"float_basic2": "float(,)",
366+
"float_min": "float(5,)",
367+
"float_max": "float(,10)",
368+
"float_minmax": "float(5,10)",
369+
}
370+
assert vd.SCHEMA_ADDON_CONFIG(config)
371+
372+
# Different valid ways of nesting dicts and lists
373+
config["schema"] = {
374+
"str_list": ["str"],
375+
"dict_in_list": [
376+
{
377+
"required": "str",
378+
"optional": "str?",
379+
}
380+
],
381+
"dict": {
382+
"required": "str",
383+
"optional": "str?",
384+
"str_list_in_dict": ["str"],
385+
"dict_in_list_in_dict": [
386+
{
387+
"required": "str",
388+
"optional": "str?",
389+
"str_list_in_dict_in_list_in_dict": ["str"],
390+
}
391+
],
392+
"dict_in_dict": {
393+
"str_list_in_dict_in_dict": ["str"],
394+
"dict_in_list_in_dict_in_dict": [
395+
{
396+
"required": "str",
397+
"optional": "str?",
398+
}
399+
],
400+
"dict_in_dict_in_dict": {
401+
"required": "str",
402+
"optional": "str",
403+
},
404+
},
405+
},
406+
}
407+
assert vd.SCHEMA_ADDON_CONFIG(config)
408+
409+
# List nested within dict within list
410+
config["schema"] = {"field": [{"subfield": ["str"]}]}
411+
assert vd.SCHEMA_ADDON_CONFIG(config)
412+
413+
# No lists directly nested within each other
414+
config["schema"] = {"field": [["str"]]}
415+
with pytest.raises(vol.Invalid):
416+
assert vd.SCHEMA_ADDON_CONFIG(config)
417+
418+
# Field types must be valid
419+
config["schema"] = {"field": "invalid"}
420+
with pytest.raises(vol.Invalid):
421+
assert vd.SCHEMA_ADDON_CONFIG(config)

tests/addons/test_options.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,64 @@ def test_complex_schema_dict(coresys):
129129
)({"name": "Pascal", "password": "1234", "extend": "test"})
130130

131131

132+
def test_complex_schema_dict_and_list(coresys):
133+
"""Test with complex dict/list nested schema."""
134+
assert AddonOptions(
135+
coresys,
136+
{
137+
"name": "str",
138+
"packages": [
139+
{
140+
"name": "str",
141+
"options": {"optional": "bool"},
142+
"dependencies": [{"name": "str"}],
143+
}
144+
],
145+
},
146+
MOCK_ADDON_NAME,
147+
MOCK_ADDON_SLUG,
148+
)(
149+
{
150+
"name": "Pascal",
151+
"packages": [
152+
{
153+
"name": "core",
154+
"options": {"optional": False},
155+
"dependencies": [{"name": "supervisor"}, {"name": "audio"}],
156+
}
157+
],
158+
}
159+
)
160+
161+
with pytest.raises(vol.error.Invalid):
162+
assert AddonOptions(
163+
coresys,
164+
{
165+
"name": "str",
166+
"packages": [
167+
{
168+
"name": "str",
169+
"options": {"optional": "bool"},
170+
"dependencies": [{"name": "str"}],
171+
}
172+
],
173+
},
174+
MOCK_ADDON_NAME,
175+
MOCK_ADDON_SLUG,
176+
)(
177+
{
178+
"name": "Pascal",
179+
"packages": [
180+
{
181+
"name": "core",
182+
"options": {"optional": False},
183+
"dependencies": [{"name": "supervisor"}, "wrong"],
184+
}
185+
],
186+
}
187+
)
188+
189+
132190
def test_simple_device_schema(coresys):
133191
"""Test with simple schema."""
134192
for device in (

0 commit comments

Comments
 (0)