From 4da701a8e9ee880fbbfbaec24c237e1e6146a509 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 21 Mar 2024 23:12:25 +0100 Subject: [PATCH] Add guard to HomeAssistantError `__str__` method to prevent a recursive loop (#113913) * Add guard to HomeAssistantError `__str__` method to prevent a recursive loop * Use repr of class instance instead * Apply suggestion to explain __str__ method is missing --------- Co-authored-by: J. Nick Koston --- homeassistant/exceptions.py | 9 +++ tests/test_exceptions.py | 156 ++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 81856f27c45..a58f683137b 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -64,6 +64,15 @@ class HomeAssistantError(Exception): return self._message if not self.generate_message: + # Initialize self._message to the string repr of the class + # to prevent a recursive loop. + self._message = ( + f"Parent class {self.__class__.__name__} is missing __str__ method" + ) + # If the there is an other super class involved, + # we want to call its __str__ method. + # If the super().__str__ method is missing in the base_class + # the call will be recursive and we return our initialized default. self._message = super().__str__() return self._message diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index f5b60627ce2..e5fd31c3b44 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -119,3 +119,159 @@ async def test_home_assistant_error( assert str(exc.value) == message # Get string of exception again from the cache assert str(exc.value) == message + + +async def test_home_assistant_error_subclass(hass: HomeAssistant) -> None: + """Test __str__ method on an HomeAssistantError subclass.""" + + class _SubExceptionDefault(HomeAssistantError): + """Sub class, default with generated message.""" + + class _SubExceptionConstructor(HomeAssistantError): + """Sub class with constructor, no generated message.""" + + def __init__( + self, + custom_arg: str, + translation_domain: str | None = None, + translation_key: str | None = None, + translation_placeholders: dict[str, str] | None = None, + ) -> None: + super().__init__( + self, + translation_domain=translation_domain, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + self.custom_arg = custom_arg + + class _SubExceptionConstructorGenerate(HomeAssistantError): + """Sub class with constructor, with generated message.""" + + generate_message: bool = True + + def __init__( + self, + custom_arg: str, + translation_domain: str | None = None, + translation_key: str | None = None, + translation_placeholders: dict[str, str] | None = None, + ) -> None: + super().__init__( + self, + translation_domain=translation_domain, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + self.custom_arg = custom_arg + + class _SubExceptionGenerate(HomeAssistantError): + """Sub class, no generated message.""" + + generate_message: bool = True + + class _SubClassWithExceptionGroup(HomeAssistantError, BaseExceptionGroup): + """Sub class with exception group, no generated message.""" + + class _SubClassWithExceptionGroupGenerate(HomeAssistantError, BaseExceptionGroup): + """Sub class with exception group and generated message.""" + + generate_message: bool = True + + with patch( + "homeassistant.helpers.translation.async_get_cached_translations", + return_value={"component.test.exceptions.bla.message": "{bla} from cache"}, + ): + # A subclass without a constructor generates a message by default + with pytest.raises(HomeAssistantError) as exc: + raise _SubExceptionDefault( + translation_domain="test", + translation_key="bla", + translation_placeholders={"bla": "Bla"}, + ) + assert str(exc.value) == "Bla from cache" + + # A subclass with a constructor that does not parse `args` to the super class + with pytest.raises(HomeAssistantError) as exc: + raise _SubExceptionConstructor( + "custom arg", + translation_domain="test", + translation_key="bla", + translation_placeholders={"bla": "Bla"}, + ) + assert ( + str(exc.value) + == "Parent class _SubExceptionConstructor is missing __str__ method" + ) + with pytest.raises(HomeAssistantError) as exc: + raise _SubExceptionConstructor( + "custom arg", + ) + assert ( + str(exc.value) + == "Parent class _SubExceptionConstructor is missing __str__ method" + ) + + # A subclass with a constructor that generates the message + with pytest.raises(HomeAssistantError) as exc: + raise _SubExceptionConstructorGenerate( + "custom arg", + translation_domain="test", + translation_key="bla", + translation_placeholders={"bla": "Bla"}, + ) + assert str(exc.value) == "Bla from cache" + + # A subclass without overridden constructors and passed args + # defaults to the passed args + with pytest.raises(HomeAssistantError) as exc: + raise _SubExceptionDefault( + ValueError("wrong value"), + translation_domain="test", + translation_key="bla", + translation_placeholders={"bla": "Bla"}, + ) + assert str(exc.value) == "wrong value" + + # A subclass without overridden constructors and passed args + # and generate_message = True, generates a message + with pytest.raises(HomeAssistantError) as exc: + raise _SubExceptionGenerate( + ValueError("wrong value"), + translation_domain="test", + translation_key="bla", + translation_placeholders={"bla": "Bla"}, + ) + assert str(exc.value) == "Bla from cache" + + # A subclass with and ExceptionGroup subclass requires a message to be passed. + # As we pass args, we will not generate the message. + # The __str__ constructor defaults to that of the super class. + with pytest.raises(HomeAssistantError) as exc: + raise _SubClassWithExceptionGroup( + "group message", + [ValueError("wrong value"), TypeError("wrong type")], + translation_domain="test", + translation_key="bla", + translation_placeholders={"bla": "Bla"}, + ) + assert str(exc.value) == "group message (2 sub-exceptions)" + with pytest.raises(HomeAssistantError) as exc: + raise _SubClassWithExceptionGroup( + "group message", + [ValueError("wrong value"), TypeError("wrong type")], + ) + assert str(exc.value) == "group message (2 sub-exceptions)" + + # A subclass with and ExceptionGroup subclass requires a message to be passed. + # The `generate_message` flag is set.` + # The __str__ constructor will return the generated message. + with pytest.raises(HomeAssistantError) as exc: + raise _SubClassWithExceptionGroupGenerate( + "group message", + [ValueError("wrong value"), TypeError("wrong type")], + translation_domain="test", + translation_key="bla", + translation_placeholders={"bla": "Bla"}, + ) + assert str(exc.value) == "Bla from cache"