Разработка надёжных Python-скриптов

Автор оригинала: Vincent Bernat
  • Перевод
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-скриптов, данные в этой публикации?

RUVDS.com
1 072,98
RUVDS – хостинг VDS/VPS серверов
Поделиться публикацией

Похожие публикации

Комментарии 27

    0
    Для сравнения
    FizzBuzz на разных языках на сайте Rosettacode

    P.S. В том числе и на Forth.
    На Питон, тоже несколько вариантов.
    Форт решение выигрывает по количеству «буковок» затраченных для описания решения. :)
    +9

    Остановились на середине. setup.py, requirements.txt, entry points.

      +4
      Какой же FizzBuzz без travis.yml и прочего continuous integration/deployment…
        0

        ci/cd — это уже delivery pipeline, а setup.py — то, как его ставить. Даже если ci/cd нет, процедура установки всё равно какая-то должна быть. И scp ~developer/git/mywork/foobar.py root@production:/var/www/production/, это явно не то, что хочется видеть в продакшене. Даже если это пятистрочный питон.

          +1

          Хотел пошутить насчёт egg-packages и pypi, но потом решил проверить:


          > pip search fizzbuzz
          
          PyFizzBuzz (0.0.3)        - FizzBuzz cli tool
          fizzbuzzy (0.0.1)         - Python package which prints Fizz, Buzz, FizzBuzz divisible by 3 and 5 and both

          Хотя до enterpise edition ещё далеко...

      +3
      Подход конечно интересный, но всё же слишком чрезмерный на данном примере. Превращать код на 10 строк в код на 160. 16-кратное (ну не совсем 16-кратное, некоторые строки это отступы для красоты, но всё таки) увеличение!!! Да безусловно, в нём появилось много интересных фич, но вопрос а стоило ли оно того? Ведь не все скрипты пишутся «на века».
      Иными словами несмотря на довольно занимательную идею пример прям активно демонстрирует позицию тех кто сравнил этот метод с «пальбой из пушки по воробьям»
        0
        Пример на то и пример чтобы сконцентрироваться на сути статьи. Есть ли смысл в качестве примера брать реальный скрипт на сотни строк?
          +2
          Не знаю много ли примеров одноразовых скриптов на сотни строк, у меня они все меньше 100, а все что больше — уже не одноразовое. Городить для действительно одноразовых то что описано в статье — мягко говоря, перебор.
            0

            Так там ёлочки не от балды стоят.

        +1
        Да, этот «тест» правда хорош. Он позволяет оценить не только наличие базовых навыков программирования, но и, что отлично видно в этом примере, подход человека к решению задачи. Сразу видно, умеешь ты читать и выполнять задачу или начинаешь придумывать отсебятину, за которую денег никто не платил.
        Вот исходный код Python-скрипта, который позволяет решить задачу
        И по идее, после кода должен быть конец.

        Конечно, в данном контексте эту задачу взяли для наглядности из-за ее компактности и минимального содержания, которое бы хоть как-то оправдывало ее обвязку и расширение.

        Переводчику я должен заметить, что у источника в заголовке слово «sustainable», которое я бы в данном контексте перевёл не как «надежный», или «прочный», а скорее — «долговечный». Тогда все вроде становится на свои места. Ведь, если мы увеличили сложность всего решения, а она очевидно возрасла в разы, то надежность будет неминуемо падать. Простое перемножение вероятностей. Так что, нельзя с уверенностью утверждать, что мы увеличили надежность скрипта, несмотря на то, что все описаное и является «хорошим тоном» в программировании. А вот «долговечность» такого кода увеличивается.

          0
          если мы увеличили сложность всего решения, а она очевидно возрасла в разы, то надежность будет неминуемо падать. Простое перемножение вероятностей. Так что, нельзя с уверенностью утверждать, что мы увеличили надежность скрипта

          Если добавили своего кода, то да, если использовали готовые надежные модули, то сложность растет уже не так катастрофически.

            0
            Я говорю про увеличение количества точек отказа. Независимо от надежности используемых компонент.
            А так, конечно, если использовать мейнстримовые библиотеки то они будут, скорее всего, надежнее, чем какое-то нишевое решение или самопал.
          0

          Добавить проверку аргументов командной строки: fizz != buzz, оба не равны 0, start <= end + 1

            0
            Зачем argparse, если есть click?
              0

              Многие свойства click — не самая лучшая идея для приложения, которое нужно хорошо протестировать, хоть и хороша для очень маленьких демо-проектов, помещающихся в одном файле.


              То, с чем столкнулся я и почему перестал использовать:


              • "положим всё в декораторы!" — хорошо, но очень много магии и труднее протестировать. Разделяй реализацию метода и вызов.
              • порядок аргументов очень важен, click позволяет только сортированный
                 * antipattern "магическое состояние, которое хранится в каком-нибудь одном модуле". Его нет в aiohttp и других asyncio фреймворках (из request можно достать app, в котором можно что-то сохранить, если нужно). "explicit is better than implicit"
                • argparse можно найти обычно в одном из модулей, если необходимо, некоторые проекты документируют
                • читать docstring, на который нет стандарта в поисках документации… часть он угадает, часть — нет (explicit is better than implicit)
                • Monkey-patching для вывода ошибок? нет, спасибо. Это последнее, что нужно в Python. Если вы пришли из Ruby, пожалуйста, научитесь писать нормально.
                +1

                Например


                Python 3.7.2 (default, Feb 19 2019, 13:23:50)
                Type "help", "copyright", "credits" or "license" for more information.
                >>> import click
                Traceback (most recent call last):
                  File "<stdin>", line 1, in <module>
                ModuleNotFoundError: No module named 'click'
                >>>
                0

                Вы написали логгер и аргпарс руками хотя могли бы использовать docopt и loguru и код увеличился бы едва ли на десять строк.

                  0
                  Одна строчка: github.com/ijevius/Test/blob/master/FizzBuzz.py

                  Не pep-8, очевидно, но python way
                    0
                    Не pep-8, очевидно, но python way

                    Взаимоисключающие параграфы?
                      +1
                      Отнюдь. Например, PEP-8 ограничивает максимальную длину строки. Python way ею не заморачивается, потому что это вопросы оформления.
                    0
                    Поговорим о том, как его улучшить.
                    может просто применить решето Эратосфена заместо трёх проверок числа на каждое значение
                      0

                      А зачем вам простые числа?

                        0
                        ну он каждый раз проверяе делится ли число на 3 5 или 15 так лучше бы сразу генерировать
                          +1

                          Вычислять до какого предела? А если чиселка с внешнего пайплайна пришла? Какой-нибудь деревянный вариант балансира, который по облачному id определяет на какой из трех серваков подать нагрузку. Решета нужны когда есть шанс переиспользоваться в одном запуске, и в случае fizzbuzz оно точно не эратосфеново

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое