diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 15cd10552ed..5286b01f67f 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -2,11 +2,10 @@ from __future__ import annotations -from abc import abstractmethod import asyncio from collections.abc import Mapping from datetime import datetime -from functools import partial +from functools import cached_property, partial import hashlib from http import HTTPStatus import io @@ -373,12 +372,25 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -class TextToSpeechEntity(RestoreEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "default_language", + "default_options", + "supported_languages", + "supported_options", +} + + +class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Represent a single TTS engine.""" _attr_should_poll = False __last_tts_loaded: str | None = None + _attr_default_language: str + _attr_default_options: Mapping[str, Any] | None = None + _attr_supported_languages: list[str] + _attr_supported_options: list[str] | None = None + @property @final def state(self) -> str | None: @@ -387,25 +399,25 @@ class TextToSpeechEntity(RestoreEntity): return None return self.__last_tts_loaded - @property - @abstractmethod + @cached_property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" + return self._attr_supported_languages - @property - @abstractmethod + @cached_property def default_language(self) -> str: """Return the default language.""" + return self._attr_default_language - @property + @cached_property def supported_options(self) -> list[str] | None: """Return a list of supported options like voice, emotions.""" - return None + return self._attr_supported_options - @property + @cached_property def default_options(self) -> Mapping[str, Any] | None: """Return a mapping with the default options.""" - return None + return self._attr_default_options @callback def async_get_supported_voices(self, language: str) -> list[Voice] | None: @@ -415,6 +427,18 @@ class TextToSpeechEntity(RestoreEntity): async def async_internal_added_to_hass(self) -> None: """Call when the entity is added to hass.""" await super().async_internal_added_to_hass() + try: + _ = self.default_language + except AttributeError as err: + raise AttributeError( + "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + ) from err + try: + _ = self.supported_languages + except AttributeError as err: + raise AttributeError( + "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + ) from err state = await self.async_get_last_state() if ( state is not None diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 0a7813415d4..bf44f120134 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -47,15 +47,8 @@ ORIG_WRITE_TAGS = tts.SpeechManager.write_tags class DefaultEntity(tts.TextToSpeechEntity): """Test entity.""" - @property - def supported_languages(self) -> list[str]: - """Return a list of supported languages.""" - return SUPPORT_LANGUAGES - - @property - def default_language(self) -> str: - """Return the default language.""" - return DEFAULT_LANG + _attr_supported_languages = SUPPORT_LANGUAGES + _attr_default_language = DEFAULT_LANG async def test_default_entity_attributes() -> None: @@ -523,10 +516,7 @@ class MockProviderWithDefaults(MockProvider): class MockEntityWithDefaults(MockTTSEntity): """Mock entity with default options.""" - @property - def default_options(self): - """Return a mapping with the default options.""" - return {"voice": "alex"} + _attr_default_options = {"voice": "alex"} @pytest.mark.parametrize( @@ -1758,3 +1748,93 @@ async def test_async_convert_audio_error(hass: HomeAssistant) -> None: with pytest.raises(RuntimeError): # Simulate a bad WAV file await tts.async_convert_audio(hass, "wav", bytes(0), "mp3") + + +async def test_ttsentity_subclass_properties( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test for errors when subclasses of the TextToSpeechEntity are missing required properties.""" + + class TestClass1(tts.TextToSpeechEntity): + _attr_default_language = DEFAULT_LANG + _attr_supported_languages = SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass1()) + + class TestClass2(tts.TextToSpeechEntity): + @property + def default_language(self) -> str: + return DEFAULT_LANG + + @property + def supported_languages(self) -> list[str]: + return SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass2()) + + assert all(record.exc_info is None for record in caplog.records) + + caplog.clear() + + class TestClass3(tts.TextToSpeechEntity): + _attr_default_language = DEFAULT_LANG + + await mock_config_entry_setup(hass, TestClass3()) + + assert ( + "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + caplog.clear() + + class TestClass4(tts.TextToSpeechEntity): + _attr_supported_languages = SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass4()) + + assert ( + "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + caplog.clear() + + class TestClass5(tts.TextToSpeechEntity): + @property + def default_language(self) -> str: + return DEFAULT_LANG + + await mock_config_entry_setup(hass, TestClass5()) + + assert ( + "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + caplog.clear() + + class TestClass6(tts.TextToSpeechEntity): + @property + def supported_languages(self) -> list[str]: + return SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass6()) + + assert ( + "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + )