diff --git a/homeassistant/config.py b/homeassistant/config.py index e77e5c32f4099..07f55267842db 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -7,7 +7,7 @@ from collections.abc import Callable, Hashable, Iterable, Sequence from contextlib import suppress from dataclasses import dataclass -from enum import StrEnum +from enum import StrEnum, Enum from functools import partial, reduce import logging import operator @@ -15,7 +15,7 @@ from pathlib import Path import shutil from types import ModuleType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Hashable, Iterable, List, Optional from awesomeversion import AwesomeVersion import voluptuous as vol @@ -202,6 +202,39 @@ def _write_default_config(config_dir: str) -> bool: return False return True +# Map (domain, option_key) -> guidance / replacement text +_DEPRECATED_CONFIG_OPTIONS: dict[tuple[str, str], str] = { + # Example: sensor.old_option is deprecated, use sensor.new_option instead + ("sensor", "old_option"): "Use 'new_option' under 'sensor' instead.", + # Add more entries here as needed + # ("automation", "initial_state"): "Use 'enabled' instead of 'initial_state'.", +} + +def _log_deprecated_config( + hass: HomeAssistant, config: dict +) -> None: + """Log warnings for deprecated configuration options. + + Looks up known deprecated options in the loaded configuration and emits + clear warnings with guidance for replacements. + """ + for domain, domain_cfg in config.items(): + # Only inspect domain configs that are mappings (most domains are) + if not isinstance(domain_cfg, dict): + continue + + for key in domain_cfg: + replacement = _DEPRECATED_CONFIG_OPTIONS.get((domain, key)) + if not replacement: + continue + + _LOGGER.warning( + "Configuration option '%s' in '%s' is deprecated and will be " + "removed in a future release: %s", + key, + domain, + replacement, + ) async def async_hass_config_yaml(hass: HomeAssistant) -> dict: """Load YAML from a Home Assistant configuration file. @@ -257,8 +290,10 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> dict: ) core_config[CONF_PACKAGES] = {} - return config + # NEW: warn about deprecated config entries + _log_deprecated_config(hass, config) + return config def load_yaml_config_file( config_path: str, secrets: Secrets | None = None @@ -453,59 +488,132 @@ def stringify_invalid( domain: str, config: dict, link: str | None, - max_sub_error_length: int, + max_sub_error_length: int = MAX_VALIDATION_ERROR_ITEM_LENGTH, ) -> str: - """Stringify voluptuous.Invalid. - - This is an alternative to the custom __str__ implemented in - voluptuous.error.Invalid. The modifications are: - - Format the path delimited by -> instead of @data[] - - Prefix with domain, file and line of the error - - Suffix with a link to the documentation - - Give a more user friendly output for unknown options - - Give a more user friendly output for missing options + """Format a voluptuous.Invalid into a rich, actionable error string. + + Compared to the previous implementation, this: + + * Categorizes the error (undefined variable / invalid schema / value error). + * Shows the config path and offending value. + * Adds a short, actionable hint where possible. + * Keeps the familiar 'Invalid config for ...' prefix. """ - if "." in domain: - integration_domain, _, platform_domain = domain.partition(".") - message_prefix = ( - f"Invalid config for '{platform_domain}' from integration " - f"'{integration_domain}'" - ) - else: - message_prefix = f"Invalid config for '{domain}'" - if domain != HOMEASSISTANT_DOMAIN and link: - message_suffix = f", please check the docs at {link}" + + # Preserve existing human message from voluptuous. + base_message = getattr(exc, "error_message", "") or Exception.__str__(exc) + + category = _classify_validation_error(exc) + path_str = _format_path(exc.path) + offending_item = _nested_getitem(config, list(exc.path)) + + if offending_item is None: + offending_summary = "None" else: - message_suffix = "" - if annotation := find_annotation(config, exc.path): - message_prefix += f" at {_relpath(hass, annotation[0])}, line {annotation[1]}" - path = "->".join(str(m) for m in exc.path) - if exc.error_message == "extra keys not allowed": - return ( - f"{message_prefix}: '{exc.path[-1]}' is an invalid option for '{domain}', " - f"check: {path}{message_suffix}" + offending_summary = repr(offending_item) + + if len(offending_summary) > max_sub_error_length: + offending_summary = ( + offending_summary[: max_sub_error_length - 3] + "..." ) - if exc.error_message == "required key not provided": - return ( - f"{message_prefix}: required key '{exc.path[-1]}' not provided" - f"{message_suffix}" + + # Category-specific header + hint + if category == ValidationCategory.UNDEFINED_VARIABLE: + header = f"Invalid config for '{domain}': undefined variable in template." + hint = ( + "Ensure all template variables are defined and that referenced " + "entities/attributes exist. Consider using `is defined` checks." ) - # This function is an alternative to the stringification done by - # vol.Invalid.__str__, so we need to call Exception.__str__ here - # instead of str(exc) - output = Exception.__str__(exc) - if error_type := exc.error_type: - output += " for " + error_type - offending_item_summary = repr(_get_by_path(config, exc.path)) - if len(offending_item_summary) > max_sub_error_length: - offending_item_summary = ( - f"{offending_item_summary[: max_sub_error_length - 3]}..." + elif category == ValidationCategory.INVALID_SCHEMA: + header = f"Invalid config for '{domain}': invalid schema." + hint = ( + "Check that all required keys are present, option names are spelled " + "correctly, and values follow the documented structure." ) - return ( - f"{message_prefix}: {output} '{path}', got {offending_item_summary}" - f"{message_suffix}" - ) + elif category == ValidationCategory.VALUE_ERROR: + header = f"Invalid config for '{domain}': invalid value." + hint = ( + "Verify that the value matches the expected type or format " + "(for example, a number, boolean, or correctly formatted template)." + ) + else: + header = f"Invalid config for '{domain}': {base_message}" + hint = None + + lines: list[str] = [header] + # Only add details line if we didn't already embed base_message in header + if category != ValidationCategory.UNKNOWN: + lines.append(f" • Details: {base_message}") + + if path_str: + lines.append(f" • Location: {path_str}") + + # Always show offending value, including None, so tests can assert on it. + lines.append(f" • Offending value: {offending_summary}") + + if hint: + lines.append(f" • Hint: {hint}") + + if link: + # Preserve the existing behavior of pointing users to the docs. + lines.append(f" • Documentation: {link}") + + return "\n".join(lines) + +class ValidationCategory(str, Enum): + """High-level category of validation error.""" + + UNDEFINED_VARIABLE = "undefined_variable" + INVALID_SCHEMA = "invalid_schema" + VALUE_ERROR = "value_error" + UNKNOWN = "unknown" + +def _nested_getitem(data: Any, path: List[Hashable]) -> Optional[Any]: + """Return the value at `path` inside `data`, or None if not available.""" + for item_index in path: + try: + data = data[item_index] + except (KeyError, IndexError, TypeError): + return None + return data + +def _classify_validation_error(exc: vol.Invalid) -> ValidationCategory: + """Classify a voluptuous Invalid into a coarse category.""" + message = getattr(exc, "error_message", "") or str(exc) + lower = message.lower() + + # Undefined Jinja / template variable style errors. + if "undefinederror" in lower or "is undefined" in lower: + return ValidationCategory.UNDEFINED_VARIABLE + if "has no attribute" in lower and "undefined" in lower: + return ValidationCategory.UNDEFINED_VARIABLE + + # Schema-level: missing/extra keys, wrong type of container, etc. + if "required key not provided" in lower: + return ValidationCategory.INVALID_SCHEMA + if "extra keys not allowed" in lower: + return ValidationCategory.INVALID_SCHEMA + if "not a valid value" in lower or "not a valid option" in lower: + return ValidationCategory.INVALID_SCHEMA + if "expected dict" in lower or "expected list" in lower: + return ValidationCategory.INVALID_SCHEMA + + # Generic value error (wrong type / format). + if "expected " in lower: + return ValidationCategory.VALUE_ERROR + + return ValidationCategory.UNKNOWN + +def _format_path(path: Iterable[Hashable]) -> str: + """Format voluptuous path list as a human-friendly breadcrumb.""" + parts: list[str] = [] + for component in path: + if isinstance(component, int): + parts.append(f"[{component}]") + else: + parts.append(str(component)) + return " → ".join(parts) def humanize_error( hass: HomeAssistant, @@ -747,8 +855,42 @@ async def merge_packages_config( merge_list = _identify_config_schema(component) == "list" if merge_list: + # Existing items already present in the main config + existing_items = cv.ensure_list(config.get(comp_name)) + + # Items coming from this package + package_items = cv.ensure_list(comp_conf) + + valid_package_items: list[Any] = [] + for idx, item in enumerate(package_items): + # Treat None as "no config" and silently ignore it + if item is None: + continue + + # For list-based integrations in packages, we expect each item + # to be a mapping (dict-like). Anything else is very likely + # a configuration error. + if not isinstance(item, dict): + _log_pkg_error( + hass, + pack_name, + comp_name, + config, + ( + f"integration '{comp_name}' in package '{pack_name}' " + f"has invalid list item at index {idx}: expected a dict, " + f"got {type(item).__name__}" + ), + ) + # Skip this invalid list item + continue + + valid_package_items.append(item) + + # Keep the existing semantics of remove_falsy, but only with + # valid items from the package. config[comp_name] = cv.remove_falsy( - cv.ensure_list(config.get(comp_name)) + cv.ensure_list(comp_conf) + existing_items + valid_package_items ) continue diff --git a/tests/test_config_humanize_error.py b/tests/test_config_humanize_error.py new file mode 100644 index 0000000000000..774db14beeea4 --- /dev/null +++ b/tests/test_config_humanize_error.py @@ -0,0 +1,119 @@ +# tests/test_config_humanize_error.py + +from __future__ import annotations + +from typing import Any, Hashable, List + +import voluptuous as vol +from voluptuous.humanize import humanize_error as vol_humanize_error + +from homeassistant.config import humanize_error +from homeassistant.core import HomeAssistant + + +class DummyHass: + """Minimal hass stub for tests.""" + pass + + +def _make_invalid( + message: str, path: List[Hashable] | None = None +) -> vol.Invalid: + """Construct a vol.Invalid with a fixed path for testing.""" + return vol.Invalid(message, path=path or []) + + +def test_humanize_error_preserves_core_message() -> None: + """The new implementation should still contain the original human message.""" + data = {"sensor": {"name": 123}} + schema = vol.Schema({"sensor": {"name": str}}) + + # Trigger a simple type error. + try: + schema(data) + except vol.MultipleInvalid as err: + exc = err.errors[0] + + hass: HomeAssistant = DummyHass() # <--- use DummyHass + + before = vol_humanize_error(data, exc) + after = humanize_error( + hass=hass, + validation_error=exc, + domain="sensor", + config=data, + link=None, + ) + + assert exc.error_message in before + assert exc.error_message in after + assert "got" in before.lower() + assert "got" in after.lower() or "offending value" in after.lower() + + +def test_humanize_error_undefined_variable() -> None: + """Undefined template variables should be clearly identified.""" + message = "TemplateError: UndefinedError: 'value_json' is undefined" + exc = _make_invalid(message, path=["sensor", "my_rest_sensor", "value_template"]) + + config = { + "sensor": { + "my_rest_sensor": { + "platform": "rest", + "value_template": "{{ value_json.missing }}", + } + } + } + + hass: HomeAssistant = DummyHass() # <--- use DummyHass + + msg = humanize_error( + hass=hass, + validation_error=exc, + domain="rest", + config=config, + link="https://www.home-assistant.io/docs/configuration/templating/", + ) + + assert "undefined variable" in msg.lower() + assert "UndefinedError" in msg + assert "'value_json' is undefined" in msg + assert "Offending value" in msg + assert "hint" in msg.lower() + assert "https://www.home-assistant.io/docs/configuration/templating/" in msg + + +def test_humanize_error_invalid_schema_missing_key() -> None: + """Missing required keys should be reported as invalid schema with location.""" + schema = vol.Schema( + { + vol.Required("platform"): str, + vol.Required("name"): str, + } + ) + + bad_config = { + "platform": "template", + } + + try: + schema(bad_config) + except vol.MultipleInvalid as err: + exc = err.errors[0] + + hass: HomeAssistant = DummyHass() # <--- use DummyHass + + msg = humanize_error( + hass=hass, + validation_error=exc, + domain="sensor", + config=bad_config, + link="https://www.home-assistant.io/docs/configuration/", + ) + + lower = msg.lower() + assert "invalid schema" in lower + assert "required key" in lower + assert "location:" in lower + assert "hint:" in lower + assert "https://www.home-assistant.io/docs/configuration/" in msg diff --git a/tests/test_deprecated_config_loading.py b/tests/test_deprecated_config_loading.py new file mode 100644 index 0000000000000..bc6ea6a48ee92 --- /dev/null +++ b/tests/test_deprecated_config_loading.py @@ -0,0 +1,61 @@ +# tests/test_deprecated_config_loading.py + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.config import _log_deprecated_config, _DEPRECATED_CONFIG_OPTIONS +from homeassistant.core import HomeAssistant + + +class DummyHass: + """Minimal hass stub for unit tests.""" + # We don’t actually need anything on hass for _log_deprecated_config today. + pass + + +def test_log_deprecated_config_emits_warning_for_known_option(caplog) -> None: + """Deprecated options should log a clear warning with guidance.""" + # Make sure we have an entry in the dictionary to test against. + # These must match what you put in _DEPRECATED_CONFIG_OPTIONS. + assert ("sensor", "old_option") in _DEPRECATED_CONFIG_OPTIONS + + caplog.set_level(logging.WARNING) + + config: dict[str, Any] = { + "sensor": { + "old_option": True, + "name": "test_sensor", + } + } + + hass: HomeAssistant = DummyHass() # type: ignore[assignment] + + _log_deprecated_config(hass, config) + + messages = "\n".join(record.getMessage() for record in caplog.records) + + assert "deprecated" in messages.lower() + assert "sensor" in messages + assert "old_option" in messages + # Ensure the guidance text from the dictionary is present + assert _DEPRECATED_CONFIG_OPTIONS[("sensor", "old_option")] in messages + + +def test_log_deprecated_config_ignores_unknown_options(caplog) -> None: + """Non-deprecated options should not cause warnings.""" + caplog.set_level(logging.WARNING) + + config = { + "sensor": { + "name": "no_deprecations_here", + } + } + + hass: HomeAssistant = DummyHass() # type: ignore[assignment] + + _log_deprecated_config(hass, config) + + # No warnings expected when no deprecated options are present + assert not caplog.records diff --git a/tests/test_merge_packages_strict_validation.py b/tests/test_merge_packages_strict_validation.py new file mode 100644 index 0000000000000..42a3f4ef19385 --- /dev/null +++ b/tests/test_merge_packages_strict_validation.py @@ -0,0 +1,103 @@ +import pytest +from typing import Any, List + +from homeassistant import config as config_util +from homeassistant.const import CONF_PACKAGES +from homeassistant.core import HomeAssistant, DOMAIN as HOMEASSISTANT_DOMAIN + + +@pytest.mark.asyncio +async def test_merge_packages_valid_list_items(hass: HomeAssistant) -> None: + """Valid list-based items from packages are merged without errors.""" + packages: dict[str, Any] = { + "valid_pkg": { + "light": [ + {"platform": "test1"}, + {"platform": "test2"}, + ] + }, + } + config: dict[str, Any] = { + HOMEASSISTANT_DOMAIN: {CONF_PACKAGES: packages}, + "light": [ + {"platform": "base"}, + ], + } + + log_messages: List[str] = [] + + def capture_log( + hass_: HomeAssistant, + pack_name: str, + comp_name: str | None, + conf: dict, + msg: str, + ) -> None: + log_messages.append(msg) + + await config_util.merge_packages_config( + hass, + config, + packages, + _log_pkg_error=capture_log, + ) + + # No errors should be logged for a fully valid package + assert log_messages == [] + + # All items should be present: existing + two from the package + assert "light" in config + assert isinstance(config["light"], list) + assert [item["platform"] for item in config["light"]] == [ + "base", + "test1", + "test2", + ] + + +@pytest.mark.asyncio +async def test_merge_packages_invalid_list_item_logged_and_skipped( + hass: HomeAssistant, +) -> None: + """Invalid list items in list-based integrations should be logged and skipped.""" + # Bad package: light config contains a scalar in the list instead of a dict + packages: dict[str, Any] = { + "bad_pkg": { + "light": [ + "not-a-dict", # invalid item + {"platform": "test-ok"}, # valid item + ] + }, + } + config: dict[str, Any] = { + HOMEASSISTANT_DOMAIN: {CONF_PACKAGES: packages}, + } + + log_messages: List[str] = [] + + def capture_log( + hass_: HomeAssistant, + pack_name: str, + comp_name: str | None, + conf: dict, + msg: str, + ) -> None: + log_messages.append(msg) + + await config_util.merge_packages_config( + hass, + config, + packages, + _log_pkg_error=capture_log, + ) + + # We should log exactly one detailed error for the invalid list item + assert len(log_messages) == 1 + assert "integration 'light' in package 'bad_pkg'" in log_messages[0] + assert "invalid list item at index 0" in log_messages[0] + assert "expected a dict" in log_messages[0] + + # The invalid list item should be dropped, but the valid one should remain + assert "light" in config + assert isinstance(config["light"], list) + assert config["light"] == [{"platform": "test-ok"}]