如何閱讀 traceback

閱讀 Python traceback 來除錯的概覽。


框架物件

呼叫函式時,會建立一個框架物件,用來保存區域變數以及傳入函式的引數。 函式回傳後,框架物件就會被銷毀。 在函式 A 中呼叫函式 B 時,函式 B 的值會放進一個框架物件,然後被放到呼叫堆疊上函式 A 的框架物件之上。

呼叫堆疊

呼叫堆疊是目前作用中函式的框架物件集合。 如果函式 A 呼叫了函式 B,而函式 B 又呼叫了函式 C,那麼這三個函式的框架物件都會在呼叫堆疊上。 一旦函式 C 回傳,它的框架物件就會從堆疊中彈出,呼叫堆疊上只會剩下函式 A 和 B 的框架物件。

Traceback

Traceback 是某個特定時間點堆疊上所有框架物件的報告。 Python 程式遇到未處理的例外時,會印出例外訊息和 Traceback。 Traceback 會顯示例外是在哪裡引發的,以及在它之前呼叫了哪些函式。

如何閱讀 Traceback

ValueError是常見的例外。

以下是嘗試只用右邊的一個值,就對左邊的兩個變數賦值時,所產生的 ValueError 範例:

>>> first, second = [1]
Traceback (most recent call last):
File <stdin>, line 1, in <module>
    first, second = [1]
ValueError: not enough values to unpack (expected 2, got 1)

Traceback 的排列方式是最新的呼叫放在最後,所以要閱讀 Traceback,就從最底部的例外開始。 從那裡往上讀,就能看出那個敘述是如何被執行到的。 如果把有問題的那一行放進函式,然後呼叫該函式,就會看到更長的追蹤:

>>> def my_func():
...     first, second = [1]
...
>>> my_func()
Traceback (most recent call last):
  File <stdin>, line 5, in <module>
    my_func()
  File <stdin>, line 2, in my_func
    first, second = [1]
ValueError: not enough values to unpack (expected 2, got 1)

從底部往前推,可以看到發生例外的那次呼叫位在 my_func的第 2 行。 我們是在第 5 行呼叫 my_func而到達那裡的。

常見例外

Python 定義了超過 60 個內建例外類別。 以下簡要介紹一些較常見的例外,以及它們代表的意義。

SyntaxError

當程式碼因為語法無效而讓 Python 無法理解時,Python 會引發 SyntaxError。 例如,可能有一個左括號沒有對應的右括號。

點這裡看程式碼範例。

執行這段程式碼:

def distance(strand_a, strand_b):
    if len(strand_a) != len(strand_b):
        raise ValueError("Strands must be of equal length."  # This is missing the closing parenthesis

會產生類似以下的堆疊追蹤(請注意最後一行的訊息):

.usr.local.lib.python3.10.site-packages._pytest.python.py:608: in _importtestmodule
    mod = import_path(self.path, mode=importmode, root=self.config.rootpath)
.usr.local.lib.python3.10.site-packages._pytest.pathlib.py:533: in import_path
    importlib.import_module(module_name)
.usr.local.lib.python3.10.importlib.__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
<frozen importlib._bootstrap>:1050: in _gcd_import ???
<frozen importlib._bootstrap>:1027: in _find_and_load ???
<frozen importlib._bootstrap>:1006: in _find_and_load_unlocked ???
<frozen importlib._bootstrap>:688: in _load_unlocked ???
.usr.local.lib.python3.10.site-packages._pytest.assertion.rewrite.py:168: in exec_module
    exec(co, module.__dict__)
.mnt.exercism-iteration.hamming_test.py:3: in <module>
    from hamming import (  
E     File ".mnt.exercism-iteration.hamming.py", line 10
E       raise ValueError("Strands must be of equal length."
E                       ^
E   SyntaxError: '(' was never closed

AssertionError

當 assert敘述(見下文)失敗時,Python 會引發 AssertionError。

點這裡看程式碼範例。

執行這段程式碼:

def distance(strand_a, strand_b):
    assert len(strand_a) == len(strand_b)


distance("ab", "abc")

會產生類似以下的堆疊追蹤(請注意最後一行的訊息):

hamming_test.py:3: in <module>
    from hamming import (
hamming.py:5: in <module>
    distance("ab", "abc")
hamming.py:2: in distance
    assert len(strand_a) == len(strand_b)
E   AssertionError

AttributeError

當程式碼(或單元測試!)嘗試存取物件的屬性,但該物件沒有這個屬性時,就會引發 AttributeError。 例如,單元測試預期 Robot物件有 direction屬性,但當它嘗試存取 robot.direction時,該屬性並不存在。

這也可能表示打錯字,例如在正確語法是 "Hello".lower()時卻用了 "Hello".lowercase()。 "Hello".lowercase()會引發 AttributeError: 'str' object has no attribute 'lowercase'。

點這裡看 Robot 類別的範例。

執行這段程式碼:

class Robot:
    def __init__():
        #note that there is no self.direction listed here
        self.position = (0, 0)
        self.orientation = 'SW'

    def forward():
        pass
        

robby = Robot
robby.direction

會產生類似以下的堆疊追蹤(請注意最後一行的訊息):

robot_simulator_test.py:3: in <module>
    from robot_simulator import (
robot_simulator.py:12: in <module>
    robby.direction
E   AttributeError: type object 'Robot' has no attribute 'direction'
點這裡看字串的範例。

執行這段程式碼:

def distance(strand_a, strand_b):
    if strand_a.lowercase() == strand_b:
        return 0


distance("ab", "abc")

會產生類似以下的堆疊追蹤(請注意最後一行的訊息):

    def distance(strand_a, strand_b):
>       if strand_a.lowercase() == strand_b:
E       AttributeError: 'str' object has no attribute 'lowercase'

ImportError

當程式碼嘗試匯入某個東西,但 Python 無法完成時,就會引發 ImportError。 例如,Guidos Gorgeous Lasagna的單元測試會執行 from lasagna import bake_time_remaining,但 lasgana.py解答檔案可能沒有定義 bake_time_remaining。

點這裡看程式碼範例

在未定義該函式的情況下執行 lasgana.py檔案,會產生以下錯誤:

We received the following error when we ran your code:

  ImportError while importing test module '.mnt.exercism-iteration.lasagna_test.py'.
Hint: make sure your test modules.packages have valid Python names.

Traceback:
.mnt.exercism-iteration.lasagna_test.py:6: in <module>
    from lasagna import (EXPECTED_BAKE_TIME,
E   ImportError: cannot import name 'bake_time_remaining' from 'lasagna' (.mnt.exercism-iteration.lasagna.py)

During handling of the above exception, another exception occurred:
.usr.local.lib.python3.10.importlib.__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
.mnt.exercism-iteration.lasagna_test.py:23: in <module>
    raise ImportError("In your 'lasagna.py' file, we can not find or import the"

E   ImportError: In your 'lasagna.py' file, we can not find or import the function named 'bake_time_remaining()'. Did you mis-name or forget to define it?

### **IndexError**

Python raises an `IndexError` when an invalid index is used to look up a value in a sequence.
This often indicates the index is not computed properly and is often an off-by-one error.


<details>
<summary>Click here for code example</summary>


Consider the following code.

```python
def distance(strand_a, strand_b):
    same = 0
    for i in range(len(strand_a)):
        if strand_a[i] == strand_b[i]:
            same += 1
    return same


distance("abc", "ab")  # Note the first strand is longer than the second strand.

Running that code will result in an error similar to this one. (Note the last line.)

hamming_test.py:3: in <module>
    from hamming import (
hamming.py:9: in <module>
    distance("abc", "ab")  # Note the first strand is longer than the second strand.
hamming.py:4: in distance
    if strand_a[i] == strand_b[i]:
E   IndexError: string index out of range

KeyError

Similar to IndexError, this exception is raised when using a key to look up a dictionary value but the key is not set in the dictionary.

Click here for code example

Consider the following code.

def to_rna(dna_letter):
    translation = {"G": "C", "C": "G", "A": "U", "T": "A"}
    return translation[dna_letter]


print(to_rna("Q"))  # Note, "Q" is not in the translation.

Running that code will result in an error similar to this one. (Note the last line.)

rna_transcription_test.py:3: in <module>
    from rna_transcription import to_rna
rna_transcription.py:6: in <module>
    print(to_rna("Q"))
rna_transcription.py:3: in to_rna
    return translation[dna_letter]
E   KeyError: 'Q'

TypeError

Typically, a TypeError is raised when the wrong type of data is passed to a function or used in an operation.

Click here for code example

Consider the following code.

def hello(name):  # This function expects a string.
    return 'Hello, ' + name + '!'


print(hello(100))  # 100 is not a string.

Running that code will result in an error similar to this one. (Note the last line.)

hello_world_test.py:3: in <module>
    import hello_world
hello_world.py:5: in <module>
    print(hello(100))
hello_world.py:2: in hello
    return 'Hello, ' + name + '!'
E   TypeError: can only concatenate str (not "int") to str

ValueError

A ValueError is usually raised when an invalid value is passed to function.

Click here for code example

Note, real square roots only exist for positive numbers. Calling math.sqrt(-1) will raise ValueError: math domain error since -1 is not a valid value for a square root. In (mathematical) technical terms, -1 is not in the domain of square roots.

import math

math.sqrt(-1)

Running that code will result in an error similar to this one. (Note the last line.)

square_root_test.py:3: in <module>
    from square_root import (
square_root.py:3: in <module>
    math.sqrt(-1)
E   ValueError: math domain error

Using the print function

Sometimes an error is not being raised, but a value is not what is expected. This can be especially perplexing if the value is the result of a chain of calculations. In such a situation it can be helpful to look at the value at each step to see which step is the one that isn't behaving as expected. The print function can be used for printing the value to the console. The following is an example of a function that doesn't return the value expected:

# the intent is to pass an integer to this function and get an integer back
def halve_and_quadruple(num):
    return (num / 2) * 4

When the function is passed 5, the expected value is 8, but it it returns 10.0. To troubleshoot, the calculating is broken up so that the value can be inspected at every step.

# the intent is to pass an integer to this function and get an integer back
def halve_and_quadruple(num):
    # verify the number in is what is expected
    # prints 5
    print(num)
    # we want the int divided by an integer to be an integer
    # but this prints 2.5! We've found our mistake.
    print(num / 2)
    # this makes sense, since 2.5 x 4 = 10.0
    print((num / 2) * 4)
    return (num / 2) * 4

What the `print` calls revealed is that we used `/` when we should have used `//`, the [floor division operator][floor division operator].

## Logging

[Logging][logging] can be used similarly to `print`, but it is more powerful.
What is logged can be configured by the logging severity (e.g., 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'.)
A call to the `logging.error` function can pass `True` to the `exc_info` parameter, which will additionally log the stack trace.
By configuring multiple handlers, logging can write to more than one place with the same logging function.

Following is an example of logging printed to the console:

```python

>>> import logging
>>>
>>> # configures minimum logging level as INFO
>>> logging.basicConfig(level=logging.INFO)
>>>
>>> def halve_and_quadruple(num):
...     # prints INFO:root: num == 5
...     logging.info(f" num == {num}")
...     return (num // 2) * 4
...
>>> print(halve_and_quadruple(5))

The level is configured as INFO because the default level is WARNING. For a persistent log, the logger can be configured to write to a file, like so:

>>> import logging
...
>>> # configures the output file name to example.log, and the minimum logging level as INFO
>>> logging.basicConfig(filename='example.log', level=logging.INFO)
...
... def halve_and_quadruple(num):
...     # prints INFO:root: num == 5 to the example.log file
...     logging.info(f" num == {num}")
...     return (num // 2) * 4
...
>>> print(halve_and_quadruple(5))

assert

assert is a statement which should always evaluate to True unless there is a bug in the program. When an assert evaluates to False it will raise an AssertionError. The Traceback for the AssertionError can include an optional message that is part of the assert statement. Although a message is optional, it is good practice to always include one in the assert definition.

The following is an example of using assert:

>>> def int_division(dividend, divisor):
...     assert divisor != 0, "divisor must not be 0"
...     return dividend // divisor
...
>>> print(int_division(2, 1))
2
>>> print(int_division(2, 0))
Traceback (most recent call last):
  File <stdin>, line 7, in <module>
    print(int_division(2, 0))
          ^^^^^^^^^^^^^^^^^^
  File <stdin>, line 2, in int_division
    assert divisor != 0, "divisor must not be 0"
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: divisor must not be 0

If we start reading the Traceback at the bottom (as we should) we quickly see the problem is that 0 should not be passed as the divisor.

assert can also be used to test that a value is of the expected type:

>>> import numbers
...
...
... def int_division(dividend, divisor):
...     assert divisor != 0, "divisor must not be 0"
...     assert isinstance(divisor, numbers.Number), "divisor must be a number"
...     return dividend // divisor
...
>>> print(int_division(2, 1))
2
>>> print(int_division(2, '0'))
Traceback (most recent call last):
  File <stdin>, line 11, in <module>
    print(int_division(2, '0'))
          ^^^^^^^^^^^^^^^^^^^^
  File <stdin>, line 6, in int_division
    assert isinstance(divisor, numbers.Number), "divisor must be a number"
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: divisor must be a number

Once a bug is identified, consider replacing the assert with error handling. This is because all assert statements can be disabled through running Python with the -O or -OO options, or from setting the PYTHONOPTIMIZE environment variable to 1 or 2. Setting PYTHONOPTIMIZE to 1 is equivalent to running Python with the -O option, which disables assertions. Setting PYTHONOPTIMIZE to 2 is equivalent to running Python with the -OO option, which both disables assertions and removes docstrings from the bytcode. Reducing bytecode is one way to make the code run faster.

Python Debugger

Python has a built in debugger, pdb. It can be used to step through code and inspect variables. You can also set breakpoints with it. To get started you have to first import pdb and then call pdb.set_trace() where you want to start debugging:

import pdb

def add(num1, num2):
    return num1 + num2

pdb.set_trace()
sum = add(1,5)
print(sum)

Running this code will give you a pdb prompt where you can type in commands. Write help to get a list of commands. The most common ones are step which steps into a function called at that line. next steps over a function call and move to the next line. where tells you which line you are on. Some other useful commands are whatis <variable> which tells you the type of a variable and print(<variable>) which prints the value of a variable. You can also just use <variable> to print the value of a variable. Another command is jump <line number> which jumps to a specific line number.

Here is a small example of how to use the debugger based on the code earlier. Note that for this and following examples, MacOS or Linux platforms would have file paths using forward slashes:

>>> python pdb.py
... > c:\pdb.py(7)<module>()
... -> sum = add(1,5)
... (Pdb)
>>> step
... > c:\pdb.py(3)add()
... -> def add(num1, num2):
... (Pdb)
>>> whatis num1
... <class 'int'>
>>> print(num2)
... 5
>>> next
... > c:\pdb.py(4)add()
... -> return num1 + num2
... (Pdb)
>>> jump 3
... > c:\pdb.py(3)add()
... -> def add(num1, num2):
... (Pdb)

Breakpoints are set up by break <filename>:<line number> <condition> where the condition is an optional condition that has to be true for the breakpoint to be hit. You can simply write break to get a list of the breakpoints you have set. To disable a breakpoint you can write disable <breakpoint number>. To enable a breakpoint you can write enable <breakpoint number>. To delete a breakpoint you can write clear <breakpoint number>. To continue execution you can write continue or c. To exit the debugger you can write quit or q.

Here is an example of how to use the above debugger commands based on the code earlier:

>>> python pdb.py
... > c:\pdb.py(7)<module>()
... -> sum = add(1,5)
... (Pdb)
>>> break
...
>>> break pdb:4
... Breakpoint 1 at c:\pdb.py:4
>>> break
... Num Type         Disp Enb   Where
... 2   breakpoint   keep yes   at c:\pdb.py:4
>>> c # continue
... > c:\pdn.py(4)add()
... -> return num1 + num2
>>> disable break 1
... Disabled breakpoint 1 at c:\pdb.py:4
>>> break
... Num Type         Disp Enb   Where
... 1   breakpoint   keep no    at c:\pdb.py:4
...         breakpoint already hit 1 time
>>> clear break 1
... Deleted breakpoint 1 at c:\pdb.py:4
>>> break
...

In Python 3.7+ there is an easier way to create breakpoints. Simply writing breakpoint() where needed will create one.

def add(num1, num2):
    breakpoint()
    return num1 + num2

breakpoint()
sum = add(1,5)
print(sum)
>>> python pdb.py
... > c:\pdb.py(7)<module>()
... -> sum = add(1,5)
... (Pdb)
>>> c # continue
... > c:\pdb.py(5)add()
... -> return num1 + num2