Свои исключения, raise from и ExceptionGroup
Зачем свои исключения
Встроенные ValueError и RuntimeError слишком общие: по ним не отличить «пользователь не найден» от «опечатки в коде». Свои исключения:
- называют проблему на языке предметной области (
OrderAlreadyPaid); - позволяют ловить ошибки вашей библиотеки отдельно от чужих;
- несут контекст в атрибутах (какой заказ, какая сумма).
class AppError(Exception):
"""Базовое исключение приложения."""
class NotFound(AppError):
def __init__(self, entity, entity_id):
self.entity = entity
self.entity_id = entity_id
super().__init__(f"{entity} с id={entity_id} не найден")
class PermissionDenied(AppError):
pass
USERS = {1: "Анна"}
def get_user(user_id):
if user_id not in USERS:
raise NotFound("Пользователь", user_id)
return USERS[user_id]
for uid in [1, 42]:
try:
print(get_user(uid))
except NotFound as e:
print("404:", e, "| атрибуты:", e.entity, e.entity_id)
Как проектировать иерархию
Правила:
- Одно базовое исключение на пакет — пользователь библиотеки может поймать «всё ваше» одним
except ShopError. - Наследуйтесь от
Exception(неBaseException). - Имя заканчивается на
Error(или описывает ситуацию:NotFound,CardExpired). - Иногда полезно наследоваться и от встроенного:
class InvalidEmail(ShopError, ValueError)— тогда сработает иexcept ValueError. - Не плодите классы без нужды: новое исключение нужно, если его будут ловить отдельно.
Цепочки исключений: raise from
Когда низкоуровневая ошибка превращается в ошибку уровня приложения, причину нужно сохранить:
import json
class ConfigError(Exception):
pass
def load_config(text):
try:
return json.loads(text)
except json.JSONDecodeError as e:
raise ConfigError("Конфигурация повреждена") from e # явная причина
load_config("{неправильный json")
В трассировке будут оба исключения, связанные фразой «The above exception was the direct cause of the following exception» (в окне вывода курса — «Это исключение стало прямой причиной следующего»). Запустите пример и посмотрите.
def demo(mode):
try:
{}["key"]
except KeyError as e:
if mode == "from":
raise RuntimeError("явно") from e
if mode == "implicit":
raise RuntimeError("неявно") # ошибка внутри except
if mode == "none":
raise RuntimeError("без цепочки") from None
for mode in ["from", "implicit", "none"]:
try:
demo(mode)
except RuntimeError as err:
print(f"{mode:9} cause={err.__cause__!r:16} context={err.__context__!r:16} "
f"suppress={err.__suppress_context__}")
| Запись | __cause__ |
Сообщение в traceback |
|---|---|---|
raise New from orig |
orig |
«direct cause of» |
raise New внутри except |
— (__context__ = orig) |
«During handling of the above exception, another exception occurred» |
raise New from None |
— | исходная ошибка скрыта |
from None уместен, когда исходная ошибка — деталь реализации, которая только запутает пользователя.
Заметки к исключениям: add_note
def process_row(i, row):
try:
return int(row)
except ValueError as e:
e.add_note(f"Строка CSV №{i}: {row!r}") # Python 3.11+
raise
for i, row in enumerate(["1", "2", "три"], start=1):
process_row(i, row)
ExceptionGroup и except*
Иногда ошибок несколько одновременно: параллельные задачи в asyncio.TaskGroup, валидация всех полей формы. Для этого есть ExceptionGroup (Python 3.11+):
def validate(user):
errors = []
if not user.get("name"):
errors.append(ValueError("имя обязательно"))
if "@" not in user.get("email", ""):
errors.append(ValueError("некорректный email"))
if not isinstance(user.get("age"), int):
errors.append(TypeError("возраст должен быть числом"))
if errors:
raise ExceptionGroup("Ошибки валидации", errors)
try:
validate({"name": "", "email": "anna", "age": "28"})
except* ValueError as group: # обработать все ValueError из группы
for e in group.exceptions:
print("ValueError:", e)
except* TypeError as group: # и отдельно все TypeError
for e in group.exceptions:
print("TypeError:", e)
Обычный except ValueError не поймает ExceptionGroup — нужен except*, который «вынимает» из группы подходящие исключения. Несколько веток except* могут сработать для одной группы.
Исключения и слои приложения
Хорошая практика — переводить ошибки на границе слоёв:
class NotFound(Exception): ...
class Repository: # слой данных
def get(self, order_id):
data = {1: "заказ №1"}
try:
return data[order_id]
except KeyError:
raise NotFound(f"заказ {order_id}") from None
def http_handler(order_id): # слой HTTP
try:
return 200, Repository().get(order_id)
except NotFound as e:
return 404, f"Не найден: {e}"
except Exception: # всё неожиданное — 500 (и в лог!)
return 500, "Внутренняя ошибка"
print(http_handler(1), http_handler(7))
KeyError — деталь хранилища; HTTP-слой знает только о NotFound. В FastAPI и Django это делают обработчики исключений (exception_handler, middleware).
assert — не для валидации
def apply_discount(price, percent):
assert 0 <= percent <= 100, "процент вне диапазона" # внутренний инвариант
return price * (100 - percent) / 100
print(apply_discount(1000, 15))
try:
apply_discount(1000, 150)
except AssertionError as e:
print("AssertionError:", e)
python -O удаляет все assert. Поэтому данные от пользователя проверяют if ... raise ValueError, а assert оставляют для «этого не может быть» и тестов.
Вопрос на собеседовании
«Зачем нужен raise ... from?» — Чтобы при преобразовании одного исключения в другое сохранить исходную причину в __cause__: трассировка покажет обе ошибки, и отладка не потеряет контекст. from None наоборот скрывает исходную ошибку.
Итог
- Своя иерархия: одно базовое исключение от
Exception, подклассы по смыслу, контекст в атрибутах. raise New from origсохраняет причину (__cause__);from Noneскрывает её.add_noteдобавляет пояснение к исключению.ExceptionGroupиexcept*— для нескольких одновременных ошибок.- Переводите исключения на границах слоёв;
assert— не валидация.
Практика решено 0 из 5
Спроектируйте иерархию исключений для платёжного модуля:
- PaymentError(Exception) — базовое;
- InsufficientFunds(PaymentError) — хранит атрибуты balance и amount,
а str(e) возвращает "Недостаточно средств: баланс 100, нужно 250";
- CardExpired(PaymentError).
Функция pay(balance, amount, expired=False) бросает подходящее исключение или возвращает остаток.
Какие тесты проверяют код (3)
assert pay(500, 200) == 300
try:
pay(100, 250)
ok = False
except InsufficientFunds as e:
ok = (e.balance, e.amount) == (100, 250) and str(e) == "Недостаточно средств: баланс 100, нужно 250"
assert ok
try:
pay(1000, 1, expired=True)
ok = False
except PaymentError as e:
ok = type(e).__name__ == "CardExpired"
assert ok, "CardExpired должен ловиться через базовый PaymentError"
class PaymentError(Exception):
pass
class InsufficientFunds(PaymentError):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(f"Недостаточно средств: баланс {balance}, нужно {amount}")
class CardExpired(PaymentError):
pass
def pay(balance, amount, expired=False):
if expired:
raise CardExpired("Срок действия карты истёк")
if amount > balance:
raise InsufficientFunds(balance, amount)
return balance - amount
Функция load_port(config) читает config["port"] и превращает в int.
Любую ошибку (KeyError, ValueError) она должна превращать в ConfigError,
сохранив исходную причину через raise ... from.
Какие тесты проверяют код (3)
assert load_port({"port": "8080"}) == 8080
try:
load_port({})
ok = False
except ConfigError as e:
ok = isinstance(e.__cause__, KeyError)
assert ok, "ConfigError с причиной KeyError в __cause__"
try:
load_port({"port": "abc"})
ok = False
except ConfigError as e:
ok = isinstance(e.__cause__, ValueError)
assert ok
class ConfigError(Exception):
pass
def load_port(config):
try:
return int(config["port"])
except (KeyError, ValueError) as e:
raise ConfigError("Некорректный порт в конфигурации") from e
Чем отличаются __cause__ и __context__ исключения?
Почему для проверки входных данных нельзя полагаться на assert?
Что делает except* ValueError?
Войдите, чтобы отмечать пройденные уроки.