pax_global_header00006660000000000000000000000064151124266170014517gustar00rootroot0000000000000052 comment=7416a03816ed7478beafbb55d296591278e08059 django-htmx-1.27.0/000077500000000000000000000000001511242661700140265ustar00rootroot00000000000000django-htmx-1.27.0/.editorconfig000066400000000000000000000003451511242661700165050ustar00rootroot00000000000000# http://editorconfig.org root = true [*] indent_style = space indent_size = 2 trim_trailing_whitespace = true insert_final_newline = true charset = utf-8 end_of_line = lf [*.py] indent_size = 4 [Makefile] indent_style = tab django-htmx-1.27.0/.github/000077500000000000000000000000001511242661700153665ustar00rootroot00000000000000django-htmx-1.27.0/.github/CODE_OF_CONDUCT.md000066400000000000000000000001311511242661700201600ustar00rootroot00000000000000This project follows [Django's Code of Conduct](https://www.djangoproject.com/conduct/). django-htmx-1.27.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001511242661700175515ustar00rootroot00000000000000django-htmx-1.27.0/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000000341511242661700215360ustar00rootroot00000000000000blank_issues_enabled: false django-htmx-1.27.0/.github/ISSUE_TEMPLATE/feature-request.yml000066400000000000000000000004111511242661700234110ustar00rootroot00000000000000name: Feature Request description: Request an enhancement or new feature. body: - type: textarea id: description attributes: label: Description description: Please describe your feature request with appropriate detail. validations: required: true django-htmx-1.27.0/.github/ISSUE_TEMPLATE/issue.yml000066400000000000000000000015271511242661700214310ustar00rootroot00000000000000name: Issue description: File an issue body: - type: input id: python_version attributes: label: Python Version description: Which version of Python were you using? placeholder: 3.14.0 validations: required: false - type: input id: django_version attributes: label: Django Version description: Which version of Django were you using? placeholder: 3.2.0 validations: required: false - type: input id: package_version attributes: label: Package Version description: Which version of this package were you using? If not the latest version, please check this issue has not since been resolved. placeholder: 1.0.0 validations: required: false - type: textarea id: description attributes: label: Description description: Please describe your issue. validations: required: true django-htmx-1.27.0/.github/SECURITY.md000066400000000000000000000001011511242661700171470ustar00rootroot00000000000000Please report security issues directly over email to me@adamj.eu django-htmx-1.27.0/.github/dependabot.yml000066400000000000000000000002471511242661700202210ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: github-actions directory: "/" groups: "GitHub Actions": patterns: - "*" schedule: interval: monthly django-htmx-1.27.0/.github/workflows/000077500000000000000000000000001511242661700174235ustar00rootroot00000000000000django-htmx-1.27.0/.github/workflows/main.yml000066400000000000000000000047671511242661700211100ustar00rootroot00000000000000name: CI on: push: branches: - main tags: - '**' pull_request: concurrency: group: ${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: tests: name: Python ${{ matrix.python-version }} runs-on: ubuntu-24.04 strategy: matrix: python-version: - '3.10' - '3.11' - '3.12' - '3.13' - '3.14' steps: - uses: actions/checkout@v5 - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true - name: Run tox targets for ${{ matrix.python-version }} run: uvx --with tox-uv tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) - name: Upload coverage data uses: actions/upload-artifact@v5 with: name: coverage-data-${{ matrix.python-version }} path: '${{ github.workspace }}/.coverage.*' include-hidden-files: true if-no-files-found: error coverage: name: Coverage runs-on: ubuntu-24.04 needs: tests steps: - uses: actions/checkout@v5 - uses: actions/setup-python@v6 with: python-version: '3.13' - name: Install uv uses: astral-sh/setup-uv@v7 - name: Install dependencies run: uv pip install --system coverage[toml] - name: Download data uses: actions/download-artifact@v6 with: path: ${{ github.workspace }} pattern: coverage-data-* merge-multiple: true - name: Combine coverage and fail if it's <100% run: | python -m coverage combine python -m coverage html --skip-covered --skip-empty python -m coverage report --fail-under=100 echo "## Coverage summary" >> $GITHUB_STEP_SUMMARY python -m coverage report --format=markdown >> $GITHUB_STEP_SUMMARY - name: Upload HTML report if: ${{ failure() }} uses: actions/upload-artifact@v5 with: name: html-report path: htmlcov release: needs: [coverage] if: success() && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-24.04 environment: release permissions: contents: read id-token: write steps: - uses: actions/checkout@v5 - uses: astral-sh/setup-uv@v7 - name: Build run: uv build - uses: pypa/gh-action-pypi-publish@release/v1 django-htmx-1.27.0/.gitignore000066400000000000000000000001351511242661700160150ustar00rootroot00000000000000*.egg-info/ *.pyc /.coverage /.coverage.* /.tox /build/ /dist/ /docs/_build/ /example/.venv/ django-htmx-1.27.0/.pre-commit-config.yaml000066400000000000000000000044131511242661700203110ustar00rootroot00000000000000ci: autoupdate_schedule: monthly default_language_version: python: python3.13 repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.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: | (?x)^( example/example/static/ext/debug\.js |src/django_htmx/static/django_htmx/htmx\.min\.js )$ - id: trailing-whitespace - repo: https://github.com/crate-ci/typos rev: b04a3e939a8f2800a1dc330d7e569e7557879d41 # frozen: v1 hooks: - id: typos exclude: | (?x)^( .*\.min\.js |.*\.svg )$ - repo: https://github.com/tox-dev/pyproject-fmt rev: 68b1ed526e7533ac54a2e42874b99ae6c26807a2 # frozen: v2.11.0 hooks: - id: pyproject-fmt - repo: https://github.com/tox-dev/tox-ini-fmt rev: be26ee0d710a48f7c1acc1291d84082036207bd3 # frozen: 1.7.0 hooks: - id: tox-ini-fmt - repo: https://github.com/rstcheck/rstcheck rev: 27258fde1ee7d3b1e6a7bbc58f4c7b1dd0e719e5 # frozen: v6.2.5 hooks: - id: rstcheck additional_dependencies: - sphinx==8.1.3 - tomli==2.2.1 - repo: https://github.com/sphinx-contrib/sphinx-lint rev: e24bb0e1a3d80232373e49ca855721ec44e6340f # frozen: v1.0.1 hooks: - id: sphinx-lint - repo: https://github.com/adamchainz/django-upgrade rev: 553731fe59437e0bd2cf18b10144116422bed259 # frozen: 1.29.1 hooks: - id: django-upgrade - repo: https://github.com/adamchainz/blacken-docs rev: dda8db18cfc68df532abf33b185ecd12d5b7b326 # frozen: 1.20.0 hooks: - id: blacken-docs additional_dependencies: - black==25.1.0 - repo: https://github.com/astral-sh/ruff-pre-commit rev: aad66557af3b56ba6d4d69cd1b6cba87cef50cbb # frozen: v0.14.3 hooks: - id: ruff-check args: [ --fix ] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: 9f70dc58c23dfcca1b97af99eaeee3140a807c7e # frozen: v1.18.2 hooks: - id: mypy additional_dependencies: - django-stubs==5.1.2 - types-python-dateutil - repo: https://github.com/adamchainz/djade-pre-commit rev: 47481957f135f6af9121b2af9c415155d260cc8e # frozen: 1.6.0 hooks: - id: djade django-htmx-1.27.0/.readthedocs.yaml000066400000000000000000000011071511242661700172540ustar00rootroot00000000000000# .readthedocs.yml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details version: 2 build: os: ubuntu-24.04 tools: python: "3.14" jobs: pre_create_environment: - asdf plugin add uv - asdf install uv latest - asdf global uv latest create_environment: - uv venv "${READTHEDOCS_VIRTUALENV_PATH}" install: - UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv sync --frozen --group docs sphinx: configuration: docs/conf.py fail_on_warning: true formats: all django-htmx-1.27.0/.typos.toml000066400000000000000000000004131511242661700161550ustar00rootroot00000000000000# Configuration file for 'typos' tool # https://github.com/crate-ci/typos [default] extend-ignore-re = [ # Single line ignore comments "(?Rm)^.*(#|//)\\s*typos: ignore$", # Multi-line ignore comments "(?s)(#|//)\\s*typos: off.*?\\n\\s*(#|//)\\s*typos: on" ] django-htmx-1.27.0/HISTORY.rst000066400000000000000000000001001511242661700157100ustar00rootroot00000000000000See https://django-htmx.readthedocs.io/en/latest/changelog.html django-htmx-1.27.0/LICENSE000066400000000000000000000020551511242661700150350ustar00rootroot00000000000000MIT License Copyright (c) 2020 Adam Johnson 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. django-htmx-1.27.0/MANIFEST.in000066400000000000000000000001541511242661700155640ustar00rootroot00000000000000include LICENSE include pyproject.toml include README.rst include src/*/py.typed recursive-include src *.js django-htmx-1.27.0/README.rst000066400000000000000000000024051511242661700155160ustar00rootroot00000000000000=========== django-htmx =========== .. image:: https://img.shields.io/readthedocs/django-htmx?style=for-the-badge :target: https://django-htmx.readthedocs.io/en/latest/ .. image:: https://img.shields.io/github/actions/workflow/status/adamchainz/django-htmx/main.yml.svg?branch=main&style=for-the-badge :target: https://github.com/adamchainz/django-htmx/actions?workflow=CI .. image:: https://img.shields.io/badge/Coverage-100%25-success?style=for-the-badge :target: https://github.com/adamchainz/django-htmx/actions?workflow=CI .. image:: https://img.shields.io/pypi/v/django-htmx.svg?style=for-the-badge :target: https://pypi.org/project/django-htmx/ .. image:: https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge :target: https://github.com/psf/black .. image:: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white&style=for-the-badge :target: https://github.com/pre-commit/pre-commit :alt: pre-commit ---- .. figure:: https://raw.githubusercontent.com/adamchainz/django-htmx/main/docs/_static/logo.svg :alt: django-htmx logo :align: center Extensions for using Django with `htmx `__. Documentation ------------- Please see https://django-htmx.readthedocs.io/. django-htmx-1.27.0/docs/000077500000000000000000000000001511242661700147565ustar00rootroot00000000000000django-htmx-1.27.0/docs/Makefile000066400000000000000000000011771511242661700164240ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= "-W" SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) django-htmx-1.27.0/docs/_static/000077500000000000000000000000001511242661700164045ustar00rootroot00000000000000django-htmx-1.27.0/docs/_static/logo.svg000066400000000000000000000263731511242661700201000ustar00rootroot00000000000000 django-htmx-1.27.0/docs/changelog.rst000066400000000000000000000244231511242661700174440ustar00rootroot00000000000000========= Changelog ========= 1.27.0 (2025-11-28) ------------------- * Drop Python 3.9 support. * Fix CSP nonce support in the template tags when they’re the first use of ``csp_nonce``. `PR #572 `__. 1.26.0 (2025-09-22) ------------------- * The :ref:`django-htmx-extension-script` now displays responses with status codes 400 (bad request) and 403 (forbidden), like the existing support for codes 404 and 500. This change can help you debug `Issue #521 `__. * Add :func:`.reselect` to set the ``HX-Reselect`` header. `Issue #559 `__. * Improve typing of :func:`.reswap` to only accept valid HTMX swap methods. Thanks to Thibaut Decombe in `PR #555 `__. * Prevent :class:`.HttpResponseClientRedirect` from being called with ``preserve_request=True``, which was added to `redirect responses `__ in Django 5.2. It doesn’t make sense in the context of a client-side redirect, which always returns a status code of 200, and would crash anyway. `Issue #517 `__. 1.25.0 (2025-09-18) ------------------- * Support Django 6.0. * Add Content Security Policy (CSP) nonce support to the template tags. Thanks to waifudegen for the report in `Issue #542 `__. 1.24.1 (2025-09-11) ------------------- * Upgrade the vendored htmx to `version 2.0.7 `__. 1.24.0 (2025-09-10) ------------------- * Support Python 3.14. * Fix crashes in the extension script for custom error pages. Thanks to S Foster for the report in `Issue #546 `__. 1.23.2 (2025-06-27) ------------------- * Upgrade the vendored htmx to `version 2.0.6 `__. 1.23.1 (2025-06-21) ------------------- * Upgrade the vendored htmx to `version 2.0.5 `__. 1.23.0 (2025-03-14) ------------------- * Vendor htmx. You can now render an htmx script tag in your templates with: .. code-block:: django {% load django_htmx %} {% htmx_script %} No need to include htmx in your project separately. See :doc:`template_tags` for more information. 1.22.0 (2025-02-06) ------------------- * Support Django 5.2. 1.21.0 (2024-10-27) ------------------- * Drop Django 3.2 to 4.1 support. 1.20.0 (2024-10-25) ------------------- * Drop Python 3.8 support. * Support Python 3.13. * Updated :ref:`the partial rendering tip ` to cover using django-template-partials. Thanks to Carlton Gibson in `PR #413 `__. 1.19.0 (2024-08-05) ------------------- * Add :func:`django_htmx.http.replace_url` for setting the ``HX-Replace-URL`` header. Thanks to Bogumil Schube in `PR #396 `__. * Add ``select`` parameter to :class:`.HttpResponseLocation`. Thanks to Nikola Anović in `PR #462 `__. * Add documentation notes under :class:`.HtmxMiddleware`, covering setting the ``Vary`` header for caching and type hinting ``request.htmx``. 1.18.0 (2024-06-19) ------------------- * Support Django 5.1. 1.17.3 (2024-03-01) ------------------- * Change ``reswap()`` type hint for ``method`` to ``str``. Thanks to Dan Jacob for the report in `Issue #421 `__ and fix in `PR #422 `__. 1.17.2 (2023-11-16) ------------------- * Fix asgiref dependency declaration. 1.17.1 (2023-11-14) ------------------- * Fix ASGI compatibility on Python 3.12. Thanks to Grigory Vydrin for the report in `Issue #381 `__. 1.17.0 (2023-10-11) ------------------- * Support Django 5.0. 1.16.0 (2023-07-10) ------------------- * Drop Python 3.7 support. * Remove the unnecessary ``type`` attribute on the `` {% django_htmx_script %} ... On Django 6.0+, the `` {{ django_htmx_script() }} ... To use a CSP nonce, pass it to the function as ``nonce``: .. code-block:: jinja {{ django_htmx_script(nonce=csp_nonce) }} .. _django-htmx-extension-script: django-htmx extension script ---------------------------- This script, rendered by either of the above template tags when ``settings.DEBUG`` is ``True``, extends htmx with an error handler. htmx’s default behaviour when encountering an HTTP error is to discard the response content, which can make it hard to debug errors. This script adds an error handler that detects responses with 400, 403, 404, and 500 status codes and replaces the page with their content. This change exposes Django’s default error responses, allowing you to debug as you would for a non-htmx request. See the script in action in the “Error Demo” section of the :doc:`example project `. See its source `on GitHub `__. django-htmx-1.27.0/docs/tips.rst000066400000000000000000000160321511242661700164710ustar00rootroot00000000000000Tips ==== This page contains some tips for using htmx with Django. .. _tips-csrf-token: Make htmx pass Django’s CSRF token ---------------------------------- If you use htmx to make requests with “unsafe” methods, such as POST via `hx-post `__, you will need to make htmx cooperate with Django’s `Cross Site Request Forgery (CSRF) protection `__. Django can accept the CSRF token in a header, normally ``x-csrftoken`` (configurable with the |CSRF_HEADER_NAME setting|__, but there’s rarely a reason to change it). .. |CSRF_HEADER_NAME setting| replace:: ``CSRF_HEADER_NAME`` setting __ https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-CSRF_HEADER_NAME You can make htmx pass the header with its |hx-headers attribute|__. It’s most convenient to place ``hx-headers`` on your ```` tag, as then all elements will inherit it. For example: .. |hx-headers attribute| replace:: ``hx-headers`` attribute __ https://htmx.org/attributes/hx-headers/ .. code-block:: django ... Note this uses ``{{ csrf_token }}``, the variable, as opposed to ``{% csrf_token %}``, the tag that renders a hidden ````. This snippet should work with both Django templates and Jinja. For an example of this in action, see the “CSRF Demo” page of the :doc:`example project `. .. _partial-rendering: Partial Rendering ----------------- For requests made with htmx, you may want to reduce the page content you render, since only part of the page gets updated. This is a small optimization compared to correctly setting up compression, caching, etc. Using django-template-partials ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The `django-template-partials package `__ extends the Django Template Language with reusable sections called “partials”. It then allows you to render just one partial from a template. Install ``django-template-partials`` and add its ``{% partialdef %}`` tag around a template section: .. code-block:: django {% extends "_base.html" %} {% load partials %} {% block main %}

Countries

... {% partialdef country-table inline %} ... {% for country in countries %} ... {% endfor %}
{% endpartialdef %} ... {% endblock main %} The above template defines a partial named ``country-table``, which renders some table of country data. The ``inline`` argument makes the partial render when the full page renders. In the view, you can select to render the partial for htmx requests. This is done by adding ``#`` and the partial name to the template name: .. code-block:: python from django.shortcuts import render from example.models import Country def country_listing(request): template_name = "countries.html" if request.htmx: template_name += "#country-table" countries = Country.objects.all() return render( request, template_name, { "countries": countries, }, ) htmx requests will render only the partial, whilst full page requests will render the full page. This allows refreshing of the table without an extra view or separating the template contents from its context. For a working example, see the “Partial Rendering” page of the :doc:`example project `. It’s also possible to use a partial from within a separate view. This may be preferable if other customizations are required for htmx requests. For more information on django-template-partials, see `its documentation `__. Swapping the base template ~~~~~~~~~~~~~~~~~~~~~~~~~~ Another technique is to swap the base template in your view. This is a little more manual but good to have on-hand in case you need it, You can use Django’s template inheritance to limit rendered content to only the affected section. In your view, set up a context variable for your base template like so: .. code-block:: python from django.http import HttpRequest, HttpResponse from django.shortcuts import render from django.views.decorators.http import require_GET @require_GET def partial_rendering(request: HttpRequest) -> HttpResponse: if request.htmx: base_template = "_partial.html" else: base_template = "_base.html" ... return render( request, "page.html", { "base_template": base_template, # ... }, ) Then in the template (``page.html``), use that variable in ``{% extends %}``: .. code-block:: django {% extends base_template %} {% block main %} ... {% endblock %} Here, ``_base.html`` would be the main site base: .. code-block:: django ...
{% block main %}{% endblock %}
…whilst ``_partial.html`` would contain only the minimum element to update: .. code-block:: django
{% block main %}{% endblock %}
.. _htmx-extensions: Install htmx extensions ----------------------- django-htmx vendors htmx and can render it with the ``{% htmx_script %}`` :doc:`template tag `. However, it does not include any of `the many htmx extensions `__, so it’s up to you to add such extensions to your project. Avoid using JavaScript CDNs like unpkg.com to include extensions, or any other resources. They reduce privacy, performance, and security - see `this blog post `__. Instead, download extension scripts into your project’s static files and serve them directly. Include their script tags after your htmx `` ... For another example, see the :doc:`example project `, which includes two extensions and a Python script to download their latest versions (``download_htmx_extensions.py``). django-htmx-1.27.0/download_htmx.py000077500000000000000000000021421511242661700172510ustar00rootroot00000000000000#!/usr/bin/env python """ Download htmx to django_htmx/static/htmx.min.js. """ from __future__ import annotations import argparse import subprocess from pathlib import Path static_dir = Path(__file__).parent.resolve() / "src/django_htmx/static/django_htmx/" def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("version", help="The version of htmx to download, e.g. 2.0.4") args = parser.parse_args() # Per: https://htmx.org/docs/#installing download_file( f"https://unpkg.com/htmx.org@{args.version}/dist/htmx.js", static_dir / "htmx.js", ) download_file( f"https://unpkg.com/htmx.org@{args.version}/dist/htmx.min.js", static_dir / "htmx.min.js", ) print("✅") return 0 def download_file(url: str, destination: Path) -> None: print(f"{destination.name}...") subprocess.run( [ "curl", "--fail", "--location", url, "-o", str(destination), ], check=True, ) if __name__ == "__main__": raise SystemExit(main()) django-htmx-1.27.0/example/000077500000000000000000000000001511242661700154615ustar00rootroot00000000000000django-htmx-1.27.0/example/.gitignore000066400000000000000000000000071511242661700174460ustar00rootroot00000000000000/venv/ django-htmx-1.27.0/example/README.rst000066400000000000000000000012651511242661700171540ustar00rootroot00000000000000Example Application =================== Use Python 3.13 to set up and run with these commands: .. code-block:: sh python -m venv .venv source .venv/bin/activate python -m pip install -e .. -r requirements.txt python manage.py runserver Open it at http://127.0.0.1:8000/ . Browse the individual examples, and take them apart! In your browser’s devtools, you can read the htmx `debug log `__ in your browser’s console, and see the requests made in the network tab. In the source code, check out the HTML comments via “view source” or templates, and the view code in ``example/views.py``. django-htmx-1.27.0/example/download_htmx_extensions.py000077500000000000000000000015731511242661700231720ustar00rootroot00000000000000#!/usr/bin/env python """ Download the htmx version and the extensions we're using. This is only intended for maintaining the example app. """ from __future__ import annotations import subprocess from pathlib import Path ext_dir = Path(__file__).parent.resolve() / "example/static/ext" def main() -> int: # Per: https://github.com/bigskysoftware/htmx-extensions/tree/main/src/event-header download_file( "https://unpkg.com/htmx-ext-event-header/event-header.js", ext_dir / "event-header.js", ) print("✅") return 0 def download_file(url: str, destination: Path) -> None: print(f"{destination.name}...") subprocess.run( [ "curl", "--fail", "--location", url, "-o", str(destination), ], ) if __name__ == "__main__": raise SystemExit(main()) django-htmx-1.27.0/example/example/000077500000000000000000000000001511242661700171145ustar00rootroot00000000000000django-htmx-1.27.0/example/example/__init__.py000066400000000000000000000000001511242661700212130ustar00rootroot00000000000000django-htmx-1.27.0/example/example/context_processors.py000066400000000000000000000003031511242661700234300ustar00rootroot00000000000000from __future__ import annotations from django.conf import settings from django.http import HttpRequest def debug(request: HttpRequest) -> dict[str, str]: return {"DEBUG": settings.DEBUG} django-htmx-1.27.0/example/example/forms.py000066400000000000000000000002021511242661700206060ustar00rootroot00000000000000from __future__ import annotations from django import forms class OddNumberForm(forms.Form): number = forms.IntegerField() django-htmx-1.27.0/example/example/settings.py000066400000000000000000000022701511242661700213270ustar00rootroot00000000000000from __future__ import annotations import os from pathlib import Path from typing import Any # Hide development server warning # https://docs.djangoproject.com/en/stable/ref/django-admin/#envvar-DJANGO_RUNSERVER_HIDE_WARNING os.environ["DJANGO_RUNSERVER_HIDE_WARNING"] = "true" BASE_DIR = Path(__file__).parent DEBUG = True SECRET_KEY = ")w%-67b9lurhzs*o2ow(e=n_^(n2!0_f*2+g+1*9tcn6_k58(f" # Dangerous: disable host header validation ALLOWED_HOSTS = ["*"] INSTALLED_APPS = [ "example", "django_htmx", "template_partials", "django.contrib.staticfiles", ] MIDDLEWARE = [ "django.middleware.csrf.CsrfViewMiddleware", "django_htmx.middleware.HtmxMiddleware", ] ROOT_URLCONF = "example.urls" DATABASES: dict[str, dict[str, Any]] = {} TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [BASE_DIR / "templates"], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.request", "example.context_processors.debug", ] }, } ] USE_TZ = True STATIC_URL = "/static/" STATICFILES_DIRS = [BASE_DIR / "static"] django-htmx-1.27.0/example/example/static/000077500000000000000000000000001511242661700204035ustar00rootroot00000000000000django-htmx-1.27.0/example/example/static/app.js000066400000000000000000000001261511242661700215200ustar00rootroot00000000000000// Log all htmx events to the console. // https://htmx.org/api/#logAll htmx.logAll(); django-htmx-1.27.0/example/example/static/ext/000077500000000000000000000000001511242661700212035ustar00rootroot00000000000000django-htmx-1.27.0/example/example/static/ext/event-header.js000066400000000000000000000017431511242661700241150ustar00rootroot00000000000000(function() { function stringifyEvent(event) { var obj = {} for (var key in event) { obj[key] = event[key] } return JSON.stringify(obj, function(key, value) { if (value instanceof Node) { var nodeRep = value.tagName if (nodeRep) { nodeRep = nodeRep.toLowerCase() if (value.id) { nodeRep += '#' + value.id } if (value.classList && value.classList.length) { nodeRep += '.' + value.classList.toString().replace(' ', '.') } return nodeRep } else { return 'Node' } } if (value instanceof Window) return 'Window' return value }) } htmx.defineExtension('event-header', { onEvent: function(name, evt) { if (name === 'htmx:configRequest') { if (evt.detail.triggeringEvent) { evt.detail.headers['Triggering-Event'] = stringifyEvent(evt.detail.triggeringEvent) } } } }) })() django-htmx-1.27.0/example/example/static/mvp.css000066400000000000000000000174701511242661700217300ustar00rootroot00000000000000/* MVP.css v1.6.2 - https://github.com/andybrewer/mvp */ :root { --border-radius: 5px; --box-shadow: 2px 2px 10px; --color: #118bee; --color-accent: #118bee15; --color-bg: #fff; --color-bg-secondary: #e9e9e9; --color-secondary: #920de9; --color-secondary-accent: #920de90b; --color-shadow: #f4f4f4; --color-text: #000; --color-text-secondary: #999; --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; --hover-brightness: 1.2; --justify-important: center; --justify-normal: left; --line-height: 1.5; --width-card: 285px; --width-card-medium: 460px; --width-card-wide: 800px; --width-content: 1080px; } /* @media (prefers-color-scheme: dark) { :root { --color: #0097fc; --color-accent: #0097fc4f; --color-bg: #333; --color-bg-secondary: #555; --color-secondary: #e20de9; --color-secondary-accent: #e20de94f; --color-shadow: #bbbbbb20; --color-text: #f7f7f7; --color-text-secondary: #aaa; } } */ /* Layout */ article aside { background: var(--color-secondary-accent); border-left: 4px solid var(--color-secondary); padding: 0.01rem 0.8rem; } body { background: var(--color-bg); color: var(--color-text); font-family: var(--font-family); line-height: var(--line-height); margin: 0; overflow-x: hidden; padding: 1rem 0; } footer, header, main { margin: 0 auto; max-width: var(--width-content); padding: 2rem 1rem; } hr { background-color: var(--color-bg-secondary); border: none; height: 1px; margin: 4rem 0; } section { display: flex; flex-wrap: wrap; justify-content: var(--justify-important); } section aside { border: 1px solid var(--color-bg-secondary); border-radius: var(--border-radius); box-shadow: var(--box-shadow) var(--color-shadow); margin: 1rem; padding: 1.25rem; width: var(--width-card); } section aside:hover { box-shadow: var(--box-shadow) var(--color-bg-secondary); } section aside img { max-width: 100%; } [hidden] { display: none; } /* Headers */ article header, div header, main header { padding-top: 0; } header { text-align: var(--justify-important); } header a b, header a em, header a i, header a strong { margin-left: 0.5rem; margin-right: 0.5rem; } header nav img { margin: 1rem 0; } section header { padding-top: 0; width: 100%; } /* Nav */ nav { align-items: center; display: flex; font-weight: bold; justify-content: space-between; margin-bottom: 7rem; } nav ul { list-style: none; padding: 0; } nav ul li { display: inline-block; margin: 0 0.5rem; position: relative; text-align: left; } /* Nav Dropdown */ nav ul li:hover ul { display: block; } nav ul li ul { background: var(--color-bg); border: 1px solid var(--color-bg-secondary); border-radius: var(--border-radius); box-shadow: var(--box-shadow) var(--color-shadow); display: none; height: auto; left: -2px; padding: .5rem 1rem; position: absolute; top: 1.7rem; white-space: nowrap; width: auto; } nav ul li ul li, nav ul li ul li a { display: block; } /* Typography */ code, samp { background-color: var(--color-accent); border-radius: var(--border-radius); color: var(--color-text); display: inline-block; margin: 0 0.1rem; padding: 0 0.5rem; } details { margin: 1.3rem 0; } details summary { font-weight: bold; cursor: pointer; } h1, h2, h3, h4, h5, h6 { line-height: var(--line-height); } mark { padding: 0.1rem; } ol li, ul li { padding: 0.2rem 0; } p { margin: 0.75rem 0; padding: 0; } pre { margin: 1rem 0; max-width: var(--width-card-wide); padding: 1rem 0; } pre code, pre samp { display: block; max-width: var(--width-card-wide); padding: 0.5rem 2rem; white-space: pre-wrap; } small { color: var(--color-text-secondary); } sup { background-color: var(--color-secondary); border-radius: var(--border-radius); color: var(--color-bg); font-size: xx-small; font-weight: bold; margin: 0.2rem; padding: 0.2rem 0.3rem; position: relative; top: -2px; } /* Links */ a { color: var(--color-secondary); display: inline-block; font-weight: bold; text-decoration: none; } a:hover { filter: brightness(var(--hover-brightness)); text-decoration: underline; } a b, a em, a i, a strong, button { border-radius: var(--border-radius); display: inline-block; font-size: medium; font-weight: bold; line-height: var(--line-height); margin: 0.5rem 0; padding: 1rem 2rem; } button { font-family: var(--font-family); } button:hover { cursor: pointer; filter: brightness(var(--hover-brightness)); } a b, a strong, button { background-color: var(--color); border: 2px solid var(--color); color: var(--color-bg); } a em, a i { border: 2px solid var(--color); border-radius: var(--border-radius); color: var(--color); display: inline-block; padding: 1rem 2rem; } /* Images */ figure { margin: 0; padding: 0; } figure img { max-width: 100%; } figure figcaption { color: var(--color-text-secondary); } /* Forms */ button:disabled, input:disabled { background: var(--color-bg-secondary); border-color: var(--color-bg-secondary); color: var(--color-text-secondary); cursor: not-allowed; } button[disabled]:hover { filter: none; } form { border: 1px solid var(--color-bg-secondary); border-radius: var(--border-radius); box-shadow: var(--box-shadow) var(--color-shadow); display: block; max-width: var(--width-card-wide); min-width: var(--width-card); padding: 1.5rem; text-align: var(--justify-normal); } form header { margin: 1.5rem 0; padding: 1.5rem 0; } input, label, select, textarea { display: block; font-size: inherit; max-width: var(--width-card-wide); } input[type="checkbox"], input[type="radio"] { display: inline-block; } input[type="checkbox"]+label, input[type="radio"]+label { display: inline-block; font-weight: normal; position: relative; top: 1px; } input, select, textarea { border: 1px solid var(--color-bg-secondary); border-radius: var(--border-radius); margin-bottom: 1rem; padding: 0.4rem 0.8rem; } input[readonly], textarea[readonly] { background-color: var(--color-bg-secondary); } label { font-weight: bold; margin-bottom: 0.2rem; } /* Tables */ table { border: 1px solid var(--color-bg-secondary); border-radius: var(--border-radius); border-spacing: 0; display: inline-block; max-width: 100%; overflow-x: auto; padding: 0; white-space: nowrap; } table td, table th, table tr { padding: 0.4rem 0.8rem; text-align: var(--justify-important); } table thead { background-color: var(--color); border-collapse: collapse; border-radius: var(--border-radius); color: var(--color-bg); margin: 0; padding: 0; } table thead th:first-child { border-top-left-radius: var(--border-radius); } table thead th:last-child { border-top-right-radius: var(--border-radius); } table thead th:first-child, table tr td:first-child { text-align: var(--justify-normal); } table tr:nth-child(even) { background-color: var(--color-accent); } /* Quotes */ blockquote { display: block; font-size: x-large; line-height: var(--line-height); margin: 1rem auto; max-width: var(--width-card-medium); padding: 1.5rem 1rem; text-align: var(--justify-important); } blockquote footer { color: var(--color-text-secondary); display: block; font-size: small; line-height: var(--line-height); padding: 1.5rem 0; } django-htmx-1.27.0/example/example/templates/000077500000000000000000000000001511242661700211125ustar00rootroot00000000000000django-htmx-1.27.0/example/example/templates/_base.html000066400000000000000000000026711511242661700230570ustar00rootroot00000000000000{% load django_htmx static %} django-htmx example app {% htmx_script %}
{% block main %}{% endblock %}
{% django_htmx_script %} django-htmx-1.27.0/example/example/templates/csrf-demo-checker.html000066400000000000000000000002531511242661700252610ustar00rootroot00000000000000{% if not form.is_valid %} Please enter a number {% elif number_is_odd %} {{ form.number.value }} is odd! {% else %} {{ form.number.value }} is not odd. {% endif %} django-htmx-1.27.0/example/example/templates/csrf-demo.html000066400000000000000000000022001511242661700236510ustar00rootroot00000000000000{% extends "_base.html" %} {% block main %}

This form shows you how to implement CSRF with htmx, using the hx-headers attribute.

View the source to see how it works!

Awaiting interaction...

{% endblock main %} django-htmx-1.27.0/example/example/templates/error-demo.html000066400000000000000000000023001511242661700240460ustar00rootroot00000000000000{% extends "_base.html" %} {% block main %}

This page shows you the django-htmx extension script error handler in action.

See more in the docs.

{% if DEBUG %} The error handler will work, since DEBUG = True. {% else %} The error handler will not work, since DEBUG = False. {% endif %}

{% endblock main %} django-htmx-1.27.0/example/example/templates/index.html000066400000000000000000000002611511242661700231060ustar00rootroot00000000000000{% extends "_base.html" %} {% block main %}
Welcome to the example app. Use one of the links in the navigation to explore!
{% endblock main %} django-htmx-1.27.0/example/example/templates/middleware-tester-table.html000066400000000000000000000045151511242661700265130ustar00rootroot00000000000000{% load example_tags %}
Attribute Value
Timestamp {{ timestamp }}
request.method {{ request.method|stringformat:'r' }}
bool(request.htmx) {% if request.htmx %} True {% else %} For {% endif %}
request.htmx.boosted {{ request.htmx.boosted|stringformat:'r' }}
request.htmx.current_url {{ request.htmx.current_url|stringformat:'r' }}
request.htmx.current_url_abs_path {{ request.htmx.current_url_abs_path|stringformat:'r' }}
request.htmx.prompt {{ request.htmx.prompt|stringformat:'r' }}
request.htmx.target {{ request.htmx.target|stringformat:'r' }}
request.htmx.trigger {{ request.htmx.trigger|stringformat:'r' }}
request.htmx.trigger_name {{ request.htmx.trigger_name|stringformat:'r' }}
request.htmx.triggering_event
(via event-header extension)
{% if request.htmx.triggering_event %}
JSON
{{ request.htmx.triggering_event|json_dumps }}
{% else %} {{ request.htmx.triggering_event|stringformat:'r' }} {% endif %}
request.POST.get('keyup_input') {{ request.POST.keyup_input|stringformat:'r' }}
django-htmx-1.27.0/example/example/templates/middleware-tester.html000066400000000000000000000030511511242661700254200ustar00rootroot00000000000000{% extends "_base.html" %} {% block main %}

The below form controls implement different patterns with HTMX. Interact with them to trigger requests that will render a table showing the Django request attributes added and changed by HtmxMiddleware.


Awaiting interaction...

{% endblock main %} django-htmx-1.27.0/example/example/templates/partial-rendering.html000066400000000000000000000063501511242661700254130ustar00rootroot00000000000000{% extends "_base.html" %} {% load partials %} {% block main %}

This example shows you how you can do partial rendering for htmx requests using django-template-partials. The view renders only the content of the table section partial for requests made with htmx, saving time and bandwidth. Paginate through the below list of randomly generated people to see this in action, and study the view and template.

See more in the docs.

{% partialdef table-section inline %} {% endpartialdef %} {% endblock main %} django-htmx-1.27.0/example/example/templatetags/000077500000000000000000000000001511242661700216065ustar00rootroot00000000000000django-htmx-1.27.0/example/example/templatetags/__init__.py000066400000000000000000000000001511242661700237050ustar00rootroot00000000000000django-htmx-1.27.0/example/example/templatetags/example_tags.py000066400000000000000000000003601511242661700246300ustar00rootroot00000000000000from __future__ import annotations import json from typing import Any from django import template register = template.Library() @register.filter def json_dumps(value: Any) -> str: return json.dumps(value, indent=2, sort_keys=True) django-htmx-1.27.0/example/example/urls.py000066400000000000000000000013201511242661700204470ustar00rootroot00000000000000from __future__ import annotations from django.urls import path from example import views urlpatterns = [ path("", views.index), path("favicon.ico", views.favicon), path("csrf-demo/", views.csrf_demo), path("csrf-demo/checker/", views.csrf_demo_checker), path("error-demo/", views.error_demo), path("error-demo/400/", views.error_demo_400), path("error-demo/403/", views.error_demo_403), path("error-demo/500/", views.error_demo_500), path("error-demo/500-custom/", views.error_demo_500_custom), path("middleware-tester/", views.middleware_tester), path("middleware-tester/table/", views.middleware_tester_table), path("partial-rendering/", views.partial_rendering), ] django-htmx-1.27.0/example/example/views.py000066400000000000000000000073731511242661700206350ustar00rootroot00000000000000from __future__ import annotations import time from dataclasses import dataclass from django.core.exceptions import PermissionDenied, SuspiciousOperation from django.core.paginator import Paginator from django.http import HttpRequest, HttpResponse, HttpResponseServerError from django.shortcuts import render from django.views.decorators.http import require_GET, require_http_methods, require_POST from faker import Faker from django_htmx.middleware import HtmxDetails from example.forms import OddNumberForm # Typing pattern recommended by django-stubs: # https://github.com/typeddjango/django-stubs#how-can-i-create-a-httprequest-thats-guaranteed-to-have-an-authenticated-user class HtmxHttpRequest(HttpRequest): htmx: HtmxDetails @require_GET def index(request: HtmxHttpRequest) -> HttpResponse: return render(request, "index.html") @require_GET def favicon(request: HtmxHttpRequest) -> HttpResponse: return HttpResponse( ( '' + '🦊' + "" ), content_type="image/svg+xml", ) # CSRF Demo @require_GET def csrf_demo(request: HtmxHttpRequest) -> HttpResponse: return render(request, "csrf-demo.html") @require_POST def csrf_demo_checker(request: HtmxHttpRequest) -> HttpResponse: form = OddNumberForm(request.POST) if form.is_valid(): number = form.cleaned_data["number"] number_is_odd = number % 2 == 1 else: number_is_odd = False return render( request, "csrf-demo-checker.html", {"form": form, "number_is_odd": number_is_odd}, ) # Error demo @require_GET def error_demo(request: HtmxHttpRequest) -> HttpResponse: return render(request, "error-demo.html") @require_GET def error_demo_400(request: HtmxHttpRequest) -> HttpResponse: raise SuspiciousOperation("What are you doing??") @require_GET def error_demo_403(request: HtmxHttpRequest) -> HttpResponse: raise PermissionDenied("Access denied!") @require_GET def error_demo_500(request: HtmxHttpRequest) -> HttpResponse: _ = 1 / 0 return render(request, "error-demo.html") # unreachable @require_GET def error_demo_500_custom(request: HtmxHttpRequest) -> HttpResponse: return HttpResponseServerError( "

😱 Woops

This is our fancy custom 500 page.

" ) # Middleware tester # This uses two views - one to render the form, and the second to render the # table of attributes. @require_GET def middleware_tester(request: HtmxHttpRequest) -> HttpResponse: return render(request, "middleware-tester.html") @require_http_methods(["DELETE", "POST", "PUT"]) def middleware_tester_table(request: HtmxHttpRequest) -> HttpResponse: return render( request, "middleware-tester-table.html", {"timestamp": time.time()}, ) # Partial rendering example # This dataclass acts as a stand-in for a database model - the example app # avoids having a database for simplicity. @dataclass class Person: id: int name: str faker = Faker() people = [Person(id=i, name=faker.name()) for i in range(1, 235)] @require_GET def partial_rendering(request: HtmxHttpRequest) -> HttpResponse: # Standard Django pagination page_num = request.GET.get("page", "1") page = Paginator(object_list=people, per_page=10).get_page(page_num) # The htmx magic - render just the `#table-section` partial for htmx # requests, allowing us to skip rendering the unchanging parts of the # template. template_name = "partial-rendering.html" if request.htmx: template_name += "#table-section" return render( request, template_name, { "page": page, }, ) django-htmx-1.27.0/example/manage.py000077500000000000000000000012401511242661700172630ustar00rootroot00000000000000#!/usr/bin/env python """Django's command-line utility for administrative tasks.""" from __future__ import annotations import os import sys def main() -> None: os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" ) from exc execute_from_command_line(sys.argv) if __name__ == "__main__": main() django-htmx-1.27.0/example/requirements.in000066400000000000000000000000461511242661700205340ustar00rootroot00000000000000django django-template-partials faker django-htmx-1.27.0/example/requirements.txt000066400000000000000000000006631511242661700207520ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile --universal requirements.in -o requirements.txt asgiref==3.8.1 # via django django==5.2 # via # -r requirements.in # django-template-partials django-template-partials==24.4 # via -r requirements.in faker==37.1.0 # via -r requirements.in sqlparse==0.5.3 # via django tzdata==2025.2 # via # django # faker django-htmx-1.27.0/pyproject.toml000066400000000000000000000067761511242661700167620ustar00rootroot00000000000000[build-system] build-backend = "setuptools.build_meta" requires = [ "setuptools>=77", ] [project] name = "django-htmx" version = "1.27.0" description = "Extensions for using Django with htmx." readme = "README.rst" keywords = [ "Django", ] license = "MIT" license-files = [ "LICENSE" ] authors = [ { name = "Adam Johnson", email = "me@adamj.eu" }, ] requires-python = ">=3.10" classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", "Framework :: Django :: 5.2", "Framework :: Django :: 6.0", "Intended Audience :: Developers", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Typing :: Typed", ] dependencies = [ "asgiref>=3.6", "django>=4.2", ] urls.Changelog = "https://django-htmx.readthedocs.io/en/latest/changelog.html" urls.Documentation = "https://django-htmx.readthedocs.io/" urls.Funding = "https://adamj.eu/books/" urls.Repository = "https://github.com/adamchainz/django-htmx" [dependency-groups] test = [ "coverage[toml]", "pytest", "pytest-django", "pytest-randomly", ] docs = [ "furo>=2024.8.6", "sphinx>=7.4.7", "sphinx-build-compatibility", "sphinx-copybutton>=0.5.2", ] django42 = [ "django>=4.2a1,<5; python_version>='3.8'" ] django50 = [ "django>=5a1,<5.1; python_version>='3.10'" ] django51 = [ "django>=5.1a1,<5.2; python_version>='3.10'" ] django52 = [ "django>=5.2a1,<6; python_version>='3.10'" ] django60 = [ "django>=6a1,<6.1; python_version>='3.12'" ] [tool.uv] conflicts = [ [ { group = "django42" }, { group = "django50" }, { group = "django51" }, { group = "django52" }, { group = "django60" }, ], ] [tool.uv.sources] sphinx-build-compatibility = { git = "https://github.com/readthedocs/sphinx-build-compatibility", rev = "4f304bd4562cdc96316f4fec82b264ca379d23e0" } [tool.ruff] lint.select = [ # flake8-bugbear "B", # flake8-comprehensions "C4", # pycodestyle "E", # Pyflakes errors "F", # isort "I", # flake8-simplify "SIM", # flake8-tidy-imports "TID", # pyupgrade "UP", # Pyflakes warnings "W", ] lint.ignore = [ # flake8-bugbear opinionated rules "B9", # line-too-long "E501", # suppressible-exception "SIM105", # if-else-block-instead-of-if-exp "SIM108", ] lint.extend-safe-fixes = [ # non-pep585-annotation "UP006", ] lint.isort.required-imports = [ "from __future__ import annotations" ] [tool.pyproject-fmt] max_supported_python = "3.14" [tool.pytest.ini_options] addopts = """\ --strict-config --strict-markers --ds=tests.settings """ django_find_project = false xfail_strict = true [tool.coverage.run] branch = true parallel = true source = [ "django_htmx", "tests", ] [tool.coverage.paths] source = [ "src", ".tox/**/site-packages", ] [tool.coverage.report] show_missing = true [tool.mypy] enable_error_code = [ "ignore-without-code", "redundant-expr", "truthy-bool", ] mypy_path = "src/" namespace_packages = false strict = true warn_unreachable = true [[tool.mypy.overrides]] module = "tests.*" allow_untyped_defs = true [tool.rstcheck] ignore_directives = [ "autoclass", "autofunction", ] report_level = "ERROR" django-htmx-1.27.0/src/000077500000000000000000000000001511242661700146155ustar00rootroot00000000000000django-htmx-1.27.0/src/django_htmx/000077500000000000000000000000001511242661700171175ustar00rootroot00000000000000django-htmx-1.27.0/src/django_htmx/__init__.py000066400000000000000000000000001511242661700212160ustar00rootroot00000000000000django-htmx-1.27.0/src/django_htmx/http.py000066400000000000000000000103601511242661700204500ustar00rootroot00000000000000from __future__ import annotations import json from typing import Any, Literal, TypeVar from django.core.serializers.json import DjangoJSONEncoder from django.http import HttpResponse from django.http.response import HttpResponseBase, HttpResponseRedirectBase HTMX_STOP_POLLING = 286 SwapMethod = Literal[ "innerHTML", "outerHTML", "beforebegin", "afterbegin", "beforeend", "afterend", "delete", "none", ] class HttpResponseStopPolling(HttpResponse): status_code = HTMX_STOP_POLLING def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._reason_phrase = "Stop Polling" class HttpResponseClientRedirect(HttpResponseRedirectBase): status_code = 200 def __init__(self, redirect_to: str, *args: Any, **kwargs: Any) -> None: if kwargs.get("preserve_request"): raise ValueError( "The 'preserve_request' argument is not supported for " "HttpResponseClientRedirect.", ) super().__init__(redirect_to, *args, **kwargs) self["HX-Redirect"] = self["Location"] del self["Location"] @property def url(self) -> str: return self["HX-Redirect"] class HttpResponseClientRefresh(HttpResponse): def __init__(self) -> None: super().__init__() self["HX-Refresh"] = "true" class HttpResponseLocation(HttpResponseRedirectBase): status_code = 200 def __init__( self, redirect_to: str, *args: Any, source: str | None = None, event: str | None = None, target: str | None = None, swap: SwapMethod | None = None, select: str | None = None, values: dict[str, str] | None = None, headers: dict[str, str] | None = None, **kwargs: Any, ) -> None: super().__init__(redirect_to, *args, **kwargs) spec: dict[str, str | dict[str, str]] = { "path": self["Location"], } del self["Location"] if source is not None: spec["source"] = source if event is not None: spec["event"] = event if target is not None: spec["target"] = target if swap is not None: spec["swap"] = swap if select is not None: spec["select"] = select if headers is not None: spec["headers"] = headers if values is not None: spec["values"] = values self["HX-Location"] = json.dumps(spec) _HttpResponse = TypeVar("_HttpResponse", bound=HttpResponseBase) def push_url(response: _HttpResponse, url: str | Literal[False]) -> _HttpResponse: response["HX-Push-Url"] = "false" if url is False else url return response def replace_url(response: _HttpResponse, url: str | Literal[False]) -> _HttpResponse: response["HX-Replace-Url"] = "false" if url is False else url return response def reswap(response: _HttpResponse, method: SwapMethod) -> _HttpResponse: response["HX-Reswap"] = method return response def retarget(response: _HttpResponse, target: str) -> _HttpResponse: response["HX-Retarget"] = target return response def reselect(response: _HttpResponse, selector: str) -> _HttpResponse: response["HX-Reselect"] = selector return response def trigger_client_event( response: _HttpResponse, name: str, params: dict[str, Any] | None = None, *, after: Literal["receive", "settle", "swap"] = "receive", encoder: type[json.JSONEncoder] = DjangoJSONEncoder, ) -> _HttpResponse: params = params or {} if after == "receive": header = "HX-Trigger" elif after == "settle": header = "HX-Trigger-After-Settle" elif after == "swap": header = "HX-Trigger-After-Swap" else: raise ValueError( "Value for 'after' must be one of: 'receive', 'settle', or 'swap'." ) if header in response: value = response[header] try: data = json.loads(value) except json.JSONDecodeError as exc: raise ValueError(f"{header!r} value should be valid JSON.") from exc data[name] = params else: data = {name: params} response[header] = json.dumps(data, cls=encoder) return response django-htmx-1.27.0/src/django_htmx/jinja.py000066400000000000000000000026161511242661700205710ustar00rootroot00000000000000from __future__ import annotations from django.conf import settings from django.templatetags.static import static from django.utils.html import format_html from django.utils.safestring import SafeString, mark_safe def htmx_script(*, minified: bool = True, nonce: str | None = None) -> SafeString: path = f"django_htmx/htmx{'.min' if minified else ''}.js" if nonce is not None: result = format_html( '', static(path), nonce, ) else: result = format_html( '', static(path), ) if settings.DEBUG: result += django_htmx_script(nonce=nonce) return result def django_htmx_script(*, nonce: str | None = None) -> SafeString: # Optimization: whilst the script has no behaviour outside of debug mode, # don't include it. if not settings.DEBUG: return mark_safe("") if nonce is not None: return format_html( '', static("django_htmx/django-htmx.js"), str(bool(settings.DEBUG)), nonce, ) else: return format_html( '', static("django_htmx/django-htmx.js"), str(bool(settings.DEBUG)), ) django-htmx-1.27.0/src/django_htmx/middleware.py000066400000000000000000000067201511242661700216130ustar00rootroot00000000000000from __future__ import annotations import json from collections.abc import Awaitable, Callable from typing import Any from urllib.parse import unquote, urlsplit, urlunsplit from asgiref.sync import iscoroutinefunction, markcoroutinefunction from django.http import HttpRequest from django.http.response import HttpResponseBase from django.utils.functional import cached_property class HtmxMiddleware: sync_capable = True async_capable = True def __init__( self, get_response: ( Callable[[HttpRequest], HttpResponseBase] | Callable[[HttpRequest], Awaitable[HttpResponseBase]] ), ) -> None: self.get_response = get_response self.async_mode = iscoroutinefunction(self.get_response) if self.async_mode: # Mark the class as async-capable, but do the actual switch # inside __call__ to avoid swapping out dunder methods markcoroutinefunction(self) def __call__( self, request: HttpRequest ) -> HttpResponseBase | Awaitable[HttpResponseBase]: if self.async_mode: return self.__acall__(request) request.htmx = HtmxDetails(request) # type: ignore [attr-defined] return self.get_response(request) async def __acall__(self, request: HttpRequest) -> HttpResponseBase: request.htmx = HtmxDetails(request) # type: ignore [attr-defined] return await self.get_response(request) # type: ignore [no-any-return, misc] class HtmxDetails: def __init__(self, request: HttpRequest) -> None: self.request = request def _get_header_value(self, name: str) -> str | None: value = self.request.headers.get(name) or None if value and self.request.headers.get(f"{name}-URI-AutoEncoded") == "true": value = unquote(value) return value def __bool__(self) -> bool: return self._get_header_value("HX-Request") == "true" @cached_property def boosted(self) -> bool: return self._get_header_value("HX-Boosted") == "true" @cached_property def current_url(self) -> str | None: return self._get_header_value("HX-Current-URL") @cached_property def current_url_abs_path(self) -> str | None: url = self.current_url if url is not None: split = urlsplit(url) if ( split.scheme == self.request.scheme and split.netloc == self.request.get_host() ): url = urlunsplit(split._replace(scheme="", netloc="")) else: url = None return url @cached_property def history_restore_request(self) -> bool: return self._get_header_value("HX-History-Restore-Request") == "true" @cached_property def prompt(self) -> str | None: return self._get_header_value("HX-Prompt") @cached_property def target(self) -> str | None: return self._get_header_value("HX-Target") @cached_property def trigger(self) -> str | None: return self._get_header_value("HX-Trigger") @cached_property def trigger_name(self) -> str | None: return self._get_header_value("HX-Trigger-Name") @cached_property def triggering_event(self) -> Any: value = self._get_header_value("Triggering-Event") if value is not None: try: value = json.loads(value) except json.JSONDecodeError: value = None return value django-htmx-1.27.0/src/django_htmx/py.typed000066400000000000000000000000001511242661700206040ustar00rootroot00000000000000django-htmx-1.27.0/src/django_htmx/static/000077500000000000000000000000001511242661700204065ustar00rootroot00000000000000django-htmx-1.27.0/src/django_htmx/static/django_htmx/000077500000000000000000000000001511242661700227105ustar00rootroot00000000000000django-htmx-1.27.0/src/django_htmx/static/django_htmx/django-htmx.js000066400000000000000000000016331511242661700254710ustar00rootroot00000000000000{ const data = document.currentScript.dataset; const isDebug = data.debug === "True"; if (isDebug) { document.addEventListener("htmx:beforeOnLoad", function (event) { const xhr = event.detail.xhr; if (xhr.status == 400 || xhr.status == 403 || xhr.status == 404 || xhr.status == 500 ) { // Tell htmx to stop processing this response event.stopPropagation(); document.children[0].innerHTML = xhr.response; // Run inline scripts, which Django’s error pages use for (const script of document.scripts) { // (1, eval) wtf - see https://stackoverflow.com/questions/9107240/1-evalthis-vs-evalthis-in-javascript (1, eval)(script.innerText); } // Run window.onload function if defined, which Django’s error pages use if (typeof window.onload === "function") { window.onload(); } } }); } } django-htmx-1.27.0/src/django_htmx/static/django_htmx/htmx.js000066400000000000000000005102101511242661700242250ustar00rootroot00000000000000var htmx = (function() { 'use strict' // Public API const htmx = { // Tsc madness here, assigning the functions directly results in an invalid TypeScript output, but reassigning is fine /* Event processing */ /** @type {typeof onLoadHelper} */ onLoad: null, /** @type {typeof processNode} */ process: null, /** @type {typeof addEventListenerImpl} */ on: null, /** @type {typeof removeEventListenerImpl} */ off: null, /** @type {typeof triggerEvent} */ trigger: null, /** @type {typeof ajaxHelper} */ ajax: null, /* DOM querying helpers */ /** @type {typeof find} */ find: null, /** @type {typeof findAll} */ findAll: null, /** @type {typeof closest} */ closest: null, /** * Returns the input values that would resolve for a given element via the htmx value resolution mechanism * * @see https://htmx.org/api/#values * * @param {Element} elt the element to resolve values on * @param {HttpVerb} type the request type (e.g. **get** or **post**) non-GET's will include the enclosing form of the element. Defaults to **post** * @returns {Object} */ values: function(elt, type) { const inputValues = getInputValues(elt, type || 'post') return inputValues.values }, /* DOM manipulation helpers */ /** @type {typeof removeElement} */ remove: null, /** @type {typeof addClassToElement} */ addClass: null, /** @type {typeof removeClassFromElement} */ removeClass: null, /** @type {typeof toggleClassOnElement} */ toggleClass: null, /** @type {typeof takeClassForElement} */ takeClass: null, /** @type {typeof swap} */ swap: null, /* Extension entrypoints */ /** @type {typeof defineExtension} */ defineExtension: null, /** @type {typeof removeExtension} */ removeExtension: null, /* Debugging */ /** @type {typeof logAll} */ logAll: null, /** @type {typeof logNone} */ logNone: null, /* Debugging */ /** * The logger htmx uses to log with * * @see https://htmx.org/api/#logger */ logger: null, /** * A property holding the configuration htmx uses at runtime. * * Note that using a [meta tag](https://htmx.org/docs/#config) is the preferred mechanism for setting these properties. * * @see https://htmx.org/api/#config */ config: { /** * Whether to use history. * @type boolean * @default true */ historyEnabled: true, /** * The number of pages to keep in **sessionStorage** for history support. * @type number * @default 10 */ historyCacheSize: 10, /** * @type boolean * @default false */ refreshOnHistoryMiss: false, /** * The default swap style to use if **[hx-swap](https://htmx.org/attributes/hx-swap)** is omitted. * @type HtmxSwapStyle * @default 'innerHTML' */ defaultSwapStyle: 'innerHTML', /** * The default delay between receiving a response from the server and doing the swap. * @type number * @default 0 */ defaultSwapDelay: 0, /** * The default delay between completing the content swap and settling attributes. * @type number * @default 20 */ defaultSettleDelay: 20, /** * If true, htmx will inject a small amount of CSS into the page to make indicators invisible unless the **htmx-indicator** class is present. * @type boolean * @default true */ includeIndicatorStyles: true, /** * The class to place on indicators when a request is in flight. * @type string * @default 'htmx-indicator' */ indicatorClass: 'htmx-indicator', /** * The class to place on triggering elements when a request is in flight. * @type string * @default 'htmx-request' */ requestClass: 'htmx-request', /** * The class to temporarily place on elements that htmx has added to the DOM. * @type string * @default 'htmx-added' */ addedClass: 'htmx-added', /** * The class to place on target elements when htmx is in the settling phase. * @type string * @default 'htmx-settling' */ settlingClass: 'htmx-settling', /** * The class to place on target elements when htmx is in the swapping phase. * @type string * @default 'htmx-swapping' */ swappingClass: 'htmx-swapping', /** * Allows the use of eval-like functionality in htmx, to enable **hx-vars**, trigger conditions & script tag evaluation. Can be set to **false** for CSP compatibility. * @type boolean * @default true */ allowEval: true, /** * If set to false, disables the interpretation of script tags. * @type boolean * @default true */ allowScriptTags: true, /** * If set, the nonce will be added to inline scripts. * @type string * @default '' */ inlineScriptNonce: '', /** * If set, the nonce will be added to inline styles. * @type string * @default '' */ inlineStyleNonce: '', /** * The attributes to settle during the settling phase. * @type string[] * @default ['class', 'style', 'width', 'height'] */ attributesToSettle: ['class', 'style', 'width', 'height'], /** * Allow cross-site Access-Control requests using credentials such as cookies, authorization headers or TLS client certificates. * @type boolean * @default false */ withCredentials: false, /** * @type number * @default 0 */ timeout: 0, /** * The default implementation of **getWebSocketReconnectDelay** for reconnecting after unexpected connection loss by the event code **Abnormal Closure**, **Service Restart** or **Try Again Later**. * @type {'full-jitter' | ((retryCount:number) => number)} * @default "full-jitter" */ wsReconnectDelay: 'full-jitter', /** * The type of binary data being received over the WebSocket connection * @type BinaryType * @default 'blob' */ wsBinaryType: 'blob', /** * @type string * @default '[hx-disable], [data-hx-disable]' */ disableSelector: '[hx-disable], [data-hx-disable]', /** * @type {'auto' | 'instant' | 'smooth'} * @default 'instant' */ scrollBehavior: 'instant', /** * If the focused element should be scrolled into view. * @type boolean * @default false */ defaultFocusScroll: false, /** * If set to true htmx will include a cache-busting parameter in GET requests to avoid caching partial responses by the browser * @type boolean * @default false */ getCacheBusterParam: false, /** * If set to true, htmx will use the View Transition API when swapping in new content. * @type boolean * @default false */ globalViewTransitions: false, /** * htmx will format requests with these methods by encoding their parameters in the URL, not the request body * @type {(HttpVerb)[]} * @default ['get', 'delete'] */ methodsThatUseUrlParams: ['get', 'delete'], /** * If set to true, disables htmx-based requests to non-origin hosts. * @type boolean * @default false */ selfRequestsOnly: true, /** * If set to true htmx will not update the title of the document when a title tag is found in new content * @type boolean * @default false */ ignoreTitle: false, /** * Whether the target of a boosted element is scrolled into the viewport. * @type boolean * @default true */ scrollIntoViewOnBoost: true, /** * The cache to store evaluated trigger specifications into. * You may define a simple object to use a never-clearing cache, or implement your own system using a [proxy object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Proxy) * @type {Object|null} * @default null */ triggerSpecsCache: null, /** @type boolean */ disableInheritance: false, /** @type HtmxResponseHandlingConfig[] */ responseHandling: [ { code: '204', swap: false }, { code: '[23]..', swap: true }, { code: '[45]..', swap: false, error: true } ], /** * Whether to process OOB swaps on elements that are nested within the main response element. * @type boolean * @default true */ allowNestedOobSwaps: true, /** * Whether to treat history cache miss full page reload requests as a "HX-Request" by returning this response header * This should always be disabled when using HX-Request header to optionally return partial responses * @type boolean * @default true */ historyRestoreAsHxRequest: true, /** * Weather to report input validation errors to the end user and update focus to the first input that fails validation. * This should always be enabled as this matches default browser form submit behaviour * @type boolean * @default false */ reportValidityOfForms: false }, /** @type {typeof parseInterval} */ parseInterval: null, /** * proxy of window.location used for page reload functions * @type location */ location, /** @type {typeof internalEval} */ _: null, version: '2.0.7' } // Tsc madness part 2 htmx.onLoad = onLoadHelper htmx.process = processNode htmx.on = addEventListenerImpl htmx.off = removeEventListenerImpl htmx.trigger = triggerEvent htmx.ajax = ajaxHelper htmx.find = find htmx.findAll = findAll htmx.closest = closest htmx.remove = removeElement htmx.addClass = addClassToElement htmx.removeClass = removeClassFromElement htmx.toggleClass = toggleClassOnElement htmx.takeClass = takeClassForElement htmx.swap = swap htmx.defineExtension = defineExtension htmx.removeExtension = removeExtension htmx.logAll = logAll htmx.logNone = logNone htmx.parseInterval = parseInterval htmx._ = internalEval const internalAPI = { addTriggerHandler, bodyContains, canAccessLocalStorage, findThisElement, filterValues, swap, hasAttribute, getAttributeValue, getClosestAttributeValue, getClosestMatch, getExpressionVars, getHeaders, getInputValues, getInternalData, getSwapSpecification, getTriggerSpecs, getTarget, makeFragment, mergeObjects, makeSettleInfo, oobSwap, querySelectorExt, settleImmediately, shouldCancel, triggerEvent, triggerErrorEvent, withExtensions } const VERBS = ['get', 'post', 'put', 'delete', 'patch'] const VERB_SELECTOR = VERBS.map(function(verb) { return '[hx-' + verb + '], [data-hx-' + verb + ']' }).join(', ') //= =================================================================== // Utilities //= =================================================================== /** * Parses an interval string consistent with the way htmx does. Useful for plugins that have timing-related attributes. * * Caution: Accepts an int followed by either **s** or **ms**. All other values use **parseFloat** * * @see https://htmx.org/api/#parseInterval * * @param {string} str timing string * @returns {number|undefined} */ function parseInterval(str) { if (str == undefined) { return undefined } let interval = NaN if (str.slice(-2) == 'ms') { interval = parseFloat(str.slice(0, -2)) } else if (str.slice(-1) == 's') { interval = parseFloat(str.slice(0, -1)) * 1000 } else if (str.slice(-1) == 'm') { interval = parseFloat(str.slice(0, -1)) * 1000 * 60 } else { interval = parseFloat(str) } return isNaN(interval) ? undefined : interval } /** * @param {Node} elt * @param {string} name * @returns {(string | null)} */ function getRawAttribute(elt, name) { return elt instanceof Element && elt.getAttribute(name) } /** * @param {Element} elt * @param {string} qualifiedName * @returns {boolean} */ // resolve with both hx and data-hx prefixes function hasAttribute(elt, qualifiedName) { return !!elt.hasAttribute && (elt.hasAttribute(qualifiedName) || elt.hasAttribute('data-' + qualifiedName)) } /** * * @param {Node} elt * @param {string} qualifiedName * @returns {(string | null)} */ function getAttributeValue(elt, qualifiedName) { return getRawAttribute(elt, qualifiedName) || getRawAttribute(elt, 'data-' + qualifiedName) } /** * @param {Node} elt * @returns {Node | null} */ function parentElt(elt) { const parent = elt.parentElement if (!parent && elt.parentNode instanceof ShadowRoot) return elt.parentNode return parent } /** * @returns {Document} */ function getDocument() { return document } /** * @param {Node} elt * @param {boolean} global * @returns {Node|Document} */ function getRootNode(elt, global) { return elt.getRootNode ? elt.getRootNode({ composed: global }) : getDocument() } /** * @param {Node} elt * @param {(e:Node) => boolean} condition * @returns {Node | null} */ function getClosestMatch(elt, condition) { while (elt && !condition(elt)) { elt = parentElt(elt) } return elt || null } /** * @param {Element} initialElement * @param {Element} ancestor * @param {string} attributeName * @returns {string|null} */ function getAttributeValueWithDisinheritance(initialElement, ancestor, attributeName) { const attributeValue = getAttributeValue(ancestor, attributeName) const disinherit = getAttributeValue(ancestor, 'hx-disinherit') var inherit = getAttributeValue(ancestor, 'hx-inherit') if (initialElement !== ancestor) { if (htmx.config.disableInheritance) { if (inherit && (inherit === '*' || inherit.split(' ').indexOf(attributeName) >= 0)) { return attributeValue } else { return null } } if (disinherit && (disinherit === '*' || disinherit.split(' ').indexOf(attributeName) >= 0)) { return 'unset' } } return attributeValue } /** * @param {Element} elt * @param {string} attributeName * @returns {string | null} */ function getClosestAttributeValue(elt, attributeName) { let closestAttr = null getClosestMatch(elt, function(e) { return !!(closestAttr = getAttributeValueWithDisinheritance(elt, asElement(e), attributeName)) }) if (closestAttr !== 'unset') { return closestAttr } } /** * @param {Node} elt * @param {string} selector * @returns {boolean} */ function matches(elt, selector) { return elt instanceof Element && elt.matches(selector) } /** * @param {string} str * @returns {string} */ function getStartTag(str) { const tagMatcher = /<([a-z][^\/\0>\x20\t\r\n\f]*)/i const match = tagMatcher.exec(str) if (match) { return match[1].toLowerCase() } else { return '' } } /** * @param {string} resp * @returns {Document} */ function parseHTML(resp) { const parser = new DOMParser() return parser.parseFromString(resp, 'text/html') } /** * @param {DocumentFragment} fragment * @param {Node} elt */ function takeChildrenFor(fragment, elt) { while (elt.childNodes.length > 0) { fragment.append(elt.childNodes[0]) } } /** * @param {HTMLScriptElement} script * @returns {HTMLScriptElement} */ function duplicateScript(script) { const newScript = getDocument().createElement('script') forEach(script.attributes, function(attr) { newScript.setAttribute(attr.name, attr.value) }) newScript.textContent = script.textContent newScript.async = false if (htmx.config.inlineScriptNonce) { newScript.nonce = htmx.config.inlineScriptNonce } return newScript } /** * @param {HTMLScriptElement} script * @returns {boolean} */ function isJavaScriptScriptNode(script) { return script.matches('script') && (script.type === 'text/javascript' || script.type === 'module' || script.type === '') } /** * we have to make new copies of script tags that we are going to insert because * SOME browsers (not saying who, but it involves an element and an animal) don't * execute scripts created in