From 6b7d6772d64249a43f49e4ec7f8951cf65e57d05 Mon Sep 17 00:00:00 2001 From: matytsyn <71841191+matytsyn@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:04:38 +0300 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=B8=D0=B5=D1=80=D0=B0=D1=80=D1=85?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B8=D1=81=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9=20=D1=81=20=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=BC=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D0=BE=D0=BC=20MaxError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- maxapi/exceptions/__init__.py | 2 + maxapi/exceptions/base.py | 10 +++++ maxapi/exceptions/dispatcher.py | 6 ++- maxapi/exceptions/download_file.py | 7 +++- maxapi/exceptions/max.py | 14 ++++--- tests/test_exceptions_hierarchy.py | 67 ++++++++++++++++++++++++++++++ 6 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 maxapi/exceptions/base.py create mode 100644 tests/test_exceptions_hierarchy.py diff --git a/maxapi/exceptions/__init__.py b/maxapi/exceptions/__init__.py index 05a12ee9..a975d623 100644 --- a/maxapi/exceptions/__init__.py +++ b/maxapi/exceptions/__init__.py @@ -1,3 +1,4 @@ +from .base import MaxError from .dispatcher import HandlerException, MiddlewareException from .download_file import DownloadFileError, NotAvailableForDownload from .max import ( @@ -14,6 +15,7 @@ "InvalidToken", "MaxApiError", "MaxConnection", + "MaxError", "MaxIconParamsException", "MaxUploadFileFailed", "MiddlewareException", diff --git a/maxapi/exceptions/base.py b/maxapi/exceptions/base.py new file mode 100644 index 00000000..5d75fcce --- /dev/null +++ b/maxapi/exceptions/base.py @@ -0,0 +1,10 @@ +from __future__ import annotations + + +class MaxError(Exception): + """Класс для всех исключений библиотеки maxapi. + + Позволяет ловить любые ошибки либы одним ``except MaxError``: + сетевые сбои, ошибки API Макса, ошибки диспетчера, скачивания + файлов и т.д. — всё наследуется от него. + """ diff --git a/maxapi/exceptions/dispatcher.py b/maxapi/exceptions/dispatcher.py index 8811c7ab..18aca87b 100644 --- a/maxapi/exceptions/dispatcher.py +++ b/maxapi/exceptions/dispatcher.py @@ -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 @@ -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 diff --git a/maxapi/exceptions/download_file.py b/maxapi/exceptions/download_file.py index 211be68f..03d095fe 100644 --- a/maxapi/exceptions/download_file.py +++ b/maxapi/exceptions/download_file.py @@ -1,5 +1,8 @@ -class NotAvailableForDownload(Exception): ... +from .base import MaxError -class DownloadFileError(Exception): +class NotAvailableForDownload(MaxError): ... + + +class DownloadFileError(MaxError): """Ошибка при скачивании файла.""" diff --git a/maxapi/exceptions/max.py b/maxapi/exceptions/max.py index b7201c4e..eb967108 100644 --- a/maxapi/exceptions/max.py +++ b/maxapi/exceptions/max.py @@ -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] diff --git a/tests/test_exceptions_hierarchy.py b/tests/test_exceptions_hierarchy.py new file mode 100644 index 00000000..09895027 --- /dev/null +++ b/tests/test_exceptions_hierarchy.py @@ -0,0 +1,67 @@ +"""Тесты иерархии исключений maxapi. + +Инвариант: любую ошибку либы можно поймать одним ``except MaxError``. +Тест защищает от регрессии — новое исключение, добавленное в +``maxapi.exceptions.__all__`` без наследования от ``MaxError``, +уронит этот тест. +""" + +from __future__ import annotations + +import inspect + +import maxapi.exceptions as exc_module +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`` действительно ловит конкретные подклассы.""" + try: + raise MaxApiError(code=500, raw={"x": 1}) + except MaxError as e: + assert isinstance(e, MaxApiError) + assert e.code == 500 + assert e.raw == {"x": 1} + else: + raise AssertionError("MaxApiError не был пойман как MaxError") + + +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) From 6e8d61dcae36f87db948c2a60d7c23222851c5ac Mon Sep 17 00:00:00 2001 From: matytsyn <71841191+matytsyn@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:47:44 +0300 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=D1=82=D0=B5=D1=81=D1=82=20=D1=82?= =?UTF-8?q?=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20=D1=81=D0=BE=D0=BE=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D1=81=D1=82=D0=B2=D1=83=D0=B5=D1=82=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0=D0=BC=20ruff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_exceptions_hierarchy.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/test_exceptions_hierarchy.py b/tests/test_exceptions_hierarchy.py index 09895027..51dcae1c 100644 --- a/tests/test_exceptions_hierarchy.py +++ b/tests/test_exceptions_hierarchy.py @@ -10,6 +10,8 @@ import inspect +import pytest + import maxapi.exceptions as exc_module from maxapi.exceptions import MaxApiError, MaxError @@ -49,14 +51,13 @@ def test_max_error_is_exception_subclass(): def test_except_max_error_catches_any_lib_error(): """``except MaxError`` действительно ловит конкретные подклассы.""" - try: + with pytest.raises(MaxError) as exc_info: raise MaxApiError(code=500, raw={"x": 1}) - except MaxError as e: - assert isinstance(e, MaxApiError) - assert e.code == 500 - assert e.raw == {"x": 1} - else: - raise AssertionError("MaxApiError не был пойман как MaxError") + + 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(): From e965bfa20c3f3aaab2fff31b84c65c4be618d794 Mon Sep 17 00:00:00 2001 From: matytsyn <71841191+matytsyn@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:47:44 +0300 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=D1=82=D0=B5=D1=81=D1=82=20=D1=82?= =?UTF-8?q?=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20=D1=81=D0=BE=D0=BE=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D1=81=D1=82=D0=B2=D1=83=D0=B5=D1=82=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0=D0=BC=20ruff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_exceptions_hierarchy.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/test_exceptions_hierarchy.py b/tests/test_exceptions_hierarchy.py index 09895027..51dcae1c 100644 --- a/tests/test_exceptions_hierarchy.py +++ b/tests/test_exceptions_hierarchy.py @@ -10,6 +10,8 @@ import inspect +import pytest + import maxapi.exceptions as exc_module from maxapi.exceptions import MaxApiError, MaxError @@ -49,14 +51,13 @@ def test_max_error_is_exception_subclass(): def test_except_max_error_catches_any_lib_error(): """``except MaxError`` действительно ловит конкретные подклассы.""" - try: + with pytest.raises(MaxError) as exc_info: raise MaxApiError(code=500, raw={"x": 1}) - except MaxError as e: - assert isinstance(e, MaxApiError) - assert e.code == 500 - assert e.raw == {"x": 1} - else: - raise AssertionError("MaxApiError не был пойман как MaxError") + + 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(): From 23de5c797da52fba89ec47214cd98a4eac40b453 Mon Sep 17 00:00:00 2001 From: matytsyn <71841191+matytsyn@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:51:49 +0300 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20=D1=83=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20MaxError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- maxapi/exceptions/base.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/maxapi/exceptions/base.py b/maxapi/exceptions/base.py index 5d75fcce..0d2d45e3 100644 --- a/maxapi/exceptions/base.py +++ b/maxapi/exceptions/base.py @@ -2,9 +2,10 @@ class MaxError(Exception): - """Класс для всех исключений библиотеки maxapi. + """Базовый класс для всех исключений библиотеки maxapi. - Позволяет ловить любые ошибки либы одним ``except MaxError``: + Позволяет ловить ошибки библиотеки одним ``except MaxError``: сетевые сбои, ошибки API Макса, ошибки диспетчера, скачивания - файлов и т.д. — всё наследуется от него. + файлов и т.д. — всё наследуется от него. Стандартные исключения + Python в эту иерархию не входят. """ From de0049f5377534da9a4b361d5f058ff34a5e9dbb Mon Sep 17 00:00:00 2001 From: matytsyn <71841191+matytsyn@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:52:53 +0300 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D0=B8=D1=81=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=B1=D0=B8=D0=B1=D0=BB=D0=B8=D0=BE=D1=82=D0=B5=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- maxapi/exceptions/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/maxapi/exceptions/base.py b/maxapi/exceptions/base.py index 0d2d45e3..e3965d03 100644 --- a/maxapi/exceptions/base.py +++ b/maxapi/exceptions/base.py @@ -1,3 +1,5 @@ +"""Базовое исключение библиотеки.""" + from __future__ import annotations From 8cc2b7193eecc1811f822d916523ed8951ce671f Mon Sep 17 00:00:00 2001 From: matytsyn <71841191+matytsyn@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:12:50 +0300 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=D1=82=D0=B5=D1=81=D1=82=20=D1=82?= =?UTF-8?q?=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20=D1=81=D0=BE=D0=BE=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D1=81=D1=82=D0=B2=D1=83=D0=B5=D1=82=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0=D0=BC=20ruff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_exceptions_hierarchy.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_exceptions_hierarchy.py b/tests/test_exceptions_hierarchy.py index 51dcae1c..048e9ada 100644 --- a/tests/test_exceptions_hierarchy.py +++ b/tests/test_exceptions_hierarchy.py @@ -10,9 +10,8 @@ import inspect -import pytest - import maxapi.exceptions as exc_module +import pytest from maxapi.exceptions import MaxApiError, MaxError