├── template_analyzer ├── templatetags │ ├── __init__.py │ └── template_analyzer_test_tags.py ├── models.py ├── templates │ └── placeholder_tests │ │ ├── outside_nested.html │ │ ├── cache_level2.html │ │ ├── child.html │ │ ├── tag_exception.html │ │ ├── extends_custom_loader_level2.html │ │ ├── cache_base.html │ │ ├── nested_super_level4.html │ │ ├── test_eleven.html │ │ ├── variable_extends_default.html │ │ ├── cache_level1.html │ │ ├── variable_extends.html │ │ ├── nested_super_level1.html │ │ ├── nested_super_level2.html │ │ ├── nested_super_level3.html │ │ ├── extends_custom_loader_level1.html │ │ ├── outside_base.html │ │ ├── base.html │ │ ├── test_one.html │ │ ├── outside.html │ │ ├── test_three.html │ │ ├── test_two.html │ │ ├── test_seven.html │ │ ├── test_five.html │ │ ├── test_four.html │ │ └── test_six.html ├── tests │ ├── __init__.py │ ├── app_loader.py │ └── test_placeholder.py ├── __init__.py └── djangoanalyzer.py ├── MANIFEST.in ├── .coverage ├── .gitignore ├── pyproject.toml ├── AUTHORS ├── tox.ini ├── runtests.py ├── CHANGES.rst ├── .github └── workflows │ └── tests.yaml ├── LICENSE ├── setup.py └── README.rst /template_analyzer/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /template_analyzer/models.py: -------------------------------------------------------------------------------- 1 | # needed to run tests 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include README.rst 3 | include LICENSE 4 | -------------------------------------------------------------------------------- /.coverage: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edoburu/django-template-analyzer/HEAD/.coverage -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/outside_nested.html: -------------------------------------------------------------------------------- 1 | {% extends "placeholder_tests/outside.html" %} -------------------------------------------------------------------------------- /template_analyzer/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .test_placeholder import PlaceholderTestCase # Expose for Django < 1.6 2 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/cache_level2.html: -------------------------------------------------------------------------------- 1 | {% extends "placeholder_tests/cache_level1.html" %} 2 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/child.html: -------------------------------------------------------------------------------- 1 | {% load template_analyzer_test_tags %} 2 | 3 | {% placeholder "child" %} 4 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/tag_exception.html: -------------------------------------------------------------------------------- 1 | {% load template_analyzer_test_tags %} 2 | 3 | {% placeholder %} 4 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/extends_custom_loader_level2.html: -------------------------------------------------------------------------------- 1 | {% extends 'placeholder_tests/extends_custom_loader_level1.html' %} 2 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/cache_base.html: -------------------------------------------------------------------------------- 1 | {% load template_analyzer_test_tags %} 2 | 3 | {% block cache %}cache_text{% endblock %} 4 | -------------------------------------------------------------------------------- /template_analyzer/__init__.py: -------------------------------------------------------------------------------- 1 | from .djangoanalyzer import get_node_instances 2 | 3 | VERSION = (2, 1, 0) 4 | 5 | # following PEP 440 6 | __version__ = "2.1" 7 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/nested_super_level4.html: -------------------------------------------------------------------------------- 1 | {% load template_analyzer_test_tags %} 2 | 3 | {% block one %} 4 | {% placeholder "level4" %} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.mo 4 | *.db 5 | *.egg-info/ 6 | .project 7 | .idea/ 8 | .pydevproject 9 | .idea/workspace.xml 10 | .DS_Store 11 | .tox/ 12 | build/ 13 | dist/ 14 | docs/_build/ 15 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/test_eleven.html: -------------------------------------------------------------------------------- 1 | {% load template_analyzer_test_tags %} 2 | {% block "myblock" %} 3 | {{ block.super }} 4 | {% placeholder "myplaceholder" %} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/variable_extends_default.html: -------------------------------------------------------------------------------- 1 | {% extends FOO|default:'placeholder_tests/base.html' %}{% load template_analyzer_test_tags %} 2 | 3 | {% placeholder "outside_block" %} 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = "black" 3 | line_length = 99 4 | 5 | [tool.black] 6 | line-length = 99 7 | exclude = ''' 8 | /( 9 | \.git 10 | | \.venv 11 | | \.tox 12 | | dist 13 | )/ 14 | ''' 15 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/cache_level1.html: -------------------------------------------------------------------------------- 1 | {% extends "placeholder_tests/cache_base.html" %} 2 | {% load template_analyzer_test_tags %} 3 | 4 | {% block cache %}{{ block.super }}{% placeholder "cache" %}{% endblock %} 5 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/variable_extends.html: -------------------------------------------------------------------------------- 1 | {% extends BASE_TEMPLATE %}{% load template_analyzer_test_tags %} 2 | 3 | {% block one %}{% placeholder "inside_block" %}{% endblock %} 4 | 5 | {% placeholder "outside_block" %} 6 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/nested_super_level1.html: -------------------------------------------------------------------------------- 1 | {% extends "placeholder_tests/nested_super_level2.html" %} 2 | {% load template_analyzer_test_tags %} 3 | 4 | {% block one %} 5 | {% placeholder "level1" %} 6 | {{ block.super }} 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/nested_super_level2.html: -------------------------------------------------------------------------------- 1 | {% extends "placeholder_tests/nested_super_level3.html" %} 2 | {% load template_analyzer_test_tags %} 3 | 4 | {% block one %} 5 | {% placeholder "level2" %} 6 | {{ block.super }} 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/nested_super_level3.html: -------------------------------------------------------------------------------- 1 | {% extends "placeholder_tests/nested_super_level4.html" %} 2 | {% load template_analyzer_test_tags %} 3 | 4 | {% block one %} 5 | {% placeholder "level3" %} 6 | {{ block.super }} 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/extends_custom_loader_level1.html: -------------------------------------------------------------------------------- 1 | {% extends 'template_analyzer:placeholder_tests/base.html' %}{% load template_analyzer_test_tags %} 2 | 3 | {% block one %}{% placeholder "new_one" %}{% endblock %} 4 | 5 | {% placeholder "outside_block" %} 6 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/outside_base.html: -------------------------------------------------------------------------------- 1 | {% load template_analyzer_test_tags %} 2 | 3 | {% block one %} 4 | {% placeholder "one" %} 5 | {% endblock %} 6 | 7 | {% block two %} 8 | {% placeholder "two" %} 9 | {% endblock %} 10 | 11 | {% placeholder "base_outside" %} 12 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Packaging in separate application: 2 | 3 | * Diederik van der Boor 4 | 5 | Original code written by Django CMS developers and contributors: 6 | 7 | * Chris Glass 8 | * Jonas Obrist 9 | * Patrick Lauber 10 | * Paul van der Linden 11 | * "philomat" 12 | * Stephan Jaekel 13 | * Tanel Külaots 14 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/base.html: -------------------------------------------------------------------------------- 1 | {% load template_analyzer_test_tags %} 2 | 3 | {% block one %} 4 | {% placeholder "one" %} 5 | {% endblock %} 6 | 7 | {% block two %} 8 | {% placeholder "two" %} 9 | {% endblock %} 10 | 11 | {% block three %} 12 | {% placeholder "three" %} 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/test_one.html: -------------------------------------------------------------------------------- 1 | {% extends "placeholder_tests/base.html" %} 2 | {% load template_analyzer_test_tags %} 3 | 4 | {% comment %} 5 | This files hould result in following placeholders: 6 | 7 | - new_one (from this) 8 | - two (from base.html) 9 | - three (from base.html) 10 | 11 | {% endcomment %} 12 | 13 | {% block one %} 14 | {% placeholder "new_one" %} 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/outside.html: -------------------------------------------------------------------------------- 1 | {% extends "placeholder_tests/outside_base.html" %} 2 | {% load template_analyzer_test_tags %} 3 | 4 | {% comment %} 5 | This files hould result in following placeholders: 6 | 7 | - new_one (from this) 8 | - two (from base.html) 9 | - base_end (from base.html) 10 | {% endcomment %} 11 | 12 | {% block one %} 13 | {% placeholder "new_one" %} 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/test_three.html: -------------------------------------------------------------------------------- 1 | {% extends "placeholder_tests/test_one.html" %} 2 | {% load template_analyzer_test_tags %} 3 | 4 | {% comment %} 5 | 6 | This file should result in following placeholders: 7 | 8 | - new_one (from test_one.html) 9 | - two (from base.html) 10 | - new_three (from this) 11 | 12 | {% endcomment %} 13 | 14 | {% block three %} 15 | {% placeholder "new_three" %} 16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/test_two.html: -------------------------------------------------------------------------------- 1 | {% extends "placeholder_tests/base.html" %} 2 | {% load template_analyzer_test_tags %} 3 | 4 | {% comment %} 5 | This files hould result in following placeholders: 6 | 7 | - child (from child.html) 8 | - three (from base.html) 9 | 10 | {% endcomment %} 11 | 12 | {% block one %} 13 | {% include "placeholder_tests/child.html" %} 14 | {% endblock %} 15 | 16 | {% block two %}{% endblock %} 17 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/test_seven.html: -------------------------------------------------------------------------------- 1 | {% load template_analyzer_test_tags %} 2 | 3 | {% comment %} 4 | 5 | This file should result in following placeholders: 6 | 7 | - new_one (from this) 8 | - new_two (from this) 9 | - new_three (from this) 10 | 11 | {% endcomment %} 12 | 13 | {% if something %} 14 |
{% placeholder "one" %}
15 | {% else %} 16 |
{% placeholder "one" %}
17 | {% endif %} 18 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/test_five.html: -------------------------------------------------------------------------------- 1 | {% extends "placeholder_tests/base.html" %} 2 | {% load template_analyzer_test_tags %} 3 | 4 | {% comment %} 5 | 6 | This file should result in following placeholders: 7 | 8 | - one (from base.html) 9 | - extra_one (from this) 10 | - two (from base.html) 11 | - three (from base.html) 12 | 13 | {% endcomment %} 14 | 15 | {% block one %} 16 | {% if something %} 17 | {{ block.super }} 18 | {% endif %} 19 | {% placeholder "extra_one" %} 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/test_four.html: -------------------------------------------------------------------------------- 1 | {% extends "placeholder_tests/test_three.html" %} 2 | {% load template_analyzer_test_tags %} 3 | 4 | {% comment %} 5 | 6 | This file should result in following placeholders: 7 | 8 | - new_one (from test_one.html) 9 | - child (from child.html) 10 | - four (from this) 11 | 12 | {% endcomment %} 13 | 14 | {% block three %} 15 | {% block subblockthree %} 16 | {% placeholder "four" %} 17 | {% endblock %} 18 | {% endblock %} 19 | {% block two %} 20 | {% include "placeholder_tests/child.html" %} 21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist= 3 | py38-django{22,31,32}, 4 | py310-django{42} 5 | coverage, 6 | 7 | [testenv] 8 | deps = 9 | django22: Django ~= 2.2 10 | django31: Django ~= 3.1 11 | django32: Django ~= 3.2 12 | django42: Django ~= 4.2 13 | django-dev: https://github.com/django/django/tarball/main 14 | commands= 15 | python runtests.py 16 | 17 | [testenv:coverage] 18 | basepython=python3.8 19 | deps= 20 | django 21 | coverage 22 | commands= 23 | coverage erase 24 | coverage run --rcfile=.coveragerc runtests.py 25 | coverage report 26 | -------------------------------------------------------------------------------- /template_analyzer/templates/placeholder_tests/test_six.html: -------------------------------------------------------------------------------- 1 | {% extends "placeholder_tests/base.html" %} 2 | {% load template_analyzer_test_tags %} 3 | 4 | {% comment %} 5 | 6 | This file should result in following placeholders: 7 | 8 | - new_one (from this) 9 | - new_two (from this) 10 | - new_three (from this) 11 | 12 | {% endcomment %} 13 | 14 | {% block one %} 15 | {% placeholder "new_one" %} 16 | 17 | {% block two %} 18 | {% placeholder "new_two" %} 19 | 20 | {% block three %} 21 | {% placeholder "new_three" %} 22 | {% endblock %} 23 | {% endblock %} 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /template_analyzer/templatetags/template_analyzer_test_tags.py: -------------------------------------------------------------------------------- 1 | from django.template import Library, Node, TemplateSyntaxError, Variable 2 | 3 | register = Library() 4 | 5 | 6 | class Placeholder(Node): 7 | """ 8 | Simple placeholder node. 9 | """ 10 | 11 | @classmethod 12 | def parse(cls, parser, token): 13 | tokens = token.contents.split() 14 | if len(tokens) == 2: 15 | return cls(Variable(tokens[1])) 16 | else: 17 | raise TemplateSyntaxError("{} tag requires 2 arguments".format(tokens[0])) 18 | 19 | def __init__(self, name_var): 20 | self.name_var = name_var 21 | 22 | def get_name(self): 23 | return self.name_var.literal 24 | 25 | def render(self, context): 26 | return "[placeholder: {}]".format(self.name_var.literal) 27 | 28 | 29 | @register.tag 30 | def placeholder(parser, token): 31 | """ 32 | A dummy placeholder template tag. 33 | """ 34 | return Placeholder.parse(parser, token) 35 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | 4 | import django 5 | from django.conf import settings 6 | from django.core.management import execute_from_command_line 7 | 8 | if not settings.configured: 9 | settings.configure( 10 | DEBUG=True, 11 | DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}, 12 | INSTALLED_APPS=("template_analyzer",), 13 | MIDDLEWARE=(), 14 | TEMPLATES=[ 15 | { 16 | "BACKEND": "django.template.backends.django.DjangoTemplates", 17 | "DIRS": (), 18 | "OPTIONS": { 19 | "loaders": ( 20 | "django.template.loaders.filesystem.Loader", 21 | "django.template.loaders.app_directories.Loader", 22 | # added dynamically: 'template_analyzer.tests.app_loader.Loader', 23 | ), 24 | }, 25 | }, 26 | ], 27 | ) 28 | 29 | 30 | def runtests(): 31 | argv = sys.argv[:1] + ["test", "template_analyzer", "--traceback"] + sys.argv[1:] 32 | execute_from_command_line(argv) 33 | 34 | 35 | if __name__ == "__main__": 36 | runtests() 37 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | Version 2.1 (2023-10-16) 5 | ------------------------ 6 | 7 | * Added Django 4.1/4.2 support 8 | 9 | Version 2.0 (2021-11-16) 10 | ------------------------ 11 | 12 | * Dropped Python 2.7 support. 13 | * Dropped Django 1.3-1.7 support. 14 | * Confirmed Django 4.0 support. 15 | * Reformatted the code with black and isort 16 | * Migated to GitHub actions 17 | 18 | Version 1.6.2 (2020-01-04) 19 | -------------------------- 20 | 21 | * Fixed Django 3.0 compatibility by removing ``django.utils.six`` dependency. 22 | 23 | Version 1.6.1 24 | ------------- 25 | 26 | * Fixed breaking templates when using the cached template loader; content was shown multiple times. 27 | * fixed support for parsing nodes from a custom template engine. 28 | * Improve error messages for invalid template block names. 29 | 30 | Version 1.6 31 | ----------- 32 | 33 | * Added Django 1.9 support 34 | 35 | Version 1.5 36 | ----------- 37 | 38 | * Added Django 1.8 support 39 | 40 | Version 1.4 41 | ----------- 42 | 43 | * Added Django 1.7 support 44 | 45 | Version 1.3 46 | ----------- 47 | 48 | * Added Python 3 support 49 | 50 | Version 1.2 51 | ----------- 52 | 53 | * Support variable extends with defaults in templates, e.g. ``{% extends FOO|default:'base.html' %}``. 54 | 55 | Version 1.1 56 | ----------- 57 | 58 | * Fix Django 1.4 support 59 | 60 | Version 1.0 61 | ----------- 62 | 63 | * First release, based on the code of django-cms. 64 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: CI Testing 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | push: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | test: 12 | name: "Python ${{ matrix.python }} Django ${{ matrix.django }}" 13 | runs-on: ubuntu-latest 14 | strategy: 15 | # max-parallel: 8 # default is max available 16 | fail-fast: false 17 | matrix: 18 | include: 19 | # Django 2.2 20 | - django: "2.2" 21 | python: "3.6" 22 | # Django 3.1 23 | - django: "3.1" 24 | python: "3.6" 25 | # Django 3.2 26 | - django: "3.2" 27 | python: "3.6" 28 | # Django 4.0 29 | - django: "4.2" 30 | python: "3.10" 31 | # Django 5.0 32 | - django: "5.0a1" 33 | python: "3.10" 34 | 35 | steps: 36 | - name: Checkout code 37 | uses: actions/checkout@v2 38 | 39 | - name: Setup Python ${{ matrix.python }} 40 | uses: actions/setup-python@v2 41 | with: 42 | python-version: ${{ matrix.python }} 43 | 44 | - name: Install Packages 45 | run: | 46 | python -m pip install -U pip 47 | python -m pip install "Django~=${{ matrix.django }}" codecov -e .[tests] 48 | 49 | - name: Run Tests 50 | run: | 51 | echo "Python ${{ matrix.python }} / Django ${{ matrix.django }}" 52 | coverage run --rcfile=.coveragerc runtests.py 53 | codecov 54 | continue-on-error: ${{ contains(matrix.django, '5.0') }} 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008, Batiste Bieler 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 | -------------------------------------------------------------------------------- /template_analyzer/tests/app_loader.py: -------------------------------------------------------------------------------- 1 | """ 2 | Based on: 3 | * https://github.com/django-admin-tools/django-admin-tools/blob/master/admin_tools/template_loaders.py 4 | * https://bitbucket.org/tzulberti/django-apptemplates/ 5 | * http://djangosnippets.org/snippets/1376/ 6 | 7 | Django template loader that allows you to load a template from a 8 | specific application. This allows you to both extend and override 9 | a template at the same time. The default Django loaders require you 10 | to copy the entire template you want to override, even if you only 11 | want to override one small block. 12 | 13 | Template usage example:: 14 | {% extends "admin:admin/base.html" %} 15 | """ 16 | from importlib import import_module 17 | from os.path import abspath, dirname, join 18 | 19 | import django 20 | from django.apps import apps 21 | from django.template import Origin 22 | from django.template.loaders.filesystem import Loader as FilesystemLoader 23 | 24 | _cache = {} 25 | 26 | 27 | def get_app_template_dir(app_name): 28 | """Get the template directory for an application 29 | 30 | Uses apps interface available in django 1.7+ 31 | 32 | Returns a full path, or None if the app was not found. 33 | """ 34 | if app_name in _cache: 35 | return _cache[app_name] 36 | template_dir = None 37 | 38 | for app in apps.get_app_configs(): 39 | if app.label == app_name: 40 | template_dir = join(app.path, "templates") 41 | break 42 | 43 | _cache[app_name] = template_dir 44 | return template_dir 45 | 46 | 47 | class Loader(FilesystemLoader): 48 | is_usable = True 49 | 50 | def get_template_sources(self, template_name, template_dirs=None): 51 | """ 52 | Returns the absolute paths to "template_name" in the specified app. 53 | If the name does not contain an app name (no colon), an empty list 54 | is returned. 55 | The parent FilesystemLoader.load_template_source() will take care 56 | of the actual loading for us. 57 | """ 58 | if ":" not in template_name: 59 | return [] 60 | app_name, template_name = template_name.split(":", 1) 61 | template_dir = get_app_template_dir(app_name) 62 | if template_dir: 63 | origin = Origin( 64 | name=join(template_dir, template_name), 65 | template_name=template_name, 66 | loader=self, 67 | ) 68 | return [origin] 69 | return [] 70 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import codecs 3 | import re 4 | from os import path 5 | 6 | from setuptools import find_packages, setup 7 | 8 | 9 | def read(*parts): 10 | file_path = path.join(path.dirname(__file__), *parts) 11 | return codecs.open(file_path, encoding="utf-8").read() 12 | 13 | 14 | def find_version(*parts): 15 | version_file = read(*parts) 16 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) 17 | if version_match: 18 | return str(version_match.group(1)) 19 | raise RuntimeError("Unable to find version string.") 20 | 21 | 22 | setup( 23 | name="django-template-analyzer", 24 | version=find_version("template_analyzer", "__init__.py"), 25 | license="BSD License", 26 | platforms=["OS Independent"], 27 | description="Django Template Analyzer - Extract template nodes from a Django template", 28 | long_description=read("README.rst"), 29 | author="Diederik van der Boor & Django CMS developers", 30 | author_email="opensource@edoburu.nl", 31 | url="https://github.com/edoburu/django-template-analyzer", 32 | download_url="https://github.com/edoburu/django-template-analyzer/zipball/master", 33 | packages=find_packages(), 34 | include_package_data=True, 35 | test_suite="runtests", 36 | zip_safe=False, 37 | classifiers=[ 38 | "Development Status :: 5 - Production/Stable", 39 | "Environment :: Web Environment", 40 | "Intended Audience :: Developers", 41 | "License :: OSI Approved :: BSD License", 42 | "Operating System :: OS Independent", 43 | "Programming Language :: Python", 44 | "Programming Language :: Python :: 3", 45 | "Programming Language :: Python :: 3.5", 46 | "Programming Language :: Python :: 3.6", 47 | "Programming Language :: Python :: 3.7", 48 | "Programming Language :: Python :: 3.8", 49 | "Programming Language :: Python :: 3.9", 50 | "Framework :: Django", 51 | "Framework :: Django :: 1.8", 52 | "Framework :: Django :: 1.9", 53 | "Framework :: Django :: 1.10", 54 | "Framework :: Django :: 1.11", 55 | "Framework :: Django :: 2.0", 56 | "Framework :: Django :: 2.1", 57 | "Framework :: Django :: 2.2", 58 | "Framework :: Django :: 3.0", 59 | "Framework :: Django :: 3.1", 60 | "Framework :: Django :: 3.2", 61 | "Framework :: Django :: 4.0", 62 | "Topic :: Software Development", 63 | "Topic :: Software Development :: Libraries :: Application Frameworks", 64 | "Topic :: Software Development :: Libraries :: Python Modules", 65 | ], 66 | ) 67 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-template-analyzer 2 | ======================== 3 | 4 | .. image:: https://github.com/edoburu/django-template-analyzer/actions/workflows/tests.yaml/badge.svg?branch=master 5 | :target: https://github.com/edoburu/django-template-analyzer/actions/workflows/tests.yaml 6 | .. image:: https://img.shields.io/pypi/v/django-template-analyzer.svg 7 | :target: https://pypi.python.org/pypi/django-template-analyzer/ 8 | .. image:: https://img.shields.io/badge/wheel-yes-green.svg 9 | :target: https://pypi.python.org/pypi/django-template-analyzer/ 10 | .. image:: https://img.shields.io/codecov/c/github/edoburu/django-template-analyzer/master.svg 11 | :target: https://codecov.io/github/edoburu/django-template-analyzer?branch=master 12 | 13 | The ``template_analyzer`` package offers an API to analyze the Django template structure. 14 | It can be used to find nodes of a particular type, e.g. to do automatic detection of placeholder tags. 15 | 16 | Supported features 17 | ================== 18 | 19 | The scanner finds tags in various situations, including: 20 | 21 | * Extend nodes 22 | * Include nodes 23 | * Overwritten blocks with new definitions 24 | * Blocks with or without ``{{ block.super }}`` 25 | * Reorganized blocks 26 | * Ignoring nodes outside blocks in extending templates 27 | * Handling multiple levels of super includes 28 | 29 | The returned nodes are provided in a natural ordering, 30 | as they would be expected to appear in the outputted page. 31 | 32 | While Django offers a ``template.nodelist.get_nodes_of_type()`` function, 33 | this function does not produce the same results. 34 | 35 | 36 | API example 37 | =========== 38 | 39 | .. code-block:: python 40 | 41 | from django.template.loader import get_template 42 | from mycms.templatetags.placeholdertags import Placeholder 43 | from template_analyzer.djangoanalyzer import get_node_instances 44 | 45 | # Load a Django template 46 | template = get_template("mycms/default-page.html") 47 | 48 | # Find all tags in the template: 49 | placeholders = get_node_instances(template, Placeholder) 50 | 51 | # Read information from the template tag themselves: 52 | # (this is an example, accessing a custom method on the Placeholder object) 53 | placeholder_names = [p.get_name() for p in placeholders] 54 | 55 | Installation 56 | ============ 57 | 58 | First install the module, preferably in a virtual environment. It can be installed from PyPI:: 59 | 60 | pip install django-template-analyzer 61 | 62 | Or the current folder can be installed:: 63 | 64 | pip install . 65 | 66 | Credits 67 | ======= 68 | 69 | * This package is based on the work of 70 | `Django CMS `_. 71 | * Many thanks to the contributors of ``cms/utils/placeholder.py`` / ``cms/utils/plugins.py`` in Django CMS! 72 | -------------------------------------------------------------------------------- /template_analyzer/tests/test_placeholder.py: -------------------------------------------------------------------------------- 1 | from django.template import Context 2 | from django.template.base import TemplateSyntaxError 3 | from django.template.loader import get_template 4 | from django.test.testcases import TestCase 5 | 6 | from template_analyzer.djangoanalyzer import get_node_instances 7 | from template_analyzer.templatetags.template_analyzer_test_tags import Placeholder 8 | 9 | 10 | def get_placeholders(filename): 11 | template = get_template(filename) 12 | return get_placeholders_in_template(template) 13 | 14 | 15 | def get_placeholders_in_template(template): 16 | placeholders = get_node_instances(template, Placeholder) 17 | return [p.get_name() for p in placeholders] 18 | 19 | 20 | class PlaceholderTestCase(TestCase): 21 | def test_placeholder_scanning_extend(self): 22 | placeholders = get_placeholders("placeholder_tests/test_one.html") 23 | self.assertEqual(sorted(placeholders), sorted(["new_one", "two", "three"])) 24 | 25 | def test_placeholder_scanning_include(self): 26 | placeholders = get_placeholders("placeholder_tests/test_two.html") 27 | self.assertEqual(sorted(placeholders), sorted(["child", "three"])) 28 | 29 | def test_placeholder_scanning_double_extend(self): 30 | placeholders = get_placeholders("placeholder_tests/test_three.html") 31 | self.assertEqual(sorted(placeholders), sorted(["new_one", "two", "new_three"])) 32 | 33 | def test_placeholder_scanning_complex(self): 34 | placeholders = get_placeholders("placeholder_tests/test_four.html") 35 | self.assertEqual(sorted(placeholders), sorted(["new_one", "child", "four"])) 36 | 37 | def test_placeholder_scanning_super(self): 38 | placeholders = get_placeholders("placeholder_tests/test_five.html") 39 | self.assertEqual(sorted(placeholders), sorted(["one", "extra_one", "two", "three"])) 40 | 41 | def test_placeholder_scanning_nested(self): 42 | placeholders = get_placeholders("placeholder_tests/test_six.html") 43 | self.assertEqual(sorted(placeholders), sorted(["new_one", "new_two", "new_three"])) 44 | 45 | # def test_placeholder_scanning_duplicate(self): 46 | # placeholders = self.assertWarns(DuplicatePlaceholderWarning, "Duplicate placeholder found: `one`", get_placeholders, 'placeholder_tests/test_seven.html') 47 | # self.assertEqual(sorted(placeholders), sorted([u'one'])) 48 | 49 | def test_placeholder_scanning_extend_outside_block(self): 50 | placeholders = get_placeholders("placeholder_tests/outside.html") 51 | self.assertEqual(sorted(placeholders), sorted(["new_one", "two", "base_outside"])) 52 | 53 | def test_placeholder_scanning_extend_outside_block_nested(self): 54 | placeholders = get_placeholders("placeholder_tests/outside_nested.html") 55 | self.assertEqual(sorted(placeholders), sorted(["new_one", "two", "base_outside"])) 56 | 57 | def test_placeholder_scanning_nested_super(self): 58 | placeholders = get_placeholders("placeholder_tests/nested_super_level1.html") 59 | self.assertEqual(sorted(placeholders), sorted(["level1", "level2", "level3", "level4"])) 60 | 61 | def test_ignore_variable_extends(self): 62 | placeholders = get_placeholders("placeholder_tests/variable_extends.html") 63 | self.assertEqual(sorted(placeholders), []) 64 | 65 | def test_variable_extends_default(self): 66 | placeholders = get_placeholders("placeholder_tests/variable_extends_default.html") 67 | self.assertEqual(sorted(placeholders), sorted(["one", "two", "three"])) 68 | 69 | def test_tag_placeholder_exception(self): 70 | exp = TemplateSyntaxError("placeholder tag requires 2 arguments") 71 | with self.assertRaises(TemplateSyntaxError) as tsx: 72 | get_placeholders("placeholder_tests/tag_exception.html") 73 | self.assertEqual(str(tsx.exception), str(exp)) 74 | 75 | def _get_custom_engine(self, **options): 76 | options.setdefault( 77 | "loaders", 78 | ( 79 | "django.template.loaders.filesystem.Loader", 80 | "django.template.loaders.app_directories.Loader", 81 | "template_analyzer.tests.app_loader.Loader", 82 | ), 83 | ) 84 | 85 | from django.template.backends.django import DjangoTemplates 86 | 87 | return DjangoTemplates( 88 | { 89 | "NAME": "loader_test", 90 | "DIRS": (), 91 | "APP_DIRS": False, 92 | "OPTIONS": options, 93 | } 94 | ) 95 | 96 | def test_custom_loader(self): 97 | """ 98 | When the application uses a custom loader, make sure the template analyzer uses that to find extends nodes. 99 | """ 100 | # See whether the engine is correctly passed; 101 | # otherwise the custom extends loader could fail. 102 | engine = self._get_custom_engine() 103 | template = engine.get_template("placeholder_tests/extends_custom_loader_level1.html") 104 | 105 | placeholders = get_placeholders_in_template(template) 106 | self.assertEqual(sorted(placeholders), sorted(["new_one", "two", "three"])) 107 | 108 | def test_custom_loader_level2(self): 109 | """ 110 | When the application uses a custom loader, make sure the template analyzer uses that to find extends nodes. 111 | """ 112 | # See whether the engine is correctly passed; 113 | # otherwise the custom extends loader could fail. 114 | engine = self._get_custom_engine() 115 | template = engine.get_template("placeholder_tests/extends_custom_loader_level2.html") 116 | 117 | placeholders = get_placeholders_in_template(template) 118 | self.assertEqual(sorted(placeholders), sorted(["new_one", "two", "three"])) 119 | 120 | def test_cached_template(self): 121 | context = {} 122 | template = get_template("placeholder_tests/cache_level2.html") 123 | result1 = template.render(context) # render first 124 | 125 | # the analyzer should not affect block nodes. 126 | placeholders = get_placeholders_in_template(template) 127 | self.assertEqual(sorted(placeholders), sorted(["cache"])) 128 | 129 | # see if the block structure is altered 130 | result2 = template.render(context) 131 | self.assertEqual(result1, result2) 132 | -------------------------------------------------------------------------------- /template_analyzer/djangoanalyzer.py: -------------------------------------------------------------------------------- 1 | # Ensure that loader is imported before loader_tags, or a circular import may happen 2 | # when this file is loaded from an `__init__.py` in an application root. 3 | # The __init__.py files in applications are loaded very early due to the scan of of translation.activate() 4 | import django 5 | import django.template.loader # noqa 6 | 7 | # Normal imports 8 | from django.template import Context, NodeList, Template, TemplateSyntaxError 9 | from django.template.backends.django import Template as TemplateAdapter 10 | from django.template.base import VariableNode 11 | from django.template.loader import get_template 12 | from django.template.loader_tags import BlockNode, ExtendsNode, IncludeNode 13 | 14 | 15 | def _is_variable_extends(extend_node): 16 | """ 17 | Check whether an ``{% extends variable %}`` is used in the template. 18 | 19 | :type extend_node: ExtendsNode 20 | """ 21 | # The FilterExpression.var can be either a string, or Variable object. 22 | return not isinstance(extend_node.parent_name.var, str) 23 | 24 | 25 | def _extend_blocks(extend_node, blocks, context): 26 | """ 27 | Extends the dictionary `blocks` with *new* blocks in the parent node (recursive) 28 | 29 | :param extend_node: The ``{% extends .. %}`` node object. 30 | :type extend_node: ExtendsNode 31 | :param blocks: dict of all block names found in the template. 32 | :type blocks: dict 33 | """ 34 | try: 35 | # This needs a fresh parent context, or it will detection recursion in Django 1.9+, 36 | # and thus skip the base template, which is already loaded. 37 | parent = extend_node.get_parent(_get_extend_context(context)) 38 | except TemplateSyntaxError: 39 | if _is_variable_extends(extend_node): 40 | # we don't support variable extensions unless they have a default. 41 | return 42 | else: 43 | raise 44 | 45 | # Search for new blocks 46 | for parent_block in parent.nodelist.get_nodes_by_type(BlockNode): 47 | if not parent_block.name in blocks: 48 | blocks[parent_block.name] = parent_block 49 | else: 50 | # set this node as the super node (for {{ block.super }}) 51 | block = blocks[parent_block.name] 52 | seen_supers = [] 53 | while hasattr(block.parent, "nodelist") and block.parent not in seen_supers: 54 | seen_supers.append(block.parent) 55 | block = block.parent 56 | block.parent = parent_block 57 | 58 | # search for further ExtendsNodes in the extended template 59 | # There is only one extend block in a template (Django checks for this). 60 | parent_extends = parent.nodelist.get_nodes_by_type(ExtendsNode) 61 | if parent_extends: 62 | _extend_blocks(parent_extends[0], blocks, context) 63 | 64 | 65 | def _find_topmost_template(extend_node, context): 66 | try: 67 | parent_template = extend_node.get_parent(context) 68 | except TemplateSyntaxError: 69 | # we don't support variable extensions 70 | if _is_variable_extends(extend_node): 71 | return 72 | else: 73 | raise 74 | 75 | # There is only one extend block in a template (Django checks for this). 76 | parent_extends = parent_template.nodelist.get_nodes_by_type(ExtendsNode) 77 | if parent_extends: 78 | return _find_topmost_template(parent_extends[0], context) 79 | else: 80 | # No ExtendsNode 81 | return parent_template 82 | 83 | 84 | def _extend_nodelist(extends_node, context, instance_types): 85 | """ 86 | Returns a list of results found in the parent template(s) 87 | :type extends_node: ExtendsNode 88 | """ 89 | results = [] 90 | 91 | # Find all blocks in the complete inheritance chain 92 | blocks = extends_node.blocks.copy() # dict with all blocks in the current template 93 | _extend_blocks(extends_node, blocks, context) 94 | 95 | # Dive into all blocks of the page one by one 96 | all_block_names = list(blocks.keys()) 97 | for block in list(blocks.values()): 98 | results += _scan_nodes( 99 | block.nodelist, context, instance_types, block, ignore_blocks=all_block_names 100 | ) 101 | 102 | # Scan topmost template for nodes that exist outside of blocks 103 | parent_template = _find_topmost_template(extends_node, context) 104 | if not parent_template: 105 | return [] 106 | else: 107 | results += _scan_nodes( 108 | parent_template.nodelist, context, instance_types, ignore_blocks=all_block_names 109 | ) 110 | return results 111 | 112 | 113 | def _scan_nodes(nodelist, context, instance_types, current_block=None, ignore_blocks=None): 114 | """ 115 | Loop through all nodes of a single scope level. 116 | 117 | :type nodelist: django.template.base.NodeList 118 | :type current_block: BlockNode 119 | :param instance_types: The instance to look for 120 | """ 121 | results = [] 122 | for node in nodelist: 123 | # first check if this is the object instance to look for. 124 | if isinstance(node, instance_types): 125 | results.append(node) 126 | # if it's a Constant Include Node ({% include "template_name.html" %}) 127 | # scan the child template 128 | elif isinstance(node, IncludeNode): 129 | # if there's an error in the to-be-included template, node.template becomes None 130 | if node.template: 131 | # This is required for Django 1.7 but works on older version too 132 | # Check if it quacks like a template object, if not 133 | # presume is a template path and get the object out of it 134 | if not callable(getattr(node.template, "render", None)): 135 | template = get_template(node.template.var) 136 | else: 137 | template = node.template 138 | 139 | if isinstance(template, TemplateAdapter): 140 | # Django 1.8+: received a new object, take original template 141 | template = template.template 142 | 143 | results += _scan_nodes(template.nodelist, context, instance_types, current_block) 144 | # handle {% extends ... %} tags 145 | elif isinstance(node, ExtendsNode): 146 | results += _extend_nodelist(node, context, instance_types) 147 | # in block nodes we have to scan for super blocks 148 | elif isinstance(node, VariableNode) and current_block: 149 | if node.filter_expression.token == "block.super": 150 | # Found a {{ block.super }} line 151 | if not hasattr(current_block.parent, "nodelist"): 152 | raise TemplateSyntaxError( 153 | "Cannot read {{{{ block.super }}}} for {{% block {0} %}}, " 154 | "the parent template doesn't have this block.".format(current_block.name) 155 | ) 156 | results += _scan_nodes( 157 | current_block.parent.nodelist, context, instance_types, current_block.parent 158 | ) 159 | # ignore nested blocks which are already handled 160 | elif isinstance(node, BlockNode) and ignore_blocks and node.name in ignore_blocks: 161 | continue 162 | # if the node has the newly introduced 'child_nodelists' attribute, scan 163 | # those attributes for nodelists and recurse them 164 | elif hasattr(node, "child_nodelists"): 165 | for nodelist_name in node.child_nodelists: 166 | if hasattr(node, nodelist_name): 167 | subnodelist = getattr(node, nodelist_name) 168 | if isinstance(subnodelist, NodeList): 169 | if isinstance(node, BlockNode): 170 | current_block = node 171 | results += _scan_nodes(subnodelist, context, instance_types, current_block) 172 | # else just scan the node for nodelist instance attributes 173 | else: 174 | for attr in dir(node): 175 | obj = getattr(node, attr) 176 | if isinstance(obj, NodeList): 177 | if isinstance(node, BlockNode): 178 | current_block = node 179 | results += _scan_nodes(obj, context, instance_types, current_block) 180 | return results 181 | 182 | 183 | def _get_main_context(nodelist): 184 | # The context is empty, but needs to be provided to handle the {% extends %} node. 185 | context = Context({}) 186 | 187 | if isinstance(nodelist, TemplateAdapter): 188 | # The top-level context. 189 | context.template = Template("", engine=nodelist.template.engine) 190 | else: 191 | # Just in case a different nodelist is provided. 192 | # Using the default template now. 193 | context.template = Template("") 194 | return context 195 | 196 | 197 | def _get_extend_context(parent_context): 198 | # For extends nodes, a fresh template instance is constructed. 199 | # The loader cache of the original `nodelist` is skipped. 200 | context = Context({}) 201 | context.template = Template("", engine=parent_context.template.engine) 202 | return context 203 | 204 | 205 | def get_node_instances(nodelist, instances): 206 | """ 207 | Find the nodes of a given instance. 208 | 209 | In contract to the standard ``template.nodelist.get_nodes_by_type()`` method, 210 | this also looks into ``{% extends %}`` and ``{% include .. %}`` nodes 211 | to find all possible nodes of the given type. 212 | 213 | :param instances: A class Type, or tuple of types to find. 214 | :param nodelist: The Template object, or nodelist to scan. 215 | :returns: A list of Node objects which inherit from the list of given `instances` to find. 216 | :rtype: list 217 | """ 218 | context = _get_main_context(nodelist) 219 | 220 | # The Django loader returns an adapter class; 221 | # it wraps the original Template in a new object to be API compatible 222 | if isinstance(nodelist, TemplateAdapter): 223 | nodelist = nodelist.template 224 | 225 | if isinstance(nodelist, Template): 226 | # As of Django 4.1, template Node objects no longer allow iteration, 227 | # which breaks Template.__iter__ too. Instead, directly walk over the nodelist. 228 | nodelist = nodelist.nodelist 229 | 230 | return _scan_nodes(nodelist, context, instances) 231 | --------------------------------------------------------------------------------