관리-도구
편집 파일: templite.py
# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 # For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt """A simple Python template renderer, for a nano-subset of Django syntax. For a detailed discussion of this code, see this chapter from 500 Lines: http://aosabook.org/en/500L/a-template-engine.html """ # Coincidentally named the same as http://code.activestate.com/recipes/496702/ from __future__ import annotations import re from typing import ( Any, Callable, Dict, List, NoReturn, Optional, Set, Union, cast, ) class TempliteSyntaxError(ValueError): """Raised when a template has a syntax error.""" pass class TempliteValueError(ValueError): """Raised when an expression won't evaluate in a template.""" pass class CodeBuilder: """Build source code conveniently.""" def __init__(self, indent: int = 0) -> None: self.code: List[Union[str, CodeBuilder]] = [] self.indent_level = indent def __str__(self) -> str: return "".join(str(c) for c in self.code) def add_line(self, line: str) -> None: """Add a line of source to the code. Indentation and newline will be added for you, don't provide them. """ self.code.extend([" " * self.indent_level, line, "\n"]) def add_section(self) -> CodeBuilder: """Add a section, a sub-CodeBuilder.""" section = CodeBuilder(self.indent_level) self.code.append(section) return section INDENT_STEP = 4 # PEP8 says so! def indent(self) -> None: """Increase the current indent for following lines.""" self.indent_level += self.INDENT_STEP def dedent(self) -> None: """Decrease the current indent for following lines.""" self.indent_level -= self.INDENT_STEP def get_globals(self) -> Dict[str, Any]: """Execute the code, and return a dict of globals it defines.""" # A check that the caller really finished all the blocks they started. assert self.indent_level == 0 # Get the Python source as a single string. python_source = str(self) # Execute the source, defining globals, and return them. global_namespace: Dict[str, Any] = {} exec(python_source, global_namespace) return global_namespace class Templite: """A simple template renderer, for a nano-subset of Django syntax. Supported constructs are extended variable access:: {{var.modifier.modifier|filter|filter}} loops:: {% for var in list %}...{% endfor %} and ifs:: {% if var %}...{% endif %} Comments are within curly-hash markers:: {# This will be ignored #} Lines between `{% joined %}` and `{% endjoined %}` will have lines stripped and joined. Be careful, this could join words together! Any of these constructs can have a hyphen at the end (`-}}`, `-%}`, `-#}`), which will collapse the white space following the tag. Construct a Templite with the template text, then use `render` against a dictionary context to create a finished string:: templite = Templite(''' <h1>Hello {{name|upper}}!</h1> {% for topic in topics %} <p>You are interested in {{topic}}.</p> {% endif %} ''', {"upper": str.upper}, ) text = templite.render({ "name": "Ned", "topics": ["Python", "Geometry", "Juggling"], }) """ def __init__(self, text: str, *contexts: Dict[str, Any]) -> None: """Construct a Templite with the given `text`. `contexts` are dictionaries of values to use for future renderings. These are good for filters and global values. """ self.context = {} for context in contexts: self.context.update(context) self.all_vars: Set[str] = set() self.loop_vars: Set[str] = set() # We construct a function in source form, then compile it and hold onto # it, and execute it to render the template. code = CodeBuilder() code.add_line("def render_function(context, do_dots):") code.indent() vars_code = code.add_section() code.add_line("result = []") code.add_line("append_result = result.append") code.add_line("extend_result = result.extend") code.add_line("to_str = str") buffered: List[str] = [] def flush_output() -> None: """Force `buffered` to the code builder.""" if len(buffered) == 1: code.add_line("append_result(%s)" % buffered[0]) elif len(buffered) > 1: code.add_line("extend_result([%s])" % ", ".join(buffered)) del buffered[:] ops_stack = [] # Split the text to form a list of tokens. tokens = re.split(r"(?s)({{.*?}}|{%.*?%}|{#.*?#})", text) squash = in_joined = False for token in tokens: if token.startswith("{"): start, end = 2, -2 squash = (token[-3] == "-") if squash: end = -3 if token.startswith("{#"): # Comment: ignore it and move on. continue elif token.startswith("{{"): # An expression to evaluate. expr = self._expr_code(token[start:end].strip()) buffered.append("to_str(%s)" % expr) else: # token.startswith("{%") # Action tag: split into words and parse further. flush_output() words = token[start:end].strip().split() if words[0] == "if": # An if statement: evaluate the expression to determine if. if len(words) != 2: self._syntax_error("Don't understand if", token) ops_stack.append("if") code.add_line("if %s:" % self._expr_code(words[1])) code.indent() elif words[0] == "for": # A loop: iterate over expression result. if len(words) != 4 or words[2] != "in": self._syntax_error("Don't understand for", token) ops_stack.append("for") self._variable(words[1], self.loop_vars) code.add_line( "for c_{} in {}:".format( words[1], self._expr_code(words[3]) ) ) code.indent() elif words[0] == "joined": ops_stack.append("joined") in_joined = True elif words[0].startswith("end"): # Endsomething. Pop the ops stack. if len(words) != 1: self._syntax_error("Don't understand end", token) end_what = words[0][3:] if not ops_stack: self._syntax_error("Too many ends", token) start_what = ops_stack.pop() if start_what != end_what: self._syntax_error("Mismatched end tag", end_what) if end_what == "joined": in_joined = False else: code.dedent() else: self._syntax_error("Don't understand tag", words[0]) else: # Literal content. If it isn't empty, output it. if in_joined: token = re.sub(r"\s*\n\s*", "", token.strip()) elif squash: token = token.lstrip() if token: buffered.append(repr(token)) if ops_stack: self._syntax_error("Unmatched action tag", ops_stack[-1]) flush_output() for var_name in self.all_vars - self.loop_vars: vars_code.add_line(f"c_{var_name} = context[{var_name!r}]") code.add_line("return ''.join(result)") code.dedent() self._render_function = cast( Callable[ [Dict[str, Any], Callable[..., Any]], str ], code.get_globals()["render_function"], ) def _expr_code(self, expr: str) -> str: """Generate a Python expression for `expr`.""" if "|" in expr: pipes = expr.split("|") code = self._expr_code(pipes[0]) for func in pipes[1:]: self._variable(func, self.all_vars) code = f"c_{func}({code})" elif "." in expr: dots = expr.split(".") code = self._expr_code(dots[0]) args = ", ".join(repr(d) for d in dots[1:]) code = f"do_dots({code}, {args})" else: self._variable(expr, self.all_vars) code = "c_%s" % expr return code def _syntax_error(self, msg: str, thing: Any) -> NoReturn: """Raise a syntax error using `msg`, and showing `thing`.""" raise TempliteSyntaxError(f"{msg}: {thing!r}") def _variable(self, name: str, vars_set: Set[str]) -> None: """Track that `name` is used as a variable. Adds the name to `vars_set`, a set of variable names. Raises an syntax error if `name` is not a valid name. """ if not re.match(r"[_a-zA-Z][_a-zA-Z0-9]*$", name): self._syntax_error("Not a valid name", name) vars_set.add(name) def render(self, context: Optional[Dict[str, Any]] = None) -> str: """Render this template by applying it to `context`. `context` is a dictionary of values to use in this rendering. """ # Make the complete context we'll use. render_context = dict(self.context) if context: render_context.update(context) return self._render_function(render_context, self._do_dots) def _do_dots(self, value: Any, *dots: str) -> Any: """Evaluate dotted expressions at run-time.""" for dot in dots: try: value = getattr(value, dot) except AttributeError: try: value = value[dot] except (TypeError, KeyError) as exc: raise TempliteValueError( f"Couldn't evaluate {value!r}.{dot}" ) from exc if callable(value): value = value() return value