Сохраняющие скобки и группировка

На этом занятии поговорим о группировках и сохранении результатов поиска, используя регулярные выражения. О чем здесь речь? Давайте представим, что нам нужно из текста

lat = 5, lon=7

выделить данные в формате: ключ=значение. Выражение можно записать так:

import re
 
text = "lat = 5, lon=7"
match = re.findall(r"\w+\s*=\s*\d+", text)
 
print(match)

Однако, это же шаблон будет работать, например, и с такими данными:

text = "pi=3, a = 5"

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

match = re.findall(r"lat\s*=\s*\d+|lon\s*=\s*\d+", text)

Смотрите, мы записали два шаблона через символ |, который в регулярных выражениях означает ИЛИ. То есть, в качестве ключа можно брать lat или lon (одно из двух) и далее, должно идти число. Запустим программу вот с таким текстом:

text = "lat = 5, lon=7, a=5"

и на выходе получаем только данные для lat и lon:

['lat = 5', 'lon=7']

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

match = re.findall(r"(?:lat|lon)\s*=\s*\d+", text)

Мы здесь использовали несохраняющую группировку двух литералов lat или lon. Вот эти два символа ?: сразу после открывающей круглой скобки как раз и указывает на несохраняющую группировку. Что это такое я поясню чуть позже. А сейчас, если запустить программу, то увидим прежний результат:

['lat = 5', 'lon=7']

и, при этом, наше правило записано без дублирующих элементов. Интерпретировать все это можно так: сначала проверяется наличие литералов lat или lon, а затем, после них должен стоять знак равенства и числа.

Теперь, давайте уберем символы ?: из наших скобок:

match = re.findall(r"(lat|lon)\s*=\s*\d+", text)

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

И, если запустить программу, то в консоли увидим как раз этот второй уровень:

['lat', 'lon']

Чтобы увидеть оба уровня, поставим сохраняющие скобки вокруг всего выражения:

match = re.findall(r"((lat|lon)\s*=\s*\d+)", text)

У нас получится следующий результат:

[('lat = 5', 'lat'), ('lon=7', 'lon')]

Фактически, здесь во второй уровень записывается сначала вся найденная фраза, а затем, включаются внутренние скобки и добавляются ключи. И, смотрите, используя этот функционал, мы можем отдельно выделить ключ и значения, записав шаблон в таком виде:

match = re.findall(r"(lat|lon)\s*=\s*(\d+)", text)

На выходе сразу получим список из кортежей с парами: ключ, значение:

[('lat', '5'), ('lon', '7')]

и это очень удобно для дальнейшей обработки этих данных. Вот так работают круглые скобки: они выполняют сразу две функции – группировку и сохранение результатов поиска. Или, же можем использовать группировку без сохранения, добавляя внутрь скобок символы

(?:<выражение>)

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

text = "Картинка <img src='bg.jpg'> в тексте</p>"

Для этого запишем регулярное выражение в виде:

match = re.findall(r"<img\s+[^>]*src=[\"'](.+?)[\"']", text)

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

['bg.jpg']

Но как можно использовать эти сохранения непосредственно внутри регулярного выражения? К ним можно обратиться с помощью такого синтаксиса:

\i  (i – натуральное число: 1, 2, 3, …)

Например, наш шаблон сработает и в таких случаях:

<img src="bg. jpg'>

<img src='bg. jpg">

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

match = re.findall(r"<img\s+[^>]*src=([\"'])(.+?)\1", text)

При запуске увидим:

[("'", 'bg.jpg')]

Первая кавычка – это первая сохраняющая скобка, а вторая строка – это вторая скобка. Все сработало, как и должно быть. Причем, изменив нашу строку с разными кавычками:

text = "<p>Картинка <img src='bg.jpg\"> в тексте</p>"

на выходе получим пустую коллекцию. И, обратите внимание, использовать в символьных классах [] ссылки на сохранения нельзя, мы их можем прописывать только внутри самого регулярного выражения.

В ряде случаев использовать цифры при обращении к сохраненному блоку не очень удобно, поэтому синтаксис позволяет назначать им имена:

(?P<name>…)

и, затем, обращаться к ним:

(?P=name)

Перепишем последнее регулярное выражение с использованием имен, получим:

match = re.findall(r"<img\s+[^>]*src=(?P<quote>[\"'])(.+?)(?P=quote)", text)

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

Давайте в качестве практического примера рассмотрим парсинг вот такого xml-файла:

<!DOCTYPE xmlmap>
<xmlmap>
 <parametrs>
  <name>map</name>
  <scale>750000</scale>
  <issuedate>20060828</issuedate>
  <correctiondate>20060828</correctiondate>
 </parametrs>
 <object code="43" >
  <attribute number="133" value="3000000" />
  <attribute number="174" value="20" />
  <primitive pointType="2" name="2" >
   <point lon="40.8482" lat="52.6274" />
   <point lon="40.8559" lat="52.6361" />

Нам здесь нужно у записи point выделить долготу (lon) и широту (lat). В самом простом варианте это можно сделать так:

with open("map.xml", "r") as f:
    lat = []
    lon = []
    for text in f:
        match = re.findall(r"<point\s+[^>]*?lon=([\"\'])([0-9.,]+)\1\s+[^>]*lat=([\"\'])([0-9.,]+)\1", text)
        print(match)
 
    print(lon, lat, sep="\n")

Мы здесь открываем файл на чтение с использованием менеджера контекста а, затем, построчно считываем из него информацию. Для каждой строки применяем регулярное выражение, выделяя атрибуты lon и lat. На выходе получаем следующий результат:


[]
[]
[]
[('"', '40.8482', '"', '52.6274')]
[('"', '40.8559', '"', '52.6361')]
[('"', '40.8614', '"', '52.651')]

В тех строчках, где нет данных атрибутов, имеем пустую коллекцию, а где формат совпадает, получаем четыре значения из сохраняющих скобок. Здесь нам нужны значения с индексом 1 – для lon и 3 – для lat. И, записывая все «в лоб», получаем такую программу:

with open("map.xml", "r") as f:
    lat = []
    lon = []
    for text in f:
        match = re.findall(r"<point\s+[^>]*?lon=([\"\'])([0-9.,]+)\1\s+[^>]*lat=([\"\'])([0-9.,]+)\1", text)
        if len(match) > 0:
            lon.append(match[0][1])
            lat.append(match[0][3])
 
    print(lon, lat, sep="\n")

Но, как вы понимаете, это не лучшая реализация поставленной задачи. Что если регулярное выражение изменится и индексы станут другими? Придется поправлять весь программный код, связанный с этим шаблоном! Это не очень удобно. Здесь лучше использовать имена сохраняющих групп, а затем, обращаться к данным по этим именам. Поэтому перепишем программу так:

with open("map.xml", "r") as f:
    lat = []
    lon = []
    for text in f:
        match = re.search(r"<point\s+[^>]*?lon=([\"\'])(?P<lon>[0-9.,]+)\1\s+[^>]*lat=([\"\'])(?P<lat>[0-9.,]+)\1", text)
        if match:
            v = match.groupdict()
            if "lon" in v and "lat" in v:
                lon.append(v["lon"])
                lat.append(v["lat"])
 
    print(lon, lat, sep="\n")

Мы здесь воспользовались другим методом модуля re – search, который возвращает не просто коллекцию, а объект, из которого можно получить словарь, содержащий коллекцию сохраненных именованных групп (об этом методе подробнее речь пойдет позже). Далее, мы проверяем: было ли найдено совпадение и если да, то дополнительная проверка на наличие ключей lon и lat в полученном словаре. При истинности обеих проверок, добавляем в наши коллекции соответствующие данные.

Здесь использование именованных групп оправдано, т.к. создает универсальность текста программы, независимо от вида регулярного выражения.