Практика 8. Практическая работа 8. Кастомные исключения и иерархии
Цель
- Научиться проектировать иерархию доменных исключений с общим базовым классом.
- Освоить передачу структурированного контекста ошибки через атрибуты, а не текст.
- Отработать цепочки исключений (
raise ... from ...,__cause__,from None). - Познакомиться с группами исключений (
ExceptionGroup/except*, Python 3.11+). - Распределить обработку ошибок по слоям: репозиторий → сервис → представление.
Краткая теория
- Кастомное исключение — обычный класс, наследник
Exception(неBaseException). Общий базовый класс приложения даёт точный (except ConcreteError) и категорийный (except AppError) перехват. - Контекст в атрибутах. Храните данные об ошибке в полях объекта (
balance,field,error_code), а не парсите из строки. Всегда вызывайтеsuper().__init__(message), чтобы сообщение попало вargsи трейсбек. - Цепочки.
raise New() from errявно связывает причину (__cause__).__context__ставится автоматически, если новое исключение возникло внутриexcept.from Noneподавляет цепочку — только осознанно. - Группы.
raise ExceptionGroup("...", [e1, e2])сообщает о нескольких ошибках сразу;except*«расщепляет» группу по типам, и сразу несколько блоков могут сработать. До Python 3.11 — собственный класс со списком ошибок. - Слои. Репозиторий переводит технические ошибки в доменные, сервис делает
откаты, представление решает, что показать пользователю. Подробности и
примеры — в
lecture_11.md.
Задания
Задание 1. Проектирование иерархии доменных исключений
Спроектируйте иерархию исключений для системы бронирования (booking).
Требования:
- Базовый класс
BookingError(Exception)с полемerror_code: str(по умолчанию"UNKNOWN") и переопределённым__str__, выводящим[CODE] message. - Подклассы (наследники
BookingError):RoomNotFoundError(room_id)— кодROOM_NOT_FOUND, хранитroom_id.RoomAlreadyBookedError(room_id, date)— кодROOM_BUSY, хранитroom_id,date.InvalidBookingDataError(field, value, reason)— кодINVALID_DATA, хранитfield,value,reason.
- Каждый подкласс формирует осмысленное сообщение и вызывает
super().__init__().
Скелет:
class BookingError(Exception): def __init__(self, message: str, error_code: str = "UNKNOWN"): super().__init__(message) self.message = message self.error_code = error_code
def __str__(self) -> str: ... # [CODE] message
class RoomNotFoundError(BookingError): def __init__(self, room_id: str): ...Проверьте: один except BookingError ловит все три подкласса; у пойманного
объекта доступны атрибуты (e.room_id, e.error_code) без разбора строки.
Задание 2. Контекст и цепочки причин (raise ... from ...)
Реализуйте «перевод» технической ошибки в доменную с сохранением причины.
Требования:
- Класс
RoomRepositoryс хранилищемdict[str, Room]и методами:get(room_id)— при отсутствии ключа выбрасываетRoomNotFoundError, подавив внутреннийKeyErrorчерезfrom None.save(room)— оборачивает любую неожиданную ошибку вRepositoryErrorчерезraise ... from e; доменные исключения (InvalidBookingDataError) пробрасывает как есть.
- Добавьте в иерархию
RepositoryError(BookingError)с кодомSTORAGE_ERROR.
Скелет:
def get(self, room_id: str) -> Room: try: return self._storage[room_id] except KeyError: raise RoomNotFoundError(room_id) from None
def save(self, room: Room) -> Room: try: room.validate() self._storage[room.id] = room return room except InvalidBookingDataError: raise except Exception as e: raise RepositoryError("save") from eПроверьте: в обёрнутой ошибке e.__cause__ указывает на исходное исключение;
для RoomNotFoundError цепочка подавлена (__cause__ is None, __suppress_context__).
Объясните в комментарии разницу между __cause__ и __context__.
Задание 3. Группы исключений и валидация формы (ExceptionGroup / except*)
Соберите все ошибки валидации сразу, а не первую попавшуюся.
Требования:
- Функция
validate_booking(data: dict) -> None: накапливает ошибки в список и, если он непуст, выбрасываетExceptionGroup("Ошибки валидации", errors). Проверки (минимум 3):room_idнепустой → иначеInvalidBookingDataError;guests— целое число > 0 → иначеTypeError/ValueError;dateв форматеYYYY-MM-DD→ иначеValueError.
- Обработчик с
except*, который отдельно считает ошибки значений (ValueError,InvalidBookingDataError) и ошибки типов (TypeError), выводя количество каждой.
Скелет:
def validate_booking(data: dict) -> None: errors: list[Exception] = [] if not data.get("room_id"): errors.append(InvalidBookingDataError("room_id", None, "пусто")) ... if errors: raise ExceptionGroup("Ошибки валидации", errors)
try: validate_booking({"room_id": "", "guests": -1, "date": "bad"})except* ValueError as eg: ...except* TypeError as eg: ...Дополнительно: реализуйте запасной класс MultipleValidationErrors(Exception)
со списком errors и методом by_type(error_type) для Python < 3.11.
Задание 4. Обработка по слоям: репозиторий → сервис → представление
Соберите трёхслойное приложение, распределив ответственность за ошибки.
Требования:
- Репозиторий (
RoomRepositoryиз задания 2) — переводит технические ошибки в доменные. - Сервис
BookingService(repository):book(room_id, date, guests)— получает комнату, проверяет занятость (RoomAlreadyBookedError), сохраняет бронь;- при ошибке сохранения выполняет откат изменённого состояния и пробрасывает
исключение (
raise).
- Представление
view_book(service, **data):- перехватывает ошибки по типам отдельными блоками
(
InvalidBookingDataError,RoomNotFoundError,RoomAlreadyBookedError, общийBookingError); - формирует понятное сообщение пользователю, опираясь на атрибуты исключения.
- перехватывает ошибки по типам отдельными блоками
(
Скелет:
def view_book(service: BookingService, **data) -> None: try: booking = service.book(**data) print(f"OK: бронь {booking.id}") except InvalidBookingDataError as e: print(f"Проверьте поле '{e.field}': {e.reason}") except RoomNotFoundError as e: print(f"Нет комнаты: {e.room_id}") except BookingError as e: print(f"Ошибка [{e.error_code}]: {e.message}")Проверьте: только представление решает, что показать; нижние слои либо обрабатывают свою зону ответственности, либо пробрасывают ошибку наверх с сохранённым контекстом.
Задание 5*. Логирование цепочки и пакетная обработка (повышенной сложности)
- Напишите функцию
log_chain(exc), которая печатает исключение и всю цепочку причин, идя по__cause__/__context__до конца (с отступами по уровням). - Реализуйте
book_many(service, requests)— обрабатывает список запросов, собирает все неуспешные вExceptionGroupи выбрасывает её после прохода по всем элементам (частичный успех не теряется).
Критерии оценки
| Критерий | Вес |
|---|---|
| Задание 1: корректная иерархия, общий базовый класс, контекст в атрибутах | 20% |
Задание 2: raise ... from ..., from None, разделение доменных/технических ошибок | 20% |
Задание 3: ExceptionGroup и except* (или запасной класс), сбор всех ошибок | 20% |
| Задание 4: распределение обработки по слоям, откат, перехват по типам | 20% |
| Задание 5*: логирование цепочки и пакетная обработка | 10% |
Качество кода: имена, docstrings, super().__init__, отсутствие «глотания» ошибок | 10% |
Штрафы: пустой except: без типа, except Exception: pass, парсинг данных из
текста сообщения, потеря причины при «переводе» ошибки (raise без from).
Вопросы для самопроверки
- Почему кастомные исключения наследуют от
Exception, а не отBaseException? - Какие преимущества даёт общий базовый класс иерархии доменных исключений?
- Чем хранение данных об ошибке в атрибутах лучше передачи их в тексте сообщения?
- В чём разница между
__cause__и__context__? Когда заполняется каждый? - Что делает
raise New() from errи когда уместноfrom None? - Чем
except*отличается от обычногоexcept? Может ли сработать несколько блоков? - Как реализовать «группу исключений» в Python до версии 3.11?
- Как распределяется ответственность за ошибки между слоями репозиторий → сервис → представление?
- Почему откат состояния (компенсация) уместен именно в слое сервиса?
- Назовите три антипаттерна обработки исключений и их корректные альтернативы.