Skip to content
2 changes: 2 additions & 0 deletions maxapi/exceptions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .base import MaxError
from .dispatcher import HandlerException, MiddlewareException
from .download_file import DownloadFileError, NotAvailableForDownload
from .max import (
Expand All @@ -14,6 +15,7 @@
"InvalidToken",
"MaxApiError",
"MaxConnection",
"MaxError",
"MaxIconParamsException",
"MaxUploadFileFailed",
"MiddlewareException",
Expand Down
13 changes: 13 additions & 0 deletions maxapi/exceptions/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Базовое исключение библиотеки."""

from __future__ import annotations


class MaxError(Exception):
"""Базовый класс для всех исключений библиотеки maxapi.

Позволяет ловить ошибки библиотеки одним ``except MaxError``:
сетевые сбои, ошибки API Макса, ошибки диспетчера, скачивания
файлов и т.д. — всё наследуется от него. Стандартные исключения
Python в эту иерархию не входят.
"""
6 changes: 4 additions & 2 deletions maxapi/exceptions/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
from dataclasses import dataclass
from typing import Any

from .base import MaxError


@dataclass(slots=True)
class HandlerException(Exception):
class HandlerException(MaxError):
handler_title: str
router_id: str | int | None
process_info: str
Expand All @@ -30,7 +32,7 @@ def __str__(self) -> str:


@dataclass(slots=True)
class MiddlewareException(Exception):
class MiddlewareException(MaxError):
middleware_title: str
router_id: str | int | None
process_info: str
Expand Down
7 changes: 5 additions & 2 deletions maxapi/exceptions/download_file.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
class NotAvailableForDownload(Exception): ...
from .base import MaxError


class DownloadFileError(Exception):
class NotAvailableForDownload(MaxError): ...


class DownloadFileError(MaxError):
"""Ошибка при скачивании файла."""
14 changes: 9 additions & 5 deletions maxapi/exceptions/max.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@
from dataclasses import dataclass
from typing import Any

from .base import MaxError

class InvalidToken(Exception): ...

class InvalidToken(MaxError): ...

class MaxConnection(Exception): ...

class MaxConnection(MaxError): ...

class MaxUploadFileFailed(Exception): ...

class MaxUploadFileFailed(MaxError): ...

class MaxIconParamsException(Exception): ...

class MaxIconParamsException(MaxError): ...


@dataclass(slots=True)
class MaxApiError(Exception):
class MaxApiError(MaxError):
"""Ошибка, пришедшая от сервера API Макса (не-2xx ответ)."""

code: int
raw: str | dict[str, Any]

Expand Down
67 changes: 67 additions & 0 deletions tests/test_exceptions_hierarchy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Тесты иерархии исключений maxapi.

Инвариант: любую ошибку либы можно поймать одним ``except MaxError``.
Тест защищает от регрессии — новое исключение, добавленное в
``maxapi.exceptions.__all__`` без наследования от ``MaxError``,
уронит этот тест.
"""

from __future__ import annotations

import inspect

import maxapi.exceptions as exc_module
import pytest
from maxapi.exceptions import MaxApiError, MaxError


def _public_exception_classes() -> list[tuple[str, type[BaseException]]]:
classes: list[tuple[str, type[BaseException]]] = []
for name in exc_module.__all__:
obj = getattr(exc_module, name)
if inspect.isclass(obj) and issubclass(obj, BaseException):
classes.append((name, obj))
return classes


def test_public_exceptions_are_present():
"""__all__ не пуст — иначе последующие проверки бесполезны."""
classes = _public_exception_classes()
assert classes, "maxapi.exceptions.__all__ не содержит исключений"


def test_all_public_exceptions_inherit_from_max_error():
"""Все публичные исключения либы наследуются от MaxError."""
non_max = [
name
for name, cls in _public_exception_classes()
if cls is not MaxError and not issubclass(cls, MaxError)
]
assert not non_max, (
f"Эти исключения не наследуются от MaxError: {non_max}. "
"Добавь MaxError в базовые классы или убери из публичного API."
)


def test_max_error_is_exception_subclass():
"""MaxError остаётся совместим с обычным ``except Exception``."""
assert issubclass(MaxError, Exception)


def test_except_max_error_catches_any_lib_error():
"""``except MaxError`` действительно ловит конкретные подклассы."""
with pytest.raises(MaxError) as exc_info:
raise MaxApiError(code=500, raw={"x": 1})

e = exc_info.value
assert isinstance(e, MaxApiError)
assert e.code == 500
assert e.raw == {"x": 1}


def test_max_api_error_dataclass_contract_preserved():
"""Смена базы на MaxError не сломала dataclass-контракт MaxApiError."""
err = MaxApiError(code=418, raw="teapot")
assert err.code == 418
assert err.raw == "teapot"
assert isinstance(err, MaxError)
Loading