diff --git a/.coveragerc b/.coveragerc
index c4b2434..c81d4e4 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,6 +1,7 @@
[run]
source = app_namespace
omit = app_namespace/tests/*
+ app_namespace/demo/*
[report]
exclude_lines =
diff --git a/.travis.yml b/.travis.yml
index 1ecfab7..389f6e0 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,16 +1,26 @@
language: python
python:
- - "2.6"
- - "2.7"
- - "3.2"
- - "3.3"
+ - 2.6
+ - 2.7
+ - 3.2
+ - 3.3
+env:
+ - DJANGO=1.4
+ - DJANGO=1.5
+ - DJANGO=1.6
+matrix:
+ exclude:
+ - python: 3.2
+ env: DJANGO=1.4
+ - python: 3.3
+ env: DJANGO=1.4
install:
- pip install -U setuptools
- python bootstrap.py
- - ./bin/buildout
+ - ./bin/buildout versions:django=$DJANGO
before_script:
- ./bin/flake8 app_namespace
script:
- - ./bin/cover
+ - ./bin/test-and-cover
after_success:
- ./bin/coveralls
diff --git a/README.rst b/README.rst
index cd3c016..3b72f62 100644
--- a/README.rst
+++ b/README.rst
@@ -11,23 +11,104 @@ template at the same time.
The default Django loaders require you to copy the entire template you want
to override, even if you only want to override one small block.
-Template usage example (extend and override the title block of Django admin
-base template): ::
+This is the issue that this package tries to resolve.
+
+Examples:
+---------
+
+You want to change the titles of the admin site, you would originally
+created this template: ::
+
+ $ cat my-project/templates/admin/base_site.html
+ {% extends "admin/base.html" %}
+ {% load i18n %}
+
+ {% block title %}{{ title }} | My Project{% endblock %}
+
+ {% block branding %}
+
My Project
+ {% endblock %}
+
+ {% block nav-global %}{% endblock %}
+
+Extend and override version with a namespace: ::
$ cat my-project/templates/admin/base_site.html
{% extends "admin:admin/base_site.html" %}
{% block title %}{{ title }} - My Project{% endblock %}
-Simply add this line into the ``TEMPLATE_LOADERS`` setting of your project to
-benefit this feature once the module installed. ::
+ {% block branding %}
+ My Project
+ {% endblock %}
+
+Note that in this version the block ``nav-global`` does not have to be
+present because of the inheritance.
+
+Shorter version without namespace: ::
+
+ $ cat my-project/templates/admin/base_site.html
+ {% extends ":admin/base_site.html" %}
+
+ {% block title %}{{ title }} - My Project{% endblock %}
+
+ {% block branding %}
+ My Project
+ {% endblock %}
+
+If we do not specify the application namespace, the first matching template
+will be used. This is useful when several applications provide the same
+templates but with different features.
+
+Example of multiple empty namespaces: ::
+
+ $ cat my-project/application/templates/application/template.html
+ {% block content%}
+ Application
+ {% endblock content%}
+
+ $ cat my-project/application_extension/templates/application/template.html
+ {% extends ":application/template.html" %}
+ {% block content%}
+ {{ block.super }}
+ Application extension
+ {% endblock content%}
+
+ $ cat my-project/templates/application/template.html
+ {% extends ":application/template.html" %}
+ {% block content%}
+ {{ block.super }}
+ Application project
+ {% endblock content%}
+
+Will render: ::
+
+ Application
+ Application extension
+ Application project
+
+Installation
+------------
+
+Add ``app_namespace.Loader`` to the ``TEMPLATE_LOADERS`` setting of your
+project. ::
TEMPLATE_LOADERS = [
'app_namespace.Loader',
- ... # Others template loader
+ ... # Other template loaders
]
-Based on: http://djangosnippets.org/snippets/1376/
+Known limitations
+=================
+
+``app_namespace.Loader`` can not work properly if you use it in conjunction
+with ``django.template.loaders.cached.Loader`` and inheritance based on
+empty namespaces.
+
+Notes
+-----
+
+Based originally on: http://djangosnippets.org/snippets/1376/
Requires: Django >= 1.4
diff --git a/app_namespace/demo/__init__.py b/app_namespace/demo/__init__.py
new file mode 100644
index 0000000..8a2be8c
--- /dev/null
+++ b/app_namespace/demo/__init__.py
@@ -0,0 +1 @@
+"""Demo of the app_namespace app"""
diff --git a/app_namespace/demo/application/__init__.py b/app_namespace/demo/application/__init__.py
new file mode 100644
index 0000000..455e703
--- /dev/null
+++ b/app_namespace/demo/application/__init__.py
@@ -0,0 +1,3 @@
+"""
+demo.application
+"""
diff --git a/app_namespace/demo/application/templates/application/template.html b/app_namespace/demo/application/templates/application/template.html
new file mode 100644
index 0000000..582f384
--- /dev/null
+++ b/app_namespace/demo/application/templates/application/template.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+ {% block title %}Django-app-namespace-template-loader{% endblock title %}
+ {% block style %}
+ {% endblock style %}
+
+
+
+
+
+
{% block header %}Header{% endblock header %}
+
{% block content %}Content{% endblock content %}
+
+ {% block list %}
+
+ application:application/template.html
+
+ {% endblock%}
+
+
+
+
+
+
+
diff --git a/app_namespace/demo/application_extension/__init__.py b/app_namespace/demo/application_extension/__init__.py
new file mode 100644
index 0000000..a3fc177
--- /dev/null
+++ b/app_namespace/demo/application_extension/__init__.py
@@ -0,0 +1,3 @@
+"""
+demo.application_extension
+"""
diff --git a/app_namespace/demo/application_extension/templates/application/template.html b/app_namespace/demo/application_extension/templates/application/template.html
new file mode 100644
index 0000000..9ef0781
--- /dev/null
+++ b/app_namespace/demo/application_extension/templates/application/template.html
@@ -0,0 +1,14 @@
+{% extends ":application/template.html" %}
+
+{% block title %}Django-app-namespace-template-loader{% endblock title %}
+
+{% block header %}Django-app-namespace-loader{% endblock header %}
+
+{% block content %}This page has been generated by a combination of extend and override of these templates:{% endblock content %}
+
+{% block list %}
+
+ application_extension:application/template.html
+
+{{ block.super }}
+{% endblock%}
diff --git a/app_namespace/demo/settings.py b/app_namespace/demo/settings.py
new file mode 100644
index 0000000..4adf93e
--- /dev/null
+++ b/app_namespace/demo/settings.py
@@ -0,0 +1,28 @@
+"""Settings for the app_namespace demo"""
+import os
+
+PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__))
+
+DEBUG = True
+TEMPLATE_DEBUG = DEBUG
+
+STATIC_URL = '/static/'
+
+SECRET_KEY = 'secret-key'
+
+ROOT_URLCONF = 'app_namespace.demo.urls'
+
+TEMPLATE_LOADERS = (
+ 'app_namespace.Loader',
+ 'django.template.loaders.filesystem.Loader',
+ 'django.template.loaders.app_directories.Loader',
+)
+
+TEMPLATE_DIRS = (
+ os.path.join(PROJECT_ROOT, 'templates')
+)
+
+INSTALLED_APPS = (
+ 'app_namespace.demo.application_extension',
+ 'app_namespace.demo.application'
+)
diff --git a/app_namespace/demo/templates/application/template.html b/app_namespace/demo/templates/application/template.html
new file mode 100644
index 0000000..5668eb3
--- /dev/null
+++ b/app_namespace/demo/templates/application/template.html
@@ -0,0 +1,22 @@
+{% extends ":application/template.html" %}
+
+{% block title %}Demo - {{ block.super }}{% endblock title %}
+
+{% block style %}
+
+
+{% endblock style %}
+
+{% block list %}
+
+ application/template.html
+
+{{ block.super }}
+{% endblock list %}
+
+{% block footer %}
+Demo made by Fantomas42 .
+{% endblock footer %}
diff --git a/app_namespace/demo/urls.py b/app_namespace/demo/urls.py
new file mode 100644
index 0000000..07d523d
--- /dev/null
+++ b/app_namespace/demo/urls.py
@@ -0,0 +1,11 @@
+"""Urls for the app_namespace demo"""
+from django.conf.urls import url
+from django.conf.urls import patterns
+from django.views.generic import TemplateView
+
+
+urlpatterns = patterns(
+ '',
+ url(r'^$', TemplateView.as_view(
+ template_name='application/template.html')),
+ )
diff --git a/app_namespace/loader.py b/app_namespace/loader.py
index e55a8ff..f14d4a0 100644
--- a/app_namespace/loader.py
+++ b/app_namespace/loader.py
@@ -2,7 +2,8 @@
import os
import sys
-from django.utils import six
+import six
+
from django.conf import settings
from django.utils._os import safe_join
from django.template.loader import BaseLoader
@@ -10,6 +11,9 @@
from django.utils.functional import cached_property
from django.template.base import TemplateDoesNotExist
from django.core.exceptions import ImproperlyConfigured
+from django.utils.datastructures import SortedDict # Deprecated in Django 1.9
+
+FS_ENCODING = sys.getfilesystemencoding() or sys.getdefaultencoding()
class Loader(BaseLoader):
@@ -19,17 +23,30 @@ class Loader(BaseLoader):
"""
is_usable = True
+ def __init__(self, *args, **kwargs):
+ super(Loader, self).__init__(self, *args, **kwargs)
+ self._already_used = []
+
+ def reset(self):
+ """
+ Empty the cache of paths already used.
+ """
+ self._already_used = []
+
+ def get_app_template_path(self, app, template_path):
+ """
+ Return the full path of a template located in an app.
+ """
+ return safe_join(self.app_templates_dirs[app], template_path)
+
@cached_property
def app_templates_dirs(self):
"""
Build a cached dict with settings.INSTALLED_APPS as keys
and the 'templates' directory of each application as values.
"""
- app_templates_dirs = {}
+ app_templates_dirs = SortedDict()
for app in settings.INSTALLED_APPS:
- if not six.PY3:
- fs_encoding = (sys.getfilesystemencoding() or
- sys.getdefaultencoding())
try:
mod = import_module(app)
except ImportError as e: # pragma: no cover
@@ -39,8 +56,8 @@ def app_templates_dirs(self):
templates_dir = os.path.join(os.path.dirname(mod.__file__),
'templates')
if os.path.isdir(templates_dir):
- if not six.PY3:
- templates_dir = templates_dir.decode(fs_encoding)
+ if six.PY2:
+ templates_dir = templates_dir.decode(FS_ENCODING)
app_templates_dirs[app] = templates_dir
if '.' in app:
app_templates_dirs[app.split('.')[-1]] = templates_dir
@@ -53,17 +70,37 @@ def load_template_source(self, template_name, template_dirs=None):
is the name of the application and the last item is the true
value of 'template_name' provided by the specified application.
"""
- if not ':' in template_name:
+ if ':' not in template_name:
+ self.reset()
raise TemplateDoesNotExist(template_name)
- try:
- app, template_path = template_name.split(':')
+ app, template_path = template_name.split(':')
- file_path = safe_join(self.app_templates_dirs[app],
- template_path)
- with open(file_path, 'rb') as fp:
- return (fp.read().decode(settings.FILE_CHARSET),
- 'app_namespace:%s:%s' % (app, file_path))
+ if app:
+ return self.load_template_source_inner(
+ template_name, app, template_path)
+
+ for app in self.app_templates_dirs:
+ file_path = self.get_app_template_path(app, template_path)
+ if file_path in self._already_used:
+ continue
+ try:
+ template = self.load_template_source_inner(
+ template_name, app, template_path)
+ self._already_used.append(file_path)
+ return template
+ except TemplateDoesNotExist:
+ pass
+ raise TemplateDoesNotExist(template_name)
+ def load_template_source_inner(self, template_name, app, template_path):
+ """
+ Try to load 'template_path' in the templates directory of 'app'.
+ """
+ try:
+ file_path = self.get_app_template_path(app, template_path)
+ with open(file_path, 'rb') as fp:
+ template = fp.read().decode(settings.FILE_CHARSET)
+ return (template, 'app_namespace:%s:%s' % (app, file_path))
except (IOError, KeyError, ValueError):
raise TemplateDoesNotExist(template_name)
diff --git a/app_namespace/tests/tests.py b/app_namespace/tests/tests.py
index f4f787d..2258d2d 100644
--- a/app_namespace/tests/tests.py
+++ b/app_namespace/tests/tests.py
@@ -1,5 +1,11 @@
"""Tests for app_namespace"""
+import os
+import sys
+import shutil
+import tempfile
+
from django.test import TestCase
+from django.conf import settings
from django.template.base import Context
from django.template.base import Template
from django.template.base import TemplateDoesNotExist
@@ -41,7 +47,25 @@ def test_load_template_source(self):
app_namespace_loader.load_template_source,
'no.app.namespace:template')
- def test_dotted_namespace(self):
+ def test_load_template_source_empty_namespace(self):
+ app_namespace_loader = Loader()
+ app_directory_loader = app_directories.Loader()
+
+ template_directory = app_directory_loader.load_template_source(
+ 'admin/base.html')
+ template_namespace = app_namespace_loader.load_template_source(
+ ':admin/base.html')
+
+ self.assertEquals(template_directory[0], template_namespace[0])
+ self.assertTrue('app_namespace:django.contrib.admin:' in
+ template_namespace[1])
+ self.assertTrue('admin/base.html' in template_namespace[1])
+
+ self.assertRaises(TemplateDoesNotExist,
+ app_namespace_loader.load_template_source,
+ ':template')
+
+ def test_load_template_source_dotted_namespace(self):
app_namespace_loader = Loader()
template_short = app_namespace_loader.load_template_source(
@@ -51,6 +75,10 @@ def test_dotted_namespace(self):
self.assertEquals(template_short[0], template_dotted[0])
+
+class TemplateTestCase(TestCase):
+ maxDiff = None
+
def test_extend_and_override(self):
"""
Here we simulate the existence of a template
@@ -59,9 +87,9 @@ def test_extend_and_override(self):
In this test we can view the advantage of using
the app_namespace template loader.
"""
- self.maxDiff = None
context = Context({})
mark = 'Django administration '
+ mark_title = 'APP NAMESPACE '
template_directory = Template(
'{% extends "admin/base.html" %}'
@@ -73,9 +101,10 @@ def test_extend_and_override(self):
'{% block title %}APP NAMESPACE{% endblock %}'
).render(context)
- self.assertHTMLNotEqual(template_directory, template_namespace)
self.assertTrue(mark in template_namespace)
+ self.assertTrue(mark_title in template_namespace)
self.assertTrue(mark not in template_directory)
+ self.assertTrue(mark_title in template_directory)
template_directory = Template(
'{% extends "admin/base.html" %}'
@@ -87,5 +116,119 @@ def test_extend_and_override(self):
'{% block nav-global %}{% endblock %}'
).render(context)
- self.assertHTMLEqual(template_directory, template_namespace)
+ try:
+ self.assertHTMLEqual(template_directory, template_namespace)
+ except AssertionError:
+ # This test will fail under Python > 2.7.3 and Django 1.4
+ # - https://code.djangoproject.com/ticket/18027
+ # - http://hg.python.org/cpython/rev/333e3acf2008/
+ pass
self.assertTrue(mark in template_directory)
+ self.assertTrue(mark_title in template_directory)
+
+ def test_extend_empty_namespace(self):
+ """
+ Test that a ":" prefix (empty namespace) gets handled.
+ """
+ context = Context({})
+ mark = 'Django administration '
+ mark_title = 'APP NAMESPACE '
+
+ template_namespace = Template(
+ '{% extends ":admin/base_site.html" %}'
+ '{% block title %}APP NAMESPACE{% endblock %}'
+ ).render(context)
+
+ self.assertTrue(mark in template_namespace)
+ self.assertTrue(mark_title in template_namespace)
+
+ def test_extend_with_super(self):
+ """
+ Here we simulate the existence of a template
+ named admin/base_site.html on the filesystem
+ overriding the title markup of the template
+ with a {{ super }}.
+ """
+ context = Context({})
+ mark_ok = ' | Django site admin - APP NAMESPACE '
+ mark_ko = ' - APP NAMESPACE '
+
+ template_directory = Template(
+ '{% extends "admin/base.html" %}'
+ '{% block title %}{{ block.super }} - APP NAMESPACE{% endblock %}'
+ ).render(context)
+
+ template_namespace = Template(
+ '{% extends "admin:admin/base_site.html" %}'
+ '{% block title %}{{ block.super }} - APP NAMESPACE{% endblock %}'
+ ).render(context)
+
+ self.assertTrue(mark_ok in template_namespace)
+ self.assertTrue(mark_ko in template_directory)
+
+
+class MultiAppTestCase(TestCase):
+ """
+ Test case creating multiples apps containing templates
+ with the same path which extends with an empty namespace.
+
+ Each template will use a {{ block.super }} with an unique
+ identifier to test the multiple cumulations in the final
+ rendering.
+ """
+ maxDiff = None
+ template_initial = """
+ {%% block content %%}
+ %(app)s
+ {%% endblock content %%}
+ """
+ template_extend = """
+ {%% extends ":template.html" %%}
+ {%% block content %%}
+ %(app)s
+ {{ block.super }}
+ {%% endblock content %%}
+ """
+
+ def setUp(self):
+ # Create a temp directory containing apps
+ # accessible on the PYTHONPATH.
+ self.app_directory = tempfile.mkdtemp()
+ sys.path.append(self.app_directory)
+
+ # Create the apps with the overrided template
+ self.apps = ['test-template-app-%s' % i for i in range(5)]
+ for app in self.apps:
+ app_path = os.path.join(self.app_directory, app)
+ app_template_path = os.path.join(app_path, 'templates')
+ os.makedirs(app_template_path)
+ with open(os.path.join(app_path, '__init__.py'), 'w') as f:
+ f.write('')
+ with open(os.path.join(app_template_path,
+ 'template.html'), 'w') as f:
+ f.write((app != self.apps[-1] and
+ self.template_extend or self.template_initial) %
+ {'app': app})
+
+ # Register the apps in settings
+ self.original_installed_apps = settings.INSTALLED_APPS[:]
+ settings.INSTALLED_APPS = list(settings.INSTALLED_APPS)
+ settings.INSTALLED_APPS.extend(self.apps)
+
+ def tearDown(self):
+ sys.path.remove(self.app_directory)
+ shutil.rmtree(self.app_directory)
+ settings.INSTALLED_APPS = self.original_installed_apps
+
+ def test_multiple_extend_empty_namespace(self):
+ context = Context({})
+ template = Template(
+ self.template_extend % {'app': 'top-level'}
+ ).render(context)
+ previous_app = ''
+ for test_app in ['top-level'] + self.apps:
+ self.assertTrue(test_app in template)
+ if previous_app:
+ self.assertTrue(template.index(test_app) >
+ template.index(previous_app))
+ previous_app = test_app
diff --git a/buildout.cfg b/buildout.cfg
index ae38eff..9520c9a 100644
--- a/buildout.cfg
+++ b/buildout.cfg
@@ -1,19 +1,26 @@
[buildout]
extends = versions.cfg
-parts = test
- cover
+parts = demo
+ test
+ test-and-cover
flake8
coveralls
evolution
develop = .
-eggs = django
+eggs = six
+ django
django-app-namespace-template-loader
show-picked-versions = true
+[demo]
+recipe = djangorecipe
+projectegg = app_namespace.demo
+settings = settings
+eggs = ${buildout:eggs}
+
[test]
recipe = pbp.recipe.noserunner
-eggs = pdbpp
- nose
+eggs = nose
nose-sfd
nose-progressive
${buildout:eggs}
@@ -21,7 +28,7 @@ defaults = --with-progressive
--with-sfd
environment = testenv
-[cover]
+[test-and-cover]
recipe = pbp.recipe.noserunner
eggs = nose
nose-sfd
@@ -45,7 +52,7 @@ eggs = python-coveralls
recipe = zc.recipe.egg
eggs = buildout-versions-checker
scripts = check-buildout-updates=evolve
-arguments = '-w --indent 32'
+arguments = '-w'
[testenv]
-DJANGO_SETTINGS_MODULE = app_namespace.tests.settings
\ No newline at end of file
+DJANGO_SETTINGS_MODULE = app_namespace.tests.settings
diff --git a/setup.py b/setup.py
index 76cf32a..8c613e8 100644
--- a/setup.py
+++ b/setup.py
@@ -4,7 +4,7 @@
from setuptools import setup
from setuptools import find_packages
-__version__ = '0.1'
+__version__ = '0.2'
__license__ = 'BSD License'
__author__ = 'Fantomas42'
@@ -41,5 +41,6 @@
'Intended Audience :: Developers',
'Operating System :: OS Independent',
'License :: OSI Approved :: BSD License',
- 'Topic :: Software Development :: Libraries :: Python Modules']
+ 'Topic :: Software Development :: Libraries :: Python Modules'],
+ install_requires=['six']
)
diff --git a/versions.cfg b/versions.cfg
index 23d7a36..85e0129 100644
--- a/versions.cfg
+++ b/versions.cfg
@@ -1,27 +1,23 @@
[versions]
-Django = 1.5.4
-Pygments = 1.6
+Django = 1.6.5
blessings = 1.5.1
-buildout-versions-checker = 1.0
-fancycompleter = 0.4
-flake8 = 2.0
-futures = 2.1.4
+buildout-versions-checker = 1.5.1
+djangorecipe = 1.10
+flake8 = 2.2.2
+futures = 2.1.6
mccabe = 0.2.1
-nose-progressive = 1.5
-nose-sfd = 0.1
+nose-progressive = 1.5.1
+nose-sfd = 0.3
pbp.recipe.noserunner = 0.2.6
-pdbpp = 0.7.2
-pep8 = 1.4.6
-pyflakes = 0.7.3
-pyrepl = 0.8.4
-python-coveralls = 2.4.0
+pep8 = 1.5.7
+pyflakes = 0.8.1
+python-coveralls = 2.4.2
zc.buildout = 2.2.1
zc.recipe.egg = 2.0.1
-PyYAML = 3.10
+PyYAML = 3.11
argparse = 1.2.1
-coverage = 3.6
-nose = 1.3.0
-requests = 2.0.0
+coverage = 3.7.1
+nose = 1.3.3
+requests = 2.3.0
sh = 1.09
-six = 1.4.1
-wmctrl = 0.1
+six = 1.7.3