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