- 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