开发健壮的Python脚本

Python是一种编程语言,非常适合开发独立脚本。 为了使用类似的脚本获得所需的结果,您需要编写数十或数百行代码。 在完成工作之后,您可以简单地忘记编写的代码,然后继续解决下一个问题。

例如,如果在编写某个“一次性”脚本后的六个月内,有人问作者该脚本为何失败,则该脚本的作者可能不知道这一点。 发生这种情况是因为该脚本不是书面文档,这是由于使用了在代码中进行硬编码的参数,由于该脚本在工作期间没有记录任何事实以及缺少允许的测试而导致的。快速了解问题原因。



应当指出,将草率编写的脚本转变为更好的脚本并不是那么困难。 即,这样的脚本很容易转换为易于使用的可靠且易于理解的代码,也易于转换为易于支持其作者和其他程序员的代码。

该材料的作者(我们今天将其翻译发表)将以经典的Fizz Buzz测试为例来演示这种“转换”。 此任务是显示1到100的数字列表,其中一些用特殊行替换。 因此,如果数字是3的倍数,则需要打印Fizz行;如果数字是5的倍数,则需要打印Buzz行;如果同时满足这两个条件,则需要打印FizzBuzz

源代码


这是解决该问题的Python脚本的源代码:

 import sys for n in range(int(sys.argv[1]), int(sys.argv[2])):    if n % 3 == 0 and n % 5 == 0:        print("fizzbuzz")    elif n % 3 == 0:        print("fizz")    elif n % 5 == 0:        print("buzz")    else:        print(n) 

让我们谈谈如何改进它。

该文件


我发现在编写代码之前先写文档很有帮助。 这简化了工作,并有助于避免无限期地延迟文档的创建。 脚本的文档可以放在其顶部。 例如,它可能看起来像这样:

 #!/usr/bin/env python3 """Simple fizzbuzz generator. This script prints out a sequence of numbers from a provided range with the following restrictions: - if the number is divisible by 3, then print out "fizz", - if the number is divisible by 5, then print out "buzz", - if the number is divisible by 3 and 5, then print out "fizzbuzz". """ 

第一行简要描述了脚本的用途。 其余段落提供有关脚本功能的其他信息。

命令行参数


改进脚本的下一个任务是将通过代码传递给脚本的文档化值替换在代码中硬编码的值。 这可以使用argparse模块来完成。 在我们的示例中,建议用户指定一个数字范围,并指定在检查指定范围内的数字时使用的“嘶嘶声”和“嗡嗡声”值。

 import argparse import sys class CustomFormatter(argparse.RawDescriptionHelpFormatter,                      argparse.ArgumentDefaultsHelpFormatter):    pass def parse_args(args=sys.argv[1:]):    """Parse arguments."""    parser = argparse.ArgumentParser(        description=sys.modules[__name__].__doc__,        formatter_class=CustomFormatter)    g = parser.add_argument_group("fizzbuzz settings")    g.add_argument("--fizz", metavar="N",                   default=3,                   type=int,                   help="Modulo value for fizz")    g.add_argument("--buzz", metavar="N",                   default=5,                   type=int,                   help="Modulo value for buzz")    parser.add_argument("start", type=int, help="Start value")    parser.add_argument("end", type=int, help="End value")    return parser.parse_args(args) options = parse_args() for n in range(options.start, options.end + 1):    # ... 

这些更改对脚本很有帮助。 即,现在已经正确记录了参数,您可以使用--help标志找出它们的用途。 此外,根据相应的命令,还将显示我们在上一节中编写的文档:

 $ ./fizzbuzz.py --help usage: fizzbuzz.py [-h] [--fizz N] [--buzz N] start end Simple fizzbuzz generator. This script prints out a sequence of numbers from a provided range with the following restrictions: - if the number is divisible by 3, then print out "fizz", - if the number is divisible by 5, then print out "buzz", - if the number is divisible by 3 and 5, then print out "fizzbuzz". positional arguments:  start     Start value  end      End value optional arguments:  -h, --help  show this help message and exit fizzbuzz settings:  --fizz N   Modulo value for fizz (default: 3)  --buzz N   Modulo value for buzz (default: 5) 

argparse模块是一个非常强大的工具。 如果您不熟悉它,则对它的文档很有用。 特别是,我喜欢他定义子命令参数组的能力。

记录中


如果您使脚本具有在执行过程中显示某些信息的能力,那么这将是对它功能的一种令人愉快的添加。 日志记录模块非常适合此目的。 首先,我们描述一个实现日志记录的对象:

 import logging import logging.handlers import os import sys logger = logging.getLogger(os.path.splitext(os.path.basename(sys.argv[0]))[0]) 

然后,我们将可以控制记录期间显示的信息的详细信息。 因此,仅在使用--debug开关运行脚本时, logger.debug()命令才应输出内容。 如果该脚本使用--silent运行,则该脚本除异常消息外不应显示任何内容。 要实现这些功能,请将以下代码添加到parse_args()

 #  parse_args() g = parser.add_mutually_exclusive_group() g.add_argument("--debug", "-d", action="store_true",               default=False,               help="enable debugging") g.add_argument("--silent", "-s", action="store_true",               default=False,               help="don't log to console") 

将以下功能添加到项目代码中以配置日志记录:

 def setup_logging(options):    """Configure logging."""    root = logging.getLogger("")    root.setLevel(logging.WARNING)    logger.setLevel(options.debug and logging.DEBUG or logging.INFO)    if not options.silent:        ch = logging.StreamHandler()        ch.setFormatter(logging.Formatter(            "%(levelname)s[%(name)s] %(message)s"))        root.addHandler(ch) 

主脚本代码将更改如下:

 if __name__ == "__main__":    options = parse_args()    setup_logging(options)    try:        logger.debug("compute fizzbuzz from {} to {}".format(options.start,                                                             options.end))        for n in range(options.start, options.end + 1):            # ..    except Exception as e:        logger.exception("%s", e)        sys.exit(1)    sys.exit(0) 

如果您打算在没有用户直接参与的情况下运行脚本,例如使用crontab ,则可以将其输出发送到syslog

 def setup_logging(options):    """Configure logging."""    root = logging.getLogger("")    root.setLevel(logging.WARNING)    logger.setLevel(options.debug and logging.DEBUG or logging.INFO)    if not options.silent:        if not sys.stderr.isatty():            facility = logging.handlers.SysLogHandler.LOG_DAEMON            sh = logging.handlers.SysLogHandler(address='/dev/log',                                                facility=facility)            sh.setFormatter(logging.Formatter(                "{0}[{1}]: %(message)s".format(                    logger.name,                    os.getpid())))            root.addHandler(sh)        else:            ch = logging.StreamHandler()            ch.setFormatter(logging.Formatter(                "%(levelname)s[%(name)s] %(message)s"))            root.addHandler(ch) 

在我们的小脚本中,仅使用logger.debug()命令似乎需要类似数量的代码。 但是在实际脚本中,此代码将不再像以前那样,它的好处将排在最前列,即,借助其帮助,用户将能够找到解决问题的过程。

 $ ./fizzbuzz.py --debug 1 3 DEBUG[fizzbuzz] compute fizzbuzz from 1 to 3 1 2 fizz 

测验


单元测试是检查应用程序是否按预期运行的有用工具。 单元脚本在脚本中很少使用,但是将它们包含在脚本中可以大大提高代码的可靠性。 我们将循环内的代码转换为一个函数,并在其文档中描述了几个交互式的用法示例:

 def fizzbuzz(n, fizz, buzz):    """Compute fizzbuzz nth item given modulo values for fizz and buzz.    >>> fizzbuzz(5, fizz=3, buzz=5)    'buzz'    >>> fizzbuzz(3, fizz=3, buzz=5)    'fizz'    >>> fizzbuzz(15, fizz=3, buzz=5)    'fizzbuzz'    >>> fizzbuzz(4, fizz=3, buzz=5)    4    >>> fizzbuzz(4, fizz=4, buzz=6)    'fizz'    """    if n % fizz == 0 and n % buzz == 0:        return "fizzbuzz"    if n % fizz == 0:        return "fizz"    if n % buzz == 0:        return "buzz"    return n 

您可以使用pytest验证函数的正确操作:

 $ python3 -m pytest -v --doctest-modules ./fizzbuzz.py ============================ test session starts ============================= platform linux -- Python 3.7.4, pytest-3.10.1, py-1.8.0, pluggy-0.8.0 -- /usr/bin/python3 cachedir: .pytest_cache rootdir: /home/bernat/code/perso/python-script, inifile: plugins: xdist-1.26.1, timeout-1.3.3, forked-1.0.2, cov-2.6.0 collected 1 item fizzbuzz.py::fizzbuzz.fizzbuzz PASSED                 [100%] ========================== 1 passed in 0.05 seconds ========================== 

为了使所有这些工作正常,您需要在脚本名称后添加.py扩展名。 我不喜欢在脚本名称中添加扩展名:语言只是技术细节,不需要向用户显示。 但是,似乎给脚本名称加上扩展名是让运行测试的系统(例如pytest )找到代码中包含的测试的最简单方法。

如果发生错误pytest将显示一条消息,指示相应代码的位置以及问题的性质:

 $ python3 -m pytest -v --doctest-modules ./fizzbuzz.py -k fizzbuzz.fizzbuzz ============================ test session starts ============================= platform linux -- Python 3.7.4, pytest-3.10.1, py-1.8.0, pluggy-0.8.0 -- /usr/bin/python3 cachedir: .pytest_cache rootdir: /home/bernat/code/perso/python-script, inifile: plugins: xdist-1.26.1, timeout-1.3.3, forked-1.0.2, cov-2.6.0 collected 1 item fizzbuzz.py::fizzbuzz.fizzbuzz FAILED                 [100%] ================================== FAILURES ================================== ________________________ [doctest] fizzbuzz.fizzbuzz _________________________ 100 101   >>> fizzbuzz(5, fizz=3, buzz=5) 102   'buzz' 103   >>> fizzbuzz(3, fizz=3, buzz=5) 104   'fizz' 105   >>> fizzbuzz(15, fizz=3, buzz=5) 106   'fizzbuzz' 107   >>> fizzbuzz(4, fizz=3, buzz=5) 108   4 109   >>> fizzbuzz(4, fizz=4, buzz=6) Expected:    fizz Got:    4 /home/bernat/code/perso/python-script/fizzbuzz.py:109: DocTestFailure ========================== 1 failed in 0.02 seconds ========================== 

单元测试也可以编写为常规代码。 假设我们需要测试以下功能:

 def main(options):    """Compute a fizzbuzz set of strings and return them as an array."""    logger.debug("compute fizzbuzz from {} to {}".format(options.start,                                                         options.end))    return [str(fizzbuzz(i, options.fizz, options.buzz))            for i in range(options.start, options.end+1)] 

在脚本的最后,我们使用pytest添加以下单元测试,以使用参数化测试功能

 #   import pytest          # noqa: E402 import shlex          # noqa: E402 @pytest.mark.parametrize("args, expected", [    ("0 0", ["fizzbuzz"]),    ("3 5", ["fizz", "4", "buzz"]),    ("9 12", ["fizz", "buzz", "11", "fizz"]),    ("14 17", ["14", "fizzbuzz", "16", "17"]),    ("14 17 --fizz=2", ["fizz", "buzz", "fizz", "17"]),    ("17 20 --buzz=10", ["17", "fizz", "19", "buzz"]), ]) def test_main(args, expected):    options = parse_args(shlex.split(args))    options.debug = True    options.silent = True    setup_logging(options)    assert main(options) == expected 

请注意,由于脚本代码以对sys.exit()的调用sys.exit() ,因此在正常调用时将不会执行测试。 因此, pytest即可运行脚本。

每组参数将调用一次测试函数。 args实体用作parse_args()函数的输入。 由于有了这种机制,我们可以将所需的内容传递给main()函数。 将expected实体与main()pytest会告诉我们一切是否按预期进行:

 $ python3 -m pytest -v --doctest-modules ./fizzbuzz.py ============================ test session starts ============================= platform linux -- Python 3.7.4, pytest-3.10.1, py-1.8.0, pluggy-0.8.0 -- /usr/bin/python3 cachedir: .pytest_cache rootdir: /home/bernat/code/perso/python-script, inifile: plugins: xdist-1.26.1, timeout-1.3.3, forked-1.0.2, cov-2.6.0 collected 7 items fizzbuzz.py::fizzbuzz.fizzbuzz PASSED                 [ 14%] fizzbuzz.py::test_main[0 0-expected0] PASSED              [ 28%] fizzbuzz.py::test_main[3 5-expected1] PASSED              [ 42%] fizzbuzz.py::test_main[9 12-expected2] PASSED             [ 57%] fizzbuzz.py::test_main[14 17-expected3] PASSED             [ 71%] fizzbuzz.py::test_main[14 17 --fizz=2-expected4] PASSED        [ 85%] fizzbuzz.py::test_main[17 20 --buzz=10-expected5] PASSED        [100%] ========================== 7 passed in 0.03 seconds ========================== 

如果发生错误, pytest将提供有关发生的情况的有用信息:

 $ python3 -m pytest -v --doctest-modules ./fizzbuzz.py [...] ================================== FAILURES ================================== __________________________ test_main[0 0-expected0] __________________________ args = '0 0', expected = ['0']    @pytest.mark.parametrize("args, expected", [        ("0 0", ["0"]),        ("3 5", ["fizz", "4", "buzz"]),        ("9 12", ["fizz", "buzz", "11", "fizz"]),        ("14 17", ["14", "fizzbuzz", "16", "17"]),        ("14 17 --fizz=2", ["fizz", "buzz", "fizz", "17"]),        ("17 20 --buzz=10", ["17", "fizz", "19", "buzz"]),    ])    def test_main(args, expected):        options = parse_args(shlex.split(args))        options.debug = True        options.silent = True        setup_logging(options)       assert main(options) == expected E    AssertionError: assert ['fizzbuzz'] == ['0'] E     At index 0 diff: 'fizzbuzz' != '0' E     Full diff: E     - ['fizzbuzz'] E     + ['0'] fizzbuzz.py:160: AssertionError ----------------------------- Captured log call ------------------------------ fizzbuzz.py        125 DEBUG compute fizzbuzz from 0 to 0 ===================== 1 failed, 6 passed in 0.05 seconds ===================== 

logger.debug()命令的输出logger.debug()包含在此输出中。 这是在脚本中使用日志记录机制的另一个很好的理由。 如果您想进一步了解pytest的强大功能,请查看材料。

总结


通过执行以下四个步骤,可以使Python脚本更可靠:

  • 在文件的顶部为脚本提供文档。
  • 使用argparse模块来记录可用来调用脚本的参数。
  • 使用logging模块显示有关脚本操作过程的信息。
  • 编写单元测试。

这是此处讨论的示例完整代码。 您可以将其用作自己的脚本的模板。

围绕该材料开始了有趣的讨论-您可以在这里这里找到它们。 看来,听众对文档和命令行参数提出了很好的建议,但是对于某些读者而言,日志记录和测试的问题似乎是“从麻雀上发射的一门大炮”。 这是针对本文撰写材料。

亲爱的读者们! 您是否打算应用此出版物中给出的有关编写Python脚本的建议?

Source: https://habr.com/ru/post/zh-CN462007/


All Articles