├── .coveragerc ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── app_namespace ├── __init__.py ├── demo │ ├── __init__.py │ ├── application │ │ ├── __init__.py │ │ └── templates │ │ │ └── application │ │ │ └── template.html │ ├── application_appconfig │ │ ├── __init__.py │ │ ├── apps.py │ │ └── templates │ │ │ └── application │ │ │ └── template.html │ ├── application_extension │ │ ├── __init__.py │ │ └── templates │ │ │ └── application │ │ │ └── template.html │ ├── settings.py │ ├── templates │ │ └── application │ │ │ └── template.html │ └── urls.py ├── loader.py └── tests │ ├── __init__.py │ ├── settings.py │ ├── template.html │ ├── tests.py │ └── urls.py ├── bootstrap.py ├── buildout.cfg ├── setup.cfg ├── setup.py └── versions.cfg /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = app_namespace 3 | omit = app_namespace/tests/* 4 | app_namespace/demo/* 5 | 6 | [report] 7 | exclude_lines = 8 | pragma: no cover 9 | raise 10 | show_missing = True 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - 2.7 5 | - 3.4 6 | - 3.5 7 | env: 8 | - DJANGO=1.8 9 | - DJANGO=1.9 10 | - DJANGO=1.10 11 | install: 12 | - pip install -U setuptools 13 | - python bootstrap.py 14 | - ./bin/buildout versions:django=$DJANGO 15 | before_script: 16 | - ./bin/flake8 app_namespace 17 | script: 18 | - ./bin/test-and-cover 19 | after_success: 20 | - ./bin/coveralls 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009-2014, Julien Fache 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 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following 12 | disclaimer in the documentation and/or other materials provided 13 | with the distribution. 14 | * Neither the name of the author nor the names of other 15 | contributors may be used to endorse or promote products derived 16 | from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include versions.cfg 4 | include buildout.cfg 5 | include bootstrap.py 6 | recursive-include app_namespace/demo * 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ==================================== 2 | Django App Namespace Template Loader 3 | ==================================== 4 | 5 | |travis-develop| |coverage-develop| 6 | 7 | Provides a template loader that allows you to load a template from a 8 | specific application. This allows you to both **extend** and **override** a 9 | template at the same time. 10 | 11 | The default Django loaders require you to copy the entire template you want 12 | to override, even if you only want to override one small block. 13 | 14 | This is the issue that this package tries to resolve. 15 | 16 | Examples: 17 | --------- 18 | 19 | You want to change the titles of the admin site, you would originally 20 | created this template: :: 21 | 22 | $ cat my-project/templates/admin/base_site.html 23 | {% extends "admin/base.html" %} 24 | {% load i18n %} 25 | 26 | {% block title %}{{ title }} | My Project{% endblock %} 27 | 28 | {% block branding %} 29 |

My Project

30 | {% endblock %} 31 | 32 | {% block nav-global %}{% endblock %} 33 | 34 | Extend and override version with a namespace: :: 35 | 36 | $ cat my-project/templates/admin/base_site.html 37 | {% extends "admin:admin/base_site.html" %} 38 | 39 | {% block title %}{{ title }} - My Project{% endblock %} 40 | 41 | {% block branding %} 42 |

My Project

43 | {% endblock %} 44 | 45 | Note that in this version the block ``nav-global`` does not have to be 46 | present because of the inheritance. 47 | 48 | Shorter version without namespace: :: 49 | 50 | $ cat my-project/templates/admin/base_site.html 51 | {% extends ":admin/base_site.html" %} 52 | 53 | {% block title %}{{ title }} - My Project{% endblock %} 54 | 55 | {% block branding %} 56 |

My Project

57 | {% endblock %} 58 | 59 | If we do not specify the application namespace, the first matching template 60 | will be used. This is useful when several applications provide the same 61 | templates but with different features. 62 | 63 | Example of multiple empty namespaces: :: 64 | 65 | $ cat my-project/application/templates/application/template.html 66 | {% block content %} 67 |

Application

68 | {% endblock content %} 69 | 70 | $ cat my-project/application_extension/templates/application/template.html 71 | {% extends ":application/template.html" %} 72 | {% block content %} 73 | {{ block.super }} 74 |

Application extension

75 | {% endblock content %} 76 | 77 | $ cat my-project/templates/application/template.html 78 | {% extends ":application/template.html" %} 79 | {% block content %} 80 | {{ block.super }} 81 |

Application project

82 | {% endblock content %} 83 | 84 | Will render: :: 85 | 86 |

Application

87 |

Application extension

88 |

Application project

89 | 90 | Installation 91 | ------------ 92 | 93 | First of all install ``django-app-namespace-template-loader`` with your 94 | favorite package manager. Example : :: 95 | 96 | $ pip install django-app-namespace-template-loader 97 | 98 | Once installed, add ``app_namespace.Loader`` to the ``TEMPLATE_LOADERS`` 99 | setting of your project. :: 100 | 101 | TEMPLATE_LOADERS = [ 102 | 'app_namespace.Loader', 103 | ... # Other template loaders 104 | ] 105 | 106 | With Django >= 1.8 ``app_namespace.Loader`` should be added to the 107 | ``'loaders'`` section in the OPTIONS dict of the ``DjangoTemplates`` backend 108 | instead. :: 109 | 110 | TEMPLATES = [ 111 | { 112 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 113 | 'OPTIONS': { 114 | 'loaders': [ 115 | 'app_namespace.Loader', 116 | 'django.template.loaders.filesystem.Loader', 117 | 'django.template.loaders.app_directories.Loader', 118 | ], 119 | }, 120 | }, 121 | ] 122 | 123 | Note: With Django 1.8, ``app_namespace.Loader`` should be first in the list 124 | of loaders. 125 | 126 | Known limitations 127 | ================= 128 | 129 | ``app_namespace.Loader`` can not work properly if you use it in conjunction 130 | with ``django.template.loaders.cached.Loader`` and inheritance based on 131 | empty namespaces. 132 | 133 | Notes 134 | ----- 135 | 136 | Based originally on: http://djangosnippets.org/snippets/1376/ 137 | 138 | Requires: Django >= 1.8 139 | 140 | Tested with Python 2.7, 3.3, 3.4. 141 | 142 | If you want to use this application for previous versions of Django, use the 143 | version 0.3.1 of the package. 144 | 145 | If you want to use this application with Python 2.6, use the version 0.2 of 146 | the package. 147 | 148 | .. |travis-develop| image:: https://travis-ci.org/Fantomas42/django-app-namespace-template-loader.png?branch=develop 149 | :alt: Build Status - develop branch 150 | :target: http://travis-ci.org/Fantomas42/django-app-namespace-template-loader 151 | .. |coverage-develop| image:: https://coveralls.io/repos/Fantomas42/django-app-namespace-template-loader/badge.png?branch=develop 152 | :alt: Coverage of the code 153 | :target: https://coveralls.io/r/Fantomas42/django-app-namespace-template-loader 154 | -------------------------------------------------------------------------------- /app_namespace/__init__.py: -------------------------------------------------------------------------------- 1 | """App namespace template loader""" 2 | from app_namespace.loader import Loader 3 | 4 | __all__ = [Loader.__name__] 5 | -------------------------------------------------------------------------------- /app_namespace/demo/__init__.py: -------------------------------------------------------------------------------- 1 | """Demo of the app_namespace app""" 2 | -------------------------------------------------------------------------------- /app_namespace/demo/application/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | demo.application 3 | """ 4 | -------------------------------------------------------------------------------- /app_namespace/demo/application/templates/application/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {% block title %}Django-app-namespace-template-loader{% endblock title %} 8 | {% block style %} 9 | {% endblock style %} 10 | 11 | 12 |
13 |
14 |
15 |

{% block header %}Header{% endblock header %}

16 |

{% block content %}Content{% endblock content %}

17 |
    18 | {% block list %} 19 |
  • 20 | application:application/template.html 21 |
  • 22 | {% endblock%} 23 |
24 | 29 |
30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /app_namespace/demo/application_appconfig/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | demo.application_extension 3 | """ 4 | -------------------------------------------------------------------------------- /app_namespace/demo/application_appconfig/apps.py: -------------------------------------------------------------------------------- 1 | """Apps for application_appconfig""" 2 | from django.apps import AppConfig 3 | 4 | 5 | class ApplicationConfig(AppConfig): 6 | name = __name__ 7 | label = 'appconfig' 8 | -------------------------------------------------------------------------------- /app_namespace/demo/application_appconfig/templates/application/template.html: -------------------------------------------------------------------------------- 1 | {% extends ":application/template.html" %} 2 | 3 | {% block list %} 4 |
  • 5 | application_appconfig:application/template.html 6 |
  • 7 | {{ block.super }} 8 | {% endblock%} 9 | -------------------------------------------------------------------------------- /app_namespace/demo/application_extension/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | demo.application_extension 3 | """ 4 | -------------------------------------------------------------------------------- /app_namespace/demo/application_extension/templates/application/template.html: -------------------------------------------------------------------------------- 1 | {% extends ":application/template.html" %} 2 | 3 | {% block title %}Django-app-namespace-template-loader{% endblock title %} 4 | 5 | {% block header %}Django-app-namespace-loader{% endblock header %} 6 | 7 | {% block content %}This page has been generated by a combination of extend and override of these templates:{% endblock content %} 8 | 9 | {% block list %} 10 |
  • 11 | application_extension:application/template.html 12 |
  • 13 | {{ block.super }} 14 | {% endblock%} 15 | -------------------------------------------------------------------------------- /app_namespace/demo/settings.py: -------------------------------------------------------------------------------- 1 | """Settings for the app_namespace demo""" 2 | import os 3 | 4 | PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) 5 | 6 | DEBUG = True 7 | 8 | STATIC_URL = '/static/' 9 | 10 | SECRET_KEY = 'secret-key' 11 | 12 | ROOT_URLCONF = 'app_namespace.demo.urls' 13 | 14 | TEMPLATES = [ 15 | { 16 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 17 | 'DIRS': [ 18 | os.path.join(PROJECT_ROOT, 'templates'), 19 | ], 20 | 'OPTIONS': { 21 | 'debug': DEBUG, 22 | 'loaders': ('app_namespace.Loader', 23 | 'django.template.loaders.filesystem.Loader', 24 | 'django.template.loaders.app_directories.Loader') 25 | } 26 | } 27 | ] 28 | 29 | INSTALLED_APPS = ( 30 | 'app_namespace.demo.application_extension', 31 | 'app_namespace.demo.application_appconfig.apps.ApplicationConfig', 32 | 'app_namespace.demo.application', 33 | ) 34 | 35 | SILENCED_SYSTEM_CHECKS = ['1_7.W001', '1_8.W001'] 36 | -------------------------------------------------------------------------------- /app_namespace/demo/templates/application/template.html: -------------------------------------------------------------------------------- 1 | {% extends ":application/template.html" %} 2 | 3 | {% block title %}Demo - {{ block.super }}{% endblock title %} 4 | 5 | {% block style %} 6 | 7 | 11 | {% endblock style %} 12 | 13 | {% block list %} 14 |
  • 15 | application/template.html 16 |
  • 17 | {{ block.super }} 18 | {% endblock list %} 19 | 20 | {% block footer %} 21 | Demo made by Fantomas42. 22 | {% endblock footer %} 23 | -------------------------------------------------------------------------------- /app_namespace/demo/urls.py: -------------------------------------------------------------------------------- 1 | """Urls for the app_namespace demo""" 2 | from django.conf.urls import url 3 | from django.views.generic import TemplateView 4 | 5 | 6 | urlpatterns = [ 7 | url(r'^$', TemplateView.as_view( 8 | template_name='application/template.html')), 9 | ] 10 | -------------------------------------------------------------------------------- /app_namespace/loader.py: -------------------------------------------------------------------------------- 1 | """Template loader for app-namespace""" 2 | import errno 3 | import io 4 | import os 5 | from collections import OrderedDict 6 | 7 | import django 8 | from django.apps import apps 9 | try: 10 | from django.template import Origin 11 | except ImportError: # pragma: no cover 12 | class Origin(object): 13 | def __init__(self, **kwargs): 14 | for k, v in kwargs.items(): 15 | setattr(self, k, v) 16 | from django.template import TemplateDoesNotExist 17 | from django.template.loaders.base import Loader as BaseLoader 18 | from django.utils._os import safe_join 19 | from django.utils._os import upath 20 | from django.utils.functional import cached_property 21 | 22 | 23 | class NamespaceOrigin(Origin): 24 | 25 | def __init__(self, app_name, *args, **kwargs): 26 | self.app_name = app_name 27 | super(NamespaceOrigin, self).__init__(*args, **kwargs) 28 | 29 | 30 | class Loader(BaseLoader): 31 | """ 32 | App namespace loader for allowing you to both extend and override 33 | a template provided by an app at the same time. 34 | """ 35 | is_usable = True 36 | 37 | def __init__(self, *args, **kwargs): 38 | super(Loader, self).__init__(*args, **kwargs) 39 | self._already_used = [] 40 | 41 | def reset(self, mandatory_on_django_18): 42 | """ 43 | Empty the cache of paths already used. 44 | """ 45 | if django.VERSION[1] == 8: 46 | if not mandatory_on_django_18: 47 | return 48 | self._already_used = [] 49 | 50 | def get_app_template_path(self, app, template_name): 51 | """ 52 | Return the full path of a template name located in an app. 53 | """ 54 | return safe_join(self.app_templates_dirs[app], template_name) 55 | 56 | @cached_property 57 | def app_templates_dirs(self): 58 | """ 59 | Build a cached dict with settings.INSTALLED_APPS as keys 60 | and the 'templates' directory of each application as values. 61 | """ 62 | app_templates_dirs = OrderedDict() 63 | for app_config in apps.get_app_configs(): 64 | templates_dir = os.path.join( 65 | getattr(app_config, 'path', '/'), 'templates') 66 | if os.path.isdir(templates_dir): 67 | templates_dir = upath(templates_dir) 68 | app_templates_dirs[app_config.name] = templates_dir 69 | app_templates_dirs[app_config.label] = templates_dir 70 | return app_templates_dirs 71 | 72 | def get_contents(self, origin): 73 | """ 74 | Try to load the origin. 75 | """ 76 | try: 77 | path = self.get_app_template_path( 78 | origin.app_name, origin.template_name) 79 | with io.open(path, encoding=self.engine.file_charset) as fp: 80 | return fp.read() 81 | except KeyError: 82 | raise TemplateDoesNotExist(origin) 83 | except IOError as error: 84 | if error.errno == errno.ENOENT: 85 | raise TemplateDoesNotExist(origin) 86 | raise 87 | 88 | def get_template_sources(self, template_name): 89 | """ 90 | Build a list of Origin to load 'template_name' splitted with ':'. 91 | The first item is the name of the application and the last item 92 | is the true value of 'template_name' provided by the specified 93 | application. 94 | """ 95 | if ':' not in template_name: 96 | self.reset(True) 97 | return 98 | 99 | app, template_path = template_name.split(':') 100 | if app: 101 | yield NamespaceOrigin( 102 | app_name=app, 103 | name='app_namespace:%s:%s' % (app, template_name), 104 | template_name=template_path, 105 | loader=self) 106 | return 107 | 108 | self.reset(False) 109 | for app in self.app_templates_dirs: 110 | file_path = self.get_app_template_path(app, template_path) 111 | if file_path in self._already_used: 112 | continue 113 | self._already_used.append(file_path) 114 | yield NamespaceOrigin( 115 | app_name=app, 116 | name='app_namespace:%s:%s' % (app, template_name), 117 | template_name=template_path, 118 | loader=self) 119 | 120 | def load_template_source(self, *ka): 121 | """ 122 | Backward compatible method for Django < 2.0. 123 | """ 124 | template_name = ka[0] 125 | for origin in self.get_template_sources(template_name): 126 | try: 127 | return self.get_contents(origin), origin.name 128 | except TemplateDoesNotExist: 129 | pass 130 | raise TemplateDoesNotExist(template_name) 131 | -------------------------------------------------------------------------------- /app_namespace/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for app_namespace""" 2 | -------------------------------------------------------------------------------- /app_namespace/tests/settings.py: -------------------------------------------------------------------------------- 1 | """Settings for testing app_namespace""" 2 | DATABASES = {'default': {'NAME': 'app_namespace.db', 3 | 'ENGINE': 'django.db.backends.sqlite3'}} 4 | 5 | SECRET_KEY = 'secret-key' 6 | 7 | TEMPLATES = [ 8 | { 9 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 10 | 'OPTIONS': { 11 | 'loaders': ('app_namespace.Loader', 12 | 'django.template.loaders.app_directories.Loader') 13 | } 14 | } 15 | ] 16 | 17 | ROOT_URLCONF = 'app_namespace.tests.urls' 18 | 19 | INSTALLED_APPS = ('django.contrib.auth', 20 | 'django.contrib.admin', 21 | 'django.contrib.contenttypes') 22 | 23 | SILENCED_SYSTEM_CHECKS = ['1_7.W001'] 24 | -------------------------------------------------------------------------------- /app_namespace/tests/template.html: -------------------------------------------------------------------------------- 1 | {% extends ":admin/base.html" %} 2 | -------------------------------------------------------------------------------- /app_namespace/tests/tests.py: -------------------------------------------------------------------------------- 1 | """Tests for app_namespace""" 2 | import os 3 | import shutil 4 | import sys 5 | import tempfile 6 | 7 | from app_namespace import Loader 8 | 9 | import django 10 | from django.core.urlresolvers import reverse 11 | from django.template import TemplateDoesNotExist 12 | from django.template.base import Context 13 | from django.template.base import Template 14 | from django.template.engine import Engine 15 | from django.template.loaders import app_directories 16 | from django.test import TestCase 17 | from django.test.utils import override_settings 18 | 19 | 20 | class LoaderTestCase(TestCase): 21 | 22 | def test_load_template(self): 23 | libraries = { 24 | 'i18n': 'django.templatetags.i18n', 25 | 'static': 'django.templatetags.static', 26 | 'admin_static': 'django.contrib.admin.templatetags.admin_static'} 27 | 28 | def build_engine(): 29 | try: 30 | return Engine(libraries=libraries) 31 | except TypeError: 32 | return Engine() 33 | 34 | app_namespace_loader = Loader(build_engine()) 35 | app_directory_loader = app_directories.Loader(build_engine()) 36 | 37 | template_directory = app_directory_loader.load_template( 38 | 'admin/base.html')[0] 39 | template_namespace = app_namespace_loader.load_template( 40 | 'admin:admin/base.html')[0] 41 | context = Context({}) 42 | self.assertEquals(template_directory.render(context), 43 | template_namespace.render(context)) 44 | 45 | def test_load_template_source(self): 46 | app_namespace_loader = Loader(Engine()) 47 | app_directory_loader = app_directories.Loader(Engine()) 48 | 49 | template_directory = app_directory_loader.load_template_source( 50 | 'admin/base.html') 51 | template_namespace = app_namespace_loader.load_template_source( 52 | 'admin:admin/base.html') 53 | self.assertEquals(template_directory[0], template_namespace[0]) 54 | self.assertTrue('app_namespace:admin:' in template_namespace[1]) 55 | self.assertTrue('admin/base.html' in template_namespace[1]) 56 | 57 | self.assertRaises(TemplateDoesNotExist, 58 | app_namespace_loader.load_template_source, 59 | 'no-namespace-template') 60 | self.assertRaises(TemplateDoesNotExist, 61 | app_namespace_loader.load_template_source, 62 | 'no.app.namespace:template') 63 | 64 | def test_load_template_source_empty_namespace(self): 65 | app_namespace_loader = Loader(Engine()) 66 | app_directory_loader = app_directories.Loader(Engine()) 67 | 68 | template_directory = app_directory_loader.load_template_source( 69 | 'admin/base.html') 70 | template_namespace = app_namespace_loader.load_template_source( 71 | ':admin/base.html') 72 | 73 | self.assertEquals(template_directory[0], template_namespace[0]) 74 | self.assertTrue('app_namespace:django.contrib.admin:' in 75 | template_namespace[1]) 76 | self.assertTrue('admin/base.html' in template_namespace[1]) 77 | 78 | self.assertRaises(TemplateDoesNotExist, 79 | app_namespace_loader.load_template_source, 80 | ':template') 81 | 82 | def test_load_template_source_dotted_namespace(self): 83 | app_namespace_loader = Loader(Engine()) 84 | 85 | template_short = app_namespace_loader.load_template_source( 86 | 'admin:admin/base.html') 87 | template_dotted = app_namespace_loader.load_template_source( 88 | 'django.contrib.admin:admin/base.html') 89 | 90 | self.assertEquals(template_short[0], template_dotted[0]) 91 | 92 | def test_load_template_invalid_namespace_valid_template(self): 93 | app_namespace_loader = Loader(Engine()) 94 | with self.assertRaises(TemplateDoesNotExist): 95 | app_namespace_loader.load_template_source( 96 | 'invalid:admin/base.html') 97 | 98 | def test_load_template_valid_namespace_invalid_template(self): 99 | app_namespace_loader = Loader(Engine()) 100 | with self.assertRaises(TemplateDoesNotExist): 101 | app_namespace_loader.load_template_source( 102 | 'admin:admin/base_invalid.html') 103 | 104 | 105 | @override_settings( 106 | TEMPLATES=[ 107 | { 108 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 109 | 'OPTIONS': { 110 | 'loaders': ('app_namespace.Loader', 111 | 'django.template.loaders.app_directories.Loader') 112 | } 113 | } 114 | ] 115 | ) 116 | class TemplateTestCase(TestCase): 117 | maxDiff = None 118 | 119 | def test_extend_and_override(self): 120 | """ 121 | Here we simulate the existence of a template 122 | named admin/base_site.html on the filesystem 123 | overriding the title markup of the template. 124 | In this test we can view the advantage of using 125 | the app_namespace template loader. 126 | """ 127 | context = Context({}) 128 | mark = 'Django administration' 129 | mark_title = 'APP NAMESPACE' 130 | 131 | template_directory = Template( 132 | '{% extends "admin/base.html" %}' 133 | '{% block title %}APP NAMESPACE{% endblock %}' 134 | ).render(context) 135 | 136 | template_namespace = Template( 137 | '{% extends "admin:admin/base_site.html" %}' 138 | '{% block title %}APP NAMESPACE{% endblock %}' 139 | ).render(context) 140 | 141 | self.assertTrue(mark in template_namespace) 142 | self.assertTrue(mark_title in template_namespace) 143 | self.assertTrue(mark not in template_directory) 144 | self.assertTrue(mark_title in template_directory) 145 | 146 | template_directory = Template( 147 | '{% extends "admin/base.html" %}' 148 | '{% load i18n %}' 149 | '{% block title %}APP NAMESPACE{% endblock %}' 150 | '{% block branding %}' 151 | '

    ' 152 | '{% trans \'Django administration\' %}' 153 | '

    {% endblock %}' 154 | '{% block nav-global %}{% endblock %}' 155 | ).render(context) 156 | 157 | self.assertHTMLEqual(template_directory, template_namespace) 158 | self.assertTrue(mark in template_directory) 159 | self.assertTrue(mark_title in template_directory) 160 | 161 | def test_extend_empty_namespace(self): 162 | """ 163 | Test that a ":" prefix (empty namespace) gets handled. 164 | """ 165 | context = Context({}) 166 | mark = 'Django administration' 167 | mark_title = 'APP NAMESPACE' 168 | 169 | template_namespace = Template( 170 | '{% extends ":admin/base_site.html" %}' 171 | '{% block title %}APP NAMESPACE{% endblock %}' 172 | ).render(context) 173 | 174 | self.assertTrue(mark in template_namespace) 175 | self.assertTrue(mark_title in template_namespace) 176 | 177 | def test_extend_with_super(self): 178 | """ 179 | Here we simulate the existence of a template 180 | named admin/base_site.html on the filesystem 181 | overriding the title markup of the template 182 | with a {{ super }}. 183 | """ 184 | context = Context({}) 185 | mark_ok = ' | Django site admin - APP NAMESPACE' 186 | mark_ko = ' - APP NAMESPACE' 187 | 188 | template_directory = Template( 189 | '{% extends "admin/base.html" %}' 190 | '{% block title %}{{ block.super }} - APP NAMESPACE{% endblock %}' 191 | ).render(context) 192 | 193 | template_namespace = Template( 194 | '{% extends "admin:admin/base_site.html" %}' 195 | '{% block title %}{{ block.super }} - APP NAMESPACE{% endblock %}' 196 | ).render(context) 197 | 198 | self.assertTrue(mark_ok in template_namespace) 199 | self.assertTrue(mark_ko in template_directory) 200 | 201 | 202 | @override_settings( 203 | TEMPLATES=[ 204 | { 205 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 206 | 'OPTIONS': { 207 | 'loaders': ( 208 | 'app_namespace.Loader', 209 | 'django.template.loaders.app_directories.Loader') 210 | } 211 | } 212 | ] 213 | ) 214 | class MultiAppTestCase(TestCase): 215 | """ 216 | Test case creating multiples apps containing templates 217 | with the same path which extends with an empty namespace. 218 | 219 | Each template will use a {{ block.super }} with an unique 220 | identifier to test the multiple cumulations in the final 221 | rendering. 222 | """ 223 | maxDiff = None 224 | template_initial = """ 225 | {%% block content %%} 226 | %(app)s 227 | {%% endblock content %%} 228 | """ 229 | template_extend = """ 230 | {%% extends ":template.html" %%} 231 | {%% block content %%} 232 | %(app)s 233 | {{ block.super }} 234 | {%% endblock content %%} 235 | """ 236 | template_app = """ 237 | from django.apps import AppConfig 238 | class ApplicationConfig(AppConfig): 239 | name = __name__ 240 | label = '%(app)s' 241 | """ 242 | 243 | def setUp(self): 244 | super(MultiAppTestCase, self).setUp() 245 | # Create a temp directory containing apps 246 | # accessible on the PYTHONPATH. 247 | self.app_directory = tempfile.mkdtemp() 248 | sys.path.append(self.app_directory) 249 | 250 | # Create the apps with the overrided template 251 | self.apps = ['test-template-app-%s' % i for i in range(5)] 252 | for app in self.apps: 253 | app_path = os.path.join(self.app_directory, app) 254 | app_template_path = os.path.join(app_path, 'templates') 255 | os.makedirs(app_template_path) 256 | with open(os.path.join(app_path, '__init__.py'), 'w') as f: 257 | f.write('') 258 | with open(os.path.join(app_path, 'apps.py'), 'w') as f: 259 | f.write(self.template_app % {'app': app}) 260 | with open(os.path.join(app_template_path, 261 | 'template.html'), 'w') as f: 262 | f.write((app != self.apps[-1] and 263 | self.template_extend or self.template_initial) % 264 | {'app': app}) 265 | 266 | def tearDown(self): 267 | super(MultiAppTestCase, self).tearDown() 268 | sys.path.remove(self.app_directory) 269 | for app in self.apps: 270 | del sys.modules[app] 271 | shutil.rmtree(self.app_directory) 272 | 273 | def multiple_extend_empty_namespace(self, apps=None): 274 | if apps is None: 275 | apps = self.apps 276 | with self.settings(INSTALLED_APPS=apps): 277 | context = Context({}) 278 | template = Template( 279 | self.template_extend % {'app': 'top-level'} 280 | ).render(context) 281 | previous_app = '' 282 | for test_app in ['top-level'] + self.apps: 283 | self.assertTrue(test_app in template) 284 | if previous_app: 285 | self.assertTrue(template.index(test_app) > 286 | template.index(previous_app)) 287 | previous_app = test_app 288 | 289 | def test_multiple_extend_empty_namespace(self): 290 | self.multiple_extend_empty_namespace() 291 | 292 | def test_app_config_multiple_extend_empty_namespace(self): 293 | apps_config = ['%s.apps.ApplicationConfig' % app 294 | for app in self.apps] 295 | self.multiple_extend_empty_namespace(apps_config) 296 | 297 | @override_settings( 298 | TEMPLATES=[ 299 | { 300 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 301 | 'OPTIONS': { 302 | 'loaders': [ 303 | ('django.template.loaders.cached.Loader', [ 304 | 'app_namespace.Loader', 305 | 'django.template.loaders.app_directories.Loader']), 306 | ] 307 | } 308 | } 309 | ] 310 | ) 311 | def test_cached_multiple_extend_empty_namespace(self): 312 | with self.assertRaises(RuntimeError): 313 | self.multiple_extend_empty_namespace() 314 | 315 | 316 | class ViewTestCase(TestCase): 317 | 318 | def load_view_twice(self): 319 | url = reverse('template-view') 320 | r1 = self.client.get(url).content 321 | r2 = self.client.get(url).content 322 | self.assertEquals(r1, r2) 323 | 324 | @override_settings( 325 | TEMPLATES=[ 326 | { 327 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 328 | 'DIRS': [ 329 | os.path.abspath(os.path.dirname(__file__)), 330 | ], 331 | 'OPTIONS': { 332 | 'loaders': ( 333 | 'app_namespace.Loader', 334 | 'django.template.loaders.filesystem.Loader', 335 | 'django.template.loaders.app_directories.Loader', 336 | ) 337 | } 338 | } 339 | ] 340 | ) 341 | def test_load_view_twice_app_namespace_first(self): 342 | self.load_view_twice() 343 | 344 | @override_settings( 345 | TEMPLATES=[ 346 | { 347 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 348 | 'DIRS': [ 349 | os.path.abspath(os.path.dirname(__file__)), 350 | ], 351 | 'OPTIONS': { 352 | 'loaders': ( 353 | 'django.template.loaders.filesystem.Loader', 354 | 'django.template.loaders.app_directories.Loader', 355 | 'app_namespace.Loader', 356 | ) 357 | } 358 | } 359 | ] 360 | ) 361 | def test_load_view_twice_app_namespace_last(self): 362 | if django.VERSION[1] == 8: 363 | with self.assertRaises(TemplateDoesNotExist): 364 | self.load_view_twice() 365 | else: 366 | self.load_view_twice() 367 | -------------------------------------------------------------------------------- /app_namespace/tests/urls.py: -------------------------------------------------------------------------------- 1 | """Urls for testing app_namespace""" 2 | from django.conf.urls import include 3 | from django.conf.urls import url 4 | from django.contrib import admin 5 | from django.views.generic import TemplateView 6 | 7 | admin.autodiscover() 8 | 9 | urlpatterns = [ 10 | url(r'^$', TemplateView.as_view( 11 | template_name='template.html'), 12 | name='template-view'), 13 | url(r'^admin/', include(admin.site.urls)), 14 | ] 15 | -------------------------------------------------------------------------------- /bootstrap.py: -------------------------------------------------------------------------------- 1 | ############################################################################## 2 | # 3 | # Copyright (c) 2006 Zope Foundation and Contributors. 4 | # All Rights Reserved. 5 | # 6 | # This software is subject to the provisions of the Zope Public License, 7 | # Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. 8 | # THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED 9 | # WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 10 | # WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS 11 | # FOR A PARTICULAR PURPOSE. 12 | # 13 | ############################################################################## 14 | """Bootstrap a buildout-based project 15 | 16 | Simply run this script in a directory containing a buildout.cfg. 17 | The script accepts buildout command-line options, so you can 18 | use the -c option to specify an alternate configuration file. 19 | """ 20 | 21 | import os 22 | import shutil 23 | import sys 24 | import tempfile 25 | 26 | from optparse import OptionParser 27 | 28 | tmpeggs = tempfile.mkdtemp() 29 | 30 | usage = '''\ 31 | [DESIRED PYTHON FOR BUILDOUT] bootstrap.py [options] 32 | 33 | Bootstraps a buildout-based project. 34 | 35 | Simply run this script in a directory containing a buildout.cfg, using the 36 | Python that you want bin/buildout to use. 37 | 38 | Note that by using --find-links to point to local resources, you can keep 39 | this script from going over the network. 40 | ''' 41 | 42 | parser = OptionParser(usage=usage) 43 | parser.add_option("-v", "--version", help="use a specific zc.buildout version") 44 | 45 | parser.add_option("-t", "--accept-buildout-test-releases", 46 | dest='accept_buildout_test_releases', 47 | action="store_true", default=False, 48 | help=("Normally, if you do not specify a --version, the " 49 | "bootstrap script and buildout gets the newest " 50 | "*final* versions of zc.buildout and its recipes and " 51 | "extensions for you. If you use this flag, " 52 | "bootstrap and buildout will get the newest releases " 53 | "even if they are alphas or betas.")) 54 | parser.add_option("-c", "--config-file", 55 | help=("Specify the path to the buildout configuration " 56 | "file to be used.")) 57 | parser.add_option("-f", "--find-links", 58 | help=("Specify a URL to search for buildout releases")) 59 | parser.add_option("--allow-site-packages", 60 | action="store_true", default=False, 61 | help=("Let bootstrap.py use existing site packages")) 62 | parser.add_option("--setuptools-version", 63 | help="use a specific setuptools version") 64 | 65 | 66 | options, args = parser.parse_args() 67 | 68 | ###################################################################### 69 | # load/install setuptools 70 | 71 | try: 72 | if options.allow_site_packages: 73 | import setuptools 74 | import pkg_resources 75 | from urllib.request import urlopen 76 | except ImportError: 77 | from urllib2 import urlopen 78 | 79 | ez = {} 80 | exec(urlopen('https://bootstrap.pypa.io/ez_setup.py').read(), ez) 81 | 82 | if not options.allow_site_packages: 83 | # ez_setup imports site, which adds site packages 84 | # this will remove them from the path to ensure that incompatible versions 85 | # of setuptools are not in the path 86 | import site 87 | # inside a virtualenv, there is no 'getsitepackages'. 88 | # We can't remove these reliably 89 | if hasattr(site, 'getsitepackages'): 90 | for sitepackage_path in site.getsitepackages(): 91 | sys.path[:] = [x for x in sys.path if sitepackage_path not in x] 92 | 93 | setup_args = dict(to_dir=tmpeggs, download_delay=0) 94 | 95 | if options.setuptools_version is not None: 96 | setup_args['version'] = options.setuptools_version 97 | 98 | ez['use_setuptools'](**setup_args) 99 | import setuptools 100 | import pkg_resources 101 | 102 | # This does not (always?) update the default working set. We will 103 | # do it. 104 | for path in sys.path: 105 | if path not in pkg_resources.working_set.entries: 106 | pkg_resources.working_set.add_entry(path) 107 | 108 | ###################################################################### 109 | # Install buildout 110 | 111 | ws = pkg_resources.working_set 112 | 113 | cmd = [sys.executable, '-c', 114 | 'from setuptools.command.easy_install import main; main()', 115 | '-mZqNxd', tmpeggs] 116 | 117 | find_links = os.environ.get( 118 | 'bootstrap-testing-find-links', 119 | options.find_links or 120 | ('http://downloads.buildout.org/' 121 | if options.accept_buildout_test_releases else None) 122 | ) 123 | if find_links: 124 | cmd.extend(['-f', find_links]) 125 | 126 | setuptools_path = ws.find( 127 | pkg_resources.Requirement.parse('setuptools')).location 128 | 129 | requirement = 'zc.buildout' 130 | version = options.version 131 | if version is None and not options.accept_buildout_test_releases: 132 | # Figure out the most recent final version of zc.buildout. 133 | import setuptools.package_index 134 | _final_parts = '*final-', '*final' 135 | 136 | def _final_version(parsed_version): 137 | try: 138 | return not parsed_version.is_prerelease 139 | except AttributeError: 140 | # Older setuptools 141 | for part in parsed_version: 142 | if (part[:1] == '*') and (part not in _final_parts): 143 | return False 144 | return True 145 | 146 | index = setuptools.package_index.PackageIndex( 147 | search_path=[setuptools_path]) 148 | if find_links: 149 | index.add_find_links((find_links,)) 150 | req = pkg_resources.Requirement.parse(requirement) 151 | if index.obtain(req) is not None: 152 | best = [] 153 | bestv = None 154 | for dist in index[req.project_name]: 155 | distv = dist.parsed_version 156 | if _final_version(distv): 157 | if bestv is None or distv > bestv: 158 | best = [dist] 159 | bestv = distv 160 | elif distv == bestv: 161 | best.append(dist) 162 | if best: 163 | best.sort() 164 | version = best[-1].version 165 | if version: 166 | requirement = '=='.join((requirement, version)) 167 | cmd.append(requirement) 168 | 169 | import subprocess 170 | if subprocess.call(cmd, env=dict(os.environ, PYTHONPATH=setuptools_path)) != 0: 171 | raise Exception( 172 | "Failed to execute command:\n%s" % repr(cmd)[1:-1]) 173 | 174 | ###################################################################### 175 | # Import and run buildout 176 | 177 | ws.add_entry(tmpeggs) 178 | ws.require(requirement) 179 | import zc.buildout.buildout 180 | 181 | if not [a for a in args if '=' not in a]: 182 | args.append('bootstrap') 183 | 184 | # if -c was provided, we push it back into args for buildout' main function 185 | if options.config_file is not None: 186 | args[0:0] = ['-c', options.config_file] 187 | 188 | zc.buildout.buildout.main(args) 189 | shutil.rmtree(tmpeggs) 190 | -------------------------------------------------------------------------------- /buildout.cfg: -------------------------------------------------------------------------------- 1 | [buildout] 2 | extends = versions.cfg 3 | parts = demo 4 | test 5 | test-and-cover 6 | flake8 7 | coveralls 8 | evolution 9 | develop = . 10 | eggs = django 11 | django-app-namespace-template-loader 12 | show-picked-versions = true 13 | 14 | [demo] 15 | recipe = djangorecipe 16 | project = app_namespace.demo 17 | settings = settings 18 | eggs = ${buildout:eggs} 19 | 20 | [test] 21 | recipe = pbp.recipe.noserunner 22 | eggs = nose 23 | nose-sfd 24 | nose-progressive 25 | ${buildout:eggs} 26 | defaults = --with-progressive 27 | --with-sfd 28 | environment = testenv 29 | 30 | [test-and-cover] 31 | recipe = pbp.recipe.noserunner 32 | eggs = nose 33 | nose-sfd 34 | coverage 35 | ${buildout:eggs} 36 | defaults = --with-coverage 37 | --cover-package=app_namespace 38 | --cover-erase 39 | --with-sfd 40 | environment = testenv 41 | 42 | [flake8] 43 | recipe = zc.recipe.egg 44 | eggs = flake8 45 | flake8-import-order 46 | pep8-naming 47 | 48 | [coveralls] 49 | recipe = zc.recipe.egg 50 | eggs = python-coveralls 51 | 52 | [evolution] 53 | recipe = zc.recipe.egg 54 | eggs = buildout-versions-checker 55 | scripts = check-buildout-updates=evolve 56 | arguments = '-w --sorting alpha' 57 | 58 | [testenv] 59 | DJANGO_SETTINGS_MODULE = app_namespace.tests.settings 60 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup script for django-app-namespace-template-loader""" 2 | import os 3 | 4 | from setuptools import find_packages 5 | from setuptools import setup 6 | 7 | __version__ = '0.4.1' 8 | __license__ = 'BSD License' 9 | 10 | __author__ = 'Fantomas42' 11 | __email__ = 'fantomas42@gmail.com' 12 | 13 | __url__ = 'https://github.com/Fantomas42/django-app-namespace-template-loader' 14 | 15 | 16 | setup( 17 | name='django-app-namespace-template-loader', 18 | version=__version__, 19 | zip_safe=False, 20 | 21 | packages=find_packages(exclude=['tests']), 22 | include_package_data=True, 23 | 24 | author=__author__, 25 | author_email=__email__, 26 | url=__url__, 27 | 28 | license=__license__, 29 | platforms='any', 30 | description='Template loader allowing you to both ' 31 | 'extend and override a template at the same time. ', 32 | long_description=open(os.path.join('README.rst')).read(), 33 | keywords='django, template, loader', 34 | classifiers=[ 35 | 'Framework :: Django', 36 | 'Environment :: Web Environment', 37 | 'Programming Language :: Python :: 2.7', 38 | 'Programming Language :: Python :: 3.3', 39 | 'Programming Language :: Python :: 3.4', 40 | 'Intended Audience :: Developers', 41 | 'Operating System :: OS Independent', 42 | 'License :: OSI Approved :: BSD License', 43 | 'Topic :: Software Development :: Libraries :: Python Modules'], 44 | install_requires=['six'] 45 | ) 46 | -------------------------------------------------------------------------------- /versions.cfg: -------------------------------------------------------------------------------- 1 | [versions] 2 | blessings = 1.6 3 | buildout-versions-checker = 1.9.4 4 | configparser = 3.5.0 5 | coverage = 4.0.3 6 | django = 1.10.6 7 | djangorecipe = 2.2.1 8 | enum34 = 1.1.6 9 | flake8 = 3.3.0 10 | flake8-import-order = 0.12 11 | futures = 3.0.5 12 | mccabe = 0.6.1 13 | nose = 1.3.7 14 | nose-progressive = 1.5.1 15 | nose-sfd = 0.4 16 | pbp.recipe.noserunner = 0.2.6 17 | pep8-naming = 0.4.1 18 | pycodestyle = 2.3.1 19 | pyflakes = 1.5.0 20 | python-coveralls = 2.9.0 21 | pyyaml = 3.12 22 | requests = 2.13.0 23 | zc.buildout = 2.9.1 24 | zc.recipe.egg = 2.0.3 25 | --------------------------------------------------------------------------------