?

Log in

No account? Create an account
killer rabbit

Разбираемся с egg’s entrypoints

Originally published at Pythy. You can comment here or there.

В setuptools есть механизм плагинов - так называемых "точек входа". Сегодня поговорим о том, как использовать egg’s entrypoints в своих программах.

Egg’s entrypoints - вводная

setuptools позволяют определять точки входа для плагинов. Эти точки определяются у плагинов, а программа, которая хочет использовать плагины "спрашивает" у setuptools, кто реализует данную EP (entry point, точка входа). Прежде чем начать, советую познакомиться с Python eggs и с тем, как их правильно устанавливать.

Чтобы было легче с пониманием, будем разбираться на простом примере. Напишем программу, которая будет печатать списки. "Что" будет определяться плагином ввода, "как" - плагином вывода.

Без плагинов

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

# lister.input
import os
def dir_list():
    """
    Lists current dir
    """

    return os.listdir('.')

в виде обычного списка

# lister.output
import pprint
def raw_list(ilist):
    """
    Prints list 'AS IS'
    """
    pprint.PrettyPrinter().pprint(list(ilist))

Соединяя вместе, получаем желаемую программу, без использования плагинов:

from lister.input import dir_list
from lister.output import raw_list

def listit_wo_plugins():
    raw_list(dir_list())

С плагинами

Указание точек входа

Как выше сказано, нужно указать у плагинов точки входа. У нас будут плагины ввода (точка входаlister.input) и плагины вывода (точка входа lister.output). Точки входа указываются в метаданных пакета - в определении setup.py:

from setuptools import setup

setup(
    # ...
    entry_points="""
    [lister.output]
    raw = lister.output:raw_list
    [lister.input]
    dir = lister.input:dir_list
    """
)

Точки входа указываются либо в формате ini-файла, многострочником; либо в виде словаря:

from setuptools import setup

setup(
    # ...
    entry_points={
    'lister.output': ['raw = lister.output:raw_list'],
    'lister.input': ['dir = lister.input:dir_list'],
    }
)

У нас получилось так, что имя EP совпало с именем модуля, но это случайность, в общем случае это не так. Что касается определения плагина - то запись выглядит как

имя_плагина = путь.к.модулю:объект

Конечным объектом может служить любой Python-объект (класс, функция, представитель класса и т.д.).

Использование точек входа

После определения точек входа мы можем воспользоваться инструментами setuptools для доступа к плагинам (поправка: egg с определенными EP должен быть установлен, так что я рекомендую поставить уже готовый lister).

>>> import pkg_resources
>>> pkg_resources.get_entry_map('lister')
{'lister.input': {'dir': EntryPoint.parse('dir = lister.input:dir_list')},
'lister.output': {'raw': EntryPoint.parse('raw = lister.output:raw_list')}}
>>> pkg_resources.load_entry_point('lister', 'lister.output', 'raw')
<function raw_list at 0xb788c95c>
>>> list(pkg_resources.iter_entry_points('lister.input'))
[EntryPoint.parse('dir = lister.input:dir_list')]

Указанных функций хватит для большинства случаев. Документация по setuptools весьма бедна, зато практически у всех функций/методов есть докстринги.

Я выделил работу с плагинами в отдельный модуль - plug.py. Основная функция показаны ниже:

def get_plugins_by_entrypoint(ep_name, plug_name=None):
    """
    Returns iterator over names, descriptions and plugin-actions
    for current entrypoint
    """

    for entrypoint in pkg_resources.iter_entry_points(ep_name, plug_name):
        plugin_func = entrypoint.load()
        plugin_description = plugin_func.__doc__
        yield (entrypoint.name, plugin_description, plugin_func)

Если имя плагина не передается, то строится список по всем плагинам для данной точки входа. В случае какой-либо ошибки (не найдена точка входа, не найден плагин) - возвращается пустой итератор.

Всё вместе

Отдельные элементы уже работают как надо. так что нужно собрать всё в месте.

Стоит начать с управление параметрами командной строки (lister.command):

# lister.command

import sys

from lister import plug

def runner(argv):
    """
    Parses command-line options
    """

    # defaults
    iplugin = 'dir'
    oplugin = 'raw'

    # ... Здесь идет разбор переданных параметров.
    # ... В зависимости от них, либо выводится список
    # ... доступных плагинов, либо переопределяются
    # ... переменные iplugin и/или oplugin

    iplugins_data = list(plug.get_input_plugins(iplugin))
    if not iplugins_data:
        print "No such input plugin: %s" % iplugin
        sys.exit(1)
    else:
        input_plugin = iplugins_data[0][-1]

    oplugins_data = list(plug.get_output_plugins(oplugin))
    if not oplugins_data:
        print "No such output plugin: %s" % oplugin
        sys.exit(1)
    else:
        output_plugin = oplugins_data[0][-1]

    return input_plugin, output_plugin

По результатам разбора параметров командной строки, либо программа завершает свою работу (например, в случае ошибки), либо возвращает загруженные плагины. Так что в главном модуле остается просто воспользоваться результатами:

from lister.command import runner

def listit(input_plugin, output_plugin):
    """
    Lists data using specified input and output plugins
    """
    output_plugin(input_plugin())

def main():
    """
    Runs lister from command line
    """
    iplugin, oplugin = runner(sys.argv)
    listit(iplugin, oplugin)

if __name__ = '__main__':
    main()

Теперь вроде всё. Можно попробовать запустить программу:

$ listit
['lister', 'setup.py', 'scripts', 'lister.egg-info']

Дописываем плагины

Надеюсь, у вас получилось с lister и теперь мы попробуем написать плагин, скажем, для вывода в html.

Для этого создаем пакет, скажем listerhtml. В нем пишем функцию вывода списка в html:

def html_list(ilist):
    """
    Prints list as HTML unordered list
    """
    print "<ul>"
    for element in ilist:
        print "<li>%s</li>" % element
    print "</ul>"

И в метаданных пакета записываем, что это - плагин к lister.output:

from setuptools import setup

setup(
    # ...
    entry_points={
        'lister.output': ['html=listerhtml:html_list'],
    }
)

Теперь устанавливаем его (python setup.py install) и смотрим список плагинов к listit:

$ listit --list
Input plugins:
dir
    Lists current dir

Output plugins:
raw
    Prints list 'AS IS'

html
    Prints list as HTML unordered list

$ listit -o html
<ul>
<li>lister</li>
<li>setup.py</li>
<li>scripts</li>
<li>lister.egg-info</li>
</ul>

Заключение

setuptools дает возможность достаточно просто реализовать плагины к своим программам. Единственный недостаток - это необходимость, чтобы нужный egg был установлен. Рабочий код lister и плагина к нему listerhtml, как обычно, - на code.google.com.

Comments

Привет. Интересный вопрос.
Только необходимости устанавливать egg нет. Я не занимался egg'ами вообще, но знаю что trac работает с плагинами не установленными в Python. Вот вариант твоей функции get_plugins_by_entrypoint:

def get_plugins_by_entrypoint(ep_name, plug_name = None):
	plugins_dirs = [os.path.realpath(os.path.dirname(__file__))]
	ws = pkg_resources.working_set
	for plugins_dir in plugins_dirs:
		ws.add_entry(plugins_dir)
	pkg_env = pkg_resources.Environment(plugins_dirs + sys.path)

	memo = set()
	def flatten(dists):
		for dist in dists:
			if dist in memo:
				continue
			memo.add(dist)
			try:
				predecessors = ws.resolve([dist.as_requirement()])
				for predecessor in flatten(predecessors):
					yield predecessor
				yield dist
			except pkg_resources.DistributionNotFound, e:
				print 'Skipping "%s" ("%s" not found)'%(dist, e)
			except pkg_resources.VersionConflict, e:
				print 'Skipping "%s" (version conflict: "%s")'%(dist, e)


	for egg in flatten([pkg_env[name][0] for name in pkg_env]):
		entry_point = egg.get_entry_info(ep_name, plug_name)
		if entry_point:
			egg.activate()
			plugin_func = entry_point.load()
			plugin_description = plugin_func.__doc__
			yield (entry_point.name, plugin_description, plugin_func)

Над кодом я особенно не задумывался, он выдран из trac.loader с незначительными изменениями. Этот код находится в отдельном от твоих модулей (lister-my.py) и модель в котором он заключен содержит такие функции из твоей реализации: get_input_plugins, get_output_plugins, usage и пр.

..bw
Фактически, ты предлагаешь делать руками то, что делает easy_install автоматом. Причем easy_install делает это один раз, а ты предлагаешь это делать при каждом старте программы. Я вполне могу назвать данную операцию "установкой".

Я знаю об одном исключении (т.е. egg представляющий плагины может быть не установлен), но для этого нужно, чтобы ты находился внутри egg'а (пример -- Pylons-проект добавляет к paster команду controller, но только внутри проекта). И ради упрощения статьи я таки не стал упоминать это исключение.