Нюансы обработки исключений в Python

Нюансы обработки исключений в Python

Постановка задачи

Обычно при проектировании чистой архитектуры есть часть сервиса, отвечающего за сохранение сущностей (Entity) бизнес-логики (BL) в хранилище (Repository). Хранилищем как вариант может выступать БД. Сохранение осуществляет репозиторий . Есть тесты, покрывающие уже существующий функционал.

Entity - это такой аналог привычных моделей ORM, только без привязки к БД, технологии, а отражающие суть BL

# abc.py import abc from typing import List from .entity import BaseAppEntity class ABCRepository(abc.ABC): _model_cls: BaseAppEntity = None @abc.abstractmethod def get_entities(self) -> List[BaseAppEntity]: pass @abc.abstractmethod def save_entities(self, authors: List[BaseAppEntity]) -> None: pass

⚠ Отмечу, что при написании ABC в качестве интерфейса лучше использовать именно наследование от abc.ABC, а не

class ABCRepository(metaclass=abc.ABCMeta):

Реализация репозитория выглядит так

# repository.py from typing import List, Any from .abc import ABCRepository from .entity import BaseAppEntity, AuthorEntity class BaseRepository(ABCRepository): def __init__(self, db_connection: Any): self._db_connection = db_connection @property def _insert_entity_sql(self) -> str: raise NotImplemented def get_entities(self) -> List[BaseAppEntity]: pass def save_entities(self, authors: List[BaseAppEntity]) -> None: if not all(isinstance(a, BaseAppEntity) for a in authors): raise TypeError('Authors must be contain instance of BaseModel') self._db_connection.execute(self._insert_entity_sql) class AuthorRepository(BaseRepository): _model_cls = AuthorEntity @property def _insert_entity_sql(self) -> str: return 'SOME INSERT SQL SCRIPT'

И теперь наши тесты. Я использую pytest для юнит-тестирования.

# conftest.py import pytest from unittest.mock import MagicMock from .repository import AuthorRepository @pytest.fixture def db_connection(): yield MagicMock() @pytest.fixture def authors_repo(db_connection): yield AuthorRepository(db_connection=db_connection)
# test_repo.py import pytest def test_save_authors_err(authors_repo): with pytest.raises(TypeError): authors_repo.save_entities([None, None, None])

Запускаем тест

$ pytest except_deco/test_repo.py::test_save_authors_err ================================================================================ test session starts ================================================================================ platform darwin -- Python 3.10.5, pytest-7.1.2, pluggy-1.0.0 rootdir: *** collected 1 item except_deco/test_repo.py . [100%] ================================================================================= 1 passed in 0.00s =================================================================================

Update исходной задачи

Теперь представим, что в какой-то момент у нас стало несколько Repository и все они рейзят одинаковое исключение. И это одинаковое описание попадает в logger - разобраться где оно возникло крайне сложно.

Решений этой проблемы несколько:

  1. изменить исходный код метода save_entities, добавив туда зависимость от model_cls
  2. добавить блок try/except в место, где используется Repository. Перехватив исключение, модифицировать как-то так:
... some BL logic ... try: repo.save_entities(entities_list) except Exception as e: raise Exception(f'Repo exception from {repo._model_cls}. Info: {e}')

1-й вариант предполагает, что мы изменяем уже оттестированный код, чего мы делать не хотим.

2-й вариант нарушает инкапсуляцию метода (раскрывает внутренную реализацию репозитория) - один из основных принципов ООП. Подробнее про ООП тут. Плюс к этому, втыкать посреди высокоуровневого BL-кода такой блок - значит ухудшить читаемость.

Есть 3-й вариант - написать декоратор, который не будет затрагивать исходную реализацию метода, а красиво добавлять мета-инфу о классе entity, где произошло исключение.

Реализация декоратора

При реализации декоратора, помимо использования functools.wraps для сохранения информации об изначальной функции, важно не потерять ИСХОДНЫЙ ТИП исключения. Вот как это может произойти.

def wrong_deco_add_model_cls_info_to_except(func: Callable) -> Callable: @wraps(func) def wrapper(self: ABCRepository, *args, **kwargs) -> Any: try: return func(self, *args, **kwargs) except Exception as ex: raise Exception(f'Repository exception from {self._model_cls}. {ex}') from ex return wrapper
class AuthorRepository(BaseRepository): _model_cls = AuthorEntity @property def _insert_entity_sql(self) -> str: return 'SOME INSERT SQL SCRIPT' @wrong_deco_add_model_cls_info_to_except def save_authors_with_bad_deco(self, authors: List[AuthorEntity]) -> None: return self.save_entities(authors) @right_deco_add_model_cls_info_to_except def save_authors_with_right_deco(self, authors: List[AuthorEntity]) -> None: return self.save_entities(authors)

Теперь запуск старый тестов, рассчитанных на обработку TypeError, упадут

===================================================================================== FAILURES ====================================================================================== ______________________________________________________________________ test_wrong_type_except_save_authors_err ______________________________________________________________________ self = <except_deco.repository.AuthorRepository object at 0x106b25c60>, args = ([None, None, None],), kwargs = {} @wraps(func) def wrapper(self: ABCRepository, *args, **kwargs) -> Any: try: > return func(self, *args, **kwargs) except_deco/deco.py:11: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ self = <except_deco.repository.AuthorRepository object at 0x106b25c60>, authors = [None, None, None] @wrong_deco_add_model_cls_info_to_except def save_authors_with_bad_deco(self, authors: List[AuthorEntity]) -> None: > return self.save_entities(authors) except_deco/repository.py:39: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ self = <except_deco.repository.AuthorRepository object at 0x106b25c60>, authors = [None, None, None] def save_entities(self, authors: List[BaseAppEntity]) -> None: if not all(isinstance(a, BaseAppEntity) for a in authors): > raise TypeError('Authors must be contain instance of BaseModel') E TypeError: Authors must be contain instance of BaseModel except_deco/repository.py:25: TypeError The above exception was the direct cause of the following exception: authors_repo = <except_deco.repository.AuthorRepository object at 0x106b25c60> def test_wrong_type_except_save_authors_err(authors_repo): with pytest.raises(TypeError): > authors_repo.save_authors_with_bad_deco([None, None, None]) except_deco/test_repo.py:12: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ self = <except_deco.repository.AuthorRepository object at 0x106b25c60>, args = ([None, None, None],), kwargs = {} @wraps(func) def wrapper(self: ABCRepository, *args, **kwargs) -> Any: try: return func(self, *args, **kwargs) except Exception as ex: > raise Exception(f'Repository exception from {self._model_cls}. {ex}') from ex E Exception: Repository exception from <class 'except_deco.entity.AuthorEntity'>. Authors must be contain instance of BaseModel except_deco/deco.py:13: Exception ============================================================================== short test summary info ============================================================================== FAILED except_deco/test_repo.py::test_wrong_type_except_save_authors_err - Exception: Repository exception from <class 'except_deco.entity.AuthorEntity'>. Authors must be contain..

Все потому, что мы ПОДМЕНИЛИ исходный тип исключений общим Exception.

Правильный вариант декоратора

def right_deco_add_model_cls_info_to_except(func: Callable) -> Callable: @wraps(func) def wrapper(self: ABCRepository, *args, **kwargs) -> Any: try: return func(self, *args, **kwargs) except Exception as ex: raise ex.__class__(f'Repository exception from {self._model_cls}. {ex}') from ex return wrapper

Вызов raise ex.__class__() позволяет зарейзить исходный тип исключения (а ведь у нас может быть своя иерархия исключений - тогда этот кейс еще критичнее !!!)

Вызов raise from позволяет сохранить исходный трейсбек исключения, что поможет в отладке и поиске причины.

Итоги

Исключения - мощный механизм "защитного программирования", позволяющий сигнализировать о некорректном состоянии данных/системы и строить поведенческие цепочки на этом основании.

Обработка исключений может строится как на стандартных типах, предоставляемых языком, так и на пользовательских типах исключений. В этом случае при обработке важно не потерять исходное исключение, дабы не нарушить флоу обработки.

@Декоратор - отличный паттерн, который может помочь нам в этом.

🖤 Подписывайтесь на мою телегу. Больше кода 🐍 - меньше багов 🪲!

11
Начать дискуссию