Custom Loaders

Build custom template loaders

3 min read 674 words

Build custom loaders to load templates from databases, APIs, or other sources.

Loader Protocol

Implement two methods:

class Loader:
    def get_source(self, name: str) -> tuple[str, str | None]:
        """Return (source, filename) for template."""
        ...

    def list_templates(self) -> list[str]:
        """Return list of all template names."""
        ...

Database Loader

Load templates from a database:

from kida import Environment, TemplateNotFoundError

class DatabaseLoader:
    def __init__(self, connection):
        self.conn = connection

    def get_source(self, name: str) -> tuple[str, str | None]:
        row = self.conn.execute(
            "SELECT source FROM templates WHERE name = ?",
            (name,)
        ).fetchone()

        if not row:
            raise TemplateNotFoundError(f"Template '{name}' not found")

        # Return (source, filename for error messages)
        return row[0], f"db://{name}"

    def list_templates(self) -> list[str]:
        rows = self.conn.execute(
            "SELECT name FROM templates"
        ).fetchall()
        return sorted(row[0] for row in rows)

# Usage
env = Environment(loader=DatabaseLoader(db_connection))
template = env.get_template("page.html")

Redis Loader

Load templates from Redis:

import redis
from kida import TemplateNotFoundError

class RedisLoader:
    def __init__(self, host="localhost", port=6379, prefix="templates:"):
        self.client = redis.Redis(host=host, port=port)
        self.prefix = prefix

    def get_source(self, name: str) -> tuple[str, str | None]:
        key = f"{self.prefix}{name}"
        source = self.client.get(key)

        if source is None:
            raise TemplateNotFoundError(f"Template '{name}' not found")

        return source.decode("utf-8"), f"redis://{key}"

    def list_templates(self) -> list[str]:
        pattern = f"{self.prefix}*"
        keys = self.client.keys(pattern)
        return sorted(k.decode().removeprefix(self.prefix) for k in keys)

# Usage
env = Environment(loader=RedisLoader())

HTTP Loader

Load templates from a remote server:

import httpx
from kida import TemplateNotFoundError

class HTTPLoader:
    def __init__(self, base_url: str):
        self.base_url = base_url.rstrip("/")
        self.client = httpx.Client()

    def get_source(self, name: str) -> tuple[str, str | None]:
        url = f"{self.base_url}/{name}"

        try:
            response = self.client.get(url)
            response.raise_for_status()
        except httpx.HTTPError:
            raise TemplateNotFoundError(f"Template '{name}' not found")

        return response.text, url

    def list_templates(self) -> list[str]:
        # Could fetch index from server
        return []

# Usage
env = Environment(loader=HTTPLoader("https://templates.example.com"))

Chained Loader

Try multiple loaders in order:

from kida import TemplateNotFoundError

class ChainLoader:
    def __init__(self, loaders: list):
        self.loaders = loaders

    def get_source(self, name: str) -> tuple[str, str | None]:
        for loader in self.loaders:
            try:
                return loader.get_source(name)
            except TemplateNotFoundError:
                continue

        raise TemplateNotFoundError(f"Template '{name}' not found in any loader")

    def list_templates(self) -> list[str]:
        templates = set()
        for loader in self.loaders:
            templates.update(loader.list_templates())
        return sorted(templates)

# Usage: Try database first, then filesystem
env = Environment(
    loader=ChainLoader([
        DatabaseLoader(db),
        FileSystemLoader("templates/"),
    ])
)

Prefix Loader

Route templates by prefix:

class PrefixLoader:
    def __init__(self, loaders: dict):
        self.loaders = loaders  # {"prefix/": loader}

    def get_source(self, name: str) -> tuple[str, str | None]:
        for prefix, loader in self.loaders.items():
            if name.startswith(prefix):
                template_name = name[len(prefix):]
                return loader.get_source(template_name)

        raise TemplateNotFoundError(f"No loader for '{name}'")

    def list_templates(self) -> list[str]:
        templates = []
        for prefix, loader in self.loaders.items():
            templates.extend(
                prefix + t for t in loader.list_templates()
            )
        return sorted(templates)

# Usage
env = Environment(
    loader=PrefixLoader({
        "db/": DatabaseLoader(db),
        "files/": FileSystemLoader("templates/"),
    })
)

# Loads from database
env.get_template("db/page.html")

# Loads from filesystem
env.get_template("files/base.html")

Caching Layer

Add caching to any loader:

from functools import lru_cache

class CachedLoader:
    def __init__(self, loader, maxsize=128):
        self.loader = loader
        self._get_source = lru_cache(maxsize=maxsize)(
            self._get_source_uncached
        )

    def _get_source_uncached(self, name: str):
        return self.loader.get_source(name)

    def get_source(self, name: str) -> tuple[str, str | None]:
        return self._get_source(name)

    def list_templates(self) -> list[str]:
        return self.loader.list_templates()

    def clear_cache(self):
        self._get_source.cache_clear()

# Usage
env = Environment(
    loader=CachedLoader(DatabaseLoader(db), maxsize=256)
)

Thread Safety

Loaders should be thread-safe:

import threading

class ThreadSafeLoader:
    def __init__(self, loader):
        self.loader = loader
        self._lock = threading.Lock()

    def get_source(self, name: str) -> tuple[str, str | None]:
        with self._lock:
            return self.loader.get_source(name)

    def list_templates(self) -> list[str]:
        with self._lock:
            return self.loader.list_templates()

Best Practices

Raise TemplateNotFoundError

from kida import TemplateNotFoundError

def get_source(self, name):
    if not self._exists(name):
        # ✅ Raise proper exception
        raise TemplateNotFoundError(f"Template '{name}' not found")

    # ❌ Don't return None
    # return None

Include Source Location

def get_source(self, name):
    source = self._load(name)
    # Include meaningful filename for error messages
    filename = f"db://{name}"  # or full path, URL, etc.
    return source, filename

Handle Encoding

def get_source(self, name):
    data = self._load_bytes(name)
    source = data.decode("utf-8")  # Handle encoding
    return source, name

See Also