Skip to main content

Custom Loaders

Loaders are responsible for finding a template's source text given a name or identifier. Built-in loaders include a FileSystemLoader, a FileExtensionLoader, a ChoiceLoader and a DictLoader. You might want to write a custom loader to load templates from a database or add extra meta data to the template context, for example.

Write a custom loader class by inheriting from liquid.loaders.BaseLoader and implementing its get_source method. Then pass an instance of your loader to a liquid.Environment as the loader argument.

We could implement our own version of DictLoader like this.

myloaders.py
from typing import TYPE_CHECKING
from typing import Dict

from liquid.loaders import BaseLoader
from liquid.loaders import TemplateSource
from liquid.exceptions import TemplateNotFound

if TYPE_CHECKING:
from liquid import Environment

class DictLoader(BaseLoader):
def __init__(self, templates: Dict[str, str]):
self.templates = templates

def get_source(self, _: Environment, template_name: str) -> TemplateSource:
try:
source = self.templates[template_name]
except KeyError as err:
raise TemplateNotFound(template_name) from err

return TemplateSource(source, template_name, None)

TemplateSource is a named tuple containing the template source as a string, its name and an optional uptodate callable. If uptodate is not None it should be a callable that returns False if the template needs to be loaded again, or True otherwise.

You could then use DictLoader like this.

from liquid import Environment
from myloaders import DictLoader

snippets = {
"greeting": "Hello {{ user.name }}",
"row": """
<div class="row"'
<div class="col">
{{ row_content }}
</div>
</div>
""",
}

env = Environment(loader=DictLoader(snippets))

template = env.from_string("""
<html>
{% include 'greeting' %}
{% for i in (1..3) %}
{% include 'row' with i as row_content %}
{% endfor %}
</html>
""")

print(template.render(user={"name": "Brian"}))

Loading Sections and Snippets

New in version 1.1.3

Custom loaders can reference the name of the tag that's trying to load a template, if used from a tag like {% include 'template_name' %} or {% render 'template_name' %}, or any custom tag that uses Context.get_template_with_context().

This is useful for situations where you want to load partial templates (or "snippets" or "sections") from sub folders within an existing search path, without requiring template authors to include sub folder names in every include or render tag.

BaseLoader.get_source_with_context() and BaseLoader.get_source_with_context_async() where added in Python Liquid version 1.1.3. These methods are similar to get_source() and get_source_async(), but are passed the active render context instead of an environment, and arbitrary keyword arguments that can be used by a loader to modify its search space. Their default implementations ignore context and keyword arguments, simply delegating to get_source() or get_source_async().

note

Context.get_template_with_context() and Context.get_template_with_context_async() do not use the default template cache. The environment that manages the default template cache does not know what context variables and keyword arguments might be used to manipulate the search space or loaded template.

This example extends FileExtensionLoader, making .liquid optional, and searches ./snippets/ (relative to the loaders search path) for templates when rendering with the built-in include tag.

from pathlib import Path

from liquid import Context
from liquid.loaders import TemplateSource
from liquid.loaders import FileExtensionLoader

class SnippetsFileSystemLoader(FileExtensionLoader):
def get_source_with_context(
self,
context: Context,
template_name: str,
**kwargs: str,
) -> TemplateSource:
if kwargs.get("tag") == "include":
section = Path("snippets").joinpath(template_name)
return self.get_source(context.env, str(section))
return self.get_source(context.env, template_name)

tag being parse as a keyword argument is a convention used by the built-in {% include %} and {% render %} tags. Custom tags are free to pass whatever keyword arguments they wish to Context.get_template_with_context(), and they will be passed on to get_source_with_context() of the configured loader.

This example leaves the include tag's search path alone, instead defining a section tag that inherits from include and searches for templates in the sections/ subfolder of templates/.

from pathlib import Path

from liquid import Context
from liquid import Environment
from liquid.loaders import FileExtensionLoader
from liquid.loaders import TemplateSource
from liquid.builtin.tags.include_tag import IncludeNode
from liquid.builtin.tags.include_tag import IncludeTag

class SectionNode(IncludeNode):
tag = "section"

class SectionTag(IncludeTag):
name = "section"
node_class = SectionNode

class SectionFileSystemLoader(FileExtensionLoader):
def get_source_with_context(
self,
context: Context,
template_name: str,
**kwargs: str,
) -> TemplateSource:
if kwargs.get("tag") == "section":
section = Path("sections").joinpath(template_name)
return self.get_source(context.env, str(section))
return self.get_source(context.env, template_name)

env = Environment(loader=SectionFileSystemLoader(search_path="templates/"))
env.add_tag(SectionTag)

Loading with Context

New in version 1.1.3

When using Liquid in multi-user applications, a loader might need to narrow its search space depending on the current user. The classic example being Shopify, where, to be able to find the appropriate template, the loader must know what the current store ID is.

A loader can reference the current render context by implementing BaseLoader.get_source_with_context() and/or BaseLoader.get_source_with_context_async(). This example gets a site_id from the active render context and uses it in combination with the template's name to query an SQLite database. It assumes a table called templates exists with columns source, updated, name and site_id.

import sqlite3
import functools

from liquid import Context
from liquid.loaders import BaseLoader
from liquid.loaders import TemplateSource
from liquid.exceptions import TemplateNotFound

class SQLiteLoader(BaseLoader):
def __init__(self, con: sqlite3.Connection):
self.con = con

def get_source_with_context(
self, context: Context, template_name: str, **kwargs: str
) -> TemplateSource:
site_id = context.resolve("site_id")
cur = self.con.cursor()
cur.execute(
"SELECT source, updated "
"FROM templates "
"WHERE name = ? "
"AND site_id = ?",
[template_name, site_id],
)

source = cur.fetchone()
if not source:
raise TemplateNotFound(template_name)

return TemplateSource(
source=source[0],
filename=template_name,
uptodate=functools.partial(
self._is_site_up_to_date,
name=template_name,
site_id=site_id,
updated=source[1],
),
)

def get_source(self, env: Environment, template_name: str) -> TemplateSource:
cur = self.con.cursor()
cur.execute(
"SELECT source, updated FROM templates WHERE name = ?",
[template_name],
)

source = cur.fetchone()
if not source:
raise TemplateNotFound(template_name)

return TemplateSource(
source=source[0],
filename=template_name,
uptodate=functools.partial(
self._is_up_to_date,
name=template_name,
updated=source[1],
),
)

def _is_site_up_to_date(self, name: str, site_id: int, updated: str) -> bool:
cur = self.con.cursor()
cur.execute(
"SELECT updated FROM templates WHERE name = ? AND site_id = ?",
[name, site_id],
)

row = cur.fetchone()
if not row:
return False
return updated == row[0]

def _is_up_to_date(self, name: str, updated: str) -> bool:
cur = self.con.cursor()
cur.execute(
"SELECT updated FROM templates WHERE name = ?",
[name],
)

row = cur.fetchone()
if not row:
return False
return updated == row[0]

Front Matter Loader

Loaders can add to a template's render context using the matter argument to TemplateSource. This example implements a Jekyll style front matter loader.

import re
import yaml # Assumes pyyaml is installed

from liquid import Environment
from liquid.loaders import FileSystemLoader
from liquid.loaders import TemplateSource

RE_FRONT_MATTER = re.compile(r"\s*---\s*(.*?)\s*---\s*", re.MULTILINE | re.DOTALL)


class FrontMatterFileSystemLoader(FileSystemLoader):
def get_source(
self,
env: Environment,
template_name: str,
) -> TemplateSource:
source, filename, uptodate, matter = super().get_source(env, template_name)
match = RE_FRONT_MATTER.search(source)

if match:
# Should add some yaml error handling here.
matter = yaml.load(match.group(1), Loader=yaml.Loader)
source = source[match.end() :]

return TemplateSource(
source,
filename,
uptodate,
matter,
)

Async Database Loader

Template loaders can implement get_source_async(). When a template is rendered by awaiting BoundTemplate.render_async() instead of calling BoundTemplate.render(), {% render %} and {% include %} tags will use get_template_async of the bound Environment, which delegates to get_source_async of the configured loader.

For example, AsyncDatabaseLoader will load templates from a PostgreSQL database using asyncpg.

import datetime
import functools

import asyncpg

from liquid import Environment
from liquid.exceptions import TemplateNotFound
from liquid.loaders import BaseLoader
from liquid.loaders import TemplateSource


class AsyncDatabaseLoader(BaseLoader):
def __init__(self, pool: asyncpg.Pool) -> None:
self.pool = pool

def get_source(self, env: Environment, template_name: str) -> TemplateSource:
raise NotImplementedError("async only loader")

async def _is_up_to_date(self, name: str, updated: datetime.datetime) -> bool:
async with self.pool.acquire() as connection:
return updated == await connection.fetchval(
"SELECT updated FROM templates WHERE name = $1", name
)

async def get_source_async(
self, env: Environment, template_name: str
) -> TemplateSource:
async with self.pool.acquire() as connection:
source = await connection.fetchrow(
"SELECT source, updated FROM templates WHERE name = $1", template_name
)

if not source:
raise TemplateNotFound(template_name)

return TemplateSource(
source=source["source"],
filename=template_name,
uptodate=functools.partial(
self._is_up_to_date, name=template_name, updated=source["updated"]
),
)

File Extension Loader

This example extends FileSystemLoader to automatically append a file extension if one is missing.

from pathlib import Path

from typing import Union
from typing import Iterable

from liquid.loaders import FileSystemLoader


class FileExtensionLoader(FileSystemLoader):
"""A file system loader that adds a file name extension if one is missing."""

def __init__(
self,
search_path: Union[str, Path, Iterable[Union[str, Path]]],
encoding: str = "utf-8",
ext: str = ".liquid",
):
super().__init__(search_path, encoding=encoding)
self.ext = ext

def resolve_path(self, template_name: str) -> Path:
template_path = Path(template_name)

if not template_path.suffix:
template_path = template_path.with_suffix(self.ext)

# Don't allow "../" to escape the search path.
if os.path.pardir in template_path.parts:
raise TemplateNotFound(template_name)

for path in self.search_path:
source_path = path.joinpath(template_path)

if not source_path.exists():
continue
return source_path
raise TemplateNotFound(template_name)