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

Мы, как разработчики ядра, хотим, с одной стороны, спрятать всё «грязное белье» за неким интерфейсом, с другой — максимально упростить взаимодействие пользователей с ядром.

Как один из вариантов решения предлагаю посмотреть создание своего диалекта Python-скриптов, предназначенного для конкретной предметной области. Этакий DSL «для бедных», с синтаксисом Python, но со средой выполнения, заточенной под выполняемые задачи.

Я — Первушин Дмитрий, разработчик в управлении по развитию бэк-офиса торговых точек сети «Магнит». Основной стек – Python, Firebird, немного HTML/JS и капелька других технологий. Моя команда занимается разработкой приложений для терминалов сбора данных, отчетов, АРМ торговых точек. В этот раз хочу рассказать, как встроить пользовательские сценарии в приложение на Python.

Доступная палитра

Навскидку видно как минимум три варианта:

  • обычный импорт;

  • инъекция зависимостей через фабричный метод или класс;

  • инъекция зависимостей напрямую в пространство имен.

Обычный импорт

Скрипт забирает объекты из ядра
Скрипт забирает объекты из ядра

Eсли ничего не изобретать, а использовать ядро, как обычный Python-пакет, то получим что-то типа:

# Импорт в скрипт необходимых объектов из ядра
from exec_core import reader, writer

# Используем объекты ядра для реализации бизнес-логики
x = reader()
writer(x / 0.87)

В этом подходе сразу три жирных минуса:

  • Мы привязываемся к конкретной реализации сервиса, что допустимо только в случае, если она единственная и неизменяемая.

  • Пользователь вынужден каждый раз повторять заклинание импорта.

  • Пользователь должен знать устройство ядра и постоянно использовать это знание, что приводит к трудностям при рефакторинге ядра.

Инъекция зависимостей через фабричный метод или класс

Ядро поставляет скрипту реализации интерфейсов
Ядро поставляет скрипту реализации интерфейсов

Можно сделать «магический» объект, которому ядро будет поставлять реализации абстрактных интерфейсов, тогда получится примерно так:

def entry_point(reader, writer):
    """Ядро импортирует скрипт и вызовет эту функцию с конкретными реализациями сервисов."""
    x = reader()
    writer(x / 0.87)

Тут мы избавились от импорта и привязки к реализации: reader может читать откуда угодно, не требуя изменения скрипта. Минус два минуса. Но другие минусы все еще с нами:

  • Ядро должно активно использовать интроспекцию, чтобы правильно вызвать функцию пользователя.

  • Пользователь должен знать сигнатуры магической функции.

Инъекция зависимостей глобальное пространство имен

Ядро поставляет реализации, скрипт использует через пространство имен
Ядро поставляет реализации, скрипт использует через пространство имен

Если инъекцию зависимостей сделать через глобальное пространство имен, то получим такой скрипт:

"""При импорте ядро производит инъекцию в глобальные переменные,
со стороны скрипта никаких действий не требуется"""

x = reader()
writer(x / 0.87)

Почти идеально. Список доступных сервисов можно получить стандартной функцией dir, помощь – help, кроме того, ядро может заменить эти функции своей реализацией, например, для открытия справки в браузере, а не печати в консоли.

Реализация движка

Скелет

Сердцем скриптового движка будут встроенные функции compile и exec. Для начала нужно из текстового вида скрипта получить «объект кода» — скрипт, разобранный до состояния байт-кода. Делаем это вызовом compiled = compile (body, script_file_name, "exec", dont_inherit=True), где:

  • body – строка с исходным текстом скрипта.

  • script_file_name – путь к файлу скрипта. Если указать реальный файл, то в качестве бонуса получите понятные стектрейсы и поддержку отладки – точки останова, пошаговое выполнение.

  • "exec" – режим компиляции «у нас много операторов». Альтернатива "eval" – «у нас единственное выражение» — в данном случае не подходит, хотя кому-то может пригодиться.

  • dont_inherit – не наследовать «настройки будущего» (from __future__ import …) из модуля ядра. Оставим это на усмотрение автора скрипта.

У нас есть код, теперь нужно окружение, в котором этот код будет выполняться. Окружение обязано быть обычным словарем, никакие другие типы не допускаются. Заполняем его методами ядра, доступными из скрипта, и координатами исходников:

runtime={
    "__file__": script_file_name,
    "__name__": os.path.splitext(os.path.basename(script_file_name))[0],
    "reader": lambda : float(input("Enter a number, please:")),
    "writer": print,
}

Если не указать ключ "__builtins__", то под этим ключом будет автоматически добавлен модуль builtins.

И финальный аккорд – выполнение: exec(compiled, runtime)

В принципе, этот минимальный скелет уже вполне работоспособен, но для реального использования его стоит дополнить:

  • логированием и обработкой ошибок;

  • возможностью импорта других скриптов.

С логированием и ошибками всё тривиально, однако для импорта добавлю пару замечаний.

Соберем всё вместе. Ну примерно.
def script_exec(script, rtl: AbstractRTL):
    """Кусочек из реального проекта. Выполняет пользовательский код,
    использующий сервисы ядра"""
    wd_old = os.getcwd()
    wd = os.path.dirname(script.name) or wd_old
    if wd != wd_old:
        os.chdir(wd)
    sys.path.insert(0, wd)
    getLogger(__name__).info(f"Трансляция скрипта {script.name} начата")
    try:
        body = script.read()
        compiled = compile(body, script.name, "exec", dont_inherit=True)

        global_dict = {
            k: getattr(rtl, k)
            for k in (set(AbstractRTL.__dict__) | set(rtl.__class__.__dict__) | set(rtl.__dict__))
            if not k.startswith("_")
        }
        global_dict.update(__file__=script.name, __name__=os.path.splitext(os.path.basename(script.name))[0])
        exec(compiled, global_dict)
        global_dict["end"]()
    except Exception:
        getLogger(__name__).exception(f"Трансляция скрипта {script.name} провалилась")
        raise
    else:
        getLogger(__name__).info(f"Трансляция скрипта {script.name} завершена успешно")
    finally:
        sys.path.pop(0)
        os.chdir(wd_old)
    return

Импорт в скриптах

Для скриптов-файлов нужно добавить каталог скриптов в список «путей импорта» sys.path и сменить текущий каталог.

Для скриптов, загружаемых из БД, сети или генератора случайных чисел придется менять механизмы импорта. Работа с этими механизмами – отдельное захватывающее приключение, поэтому интересующиеся могут заглянуть в пакет importlib стандартной библиотеки.

Безопасность

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

Заключение

С помощью показанной технологии вы можете не только значительно упростить и ускорить написание скриптов для работы с вашим приложением, но и усложнить жизнь пользователей: функционал появляется в скриптах «магическим», невидимым пользователям образом, удивляя и пугая их. Для более комфортной работы со скриптами не забывайте документировать доступные возможности. Например, используйте привычную для Python связку dir + help + docstring: пользователи обязательно скажут за это спасибо.

На этом всё. Желаю всем поменьше багов и побольше довольных пользователей.

Комментарии (5)


  1. Vindicar
    20.04.2024 09:09
    +6

    Я бы поспорил, что первый вариант лучше.

    • Мы привязываемся к конкретной реализации сервиса, что допустимо только в случае, если она единственная и неизменяемая.

    • Пользователь вынужден каждый раз повторять заклинание импорта.

    • Пользователь должен знать устройство ядра и постоянно использовать это знание, что приводит к трудностям при рефакторинге ядра.

    1. Совершенно не факт. Никто не мешает начать экспортировать из ядра новую реализацию, до тех пор, пока эта реализация не нарушает контракт предыдущей. А если нарушает, то ни один из вариантов не поможет, придётся переписывать пользовательский код.

    2. Мы можем по импортам сразу понять, затронут ли пользовательский код изменениями в ядре. Вариант с инъекцией в глобальное пространство имён по сути эквивалентен from core import *.

    3. Пользователь должен знать видимый контракт ядра, а не его реализацию. И он должен знать контракт независимо от способа импорта. Не зная контракта, он не сможет пользоваться функциями ядра.

    4. Ну и до кучи, только первый вариант позволяет удобно делить функции ядра на отдельные пространства имён.


    1. hkm2 Автор
      20.04.2024 09:09
      +3

      1. Не новую, а другую. Если рассмотреть этот пример, то reader может читать с консоли, из файла, из сети, из БД, из хрустального шара. Cоответвственно, нужно менять пользовательский скрипт, либо иметь набор скриптов для каждой реализации reader.

      2. Мы не видим пользовательские скрипты. Пользователи используют их в своих процессах, а мы им поставляем только некий сервис/приложение по обработке скриптов. Плюс пользователи не будут лезть в исходники ядра и читать release notes тоже вряд-ли станут. Если у вас сотни объектов в ядре, то показанный подход, как и from core import *, вам скорее всего не подходит.

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

      4. В показанном подходе функции ядра и функции скрипта никак не фиксируются по иерархии и именам. Например, вместо reader и writer скрипту можно показать IO с методами read и write, причем собирать этот IO перед запуском скрипта из чего угодно с подходящими сигнатурами


      1. Vindicar
        20.04.2024 09:09

        1. Тогда я не вполне понял, в чём преимущество. Либо пользователю надо знать, с какой имплементацией он работает, и тогда он будет явно обращаться к core.crystal_ball_reader, либо мы этот выбор делаем за него заранее, и тогда внутри core/__init__.py у нас будет строка вида from .readers import crystal_ball_reader as reader . Вариант с инжекцией в глобальное пространство имён, по сути, эквивалентен второму варианту, и не позволяет реализовать первый вариант без дополнительного уровня абстракции. Единственное, что мне приходит в голову, это ситуация, когда разные скрипты одного пользователя должны получать разные имплементации одного сервиса в зависимости от внешних факторов, что-то типа паттерна стратегия наоборот. Тогда логику выбора имплементации будет очень неудобно размещать в __init__.py, в отличие от предлагаемого вами подхода.

        2. Если в API ядра не будет обратно-несовместимых изменений, то любой подход сойдёт. Если будут, пользователям придётся обновлять свои скрипты и придётся читать release notes - и тут будет проще найти те из них, в которых используется устаревшее API. Кроме того, "мы не видим скрипты пользователей" - это всё же перебор. Вы их видите, так как вы их исполняете. А потому можете проверить импорты и послать пользователю оповещение "вы используете API, которое мы сломаем через неделю, пните свой отдел разработки, чтобы они вовремя обновили скрипт". Это, наверно, можно реализовать и в случае инжекции, но не так легко, я полагаю.

        3. Не спорю. Мой аргумент был, что пользователю ни в одном из приведённых решений не требуется "знать устройство ядра и постоянно использовать это знание". А потому это не аргумент в пользу выбранного варианта (как и любого другого, впрочем).

        4. Можно и так, да. Но модули уже предлагают готовый естественный способ группировки функций, не имеющих общего состояния, так что если у пользователя есть доступ к модулю (как, скажем, при импорте), группировка в неймспейс уже реализована без нужды в отдельном классе. Хотя соглашусь, это не очень большое преимущество.


        1. hkm2 Автор
          20.04.2024 09:09
          +2

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

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

          1. модули уже предлагают готовый естественный способ модули все-таки ориентированы не на интерфейсы, а на реализацию. Для работы с альтернативным reader-ом без изменений скрипта придется влезать руками в пространство имен модуля, что по-моему сильно сложнее инъекции в скрипт.


          1. Vindicar
            20.04.2024 09:09
            +1

            Хммм. Ну ладно, может, я просто не очень чётко представляю набор требований к такой системе. Спасибо за дискуссию. =)