| 
 | 1 | +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0  | 
 | 2 | +# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt  | 
 | 3 | + | 
 | 4 | +"""A simple Python template renderer, for a nano-subset of Django syntax.  | 
 | 5 | +
  | 
 | 6 | +For a detailed discussion of this code, see this chapter from 500 Lines:  | 
 | 7 | +http://aosabook.org/en/500L/a-template-engine.html  | 
 | 8 | +
  | 
 | 9 | +"""  | 
 | 10 | + | 
 | 11 | +# Coincidentally named the same as http://code.activestate.com/recipes/496702/  | 
 | 12 | + | 
 | 13 | +import re  | 
 | 14 | + | 
 | 15 | + | 
 | 16 | +class TempliteSyntaxError(ValueError):  | 
 | 17 | +    """Raised when a template has a syntax error."""  | 
 | 18 | + | 
 | 19 | + | 
 | 20 | +class TempliteValueError(ValueError):  | 
 | 21 | +    """Raised when an expression won't evaluate in a template."""  | 
 | 22 | + | 
 | 23 | + | 
 | 24 | +class CodeBuilder:  | 
 | 25 | +    """Build source code conveniently."""  | 
 | 26 | + | 
 | 27 | +    def __init__(self, indent=0):  | 
 | 28 | +        self.code = []  | 
 | 29 | +        self.indent_level = indent  | 
 | 30 | + | 
 | 31 | +    def __str__(self):  | 
 | 32 | +        return "".join(str(c) for c in self.code)  | 
 | 33 | + | 
 | 34 | +    def add_line(self, line):  | 
 | 35 | +        """Add a line of source to the code.  | 
 | 36 | +
  | 
 | 37 | +        Indentation and newline will be added for you, don't provide them.  | 
 | 38 | +
  | 
 | 39 | +        """  | 
 | 40 | +        self.code.extend([" " * self.indent_level, line, "\n"])  | 
 | 41 | + | 
 | 42 | +    def add_section(self):  | 
 | 43 | +        """Add a section, a sub-CodeBuilder."""  | 
 | 44 | +        section = CodeBuilder(self.indent_level)  | 
 | 45 | +        self.code.append(section)  | 
 | 46 | +        return section  | 
 | 47 | + | 
 | 48 | +    INDENT_STEP = 4  # PEP8 says so!  | 
 | 49 | + | 
 | 50 | +    def indent(self):  | 
 | 51 | +        """Increase the current indent for following lines."""  | 
 | 52 | +        self.indent_level += self.INDENT_STEP  | 
 | 53 | + | 
 | 54 | +    def dedent(self):  | 
 | 55 | +        """Decrease the current indent for following lines."""  | 
 | 56 | +        self.indent_level -= self.INDENT_STEP  | 
 | 57 | + | 
 | 58 | +    def get_globals(self):  | 
 | 59 | +        """Execute the code, and return a dict of globals it defines."""  | 
 | 60 | +        # A check that the caller really finished all the blocks they started.  | 
 | 61 | +        assert self.indent_level == 0  | 
 | 62 | +        # Get the Python source as a single string.  | 
 | 63 | +        python_source = str(self)  | 
 | 64 | +        # Execute the source, defining globals, and return them.  | 
 | 65 | +        global_namespace = {}  | 
 | 66 | +        exec(python_source, global_namespace)  | 
 | 67 | +        return global_namespace  | 
 | 68 | + | 
 | 69 | + | 
 | 70 | +class Templite:  | 
 | 71 | +    """A simple template renderer, for a nano-subset of Django syntax.  | 
 | 72 | +
  | 
 | 73 | +    Supported constructs are extended variable access::  | 
 | 74 | +
  | 
 | 75 | +        {{var.modifier.modifier|filter|filter}}  | 
 | 76 | +
  | 
 | 77 | +    loops::  | 
 | 78 | +
  | 
 | 79 | +        {% for var in list %}...{% endfor %}  | 
 | 80 | +
  | 
 | 81 | +    and ifs::  | 
 | 82 | +
  | 
 | 83 | +        {% if var %}...{% endif %}  | 
 | 84 | +
  | 
 | 85 | +    Comments are within curly-hash markers::  | 
 | 86 | +
  | 
 | 87 | +        {# This will be ignored #}  | 
 | 88 | +
  | 
 | 89 | +    Lines between `{% joined %}` and `{% endjoined %}` will have lines stripped  | 
 | 90 | +    and joined.  Be careful, this could join words together!  | 
 | 91 | +
  | 
 | 92 | +    Any of these constructs can have a hyphen at the end (`-}}`, `-%}`, `-#}`),  | 
 | 93 | +    which will collapse the whitespace following the tag.  | 
 | 94 | +
  | 
 | 95 | +    Construct a Templite with the template text, then use `render` against a  | 
 | 96 | +    dictionary context to create a finished string::  | 
 | 97 | +
  | 
 | 98 | +        templite = Templite('''  | 
 | 99 | +            <h1>Hello {{name|upper}}!</h1>  | 
 | 100 | +            {% for topic in topics %}  | 
 | 101 | +                <p>You are interested in {{topic}}.</p>  | 
 | 102 | +            {% endif %}  | 
 | 103 | +            ''',  | 
 | 104 | +            {'upper': str.upper},  | 
 | 105 | +        )  | 
 | 106 | +        text = templite.render({  | 
 | 107 | +            'name': "Ned",  | 
 | 108 | +            'topics': ['Python', 'Geometry', 'Juggling'],  | 
 | 109 | +        })  | 
 | 110 | +
  | 
 | 111 | +    """  | 
 | 112 | + | 
 | 113 | +    def __init__(self, text, *contexts):  | 
 | 114 | +        """Construct a Templite with the given `text`.  | 
 | 115 | +
  | 
 | 116 | +        `contexts` are dictionaries of values to use for future renderings.  | 
 | 117 | +        These are good for filters and global values.  | 
 | 118 | +
  | 
 | 119 | +        """  | 
 | 120 | +        self.context = {}  | 
 | 121 | +        for context in contexts:  | 
 | 122 | +            self.context.update(context)  | 
 | 123 | + | 
 | 124 | +        self.all_vars = set()  | 
 | 125 | +        self.loop_vars = set()  | 
 | 126 | + | 
 | 127 | +        # We construct a function in source form, then compile it and hold onto  | 
 | 128 | +        # it, and execute it to render the template.  | 
 | 129 | +        code = CodeBuilder()  | 
 | 130 | + | 
 | 131 | +        code.add_line("def render_function(context, do_dots):")  | 
 | 132 | +        code.indent()  | 
 | 133 | +        vars_code = code.add_section()  | 
 | 134 | +        code.add_line("result = []")  | 
 | 135 | +        code.add_line("append_result = result.append")  | 
 | 136 | +        code.add_line("extend_result = result.extend")  | 
 | 137 | +        code.add_line("to_str = str")  | 
 | 138 | + | 
 | 139 | +        buffered = []  | 
 | 140 | + | 
 | 141 | +        def flush_output():  | 
 | 142 | +            """Force `buffered` to the code builder."""  | 
 | 143 | +            if len(buffered) == 1:  | 
 | 144 | +                code.add_line("append_result(%s)" % buffered[0])  | 
 | 145 | +            elif len(buffered) > 1:  | 
 | 146 | +                code.add_line("extend_result([%s])" % ", ".join(buffered))  | 
 | 147 | +            del buffered[:]  | 
 | 148 | + | 
 | 149 | +        ops_stack = []  | 
 | 150 | + | 
 | 151 | +        # Split the text to form a list of tokens.  | 
 | 152 | +        tokens = re.split(r"(?s)({{.*?}}|{%.*?%}|{#.*?#})", text)  | 
 | 153 | + | 
 | 154 | +        squash = in_joined = False  | 
 | 155 | + | 
 | 156 | +        self.__process(buffered, code, flush_output, in_joined, ops_stack, squash, tokens)  | 
 | 157 | + | 
 | 158 | +        if ops_stack:  | 
 | 159 | +            self._syntax_error("Unmatched action tag", ops_stack[-1])  | 
 | 160 | + | 
 | 161 | +        flush_output()  | 
 | 162 | + | 
 | 163 | +        for var_name in self.all_vars - self.loop_vars:  | 
 | 164 | +            vars_code.add_line("c_%s = context[%r]" % (var_name, var_name))  | 
 | 165 | + | 
 | 166 | +        code.add_line('return "".join(result)')  | 
 | 167 | +        code.dedent()  | 
 | 168 | +        self._render_function = code.get_globals()['render_function']  | 
 | 169 | + | 
 | 170 | +    def __process(self, buffered, code, flush_output, in_joined, ops_stack, squash, tokens):  | 
 | 171 | +        for token in tokens:  | 
 | 172 | +            if token.startswith('{'):  | 
 | 173 | +                start, end = 2, -2  | 
 | 174 | +                squash = (token[-3] == '-')  | 
 | 175 | +                if squash:  | 
 | 176 | +                    end = -3  | 
 | 177 | + | 
 | 178 | +                if token.startswith('{#'):  | 
 | 179 | +                    # Comment: ignore it and move on.  | 
 | 180 | +                    continue  | 
 | 181 | +                if token.startswith('{{'):  | 
 | 182 | +                    # An expression to evaluate.  | 
 | 183 | +                    expr = self._expr_code(token[start:end].strip())  | 
 | 184 | +                    buffered.append("to_str(%s)" % expr)  | 
 | 185 | +                else:  | 
 | 186 | +                    # token.startswith('{%')  | 
 | 187 | +                    # Action tag: split into words and parse further.  | 
 | 188 | +                    flush_output()  | 
 | 189 | + | 
 | 190 | +                    words = token[start:end].strip().split()  | 
 | 191 | +                    if words[0] == 'if':  | 
 | 192 | +                        # An if statement: evaluate the expression to determine if.  | 
 | 193 | +                        if len(words) != 2:  | 
 | 194 | +                            self._syntax_error("Don't understand if", token)  | 
 | 195 | +                        ops_stack.append('if')  | 
 | 196 | +                        code.add_line("if %s:" % self._expr_code(words[1]))  | 
 | 197 | +                        code.indent()  | 
 | 198 | +                    elif words[0] == 'for':  | 
 | 199 | +                        # A loop: iterate over expression result.  | 
 | 200 | +                        if len(words) != 4 or words[2] != 'in':  | 
 | 201 | +                            self._syntax_error("Don't understand for", token)  | 
 | 202 | +                        ops_stack.append('for')  | 
 | 203 | +                        self._variable(words[1], self.loop_vars)  | 
 | 204 | +                        code.add_line(  | 
 | 205 | +                            "for c_%s in %s:" % (  | 
 | 206 | +                                words[1],  | 
 | 207 | +                                self._expr_code(words[3])  | 
 | 208 | +                            )  | 
 | 209 | +                        )  | 
 | 210 | +                        code.indent()  | 
 | 211 | +                    elif words[0] == 'joined':  | 
 | 212 | +                        ops_stack.append('joined')  | 
 | 213 | +                        in_joined = True  | 
 | 214 | +                    elif words[0].startswith('end'):  | 
 | 215 | +                        # Endsomething.  Pop the ops stack.  | 
 | 216 | +                        if len(words) != 1:  | 
 | 217 | +                            self._syntax_error("Don't understand end", token)  | 
 | 218 | +                        end_what = words[0][3:]  | 
 | 219 | +                        if not ops_stack:  | 
 | 220 | +                            self._syntax_error("Too many ends", token)  | 
 | 221 | +                        start_what = ops_stack.pop()  | 
 | 222 | +                        if start_what != end_what:  | 
 | 223 | +                            self._syntax_error("Mismatched end tag", end_what)  | 
 | 224 | +                        if end_what == 'joined':  | 
 | 225 | +                            in_joined = False  | 
 | 226 | +                        else:  | 
 | 227 | +                            code.dedent()  | 
 | 228 | +                    else:  | 
 | 229 | +                        self._syntax_error("Don't understand tag", words[0])  | 
 | 230 | +            else:  | 
 | 231 | +                # Literal content.  If it isn't empty, output it.  | 
 | 232 | +                if in_joined:  | 
 | 233 | +                    token = re.sub(r"\s*\n\s*", "", token.strip())  | 
 | 234 | +                elif squash:  | 
 | 235 | +                    token = token.lstrip()  | 
 | 236 | +                if token:  | 
 | 237 | +                    buffered.append(repr(token))  | 
 | 238 | + | 
 | 239 | +    def _expr_code(self, expr):  | 
 | 240 | +        """Generate a Python expression for `expr`."""  | 
 | 241 | +        if "|" in expr:  | 
 | 242 | +            pipes = expr.split("|")  | 
 | 243 | +            code = self._expr_code(pipes[0])  | 
 | 244 | +            for func in pipes[1:]:  | 
 | 245 | +                self._variable(func, self.all_vars)  | 
 | 246 | +                code = "c_%s(%s)" % (func, code)  | 
 | 247 | +        elif "." in expr:  | 
 | 248 | +            dots = expr.split(".")  | 
 | 249 | +            code = self._expr_code(dots[0])  | 
 | 250 | +            args = ", ".join(repr(d) for d in dots[1:])  | 
 | 251 | +            code = "do_dots(%s, %s)" % (code, args)  | 
 | 252 | +        else:  | 
 | 253 | +            self._variable(expr, self.all_vars)  | 
 | 254 | +            code = "c_%s" % expr  | 
 | 255 | +        return code  | 
 | 256 | + | 
 | 257 | +    def _syntax_error(self, msg, thing):  | 
 | 258 | +        """Raise a syntax error using `msg`, and showing `thing`."""  | 
 | 259 | +        raise TempliteSyntaxError("%s: %r" % (msg, thing))  | 
 | 260 | + | 
 | 261 | +    def _variable(self, name, vars_set):  | 
 | 262 | +        """Track that `name` is used as a variable.  | 
 | 263 | +
  | 
 | 264 | +        Adds the name to `vars_set`, a set of variable names.  | 
 | 265 | +
  | 
 | 266 | +        Raises an syntax error if `name` is not a valid name.  | 
 | 267 | +
  | 
 | 268 | +        """  | 
 | 269 | +        if not re.match(r"[_a-zA-Z][_a-zA-Z0-9]*$", name):  | 
 | 270 | +            self._syntax_error("Not a valid name", name)  | 
 | 271 | +        vars_set.add(name)  | 
 | 272 | + | 
 | 273 | +    def render(self, context=None):  | 
 | 274 | +        """Render this template by applying it to `context`.  | 
 | 275 | +
  | 
 | 276 | +        `context` is a dictionary of values to use in this rendering.  | 
 | 277 | +
  | 
 | 278 | +        """  | 
 | 279 | +        # Make the complete context we'll use.  | 
 | 280 | +        render_context = dict(self.context)  | 
 | 281 | +        if context:  | 
 | 282 | +            render_context.update(context)  | 
 | 283 | +        return self._render_function(render_context, self._do_dots)  | 
 | 284 | + | 
 | 285 | +    def _do_dots(self, value, *dots):  | 
 | 286 | +        """Evaluate dotted expressions at run-time."""  | 
 | 287 | +        for dot in dots:  | 
 | 288 | +            try:  | 
 | 289 | +                value = getattr(value, dot)  | 
 | 290 | +            except AttributeError:  | 
 | 291 | +                try:  | 
 | 292 | +                    value = value[dot]  | 
 | 293 | +                except (TypeError, KeyError) as e:  | 
 | 294 | +                    raise TempliteValueError(  | 
 | 295 | +                        "Couldn't evaluate %r.%s" % (value, dot)  | 
 | 296 | +                    ) from e  | 
 | 297 | +            if callable(value):  | 
 | 298 | +                value = value()  | 
 | 299 | +        return value  | 
0 commit comments