Что такое PLY?
PLY — это аббревиатура из первых букв выражения: Python Lex-Yacc.
Фактически, это порт утилит lex и yacc на python в красивой обертке.
Работать с ply очень просто и порог входа для начала использования практически нулевой.
Написан он на чистом питоне и представляет из себя LALR(1) парсер, но кому это интересно?
Я по натуре практик (как и большинсво из вас) поэтому пошли в бой!
Что будем делать?
На сайте есть пример написания очередного калькулятора, поэтому повторяться не будем. А сделаем что-то навроде парсера очень очень узкого подмножества PHP :)
Наша задача в конце статьи построить синтаксическое дерево для такого примера:
<?php
$val = 5;
$result = substr( "foobar", 2*(7-$val) );
echo "это наш результат: $result";
Пример очень маленький и взят с потолка. Но чтобы построить дерево кода нужно много и походу мы задействуем такой механизм PLY как state.
Lex
Lex — это штука, которая разбивает текст на базовые элементы языка. Ну или группирует текст в базовые элементы. Как-то так.
Что мы здесь видим, кроме бесполезного кода? Видим токены (базовые элементы):
PHP_START — '<?php'
PHP_VAR — '$result'
PHP_EQUAL — '='
PHP_NAME — 'substr'
PHP_OPEN — '('
PHP_STRING — «foobar», 'это наш результат: $result'
PHP_NUM — '2', '7'
PHP_CLOSE — ')'
PHP_ECHO — «echo»
PHP_COLON — ';'
PHP_COMA — ','
PHP_PLUSMINUS — '-'
PHP_DIVMUL — '*'
Для разбора текста на токены в PLY имеется ply.lex. И работает он с регулярными выражениями.
А еще он очень придирчивый к тому что мы пишем в коде. Он требует обязательного присутствия массива с именем tokens.
Для каждого элемента этого массива мы обязаны иметь в коде регулярку или функцию с именем t_ЭЛЕМЕНТ.
Почему он такой придирчивый? Потому что он не работает напрямую во время выполнения программы, а выполняется лишь единожды при первом запуске программы и создает файл lextab.py, в котором описаны таблицы переходов и регулярки. При следующем запуске программы он проверяет наличие этого файла и в случае его присутствия уже не пытается строить таблицы заново, а использует сгенерированные.
Назад к коду.
Если бы синтаксис PHP ограничивался тринадцатью выше перечисленными токенами, то мы бы написали следующее:
# coding=utf8
import ply.lex as lex
# без этой штуки ничего не сынтерпретируется, потому что этот массив шарится между лексером и парсером и кроме того используется внутренне библиотекой
tokens = (
'PHPSTART', 'PHPVAR', 'PHPEQUAL', 'PHPFUNC',
'PHPSTRING', 'PHPECHO', 'PHPCOLON', 'PHPCOMA',
'PHPOPEN', 'PHPCLOSE', 'PHPNUM', 'PLUSMINUS', 'DIVMUL'
)
# определим регулярку для абстрактного идетификатора
ident = r'[a-z]\w*'
# для каждого токена из массива мы должны написать его определение вида t_ИМЯТОКЕНА = регулярка
t_PHPSTART = r'\<\?php'
t_PHPVAR = r'\$'+ident # очень удобно, правда?
t_PHPEQUAL = r'\='
t_PHPFUNC = ident
t_PHPSTRING = r'"(\\.|[^"])*"'
t_PHPECHO = r'echo'
t_PHPCOLON = r';'
t_PHPCOMA = r','
t_PHPOPEN = r'\('
t_PHPCLOSE = r'\)'
t_PHPNUM = r'\d+'
t_PLUSMINUS = r'\+|\-'
t_DIVMUL = r'/|\*'
# здесь мы игнорируем незначащие символы. Нам ведь все равно, написано $var=$value или $var = $value
t_ignore = ' \r\n\t\f'
# а здесь мы обрабатываем ошибки. Кстати заметьте формат названия функции
def t_error(t):
print "Illegal character '%s'" % t.value[0]
t.lexer.skip(1)
lexer = lex.lex(reflags=re.UNICODE | re.DOTALL)
data = '''
<?php
$result = substr("foobar", "bar");
echo $result;
'''
lexer.input(data)
while True:
tok = lexer.token() # читаем следующий токен
if not tok: break # закончились печеньки
print tok
В комментарих к коду я примерно описал что у нас там творится. Замечу лишь, что вместо регулярок, определяющих токен, можно писать функции в которых будут эти же регулярки, но кроме этого можно будет туда вписать что-то еще свое. Например:
def t_PHPVAR(t):
r'\$[a-zA-Z]\w*'
print 'Урра, мы распарсили переменную ' + t.value # value - это то что нашлось регуляркой
return t
Кстати вывод примера, что написан выше будет таким:
LexToken(PHPSTART,'<?php',1,1) LexToken(PHPVAR,'$val',1,7) LexToken(PHPEQUAL,'=',1,12) LexToken(PHPNUM,'5',1,14) LexToken(PHPCOLON,';',1,15) LexToken(PHPVAR,'$result',1,17) LexToken(PHPEQUAL,'=',1,25) LexToken(PHPFUNC,'substr',1,27) LexToken(PHPOPEN,'(',1,33) LexToken(PHPSTRING,'"foobar"',1,35) LexToken(PHPCOMA,',',1,43) LexToken(PHPNUM,'2',1,45) LexToken(DIVMUL,'*',1,46) LexToken(PHPOPEN,'(',1,47) LexToken(PHPNUM,'7',1,48) LexToken(PLUSMINUS,'-',1,49) LexToken(PHPVAR,'$val',1,50) LexToken(PHPCLOSE,')',1,54) LexToken(PHPCLOSE,')',1,56) LexToken(PHPCOLON,';',1,57) LexToken(PHPFUNC,'echo',1,59) LexToken(PHPSTRING,'"\xd1\x8d\xd1\x82\xd0\xbe \xd0\xbd\xd0\xb0\xd1\x88 \xd1\x80\xd0\xb5\xd0\xb7\xd1\x83\xd0\xbb\xd1\x8c\xd1\x82\xd0\xb0\xd1\x82: $result"',1,64) LexToken(PHPCOLON,';',1,107)
Числа в конце — это номер строки и номер символа. Как видите, номер строки не изменяется. Его нужно менять самим. Как? При проходе токена с переходом на новую строку. Для этого уберем символ новой строки из игнорируемых токенов и добавим новую функцию t_newline:
t_ignore = ' \r\t\f'
def t_newline(t):
r'\n+'
t.lexer.lineno += len(t.value)
Вот теперь у нас все работает:
LexToken(PHPSTART,'<?php',2,1) LexToken(PHPVAR,'$val',3,7) LexToken(PHPEQUAL,'=',3,12) LexToken(PHPNUM,'5',3,14) LexToken(PHPCOLON,';',3,15) LexToken(PHPVAR,'$result',4,17) LexToken(PHPEQUAL,'=',4,25) LexToken(PHPFUNC,'substr',4,27) LexToken(PHPOPEN,'(',4,33) LexToken(PHPSTRING,'"foobar"',4,35) LexToken(PHPCOMA,',',4,43) LexToken(PHPNUM,'2',4,45) LexToken(DIVMUL,'*',4,46) LexToken(PHPOPEN,'(',4,47) LexToken(PHPNUM,'7',4,48) LexToken(PLUSMINUS,'-',4,49) LexToken(PHPVAR,'$val',4,50) LexToken(PHPCLOSE,')',4,54) LexToken(PHPCLOSE,')',4,56) LexToken(PHPCOLON,';',4,57) LexToken(PHPFUNC,'echo',5,59) LexToken(PHPSTRING,'"\xd1\x8d\xd1\x82\xd0\xbe \xd0\xbd\xd0\xb0\xd1\x88 \xd1\x80\xd0\xb5\xd0\xb7\xd1\x83\xd0\xbb\xd1\x8c\xd1\x82\xd0\xb0\xd1\x82: $result"',5,64) LexToken(PHPCOLON,';',5,107)
Работает, но не полностью. Наша строка «это наш результат: $result» возвращается лексером как есть. А как парсить её будем? Создавать отдельный лексер? Неа. В PLY (да и в сишном lex) есть поддержка такой штуки как state. State — это переключалка на другой тип токенов. Вот это мы и будем использовать.
State
Итак, для того чтобы создать новый state нам нужно его предварительно объявить:
states = (
('string','exclusive'),
)
'string' — это название нового состояния, а exclusive — это тип. Вообще, может быть два типа состояний: exclusive и inclusive. Exclusive — общих токенов не видно (общие, это те которые мы писали до этого, они по умолчанию находятся в состоянии INITIAL). Inclusive — видны общие токены и в текущем состоянии мы просто добавляем к ним дополнительные.
В случае нашей строки нам не нужны другие токены, так как внутри у нас там все по другому. Впрочем парочку мы позаимствуем. Нам нужен токен PHPVAR, потому что переменная у нас выглядит одинаково и снаружи и внутри строки, и нужен будет еще один токен, который вы увидите походу ниже.
Чтобы сделать токен общим мы должны присвоить ему префикс ANY_, ну а чтобы сделать токен видимым только для какого-то определенного состояния — <состояние>_. Ниже приведены измененные токены.
# для каждого токена из массива мы должны написать его определение вида t_ИМЯТОКЕНА = регулярка
t_PHPSTART = r'\<\?php'
t_ANY_PHPVAR = r'\$'+ident # очень удобно, правда?
t_PHPEQUAL = r'\='
t_PHPFUNC = ident
t_PHPECHO = r'echo'
t_PHPCOLON = r';'
t_PHPCOMA = r','
t_PHPOPEN = r'\('
t_PHPCLOSE = r'\)'
t_PHPNUM = r'\d+'
t_PLUSMINUS = r'\+|\-'
t_DIVMUL = r'/|\*'
# изменили токен PHPSTRING и сделали из него функцию, добавили его во все состояния
def t_ANY_PHPSTRING(t): # нужен в обоих состояниях, потому что двойные кавычки матчатся и там и там.
r'"'
if t.lexer.current_state() == 'string':
t.lexer.begin('INITIAL') # переходим в начальное состояние
else:
t.lexer.begin('string') # парсим строку
return t
t_string_STR = r'(\\.|[^$"])+' # парсим пока не дойдем до переменной или до кавычки, попутно игнорируем экранки
# говорим что ничего не будем игнорировать
t_string_ignore = '' # это кстати обязательная переменная, без неё нельзя создать новый state
# ну и куда же мы без обработки ошибок
def t_string_error(t):
print "Illegal character '%s'" % t.value[0]
t.lexer.skip(1)
Кстати, заметили это: LexToken(PHPFUNC,'echo',5,59)?
PLY перед построением таблицы сортирует регулярные выражения по возрастанию и в процессе парсинга побеждает длиннейшее. Вот поэтому echo и не парсится как PHPECHO. Как это обойти? Легко. Просто изменим тип возвращаемого токена в функции.
Вот так:
@TOKEN(ident)
def t_PHPFUNC(t):
if t.value.lower() == 'echo':
t.type = 'PHPECHO'
return t
Теперь echo возвращается как нужно. Кстати о декораторе TOKEN: вместо того, чтобы писать регулярку в начале тела функции, можно просто поместить её в переменную и применить к функции как декоратор. Что мы и сделали.
Вот. Теперь вроде все. Да не все.
Неплохо бы игнорировать комментарии.
Что же, добавим еще одну маленькую функцию в лексер:
def t_comment(t):
r'(/\*(.|\n)*?\*/)|(//.*)'
pass
Полный исходный код лексера (lexer.py)
# coding=utf8
import ply.lex as lex
from ply.lex import TOKEN
import re
states = (
('string','exclusive'),
)
# без этой штуки ничего не съинтерпретируется, потому что этот массив шарится между лексером и парсером и кроме того используется внутренне библиотекой
tokens = (
'PHPSTART', 'PHPVAR', 'PHPEQUAL', 'PHPFUNC',
'PHPSTRING', 'PHPECHO', 'PHPCOLON', 'PHPCOMA',
'PHPOPEN', 'PHPCLOSE', 'PHPNUM', 'PLUSMINUS', 'DIVMUL',
'STR'
)
# определим регулярку для абстрактного идетификатора
ident = r'[a-z]\w*'
# для каждого токена из массива мы должны написать его определение вида t_ИМЯТОКЕНА = регулярка
t_PHPSTART = r'\<\?php'
t_ANY_PHPVAR = r'\$'+ident # очень удобно, правда?
t_PHPEQUAL = r'\='
t_PHPCOLON = r';'
t_PHPCOMA = r','
t_PHPOPEN = r'\('
t_PHPCLOSE = r'\)'
t_PHPNUM = r'\d+'
t_PLUSMINUS = r'\+|\-'
t_DIVMUL = r'/|\*'
@TOKEN(ident)
def t_PHPFUNC(t):
if t.value.lower() == 'echo':
t.type = 'PHPECHO'
return t
# игнорируем комментарии
def t_comment(t):
r'(/\*(.|\n)*?\*/)|(//.*)'
pass
# изменили токен PHPSTRING и сделали из него функцию, добавили его во все состояния
def t_ANY_PHPSTRING(t): # нужен в обоих состояниях, потому что двойные кавычки матчатся и там и там.
r'"'
if t.lexer.current_state() == 'string':
t.lexer.begin('INITIAL') # переходим в начальное состояние
else:
t.lexer.begin('string') # парсим строку
return t
t_string_STR = r'(\\.|[^$"])+' # парсим пока не дойдем до переменной или до кавычки, попутно игнорируем экранки
# говорим что ничего не будем игнорировать
t_string_ignore = '' # это кстати обязательная переменная, без неё нельзя создать новый state
# ну и куда же мы без обработки ошибок
def t_string_error(t):
print "Illegal character '%s'" % t.value[0]
t.lexer.skip(1)
# здесь мы игнорируем незначащие символы. Нам ведь все равно, написано $var=$value или $var = $value
t_ignore = ' \r\t\f'
def t_newline(t):
r'\n+'
t.lexer.lineno += len(t.value)
# а здесь мы обрабатываем ошибки. Кстати заметьте формат названия функции
def t_error(t):
print "Illegal character '%s'" % t.value[0]
t.lexer.skip(1)
lexer = lex.lex(reflags=re.UNICODE | re.DOTALL | re.IGNORECASE)
if __name__=="__main__":
data = '''
<?php
$val = 5;
$result = substr( "foobar", 2*(7-$val) );
echo "это наш результат: $result";
'''
lexer.input(data)
while True:
tok = lexer.token() # читаем следующий токен
if not tok: break # закончились печеньки
print tok
Вот теперь переходим к парсеру (parser.py).
Yacc
Yacc — это штука (парсер), в которую мы передаем токены и описываем правила их соединения (грамматику). Походу работы этой программы мы можем создать абстрактное дерево или же сразу выполнять программу (как это сделано в примере с калькулятором на сайте).
В PLY для описания грамматики существует класс ply.yacc.
Описывать граматику в ply очень просто и это доставляет даже некоторое наслаждение. Для описания у нас снова существуют специальные функции p_имяфункции, где в doc-строках мы и описываем эту самую грамматику.
Давайте попробуем написать очень простую грамматику для нашего абстрактного примера с php:
php -> [PHPSTART phpbody]? phpbody -> [phpline phpcolons]* phpcolons -> [ PHPCOLON ]+ phpline -> assign | func | [PHPECHO args] assign -> PHPVAR PHPEQUAL expr expr -> [fact | expr PLUSMINUS fact] fact -> [term | fact DIVMUL term] term -> [arg | PHPOPEN expr PHPCLOSE] func -> PHPFUNC PHPOPEN args PHPCLOSE args -> [ expr [PHPCOMA expr]* ]? arg -> string | phpvar | PHPNUM | func string -> PHPSTRING str PHPSTRING str -> [STR | str phpvar]? phpvar -> PHPVAR
Текста написано уже много, и рассказывать еще можно очень долго. Но для понимания основ ply.yacc достаточно разобрать один пример. А дальше я уже выложу исходник парсера.
Итак, выдранный кусок из парсера:
def p_str(p):
'''str :
| STR
| str phpvar'''
if len(p) == 1:
p[0] = Node('str', [''])
elif len(p) == 2:
p[0] = Node('str', [p[1]])
else:
p[0] = p[1].add_parts([p[2]])
По порядку. Парсинг начинается с первой сверху функции с шаблоном p_<функция>. Т.е. у меня например первой в исходнике стоит p_php, поэтому парсер начнет работу именно с неё (сверху вниз). Парсер работает с правилами в doc-строках, и после их матчинга возвращает управление в функцию с правилами и при этом передает переменную p. В переменной p хранится результат парсинга. Причем после обработки мы должны засунуть наши результаты в p[0]. Элементы, начиная с p[1], это правая часть наших правил — отматченные токены и правила.
'''str :
| STR
| str phpvar'''
По сути правило выше — это скомбинированное (слитое) правило. '|' как несложно догадаться — это ИЛИ, ну или различные варианты токена.
Между двоеточием и левой/правой частью обязателен пробел. Так любит ply. Если правая часть может быть пустой, то после двоеточия ничего не пишем. Токены должны быть написаны большими буквами, а правила маленькими, без префикса p_. Т.е. в примере выше использовались правила p_str и p_phpvar.
Например, если мы имеем "" (пустую строку), то в p у нас будет только одно значение — p[0], причем оно не определно и мы должны его заполнить.
Если у нас строка «привет $vasya пупкин», то эта функция выполнится три раза. Первый раз для значения STR, и мы вернем это значение обратно в p[0], второй раз для $vasya, и добавим это к предыдущему значению (p[1] будет содержать ноду, которую мы вернули в p[0] на прошлой итерации), а третий раз добавим на выход «пупкин». Наверное я сумбурно описал, но это надо просто потрогать. Открыть среду исполнения и поиграться :)
Кстати, иногда более удобен вариант с разделенными вариантами (тафтология, простите):
def p_str_empty(p):
'''str :'''
p[0] = Node('str', [''])
def p_str_raw(p):
'''str : STR'''
p[0] = Node('str', [p[1]])
def p_str_var(p):
'''str : str phpvar'''
p[0] = p[1].add_parts([p[2]])
Он абсолютно идентичен предыдущему варианту кода. Просто фломастеры другие. Функции имеют разные названия (потому что разделять их надо в питоне), но префиксы идентичные (str), что и заставляет ply группировать их вместе как варианты одного правила.
Для удобства построения дерева я использовал такой простенький и эффективный класс:
class Node:
def parts_str(self):
st = []
for part in self.parts:
st.append( str( part ) )
return "\n".join(st)
def __repr__(self):
return self.type + ":\n\t" + self.parts_str().replace("\n", "\n\t")
def add_parts(self, parts):
self.parts += parts
return self
def __init__(self, type, parts):
self.type = type
self.parts = parts
Он одновременно и хранит все нужное, и структурирует вывод, что чень удобно при отладке.
Полный код парсера
# coding=utf8
from lexer import tokens
import ply.yacc as yacc
class Node:
def parts_str(self):
st = []
for part in self.parts:
st.append( str( part ) )
return "\n".join(st)
def __repr__(self):
return self.type + ":\n\t" + self.parts_str().replace("\n", "\n\t")
def add_parts(self, parts):
self.parts += parts
return self
def __init__(self, type, parts):
self.type = type
self.parts = parts
def p_php(p):
'''php :
| PHPSTART phpbody'''
if len(p) == 1:
p[0] = None
else:
p[0] = p[2]
def p_phpbody(p):
'''phpbody :
| phpbody phpline phpcolons'''
if len(p) > 1:
if p[1] is None:
p[1] = Node('body', [])
p[0] = p[1].add_parts([p[2]])
else:
p[0] = Node('body', [])
def p_phpcolons(p):
'''phpcolons : PHPCOLON
| phpcolons PHPCOLON'''
def p_phpline(p):
'''phpline : assign
| func
| PHPECHO args'''
if len(p) == 2:
p[0] = p[1]
else:
p[0] = Node('echo', [p[2]])
def p_assign(p):
'''assign : PHPVAR PHPEQUAL expr'''
p[0] = Node('assign', [p[1], p[3]])
def p_expr(p):
'''expr : fact
| expr PLUSMINUS fact'''
if len(p) == 2:
p[0] = p[1]
else:
p[0] = Node(p[2], [p[1], p[3]])
def p_fact(p):
'''fact : term
| fact DIVMUL term'''
if len(p) == 2:
p[0] = p[1]
else:
p[0] = Node(p[2], [p[1], p[3]])
def p_term(p):
'''term : arg
| PHPOPEN expr PHPCLOSE'''
if len(p) == 2:
p[0] = p[1]
else:
p[0] = p[2]
def p_func(p):
'''func : PHPFUNC PHPOPEN args PHPCLOSE'''
p[0] = Node('func', [p[1], p[3]])
def p_args(p):
'''args :
| expr
| args PHPCOMA expr'''
if len(p) == 1:
p[0] = Node('args', [])
elif len(p) == 2:
p[0] = Node('args', [p[1]])
else:
p[0] = p[1].add_parts([p[3]])
def p_arg(p):
'''arg : string
| phpvar
| PHPNUM
| func'''
p[0] = Node('arg', [p[1]])
def p_phpvar(p):
'''phpvar : PHPVAR'''
p[0] = Node('var', [p[1]])
def p_string(p):
'''string : PHPSTRING str PHPSTRING'''
p[0] = p[2]
def p_str(p):
'''str :
| STR
| str phpvar'''
if len(p) == 1:
p[0] = Node('str', [''])
elif len(p) == 2:
p[0] = Node('str', [p[1]])
else:
p[0] = p[1].add_parts([p[2]])
def p_error(p):
print 'Unexpected token:', p
parser = yacc.yacc()
def build_tree(code):
return parser.parse(code)
Основной файлик, откуда вызываем все
# coding=utf8
from parser import build_tree
data = '''
<?php
$val = 5;
$result = substr( "foobar", 2*(7-$val) ); /* comment */
echo "это наш результат: ", $result;
'''
result = build_tree(data)
print result
Вывод синтаксического дерева
line: assign: $val arg: 5 assign: $result arg: func: substr args: arg: str: foobar *: arg: 2 -: arg: 7 arg: var: $val echo: args: arg: str: это наш результат: arg: var: $result
Заключение
Если есть какие-то замечания или пожелания, с удовольствием допишу/изменю пост.
Код статьи здесь
Ну и если интересно с чего я вдруг про ply решил написать — pyfox. Делаю потихонечьку обработку css на python. Вернее парсер css написан, но вот прогулка по dom еще полностью не реализована (не все псевдоселекторы работают). Сейчас конкретно проблема с nth-child. Не на уровне css а на уровне эффективного матчинга — в dom нету такого свойства как номер среди соседей, а считать предыдущих соседей не хочется. Видимо придется HTMLParser кастомизировать. Может кто решит присоединитсья ко мне? Всех welcome :)