# https://mustache.github.io/mustache.5.html from __future__ import annotations from collections import ChainMap from collections.abc import Callable from collections.abc import Iterable from collections.abc import Mapping from dataclasses import dataclass from functools import cache Escape = Callable[[str], str] @dataclass class Node: type: str token: str file: str line: str lineno: int children: list[Node] class TemplateError(Exception): def __str__(self) -> str: node = self.args[0] return f'"{node.token}" in {node.file}:{node.lineno}' def tokenize(s: str, path: str) -> Iterable[Node]: for i, line in enumerate(s.splitlines(keepends=True), start=1): tail = line while '{{' in tail: head, tail = tail.split('{{', 1) try: token, tail = tail.split('}}', 1) except ValueError as e: node = Node('err', tail, path, line, i, []) raise TemplateError(node) from e yield Node('text', head, path, line, i, []) if token and token[0] in ['!', '#', '^', '/', '>', '&']: yield Node(token[0], token[1:].strip(), path, line, i, []) else: yield Node('var', token.strip(), path, line, i, []) yield Node('text', tail, path, line, i, []) def parse(s: str, path: str='') -> list[Node]: stack: list[list[Node]] = [[]] for node in tokenize(s, path): if node.type in ['#', '^']: stack.append([]) elif node.type == '/': section = stack.pop() if section[0].token != node.token: raise TemplateError(node) node = section.pop(0) node.children = section stack[-1].append(node) if len(stack) != 1: raise TemplateError(stack[-1][0]) return stack[0] @cache def get_template(path: str, indent: str = '') -> list[Node]: with open(path) as fh: return parse(fh.read(), path) def is_standalone(line: str) -> bool: nodes = list(tokenize(line, '')) return ( len(nodes) == 3 and not nodes[0].token.strip() and nodes[1].type in ['!', '#', '^', '/'] and not nodes[2].token.strip() ) def render_section(node: Node, context, escape: Escape) -> str: value = context.get(node.token) if isinstance(value, list): output = '' for item in value: if not isinstance(item, Mapping): item = {'.': item} output += render(node.children, ChainMap(item, context), escape) return output elif callable(value): return value(node.children, context, escape) elif value: if not isinstance(value, Mapping): value = {'.': value} return render(node.children, ChainMap(value, context), escape) else: return '' def render(nodes: Iterable[Node], context, escape: Escape) -> str: output = '' for node in nodes: try: if node.type == 'text': if not is_standalone(node.line): output += node.token elif node.type == '#': output += render_section(node, context, escape) elif node.type == '^': value = context.get(node.token) if not value: output += render(node.children, context, escape) elif node.type == '>': children = get_template(node.token) output += render(children, context, escape) elif node.type == '&': output += str(context.get(node.token) or '') elif node.type == 'var': output += escape(str(context.get(node.token) or '')) except KeyError as e: raise TemplateError(node) from e return output