Otera お寺

Otera (お寺, "temple") renders Jinja templates in pure Elixir. It has no native code and no dependencies.

The first goal is the chat templates of Hugging Face models. Otera renders them like apply_chat_template of Python transformers, byte for byte. The tests compare Otera with Python Jinja2 on 112 chat templates and on the core semantics of Jinja2.

The name follows Jinja, which got its name because "temple" sounds like "template". Jinja (神社) is a Shinto shrine. Otera (お寺) is a Buddhist temple.

Add it to your dependencies:

{:otera, "~> 0.1"}

Usage

Otera.render("Hello {{ name }}!", %{name: "World"})
#=> {:ok, "Hello World!"}

Parse a template once to render it many times:

{:ok, template} = Otera.parse(source, trim_blocks: true, lstrip_blocks: true)
Otera.render(template, %{messages: messages})

Otera.render/3 returns {:error, exception} with an Otera.SyntaxError for an error in the template syntax, and an Otera.Error for an error during the render. The kind of an Otera.Error names the Python exception, for example :undefined_error or :type_error, and the message is the message of Jinja2. Both errors give the line of the template where the error happened. For an error during the render, the line follows the template traceback of Jinja2: an error in a macro gives the line in the macro.

Custom filters, tests and functions are Elixir functions:

Otera.render("{{ name|shout }}", %{name: "otera"}, filters: %{"shout" => &String.upcase/1})
#=> {:ok, "OTERA"}

A plain Elixir function gets the positional arguments of the call. For keyword arguments and defaults, give an Otera.Function, which binds the arguments to a Python signature:

shout = Otera.Function.new("shout", ["value", {"times", 1}], fn value, times -> value <> String.duplicate("!", times) end)
Otera.render("{{ 'hi'|shout(times=2) }}", %{}, filters: %{"shout" => shout})
#=> {:ok, "hi!!"}

Autoescape

With the autoescape option, Otera escapes the HTML characters in the output of each {{ ... }}, like Jinja2. The safe filter marks a string as safe, and an Otera.Markup in the variables is safe too:

Otera.render("{{ comment }} {{ link|safe }}", %{comment: "<script>", link: "<a href='/'>Home</a>"}, autoescape: true)
#=> {:ok, "&lt;script&gt; <a href='/'>Home</a>"}

autoescape can also be a function that gets the name of the template, for example to escape only the templates that end with .html. The tag {% autoescape false %} turns the escaping off in a part of a template.

Inheritance, includes and imports

Give a loader to use the tags extends, include, import and from. A loader is a map from names to sources, a function, or Otera.Loader.file_system/1, which reads the templates from directories:

loader = %{
  "base.html" => "<title>{% block title %}Site{% endblock %}</title>{% block body %}{% endblock %}",
  "macros.html" => "{% macro link(url, text) %}<a href=\"{{ url }}\">{{ text }}</a>{% endmacro %}"
}

page = """
{% extends "base.html" %}
{% from "macros.html" import link %}
{% block title %}Docs | {{ super() }}{% endblock %}
{% block body %}{{ link("/docs", "Read the docs") }}{% endblock %}
"""

Otera.render(page, %{}, loader: loader)
#=> {:ok, "<title>Docs | Site</title><a href=\"/docs\">Read the docs</a>"}

Otera.load/2 loads a template by name, like get_template of Jinja2:

{:ok, template} = Otera.load("page.html", loader: Otera.Loader.file_system("templates"), autoescape: true)
Otera.render(template, %{user: user})

Blocks support super(), self.name(), scoped and required. An include can take a list of names, ignore missing, and with context or without context. An import is without context by default, like in Jinja2.

During a render, Otera loads and parses each template once. To keep the parsed templates for many renders, like the cache of a Jinja2 Environment, start an Otera.Cache in your supervision tree and give its name with the :cache option:

children = [{Otera.Cache, name: MyApp.Templates}]

opts = [loader: Otera.Loader.file_system("priv/templates"), cache: MyApp.Templates]
{:ok, template} = Otera.load("page.html", opts)
Otera.render(template, %{title: "Home"}, opts)

Like the auto reload of Jinja2, the cache loads a file again when its modification time or size changes, and a template of a map again when the map has another source for its name. A template of a function stays until Otera.Cache.clear/1, unless the function gives an uptodate function with the source. These checks read the status of each file at each render. Like auto_reload=False in Jinja2, start the cache with auto_reload: false to use each template without a check until Otera.Cache.clear/1:

children = [{Otera.Cache, name: MyApp.Templates, auto_reload: false}]

For a page with an extends tag and two includes from the file system, a render takes about 650 µs without the cache, 160 µs with it, and 67 µs with auto_reload: false.

Chat templates

Otera.ChatTemplate renders a chat template with the environment of transformers: trim_blocks, lstrip_blocks, the functions raise_exception and strftime_now, and the tojson filter of transformers.

Otera.ChatTemplate.render(template, messages,
  add_generation_prompt: true,
  tools: tools,
  variables: %{bos_token: "<|begin_of_text|>", enable_thinking: false}
)

It also supports continue_final_message, which ends the prompt inside the final message, and documents.

strftime_now formats the current local time. To get the same prompt every time, give a fixed time to Otera.ChatTemplate.options/1 and parse the template with these options:

{:ok, template} = Otera.parse(source, Otera.ChatTemplate.options(now: ~N[2025-03-09 14:30:05]))
Otera.ChatTemplate.render(template, messages, add_generation_prompt: true)

Key order

tojson and the iteration over a dict give the keys in order. Python dicts keep the insertion order, but Elixir maps do not. Otera uses these rules:

To get the same prompt as transformers for messages and tool definitions that come from JSON, decode them with Otera.JSON.decode!/1.

Compatibility

Otera follows Python Jinja2 3.1: the lexer and its whitespace control, the operators and their precedence, scoping, macros and call blocks, loops with loop and break and continue, namespaces, template inheritance, includes and imports, autoescape and the Markup of markupsafe, the builtin filters and tests, and the Python methods of strings, lists and dicts. Values print like Python values, for example True, None, ['a', 1.5] and range(0, 3). Like transformers, Otera renders in the immutable sandbox of Jinja2, so list.append and dict.update raise a security error.

Scoping follows the compiler of Jinja2. A macro reads the variables around its definition as they are when it runs, like a Python closure. A generator, such as the result of map or select, is lazy and makes a single pass, like in Python: it does its work when the template takes its items. A loop that reads a variable before a later top-level {% set %} of that variable sees it as undefined, like in Jinja2.

Known differences from Python:

Tests

The tests compare Otera with Python Jinja2. The scripts in scripts/ make the expected results with uv:

scripts/pyproject.toml and scripts/uv.lock pin the versions of Python, Jinja2 and transformers. To make the fixtures again, run these commands from the root of the repository:

uv run --project scripts python -I scripts/fetch_chat_templates.py
uv run --project scripts python -I scripts/generate_chat_expected.py
uv run --project scripts python -I scripts/generate_semantics_expected.py
uv run --project scripts python -I scripts/generate_html_entities.py
uv run --project scripts python -I scripts/generate_unicode_properties.py

The first command changes the corpus. Run it only when you want the new templates of the Hub. Set HF_TOKEN to fetch the gated repositories instead of their public mirrors.

CI tracks the Python reference:

A red PR shows that Python changed its behavior, or that Otera does not render a new template like Python.

License

Apache-2.0