Python - wprowadzenie

01 - Moduł `re` i wyrażenia regularne

Podczas jednej z lekcji poświęconej łańcuchom znaków poznaliśmy sposoby wyszukiwania i zamiany jednych łańcuchów w innych. Używaliśmy w tym celu metod obiektów typu String:

Metoda Działanie
startswitch(str) Wyszukiwanie sekwencji znaków na początku łańcucha
endswitch(str) Wyszukiwanie sekwencji znaków na końcu łańcucha
count(str [,START [, KONIEC]]) Liczba wystąpień sekwencji znaków
find(str [,START [, KONIEC]]) Wyszukiwanie sekwencji znaków od początku łańcucha. Zwracany jest indeks pierwszego wystąpienia. W przypadku nieznalezienia sekwencji zwracana jest wartość -1.
rfind(str [,START [, KONIEC]]) Wyszukiwanie sekwencji znaków od końca łańcucha. Zwracany jest indeks pierwszego wystąpienia od końca. W przypadku nieznalezienia sekwencji zwracana jest wartość -1.
index(str [,START [, KONIEC]]) Podobnie jak find(), ale w przypadku nieznalezienia sekwencji zwracany jest błąd.
replace(str1, str2 [,N]) Zmienia wystąpienia ciągu str1 na str2 w łańcuchu znaków. Jeśli poda się liczbę (N) to dokona się maksymalnie N zmian, w przypadku braku tego parametru, zmienią się wszystkie znalezione sekwencje znaków.

Metody te są użyteczne, ale mają pewne ograniczenia, na przykład pozwalają jedynie na znalezienie dokładnie takich sekwencji znaków, jakie zostały wskazane. Znacznie większe możliwości oferują wyrażenia regularne.

Moduł re

Do pracy z wyrażeniami regularnymi będziemy używać modułu re wchodzącego w skład Biblioteki Standardowej Pythona. Jego dokumentację można znaleźć pod adresem https://docs.python.org/3.8/library/re.html.

Zacznijmy od bardzo prostych przykładów pokazujących niektóre (!) dostępne w module funkcje, na razie bez wyrażeń regularnych. Zauważ, że przy tworzeniu ciągów znaków, które będziemy dopasowywać, będziemy używać surowych łańcuchów (ang. raw strings), które oznaczamy literą r lub R, dzięki czemu znak \ nie tworzy sekwencji ucieczkowej, czyli jest, wraz z następnym znakiem odczytywany literalnie. Nie zawsze jest to konieczne, ale jest uważane za dobrą praktykę, więc wprowadzimy ją od początku.

# Import modułu re
import re
wynik = re.match(r'Python', 'Python to nie tylko wąż, ale też język programowania.')
print(wynik)
<re.Match object; span=(0, 6), match='Python'>

Funkcja match() zwróciła obiekt typu Match, który zawiera informację o znalezionym ciągu znaków dopasowanym do szukanego, oraz jego lokalizacji. Oczywiście możemy łatwo te informacje uzyskać:

print(f"Dopasowano: '{wynik.group()}', początek: {wynik.span()[0]}, koniec: {wynik.span()[1]}")
Dopasowano: 'Python', początek: 0, koniec: 6

Powyżej, do pobrania lokalizacji dopasowania użyliśmy metody span(), która zwraca krotkę zawierającą indeksy początku i końca dopasowania. Wartości te mozna także uzyskać wywołując na obiekcie typy Match metody start() i end():

print(f"Dopasowano: '{wynik.group()}', początek: {wynik.start()}, koniec: {wynik.end()}")
Dopasowano: 'Python', początek: 0, koniec: 6

Metoda group() zwraca wynik dopasowania, z kolei span() zwraca krotkę zawierającą indeksy pierwszego i ostatniego znaku dopasowania w szukanym łańcuchu. Poszukajmy teraz ciągu ni.

wynik = re.match(r'ni', 'Python to nie tylko wąż, ale też język programowania.')
print(wynik)
None

Tym razem zamiast obiektu typu Match otrzymaliśmy None, tak jest, gdy nie znaleziono dopasowania. Stało się tak, ponieważ metoda match() dopasowuje szukany ciąg znaków od pierwszego znaku łańcucha, w którym szukamy. Zatem jeśli dopasowanie nie znajduje się na początku, ale gdzieś dalej (lub w ogóle go nie ma), otrzymujemy None.

Jeśli chcemy wyszukać ciąg znaków, niekoniecznie na początku łańcucha to użyjemy metody search():

wynik = re.search(r'ni', 'Python to nie tylko wąż, ale też język programowania.')
# Jeżeli zwrócony zostanie wynik
if wynik:
    print(f"Dopasowano: '{wynik.group()}', początek: {wynik.start()}, koniec: {wynik.end()}")
# Jeżeli zwrócone zostanie None
else:
    print('Nie znaleziono szukanego ciągu znaków.')
Dopasowano: 'ni', początek: 10, koniec: 12

Powyżej wykorzystywaliśmy metody match() i search() przekazując im bezpośrednio dwa argumenty: łańcuch szukany (czyli wzorzec) i łańcuch, w którym szukamy. Zwykle postępuje się jednak inaczej. Najpierw kompiluje się wzorzec, a potem wykorzystuje się otrzymany obiekt (typu Pattern) do wyszukiwania w ciągu znaków. Przy okazji przypiszmy łańcuch znaków do zmiennej, co zwiększy przejrzystość kodu.

# Łańcuch znaków, w którym wyszukujemy wzorca
tekst = 'Python to nie tylko wąż, ale też język programowania.'
# Kompilacja wzorca
wzorzec = re.compile(r'ni')
# Wyszukiwanie
wynik = wzorzec.search(tekst)
# Jeżeli zwrócony zostanie wynik
if wynik:
    print(f"Dopasowano: '{wynik.group()}', początek: {wynik.start()}, koniec: {wynik.end()}")
# Jeżeli zwrócone zostanie None
else:
    print('Nie znaleziono szukanego ciągu znaków.')
Dopasowano: 'ni', początek: 10, koniec: 12

Początkowo może się wydawać, że jest to niepotrzebna komplikacja, ale dzięki kompilowaniu wzorca wyszukiwanie przebiega szybciej, zatem dalej będziemy używać tego sposobu.

Aby znaleźć wszystkie wystąpienie wzorca, użyjemy funkcji findall(). Jeśli wzorzec zostanie znaleziony, zostanie zwrócona lista z dopasowanymi, niezachodzącymi na siebie, ciągami znaków. Jeśli nie, zostanie zwrócona pusta lista.

wzorzec = re.compile(r'ni')
# Wyszukiwanie
wynik = wzorzec.findall(tekst)
# Jeżeli zwrócony zostanie wynik
if len(wynik) > 0:
    print(f'Dopasowano: {wynik}')
# Jeżeli zwrócone zostanie None
else:
    print(f'{wynik} Nie znaleziono szukanego ciągu znaków.')
Dopasowano: ['ni', 'ni']

Metoda findall() pozwala uzyskać listę dopasowań, ale nie otrzymujemy informacji na temat ich położenia. Jeśli potrzebujemy takich danych, możemy użyć metody finditer(), która zwraca iterator - obiekt, który w tym przypadku zawiera obiekty typu Match i umożliwia uzyskanie ich np. w pętli for.

wzorzec = re.compile(r'ni')
# Wyszukiwanie
wynik = wzorzec.finditer(tekst)
for w in wynik:
    print(f'Dopasowano: "{w.group()}", początek: {w.start()}, koniec: {w.end()}')
Dopasowano: "ni", początek: 10, koniec: 12
Dopasowano: "ni", początek: 49, koniec: 51

Uwaga: pobierając z iteratora elementy, usuwamy je, nie można zatem ponownie się do nich odwołać. Rozwiązaniem tego problemu może być uzyskanie z niego listy elementów, co przy okazji umożliwia np. użycie funkcji len() aby pobrać liczbę wyników:

wzorzec = re.compile(r'ni')
# Wyszukiwanie
wynik = wzorzec.finditer(tekst)
lista_wynikow = list(wynik)
if len(lista_wynikow) > 0:
    print(f'Uzyskano {len(lista_wynikow)} wyniki/ów.')
    for w in lista_wynikow:
        print(f'Dopasowano: "{w.group()}", początek: {w.start()}, koniec: {w.end()}')
else:
    print('Brak wyników.')
Uzyskano 2 wyniki/ów.
Dopasowano: "ni", początek: 10, koniec: 12
Dopasowano: "ni", początek: 49, koniec: 51

Wzorca możemy też użyć do ,,pocięcia'' ciągu znaków i uzyskania listy elementów, występujących między dopasowaniami wzorca. Służy do tego funkcja split(). Podzielmy tekst na poszczególne słowa:

wzorzec = re.compile(r' ')
# Cięcie łańcucha znaków
wynik = wzorzec.split(tekst)
print(f'Uzyskano {len(wynik)} wyniki/ów.')
for w in wynik:
    print(w)
Uzyskano 9 wyniki/ów.
Python
to
nie
tylko
wąż,
ale
też
język
programowania.

Kolejną użyteczną metodą jest sub(), która pozwala na zmianę wzorca na inny ciąg znaków:

wzorzec = re.compile(r'ni')
# Zamiana
zmieniony = wzorzec.sub('Ni!', tekst)
print(zmieniony)
Python to Ni!e tylko wąż, ale też język programowaNi!a.

Podsumujmy poznane metody:

Metoda Działanie Zwracany obiekt
match() Dopasowanie wzorca na początku łańcucha. Match
search() Dopasowanie w dowolnym miejscu łańcucha. Match
findall() Dopasowanie wszystkich, niezachodzących dopasowań. lista
finditer() Dopasowanie wszystkich, niezachodzących dopasowań. iterator
split() Pocięcie łańcucha w miejscach dopasowań. lista
sub() Zamiana dopasowania wzorca na inny łańcuch. łańcuch znaków

Więcej metod można znaleźć w dokumentacji

W większości powyższych przykładów używaliśmy tych metod wywołując je na obiektach typu Pattern, ale możemy też ich użyć jako funkcji modułu re, choć należy pamiętać, że w takich przypadkach trzeba dodać dodatkowy argument określający wzorzec:

tekst = 'Python to nie tylko wąż, ale też język programowania.'
print(re.match(r'Python', tekst)) # Zwracany obiekt typu Match
print(re.search(r'ni', tekst)) # Zwracany obiekt typu Match
print(re.findall(r'ni', tekst)) # Zwracana lista
for w in re.finditer(r'ni', tekst): # Zwracany iterator
    # Zwracany obiekt typu Match
    print(f'Dopasowano: "{w.group()}", początek: {w.start()}, koniec: {w.end()}')
print(re.split(r' ', tekst)) # Zwracana lista
print(re.sub(r'ni', "Ni!", tekst)) # Zwracany łańcuch znaków
<re.Match object; span=(0, 6), match='Python'>
<re.Match object; span=(10, 12), match='ni'>
['ni', 'ni']
Dopasowano: "ni", początek: 10, koniec: 12
Dopasowano: "ni", początek: 49, koniec: 51
['Python', 'to', 'nie', 'tylko', 'wąż,', 'ale', 'też', 'język', 'programowania.']
Python to Ni!e tylko wąż, ale też język programowaNi!a.

Wyrażenia regularne

Wyrażenia regularne (ang. regular expressions, w skrócie regex lub regexp) pozwalają na dopasowanie nie tylko dokładnych ciągów znaków, ale także opisać z różnym stopniem ogólności, pewne grupy ciągów znaków, czy ich kategorie, a następnie je wyszukać, znaleźć ich położenie, usunąć, czy zamienić na inne.

Niektóre znaki w wyrażeniach regularnych odczytywanie są dosłownie (np. litery, cyfry), inne mają znaczenie specjalne. Na przykład . (kropka) oznacza dowolny znak a + wskazuje, że poprzednia litera, lub wyrażenie może wystąpić raz, lub więcej razy. Ponadto znaczenie niektórych znaków, np. ^ może się zmieniać zależnie od kontekstu. Szczególnym przypadkiem jest znak lewego ukośnika (ang. backslash), czyli , który nadaje specjalne znaczenie kolejnemu znakowi. Poznaliśmy np. \n oznaczający nową linię, czy \t oznaczający tabulator, ale jest takich oznaczeń znacznie więcej, i \ może także ,,przywracać'' dosłowne znaczenie znakom, które zwykle mają znaczenie specjalne. Na przykład, jak wspomniałem wyżej, w wyrażeniach regularnych . oznacza dowolny znak, ale \. oznacza po prostu znak kropki.

Początkowo wyrażenia regularne, zwłaszcza złożone, mogą się wydawać niezrozumiałym szyfrem, ale z czasem stają się coraz bardziej czytelne. Warto się z nimi zapoznać, ponieważ są potężnym narzędziem, niezwykle przydatnym w pracy z tekstem, danymi czy sekwencjami nukleotydów. Co więcej, składnia i zasady dotyczące wyrażeń regularnych, które tu przedstawię, są podobne w wielu innych językach programowania, programach przeznaczonych do pracy z tekstem (np. grep, sed) czy edytorach tekstu. Zatem opanowanie ich może być przydatne także poza programowaniem w Pythonie.

Zacznijmy zatem przygodę z wyrażeniami regularnymi. Jak wspomniałem kropka (.) oznacza dowolny znak. Sprawdźmy, jak to działa:

tekst = 'Python to nie tylko wąż, ale też język programowania.'
wzorzec = re.compile(r'.')
print(wzorzec.findall(tekst))
['P', 'y', 't', 'h', 'o', 'n', ' ', 't', 'o', ' ', 'n', 'i', 'e', ' ', 't', 'y', 'l', 'k', 'o', ' ', 'w', 'ą', 'ż', ',', ' ', 'a', 'l', 'e', ' ', 't', 'e', 'ż', ' ', 'j', 'ę', 'z', 'y', 'k', ' ', 'p', 'r', 'o', 'g', 'r', 'a', 'm', 'o', 'w', 'a', 'n', 'i', 'a', '.']

Metoda findall() zwróciła listę wszystkich znaków, ponieważ zgodnie z opisem zwraca listę, niezachodzących na siebie dopasowań. W tym przypadku każdy znak w teście został dopasowany do wzorca.

Kwantyfikatory

Kwantyfikatory pozwalają na określenie ile wystąpień znaków, czy wzorców jest konieczne, aby doszło do dopasowania.

Uzupełnijmy wyrażenie o znak +:

import re
tekst = "ACGGGATTAGGGGGGACCCGGT"
print(f'Łańcuch: {tekst}')
wzorzec = re.compile(r'.+')
print(wzorzec.findall(tekst))
Łańcuch: ACGGGATTAGGGGGGACCCGGT
['ACGGGATTAGGGGGGACCCGGT']

Znak + oznacza, jeden lub więcej dopasowań wzorca znajdującego się przed znakiem. Powyższy wzorzec wyglądał tak: .+, zatem oznaczał ,,jeden, lub więcej dowolnych znaków'' (bez \n), zatem dopasowane zostały wszystkie znaki w łańcuchu.

Teraz zamieńmy + na *:

print(f'Łańcuch: {tekst}')
wzorzec = re.compile(r'.*')
print(wzorzec.findall(tekst))
Łańcuch: ACGGGATTAGGGGGGACCCGGT
['ACGGGATTAGGGGGGACCCGGT', '']

Znak * oznacza zero lub więcej dopasowań, zatem otrzymaliśmy dodatkowo wynik z pustym dopasowaniem. Zamieńmy w powyższych przykładach wzorców . na X, który nie występuje w tekście:

print(f'Łańcuch: {tekst}')
wzorzec = re.compile(r'X+')
print(f'Jeden lub więcej X: {wzorzec.findall(tekst)}')
wzorzec = re.compile(r'X*')
print(f'Zero lub więcej X: {wzorzec.findall(tekst)}')
Łańcuch: ACGGGATTAGGGGGGACCCGGT
Jeden lub więcej X: []
Zero lub więcej X: ['', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '']

Skoro w tekście nie było znaku X, w pierwszym przypadku nie uzyskaliśmy wyniku, w drugim otrzymaliśmy listę dopasowań ,,zera lub więcej X'', w takim przypadku został dopasowany każdy znak.

Użyjmy teraz znaku ?, który oznacza: ,,zero, lub jeden'':

print(f'Łańcuch: {tekst}')
wzorzec = re.compile(r'.?')
print(wzorzec.findall(tekst))
wzorzec = re.compile(r'.?T')
print(wzorzec.findall(tekst))
Łańcuch: ACGGGATTAGGGGGGACCCGGT
['A', 'C', 'G', 'G', 'G', 'A', 'T', 'T', 'A', 'G', 'G', 'G', 'G', 'G', 'G', 'A', 'C', 'C', 'C', 'G', 'G', 'T', '']
['AT', 'T', 'GT']

Powyższe dopasowania z użyciem znaków * oraz + były zachłanne, co oznacza, że starały się dopasować tak wiele znaków, jak to tylko możliwe (zastanów się, czemu drugie dopasowanie dla wzorca .?t zwróciło pojedynczą literą T). Dodanie znaku ?, zmienia to zachowanie, na leniwe - dopasowane jest tak mało znaków, jak to możliwe. Przy okazji zwróć uwagę, także w dalszej części lekcji, jak zmienia się znaczenie znaku ? w zależności od kontekstu.

print(f'Łańcuch: {tekst}')
print(tekst)
wzorzec = re.compile(r'.+')
print(f'Dopasowanie zachłanne: {wzorzec.findall(tekst)}')
wzorzec = re.compile(r'.+?')
print(f'Dopasowanie leniwe: {wzorzec.findall(tekst)}')
Łańcuch: ACGGGATTAGGGGGGACCCGGT
ACGGGATTAGGGGGGACCCGGT
Dopasowanie zachłanne: ['ACGGGATTAGGGGGGACCCGGT']
Dopasowanie leniwe: ['A', 'C', 'G', 'G', 'G', 'A', 'T', 'T', 'A', 'G', 'G', 'G', 'G', 'G', 'G', 'A', 'C', 'C', 'C', 'G', 'G', 'T']

Jeszcze jeden przykład:

print(f'Łańcuch: {tekst}')
wzorzec = re.compile(r'G+')
print(f'Dopasowanie zachłanne: {wzorzec.findall(tekst)}')
wzorzec = re.compile(r'G+?')
print(f'Dopasowanie leniwe: {wzorzec.findall(tekst)}')
Łańcuch: ACGGGATTAGGGGGGACCCGGT
Dopasowanie zachłanne: ['GGG', 'GGGGGG', 'GG']
Dopasowanie leniwe: ['G', 'G', 'G', 'G', 'G', 'G', 'G', 'G', 'G', 'G', 'G']

W pierwszym przypadku, dopasowane zostało tak wiele liter G jak to możliwe, w drugim, tak mało jak to możliwe.

Możemy także określić ile dokładnie powinno być poprzedzających kwantyfikator wzorców. W takim przypadku umieszczamy odpowiednie liczby w parze nawiasów klamrowych: wzorzec{n} - dokładnie n dopasowań, wzorzec{n,m} - miedzy n a m dopasowań, wzorzec{n,} - n lub więcej dopasowań, wzorzec{,m} - m lub mniej dopasowań, z 0 włącznie:

print(f'Łańcuch: {tekst}')
wzorzec = re.compile(r'G{3}')
print(f'{wzorzec.findall(tekst)}')
wzorzec = re.compile(r'G{2,3}')
print(f'{wzorzec.findall(tekst)}')
wzorzec = re.compile(r'G{2,}')
print(f'{wzorzec.findall(tekst)}')
wzorzec = re.compile(r'G{,3}')
print(f'{wzorzec.findall(tekst)}')
Łańcuch: ACGGGATTAGGGGGGACCCGGT
['GGG', 'GGG', 'GGG']
['GGG', 'GGG', 'GGG', 'GG']
['GGG', 'GGGGGG', 'GG']
['', '', 'GGG', '', '', '', '', 'GGG', 'GGG', '', '', '', '', 'GG', '', '']

Podsumujmy:

Kwantyfikator Znaczenie
+ Jedno lub więcej dopasowań.
* Zero lub więcej dopasowań.
? Zero lub jedno dopasowanie.
{n} dokładnie n dopasowań.
{n,m} miedzy n a m dopasowań.
{n,} n lub więcej, dopasowań.
{,m} m lub mniej dopasowań, z 0 włącznie.

UWAGA: jeśli znaki +, * czy ? mają w wyrażeniu oznaczać konkretnie te znaki, to zależy je poprzedzić znakiem ucieczki \. Np. \+ oznacza znak plusa, a nie jedno lub więcej dopasowań.

Wzorce poprzedzające i następujące po szukanym wzorcu

Możliwe jest też dopasowanie wzorca w zależności od tego, czy jest on (lub nie jest) poprzedzony lub poprzedza dany wzorzec.

wzorzec(?=X) oznacza, że po wzorcu powinien występować X:

import re
tekst = "ACGGGATTAGGGGGGACCCGGT"
print(f'Łańcuch: {tekst}')
wzorzec = re.compile(r'G+(?=A)')
print(f'{wzorzec.findall(tekst)}')
Łańcuch: ACGGGATTAGGGGGGACCCGGT
['GGG', 'GGGGGG']

Zwróć uwagę, że X nie jest częścią zwracanego dopasowania.

wzorzec(?!X) oznacza, że po wzorcu nie powinien występować X:

print(f'Łańcuch: {tekst}')
wzorzec = re.compile(r'G+(?!A)')
print(f'{wzorzec.findall(tekst)}')
Łańcuch: ACGGGATTAGGGGGGACCCGGT
['GG', 'GGGGG', 'GG']

(?<=X)wyrażenie - X poprzedza wyrażenie.

print(f'Łańcuch: {tekst}')
wzorzec = re.compile(r'(?<=C)G+')
print(f'{wzorzec.findall(tekst)}')
Łańcuch: ACGGGATTAGGGGGGACCCGGT
['GGG', 'GG']

(?<!X)wyrażenie - X nie poprzedza wyrażenia.

print(f'Łańcuch: {tekst}')
wzorzec = re.compile(r'(?<!C)G+')
print(f'{wzorzec.findall(tekst)}')
Łańcuch: ACGGGATTAGGGGGGACCCGGT
['GG', 'GGGGGG', 'G']

UWAGA: omawiane tu wzorce poprzedzające i następujące po szukanym wzorcu, muszą mieć okresloną długość, nie stosujemy zatem takich znaków jak +, czy *.

Podsumujmy:

Wzorzec Znaczenie
wzorzec(?=X) po wzorcu powinien występować X
wzorzec(?!X) po wzorcu nie powinien występować X
(?<=X)wyrażenie X poprzedza wyrażenie.
(?<!X)wyrażenie X nie poprzedza wyrażenia.

Początek i koniec łańcucha

Istnieją także dwa znaki, pozwalające ,,zakotwiczyć'' wzorzec przy początku (^) i końcu ($) łańcucha znaków:

import re
tekst = "Jesteśmy rycerzami, którzy mówią: Ni!"
wzorzec = re.compile(r'^Jesteśmy')
print(f'{wzorzec.findall(tekst)}')
wzorzec = re.compile(r'^rycerzami')
print(f'{wzorzec.findall(tekst)}')
wzorzec = re.compile(r'Jesteśmy$')
print(f'{wzorzec.findall(tekst)}')
wzorzec = re.compile(r'Ni!$')
print(f'{wzorzec.findall(tekst)}')
['Jesteśmy']
[]
[]
['Ni!']

…lub…

Można także tak zdefiniować wyrażenie, aby uwzględniały kilka możliwości, używając znaku |, który można odczytać jako “albo” czy “lub”:

import re
tekst = "Jesteśmy rycerzami, którzy mówią: Ni!"
wzorzec = re.compile(r'Jesteśmy|którzy|Ni!')
print(f'{wzorzec.findall(tekst)}')
['Jesteśmy', 'którzy', 'Ni!']

Wykorzystując parę nawiasów [] można na utworzyć wyrażenie, które pozwala na dopasowanie któregokolwiek ze znajdujących się w nim znaków (znak | jest tu zbędny). Na przykład stwórzmy wzorzec dopasowujący sekwencje GC poprzedzone A lub C. Zauważ, że zwracane są całe dopasowania:

import re
tekst = "AAAGCCTGCTTAGCC"
wzorzec = re.compile(r'[AT]GC')
print(f'{wzorzec.findall(tekst)}')
['AGC', 'TGC', 'AGC']

Jeśli dodamy w parze nawiasów kwadratowych [], znak ^, będzie to oznaczało, że następne znaki nie mogą znaleźć się w dopasowaniu (zwróć uwagę na inne znaczenie znaku ^, niż w przykładach powyżej):

tekst = "AAAGCCTGCTTAGCC"
wzorzec = re.compile(r'[^AC]GC')
print(f'{wzorzec.findall(tekst)}')
['TGC']

Znaki specjalne i zakresy znaków

Wyrażenia regularne pozwalają także na określenie pewnych typów i zakresów znaków. Zauważ, że wyrażenie z wielką litera oznacza przeciwieństwo wyrażenia z małą literą (np. \s i `\S'):

Wzorzec Znaczenie
\s biały znak
\S nie-biały znak
\d cyfra
\D nie-cyfra
\w znaki alfanumeryczne (litery i cyfry) oraz _
\W znaki nie-alfanumeryczne i nie _
\b początek lub koniec ,,słowa'' - formalnie, \b oznacza granicę między \w i \W
\B nie początek lub koniec ,,słowa''
[a-z] małe litery
[A-Z] wielkie litery
[0-9] cyfry

Wypróbujmy je na kilku przykładach:

import re
tekst = "AA-AGC*Caggt$12345_CGCT TAGacctgaCC 1234\n"
print(tekst)

def szukaj(opis, wzorzec):
    print(f'{opis}: {wzorzec}')
    wz = re.compile(wzorzec)
    print(f'\t{wz.findall(tekst)}')
    
szukaj('Jeden lub więcej nie-białych znaków', r'\S+')
szukaj('Jeden lub więcej cyfr', r'\d+')
szukaj('Jeden lub więcej nie-cyfr',r'\D+')
szukaj('Jeden lub więcej znaków alfanumerycznych + _', r'\w+')
szukaj('Jeden lub więcej znaków nie-alfanumerycznych + _', r'\W+')
szukaj('Trzy znaki od granicy słowa', r'\b.{3}')
szukaj('Jeden lub więcej cyfr', r'[0-9]+')
szukaj('Jeden lub więcej małych liter',r'[a-z]+')
szukaj('Jeden lub więcej wielkich liter', r'[A-Z]+')
AA-AGC*Caggt$12345_CGCT TAGacctgaCC 1234

Jeden lub więcej nie-białych znaków: \S+
	['AA-AGC*Caggt$12345_CGCT', 'TAGacctgaCC', '1234']
Jeden lub więcej cyfr: \d+
	['12345', '1234']
Jeden lub więcej nie-cyfr: \D+
	['AA-AGC*Caggt$', '_CGCT TAGacctgaCC ', '\n']
Jeden lub więcej znaków alfanumerycznych + _: \w+
	['AA', 'AGC', 'Caggt', '12345_CGCT', 'TAGacctgaCC', '1234']
Jeden lub więcej znaków nie-alfanumerycznych + _: \W+
	['-', '*', '$', ' ', ' ', '\n']
Trzy znaki od granicy słowa: \b.{3}
	['AA-', 'AGC', '*Ca', '$12', ' TA', ' 12']
Jeden lub więcej cyfr: [0-9]+
	['12345', '1234']
Jeden lub więcej małych liter: [a-z]+
	['aggt', 'acctga']
Jeden lub więcej wielkich liter: [A-Z]+
	['AA', 'AGC', 'C', 'CGCT', 'TAG', 'CC']

Flagi

Czytając dokumentację modułu re można zauważyć, że przy opisie wielu funkcji pojawia się argument flags, np: re.compile(pattern, flags=0). Ustawiając opdowiednie flagi, można zmienić zachowanie tych funkcji. Przyjrzyjmy się dwu z nich.

Flaga re.IGNORECASE, jak wskazuje nazwa, pozwala na ignorowanie wielkości znaków przy dopasowywaniu:

import re
tekst = "AAGCGCCAAATGCGCGCTTAAAAGCC"
# Bez flagi
wzorzec = re.compile(r'gc')
print(f'{wzorzec.findall(tekst)}')
# z flagą
wzorzec = re.compile(r'gc', flags=re.IGNORECASE)
print(f'{wzorzec.findall(tekst)}')
[]
['GC', 'GC', 'GC', 'GC', 'GC', 'GC']

Flaga re.DOTALL pozwala na dopasowanie przez znak kropki . wszystkich znaków, także znaku nowej linii:

tekst = 'Jesteśmy rycerzami,\nktórzy mówią: Ni!'
print(tekst)
# Bez flagi
wzorzec = re.compile(r'rycerzami,.którzy')
print(f'{wzorzec.findall(tekst)}')
# z flagą
wzorzec = re.compile(r'rycerzami,.którzy', flags=re.DOTALL)
print(f'{wzorzec.findall(tekst)}')
Jesteśmy rycerzami,
którzy mówią: Ni!
[]
['rycerzami,\nktórzy']

Bardziej złożone wyrażenia

Dotychczasowe wzorce były dość proste, możemy jednak budować bardziej złożone, pozwalające na dopasowanie bardziej skomplikowanych ciągów znaków.

Na przykład z podanego tekstu, który może być odczytanym plikiem w formacie csv, wybierzmy rzędy/linie, które zawierają dane dla Rumex acetosa o wartości pomiaru przynajmniej trzycyfrowej, zawierające wartość T w polu ,,żywy'':

tekst = """
nr.,gatunek,próbka,pomiar,żywy
1,Rumex acetosa,A,243,T
2,Rumex acetosa,B,91,T
3,Zea mays,A,2432,T
4,Rumex thyrsiflorus,A,347,T
5,Rumex acetosa,C,3324,F
6,Rumex acetosella,A,347,T
7,Rumex thyrsiflorus,B,445,F
8,Oryza sativa,A,237,T
9,Rumex acetosa,C,1224,T
"""

wzorzec = re.compile(r'.+Rumex acetosa.+\d{3,}.+T')
print(f'{wzorzec.findall(tekst)}')
['1,Rumex acetosa,A,243,T', '9,Rumex acetosa,C,1224,T']

Rozbierzmy na podstawowe elementy wzorzec: .+Rumex acetosa.+\d{3,}.+T:

  • .+ -jeden lub więcej dowolnych znaków
  • Rumex acetosa - wiadomo
  • .+ - jeden lub więcej dowolnych znaków
  • \d{3,} - trzy lub więcej cyfr
  • .+- jeden lub więcej dowolnych znaków
  • T - litera ,,T''

Grupy przechwytywania i odwołania wsteczne

Kolejnym narzędziem, które często przydaje się przy pracy z wyrażeniami regularnymi, są grupy przechwytywania i odwołania wsteczne (ang. backreferences). Pozwalają one np. zmieniać fragmenty wyrażeń, czy ich kolejność. Jeśli obejmiemy parą nawiasów (``) fragment wyrażenia, to do dopasowania będzie można się odwołać za pomocą odwołań wstecznych, które działają podobnie do zmiennych. Oznaczamy je kolejno \1, \2\99.

Przeanalizujmy to na przykładach:

import re
tekst = 'Co nam dali Rzymianie?'
wzorzec = re.compile(r'\w+ (\w+) (\w+) (\w+)\?')
print(wzorzec.sub(r'\3 \1 \2:', tekst))
Rzymianie nam dali:

Wzorzec \w+ (\w+) (\w+) (\w+)\? możemy odczytać tak: słowo (jeden lub więcej znaków alfanumerycznych), spacja, słowo, spacja, słowo, spacja, słowo, znak ?. Zauważ jednak, że wyrażenia oznaczające drugie, trzecie i czwarte słowo są objęte nawiasami. Zostają one przyporządkowane kolejno do odwołań \1, \2, \3. Kiedy użyjemy metody sub(), powyższe dopasowanie zamieniamy na łańcuch składający się kolejno z wyrażeń przypisanych do odwołań \3, \1 i \2 (tak jakby to były nazwy zmiennych) poprzedzielanych spacjami, po których umieszczamy dwukropek.

W drugim przykładzie użyjemy używanego wcześniej fragmentu hipotetycznego pliku .csv z danymi, dla uproszczenia z usuniętą linią z nagłówkami kolumn. Pobierzmy z każdej linii nazwę organizmu oraz wartość pomiaru i wydrukujmy je:

tekst = """
1,Rumex acetosa,A,243,T
2,Rumex acetosa,B,91,T
3,Zea mays,A,2432,T
4,Rumex thyrsiflorus,A,347,T
5,Rumex acetosa,C,3324,F
6,Rumex acetosella,A,347,T
7,Rumex thyrsiflorus,B,445,F
8,Oryza sativa,A,237,T
9,Rumex acetosa,C,1224,T
"""
wzorzec = re.compile(r'\d+,(\w+ \w+).+,(\d+).+')
print(wzorzec.sub(r'\1 - \2', tekst))
Rumex acetosa - 243
Rumex acetosa - 91
Zea mays - 2432
Rumex thyrsiflorus - 347
Rumex acetosa - 3324
Rumex acetosella - 347
Rumex thyrsiflorus - 445
Oryza sativa - 237
Rumex acetosa - 1224

Zastosowane wyrażenie \d+,(\w+ \w+).+,(\d+).+ można odczytać tak (w nawiasach części wyrażenia, do których przyporządkowujemy odwołania wsteczne):

  • \d+ - jedna lub więcej cyfr
  • , - przecinek
  • (\w+ \w+) - dwa ciągi znaków alfanumerycznych (słowa) przedzielone spacją - przyporządkowane do odwołania \1
  • .+ - jeden lub więcej dowolnych znaków
  • , - przecinek
  • (\d+) - jedna lub więcej cyfr - przyporządkowane do odwołania \2
  • .+ - jeden lub więcej dowolnych znaków

Zadania

Zadanie 1

Wg. standardu IUPAC, poza oznaczeniami literami G, A, C, T (w DNA), U (w RNA) zasad, w zapisie sekwencji kwasów nukleinowych można także używać liter oznaczających możliwe występowanie w danym miejscu więcej niż jednej zasady, np. Y oznacza T lub C (,,pYrimidine'').

Napisz kod, który w podanej sekwencji (np. DAARYCGGGTANTTM) znajdzie wszystkie znaki poza tymi, oznaczające konkretne zasady (G, A, T, C) oraz poda miejsce w sekwencji ich występowania, począwszy od 1.

Zadanie 2

Pobierz plik sequence.fasta. Zawiera on zestaw sekwencji genu atp6 pobranych z bazy GenBank. Odczytaj plik i przepisz jego zawartość do innego pliku, o nazwie sekwencje-atp6.fasta, ale ze zmianami linii opisującej sekwencje tak, aby:

  • pozostały w nich wyłącznie numery GenBank, ale z usuniętym numerem wersji,
  • dwuczłonowa nazwa rodzajowa i gatunkowa, bez nazw podgatunku etc.
  • wszystkie spacje zostały zmienione na podkreślniki (_)

Czyli np. linia:

>KU180469.1 Orobanche alba subsp. alba clone 50 ATPase subunit 6 (atp6) gene, partial cds; mitochondrial

Po zmianach powinna wyglądać tak:

>KU180469_Orobanche_alba

Przykładowe rozwiązania

Zadanie 1

import re
tekst = 'DAARYCGGGTANTTM'
wzorzec = re.compile(r'[^AGCTU]')
wynik = wzorzec.finditer(tekst)
for w in wynik:
    print(f'Znak: "{w.group()}", miejsce: {w.start()+1}')
Znak: "D", miejsce: 1
Znak: "R", miejsce: 4
Znak: "Y", miejsce: 5
Znak: "N", miejsce: 12
Znak: "M", miejsce: 15

Zadanie 2

import re
plik_odczyt = open('sequence.fasta', 'rt')
plik_zapis = open('sekwencje-atp6.fasta', 'wt')
for linia in plik_odczyt:
    wzorzec = re.compile(r'(>\w+).\d (\w+) (\w+).+')
    print(wzorzec.sub(r'\1_\2_\3', linia), end='', file=plik_zapis)
plik_odczyt.close()
plik_zapis.close()
Last updated on 27 Feb 2021
Published on 27 Feb 2021