python

Вглубь Pyparsing: парсим единицы измерения на Python

  • четверг, 30 октября 2014 г. в 02:10:43
http://habrahabr.ru/post/241670/

В прошлой статье мы познакомились с удобной библиотекой синтаксического анализа 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 следует придерживаться следующей методики написания парсера:
  1. Сначала из текстовой строки выделяются ключевые слова или отдельные важные символы, которые являются «кирпичиками» для построения конечной строки.
  2. Пишем отдельные парсеры для «кирпичиков».
  3. «Собираем» парсер для конечной строки.

В нашем случае основными «кирпичиками» являются названия отдельных единиц измерения и их степени.

Написание парсера для единицы измерения. Парсинг русских букв.


Единица измерения — это слово, которое начинается с буквы и состоит из букв и точек (например мм.рт.ст.). В pyparsing мы можем записать:

ph_unit = Word(alphas, alphas+'.')

Обратите внимание, что у класса Word теперь 2 аргумента. Первый аргумент отвечает за то, что должно быть первым символом у слова, второй аргумент — за то, какими могут быть остальные символы слова. Единица измерения обязательно начинается с буквы, поэтому мы поставили первым аргументом alphas. Помимо букв единица измерения может содержать точку (например, мм.рт.ст), поэтому второй аргумент у Wordalphas + '.'.

К сожалению, если мы попробуем распарсить любую единицу измерения, мы обнаружим, что парсер работает только для единиц измерения на английском языке. Это потому, что alphas подразумевает не просто буквы, а буквы английского алфавита.

Данная проблема обходится очень легко. Сначала создадим строку, перечисляющую все буквы на русском:

rus_alphas = 'йцукенгшщзхъфывапролджэячсмитьбюЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ'

И код парсера для отдельной единицы измерения следует изменить на:

ph_unit = Word(alphas+rus_alphas, alphas+rus_alphas+'.')

Теперь наш парсер понимает единицы измерения на русском и английском языках. Для других языков код парсера пишется аналогично.

Коррекция кодировки результата работы парсера.


При тестировании парсера для единицы измерения Вы можете получить результат, в котором русские символы заменены их кодовым обозначением. Например, на Sage:

ph_unit.parseString("мм").asList()
# Получим: ['\xd0\xbc\xd0\xbc']

Если Вы получили такой же результат, значит, всё работает правильно, но нужно поправить кодировку. В моём случае (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 на качественно новом уровне.

Благодарю Дарью Фролову и Никиту Коновалова за помощь в проверке статьи перед её публикацией.