Для описания объектов и процессов в терминах бизнес-логики, конфигурирования и определения структуры и логики в сложных системах популярным подходом является использование предметно-специфических языков (Domain Specific Language - DSL), которые реализуются либо через синтаксические особенности языка программирования (например, с использованием средств метапрограммирования, аннотаций/декораторов, переопределения операторов и создания инфиксных операторов, как например в Kotlin DSL) или с помощью применения специализированных инструментов разработки и компиляторов (например, Jetbrains MPS или парсеров общего назначения, таких как ANTLR или Bison). Но существует также подход реализации DSL, основанный на синтаксическом разборе и одновременной кодогенерации для создания исполняемого кода по описанию и в этой статье мы рассмотрим некоторые примеры использования библиотеки textx для создания DSL на Python.

textX - это инструмент для создания языковых моделей (DSL) на Python. Он позволяет быстро и легко определить грамматику языка и сгенерировать парсер для этого языка. textX распространяется с открытым исходным кодом, легко интегрируется с другими инструментами Python и может быть использован в различных проектах, где необходимо определять и обрабатывать языки на основе текста.

С помощью textX можно определять различные типы языковых конструкций, такие как ключевые слова, идентификаторы, числа, строки и т. д., а также определять их свойства, например, типы данных или ограничения на значения. Определение грамматики языка происходит в текстовом файле в формате, который называется meta-DSL (и зарегистрован как язык tx, связанный с маской файлов *.tx). Метамодель используется для проверки синтаксической корректности DSL-модели и позволяет сформировать дерево объектов для использования в кодогенераторе Python (который может сформировать как другой Python-код, так и исходный текст на любом другом языке программирования и даже сделать PDF-документ или создать dot-файл для graphviz с целью визуализации мета-модели).

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

Для использования textX прежде всего установим необходимые модули:

pip install textX click

После установки появится консольная утилита textx, которая будет использоваться для проверки корректности метамоделей и DSL-модулей (в соответствии с грамматикой языка). Textx использует setuptools для поиска зарегистрированных компонентов и позволяет расширять свои возможности через добавление языков (список может быть просмотрен через textx list-languages) и подключение генераторов (textx list-generators). Расширения могут быть установлены как модули через pip install, например:

  • textx-jinja - использование шаблонизатора jinja для преобразования DSL-модели в текстовый документ (например, в HTML)

  • textx-lang-questionnaire - DSL для определения опросников

  • PDF-Generator-with-TextX - генератор PDF на основе DSL-описания

Мы будем создавать свой язык без использования дополнительных расширений, чтобы увидеть весь процесс. Начнем с простой задачи интерпретатора текстового файла, состоящего из строк "hello ", который будет генерировать выполняемый код на Python для отображения адресных приветствий. Генератор и описание языка будем создавать в пакете hello и также определим файл setup.py для конфигурирования точек входа в извлечение метамодели для определяемого языка и генератора кода.

Описание (hello.tx) может выглядеть следующим образом:

DSL:
  hello*=Hello
;

Hello:
  'Hello' Name
;

Name:
  name=/[A-Za-z\ 0-9]+/
;

В определении используются условные обозначения:

  • hello*=Hello - перечисление из нескольких элементов (Hello), могут отсутствовать (будут собираться в список hello)

  • hello+=Hello - один или несколько элементов

  • hello?=Hello - элемент может присутствовать, но необязательно (в модели будет None)

  • hello=Hello - точно один элемент

Также определение может включать в себя строковые константы, регулярные выражения, их группировки (например, через вертикальную черту обозначается выбор одного из значений) с модификаторами (+, ?, * имеют обычный смысл как для регулярных выражений, # подразумевает возможный произвольный порядок определений).

Создадим определение языка в hello/__init__.py:

import os.path
from os.path import dirname

from textx import language, metamodel_from_file


@language('hello', '*.hello')
def hello():
    """Sample hello language"""
    return metamodel_from_file(os.path.join(dirname(__file__), 'hello.tx'))

Здесь мы определяем новый язык с идентификатором hello, который будет применяться к любым файлам с маской имени *.hello. Добавим определение setup.py:

from setuptools import setup, find_packages

setup(
    name='hello',
    packages=find_packages(),
    package_data={"": ["*.tx"]},
    version='0.0.1',
    install_requires=["textx_ls_core"],
    description='Hello language',
    entry_points={
        'textx_languages': [
            'hello = hello:hello'
        ]
    }
)

И установим наш модуль:

python setup.py install

И проверим наличие установленного языка:

textx list-languages   
textX (*.tx)                  textX[3.1.1]                            A meta-language for language definition
hello (*.hello)               hello[0.0.1]                            Sample hello language

Теперь создадим тестовую модель, основанную на грамматике (test.hello):

Hello World
Hello Universe

Проверим корректность метамодели и нашей DSL-модели (на соответствие метамодели):

textx check hello/hello.tx
hello/hello.tx: OK.
textx check test.hello    
test.hello: OK.

Мы можем получить визуальную диаграмму для описания композиции DSL (может быть создано описание для Graphviz или для PlantUML):

textx generate hello/hello.tx --target dot
dot -Tpng -O hello/hello.dot

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

from textx import metamodel_from_file

metamodel = metamodel_from_file('hello/hello.tx')
dsl = metamodel.model_from_file('test.hello')
for entry in dsl.hello:
    print(entry.name)

Здесь мы увидим список имен (World, Universe), которые относятся к термам Hello (сами термы будут доступны через список hello в корневом объекте разобранной модели). Добавим теперь возможность кодогенерации, для этого добавим функцию с аннотацией @generator:

@generator('hello', 'python')
def python(metamodel, model, output_path, overwrite, debug, **custom):
    """Generate python code"""
    if output_path is None:
        output_path = dirname(__file__)
    with open(output_path + "/hello.py", "wt") as p:
        for entry in model.hello:
            p.write(f"print('Generated Hello {entry.name}')\n")
    print(f"Generated file: {output_path}/hello.py")

Генератор создается для языка hello и будет доступен как target python. Добавим в entry_points регистрацию в setup.py:

'textx_generators': [
            'python = hello:python'
        ]

И теперь выполним генерацию кода:

textx generate test.hello --target python

Результат генерации будет выглядеть так:

print('Generated Hello World')
print('Generated Hello Universe')

Теперь немного усложним задачу и реализуем конечный автомат. Определение DSL для конечного автомата может выглядеть так:

states {
  RED,
  YELLOW,
  RED_WITH_YELLOW,
  GREEN,
  BLINKING_GREEN
}

transitions {
  RED -> RED_WITH_YELLOW (on1)
  RED_WITH_YELLOW -> GREEN (on2)
  GREEN -> BLINKING_GREEN (off1)
  BLINKING_GREEN -> YELLOW (off2)
  YELLOW -> RED (off3)
}

Здесь мы перечисляем возможные состояния и переходы между ними (с идентификаторами переходов). Для определения грамматики можно использовать такое описание:

StateMachine:
  'states' '{'
    states+=State
  '}'
  'transitions' '{'
    transitions+=Transition
  '}'
;

State:
  id=ID (',')?
;

TransitionName:
  /[A-Za-z0-9]+/
;

Transition:
  from=ID '->' to=ID '(' name=TransitionName ')'
;

Здесь используется модификатор необязательности для запятой после идентификатора события. Созданную модель можно использовать непосредственно, через применение метамодели DSL к описанию состояний светофора:

from textx import metamodel_from_file

metamodel = metamodel_from_file('statemachine/statemachine.tx')
dsl = metamodel.model_from_file('test.sm')


# описание перехода
class Transition:
    state_from: str
    state_to: str
    action: str

    def __init__(self, state_from, state_to, action):
        self.state_from = state_from
        self.state_to = state_to
        self.action = action

    def __repr__(self):
        return f"{self.state_from} -> {self.state_to} on {self.action}"


states = map(lambda state: state.id, dsl.states)
transitions = map(lambda transition: Transition(transition.__getattribute__('from'), transition.to, transition.name),
                  dsl.transitions)
# извлекаем название действий (для меню)
actions = list(map(lambda t: t.action, transitions))

И теперь реализуем конечный автомат:

current_state = states[0]

while True:
    print(f'Current state is {current_state}')
    print('0. Exit')
    for p, a in enumerate(actions):
        print(f'{p + 1}. {a}')
    i = int(input('Select option: '))
    if i == 0:
        break
    if 0 < i <= len(actions):
        a = actions[i-1]
        for t in transitions:
            if t.state_from == current_state and t.action == a:
                current_state = t.state_to
                break

Альтернативным решением может быть генерация функции перехода на основе DSL, которая будет принимать enum-значение из пространства состояний и переход (из списка возможных) и возвращает новое состояние или исключение, если такой переход не реализован.

Аналогично может быть реализовано более сложная грамматика как для представления данных, так и для описания алгоритмических конструкций языка (таких как ветвления или циклы).

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

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