Мерзость ее заключалась в том, что появлялась она время от времени. Да, я замечал, что изредка breadcrumbs -- так называется маршрут к текущей странице ('вы здесь...') на веб-портале не появлялся, но винил в этом браузер, memcached, скомпилированные Django-шаблоны -- все, что угодно, только не свой модуль, который отвечает за построение breadcrumbs.
Модуль работает следующим образом:
- Из YAML-файла читается карта сайта. В результате получается дерево. Это происходит один раз, при старте Django-приложения
- При выводе очередной страницы вызывается функция 'search' с текущим адресом в качестве аргумента. Она ищет в дереве узел, соответствующий заданному адресу.
- Если узел найден, то в шаблон возвращается маршрут к нему. Каждый пункт маршрута содержит адрес и заголовок. В результате получается что-то вроде: "Вы здесь: Главная/Сообщения/Непрочитанные".
Понятно, что карту сайта нельзя ограничивать статическими адресами. В YAML-файл следовало включить шаблоны, а модуль, который сопоставляет узлы дерева с текущим адресом, должен уметь их понимать.
YAML получился примерно такой:
--- # Карта сайта
url : /
title : Главная
nodes :
- url : accounts/
title : Пользователи
nodes :
- url : accounts/{USER_ID}/
- url : accounts/{USER_NAME}/
- url : blog/
title : Блог
nodes :
- url : blog/{ENTRY_ID}/
- url : blog/add/
title : Новая запись
...
Наряду со статическими адресами (например: "/", "accounts/"), здесь присутствуют и динамические. Когда функция, отвечающая за обход дерева, встречает узел accounts/{USER_ID}/ и проверяет, соответствует ли этому узлу текущий адрес account/1111/, она говорит: "Ага, это то что мне надо!"
Чтобы найти маршрут для breadcrumbs, достаточно пройти от найденного узла к вершине дерева. Получится:
- / - "Главная"
- /accounts - "Пользователи "
- /accounts/1111 - ...
Вот как выглядела основная функция search до того, как я исправил ту самую ошибку, с которой начал эту заметку:
def translate(node, goal):
"""
Преобразует узел node карты в структуру, пригодную для использования.
Если в адресе имеются шаблоны, они заменяется динамическими данными
(например: {USER_ID} --> 1111
Если существует правило замены заголовка, то node получает атрибут
'title' с новым значением.
node -- узел карты сайта
goal -- текущий URL
"""
...
def search(url):
"""
Поиск адреса в карте сайта.
Аргументы:
url -- искомый адрес (строка)
Возвращает:
True, маршрут -- если адрес найден
False, пустой список -- если адрес не найден.
Маршрут -- это список узлов, каждый из которых представлен словарем
c теми же ключами, что заданы в конфигурационном файле.
"""
path = [] # маршрут
url = normalize_url(url) # приводит искомый URL к правильному виду
# рекурсивный обход дерева
if visit(SITE_MAP, url, path):
# узел найден
return True, map(
lambda node: translate(node, url), path)
else:
# неудача
return False, []
***
И вот я написал серию тестов для модуля. Самое простое -- проверить сразу дюжину адресов, про которые заранее известно, что они прописаны в карте сайта, и убедиться, что маршруты найдены.
import unittest
class TestCase(unittest.TestCase):
def setUp(self):
self.urls = (
# адреса, про которые заранее известно,
# что они прописаны в карте сайта
...
)
def test_search_results(self):
"""
Прогоняем через search self.urls;
Если хотя бы один не найден, тест провален.
"""
print 'Searching %d entries' % len(self.urls)
search_all = ((url, search(url)[0])
for url in self.urls)
negative = [ url for url, result in search_all if not result ]
msg = 'Some urls were not found: %s' % ', '.join(negative)
self.assertEqual(len(negative), 0, msg)
class TestCase(unittest.TestCase):
def setUp(self):
self.urls = (
# адреса, про которые заранее известно,
# что они прописаны в карте сайта
...
)
def test_search_results(self):
"""
Прогоняем через search self.urls;
Если хотя бы один не найден, тест провален.
"""
print 'Searching %d entries' % len(self.urls)
search_all = ((url, search(url)[0])
for url in self.urls)
negative = [ url for url, result in search_all if not result ]
msg = 'Some urls were not found: %s' % ', '.join(negative)
self.assertEqual(len(negative), 0, msg)
Я добавил в тест функцию, которая искала этот единственный проблематичный узел:
def test_idpattern(self):
print 'Id pattern'
url = u'/accounts/2227/'
result, path = search(url)
self.assertEqual(result, True, "URL '%s' not found" % url)
Наконец, я понял, в чем дело. Я попался на типичное питоновское 'gotcha'.
На вход функции "visit" подается исходное дерево, полученное YAML-парсером:
visit(SITE_MAP, url, path)
Когда 'visit ' возвращает маршрут, оно берет его из исходного SITE_MAP. Причем возвращается не копия, а ссылка. Perl, между прочим, вернул бы копию, там операция присвоения работает иначе:
my %a = (a => 'foo', b => 'bar');
my %b = %a;
$b{'a'} = 'buz';
printf "Original: %s\n", $a{'a'};
printf "Copy: %s\n", $b{'a'};
Original: foo
Copy: buz
На вход функции 'translate' подавался все тот же узел (точнее, ссылка на него) и преобразовывала она его же. Из-за этого преобразования и возникала ошибка. После благополучного нахождения узла /accounts/1/, шаблон /accounts/{USER_ID} переставал существовать, "{USER_ID}" заменялось на "1". Не удивительно, что другой адрес такого же типа: accounts/2227 уже не имел шансов. Это все равно, что пытаться ориентироваться по карте, которая по мере твоего продвижения сама меняется.
Чтобы поправить ошибку, достаточно было отредактировать одну-единственную строчку в функции 'search':
from copy import copy
# рекурсивный обход дерева
if visit(SITE_MAP, url, path):
# узел найден
return True, map(
lambda node: translate(copy(node), url), path)
...
# рекурсивный обход дерева
if visit(SITE_MAP, url, path):
# узел найден
return True, map(
lambda node: translate(copy(node), url), path)
...
Комментариев нет:
Отправить комментарий