Moduły, importy i wyjątki

Do tej pory kod, który pisaliśmy istniał jedynie w powłoce interpretera Pythona. Po jej zamknięciu nasz kod “znikał” i nie mogliśmy do niego wrócić. Pisząc aplikację zazwyczaj chcielibyśmy zapisać jej kod aby do niego za jakiś czas wrócić, rozwijać dalej lub się nim podzielić.

Python umożliwia nam to poprzez moduły i paczki. Moduł to po prostu skrypt napisany w Pythonie zapisany w pliku z rozszerzeniem .py, a paczka to katalog który zawiera plik __init__.py *, inne moduły oraz paczki.

Weźmy za przykład kod napisany w drugim module. Utwórzmy plik (np. za pomocą Pythona :)) o nazwie blog.py w uprzednio utworzonym katalogu homework. Skopiujmy tam znany nam już kod definiujący klasy Post oraz Author:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class Author:

    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    def __str__(self):
        return self.first_name + " " + self.last_name


class Post:
    def __init__(self, id_, title, content, author):
        self.id = id_
        self.title = title
        self.content = content
        self.author = author

    def __str__(self):
        return ",".join((str(self.id), self.title, self.content, self.author))

i dodajmy dodatkową instrukcję na końcu skryptu która wyświetli informację o poprawnym załadowaniu modułu

20
print("Author and Post classes were imported successfully")

Teraz będąc w katalogu homework jesteśmy w stanie wykonać utworzony skrypt za pomocą polecenia

1
2
$ python3 blog.py
Author and Post classes were imported successfully

co poskutkowało wyświetleniem się informacji którą wcześniej zaprogramowaliśmy.

Tak jak w przypadku innych języków skryptowych, jesteśmy w stanie wskazać interpreter z którego ma skorzystać powłoka systemu uniksowego by wykonać dany skrypt. Dodajmy więc na początku naszego skryptu shebang ze ścieżką do naszego interpretera Python.

1
2
3
4
5
6
#!/usr/bin/python

class Author:

     def __init__(self, first_name, last_name):
         ...

dodatkowo, nadajmy naszemu skryptowi prawo do bycia plikiem wykonywalnym po przez polecenie chmod

1
$ chmod +x blog.py

i spróbujmy wykonać nasz skrypt tak, jakby był to zwykły skrypt powłoki

1
2
$ ./blog.py
Author and Post classes were imported successfully

1001 linii kodu

Z czasem nasz kod zacznie się rozrastać i w pewnym momencie nie będziemy w stanie swobodnie poruszać i utrzymywać go trzymając w tylko jednym module. Na przykład kod odpowiedzialny za pracę na plikach mógłby żyć w innym miejscu niż definicje klas. Tak więc spróbujmy go teraz podzielić.

Zapiszmy następujący kod w pliku file.py, znowu w katalogu homework

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def save_post_to_file(id_, title, content, author):
    post = Post(id_=id_, title=title, content=content, author=author)

    with open("posts.txt", "a+") as file:
        file.write(f"{post}\n")

    return post

save_post_to_file(
    1,
    "Automate the Boring Stuff with Python",
    "Check out this cool book about automating daily tasks with Python!",
    "Al Sweigart"
)

i spróbujmy wykonać nasz nowy skrypt

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ python3 file.py
Traceback (most recent call last):
File "/home/devpila/Projects/homework/file.py", line 8, in <module>
    save_post_to_file(
        "Automate the Boring Stuff with Python",
        "Check out this cool book about automating daily tasks with Python!",
        "Al Sweigart"
    )
File "/home/devpila/Projects/homework/file.py", line 2, in save_post_to_file
    post = Post(id_, title=title, content=content, author=author)
NameError: name 'Post' is not defined

Jak widać kod nie wykonał się z powodzeniem - wystąpił wyjątek NameError który oznacza, że dana nazwa nie jest rozpoznana przez interpreter.

Nasz intepreter domyślnie nie interesuje się innymi plikami niż tym, który wykonuje. Z tego powodu nie wie nic o istnieniu klasy Post która zadeklarowana jest w module blog.py.

Aby nasz intepreter zaczął “widzieć” naszą wcześniej utworzoną klasę musimy mu dać znać z czego chcemy skorzystać i z jakiego modułu/paczki to pochodzi. Wykorzystamy do tego instrukcje import oraz from.

Zmodyfikujmy nasz skrypt file.py z następującą zmianą:

1
2
3
4
from blog import Post

def save_post_to_file(id_, title, content, author):
     ...

i znów spróbujmy wykonać nasz skrypt

1
2
$ python3 file.py
Author and Post classes were imported successfully

Jak widać tym razem kod zadziałał. Warto zauważyć, że funkcja print której wywołanie wcześniej dodaliśmy do modułu blog.py również się wykonała.

Co w trawie piszczy

W powyższym przykładzie użyliśmy jednego z dwóch dostępnych rodzajów importów. Jego składnia wygląda następująco

1
from <module|package> import [<module|package|object> (as <alias>),]

w naszym wypadku zaimportowaliśmy obiekt `Post` z modułu `blog`. Warto wiedzieć, że składnia pozwala nam zaimportować wiele obiektów jak i nadać im alias.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
>>> from math import ceil, floor
>>> ceil
<built-in function ceil>
>>> from pathlib import PosixPath as pos_path, WindowsPath as win_path
>>> pos_path
<class 'pathlib.PosixPath'>
>>> win_path
<class 'pathlib.WindowsPath'>
>>> PosixPath  # Nadając alias tracimy dostęp do pierwotnej nazwy
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'PosixPath' is not defined
>>> from pathlib import PosixPath  # Ale nadal możemy zaimportować po staremu :)

Drugi rodzaj importu wygląda następująco

1
import [<module|package> (as <alias>),]

W tym wypadku nie możemy zaimportować obiektu z danego modułu a jedynie cały moduł bądź paczkę.

1
2
3
>>> import math
>>> math.ceil(2.1)
3

Istnieje jeszcze jeden sposób na wykonanie importu który jest wariacją pierwszego rodzaju. Jest to tak zwany “import star” który importuje wszystko z danego modułu/paczki np.

1
2
3
4
5
>>> from string import *
>>> digits
'0123456789'
>>> ascii_letters
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'

Ok, ale “math” ani “string” nie ma w moim katalogu?

Tak jak słusznie mogłeś/mogłaś zauważyć, obu tych paczek nie ma w naszym katalogu. Skąd więc mamy do nich dostęp?

Gdy wykonujesz instrukcję import xyz interpreter Python zaczyna swoje poszukiwania w następujących miejscach:

  • Katalog z którego został wywołany skrypt (bądź obecny katalog jeżeli intepreter został uruchomiony w trybie interaktywnym)

  • Lista katalogów zawartych w zmiennej środowiskowej PYTHONPATH

  • Katalog instalacyjny Pythona

Wszystkie te miejsca tworzą zbiór który można podejrzeć za pomocą paczki sys

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> import sys
>>> sys.path
[
 '',
 '/sciezka/z/pythonpath',
 '/home/devpila/python39.zip',
 '/home/devpila/python3.9',
 '/home/devpila/python3.9/lib-dynload',
 '/home/devpila/python3.9/site-packages'
]

Co ciekawe, tekstowa reprezentacja modułu/paczki wskazuje na ścieżkę w której został znaleziony

1
2
3
4
5
6
7
>>> import math
>>> math
<module 'math' from '/home/devpila/python3.9/lib-dynload/math.cpython-39-x86_64-linux-gnu.so'>
>>> import blog
Author and Post classes were imported successfully
>>> blog
<module 'blog' from '/home/devpila/Projects/homework/blog.py'>

Import relatywny

Do tej pory wszystkie importy które wykonywaliśmy zawierały ścieżkę absolutną (pełną) do danego obiektu/modułu.

Python umożliwia również import korzystając ze ścieżki relatywnej - czyli w zależności od tego, gdzie odbywa się dany import.

Załóżmy że katalog notebook ma następującą strukturę:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ tree
├── __init__.py
├── module.py
└── package
    ├── __init__.py
    ├── sub_module.py
    └── package2
        ├── __init__.py
        ├── sub_module2.py
        └── sub_module3.py

Wtedy sub_module3.py może definiować następujące relatywne importy:

1
2
3
from .sub_module2 import var2  # Z modułu "sub_module2" który znajduje się w mojej lokalizacji zaimportuj obiekt var2
from ..sub_module import var  # Z modułu "sub_module" który znajduje się w katalogu nadrzędnym zaimportuj obiekt var
from . import sub_module2 # Z mojej lokalizacji zaimportuj moduł "sub_module2"

Wyjątki i Błędy

Nieuchronnym dla naszych aplikacji jest napotkanie wyjątku czy błędu, czy to na skutek błędnych danych wpisanych przez użytkownika, braku internetu czy błędu programisty. Parę z nich już udało Ci się spotkać, np. w module trzecim podczas otwierania nieistniejącego pliku otrzymaliśmy następujący wyjątek:

1
2
3
4
>>> open('non_existing_file.txt')
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
    FileNotFoundError: [Errno 2] No such file or directory: 'non_existing_file.txt'

Aby lepiej zobrazować co oznaczają te informacje, utworzyłem plik bad_open.py z następującym kodem

1
2
3
4
def open_file(filename):
    open(filename)

open_file("non_existing_file.txt")

Uruchomienie tego skryptu zwróci następujące informacje

1
2
3
4
5
6
Traceback (most recent call last):
  File "/home/devpila/Projects/notebook/bad_open.py", line 4, in <module>
    open_file("non_existing_file.txt")
  File "/home/devpila/Projects/notebook/bad_open.py", line 2, in open_file
    open(filename)
FileNotFoundError: [Errno 2] No such file or directory: 'non_existing_file.txt'

[1] Informuje nas, że wystąpił wyjątek i to są najnowsze komunikaty z ostatnich wywołań

[2] Informuje nas w którym pliku została wywołana funkcja która spowodowała wyjątek

[3] Informuje nas w którym miejscu kodu została wywołana funkcja która spowodowała wyjątek

[4] Informuje nas w którym pliku został wywołany wyjątek

[5] Informuje nas która linia kodu wywołała wyjątek.

[6] Informuje nas jaki jest to wyjątek i jaki jest jego powód.

Co mogę z tym zrobić? Jak temu zapobiec?

Wyjątki w Pythonie, tak jak w innych językach programowania, można łapać i obsługiwać. Do tego służy blok try ... except ....

Poprawmy nasz poprzedni kod tak, aby obsługiwał scenariusz w którym plik nie istnieje:

1
2
3
4
5
6
7
def open_file(filename):
    try:
        open(filename)
    except FileNotFoundError:
        print("Nie znaleziono pliku.")

open_file("non_existing_file.txt")

Wywołanie tego skryptu ma teraz o wiele przyjemniejszy rezultat dla użytkownika:

1
2
$ python bad_open.py
Nie znaleziono pliku.

Przejdźmy teraz linia po linii by zrozumieć jak działa ten kod.

[1] Zaczyna się nasza funkcja otwierająca plik

[2] Zaczyna się wyrażenie try, które oznacza “Jeżeli cokolwiek do czasu klauzuli except wywoła jakikolwiek wyjątek - przechwyć go”

[3] Próbujemy otworzyć plik

[4] “W przypadku wystąpienia wyjątku FileNotFoundError - obsłuż go”

[5] Wyświetla informację o braku danego pliku.

Jak widać obsługa wyjątków w Pythonie jest prosta.

Czym jest wyjątek w Pythonie?

Jak wszystko w Pythonie, wyjątek jest niczym innym jak obiektem, a dokładnie instancją klasy która dziedziczy po klasie BaseException. Przykładowo, wyjątek FileNotFoundError dziedziczy po klasie OSError, która dziedziczy po klasie Exception, która ostatecznie dziedziczy po klasie BaseException

FileNotFoundError <- OSError <- Exception <- BaseException

Czyli aby utworzyć własny wyjątek, muszę stworzyć klasę która dziedziczy po klasie “BaseException”?

Nie. Wszystkie wyjątki utworzone przez użytkownika (programistę) powinny dziedziczyć po klasie Exception zamiast BaseException. BaseException prawie nigdy nie powinno być klasą po której dziedziczy inny wyjątek.

Przykłady

Znów posłużymy się kodem z modułu trzeciego, ale z drobnymi modyfikacjami.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class PostNotFound(Exception):
    pass


def get_post_by_id(post_id):
    with open("posts.txt", "r") as file:
        for line in file.readlines():
            post = get_post_from_line(line)
            if post.id == post_id:
                return post
        raise PostNotFound()

try:
    get_post_by_id(999)
except FileNotFoundError:
    print("Nie znaleziono pliku.")
except PostNotFound:
    print("Nie znaleziono posta.")

[1] Deklarujemy własny wyjątek, który informuje o braku danego postu [2] Instrukcja pass oznacza “nie rób nic” [11] Instrukcja raise służy do wywoływania wyjątków. Zauważ, że wywoływana jest instancja klasy, a nie sama klasa. [13] Początek instrukcji try [14] Wywołanie funkcji get_post_by_id [15] Obsłużenie wyjątku FileNotFoundError [17] Obsłużenie wyjątku PostNotFound

Kolejność ma znaczenie

Załóżmy, że nasz wyjątek zacznie dziedziczyć po klasie FileNotFoundError.

1
2
class PostNotFound(FileNotFoundError):
    pass

Jak wtedy zachowa się nasz kod w przypadku wystąpienia wyjątku PostNotFound()? Sprawdźmy. Dla uproszczenia, zmodyfikuję funkcję get_post_by_id aby jedynie rzucała wyjątek.

5
6
def get_post_by_id(post_id):
    raise PostNotFound()
1
2
python3 file.py
Nie znaleziono pliku.

Jak widać, pomimo tego, że nasz wyjątek to PostNotFound to obsłużyła go klauzula except FileNotFoundError. Wynika to z tego, że przy porównywaniu wystąpionego wyjątku z oczekiwanym wyjątkiem porównywane jest dziedziczenie klas. Jeżeli znajdzie się wspólny przodek, bądź wyjątki są tej samej klasy, klauzula except zostanie wykonana.

Klauzula finally

Obsługiwanie wyjątków ma dodatkową funkcjonalność - klauzulę finally. Służy ona do sfinalizowania i posprzątania procesu łapania wyjątku. Wykona się zawsze, niezależnie od tego czy wyjątek będzie obsłużony i w jaki sposób.

1
2
3
4
5
6
7
8
9
>>> try:
...     2/0
... except ZeroDivisionError:
...     print("Nie dziel przez zero!")
... finally:
...     print("Koniec łapania wyjątku")
...
Nie dziel przez zero!
Koniec łapania wyjątku

Co ciekawe, klauzula finally ma pierwszeństwo w kontroli przepływu kodu

1
2
3
4
5
6
7
8
>>> def foo():
...     try:
...         return 'try'
...     finally:
...         return 'finally'
...
>>> foo()
'finally'

Koniec? Co dalej?

  1. Poćwicz to co dziś przerobiliśmy.

  2. Napisz aplikację korzystającą z wielu modułów - na przykład prostą grę tekstową w której będą klasy Player (player.py), Inventory (inventory/inventory.py), Item (inventory/item.py). Dodaj obsługę wyjątków i własne wyjątki.


*

teraz już nie musi, dzięki PEP 420 – Implicit Namespace Packages

wszystko co jest zdefiniowane w __all__ modułu/paczki - domyślnie wszystko oprócz nazw prywatnych czyli zaczynających się od podłogi np _sekret = 2.