Regexp и Python: извлечение токенов из текста
Разбор логов и конфигурационных файлов — задача часто возникающая и многократно описанная. В этой статье я расскажу как на языке python реализовать ее классическое решение: с помощью регулярных выражений и именованных групп. По возможности постараюсь рассказать причины, по которым применяется то или иное решение, а также обрисовать подводные камни и методы их обхода.
Зачем разбирать текст и кто такие токеныВ текстовых файлах, интересных нашим с Вами программам, обычно находится больше одной единицы информации. Чтобы программа могла отделить одну часть информации от другой, мы устанавливаем форматы файлов — то есть договоренность о том, как внутри файла записан текст. Самый простой формат — каждая единица информации находится на отдельной строке. Такой файл почти не требует дополнительной обработки — достаточно считать его средствами используемого языка программирования и разбить на строки. Большинство языков позволяют разбить файл на строки одной-двумя командами. К сожалению, большинство файлов, которые необходимо обработать, имеют чуть более сложный формат. Например, классический файл настроек содержит строчки вида имя=значение. В общем случае такой формат тоже достаточно легко разобрать, считав файл построчно и найдя в каждой строке '='. То что слева от него будет именем поля, а то что справа — значением. Эта логика будет работать, пока нам не понадобится разобрать файл с многостроковыми значениями полей и значениями, который содержат символ " http://dl.getdropbox.com/u/239055/habr/py_regexp_tokenize_cfg.png" data-src="http://dl.getdropbox.com/u/239055/habr/py_regexp_tokenize_cfg.png"/> можно выделить три токена: «name» как имя поля, " http://docs.python.org/library/re.html">официальной документации. Во-вторых, регулярные выражения не разделяют синтаксис самого языка и пользовательские данные. То есть если мы хотим найти слово «вася», то регулярное выражение для его поиска так и будет выглядеть — «вася». В этой строке непосредственно язык программирования не присутствует, присутствует только заданная нами строка, которую надо искать. А вот если мы хотим найти слово «вася», после которого идет запятая или точка с запятой, то регулярное выражение обрастет нужными и важными подробностями: «вася,|вася;». Как мы видим, здесь добавилась конструкция языка «логическое или», которое представлено вертикальной чертой. При этом заданные нами строки никак не отделены от синтаксиса языка. Это приводит к важному и неприятному последствию — если мы хотим в строке для поиска задать символ, который присутствует в синтаксисе языка, то нам нужно перед ним написать "\". Так что регулярное выражение, которое ищет слово «вася» после которого идет точка или знак вопроса будет выглядить вот так: «вася\.|вася\?». И точка, и знак вопроса используются в синтаксисе языка регулярных выражений :(. В-третьих, регулярные выражения по умолчанию жадные. То есть, если специально этого не указывать, то будет найдена строка максимальной длины, удовлетворяющая регулярному выражению. Например, если мы хотим найти в тексте строки вида «имя=значение» и напишем регулярное выражние: ".+=.+", то для текста «a=b» оно сработает правильно, вернув «a=b». А вот для текста «a=b, c=d» оно вернет «a=b, c=d» — тоесть текст целиком. Об этом свойстве регулярных выражений нужно помнить всегда и писать их таким образом, чтобы у библиотеки не возникла соблазна вернуть половину «войны и мира» как результат поиска. Например, предыдущее регулярное выражение достаточно немного модифицировать: "[^=]+=[^=]+" — такая версия будет учитывать, что в тексте перед и после символа "=" не должно быть самого символа " \" не преобразовывались в строковые escape-последовательности. Пример поиска:
Как видно из примера, метод search() возвращает объект типа 'результат поиска', который имеет несколько методов и полей с помощью которых можно получить найденный текст, его позицию в исходном выражении и прочие нужные и полезные свойства. Рассмотрим более жизненный пример — классический конфигурационный файл, состоящий из имен секций в фигурных скобках, имен полей и их значений. Регулярное выражение для поиска имен секций будет выглядеть следующим образом:
Результатом выполнения этого кода будет строка "" — имя секции успешно найдено.
Ищем все экземпляры токена в текстеКак видно из предыдущего примера, просто вызов re.search() найдет только первый токен в тексте. Для нахождения всех экземпляров токена библиотека re предлагает несколько способов. Самый, на мой взгляд, корректный — это вызов метода finditer(), который возвращает список объектов типа 'результат поиска'. Получая эти загадочные объекты вместо обычных строк (которые может вернуть, к примеру, метод findall), мы получаем возможность не только ознакомиться с фактом что текст найден, но и узнать где именно он найден — для этого у объекта типа 'результат поиска' есть специально обученный метод span(), возвращающий точное положение найденного фрагмента в исходном тексте. Измененный код для нахождения всех экземпляров токена с использованием метода finditer() будет выглядеть следующим образом:
Ищем в тексте разные токеныК сожалению, поиск одного токена — дело, безусловно, интересное — но практически мало полезное. Обычно в тексте, даже таком простом как конфигурационный файл, есть много интересующих нас токенов. В примере с конфигурационным файлом это будет как минимум имена секций, имена полей и значения полей. Для того, чтобы искать несколько разных токенов в языке регулярных выражений используются группы. Группы — это фрагменты регулярного выражения, заключенные в круглые скобки — соответствующие этим фрагментам части текста будут возвращены как отдельные результаты. Таким образом регулярное выражение, способное искать секции, поля и значения, будет выглядеть следующим образом:
Обратите внимание, что этот код значительно от отличается от предыдущего. Во-первых, в регулярном выражении выделены три группы: "(\n]+>)" соответствует заголовку в фигурных скобках, "([^=\n]+)" перед знаком '=' соответствует имени поля и "([^\n]+)" после знака '=' соответствует значению поля. При этом также используется странная группа "(?:)", которая объединяет группы имен и значений полей. Это специальная группа для использования с логическим оператором '|' — она позволяет объединять несколько групп одним оператором '|' без побочных эффектов. Во-вторых, для распечатки результатов использован метод groups() вместо метода group(). Это не спроста — библиотека регулярных выражений в питоне имеет свое собственное представление о том, что такое «результат поиска». Выражается эта самостийность в том, что регулярное выражение из двух групп "([^=\n]+)=([^=\n]+)", примененное к тесту «a=b» вернет ОДИН объект типа «результат», который состоит из нескольких ГРУПП.
Определяем, что именно мы нашлиЕсли запустить предыдущий пример, то на экран выведется примерно следующий результат:
Как видим, для каждого результата метод groups() возвращает некий волшебный список из трех элементов, каждый из которых может быть либо None (пусто) либо найденным текстом. Если вдумчиво покурить документацию, то можно разобраться что библиотека нашла в нашем выражении три группы и теперь для каждого результата выводит какие именно группы в нем присутствуют. Мы видим, что первая группа соответствует имени секции, вторая имени поля и третья значению поля. Таким образом первый результат, "", это имя секции. Второй результат, «num=1» — это имя поля и значение поля, ну и так далее. Как видим, довольно запутано и неудобно — в общем случае трудно определить ЧТО ИМЕННО мы нашли. Для ответа на этот важный вопрос группы можно именовать. Для этого в языке регулярных выражений предусмотрен специальный синтаксис: "(?P<имя_группы>выражение)". Если немного изменить наш код и дать трем группам имена, то все будет гораздо удобнее:
Обратите внимание на ряд косметических изменений. Перед поиском используется метод re.compile(), который возвращает так называемое «скомпилированное регулярное выражение». Кроме скорости работы и удобства у него есть одно замечательное свойство — если вызвать его метод groupindex(), то мы получим словарь, содержащий имена всех найденных групп и из индексы. К сожалению, словарь почему-то инвертирован — кючем в нем является не индекс, а имя группы. страшное выражение с dict() исправляет это досадное недоразумение и словарь group_name_by_index можно использовать для получения имени группы по ее номеру. Также при компиляции используются флаги re.M (корректный поиск начала строки "^" и конца строки "$" в многостроковом тексте), re.S ("." находит совсем все, включая \n) и .U (корректный поиск в unicode тексте). В результате разбор найденного занимает два цикла — сначала мы итерируемся по результатам поиска, а затем для каждого результата по содержащимся в нем группам. Результатом является точный и полный список токенов, с указанием их типа и позиции в тексте. Этот список можно использовать для обработки текста, подсветки синтаксиса, нахождения ошибок — в общем штука нужная и полезная.
ЗаключениеПродемонстрированный способ нахождения лексем в тексте не является самым лучшим или самым правильным — даже в рамках языка питон и его стандартной библиотеки регулярных выражений существует как минимум десяток альтернативных способов, ничем не уступающих этому. Но я надеюсь, что приведенные примеры и объяснения помогут кому-нибудь при необходимости быстро войти в курс дела, сэкономив время на гугление и выяснения почему оно работает не совсем так, как хотелось бы. Всем удачи, жду комментариев.