Wyzwanie Python #6: Wyjątki i operacje na plikach

Maciej Bartoszuk 6 stycznia 2022
 

W dzisiejszym odcinku naszych wyzwań podamy swego rodzaju informacje uzupełniające, które ułatwią nam obsługę błędów oraz pozwolą choć trochę “otworzyć się na świat”, czyli obsługiwać pliki i foldery.

Wyjątki

Pewną trudnością w programowaniu jest zarządzanie błędami. Te mogą pojawić się z zupełnie prozaicznych przyczyn: użytkownik podał nam ścieżkę do pliku, który nie istnieje albo do którego nie ma praw dostępu, pojawiło się dzielenie przez zero czy próba policzenia silni dla liczby ujemnej. Jak powinna zachować się funkcja, którą piszemy, gdy pojawi się taki problem? W przypadku silni możemy się umówić, że gdy zwrócimy -1, to oznacza, że argument był niepoprawny. Jednak dla funkcji, które mogą zwrócić dowolną wartość (np. dzielenie), nie możemy się umówić na taką wartość, gdyż każda z nich może być po prostu poprawną odpowiedzią. Co więcej, nawet gdy pewna wartość zwracana oznacza błąd, dalsze postępowanie jest dość problematyczne. Bo trzeba przy każdym wywołaniu napisać instrukcję warunkową, która sprawdza, czy czasem nie otrzymaliśmy błędu. Poza tym być może powinniśmy przerwać działanie programu, lub przenieść się całkiem mocno wstecz w łańcuchu wywołań funkcji?

Na te wszystkie bolączki został wymyślony mechanizm wyjątków. Wprowadźmy dwa słowa, których będziemy używać w odniesieniu do wyjątków: gdy pojawia się błąd i chcemy o tym poinformować, rzucamy wyjątek. Następnie, w innej partii kodu, gdzie będziemy obsługiwać sytuację wyjątkową, mówimy, że łapiemy wyjątek.

Wyjątki tworzą hierarchię klas. Pozwala to na selektywną obsługę błędów: być może jesteśmy przygotownani na fakt, że danego pliku na dysku zwyczajnie nie ma, jednak nie przewidujemy obsługi sytuacji dzielenia przez zero? Możemy także, dzięki różnym klasom wyjątków, napisać dwa osobne fragmenty kodów, obsługujące różne rodzaje błędów. Co więcej, każdy typ wyjątku może nieść inne informacje, które są adekwatne do zaistniałej sytuacji.

Zacznijmy od typowej sytuacji, która mogła zajść chociażby w kalkulatorze, gdy wczytujemy dane od użytkownika z klawiatury, spodziewamy się liczby, jednak otrzymujemy coś, co liczbą nie jest. Jak się wtedy zachować?

napis_wczytany = "2,5"
try:
  liczba = float(napis_wczytany)
  print(f"Liczbą jest {liczba}")
except ValueError as e:
  print("Och nie, nie udało się sparsować liczby! Szczegóły poniżej:")
  print(e)
print("tutaj kod działa zwyczajnie, reszta programu")
## Och nie, nie udało się sparsować liczby! Szczegóły poniżej:
## could not convert string to float: '2,5'
## tutaj kod działa zwyczajnie, reszta programu

Gdy spodziewamy się, że dany fragment kodu może rzucać wyjątkami, opakowujemy go w konstrukcję try-except. Kod, który chcemy wykonać, a który może rzucić wyjątek, zapisujemy po try:. Następnie, na dole tego kodu, piszemy except, po czym piszemy nazwę klasy wyjątku, a także as, po którym mówimy, jakim identyfikatorem (w jakiej zmiennej) chcemy się odnosić do instancji tego wyjątku. Najważniejsza jest nazwa klasy, aby ustalić, jaki typ błędów łapiemy. Konkretna instancja, w przykładzie e, przydaje się, gdy np. chcemy wyświetlić komunikat błędu na ekran. Teoretycznie instanacja ma swoje pola, do których możemy się odnieść, jednak rzadko się z nich korzysta.

Listę wbudowanych klas wyjątków znajdziemy pod tym adresem. Szczególnej uwadze polecamy IndexError, gdy odwołujemy się do nieistniejącego elementu listy, FileNotFoundError, gdy plik nie istnieje, ZeroDivisionError dla dzielenia przez zero i wymieniony w przykładzie ValueError, gdy argumenty funkcji są błędne.

Teraz omówmy drugą stonę: to my rzucimy wyjątek w naszej funkcji. Weźmy za przykład wspomnianą już wcześniej silnię:

def silnia(n):
  if n < 0:
    raise ValueError("silnia niezdefiniowana dla liczb ujemnych")
  wynik = 1
  for i in range(1, n+1):
    wynik *= i
  return wynik

Jak widzimy, z punktu widzenia twórcy funkcji nie dbamy o to, jak błąd zostanie obsłużony: naszą odpowiedzialnością jest tylko to, aby go zgłosić. Służy do tego słowo kluczowe raise. Po nim podajemy instancję wyjątku, w praktyce najczęściej wywołujemy tu jego konstruktor. Głównym argumentem konstruktora jest treść komunikatu o błędzie. Po słowie raise cała reszta funkcji jest przerywana. Następnie poszukiwane jest pierwsze miejsce, gdzie występuje blok try-except, w którym wymieniona jest nasza klasa błędu. Może to być funkcja wywołująca naszą, albo np. funkcja, która wywołała funkcję, która wywołała naszą.

Spójrzmy na przykład:

try:
  print(f"Silnia z -5 to {silnia(-5)}")
except ValueError as e:
  print("Och nie, coś poszło nie tak! Szczegóły poniżej:")
  print(e)
## Och nie, coś poszło nie tak! Szczegóły poniżej:
## silnia niezdefiniowana dla liczb ujemnych

Na koniec dodajmy, że istnieje jeszcze konstrukcja try-except-finally. finally służy do zdefiniowania kodu, który ma się wykonać na końcu zawsze, zarówno wtedy, gdy kod wykonał się poprawnie, jak i niepoprawnie. Taka klauzula służy zwalnianiu tzw. zasobów. Zasób to coś cennego, co pozyskujemy “ze świata zewnętrznego”, czego jest ograniczona ilość, a ten, od którego zasób pozyskujemy, chce, abyśmu mu to “oddali”. Przykładem może być połączenie do bazy danych. Serwery bazodanowe zazwyczaj skonstruowane są tak, że mają pewną ciągle działającą pulę wątków (podprogramów). Gdy ktoś (na takiego “ktosia” mówimy klient) nawiąże połączenie z takim serwerem, zostaje mu przydzielony jeden z wątków z puli. Wątek ten zajmuje się odpowiadaniem na jego zapytania. Gdy wszystkie wątki z puli zostaną przydzielone klientom, kolejny nie może być obsłużony i zostaje odesłany z kwitkiem. Dlatego tak ważne jest zwalnianie zasobów. Dlatego istnieje klauzula finally: żeby mieć pewność, bez powtarzania kodu, że zasób zostanie zwolniony, niezależnie od tego, czy nastąpiła sytuacja wyjątkowa czy też nie. Innym przykładem może być otworzony plik na dysku twardym, do czego przejdziemy za moment. W poniższym przykładzie jedynie zasymulujemy pozyskiwanie zasobu i jego zwalnianie poprzez odpowiednie komunikaty na ekran:

try:
  print("Pozyskuję zasób")
  print(f"Silnia z -5 to {silnia(-5)}")
except ValueError as e:
  print("Och nie, coś poszło nie tak! Szczegóły poniżej:")
  print(e)
finally:
  print("Zwalniam zasób")
## Pozyskuję zasób
## Och nie, coś poszło nie tak! Szczegóły poniżej:
## silnia niezdefiniowana dla liczb ujemnych
## Zwalniam zasób

Możliwe jest tworzenie własnych klas wyjątków, ale robi się to niezmiernie rzadko i wykracza to poza nasz poradnik kierowany do osób początkujących.

Pliki

Do tej pory nasze programy działały tylko w świecie konsoli. Teraz pokażemy, jak czytać i zapisywać do plików. Załóżmy, że mamy plik o następującej treści:

Ala ma kota.
Kot ma Alę.

Niech jego sciezka to będzie ('r' służy temu, aby \ nie był interpretowany jako znak specjalny):

sciezka_do_pliku = r"C:\przykladowy.txt"

Aby go odczytać i wypisać na ekran, napiszemy:

f = open(sciezka_do_pliku)
print(f.read())
f.close()
## Ala ma kota.
## Kot ma Alę

Tutaj mamy wszystko: open() służy otworzeniu połączenia do pliku. Jest to wspomniane wcześniej pozyskanie zasobu. Następnie następuje użycie metody read(). Odczytuje ona całą treść pliku za jednym zamachu. Po pojedynczym odczytaniu, drugie wywołanie zwróci nam napis pusty. Na końcu jest close(), zamknięcie połączenia do pliku. Jest to zwolnienie zasobu.

Aby upewnić się, że plik na pewno zostanie zamknięty, moglibyśmy napisać blok try-catch-finally:

try:
    f = open(sciezka_do_pliku)
    print(2/0)
    print(f.read())
except ZeroDivisionError as e:
    print(e)
finally:
    f.close()
## division by zero

Jednak istnieje jeszcze drugi zapis. Używamy słowa kluczowego with. Wtedy definiujemy zasób, mówimy co chcemy zrobić, gdy go pozyskamy, a na końcu, po wykonaniu całej klauzuli, zasób jest zwolniony, niezależnie od tego, czy wydarzyła się sytuacja wyjątkowa czy też nie:

try:
    with open(sciezka_do_pliku) as f:
        print(f.read())
        print(2/0)
except ZeroDivisionError as e:
    print(e)
## Ala ma kota.
## Kot ma Alę
## 
## division by zero
print(f.closed)
## True

Pole closed informuje nas czy plik został zamknięty. Jak widzimy został, choć ani razu nie wywołaliśmy close(), a po drodze był rzucony wyjątek.

Teraz przejdźmy do czytania pliku po linijce. Aby wczytać cały plik, ale dostać w wyniku listę, gdzie każdy element jest osobną linią w pliku, użyjemy readlines():

with open(sciezka_do_pliku) as f:
    print(f.readlines())
## ['Ala ma kota.\n', 'Kot ma Alę\n']

Aby po prostu wczytać jedną linię, użyjemy readline():

with open(sciezka_do_pliku) as f:
    print(f.readline())
    print("---")
    print(f.readline())
## Ala ma kota.
## 
## ---
## Kot ma Alę

Możliwe też jest przechodzenie po kolejnych liniach pętlą for:

with open(sciezka_do_pliku) as f:
    for wiersz in f:
      print(wiersz)
      print("---")
## Ala ma kota.
## 
## ---
## Kot ma Alę
## 
## ---

Teraz przejdźmy do zapisu do pliku. Przede wszystkim trzeba będzie otworzyć plik w innym trybie. Do tej pory używaliśmy domyślnego trybu, 'r', czyli read, otwarcia pliku do odczytu. Teraz otworzymy plik do zapisu, write, 'w':

with open(sciezka_do_pliku, 'w') as f:
    f.write("Trzeci wiersz")
    f.write("Czwarty wiersz")

Okaże się, że kod zadziała zupełnie inaczej, niż moglibyśmy się spodziewać. Przede wszystkim otworzenie pliku w trybie 'w' ustawia nas na jego początku, co oznacza, że będziemy nadpisywać dotychczasową treść. Dlatego 'Trzeci wiersz' będzie w istocie pierwszym wierszem. Co więcej, 'Czwarty wiersz' wcale nie będzie drugim wierszem. Będzie dalej w pierwszym wierszu, zaraz po napisie 'Trzeci wiersz'. Aby były one w osobnych wierszach, należy użyć znaku przejścia do nowego wiersza '\n':

with open(sciezka_do_pliku, 'w') as f:
    f.write("Trzeci wiersz\n")
    f.write("Czwarty wiersz\n")

Aby otworzyć plik i dopisywać na jego koniec, użyjemy trybu 'a' (append):

with open(sciezka_do_pliku, 'a') as f:
    f.write("Piąty wiersz\n")
    f.write("Szósty wiersz\n")

Zgodnie z przewidywaniami piąty i szósty wiersz wylądowały na miejscu trzecim i czwartym.

Teraz zobaczmy, jak użyć pakietu os, aby uzyskać podstawowe informacje o plikach: czy plik istnieje, jakie są pliki w folderze itp.

Aby sprawdzić, czy plik istnieje, napiszemy:

import os
print(os.path.exists(sciezka_do_pliku))
## True

Aby wylistować wszystkie pliki w folderze, użyjemy listdir():

print(os.listdir("C:\folder\"))
## ['Wyzwanie6', 'pomysly.txt', 'Wyzwanie5', 'Wyzwanie2', 'Wyzwanie1', 'Wyzwanie4', 'Wyzwanie3']

Na koniec omówmy os.path.join(). Jest to funkcja, która skleja kawałki ścieżki. Np. gdy mamy ścieżkę do folderu i chcemy dokleić na koniec nazwę pliku, który znajduje się w tym folderze:

print(os.path.join("folder1", "folder2", "plik.txt"))
## folder1/folder2/plik.txt

Możemy zastanowić się, czemu istnieje dedykowana funkcja, skoro można by ją zastąpić poprzez zwyczajną konkatenację napisów. Otóż funkcja ta jest świadoma tego, na jakim systemie operacyjnym jest uruchamiana i wstawi separator właściwy dla Linuksa (‘/’) lub Windowsa (‘\’). Pozwala nam to pisać kod przenośny pomiędzy platformami.

Aby pobrać interesujące nas właściwości pliku, jak jego rozmiar czy data modyfikacji, napiszemy:

import datetime
(mode, ino, dev, nlink, uid, gid, size, atime, mtime, ctime) = os.stat(sciezka_do_pliku)
data_modyfikacji = datetime.datetime.fromtimestamp(mtime)
print(data_modyfikacji)
## 2018-12-21 10:46:54

Ostatecznie, do usunięcia pliku służy os.remove():

os.remove(sciezka_do_pliku)

Zadanie 6

Wyjątki w poprzednich zadaniach

Wróćmy do zadań z poprzednich wyzwań: kalkulator, klasa ułamek i hierarchia operacji arytmetycznych. W tych zadaniach mogły pojawić się dzielenie przez zero oraz parsowanie tekstu, który nie jest liczbą. Teraz, znając wyjątki, popraw tamten kod, obsługując te sytuacje.

Zliczanie linii

Niech program poprosi użytkownika o podanie ścieżki do pliku wejściowego oraz wyjściowego, gdzie będzie wynik. Następnie program wczyta plik wejściowy, zliczy w nim ilość linii, wyświetli tę liczbę użytkownikowi i zapisze nazwę pliku oraz liczbę linii w pliku wyjściowym.

Sprzątanie

Napisz program, który na początku pyta o ścieżkę do folderu. Następnie w tymże folderze przegląda wszystkie pliki. Te, które mają odpowiednio starą datę modyfikacji i jednocześnie są odpowiednio duże, np. są sprzed roku i zajmują ponad 1 MB, kasuje.


Gotowe rozwiązanie zadania 6 znajdziecie tutaj.


Wszystkie wpisy z cyklu #pythonowewyzwanie:

Wyzwanie 1 - Instalacja środowiska i pierwsze kroki

Wyzwanie 2 - Podstawowe instrukcje

Wyzwanie 3 - Algorytmy i struktury danych

Wyzwanie 4 - Programowanie obiektowe

Wyzwanie 5 - Zaawansowane aspekty programowania obiektowego

Wyzwanie 6 - Wyjątki i operacje na plikach

Wyzwanie 7 - Web scraping

Maciej Bartoszuk

Ukończył z wyróżnieniem informatykę na wydziale Matematyki i Nauk Informacyjnych Politechniki Warszawskiej, gdzie aktualnie pracuje w zakładzie Sztucznej Inteligencji i Metod Obliczeniowych. Tam też od 2013 roku prowadzi zajęcia dydaktyczne z programowania w R, Pythonie, C/C++, C#. Uczestnik studiów doktoranckich w Instytucie Podstaw Informatyki Polskiej Akademii Nauk w latach 2013-2015. W 2018 roku obronił doktorat z wyróżnieniem na swoim rodzimym wydziale: System do oceny podobieństwa kodów źródłowych w językach funkcyjnych oparty na metodach uczenia maszynowego i agregacji danych, który obejmuje zarówno algorytmy przetwarzania kodów źródłowych programów, jak i data science. Współautor książki Przetwarzanie i analiza danych w języku Python wydanej przez PWN. Ponadto trener na bootcampach Data Science, gdzie uczy programować w języku Python pod kątem analizy danych.
Komentarze
Ostatnie posty
Data Science News #204
Data Science News #203
Data Science News #202
Data Science News #201