├── 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 |
--------------------------------------------------------------------------------