stache

git clone https://git.ce9e.org/stache.git

commit
56dd23f00ef204833c007e81c605f81f774c00e5
parent
b56ce3a0a9740e38e670bd18cb2a26d7ee689b47
Author
Tobias Bengfort <tobias.bengfort@posteo.de>
Date
2021-07-04 12:28
init

Diffstat

A stache.py 123 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A tests.py 48 ++++++++++++++++++++++++++++++++++++++++++++++++

2 files changed, 171 insertions, 0 deletions


diff --git a/stache.py b/stache.py

@@ -0,0 +1,123 @@
   -1     1 # https://mustache.github.io/mustache.5.html
   -1     2 
   -1     3 from __future__ import annotations
   -1     4 
   -1     5 from collections import ChainMap
   -1     6 from collections.abc import Callable
   -1     7 from collections.abc import Iterable
   -1     8 from collections.abc import Mapping
   -1     9 from dataclasses import dataclass
   -1    10 from functools import cache
   -1    11 
   -1    12 Escape = Callable[[str], str]
   -1    13 
   -1    14 
   -1    15 @dataclass
   -1    16 class Node:
   -1    17     type: str
   -1    18     token: str
   -1    19     file: str
   -1    20     line: str
   -1    21     lineno: int
   -1    22     children: list
   -1    23 
   -1    24 
   -1    25 class TemplateError(Exception):
   -1    26     def __str__(self):
   -1    27         node = self.args[0]
   -1    28         return f'"{node.token}" in {node.file}:{node.line}'
   -1    29 
   -1    30 
   -1    31 def tokenize(s: str, path: str) -> Iterable[Node]:
   -1    32     for i, line in enumerate(s.splitlines(keepends=True), start=1):
   -1    33         tail = line
   -1    34         while '{{' in tail:
   -1    35             head, tail = tail.split('{{', 1)
   -1    36             try:
   -1    37                 token, tail = tail.split('}}', 1)
   -1    38             except ValueError as e:
   -1    39                 node = Node('err', tail, path, line, i, [])
   -1    40                 raise TemplateError(node) from e
   -1    41             yield Node('text', head, path, line, i, [])
   -1    42             if token and token[0] in ['!', '#', '^', '/', '>', '&']:
   -1    43                 yield Node(token[0], token[1:].strip(), path, line, i, [])
   -1    44             else:
   -1    45                 yield Node('var', token.strip(), path, line, i, [])
   -1    46         yield Node('text', tail, path, line, i, [])
   -1    47 
   -1    48 
   -1    49 def parse(s: str, path: str) -> list[Node]:
   -1    50     stack: list[list[Node]] = [[]]
   -1    51     for node in tokenize(s, path):
   -1    52         if node.type in ['#', '^']:
   -1    53             stack.append([])
   -1    54         elif node.type == '/':
   -1    55             section = stack.pop()
   -1    56             if section[0].token != node.token:
   -1    57                 raise TemplateError(node)
   -1    58             node = section.pop(0)
   -1    59             node.children = section
   -1    60         stack[-1].append(node)
   -1    61     if len(stack) != 1:
   -1    62         raise TemplateError(stack[-1][0])
   -1    63     return stack[0]
   -1    64 
   -1    65 
   -1    66 @cache
   -1    67 def get_template(path: str, indent: str = '') -> list[Node]:
   -1    68     with open(path) as fh:
   -1    69         return parse(fh.read(), path)
   -1    70 
   -1    71 
   -1    72 def is_standalone(line):
   -1    73     nodes = list(tokenize(line, ''))
   -1    74     return (
   -1    75         len(nodes) == 3
   -1    76         and not nodes[0].token.strip()
   -1    77         and nodes[1].type in ['!', '#', '^', '/']
   -1    78         and not nodes[2].token.strip()
   -1    79     )
   -1    80 
   -1    81 
   -1    82 def render_section(node: Node, context, escape: Escape) -> str:
   -1    83     value = context.get(node.token)
   -1    84     if isinstance(value, list):
   -1    85         output = ''
   -1    86         for item in value:
   -1    87             if not isinstance(item, Mapping):
   -1    88                 item = {'.': item}
   -1    89             output += render(node.children, ChainMap(item, context), escape)
   -1    90         return output
   -1    91     elif callable(value):
   -1    92         return value(node.children, context, escape)
   -1    93     elif value:
   -1    94         if not isinstance(value, Mapping):
   -1    95             value = {'.': value}
   -1    96         return render(node.children, ChainMap(value, context), escape)
   -1    97     else:
   -1    98         return ''
   -1    99 
   -1   100 
   -1   101 def render(nodes: Iterable[Node], context, escape: Escape) -> str:
   -1   102     output = ''
   -1   103     for node in nodes:
   -1   104         try:
   -1   105             if node.type == 'text':
   -1   106                 if not is_standalone(node.line):
   -1   107                     output += node.token
   -1   108             elif node.type == '#':
   -1   109                 output += render_section(node, context, escape)
   -1   110             elif node.type == '^':
   -1   111                 value = context.get(node.token)
   -1   112                 if not value:
   -1   113                     output += render(node.children, context, escape)
   -1   114             elif node.type == '>':
   -1   115                 children = get_template(node.token)
   -1   116                 output += render(children, context, escape)
   -1   117             elif node.type == '&':
   -1   118                 output += str(context.get(node.token) or '')
   -1   119             elif node.type == 'var':
   -1   120                 output += escape(str(context.get(node.token) or ''))
   -1   121         except KeyError as e:
   -1   122             raise TemplateError(node) from e
   -1   123     return output

diff --git a/tests.py b/tests.py

@@ -0,0 +1,48 @@
   -1     1 import json
   -1     2 import unittest
   -1     3 from html import escape
   -1     4 from pathlib import Path
   -1     5 from unittest.mock import patch
   -1     6 
   -1     7 import stache
   -1     8 
   -1     9 IGNORED = ['delimiters.json', '~inheritance.json', '~partials.json']
   -1    10 
   -1    11 
   -1    12 def generate_test(test, filename):
   -1    13     class TestCase(unittest.TestCase):
   -1    14         def runTest(self):
   -1    15             def _get_template(key, indent=''):
   -1    16                 try:
   -1    17                     s = test['partials'][key]
   -1    18                     s = stache.add_indent(s, indent)
   -1    19                     return stache.parse(s, key)
   -1    20                 except KeyError:
   -1    21                     return []
   -1    22 
   -1    23             with patch('stache.get_template') as get_template:
   -1    24                 get_template.side_effect = _get_template
   -1    25 
   -1    26                 nodes = stache.parse(test['template'], '')
   -1    27                 html = stache.render(nodes, test['data'], escape)
   -1    28                 self.assertEqual(html, test['expected'])
   -1    29 
   -1    30         def __str__(self):
   -1    31             return f'{filename} - {test["name"]}'
   -1    32 
   -1    33         def shortDescription(self):
   -1    34             return test['desc']
   -1    35 
   -1    36     return TestCase()
   -1    37 
   -1    38 
   -1    39 def load_tests(loader, standard_tests, pattern):
   -1    40     suite = unittest.TestSuite()
   -1    41     for path in Path('spec/specs/').iterdir():
   -1    42         if path.suffix == '.json' and path.name not in IGNORED:
   -1    43             with open(path) as fh:
   -1    44                 data = json.load(fh)
   -1    45             suite.addTest(unittest.TestSuite(
   -1    46                 generate_test(test, path.name) for test in data['tests']
   -1    47             ))
   -1    48     return suite