Skip to content

Latest commit

 

History

History
957 lines (703 loc) · 33.8 KB

File metadata and controls

957 lines (703 loc) · 33.8 KB

十一、调试——解决 bug

上一章向您展示了如何在代码中添加日志记录和测试,但是无论您有多少测试,您总是会有 bug。最大的问题总是用户输入,因为根本不可能测试所有可能的输入,这意味着在某一点上,我们需要调试代码。

有许多调试技术,最肯定的是,您已经使用了其中的一些。在本章中,我们将重点介绍打印/跟踪调试和交互式调试。

使用打印语句、堆栈跟踪和日志进行调试是最通用的方法之一,而且很可能是您使用过的第一种调试类型。甚至一个print 'Hello world'也可以被认为是这种类型,因为输出将显示您的代码正在正确执行。显然,解释如何以及在何处放置 print 语句来调试代码是没有意义的,但是使用 decorator 和其他 Python 模块有很多不错的技巧,使这种类型的调试更加有用,例如faulthandler

交互式调试是一种更复杂的调试方法。它允许您在程序仍在运行时调试程序。使用此方法,甚至可以在应用运行时更改变量,并在所需的任意点暂停应用。缺点是它需要一些关于调试器命令的知识才能真正有用。

总而言之,我们将涵盖以下主题:

  • 使用printtraceloggingfaulthandler进行调试
  • 使用pdb进行交互式调试

非交互调试

最基本的调试形式是在代码中添加一个简单的 print 语句,以查看哪些仍在工作,哪些不工作。这在各种情况下都很有用,可能有助于解决大多数问题。在本章后面,我们将展示一些交互式调试方法,但这些方法并不总是适用的。在多线程环境中,交互式调试往往变得困难甚至不可能,而在关闭的远程服务器上,您可能还需要不同的解决方案。这两种方法都有各自的优点,但我个人 90%的时间选择非交互式调试,因为一个简单的打印/日志语句通常足以分析问题的原因。

生成器的一个基本示例(我也做过类似的工作)如下所示:

>>> def spam_generator():
...     print('a')
...     yield 'spam'
...     print('b')
...     yield 'spam!'
...     print('c')
...     yield 'SPAM!'
...     print('d')

>>> generator = spam_generator()

>>> next(generator)
a
'spam'

>>> next(generator)
b
'spam!'

这准确地显示了代码所处的位置,并且因此无法到达。如果没有这个示例,您可能会期望第一次打印在spam_generator()调用之后立即出现,因为它是一个生成器。然而,在我们yield得到一个项目之前,执行完全停滞。假设您在第一个yield之前有一些设置代码,它将在实际调用next之前不会运行。

虽然这是使用 print 语句调试函数的最简单方法之一,但绝对不是最好的方法。我们可以先制作一个自动打印功能,自动增加字母:

>>> import string

>>> def print_character():
...     i = 0
...     while True:
...         print('Letter: %r' % string.ascii_letters[i])
...         i = (i + 1) % len(string.ascii_letters)
...         yield
>>> # Always initialize
>>> print_character = print_character()

>>> next(print_character)
Letter: 'a'
>>> next(print_character)
Letter: 'b'
>>> next(print_character)
Letter: 'c'

尽管打印语句生成器略优于裸打印语句,但它的帮助还不大。在运行代码时,查看实际执行了哪些行会更有用。我们可以使用inspect.currentframe手动完成,但不需要黑客。Python 为您介绍了一些专用工具。

使用 trace 检查您的脚本

简单的打印语句在很多情况下都很有用,因为您几乎可以在每个应用中轻松地合并打印语句。无论它是远程的还是本地的、线程化的还是使用多进程的,这都无关紧要。它几乎在任何地方都能工作,使它成为除日志记录之外最通用的解决方案。然而,一般解决方案往往不是最佳解决方案。对于最常见的场景,有更好的解决方案。其中之一是trace模块。它为您提供了一种跟踪每次执行、函数之间的关系以及其他一些情况的方法。

为了演示,我们将使用前面的代码,但不使用打印语句:

def eggs_generator():
    yield 'eggs'
    yield 'EGGS!'

def spam_generator():
    yield 'spam'
    yield 'spam!'
    yield 'SPAM!'

generator = spam_generator()
print(next(generator))
print(next(generator))

generator = eggs_generator()
print(next(generator))

我们将通过跟踪模块执行:

# python3 -m trace --trace --timing tracing.py
 --- modulename: tracing, funcname: <module>
0.00 tracing.py(1): def eggs_generator():
0.00 tracing.py(6): def spam_generator():
0.00 tracing.py(11): generator = spam_generator()
0.00 tracing.py(12): print(next(generator))
 --- modulename: tracing, funcname: spam_generator
0.00 tracing.py(7):     yield 'spam'
spam
0.00 tracing.py(13): print(next(generator))
 --- modulename: tracing, funcname: spam_generator
0.00 tracing.py(8):     yield 'spam!'
spam!
0.00 tracing.py(15): generator = eggs_generator()
 --- modulename: tracing, funcname: spam_generator
0.00 tracing.py(16): print(next(generator))
 --- modulename: tracing, funcname: eggs_generator
0.00 tracing.py(2):     yield 'eggs'
eggs
 --- modulename: trace, funcname: _unsettrace
0.00 trace.py(77):         sys.settrace(None)

很好,不是吗?它准确地显示了使用函数名执行的行,更重要的是,哪一行是由哪一条语句(或多条语句)引起的。此外,它还显示了相对于程序的开始时间执行它的时间。这是由于--timing标志。

正如您可能期望的那样,这个输出有点过于冗长,无法普遍使用。尽管您可以通过使用命令行参数选择忽略特定模块和目录,但在许多情况下仍然过于冗长。那么,让我们来看下一个解决方案——上下文管理器。前面的输出已经揭示了一些trace内部结构。最后一行显示了一个sys.settrace调用,这正是我们手动跟踪所需要的:

import- sys
import trace as trace_module
import contextlib

@contextlib.contextmanager
def trace(count=False, trace=True, timing=True):
    tracer = trace_module.Trace(
        count=count, trace=trace, timing=timing)
    sys.settrace(tracer.globaltrace)
    yield tracer
    sys.settrace(None)

    result = tracer.results()
    result.write_results(show_missing=False, summary=True)

def eggs_generator():
    yield 'eggs'
    yield 'EGGS!'

def spam_generator():
    yield 'spam'
    yield 'spam!'
    yield 'SPAM!'

with trace():
    generator = spam_generator()
    print(next(generator))
    print(next(generator))

generator = eggs_generator()
print(next(generator))

当作为常规 Python 文件执行时,返回:

# python3 tracing.py
 --- modulename: tracing, funcname: spam_generator
0.00 tracing.py(24):     yield 'spam'
spam
 --- modulename: tracing, funcname: spam_generator
0.00 tracing.py(25):     yield 'spam!'
spam!
 --- modulename: contextlib, funcname: __exit__
0.00 contextlib.py(64):         if type is None:
0.00 contextlib.py(65):             try:
0.00 contextlib.py(66):                 next(self.gen)
 --- modulename: tracing, funcname: trace
0.00 tracing.py(12):     sys.settrace(None)

这个代码立即揭示了跟踪代码在内部的作用:它使用sys.settrace告诉 Python 解释器将要执行的每个语句发送到哪里。考虑到这一点,作为装饰器编写函数显然是微不足道的,但如果您需要,我将把它作为练习留给您。

另一个好处是,通过包装tracer.globaltrace,您可以轻松地向跟踪函数添加额外的过滤器。该函数采用以下参数(来自标准 Python 文档):

|

参数

|

描述

| | --- | --- | | Call | 调用一个函数(或输入一些其他代码块)。调用全局跟踪函数;argNone。返回值指定本地跟踪函数。 | | Line | 解释器即将执行一行新代码或重新执行循环的条件。调用本地跟踪函数;argNone。返回值指定新的本地跟踪函数。有关如何工作的详细说明,请参见Objects/lnotab_notes.txt 。 | | return | 一个函数(或另一个代码块)即将返回。调用本地跟踪函数;arg是将返回的值,如果事件是由引发的异常引起的,则返回None。跟踪函数的返回值被忽略。 | | exception | 此表示发生了异常。调用本地跟踪函数;arg是一个元组(exceptionvaluetraceback)。返回值指定新的本地跟踪函数。 | | c_call | 即将调用一个 C 函数。这可能是一个扩展函数或内置函数。arg是 C 函数对象。 | | c_return | 一个 C 函数已经返回,arg是 C 函数对象。 | | c_exception | C 函数引发了异常,arg是 C 函数对象。 |

正如您所预期的,通过一个简单的过滤函数,您可以轻松地确保只返回特定的函数,而不是通常得到的长列表。您真的不应该低估通过少量导入跟踪代码生成的数据量。前面的上下文管理器代码提供了 300 多行输出。

使用日志进行调试

第 10 章测试和记录–为 Bug 做准备、我们关于测试和记录的章节中,我们了解了如何创建自定义记录器,为它们设置级别,并将处理程序添加到特定级别。我们现在将使用logging.DEBUG级别进行日志记录,这本身并没有什么特别之处,但是通过一些装饰程序,我们可以添加一些非常有用的只需调试的代码。

无论何时调试,我总是发现了解函数的输入和输出非常有用。带有装饰器的基本版本非常简单,可以编写;只需打印argskwargs即可。下面的示例更进一步。通过使用inspect模块,我们还可以检索默认参数,从而可以在所有情况下显示带有参数名称和值的所有参数,即使未指定参数:

import pprint
import inspect
import logging
import functools

logging.basicConfig(level=logging.DEBUG)

def debug(function):
    @functools.wraps(function)
    def _debug(*args, **kwargs):
        try:
            result = function(*args, **kwargs)
        finally:
            # Extract the signature from the function
            signature = inspect.signature(function)
            # Fill the arguments
            arguments = signature.bind(*args, **kwargs)
            # NOTE: This only works for Python 3.5 and up!
            arguments.apply_defaults()

            logging.debug('%s(%s): %s' % (
                function.__qualname__,
                ', '.join('%s=%r' % (k, v) for k, v in
                          arguments.arguments.items()),
                pprint.pformat(result),
            ))

    return _debug

@debug
def spam(a, b=123):
    return 'some spam'

spam(1)
spam(1, 456)
spam(b=1, a=456)

返回以下输出:

# python3 logged.py
DEBUG:root:spam(a=1, b=123): 'some spam'
DEBUG:root:spam(a=1, b=456): 'some spam'
DEBUG:root:spam(a=456, b=1): 'some spam'

非常好的当然,因为我们清楚地看到何时调用函数,使用了哪些参数,以及返回了什么。但是,这可能只有在您积极调试代码时才能执行。通过添加特定于调试的记录器,您还可以使代码中的常规logging.debug语句变得更加有用,这将显示更多信息。只需将前面示例的日志记录配置替换为:

import logging

log_format = (
    '[%(relativeCreated)d %(levelname)s] '
    '%(pathname)s:%(lineno)d:%(funcName)s: %(message)s'
)
logging.basicConfig(level=logging.DEBUG, format=log_format)

那么你的结果会是这样的:

# time python3 logged.py
[0 DEBUG] logged.py:31:_debug: spam(a=1, b=123): 'some spam'
[0 DEBUG] logged.py:31:_debug: spam(a=1, b=456): 'some spam'
[0 DEBUG] logged.py:31:_debug: spam(a=456, b=1): 'some spam'
python3 logged.py  0.04s user 0.01s system 96% cpu 0.048 total

它显示相对于应用启动的时间(毫秒)和日志级别。随后是一个标识块,显示文件名、行号和生成日志的函数名。当然,最后还有一条信息。

无异常显示调用堆栈

当查看一段代码是如何运行的以及为什么运行时,查看整个堆栈跟踪通常很有用。当然,简单地提出一个例外是一种选择。然而,这将终止当前代码的执行,这通常不是我们要寻找的。这就是traceback模块派上用场的地方。只需几行简单的代码,我们就可以得到完整的(或有限的,如果您愿意)堆栈列表:

import traceback

class Spam(object):

    def run(self):
        print('Before stack print')
        traceback.print_stack()
        print('After stack print')

class Eggs(Spam):
    pass

if __name__ == '__main__':
    eggs = Eggs()
    eggs.run()

其结果如下:

# python3 traceback_test.py
Before stack print
 File "traceback_test.py", line 18, in <module>
 eggs.run()
 File "traceback_test.py", line 8, in run
 traceback.print_stack()
After stack print

正如您所看到的,回溯只是简单地打印,没有任何异常。traceback模块实际上有很多其他方法用于打印基于异常等的回溯,但您可能不经常需要它们。最有用的可能是limit参数;此参数允许您将堆栈跟踪限制为有用的部分。例如,如果使用 decorator 或 helper 函数添加了此代码,则可能不需要在堆栈跟踪中包含这些代码。这就是limit参数的作用:

import traceback

class Spam(object):

    def run(self):
        print('Before stack print')
        traceback.print_stack(limit=-1)
        print('After stack print')

class Eggs(Spam):
    pass

if __name__ == '__main__':
    eggs = Eggs()
    eggs.run()

此导致如下:

# python3 traceback_test.py
Before stack print
 File "traceback_test.py", line 18, in <module>
 eggs.run()
After stack print

如您所见,print_stack函数本身现在已从堆栈跟踪中隐藏,这使所有内容都变得更干净。

Python 3.5 中添加了负限制支持。在此之前,只支持正面限制。

异步调试

asyncio模块有一些特殊的规定,使调试更加容易。鉴于asyncio中函数的异步性质,这是一个非常受欢迎的壮举。虽然调试多线程/多进程函数或类可能很困难,因为并发类可以轻松地与asyncio并行更改环境变量,但如果不是更多的话,也同样困难。

在大多数 Linux/Unix/Mac shell 会话中,可以使用它作为前缀来设置环境变量:

SOME_ENVIRONMENT_VARIABLE=value python3 script.py

此外,可以使用export为当前 shell 会话配置:

export SOME_ENVIRONMENT_VARIABLE=value

可以使用以下行获取当前值:

echo $SOME_ENVIRONMENT_VARIABLE

在 Windows 上,您可以使用set命令为本地 shell 会话配置环境变量:

set SOME_ENVIRONMENT_VARIABLE=value

可以使用以下行获取当前值:

set SOME_ENVIRONMENT_VARIABLE

当使用PYTHONASYNCIODEBUG环境设置启用调试模式时,asyncio模块将检查每个定义的协同程序是否实际运行:

import asyncio

@asyncio.coroutine
def printer():
    print('This is a coroutine')

printer()

这会导致打印机协同程序出错,此处不会产生此错误:

# PYTHONASYNCIODEBUG=1 python3 asyncio_test.py
<CoroWrapper printer() running, defined at asyncio_test.py:4, created at asyncio_test.py:8> was never yielded from
Coroutine object created at (most recent call last):
 File "asyncio_test.py", line 8, in <module>
 printer()

此外,event循环默认有一些日志消息:

import asyncio
import logging

logging.basicConfig(level=logging.DEBUG)
loop = asyncio.get_event_loop()

这将导致调试消息中出现,例如如下所示:

# PYTHONASYNCIODEBUG=1 python3 asyncio_test.py
DEBUG:asyncio:Using selector: KqueueSelector
DEBUG:asyncio:Close <_UnixSelectorEventLoop running=False closed=False debug=True>

您可能想知道为什么我们使用PYTHONASYNCIODEBUG标志而不是loop.set_debug(True)。原因是,在某些情况下,由于启用调试太晚,这将不起作用。例如,在使用前面的printer()时,您会发现单独使用loop.set_debug(True)不会出现任何错误。

启用调试时,以下内容将更改:

  • 尚未生成的协程(如前几行所示)将引发异常。
  • 从“错误”线程调用协同路由会引发异常。
  • 将记录选择器的执行时间。
  • 将记录慢速回调(超过 100 毫秒)。此超时可通过loop.slow_callback_duration修改。
  • 资源未正确关闭时将发出警告。
  • 将记录在执行之前销毁的任务。

使用 faulthandler 处理崩溃

faulthandler模块有助于调试真正的低级别崩溃,也就是说,只有在使用低级别内存访问(如 C 扩展)时才可能发生崩溃。

例如,下面是一些会导致 Python 解释器崩溃的代码:

import ctypes

# Get memory address 0, your kernel shouldn't allow this:
ctypes.string_at(0)

其结果与以下类似:

# python faulthandler_test.py
zsh: segmentation fault  python faulthandler_test.py

当然,这是一个相当丑陋的回答,你不可能处理这个错误。万一你想知道,在这些情况下,拥有一个try/except结构也帮不了你。以下代码将以完全相同的方式崩溃:

import ctypes

try:
    # Get memory address 0, your kernel shouldn't allow this:
    ctypes.string_at(0)
except Exception as e:
    print('Got exception:', e)

这就是faulthandler模块帮助的地方。它仍然会导致解释器崩溃,但至少您会看到一条正确的错误消息,因此,如果您(或任何子库)与原始内存有任何交互,这是一个良好的默认设置:

import ctypes
import faulthandler

faulthandler.enable()

# Get memory address 0, your kernel shouldn't allow this:
ctypes.string_at(0)

其结果大致如下:

# python faulthandler_test.py
Fatal Python error: Segmentation fault

Current thread 0x00007fff79171300 (most recent call first):
 File "ctypes/__init__.py", line 491 in string_at
 File "faulthandler_test.py", line 7 in <module>
zsh: segmentation fault  python faulthandler_test.py

显然,以这种方式退出 Python 应用是不可取的,因为代码不会以正常的清理方式退出。资源不会完全关闭,也不会调用退出处理程序。如果您需要捕获这种行为,那么最好将 Python 可执行文件包装在一个单独的脚本中。

交互调试

既然我们已经讨论了将始终有效的基本调试方法,那么我们将看看交互式调试,了解一些更高级的调试技术。以前的调试方法通过修改代码和/或预见使变量和堆栈可见。这一次,我们将看一个稍微聪明一点的方法,它包括交互地做同样的事情,但一旦需要时。

按需控制台

在测试一些 Python 代码时,您可能已经多次使用交互控制台,因为它是测试 Python 代码的一个简单而有效的工具。您可能不知道的是,从代码中启动自己的 shell 实际上很简单。因此,无论何时,只要您想从代码中的特定点进入常规 shell,都很容易做到:

import code

def spam():
    eggs = 123
    print('The begin of spam')
    code.interact(banner='', local=locals())
    print('The end of spam')
    print('The value of eggs: %s' % eggs)

if __name__ == '__main__':
    spam()

执行此操作时,我们将中途进入交互式控制台:

# python3 test_code.py
The begin of spam
>>> eggs
123
>>> eggs = 456
>>>
The end of spam
The value of eggs: 123

要退出此控制台,我们可以在 Linux/Mac 系统上使用*^d*(Ctrl+d),在 Windows 系统上使用*^z*(Ctrl+z)。

这里需要注意的一个重要的点是,范围在两者之间不共享。尽管为了方便起见,我们通过locals()来共享局部变量,但这种关系不是双向的。结果是,即使我们在交互会话中将eggs设置为456,它也不会转移到外部功能。如果愿意,可以通过直接操作(例如,设置属性)修改外部范围中的变量,但所有在本地声明的变量都将保持在本地。

使用 pdb 进行调试

当涉及到实际调试代码时,常规的交互控制台并不适合。只要稍加努力,您就可以使它工作,但调试起来并不那么方便,因为您只能看到当前的作用域,不能轻松地跳转堆栈。使用pdb(Python 调试器),这很容易实现。让我们来看一个使用pdb的简单示例:

import pdb

def spam():
    eggs = 123
    print('The begin of spam')
    pdb.set_trace()
    print('The end of spam')
    print('The value of eggs: %s' % eggs)

if __name__ == '__main__':
    spam()

这个例子与上一段中的例子几乎相同,只是这次我们使用的是pdb控制台,而不是常规的交互式控制台。让我们尝试一下交互式调试器:

# python3 test_pdb.py
The begin of spam
> test_pdb.py(8)spam()
-> print('The end of spam')
(Pdb) eggs
123
(Pdb) eggs = 456
(Pdb) continue
The end of spam
The value of eggs: 456

正如你所看到的,我们实际上已经修改了eggs的值。在这个例子中,我们使用了完整的continue命令,但是所有的pdb命令都有简短的版本。因此,使用c而不是continue会得到相同的结果。只需键入eggs(或任何其他变量)即可显示内容,设置变量只需设置即可,就像我们在交互式会话中所期望的那样。

要开始使用pdb,首先,这里显示了最有用(完整)的速记命令列表:

|

命令

|

解释

| | --- | --- | | h(elp) | 此显示命令列表(此列表)。 | | h(elp) command | 此显示给定命令的帮助。 | | w(here) | 当前在当前帧上带有箭头的堆栈跟踪。 | | d(own) | 将向下/移动到堆栈中较新的帧。 | | u(p) | 将向上/移动到堆栈中较旧的帧。 | | s(tep) | 执行当前行并尽快停止。 | | n(ext) | 执行当前行,并在当前功能的下一行停止。 | | r(eturn) | 继续执行,直到函数返回。 | | c(ont(inue)) | 继续执行直到下一个断点。 | | l(ist) [first[, last]] | 列出当前行周围的行源代码(默认为 11 行)。 | | ll &#124; longlist | 列出当前函数或帧的所有源代码。 | | source expression | 列出给定对象的源代码。这类似于长列表。 | | a(rgs) | 打印当前函数的参数。 | | pp expression | 漂亮的印刷体给定的表达式。 | | b(reak) | 显示断点列表。 | | b(reak) [filename:]lineno | 将断点放置在给定行号处,也可以选择文件。 | | b(reak) function[, condition] | 在给定函数处放置一个断点。该条件是一个表达式,其计算结果必须为True,断点才能工作。 | | cl(ear) [filename:]lineno | 清除此行的断点。 | | cl(ear) breakpoint [breakpoint ...] | 用这些数字清除断点。 | | Command | 列出所有定义的命令。 | | command breakpoint | 指定在遇到给定断点时要执行的命令列表。使用end命令结束列表。 | | Alias | 列出所有别名。 | | alias name command | 创建一个别名。该命令可以是任何有效的 Python 表达式,因此您可以执行以下操作来打印对象的所有属性:

alias pd pp %1.__dict__ 

| | unalias name | 删除别名。 | | ! statement | 在堆栈中的当前点执行语句。通常不需要!符号,但如果与调试器命令发生冲突,这可能会很有用。例如,尝试b = 123。 | | Interact | 打开与上一段类似的互动会话。请注意,在该局部范围内设置的变量将不会被传输。 |

断点

这是一个相当长的列表,但是你可能会经常使用其中的大部分。为了突出显示上表中显示的选项之一,让我们演示断点的设置和使用:

import pdb

def spam():
    print('The begin of spam')
    print('The end of spam')

if __name__ == '__main__':
    pdb.set_trace()
    spam()

到目前为止,没有发生任何新的情况,但现在让我们打开交互式调试会话,如下所示:

# python3 test_pdb.py
> test_pdb.py(11)<module>()
-> while True:
(Pdb) source spam  # View the source of spam
 4     def spam():
 5         print('The begin of spam')
 6         print('The end of spam')

(Pdb) b 5  # Add a breakpoint to line 5
Breakpoint 1 at test_pdb.py:5

(Pdb) w  # Where shows the current line
> test_pdb.py(11)<module>()
-> while True:

(Pdb) c  # Continue (until the next breakpoint or exception)
> test_pdb.py(5)spam()
-> print('The begin of spam')

(Pdb) w  # Where again
 test_pdb.py(12)<module>()
-> spam()
> test_pdb.py(5)spam()
-> print('The begin of spam')

(Pdb) ll  # List the lines of the current function
 4     def spam():
 5 B->     print('The begin of spam')
 6         print('The end of spam')

(Pdb) b  # Show the breakpoints
Num Type         Disp Enb   Where
1   breakpoint   keep yes   at test_pdb.py:5
 breakpoint already hit 1 time

(Pdb) cl 1  # Clear breakpoint 1
Deleted breakpoint 1 at test_pdb.py:5

这是一个大量的输出,但实际上并不像看起来那么复杂:

  1. 首先,我们使用source spam命令查看spam函数的源代码。
  2. 之后,我们知道了第一条print语句的行号,我们使用它在第 5 行放置断点(b 5
  3. 为了检查我们是否仍然在正确的位置,我们使用了w命令。
  4. 由于设置了断点,我们使用c继续到下一个断点。
  5. 在第 5 行的断点处停下来后,我们再次使用w来确认这一点。
  6. 使用ll列出当前函数的代码。
  7. 使用b列出断点。
  8. 使用带有上一个命令中的断点编号的cl 1再次删除断点。

一开始这一切看起来有点复杂,但您会发现,一旦您尝试了几次,它实际上是一种非常方便的调试方式。

为了更好,这次我们只在eggs = 3时执行断点。代码基本相同,但在本例中我们需要一个变量:

import pdb

def spam(eggs):
    print('eggs:', eggs)

if __name__ == '__main__':
    pdb.set_trace()
    for i in range(5):
        spam(i)

现在,让我们执行代码,确保它只在特定时间中断:

# python3 test_breakpoint.py
> test_breakpoint.py(10)<module>()
-> for i in range(5):
(Pdb) source spam
 4     def spam(eggs):
 5         print('eggs:', eggs)
(Pdb) b 5, eggs == 3  # Add a breakpoint to line 5 whenever eggs=3
Breakpoint 1 at test_breakpoint.py:5
(Pdb) c  # Continue
eggs: 0
eggs: 1
eggs: 2
> test_breakpoint.py(5)spam()
-> print('eggs:', eggs)
(Pdb) a  # Show function arguments
eggs = 3
(Pdb) c  # Continue
eggs: 3
eggs: 4

要列出我们所做的工作:

  1. 首先,使用source垃圾邮件,我们查找行号。
  2. 之后,我们放置了一个带有eggs == 3条件的断点。
  3. 然后我们使用c继续执行。如您所见,012的值打印正常。
  4. 在值3处达到断点。为了验证这一点,我们使用a查看函数参数。
  5. 我们继续执行代码的其余部分。

捕捉异常

所有这些都是对pdb.set_trace()函数的手动调用,但通常情况下,您只是在运行应用,并不真正期待问题。这就是异常捕获非常方便的地方。除了自己导入pdb之外,您还可以通过pdb作为一个模块运行脚本。让我们检查一下这段代码,它一达到零除法就消失了:

print('This still works')
1/0
print('We shouldnt reach this code')

如果我们使用pdb参数运行它,那么每当它崩溃时,我们都可以在 Python 调试器中结束:

# python3 -m pdb test_zero.py
> test_zero.py(1)<module>()
-> print('This still works')
(Pdb) w  # Where
 bdb.py(431)run()
-> exec(cmd, globals, locals)
 <string>(1)<module>()
> test_zero.py(1)<module>()
-> print('This still works')
(Pdb) s  # Step into the next statement
This still works
> test_zero.py(2)<module>()
-> 1/0
(Pdb) c  # Continue
Traceback (most recent call last):
 File "pdb.py", line 1661, in main
 pdb._runscript(mainpyfile)
 File "pdb.py", line 1542, in _runscript
 self.run(statement)
 File "bdb.py", line 431, in run
 exec(cmd, globals, locals)
 File "<string>", line 1, in <module>
 File "test_zero.py", line 2, in <module>
 1/0
ZeroDivisionError: division by zero
Uncaught exception. Entering post mortem debugging
Running 'cont' or 'step' will restart the program
> test_zero.py(2)<module>()
-> 1/0

提示

pdb中一个有用的小技巧是使用回车按钮,默认情况下,该按钮将再次执行之前执行的命令。这在单步执行程序时非常有用。

命令

commands命令有点复杂,但非常有用。它允许您在遇到特定断点时执行命令。为了说明这一点,让我们再次从一个简单的例子开始:

import pdb

def spam(eggs):
    print('eggs:', eggs)

if __name__ == '__main__':
    pdb.set_trace()
    for i in range(5):
        spam(i)

代码非常简单,因此现在我们将添加断点和命令,如下所示:

# python3 test_breakpoint.py
> test_breakpoint.py(10)<module>()
-> for i in range(3):
(Pdb) b spam  # Add a breakpoint to function spam
Breakpoint 1 at test_breakpoint.py:4
(Pdb) commands 1  # Add a command to breakpoint 1
(com) print('The value of eggs: %s' % eggs)
(com) end  # End the entering of the commands
(Pdb) c  # Continue
The value of eggs: 0
> test_breakpoint.py(5)spam()
-> print('eggs:', eggs)
(Pdb) c  # Continue
eggs: 0
The value of eggs: 1
> test_breakpoint.py(5)spam()
-> print('eggs:', eggs)
(Pdb) cl 1  # Clear breakpoint 1
Deleted breakpoint 1 at test_breakpoint.py:4
(Pdb) c  # Continue
eggs: 1
eggs: 2

正如您所看到的,我们可以很容易地向断点添加命令。删除断点后,这些命令显然将不再执行。

使用 ipdb 进行调试

虽然通用 Python 控制台很有用,但它的边缘可能有点粗糙。IPython 控制台提供了全新的额外功能,这使它成为一个更好的控制台。其中一个特性是更方便的调试器。

首先,确保已安装ipdb

pip install ipdb

接下来,让我们使用前面的脚本再次尝试调试器。唯一的小变化是我们现在导入的是ipdb而不是pdb

import ipdb

def spam(eggs):
    print('eggs:', eggs)

if __name__ == '__main__':
    ipdb.set_trace()
    for i in range(3):
        spam(i)

然后我们执行它:

# python3 test_ipdb.py
> test_ipdb.py(10)<module>()
 9     ipdb.set_trace()
---> 10     for i in range(3):
 11         spam(i)

ipdb> b spam  # Set a breakpoint
Breakpoint 1 at test_ipdb.py:4
ipdb> c  # Continue (until exception or breakpoint)
> test_ipdb.py(5)spam()
1     4 def spam(eggs):
----> 5     print('eggs:', eggs)
 6

ipdb> a  # Show the arguments
eggs = 0
ipdb> c  # Continue
eggs: 0
> test_ipdb.py(5)spam()
1     4 def spam(eggs):
----> 5     print('eggs:', eggs)
 6

ipdb>   # Repeat the previous command, so continue again
eggs: 1
> test_ipdb.py(5)spam()
1     4 def spam(eggs):
----> 5     print('eggs:', eggs)
 6

ipdb> cl 1  # Remove breakpoint 1
Deleted breakpoint 1 at test_ipdb.py:4
ipdb> c  # Continue
eggs: 2

命令都是一样的,但在我看来,输出只是更清晰了一点。实际版本还包括语法突出显示,这使得输出更容易理解。

简言之,在大多数情况下,您只需将pdb替换为ipdb,即可获得更直观的调试器。但我也会向ipdb上下文管理器推荐:

import ipdb

with ipdb.launch_ipdb_on_exception():
    main()

这看起来很方便。它只是将ipdb挂接到您的异常中,以便您可以在需要时轻松地进行调试。将其与调试标志结合到应用中,以便在需要时方便地允许调试。

其他调试器

pdbipdb只是 Python 可用的大量调试器中的两个。目前值得注意的一些调试器如下:

  • pudb:此提供全屏命令行调试器
  • pdbpp:将勾入常规pdb
  • rpdb2:这是一个远程调试器,允许将挂接到正在运行的(远程)应用中
  • Werkzeug:这是一个基于 web 的调试器,允许在 web 应用运行时进行调试

当然还有很多其他的,没有一个是绝对最好的。与所有工具一样,它们都有各自的优点和缺点,只有您才能正确地决定最适合您当前目的的工具。您当前的 Python IDE 可能已经有了一个集成的调试器。

调试服务

除了在遇到问题时进行调试外,有时您只需跟踪错误以便以后进行调试。尤其是在使用远程服务器时,这些对于检测 Python 进程何时以及如何发生故障非常有用。此外,这些服务还提供了错误分组,这使得它们比一个简单的电子邮件异常类型的脚本更有用,它可以快速地向收件箱发送垃圾邮件。

一个很好的跟踪错误的开源解决方案是sentry。如果您需要提供性能跟踪的完整 Fletch 解决方案,那么 Opbeat 和 Newrelic 是非常好的解决方案;他们提供免费和付费版本。请注意,所有这些还支持跟踪其他语言,例如 JavaScript。

总结

本章解释了几种不同的调试技术和要点。当然,关于调试还有很多可以说的,但我希望您现在已经获得了调试 Python 代码的有利位置。交互式调试技术对于单线程应用和交互式会话可用的位置非常有用。但由于情况并非总是如此,我们还讨论了一些非交互式选项。

以下是本章讨论的所有要点的概述:

  • 非交互式调试使用:
    • print
    • logging
    • trace
    • traceback
    • asyncio
    • faulthandler
  • 使用pdbipdb进行交互调试

在下一章中,我们将看到如何监视和改进 CPU 和内存性能,以及如何查找和修复内存泄漏。