Skip to content
Merged
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
20 changes: 6 additions & 14 deletions majordom_integration_sdk/errors.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
"""Errors an integration raises for the Hub (and through it the app) to act on."""

import re
from typing import ClassVar, Final, Self
from typing import ClassVar

_TYPE = re.compile(r"[a-z][a-z0-9]*(?:_[a-z0-9]+)*")
_PREFIX: Final = "Missing credentials: "


class MissingCredentialsError(LookupError):
"""The Hub lacks credentials of ``type`` to pair a device, e.g. a Hub on Ethernet has no Wi-Fi to give a node.

Raised before the device is touched, with ``str(error)`` as the discovery's ``last_error``, so the app can ask the
user for them and pair again. ``type`` is a lower_snake_case string kept open for integrations: the known ones are
the class constants below; an integration documents any custom one it raises. The app maps it to an enum.
Raised before the device is touched; the Hub answers the pairing request with it (422 ``missing_credentials``,
carrying ``type`` and the kept device id), so the app can ask the user for them and pair again. ``type`` is a
lower_snake_case string kept open for integrations: the known ones are the class constants below; an integration
documents any custom one it raises. The app maps it to an enum.
"""

WIFI: ClassVar[str] = "wifi"
Expand All @@ -22,12 +22,4 @@ def __init__(self, type: str = WIFI) -> None:
if not _TYPE.fullmatch(type):
raise ValueError(f"Credentials type must be lower_snake_case, got {type!r}")
self.type = type
super().__init__(f"{_PREFIX}{type}")

@classmethod
def parse(cls, last_error: str | None) -> Self | None:
"""The error a ``last_error`` was set from, or None if it was set from anything else."""
if last_error is None or not last_error.startswith(_PREFIX):
return None
type_ = last_error.removeprefix(_PREFIX)
return cls(type_) if _TYPE.fullmatch(type_) else None
super().__init__(f"Missing credentials: {type}")
14 changes: 1 addition & 13 deletions tests/test_errors.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""MissingCredentialsError: what the Hub lacks to pair a device, readable back from a last_error."""
"""MissingCredentialsError: what the Hub lacks to pair a device."""

import pytest

Expand Down Expand Up @@ -29,15 +29,3 @@ def test_the_type_is_lower_snake_case(type_: str):

def test_the_hub_already_handles_it_as_a_lookup_error():
assert issubclass(MissingCredentialsError, LookupError)


@pytest.mark.parametrize("type_", ["wifi", "thread", "zigbee_network_key"])
def test_the_type_is_read_back_from_a_last_error(type_: str):
parsed = MissingCredentialsError.parse(str(MissingCredentialsError(type_)))
assert parsed is not None
assert parsed.type == type_


@pytest.mark.parametrize("last_error", [None, "", "Device is no longer connected", "Missing credentials: Wi-Fi"])
def test_other_errors_are_not_read_as_missing_credentials(last_error: str | None):
assert MissingCredentialsError.parse(last_error) is None
Loading