OptionParser и UnitTest в python скриптах

    python logoВ этой статье я хочу поинтересоваться у общественности правильно ли я реализую возможности данных двух замечательных модулей питона, которые уже давно вошли в стандартный (жизненно необходимый) набор питоноводов всей планеты.


    Предыстория


    Пишется скрипт для обработки и графического вывода неких исследовательских данных. Но история не об этом. Хотелось бы показать как именно используется комбинация OptionParser и UnitTest модулей. А за одно узнать способы улучшить код и его читаемость. Так что гуру Pythonоводства, я буду премного благодарен Вашей критике и предложениям.

    Модули


    Об методе Test Driven Development (TDD) разработке программных продуктов каждый слышал хотя бы раз в своей жизни. Непосредственно с реализацией такого подхода для питона я столкнулся в книге Макра Пилгрима «Diving into Python 3». В девятой главе своей книги Марк подробно описывает способ реализации юнит тестов для своего модуля преобразования римских чисел. Основой этого принципа можно назвать написание теста для проверки правильности выполнения кода до написания самого кода.

    От себя хочется добавить, что знал я об этом способе программирования давно, но никогда его до этого не применял по банальной причине — время написания кода увеличивается почти в два раза. И таким образом, становиться совершенно не целесообразно использовать данных подход для коротких скриптов, выполняющих специфически-определённую задачу. С такого рода скриптами и так понятно если есть ошибка в коде, т.к. приблизительно знаешь из какого диапазона распределений (статистическая обработка) нужно ожидать выходные данные.

    Проектирование же новой задачи показало, что скрипт будет достаточно сложный. И написание Unit testов к нему будет оправдано. Т.к. последующий рефакторинг и отладка будет намного упрощена.

    Следующим на очереди идёт OptionParser, который используется мной давно, и похоже, ещё долго будет использоваться. Читаемость кода при его использовании увеличивается многократно. Парсеров подобных данному — несколько штук. И в своё время велись активные холивары, про то, какой из них лучше. Были обвинения в том, что он навязывает свою философию в организации и обработке опций. Честно говоря, ничего «странного» в этой самой организации мной замечено не было. И опять таки, это будет в первую очередь зависеть от программиста как он реализует вчитываемость той или иной опции. Так что холивар отложим пока в сторонку.

    Source code


    Давайте сразу перейдём к исходному коду. В выполняемом модуле пока присутствует только одна рабочая функция readin_monitor(monitor).

    Copy Source | Copy HTML
    1. #!/usr/bin/env python
    2. # -*- coding: utf-8 -*- 
    3.  
    4. version = '0.0.1'
    5. version_name = 'gamma'
    6. modify_date = '2009-12-26'
    7.  
    8. from optparse import OptionParser
    9.  
    10. import matplotlib
    11. import numpy as np
    12. import scipy.stats as stats
    13.  
    14. import warnings
    15. warnings.filterwarnings('ignore', '', DeprecationWarning)
    16. # turning off deprecation warning in python 2.6
    17.  
    18. kB = 8.31441e-3 / 4.184
    19.  
    20. def readin_monitor(monitor):
    21.     '''Read in monitor file. Ignoring all strings starting with # symbol.<br/>    Function returns all stored data from the strings as list of lists of <br/>    floats.'''
    22.     num =  0
    23.     data = []
    24.     for line in open(monitor, 'r'):
    25.         try:
    26.             if line[ 0] != '#':
    27.                 data.append([float(i) for i in line.split()])
    28.                 num = num + 1
    29.         except:
    30.             pass
    31.     if options.verbose:
    32.         print('Read in %i data points from monitor file %s' % (num, monitor))
    33.     return data
    34.  
    35. def main():
    36.     return  0
    37.  
    38.  
    39. global options
    40. global args
    41. parser = OptionParser("usage: %prog [options] [monitors]",
    42.                   version='%prog ' +version+ ' from '+modify_date)
    43. parser.add_option("-v", "--verbose",
    44.                   action="store_true", dest="verbose", default=False,
    45.                   help="Print status messages to stdout")
    46. parser.add_option("-C", "--combine", dest="combine",
    47.                   action="store", default="",
    48.                   help='Combine all monitor files passed as arguments \<br/>                  to the UC.py script to one COMBINE file')
    49. parser.add_option('-D', '--dimentions', dest='dimentions',
    50.                   default = '1:2',
    51.                   help='String of DIMENTIONS for monitor files to be \<br/>                  read in. (defaut = %default)')
    52. (options, args) = parser.parse_args()
    53. if __name__ == '__main__':
    54.     main()


    Из особенностей расположения кода хочется отметить определение опций парсера в конце самого модуля. Т.е. этот кусок кода будет выполняться всегда, даже если модуль вызывается другим скриптом. Таким образом в глобально определённых переменных options и args будут значения опций по умолчанию (default values), args будет пустой. Т.к. переменные глобальные, то доступ к ним будет возможен из любого окружения.

    Запуск скрипта с опцией -h выдаст подробную справку об использовании опций:

    Copy Source | Copy HTML
    1. Usage: UC.py [options] [monitors]
    2.  
    3. Options:
    4.   --version show program's version number and exit
    5.   -h, --help show this help message and exit
    6.   -v, --verbose Print status messages to stdout
    7.   -C COMBINE, --combine=COMBINE
    8.                         Combine all monitor files passed as arguments
    9.                         to the UC.py script to one COMBINE file.
    10.                         (defaut = out)
    11.   -D DIMENTIONS, --dimentions=DIMENTIONS
    12.                         String of DIMENTIONS for monitor files to be
    13.                         read in. (defaut =  0:1:2)


    Далее сами unit tests:

    Copy Source | Copy HTML
    1. #!/usr/bin/env python
    2. # -*- coding: utf-8 -*-
    3. '''Unit tests for UC.py module.'''
    4.  
    5. import UC
    6. import unittest
    7.  
    8. global monitor
    9. monitor = '''#<br/># MD time (ps), CV #1, CV #2<br/>#<br/>      0.9990     9.2349535263     7.7537518211<br/>      1.9990     9.4331321327     7.9555258177<br/>      2.9990     9.5368308183     8.1341402536<br/>      3.9990     9.4468066031     7.9086253193<br/>      4.9990     9.1565151681     8.0027457962<br/>      5.9990     9.2310306859     7.9872398398<br/>      6.9990     9.1540695183     7.5236796623<br/>      7.9990     9.0727576308     7.8499035889<br/>      8.9990     9.3113419250     8.1227557439<br/>      9.9990     8.9597834513     8.3754973753<br/>     10.9990     9.5761421491     8.3053224696<br/>     11.9990     9.5178829977     8.1660258902'''
    10.  
    11. class Combine_monitors(unittest.TestCase):
    12.     def test_readin_monitor(self):
    13.         with open('test_mon', 'w') as MON:
    14.             MON.write(monitor)
    15.         UC.options.verbose = False
    16.         self.assertEqual([[ 0.999, 9.2349535263, 7.7537518210999998],
    17.             [1.9990000000000001, 9.4331321327000008, 7.9555258176999999],
    18.             [2.9990000000000001, 9.5368308183000003, 8.1341402536],
    19.             [3.9990000000000001, 9.4468066031000006, 7.9086253192999996],
    20.             [4.9989999999999997, 9.1565151681000003, 8.0027457961999993],
    21.             [5.9989999999999997, 9.2310306859000004, 7.9872398398],
    22.             [6.9989999999999997, 9.1540695183, 7.5236796623000002],
    23.             [7.9989999999999997, 9.0727576308, 7.8499035889000002],
    24.             [8.9990000000000006, 9.3113419250000007, 8.1227557439000009],
    25.             [9.9990000000000006, 8.9597834512999999, 8.3754973753000002],
    26.             [10.999000000000001, 9.5761421491000007, 8.3053224696000001],
    27.             [11.999000000000001, 9.5178829976999992, 8.1660258902000002]],
    28.             UC.readin_monitor('test_mon'))
    29.  
    30. def main():
    31.     unittest.main()
    32.     return  0
    33.  
    34. if __name__ == '__main__':
    35.     main()
    36.  


    К написанному тесту стоит добавить удаление временных файлов. Ну и конечно же увеличить число тестов, по мере реализации новых функций скрипта. Запуск скрипта приводит к такому вот выводу:

    $ ./test-UC.py
    .
    ----------------------------------------------------------------------
    Ran 1 test in 0.000s

    OK


    Для написания этого небольшого куска кода мне пришлось немного “обмануть всех” (кажется так можно перевести глагол cheat). Тест был написан уже после написания самой функции readin_monitor() из основного модуля. Результат функции просто был выкинут print’ом в stdout. И от туда перекачивал в исходный код тест-модуля.

    Что не нравиться — вроде бы обманываем сами себя. Сначала пишем код, потом тест, нарушая тем самым философию TDD разработки. Ещё и результаты на выходе, из-за специфики языка получились не точными (имеется в виду 5.9989999999999997 = 5.9990 округление). Если запустить этот же юнит тест в другой версии питона, то может получиться ошибка теста. Для Python 3.1 тест был пройден положительно, но меня всё равно настораживают такие тесты на точность. Можно, конечно, самому организовать округление до, скажем, 5ого знака после запятой, и сравнивать уже округлённые данные. Но это чревато сильным утяжелением кода, и, как результат, плохочитаемостью оного.

    Итого


    На примере двух коротких зародышей скриптов мы показали как можно использовать возможности OptionParser и UnitTest модулей. Целью стати не являлось полноценное описание всех их возможностей, поэтому любопытному читателю дана возможность разобраться в них самому, использую ссылки из начала статьи.

    Ну и к основному вопросу. Что можно улучшить в данном коде/подходе? Жду ваших ответов.

    Спасибо за внимание.
    Поделиться публикацией

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

      0
      Пока кинул статью в свой персональный блог. Не знаю достойна ли статейка блога про Питон.
      Хотя как пример такого шаблона с OptionParser и UnitTest — может и ничего.
        +1
        Зачем вы везде global понаставили?
          0
          Т.к. переменные глобальные, то доступ к ним будет возможен из любого окружения.

          Если их не объявить глобальными, то достучаться до них из тела функции можно будет только объявляя их внутри функции как nonlocal или опять таки global. Проще один раз это сделать во внешнем окружении.
            +1
            Попробуйте убрать global — всё должно работать.
              0
              Да, Вы правы. Действительно заработало. Может это были особенности python 2.5, что приходилось так изгаляться с global объявлениями.
              Но в любом случае спасибо за наводку. Нужно будет ещё раз перечитать области видимости переменных.
              0
              Вы не правы, в питоне есть замыкания, а global используется для другого и крайне редко.
                0
                Про замыкания, не могу ничего припомнить. Можете навести на материал? Заранее спасибо.
                  +1
                  Да все просто, тут даже никаких материалов не нужно, чтоб в суть въехать. Глобальные переменные в питоне — это такой вырожденный случай замыкания.

                  x = 5 # глобальная переменная
                  def f()
                      print x # напечатает x
                  
                  def my_func1():
                      # простой пример замыкания: отличается от предыдущего только тем, что мы все завернули в функцию
                      y = 5
                      def my_inner_func():
                           print y
                      my_inner_func() # напечатает 5
                  
                  def my_func2(y):
                      # чуть более сложный пример
                      def my_inner_func():
                           print y
                      my_inner_func() # напечатает параметр y, переданный функции myfunc
                  
                  


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

                  Очень часто это все используется в сочетании с тем фактом, что в питоне функция — тоже объект (ее, например, можно передать как параметр, и вернуть как результат вычислений). Немного надуманный пример: get_incrementer возвращает функцию, которая принимает какое-то значение, добавляет к нему заранее заданное число step и возвращает результат.

                  
                  def get_incrementer(step):
                      def my_inner_func(value):
                           return value+step
                      return my_inner_func 
                  
                  inc3 = get_incrementer(3) # создаем функцию, которая добавляет 3 к переданному ей числу
                  print inc3(7) # напечатает 10. 
                  
                  inc5 = get_incrementer(5) # создаем функцию, которая добавляет 5 к переданному ей числу
                  print inc5(7) # напечатает 12. 
                  
                  


                  Если это все понятно, то можно, например, написать простейший декоратор:

                  def ya_ne_poyehal(func):
                      def inner():
                          return func()+u" А я не поехал. Я купил мотыля и пошел на реку."
                      return inner
                  
                  @ya_ne_poyehal
                  def get_serious_text():
                      return u"Кто-то там выступил с пакетом конструктивных предложений."
                  
                  print get_serious_text() # а текст-то уже не серьезный
                  
                  

                  @декоратор — это просто синтаксический сахар. Абсолютно то же самое можно было записать вот так:
                  
                  def get_serious_text():
                      return u"Кто-то там выступил с пакетом конструктивных предложений."
                  get_serious_text = ya_ne_poehal(get_serious_text)
                  
                  print get_serious_text() # а текст-то уже не серьезный
                  
                  

                  Если и это все понятно, то дальше хорошо бы позаботиться, чтоб наш декоратор сохранял параметры функции, ее имя, докстринги и тд (см. в сторону functools.wraps), но это меня уже и так чего-то не в ту степь занесло.
            +2
            Специально для тестирования чисел с плавающей запятой в unittest.TestCase есть методы assertAlmostEqual и assertNotAlmostEqual. Они вычисляют разницу между двумя числами, после округляют до указанного количества знаков после запятой и сравнивают результат с нулем. Конечно, списки с числами придется сравнивать вручную.
              0
              Спасибо. Я прочитал об этих двух методах уже после публикации. Думал упомянуть их в статье, но не стал.
              Я так понимаю эти методы так же работаю и со списком. При этом приблизительно будут сравниваться элементы списка. Так?
                +1
                Сами списки так сравнить не получится, только поочередно их элементы. Я бы написал вот так:
                1. # У вас матрица, поэтому берем по каждой строчке
                2. # из обоих обоих списков, после этого сравниваем
                3. # элементы в этих строках попарно.
                4. for x, y in zip(list1, list2):
                5.     map(self.assertAlmostEqual, zip(x, y))

                Еще для уменьшения количества используемой памяти (вдруг данные большие будут) можно заменить zip на itertools.izip, который возвращает генератор.
                  0
                  Просто отличнейшее решение!
              0
              Насколько помню университетский курс по численным методам определение точности вычислений с плавающей точкой определяется через E (эпсилон). Т.е. задается некое E (в зависимости от нужной точности, если до 4 знака после точки, то 0.0001), и полученое значение сверяется с эталонным с точностью до E: если abs(эталонное — полученно) <= E, то считаем что результат получен верно в рамках заданной точности.
                0
                незачем привязывать основную функцию к файлу. я бы, все же, ее на 2 (а то и больше) разбил:
                  +1
                  извините, раньше отправил…

                     for line in open(monitor, 'r'):
                  заменил бы на
                     for line in monitor:
                  а файл открывал в функции-обертке. тогда и тестировать было бы легче — не пришлось бы писать ваш тестовый список в файл и передавать путь к нему
                    0
                    я так в последствии и сделал, правда получилось как-то кривовато. Приведу код:

                    Copy Source | Copy HTML
                    1. def main():
                    2.     with open('test_mon', 'w') as MON:
                    3.         MON.write(monitor)
                    4.     try:
                    5.         unittest.main()
                    6.     finally:
                    7.         os.remove('test_mon')
                    8.         pass
                    9.     return  0


                    Удаление файла сидит в файнале, иначе файл не удаляется. При этом не появляется никакой эксепшн… Не знаю в чём причина. Но с файнал стейтментом — работает.
                      0
                      pass в файнале не нужен — он там случайно оказался.
                        0
                        Совсем мимо.
                        Необходимо избавится от временного файла

                        def integration_test():
                        data = ""«some
                        data»""
                        UC.readin_monitor(test_data)

                        В метод передается список, а значит можно передать файл в main

                        UC.readin_monitor(open(path))
                          0
                          Простите пожалуйста, я совершенно запутался о чём мы говорим. Перечитал ещё раз комментарий susl'а и понял что я вообще не про то говорил, отвечая на его вопрос… последствия 4х утра.

                          По порядочку. Я буду повествовать, а Вы поправите меня, где я начну ошибаться.
                          1) С комментарием susl'a, действительно плохо как-то вышло. Я стараюсь таких конструкций не использовать. После появления with statement в python 2.6 — правильной конструкцией будет являться:

                          Copy Source | Copy HTML
                          1. with open(filename, 'r') as IN:
                          2.     for line in IN:
                          3.         processing(line)


                          With statement обеспечивает обязательное закрытие файла при любых, даже самых непредвиденных обстоятельствах.

                          2) Ему в ответ я нагородил огородов с main функцией. В неё я вынес запись данных из многострочечной переменной monitor в тестовый файл. Именно его мы будет вчитывать для тестов. Проблема оказалась в том, что если попытаться выполнить os.remove('test_mon'), где 'test_mon' — это path к тестовому файлу данных, и поставить эту функцию сразу за unittest.main(), то файл так и остаётся не удалённым после прохождения тестов. Может этот самый unittest.main() после своего выполнения останавливает внешний скрипт тоже. Поэтому только finally можно заставить что-то выполниться в любом случае. Есть конечно методы самого unittest модуля, например, которые выполняются сразу после исполнения какого-либо теста. Можно наверное удаления файла поручить последнему тесту в самом конце.

                          3) В readin_monitor(monitor) передаётся строка. monitor — это название файла, который потом отрывается дурацким способом (смотри пункт 1) в теле самой функции. Можно в функцию передавать указатель на файл, или это правильнее назвать файловым объектом. Кажется, именно так Вы советовали: readin_monitor(open(filename)). Но я до конца не понимаю что этим можно выиграть. Открываем файл в main — передаём функцию указатель. Кусок кода перетекает из тела функции в main. Я не вижу того, что Вы пытаетесь мне показать. Можно поподробнее пожалуйста?

                          Спасибо.
                            0
                            Все верно.

                            Одна из наиболее приятных вещей в TDD это определение интерфейса. В момент написания теста вы определяете как бы хотели увидеть интерфейс. Это очень важно. Не нужно думать о реализации — она сковывает действия.

                            Тесты это первые пользователи интерфейса. В отличии от кода проекта они чисты — к их коду не примешивается логика приложения.

                            Что же мы видим в тесте? Создание файла в тесте смотрится убого. Много лишних телодвижений, а главное они никак не относятся к сути решаемой задачи. Итак, тесты первые пользователи интерфейса, следующие — программисты. И они будут решать задачу точно так же!

                            Всегда стоит писать предполагая что человек поддерживающий код — маньяк знающий ваш адрес :)
                              0
                              Хех… Понравилось про маньяков)))
                              Теперь я понял про что Вы.
                    +1
                    Отличные вопросы.

                    Общие впечатления — глобальные переменные мозолят глаза. Их стоит вынести в метод и подключать по желанию. При использовании модуля другим человеком будет меньше неожиданностей. Далее по поводу тестов.

                    Это не TDD, а довольно многословный интеграционный тест, опирающийся на особенности интерпретатора (assertAlmostEqual как подсказали выше). Все что он может сказать — что-то не работает. Что же именно сломано показывают юнит тесты.

                    Также стоит посмотреть на иные системы тестирования, мне больше нравится nose somethingaboutorange.com/mrl/projects/nose/. А еще больше rspec rspec.info, руби комьюнити уделяют тестированию значительно больше внимание. И конечно обязательно стоит прочесть The RSpec Book.

                    TDD описывает функциональность. Например «комментарий начинается с решетки»

                    def test_comment_start_with_sharp():
                    assert is_comment("# comment")
                    assert not is_comment(«some data»)

                    Аналогично поступаем со всеми знаниями — формат строки (разделенный пробелами, три значения), выбрасывание исключения итд.

                    Это простые тесты. Тест «комментарии не добавляются в выборку» слегка сложнее

                    class TestMeasure:
                    def setUp(self):
                    self.measure = Measure()

                    def test_add_values(self):
                    self.measure.add(«some data»)
                    eq_(1, self.measure.count)

                    def test_skip_comments(self):
                    self.measure.add("# comment")
                    eq_(0, self.measure.count)

                    Этот тест опирается на наше знание о комментарии. Это знание можно вынести в фабрики def create_comment_line(): return "# comment". Или использовать моки и стабы.

                    Успехов
                      0
                      Спасибо за НОС =)
                      Там, кстати, используются одновременно UnitTest и OptionParser. Правда кода очень много. И на мою неподготовленную психику столько кода сразу — вызвало лёгкое недомогание. Но ничего. Всё у нас впереди. Ещё раз спасибо.
                      +1
                      Есть еще optmatch
                      Их стандартный пример(два аргумнета, один флаг и одна опция):
                      class Example(OptionMatcher):
                      
                      	@optmatcher(flags='verbose', options='mode')
                      	def handle(self, file, verbose=False, mode='simple', where=None):
                      		...	
                        0
                        Еще одна вещь которая может значительно ускорить обучение — знакомство с функциональным программированием. Для начала хватит поверхностного. Покажу на вашем примере набор методов:

                        * преобразование имени файла в итератор
                        * его фильтрация по функции is_comment
                        * преобразование линии в элемент (массив трех значений)

                        Для полного покрытия приложения достаточно описать эти методы. При этом в первом методе стоит не столько проверять метод open (он уже проверяется тестами реализации языка), сколько выводом данных в verbose режиме.

                        def test_should_print_data_points_count_and_filename_in_verbose_mode():
                        # не следует здесь проверять вывод данных
                        # только проверка интересующей строки по регулярному

                        RSpec позволяет создавать конкретизировать описание, создать несколько уровней. Не желая затрагивать файловую систему, мы используем стаб, подменяя результат на тестовую строку.

                        describe Calculation
                        describe «verbose mode»
                        subject { Calculation.new(:verbose => true) }
                        it «should print data points count and filename» do
                        File.stub!(:open).and_return([«some data»])
                        subject.process('filename').should match(«Read in 1 data points from monitor file filename»)
                        end
                        end
                        end

                        В питоне обычно моделирую классами

                        class TestMeasure:
                        pass

                        class TestVerboseMeasure:
                        pass
                          0
                          Я поверхностно знаком с методами функционального программирования. И именно в питоне я встретился с ними в первый раз. \
                          Но если по честному, функциональное программирование делает код короче и, может, даже красивее, но далеко не понятнее. Я сужу просто о себе. Когда разбирался с примерами функционального программирования, въезжал в них относительно намного дольше чем с любыми другими конструкциями языка.
                          Немного на другую тему. Вы, как мне показалось, приверженец языка Ruby. Можно ли поинтересоваться чем именно Вас этот язык притянул сильнее, чем, скажем, питон? Можете в 3х словах дать основные ключевые отличия и достоинства Ruby над Python (одно из которых наверное будет большее уделение внимания юнит тестам).
                            0
                            Немного истории — C++ разработчик открывший для себя питон, как первый динамический язык программирования. Все так же люблю этот язык, иногда пишу на нем, но год назад перешел на Rails, а значит и Ruby.

                            Итак, причина выбора — Rails. Этого факта достаточно чтобы судить о схожести языков. Вокруг фреймворка сформировалось замечательное комьюнити придерживающиеся KISS, DRY принципов, развивающих TDD подход в BDD и SDD (RSpec & Cucumber). Они стремятся к совершенству, создают все новые и новые средства, но не забывают о новичках, предоставляя неплохие предложения по умолчанию. Очень много качественной литературы, в сети информации тоже хватает.

                            Ruby очень красивый и выразительный язык. Слова бессильны — нужно посмотреть.

                            Меня сложно назвать объективным, но это текущий выбор. Теперь изучаю Haskell, Lisp, Erlang… или вернусь к Python, дальше будет видно :)

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

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