Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions aiohasupervisor/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
"""Init file for aiohasupervisor."""

from aiohasupervisor.exceptions import (
AddonNotSupportedArchitectureError,
AddonNotSupportedError,
AddonNotSupportedHomeAssistantVersionError,
AddonNotSupportedMachineTypeError,
SupervisorAuthenticationError,
SupervisorBadRequestError,
SupervisorConnectionError,
Expand All @@ -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",
]
10 changes: 9 additions & 1 deletion aiohasupervisor/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from .const import DEFAULT_TIMEOUT, ResponseType
from .exceptions import (
ERROR_KEYS,
SupervisorAuthenticationError,
SupervisorBadRequestError,
SupervisorConnectionError,
Expand Down Expand Up @@ -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(
Expand Down
53 changes: 51 additions & 2 deletions aiohasupervisor/exceptions.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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."""
3 changes: 3 additions & 0 deletions aiohasupervisor/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 28 additions & 2 deletions aiohasupervisor/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions tests/fixtures/store_addon_availability_error_architecture.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
10 changes: 10 additions & 0 deletions tests/fixtures/store_addon_availability_error_home_assistant.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
10 changes: 10 additions & 0 deletions tests/fixtures/store_addon_availability_error_machine.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
10 changes: 10 additions & 0 deletions tests/fixtures/store_addon_availability_error_other.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"result": "error",
"message": "Add-on core_mosquitto not supported on this system, requires <something new> to be <something else>",
"error_key": "addon_not_supported_other_error",
"message_template": "Add-on {slug} not supported on this system, requires <something new> to be {requirement}",
"extra_fields": {
"slug": "core_mosquitto",
"requirement": "<something else>"
}
}
97 changes: 97 additions & 0 deletions tests/test_store.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading