commit cc50e071e51af63e24583dc7274547796478fec3 Author: Jeff Kistler <jeff@jeffkistler.com> Date: Mon Jun 6 20:53:06 2011 -0700 A working implementation of decorator_include. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..328d6be --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +*.pyc +*.pyo +*~ +.DS_Store +.coverage +.installed.cfg +MANIFEST +dist +parts/* +eggs/* +downloads/* +bin/* +develop-eggs/* +src/*.egg-info \ No newline at end of file diff --git a/AUTHORS.rst b/AUTHORS.rst new file mode 100644 index 0000000..a6875c8 --- /dev/null +++ b/AUTHORS.rst @@ -0,0 +1 @@ +``decorator_include`` was written by Jeff Kistler in 2011. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..89c4525 --- /dev/null +++ b/README.rst @@ -0,0 +1,42 @@ +decorator_include +================= + +Include Django URL patterns with decorators. + +Installation +------------ + +Installation from Source +```````````````````````` + +Unpack the archive, ``cd`` to the source directory, and run the following +command:: + + python setup.py install + +Installation with pip and git +````````````````````````````` + +Assuming you have pip and git installed, run the following command to +install from the GitHub repository:: + + pip install git+git://github.com/jeffkistler/decorator_include.git#egg=decorator_include + +Usage +----- + +``decorator_include`` is intended for use in URL confs as a replacement +for the ``django.conf.urls.defaults.include`` function. It works in almost +the same way as ``include``, however the first argument should be either a +decorator or an iterable of decorators to apply, in reverse order, to all +included views. Here is an example URL conf:: + + from django.conf.urls.defaults import * + from django.contrib.auth.decorators import login_required + + from decorator_include import decorator_include + + urlpatterns = patterns('', + url(r'^$', 'mysite.views.index', name='index'), + url(r'^secret/', decorator_include(login_required, 'mysite.secret.urls'), + ) diff --git a/bootstrap.py b/bootstrap.py new file mode 100644 index 0000000..7728587 --- /dev/null +++ b/bootstrap.py @@ -0,0 +1,77 @@ +############################################################################## +# +# Copyright (c) 2006 Zope Corporation and Contributors. +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# +############################################################################## +"""Bootstrap a buildout-based project + +Simply run this script in a directory containing a buildout.cfg. +The script accepts buildout command-line options, so you can +use the -c option to specify an alternate configuration file. + +$Id$ +""" + +import os, shutil, sys, tempfile, urllib2 + +tmpeggs = tempfile.mkdtemp() + +is_jython = sys.platform.startswith('java') + +try: + import pkg_resources +except ImportError: + ez = {} + exec urllib2.urlopen('http://peak.telecommunity.com/dist/ez_setup.py' + ).read() in ez + ez['use_setuptools'](to_dir=tmpeggs, download_delay=0) + + import pkg_resources + +if sys.platform == 'win32': + def quote(c): + if ' ' in c: + return '"%s"' % c # work around spawn lamosity on windows + else: + return c +else: + def quote (c): + return c + +cmd = 'from setuptools.command.easy_install import main; main()' +ws = pkg_resources.working_set + +if is_jython: + import subprocess + + assert subprocess.Popen([sys.executable] + ['-c', quote(cmd), '-mqNxd', + quote(tmpeggs), 'zc.buildout'], + env=dict(os.environ, + PYTHONPATH= + ws.find(pkg_resources.Requirement.parse('setuptools')).location + ), + ).wait() == 0 + +else: + assert os.spawnle( + os.P_WAIT, sys.executable, quote (sys.executable), + '-c', quote (cmd), '-mqNxd', quote (tmpeggs), 'zc.buildout', + dict(os.environ, + PYTHONPATH= + ws.find(pkg_resources.Requirement.parse('setuptools')).location + ), + ) == 0 + +ws.add_entry(tmpeggs) +ws.require('zc.buildout') +import zc.buildout.buildout +zc.buildout.buildout.main(sys.argv[1:] + ['bootstrap']) +shutil.rmtree(tmpeggs) diff --git a/buildout.cfg b/buildout.cfg new file mode 100644 index 0000000..33f0585 --- /dev/null +++ b/buildout.cfg @@ -0,0 +1,16 @@ +[buildout] +parts = decorator_include +unzip = true +develop = . + +[decorator_include] +recipe = djangorecipe +version = 1.3 +project = testproject +settings = settings +test = decorator_include +testrunner = test +eggs = + decorator_include + django-nose + coverage diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b58821b --- /dev/null +++ b/setup.py @@ -0,0 +1,26 @@ +import os +from distutils.core import setup + +def read(fname): + return open(os.path.join(os.path.dirname(__file__), fname)).read() + +setup( + name = 'django-decorator-include', + version = '0.1', + license = 'BSD', + description = 'Include Django URL patterns with decorators.', + long_description = read('README.rst'), + author = 'Jeff Kistler', + author_email = 'jeff@jeffkistler.com', + url = 'https://github.com/jeffkistler/django-decorator-include/', + packages = ['decorator_include', 'decorator_include.tests'], + package_dir = {'': 'src'}, + classifiers = [ + 'Framework :: Django', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Topic :: Internet :: WWW/HTTP', + ] +) diff --git a/src/decorator_include/__init__.py b/src/decorator_include/__init__.py new file mode 100644 index 0000000..fc9a3ff --- /dev/null +++ b/src/decorator_include/__init__.py @@ -0,0 +1,83 @@ +""" +A replacement for ``django.conf.urls.defaults.include`` that takes a decorator, +or an iterable of view decorators as the first argument and applies them, in +reverse order, to all views in the included urlconf. +""" +from django.core.exceptions import ImproperlyConfigured +from django.core.urlresolvers import RegexURLPattern, RegexURLResolver +from django.utils.importlib import import_module + +class DecoratedPatterns(object): + """ + A wrapper for an urlconf that applies a decorator to all its views. + """ + def __init__(self, urlconf_name, decorators): + self.urlconf_name = urlconf_name + try: + iter(decorators) + except TypeError: + decorators = [decorators] + self.decorators = decorators + if not isinstance(urlconf_name, basestring): + self._urlconf_module = self.urlconf_name + else: + self._urlconf_module = None + + def decorate_pattern(self, pattern): + if isinstance(pattern, RegexURLResolver): + regex = pattern.regex.pattern + urlconf_module = pattern.urlconf_name + default_kwargs = pattern.default_kwargs + namespace = pattern.namespace + app_name = pattern.app_name + urlconf = DecoratedPatterns(urlconf_module, self.decorators) + decorated = RegexURLResolver( + regex, urlconf, default_kwargs, + app_name, namespace + ) + else: + callback = pattern.callback + for decorator in reversed(self.decorators): + callback = decorator(callback) + decorated = RegexURLPattern( + pattern.regex.pattern, + callback, + pattern.default_args, + pattern.name + ) + return decorated + + def _get_urlconf_module(self): + if self._urlconf_module is None: + self._urlconf_module = import_module(self.urlconf_name) + return self._urlconf_module + urlconf_module = property(_get_urlconf_module) + + def _get_urlpatterns(self): + try: + patterns = self.urlconf_module.urlpatterns + except AttributeError: + patterns = self.urlconf_module + return [self.decorate_pattern(pattern) for pattern in patterns] + urlpatterns = property(_get_urlpatterns) + + def __getattr__(self, name): + return getattr(self.urlconf_module, name) + + +def decorator_include(decorators, arg, namespace=None, app_name=None): + """ + Works like ``django.conf.urls.defaults.include`` but takes a view decorator + or an iterable of view decorators as the first argument and applies them, + in reverse order, to all views in the included urlconf. + """ + if isinstance(arg, tuple): + if namespace: + raise ImproperlyConfigured( + 'Cannot override the namespace for a dynamic module that provides a namespace' + ) + urlconf, app_name, namespace = arg + else: + urlconf = arg + decorated_urlconf = DecoratedPatterns(urlconf, decorators) + return (decorated_urlconf, app_name, namespace) diff --git a/src/decorator_include/models.py b/src/decorator_include/models.py new file mode 100644 index 0000000..e69de29 diff --git a/src/decorator_include/tests/__init__.py b/src/decorator_include/tests/__init__.py new file mode 100644 index 0000000..206f65e --- /dev/null +++ b/src/decorator_include/tests/__init__.py @@ -0,0 +1,98 @@ +from django.test import TestCase + +class IncludeDecoratedTests(TestCase): + urls = 'decorator_include.tests.urls' + + def getDecoratorInclude(self): + from decorator_include import decorator_include + return decorator_include + + def testBasic(self): + decorator_include = self.getDecoratorInclude() + def test_decorator(func): + func.tested = True + return func + result = decorator_include( + test_decorator, + 'decorator_include.tests.urls' + ) + self.assertEquals(3, len(result)) + self.assertTrue('DecoratedPatterns', result[0].__class__.__name__) + self.assertTrue(result[1] is None) + self.assertTrue(result[2] is None) + + def testBasicNamespace(self): + decorator_include = self.getDecoratorInclude() + def test_decorator(func): + func.tested = True + return func + result = decorator_include( + test_decorator, + 'decorator_include.tests.urls', + 'test' + ) + self.assertEquals(3, len(result)) + self.assertTrue('DecoratedPatterns', result[0].__class__.__name__) + self.assertTrue(result[1] is None) + self.assertEquals('test', result[2]) + + def testGetURLPatterns(self): + decorator_include = self.getDecoratorInclude() + def test_decorator(func): + func.decorator_flag = 'test' + return func + result = decorator_include( + test_decorator, + 'decorator_include.tests.urls' + ) + self.assertEquals(3, len(result)) + self.assertTrue('DecoratedPatterns', result[0].__class__.__name__) + patterns = result[0].urlpatterns + self.assertEquals(2, len(patterns)) + self.assertEquals('test', patterns[0].callback.decorator_flag) + + def testMultipleDecorators(self): + decorator_include = self.getDecoratorInclude() + def first_decorator(func): + func.decorator_flag = 'first' + return func + def second_decorator(func): + func.decorator_flag = 'second' + func.decorated_by = 'second' + return func + result = decorator_include( + (first_decorator, second_decorator), + 'decorator_include.tests.urls' + ) + self.assertTrue('DecoratedPatterns', result[0].__class__.__name__) + patterns = result[0].urlpatterns + pattern = patterns[0] + self.assertEquals('first', pattern.callback.decorator_flag) + self.assertEquals('second', pattern.callback.decorated_by) + + def testFollowInclude(self): + decorator_include = self.getDecoratorInclude() + def test_decorator(func): + func.decorator_flag = 'test' + return func + result = decorator_include( + test_decorator, + 'decorator_include.tests.urls' + ) + patterns = result[0].urlpatterns + decorated = patterns[1] + self.assertEquals('test', decorated.url_patterns[1].callback.decorator_flag) + decorated = patterns[1].url_patterns[0].url_patterns[0] + self.assertEquals('test', decorated.callback.decorator_flag) + + def testGetIndex(self): + response = self.client.get('/') + self.assertEquals(200, response.status_code) + + def testGetTest(self): + response = self.client.get('/include/test/') + self.assertEquals(302, response.status_code) + + def testGetDeeplyNested(self): + response = self.client.get('/include/included/deeply_nested/') + self.assertEquals(302, response.status_code) diff --git a/src/decorator_include/tests/included.py b/src/decorator_include/tests/included.py new file mode 100644 index 0000000..a7f00fd --- /dev/null +++ b/src/decorator_include/tests/included.py @@ -0,0 +1,10 @@ +from django.conf.urls.defaults import * +from django.http import HttpResponse + +def testify(request): + return HttpResponse('testify!') + +urlpatterns = patterns('', + url(r'^included/', include('decorator_include.tests.included2')), + url(r'^test/$', testify, name='testify'), +) diff --git a/src/decorator_include/tests/included2.py b/src/decorator_include/tests/included2.py new file mode 100644 index 0000000..206b815 --- /dev/null +++ b/src/decorator_include/tests/included2.py @@ -0,0 +1,9 @@ +from django.conf.urls.defaults import * +from django.http import HttpResponse + +def deeply_nested(request): + return HttpResponse('deeply nested!') + +urlpatterns = patterns('', + url(r'^deeply_nested/$', deeply_nested, name='deeply_nested'), +) diff --git a/src/decorator_include/tests/urls.py b/src/decorator_include/tests/urls.py new file mode 100644 index 0000000..99a52c9 --- /dev/null +++ b/src/decorator_include/tests/urls.py @@ -0,0 +1,13 @@ +from django.conf.urls.defaults import * +from django.http import HttpResponse +from django.contrib.auth.decorators import login_required + +from decorator_include import decorator_include + +def index(request): + return HttpResponse('Index!') + +urlpatterns = patterns('', + url(r'^$', index, name='index'), + url(r'^include/', decorator_include(login_required, 'decorator_include.tests.included')), +) diff --git a/testproject/__init__.py b/testproject/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/testproject/settings.py b/testproject/settings.py new file mode 100644 index 0000000..55e8856 --- /dev/null +++ b/testproject/settings.py @@ -0,0 +1,24 @@ +import os.path + +DEBUG = True +TEMPLATE_DEBUG = DEBUG + +BASE_DIR = os.path.dirname(__file__) + +def absolute_path(path): + return os.path.normpath(os.path.join(BASE_DIR, path)) + +SITE_ID = 1 +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': absolute_path('database.sqlite3'), + } +} + +INSTALLED_APPS = ( + 'django.contrib.sites', + 'decorator_include', +) + +ROOT_URLCONF = 'decorator_include.tests.urls'