Add domain key config validation (#104242)
* Drop use of regex in helpers.extract_domain_configs * Update test * Revert test update * Add domain_from_config_key helper * Add validator * Address review comment * Update snapshots * Inline domain_from_config_key in validator
This commit is contained in:
parent
25bea91683
commit
3bcc6194ef
19 changed files with 144 additions and 9 deletions
|
@ -27,6 +27,7 @@ from .const import (
|
|||
from .exceptions import HomeAssistantError
|
||||
from .helpers import (
|
||||
area_registry,
|
||||
config_validation as cv,
|
||||
device_registry,
|
||||
entity,
|
||||
entity_registry,
|
||||
|
@ -473,7 +474,9 @@ async def async_mount_local_lib_path(config_dir: str) -> str:
|
|||
def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
|
||||
"""Get domains of components to set up."""
|
||||
# Filter out the repeating and common config section [homeassistant]
|
||||
domains = {key.partition(" ")[0] for key in config if key != core.DOMAIN}
|
||||
domains = {
|
||||
domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN
|
||||
}
|
||||
|
||||
# Add config entry domains
|
||||
if not hass.config.recovery_mode:
|
||||
|
|
|
@ -449,6 +449,19 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> dict:
|
|||
base_exc.problem_mark.name = _relpath(hass, base_exc.problem_mark.name)
|
||||
raise
|
||||
|
||||
invalid_domains = []
|
||||
for key in config:
|
||||
try:
|
||||
cv.domain_key(key)
|
||||
except vol.Invalid as exc:
|
||||
suffix = ""
|
||||
if annotation := find_annotation(config, exc.path):
|
||||
suffix = f" at {_relpath(hass, annotation[0])}, line {annotation[1]}"
|
||||
_LOGGER.error("Invalid domain '%s'%s", key, suffix)
|
||||
invalid_domains.append(key)
|
||||
for invalid_domain in invalid_domains:
|
||||
config.pop(invalid_domain)
|
||||
|
||||
core_config = config.get(CONF_CORE, {})
|
||||
await merge_packages_config(hass, config, core_config.get(CONF_PACKAGES, {}))
|
||||
return config
|
||||
|
@ -982,9 +995,13 @@ async def merge_packages_config(
|
|||
for comp_name, comp_conf in pack_conf.items():
|
||||
if comp_name == CONF_CORE:
|
||||
continue
|
||||
# If component name is given with a trailing description, remove it
|
||||
# when looking for component
|
||||
domain = comp_name.partition(" ")[0]
|
||||
try:
|
||||
domain = cv.domain_key(comp_name)
|
||||
except vol.Invalid:
|
||||
_log_pkg_error(
|
||||
hass, pack_name, comp_name, config, f"Invalid domain '{comp_name}'"
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
integration = await async_get_integration_with_requirements(
|
||||
|
@ -1263,8 +1280,13 @@ def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]:
|
|||
|
||||
Async friendly.
|
||||
"""
|
||||
pattern = re.compile(rf"^{domain}(| .+)$")
|
||||
return [key for key in config if pattern.match(key)]
|
||||
domain_configs = []
|
||||
for key in config:
|
||||
with suppress(vol.Invalid):
|
||||
if cv.domain_key(key) != domain:
|
||||
continue
|
||||
domain_configs.append(key)
|
||||
return domain_configs
|
||||
|
||||
|
||||
async def async_process_component_config( # noqa: C901
|
||||
|
|
|
@ -31,6 +31,7 @@ from homeassistant.requirements import (
|
|||
)
|
||||
import homeassistant.util.yaml.loader as yaml_loader
|
||||
|
||||
from . import config_validation as cv
|
||||
from .typing import ConfigType
|
||||
|
||||
|
||||
|
@ -175,7 +176,7 @@ async def async_check_ha_config_file( # noqa: C901
|
|||
core_config.pop(CONF_PACKAGES, None)
|
||||
|
||||
# Filter out repeating config sections
|
||||
components = {key.partition(" ")[0] for key in config}
|
||||
components = {cv.domain_key(key) for key in config}
|
||||
|
||||
frontend_dependencies: set[str] = set()
|
||||
if "frontend" in components or "default_config" in components:
|
||||
|
|
|
@ -351,6 +351,30 @@ comp_entity_ids_or_uuids = vol.Any(
|
|||
)
|
||||
|
||||
|
||||
def domain_key(config_key: Any) -> str:
|
||||
"""Validate a top level config key with an optional label and return the domain.
|
||||
|
||||
A domain is separated from a label by one or more spaces, empty labels are not
|
||||
allowed.
|
||||
|
||||
Examples:
|
||||
'hue' returns 'hue'
|
||||
'hue 1' returns 'hue'
|
||||
'hue 1' returns 'hue'
|
||||
'hue ' raises
|
||||
'hue ' raises
|
||||
"""
|
||||
if not isinstance(config_key, str):
|
||||
raise vol.Invalid("invalid domain", path=[config_key])
|
||||
|
||||
parts = config_key.partition(" ")
|
||||
_domain = parts[0] if parts[2].strip(" ") else config_key
|
||||
if not _domain or _domain.strip(" ") != _domain:
|
||||
raise vol.Invalid("invalid domain", path=[config_key])
|
||||
|
||||
return _domain
|
||||
|
||||
|
||||
def entity_domain(domain: str | list[str]) -> Callable[[Any], str]:
|
||||
"""Validate that entity belong to domain."""
|
||||
ent_domain = entities_domain(domain)
|
||||
|
|
|
@ -56,3 +56,8 @@ custom_validator_bad_1:
|
|||
|
||||
# This always raises ValueError
|
||||
custom_validator_bad_2:
|
||||
|
||||
# Invalid domains
|
||||
"iot_domain ":
|
||||
"":
|
||||
5:
|
||||
|
|
|
@ -8,3 +8,6 @@ custom_validator_ok_1: !include integrations/custom_validator_ok_1.yaml
|
|||
custom_validator_ok_2: !include integrations/custom_validator_ok_2.yaml
|
||||
custom_validator_bad_1: !include integrations/custom_validator_bad_1.yaml
|
||||
custom_validator_bad_2: !include integrations/custom_validator_bad_2.yaml
|
||||
"iot_domain ": !include integrations/iot_domain .yaml
|
||||
"": !include integrations/.yaml
|
||||
5: !include integrations/5.yaml
|
||||
|
|
0
tests/fixtures/core/config/component_validation/basic_include/integrations/.yaml
vendored
Normal file
0
tests/fixtures/core/config/component_validation/basic_include/integrations/.yaml
vendored
Normal file
0
tests/fixtures/core/config/component_validation/basic_include/integrations/5.yaml
vendored
Normal file
0
tests/fixtures/core/config/component_validation/basic_include/integrations/5.yaml
vendored
Normal file
0
tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain .yaml
vendored
Normal file
0
tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain .yaml
vendored
Normal file
0
tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/.yaml
vendored
Normal file
0
tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/.yaml
vendored
Normal file
0
tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/5.yaml
vendored
Normal file
0
tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/5.yaml
vendored
Normal file
|
@ -68,3 +68,10 @@ homeassistant:
|
|||
pack_custom_validator_bad_2:
|
||||
# This always raises ValueError
|
||||
custom_validator_bad_2:
|
||||
# Invalid domains
|
||||
pack_iot_domain_space:
|
||||
"iot_domain ":
|
||||
pack_empty:
|
||||
"":
|
||||
pack_5:
|
||||
5:
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
5:
|
|
@ -0,0 +1 @@
|
|||
"":
|
|
@ -0,0 +1 @@
|
|||
"iot_domain ":
|
|
@ -1631,3 +1631,19 @@ def test_platform_only_schema(
|
|||
cv.platform_only_config_schema("test_domain")({"test_domain": {"foo": "bar"}})
|
||||
assert expected_message in caplog.text
|
||||
assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, expected_issue)
|
||||
|
||||
|
||||
def test_domain() -> None:
|
||||
"""Test domain."""
|
||||
with pytest.raises(vol.Invalid):
|
||||
cv.domain_key(5)
|
||||
with pytest.raises(vol.Invalid):
|
||||
cv.domain_key("")
|
||||
with pytest.raises(vol.Invalid):
|
||||
cv.domain_key("hue ")
|
||||
with pytest.raises(vol.Invalid):
|
||||
cv.domain_key("hue ")
|
||||
assert cv.domain_key("hue") == "hue"
|
||||
assert cv.domain_key("hue1") == "hue1"
|
||||
assert cv.domain_key("hue 1") == "hue"
|
||||
assert cv.domain_key("hue 1") == "hue"
|
||||
|
|
|
@ -1,6 +1,18 @@
|
|||
# serializer version: 1
|
||||
# name: test_component_config_validation_error[basic]
|
||||
list([
|
||||
dict({
|
||||
'has_exc_info': False,
|
||||
'message': "Invalid domain 'iot_domain ' at configuration.yaml, line 61",
|
||||
}),
|
||||
dict({
|
||||
'has_exc_info': False,
|
||||
'message': "Invalid domain '' at configuration.yaml, line 62",
|
||||
}),
|
||||
dict({
|
||||
'has_exc_info': False,
|
||||
'message': "Invalid domain '5' at configuration.yaml, line 1",
|
||||
}),
|
||||
dict({
|
||||
'has_exc_info': False,
|
||||
'message': "Invalid config for 'iot_domain' at configuration.yaml, line 6: required key 'platform' not provided",
|
||||
|
@ -57,6 +69,18 @@
|
|||
# ---
|
||||
# name: test_component_config_validation_error[basic_include]
|
||||
list([
|
||||
dict({
|
||||
'has_exc_info': False,
|
||||
'message': "Invalid domain 'iot_domain ' at configuration.yaml, line 11",
|
||||
}),
|
||||
dict({
|
||||
'has_exc_info': False,
|
||||
'message': "Invalid domain '' at configuration.yaml, line 12",
|
||||
}),
|
||||
dict({
|
||||
'has_exc_info': False,
|
||||
'message': "Invalid domain '5' at configuration.yaml, line 1",
|
||||
}),
|
||||
dict({
|
||||
'has_exc_info': False,
|
||||
'message': "Invalid config for 'iot_domain' at integrations/iot_domain.yaml, line 5: required key 'platform' not provided",
|
||||
|
@ -161,6 +185,18 @@
|
|||
# ---
|
||||
# name: test_component_config_validation_error[packages]
|
||||
list([
|
||||
dict({
|
||||
'has_exc_info': False,
|
||||
'message': "Setup of package 'pack_iot_domain_space' at configuration.yaml, line 72 failed: Invalid domain 'iot_domain '",
|
||||
}),
|
||||
dict({
|
||||
'has_exc_info': False,
|
||||
'message': "Setup of package 'pack_empty' at configuration.yaml, line 74 failed: Invalid domain ''",
|
||||
}),
|
||||
dict({
|
||||
'has_exc_info': False,
|
||||
'message': "Setup of package 'pack_5' at configuration.yaml, line 76 failed: Invalid domain '5'",
|
||||
}),
|
||||
dict({
|
||||
'has_exc_info': False,
|
||||
'message': "Invalid config for 'iot_domain' at configuration.yaml, line 11: required key 'platform' not provided",
|
||||
|
@ -217,6 +253,18 @@
|
|||
# ---
|
||||
# name: test_component_config_validation_error[packages_include_dir_named]
|
||||
list([
|
||||
dict({
|
||||
'has_exc_info': False,
|
||||
'message': "Setup of package 'pack_5' at integrations/pack_5.yaml, line 1 failed: Invalid domain '5'",
|
||||
}),
|
||||
dict({
|
||||
'has_exc_info': False,
|
||||
'message': "Setup of package 'pack_empty' at integrations/pack_empty.yaml, line 1 failed: Invalid domain ''",
|
||||
}),
|
||||
dict({
|
||||
'has_exc_info': False,
|
||||
'message': "Setup of package 'pack_iot_domain_space' at integrations/pack_iot_domain_space.yaml, line 1 failed: Invalid domain 'iot_domain '",
|
||||
}),
|
||||
dict({
|
||||
'has_exc_info': False,
|
||||
'message': "Invalid config for 'adr_0007_2' at integrations/adr_0007_2.yaml, line 2: required key 'host' not provided",
|
||||
|
@ -273,6 +321,9 @@
|
|||
# ---
|
||||
# name: test_component_config_validation_error_with_docs[basic]
|
||||
list([
|
||||
"Invalid domain 'iot_domain ' at configuration.yaml, line 61",
|
||||
"Invalid domain '' at configuration.yaml, line 62",
|
||||
"Invalid domain '5' at configuration.yaml, line 1",
|
||||
"Invalid config for 'iot_domain' at configuration.yaml, line 6: required key 'platform' not provided, please check the docs at https://www.home-assistant.io/integrations/iot_domain",
|
||||
"Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007",
|
||||
"Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007",
|
||||
|
|
|
@ -2043,7 +2043,7 @@ async def test_component_config_validation_error(
|
|||
|
||||
for domain_with_label in config:
|
||||
integration = await async_get_integration(
|
||||
hass, domain_with_label.partition(" ")[0]
|
||||
hass, cv.domain_key(domain_with_label)
|
||||
)
|
||||
await config_util.async_process_component_and_handle_errors(
|
||||
hass,
|
||||
|
@ -2088,7 +2088,7 @@ async def test_component_config_validation_error_with_docs(
|
|||
|
||||
for domain_with_label in config:
|
||||
integration = await async_get_integration(
|
||||
hass, domain_with_label.partition(" ")[0]
|
||||
hass, cv.domain_key(domain_with_label)
|
||||
)
|
||||
await config_util.async_process_component_and_handle_errors(
|
||||
hass,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue