diff --git a/.strict-typing b/.strict-typing index 2dadf1a1d5b636c2428c317b2e93a27bb8fa2af2..97fc7587a9de11f3095d8fcb4f2dedf777a3316a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -58,6 +58,7 @@ homeassistant.components.http.* homeassistant.components.huawei_lte.* homeassistant.components.hyperion.* homeassistant.components.image_processing.* +homeassistant.components.input_select.* homeassistant.components.integration.* homeassistant.components.iqvia.* homeassistant.components.jewish_calendar.* diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index 859ae6f91e1d6a02c65a89eccaa14db12d05612f..1443ac6d1e17d9c32109c084c1451047510cdf04 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -2,9 +2,11 @@ from __future__ import annotations import logging +from typing import Any, Dict, cast import voluptuous as vol +from homeassistant.components.select import SelectEntity from homeassistant.const import ( ATTR_EDITABLE, ATTR_OPTION, @@ -55,7 +57,7 @@ UPDATE_FIELDS = { } -def _cv_input_select(cfg): +def _cv_input_select(cfg: dict[str, Any]) -> dict[str, Any]: """Configure validation helper for input select (voluptuous).""" options = cfg[CONF_OPTIONS] initial = cfg.get(CONF_INITIAL) @@ -183,138 +185,137 @@ class InputSelectStorageCollection(collection.StorageCollection): CREATE_SCHEMA = vol.Schema(vol.All(CREATE_FIELDS, _cv_input_select)) UPDATE_SCHEMA = vol.Schema(UPDATE_FIELDS) - async def _process_create_data(self, data: dict) -> dict: + async def _process_create_data(self, data: dict[str, Any]) -> dict[str, Any]: """Validate the config is valid.""" - return self.CREATE_SCHEMA(data) + return cast(Dict[str, Any], self.CREATE_SCHEMA(data)) @callback - def _get_suggested_id(self, info: dict) -> str: + def _get_suggested_id(self, info: dict[str, Any]) -> str: """Suggest an ID based on the config.""" - return info[CONF_NAME] + return cast(str, info[CONF_NAME]) - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data( + self, data: dict[str, Any], update_data: dict[str, Any] + ) -> dict[str, Any]: """Return a new updated data object.""" update_data = self.UPDATE_SCHEMA(update_data) return _cv_input_select({**data, **update_data}) -class InputSelect(RestoreEntity): +class InputSelect(SelectEntity, RestoreEntity): """Representation of a select input.""" - def __init__(self, config: dict) -> None: + _attr_should_poll = False + editable = True + + def __init__(self, config: ConfigType) -> None: """Initialize a select input.""" - self._config = config - self.editable = True - self._current_option = config.get(CONF_INITIAL) + self._attr_current_option = config.get(CONF_INITIAL) + self._attr_icon = config.get(CONF_ICON) + self._attr_name = config.get(CONF_NAME) + self._attr_options = config[CONF_OPTIONS] + self._attr_unique_id = config[CONF_ID] @classmethod - def from_yaml(cls, config: dict) -> InputSelect: + def from_yaml(cls, config: ConfigType) -> InputSelect: """Return entity instance initialized from yaml storage.""" input_select = cls(config) input_select.entity_id = f"{DOMAIN}.{config[CONF_ID]}" input_select.editable = False return input_select - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added.""" await super().async_added_to_hass() - if self._current_option is not None: + if self.current_option is not None: return state = await self.async_get_last_state() - if not state or state.state not in self._options: - self._current_option = self._options[0] + if not state or state.state not in self.options: + self._attr_current_option = self.options[0] else: - self._current_option = state.state - - @property - def should_poll(self): - """If entity should be polled.""" - return False - - @property - def name(self): - """Return the name of the select input.""" - return self._config.get(CONF_NAME) - - @property - def icon(self): - """Return the icon to be used for this entity.""" - return self._config.get(CONF_ICON) + self._attr_current_option = state.state @property - def _options(self) -> list[str]: - """Return a list of selection options.""" - return self._config[CONF_OPTIONS] - - @property - def state(self): - """Return the state of the component.""" - return self._current_option - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, bool]: """Return the state attributes.""" - return {ATTR_OPTIONS: self._config[ATTR_OPTIONS], ATTR_EDITABLE: self.editable} - - @property - def unique_id(self) -> str | None: - """Return unique id for the entity.""" - return self._config[CONF_ID] + return {ATTR_EDITABLE: self.editable} - @callback - def async_select_option(self, option): + async def async_select_option(self, option: str) -> None: """Select new option.""" - if option not in self._options: + if option not in self.options: _LOGGER.warning( "Invalid option: %s (possible options: %s)", option, - ", ".join(self._options), + ", ".join(self.options), ) return - self._current_option = option + self._attr_current_option = option self.async_write_ha_state() @callback - def async_select_index(self, idx): + def async_select_index(self, idx: int) -> None: """Select new option by index.""" - new_index = idx % len(self._options) - self._current_option = self._options[new_index] + new_index = idx % len(self.options) + self._attr_current_option = self.options[new_index] self.async_write_ha_state() @callback - def async_offset_index(self, offset, cycle): + def async_offset_index(self, offset: int, cycle: bool) -> None: """Offset current index.""" - current_index = self._options.index(self._current_option) + + current_index = ( + self.options.index(self.current_option) + if self.current_option is not None + else 0 + ) + new_index = current_index + offset if cycle: - new_index = new_index % len(self._options) - else: - if new_index < 0: - new_index = 0 - elif new_index >= len(self._options): - new_index = len(self._options) - 1 - self._current_option = self._options[new_index] + new_index = new_index % len(self.options) + elif new_index < 0: + new_index = 0 + elif new_index >= len(self.options): + new_index = len(self.options) - 1 + + self._attr_current_option = self.options[new_index] self.async_write_ha_state() @callback - def async_next(self, cycle): + def async_next(self, cycle: bool) -> None: """Select next option.""" + # If there is no current option, first item is the next + if self.current_option is None: + self.async_select_index(0) + return self.async_offset_index(1, cycle) @callback - def async_previous(self, cycle): + def async_previous(self, cycle: bool) -> None: """Select previous option.""" + # If there is no current option, last item is the previous + if self.current_option is None: + self.async_select_index(-1) + return self.async_offset_index(-1, cycle) - @callback - def async_set_options(self, options): + async def async_set_options(self, options: list[str]) -> None: """Set options.""" - self._current_option = options[0] - self._config[CONF_OPTIONS] = options + self._attr_options = options + + if self.current_option not in self.options: + _LOGGER.warning( + "Current option: %s no longer valid (possible options: %s)", + self.current_option, + ", ".join(self.options), + ) + self._attr_current_option = options[0] + self.async_write_ha_state() - async def async_update_config(self, config: dict) -> None: + async def async_update_config(self, config: ConfigType) -> None: """Handle when the config is updated.""" - self._config = config + self._attr_icon = config.get(CONF_ICON) + self._attr_name = config.get(CONF_NAME) + self._attr_options = config[CONF_OPTIONS] self.async_write_ha_state() diff --git a/mypy.ini b/mypy.ini index 9ea9822216336e36c5bab1cd1ab26e93f9242d83..058d587ec22addadc8801409ef07f44666d8d4fa 100644 --- a/mypy.ini +++ b/mypy.ini @@ -649,6 +649,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.input_select.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.integration.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/input_select/test_init.py b/tests/components/input_select/test_init.py index f5f7956e9c5ff667021ad10ccdbb0eb47192b31f..783c4c2b9e4ba83f52a1cc51d67fc1a5caab9606 100644 --- a/tests/components/input_select/test_init.py +++ b/tests/components/input_select/test_init.py @@ -27,7 +27,6 @@ from homeassistant.const import ( from homeassistant.core import Context, State from homeassistant.exceptions import Unauthorized from homeassistant.helpers import entity_registry as er -from homeassistant.loader import bind_hass from homeassistant.setup import async_setup_component from tests.common import mock_restore_cache @@ -65,80 +64,12 @@ def storage_setup(hass, hass_storage): return _storage -@bind_hass -def select_option(hass, entity_id, option): - """Set value of input_select. - - This is a legacy helper method. Do not use it for new tests. - """ - hass.async_create_task( - hass.services.async_call( - DOMAIN, - SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: option}, - ) - ) - - -@bind_hass -def select_next(hass, entity_id): - """Set next value of input_select. - - This is a legacy helper method. Do not use it for new tests. - """ - hass.async_create_task( - hass.services.async_call( - DOMAIN, SERVICE_SELECT_NEXT, {ATTR_ENTITY_ID: entity_id} - ) - ) - - -@bind_hass -def select_previous(hass, entity_id): - """Set previous value of input_select. - - This is a legacy helper method. Do not use it for new tests. - """ - hass.async_create_task( - hass.services.async_call( - DOMAIN, SERVICE_SELECT_PREVIOUS, {ATTR_ENTITY_ID: entity_id} - ) - ) - - -@bind_hass -def select_first(hass, entity_id): - """Set first value of input_select. - - This is a legacy helper method. Do not use it for new tests. - """ - hass.async_create_task( - hass.services.async_call( - DOMAIN, SERVICE_SELECT_FIRST, {ATTR_ENTITY_ID: entity_id} - ) - ) - - -@bind_hass -def select_last(hass, entity_id): - """Set last value of input_select. - - This is a legacy helper method. Do not use it for new tests. - """ - hass.async_create_task( - hass.services.async_call( - DOMAIN, SERVICE_SELECT_LAST, {ATTR_ENTITY_ID: entity_id} - ) - ) - - async def test_config(hass): """Test config.""" invalid_configs = [ None, {}, {"name with space": None}, - # {'bad_options': {'options': None}}, {"bad_initial": {"options": [1, 2], "initial": 3}}, ] @@ -158,15 +89,21 @@ async def test_select_option(hass): state = hass.states.get(entity_id) assert state.state == "some option" - select_option(hass, entity_id, "another option") - await hass.async_block_till_done() - + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "another option"}, + blocking=True, + ) state = hass.states.get(entity_id) assert state.state == "another option" - select_option(hass, entity_id, "non existing option") - await hass.async_block_till_done() - + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "non existing option"}, + blocking=True, + ) state = hass.states.get(entity_id) assert state.state == "another option" @@ -190,15 +127,21 @@ async def test_select_next(hass): state = hass.states.get(entity_id) assert state.state == "middle option" - select_next(hass, entity_id) - await hass.async_block_till_done() - + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_NEXT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) state = hass.states.get(entity_id) assert state.state == "last option" - select_next(hass, entity_id) - await hass.async_block_till_done() - + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_NEXT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) state = hass.states.get(entity_id) assert state.state == "first option" @@ -222,15 +165,21 @@ async def test_select_previous(hass): state = hass.states.get(entity_id) assert state.state == "middle option" - select_previous(hass, entity_id) - await hass.async_block_till_done() - + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_PREVIOUS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) state = hass.states.get(entity_id) assert state.state == "first option" - select_previous(hass, entity_id) - await hass.async_block_till_done() - + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_PREVIOUS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) state = hass.states.get(entity_id) assert state.state == "last option" @@ -254,14 +203,22 @@ async def test_select_first_last(hass): state = hass.states.get(entity_id) assert state.state == "middle option" - select_first(hass, entity_id) - await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_FIRST, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) state = hass.states.get(entity_id) assert state.state == "first option" - select_last(hass, entity_id) - await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_LAST, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) state = hass.states.get(entity_id) assert state.state == "last option" @@ -326,20 +283,39 @@ async def test_set_options_service(hass): state = hass.states.get(entity_id) assert state.state == "middle option" - data = {ATTR_OPTIONS: ["test1", "test2"], "entity_id": entity_id} - await hass.services.async_call(DOMAIN, SERVICE_SET_OPTIONS, data) - await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_OPTIONS, + {ATTR_OPTIONS: ["first option", "middle option"], ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "middle option" + await hass.services.async_call( + DOMAIN, + SERVICE_SET_OPTIONS, + {ATTR_OPTIONS: ["test1", "test2"], ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) state = hass.states.get(entity_id) assert state.state == "test1" - select_option(hass, entity_id, "first option") - await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "first option"}, + blocking=True, + ) state = hass.states.get(entity_id) assert state.state == "test1" - select_option(hass, entity_id, "test2") - await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "test2"}, + blocking=True, + ) state = hass.states.get(entity_id) assert state.state == "test2" @@ -488,7 +464,6 @@ async def test_reload(hass, hass_admin_user, hass_read_only_user): blocking=True, context=Context(user_id=hass_admin_user.id), ) - await hass.async_block_till_done() assert count_start + 2 == len(hass.states.async_entity_ids()) @@ -671,6 +646,5 @@ async def test_setup_no_config(hass, hass_admin_user): blocking=True, context=Context(user_id=hass_admin_user.id), ) - await hass.async_block_till_done() assert count_start == len(hass.states.async_entity_ids()) diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index 40ead0d637c17ba1e4a9c64f3e4137d28ac75a83..66f67d93754d26c6f31f7eb569da2eb5e815629e 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -214,7 +214,7 @@ async def test_templates_with_entities(hass, calls): blocking=True, ) await hass.async_block_till_done() - _verify(hass, "a", ["a", "b", "c"]) + _verify(hass, "b", ["a", "b", "c"]) await hass.services.async_call( SELECT_DOMAIN,