Изучаем Django

Очерки о разработке

7 Июнь 2009 г.

Покрытометр (Django coverage)

В рамках программы по улучшению качества кода начали писать на работе модульные тесты.

Они оказались достаточно полезными. Пока, конечно рано, говорить насколько увеличилось качество и предсказуемость кода. Но теперь я не боюсь делать рефакторинг, даже переписать целый модуль. Тесты дают мне уверенность, что я ничего не сломал :).

В этой уверенности кроется одна проблема, я не знаю какая часть кода покрыта тестами. Так как нам еще далеко до TDD, тесты проверяют лишь некоторые функции.

Если бы степень покрытия различных модулей системы была известна, мы бы могли ответить на два вопроса:

  • С какой вероятностью рефакторинг не добавил ошибок.
  • Над тестами к каким модулям стоит поработать для более равномерного покрытия.

Сказано — сделано. Для Python есть замечательная библиотека coverage, которая умеет определять степень покрытия кода.

Библиотеки такого рода работают достаточно просто, в исходных файлах подсчитывается количество строк кода. Затем библиотека запускает хук, который отслеживает, какие строчки кода были выполнены. В итоге степень покрытия это число выполненных во время тестов строк кода, поделенное на общее число строк кода.

Итак, coverage умеет высчитывать степень покрытия кода и строить изумительные html-отчеты, осталось немного — подружить ее с Django.

Для себя я выбрал следующий вариант. Обертка для стандратного test runner, которая по окончании работы генерирует html-отчет. На мой взгляд, достаточно удобно. Одна команда:

./manage.py test

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

Перейдем к самому интересному, к коду обертки, она требует совсем немного настроек в settings.py

#переопределиние стандартного test runner на наш
TEST_RUNNER = "core.test.coverage_runner.run_tests"
#путь к сгенерированным отчетам
COVERAGE_REPORT_PATH = os.path.join(workdir, 'coverage_report')

и кода в файле coverage_runner.py

""" 
Test runner with code coverage.
Falls back to django simple runner if coverage-lib not installed 
"""
import os

from django.test import simple
from django.conf import settings


#попытка импорта coverage библиотеки
try:
    from coverage import coverage as Coverage
except ImportError:
#если она не установлена, просто запустим стандартный обработчик
    run_tests = simple.run_tests 
else:
#если установлена примемся за построение отчета
    coverage = Coverage()
    def run_tests(test_labels, verbosity=1, interactive=True, extra_tests=[]):

# часть непосредственно отвечающая за получение данных о покрытии (включить сбор; выполнить тесты; выключить сбор)
        coverage.start()
        test_results = simple.run_tests(test_labels, verbosity, interactive, extra_tests)
        coverage.stop()

# Как известно, manage.py test может не все тесты, а только из определенных приложений
# немного не логично, что в этом случае покрытие будет определено для всего кода в проекте
# и для не ваших приложений и для библиотек.
# Следующий код берет имена приложений для тестирования из test_labels, определяет все модули
# входящие в их состав и передает список для построения отчета.
# Это очень удобно, мы тестируем только свой код и получаем только его покрытие.
        coverage_modules = []
        for app in test_labels:
            try:
                module = __import__(app, globals(), locals(), [''])
            except ImportError:
# Эта ситуация возникает в случае если тестирование ограничено одним TestCase, либо модуль недоступен
# В обоих случая нам не нужен отчет о покрытии.
                coverage_modules = None
                break
            if module:
                base_path = os.path.join(os.path.split(module.__file__)[0], "")
# Ищем внутри приложения непустые .py файлы
                for root, dirs, files in os.walk(base_path):
                    for fname in files:
                        if fname.endswith(".py") and os.path.getsize(os.path.join(root, fname)) > 1:
                            try:
                                mname = os.path.join(app, os.path.join(root, fname).replace(base_path, "")) 
                                coverage_modules.append(mname)
                            except ImportError:
                                pass #do nothing
        
# Строим html-отчет        
        if coverage_modules or not test_labels:
            coverage.html_report(coverage_modules, directory=settings.COVERAGE_REPORT_PATH)
                    
        return test_results

Для успешного использования coverage runner необходимо:

На выходе получаются красивые картинки. Общий отчет , отчет покрытия одного файла .

С радостью выслушаю замечания, мысли и истории "как у нас" по модульному тестированию и TDD в комментариях.

Еще предлагаю померяться в комментариях степенью покрытия кода. У нас49%.

25 Май 2009 г.

Тайна замыкания

Перед нами на работе встала задача, ограничить частоту публикации комментариев. Реализовать решили через декоратор для вьюшек.

Другими словами нужно создать декоратор, не допускающий обращение к конкретной view, конкретным пользователем в течении определенного времени.

Что такое декоратор?

Декоратор — обертка для функции. С помощью декоратора можно изменять поведение декорируемой функции, ее входные или выходные параметры.

Примеры декораторов из Django:

login_required
при обращении анонимного пользователя к декорированому view, перенаправляет его на страницу логина.
transaction.commit_on_success
выполняет все запросы из декорируемой функции к БД в одной транзакции и коммитит ее при успешном завершении.
cache_page(sec)
кеширует результат выполнения view на sec секунд.

Два способа применения декоратора:

@login_required
def my_view(request):
 ...
def my_view(request):
 ...
my_view = login_required(my_view) #python 2.3

Получение id пользователя не вызывает проблем — request.user. А вот с получением имени функции не так все просто. Вот шаблон декоратора:


def decorator(function):
    def actual_decorator(request, *args, **kwargs):
        pass
    return actual_decorator

То есть, мы имеем указатель на декорируемую функцию function, и можно получить ее имя через function.func_name. Но тут возникает проблема, к функции часто применяется последовательность декораторов, например:


@limit_rate_request
@login_required
def my_view(request):
    ...

В этой ситуации function будет ссылаться на декоратор login_required и имя декорируемой функции мы получить не можем.

Что такое замыкание?

Это внутренняя функция, содержащая ссылки на локальные переменные внешней функции.

Например:


def func1():
    closure = 1
    def func2():
        print closure
 

В этом примере переменная closure замкнута в функции func2.

Так как на login_required замкнут указатель на декорируемую функцию, можно попробовать получить ее значение. У функции в питоне существует параметр func_closure — все замкнутые на функцию переменную. Но, к сожалению, их значения представлены классом cell и получить значение напрямую нельзя.

На помощь приходит Решение :)


def get_cell_value(cell):
 # функция, которая возращает функцию, которая в свою очередь вернет замкнутую на нее переменную
    def make_closure_that_returns_value(use_this_value):
        def closure_that_returns_value():
            return use_this_value
        return closure_that_returns_value
    # получаем экземпляр функции с замыканием с параметром 0
    dummy_function = make_closure_that_returns_value(0)
    # получаем байт-код этой функции
    dummy_function_code = dummy_function.func_code
    # создаем новый экземпляр функции вместо старой замкнутой переменной используем наш cell
    our_function = new.function(dummy_function_code, {}, None, None, (cell,))
    # вызываем функцию, и она возвращает значение нашей замкнутой переменной из cell
    value_from_cell = our_function()
    return value_from_cell 

Далее дело за малым — создать декоратор. Для хранения времени последнего вызова достаточно удобно использовать кеш, установив время его жизни равным времени ограничения на запуск функции.

Таким образом, после первого запроса View в кеше будет установлена переменная PREFIX_USER_ID_FUNCTION_NAME, пока повторный запуск запрещен эта переменная хранится в кеше, как только таймаут вышел, кеш «протухает» и пользователь снова может сделать запрос.

Полный код декоратора:


def get_cell_value(cell):
    def make_closure_that_returns_value(use_this_value):
        def closure_that_returns_value():
            return use_this_value
        return closure_that_returns_value
    dummy_function = make_closure_that_returns_value(0)
    dummy_function_code = dummy_function.func_code
    our_function = types.FunctionType(dummy_function_code, {}, None, None, (cell,))    
    value_from_cell = our_function()
    return value_from_cell


def get_decorated_function(function):
    """ Returns actual decorated function in decorators stack """
    while function.func_closure is not None:
        function = get_cell_value(function.func_closure[0])
    return function
 
class limit_request_rate(object):
    """
    Decorator for view that limit request rate for view for concrete user.
    Anonymous users not limited. 
    @todo: differentiate anonymous users by IP
    """    
    CACHE_VAR_PREFIX = "limit_request_rate_"
    
    def __init__(self, timeout = None):
        self.timeout = timeout or settings.DEFAULT_REQUEST_TIMEOUT
    
    def __call__(self, function):        
        def actual_decorator(request, *args, **kwargs):
            if request.user.is_authenticated():                
                dec_func = get_decorated_function(function)
                cache_key = self.CACHE_VAR_PREFIX + str(request.user.id) + dec_func
.func_name + dec_func.func_code.co_filename
                if not cache.get(cache_key):
                    cache.set(cache_key, 1, self.timeout)
                    return function(request, *args, **kwargs)
                else:
                    return HttpResponseServiceUnavailable()            
            return function(request, *args, **kwargs)
        return actual_decorator

up: deprecated new.function заменен на Types.FunctionType

Оставьте комментарий, если вам есть что добавить, вы нашли ошибку в коде или что-то осталось непонятным.

Обо мне

photo of Анатолий Ларин
Россия, Санкт-Петербург
+7 (921) 595-05-51