Skip to content

Commit

Permalink
Jinja2 support (#664)
Browse files Browse the repository at this point in the history
* add `jinja2` as optional dependency

* Add Jinja2 `bootstrap_*` tags extension

---------

Co-authored-by: Dylan Verheul <dylan@dyve.net>
  • Loading branch information
jorenham and dyve authored Sep 15, 2024
1 parent 83714f6 commit 0f7f261
Show file tree
Hide file tree
Showing 5 changed files with 192 additions and 1 deletion.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ readme = "README.md"
requires-python = ">=3.8"
version = "24.2"

[project.optional-dependencies]
jinja = ["Jinja2>=3.0,<4"]

[project.urls]
Changelog = "https://github.com/zostera/django-bootstrap5/blob/main/CHANGELOG.md"
Documentation = "https://django-bootstrap5.readthedocs.io/"
Expand Down
1 change: 1 addition & 0 deletions requirements-test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ coverage==7.6.1
ruff==0.6.5
pillow>=10.4.0
beautifulsoup4>=4.12.3
Jinja2>=3.1.4,<4
70 changes: 70 additions & 0 deletions src/django_bootstrap5/jinja2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from typing import Final

import jinja2
from jinja2.ext import Extension

from . import components, core, css, forms
from .templatetags import django_bootstrap5 as tags

__all__ = ["BootstrapTags"]

_PREFIX: Final = "bootstrap_"


def get_language_code(context) -> str:
language_code: None | str

# recycle `LANGUAGE_CODE` if it exists
language_code = context.get("LANGUAGE_CODE")
if language_code:
return language_code

# check for a request object to extract the language from
request = context.get("request")
language_code = getattr(request, "LANGUAGE_CODE")
if language_code:
return language_code

# defer expensive django import (python caches imports)
from django.utils.translation import get_language

return get_language.get_language()


def pagination(context, page, **kwargs) -> str:
from django.template.loader import render_to_string

context = dict(context) | tags.bootstrap_pagination(page, **kwargs)
return render_to_string("django_bootstrap5/pagination.html", context=context)


class BootstrapTags(Extension):
def __init__(self, environment: jinja2.Environment):
super().__init__(environment)

self.environment.globals.update({
f"{_PREFIX}alert": components.render_alert,
f"{_PREFIX}button": components.render_button,
f"{_PREFIX}css": tags.bootstrap_css,
f"{_PREFIX}css_url": core.css_url,
f"{_PREFIX}field": forms.render_field,
f"{_PREFIX}form": forms.render_form,
f"{_PREFIX}form_errors": forms.render_form_errors,
f"{_PREFIX}formset": forms.render_formset,
f"{_PREFIX}formset_errors": forms.render_formset_errors,
f"{_PREFIX}javascript": tags.bootstrap_javascript,
f"{_PREFIX}javascript_url": core.javascript_url,
f"{_PREFIX}label": forms.render_label,
f"{_PREFIX}messages": jinja2.pass_context(
lambda ctx: tags.bootstrap_messages(dict(ctx))
),
f"{_PREFIX}pagination": jinja2.pass_context(pagination),

# undocumented private functions
f"{_PREFIX}setting": core.get_bootstrap_setting,
f"{_PREFIX}server_side_validation_class": (
tags.bootstrap_server_side_validation_class
),
f"{_PREFIX}classes": css.merge_css_classes,
f"{_PREFIX}language_code": jinja2.pass_context(get_language_code),
})
32 changes: 31 additions & 1 deletion tests/base.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import functools
from typing import ClassVar, Literal

import jinja2
import jinja2.ext
from django import get_version
from django.template import engines
from django.test import TestCase
Expand All @@ -8,8 +13,33 @@
class BootstrapTestCase(TestCase):
"""TestCase with render function for template code."""

def render(self, text, context=None, load_bootstrap=True):
template_engine: ClassVar[Literal["django", "jinja2"]] = "django"

@functools.cached_property
def _jinja2_env(self) -> jinja2.Environment:
"""Lazy initialization of jinja2 Environment."""
from django_bootstrap5.jinja2 import BootstrapTags

return jinja2.Environment(
autoescape=True,
extensions=[jinja2.ext.i18n, BootstrapTags],
loader=jinja2.FileSystemLoader("tests/templates"),
)

def _render_django(self, text, context=None, load_bootstrap=True):
"""Return rendered result of template with given context."""
prefix = "{% load django_bootstrap5 %}" if load_bootstrap else ""
template = engines["django"].from_string(f"{prefix}{text}")
return template.render(context or {})

def _render_jinja2(self, content: str, /, context=None) -> str:
"""Render the jinja2 html content to string with given context."""
template = self._jinja2_env.from_string(content)
return template.render(context or {})

def render(self, content: str, /, context=None, *args, **kwargs) -> str:
engine = self.template_engine
if engine == "django":
return self._render_django(content, context, *args, **kwargs)
elif engine == "jinja2":
return self._render_jinja2(content, context, *args, **kwargs)
87 changes: 87 additions & 0 deletions tests/test_jinja2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from tests.base import BootstrapTestCase
from tests.test_bootstrap_css_and_js_tags import MediaTestCase
from tests.test_bootstrap_field_input_text import CharFieldTestForm
from tests.test_bootstrap_formset import TestFormSet


class Jinja2TestCase(BootstrapTestCase):
template_engine = "jinja2"

def test_empty_template(self) -> None:
self.assertEqual(self.render("").strip(), "")

def test_text_template(self):
self.assertEqual(self.render("some text").strip(), "some text")

def test_alert(self):
self.assertEqual(
self.render('{{ bootstrap_alert("content", dismissible=False) }}'),
'<div class="alert alert-info" role="alert">content</div>',
)

def test_button(self):
self.assertHTMLEqual(
self.render("{{ bootstrap_button('button', id='foo') }}"),
'<button class="btn btn-primary" id="foo">button</button>',
)

def test_css(self):
self.assertHTMLEqual(
self.render("{{ bootstrap_css() }}"),
MediaTestCase.expected_bootstrap_css,
)

def test_js(self):
self.assertHTMLEqual(
self.render("{{ bootstrap_javascript() }}"),
MediaTestCase.expected_bootstrap_js,
)

def test_field(self):
form = CharFieldTestForm()
self.assertTrue(
self.render("{{ bootstrap_field(form.test) }}", context={"form": form}),
)

def test_form(self):
form = CharFieldTestForm()
self.assertTrue(
self.render("{{ bootstrap_form(form) }}", context={"form": form}),
)

def test_formset(self):
formset = TestFormSet()
self.assertTrue(
self.render("{{ bootstrap_formset(formset) }}", context={"formset": formset}),
)

def test_label(self):
self.assertHTMLEqual(
self.render('{{ bootstrap_label("Subject") }}'),
'<label class="form-label">Subject</label>',
)

def test_pagination(self):
from django.core.paginator import Paginator

paginator = Paginator(["john", "paul", "george", "ringo"], 2)
html = self.render(
"{{ bootstrap_pagination(page, extra='url=\"/projects/?foo=bar\"') }}}",
{"page": paginator.page(1)}
)
self.assertTrue(html)

def test_messages(self):
from django.contrib.messages import constants as DEFAULT_MESSAGE_LEVELS
from django.contrib.messages.storage.base import Message

messages = [Message(DEFAULT_MESSAGE_LEVELS.ERROR, "hello")]
self.assertTrue(
self.render("{{ bootstrap_messages() }}", {"messages": messages}),
)

def test_setting(self):
self.assertEqual(
self.render('{{ bootstrap_setting("required_css_class") }}'),
"django_bootstrap5-req",
)

0 comments on commit 0f7f261

Please sign in to comment.