Вглубь Pyparsing: парсим единицы измерения на Python
В прошлой статье мы познакомились с удобной библиотекой синтаксического анализа Pyparsing и написали парсер для выражения 'import matplotlib.pyplot as plt' .
В этой статье мы начнём погружение в Pyparsing на примере задачи парсинга единиц измерения. Шаг за шагом мы создадим рекурсивный парсер, который умеет искать символы на русском языке, проверять допустимость названия единицы измерения, а также группировать те из них, которые пользователь заключил в скобки. Примечание: Код этой статьи протестирован и выложен на Sagemathclod. Если у Вас вдруг что-то не работает (скорее всего из-за кодировки текста), обязательно сообщите мне об этом в личку, в комментариях или напишите мне на почту или в ВК.
Начало работы. Исходные данные и задача.
В качестве примера будем парсить выражение:
Эта единица измерения была взята из головы с целью получить строку, анализ которой задействовал бы все возможности нашего парсера. Нам нужно получить:
Заменив в строке s деление умножением, раскрыв скобки и явно проставив степени у единиц измерения, получим: Н*м^2/(кг*с^2) = Н^1 * м^2 * кг^-1 * с^-2.
Таким образом, каждый кортеж в переменной res содержит название единицы измерения и степень, в которую её необходимо возвести. Между кортежами можно мысленно поставить знаки умножения.
Перед тем, как использовать pyparsing, его необходимо импортировать:
Когда мы напишем парсер, мы заменим * на использованные нами классы.
Методика написания парсера на Pyparsing
- Сначала из текстовой строки выделяются ключевые слова или отдельные важные символы, которые являются «кирпичиками» для построения конечной строки.
- Пишем отдельные парсеры для «кирпичиков».
- «Собираем» парсер для конечной строки.
Написание парсера для единицы измерения. Парсинг русских букв.
Единица измерения — это слово, которое начинается с буквы и состоит из букв и точек (например мм.рт.ст.). В pyparsing мы можем записать:
Обратите внимание, что у класса Word теперь 2 аргумента. Первый аргумент отвечает за то, что должно быть первым символом у слова, второй аргумент — за то, какими могут быть остальные символы слова. Единица измерения обязательно начинается с буквы, поэтому мы поставили первым аргументом alphas . Помимо букв единица измерения может содержать точку (например, мм.рт.ст), поэтому второй аргумент у Word – alphas + '.' .
К сожалению, если мы попробуем распарсить любую единицу измерения, мы обнаружим, что парсер работает только для единиц измерения на английском языке. Это потому, что alphas подразумевает не просто буквы, а буквы английского алфавита.
Данная проблема обходится очень легко. Сначала создадим строку, перечисляющую все буквы на русском:
И код парсера для отдельной единицы измерения следует изменить на:
Теперь наш парсер понимает единицы измерения на русском и английском языках. Для других языков код парсера пишется аналогично.
Коррекция кодировки результата работы парсера.
При тестировании парсера для единицы измерения Вы можете получить результат, в котором русские символы заменены их кодовым обозначением. Например, на Sage:
Если Вы получили такой же результат, значит, всё работает правильно, но нужно поправить кодировку. В моём случае (sage) работает использование «самодельной» функции bprint (better print):
Используя эту функцию, мы получим вывод в Sage в правильной кодировке:
Написание парсера для степени. Парсинг произвольного числа.
Научимся парсить степень. Обычно степень — это целое число. Однако в редких случаях степень может содержать дробную часть или быть записанной в экспоненциальной нотации. Поэтому мы напишем парсер для обычного числа, например, такого:
«Кирпичиком» произвольного числа является натуральное число, которое состоит из цифр:
Перед числом может стоять знак плюс или минус. При этом знак плюс выводить в результат не надо (используем Suppress() ).
Вертикальная черта означает «или» (плюс или минус). Literal() означает точное соответствие текстовой строке. Таким образом, выражение для pm_sign означает, что надо найти в тексте необязательный символ +, который не надо выводить в результат парсинга, или необязательный символ минус.
Теперь мы можем написать парсер для всего числа. Число начинается с необязательного знака плюс или минус, потом идут цифры, потом необязательная точка — разделитель дробной части, потом цифры, потом может идти символ e, после которого — снова число: необязательный плюс-минус и цифры. У числа после e дробной части уже нет. На pyparsing:
У нас теперь есть парсер для числа. Посмотрим, как работает парсер:
Как мы видим, число разбито на отдельные составляющие. Нам это ни к чему, и мы бы хотели «собрать» число обратно. Это делается при помощи Combine() :
Отлично! Но… На выходе по-прежнему строка, а нам нужно число. Добавим преобразование строки в число, используя ParseAction() :
Мы используем анонимную функцию lambda , аргументом которой является t . Сначала мы получаем результат в виде списка (t.asList()) . Т.к. полученный список имеет только один элемент, его сразу можно извлечь: t.asList()[0] . Функция float() преобразует текст в число с плавающей точкой. Если вы работаете в Sage, можете заменить float на RR — конструктор класса вещественных чисел Sage.
Парсинг единицы измерения со степенью.
Отдельная единица измерения — это название единицы измерения, после которой может идти знак степени ^ и число — степень, в которую необходимо возвести. На pyparsing:
Сразу усовершенствуем вывод. Нам не нужно видеть ^ в результате парсинга, и мы хотим видеть результат в виде кортежа (см. переменную res в начале этой статьи). Для подавления вывода используем Suppress() , для преобразования списка в кортеж — ParseAction() :
Парсинг единиц измерения, обрамлённых скобками. Реализация рекурсии.
Мы подошли к интересному месту — описанию реализации рекурсии. При написании единицы измерения пользователь может обрамить скобками одну или несколько единиц измерения, между которыми стоят знаки умножения и деления. Выражение в скобках может содержать другое, вложенное выражение, обрамлённое скобками (например "(м^2/ (с^2 * кг))" ). Возможность вложения одних выражений со скобками в другие и есть источник рекурсии. Перейдём к Pyparsing.
Вначале напишем выражение, не обращая внимание, что у нас есть рекурсия:
Optional содержит ту часть строки, которая может присутствовать, а может отсутствовать. OneOrMore (переводится как «один или больше») содержит ту часть строки, которая должна встретиться в тексте не менее одного раза. OneOrMore содержит два «слагаемых»: сначала мы ищем знак умножения и деления, потом единицу измерения или вложенное выражение.
В том виде, как сейчас, оставлять unit_expr нельзя: слева и справа от знака равенства есть unit_expr , что однозначно свидетельствует о рекурсии. Решается эта проблема очень просто: надо поменять знак присваивания на <<, а в строке перед unit_expr добавить присваивание специального класса Forward() :
Таким образом, при написании парсера нет необходимости заранее предвидеть рекурсию. Сначала пишите выражение так, как будто в нём не будет рекурсии, а когда увидите, что она появилась, просто замените знак = на << и строкой выше добавьте присваивание класса Forward() .
Парсинг общего выражения для единицы измерения.
У нас остался последний шаг: общее выражение для единицы измерения. На pyparsing:
Обратите внимание, что выражение имеет вид (a | b) + (c | d) . Скобки здесь обязательны и имеют ту же роль, что и в математике. Используя скобки, мы хотим указать, что вначале надо проверить, что первое слагаемое — unit_expr или single_unit , а второе слагаемое — необязательное выражение. Если скобки убрать, то получится, что parse_unit – это unit_expr или single_unit + необязательное выражение, что не совсем то, что мы задумывали. Те же рассуждения применимы и к выражению внутри Optional() .
Черновой вариант парсера. Коррекция кодировки результата.
Итак, мы написали черновой вариант парсера:
Группировка единиц измерения, обрамлённых скобками.
Мы уже близко к тому результату, который хотим получить. Первое, что нам нужно реализовать — группировка тех единиц измерения, которых пользователь обрамил скобками. Для этого в Pyparsing используется Group() , который мы применим к unit_expr :
Посмотрим, что изменилось:
Ставим степень 1 в тех кортежах, где степень отсутствует.
В некоторых кортежах после запятой ничего не стоит. Напомню, что кортеж соответствует единице измерения и имеет вид (единица измерения, степень). Вспомним, что мы можем давать имена определённым кусочкам результата работы парсера (описано в прошлой статье). В частности, назовём найденную единицу измерения как 'unit_name' , а её степень как 'unit_degree' . В setParseAction() напишем анонимную функцию lambda() , которая будет ставить 1 там, где пользователь не указал степень единицы измерения). На pyparsing:
Теперь весь наш парсер выдаёт следующий результат:
В коде выше вместо float(1) можно было бы написать просто 1.0 , но в Sage в таком случае получится не тип float , а собственный тип Sage для вещественных чисел.
Убираем из результата парсера знаки * и /, раскрываем скобки.
Всё, что нам осталось сделать — это убрать в результате парсера знаки * и /, а также вложенные квадратные скобки. Если перед вложенным списком (т. е. перед [) стоит деление, знак степени у единиц измерения во вложенном списке надо поменять на противоположный. Для этого напишем отдельную функцию transform_unit() , которую будем использовать в setParseAction() для parse_unit :
После этого наш парсер возвращает единицу измерения в нужном формате:
Обратите внимание, что функция transform_unit() убирает вложенность. В процессе преобразования все скобки раскрываются. Если перед скобкой стоит знак деления, знак степени единиц измерения в скобках меняется на противоположный.
Реализация проверки единиц измерения непосредственно в процессе парсинга.
Последнее, что было обещано сделать — внедрить раннюю проверку единиц измерения. Другими словами, как только парсер найдёт единицу измерения, он сразу проверит её по нашей базе данных.
В качестве базы данных будем использовать словарь Python:
Чтобы быстро проверить единицу измерения, хорошо было бы создать множество Python, поместив в него единицы измерения:
Напишем функцию check_unit , которая будет проверять единицу измерения, и вставим её в setParseAction для ph_unit :
Вывод парсера не изменится, но, если попадётся единица измерения, которая отсутствует в базе данных или в науке, то пользователь получит сообщение об ошибке. Пример:
Последняя строчка и есть наше сообщение пользователю об ошибке.
Полный код парсера. Заключение.
В заключение приведу полный код парсера. Не забудьте в строке импорта "from pyparsing import *" заменить * на использованные классы.
Благодарю вас за терпение, с которым вы прочитали мою статью. Напомню, что код, представленный в этой статье, выложен на Sagemathcloud. Если вы не зарегистрированы на Хабре, вы можете прислать мне вопрос на почту или написать в ВК. В следующей статье я хочу познакомить вас с Sagemathcloud, показать, насколько сильно он может упростить вашу работу на Python. После этого я вернусь к теме парсинга на Pyparsing на качественно новом уровне.
Благодарю Дарью Фролову и Никиту Коновалова за помощь в проверке статьи перед её публикацией.