Плагинная архитектура на Python: entry points и динамическая загрузка
Два способа расширять приложение сторонним кодом: сканировать каталог с модулями или объявлять плагины через entry points в метаданных пакета. Разбираем, что выбрать и чем платишь за каждый вариант.

Вы пишете приложение, которое должны расширять другие люди: форматтеры, экспортёры, коннекторы к сервисам. Вопрос — как приложение найдёт чужой код, не зная о нём на этапе сборки. В Python сложились два ответа: сканирование каталога и entry points — записи в метаданных установленного пакета. Первый проще на старте, второй лучше живёт в проде, где плагины ставятся через pip. Разберём оба, покажем рабочий код и сравним по цене.
Всё ниже актуально для Python 3.9+ и стандартной библиотеки importlib.metadata. Для 3.9 есть нюанс по API — о нём отдельно. Внешних зависимостей вроде pluggy или stevedore не касаемся: сначала стоит понять, что делает база.
Два механизма и в чём разница
Плагин — это код, который приложение вызывает по общему контракту, но который живёт вне основного репозитория. Чтобы вызвать, его сначала нужно найти. Разница между подходами именно в этом — в способе обнаружения (discovery).
- Сканирование каталога. Приложение читает папку
plugins/, импортирует каждый.py-файл и ищет в нём нужный класс или функцию. Плагин — просто файл, который положили рядом. - Entry points. Плагин — это отдельный устанавливаемый пакет, который в своих метаданных объявляет: «я предоставляю такую-то точку расширения». Приложение спрашивает у системы установленных пакетов, кто её объявил.
Ключевое следствие: в первом случае вы отвечаете за импорт руками и держите папку. Во втором обнаружение делает pip при установке, а вы только читаете готовый список.
Entry points переносят вопрос «где лежат плагины» из вашего кода в метаданные пакетов. Приложение перестаёт знать пути на диске — оно знает только имя группы.
Общий контракт плагина
Оба подхода бессмысленны без договорённости, что именно ищем. Опишем контракт через Protocol — приложению не важен базовый класс, важны методы.
# contract.py — общий для приложения и плагинов
from typing import Protocol, runtime_checkable
@runtime_checkable
class Exporter(Protocol):
# человекочитаемое имя формата
name: str
# сериализует данные в байты
def export(self, data: dict) -> bytes:
...
Подход 1: сканирование каталога
Работает без установки: положил файл в папку — плагин подхватился. Хорош для настольного приложения, где пользователь сам кидает скрипты, или для быстрого прототипа.
# loader_dir.py
import importlib.util
import inspect
from pathlib import Path
from contract import Exporter
def load_from_dir(plugins_dir: Path) -> list[Exporter]:
found: list[Exporter] = []
for path in sorted(plugins_dir.glob("*.py")):
if path.name.startswith("_"):
continue # пропускаем служебные файлы
# загружаем модуль из файла по абсолютному пути
spec = importlib.util.spec_from_file_location(path.stem, path)
if spec is None or spec.loader is None:
continue
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # здесь выполняется код плагина
# ищем классы, удовлетворяющие протоколу
for _, obj in inspect.getmembers(module, inspect.isclass):
if obj.__module__ != module.__name__:
continue # пропускаем импортированное извне
if isinstance(obj(), Exporter):
found.append(obj())
return found
Проверка результата: положите в plugins/ файл с классом, у которого есть name и export, вызовите load_from_dir(Path("plugins")) — в списке появится экземпляр.
Цена подхода видна сразу. exec_module выполняет произвольный код из файла — любой .py в папке получает права вашего процесса. Порядок загрузки зависит от имён файлов. Ошибка импорта в одном плагине уронит цикл, если её не ловить. И конфликт имён: два плагина с классом Exporter в разных файлах — это два разных класса, но легко запутаться.
Подход 2: entry points
Плагин публикуется как пакет и в pyproject.toml объявляет группу. Приложение не знает ни путей, ни имён файлов — только имя группы, например myapp.exporters.
Как плагин себя объявляет
В пакете плагина, в его pyproject.toml:
# pyproject.toml пакета-плагина
[project]
name = "myapp-exporter-csv"
version = "0.1.0"
[project.entry-points."myapp.exporters"]
csv = "myapp_exporter_csv:CsvExporter"
Слева — имя записи (csv), справа — путь к объекту в формате модуль:атрибут. После pip install myapp-exporter-csv запись попадает в метаданные окружения.
Как приложение читает записи
# loader_ep.py — Python 3.10+
from importlib.metadata import entry_points
from contract import Exporter
def load_from_entry_points() -> list[Exporter]:
found: list[Exporter] = []
# выбираем записи только нужной группы
for ep in entry_points(group="myapp.exporters"):
obj = ep.load() # импортирует модуль и возвращает атрибут
instance = obj()
if isinstance(instance, Exporter):
found.append(instance)
return found
Проверка: pip install пакета-плагина в то же окружение, затем вызов функции — экземпляр CsvExporter окажется в списке, хотя приложение о нём нигде не упоминало.
Нюанс версии: 3.9 против 3.10+
API entry_points() менялся. В Python 3.9 функция возвращала dict, где ключ — имя группы, а параметра group= не было. В 3.10 добавили выборку через entry_points(group=...), а с 3.12 старый способ обращения как к словарю убрали. Если поддерживаете 3.9, для этой версии вызывайте entry_points() без аргументов и берите нужную группу из результата. Точное поведение вашей версии сверяйте в документации importlib.metadata — раздел про entry points.
import sys
from importlib.metadata import entry_points
if sys.version_info >= (3, 10):
eps = entry_points(group="myapp.exporters")
else:
# 3.9: результат — отображение группа -> список записей
eps = entry_points().get("myapp.exporters", [])
Сравнение подходов
| Критерий | Каталог | Entry points |
|---|---|---|
| Установка плагина | копирование файла | pip install пакета |
| Кто обнаруживает | ваш код | importlib.metadata |
| Версионирование, зависимости | нет | есть, через pip |
| Работает без установки | да | нет |
| Порядок загрузки | по именам файлов | не гарантирован |
| Изоляция чужого кода | нет | нет |
| Типичное применение | прототип, десктоп-скрипты | прод, распространяемое приложение |
Когда что выбирать
- Каталог — когда плагины пишете вы сами или доверенные пользователи, а установка через pip избыточна: CLI-утилита с папкой расширений, внутренний инструмент.
- Entry points — когда плагины распространяются отдельно, ставятся из PyPI или приватного индекса, имеют собственные зависимости и версии. Так работают расширения
pytest,flake8и большинство экосистемных инструментов.
Критерий простой: если плагину нужны свои зависимости и версия — это пакет, а значит entry points. Если это один файл без внешних импортов — каталог дешевле.
Что ломается и на что смотреть
Оба подхода имеют общие острые углы, о которых узнаёшь в проде, а не при первом запуске.
- Ошибка при загрузке плагина роняет цикл. Если
ep.load()илиexec_moduleбросают исключение (битый плагин, отсутствующая зависимость), падает всё обнаружение. Оборачивайте загрузку каждого плагина вtry/exceptи логируйте имя проблемного, а не только трейсбек. - Стоимость импорта. И
ep.load(), иexec_moduleисполняют код модуля плагина целиком. Если плагинов десятки и каждый тянет тяжёлый импорт, старт приложения растягивается. Загружайте лениво — по имени записи, когда плагин реально понадобился, а не все сразу. - Кеш метаданных.
importlib.metadataчитает метаданные окружения. Если вы установили плагин в уже запущенный процесс, новые записи в этом процессе не появятся без перезапуска или сброса кеша — в долгоживущих сервисах на это натыкаются. - Нет изоляции. Ни один из подходов не запускает чужой код в песочнице. Плагин выполняется с правами процесса и видит всё окружение. Если источник плагинов недоверенный — это архитектурная проблема, которую entry points не решают.
- Конфликт имён записей. Два установленных пакета могут объявить запись
csvв одной группе.entry_points()вернёт обе — решите заранее, что делать: первую побеждает, последняя, или ошибка.
Что делать дальше
Начните с контракта через Protocol — он один и тот же для обоих механизмов, и с него дешевле мигрировать с каталога на entry points, когда плагины перерастут в отдельные пакеты. Заверните загрузку каждого плагина в изоляцию по ошибкам и добавьте лог с именем плагина и версией пакета: в проде это первое, что спросят при разборе инцидента. Замерьте время старта с реальным числом плагинов — если оно заметное, переходите на ленивую загрузку по имени. Дальше стоит посмотреть на pluggy (используется в pytest) — он добавляет хуки и порядок вызова поверх того же механизма entry points.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: importlib.metadata — Python documentation, Entry points specification — Python Packaging User Guide
Частые вопросы
У меня Python 3.9, код с entry_points(group=...) падает. Что делать?
Плагин установлен через pip, но приложение его не видит. Почему?
Как защититься от плагина, который выполняет вредоносный код?
Что быстрее на старте — каталог или entry points?
Можно ли смешивать оба подхода в одном приложении?
Нужен ли pluggy или stevedore, или хватит стандартной библиотеки?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.