Python · Синтаксис · Проміжний

Регулярні вирази

10 завдань

Пошук за шаблоном за допомогою модуля re. Охоплює search, match, findall, групи та поширені патерни.

Модуль re: search, match, fullmatch, findall, sub

#
Регулярні вирази — це міні-мова для опису текстових шаблонів. Модуль `re` компілює ці шаблони та застосовує їх до рядків. Чотири найпоширеніші функції різняться тим, *де* вони шукають: ```python import re text = "User: alice, age 30" re.search(r"\d+", text) # перший збіг ДЕ ЗАВГОДНО в рядку -> Match "30" re.match(r"\d+", text) # збіг лише НА ПОЧАТКУ -> None (починається з "U") re.fullmatch(r"\d+", "42") # весь рядок має збігтися -> Match "42" re.findall(r"\d+", text) # всі незбіжні збіги -> ["30"] re.sub(r"\d+", "??", text) # замінити всі збіги -> "User: alice, age ??" ``` **Навіщо raw-рядки?** Зворотний слеш у рядку Python має особливе значення: `\n` = новий рядок, `\t` = табуляція. Але regex теж активно використовує зворотні слеші (`\d`, `\w`, `\s`). Без raw-рядків довелось би подвоювати кожен слеш: ```python re.search("\\d+", text) # працює, але нечитабельно re.search(r"\d+", text) # те саме - завжди використовуй raw-рядки ``` Правило: кожен патерн regex має бути raw-рядком `r"..."`. **Читаємо патерн посимвольно** Візьмемо `r"(\w+)@(\w+\.\w+)"` (простий патерн email): ``` ( відкрити групу 1 \w+ один або більше символів слова [a-zA-Z0-9_] ) закрити групу 1 @ літеральний "@" ( відкрити групу 2 \w+ один або більше символів слова \. літеральна крапка (екранована - звичайна . означає "будь-який символ") \w+ один або більше символів слова ) закрити групу 2 ``` ```python m = re.search(r"(\w+)@(\w+\.\w+)", "Надіслати на [email protected] будь ласка") m.group(0) # "[email protected]" - повний збіг m.group(1) # "bob" - група 1 m.group(2) # "example.com" - група 2 ``` **Методи об'єкта Match** ```python m = re.search(r"\d{4}", "Рік: 2024 або 2025") if m: m.group() # "2024" - знайдений текст m.start() # 6 - початковий індекс m.end() # 10 - кінцевий індекс m.span() # (6, 10) - обидва разом ``` Завжди перевіряй `if m:` перед викликом методів - `search` повертає `None`, якщо збігу немає. **re.sub з функцією** Заміна може бути функцією, що отримує кожен об'єкт збігу: ```python def double(m): return str(int(m.group()) * 2) re.sub(r"\d+", double, "a=3, b=7") # "a=6, b=14" ``` Це потужніше за статичну заміну рядка.

Групи, не-захоплюючі групи, прапори, re.compile, finditer

#
**Захоплюючі групи** дозволяють витягувати частини збігу. Оберни дужками те, що хочеш захопити. ```python import re log = "2024-03-15 ERROR: disk full" m = re.search(r"(\d{4}-\d{2}-\d{2}) (\w+): (.+)", log) m.group(1) # "2024-03-15" m.group(2) # "ERROR" m.group(3) # "disk full" ``` **Іменовані групи** роблять код самодокументованим: `(?P<name>...)` визначає групу, `m.group('name')` її отримує. ```python m = re.search(r"(?P<date>\d{4}-\d{2}-\d{2}) (?P<level>\w+): (?P<msg>.+)", log) m.group("date") # "2024-03-15" m.group("level") # "ERROR" m.group("msg") # "disk full" ``` **Не-захоплюючі групи** `(?:...)` групують без створення нумерованого захоплення - зручні для чергування або квантифікаторів, коли не треба витягувати: ```python # Знайти 'colour' або 'color', не захоплюючи необов'язкову 'u' re.findall(r"colou?r", "colour and color") # ['colour', 'color'] # Не-захоплююча група для чергування re.findall(r"(?:cat|dog)s?", "cats and dogs") # ['cats', 'dogs'] ``` **Прапори** змінюють поведінку рушія: ```python re.search(r"hello", "Hello World", re.IGNORECASE) # без урахування регістру re.findall(r"^\w+", text, re.MULTILINE) # ^ збігається з початком кожного рядка re.search(r"begin.+end", text, re.DOTALL) # . збігається і з новими рядками re.search(r"\d+", text, re.IGNORECASE | re.MULTILINE) # комбінація через | ``` Вбудований прапор у патерн: `(?i)hello` означає без урахування регістру (те саме, що `re.IGNORECASE`). **re.compile** - попередня компіляція, якщо патерн використовується багато разів: ```python phone_pat = re.compile(r"\+?\d[\d\s\-]{7,14}\d") for line in big_list: m = phone_pat.search(line) # швидше, ніж re.search(pattern, line) щоразу ``` **re.finditer vs re.findall** `findall` повертає список рядків. `finditer` повертає ітератор об'єктів Match - більш ощадливий до пам'яті для великих текстів, і дає інформацію про позицію: ```python for m in re.finditer(r"\d+", "a1 b22 c333"): print(m.group(), m.start()) # "1" 1 / "22" 4 / "333" 7 ``` **re.split** ділить за патерном (не лише за фіксованим роздільником): ```python re.split(r"[,;\s]+", "one, two; three") # ['one', 'two', 'three'] # Захоплююча група в патерні -> роздільник включається в результат re.split(r"(,)", "a,b,c") # ['a', ',', 'b', ',', 'c'] ```

Жадібні vs ліниві квантифікатори, re.VERBOSE і довідник синтаксису

#
**Жадібні vs ліниві квантифікатори** За замовчуванням квантифікатори (`*`, `+`, `{n,m}`) є *жадібними* - вони захоплюють якомога більше. Додай `?`, щоб зробити їх *лінивими* - захоплювати якомога менше. ```python import re html = "<b>bold</b> and <i>italic</i>" re.findall(r"<.+>", html) # жадібний -> ['<b>bold</b> and <i>italic</i>'] re.findall(r"<.+?>", html) # лінивий -> ['<b>', '</b>', '<i>', '</i>'] ``` Поширена помилка: використання `.+` всередині кутових дужок і захоплення надто багато. Завжди питай: чи має це зупинитися на першій можливій позиції? **re.VERBOSE - читабельні складні патерни** Режим verbose ігнорує пробіли та коментарі `#` всередині патерну: ```python date_pat = re.compile(r''' (?P<year> \d{4} ) # 4-цифровий рік - (?P<month> \d{2} ) # 2-цифровий місяць - (?P<day> \d{2} ) # 2-цифровий день ''', re.VERBOSE) m = date_pat.search("Дедлайн: 2024-12-31") m.group("year") # "2024" m.group("month") # "12" m.group("day") # "31" ``` Використовуй VERBOSE для будь-якого патерну довшого за ~40 символів. **Довідник синтаксису патернів** ``` . будь-який символ крім нового рядка (re.DOTALL включає новий рядок) ^ початок рядка (або рядка в тексті з re.MULTILINE) $ кінець рядка \d цифра [0-9] \D не-цифра \w символ слова [a-zA-Z0-9_] \W не-символ слова \s пробільний символ (пробіл, табуляція, новий рядок) \S не-пробільний символ * 0 або більше (жадібний) *? лінивий + 1 або більше (жадібний) +? лінивий ? 0 або 1 {n} рівно n {n,m} від n до m (жадібний) {n,m}? лінивий [abc] клас символів: a, b або c [^abc] негативний клас: все крім a, b, c [a-z] діапазон (...) захоплююча група (?:...) не-захоплююча група (?P<name>...) іменована захоплююча група | чергування: a|b збігається з a або b \ екранує наступний символ ``` **Типові пастки** - `.` збігається з будь-яким символом - використовуй `\.` для літеральної крапки - `^` та `$` поводяться інакше з `re.MULTILINE` - перевіряй явно - `re.match` перевіряє лише початок; `re.fullmatch` перевіряє весь рядок - не плутай їх при валідації - Відкат у складних патернах може бути повільним - тестуй на довгих рядках, якщо важлива швидкодія

Кулінарна книга поширених regex-патернів

#
Готова колекція патернів для найпоширеніших задач пошуку. **Email-адреса (простий)** ```python import re EMAIL = re.compile(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}") EMAIL.findall("Контакти: [email protected] або [email protected]") # ['[email protected]', '[email protected]'] ``` **Номери телефонів (міжнародний формат)** ```python PHONE = re.compile(r"\+?\d[\d\s\-().]{6,14}\d") PHONE.findall("Дзвоніть +1 (800) 555-1234 або +44 20 7946 0958") # ['+1 (800) 555-1234', '+44 20 7946 0958'] ``` **ISO дата (YYYY-MM-DD)** ```python ISO_DATE = re.compile(r"\b(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b") ISO_DATE.findall("Захід 2024-03-15 і дедлайн 2024-12-31") # [('2024', '03', '15'), ('2024', '12', '31')] ``` **URL (базовий)** ```python URL = re.compile(r"https?://[^\s<>'"]+") URL.findall("Дивись https://docs.python.org та http://example.com/path?q=1") # ['https://docs.python.org', 'http://example.com/path?q=1'] ``` **Витягнути числа (ціле або дробове)** ```python NUMBER = re.compile(r"-?\d+(?:\.\d+)?") NUMBER.findall("Темп: -3.5 C, висота: 1200 м") # ['-3.5', '1200'] ``` **Camel до snake_case** ```python def camel_to_snake(name): s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name) return re.sub(r"([a-z\d])([A-Z])", r"\1_\2", s).lower() camel_to_snake("HTTPSResponse") # "https_response" camel_to_snake("myVariableName") # "my_variable_name" ``` **Коли НЕ використовувати regex** - Парсинг HTML/XML - використовуй `html.parser`, `lxml` або `BeautifulSoup` - Валідація email для продакшн - використовуй бібліотеку на кшталт `email-validator` - Парсинг JSON/CSV - використовуй модулі `json`, `csv` - Складна вкладена структура - regex не може обробляти рекурсивну граматику Regex ідеально підходить для сканування, витягування та заміни текстових патернів. Це неправильний інструмент, коли формат має ієрархію або вкладеність.
01

#

Використайте `re.search`, щоб перевірити, чи містить рядок послідовність із 3 або більше цифр поспіль. Поверніть True або False.

import re

def has_three_digits(s):
    # ваш код тут
    pass

print(has_three_digits('abc123def'))  # True
print(has_three_digits('abc12def'))   # False
print(has_three_digits('1234'))       # True
Рішення
import re

def has_three_digits(s):
    return bool(re.search(r'\d{3,}', s))
02

#

Використайте `re.match`, щоб перевірити, чи є рядок дійсним ідентифікатором Python (літери, цифри, підкреслення; повинен починатися з літери або підкреслення).

import re

def is_identifier(s):
    # ваш код тут
    pass

print(is_identifier('my_var'))    # True
print(is_identifier('_private'))  # True
print(is_identifier('2bad'))      # False
print(is_identifier('hello world')) # False
Рішення
import re

def is_identifier(s):
    return bool(re.match(r'^[A-Za-z_][A-Za-z0-9_]*$', s))
03

#

Витягніть усі адреси електронної пошти з багаторядкового рядка. Поверніть їх як список.

import re

def find_emails(text):
    # ваш код тут
    pass

text = '''
Contact us at [email protected] or [email protected].
You can also reach [email protected] for questions.
'''
print(find_emails(text))
# ['[email protected]', '[email protected]', '[email protected]']
Рішення
import re

def find_emails(text):
    return re.findall(r'[\w.+-]+@[\w-]+\.[\w.]+', text)
04

#

Замініть усі входження кількох пробілів одним пробілом у рядку.

import re

def normalize(s):
    # ваш код тут
    pass

print(normalize('hello   world'))   # 'hello world'
print(normalize('  a  b  c  '))     # ' a b c '
Рішення
import re

def normalize(s):
    return re.sub(r' {2,}', ' ', s)
05

#

Використайте групи захоплення, щоб витягти рік, місяць і день з рядка дати у форматі 'DD/MM/YYYY'.

import re

def parse_date(s):
    # Повернути (day, month, year) як цілі числа або None при невірному форматі
    pass

print(parse_date('15/03/2024'))  # (15, 3, 2024)
print(parse_date('01/12/1999'))  # (1, 12, 1999)
print(parse_date('invalid'))     # None
Рішення
import re

def parse_date(s):
    m = re.match(r'^(\d{2})/(\d{2})/(\d{4})$', s)
    if not m:
        return None
    return int(m.group(1)), int(m.group(2)), int(m.group(3))
06

#

Використайте `re.sub` з функцією для подвоєння всіх чисел, знайдених у рядку.

import re

def double_numbers(s):
    # ваш код тут
    pass

print(double_numbers('I have 3 cats and 12 dogs'))  # 'I have 6 cats and 24 dogs'
print(double_numbers('price: 9.99'))                 # 'price: 19.98'
Рішення
import re

def double_numbers(s):
    return re.sub(r'\d+\.?\d*', lambda m: str(float(m.group()) * 2), s)
07

#

Скомпілюйте шаблон regex один раз і використайте його для фільтрації списку рядків, залишаючи лише ті, що відповідають шаблону номера телефону: `+X-XXX-XXX-XXXX` (де X — цифра).

import re

def filter_phones(strings):
    # ваш код тут
    pass

items = ['+1-800-555-1234', 'not a phone', '+44-020-555-9876', '123-456']
print(filter_phones(items))  # ['+1-800-555-1234', '+44-020-555-9876']
Рішення
import re

def filter_phones(strings):
    pattern = re.compile(r'^\+\d+-\d{3}-\d{3}-\d{4}$')
    return [s for s in strings if pattern.match(s)]
08

#

Використайте іменовані групи для розбору рядка логу у форматі `[LEVEL] YYYY-MM-DD: message`. Поверніть словник з ключами 'level', 'date' і 'message'.

import re

def parse_log(line):
    # ваш код тут
    pass

print(parse_log('[ERROR] 2024-03-15: Connection refused'))
# {'level': 'ERROR', 'date': '2024-03-15', 'message': 'Connection refused'}
print(parse_log('[INFO] 2024-01-01: Server started'))
# {'level': 'INFO', 'date': '2024-01-01', 'message': 'Server started'}
Рішення
import re

def parse_log(line):
    m = re.match(r'^\[(?P<level>\w+)\] (?P<date>\d{4}-\d{2}-\d{2}): (?P<message>.+)$', line)
    return m.groupdict() if m else None
09

#

Використайте `re.split` для розбивки рядка на будь-який знак пунктуації (`.`, `,`, `!`, `?`, `;`, `:`). Відфільтруйте порожні рядки з результату.

import re

def split_on_punctuation(s):
    # ваш код тут
    pass

print(split_on_punctuation('Hello, world! How are you?'))
# ['Hello', ' world', ' How are you']
print(split_on_punctuation('one.two;three:four'))
# ['one', 'two', 'three', 'four']
Рішення
import re

def split_on_punctuation(s):
    return [part for part in re.split(r'[.,!?;:]', s) if part]
10

#

Використайте прапор `re.VERBOSE` (або `re.X`) для написання зрозумілого regex з коментарями, що перевіряє надійний пароль: мінімум 8 символів, мінімум одна велика, одна мала, одна цифра, один спеціальний символ (`!@#$%^&*`).

import re

def is_strong_password(s):
    # ваш код тут
    pass

print(is_strong_password('Abc@1234'))    # True
print(is_strong_password('abcdefgh'))    # False (немає великої/цифри/спец.)
print(is_strong_password('Abc@12'))      # False (занадто короткий)
Рішення
import re

def is_strong_password(s):
    pattern = re.compile(r'''
        ^                    # початок рядка
        (?=.*[A-Z])          # мінімум одна велика
        (?=.*[a-z])          # мінімум одна мала
        (?=.*\d)             # мінімум одна цифра
        (?=.*[!@#$%^&*])    # мінімум один спецсимвол
        .{8,}               # мінімум 8 символів загалом
        $                   # кінець рядка
    ''', re.VERBOSE)
    return bool(pattern.match(s))