Skip to main content

Render HTML templates using Jinja2.

Project description

plain.templates

Render HTML templates using Jinja2.

Overview

Plain uses Jinja2 for template rendering. You can refer to the Jinja documentation for all of the features available.

Templates are typically used with TemplateView or one of its subclasses (see Template-rendering views).

# app/views.py
from plain.templates.views import TemplateView


class ExampleView(TemplateView):
    template_name = "example.html"

    def get_template_context(self):
        context = super().get_template_context()
        context["message"] = "Hello, world!"
        return context
<!-- app/templates/example.html -->
{% extends "base.html" %}

{% block content %}
    <h1>{{ message }}</h1>
{% endblock %}

Template files

Template files can live in two locations:

  1. app/templates/ - Your app's templates (highest priority)
  2. {package}/templates/ - Templates inside any installed package

All template directories are merged together, so you can override templates from installed packages by creating a file with the same name in app/templates/.

Template-rendering views

plain.templates.views ships the view classes that render templates. The base View class lives in core plain.views and doesn't know about templates — install plain.templates to use any of these.

TemplateView

TemplateView renders a Jinja template:

from plain.templates.views import TemplateView


class ExampleView(TemplateView):
    template_name = "example.html"

    def get_template_context(self):
        context = super().get_template_context()
        context["message"] = "Hello, world!"
        return context

For simple pages that don't need custom context, configure TemplateView directly in your URL routes:

from plain.templates.views import TemplateView
from plain.urls import path, Router


class AppRouter(Router):
    routes = [
        path("/example/", TemplateView.as_view(template_name="example.html")),
    ]

FormView

FormView handles displaying and processing forms. The form is automatically available in your template as form:

from plain.templates.views import FormView
from .forms import ExampleForm


class ExampleView(FormView):
    template_name = "example.html"
    form_class = ExampleForm
    success_url = "."

    def form_valid(self, form):
        return super().form_valid(form)

Object views

DetailView, CreateView, UpdateView, DeleteView, and ListView provide standard CRUD scaffolding. Each requires you to implement get_object() or get_objects():

from plain.templates.views import DetailView


class ExampleDetailView(DetailView):
    template_name = "detail.html"

    def get_object(self):
        return MyObjectClass.query.get(
            id=self.url_kwargs["id"],
            user=self.request.user,
        )

The single object is exposed in templates as object; list views expose objects. Set context_object_name for a more descriptive name.

Error views

TemplateView overrides handle_exception to render {status}.html for any exception that escapes the handler — 404.html for NotFoundError404, 500.html for unhandled errors, etc. The context is {request, status_code, exception, DEBUG}. On TemplateFileMissing the view returns a plain-text status response (404 Not Found, 500 Internal Server Error); on any other render failure it logs and returns a bare-status Response so _respond_to_exception can still attach response.exception for observability.

Plain core's exception handler — the one that catches pre-view failures like URL resolution and middleware errors — returns plain text. To get a styled 404 for unmatched URLs, mount NotFoundView as the last route:

from plain.templates.views import NotFoundView
from plain.urls import Router, path

class AppRouter(Router):
    urls = [
        # ... your routes ...
        path("<path:_>", NotFoundView),
    ]

NotFoundView.before_request raises NotFoundError404 before method dispatch, so every HTTP method produces a 404 instead of a 405.

The resolver recognizes a sole-segment terminal <path:> as a catchall: it handles both /missing and /missing/ from one mount, and it yields to trailing-slash redirects from specific routes. So path("login/", LoginView) followed by path("<path:_>", NotFoundView) still 308's /login to /login/ rather than serving the 404 — the catchall only fires when nothing else came close.

Your 500.html template should be self-contained — avoid extending base templates or accessing the database/session, since 500s can fire during middleware or template-rendering errors. 404.html and 403.html can safely extend base templates since they happen after middleware runs.

Template context

When using TemplateView, you pass data to templates by overriding get_template_context():

from plain.templates.views import TemplateView


class ProductView(TemplateView):
    template_name = "product.html"

    def get_template_context(self):
        context = super().get_template_context()
        context["product"] = Product.objects.get(id=self.url_kwargs["id"])
        context["related_products"] = Product.objects.filter(category=context["product"].category)[:5]
        return context

The context is then available in your template:

<h1>{{ product.name }}</h1>
<ul>
{% for item in related_products %}
    <li>{{ item.name }}</li>
{% endfor %}
</ul>

get_template_context() is a pull — the framework calls it at render time, so the data has to be reachable from self. When a view writes its own handlers (a .get() and .post() that render the same template), render(**context) is the push alternative: the handler passes context straight in, and gets the Response back.

class ProductView(TemplateView):
    template_name = "product.html"

    def get(self):
        return self.render(product=Product.query.get(id=self.url_kwargs["id"]))

render(**context) layers context over get_template_context(), so the base context (request, DEBUG, template_names) and anything the view's get_template_context() adds are still present.

Built-in globals

Plain provides several global functions available in all templates:

Global Description
asset(path) Returns the URL for a static asset
url(name, *args, **kwargs) Reverses a URL by name
Paginator The Paginator class for pagination
now() Returns the current datetime
timedelta The timedelta class for date math
localtime(dt) Converts a datetime to local time
<link rel="stylesheet" href="{{ asset('css/style.css') }}">
<a href="{{ url('product_detail', id=product.id) }}">View</a>
<p>Generated at {{ now() }}</p>

Built-in filters

Plain includes several filters for common operations:

Filter Description
strftime(format) Formats a datetime
strptime(format) Parses a string to datetime
fromtimestamp(ts) Creates datetime from timestamp
fromisoformat(s) Creates datetime from ISO string
localtime(tz) Converts to local timezone
timeuntil Human-readable time until a date
timesince Human-readable time since a date
json_script(id) Outputs JSON safely in a script tag
islice(stop) Slices iterables (useful for dicts)
pluralize(singular, plural) Returns plural suffix based on count
<p>Posted {{ post.created_at|timesince }} ago</p>
<p>{{ items|length }} item{{ items|length|pluralize }}</p>
<p>{{ 5 }} ox{{ 5|pluralize("en") }}</p>
{{ data|json_script("page-data") }}

Custom globals and filters

You can register your own globals and filters in app/templates.py (or {package}/templates.py). These files are automatically imported when the template environment loads.

# app/templates.py
from plain.templates import register_template_filter, register_template_global


@register_template_filter
def camel_case(value):
    """Convert a string to CamelCase."""
    return value.replace("_", " ").title().replace(" ", "")


@register_template_global
def app_version():
    """Return the current app version."""
    return "1.0.0"

Now you can use these in templates:

<p>{{ "my_variable"|camel_case }}</p>  <!-- outputs: MyVariable -->
<footer>Version {{ app_version() }}</footer>

You can also register non-callable values as globals by providing a name:

from plain.templates import register_template_global

register_template_global("1.0.0", name="APP_VERSION")

Custom template extensions

For more complex template features, you can create Jinja extensions. The InclusionTagExtension base class makes it easy to create custom tags that render their own templates.

# app/templates.py
from plain.templates import register_template_extension
from plain.templates.jinja.extensions import InclusionTagExtension
from plain.runtime import settings


@register_template_extension
class AlertExtension(InclusionTagExtension):
    tags = {"alert"}
    template_name = "components/alert.html"

    def get_context(self, context, *args, **kwargs):
        return {
            "message": args[0] if args else "",
            "type": kwargs.get("type", "info"),
        }
<!-- app/templates/components/alert.html -->
<div class="alert alert-{{ type }}">{{ message }}</div>
<!-- Usage in any template -->
{% alert "Something happened!" type="warning" %}

Rendering templates manually

You can render templates outside of views using the Template class.

from plain.templates import Template

html = Template("email/welcome.html").render({
    "user_name": "Alice",
    "activation_url": "https://example.com/activate/abc123",
})

If the template file doesn't exist, a TemplateFileMissing exception is raised.

Custom Jinja environment

By default, Plain uses a DefaultEnvironment that configures Jinja2 with sensible defaults:

  • Autoescaping enabled for security
  • StrictUndefined so undefined variables raise errors
  • Auto-reload in debug mode
  • Loop controls extension (break, continue)
  • Debug extension

You can customize the environment by creating your own class and pointing to it in settings:

# app/jinja.py
from plain.templates.jinja.environments import DefaultEnvironment


class CustomEnvironment(DefaultEnvironment):
    def __init__(self):
        super().__init__()
        # Add your customizations here
        self.globals["CUSTOM_SETTING"] = "value"
# app/settings.py
TEMPLATES_JINJA_ENVIRONMENT = "app.jinja.CustomEnvironment"

FAQs

Why am I getting "undefined variable" errors?

Plain uses Jinja's StrictUndefined mode, which raises an error when you reference a variable that doesn't exist in the context. This helps catch typos and missing data early. Make sure you're passing all required variables in get_template_context().

Why does my template show an error about a callable?

Plain's template environment prevents accidentally rendering callables (functions, methods) directly. If you see an error like "X is callable, did you forget parentheses?", you probably need to add () to call the function:

<!-- Wrong -->
{{ user.get_full_name }}

<!-- Correct -->
{{ user.get_full_name() }}

How do I use Jinja's loop controls?

Plain enables the loopcontrols extension by default, so you can use break and continue in loops:

{% for item in items %}
    {% if item.skip %}
        {% continue %}
    {% endif %}
    {% if item.stop %}
        {% break %}
    {% endif %}
    <p>{{ item.name }}</p>
{% endfor %}

Where can I learn more about Jinja2?

The Jinja2 documentation covers all the template syntax, including conditionals, loops, macros, and inheritance.

Forms

Forms are rendered manually using the bound field attributes:

<form method="post">
    <div>
        <label for="{{ form.email.html_id }}">Email</label>
        <input
            type="email"
            name="{{ form.email.html_name }}"
            id="{{ form.email.html_id }}"
            value="{{ form.email.value }}"
        >
        {% for error in form.email.errors %}
        <p>{{ error }}</p>
        {% endfor %}
    </div>
    <button type="submit">Submit</button>
</form>

Each bound field provides: html_name, html_id, value, errors, field, initial.

Installation

Install the plain.templates package:

uv add plain.templates

Then add it to INSTALLED_PACKAGES:

# app/settings.py
INSTALLED_PACKAGES = [
    "plain.templates",
    # ...
]

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

plain_templates-0.3.0.tar.gz (20.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

plain_templates-0.3.0-py3-none-any.whl (23.2 kB view details)

Uploaded Python 3

File details

Details for the file plain_templates-0.3.0.tar.gz.

File metadata

  • Download URL: plain_templates-0.3.0.tar.gz
  • Upload date:
  • Size: 20.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.14 {"installer":{"name":"uv","version":"0.11.14","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for plain_templates-0.3.0.tar.gz
Algorithm Hash digest
SHA256 265f57de6db1073fd3a7a60b9b675f0abcbea0501fdb76b8386d9884281347dc
MD5 752c49c5c9e2ef91c4856df87df03764
BLAKE2b-256 e64ed711cb2e8e5a8b1bab12c663e5801ac81daf65a5e9238a602bd67748ed92

See more details on using hashes here.

File details

Details for the file plain_templates-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: plain_templates-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 23.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.14 {"installer":{"name":"uv","version":"0.11.14","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for plain_templates-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 9f1ed55b1140c983fed33ab9d52ca4b3c9a7bacc591ec59c398b68fedadd48c9
MD5 5815bf446f281c83a308459cf22f22b1
BLAKE2b-256 461f100486a08fcc69671aa4d3a3ea488949747e3dc0c656c17497ea4c658f67

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page