Как стать автором
Обновить
113.11
Magnit Tech
Соединяем IT и ретейл

Создаем свой диалект змеиного, или DSL на Python

Уровень сложностиПростой
Время на прочтение5 мин
Количество просмотров2.8K

Допустим, у нас есть некоторое исполнительное ядро и множество пользователей, владеющих 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: пользователи обязательно скажут за это спасибо.

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

Теги:
Хабы:
+16
Комментарии5

Публикации

Информация

Сайт
magnit.tech
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия