diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 34e5d66a5e7..f1040825cdf 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -175,13 +175,13 @@ PLATFORM_SCHEMA = vol.All( # CONF_SPEED_COMMAND_TOPIC, CONF_SPEED_LIST, CONF_SPEED_STATE_TOPIC, CONF_SPEED_VALUE_TEMPLATE and # Speeds SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH SPEED_OFF, # are deprecated, support will be removed with release 2021.9 - cv.deprecated(CONF_PAYLOAD_HIGH_SPEED), - cv.deprecated(CONF_PAYLOAD_LOW_SPEED), - cv.deprecated(CONF_PAYLOAD_MEDIUM_SPEED), - cv.deprecated(CONF_SPEED_COMMAND_TOPIC), - cv.deprecated(CONF_SPEED_LIST), - cv.deprecated(CONF_SPEED_STATE_TOPIC), - cv.deprecated(CONF_SPEED_VALUE_TEMPLATE), + cv.removed(CONF_PAYLOAD_HIGH_SPEED), + cv.removed(CONF_PAYLOAD_LOW_SPEED), + cv.removed(CONF_PAYLOAD_MEDIUM_SPEED), + cv.removed(CONF_SPEED_COMMAND_TOPIC), + cv.removed(CONF_SPEED_LIST), + cv.removed(CONF_SPEED_STATE_TOPIC), + cv.removed(CONF_SPEED_VALUE_TEMPLATE), _PLATFORM_SCHEMA_BASE, valid_speed_range_configuration, valid_preset_mode_configuration, @@ -191,13 +191,13 @@ DISCOVERY_SCHEMA = vol.All( # CONF_SPEED_COMMAND_TOPIC, CONF_SPEED_LIST, CONF_SPEED_STATE_TOPIC, CONF_SPEED_VALUE_TEMPLATE and # Speeds SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH SPEED_OFF, # are deprecated, support will be removed with release 2021.9 - cv.deprecated(CONF_PAYLOAD_HIGH_SPEED), - cv.deprecated(CONF_PAYLOAD_LOW_SPEED), - cv.deprecated(CONF_PAYLOAD_MEDIUM_SPEED), - cv.deprecated(CONF_SPEED_COMMAND_TOPIC), - cv.deprecated(CONF_SPEED_LIST), - cv.deprecated(CONF_SPEED_STATE_TOPIC), - cv.deprecated(CONF_SPEED_VALUE_TEMPLATE), + cv.removed(CONF_PAYLOAD_HIGH_SPEED), + cv.removed(CONF_PAYLOAD_LOW_SPEED), + cv.removed(CONF_PAYLOAD_MEDIUM_SPEED), + cv.removed(CONF_SPEED_COMMAND_TOPIC), + cv.removed(CONF_SPEED_LIST), + cv.removed(CONF_SPEED_STATE_TOPIC), + cv.removed(CONF_SPEED_VALUE_TEMPLATE), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), valid_speed_range_configuration, valid_preset_mode_configuration, diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index f2ac86239f8..c9cd3c66e92 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -78,7 +78,6 @@ from homeassistant.helpers import ( script_variables as script_variables_helper, template as template_helper, ) -from homeassistant.helpers.logging import KeywordStyleAdapter from homeassistant.util import raise_if_invalid_path, slugify as util_slugify import homeassistant.util.dt as dt_util @@ -709,23 +708,26 @@ class multi_select: return selected -def deprecated( +def _deprecated_or_removed( key: str, replacement_key: str | None = None, default: Any | None = None, + raise_if_present: bool | None = False, + option_status: str | None = "is deprecated", ) -> Callable[[dict], dict]: """ - Log key as deprecated and provide a replacement (if exists). + Log key as deprecated and provide a replacement (if exists) or fail. Expected behavior: - - Outputs the appropriate deprecation warning if key is detected + - Outputs or throws the appropriate deprecation warning if key is detected + - Outputs or throws the appropriate error if key is detected and removed from support - Processes schema moving the value from key to replacement_key - Processes schema changing nothing if only replacement_key provided - No warning if only replacement_key provided - No warning if neither key nor replacement_key are provided - Adds replacement_key with default value in this case """ - module = inspect.getmodule(inspect.stack(context=0)[1].frame) + module = inspect.getmodule(inspect.stack(context=0)[2].frame) if module is not None: module_name = module.__name__ else: @@ -733,36 +735,36 @@ def deprecated( # will be missing information, so let's guard. # https://github.com/home-assistant/core/issues/24982 module_name = __name__ - if replacement_key: warning = ( - "The '{key}' option is deprecated," + "The '{key}' option {option_status}," " please replace it with '{replacement_key}'" ) else: warning = ( - "The '{key}' option is deprecated," + "The '{key}' option {option_status}," " please remove it from your configuration" ) def validator(config: dict) -> dict: - """Check if key is in config and log warning.""" + """Check if key is in config and log warning or error.""" if key in config: try: - KeywordStyleAdapter(logging.getLogger(module_name)).warning( - warning.replace( - "'{key}' option", - f"'{key}' option near {config.__config_file__}:{config.__line__}", # type: ignore - ), - key=key, - replacement_key=replacement_key, + warning_local = warning.replace( + "'{key}' option", + f"'{key}' option near {config.__config_file__}:{config.__line__}", # type: ignore ) except AttributeError: - KeywordStyleAdapter(logging.getLogger(module_name)).warning( - warning, - key=key, - replacement_key=replacement_key, - ) + warning_local = warning + warning_local = warning_local.format( + key=key, + replacement_key=replacement_key, + option_status=option_status, + ) + if raise_if_present: + raise vol.Invalid(warning_local) + + logging.getLogger(module_name).warning(warning_local) value = config[key] if replacement_key: config.pop(key) @@ -782,6 +784,52 @@ def deprecated( return validator +def deprecated( + key: str, + replacement_key: str | None = None, + default: Any | None = None, + raise_if_present: bool | None = False, +) -> Callable[[dict], dict]: + """ + Log key as deprecated and provide a replacement (if exists). + + Expected behavior: + - Outputs the appropriate deprecation warning if key is detected or raises an exception + - Processes schema moving the value from key to replacement_key + - Processes schema changing nothing if only replacement_key provided + - No warning if only replacement_key provided + - No warning if neither key nor replacement_key are provided + - Adds replacement_key with default value in this case + """ + return _deprecated_or_removed( + key, + replacement_key=replacement_key, + default=default, + raise_if_present=raise_if_present, + option_status="is deprecated", + ) + + +def removed( + key: str, + default: Any | None = None, + raise_if_present: bool | None = True, +) -> Callable[[dict], dict]: + """ + Log key as deprecated and fail the config validation. + + Expected behavior: + - Outputs the appropriate error if key is detected and removed from support or raises an exception + """ + return _deprecated_or_removed( + key, + replacement_key=None, + default=default, + raise_if_present=raise_if_present, + option_status="was removed", + ) + + def key_value_schemas( key: str, value_schemas: dict[Hashable, vol.Schema] ) -> Callable[[Any], dict[Hashable, Any]]: diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index cf832dfde50..79fa5538417 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -712,6 +712,46 @@ def test_deprecated_with_no_optionals(caplog, schema): assert test_data == output +def test_deprecated_or_removed_param_and_raise(caplog, schema): + """ + Test removed or deprecation options and fail the config validation by raising an exception. + + Expected behavior: + - Outputs the appropriate deprecation or removed from support error if key is detected + """ + removed_schema = vol.All(cv.deprecated("mars", raise_if_present=True), schema) + + test_data = {"mars": True} + with pytest.raises(vol.Invalid) as excinfo: + removed_schema(test_data) + assert ( + "The 'mars' option is deprecated, please remove it from your configuration" + in str(excinfo.value) + ) + assert len(caplog.records) == 0 + + test_data = {"venus": True} + output = removed_schema(test_data.copy()) + assert len(caplog.records) == 0 + assert test_data == output + + deprecated_schema = vol.All(cv.removed("mars"), schema) + + test_data = {"mars": True} + with pytest.raises(vol.Invalid) as excinfo: + deprecated_schema(test_data) + assert ( + "The 'mars' option was removed, please remove it from your configuration" + in str(excinfo.value) + ) + assert len(caplog.records) == 0 + + test_data = {"venus": True} + output = deprecated_schema(test_data.copy()) + assert len(caplog.records) == 0 + assert test_data == output + + def test_deprecated_with_replacement_key(caplog, schema): """ Test deprecation behaves correctly when only a replacement key is provided. @@ -846,17 +886,43 @@ def test_deprecated_cant_find_module(): default=False, ) + with patch("inspect.getmodule", return_value=None): + # This used to raise. + cv.removed( + "mars", + default=False, + ) -def test_deprecated_logger_with_config_attributes(caplog): + +def test_deprecated_or_removed_logger_with_config_attributes(caplog): """Test if the logger outputs the correct message if the line and file attribute is available in config.""" file: str = "configuration.yaml" line: int = 54 - replacement = f"'mars' option near {file}:{line} is deprecated" + + # test as deprecated option + replacement_key = "jupiter" + option_status = "is deprecated" + replacement = f"'mars' option near {file}:{line} {option_status}, please replace it with '{replacement_key}'" config = OrderedDict([("mars", "blah")]) setattr(config, "__config_file__", file) setattr(config, "__line__", line) - cv.deprecated("mars", replacement_key="jupiter", default=False)(config) + cv.deprecated("mars", replacement_key=replacement_key, default=False)(config) + + assert len(caplog.records) == 1 + assert replacement in caplog.text + + caplog.clear() + assert len(caplog.records) == 0 + + # test as removed option + option_status = "was removed" + replacement = f"'mars' option near {file}:{line} {option_status}, please remove it from your configuration" + config = OrderedDict([("mars", "blah")]) + setattr(config, "__config_file__", file) + setattr(config, "__line__", line) + + cv.removed("mars", default=False, raise_if_present=False)(config) assert len(caplog.records) == 1 assert replacement in caplog.text