diff --git a/aiohasupervisor/__init__.py b/aiohasupervisor/__init__.py index 4f9f524..09a6efa 100644 --- a/aiohasupervisor/__init__.py +++ b/aiohasupervisor/__init__.py @@ -1,6 +1,10 @@ """Init file for aiohasupervisor.""" from aiohasupervisor.exceptions import ( + AddonNotSupportedArchitectureError, + AddonNotSupportedError, + AddonNotSupportedHomeAssistantVersionError, + AddonNotSupportedMachineTypeError, SupervisorAuthenticationError, SupervisorBadRequestError, SupervisorConnectionError, @@ -14,14 +18,18 @@ from aiohasupervisor.root import SupervisorClient __all__ = [ - "SupervisorError", - "SupervisorConnectionError", + "AddonNotSupportedArchitectureError", + "AddonNotSupportedError", + "AddonNotSupportedHomeAssistantVersionError", + "AddonNotSupportedMachineTypeError", "SupervisorAuthenticationError", "SupervisorBadRequestError", + "SupervisorClient", + "SupervisorConnectionError", + "SupervisorError", "SupervisorForbiddenError", "SupervisorNotFoundError", "SupervisorResponseError", "SupervisorServiceUnavailableError", "SupervisorTimeoutError", - "SupervisorClient", ] diff --git a/aiohasupervisor/client.py b/aiohasupervisor/client.py index fdca4ec..62cfdf8 100644 --- a/aiohasupervisor/client.py +++ b/aiohasupervisor/client.py @@ -17,6 +17,7 @@ from .const import DEFAULT_TIMEOUT, ResponseType from .exceptions import ( + ERROR_KEYS, SupervisorAuthenticationError, SupervisorBadRequestError, SupervisorConnectionError, @@ -73,7 +74,14 @@ async def _raise_on_status(self, response: ClientResponse) -> None: if is_json(response): result = Response.from_json(await response.text()) - raise exc_type(result.message, result.job_id) + if result.error_key in ERROR_KEYS: + exc_type = ERROR_KEYS[result.error_key] + raise exc_type( + result.message, + result.message_template, + result.extra_fields, + result.job_id, + ) raise exc_type() async def _request( diff --git a/aiohasupervisor/exceptions.py b/aiohasupervisor/exceptions.py index 4bd8b75..b397bb5 100644 --- a/aiohasupervisor/exceptions.py +++ b/aiohasupervisor/exceptions.py @@ -1,17 +1,47 @@ """Exceptions from supervisor client.""" +from abc import ABC +from collections.abc import Callable +from typing import Any + class SupervisorError(Exception): """Generic exception.""" - def __init__(self, message: str | None = None, job_id: str | None = None) -> None: + error_key: str | None = None + + def __init__( + self, + message: str | None = None, + message_template: str | None = None, + extra_fields: dict[str, Any] | None = None, + job_id: str | None = None, + ) -> None: """Initialize exception.""" if message is not None: super().__init__(message) else: super().__init__() - self.job_id: str | None = job_id + self.job_id = job_id + self.message_template = message_template + self.extra_fields = extra_fields + + +ERROR_KEYS: dict[str, type[SupervisorError]] = {} + + +def error_key( + key: str, +) -> Callable[[type[SupervisorError]], type[SupervisorError]]: + """Store exception in keyed error map.""" + + def wrap(cls: type[SupervisorError]) -> type[SupervisorError]: + ERROR_KEYS[key] = cls + cls.error_key = key + return cls + + return wrap class SupervisorConnectionError(SupervisorError, ConnectionError): @@ -44,3 +74,22 @@ class SupervisorServiceUnavailableError(SupervisorError): class SupervisorResponseError(SupervisorError): """Unusable response received from Supervisor with the wrong type or encoding.""" + + +class AddonNotSupportedError(SupervisorError, ABC): + """Addon is not supported on this system.""" + + +@error_key("addon_not_supported_architecture_error") +class AddonNotSupportedArchitectureError(AddonNotSupportedError): + """Addon is not supported on this system due to its architecture.""" + + +@error_key("addon_not_supported_machine_type_error") +class AddonNotSupportedMachineTypeError(AddonNotSupportedError): + """Addon is not supported on this system due to its machine type.""" + + +@error_key("addon_not_supported_home_assistant_version_error") +class AddonNotSupportedHomeAssistantVersionError(AddonNotSupportedError): + """Addon is not supported on this system due to its version of Home Assistant.""" diff --git a/aiohasupervisor/models/base.py b/aiohasupervisor/models/base.py index 0cb0d22..18c40f5 100644 --- a/aiohasupervisor/models/base.py +++ b/aiohasupervisor/models/base.py @@ -84,6 +84,9 @@ class Response(DataClassORJSONMixin): data: Any | None = None message: str | None = None job_id: str | None = None + error_key: str | None = None + message_template: str | None = None + extra_fields: dict[str, Any] | None = None @dataclass(frozen=True, slots=True) diff --git a/aiohasupervisor/store.py b/aiohasupervisor/store.py index 71ebc9e..5013c2f 100644 --- a/aiohasupervisor/store.py +++ b/aiohasupervisor/store.py @@ -45,14 +45,40 @@ async def addon_documentation(self, addon: str) -> str: ) return result.data + async def addon_availability(self, addon: str) -> None: + """Determine if latest version of addon can be installed on this system. + + No return means it can be. If not, raises one of the following errors: + - AddonUnavailableHomeAssistantVersionError + - AddonUnavailableArchitectureError + - AddonUnavailableMachineTypeError + + If Supervisor adds a new reason an add-on can be restricted from being + installed on some systems in the future, older versions of this client + will raise the generic SupervisorBadRequestError for that reason. + """ + await self._client.get( + f"store/addons/{addon}/availability", response_type=ResponseType.NONE + ) + async def install_addon(self, addon: str) -> None: - """Install an addon.""" + """Install an addon. + + Supervisor does an availability check before install. If the addon + cannot be installed on this system it will raise one of those errors + shown in the `addon_availability` method. + """ await self._client.post(f"store/addons/{addon}/install", timeout=None) async def update_addon( self, addon: str, options: StoreAddonUpdate | None = None ) -> None: - """Update an addon to latest version.""" + """Update an addon to latest version. + + Supervisor does an availability check before update. If the new version + cannot be installed on this system it will raise one of those errors + shown in the `addon_availability` method. + """ await self._client.post( f"store/addons/{addon}/update", json=options.to_dict() if options else None, diff --git a/tests/fixtures/store_addon_availability_error_architecture.json b/tests/fixtures/store_addon_availability_error_architecture.json new file mode 100644 index 0000000..edbff25 --- /dev/null +++ b/tests/fixtures/store_addon_availability_error_architecture.json @@ -0,0 +1,10 @@ +{ + "result": "error", + "message": "Add-on core_mosquitto not supported on this platform, supported architectures: i386", + "error_key": "addon_not_supported_architecture_error", + "message_template": "Add-on {slug} not supported on this platform, supported architectures: {architectures}", + "extra_fields": { + "slug": "core_mosquitto", + "architectures": "i386" + } +} diff --git a/tests/fixtures/store_addon_availability_error_home_assistant.json b/tests/fixtures/store_addon_availability_error_home_assistant.json new file mode 100644 index 0000000..f2c5929 --- /dev/null +++ b/tests/fixtures/store_addon_availability_error_home_assistant.json @@ -0,0 +1,10 @@ +{ + "result": "error", + "message": "Add-on core_mosquitto not supported on this system, requires Home Assistant version 2023.1.1 or greater", + "error_key": "addon_not_supported_home_assistant_version_error", + "message_template": "Add-on {slug} not supported on this system, requires Home Assistant version {version} or greater", + "extra_fields": { + "slug": "core_mosquitto", + "version": "2023.1.1" + } +} diff --git a/tests/fixtures/store_addon_availability_error_machine.json b/tests/fixtures/store_addon_availability_error_machine.json new file mode 100644 index 0000000..2d60a5e --- /dev/null +++ b/tests/fixtures/store_addon_availability_error_machine.json @@ -0,0 +1,10 @@ +{ + "result": "error", + "message": "Add-on core_mosquitto not supported on this machine, supported machine types: odroid-n2", + "error_key": "addon_not_supported_machine_type_error", + "message_template": "Add-on {slug} not supported on this machine, supported machine types: {machine_types}", + "extra_fields": { + "slug": "core_mosquitto", + "machine_types": "odroid-n2" + } +} diff --git a/tests/fixtures/store_addon_availability_error_other.json b/tests/fixtures/store_addon_availability_error_other.json new file mode 100644 index 0000000..1ee0235 --- /dev/null +++ b/tests/fixtures/store_addon_availability_error_other.json @@ -0,0 +1,10 @@ +{ + "result": "error", + "message": "Add-on core_mosquitto not supported on this system, requires to be ", + "error_key": "addon_not_supported_other_error", + "message_template": "Add-on {slug} not supported on this system, requires to be {requirement}", + "extra_fields": { + "slug": "core_mosquitto", + "requirement": "" + } +} diff --git a/tests/test_store.py b/tests/test_store.py index 385fd04..f135509 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -1,9 +1,19 @@ """Tests for store supervisor client.""" +from json import loads + from aioresponses import aioresponses +import pytest from yarl import URL from aiohasupervisor import SupervisorClient +from aiohasupervisor.exceptions import ( + AddonNotSupportedArchitectureError, + AddonNotSupportedHomeAssistantVersionError, + AddonNotSupportedMachineTypeError, + SupervisorBadRequestError, + SupervisorError, +) from aiohasupervisor.models import StoreAddonUpdate, StoreAddRepository from . import load_fixture @@ -132,6 +142,93 @@ async def test_store_addon_update( } +async def test_store_addon_availability( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test store addon availability API.""" + responses.get( + f"{SUPERVISOR_URL}/store/addons/core_mosquitto/availability", status=200 + ) + + assert (await supervisor_client.store.addon_availability("core_mosquitto")) is None + + +@pytest.mark.parametrize( + ("error_fixture", "error_key", "exc_type"), + [ + ( + "store_addon_availability_error_architecture.json", + "addon_not_supported_architecture_error", + AddonNotSupportedArchitectureError, + ), + ( + "store_addon_availability_error_machine.json", + "addon_not_supported_machine_type_error", + AddonNotSupportedMachineTypeError, + ), + ( + "store_addon_availability_error_home_assistant.json", + "addon_not_supported_home_assistant_version_error", + AddonNotSupportedHomeAssistantVersionError, + ), + ( + "store_addon_availability_error_other.json", + None, + SupervisorBadRequestError, + ), + ], +) +async def test_store_addon_availability_error( + responses: aioresponses, + supervisor_client: SupervisorClient, + error_fixture: str, + error_key: str | None, + exc_type: type[SupervisorError], +) -> None: + """Test store addon availability errors.""" + error_body = load_fixture(error_fixture) + error_data = loads(error_body) + + def check_availability_error(err: SupervisorError) -> bool: + assert err.error_key == error_key + assert err.message_template == error_data["message_template"] + assert err.extra_fields == error_data["extra_fields"] + return True + + # Availability API + responses.get( + f"{SUPERVISOR_URL}/store/addons/core_mosquitto/availability", + status=400, + body=error_body, + ) + with pytest.raises( + exc_type, match=error_data["message"], check=check_availability_error + ): + await supervisor_client.store.addon_availability("core_mosquitto") + + # Install API + responses.post( + f"{SUPERVISOR_URL}/store/addons/core_mosquitto/install", + status=400, + body=error_body, + ) + with pytest.raises( + exc_type, match=error_data["message"], check=check_availability_error + ): + await supervisor_client.store.install_addon("core_mosquitto") + + # Update API + responses.post( + f"{SUPERVISOR_URL}/store/addons/core_mosquitto/update", + status=400, + body=error_body, + ) + with pytest.raises( + exc_type, match=error_data["message"], check=check_availability_error + ): + await supervisor_client.store.update_addon("core_mosquitto") + + async def test_store_reload( responses: aioresponses, supervisor_client: SupervisorClient ) -> None: