Пишемо DSL на Python

19 хв. читання

Предметно-орієнтована мова програмування (DSL) спеціалізується на якійсь певній області застосування. Іншими словами, це така мова програмування, яка використовується для більш специфічних застосунків чи у випадках коли можливостей мови програмування загального призначення (наприклад Python) не вистачає.

Як приклад: регулярні вирази – DSL. Інший приклад широковживаної DSL є SQL. DSL використовується для вирішення складних завдань в специфічних нішах. За допомогою DSL також можна створювати й дуже прості рішення, що ми й будемо робити в цій статті.

Щоб мати уявлення про те, наскільки простими такі рішення можуть бути, поглянемо, як наша DSL на Python буде виглядати:

# Це коментар
module1 add 1 2
module2 sub 12 7
module1 print_results

За допомогою DSL, яку ви створите в цій статті, ви зможете викликати функції в Python і передавати їм аргументи, використовуючи синтаксис, що нагадає вам мову асемблера.

Пусті рядки чи рядки коментарів, що починаються з "#", будуть проігноровані так само як і в Python. Будь-які інші рядки мають починатися з ім'я модуля, потім має бути назва функції, за якою слідують аргументи, що розділені між собою пробілами.

Як ви побачите згодом, навіть проста мова програмування, як ця, може мати достатню гнучкість та бути скриптопридатною для ваших програм на Python.

Що ви дізнаєтесь з цієї статті

«Написання предметно-орієнтованої мови програмування » може звучати страхітливо, так, ніби на це здатні тільки високопрофесійні програмісти. Можливо ви не чули про DSL раніше. Чи може ви не впевнені, що це взагалі таке?

Тоді стаття саме для вас. Це доступно не тільки просунутим програмістам. DSL не має бути складною чи включати вивчення теоретичних основ синтаксичного аналізу та абстрактних синтаксичних дерев.

Ми збираємось написати просту DSL на Python, яка використовує інші вихідні файли Python для виконання якоїсь певної роботи. Це просто. Я хочу показати вам як легко можна використовувати Python для написання DSL і, згодом, адаптувати її для власних потреб у ваших проектах.

Навіть якщо у вас сьогодні не має потреби в DSL, ви зможете збагатити свій арсенал ідеями та методами, яких ви раніше не бачили. Ми розглянемо:

  • Динамічне імпортування модулів під час роботи застосунку
  • Використання getatttr() для доступу до атрибутів об'єкту
  • Використання функцій зі змінною довжиною аргументів та аргументів ключових слів
  • Конвертування рядків в інші типи даних

Визначення вашої власної мови програмування

Наша DSL буде виконувати певну роботу з використанням Python. Ця робота може бути довільного характеру. Тільки ви вирішуєте, що буде необхідно користувачу для успішного виконання ним свого завдання. Для користувачів нашої DSL нема потреби бути Python програмістами. Все що їм потрібно знати, це те, як зробити свою роботу за допомогою нашої DLS.

Треба дізнатися потреби користувачів, а потім реалізувати це все у вихідному DSL файлі. Тоді всі потенційні користувачі будуть забезпечені бібліотекою з необхідним функціоналом й командами та зможуть з легкістю використовувати нашу DSL.

Для написання DSL почнемо з найпростішої імплементації й поступово будемо додавати необхідний функціонал. Кожна версія вихідного файлу Python та DSL будуть мати однаковий суфікс версій в своїй назві.

Наша перша імплементація буде мати назву «dls1.py», «src1.py» «module1.py». Друга версія з додатковим функціоналом буде закінчуватися на «2» і так далі.

Ми маємо таку схему імен для наших файлів:

  1. «src1.dsl» вихідний DSL файл, який використовує користувач. Він не містить Python код, а є тільки код, що написаний на нашій DSL.
  2. «dsl1.py» вихідний Python файл, що містить імплементація нашої предметно-орієнтованої мови.
  3. «module1.py» містить Python код, який користувач може використовувати непрямо в нашій DSL.

Якщо у вас виникнуть проблеми, ви зможете знайти повний вихідний код цієї статті на GitHub.

DSL Версія 1 : Основи

Треба вирішити, що буде робити перша версія нашої DSL. Що найпростіше ми можемо зробити?

Для того щоб користувачі могли використовувати наш Python код, вони мають вказати назву модуля, функції та додати аргументи, які ця функція прийме. Отже, перша версія нашої DSL буде мати такий вигляд:

# src1.dsl
module1 add 1 2

Пусті рядки та коментарі, що починаються «#» ігноруються так само, як і в Python. Будь-які інші рядки мають починатися з назви модуля, потім необхідно буде вказати назву функції і вказати аргументи, розділивши їх за допомогою пробілів.

Завдяки Python можна легко, рядок за рядком, прочитати вихідний файл нашого DSL за допомогою методів рядка:

# dsl1.py

#!/usr/bin/env python3
import sys

# Вихідний файл є першим аргументом скрипту
if len(sys.argv) != 2:
    print('usage: %s <src.dsl>' % sys.argv[0])
    sys.exit(1)

with open(sys.argv[1], 'r') as file:
    for line in file:
        line = line.strip()
        if not line or line[0] == '#':
            continue
        parts = line.split()
        print(parts)

Результат роботи «dsl1.py» з командного рядка:

$ dsl1.py src1.dsl
['module1', 'add', '1', '2']

У випадку, якщо ви використовуєте macOS чи Linux на забудьте зробити «dsl1.py» виконуваним. Це дозволить вам запускати вашу програму як команду командного рядка.

Це можна зробити в терміналі, виконавши команду chmod +x dsl1.py. Для Windows це має працювати зі встановленим за замовчуванням Python. Якщо робота програми видає помилки, перегляньте, будь ласка, Python FAQ.

За допомогою лише декількох рядків коду, ми можемо отримати перелік токенів з рядка нашого вихідного файлу. Отримані значення в переліку являють собою назву модуля, функції та аргументи функції. І тепер, коли в нас є всі значення, ми легко можемо викликати функцію з нашого модуля та передати їй необхідні аргументи.

Імпорт Python модуля під час роботи програми

Тепер перед нами постає новий виклик. Як нам імпортувати Python модуль, якщо ми не знаємо його назву? Зазвичай, коли ми пишемо код, ми знаємо назву модуля, який нам потрібно імпортувати. Тому ми просто вводимо import module1.

Але з нашою DSL, ми маємо назву модуля лише як перший елемент в переліку значень рядка. Як же нам це зробити?

А відповідь така. Ми можемо використати importlib зі стандартної бібліотеки, щоб динамічно імпортувати модуль під час роботи. Додамо динамічний імпорт нашого модуля, додавши наступний рядок на початку «dsl1.py» одразу після import sys:

import importlib

Перед блоком with вам можливо знадобиться додати ще один рядок для того, щоб повідомити Python звідки імпортувати модулі:

sys.path.insert(0, '/Users/nathan/code/dsl/modules')

Рядок sys.path.insert() потрібен для того, щоб Python знав де шукати директорії, що містять наш модуль. Налаштуйте шлях, як це необхідно для вашої програми та збережіть.

Далі, в кінці файлу вставте наступні рядки коду:

mod = importlib.import_module(parts[0])
print(mod)

Після усіх цих змін «dsl1.py» буде виглядати наступним чином:

# dsl1.py -- Оновлено

#!/usr/bin/env python3
import sys
import importlib

# Вихідний файл є першим аргументом скрипт
if len(sys.argv) != 2:
    print('usage: %s <src.dsl>' % sys.argv[0])
    sys.exit(1)

sys.path.insert(0, '/Users/nathan/code/dsl/modules')

with open(sys.argv[1], 'r') as file:
    for line in file:
        line = line.strip()
        if not line or line[0] == '#':
            continue
        parts = line.split()
        print(parts)

        mod = importlib.import_module(parts[0])
        print(mod)

Тепер коли ми виконаємо «dsl1.py» з командного рядка знову, ми отримаємо ось такий результат:

$ dsl1.py src1.dsl
['module1', 'add', '1', '2']
<module 'module1' from '/Users/nathan/code/dsl/modules/module1.py'>

Чудово! Ми щойно динамічно імпортували Python модуль під час роботи програми, використавши при цьому importlib модуль зі стандартної бібліотеки.

Додаткові ресурси для вивчення imporlib

Якщо ви хочете більше дізнатися про бібліотеку importlib і про те, які можна отримати переваги від використання її у ваших програмах, перегляньте наступні ресурси:

Викликаємо код

Тепер, коли імпортували модуль динамічно та маємо посилання на місце, де наші модулі зберігаються у змінній mod, ми можемо викликати потрібну функцію з аргументами. В кінці «dsl1.py» додаймо ось такий рядок:

getattr(mod, parts[1])(parts[2], parts[3])

Це може виглядати досить дивно. Що ж тут відбувається?

Нам необхідно отримати посилання на об'єкт функції в модулі для виклику цієї функції. Ми можемо це зробити з використанням getattr посилання на модуль. І це буде працювати аналогічно import_module для динамічного отримання посилання на модуль.

Передавши наш модуль в getattr і назву функції, ми отримаємо посилання на об'єкт функції add. Після цього можна викликати необхідну функцію, використовуючи дужки передати їй аргументи (два останніх елементи списку).

Запам'ятайте, все в Python є об'єктом. Всі об'єкти мають атрибути. А отже, виходячи з цього, якщо ми завжди можемо отримати доступ до модуля динамічно, то за допомогою getattr ми також можемо отримати доступ і до атрибутів модуля. Для більш детальної інформації перегляньте розділ getattr в Python.

Поглянемо в «module1.py»:

# module1.py

def add(a, b):
    print(a + b)

Якщо ми виконаємо «dsl1.py src1.dsl» зараз, яким буде вивід програми? «3»? Перевірмо:

$ dsl1.py src1.dsl
['module1', 'add', '1', '2']
<module 'module1' from '/Users/nathan/code/dsl/modules/module1.py'>
12

Зачекайте, «12»? Як це сталося? Чи не має відповідь бути «3»?

Можна дуже легко щось пропустити та отримати не те що очікуєш. Це залежить від вашої програми. Аргументи нашої функції add виявились рядками. Таким чином Python слухняно склав їх і повернув нам рядок «12».

Перед нами виникає складніше питання. Як має наша DSL обробляти аргументи різних типів? Що якщо користувачу необхідно працювати лише з цілими числами?

Як варіант, можна створити дві функції add (add_str та add_int). Функція add_int буде конвертувати рядки в цілі числа:

print(int(a) + int(b))

Або користувач може сам визначати з яким типом даних він хоче працювати за допомогою додаткового аргументу:

module1 add int 1 2

Яке рішення ви будете реалізовувати у синтаксисі вашої DSL, залежить від потреб користувача та задач, які DSL буде вирішувати. Це ми продемонстрували на досить простому прикладі, проте динамічна природа Python робить його досить потужним інструментом.

Іншими словами, вбудовані особливості Python будуть вас супроводжувати досить довго, вам не потрібно буде багато писати свої нестандартні рішення. Ці можливості ми будемо досліджувати з вами під час написання версії нашої DSL.

Ви зможете знайти фінальну версію «dsl1.py» на GitHub.

DSL версія 2: Парсинг аргументів

Перейдемо до написання версії 2 і зробимо деякі речі більш загальними та гнучкими для наших користувачів. Замість того, щоб захардкодити аргументи, ми дозволимо їм передавати в якості аргументів будь яке число. Поглянемо на новий вихідний файл DSL:

# src2.dsl
module2 add_str foo bar baz debug=1 trace=0
module2 add_num 1 2 3 type=int
module2 add_num 1 2 3.0 type=float

Тепер напишемо функцію, що розподіляє аргументи DSL в список «args» та «kwargs» словник, таким чином, щоб можливо було передавати їх до функцій нашого модуля:

def get_args(dsl_args):
    """повертає args, kwargs"""
    args = []
    kwargs = {}
    for dsl_arg in dsl_args:
        if '=' in dsl_arg:
            k, v = dsl_arg.split('=', 1)
            kwargs[k] = v
        else:
            args.append(dsl_arg)
    return args, kwargs

Цю get_args функцію можна використовувати ось таким чином:

args, kwargs = get_args(parts[2:])
getattr(mod, parts[1])(*args, **kwargs)

Після виклику get_args, ми отримаємо перелік аргументів та словник ключових слів аргументів. Нам залишається змінити функції нашого модуля, таким чином, щоб вони могли приймати *args та **kwargs. Змінимо наш код, щоб він міг працювати з новими значеннями.

Всередині нашого модуля *args буде розглядатися як кортеж, а **kwargs – як словник. І ось новий узагальнений вигляд коду «module.py», який використовує ці нові значення:

# module2.py

def add_str(*args, **kwargs):
    kwargs_list = ['%s=%s' % (k, kwargs[k]) for k in kwargs]
    print(''.join(args), ','.join(kwargs_list))

def add_num(*args, **kwargs):
    t = globals()['__builtins__'][kwargs['type']]
    print(sum(map(t, args)))

В add_str, kwargs_list – список, який був створений за допомогою генератора списку. Якщо ви з цим раніше не стикались, генератор списку використовує зручний синтаксис виразів.

Ми просто проганяємо в циклі ключі словника (for k in kwargs) та створюємо рядок, що репрезентує кожну пару ключ-значення в словнику. Після цього виводимо результат об'єднання списку аргументів з пустим рядком та результат об'єднання списку ключових слів з «,»:

foobarbaz debug=1,trace=0

Ми вирішили додати більше можливостей користувачу під час використання функції add_num. Оскільки користувач тепер може додавати числа різних типів, нам тепер необхідно якось робити конвертацію рядків.

Для отримання словника з посиланнями на глобальні змінні Python необхідно викликати функцію globals(). Це дасть нам можливість отримати доступ до __builtins__ пар значень ключ – значення, що в свою чергу дасть нам доступ до конструкторів класів «int» та «float».

Це дозволяє користувачу визначати необхідний тип конвертації рядкових значень, що передаються у вихідному файлі нашої DSL «src2.dsl», наприклад: «type=int». Конвертація проводиться за один крок для всіх аргументів під час виклику функції map з наступним виводом суми значень.

map() бере функцію та за допомогою ітерацій викликає функцію для кожного зі значень в ітерації, фіксуючи при цьому вивід результатів. Це можна розглядати як перетворення послідовності значень в нові значення. Якщо це не достатньо зрозуміло і цього забагато для одного рядка, для більшої ясності розіб'ємо це на два рядки:

converted_types = map(t, args)  # t is class "int" or "float"
print(sum(converted_types))

Для рядків вихідного файлу DSL:

module2 add_num 1 2 3 type=int
module2 add_num 1 2 3.0 type=float

Ми отримаємо ось такий вивід:

6
6.0

Тепер наші користувачі можуть передавати будь-які числа в якості аргументів в наші функції. Що є особливо корисним на мою думку, так це використання **kwargs та словника аргументів ключових слів.

Користувачі можуть викликати наші функції з ключовими словами DSL, передавати параметри так, як це вони б робили, викликаючи програми Python з командного рядка. Ключові слова є також формою мікродокументації та слугує нагадуванням про можливості нашої DSL. Для досягнення кращих результатів спробуйте підбирати короткі та інформативні назви для ваших ключових слів.

Знову ж таки ви зможете знайти фінальну версію «dsl2.py» на GitHub.

DSL версія 3: Документація

Створімо ще одну корисну властивість для наших користувачів і створимо версію 3. Користувачі потребують документації. Їм необхідно вказати шлях для розуміння функціоналу бібліотек модулів.

Це ми можемо зробити додавши новий параметр командного рядка в «dsl3.py» та перевірку модулів та функцій на рядки документації. Рядки документації Python – рядкові літерали, що з'являються на першому рядку визначення модуля, функції, класу чи методу. Заведено використовувати потрійні лапки. Наприклад, ось так:

def function_name():
    """Корисний рядок документації."""
    # Тіло функції

Коли користувач передає «help=module3» в командному рядку до «dsl3.py», get_help функція буде викликатися разом з «module3»:

def get_help(module_name):
    mod = importlib.import_module(module_name)
    print(mod.__doc__ or '')
    for name in dir(mod):
        if not name.startswith('_'):
            attr = getattr(mod, name)
            print(attr.__name__)
            print(attr.__doc__ or '', '\
')

У функції get_help, модуль динамічно імпортується за допомогою import_module, як це ми вже робили раніше. А наступним кроком є перевірка присутності рядка документації за допомогою атрибута модуля __doc__.

Далі нам необхідно перевірити всі функції в модулі на наявність рядків документації. Для цього на потрібно використати вбудовану функцію «dir». Функція «dir» повертає перелік всіх назв атрибутів для об'єкта. Таким чином ми можемо з використання простого циклу пройти по всім назвам атрибутів в модулі, відфільтровуючи будь-які приватні чи спеціальні назви, що починаються з «__», та виводити назви функцій та рядки документацій, якщо вони існують.

Фінальна версія «dsl3.py» доступна на GitHub.

Пишемо DSL на Python – Огляд та Підсумки

Підсумуємо, що ми робили в цій статті. Ми створили просту DSL, що дозволяє користувачу легко вирішувати завдання, викликаючи бібліотечні функції. На щастя для нас, ми знаємо Python. Все що нам потрібно – імплементувати нашу DSL та зробити речі простішими.

DSL є потужним інструментом, над якими цікаво розмірковувати та працювати. Вони є іншим шляхом, що дозволяє бути нам креативними та вирішувати завдання, роблячи їх простішими для наших користувачів. Я сподіваюсь, що ця стаття наштовхнула вас на нові ідеї та речі, які ви зможете застосувати у вашому власному коді.

З точки зору користувача, тут просто використовуються «команди». З нашого боку ми отримуємо переваги динамічної природи Python, що, у свою чергу, дозволяє використовувати Python та його екосистему на повну потужність. Наприклад: ми можемо легко вносити зміни в бібліотечний модуль чи розширювати бібліотеки новими модулями для отримання нового функціоналу в стандартних бібліотеках чи бібліотеках від третіх сторін.

В цій статті ми розглянули декілька технік:

  • importlib.import_module(): динамічний імпорт модулів під час роботи програми
  • getattr(): отримання атрибутів об'єкта
  • Функції зі змінною довжиною параметрів та ключових слів
  • Конвертація рядків до різних типів

Використання цих технік є досить потужним. Я б радив вам приділити трохи часу і подумати, як можна розширити функціонал вашого коду. Це може бути просте додавання декількох рядків чи написання більш складних ділянок коду з використанням класів.

Використання importlib

Я б хотів написати, ще одну річ про використання «importlib». Інше використання та приклад динамічного імпорту за допомогою «importlib» є впровадження системи плагінів. Система плагінів є дуже популярною та широковживаною в усіх типах програмного забезпечення.

Через це система плагінів є методом, що дозволяє робити статичний застосунок гнучким та розширювати його. Якщо ви зацікавлення в поглибленні ваших знань, перегляньте відео «Система плагінів: динамічне завантаження модулів за допомогою importlib»

Перевірка на помилки

У цій статті ми пропустили перевірку на помилки. Це було зроблено з однією метою – зробити код в наших прикладах чистим та зрозумілим. Проте користувачі та програмісти на Python під час використання бібліотеки модулів можуть побачити інформацію про помилки, якщо такі трапляються.

Це може призвести до неочікуваної перевірки вашої програми. Подумайте проте, як правильно обробляти повідомлення про помилки, особливо про найбільш типові випадки.

Безпека

Беріть до уваги динамічну природу імпорту в коді, що працює, та вплив на безпеку вашого програмного середовища. Переконайтесь в тому, що тільки авторизовані користувачі можуть мати доступ до тек, де містяться вихідні файли та модулі. Наприклад: неавторизовані права запису до директорій модуля дозволять користувачам виконувати довільний код.

Python DSL: Наступні кроки

Куди далі рухатись? Що наступне? Ви можете подумати: « Що ж, виглядає гарно й все, але мені потрібно більше! Мені потрібна справжня DSL з реальним синтаксисом та ключовими словами».

Гарним кроком буде вивчення бібліотек парсингу у Python. Їх безліч! А їх функціонал, легкість використання та документація широко варіюється.

Якщо ви хотіли б використовувати код з цієї статті у своїх експериментах, то повний код доступний на GitHub.

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.9K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація