вторник, 13 марта 2012 г.

PEP 3151: Реструктуризация иерархии исключений OS и IO


Вступление:
Стандартная иерархия исключений — важная часть языка Python. При этом она имеет два определяющих свойства: она с одной стороны общая, а с другой - селективная. Общая, в данном случае, значит, что один и то же тип исключения может быть возбуждён и перехвачен вне зависимости от контекста (например, когда вы пытаетесь прибавить что-то к числу, вызвать строковый метод или записать объект в сокет, при неверных аргументах будет вызвано исключение TypeError). Селективное означает, что мы можем какие-то исключения перехватывать, обрабатывать, сохранять, инкапсулировать, а какие-то выпускать на более высокий уровень перехвата. Например, вы можете перехватывать ZeroDivisionErrors и пропускать другие типы ArthmeticErrors (например, OverflowError).
Данный PEP предлагает внести изменения в часть этой иерархии для лучшего воплощения данных принципов; он касается ошибок, связанных с системными вызовами (OSError, IOError, mmap.error, select.error и все их подклассы).
Предложение:
Сбивающий с толку набор исключений, связанных от ОС
Исключения, вызванные ОС или системными вызовами, на данный момент представляют из себя различные классы, организованные в следующую иерархию:
  • EnvironmentError
    • IOError
      • io.BlockingIOError
      • io.UnsupportedOperation (так же является подклассом ValueError)
      • socket.error
        • socket.gaierror
        • socket.herror
        • socket.timeout
    • OSError
      • VMSError
      • WindowsError
    • mmap.error
  • select.error
Не смотря на то, что различия между ними могут быть выяснены при обсуждении их реализации, тем не менее на более высоком уровне они часто не выглядят логичными. Граница между OSError и IOError, например, не так уж и ясна. Посмотрите на этот пример:
>>> os.remove("fff")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
OSError: [Errno 2] No such file or directory: 'fff'
>>> open("fff")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IOError: [Errno 2] No such file or directory: 'fff'
Одна и та же проблема (отсутствующий файл) вызывает два разных типа исключения, в зависимости от того, функция из какой библиотеки вызывается. В данном случае модуль os возбуждает OSError (или его подкласс WindowsError), тогда как модуль io возбуждает IOError. При всём при этом, пользователя интересует суть ошибки, а не то, в каком модуле она возникла (так как последнее можно и так явно понять из трассировочного сообщения или исходного кода).
На самом деле, тяжело представить ситуацию, где надо было бы перехватить OSError и пропустить IOError, или наоборот.
Ещё один минус такой двусмысленной сегментации состоит в том, что сама стандартная библиотека не всегда может чётко понять, какое исключение ей надо возбудить. Например, в модуле theselect одинаковые ошибки вызывают select.error, OSError или IOError в зависимости от того, используете ли вы select(), poll, kqueue или epoll объекты. Это делает пользовательский код бессмысленно сложным, так он должен быть готов перехватывать различные типы исключений, в зависимости от того, какая реализация будет выбрана в момент использования кода.
Что же касается WindowsError, так этот тип вообще выглядит бесполезным. Во-первых, этот тип исключений относится исключительно к Windows, что добавляет необоснованной сложности при создании кросс-платформенного кода (такой код можно найти в Lib/shutil.py). Во-вторых, он является дочерним классов OSError и возбуждается при тех же ошибках, что и OSError на других системах. В-третьих, пользователи, желающие доступа к низкоуровневым исключениям по любому должны будут проверять errno или winerrorattribute.
Заметка:
В приложении Б приведен обзор различных типов исключений интерпретатора и стандартной библиотеки.
Отсутствие детально структурированных исключений
Текущее разнообразие связанных с ОС исключений не позволяет пользователю легко отфильтровать желаемые типы ошибок. Как пример, представим, что нам надо удалить файл, если он существует. Подход Look Before You Leap (Посмотри Перед Тем, Как Прыгать, LBYL) предполагает очевидный путь решения этой проблемы:
if os.path.exists(filename):
    os.remove(filename)
Если файл filename создается другим потоком между вызовами os.path.exists и os.remove, то он не будет удален, что в свою очередь, может создать проблему для программы и даже дыру в безопасности.
По этой причине более правильным подходом будет попробовать удалить файл и проигнорировать ошибку, если окажется, что он не существует. Этот подход известен как Easier to Ask Forgiveness than to get Permission (Проще Попросить Прощения, чем Спросить Разрешения, EAFP). Правильный код будет выглядеть так (и будет работать и на POSIX и на Windows):
try:
    os.remove(filename)
except OSError as e:
    if e.errno != errno.ENOENT:
        raise
или
try:
    os.remove(filename)
except EnvironmentError as e:
    if e.errno != errno.ENOENT:
        raise
Правда, в данном случае приходится больше набирать и помнить различные мнемонические значения модуля errno. Всё это требует больше умственного труда и быстро вызывает утомление. Логично, что многие программисты вместо этого предпочтут следующий код, который, однако, перехватывает исключения слишком широко:
try:
    os.remove(filename)
except OSError:
    pass
os.remove может возбуждать OSError не только когда файла не существует, но и в ряде других случаев — например, когда имя файла указывает на директорию, или текущий процесс не имеет прав на удаление файла; любой из этих случаев говорит об ошибке  в логике программы и потому не должен оставаться незамеченным. Что на самом деле нужно программистам, так это
try:
    os.remove(filename)
except FileNotFoundError:
    pass
Стратегия обеспечения совместимости
Реструктуризация иерархии исключений очевидно изменит семантическое значение части существующего кода. Поскольку улучшение существующей ситуации невозможно без изменения этой семантики, необходимо определить узкий тип совместимости, который мы назовем "полезной совместимостью".
Для этого, во-первых, мы должны объяснить, что мы имеем ввиду под осторожным или небрежным перехватом исключений. Небрежный, он же "наивный" код — это код, который вслепую перехватывает любое исключение типа OSError, IOError, socket.error, mmap.error, WindowsError, select.error без проверки атрибута errno. Причина такого названия в том, что эти типы исключений слишком широки, чтобы обозначать что-либо. Любое из них может быть возбуждено при различных условиях, например: плохой дескриптор файла (что обычно обозначает программную ошибку), сокет без соединения (то же самое), таймаут сокета, несоответствие типа файла, неверный аргумент, ошибка передачи, недостаточные права, несуществующая директория, переполнение файловой системы и т.д.
(более того, использование некоторых этих исключений не постоянно, смотрите Приложение А, где приведен обзор модуля select, который возбуждает различные исключения в зависимости от реализации)
Осторожный код — это код, который при перехвате любого из вышеупомянутых исключений, проверяет его errno атрибут, чтобы выяснить условия возбуждения исключения и обработать его в соответствии с этим.
Теперь мы можем дать определение полезной совместимости:

  • полезная совместимость не делает перехват исключений имеющимся кодом уже, но может сделать его шире для небрежного кода. В следующем примере все исключения, перехватывавшиеся до этого PEP будут перехватываться и после его внедрения, однако для пропуска исключений это утверждение не верно (так как соединение OSError, IOError и других типов подразумевает, что выражение except будет забрасывать более широкую сеть):
    try:
        ...
        os.remove(filename)
        ...
    except OSError:
        pass
  • полезная совместимость не изменяет поведение осторожного кода. В следующем примере ошибки будут пропускаться и перехватываться вне зависимости от внедрения этого PEP:
    try:
        os.remove(filename)
    except OSError as e:
        if e.errno != errno.ENOENT:
            raise
Обоснованием для первого пункта служит то, что небрежному коду уже ничто не поможет, но тот код, который уже работает, по крайней мере, не будет внезапно выдавать ошибки или падать. Это важно, поскольку такой код присутствует в скриптах, запускаемых автоматически и используются для администрирования.
Осторожный код, с другой стороны, не должен пострадать, так как одна из целей этого PEP — облегчить написание такого кода.
Шаг 1: объединение типов исключений
Первый шаг на нашем пути — соединение существующих типов исключений. Предложены следующий изменения:

  • добавление псевдонима OSError для socket.error и select.error
  • добавление псевдонима OSError для mmap.error
  • добавление псевдонима OSError для WindowsError и VMSError
  • добавление псевдонима OSError для IOError
  • включение EnvironmentError в OSError
Каждое из этих изменений не сохраняет точной совместимости, но сохраняет полезную совместимость (см. предыдущий пункт).
Каждое из этих изменений может быть принято или отвергнуто по отдельности, но, очевидно, что наилучший результат будет достигнут после принятия всех предложенных изменений.
В таком случае иерархия IO исключений будет такой:
+-- OSError   (replacing IOError, WindowsError, EnvironmentError, etc.)
    +-- io.BlockingIOError
    +-- io.UnsupportedOperation (also inherits from ValueError)
    +-- socket.gaierror
    +-- socket.herror
    +-- socket.timeout
Объяснение правомерности
Этот шаг не только позволит получить более простой ландшафт исключений, как говорилось в Предложении, но и позволит достичь более полного осуществления второго шага (см. пункт Предварительные требования)
Причина сохранения OSError в качестве имени для всех исключений, зависящих от операционной системы, в том, что оно более общее, чем IOError. EnvironmentError гораздо дольше набирать да и менее известен.
Обзор в Приложении Б показывает, что IOError на данный момент является преобладающим исключением в стандартной библиотеке. Для стороннего кода Google Code Search  показывает, что IOError в десять раз популярнее, чем EnvironmentError и в три раза чем OSError. И тем не менее, при всём уважении к IOError, меньшая известность OSError не является препятствием для нашего предложения.
Атрибуты исключений
Так как WindowsError включается в OSError, последний тип получает winerror атрибут в Windows. Он устанавливается в None в тех ситуациях, когда он не имеет смысла, как уже происходит с errno, filename и sterror атрибутами (например, когда OSError возбуждается напрямую кодом)
Неодобряемые имена
Следующие параграфы содержат краткий обзор возможных стратегий введения неодобряемых имен исключений.На данный момент все имена продолжают существовать как псевдонимы. Это решение может быть пересмотрено в релизе 4,0
встроенные исключения
Отказ от старых встроенных исключений не может быть проведена топорно, просто перехватывая все их имена во встроенном пространстве имён, так как это критично отразится на производительности. Мы так же не можем работать на объектном уровне, так как неодобряемые имена будут псевдонимами для одобряемых объектов (не спрашивайте меня, что тут имелось ввиду:) )
Решением может быть распознавание этих имен в процессе компиляции и выдача отдельного опкода LOAD_OLD_GLOBAL вместо обычного LOAD_GLOBAL. Этот опкод перехватывает вывод DeprecationWarning (или PendingDeprecationWarning, в зависимости от политики, определённой в результате) в случае, если имя не существует в глобальном пространстве имён, но только если оно является встроенным. Этого достаточно чтобы избежать ложных срабатываний (в случае если кто-то определяет свой собственный OSError в модуле), а ошибки будут достаточно редки (например, если кто-то получает доступ к OSError через thebuiltins модуль, а не напрямую)
исключения, определяемые в модулях
Изложенный выше подход не может быть просто использован в данном случае, так как он требует специальной обертки для модулей во время их компиляции. Однако, эти имена менее видимы (они не попадают во встроенное пространство имён) и так же менее известны, так что мы можем сохранить им жизнь в их собственных пространствах имён.
Шаг 2: определение дополнительных подклассов
Второй шаг нашего пути — расширение иерархии путём определения подклассов, которые и будут возбуждаться, вместо родительского класса с некоторым errno. Конкретные значения errno — предмет для обсуждения, но обзор существующих исключений (см Приложение А) поможет нам предложить разумный набор всех значений. Альтернативный вариант выдачи каждому errno мнемонического имени выглядит глупым, бессмысленным и лишь загрязнит корневое пространство имён.
Более того, в некоторых случаях, различные errno соответствуют одному и тому же подклассу. Например, EAGAIN, EALREADY, EWOULDBLOCK и EINPROGRESS используются для того, чтобы сообщить, что операция над не блокируемым сокетом будет заблокирована (и потому надо попробовать ещё раз позже). Поэтому все они могут возбуждать один и тот же подкласс и оставить на усмотрение пользователя проверку errno (смотрите ниже "атрибуты исключений").
Предварительные требования
Шаг 1 — необязательное предварительное требования для этого шага.
Предварительное требование, так как некоторые errno могут относиться к различным классам исключений: например, ENOENT может быть присоединён как к OSError так и к IOError, в зависимости от контекста. Если мы не хотим нарушать полезную совместимость, мы не можем допустить, чтобы OSError или IOError не подпадал под соответствие выражению except там, где сейчас оно срабатывает.
Необязательное, так как мы можем выполнить второй шаг и без объединения соответствующих классов. Например, ENOENT может возбуждать гипотетическое исключение FileNotFoundError в случае, если перед этим было возбуждено IOError, или  OSError во всех остальных случаях.
Зависимость от первого шага может быть в принципе полностью устранена, если новые подклассы будут иметь множественное наследование, чтобы соответствовать всем существующим подклассам (или, как минимум, OSError и IOError, как наиболее распространённым). Но такой вариант сделает иерархию более сложной и потому тяжелее воспринимаемым пользователями.
Новые классы исключений
Следующий предварительный список подклассов вместе с их описанием и соответствующими errno является объектом для обсуждения:

  • FileExistsError: попытка создать файл или директорию, которые уже существуют (EEXIST)
  • FileNotFoundError: для всех случаев, когда требуемый файл или директория не существуют (ENOENT)
  • IsADirectoryError: операции над файлами (open(), os.remove()...), применяемые к директории (EISDIR)
  • NotADirectoryError: операции над директорией, применяемые к чему-либо другому (ENOTDIR)
  • PermissionError: попытка выполнить операцию без адекватный прав доступа — например, прав файловой системы (EACCES, EPERM)
  • BlockingIOError: операция, которая должна заблокировать объект (например, сокет) устанавливается для неблокируемой операции (EAGAIN, EALREADY, EWOULDBLOCK, EINPROGRESS); на данный момент эту роль выполняет io.BlockingIOError с расширенной ролью
  • BrokenPipeError: попытка записать в канал, закрытый на другом конце, или попытка записать в сокет, который закрыт для записи (EPIPE, ESHUTDOWN)
  • InterruptedError: системный вызов прерван входящим сигналом (EINTR)
  • ConnectionAbortedError: попытка прервать соединение узлом (ECONNABORTED)
  • ConnectionRefusedError: соединение отклонено узлом (ECONNREFUSED)
  • ConnectionResetError: соединение переустановлено узлом (ECONNRESET)
  • TimeoutError: таймаут соединения (ETIMEDOUT); оно может быть использовано как стандартное таймаут исключение, заменив socket.timeout и будучи полезным для других ситуаций с таймаутом (например, для Lock.acquire())
  • ChildProcessError: операция над дочерним процессом не удалась (ECHILD), очень часто возбуждается семейством функций wait()
  • ProcessLookupError: данный процесс (идентифицируемый, например, по process id) не существует (ESRCH)
Кроме того, предлагается добавить следующий класс исключений:

  • ConnectionError: базовый класс для ConnectionAbortedError, ConnectionRefusedError и ConnectionResetError
Следующее дерево суммирует предлагаемые изменения вместе с соответствующими значениями errno там, где это применимо. Корень субиерархии (если выполнен шаг 1, то это OSError) не показан:
+-- BlockingIOError     EAGAIN, EALREADY, EWOULDBLOCK, EINPROGRESS
+-- ChildProcessError                                       ECHILD
+-- ConnectionError
    +-- BrokenPipeError                           EPIPE, ESHUTDOWN
    +-- ConnectionAbortedError                        ECONNABORTED
    +-- ConnectionRefusedError                        ECONNREFUSED
    +-- ConnectionResetError                            ECONNRESET
+-- FileExistsError                                         EEXIST
+-- FileNotFoundError                                       ENOENT
+-- InterruptedError                                         EINTR
+-- IsADirectoryError                                       EISDIR
+-- NotADirectoryError                                     ENOTDIR
+-- PermissionError                                  EACCES, EPERM
+-- ProcessLookupError                                       ESRCH
+-- TimeoutError                                         ETIMEDOUT
Имена
По поводу имён могут возникать различные споры. В частности, должны ли все имена исключений заканчиваться на Error. За — согласованность с остальной иерархией исключений, против — краткость (особенно для длинных имён, таких как ConnectionAbortedError).
Атрибуты исключений
Для того, чтобы сохранить полезную совместимость, данные подклассы должны устанавливать адекватные значения различных атрибутов исключений, в зависимости от суперкласса (например, errno, filename и, возможно, winerror).
Реализация
Так как предполагается, что подклассы будут возбуждаться на основе значения errno, то изменения в модулях расширения (стандартных или сторонних) если и потребуются, то будут минимальны.
Первая возможность — адаптировать семейство функций PyErr_SetFromErrno() (PyErr_SetFromWindowsErrno() под Windows) для возбуждения соответствующего подкласса OSError. Однако, это не покроет код на Python, который возбуждает OSError напрямую, используя такой код (из Lib/tempfile.py):
raise IOError(_errno.EEXIST, "No usable temporary file name found")
Вторая возможность, предложенная Marc-Andre Lemburg — адаптировать OSError.__new__ для создания экземпляров соответствующего подкласса. Такая реализация покроет так же и код, приведённый выше.
Возможные возражения
Загрязнение пространства имён
Более тонкая иерархия делает корневое (или встроенное) пространство имён более заполненным. Однако, разрастание пространства имён будет ограниченным, так как:

  • предлагается лишь несколько дополнительных классов
  • хотя стандартные исключения живут в корневом пространстве имён, они визуально отличаются от других встроенных имён, тем что используют ВерблюжийРегистр в то время как другие встроенные имена пишутся строчными буквами (кроме True, False, None, Ellipsis и NotImplemented)
В качестве альтернативы может быть предложено вынести детальную иерархию в отдельный модуль, но это бы помешало реализации одной из целей этого PEP: перехода с небрежного кода на осторожный, так как пользователю пришлось бы импортировать дополнительный модуль вместо того, чтобы использовать уже имеющиеся имена.
Ранние обсуждения
Хотя это и первое формальное предложение, тем не менее данная идея уже получала поддержку в прошлом и касательно детализации иерархии исключений и касательно слияния OSError и IOError.
Удаление WindowsError уже обсуждалось, но было отклонено в рамках другого PEP, однако и там пришли к консенсусу, что выделение этого типа из OSError не было осмысленным, что говорит в пользу слияния с OSError.
Реализации
Эталонная реализация была включена в Python 3.3. Разработка проводилась на http://hg.python.org/features/pep-3151/ в ветке pep-3151, багтрекер находился тут. Реализация была успешно протестирована на различных системах: Linux, Windows, OpenIndiana и FreeBSD.
Одним из источников проблем были соответствующие конструкторы OSError и WindowsError, которые оказались несовместимыми. Решили это сохранением сигнатуры OSError и добавлением четвёртого  опционного аргумента для передачи Windows error code (отличающегося от POSIX errno). В четвёртом аргументе сохранен winerror, соответствующий POSIX errno. PyErr_SetFromWindowsErr* функции были адаптированны для правильного вызова конструктора.
Есть одна маленькая сложность когда функции PyErr_SetFromWindowsErr* вызываются из OSError а не из WindowsError: в атрибуте errno объекта исключения сохранится Windows error code (например, 109 для ERROR_BROKEN_PIPE) а не его POSIX аналог (32 для EPIPE), как это делается сейчас. Для кодов ошибок не связанных с сокетом это касается только частного модуля _multiprocessing, так что нет причин для беспокойства.
Заметка
Для ошибок в сокете POSIX errno, как показано в модуле errno, численно равно Windows Socket error code, возвращаемому системным вызовом WSAGetLastError:
>>> errno.EWOULDBLOCK
10035
>>> errno.WSAEWOULDBLOCK
10035
Возможные альтернативы
Соответствие шаблонам
Альтернативная возможность представлена продвинутым соответствием шаблонам при перехвате исключений. Например:
try:
    os.remove(filename)
except OSError as e if e.errno == errno.ENOENT:
    pass
Некоторые проблемы при таком решении:

  • тут используется новый синтаксис, что воспринимается автором как более серьезное исключение, чем реструктуризация иерархии
  • это не сокращает количество кода, который требуется набрать
  • это не спасает программиста от необходимости помнить мнемонику errno
Исключения, игнорируемые данным PEP
Данный PEP игнорирует EOFError, что сигнализирует о прерванном вводе из потока для различных протоколов и реализаций форматов файлов (например, GzipFile). EOFError не связан с IOError или OSError, это логическая ошибка, возбуждаемая на высоком уровне.
Данный PEP так же игнорирует SSLError, возбуждаемый модулем ssl для распространения ошибок, обозначенных в библиотеке OpenSSL. В идеале, SSLError выиграл бы от подобного подхода, но для него требуется отдельная обработка, так как он определяет свои собственные константы типов ошибок (ssl.SSL_ERROR_WANT_READ и тд.). В Python 3.2 SSLError уже заменён socket.timeout в случае таймаута сокета (см проблему 10272)
И, наконец, судьба socket.gaierror и socket.herror ещё не определена. Хотя они заслуживают менее таинственных имён, это может быть сделано вне зависимости от пересмотра иерархии исключений.

Комментариев нет:

Отправить комментарий