Итак, всех приветствую.

Я думаю, что практически каждый, кто программирует на 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)


  1. Vindicar
    07.07.2024 14:55
    +3

    pip install importlib

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

    Далее: почему os.path и ручное форматирование путей? Почему не pathlib?

    Ну и закончили огрызком того, с чего стоило начать:

    • описать решаемый плагином круг задач (обязанности плагина)

    • описать интерфейс, по которому центральная программа будет обращаться к плагину

    • определить, какие ресурсы от центральной программы плагину потребуются

    • описать интерфейс, по которому центральная программа будет давать доступ к этим ресурсам

    Это не только сделало бы более понятной решаемую задачу, но и повлияло бы на контроль корректности плагинов. typing.Protocol и @typing.runtimecheckable для описания интерфейса взаимодействия, isinstance() для проверки, что предполагаемый класс плагина соответствует этому интерфейсу.

    В общем, статья учит плохому.


  1. janvarev
    07.07.2024 14:55
    +1

    Если кому нужно - я поддерживаю библиотеку Python, которая позволяет делать плагинные системы: https://github.com/janvarev/jaapy

    Используется в голосовом помощнике Ирина (700+ звезд на Гитхабе) для плагинов, в OneRingTranslator (универсальный REST-интерфейс для разных переводчиков), и еще у меня кое-где. Полет нормальный, рекомендую.