Python调试终极指南 - martinheinz


即使您编写清晰易读的代码,即使使用测试覆盖了代码,即使您是非常有经验的开发人员,也不可避免地会出现奇怪的错误,并且您将需要以某种方式进行调试。许多人只使用一堆print语句来查看代码中正在发生的事情。这种方法远非理想,还有很多更好的方法来找出您的代码出了什么问题,我们将在本文中探讨其中的一些方法。

记录是必须的
如果您编写的应用程序没有某种日志设置,您最终会后悔的。您的应用程序中没有任何日志可能会很难对所有错误进行故障排除。幸运的是-在Python中-设置基本记录器非常简单:

import logging
logging.basicConfig(
    filename='application.log',
    level=logging.WARNING,
    format= '[%(asctime)s] {%(pathname)s:%(lineno)d} %(levelname)s - %(message)s',
    datefmt='%H:%M:%S'
)

logging.error("Some serious error occurred.")
logging.warning('Function you are using is deprecated.')

这就是开始将日志写入文件的所有操作,该日志看起来像这样(您可以使用来找到文件的路径logging.getLoggerClass().root.handlers[0].baseFilename):

[12:52:35] {<stdin>:1} ERROR - Some serious error occurred.
[12:52:35] {<stdin>:1} WARNING - Function you are using is deprecated.

这种设置看起来似乎已经足够好了(通常是这样),但是配置合理,格式清晰,可读性强的日志可以使您的生活更加轻松。改善和扩大配置的一种方法是使用.ini或.yaml文件被由记录器读取。作为您可以在配置中执行的操作的示例:

version: 1
disable_existing_loggers: true

formatters:
  standard:
    format: "[%(asctime)s] {%(pathname)s:%(lineno)d} %(levelname)s - %(message)s"
    datefmt: '%H:%M:%S'

handlers:
  console:  # handler which will log into stdout
    class: logging.StreamHandler
    level: DEBUG
    formatter: standard  # Use formatter defined above
    stream: ext:
//sys.stdout
  file:  # handler which will log into file
    class: logging.handlers.RotatingFileHandler
    level: WARNING
    formatter: standard  # Use formatter defined above
    filename: /tmp/warnings.log
    maxBytes: 10485760 # 10MB
    backupCount: 10
    encoding: utf8

root:  # Loggers are organized in hierarchy - this is the root logger config
  level: ERROR
  handlers: [console, file]  # Attaches both handler defined above

loggers:  # Defines descendants of root logger
  mymodule:  # Logger for
"mymodule"
    level: INFO
    handlers: [file]  # Will only use
"file" handler defined above
    propagate: no  # Will not propagate logs to
"root" logger

在您的python代码中拥有这种广泛的配置将很难导航,编辑和维护。将内容保存在YAML文件中,可以使用上述非常特定的设置轻松设置和调整多个记录器。
如果您想知道所有这些配置字段的来源,请在此处进行记录,它们中的大多数只是关键字参数,如第一个示例所示。
因此,现在在文件中包含配置,意味着我们需要以某种方式加载。使用YAML文件的最简单方法是:

import yaml
from logging import config

with open("config.yaml", 'rt') as f:
    config_data = yaml.safe_load(f.read())
    config.dictConfig(config_data)

Python记录器实际上并不直接支持YAML文件,但它支持字典配置,可以使用yaml.safe_load轻松从YAML中创建。如果您倾向于使用旧.ini文件,那么我只想指出,作为一种docs,对于新应用程序,建议使用字典配置。有关更多示例,请查看日志记录手册

日志记录装饰器
使用日志记录修饰器代替修改函数的主体,该修饰符将记录具有特定日志级别和可选消息的每个函数调用。让我们看一下装饰器:

from functools import wraps, partial
import logging

def attach_wrapper(obj, func=None):  # Helper function that attaches function as attribute of an object
    if func is None:
        return partial(attach_wrapper, obj)
    setattr(obj, func.__name__, func)
    return func

def log(level, message):  # Actual decorator
    def decorate(func):
        logger = logging.getLogger(func.__module__)  # Setup logger
        formatter = logging.Formatter(
            '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
        handler = logging.StreamHandler()
        handler.setFormatter(formatter)
        logger.addHandler(handler)
        log_message = f"{func.__name__} - {message}"

        @wraps(func)
        def wrapper(*args, **kwargs):  # Logs the message and before executing the decorated function
            logger.log(level, log_message)
            return func(*args, **kwargs)

        @attach_wrapper(wrapper)  # Attaches
"set_level" to "wrapper" as attribute
        def set_level(new_level):  # Function that allows us to set log level
            nonlocal level
            level = new_level

        @attach_wrapper(wrapper)  # Attaches
"set_message" to "wrapper" as attribute
        def set_message(new_message):  # Function that allows us to set message
            nonlocal log_message
            log_message = f
"{func.__name__} - {new_message}"

        return wrapper
    return decorate

# Example Usage
@log(logging.WARN,
"example-param")
def somefunc(args):
    return args

somefunc(
"some args")

somefunc.set_level(logging.CRITICAL)  # Change log level by accessing internal decorator function
somefunc.set_message(
"new-message")  # Change log message by accessing internal decorator function
somefunc(
"some args")

有点复杂,这里的想法是log函数接受参数并将其提供给内部wrapper函数使用。然后,通过添加附加到装饰器的访问器函数使这些参数可调整。
至于functools.wraps装饰器:如果我们在这里不使用它,函数(func.__name__)的名称将被装饰器的名称覆盖。但这是一个问题,因为我们要打印名称。通过functools.wraps将函数名称,文档字符串和参数列表复制到装饰器函数上,可以解决此问题。
无论如何,这是上面代码的输出。很整洁吧?

2020-05-01 14:42:10,289 - __main__ - WARNING - somefunc - example-param
2020-05-01 14:42:10,289 - __main__ - CRITICAL - somefunc - new-message

__repr__ 有关更多可读日志
对您的代码的轻松改进使其更易于调试,这是__repr__在类中添加方法。如果您不熟悉此方法,它所做的就是返回类实例的字符串表示形式。__repr__方法的最佳实践是输出可用于重新创建实例的文本。例如:

class Circle:
    def __init__(self, x, y, radius):
        self.x = x
        self.y = y
        self.radius = radius

    def __repr__(self):
        return f"Rectangle({self.x}, {self.y}, {self.radius})"

...
c = Circle(100, 80, 30)
repr(c)
# Circle(100, 80, 30)

如果不希望或不可能像上面那样显示对象,那么很好的选择是使用<...>,例如使用<_io.TextIOWrapper name='somefile.txt' mode='w' encoding='UTF-8'>。
除了__repr__之外,最好还是实现__str__,这是在print(instance)调用时默认使用的方法。使用这两种方法,您只需打印变量即可获得很多信息。

__missing__ 字典的Dunder方法
如果出于某种原因需要实现自定义词典类,那么当您尝试访问实际上不存在的键时,可能会出现来自KeyErrors 一些错误。为了避免在代码中四处查找并查看缺少哪个,可以实现特殊的__missing__方法,该方法在每次KeyError引发时都会调用。

class MyDict(dict):
    def __missing__(self, key):
        message = f'{key} not present in the dictionary!'
        logging.warning(message)
        return message  # Or raise some error instead

上面的实现非常简单,只返回并记录缺少键的消息,但是您也可以记录其他有价值的信息,以便为您提供更多有关代码错误的上下文。

调试崩溃的应用程序
如果您的应用程序崩溃后才有机会查看其中发生的情况,那么您可能会发现此技巧非常有用。
使用-i参数(python3 -i app.py)运行该应用程序会使该程序在退出时立即启动交互式Shell。此时,您可以检查变量和函数。
如果这还不够好,可以使用更大的锤子- pdb- Python调试。pdb具有相当多的功能,这些功能可以保证自己撰写一篇文章。但这是示例,也是最重要的部分的摘要。首先让我们看一下崩溃的脚本:

# crashing_app.py
SOME_VAR = 42

class SomeError(Exception):
    pass

def func():
    raise SomeError("Something went wrong...")

func()

现在,如果使用-i参数运行它,我们将有机会对其进行调试:

# Run crashing application
~ $ python3 -i crashing_app.py
Traceback (most recent call last):
  File "crashing_app.py", line 9, in <module>
    func()
  File
"crashing_app.py", line 7, in func
    raise SomeError(
"Something went wrong...")
__main__.SomeError: Something went wrong...
>>> # We are interactive shell
>>> import pdb
>>> pdb.pm()  # start Post-Mortem debugger
> .../crashing_app.py(7)func()
-> raise SomeError(
"Something went wrong...")
(Pdb) # Now we are in debugger and can poke around and run some commands:
(Pdb) p SOME_VAR  # Print value of variable
42
(Pdb) l  # List surrounding code we are working with
  2
  3   class SomeError(Exception):
  4       pass
  5
  6   def func():
  7  ->     raise SomeError(
"Something went wrong...")
  8
  9   func()
[EOF]
(Pdb)  # Continue debugging... set breakpoints, step through the code, etc.

上面的调试会话非常简要地显示了您可以使用的功能pdb。程序终止后,我们进入交互式调试会话。首先,我们导入pdb并启动调试器。在这一点上,我们可以使用所有pdb命令。在上面的示例中,我们使用p命令打印变量,并使用l命令列出代码。大多数情况下,您可能希望设置可以使用b LINE_NO的断点并运行程序,直到命中断点(c),然后继续使用逐步执行该功能s,还可以选择使用来打印stacktrace w。有关命令的完整列表,请转到pdbdocs

检查堆栈跟踪
假设您的代码是例如在远程服务器上运行的Flask或Django应用程序,您无法获得交互式调试会话。在这种情况下,您可以使用traceback和sys软件包来更深入地了解代码失败的原因:

import traceback
import sys

def func():
    try:
        raise SomeError("Something went wrong...")
    except:
        traceback.print_exc(file=sys.stderr)

运行后,上面的代码将打印最后引发的异常。除了打印例外,您还可以使用traceback包来打印stacktrace(traceback.print_stack())或提取原始堆栈帧,对其进行格式化并进一步检查(traceback.format_list(traceback.extract_stack()))。

在调试过程中重新加载模块
有时您可能正在调试或尝试使用交互式Shell中的某些功能并对其进行频繁更改。为了简化运行/测试和修改的周期,可以运行importlib.reload(module)以避免每次更改后都必须重新启动交互式会话:

>>> import func from module
>>> func()
"This is result..."

# Make some changes to
"func"
>>> func()
"This is result..."  # Outdated result
>>> from importlib import reload; reload(module)  # Reload
"module" after changes made to "func"
>>> func()
"New result..."

本技巧更多地是关于效率而不是调试。能够跳过一些不必要的步骤,并使您的工作流程更快,更高效,总是很高兴的。通常,不时重新加载模块是个好主意,因为它可以帮助您避免尝试调试同时修改过很多次的代码。