четверг, 27 мая 2010 г.

Питон: импорт и модули - часть 3

Питон: импорт и модули - часть 3.

Начало - в первой и второй частях.

Поговорим о расширениях импорта (import hooks) - sys.meta_path, sys.path_hooks, sys.path_import_cache

Зачем они вообще появились?

Все очень просто. Захотелось добавить поддержку импорта из .zip архивов. Ява такое может (.jar) - чем Питон хуже?

Разразилась довольно бурная дискуссия, в ходе которой стало ясно - если уж расширять, то делать это хорошо, чтобы сразу убить одним выстрелом всех зайцев и оставить сестрам по серьгам.

Например, уметь загружать модули из базы данных или с соседнего сервера.

Так появился PEP 302 - New Import Hooks

Думаю, все получилось неплохо.

Это

  • искатель (finder)
  • загрузчик (loader)
  • sys.meta_path
  • sys.path_hooks
  • sys.path_import_cache

В этой статье я хочу разобрать перечисленные пункты, оставив написание нетривиальных расширений на следующий раз. Тема довольно сложная, и изложить все быстро - нелегко.

Найти и загрузить

На самом деле импорт довольно естественно раскладывается на две части:

  • найти модуль по имени, получив в результате объект - загрузчик.
  • загрузить этот модуль

Соответственно имеем два контракта:

class Finder:
    def find_module(self, fullname:str, path:[str]=None) -> Loader:
        pass

class Loader:
    def load_module(self, fullname:str) -> types.ModuleType:
        pass

Искатель имеет единственный метод .find_module, который принимает полное имя модуля и необязательный путь. Путь сейчас неинтересен, а на имени остановимся более подробно.

Хочу подчеркнуть, что это именно полное имя модуля. Т.е. __import__ сначала превратит относительное имя в абсолютное, если нужно - и только затем начнет работать с искателями и загрузчиками.

Если модуль найден - возвращается экземпляр загрузчика для этого модуля (у которого будет вызван .load_module вторым шагом). Если искатель не может найти модуль с запрашиваемым именем (например, потому что с этим именем работает другое расширение импорта) - следует вернуть None. Выброшенное исключение приведет к ImportError.

Загрузчик должен иметь метод .load_module, который проводит всю загрузку. Есть еще несколько необязательных методов, применяемых для "продвинутых" загрузчиков.

Мы подробно рассмотрим искатель и загрузчик в следующей статье. Сейчас нужно запомнить следующее:

  • импорт модулей выполняется в два шага
  • все начинается с искателей, которые возвращают загрузчики для тех полных имен модулей, которые эти искатели умеют обрабатывать.
  • механизм импорта сам умеет построить полное имя модуля до вызова расширений. Т.е. он правильно преобразует относительный импорт в абсолютный, и попросит найти модуль по его абсолютному имени. Более того, он обеспечит загрузку пакета перед тем, как перейти к модулю в нем.
  • имена модулей (как абсолютные так и относительные) не имеют никакого отношения к файловой системе. Да, часто они похожи - потому что Питон использует файловую систему для хранения своих файлов. Но с точки зрения расширений импорта это - совершеннно разные вещи.

Я вообще считаю, что употребление терминов ".py файл" и "папка с __init__.py" в большинстве случаев некорректно. Используйте "модуль" и "пакет", объединяя последние в "библиотеки".

Регистрация искателей.

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

Таких мест два:

  • sys.meta_path
  • sys.path_hooks (в связке с sys.path_import_cache)

sys.meta_path

Содержит список экземпляров искателей. По умолчанию пустой.

sys.path_hooks

Содержит список фабрик искателей. (Класс - фабрика для его экземпляров). По умолчанию в нем зарегистрирован импортер для zip файлов.

>>> import sys
>>> sys.path_hooks
[<type 'zipimport.zipimporter'>]

Схема работы расширений импорта

Здесь нужно сделать оговорку. Сначала опишу, как все задумывалось.

Процесс "ввода в строй" PEP 302 несколько затянулся. Т.е. полностью поддерживается вся спецификация, но унификация расширений и старого механизма импорта не выполнена.

В результате имеем как бы две подсистемы - сначала пытаемся задействовать расширения. Если они не подошли - переходим к импорту "по старинке".

Это неудобно разработчикам Питона (частичное дублирование функционала и трудность поддержки двух систем одновременно).

Еще более неудобно создателям расширений - в стандартной библиотеке просто напрочь отсутствует масса необходимых утилитных функций, их приходится создавать каждый раз заново.

На пути к окончательному решению вопроса стоит два препятствия:

  • огромный объем работ, требуемых для переписывания той вермишели, которая уже присутствует в Python/import.c. Плюс нужно все тщательно протестировать, чтобы не нарушить ни один пункт текущего поведения. Блок юниттестов большой, но не совсем полный. К тому же есть довольно значительная часть платформозависимого кода (Linux, Windows, MacOS, OS/2). Ее тестировать еще сложнее
  • опасения по деградации производительности. Новый код должен быть не медленней старого

Есть и довольно существенный стимул помимо красоты единого дизайна - в Python 3 введена поддержка идентификаторов на родном языке пользователя (т.е. можно создавать переменную Вася_Пупкин = True). Модули, конечно, тоже можно называть по русски. Так вот, сейчас все более или менее работает, но на ряде граничных условий таки ломается (редко, такой случай действительно трудно поймать). Закрыть все ошибки можно только переписав большую часть кода. Когда-нибудь (Python 3.3?) все починится.

Изложение всех хитросплетений сбивает с толку.

Поэтому рассмотрим сначала "идеальную картинку".

Импорт - как оно должно быть

Импорт начинается с sys.meta_path (список - упорядоченная коллекция).

В нем находятся экземпляры искателей. Обрабатываем их по очереди.

Первый искатель, у которого .find_module вернет не None - наш (по аналогии с обработкой sys.path). Сам sys.path на этом уровне пока не участвует.

Возвращенный объект - загрузчик. Вызываем у него .load_module и получаем наш модуль. Загрузчик сам должен зарегистрировать его в sys.modules. Все, модуль загружен.

package_name = fullname.rpartition('.')[0]
path = None
if package_name:
    # fullname имеет форму 'a.b.c'
    # package_name равен 'a.b'
    # в sys.modules уже есть пакет 'a.b'
    # и его __path__ обязан быть установлен
    # path необходим - помните, в первой статье из серии
    # был пример пакета, который разбросан по нескольким
    # путям 
    path = getattr(sys.modules[package_name].__path__)

for finder in sys.meta_path:
    loader = finder.find_module(fullname, path)
    if loader is not None:
        module = loader.load_module(fullname)
        return module

После инициализации интерпретатора sys.meta_path уже должен содержать как минимум один элемент. Назовем его StandardFinder.

Этот "стандартный искатель" обрабатывает оставшуюся часть механизма импорта. Лучше, если он будет последним элементом списка - чтобы больше походить на текущую реализацию (но, строго говоря, это не обязательно).

Стандартный искатель в свою очередь пройдет по списку sys.path_hooks. Для каждого элемента sys.path он попробует создать "искатель второго рода" (используя sys.path_import_cache для ускорения работы). Если вложенный искатель отзовется на свой .find_module - будет использоваться возвращенный им загрузчик.

class StandardFinder:
    def find_module(self, fullname, path=None):
        if path is None:
            # путь не указан, используем sys.path
            path = sys.path

        for elem in path:
            # стандартный искатель ожидает, что элементы -
            # пути в файловой системе 
            # (с небольшими вариациями как у zip архивов)
            # для каждого элемента из предложенных
            # сначала пробуем кеш
            finder = sys.path_import_cache.get(elem)
            if finder is None:
                # в кеше ничего не найдено
                for finder_class in sys.path_hooks:
                    try:
                         # пытаемся создать искатель для элемента
                         finder = finder_class(elem)
                    except ImportError:
                         # это нормально, зарегистрированный
                         # искатель второго рода не умеет обрабатывать
                         # предложенный элемент
                         # попробуем следующего кандидата
                         continue
                    else:
                         # вторичный искатель найден,
                         # зарегистрируем его в кеше для дальнейшего
                         # использования
                         sys.path_hooks[elem] = finder
                         break
                if finder is not None:
                    break

        if finder is not None:
            # как бы то ни было, вторичный искатель нашелся
            loader = finder.find_module(fullname)
            if loader is not None:
                # и, о счастье, он готов предоставить загрузчик
                # его и вернем
                return loader
        # никто не подошел, 
        # даем шанс системе импорта найти следующего кандидата
        return None

У стандартного искателя код получился более замысловатым. Обратите внимание:

  • в sys.path_hooks хранятся фабрики (класс ведь тоже фабрика для создания экземпляров).
  • фабрика вызывается со строкой - элементом, похожим на файловый путь (здесь может быть уместно применение URI/URL, если удобно). Если фабрика готова работать с элементом - она выбрана и следующие кандидаты не рассматриваются. Косвенным подтверждением данного факта является sys.path_import_cache - словарь элементов на искатели второго уровня.
  • У выбранного искателя второго рода .find_module вызывается без параметра path - он уже был передан в конструктор.

Естественно, чтобы эта схема заработала нужно в sys.path_hooks при инициализации зарегистрировать еще один вторичный искатель помимо одинокого zipimporter, который бы обрабатывал старые добрые пакеты и модули, лежащие просто в файловой системе (StandardFileSystemFinder). Также он должен поддерживать импорт C Extensions (все равно zipimporter без дополнительных ухищрений этого не умеет).

Текущая ситуация

Состояние дел на сегодняшний момент внешне не сильно отличается от предложенной схемы.

Конечно, StandardFinder и StandardFilesystemFinder не существуют и нигде не зарегистрированы.

Просто подразумевайте, что StandardFinder неявно стоит в конце sys.meta_path, а StandardFilesystemFinder в конце sys.path_hooks.

В sys.path_import_cache StandardFilesystemFinder тоже неявно присутствует.

Я сделал файл a.zip со следующим содержимым:

  • a.zip
    • a
      • __init__.py
      • b.py

Смотрите, что вышло:

>>> import sys
>>> sys.path.append('./a.zip')
>>> import a
>>> a.__path__
['./a.zip/a']
>>> import a.b
>>> from xml import dom
>>> sys.path_importer_cache
{
 ...
 './a.zip/a': <zipimporter "./a.zip/a/">,
 './a.zip': <zipimporter "./a.zip">,
 '.../py3k/Lib/xml/dom': None
 '.../py3k/Lib/xml': None
 ...
}

Вместо гипотетического StandardFilesystemFinder в sys.path_importer_cache проставляется None - не стоит пытаться обнаружить искатель для того файлового пути, которому уже не подошел ни один зарегистрированный кандидат.

В остальном наша "идеальная картинка" совпала с существующим интерпретатором.

Заключение

Мы весьма подробно рассмотрели то, как Питон работает с расширениями импорта.

Несмотря на то, что я иногда использовал аннотации из третьего Питона - все обратно совместимо до версии 2.3, в которой PEP 302 был впервые реализован.

Важно понимать различия между sys.meta_path и sys.path_hooks. При всей внешней схожести применяемых интерфейсов они служат разным целям.

К сожалению, спецификация PEP 302 уделяет этому недостаточное внимание. Общий совет такой:

  • если ваши расширения хорошо укладываются на файловую систему (zip архивы выглядят классическим примером) - используйте sys.path_hooks
  • в противном случае следует работать с sys.meta_path

В целом рекомендация правильная - но мне было очень нелегко понять разницу между первым и вторым подходом. Ведь практически в любом случае можно так или иначе построить соответствие "имя модуля" -> "уникальная строка", по которой можно загрузить этот модуль. Например, для базы данных это может быть sqlite:///<path to database>.

Различие лежит глубже. Для доступа к базе данных требуется открыть соединение к ней. Если повторное открытие архива на чтение - довольно необременительная операция (файл скорее всего уже в кеше операционной системы и, значит, в оперативной памяти), то для базы данных каждое новое соединение довольно накладно.

С другой стороны создание искателей первого рода, зарегистрированных в sys.meta_path требует большей внимательности к деталям того же кеширования - нельзя воспользоваться уже готовым sys.path_importer_cache.

Поэтому я попытался дать максимально подробное описание того, как это все работает. Надеюсь, вышеизложенное поможет сделать правильный выбор - или подскажет момент, когда следует переключиться на альтернативную схему.

В следующей части будут столь же скурпулезно разобраны искатель и загрузчик (Finder и Loader) с созданием элементарного примера - и будет дан беглый обзор замечательной стандартной библиотеки importlib (3.1+)

И, конечно, где-то впереди нетривиальная "конфетка" - демонстрация по настоящему сложной элегантной системы, которую невозможно построить без описываемого механизма.

7 комментариев:

  1. А что это за синтаксис у тебя filename:str ?

    ОтветитьУдалить
  2. Аннотации из тройки. Для двойки их можно просто выбросить - все равно ни на что не влияют.

    ОтветитьУдалить
  3. Здраствуй Андрей (с Новым Годом тебя)

    у меня есть вопрос --
    при просмотре твоего выступления http://uapycon.blip.tv/file/4380187/
    есть момент где в ipython ты вводишь
    код:
    from sys import getframe
    и вопрос у меня в Python 2.6.6 (r266:84292) но данной функции getframe нет.
    Объясни пожайлуста как до нее добраться

    Спасибо

    ОтветитьУдалить
  4. from sys import _getframe
    Есть начиная, кажется, с 2.4

    ОтветитьУдалить
  5. Точно. из-за нечеткого видео пропустил _
    спасибо все работает

    ОтветитьУдалить
  6. День добрый. Хорошая статья. Только с проблемой так и не разобрался. Используется следующий загрузчик описанный здесь:
    http://src.chromium.org/svn/trunk/tools/third_party/python_26/Lib/site-packages/zipextimporter.py

    в sys.path имеется путь к папке с приложением, в которой находится dll с модулями. Если в пути имеются русские символы, то загрузка валится с ошибкой типа
    Exceptions.UnicodeDecodeError: 'ascii' codec can't decode byte 0xca in position 17: ordinal not in range(128)
    В __init__ загрузчика уже приходит корявый путь, но где он коверкается никак не могу понять, и в какой момент он вообще вызывается. Если вывести через print пути из sys.path, то то они выглядят нормально. А вот print archivepath из _init_ возвращает пути с вырезанными русскими символами.

    ОтветитьУдалить
  7. Вы вот это уже читали?
    http://asvetlov.blogspot.com/2011/03/import-and-unicode.html

    ОтветитьУдалить