Typed exceptions: try/except/else/finally + custom Exception
Это — PYTH-09 home урока. Exceptions — first-class механизм Python для error handling. Хорошо спроектированная exception hierarchy — фундамент typed error API: каждая ошибка имеет свой class, type checker знает что function может raise, caller обрабатывает selectively. В этом уроке — полная грамматика try/except/else/finally + custom typed exceptions.
В этом уроке:
- Why custom exceptions — domain semantics над generic
Exception. class ValidationError(Exception):typed hierarchy + sub-types.try/except (A, B) as e:— multi-except clause.elseclause — no-exception path.finally— cleanup runs unconditionally.- Typed
raise—raise ValidationError(...). - Cross-link M06 урок 04 — context-manager vs try/finally — when to choose.
- Forward-note —
ExceptionGroup/except*(PEP 654, Python 3.11+) к v2 asyncio.
Why custom exceptions — domain semantics
Простая validate_age функция:
def validate_age_naive(s: str) -> int:
"""Validate age string. Raises ValueError on bad input."""
n = int(s) # raises ValueError if не int
if not 0 <= n <= 150:
raise ValueError(f'out of range: {n}')
return n
# Caller's perspective:
try:
age = validate_age_naive('-5')
except ValueError as e:
# Был это invalid number? Out of range? — caller не знает!
print(f'something failed: {e}')
ValueError — слишком generic. Caller не различает “invalid number format” от “out of range”. Нужен domain-specific exception type.
Custom typed exception hierarchy:
class ValidationError(Exception):
"""Base class for all validation errors."""
pass
class InvalidFormatError(ValidationError):
"""Raised when input has wrong format (not parseable)."""
pass
class OutOfRangeError(ValidationError):
"""Raised when value is out of expected range."""
pass
def validate_age(s: str) -> int:
"""Validate age string. Raises ValidationError sub-type on bad input."""
try:
n = int(s)
except ValueError:
raise InvalidFormatError(f'not a number: {s!r}')
if not 0 <= n <= 150:
raise OutOfRangeError(f'age {n} out of range [0, 150]')
return n
# Caller's perspective — selective handling:
for s in ['42', 'abc', '-5']:
try:
age = validate_age(s)
print(f'OK: age={age}')
except InvalidFormatError as e:
print(f'parse error: {e}')
except OutOfRangeError as e:
print(f'range error: {e}')
# Output:
# OK: age=42
# parse error: not a number: 'abc'
# range error: age -5 out of range [0, 150]
Каждая subclass ValidationError — distinct type. Caller обрабатывает selectively. except ValidationError ловит обе sub-types (catch parent — catches all children). Это typed error API — typed via class hierarchy.
Cite: Lib/builtins.py — Exception hierarchy (BaseException → Exception → ValueError, TypeError, KeyError, …).
try/except (A, B) as e: — multi-except clause
except clause может ловить несколько types в один branch — except (A, B, C) as e::
import json
import io
def safe_load_json(source: str) -> dict | None:
"""Try to parse JSON. Return None on parse OR I/O failure."""
try:
return json.loads(source)
except (json.JSONDecodeError, ValueError, TypeError) as e:
# Один handler для трёх types — общая логика
print(f'parse failed: {type(e).__name__}: {e}')
return None
print(safe_load_json('{"port": 8080}')) # {'port': 8080}
print(safe_load_json('not-json')) # parse failed: JSONDecodeError: ...
print(safe_load_json(b'bytes-not-str')) # parse failed: TypeError: ... (json.loads expects str, not bytes — TypeError)
Tuple in except (...) — OR между exception types. Equivalent написать отдельные clauses:
# Same effect, more verbose:
try:
...
except json.JSONDecodeError as e:
handle(e)
except ValueError as e:
handle(e)
except TypeError as e:
handle(e)
Pragmatic rule: combine types в except (...) если handling одинаковый; разделяйте на separate clauses если handling отличается.
else clause — no-exception path
else блок выполняется только если try-body завершился без exception. Это часто missed feature — separates “result handling” от “exception handling”:
import json
def process_config(raw: str) -> str:
"""Parse config, return summary string."""
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
return f'parse failed: {e}'
else:
# Runs only если try-body не raised — data: dict (если JSON object)
# Result handling separated от exception handling
return f'parsed: {len(data)} keys'
print(process_config('{"a": 1, "b": 2}')) # parsed: 2 keys
print(process_config('not-json')) # parse failed: ...
Зачем else если можно просто после try-body? Двойная польза:
- Symbolic clarity — “это runs только при success path”.
- Scope guard — exceptions из
elsebody не ловятсяexcept-clauses этогоtry. Это позволяет distinguishing “parse error” от “post-parse logic error”:
import json
def parse_and_query(raw: str, key: str) -> str:
try:
data = json.loads(raw)
except json.JSONDecodeError:
return 'parse error'
else:
# KeyError здесь НЕ ловится JSONDecodeError clause — он propagates наверх
return f'value: {data[key]}'
# parse_and_query('{}', 'missing') # KeyError — not caught
Если бы мы написали return f'value: {data[key]}' внутри try-body — KeyError мог бы быть пойман generic except Exception:. С else — exception propagates correctly.
Cite: Python Tutorial Section 8.3 — Handling Exceptions — else clause documented.
finally — cleanup runs unconditionally
finally блок исполняется всегда — на normal exit, на exception, на return inside try. Это — the механизм для cleanup:
import io
def process_buffer(data: str) -> int:
"""Process data; ensure log entry written even on exception."""
log = io.StringIO()
log.write(f'start: data={data!r}\n')
try:
if not data:
raise ValueError('empty data')
result = len(data) * 2
log.write(f'computed: result={result}\n')
return result # ← return inside try — finally still runs
except ValueError as e:
log.write(f'error: {e}\n')
raise # re-raise (finally still runs)
finally:
log.write('cleanup: log finalized\n')
# Эту строку видим ВСЕГДА — на success / on exception / on return
print(log.getvalue())
print(process_buffer('hello'))
# start: data='hello'
# computed: result=10
# cleanup: log finalized
# 10
# process_buffer('') # raises ValueError, finally still runs
finally обязательно runs:
- На normal completion — runs после try-body finishes.
- На exception (caught или uncaught) — runs до propagation.
- На
return/break/continueinside try — runs до actual control flow.
Performance note (Python 3.11+): finally lowered в ExceptionTable instead of SETUP_FINALLY opcode (M06 урок 04 cross-link — bytecode lowering). Zero-cost normal-path; small overhead exception-path.
Cross-link M06 урок 04 — context-manager vs try/finally
В M06 урок 04 мы установили: with cm: desugars в cm.__enter__() + try/finally + cm.__exit__(). Это поднимает прикладной вопрос: когда context-manager, когда try/finally?
Rule of thumb:
| Pattern | Use case |
|---|---|
with cm: (context-manager) | Resource имеет clear lifecycle + reusable. with open(...), with lock:, with conn:. Standard library предоставляет CM. |
try/finally | Ad-hoc cleanup. One-off cleanup logic. Cleanup tied к specific function call, не resource. |
# Context-manager — file resource (lifecycle = open / close)
import io
buf = io.StringIO('content')
with buf: # ← __enter__ + __exit__ guaranteed
data = buf.read()
# try/finally — ad-hoc cleanup (logging, metrics)
import time
def measured_compute(n: int) -> int:
start = time.monotonic()
try:
result = sum(i * i for i in range(n))
return result
finally:
elapsed = time.monotonic() - start
# Logging — не resource lifecycle, just ad-hoc cleanup
print(f'elapsed: {elapsed:.4f}s')
Когда выбрать context-manager:
- Resource имеет
__enter__/__exit__(file, lock, conn). - Cleanup reusable — same pattern в multiple places (DRY).
@contextmanagerdecorator — quick wrapper для generator-based CMs (M06 урок 05).
Когда выбрать try/finally:
- Cleanup specific к этой function call.
- Нет obvious “resource”.
- Logging, metrics, ad-hoc state mutations.
Cite: M06 урок 04 (context-manager protocol — __enter__/__exit__); M06 урок 05 (contextlib.contextmanager — generator-based CM).
Typed raise and exception chaining
raise Python supports chaining через from:
class ValidationError(Exception):
pass
def parse_int(s: str) -> int:
try:
return int(s)
except ValueError as e:
# raise from — preserves original exception как __cause__
raise ValidationError(f'invalid int: {s!r}') from e
try:
parse_int('not-a-number')
except ValidationError as e:
print(f'caught: {e}')
print(f'caused by: {e.__cause__}')
# 'caused by: invalid literal for int() with base 10: \'not-a-number\''
raise X from Y — устанавливает X.__cause__ = Y. Print exception показывает full chain (“During handling of the above exception, another exception occurred”). Useful для error transformation: low-level error (ValueError) → domain-level error (ValidationError) с preserved context.
raise X from None — explicit suppress chaining (если original irrelevant).
Cite: PEP 3134 — Exception Chaining and Embedded Tracebacks.
Forward note — ExceptionGroup / except* (PEP 654)
PEP 654 (Python 3.11) ввёл ExceptionGroup — собирает multiple exceptions в один объект, и except* clause для their handling. Use case: parallel/async код где multiple tasks raise одновременно:
# Concept (Python 3.11+):
# from concurrent.futures import ThreadPoolExecutor
#
# def task(i: int) -> int:
# if i % 2 == 0:
# raise ValueError(f'even task {i}')
# return i
#
# errors = []
# with ThreadPoolExecutor() as ex:
# futures = [ex.submit(task, i) for i in range(5)]
# for f in futures:
# try:
# f.result()
# except ValueError as e:
# errors.append(e)
#
# if errors:
# raise ExceptionGroup('multiple failures', errors)
#
# # Caller:
# try:
# run_parallel()
# except* ValueError as eg:
# # eg.exceptions — list of ValueError
# print(f'got {len(eg.exceptions)} value errors')
Forward note: ExceptionGroup / except* — main-stream usage в asyncio код (multiple concurrent tasks). M07 фокусирован на synchronous code; serious treatment ExceptionGroup deferred к v2 курса (asyncio module). Single paragraph mentioning here для awareness.
Cite: PEP 654 — Exception Groups and except*.
Diagram: try/except/else/finally control flow
Recipe: typed validation function (PYTH-09 closure)
End-to-end production pattern — full PYTH-09 demonstration: custom typed exception hierarchy + try/except/else/finally:
class ValidationError(Exception):
"""Base for all validation errors."""
def __init__(self, field: str, message: str) -> None:
self.field = field
self.message = message
super().__init__(f'{field}: {message}')
class InvalidFormatError(ValidationError):
"""Raised when input has wrong format."""
pass
class OutOfRangeError(ValidationError):
"""Raised when value is out of expected range."""
pass
def validate_user_data(raw: dict[str, str]) -> dict[str, int | str]:
"""Validate user data dict. Raise ValidationError sub-type on bad input."""
log: list[str] = []
log.append('start validation')
try:
# Validate id
try:
uid = int(raw['id'])
except (KeyError, ValueError):
raise InvalidFormatError('id', f'expected int, got {raw.get("id")!r}')
if uid <= 0:
raise OutOfRangeError('id', f'must be positive, got {uid}')
# Validate name
name = raw.get('name', '')
if not isinstance(name, str) or not name:
raise InvalidFormatError('name', 'expected non-empty str')
if len(name) > 64:
raise OutOfRangeError('name', f'too long: {len(name)} chars')
log.append(f'id={uid}, name={name}')
except ValidationError as e:
log.append(f'validation failed: {e}')
raise
else:
log.append('all fields valid')
return {'id': uid, 'name': name}
finally:
log.append('finalize')
# Always reached:
for entry in log:
print(entry)
# Test:
print(validate_user_data({'id': '42', 'name': 'alice'}))
# start validation
# id=42, name=alice
# all fields valid
# finalize
# {'id': 42, 'name': 'alice'}
try:
validate_user_data({'id': '-5', 'name': 'bob'})
except ValidationError as e:
print(f'caught: {e.field}: {e.message}')
# start validation
# validation failed: id: must be positive, got -5
# finalize
# caught: id: must be positive, got -5
Все четыре блока в action: try (validation logic) + except (catch ValidationError, log + re-raise) + else (success path with all-valid log) + finally (always print log). Это — complete PYTH-09 demonstration.
Ключевые выводы
- Custom Exception class — domain semantics над generic
Exception.class ValidationError(Exception): pass— fundamental pattern. - Hierarchy —
class InvalidFormatError(ValidationError)etc. Caller catches parent — catches all children. Typed error API через class hierarchy. try/except (A, B) as e:— multi-type catch (tuple = OR). Combine types если handling одинаковый; separate если handling отличается.elseclause — no-exception path. Separates result-handling от exception-handling. Exceptions из else не ловятся этими except clauses.finally— runs unconditionally (normal, exception, return). Standard mechanism для cleanup. Python 3.11+ lowered в ExceptionTable (M06 урок 04 cross-link).raise X from Y— exception chaining через__cause__(PEP 3134). Useful для error transformation: low-level → domain-level с preserved context.- Cross-link M06 урок 04 — context-manager vs try/finally rule of thumb: CM для resources с clear lifecycle (file, lock, conn); try/finally для ad-hoc cleanup (logging, metrics) tied к specific function call.
- Forward-note —
ExceptionGroup/except*(PEP 654, Python 3.11+) — main-stream usage в asyncio. Serious treatment deferred к v2 курса. - PYTH-09 closure — full pattern: custom typed exception hierarchy + try/except/else/finally — все четыре блока в одной production-grade функции.
Дальше — урок 07 — module summary + Run-on-Your-Machine bridge к mypy (M00 урок 03 promise fulfilled): pip install mypy, mypy --strict file.py, mypy.ini configuration; forward-link Phase 69 (CI integration).