Learning Platform
Глоссарий Troubleshooting
Урок 08.06 · 22 мин
Средний
Custom exceptionstry/except/else/finallyPYTH-09ExceptionGroupPEP 654Exception hierarchyTyped raise

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.

В этом уроке:

  1. Why custom exceptions — domain semantics над generic Exception.
  2. class ValidationError(Exception): typed hierarchy + sub-types.
  3. try/except (A, B) as e: — multi-except clause.
  4. else clause — no-exception path.
  5. finally — cleanup runs unconditionally.
  6. Typed raiseraise ValidationError(...).
  7. Cross-link M06 урок 04 — context-manager vs try/finally — when to choose.
  8. Forward-noteExceptionGroup/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 ValidationErrordistinct type. Caller обрабатывает selectively. except ValidationError ловит обе sub-types (catch parent — catches all children). Это typed error API — typed via class hierarchy.

Cite: Lib/builtins.pyException 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? Двойная польза:

  1. Symbolic clarity — “это runs только при success path”.
  2. Scope guard — exceptions из else body не ловятся 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 Exceptionselse 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/continue inside 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.


В M06 урок 04 мы установили: with cm: desugars в cm.__enter__() + try/finally + cm.__exit__(). Это поднимает прикладной вопрос: когда context-manager, когда try/finally?

Rule of thumb:

PatternUse case
with cm: (context-manager)Resource имеет clear lifecycle + reusable. with open(...), with lock:, with conn:. Standard library предоставляет CM.
try/finallyAd-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).
  • @contextmanager decorator — 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

try / except / else / finally execution paths
try-bodyentry pointCode that may raise. Compiler attaches ExceptionTable handler-mappings (Python 3.11+ — M06 урок 04 cross-link, replaces SETUP_FINALLY). Zero-cost normal path. Standard idiomatic — попытайтесь minimal scope в try-body, чтобы нарушения легче локализовать
except (A, B) as e:exception pathMulti-type catch — tuple = OR. except A — runs если raised A или subclass(A). raise without arg — re-raise current exception. raise X from Y — chaining через __cause__ (PEP 3134). Catches stop at first matching clause (top-down)
else:no-exception pathRuns ONLY если try-body completed без exception. Exceptions из else NOT caught этими except clauses — propagate up. Use case: separation result-handling от exception-handling. Often missed feature
finally:alwaysRuns unconditionally — normal exit, exception (caught or uncaught), return inside try, break, continue. Standard mechanism для cleanup. Python 3.11+ lowered в ExceptionTable (zero-cost normal path). Cross-link M06 урок 04 — context-manager vs try/finally

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.


Ключевые выводы

  1. Custom Exception class — domain semantics над generic Exception. class ValidationError(Exception): pass — fundamental pattern.
  2. Hierarchyclass InvalidFormatError(ValidationError) etc. Caller catches parent — catches all children. Typed error API через class hierarchy.
  3. try/except (A, B) as e: — multi-type catch (tuple = OR). Combine types если handling одинаковый; separate если handling отличается.
  4. else clause — no-exception path. Separates result-handling от exception-handling. Exceptions из else не ловятся этими except clauses.
  5. finally — runs unconditionally (normal, exception, return). Standard mechanism для cleanup. Python 3.11+ lowered в ExceptionTable (M06 урок 04 cross-link).
  6. raise X from Y — exception chaining через __cause__ (PEP 3134). Useful для error transformation: low-level → domain-level с preserved context.
  7. 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.
  8. Forward-noteExceptionGroup/except* (PEP 654, Python 3.11+) — main-stream usage в asyncio. Serious treatment deferred к v2 курса.
  9. 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).

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Зачем определять `class ValidationError(Exception)` вместо использования generic `ValueError`?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7