[Перевод] Разработка надёжных Python-скриптов
Python — это язык программирования, который отлично подходит для разработки самостоятельных скриптов. Для того чтобы добиться с помощью подобного скрипта желаемого результата, нужно написать несколько десятков или сотен строк кода. А после того, как дело сделано, можно просто забыть о написанном коде и перейти к решению следующей задачи.
Если, скажем, через полгода после того, как был написан некий «одноразовый» скрипт, кто-то спросит его автора о том, почему этот скрипт даёт сбои, об этом может не знать и автор скрипта. Происходит подобное из-за того, что к такому скрипту не была написана документация, из-за использования параметров, жёстко заданных в коде, из-за того, что скрипт ничего не логирует в ходе работы, и из-за отсутствия тестов, которые позволили бы быстро понять причину проблемы.
При этом надо отметить, что превратить скрипт, написанный на скорую руку, в нечто гораздо более качественное, не так уж и сложно. А именно, такой скрипт довольно легко превратить в надёжный и понятный код, которым удобно пользоваться, в код, который просто поддерживать как его автору, так и другим программистам.
Автор материала, перевод которого мы сегодня публикуем, собирается продемонстрировать подобное «превращение» на примере классической задачи «Fizz Buzz Test». Эта задача заключается в том, чтобы вывести список чисел от 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. В нашем примере мы предлагаем пользователю указать диапазон чисел и указать значения для «fizz» и «buzz», используемые при проверке чисел из указанного диапазона.
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
— это весьма мощный инструмент. Если вы с ним не знакомы — вам полезно будет просмотреть документацию по нему. Мне, в частности, нравятся его возможности по определению подкоманд и групп аргументов.
Логирование
Если оснастить скрипт возможностями по выводу некоей информации в ходе его выполнения — это окажется приятным дополнением к его функционалу. Для этой цели хорошо подходит модуль logging. Для начала опишем объект, реализующий логирование:
import logging
import logging.handlers
import os
import sys
logger = logging.getLogger(os.path.splitext(os.path.basename(sys.argv[0]))[0])
Затем сделаем так, чтобы подробностью сведений, выводимых при логировании, можно было бы управлять. Так, команда logger.debug()
должна выводить что-то только в том случае, если скрипт запускают с ключом --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()
, при его обычном вызове тесты выполняться не будут. Благодаря этому 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()
. Это — ещё одна веская причина для использования в скриптах механизмов логирования. Если вы хотите узнать подробности о замечательных возможностях pytest
— взгляните на этот материал.
Итоги
Сделать Python-скрипты надёжнее можно, выполнив следующие четыре шага:
- Оснастить скрипт документацией, размещаемой в верхней части файла.
- Использовать модуль
argparse
для документирования параметров, с которыми можно вызывать скрипт. - Использовать модуль
logging
для вывода сведений о процессе работы скрипта. - Написать модульные тесты.
Вот полный код рассмотренного здесь примера. Вы можете использовать его в качестве шаблона для собственных скриптов.
Вокруг этого материала развернулись интересные обсуждения — найти их можно здесь и здесь. Аудитория, как кажется, хорошо восприняла рекомендации по документации и по аргументам командной строки, а вот то, что касается логирования и тестов, показалось некоторым читателям «пальбой из пушки по воробьям». Вот материал, который был написан в ответ на данную статью.
Уважаемые читатели! Планируете ли вы применять рекомендации по написанию Python-скриптов, данные в этой публикации?