Czytanie Tracebacków – Czy To Piekło Kiedyś Się Kończy?

Podczas pisania kodu gdy zrobimy coś nie tak, nasz kod przy wywołaniu wyświetli Traceback (most recent call last) z masą informacji. Dlaczego tak się dzieje? Ponieważ kod Pythona nie jest kompilowany przed uruchomieniem. Ze względu na swoją dynamikę, błędy pojawiają się w trakcie działania kodu. Jak zatem naprawiać typowe problemy Pythona?

Czym jest traceback?

Traceback, czyli informacja z ostatniej transkacji. Jest to kawałek logów o ustalonej strukturze który informuje nas o błędzie, a także jego źródle w kodzie. Aby lepiej go zrozumieć, rozłożymy traceback na elementy aby zrozumieć jego konstrukcje. Przypuśćmy, że chcemy skorzystać z biblioteki Beautiful Soup. Aby to zrobić robimy import bs4 jak poniżej:

import bs4

Po wywołaniu komendy dostaniemy błąd. W tym przypadku związane jest to z brakiem możliwości zaimportowania takiego modułu o czym informuje nas wyjątek ModuleNotFoundError widoczny poniżej w logach błędu:

Traceback (most recent call last):
  File "/home/jarek/Playground/blog_entry/main.py", line 1, in <module>
    import bs4
ModuleNotFoundError: No module named 'bs4'

Zaraz po nazwie wyjątku otrzymujemy konkretny komunikat No module named 'bs4' . Oznacza on, że taki moduł był niemożliwy do zaimportowania. Błąd ten może pojawić się w różnych sytuacjach:

  • Błędna nazwa importowanego modułu
  • Brak pliku o takiej nazwie w katalogu
  • Moduł nie został zainstalowany

Poza błędem, dostajemy także ważną informacje o tym, jaki kod wywołał ten błąd:

Traceback (most recent call last):
  File "/home/jarek/Playground/blog_entry/main.py", line 1, in <module>
    import bs4
ModuleNotFoundError: No module named 'bs4'

W tym przypadu jest to import bs4. Co dla nas najistotniejsze, dostajemy także informacje w którym pliku oraz w której lini znajduje się ten kod:

Traceback (most recent call last):
  File "/home/jarek/Playground/blog_entry/main.py", line 1, in <module>
    import bs4
ModuleNotFoundError: No module named 'bs4'

Jest to pierwsza linia w pliku main.py. Jeżeli błąd jest zagnieżdżony to konstrukcja ta pokaże nam całą drogę zagnieżdzeń błędu. Mając wszystkie te informacje nie pozostaje nam nic innego jak naprawić nasz kod.

Praktyka!

Aby zaprezentować sposób naprawy typowych Pythonowych błędów przygotowałem kawałek kodu z kilkoma błędami które popełniłem podczas implementacji. Jest to kod który ma na celu stworzyć nam API poprzez które będziemy mogli dodawać nowe kursy oraz ich moduły.

@dataclass
class Module:
    ...

@dataclass
class Course:
    ...

class CourseRepository:
    ...

class ModuleRepository:
    ...

class CourseLogic:
    ...

if __name__ == "__main__":
    cl = CourseLogic(CourseRepository(), ModuleRepository())
    course = cl.create("Python Basics")
    cl.create_module(course.uuid, "For Loop")
    cl.create_module(course.uuid, "For Else Loop")
    course = cl.get(f"course.uuid")
    print(course)
from dataclasses import dataclass, field, replace
from uuid import uuid4


@dataclass
class Module:
    name: str
    uuid: str = field(default_factory=str(uuid4()))

    def __repr__(self):
        return f"Module(uuid={self.uuid}, name={self.name})"


@dataclass
class Course:
    name: str
    uuid: str = field(default_factory=str(uuid4()))
    items: List[Module] = field(default_factory=list)

    def __repr__(self):
        return f"Course(uuid={self.id}, name={self.name}, items={self.items})"


class CourseRepository:
    def __init__(self):
        self._store: dict = {}

    def create(self, name: str) -> Course:
        course = Course(name)
        self._store[course.uuid] = course
        return course

    def get(self, course_id: str) -> Course:
        return self._store[course_id]

    def save(self, course: Course) -> Course:
        self._store[course.uuid] = course
        return course


class ModuleRepository:
    def __init__(self):
        self._store = {}

    def create(self, name):
        module = Module(name)
        self._store[module.uuid] = module
        return module

    def get(self, module_id):
        return self._store[module_id]


class CourseLogic:
    def __init__(
        self, course_repository: CourseRepository, module_repository: ModuleRepository
    ):
        self._courses = course_repository
        self._modules = module_repository

    def create(self, name):
        return self._courses.create(name)

    def get(self, course_id):
        return self._courses.get(course_id)

    def create_module(self, course_id, name):
        course = self._courses.get(course_id)
        module = self._modules.create(name)
        course = replace(course, items=course.items + module)
        self._courses.save(course)


if __name__ == "__main__":
    cl = CourseLogic(CourseRepository(), ModuleRepository())
    course = cl.create("Python Basics")
    cl.create_module(course.uuid, "For Loop")
    cl.create_module(course.uuid, "For Else Loop")
    course = cl.get(f"course.uuid")
    print(course)

NameError

Po uruchomieniu naszego kodu otrzymujemy pierwszy błąd. Jest to NameError który informuje nas, że dany element jest nie dostępny. W tym przypadku jest to element List.

Traceback (most recent call last):
  File "/home/jarek/Playground/blog_entry/traceback.py", line 15, in <module>
    class Course:
  File "/home/jarek/Playground/blog_entry/traceback.py", line 18, in Course
    items: List[Module] = field(default_factory=list)
NameError: name 'List' is not defined

Jest tutaj także informacja, że jest to linia 18 w naszym pliku, oraz mamy informacje o elemencie nadrzędnym tego wywołania którym jest Course. W tym przypadku zapomniałem zaimportować element z biblioteki typing, więc dopisanie tego naprawia nasz problem:

from dataclasses import dataclass, field, replace
from uuid import uuid4
from typing import List

@dataclass
class Module:
    name: str

TypeError

Kolejny błąd to TypeError. Wywołany jest ze względu na zły typ zmiennej. W tym przypadku kod oczekiwał zmiennej która będzie mogła być wywołana: czyli funkcji bądź lambdy. Jest to błąd troszkę ukryty w tym przypadku więc prześledźmy jego drogę. Wywołał się podczas próby użycia metody create w klasie CourseLogic. Wewnątrz tej metody błąd pojawia się dokładniej przy metodzie create klasy CourseRepository, a ostatecznie przy tworzeniu obiektu Course. Dodatkowo mamy jeszcze informacje o metodzie __init__ tej klasy.

Traceback (most recent call last):
  File "/home/jarek/Playground/blog_entry/traceback.py", line 76, in <module>
    course = cl.create("Python Basics")
  File "/home/jarek/Playground/blog_entry/traceback.py", line 62, in create
    return self._courses.create(name)
  File "/home/jarek/Playground/blog_entry/traceback.py", line 29, in create
    course = Course(name)
  File "<string>", line 4, in __init__
TypeError: 'str' object is not callable

Tak więc podejrzewałem, że może to być związane z użyciem funkcji field z biblioteki dataclasses której jeszcze dobrze nie znałem. Po przejrzeniu dokumentacji okazało się, że parametr default_factory oczekuje funkcji. Dlatego też obudowałem aktualnie istniejący kawałek kodu za pomocą lambda i w ten sposób rozwiązaliśmy kolejny błąd.

@dataclass
class Course:
    name: str
    uuid: str = field(default_factory=lambda:str(uuid4()))
    items: List[Module] = field(default_factory=list)

    def __repr__(self):
        return f"Course(uuid={self.id}, name={self.name}, items={self.items})"

Jednak pojawił się kolejny TypeError. Metoda create_module w klasie CourseLogic. Używając funkcji replace wywołał się błąd, ponieważ Python nie jest w stanie połączyć listy z obiektem klasy Module.

Traceback (most recent call last):
  File "/home/jarek/Playground/blog_entry/traceback.py", line 77, in <module>
    cl.create_module(course.uuid, "For Loop")
  File "/home/jarek/Playground/blog_entry/traceback.py", line 70, in create_module
    course = replace(course, items=course.items + module)
TypeError: can only concatenate list (not "Module") to list

Problem okazuje się prosty do rozwiązania, wystarczy nasz obiekt obudować w jednoelementową listę. W taki oto sposób udało nam się pozbyć kolejnego błędu.

    def create_module(self, course_id, name):
        course = self._courses.get(course_id)
        module = self._modules.create(name)
        course = replace(course, items=course.items + [module])
        self._courses.save(course)

KeyError

Kolejny wyjątek który nam się pojawił to KeyError. Jest to typowy błąd przy próbie odczytu elementu ze słownika który nie istnieje. Ale prześledźmy gdzie ten problem się pojawił. Mamy metodę get klasy CourseLogic. Następnie w niej mamy wywołanie metody get z klasy CourseRepository. Ostatecznie błąd pojawia się w linii w której próbujemy odczytać wartość ze słownika spod klucza o nazwie course.uuid. Jest to zdecydowanie mój błąd, gdyż chciałem używać do tego wartości tego atrybutu, a nie jego nazwy.

Traceback (most recent call last):
  File "/home/jarek/Playground/blog_entry/traceback.py", line 79, in <module>
    course = cl.get(f"course.uuid")
  File "/home/jarek/Playground/blog_entry/traceback.py", line 65, in get
    return self._courses.get(course_id)
  File "/home/jarek/Playground/blog_entry/traceback.py", line 34, in get
    return self._store[course_id]
KeyError: 'course.uuid'

Po krótkim sprawdzeniu zdecydowałem zmienić wywołanie tej metody by zamiast f"course.uuid" użyć po prostu course.uuid. Innym rozwiązaniem jest także użycie metody get na słowniku która może zwrócić domyślną wartość w przypadku gdy podanego klucza nie będzie w słowniku. W tym przypadku jednak chciałbym aby wyjątek był wywoływany.

if __name__ == "__main__":
    cl = CourseLogic(CourseRepository(), ModuleRepository())
    course = cl.create("Python Basics")
    cl.create_module(course.uuid, "For Loop")
    cl.create_module(course.uuid, "For Else Loop")
    course = cl.get(course.uuid)
    print(course)

AttributeError

Kolejny błąd z którym mamy doczynienia to AttributeError. Związany z problemem w dostępie do danego atrybutu. Idąc po nitce problem pojawia się przy próbie wyświetlenia obiektu klasy Course. W metodzie __repr__ próbujemy odczytać atrybut id.

Traceback (most recent call last):
  File "/home/jarek/Playground/blog_entry/traceback.py", line 80, in <module>
    print(course)
  File "/home/jarek/Playground/blog_entry/traceback.py", line 21, in __repr__
    return f"Course(uuid={self.id}, name={self.name}, items={self.items})"
AttributeError: 'Course' object has no attribute 'id'

Moja klasa nie posiada takiego atrybutu, posiada natomiast atrybut uuid. I właśnie tego atrybutu chciałem użyć pisząc ten kod. A więc wprowadzamy poprawkę i w miejsce self.id wpisujemy self.uuid i problem rozwiązany.

@dataclass
class Course:
    name: str
    uuid: str = field(default_factory=lambda:str(uuid4()))
    items: List[Module] = field(default_factory=list)

    def __repr__(self):
        return f"Course(uuid={self.uuid}, name={self.name}, items={self.items})"

Czy to meta?

Tak! W końcu udało się uruchomić nasz skrypt poprawnie, a w terminalu ujrzeliśmy obiekt klasy Course zawierający dwa moduły.

Course(uuid=52e506a4-1117-4b96-9658-434122abbb26, name=Python Basics, items=[Module(uuid=3124dea7-3533-4844-acf7-3fbbe55d62c9, name=For Loop), Module(uuid=31ecb984-b2cb-464a-bd81-24642594f440, name=For Else Loop)])

Co dalej?

W ramach kodu postanowiłem w miejscu gdzie oczekuje błędu stworzyć dokładniejszy wyjątek. Dlatego też w pierwszym kroku zrobiłem nowy wyjątek o nazwie CourseNotFoundError, dziedziczący po klasie Exception.

class CourseNotFoundError(Exception):
    ...

Następnie wprowadziłem try except do metody, w której chciałem aby błąd się faktycznie pojawiał.

    def get(self, course_id: str) -> Course:
        try:
            return self._store[course_id]
        except KeyError:
            raise CourseNotFoundError(f"Course for uuid={course_id} is not found")

Teraz gdy wywołamy ponownie błąd podając nie poprawne course_id otrzymamy nasz CourseNotFoundError. Ze względu na wywołanie naszego wyjątku podczas obsługi wyjątku KeyError dostaliśmy rozwinięty traceback. Informuje on nas, że podczas gdy pojawił się wyjątek KeyError, podczas jego obsługi pojawił się kolejny wyjątek.

Traceback (most recent call last):
  File "/home/jarek/Playground/blog_entry/traceback.py", line 38, in get
    return self._store[course_id]
KeyError: '52e506a4-1117-4b96-9658-434122abbb26'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/jarek/Playground/blog_entry/traceback.py", line 87, in <module>
    cl.get('52e506a4-1117-4b96-9658-434122abbb26')
  File "/home/jarek/Playground/blog_entry/traceback.py", line 71, in get
    return self._courses.get(course_id)
  File "/home/jarek/Playground/blog_entry/traceback.py", line 40, in get
    raise CourseNotFoundError(f"Course for uuid={course_id} is not found")
__main__.CourseNotFoundError: Course for uuid=52e506a4-1117-4b96-9658-434122abbb26 is not found

W ten oto sposób Python stara się przekazać nam wystarczającą ilość informacji abyśmy mogli rozwiązać nasz problem i obsłużyć błąd który się pojawił. W powyższym przypadku możemy pozbyć się nadmiernej ilości logów poprzez zmianę logiki kodu. Tym razem najpierw pobierzemy wartość poprzez metode get biorąc pod uwagę, że jeżeli klucz nie będzie dostępny, otrzymamy None

    def get(self, course_id: str) -> Course:
        course = self._store.get(course_id, None)
        if course is not None:
            return course
        raise CourseNotFoundError(f"Course for uuid={course_id} is not found")

Następnie weryfikujemy, czy nasz obiekt nie jest przypadkiem typu None. Jeżeli nie jest, zwracamy go z metody. W innym przypadku wywołujemy wyjątek.

Traceback (most recent call last):
  File "/home/jarek/Playground/blog_entry/traceback.py", line 87, in <module>
    cl.get('52e506a4-1117-4b96-9658-434122abbb26')
  File "/home/jarek/Playground/blog_entry/traceback.py", line 71, in get
    return self._courses.get(course_id)
  File "/home/jarek/Playground/blog_entry/traceback.py", line 40, in get
    raise CourseNotFoundError(f"Course for uuid={course_id} is not found")
__main__.CourseNotFoundError: Course for uuid=52e506a4-1117-4b96-9658-434122abbb26 is not found

W przypadku naszych wyjątków informacja pojawia się bezpośrednio w miejscu gdzie używamy instrukcji raise aby go wywołać. Na szczęście wiemy, że to nasz błąd i oczekujemy go tutaj, więc aby błąd się nie pojawił musimy coś zrobić na wyższym poziomie. W przypadku tego kodu należałoby użyć poprawnego course_id lub użyć try except w momencie wywoływania metody get klasy CourseLogic.

Q&A

Dlaczego kod nie poprawia się sam?

Najprostszą odpowiedzą byłoby: gdyby kod był wstani naprawić się sam, prawdopodobni potrafiłby także sam się napisać. Więc nie potrzebni byliby programiści, a Ci nie przeżywaliby niemiłych chwil związanych z naprawianiem występujących błędów.

Z drugiej strony komputer wie tylko, że wystąpił błąd. On nie wie w jaki sposób chcesz go rozwiązać. Na powyższych przykładach mogliśmy prześledzić naprawę kilku błędów. Niektóre z nich były dość trywialne, inne wymagały dopisania kilku linii kodu. Czytanie logów i naprawianie błędów to część pracy programisty.

Jak uchronić się od błędów?

Najlepiej ich nie robić! Są błędy które z początku będziemy popełniać. Z czasem nauczymy się jak wywoływać metody, czy pisać kod aby te błędy się nie pojawiały. W dużej mierze przed błędami może nas uchronić wyłącznie nasze doświadczenie.

Na szczęście mamy wielu pomocników którzy wspomogą nas w unikaniu problemów. Są to chociażby IDE jak PyCharm. W momencie pisania niepoprawnego kodu jest on w stanie wyłapać niektóre przypadki i wyświetlić nam błąd zanim uruchomimy kod. Mówimy na takie systemy, że są to sytemy do statycznej analizy kodu. Poza IDE możemy skorzystać także z takich narzędzi jak flake8 czy pylint.

Co mogę jeszcze zrobić?

Typowanie. Na powyższym przykładzie użyłem dużo typowania w kodzie. Pozwala ono weryfikować podczas pisania kodu czy mamy dostęp dla danych obiektów do metod/atrybutów do których staramy się właśnie odwołać. Do analizy kodu pod względem poprawności typowania służy chociażby narzędzie mypy.

To na tyle. Mam nadzieję, że te przykłady pozwolą Ci dużo szybciej rozwiązywać problemy w Twoim kodzie. Staraj się uczyć na błędach i pisząc kod od razu przewidywać jego zachowanie aby nie czekać, aż błąd pojawi się w uruchomionym skrypcie.