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 .exceptions import HomeAssistantError
|
||||||
from .helpers import (
|
from .helpers import (
|
||||||
area_registry,
|
area_registry,
|
||||||
|
config_validation as cv,
|
||||||
device_registry,
|
device_registry,
|
||||||
entity,
|
entity,
|
||||||
entity_registry,
|
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]:
|
def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
|
||||||
"""Get domains of components to set up."""
|
"""Get domains of components to set up."""
|
||||||
# Filter out the repeating and common config section [homeassistant]
|
# 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
|
# Add config entry domains
|
||||||
if not hass.config.recovery_mode:
|
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)
|
base_exc.problem_mark.name = _relpath(hass, base_exc.problem_mark.name)
|
||||||
raise
|
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, {})
|
core_config = config.get(CONF_CORE, {})
|
||||||
await merge_packages_config(hass, config, core_config.get(CONF_PACKAGES, {}))
|
await merge_packages_config(hass, config, core_config.get(CONF_PACKAGES, {}))
|
||||||
return config
|
return config
|
||||||
|
@ -982,9 +995,13 @@ async def merge_packages_config(
|
||||||
for comp_name, comp_conf in pack_conf.items():
|
for comp_name, comp_conf in pack_conf.items():
|
||||||
if comp_name == CONF_CORE:
|
if comp_name == CONF_CORE:
|
||||||
continue
|
continue
|
||||||
# If component name is given with a trailing description, remove it
|
try:
|
||||||
# when looking for component
|
domain = cv.domain_key(comp_name)
|
||||||
domain = comp_name.partition(" ")[0]
|
except vol.Invalid:
|
||||||
|
_log_pkg_error(
|
||||||
|
hass, pack_name, comp_name, config, f"Invalid domain '{comp_name}'"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
integration = await async_get_integration_with_requirements(
|
integration = await async_get_integration_with_requirements(
|
||||||
|
@ -1263,8 +1280,13 @@ def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]:
|
||||||
|
|
||||||
Async friendly.
|
Async friendly.
|
||||||
"""
|
"""
|
||||||
pattern = re.compile(rf"^{domain}(| .+)$")
|
domain_configs = []
|
||||||
return [key for key in config if pattern.match(key)]
|
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
|
async def async_process_component_config( # noqa: C901
|
||||||
|
|
|
@ -31,6 +31,7 @@ from homeassistant.requirements import (
|
||||||
)
|
)
|
||||||
import homeassistant.util.yaml.loader as yaml_loader
|
import homeassistant.util.yaml.loader as yaml_loader
|
||||||
|
|
||||||
|
from . import config_validation as cv
|
||||||
from .typing import ConfigType
|
from .typing import ConfigType
|
||||||
|
|
||||||
|
|
||||||
|
@ -175,7 +176,7 @@ async def async_check_ha_config_file( # noqa: C901
|
||||||
core_config.pop(CONF_PACKAGES, None)
|
core_config.pop(CONF_PACKAGES, None)
|
||||||
|
|
||||||
# Filter out repeating config sections
|
# 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()
|
frontend_dependencies: set[str] = set()
|
||||||
if "frontend" in components or "default_config" in components:
|
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]:
|
def entity_domain(domain: str | list[str]) -> Callable[[Any], str]:
|
||||||
"""Validate that entity belong to domain."""
|
"""Validate that entity belong to domain."""
|
||||||
ent_domain = entities_domain(domain)
|
ent_domain = entities_domain(domain)
|
||||||
|
|
|
@ -56,3 +56,8 @@ custom_validator_bad_1:
|
||||||
|
|
||||||
# This always raises ValueError
|
# This always raises ValueError
|
||||||
custom_validator_bad_2:
|
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_ok_2: !include integrations/custom_validator_ok_2.yaml
|
||||||
custom_validator_bad_1: !include integrations/custom_validator_bad_1.yaml
|
custom_validator_bad_1: !include integrations/custom_validator_bad_1.yaml
|
||||||
custom_validator_bad_2: !include integrations/custom_validator_bad_2.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:
|
pack_custom_validator_bad_2:
|
||||||
# This always raises ValueError
|
# This always raises ValueError
|
||||||
custom_validator_bad_2:
|
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"}})
|
cv.platform_only_config_schema("test_domain")({"test_domain": {"foo": "bar"}})
|
||||||
assert expected_message in caplog.text
|
assert expected_message in caplog.text
|
||||||
assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, expected_issue)
|
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
|
# serializer version: 1
|
||||||
# name: test_component_config_validation_error[basic]
|
# name: test_component_config_validation_error[basic]
|
||||||
list([
|
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({
|
dict({
|
||||||
'has_exc_info': False,
|
'has_exc_info': False,
|
||||||
'message': "Invalid config for 'iot_domain' at configuration.yaml, line 6: required key 'platform' not provided",
|
'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]
|
# name: test_component_config_validation_error[basic_include]
|
||||||
list([
|
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({
|
dict({
|
||||||
'has_exc_info': False,
|
'has_exc_info': False,
|
||||||
'message': "Invalid config for 'iot_domain' at integrations/iot_domain.yaml, line 5: required key 'platform' not provided",
|
'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]
|
# name: test_component_config_validation_error[packages]
|
||||||
list([
|
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({
|
dict({
|
||||||
'has_exc_info': False,
|
'has_exc_info': False,
|
||||||
'message': "Invalid config for 'iot_domain' at configuration.yaml, line 11: required key 'platform' not provided",
|
'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]
|
# name: test_component_config_validation_error[packages_include_dir_named]
|
||||||
list([
|
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({
|
dict({
|
||||||
'has_exc_info': False,
|
'has_exc_info': False,
|
||||||
'message': "Invalid config for 'adr_0007_2' at integrations/adr_0007_2.yaml, line 2: required key 'host' not provided",
|
'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]
|
# name: test_component_config_validation_error_with_docs[basic]
|
||||||
list([
|
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' 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 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",
|
"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:
|
for domain_with_label in config:
|
||||||
integration = await async_get_integration(
|
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(
|
await config_util.async_process_component_and_handle_errors(
|
||||||
hass,
|
hass,
|
||||||
|
@ -2088,7 +2088,7 @@ async def test_component_config_validation_error_with_docs(
|
||||||
|
|
||||||
for domain_with_label in config:
|
for domain_with_label in config:
|
||||||
integration = await async_get_integration(
|
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(
|
await config_util.async_process_component_and_handle_errors(
|
||||||
hass,
|
hass,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue