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 <nick@koston.org>
This commit is contained in:
Jan Bouwhuis 2024-03-21 23:12:25 +01:00 committed by GitHub
parent a6d98c1857
commit 4da701a8e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 165 additions and 0 deletions

View File

@ -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

View File

@ -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"