├── requirements.txt ├── MANIFEST.in ├── tox.ini ├── .gitignore ├── .travis.yml ├── setup.py ├── LICENSE.txt ├── hurl.py ├── tests.py └── README.rst /requirements.txt: -------------------------------------------------------------------------------- 1 | Django >= 1.4 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE.txt 3 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py26,py27,py33 3 | [testenv] 4 | deps=django 5 | commands={envpython} tests.py 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | MANIFEST 2 | include 3 | lib 4 | local 5 | bin 6 | build 7 | dist 8 | *.pyc 9 | 10 | .idea 11 | .tox 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.6" 4 | - "2.7" 5 | - "3.3" 6 | install: 7 | - pip install -r requirements.txt 8 | script: 9 | - python tests.py 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | from os import path 3 | 4 | ROOT = path.dirname(__file__) 5 | README = path.join(ROOT, 'README.rst') 6 | 7 | setup( 8 | name='hurl', 9 | py_modules=['hurl'], 10 | url='https://github.com/oinopion/hurl', 11 | author='Tomek Paczkowski & Aleksandra Sendecka', 12 | author_email='tomek@hauru.eu', 13 | version='2.1', 14 | license='New BSD License', 15 | long_description=open(README).read(), 16 | classifiers=[ 17 | 'Development Status :: 5 - Production/Stable', 18 | 'Environment :: Web Environment', 19 | 'Intended Audience :: Developers', 20 | 'License :: OSI Approved :: BSD License', 21 | 'Operating System :: OS Independent', 22 | 'Framework :: Django', 23 | 'Programming Language :: Python', 24 | 'Programming Language :: Python :: 2', 25 | 'Programming Language :: Python :: 3', 26 | ], 27 | ) 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Aleksandra Sendecka & Tomasz Paczkowski 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the names of the copyright holders nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /hurl.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import Callable 3 | from django.conf.urls import url, include, patterns as urls_patterns 4 | from django.core.exceptions import ImproperlyConfigured 5 | 6 | __all__ = 'Hurl', 'ViewSpec', 'v', 'patterns' 7 | 8 | DEFAULT_MATCHER = 'slug' 9 | DEFAULT_MATCHERS = { 10 | 'int': r'\d+', 11 | 'slug': r'[\w-]+', 12 | 'str': r'[^/]+', 13 | } 14 | 15 | PATH_SEPARATOR = '/' 16 | PARAM_SEPARATOR = ':' 17 | DEFAULT_MATCHER_KEY = '__default__' 18 | PATTERN_RE = re.compile(r'<([\w:]+?)>') 19 | NAMED_TEMPLATE = r'(?P<{name}>{matcher})' 20 | ANON_TEMPLATE = r'({matcher})' 21 | 22 | try: 23 | string_type = basestring 24 | except NameError: 25 | string_type = str 26 | 27 | 28 | def patterns(prefix, pattern_dict): 29 | h = Hurl() 30 | return h.patterns(prefix, pattern_dict) 31 | 32 | 33 | def include(arg, namespace=None, app_name=None): 34 | return ViewSpec(view=(arg, namespace, app_name)) 35 | 36 | 37 | class Hurl(object): 38 | def __init__(self, name_prefix=''): 39 | self.matchers = Matchers() 40 | self.name_prefix = name_prefix 41 | self.transcriber = PatternTranscriber(self.matchers) 42 | 43 | def patterns(self, prefix, pattern_dict): 44 | urls = self.urls(pattern_dict) 45 | return urls_patterns(prefix, *urls) 46 | 47 | def urls(self, pattern_dict): 48 | return tuple(self._urls(pattern_dict)) 49 | 50 | def _urls(self, pattern_dict): 51 | tree = build_tree(pattern_dict) 52 | urls = tree.urls(self.transcriber) 53 | for pattern, view_spec, view_params in urls: 54 | pattern = finalize_pattern(pattern) 55 | view = view_spec.view 56 | kwargs = view_spec.view_kwargs 57 | name = self._view_name(view_spec) 58 | yield pattern, view, kwargs, name 59 | 60 | def _view_name(self, view_spec): 61 | name = view_name(view_spec.view, view_spec.name) 62 | if name and self.name_prefix: 63 | name = '{prefix}_{name}'.format(prefix=self.name_prefix, name=name) 64 | return name 65 | 66 | @property 67 | def default_matcher(self): 68 | return self.matchers.default_matcher_name 69 | 70 | @default_matcher.setter 71 | def default_matcher(self, value): 72 | self.matchers.default_matcher_name = value 73 | 74 | def include(self, arg, namespace=None, app_name=None): 75 | return include(arg, namespace, app_name) 76 | 77 | # internals 78 | 79 | 80 | def build_tree(url_conf, pattern=''): 81 | if isinstance(url_conf, (string_type, Callable)): 82 | url_conf = ViewSpec(url_conf) 83 | if isinstance(url_conf, ViewSpec): 84 | return UrlLeaf(pattern, view=url_conf) 85 | if isinstance(url_conf, dict): 86 | url_conf = url_conf.items() 87 | node = UrlNode(pattern) 88 | for sub_pattern, sub_conf in url_conf: 89 | child = build_tree(sub_conf, sub_pattern) 90 | node.children.append(child) 91 | return node 92 | 93 | 94 | class ViewSpec(object): 95 | def __init__(self, view, name=None, view_kwargs=None): 96 | self.view = view 97 | self.name = name 98 | self.view_kwargs = view_kwargs 99 | 100 | v = ViewSpec # convenient alias 101 | 102 | 103 | class UrlNode(object): 104 | def __init__(self, pattern): 105 | self.pattern = pattern 106 | self.children = [] 107 | 108 | def urls(self, transcribe): 109 | pattern, params = transcribe(self.pattern) 110 | for child in self.children: 111 | child_urls = list(child.urls(transcribe)) 112 | for url in child_urls: 113 | yield self.merge_child_url(pattern, params, url) 114 | 115 | def merge_child_url(self, pattern, params, child_url): 116 | sub_pattern, view_spec, sub_params = child_url 117 | if not sub_pattern: 118 | sub_pattern = pattern 119 | elif pattern: 120 | sub_pattern = PATH_SEPARATOR.join((pattern, sub_pattern)) 121 | sub_params = sub_params.copy() 122 | sub_params.update(params) 123 | return sub_pattern, view_spec, sub_params 124 | 125 | 126 | class UrlLeaf(object): 127 | def __init__(self, pattern, view): 128 | self.pattern = pattern 129 | self.view = view 130 | 131 | def urls(self, transcribe): 132 | pattern, params = transcribe(self.pattern) 133 | yield pattern, self.view, params 134 | 135 | 136 | def view_name(view, name=None): 137 | if name: 138 | return name 139 | if isinstance(view, string_type): 140 | return view.split('.')[-1] 141 | if isinstance(view, Callable): 142 | return getattr(view, '__name__') 143 | 144 | 145 | def finalize_pattern(pattern): 146 | if pattern == '': 147 | return r'^$' 148 | return r'^{url}/$'.format(url=pattern) 149 | 150 | 151 | class PatternTranscriber(object): 152 | def __init__(self, matchers): 153 | self.matchers = matchers 154 | self.params = None 155 | 156 | def transcribe_pattern(self, pattern): 157 | self.params = {} 158 | transcribed = PATTERN_RE.sub(self.replace, pattern) 159 | return transcribed, self.params 160 | 161 | __call__ = transcribe_pattern 162 | 163 | def replace(self, match): 164 | param, type = self.split_param(match.group(1)) 165 | self.params[param] = type 166 | matcher = self.matchers.matcher(param, type) 167 | template = NAMED_TEMPLATE if param else ANON_TEMPLATE 168 | return template.format(matcher=matcher, name=param) 169 | 170 | def split_param(self, param_string): 171 | if param_string.count(PARAM_SEPARATOR) > 1: 172 | raise ImproperlyConfigured('Syntax error: %s' % param_string) 173 | param, _, type = param_string.partition(':') 174 | return param, type 175 | 176 | 177 | class Matchers(dict): 178 | def __init__(self): 179 | self.default_matcher_name = DEFAULT_MATCHER 180 | super(Matchers, self).__init__(DEFAULT_MATCHERS) 181 | 182 | def default_matcher(self): 183 | return self[self.default_matcher_name] 184 | 185 | def matcher(self, param, type): 186 | if type: 187 | if type in self: 188 | return self[type] 189 | else: 190 | raise ImproperlyConfigured('Matcher not known: %s' % type) 191 | else: 192 | if param in self: 193 | return self[param] 194 | else: 195 | return self.default_matcher() 196 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | from django.utils import unittest 2 | from django.conf.urls import patterns 3 | 4 | import hurl 5 | 6 | 7 | class BasicPatternsTest(unittest.TestCase): 8 | def test_simple_string(self): 9 | h = hurl.Hurl() 10 | result = h.urls({ 11 | '2003': '2003_view', 12 | }) 13 | expected = ( 14 | (r'^2003/$', '2003_view', None, '2003_view'), 15 | ) 16 | self.assertSequenceEqual(result, expected) 17 | 18 | def test_with_callable(self): 19 | def some_view(req): 20 | return '' 21 | 22 | h = hurl.Hurl() 23 | result = h.urls({ 24 | '2003': some_view, 25 | }) 26 | expected = ( 27 | (r'^2003/$', some_view, None, 'some_view'), 28 | ) 29 | self.assertSequenceEqual(result, expected) 30 | 31 | def test_simple_named_parameter(self): 32 | h = hurl.Hurl() 33 | result = h.urls({ 34 | '': 'news.views.details', 35 | }) 36 | expected = ( 37 | (r'^(?P\d+)/$', 'news.views.details', None, 'details'), 38 | ) 39 | self.assertSequenceEqual(result, expected) 40 | 41 | def test_two_named_parameters(self): 42 | h = hurl.Hurl() 43 | result = h.urls({ 44 | 'articles': { 45 | '/': 'news.views.details', 46 | } 47 | }) 48 | expected = ( 49 | (r'^articles/(?P\d+)/(?P\d+)/$', 50 | 'news.views.details', None, 'details'), 51 | ) 52 | self.assertSequenceEqual(result, expected) 53 | 54 | def test_tree_urls(self): 55 | self.maxDiff = None 56 | h = hurl.Hurl() 57 | result = h.urls({ 58 | 'articles': [ 59 | ('/', 'news.views.details'), 60 | ('text/author', [ 61 | ('', 'news.views.author_details'), 62 | ('archive/', 'news.views.archive'), 63 | ]) 64 | ] 65 | }) 66 | expected = ( 67 | (r'^articles/(?P\d+)/(?P\d+)/$', 68 | 'news.views.details', None, 'details'), 69 | (r'^articles/text/author/(?P\d+)/$', 70 | 'news.views.author_details', None, 'author_details'), 71 | (r'^articles/text/author/archive/(?P\d+)/$', 72 | 'news.views.archive', None, 'archive'), 73 | ) 74 | self.assertSequenceEqual(result, expected) 75 | 76 | def test_slug_named_type(self): 77 | h = hurl.Hurl() 78 | result = h.urls({ 79 | '': 'news.views.details', 80 | }) 81 | expected = ( 82 | (r'^(?P[\w-]+)/$', 'news.views.details', None, 'details'), 83 | ) 84 | self.assertSequenceEqual(result, expected) 85 | 86 | def test_custom_named_type(self): 87 | h = hurl.Hurl() 88 | h.matchers['year'] = r'\d{4}' 89 | result = h.urls({ 90 | '': 'news.views.details', 91 | }) 92 | expected = ( 93 | (r'^(?P\d{4})/$', 'news.views.details', None, 'details'), 94 | ) 95 | self.assertSequenceEqual(result, expected) 96 | 97 | def test_custom_guessed_named_type(self): 98 | h = hurl.Hurl() 99 | h.matchers['year'] = r'\d{4}' 100 | result = h.urls({ 101 | '': 'news.views.details', 102 | }) 103 | expected = ( 104 | (r'^(?P\d{4})/$', 'news.views.details', None, 'details'), 105 | ) 106 | self.assertSequenceEqual(result, expected) 107 | 108 | def test_default_type_is_slug(self): 109 | h = hurl.Hurl() 110 | result = h.urls({ 111 | '': 'news.views.details', 112 | }) 113 | expected = ( 114 | (r'^(?P[\w-]+)/$', 'news.views.details', None, 'details'), 115 | ) 116 | self.assertSequenceEqual(result, expected) 117 | 118 | def test_setting_custom_default_type(self): 119 | h = hurl.Hurl() 120 | h.default_matcher = 'int' 121 | result = h.urls({ 122 | '': 'news.views.details', 123 | }) 124 | expected = ( 125 | (r'^(?P\d+)/$', 'news.views.details', None, 'details'), 126 | ) 127 | self.assertSequenceEqual(result, expected) 128 | 129 | def test_empty_url(self): 130 | h = hurl.Hurl() 131 | result = h.urls({ 132 | '': 'details', 133 | }) 134 | expected = ( 135 | (r'^$', 'details', None, 'details'), 136 | ) 137 | self.assertSequenceEqual(result, expected) 138 | 139 | def test_empty_nested_url(self): 140 | h = hurl.Hurl() 141 | result = h.urls({ 142 | 'bla': { 143 | '': 'details', 144 | } 145 | }) 146 | expected = ( 147 | (r'^bla/$', 'details', None, 'details'), 148 | ) 149 | self.assertSequenceEqual(result, expected) 150 | 151 | def test_no_name_only_type(self): 152 | h = hurl.Hurl() 153 | result = h.urls({ 154 | '<:int>': 'details', 155 | }) 156 | expected = ( 157 | (r'^(\d+)/$', 'details', None, 'details'), 158 | ) 159 | self.assertSequenceEqual(result, expected) 160 | 161 | def test_name_prefix(self): 162 | h = hurl.Hurl(name_prefix='news') 163 | result = h.urls({ 164 | '': 'news.views.details', 165 | }) 166 | expected = ( 167 | (r'^(?P\d+)/$', 'news.views.details', None, 'news_details'), 168 | ) 169 | self.assertSequenceEqual(result, expected) 170 | 171 | def test_include(self): 172 | h = hurl.Hurl() 173 | urlpatterns = h.patterns('', { 174 | '': 'news.views.details', 175 | }) 176 | result = h.urls({ 177 | '': { 178 | '': 'news.views.details', 179 | 'comments': h.include(urlpatterns) 180 | } 181 | }) 182 | expected = ( 183 | (r'^(?P\d+)/$', 'news.views.details', None, 'details'), 184 | (r'^(?P\d+)/comments/$', (urlpatterns, None, None), None, None), 185 | ) 186 | self.assertEqual(result[0], expected[0]) 187 | self.assertEqual(result[1][0], expected[1][0]) 188 | self.assertEqual(result[1][2], expected[1][2]) 189 | self.assertEqual(result[1][3], expected[1][3]) 190 | self.assertEqual( 191 | result[1][1][0][0].__dict__, 192 | expected[1][1][0][0].__dict__) 193 | 194 | def test_regexurlpatter_returned(self): 195 | h = hurl.Hurl() 196 | urlpatterns = h.patterns('', { 197 | '': 'news.views.details' 198 | }) 199 | expected = patterns('', 200 | (r'^(?P[0-9]+)/', 'news.views.details', None, 201 | 'news_details') 202 | ) 203 | self.assertTrue(urlpatterns, expected) 204 | 205 | 206 | if __name__ == '__main__': 207 | unittest.main() 208 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django Happy Urls 2 | ================= 3 | 4 | Comments/feedback is very welcome, use issues or twitter: 5 | https://twitter.com/oinopion 6 | 7 | .. image:: https://secure.travis-ci.org/oinopion/hurl.png 8 | 9 | Django has nice routing, but it's too low level. Regexps are powerful, 10 | but have cryptic syntax. This library strives to make writing DRY 11 | urls a breeze. 12 | 13 | Consider a standard urls.py:: 14 | 15 | urlpatterns = patterns('blog.entries.views', 16 | url(r'^$', 'recent_entries', name='entries_recent_entries'), 17 | url(r'^new/$', 'new_entry', name='entries_new_entry'), 18 | url(r'^(?P[\w-]+)/$', 'show_entry', name='entries_show_entry'), 19 | url(r'^(?P[\w-]+)/edit/$', 'edit_entry', name='entries_edit_entry'), 20 | url(r'^(?P[\w-]+)/delete/$', 'delete_entry', name='entries_delete_entry'), 21 | url(r'^(?P[\w-]+)/comments/$', 'comments_list', name='entries_comments_list'), 22 | url(r'^(?P[\w-]+)/comments/(\d+)/$', 'comment_details', name='entries_comment_detail'), 23 | ) 24 | 25 | It has many issues: 26 | 27 | - you need to remember about the '^' and the '$' 28 | - you repeat the entry_slug url 29 | - you need to remember arcane named group syntax 30 | - you repeat the [\\w-]+ group 31 | - you associate name with urls conf 32 | 33 | Better way of writing urls would be:: 34 | 35 | urlpatterns = hurl.patterns('blog.entries.views', [ 36 | ('', 'recent_entries'), 37 | ('new', 'new_entry'), 38 | ('', [ 39 | ('', 'show_entry'), 40 | ('edit', 'edit_entry'), 41 | ('delete', 'delete_entry'), 42 | ('comments', 'comments_list'), 43 | ('comments/<:int>', 'comment_detail'), 44 | ]), 45 | ]) 46 | 47 | It conveys url structure more clearly, is much more readable and 48 | avoids repetition. If your views don't rely on order, you can also use 49 | dictionary like this:: 50 | 51 | urlpatterns = hurl.patterns('blog.entries.views', { 52 | 'show': 'show_entry', 53 | 'edit': 'edit_entry', 54 | 'delete': 'delete_entry', 55 | }) 56 | 57 | 58 | How to use it 59 | ------------- 60 | 61 | patterns (prefix, url_conf) 62 | 63 | * prefix is same as in django.conf.url.patterns 64 | * url_conf is either a dictionary or a list of 2-tuples 65 | The key (in dict) or first element (tuple) is a url fragment, 66 | value/second element can be one of: another url_conf, a string, an instance 67 | of ViewSpec:: 68 | 69 | { 70 | 'show': 'blog.views.show_entry', 71 | } 72 | 73 | is equivalent to:: 74 | 75 | [ 76 | ('show', 'blog.views.show_entry'), 77 | ] 78 | 79 | URL conf creates a tree of url fragments and generates a list 80 | by joining each fragment with the "/":: 81 | 82 | { 83 | 'entries': { 84 | 'edit': 'edit_entry', 85 | 'delete': 'delete_entry', 86 | } 87 | } 88 | 89 | This will generate these urls:: 90 | 91 | (r'^entries/edit/$', 'edit_entry', name='edit_entry') 92 | (r'^entries/delete/$', 'delete_entry', name='edit_entry') 93 | 94 | 95 | Url fragment may include multiple parameters in format:: 96 | 97 | '' 98 | 99 | parameter_name can be any python identifier 100 | parameter_type must be one of default or defined matchers 101 | 102 | If you have parameter_type same as parameter_name, you can skip 103 | duplication and use shorter form:: 104 | 105 | '' -> '' 106 | 107 | If you want to use default matcher also use shortcut:: 108 | 109 | '' -> '' 110 | 111 | If you don't want to define parameter name, leave it empty:: 112 | 113 | '<:int>' # will generate r'(\d+)' 114 | 115 | 116 | 117 | Default Matchers 118 | ---------------- 119 | 120 | :slug: 121 | 122 | r'[\\w-]+' 123 | This is the default matcher. 124 | 125 | :int: 126 | 127 | r'\\d+' 128 | 129 | :str: 130 | 131 | r'[^/]+' 132 | 133 | Custom Matchers 134 | --------------- 135 | 136 | You can define your own matchers. Just instantiate Hurl and set:: 137 | 138 | import hurl 139 | h = hurl.Hurl() 140 | h.matchers['year'] = r'\d{4}' 141 | 142 | urlpatterns = h.patterns('', {'': 'year_archive'}) 143 | 144 | .. note:: 145 | 146 | When defining custom matchers use the 'patterns' method of your instance, 147 | rather than function provided by module. 148 | 149 | Names generation 150 | ---------------- 151 | 152 | Hurl will automatically generate view names for you. When provided with 153 | view as string ('blog.views.show_entry') it will take last part after the dot. 154 | When provided with function it will take the func_name of it:: 155 | 156 | def some_view(req): 157 | pass 158 | 159 | urlpatterns = hurl.patterns('', { 160 | 'show': 'blog.views.show_entry', # generates 'show_entry' name 161 | 'some': some_view, # generates 'some_view' name 162 | }) 163 | 164 | You can also want to change the name use the 'v' function:: 165 | 166 | import hurl 167 | urlpatterns = hurl.patterns('', { 168 | 'show': hurl.v('show_view', name='show'), 169 | }) 170 | 171 | Includes 172 | -------- 173 | 174 | If you want to include some other urlpatterns, use the `include` method:: 175 | 176 | import hurl 177 | urlpatterns = hurl.patterns('', { 178 | 'shop': hurl.include('shop.urls'), 179 | 'blog': hurl.include('blog.urls'), 180 | }) 181 | 182 | 183 | Mixing with pure Django urls 184 | ---------------------------- 185 | 186 | Hurl doesn't do anything special, it just generates plain old Django urls. 187 | You can easily mix two APIs:: 188 | 189 | from django.conf.urls import url, include, patterns 190 | import hurl 191 | 192 | urlpatterns = patterns('', # plain Django 193 | url(r'^hello/$ 194 | ) 195 | 196 | 197 | More examples 198 | ------------- 199 | 200 | Django tutorial:: 201 | 202 | # original: 203 | urlpatterns = patterns('', 204 | (r'^articles/2003/$', 'news.views.special_case_2003', {}, 'news_special_case_2003'), 205 | (r'^articles/(?P\d{4})/$', 'news.views.year_archive', {}, 'news_year_archive'), 206 | (r'^articles/(?P\d{4})/(?P\d{2})/$', 'news.views.month_archive', {}, 'news_month_archive'), 207 | (r'^articles/(?P\d{4})/(?P\d{2})/(?P\d{2})/$', 'news.views.article_detail', {}, 'news_article_detail'), 208 | ) 209 | 210 | # hurled: 211 | hurl = Hurl(name_prefix='news') 212 | hurl.matchers['year'] = r'\d{4}' 213 | hurl.matchers['month'] = r'\d{2}' 214 | hurl.matchers['day'] = r'\d{2}' 215 | 216 | urlpatterns = hurl.patterns('news.views', { 217 | 'articles': { 218 | '2003': 'special_case_2003', 219 | '': 'year_archive', 220 | '/': 'month_archive', 221 | '//': 'article_detail', 222 | } 223 | }) 224 | 225 | --------------------------------------------------------------------------------