diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..be006de
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,13 @@
+# Keep GitHub Actions up to date with GitHub's Dependabot...
+# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
+# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem
+version: 2
+updates:
+ - package-ecosystem: github-actions
+ directory: /
+ groups:
+ github-actions:
+ patterns:
+ - "*" # Group all Actions updates into a single larger pull request
+ schedule:
+ interval: weekly
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000..99002ac
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,117 @@
+name: CI
+
+on:
+ pull_request:
+ branches:
+ - main
+
+concurrency:
+ group: test-${{ github.head_ref }}
+ cancel-in-progress: true
+
+env:
+ PYTHONUNBUFFERED: "1"
+ FORCE_COLOR: "1"
+
+jobs:
+ test:
+ name: Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}
+ runs-on: "ubuntu-latest"
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.10", "3.11", "3.12"]
+ django-version: ["4.2", "5.0", "main"]
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: extractions/setup-just@v2
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: "pip"
+ allow-prereleases: true
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ if [[ "${{ matrix.django-version }}" == "main" ]]; then
+ python -m pip install https://github.com/django/django/archive/refs/heads/main.zip
+ else
+ python -m pip install django==${{ matrix.django-version }}
+ fi
+ python -m pip install '.[tests]'
+
+ - name: Run tests
+ run: |
+ just test
+
+# tests:
+# runs-on: ubuntu-latest
+# needs: test
+# if: always()
+# steps:
+# - name: OK
+# if: ${{ !(contains(needs.*.result, 'failure')) }}
+# run: exit 0
+# - name: Fail
+# if: ${{ contains(needs.*.result, 'failure') }}
+# run: exit 1
+
+ coverage:
+ runs-on: ubuntu-latest
+ env:
+ PYTHON_MIN_VERSION: "3.10"
+ DJANGO_MIN_VERSION: "4.2"
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: extractions/setup-just@v2
+
+ - name: Set up Python ${{ env.PYTHON_MIN_VERSION }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.PYTHON_MIN_VERSION }}
+ cache: "pip"
+ allow-prereleases: true
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install django==${{ env.DJANGO_MIN_VERSION }}
+ python -m pip install '.[tests, dev]'
+
+ - name: Run coverage
+ run: |
+ just coverage
+
+ ruff:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install '.[dev]'
+
+ - name: Run ruff
+ run: |
+ ruff check .
+ ruff format --check
+
+ mypy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install '.[dev]'
+
+ - name: Run mypy
+ run: |
+ mypy .
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1624ed1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+__pycache__
+dist
+.idea
+.venv
+.coverage
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..f3a9f07
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,43 @@
+default_language_version:
+ python: python3.12
+
+repos:
+- repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v4.6.0
+ hooks:
+ - id: check-added-large-files
+ - id: check-case-conflict
+ - id: check-json
+ - id: check-merge-conflict
+ - id: check-symlinks
+ - id: check-toml
+ - id: end-of-file-fixer
+ exclude_types: [text]
+ - id: trailing-whitespace
+- repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.4.7
+ hooks:
+ - id: ruff
+ - id: ruff-format
+- repo: https://github.com/rstcheck/rstcheck
+ rev: v6.2.1
+ hooks:
+ - id: rstcheck
+ additional_dependencies:
+ - tomli==2.0.1
+- repo: https://github.com/asottile/pyupgrade
+ rev: v3.15.2
+ hooks:
+ - id: pyupgrade
+ args: [--py310-plus]
+- repo: https://github.com/adamchainz/django-upgrade
+ rev: 1.18.0
+ hooks:
+ - id: django-upgrade
+ args: [--target-version, '4.2']
+- repo: https://github.com/pre-commit/mirrors-mypy
+ rev: v1.10.0
+ hooks:
+ - id: mypy
+ additional_dependencies:
+ - django-stubs==5.0.2
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
new file mode 100644
index 0000000..6a67627
--- /dev/null
+++ b/CHANGELOG.rst
@@ -0,0 +1,8 @@
+=========
+CHANGELOG
+=========
+
+0.1.0 (2024-06-19)
+==================
+
+Initial release.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..be3ff47
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2024 Giannis Terzopoulos
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..d14a86c
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,270 @@
+===================
+django-xinclude
+===================
+
+.. image:: https://img.shields.io/pypi/v/django-xinclude.svg
+ :target: https://pypi.org/project/django-xinclude/
+ :alt: PyPI version
+
+Render a template using htmx with the current context.
+
+----
+
+| ``hx-get`` is often used to delegate potentially computationally expensive template fragments to ``htmx``.
+ Achieving this sometimes requires the creation of multiple views, each of which needs to inherit from mixins that
+ provide access to the same context.
+| ``django-xinclude`` provides a template tag that aims to make this easier by leveraging the cache,
+ removing the need for the extra views.
+
+----
+
+Requirements
+------------
+* Python 3.10 to 3.12 supported.
+* Django 4.2 to 5.0 supported.
+* `htmx `__
+
+Setup
+-----
+
+* Install from **pip**:
+
+.. code-block:: sh
+
+ python -m pip install django-xinclude
+
+* Add it to your installed apps:
+
+.. code-block:: python
+
+ INSTALLED_APPS = [
+ ...,
+ "django_xinclude",
+ ...,
+ ]
+
+* Include the app URLs in your root URLconf:
+
+.. code-block:: python
+
+ from django.urls import include, path
+
+ urlpatterns = [
+ ...,
+ path("__xinclude__/", include("django_xinclude.urls")),
+ ]
+
+You can use a different prefix if required.
+
+
+Usage
+-----
+
+Once installed, load the ``xinclude`` library and use the tag passing the template that you want to include:
+
+.. code-block:: html
+
+ {% load xinclude %}
+
+ {% xinclude "footer.html" %}{% endxinclude %}
+
+Every feature of the regular |include|__ tag is supported, including the use of ``with`` and ``only``.
+
+.. |include| replace:: ``include``
+__ https://docs.djangoproject.com/en/dev/ref/templates/builtins/#include
+
+You can use the following htmx-specific arguments:
+
+* ``hx-trigger``: corresponds to the |hx-trigger|__ htmx attribute. Defaults to ``load once``.
+
+.. |hx-trigger| replace:: ``hx-trigger``
+__ https://htmx.org/attributes/hx-trigger/
+
+* ``swap-time``: corresponds to the ``swap`` timing of the |hx-swap|__ htmx attribute.
+
+.. |hx-swap| replace:: ``hx-swap``
+__ https://htmx.org/attributes/hx-swap/#timing-swap-settle
+
+* ``settle-time``: corresponds to the ``settle`` timing of the |hx-swap|__ htmx attribute.
+
+__ https://htmx.org/attributes/hx-swap/#timing-swap-settle
+
+"Primary nodes" may be passed along to render initial content prior to htmx swapping. For example:
+
+.. code-block:: html
+
+ {% xinclude "footer.html" %}
+
Loading...
+ {% endxinclude %}
+
+``django-xinclude`` plays well with the excellent `django-template-partials `__
+package, to select specific partials on the target template.
+
+Advanced usage
+^^^^^^^^^^^^^^
+Below is a more complete example making use of the htmx `transition classes `__.
+Note the ``intersect once`` trigger, which will fire the request once when the element intersects the viewport.
+
+.. code-block:: html
+
+
+
+ {% xinclude "magic.html" with wand="🪄" hx-trigger="intersect once" swap-time="1s" settle-time="1s" %}
+
+ Loading...
+
+ {% endxinclude %}
+
+``magic.html``:
+
+.. code-block:: html
+
+
+
+
+ 🔮 {{ wand }}
+
+
+----
+
+You can preload the ``xinclude`` libary in every template by appending to your ``TEMPLATES`` ``builtins`` setting.
+This way you don't need to repeat the ``{% load xinclude %}`` in every template that you need the tag:
+
+.. code-block:: python
+
+ TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ ...,
+ "OPTIONS": {
+ "builtins": [
+ "django_xinclude.templatetags.xinclude",
+ ],
+ },
+ },
+ ]
+
+
+How It Works
+------------
+``django-xinclude`` first checks if it needs to render the target template synchronously;
+see the `Rendering synchronously <#rendering-synchronously>`__ section for cases where this might be useful.
+If this is not the case, it stores the current context and the target template to the cache and constructs a url
+with a ``fragment_id`` that targets an internal view. It then renders a parent ``div`` element containing all the
+necessary htmx attributes. Once the htmx request fires, the view fetches the cache context and template that match
+the passed ``fragment_id`` and uses that context to render the template.
+
+Cache
+^^^^^
+``django-xinclude`` uses either the cache that corresponds to the ``XINCLUDE_CACHE_ALIAS`` setting, if specified,
+or the ``CACHES["default"]``.
+When setting a new cache key, it finds unpickable values and discards them.
+If you want to see which keys get discarded, update your ``settings.LOGGERS`` to include ``"django_xinclude"``
+with ``"level": "DEBUG"``.
+
+| All official `Django cache backends `__ should work,
+ under one **important condition**:
+| Your cache should be accessible from all your app instances. If you are using multi-processing for your Django application,
+ or multiple servers clusters, make sure that your ``django-xinclude`` cache is accessible from all the instances,
+ otherwise your requests will result in 404s.
+
+Authorization
+^^^^^^^^^^^^^
+The request user is expected to be the one that initially accessed the original view (and added to cache),
+or ``AnonymousUser`` in both cases; otherwise ``django-xinclude`` will return 404 for the htmx requests.
+If ``request.user`` is not available, for instance when ``django.contrib.auth`` is not in the ``INSTALLED_APPS``,
+then ``django-xinclude`` assumes that the end user can access the data.
+
+Rendering synchronously
+^^^^^^^^^^^^^^^^^^^^^^^
+There are cases where you might want to conditionally render fragments synchronously (i.e. use the regular ``include``).
+For example, you could render synchronously for SEO purposes, when robots are crawling your pages, but still make use
+of the htmx functionality for regular users. ``django-xinclude`` supports this, it checks for a ``xinclude_sync``
+attribute on the request and renders synchronously if that evaluates to ``True``.
+So you can add a custom middleware that sets the ``xinclude_sync`` attribute upon your individual conditions.
+
+See also `Configuration <#configuration>`__ below for the ``XINCLUDE_SYNC_REQUEST_ATTR`` setting.
+
+Configuration
+-------------
+
+``XINCLUDE_CACHE_ALIAS: str``
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+The cache alias that ``django-xinclude`` will use, it defaults to ``CACHES["default"]``.
+
+``XINCLUDE_CACHE_TIMEOUT: int``
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+The number of seconds that contexts should be stored in the cache. If the setting is not present, Django will
+use the default timeout argument of the appropriate backend in the ``CACHES`` setting.
+
+``XINCLUDE_SYNC_REQUEST_ATTR: str``
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+The request attribute that ``django-xinclude`` will check on to determine if it needs to render synchronously.
+It defaults to ``xinclude_sync``.
+
+Running the tests
+-----------------
+
+Fork, then clone the repo:
+
+.. code-block:: sh
+
+ git clone git@github.com:your-username/django-xinclude.git
+
+Set up a venv:
+
+.. code-block:: sh
+
+ python -m venv .venv
+ source .venv/bin/activate
+ python -m pip install -e '.[tests,dev]'
+
+Set up the |pre-commit|__ hooks:
+
+.. |pre-commit| replace:: ``pre-commit``
+__ https://pre-commit.com/
+
+.. code-block:: sh
+
+ pre-commit install
+
+Then you can run the tests with the |just|__ command runner:
+
+.. |just| replace:: ``just``
+__ https://github.com/casey/just
+
+.. code-block:: sh
+
+ just test
+
+Or with coverage:
+
+.. code-block:: sh
+
+ just coverage
+
+If you don't have ``just`` installed, you can look in the ``justfile`` for the
+commands that are run.
+
+|
+
+Complementary packages
+----------------------
+* |django-htmx|__: Extensions for using Django with htmx.
+* |django-template-partials|__: Reusable named inline partials for the Django Template Language.
+
+.. |django-htmx| replace:: ``django-htmx``
+__ https://github.com/adamchainz/django-htmx
+
+.. |django-template-partials| replace:: ``django-template-partials``
+__ https://github.com/carltongibson/django-template-partials/
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..1f1ee6e
--- /dev/null
+++ b/justfile
@@ -0,0 +1,10 @@
+default:
+ just --list
+
+@test *options:
+ python -m pytest --ds=tests.settings {{ options }}
+
+@coverage:
+ coverage erase
+ coverage run -m django test --settings=tests.settings --pythonpath=.
+ coverage report
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..a6b3494
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,117 @@
+[build-system]
+requires = ["flit_core >=3.2,<4"]
+build-backend = "flit_core.buildapi"
+
+[project]
+name = "django-xinclude"
+authors = [{ name = "Giannis Terzopoulos", email = "terzo.giannis@gmail.com" }]
+readme = "README.rst"
+keywords = [
+ "django",
+ "templates",
+ "htmx",
+ "xhr",
+ "cache",
+]
+license = {file = "LICENSE"}
+requires-python = ">=3.10"
+classifiers = [
+ "Development Status :: 1 - Planning",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Framework :: Django",
+ "Framework :: Django :: 4.2",
+ "Framework :: Django :: 5.0",
+ "Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Typing :: Typed",
+]
+dynamic = ["version", "description"]
+dependencies = ["Django>=4.2"]
+
+[project.urls]
+Changelog = "https://github.com/g-nie/django-xinclude/blob/main/CHANGELOG.rst"
+Repository = "https://github.com/g-nie/django-xinclude"
+
+[project.optional-dependencies]
+tests = [
+ "pytest",
+ "pytest-django",
+ "coverage",
+]
+dev = [
+ "django-stubs[compatible-mypy]",
+ "ipython",
+ "ruff",
+]
+
+# TODO: This is not working for some reason:
+[tool.flit.sdist]
+include = [
+ "CHANGELOG.rst",
+ "LICENSE",
+ "README.rst",
+ "pyproject.toml"
+]
+
+[tool.ruff.lint]
+select = [
+ "E", "F", "I", "W", "N", "B", "A", "C4", "T20", "DJ", "UP",
+ "COM818", # trailing-comma-on-bare-tuple
+ "RUF006", # asyncio-dangling-task
+ "RUF013", # Implicit Optional
+ "RUF015", # Unnecessary iterable allocation for first element
+ "RUF017", # Avoid quadratic list summation
+ "RUF019", # Unnecessary key check
+ "RUF100", # Unused noqa directive
+]
+ignore = ["DJ008"] # Model does not define __str__ method
+
+[tool.ruff.lint.isort]
+required-imports = ["from __future__ import annotations"]
+combine-as-imports = true
+order-by-type = true
+no-lines-before = ["local-folder"]
+
+[tool.mypy]
+plugins = ["mypy_django_plugin.main"]
+mypy_path = "src/"
+namespace_packages = false
+show_error_codes = true
+strict = true
+warn_unreachable = true
+
+[[tool.mypy.overrides]]
+module = "tests.*"
+allow_untyped_defs = true
+allow_untyped_calls = true
+disable_error_code = ["var-annotated", "has-type"]
+
+[tool.django-stubs]
+django_settings_module = "tests.settings"
+
+[tool.pytest.ini_options]
+python_files = ["tests.py", "test_*.py"]
+django_find_project = false
+
+[tool.coverage.run]
+branch = true
+source = ["django_xinclude"]
+
+[tool.coverage.paths]
+source = ["src"]
+
+[tool.coverage.report]
+fail_under = 100
+show_missing = true
+ignore_errors = true
+exclude_also = [
+ # Don't complain if tests don't hit defensive assertion code:
+ "raise AssertionError",
+ "raise NotImplementedError",
+ # Nor complain about type checking
+ "if TYPE_CHECKING:",
+]
diff --git a/src/django_xinclude/__init__.py b/src/django_xinclude/__init__.py
new file mode 100644
index 0000000..e87ac64
--- /dev/null
+++ b/src/django_xinclude/__init__.py
@@ -0,0 +1,13 @@
+"""
+django-xinclude
+
+Render a template using htmx with the current context.
+"""
+
+from __future__ import annotations
+
+from django_xinclude.logger import logger
+
+__all__ = ["logger"]
+
+__version__ = "0.1.0"
diff --git a/src/django_xinclude/apps.py b/src/django_xinclude/apps.py
new file mode 100644
index 0000000..5c13007
--- /dev/null
+++ b/src/django_xinclude/apps.py
@@ -0,0 +1,11 @@
+from __future__ import annotations
+
+from django.apps import AppConfig
+
+
+class XincludeAppConfig(AppConfig):
+ name = "django_xinclude"
+ verbose_name = "django-xinclude"
+
+ def ready(self) -> None:
+ from django_xinclude import checks # noqa: F401
diff --git a/src/django_xinclude/cache.py b/src/django_xinclude/cache.py
new file mode 100644
index 0000000..3491399
--- /dev/null
+++ b/src/django_xinclude/cache.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+import pickle
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+from django.core.cache import caches
+
+from django_xinclude import logger
+from django_xinclude.conf import conf
+from django_xinclude.exceptions import FragmentNotFoundError
+
+if TYPE_CHECKING:
+ from django.contrib.auth.models import AbstractUser, AnonymousUser
+ from django.core.cache.backends.base import BaseCache
+
+
+class ContextCache:
+ @property
+ def cache(self) -> BaseCache:
+ return caches[conf.XINCLUDE_CACHE_ALIAS]
+
+ def get_pickled_context(
+ self, ctx: dict[str, Any], fragment_id: str
+ ) -> dict[str, Any]:
+ """Iterates over ``ctx`` to discard unpickable elements."""
+ safe_ctx = {}
+ discarded = []
+ for k, v in ctx.items():
+ try:
+ pickle.dumps(v)
+ except (pickle.PicklingError, TypeError):
+ discarded.append(k)
+ else:
+ safe_ctx[k] = v
+ if discarded:
+ logger.debug(
+ "The values for the following keys could not get pickled "
+ f"and thus were not stored in cache (fragment_id: {fragment_id}): "
+ f"{discarded}"
+ )
+ return safe_ctx
+
+ def set(self, key: str, val: dict[str, Any]) -> None:
+ """
+ Sets the cache ``key`` to ``val``.
+
+ It uses the ``XINCLUDE_CACHE_TIMEOUT`` setting if specified,
+ otherwise Django will use the timeout argument of the appropriate backend
+ in the CACHES setting.
+ """
+ if conf.XINCLUDE_CACHE_TIMEOUT is not None:
+ set_kwargs = {"timeout": conf.XINCLUDE_CACHE_TIMEOUT}
+ else:
+ set_kwargs = {}
+ self.cache.set(key, val, **set_kwargs)
+
+ def get(self, key: str) -> FragmentData:
+ data = self.cache.get(key)
+ if data is None:
+ raise FragmentNotFoundError()
+ return FragmentData(meta=data["meta"], context=data["context"])
+
+
+@dataclass
+class FragmentData:
+ meta: dict[str, Any]
+ context: dict[str, Any]
+
+ def __post_init__(self) -> None:
+ self.user: AbstractUser | AnonymousUser | None = self.meta.get("user")
+ self.template_names: list[str] = self.meta["template_names"]
+
+
+ctx_cache = ContextCache()
diff --git a/src/django_xinclude/checks.py b/src/django_xinclude/checks.py
new file mode 100644
index 0000000..c4761e6
--- /dev/null
+++ b/src/django_xinclude/checks.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from django.apps import AppConfig
+from django.conf import settings
+from django.core.checks import Error, Tags, register
+
+from django_xinclude.conf import conf
+
+if TYPE_CHECKING:
+ from django.core.checks.messages import CheckMessage
+
+
+@register(Tags.compatibility) # type: ignore[type-var]
+def check_settings(
+ app_configs: list[AppConfig] | None, **kwargs: Any
+) -> list[CheckMessage]:
+ checks: list[CheckMessage] = []
+ if conf.XINCLUDE_CACHE_TIMEOUT == 0:
+ msg = "XINCLUDE_CACHE_TIMEOUT setting should be greater than 0."
+ checks.append(Error(msg, id="django_xinclude.E001"))
+ if (alias := conf.XINCLUDE_CACHE_ALIAS) not in settings.CACHES:
+ msg = f'Cache alias "{alias}" not found in CACHES.'
+ checks.append(Error(msg, id="django_xinclude.E002"))
+ return checks
diff --git a/src/django_xinclude/conf.py b/src/django_xinclude/conf.py
new file mode 100644
index 0000000..434f826
--- /dev/null
+++ b/src/django_xinclude/conf.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from django.conf import settings
+
+
+class Settings:
+ @property
+ def XINCLUDE_CACHE_ALIAS(self) -> str: # noqa: N802
+ return getattr(settings, "XINCLUDE_CACHE_ALIAS", "default")
+
+ @property
+ def XINCLUDE_CACHE_TIMEOUT(self) -> int | None: # noqa: N802
+ # The number of seconds the value should be stored in the cache.
+ # If the setting is not present, Django will use the default timeout
+ # argument of the appropriate backend in the CACHES setting.
+ return getattr(settings, "XINCLUDE_CACHE_TIMEOUT", None)
+
+ @property
+ def XINCLUDE_SYNC_REQUEST_ATTR(self) -> str: # noqa: N802
+ return getattr(settings, "XINCLUDE_SYNC_REQUEST_ATTR", "xinclude_sync")
+
+
+conf = Settings()
diff --git a/src/django_xinclude/exceptions.py b/src/django_xinclude/exceptions.py
new file mode 100644
index 0000000..ab268f8
--- /dev/null
+++ b/src/django_xinclude/exceptions.py
@@ -0,0 +1,5 @@
+from __future__ import annotations
+
+
+class FragmentNotFoundError(Exception):
+ pass
diff --git a/src/django_xinclude/logger.py b/src/django_xinclude/logger.py
new file mode 100644
index 0000000..67d0c34
--- /dev/null
+++ b/src/django_xinclude/logger.py
@@ -0,0 +1,6 @@
+from __future__ import annotations
+
+import logging
+
+# Global logger.
+logger = logging.getLogger("django_xinclude")
diff --git a/src/django_xinclude/templates/django_xinclude/include.html b/src/django_xinclude/templates/django_xinclude/include.html
new file mode 100644
index 0000000..b9720b4
--- /dev/null
+++ b/src/django_xinclude/templates/django_xinclude/include.html
@@ -0,0 +1,4 @@
+
+ {% if primary_nodes %}{{ primary_nodes }}{% endif %}
+
diff --git a/src/django_xinclude/templatetags/__init__.py b/src/django_xinclude/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/django_xinclude/templatetags/xinclude.py b/src/django_xinclude/templatetags/xinclude.py
new file mode 100644
index 0000000..d567263
--- /dev/null
+++ b/src/django_xinclude/templatetags/xinclude.py
@@ -0,0 +1,181 @@
+from __future__ import annotations
+
+import uuid
+from typing import TYPE_CHECKING, Any
+
+from django.template.base import FilterExpression, Parser, Token, Variable
+from django.template.library import Library
+from django.template.loader_tags import IncludeNode, do_include
+from django.urls import reverse
+from django.utils.safestring import mark_safe
+
+from django_xinclude.cache import ctx_cache
+from django_xinclude.conf import conf
+
+if TYPE_CHECKING:
+ from collections.abc import KeysView
+
+ from django.contrib.auth.models import AbstractUser, AnonymousUser
+ from django.template.base import NodeList
+ from django.template.context import RequestContext
+ from django.utils.safestring import SafeString
+
+register = Library()
+
+
+class SpecialVariables:
+ _vars = {
+ "hx_trigger": "load once",
+ "swap_time": None,
+ "settle_time": None,
+ }
+
+ @classmethod
+ def make_context_defaults(cls, ctx: dict[str, Any], parser: Parser) -> None:
+ for key, val in cls._vars.items():
+ if val is not None:
+ ctx.setdefault(key, FilterExpression(f'"{val}"', parser))
+
+ @classmethod
+ def keys(cls) -> KeysView[str]:
+ return cls._vars.keys()
+
+ @classmethod
+ def raw_list(cls) -> list[str]:
+ return [k.replace("_", "-") for k in cls._vars.keys()]
+
+
+class HtmxIncludeNode(IncludeNode):
+ def __init__(
+ self,
+ template: FilterExpression,
+ target_template: FilterExpression,
+ primary_nodelist: NodeList,
+ *args: Any,
+ **kwargs: Any,
+ ):
+ super().__init__(template, *args, **kwargs)
+ self.target_template = target_template
+ self.primary_nodelist = primary_nodelist
+
+ def save_context(self, context: RequestContext, fragment_id: str) -> None:
+ """
+ Save the context data to the cache.
+ """
+ values = {
+ name: var.resolve(context)
+ for name, var in self.extra_context.items()
+ if name not in SpecialVariables.keys()
+ }
+ meta: dict[str, Any] = {"template_names": self.get_template_names(context)}
+ if user := self.get_user(context):
+ meta["user"] = user
+ if self.isolated_context: # using "only"
+ ctx = values
+ else:
+ with context.push(**values):
+ ctx = context.flatten() # type: ignore[assignment]
+ ctx = ctx_cache.get_pickled_context(ctx, fragment_id)
+ ctx_cache.set(fragment_id, {"meta": meta, "context": ctx})
+
+ def get_user(self, context: RequestContext) -> AbstractUser | AnonymousUser | None:
+ try:
+ return context.get("user") or context["request"].user # type: ignore[no-any-return]
+ except (KeyError, AttributeError):
+ return None
+
+ @property
+ def fragment_id(self) -> str:
+ return str(uuid.uuid4())
+
+ def sync_include(self, context: RequestContext) -> bool:
+ try:
+ return getattr(context.request, conf.XINCLUDE_SYNC_REQUEST_ATTR, False)
+ except AttributeError:
+ return False
+
+ def get_template_names(self, context: RequestContext) -> list[str]:
+ tmpt = self.target_template.var
+ if isinstance(tmpt, Variable):
+ tmpt = tmpt.resolve(context)
+ if isinstance(tmpt, str):
+ return [tmpt]
+ # tmpt should be an Iterable then
+ return tmpt # type: ignore[no-any-return]
+ return [tmpt]
+
+ def render(self, context: RequestContext) -> SafeString: # type: ignore[override]
+ if self.sync_include(context):
+ self.template = self.target_template
+ return super().render(context)
+ fragment_id = self.fragment_id
+ self.save_context(context, fragment_id)
+ context["url"] = (
+ f"{reverse('django_xinclude:xinclude')}?fragment_id={fragment_id}"
+ )
+ if self.primary_nodelist:
+ element = self.primary_nodelist.render(context)
+ context["primary_nodes"] = mark_safe(element.strip())
+ return super().render(context)
+
+
+@register.tag("xinclude")
+def do_xinclude(parser: Parser, token: Token) -> HtmxIncludeNode:
+ """
+ Render a template using htmx with the current context.
+ The context gets passed to the underlying view using the cache framework,.
+ Every feature of the regular ``include`` tag is supported.
+ You can use the following htmx-specfic arguments:
+ - ``hx-trigger``: corresponds to the "hx-trigger" htmx attribute.
+ Defaults to "load once".
+ - ``swap-time``: corresponds to the "swap" timing of the hx-swap htmx attribute.
+ - ``settle-time``: corresponds to the "settle" timing of the hx-swap
+ htmx attribute.
+
+ Example::
+
+ {% xinclude "foo.html" %}{% endxinclude %}
+ {% xinclude "foo.html" with foo="bar" only %}{% endxinclude %}
+
+ Use "primary nodes" to render initial content prior to htmx swapping.
+
+ {% xinclude "foo" hx-trigger="intersect once" swap-time="1s" settle-time="1s" %}
+ Loading...
+ {% endxinclude %}
+ """
+
+ bits = token.split_contents()
+ remaining_bits = []
+ options = {}
+ while bits:
+ # Find the xinclude-specific arguments and add the rest to remaining_bits
+ option = bits.pop(0)
+ try:
+ key, value = option.split("=", 1)
+ except ValueError:
+ remaining_bits.append(option)
+ continue
+ if key in SpecialVariables.raw_list():
+ options[key.replace("-", "_")] = FilterExpression(value, parser)
+ else:
+ remaining_bits.append(option)
+
+ token = Token(
+ token.token_type, " ".join(remaining_bits), token.position, token.lineno
+ )
+ node = do_include(parser, token) # the regular IncludeNode
+ template = FilterExpression('"django_xinclude/include.html"', parser)
+ context = {**node.extra_context, **options}
+ # add the variable defaults to context
+ SpecialVariables.make_context_defaults(context, parser)
+ # extract the primary element
+ primary_nodelist = parser.parse(["endxinclude"])
+ parser.delete_first_token()
+
+ return HtmxIncludeNode(
+ template,
+ target_template=node.template,
+ extra_context=context,
+ isolated_context=node.isolated_context,
+ primary_nodelist=primary_nodelist,
+ )
diff --git a/src/django_xinclude/urls.py b/src/django_xinclude/urls.py
new file mode 100644
index 0000000..5398ee2
--- /dev/null
+++ b/src/django_xinclude/urls.py
@@ -0,0 +1,11 @@
+from __future__ import annotations
+
+from django.urls import path
+
+from django_xinclude.views import XincludeView
+
+app_name = "django_xinclude"
+
+urlpatterns = [
+ path("", XincludeView.as_view(), name="xinclude"),
+]
diff --git a/src/django_xinclude/views.py b/src/django_xinclude/views.py
new file mode 100644
index 0000000..cddb0d7
--- /dev/null
+++ b/src/django_xinclude/views.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from django.http import Http404, HttpResponseServerError
+from django.template.exceptions import TemplateDoesNotExist
+from django.template.loader import select_template
+from django.views.generic import TemplateView
+
+from django_xinclude import logger
+from django_xinclude.cache import ctx_cache
+from django_xinclude.exceptions import FragmentNotFoundError
+
+if TYPE_CHECKING:
+ from django.http.request import HttpRequest
+ from django.http.response import HttpResponseBase
+
+
+class XincludeView(TemplateView):
+ def dispatch(
+ self, request: HttpRequest, *args: Any, **kwargs: Any
+ ) -> HttpResponseBase:
+ try:
+ fragment_id = request.GET["fragment_id"]
+ except KeyError:
+ raise Http404 # noqa: B904
+ try:
+ # noinspection PyAttributeOutsideInit
+ self.fragment = ctx_cache.get(fragment_id)
+ except (KeyError, TypeError):
+ raise Http404 # noqa: B904
+ except FragmentNotFoundError:
+ logger.debug(f"fragment_id: {fragment_id} not found in cache.")
+ return HttpResponseServerError()
+ self.authorize_user()
+ return super().dispatch(request, *args, **kwargs)
+
+ def authorize_user(self) -> None:
+ # The request user should be the one that initially accessed the parent view
+ # (and added to cache), or AnonymousUser in both cases;
+ # otherwise raise 404.
+ try:
+ if self.request.user != self.fragment.user:
+ raise Http404
+ except AttributeError:
+ pass
+
+ def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
+ return self.fragment.context
+
+ def get_template_names(self) -> list[str]:
+ try:
+ select_template(self.fragment.template_names)
+ except TemplateDoesNotExist:
+ raise Http404 # noqa: B904
+ return self.fragment.template_names
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/settings.py b/tests/settings.py
new file mode 100644
index 0000000..4594f5b
--- /dev/null
+++ b/tests/settings.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+sys.path.append("src")
+BASE_DIR = Path(__file__).resolve().parent
+
+ALLOWED_HOSTS: list[str] = []
+
+INSTALLED_APPS = [
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django_xinclude",
+ "tests",
+]
+
+MIDDLEWARE = [
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+]
+
+ROOT_URLCONF = "tests.urls"
+
+SECRET_KEY = "NOTASECRET"
+
+DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": BASE_DIR / "db.sqlite3",
+ }
+}
+
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.contrib.auth.context_processors.auth",
+ ],
+ "debug": True,
+ },
+ },
+]
diff --git a/tests/templates/tests/core.html b/tests/templates/tests/core.html
new file mode 100644
index 0000000..e62e6dd
--- /dev/null
+++ b/tests/templates/tests/core.html
@@ -0,0 +1,2 @@
+{% load xinclude %}
+{% xinclude "tests/partials/hello.html" %}{% endxinclude %}
diff --git a/tests/templates/tests/for_loop.html b/tests/templates/tests/for_loop.html
new file mode 100644
index 0000000..bbda7bb
--- /dev/null
+++ b/tests/templates/tests/for_loop.html
@@ -0,0 +1,4 @@
+{% load xinclude %}
+{% for i in "00" %}
+ {% xinclude "tests/partials/hello.html" %}{% endxinclude %}
+{% endfor %}
diff --git a/tests/templates/tests/partials/hello.html b/tests/templates/tests/partials/hello.html
new file mode 100644
index 0000000..e965047
--- /dev/null
+++ b/tests/templates/tests/partials/hello.html
@@ -0,0 +1 @@
+Hello
diff --git a/tests/test_checks.py b/tests/test_checks.py
new file mode 100644
index 0000000..4619bc8
--- /dev/null
+++ b/tests/test_checks.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from django.core.management import call_command
+from django.core.management.base import SystemCheckError
+from django.test import SimpleTestCase, override_settings
+
+
+class CheckTests(SimpleTestCase):
+ @override_settings(XINCLUDE_CACHE_TIMEOUT=0)
+ def test_cache_timeout_0(self):
+ message = (
+ "(django_xinclude.E001) XINCLUDE_CACHE_TIMEOUT setting "
+ "should be greater than 0."
+ )
+ with self.assertRaisesMessage(SystemCheckError, message):
+ call_command("check")
+
+ @override_settings(
+ CACHES={
+ "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
+ },
+ XINCLUDE_CACHE_ALIAS="other",
+ )
+ def test_invalid_cache_alias(self):
+ message = '(django_xinclude.E002) Cache alias "other" not found in CACHES.'
+ with self.assertRaisesMessage(SystemCheckError, message):
+ call_command("check")
diff --git a/tests/test_conf.py b/tests/test_conf.py
new file mode 100644
index 0000000..4b7e404
--- /dev/null
+++ b/tests/test_conf.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from django.test import SimpleTestCase
+from django.test.utils import override_settings
+from django_xinclude.conf import conf
+
+
+class ConfTests(SimpleTestCase):
+ def test_sync_include_request_attr_default(self):
+ self.assertEqual(conf.XINCLUDE_SYNC_REQUEST_ATTR, "xinclude_sync")
+
+ @override_settings(XINCLUDE_SYNC_REQUEST_ATTR="foo")
+ def test_sync_include_request_attr_override(self):
+ self.assertEqual(conf.XINCLUDE_SYNC_REQUEST_ATTR, "foo")
+
+ def test_cache_alias_default(self):
+ self.assertEqual(conf.XINCLUDE_CACHE_ALIAS, "default")
+
+ @override_settings(XINCLUDE_CACHE_ALIAS="custom")
+ def test_cache_alias_override(self):
+ self.assertEqual(conf.XINCLUDE_CACHE_ALIAS, "custom")
+
+ def test_cache_timeout_default(self):
+ self.assertEqual(conf.XINCLUDE_CACHE_TIMEOUT, None)
+
+ @override_settings(XINCLUDE_CACHE_TIMEOUT=100)
+ def test_cache_timeout_override(self):
+ self.assertEqual(conf.XINCLUDE_CACHE_TIMEOUT, 100)
diff --git a/tests/tests.py b/tests/tests.py
new file mode 100644
index 0000000..9501133
--- /dev/null
+++ b/tests/tests.py
@@ -0,0 +1,531 @@
+from __future__ import annotations
+
+import pickle
+import re
+import uuid
+from io import TextIOWrapper
+from typing import Any
+from unittest import mock
+
+from django.contrib.auth.models import AnonymousUser, User
+from django.core.cache import caches
+from django.core.cache.backends.redis import RedisCache
+from django.http import HttpRequest
+from django.template import engines
+from django.test import (
+ RequestFactory,
+ SimpleTestCase,
+ TestCase,
+ override_settings,
+)
+from django.urls import reverse
+from django_xinclude.cache import ctx_cache
+
+
+def get_include_html(fragment_id, primary_nodes=""):
+ return (
+ f"""\n """
+ f"""{primary_nodes}\n
"""
+ )
+
+
+def request_mock(**kwargs: Any) -> mock.MagicMock:
+ return mock.MagicMock(spec=HttpRequest, **kwargs)
+
+
+class CacheTests(SimpleTestCase):
+ def test_context_cache_alias_default(self):
+ self.assertIs(ctx_cache.cache, caches["default"])
+
+ @override_settings(
+ CACHES={
+ "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
+ "redis": {"BACKEND": "django.core.cache.backends.redis.RedisCache"},
+ },
+ XINCLUDE_CACHE_ALIAS="redis",
+ )
+ def test_context_cache_alias_override(self):
+ self.assertTrue(isinstance(ctx_cache.cache, RedisCache))
+
+ @mock.patch("django_xinclude.cache.ContextCache.cache")
+ def test_context_cache_timeout_default(self, cache):
+ ctx_cache.set("foo", {})
+ cache.set.assert_called_once_with("foo", {}) # no kwarg
+
+ @override_settings(XINCLUDE_CACHE_TIMEOUT=100)
+ @mock.patch("django_xinclude.cache.ContextCache.cache")
+ def test_context_cache_timeout_set(self, cache):
+ ctx_cache.set("foo", {})
+ cache.set.assert_called_once_with("foo", {}, timeout=100)
+
+
+class TemplateTagTests(SimpleTestCase):
+ fragment_id: str
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.fragment_id = str(uuid.uuid4())
+
+ def setUp(self):
+ super().setUp()
+ node_klass = "django_xinclude.templatetags.xinclude.HtmxIncludeNode"
+ fragment_patcher = mock.patch(
+ f"{node_klass}.fragment_id",
+ new_callable=mock.PropertyMock,
+ return_value=self.fragment_id,
+ )
+ self.fragment = fragment_patcher.start()
+ self.addCleanup(fragment_patcher.stop)
+ cache_patcher = mock.patch("django_xinclude.cache.ContextCache.cache")
+ self.cache = cache_patcher.start()
+ self.addCleanup(cache_patcher.stop)
+
+ def test_xinclude(self):
+ template = """
+ {% load xinclude %}
+ {% xinclude "tests/partials/hello.html" %}{% endxinclude %}
+ """
+ t = engines["django"].from_string(template)
+ rendered = t.render({"request": request_mock()}).strip()
+ self.assertEqual(rendered, get_include_html(self.fragment_id))
+
+ def test_cache_context(self):
+ template = """
+ {% load xinclude %}
+ {% with foo="bar" %}
+ {% xinclude "tests/partials/hello.html" with zoo="car" %}{% endxinclude %}
+ {% endwith %}
+ """
+ t = engines["django"].from_string(template)
+ t.render({"request": request_mock()})
+ self.cache.set.assert_called_once()
+ fragment_id, ctx = self.cache.set.mock_calls[0].args
+ self.assertEqual(fragment_id, self.fragment_id)
+ self.assertTrue({"foo": "bar", "zoo": "car"}.items() <= ctx["context"].items())
+ self.assertEqual(ctx["meta"]["template_names"], ["tests/partials/hello.html"])
+
+ def test_skips_unpickable_objects(self):
+ template = """
+ {% load xinclude %}
+ {% xinclude "tests/partials/hello.html" %}{% endxinclude %}
+ """
+ t = engines["django"].from_string(template)
+ with mock.patch("django_xinclude.cache.logger") as logger:
+ t.render({"a": mock.MagicMock(), "b": mock.MagicMock()})
+ ctx = self.cache.set.mock_calls[0].args[1]["context"]
+ self.assertNotIn("a", ctx)
+ self.assertNotIn("b", ctx)
+ logger.debug.assert_called_once_with(
+ "The values for the following keys could not get pickled and thus were not "
+ f"stored in cache (fragment_id: {self.fragment_id}): ['a', 'b']"
+ )
+
+ def test_pickling_typerror_is_tolerated(self):
+ unp = TextIOWrapper(mock.MagicMock())
+ self.assertRaises(TypeError, lambda: pickle.dumps(unp))
+ template = """
+ {% load xinclude %}
+ {% xinclude "tests/partials/hello.html" %}{% endxinclude %}
+ """
+ t = engines["django"].from_string(template)
+ req = RequestFactory().get("")
+ req.user = AnonymousUser()
+ t.render({"request": req, "zoo": unp})
+ ctx = self.cache.set.mock_calls[0].args[1]
+ self.assertIn("user", ctx["meta"])
+ self.assertNotIn("zoo", ctx)
+
+ def test_context_only(self):
+ template = """
+ {% load xinclude %}
+ {% with foo="bar" %}
+ {% xinclude "tests/partials/hello.html" with zoo="car" only %}{% endxinclude %}
+ {% endwith %}
+ """
+ t = engines["django"].from_string(template)
+ t.render({"request": request_mock()})
+ ctx = self.cache.set.mock_calls[0].args[1]["context"]
+ self.assertEqual(ctx["zoo"], "car")
+ self.assertNotIn("foo", ctx)
+
+ def test_trigger_may_be_passed(self):
+ template = """
+ {% load xinclude %}
+ {% xinclude "tests/partials/hello.html" hx-trigger="intersect once" %}
+ {% endxinclude %}
+ """
+ t = engines["django"].from_string(template)
+ self.assertIn(
+ 'hx-trigger="intersect once"', t.render({"request": request_mock()})
+ )
+
+ def test_hx_vars_not_passed_to_cache(self):
+ template = """
+ {% load xinclude %}
+ {% xinclude "tests/partials/hello.html" hx-trigger="intersect once" %}
+ {% endxinclude %}
+ """
+ t = engines["django"].from_string(template)
+ t.render({"request": request_mock()})
+ ctx = self.cache.set.mock_calls[0].args[1]
+ self.assertNotIn("hx_trigger", ctx)
+
+ def test_with_and_trigger(self):
+ template = (
+ """
+ {% load xinclude %}
+ {% xinclude "tests/partials/hello.html" """
+ + """hx-trigger="intersect once" with foo="bar" %}{% endxinclude %}
+ """
+ )
+ t = engines["django"].from_string(template)
+ self.assertIn(
+ 'hx-trigger="intersect once"', t.render({"request": request_mock()})
+ )
+ self.cache.set.assert_called_once()
+ ctx = self.cache.set.mock_calls[0].args[1]["context"]
+ self.assertTrue({"foo": "bar"}.items() <= ctx.items())
+
+ def test_only_and_trigger(self):
+ template = (
+ """
+ {% load xinclude %}
+ {% with foo="bar" %}
+ {% xinclude "tests/partials/hello.html" """
+ + """hx-trigger="intersect once" with zoo="car" only %}{% endxinclude %}
+ {% endwith %}
+ """
+ )
+ t = engines["django"].from_string(template)
+ rendered = t.render({"request": request_mock()})
+ self.assertIn('hx-trigger="intersect once"', rendered)
+ self.cache.set.assert_called_once()
+ ctx = self.cache.set.mock_calls[0].args[1]["context"]
+ self.assertTrue({"zoo": "car"}.items() <= ctx.items())
+ self.assertNotIn("foo", ctx.keys())
+
+ def test_timing(self):
+ template = """
+ {% load xinclude %}
+ {% xinclude "tests/partials/hello.html" swap-time="1s" settle-time="2s" %}
+ {% endxinclude %}
+ """
+ t = engines["django"].from_string(template)
+ self.assertIn(
+ 'hx-swap="outerHTML swap:1s settle:2s"',
+ t.render({"request": request_mock()}),
+ )
+
+ def test_sync_include(self):
+ template = """
+ {% load xinclude %}
+ {% xinclude "tests/partials/hello.html" hx-trigger="intersect once" %}
+ {% endxinclude %}
+ """
+ t = engines["django"].from_string(template)
+ rendered = t.render(request=mock.MagicMock(xinclude_sync=True)).strip()
+ self.assertEqual(rendered, "Hello")
+
+ def test_xinclude_template_variable(self):
+ template = """
+ {% load xinclude %}
+ {% xinclude template_name %}{% endxinclude %}
+ """
+ t = engines["django"].from_string(template)
+ rendered = t.render(
+ {"template_name": "tests/partials/hello.html", "request": request_mock()}
+ ).strip()
+ self.assertEqual(rendered, get_include_html(self.fragment_id))
+
+ def test_xinclude_template_variable_iterable(self):
+ template = """
+ {% load xinclude %}
+ {% xinclude template_name %}{% endxinclude %}
+ """
+ t = engines["django"].from_string(template)
+ rendered = t.render(
+ {"template_name": ["tests/partials/hello.html"], "request": request_mock()}
+ ).strip()
+ self.assertEqual(rendered, get_include_html(self.fragment_id))
+ ctx = self.cache.set.mock_calls[0].args[1]
+ self.assertEqual(ctx["meta"]["template_names"], ["tests/partials/hello.html"])
+
+ def test_for_loop_include(self):
+ template = """
+ {% load xinclude %}
+ {% for i in "00" %}
+ {% xinclude "tests/partials/hello.html" %}{% endxinclude %}
+ {% endfor %}
+ """
+ self.fragment.side_effect = ["fr1", "fr2"]
+ t = engines["django"].from_string(template)
+ rendered = t.render({"request": request_mock()})
+ fragment_ids = re.findall(r'\?fragment_id=([\w-]+)"', rendered)
+ self.assertEqual(fragment_ids, ["fr1", "fr2"])
+
+ def test_primary_nodes(self):
+ template = """
+ {% load xinclude %}
+ {% xinclude "tests/partials/hello.html" %}
+ Processing
+ {% with foo="bar" %}foo
{% endwith %}
+ {% endxinclude %}
+ """
+ t = engines["django"].from_string(template)
+ rendered = t.render({}).strip()
+ pr_el = 'Processing
\n foo
'
+ self.assertEqual(rendered, get_include_html(self.fragment_id, pr_el))
+
+
+class ViewTests(SimpleTestCase):
+ def setUp(self):
+ super().setUp()
+ self.anon = AnonymousUser()
+ cache_patcher = mock.patch("django_xinclude.cache.ContextCache.cache")
+ self.cache = cache_patcher.start()
+ self.addCleanup(cache_patcher.stop)
+
+ def test_view_renders_passed_template(self):
+ self.cache.get.return_value = {
+ "meta": {
+ "user": self.anon,
+ "template_names": ["tests/partials/hello.html"],
+ },
+ "context": {},
+ }
+ url = "/__xinclude__/?fragment_id=abc123"
+ response = self.client.get(url)
+ self.assertTemplateUsed(response, "tests/partials/hello.html")
+ self.assertEqual(response.rendered_content, "Hello\n") # type: ignore[attr-defined]
+
+ def test_view_context(self):
+ self.cache.get.return_value = {
+ "meta": {
+ "user": self.anon,
+ "template_names": ["tests/partials/hello.html"],
+ },
+ "context": {"foo": "bar"},
+ }
+ url = "/__xinclude__/?fragment_id=abc123"
+ response = self.client.get(url)
+ self.assertEqual(response.context_data, {"foo": "bar"}) # type: ignore[attr-defined]
+
+ def test_missing_context(self):
+ self.cache.get.return_value = None
+ url = "/__xinclude__/?fragment_id=missing"
+ with mock.patch("django_xinclude.views.logger") as logger:
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 500)
+ logger.debug.assert_called_once_with("fragment_id: missing not found in cache.")
+
+ def test_missing_meta(self):
+ self.cache.get.return_value = {}
+ url = "/__xinclude__/?fragment_id=abc123"
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 404)
+
+ def test_missing_meta_user(self):
+ self.cache.get.return_value = {
+ "meta": {"template_names": ["tests/partials/hello.html"]},
+ "context": {},
+ }
+ url = "/__xinclude__/?fragment_id=abc123"
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 404)
+
+ def test_missing_fragment_id(self):
+ url = "/__xinclude__/"
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 404)
+
+ def test_missing_template_404(self):
+ self.cache.get.return_value = {"meta": {"user": self.anon}, "context": {}}
+ url = "/__xinclude__/?fragment_id=abc123"
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 404)
+
+ def test_only_1_fragment_id_allowed(self):
+ self.cache.get.return_value = {"meta": {"user": self.anon}, "context": {}}
+ url = "/__xinclude__/?fragment_id=abc123&fragment_id=abc123"
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 404)
+
+ def test_inexistent_template_404(self):
+ self.cache.get.return_value = {
+ "meta": {"user": self.anon, "template_names": ["inexistent"]},
+ "context": {},
+ }
+ url = "/__xinclude__/?fragment_id=abc123"
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 404)
+
+ def test_missing_context_404(self):
+ self.cache.get.return_value = {
+ "meta": {"user": self.anon, "template_names": ["tests/partials/hello.html"]}
+ }
+ url = "/__xinclude__/?fragment_id=abc123"
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 404)
+
+ def test_multiple_templates_inexistent_first(self):
+ self.cache.get.return_value = {
+ "meta": {
+ "user": self.anon,
+ "template_names": ["inexistent", "tests/partials/hello.html"],
+ },
+ "context": {},
+ }
+ url = "/__xinclude__/?fragment_id=abc123"
+ response = self.client.get(url)
+ self.assertTemplateUsed(response, "tests/partials/hello.html")
+ self.assertEqual(response.rendered_content, "Hello\n") # type: ignore[attr-defined]
+
+ def test_multiple_templates_inexistent_second(self):
+ self.cache.get.return_value = {
+ "meta": {
+ "user": self.anon,
+ "template_names": ["tests/partials/hello.html", "inexistent"],
+ },
+ "context": {},
+ }
+ url = "/__xinclude__/?fragment_id=abc123"
+ response = self.client.get(url)
+ self.assertTemplateUsed(response, "tests/partials/hello.html")
+ self.assertEqual(response.rendered_content, "Hello\n") # type: ignore[attr-defined]
+
+
+def get_templates_settings(context_processors: list[str]) -> list[dict[str, Any]]:
+ return [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "APP_DIRS": True,
+ "OPTIONS": {"context_processors": context_processors},
+ }
+ ]
+
+
+class AuthTests(TestCase):
+ def setUp(self):
+ super().setUp()
+ cache_patcher = mock.patch("django_xinclude.cache.ContextCache.cache")
+ self.cache = cache_patcher.start()
+ self.addCleanup(cache_patcher.stop)
+
+ def test_tag_stores_authed_user_in_cache(self):
+ user = User.objects.create()
+ self.client.force_login(user)
+ self.client.get(reverse("core"))
+ ctx = self.cache.set.mock_calls[0].args[1]
+ self.assertEqual(ctx["meta"]["user"], user)
+
+ def test_tag_stores_anon_user_in_cache(self):
+ self.client.get(reverse("core"))
+ ctx = self.cache.set.mock_calls[0].args[1]
+ self.assertEqual(ctx["meta"]["user"], AnonymousUser())
+
+ @override_settings(
+ INSTALLED_APPS=["django.contrib.auth", "django_xinclude", "tests"],
+ TEMPLATES=get_templates_settings(
+ ["django.template.context_processors.request"]
+ ),
+ )
+ def test_tag_store_user_minimal_processors_1(self):
+ self.client.get(reverse("core"))
+ ctx = self.cache.set.mock_calls[0].args[1]
+ self.assertEqual(ctx["meta"]["user"], AnonymousUser())
+
+ @override_settings(
+ INSTALLED_APPS=["django.contrib.auth", "django_xinclude", "tests"],
+ TEMPLATES=get_templates_settings(
+ ["django.contrib.auth.context_processors.auth"]
+ ),
+ )
+ def test_tag_store_user_minimal_processors_2(self):
+ self.client.get(reverse("core"))
+ ctx = self.cache.set.mock_calls[0].args[1]
+ self.assertEqual(ctx["meta"]["user"], AnonymousUser())
+
+ @override_settings(
+ INSTALLED_APPS=["django_xinclude", "tests"],
+ TEMPLATES=get_templates_settings(
+ ["django.contrib.auth.context_processors.auth"]
+ ),
+ MIDDLEWARE=[],
+ )
+ def test_tag_store_user_minimal_processors_3(self):
+ self.client.get(reverse("core"))
+ ctx = self.cache.set.mock_calls[0].args[1]
+ self.assertEqual(ctx["meta"]["user"], AnonymousUser())
+
+ @override_settings(
+ INSTALLED_APPS=["django_xinclude", "tests"],
+ TEMPLATES=get_templates_settings([]),
+ MIDDLEWARE=[],
+ )
+ def test_tag_may_not_store_user_1(self):
+ self.client.get(reverse("core"))
+ ctx = self.cache.set.mock_calls[0].args[1]
+ self.assertNotIn("user", ctx["meta"])
+
+ @override_settings(
+ INSTALLED_APPS=["django_xinclude", "tests"],
+ TEMPLATES=get_templates_settings(
+ ["django.template.context_processors.request"]
+ ),
+ MIDDLEWARE=[],
+ )
+ def test_tag_may_not_store_user_2(self):
+ self.client.get(reverse("core"))
+ ctx = self.cache.set.mock_calls[0].args[1]
+ self.assertNotIn("user", ctx["meta"])
+
+ def test_view_404_for_different_user(self):
+ self.cache.get.return_value = {
+ "meta": {
+ "user": mock.MagicMock(pk=10),
+ "template_names": ["tests/partials/hello.html"],
+ },
+ "context": {},
+ }
+ user = User.objects.create(id=99)
+ self.client.force_login(user)
+ url = "/__xinclude__/?fragment_id=abc123"
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 404)
+
+ def test_view_200_for_same_user(self):
+ user = User.objects.create(id=99)
+ self.cache.get.return_value = {
+ "meta": {"user": user, "template_names": ["tests/partials/hello.html"]},
+ "context": {},
+ }
+ self.client.force_login(user)
+ url = "/__xinclude__/?fragment_id=abc123"
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ @override_settings(INSTALLED_APPS=["django_xinclude", "tests"], MIDDLEWARE=[])
+ def test_view_cache_no_user(self):
+ # If auth is disabled altogether, then we assume that
+ # the user can access the view.
+ self.cache.get.return_value = {
+ "meta": {"template_names": ["tests/partials/hello.html"]},
+ "context": {},
+ }
+ url = "/__xinclude__/?fragment_id=abc123"
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ def test_cache_no_user_and_view_user(self):
+ # Not sure if this can even happen, but we should raise 404 if it does.
+ self.cache.get.return_value = {
+ "meta": {"template_names": ["tests/partials/hello.html"]},
+ "context": {},
+ }
+ user = User.objects.create(id=99)
+ self.client.force_login(user)
+ url = "/__xinclude__/?fragment_id=abc123"
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 404)
diff --git a/tests/urls.py b/tests/urls.py
new file mode 100644
index 0000000..dde0a49
--- /dev/null
+++ b/tests/urls.py
@@ -0,0 +1,11 @@
+from __future__ import annotations
+
+from django.urls import include, path
+
+from tests import views
+
+urlpatterns = [
+ path("__xinclude__/", include("django_xinclude.urls")),
+ path("core/", views.CoreView.as_view(), name="core"),
+ path("for_loop/", views.ForLoopView.as_view(), name="for_loop"),
+]
diff --git a/tests/views.py b/tests/views.py
new file mode 100644
index 0000000..706da1a
--- /dev/null
+++ b/tests/views.py
@@ -0,0 +1,11 @@
+from __future__ import annotations
+
+from django.views.generic import TemplateView
+
+
+class CoreView(TemplateView):
+ template_name = "tests/core.html"
+
+
+class ForLoopView(TemplateView):
+ template_name = "tests/for_loop.html"