В прошлой статье мы познакомились с удобной библиотекой синтаксического анализа Pyparsing и написали парсер для выражения 'import matplotlib.pyplot as plt'
.
В этой статье мы начнём погружение в Pyparsing на примере задачи парсинга единиц измерения. Шаг за шагом мы создадим рекурсивный парсер, который умеет искать символы на русском языке, проверять допустимость названия единицы измерения, а также группировать те из них, которые пользователь заключил в скобки.
Примечание: Код этой статьи протестирован и выложен на Sagemathclod. Если у Вас вдруг что-то не работает (скорее всего из-за кодировки текста), обязательно сообщите мне об этом в личку, в комментариях или напишите мне на почту или в ВК.
Начало работы. Исходные данные и задача.
В качестве примера будем парсить выражение:
s = "Н*м^2/(кг*с^2)"
Эта единица измерения была взята из головы с целью получить строку, анализ которой задействовал бы все возможности нашего парсера. Нам нужно получить:
res = [('Н',1.0), ('м',2.0), ('кг',-1.0), ('с',-2.0)]
Заменив в строке s
деление умножением, раскрыв скобки и явно проставив степени у единиц измерения, получим: Н*м^2/(кг*с^2) = Н^1 * м^2 * кг^-1 * с^-2.
Таким образом, каждый кортеж в переменной res
содержит название единицы измерения и степень, в которую её необходимо возвести. Между кортежами можно мысленно поставить знаки умножения.
Перед тем, как использовать pyparsing, его необходимо импортировать:
from pyparsing import *
Когда мы напишем парсер, мы заменим * на использованные нами классы.
Методика написания парсера на Pyparsing
При использовании pyparsing следует придерживаться следующей методики написания парсера:
- Сначала из текстовой строки выделяются ключевые слова или отдельные важные символы, которые являются «кирпичиками» для построения конечной строки.
- Пишем отдельные парсеры для «кирпичиков».
- «Собираем» парсер для конечной строки.
В нашем случае основными «кирпичиками» являются названия отдельных единиц измерения и их степени.
Написание парсера для единицы измерения. Парсинг русских букв.
Единица измерения — это слово, которое начинается с буквы и состоит из букв и точек (например мм.рт.ст.). В pyparsing мы можем записать:
ph_unit = Word(alphas, alphas+'.')
Обратите внимание, что у класса Word
теперь 2 аргумента. Первый аргумент отвечает за то, что должно быть первым символом у слова, второй аргумент — за то, какими могут быть остальные символы слова. Единица измерения обязательно начинается с буквы, поэтому мы поставили первым аргументом alphas
. Помимо букв единица измерения может содержать точку (например, мм.рт.ст), поэтому второй аргумент у Word
– alphas + '.'
.
К сожалению, если мы попробуем распарсить любую единицу измерения, мы обнаружим, что парсер работает только для единиц измерения на английском языке. Это потому, что alphas
подразумевает не просто буквы, а буквы английского алфавита.
Данная проблема обходится очень легко. Сначала создадим строку, перечисляющую все буквы на русском:
rus_alphas = 'йцукенгшщзхъфывапролджэячсмитьбюЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ'
И код парсера для отдельной единицы измерения следует изменить на:
ph_unit = Word(alphas+rus_alphas, alphas+rus_alphas+'.')
Теперь наш парсер понимает единицы измерения на русском и английском языках. Для других языков код парсера пишется аналогично.
Коррекция кодировки результата работы парсера.
При тестировании парсера для единицы измерения Вы можете получить результат, в котором русские символы заменены их кодовым обозначением. Например, на Sage:
ph_unit.parseString("мм").asList()
# Получим: ['xd0xbcxd0xbc']
Если Вы получили такой же результат, значит, всё работает правильно, но нужно поправить кодировку. В моём случае (sage) работает использование «самодельной» функции bprint
(better print):
def bprint(obj):
print(obj.__repr__().decode('string_escape'))
Используя эту функцию, мы получим вывод в Sage в правильной кодировке:
bprint(ph_unit.parseString("мм").asList())
# Получим: ['мм']
Написание парсера для степени. Парсинг произвольного числа.
Научимся парсить степень. Обычно степень — это целое число. Однако в редких случаях степень может содержать дробную часть или быть записанной в экспоненциальной нотации. Поэтому мы напишем парсер для обычного числа, например, такого:
test_num = "-123.456e-3"
«Кирпичиком» произвольного числа является натуральное число, которое состоит из цифр:
int_num = Word(nums)
Перед числом может стоять знак плюс или минус. При этом знак плюс выводить в результат не надо (используем Suppress()
).
pm_sign = Optional(Suppress("+") | Literal("-"))
Вертикальная черта означает «или» (плюс или минус). Literal()
означает точное соответствие текстовой строке. Таким образом, выражение для pm_sign
означает, что надо найти в тексте необязательный символ +, который не надо выводить в результат парсинга, или необязательный символ минус.
Теперь мы можем написать парсер для всего числа. Число начинается с необязательного знака плюс или минус, потом идут цифры, потом необязательная точка — разделитель дробной части, потом цифры, потом может идти символ e, после которого — снова число: необязательный плюс-минус и цифры. У числа после e дробной части уже нет. На pyparsing:
float_num = pm_sign + int_num + Optional('.' + int_num) + Optional('e' + pm_sign + int_num)
У нас теперь есть парсер для числа. Посмотрим, как работает парсер:
float_num.parseString('-123.456e-3').asList()
# Получим ['-', '123', '.', '456', 'e', '-', '3']
Как мы видим, число разбито на отдельные составляющие. Нам это ни к чему, и мы бы хотели «собрать» число обратно. Это делается при помощи Combine()
:
float_num = Combine(pm_sign + int_num + Optional('.' + int_num) + Optional('e' + pm_sign + int_num))
Проверим:
float_num.parseString('-123.456e-3').asList()
# Получим ['-123.456e-3']
Отлично! Но… На выходе по-прежнему строка, а нам нужно число. Добавим преобразование строки в число, используя ParseAction()
:
float_num = Combine(pm_sign + int_num + Optional('.' + int_num) + Optional('e' + pm_sign + int_num)).setParseAction(lambda t: float(t.asList()[0]))
Мы используем анонимную функцию lambda
, аргументом которой является t
. Сначала мы получаем результат в виде списка (t.asList())
. Т.к. полученный список имеет только один элемент, его сразу можно извлечь: t.asList()[0]
. Функция float()
преобразует текст в число с плавающей точкой. Если вы работаете в Sage, можете заменить float
на RR
— конструктор класса вещественных чисел Sage.
Парсинг единицы измерения со степенью.
Отдельная единица измерения — это название единицы измерения, после которой может идти знак степени ^ и число — степень, в которую необходимо возвести. На pyparsing:
single_unit = ph_unit + Optional('^' + float_num)
Протестируем:
bprint(single_unit.parseString("м^2").asList())
# Получим: ['м', '^', 2.0]
Сразу усовершенствуем вывод. Нам не нужно видеть ^ в результате парсинга, и мы хотим видеть результат в виде кортежа (см. переменную res в начале этой статьи). Для подавления вывода используем Suppress()
, для преобразования списка в кортеж — ParseAction()
:
single_unit = (ph_unit + Optional(Suppress('^') + float_num)).setParseAction(lambda t: tuple(t.asList()))
Проверим:
bprint(single_unit.parseString("м^2").asList())
# Получим: [('м', 2.0)]
Парсинг единиц измерения, обрамлённых скобками. Реализация рекурсии.
Мы подошли к интересному месту — описанию реализации рекурсии. При написании единицы измерения пользователь может обрамить скобками одну или несколько единиц измерения, между которыми стоят знаки умножения и деления. Выражение в скобках может содержать другое, вложенное выражение, обрамлённое скобками (например "(м^2/ (с^2 * кг))"
). Возможность вложения одних выражений со скобками в другие и есть источник рекурсии. Перейдём к Pyparsing.
Вначале напишем выражение, не обращая внимание, что у нас есть рекурсия:
unit_expr = Suppress('(') + single_unit + Optional(OneOrMore((Literal("*") | Literal("/")) + (single_unit | unit_expr))) + Suppress(")")
Optional
содержит ту часть строки, которая может присутствовать, а может отсутствовать. OneOrMore
(переводится как «один или больше») содержит ту часть строки, которая должна встретиться в тексте не менее одного раза. OneOrMore
содержит два «слагаемых»: сначала мы ищем знак умножения и деления, потом единицу измерения или вложенное выражение.
В том виде, как сейчас, оставлять unit_expr
нельзя: слева и справа от знака равенства есть unit_expr
, что однозначно свидетельствует о рекурсии. Решается эта проблема очень просто: надо поменять знак присваивания на <<, а в строке перед unit_expr
добавить присваивание специального класса Forward()
:
unit_expr = Forward()
unit_expr << Suppress('(') + single_unit + Optional(OneOrMore((Literal("*") | Literal("/")) + (single_unit | unit_expr))) + Suppress(")")
Таким образом, при написании парсера нет необходимости заранее предвидеть рекурсию. Сначала пишите выражение так, как будто в нём не будет рекурсии, а когда увидите, что она появилась, просто замените знак = на << и строкой выше добавьте присваивание класса Forward()
.
Проверим:
bprint(unit_expr.parseString("(Н*м/с^2)").asList())
# Получим: [('Н',), '*', ('м',), '/', ('с', 2.0)]
Парсинг общего выражения для единицы измерения.
У нас остался последний шаг: общее выражение для единицы измерения. На pyparsing:
parse_unit = (unit_expr | single_unit) + Optional(OneOrMore((Literal("*") | Literal("/")) + (single_unit | unit_expr)))
Обратите внимание, что выражение имеет вид (a | b) + (c | d)
. Скобки здесь обязательны и имеют ту же роль, что и в математике. Используя скобки, мы хотим указать, что вначале надо проверить, что первое слагаемое — unit_expr
или single_unit
, а второе слагаемое — необязательное выражение. Если скобки убрать, то получится, что parse_unit
– это unit_expr
или single_unit
+ необязательное выражение, что не совсем то, что мы задумывали. Те же рассуждения применимы и к выражению внутри Optional()
.
Черновой вариант парсера. Коррекция кодировки результата.
Итак, мы написали черновой вариант парсера:
from pyparsing import *
rus_alphas = 'йцукенгшщзхъфывапролджэячсмитьбюЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ'
ph_unit = Word(rus_alphas+alphas, rus_alphas+alphas+'.')
int_num = Word(nums)
pm_sign = Optional(Suppress("+") | Literal("-"))
float_num = Combine(pm_sign + int_num + Optional('.' + int_num) + Optional('e' + pm_sign + int_num)).setParseAction(lambda t: float(t.asList()[0]))
single_unit = (ph_unit + Optional(Suppress('^') + float_num)).setParseAction(lambda t: tuple(t.asList()))
unit_expr = Forward()
unit_expr << Suppress('(') + single_unit + Optional(OneOrMore((Literal("*") | Literal("/")) + (single_unit | unit_expr))) + Suppress(")")
parse_unit = (unit_expr | single_unit) + Optional(OneOrMore((Literal("*") | Literal("/")) + (single_unit | unit_expr)))
Проверим:
print(s) # s = "Н*м^2/(кг*с^2)" — см. начало статьи.
bprint(parse_unit.parseString(s).asList())
# Получим: [('Н',), '*', ('м', 2.0), '/', ('кг',), '*', ('с', 2.0)]
Группировка единиц измерения, обрамлённых скобками.
Мы уже близко к тому результату, который хотим получить. Первое, что нам нужно реализовать — группировка тех единиц измерения, которых пользователь обрамил скобками. Для этого в Pyparsing используется Group()
, который мы применим к unit_expr
:
unit_expr = Forward()
unit_expr << Group(Suppress('(') + single_unit + Optional(OneOrMore((Literal("*") | Literal("/")) + (single_unit | unit_expr))) + Suppress(")"))
Посмотрим, что изменилось:
bprint(parse_unit.parseString(s).asList())
# Получим: [('Н',), '*', ('м', 2.0), '/', [('кг',), '*', ('с', 2.0)]]
Ставим степень 1 в тех кортежах, где степень отсутствует.
В некоторых кортежах после запятой ничего не стоит. Напомню, что кортеж соответствует единице измерения и имеет вид (единица измерения, степень). Вспомним, что мы можем давать имена определённым кусочкам результата работы парсера (описано в прошлой статье). В частности, назовём найденную единицу измерения как 'unit_name'
, а её степень как 'unit_degree'
. В setParseAction()
напишем анонимную функцию lambda()
, которая будет ставить 1 там, где пользователь не указал степень единицы измерения). На pyparsing:
single_unit = (ph_unit('unit_name') + Optional(Suppress('^') + float_num('unit_degree'))).setParseAction(lambda t: (t.unit_name, float(1) if t.unit_degree == "" else t.unit_degree))
Теперь весь наш парсер выдаёт следующий результат:
bprint(parse_unit.parseString(s).asList())
# Получим: [('Н', 1.0), '*', ('м', 2.0), '/', [('кг', 1.0), '*', ('с', 2.0)]]
В коде выше вместо float(1)
можно было бы написать просто 1.0
, но в Sage в таком случае получится не тип float
, а собственный тип Sage для вещественных чисел.
Убираем из результата парсера знаки * и /, раскрываем скобки.
Всё, что нам осталось сделать — это убрать в результате парсера знаки * и /, а также вложенные квадратные скобки. Если перед вложенным списком (т. е. перед [) стоит деление, знак степени у единиц измерения во вложенном списке надо поменять на противоположный. Для этого напишем отдельную функцию transform_unit()
, которую будем использовать в setParseAction()
для parse_unit
:
def transform_unit(unit_list, k=1):
res = []
for v in unit_list:
if isinstance(v, tuple):
res.append(tuple((v[0], v[1]*k)))
elif v == "/":
k = -k
elif isinstance(v, list):
res += transform_unit(v, k=k)
return(res)
parse_unit = ((unit_expr | single_unit) + Optional(OneOrMore((Literal("*") | Literal("/")) + (single_unit | unit_expr)))).setParseAction(lambda t: transform_unit(t.asList()))
После этого наш парсер возвращает единицу измерения в нужном формате:
bprint(transform_unit(parse_unit.parseString(s).asList()))
# Получим: [('Н', 1.0), ('м', 2.0), ('кг', -1.0), ('с', -2.0)]
Обратите внимание, что функция transform_unit()
убирает вложенность. В процессе преобразования все скобки раскрываются. Если перед скобкой стоит знак деления, знак степени единиц измерения в скобках меняется на противоположный.
Реализация проверки единиц измерения непосредственно в процессе парсинга.
Последнее, что было обещано сделать — внедрить раннюю проверку единиц измерения. Другими словами, как только парсер найдёт единицу измерения, он сразу проверит её по нашей базе данных.
В качестве базы данных будем использовать словарь Python:
unit_db = {'Длина':{'м':1, 'дм':1/10, 'см':1/100, 'мм':1/1000, 'км':1000, 'мкм':1/1000000}, 'Сила':{'Н':1}, 'Мощность':{'Вт':1, 'кВт':1000}, 'Время':{'с':1}, 'Масса':{'кг':1, 'г':0.001}}
Чтобы быстро проверить единицу измерения, хорошо было бы создать множество Python, поместив в него единицы измерения:
unit_set = set([t for vals in unit_db.values() for t in vals])
Напишем функцию check_unit
, которая будет проверять единицу измерения, и вставим её в setParseAction
для ph_unit
:
def check_unit(unit_name):
if not unit_name in unit_set:
raise ValueError("Единица измерения указана неверно или отсутствует в базе данных: " + unit_name)
return(unit_name)
ph_unit = Word(rus_alphas+alphas, rus_alphas+alphas+'.').setParseAction(lambda t: check_unit(t.asList()[0]))
Вывод парсера не изменится, но, если попадётся единица измерения, которая отсутствует в базе данных или в науке, то пользователь получит сообщение об ошибке. Пример:
ph_unit.parseString("дюйм")
# Получим сообщение об ошибке:
Error in lines 1-1
Traceback (most recent call last):
…
File "", line 1, in <lambda>
File "", line 3, in check_unit
ValueError: Единица измерения указана неверно или отсутствует в базе данных: дюйм
Последняя строчка и есть наше сообщение пользователю об ошибке.
Полный код парсера. Заключение.
В заключение приведу полный код парсера. Не забудьте в строке импорта "from pyparsing import *"
заменить * на использованные классы.
from pyparsing import nums, alphas, Word, Literal, Optional, Combine, Forward, Group, Suppress, OneOrMore
def bprint(obj):
print(obj.__repr__().decode('string_escape'))
# База данных единиц измерения
unit_db = {'Длина':{'м':1, 'дм':1/10, 'см':1/100, 'мм':1/1000, 'км':1000, 'мкм':1/1000000}, 'Сила':{'Н':1}, 'Мощность':{'Вт':1, 'кВт':1000}, 'Время':{'с':1}, 'Масса':{'кг':1, 'г':0.001}}
unit_set = set([t for vals in unit_db.values() for t in vals])
# Парсер для единицы измерения с проверкой её по базе данных
rus_alphas = 'йцукенгшщзхъфывапролджэячсмитьбюЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ'
def check_unit(unit_name):
"""
Проверка единицы измерения по базе данных.
"""
if not unit_name in unit_set:
raise ValueError("Единица измерения указана неверно или отсутствует в базе данных: " + unit_name)
return(unit_name)
ph_unit = Word(rus_alphas+alphas, rus_alphas+alphas+'.').setParseAction(lambda t: check_unit(t.asList()[0]))
# Парсер для степени
int_num = Word(nums)
pm_sign = Optional(Suppress("+") | Literal("-"))
float_num = Combine(pm_sign + int_num + Optional('.' + int_num) + Optional('e' + pm_sign + int_num)).setParseAction(lambda t: float(t.asList()[0]))
# Парсер для единицы измерения со степенью
single_unit = (ph_unit('unit_name') + Optional(Suppress('^') + float_num('unit_degree'))).setParseAction(lambda t: (t.unit_name, float(1) if t.unit_degree == "" else t.unit_degree))
# Парсер для выражения в скобках
unit_expr = Forward()
unit_expr << Group(Suppress('(') + single_unit + Optional(OneOrMore((Literal("*") | Literal("/")) + (single_unit | unit_expr))) + Suppress(")"))
# Парсер для общего выражения единицы измерения
def transform_unit(unit_list, k=1):
"""
Функция раскрывает скобки в результате, выданном парсером, корректирует знак степени и убирает знаки * и /
"""
res = []
for v in unit_list:
if isinstance(v, tuple):
res.append(tuple((v[0], v[1]*k)))
elif v == "/":
k = -k
elif isinstance(v, list):
res += transform_unit(v, k=k)
return(res)
parse_unit = ((unit_expr | single_unit) + Optional(OneOrMore((Literal("*") | Literal("/")) + (single_unit | unit_expr)))).setParseAction(lambda t: transform_unit(t.asList()))
#Проверка
s = "Н*м^2/(кг*с^2)"
bprint(parse_unit.parseString(s).asList())
Благодарю вас за терпение, с которым вы прочитали мою статью. Напомню, что код, представленный в этой статье, выложен на Sagemathcloud. Если вы не зарегистрированы на Хабре, вы можете прислать мне вопрос на почту или написать в ВК. В следующей статье я хочу познакомить вас с Sagemathcloud, показать, насколько сильно он может упростить вашу работу на Python. После этого я вернусь к теме парсинга на Pyparsing на качественно новом уровне.
Благодарю Дарью Фролову и Никиту Коновалова за помощь в проверке статьи перед её публикацией.
Автор: AndreWin