Итак, всех приветствую.
Я думаю, что практически каждый, кто программирует на python достаточно долго, хотел сделать так, чтобы любой сторонний разработчик смог добавить функционал в Ваше приложение без изменения его исходного кода. Поэтому, я хочу сделать гайд для всех новичков - как сделать систему плагинов для программы. Начнем.
А начнем мы с того, что установим importlib в ваше виртуальное окружение.
pip install importlib
Если установка прошла успешно, движемся дальше.
Теперь напишем небольшое приложение, которое будет считывать и выполнять команду пользователя:
import importlib
while True:
command = str(input(">>> "))
if command == "hello":
print("Hello, world!")
elif command == "help":
print("hello - displays Hello, world!")
else:
print(f"Unknown command: {command}")
Если мы запустим скрипт и выполним несколько команд, это будет выглядеть примерно так;
>>> help
hello - displays Hello, world!
>>> hello
Hello, world!
>>> wewewewew
Unknown command: wewewewew
>>>
Отлично, все работает. Теперь немного погрузимся в теорию и подумаем, как эта система может работать. Я придумал такой вариант:
Программа ищет все папки в папке plugins
Программа поочередно пытается открыть файл с информацией о плагине из каждой папки
Прочитав конфигурационный файл, программа попытается импортировать из указанного файла указанный класс
С эти разобрались. Теперь попробуем применить теорию на практике. Сделаем перед основным циклом перебор всех папок в папке plugins. Это можно сделать списочным выражением:
import os
plugins_dirs = [name for name in os.listdir("./plugins") if os.path.isdir(os.path.join("./plugins", name))]
или с помощью простого цикла (Что в принципе одно и тоже):
import os
plugins_dirs = []
for name in os.listdir("./plugins"):
if os.path.isdir(os.path.join("./plugins", name)):
plugins_dirs.append(name)
print(plugins_dirs)
Я выберу второй вариант, т.к. он более читаемый и понятный для человека.
Немного изменим цикл, чтобы он не добавлял папки в список, но читал конфигурационный файл в каждой из папок (Если он там, конечно, есть) и выводил результат на печать:
import os
import json
for name in os.listdir("./plugins"):
if os.path.isdir(os.path.join("./plugins", name)):
with open(f"./plugins/{name}/metadata.json") as f:
plugin_data = json.load(f)
print(plugin_data)
Пока что не будем запускать код, а создадим свой первый плагин. Просто в папке plugins создаем любую папку. Я назвал ее Test_plugin. Структура в ней должна быть следующая:
+ Test_plugin
|
+---- metadata.json
|
+---- plugin.py
Содержимое plugin.py пока пустое, а metadata.json такое:
{
"Plugin_name": "TestPlugin",
"Plugin_file": "plugin",
"Plugin_main_class": "Plugin_class"
}
В этом файле будет храниться следующая информация о плагине:
Plugin_name - Имя плагина. Может быть любым
Plugin_file - Путь к основному файлу плагина (Без .py в конце)
Plugin_main_class - Основной класс плагина, содержащийся в файле Plugin_file
Теперь можем запустить наш основной скрипт и увидеть что-то подобное:
{'Plugin_name': 'TestPlugin', 'Plugin_file': 'plugin', 'Plugin_main_class': 'Plugin_class'}
>>>
Отлично, это означает, что мы успешно загрузили данные плагина в приложение.
С этого момента начинаются трудности
Сейчас нам нужно как-то импортировать из файла с плагином класс. Как же это сделать если мы понятия не имеем о том, какие плагины загрузит пользователь? Тут-то нам и придет на помощь importlib. Сейчас код должен выглядеть примерно так:
import importlib
import os
import json
for name in os.listdir("./plugins"):
if os.path.isdir(os.path.join("./plugins", name)):
with open(f"./plugins/{name}/metadata.json") as f:
plugin_data = json.load(f)
print(plugin_data)
while True:
command = str(input(">>> "))
if command == "hello":
print("Hello, world!")
elif command == "help":
print("hello - displays Hello, world!")
else:
print(f"Unknown command: {command}")
Заменим первую строчку на
from importlib import __import__
Данная функция поможет импортировать файлы, название и путь к которым мы не знаем на этапе разработки приложения. Добавим в наш цикл перебора плагинов строку
imported = __import__(f"plugins.{name}.{plugin_data['Plugin_file']}")
Она обозначает, что нам нужно импортировать файл plugins.Имя_папки_с_плагином.Имя_файла_класса_из_конфига !(ОБЯЗАТЕЛЬНО ЧЕРЕЗ ТОЧКИ)! Посмотрим, что мы импортировали, обернув это в print(dir()).
print(dir(imported))
Добавим такой код в plugin.py:
class Plugin_class:
def __init__(self):
pass
def test_func(self, a, b):
return a + b
Сделав это и запустив главный файл, мы получим примерно такое:
['Test_plugin', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__path__', '__spec__']
Если вы сделали все правильно, то тогда где-то в списке будет имя вашей папки с плагином. Попробуем получить ее атрибут с файлом плагина с помощью:
print(dir(getattr(imported, name)))
Увидим следующее:
['__doc__', '__file__', '__loader__', '__name__', '__package__', '__path__', '__spec__', 'plugin']
И чтобы уже наконец-то добраться до заветного класса с плагином добавим еще немного букв:
print(dir(getattr(getattr(getattr(imported, name), plugin_data['Plugin_file']), plugin_data['Plugin_main_class'])))
Да - да, я знаю что тут больше 121 символа в строке, и что вы мне сделаете? Выполнив этот код, мы увидим все функции нашего класса. У меня это:
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'test_func']
Здесь мы видим __init__ и test_func, которые, как мы помним, мы определяли в классе Plugin_class. На остальное можно не обращать внимания - это встроенные функции и они есть у всех классов python по умолчанию. Попробуем вместо print и dir просто инициализировать этот класс и вызвать у него test_func, записав значение которое он вернет в var. Делается это так:
var = (getattr(getattr(getattr(imported, name), plugin_data['Plugin_file']), plugin_data['Plugin_main_class']))().test_func(150, 150)
print(var)
Видим следующий вывод и радуемся жизни:
300
>>>
Это конечно все хорошо, но как нам дать пользователю возможность взаимодействовать с плагином? Все достаточно просто: нам нужно лишь чуть - чуть переписать обработчик функций. Сейчас он выглядит так
from importlib import __import__
import os
import json
for name in os.listdir("./plugins"):
if os.path.isdir(os.path.join("./plugins", name)):
with open(f"./plugins/{name}/metadata.json") as f:
plugin_data = json.load(f)
print(plugin_data)
var = (getattr(getattr(getattr(imported, name), plugin_data['Plugin_file']), plugin_data['Plugin_main_class']))().test_func(150, 150)
print(var)
while True:
command = str(input(">>> "))
if command == "hello":
print("Hello, world!")
elif command == "help":
print("hello - displays Hello, world!")
else:
print(f"Unknown command: {command}")
Мы же сделаем так:
from importlib import __import__
import os
import json
compiled_plugins = {}
for name in os.listdir("./plugins"):
if os.path.isdir(os.path.join("./plugins", name)):
with open(f"./plugins/{name}/metadata.json") as f:
plugin_data = json.load(f)
print(plugin_data)
imported = __import__(f"plugins.{name}.{plugin_data['Plugin_file']}")
compiled_plugins[plugin_data['Plugin_name']] = (getattr(getattr(getattr(imported, name), plugin_data['Plugin_file']), plugin_data['Plugin_main_class']))()
while True:
command = str(input(">>> "))
if command == "hello":
print("Hello, world!")
elif command == "help":
print("hello - displays Hello, world!")
elif command.split()[0] in compiled_plugins.keys():
worker = compiled_plugins[command.split()[0]]
if len(command.split()) > 1:
worker.execute(command.split()[1:])
else:
print("Syntax error")
else:
print(f"Unknown command: {command}")
И добавим метод execute в класс нашего плагина:
def execute(self, com):
if com[0] == "test":
print("Hello from your first plugin!")
else:
print(f"Unknown options: {com}")
Ну, что ж, хочу вас поздравить: Запустив программу сейчас и написав, подставив вместо TestPlugin имя, указанное в json, в терминал это:
TestPlugin test
Вы получите это:
Hello from your first plugin!
В заключение хочу сказать, что то, что мы сейчас написали - это очень плохая система:
Нарушено много правил хорошего кода (pep-8), нет обработки исключений при загрузке и использовании, не предусмотрен конфликт имен плагинов, в общем, если вы хотите сделать что-то хоть немного рабочее, то учитывайте все эти факторы при написании кода. Надеюсь, что вам понравилась моя статья и вы оцените ее. Всем удачи!
Комментарии (2)
janvarev
07.07.2024 14:55+1Если кому нужно - я поддерживаю библиотеку Python, которая позволяет делать плагинные системы: https://github.com/janvarev/jaapy
Используется в голосовом помощнике Ирина (700+ звезд на Гитхабе) для плагинов, в OneRingTranslator (универсальный REST-интерфейс для разных переводчиков), и еще у меня кое-где. Полет нормальный, рекомендую.
Vindicar
Уже удивило. importlib - вообще-то встроенный модуль. Тот importlib, который лежит на pypi - клон модуля из второго питона, предназначенный для облегчения портирования старого кода.
Далее: почему os.path и ручное форматирование путей? Почему не pathlib?
Ну и закончили огрызком того, с чего стоило начать:
описать решаемый плагином круг задач (обязанности плагина)
описать интерфейс, по которому центральная программа будет обращаться к плагину
определить, какие ресурсы от центральной программы плагину потребуются
описать интерфейс, по которому центральная программа будет давать доступ к этим ресурсам
Это не только сделало бы более понятной решаемую задачу, но и повлияло бы на контроль корректности плагинов.
typing.Protocol
и@typing.runtimecheckable
для описания интерфейса взаимодействия,isinstance()
для проверки, что предполагаемый класс плагина соответствует этому интерфейсу.В общем, статья учит плохому.