Допустим, у нас есть некоторое исполнительное ядро и множество пользователей, владеющих 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: пользователи обязательно скажут за это спасибо.
На этом всё. Желаю всем поменьше багов и побольше довольных пользователей.
ссылка на оригинал статьи https://habr.com/ru/articles/807621/
Добавить комментарий