#!/usr/bin/env python # -*- coding: utf-8 -*- """ Small, simple and powerful template-engine for Python. A template-engine for Python, which is very simple, easy to use, small, fast, powerful, modular, extensible, well documented and pythonic. See documentation for a list of features, template-syntax etc. :Version: 0.3.2 :Requires: Python >=2.6 / 3.x :Usage: see class ``Template`` and examples below. :Example: Note that the examples are in Python 2; they also work in Python 3 if you replace u"..." by "...", unicode() by str() and partly "..." by b"...". quickstart:: >>> t = Template("hello @!name!@") >>> print(t(name="marvin")) hello marvin quickstart with a template-file:: # >>> t = Template(filename="mytemplate.tmpl") # >>> print(t(name="marvin")) # hello marvin generic usage:: >>> t = Template(u"output is in Unicode \\xe4\\xf6\\xfc\\u20ac") >>> t #doctest: +ELLIPSIS <...Template instance at 0x...> >>> t() u'output is in Unicode \\xe4\\xf6\\xfc\\u20ac' >>> unicode(t) u'output is in Unicode \\xe4\\xf6\\xfc\\u20ac' with data:: >>> t = Template("hello @!name!@", data={"name":"world"}) >>> t() u'hello world' >>> t(name="worlds") u'hello worlds' # >>> t(note="data must be Unicode or ASCII", name=u"\\xe4") # u'hello \\xe4' escaping:: >>> t = Template("hello escaped: @!name!@, unescaped: $!name!$") >>> t(name='''<>&'"''') u'hello escaped: <>&'", unescaped: <>&\\'"' result-encoding:: # encode the unicode-object to your encoding with encode() >>> t = Template(u"hello \\xe4\\xf6\\xfc\\u20ac") >>> result = t() >>> result u'hello \\xe4\\xf6\\xfc\\u20ac' >>> result.encode("utf-8") 'hello \\xc3\\xa4\\xc3\\xb6\\xc3\\xbc\\xe2\\x82\\xac' >>> result.encode("ascii") Traceback (most recent call last): ... UnicodeEncodeError: 'ascii' codec can't encode characters in position 6-9: ordinal not in range(128) >>> result.encode("ascii", 'xmlcharrefreplace') 'hello äöü€' Python-expressions:: >>> Template('formatted: @! "%8.5f" % value !@')(value=3.141592653) u'formatted: 3.14159' >>> Template("hello --@!name.upper().center(20)!@--")(name="world") u'hello -- WORLD --' >>> Template("calculate @!var*5+7!@")(var=7) u'calculate 42' blocks (if/for/macros/...):: >>> t = Template("barbazunknown(@!foo!@)") >>> t(foo=2) u'baz' >>> t(foo=5) u'unknown(5)' >>> t = Template("@!i!@ (empty)") >>> t(mylist=[]) u'(empty)' >>> t(mylist=[1,2,3]) u'1 2 3 ' >>> t = Template(" - @!i!@: @!elem!@") >>> t(mylist=["a","b","c"]) u' - 0: a - 1: b - 2: c' >>> t = Template('hello @!name!@ @!greetings(name=user)!@') >>> t(user="monty") u' hello monty' exists:: >>> t = Template('YESNO') >>> t() u'NO' >>> t(foo=1) u'YES' >>> t(foo=None) # note this difference to 'default()' u'YES' default-values:: # non-existing variables raise an error >>> Template('hi @!optional!@')() Traceback (most recent call last): ... TemplateRenderError: Cannot eval expression 'optional'. (NameError: name 'optional' is not defined) >>> t = Template('hi @!default("optional","anyone")!@') >>> t() u'hi anyone' >>> t(optional=None) u'hi anyone' >>> t(optional="there") u'hi there' # the 1st parameter can be any eval-expression >>> t = Template('@!default("5*var1+var2","missing variable")!@') >>> t(var1=10) u'missing variable' >>> t(var1=10, var2=2) u'52' # also in blocks >>> t = Template('yesno') >>> t() u'no' >>> t(opt1=23, opt2=42) u'yes' >>> t = Template('@!i!@') >>> t() u'' >>> t(optional_list=[1,2,3]) u'123' # but make sure to put the expression in quotation marks, otherwise: >>> Template('@!default(optional,"fallback")!@')() Traceback (most recent call last): ... TemplateRenderError: Cannot eval expression 'default(optional,"fallback")'. (NameError: name 'optional' is not defined) setvar:: >>> t = Template('$!setvar("i", "i+1")!$@!i!@') >>> t(i=6) u'7' >>> t = Template('''$!setvar("s", '"\\\\\\\\n".join(s)')!$@!s!@''') >>> t(isinstance=isinstance, s="123") u'123' >>> t(isinstance=isinstance, s=["123", "456"]) u'123\\n456' :Author: Roland Koebler (rk at simple-is-better dot org) :Copyright: Roland Koebler :License: MIT/X11-like, see __license__ :RCS: $Id: pyratemp.py,v 1.22 2013/09/17 07:44:13 rk Exp $ """ from __future__ import unicode_literals __version__ = "0.3.2" __author__ = "Roland Koebler " __license__ = """Copyright (c) Roland Koebler, 2007-2013 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.""" #========================================= import os, re, sys, types if sys.version_info[0] >= 3: import builtins unicode = str long = int else: import __builtin__ as builtins from codecs import open #========================================= # some useful functions #---------------------- # string-position: i <-> row,col def srow(string, i): """Get line numer of ``string[i]`` in `string`. :Returns: row, starting at 1 :Note: This works for text-strings with ``\\n`` or ``\\r\\n``. """ return string.count('\n', 0, max(0, i)) + 1 def scol(string, i): """Get column number of ``string[i]`` in `string`. :Returns: column, starting at 1 (but may be <1 if i<0) :Note: This works for text-strings with ``\\n`` or ``\\r\\n``. """ return i - string.rfind('\n', 0, max(0, i)) def sindex(string, row, col): """Get index of the character at `row`/`col` in `string`. :Parameters: - `row`: row number, starting at 1. - `col`: column number, starting at 1. :Returns: ``i``, starting at 0 (but may be <1 if row/col<0) :Note: This works for text-strings with '\\n' or '\\r\\n'. """ n = 0 for _ in range(row-1): n = string.find('\n', n) + 1 return n+col-1 #---------------------- def dictkeyclean(d): """Convert all keys of the dict `d` to strings. """ new_d = {} for k, v in d.items(): new_d[str(k)] = v return new_d #---------------------- def dummy(*_, **__): """Dummy function, doing nothing. """ pass def dummy_raise(exception, value): """Create an exception-raising dummy function. :Returns: dummy function, raising ``exception(value)`` """ def mydummy(*_, **__): raise exception(value) return mydummy #========================================= # escaping (NONE, HTML, LATEX, MAIL_HEADER) = range(0, 4) ESCAPE_SUPPORTED = {"NONE":None, "HTML":HTML, "LATEX":LATEX, "MAIL_HEADER":MAIL_HEADER} def escape(s, format=HTML): """Replace special characters by their escape sequence. :Parameters: - `s`: unicode-string to escape - `format`: - `NONE`: nothing is replaced - `HTML`: replace &<>'" by &...; - `LATEX`: replace \#$%&_{}~^ - `MAIL_HEADER`: escape non-ASCII mail-header-contents :Returns: the escaped string in unicode :Exceptions: - `ValueError`: if `format` is invalid. :Uses: MAIL_HEADER uses module email """ #Note: If you have to make sure that every character gets replaced # only once (and if you cannot achieve this with the following code), # use something like "".join([replacedict.get(c,c) for c in s]) # which is about 2-3 times slower (but maybe needs less memory). #Note: This is one of the most time-consuming parts of the template. if format is None or format == NONE: pass elif format == HTML: s = s.replace("&", "&") # must be done first! s = s.replace("<", "<") s = s.replace(">", ">") s = s.replace('"', """) s = s.replace("'", "'") elif format == LATEX: s = s.replace("\\", "\\x") #must be done first! s = s.replace("#", "\\#") s = s.replace("$", "\\$") s = s.replace("%", "\\%") s = s.replace("&", "\\&") s = s.replace("_", "\\_") s = s.replace("{", "\\{") s = s.replace("}", "\\}") s = s.replace("\\x","\\textbackslash{}") s = s.replace("~", "\\textasciitilde{}") s = s.replace("^", "\\textasciicircum{}") elif format == MAIL_HEADER: import email.header try: s.encode("ascii") return s except UnicodeEncodeError: return email.header.make_header([(s, "utf-8")]).encode() else: raise ValueError('Invalid format (only None, HTML, LATEX and MAIL_HEADER are supported).') return s #========================================= #----------------------------------------- # Exceptions class TemplateException(Exception): """Base class for template-exceptions.""" pass class TemplateParseError(TemplateException): """Template parsing failed.""" def __init__(self, err, errpos): """ :Parameters: - `err`: error-message or exception to wrap - `errpos`: ``(filename,row,col)`` where the error occured. """ self.err = err self.filename, self.row, self.col = errpos TemplateException.__init__(self) def __str__(self): if not self.filename: return "line %d, col %d: %s" % (self.row, self.col, str(self.err)) else: return "file %s, line %d, col %d: %s" % (self.filename, self.row, self.col, str(self.err)) class TemplateSyntaxError(TemplateParseError, SyntaxError): """Template syntax-error.""" pass class TemplateIncludeError(TemplateParseError): """Template 'include' failed.""" pass class TemplateRenderError(TemplateException): """Template rendering failed.""" pass #----------------------------------------- # Loader class LoaderString: """Load template from a string/unicode. Note that 'include' is not possible in such templates. """ def __init__(self, encoding='utf-8'): self.encoding = encoding def load(self, s): """Return template-string as unicode. """ if isinstance(s, unicode): u = s else: u = s.decode(self.encoding) return u class LoaderFile: """Load template from a file. When loading a template from a file, it's possible to including other templates (by using 'include' in the template). But for simplicity and security, all included templates have to be in the same directory! (see ``allowed_path``) """ def __init__(self, allowed_path=None, encoding='utf-8'): """Init the loader. :Parameters: - `allowed_path`: path of the template-files - `encoding`: encoding of the template-files :Exceptions: - `ValueError`: if `allowed_path` is not a directory """ if allowed_path and not os.path.isdir(allowed_path): raise ValueError("'allowed_path' has to be a directory.") self.path = allowed_path self.encoding = encoding def load(self, filename): """Load a template from a file. Check if filename is allowed and return its contens in unicode. :Parameters: - `filename`: filename of the template without path :Returns: the contents of the template-file in unicode :Exceptions: - `ValueError`: if `filename` contains a path """ if filename != os.path.basename(filename): raise ValueError("No path allowed in filename. (%s)" %(filename)) filename = os.path.join(self.path, filename) f = open(filename, 'r', encoding=self.encoding) u = f.read() f.close() return u #----------------------------------------- # Parser class Parser(object): """Parse a template into a parse-tree. Includes a syntax-check, an optional expression-check and verbose error-messages. See documentation for a description of the parse-tree. """ # template-syntax _comment_start = "#!" _comment_end = "!#" _sub_start = "$!" _sub_end = "!$" _subesc_start = "@!" _subesc_end = "!@" _block_start = "" # build regexps # comment # single-line, until end-tag or end-of-line. _strComment = r"""%s(?P.*?)(?P%s|\n|$)""" \ % (re.escape(_comment_start), re.escape(_comment_end)) _reComment = re.compile(_strComment, re.M) # escaped or unescaped substitution # single-line ("|$" is needed to be able to generate good error-messges) _strSubstitution = r""" ( %s\s*(?P.*?)\s*(?P%s|$) #substitution | %s\s*(?P.*?)\s*(?P%s|$) #escaped substitution ) """ % (re.escape(_sub_start), re.escape(_sub_end), re.escape(_subesc_start), re.escape(_subesc_end)) _reSubstitution = re.compile(_strSubstitution, re.X|re.M) # block # - single-line, no nesting. # or # - multi-line, nested by whitespace indentation: # * start- and end-tag of a block must have exactly the same indentation. # * start- and end-tags of *nested* blocks should have a greater indentation. # NOTE: A single-line block must not start at beginning of the line with # the same indentation as the enclosing multi-line blocks! # Note that " " and "\t" are different, although they may # look the same in an editor! _s = re.escape(_block_start) _e = re.escape(_block_end) _strBlock = r""" ^(?P[ \t]*)%send%s(?P.*)\r?\n? # multi-line end (^ IGNORED_TEXT\n) | (?P)%send%s # single-line end () | (?P[ \t]*) # single-line tag (no nesting) %s(?P\w+)[ \t]*(?P.*?)%s (?P.*?) (?=(?:%s.*?%s.*?)??%send%s) # (match until end or i.e. ) | # multi-line tag, nested by whitespace indentation ^(?P[ \t]*) # save indentation of start tag %s(?P\w+)\s*(?P.*?)%s(?P.*)\r?\n (?P(?:.*\n)*?) (?=(?P=indent)%s(?:.|\s)*?%s) # match indentation """ % (_s, _e, _s, _e, _s, _e, _s, _e, _s, _e, _s, _e, _s, _e) _reBlock = re.compile(_strBlock, re.X|re.M) # "for"-block parameters: "var(,var)* in ..." _strForParam = r"""^(?P\w+(?:\s*,\s*\w+)*)\s+in\s+(?P.+)$""" _reForParam = re.compile(_strForParam) # allowed macro-names _reMacroParam = re.compile(r"""^\w+$""") def __init__(self, loadfunc=None, testexpr=None, escape=HTML): """Init the parser. :Parameters: - `loadfunc`: function to load included templates (i.e. ``LoaderFile(...).load``) - `testexpr`: function to test if a template-expressions is valid (i.e. ``EvalPseudoSandbox().compile``) - `escape`: default-escaping (may be modified by the template) :Exceptions: - `ValueError`: if `testexpr` or `escape` is invalid. """ if loadfunc is None: self._load = dummy_raise(NotImplementedError, "'include' not supported, since no 'loadfunc' was given.") else: self._load = loadfunc if testexpr is None: self._testexprfunc = dummy else: try: # test if testexpr() works testexpr("i==1") except Exception as err: raise ValueError("Invalid 'testexpr'. (%s)" %(err)) self._testexprfunc = testexpr if escape not in ESCAPE_SUPPORTED.values(): raise ValueError("Unsupported 'escape'. (%s)" %(escape)) self.escape = escape self._includestack = [] def parse(self, template): """Parse a template. :Parameters: - `template`: template-unicode-string :Returns: the resulting parse-tree :Exceptions: - `TemplateSyntaxError`: for template-syntax-errors - `TemplateIncludeError`: if template-inclusion failed - `TemplateException` """ self._includestack = [(None, template)] # for error-messages (_errpos) return self._parse(template) def _errpos(self, fpos): """Convert `fpos` to ``(filename,row,column)`` for error-messages.""" filename, string = self._includestack[-1] return filename, srow(string, fpos), scol(string, fpos) def _testexpr(self, expr, fpos=0): """Test a template-expression to detect errors.""" try: self._testexprfunc(expr) except SyntaxError as err: raise TemplateSyntaxError(err, self._errpos(fpos)) def _parse_sub(self, parsetree, text, fpos=0): """Parse substitutions, and append them to the parse-tree. Additionally, remove comments. """ curr = 0 for match in self._reSubstitution.finditer(text): start = match.start() if start > curr: parsetree.append(("str", self._reComment.sub('', text[curr:start]))) if match.group("sub") is not None: if not match.group("end"): raise TemplateSyntaxError("Missing closing tag '%s' for '%s'." % (self._sub_end, match.group()), self._errpos(fpos+start)) if len(match.group("sub")) > 0: self._testexpr(match.group("sub"), fpos+start) parsetree.append(("sub", match.group("sub"))) else: assert(match.group("escsub") is not None) if not match.group("escend"): raise TemplateSyntaxError("Missing closing tag '%s' for '%s'." % (self._subesc_end, match.group()), self._errpos(fpos+start)) if len(match.group("escsub")) > 0: self._testexpr(match.group("escsub"), fpos+start) parsetree.append(("esc", self.escape, match.group("escsub"))) curr = match.end() if len(text) > curr: parsetree.append(("str", self._reComment.sub('', text[curr:]))) def _parse(self, template, fpos=0): """Recursive part of `parse()`. :Parameters: - template - fpos: position of ``template`` in the complete template (for error-messages) """ # blank out comments # (So that its content does not collide with other syntax, and # because removing them completely would falsify the character- # position ("match.start()") of error-messages) template = self._reComment.sub(lambda match: self._comment_start+" "*len(match.group(1))+match.group(2), template) # init parser parsetree = [] curr = 0 # current position (= end of previous block) block_type = None # block type: if,for,macro,raw,... block_indent = None # None: single-line, >=0: multi-line # find blocks for match in self._reBlock.finditer(template): start = match.start() # process template-part before this block if start > curr: self._parse_sub(parsetree, template[curr:start], fpos) # analyze block syntax (incl. error-checking and -messages) keyword = None block = match.groupdict() pos__ = fpos + start # shortcut if block["sKeyw"] is not None: # single-line block tag block_indent = None keyword = block["sKeyw"] param = block["sParam"] content = block["sContent"] if block["sSpace"]: # restore spaces before start-tag if len(parsetree) > 0 and parsetree[-1][0] == "str": parsetree[-1] = ("str", parsetree[-1][1] + block["sSpace"]) else: parsetree.append(("str", block["sSpace"])) pos_p = fpos + match.start("sParam") # shortcuts pos_c = fpos + match.start("sContent") elif block["mKeyw"] is not None: # multi-line block tag block_indent = len(block["indent"]) keyword = block["mKeyw"] param = block["mParam"] content = block["mContent"] pos_p = fpos + match.start("mParam") pos_c = fpos + match.start("mContent") ignored = block["mIgnored"].strip() if ignored and ignored != self._comment_start: raise TemplateSyntaxError("No code allowed after block-tag.", self._errpos(fpos+match.start("mIgnored"))) elif block["mEnd"] is not None: # multi-line block end if block_type is None: raise TemplateSyntaxError("No block to end here/invalid indent.", self._errpos(pos__) ) if block_indent != len(block["mEnd"]): raise TemplateSyntaxError("Invalid indent for end-tag.", self._errpos(pos__) ) ignored = block["meIgnored"].strip() if ignored and ignored != self._comment_start: raise TemplateSyntaxError("No code allowed after end-tag.", self._errpos(fpos+match.start("meIgnored"))) block_type = None elif block["sEnd"] is not None: # single-line block end if block_type is None: raise TemplateSyntaxError("No block to end here/invalid indent.", self._errpos(pos__)) if block_indent is not None: raise TemplateSyntaxError("Invalid indent for end-tag.", self._errpos(pos__)) block_type = None else: raise TemplateException("FATAL: Block regexp error. Please contact the author. (%s)" % match.group()) # analyze block content (mainly error-checking and -messages) if keyword: keyword = keyword.lower() if 'for' == keyword: if block_type is not None: raise TemplateSyntaxError("Missing block-end-tag before new block at '%s'." %(match.group()), self._errpos(pos__)) block_type = 'for' cond = self._reForParam.match(param) if cond is None: raise TemplateSyntaxError("Invalid 'for ...' at '%s'." %(param), self._errpos(pos_p)) names = tuple(n.strip() for n in cond.group("names").split(",")) self._testexpr(cond.group("iter"), pos_p+cond.start("iter")) parsetree.append(("for", names, cond.group("iter"), self._parse(content, pos_c))) elif 'if' == keyword: if block_type is not None: raise TemplateSyntaxError("Missing block-end-tag before new block at '%s'." %(match.group()), self._errpos(pos__)) if not param: raise TemplateSyntaxError("Missing condition for 'if' at '%s'." %(match.group()), self._errpos(pos__)) block_type = 'if' self._testexpr(param, pos_p) parsetree.append(("if", param, self._parse(content, pos_c))) elif 'elif' == keyword: if block_type != 'if': raise TemplateSyntaxError("'elif' may only appear after 'if' at '%s'." %(match.group()), self._errpos(pos__)) if not param: raise TemplateSyntaxError("Missing condition for 'elif' at '%s'." %(match.group()), self._errpos(pos__)) self._testexpr(param, pos_p) parsetree.append(("elif", param, self._parse(content, pos_c))) elif 'else' == keyword: if block_type not in ('if', 'for'): raise TemplateSyntaxError("'else' may only appear after 'if' or 'for' at '%s'." %(match.group()), self._errpos(pos__)) if param: raise TemplateSyntaxError("'else' may not have parameters at '%s'." %(match.group()), self._errpos(pos__)) parsetree.append(("else", self._parse(content, pos_c))) elif 'macro' == keyword: if block_type is not None: raise TemplateSyntaxError("Missing block-end-tag before new block '%s'." %(match.group()), self._errpos(pos__)) block_type = 'macro' # make sure param is "\w+" (instead of ".+") if not param: raise TemplateSyntaxError("Missing name for 'macro' at '%s'." %(match.group()), self._errpos(pos__)) if not self._reMacroParam.match(param): raise TemplateSyntaxError("Invalid name for 'macro' at '%s'." %(match.group()), self._errpos(pos__)) #remove last newline if len(content) > 0 and content[-1] == '\n': content = content[:-1] if len(content) > 0 and content[-1] == '\r': content = content[:-1] parsetree.append(("macro", param, self._parse(content, pos_c))) # parser-commands elif 'raw' == keyword: if block_type is not None: raise TemplateSyntaxError("Missing block-end-tag before new block '%s'." %(match.group()), self._errpos(pos__)) if param: raise TemplateSyntaxError("'raw' may not have parameters at '%s'." %(match.group()), self._errpos(pos__)) block_type = 'raw' parsetree.append(("str", content)) elif 'include' == keyword: if block_type is not None: raise TemplateSyntaxError("Missing block-end-tag before new block '%s'." %(match.group()), self._errpos(pos__)) if param: raise TemplateSyntaxError("'include' may not have parameters at '%s'." %(match.group()), self._errpos(pos__)) block_type = 'include' try: u = self._load(content.strip()) except Exception as err: raise TemplateIncludeError(err, self._errpos(pos__)) self._includestack.append((content.strip(), u)) # current filename/template for error-msg. p = self._parse(u) self._includestack.pop() parsetree.extend(p) elif 'set_escape' == keyword: if block_type is not None: raise TemplateSyntaxError("Missing block-end-tag before new block '%s'." %(match.group()), self._errpos(pos__)) if param: raise TemplateSyntaxError("'set_escape' may not have parameters at '%s'." %(match.group()), self._errpos(pos__)) block_type = 'set_escape' esc = content.strip().upper() if esc not in ESCAPE_SUPPORTED: raise TemplateSyntaxError("Unsupported escape '%s'." %(esc), self._errpos(pos__)) self.escape = ESCAPE_SUPPORTED[esc] else: raise TemplateSyntaxError("Invalid keyword '%s'." %(keyword), self._errpos(pos__)) curr = match.end() if block_type is not None: raise TemplateSyntaxError("Missing end-tag.", self._errpos(pos__)) if len(template) > curr: # process template-part after last block self._parse_sub(parsetree, template[curr:], fpos+curr) return parsetree #----------------------------------------- # Evaluation # some checks assert len(eval("dir()", {'__builtins__':{'dir':dir}})) == 1, \ "FATAL: 'eval' does not work as expected (%s)." assert compile("0 .__class__", "", "eval").co_names == ('__class__',), \ "FATAL: 'compile' does not work as expected." class EvalPseudoSandbox: """An eval-pseudo-sandbox. The pseudo-sandbox restricts the available functions/objects, so the code can only access: - some of the builtin Python-functions, which are considered "safe" (see safe_builtins) - some additional functions (exists(), default(), setvar(), escape()) - the passed objects incl. their methods. Additionally, names beginning with "_" are forbidden. This is to prevent things like '0 .__class__', with which you could easily break out of a "sandbox". Be careful to only pass "safe" objects/functions to the template, because any unsafe function/method could break the sandbox! For maximum security, restrict the access to as few objects/functions as possible! :Warning: Note that this is no real sandbox! (And although I don't know any way to break out of the sandbox without passing-in an unsafe object, I cannot guarantee that there is no such way. So use with care.) Take care if you want to use it for untrusted code!! """ safe_builtins = { "True" : True, "False" : False, "None" : None, "abs" : builtins.abs, "chr" : builtins.chr, "divmod" : builtins.divmod, "hash" : builtins.hash, "hex" : builtins.hex, "isinstance": builtins.isinstance, "len" : builtins.len, "max" : builtins.max, "min" : builtins.min, "oct" : builtins.oct, "ord" : builtins.ord, "pow" : builtins.pow, "range" : builtins.range, "round" : builtins.round, "sorted" : builtins.sorted, "sum" : builtins.sum, "unichr" : builtins.chr, "zip" : builtins.zip, "bool" : builtins.bool, "bytes" : builtins.bytes, "complex" : builtins.complex, "dict" : builtins.dict, "enumerate" : builtins.enumerate, "float" : builtins.float, "int" : builtins.int, "list" : builtins.list, "long" : long, "reversed" : builtins.reversed, "set" : builtins.set, "str" : builtins.str, "tuple" : builtins.tuple, "unicode" : unicode, "dir" : builtins.dir, } if sys.version_info[0] < 3: safe_builtins["unichr"] = builtins.unichr def __init__(self): self._compile_cache = {} self.vars_ptr = None self.eval_allowed_builtins = self.safe_builtins.copy() self.register("__import__", self.f_import) self.register("exists", self.f_exists) self.register("default", self.f_default) self.register("setvar", self.f_setvar) self.register("escape", self.f_escape) def register(self, name, obj): """Add an object to the "allowed eval-builtins". Mainly useful to add user-defined functions to the pseudo-sandbox. """ self.eval_allowed_builtins[name] = obj def _check_code_names(self, code, expr): """Check if the code tries to access names beginning with "_". Used to prevent sandbox-breakouts via new-style-classes, like ``"".__class__.__base__.__subclasses__()``. :Raises: NameError if expression contains forbidden names. """ for name in code.co_names: if name[0] == '_' and name != '_[1]': # _[1] is necessary for [x for x in y] raise NameError("Name '%s' is not allowed in '%s'." % (name, expr)) # recursively check sub-codes (e.g. lambdas) for const in code.co_consts: if isinstance(const, types.CodeType): self._check_code_names(const, expr) def compile(self, expr): """Compile a Python-eval-expression. - Use a compile-cache. - Raise a `NameError` if `expr` contains a name beginning with ``_``. :Returns: the compiled `expr` :Exceptions: - `SyntaxError`: for compile-errors - `NameError`: if expr contains a name beginning with ``_`` """ if expr not in self._compile_cache: c = compile(expr, "", "eval") self._check_code_names(c, expr) self._compile_cache[expr] = c return self._compile_cache[expr] def eval(self, expr, variables): """Eval a Python-eval-expression. Sets ``self.vars_ptr`` to ``variables`` and compiles the code before evaluating. """ sav = self.vars_ptr self.vars_ptr = variables try: x = eval(self.compile(expr), {"__builtins__": self.eval_allowed_builtins}, variables) except NameError: # workaround for lambdas like ``sorted(..., key=lambda x: my_f(x))`` vars2 = {"__builtins__": self.eval_allowed_builtins} vars2.update(variables) x = eval(self.compile(expr), vars2) self.vars_ptr = sav return x def f_import(self, name, *_, **__): """``import``/``__import__()`` for the sandboxed code. Since "import" is insecure, the PseudoSandbox does not allow to import other modules. But since some functions need to import other modules (e.g. "datetime.datetime.strftime" imports "time"), this function replaces the builtin "import" and allows to use modules which are already accessible by the sandboxed code. :Note: - This probably only works for rather simple imports. - For security, it may be better to avoid such (complex) modules which import other modules. (e.g. use time.localtime and time.strftime instead of datetime.datetime.strftime, or write a small wrapper.) :Example: >>> from datetime import datetime >>> import pyratemp >>> t = pyratemp.Template('@!mytime.strftime("%H:%M:%S")!@') # >>> print(t(mytime=datetime.now())) # Traceback (most recent call last): # ... # ImportError: import not allowed in pseudo-sandbox; try to import 'time' yourself and pass it to the sandbox/template >>> import time >>> print(t(mytime=datetime.strptime("13:40:54", "%H:%M:%S"), time=time)) 13:40:54 # >>> print(t(mytime=datetime.now(), time=time)) # 13:40:54 """ if self.vars_ptr is not None and name in self.vars_ptr and isinstance(self.vars_ptr[name], types.ModuleType): return self.vars_ptr[name] else: raise ImportError("import not allowed in pseudo-sandbox; try to import '%s' yourself (and maybe pass it to the sandbox/template)" % name) def f_exists(self, varname): """``exists()`` for the sandboxed code. Test if the variable `varname` exists in the current namespace. This only works for single variable names. If you want to test complicated expressions, use i.e. `default`. (i.e. `default("expr",False)`) :Note: the variable-name has to be quoted! (like in eval) :Example: see module-docstring """ return (varname in self.vars_ptr) def f_default(self, expr, default=None): """``default()`` for the sandboxed code. Try to evaluate an expression and return the result or a fallback-/default-value; the `default`-value is used if `expr` does not exist/is invalid/results in None. This is very useful for optional data. :Parameter: - expr: "eval-expression" - default: fallback-value if eval(expr) fails or is None. :Returns: the eval-result or the "fallback"-value. :Note: the eval-expression has to be quoted! (like in eval) :Example: see module-docstring """ try: r = self.eval(expr, self.vars_ptr) if r is None: return default return r #TODO: which exceptions should be catched here? except (NameError, LookupError, TypeError, AttributeError): return default def f_setvar(self, name, expr): """``setvar()`` for the sandboxed code. Set a variable. :Example: see module-docstring """ self.vars_ptr[name] = self.eval(expr, self.vars_ptr) return "" def f_escape(self, s, format="HTML"): """``escape()`` for the sandboxed code. """ if isinstance(format, (str, unicode)): format = ESCAPE_SUPPORTED[format.upper()] return escape(unicode(s), format) #----------------------------------------- # basic template / subtemplate class TemplateBase: """Basic template-class. Used both for the template itself and for 'macro's ("subtemplates") in the template. """ def __init__(self, parsetree, renderfunc, data=None): """Create the Template/Subtemplate/Macro. :Parameters: - `parsetree`: parse-tree of the template/subtemplate/macro - `renderfunc`: render-function - `data`: data to fill into the template by default (dictionary). This data may later be overridden when rendering the template. :Exceptions: - `TypeError`: if `data` is not a dictionary """ #TODO: parameter-checking? self.parsetree = parsetree if isinstance(data, dict): self.data = data elif data is None: self.data = {} else: raise TypeError('"data" must be a dict (or None).') self.current_data = data self._render = renderfunc def __call__(self, **override): """Fill out/render the template. :Parameters: - `override`: objects to add to the data-namespace, overriding the "default"-data. :Returns: the filled template (in unicode) :Note: This is also called when invoking macros (i.e. ``$!mymacro()!$``). """ self.current_data = self.data.copy() self.current_data.update(override) u = "".join(self._render(self.parsetree, self.current_data)) self.current_data = self.data # restore current_data return _dontescape(u) # (see class _dontescape) def __unicode__(self): """Alias for __call__().""" return self.__call__() def __str__(self): """Alias for __call__().""" return self.__call__() #----------------------------------------- # Renderer class _dontescape(unicode): """Unicode-string which should not be escaped. If ``isinstance(object,_dontescape)``, then don't escape the object in ``@!...!@``. It's useful for not double-escaping macros, and it's automatically used for macros/subtemplates. :Note: This only works if the object is used on its own in ``@!...!@``. It i.e. does not work in ``@!object*2!@`` or ``@!object + "hi"!@``. """ __slots__ = [] class Renderer(object): """Render a template-parse-tree. :Uses: `TemplateBase` for macros """ def __init__(self, evalfunc, escapefunc): """Init the renderer. :Parameters: - `evalfunc`: function for template-expression-evaluation (i.e. ``EvalPseudoSandbox().eval``) - `escapefunc`: function for escaping special characters (i.e. `escape`) """ #TODO: test evalfunc self.evalfunc = evalfunc self.escapefunc = escapefunc def _eval(self, expr, data): """evalfunc with error-messages""" try: return self.evalfunc(expr, data) #TODO: any other errors to catch here? except (TypeError, NameError, LookupError, AttributeError, SyntaxError) as err: raise TemplateRenderError("Cannot eval expression '%s'. (%s: %s)" %(expr, err.__class__.__name__, err)) def render(self, parsetree, data): """Render a parse-tree of a template. :Parameters: - `parsetree`: the parse-tree - `data`: the data to fill into the template (dictionary) :Returns: the rendered output-unicode-string :Exceptions: - `TemplateRenderError` """ _eval = self._eval # shortcut output = [] do_else = False # use else/elif-branch? if parsetree is None: return "" for elem in parsetree: if "str" == elem[0]: output.append(elem[1]) elif "sub" == elem[0]: output.append(unicode(_eval(elem[1], data))) elif "esc" == elem[0]: obj = _eval(elem[2], data) #prevent double-escape if isinstance(obj, _dontescape) or isinstance(obj, TemplateBase): output.append(unicode(obj)) else: output.append(self.escapefunc(unicode(obj), elem[1])) elif "for" == elem[0]: do_else = True (names, iterable) = elem[1:3] try: loop_iter = iter(_eval(iterable, data)) except TypeError: raise TemplateRenderError("Cannot loop over '%s'." % iterable) for i in loop_iter: do_else = False if len(names) == 1: data[names[0]] = i else: data.update(zip(names, i)) #"for a,b,.. in list" output.extend(self.render(elem[3], data)) elif "if" == elem[0]: do_else = True if _eval(elem[1], data): do_else = False output.extend(self.render(elem[2], data)) elif "elif" == elem[0]: if do_else and _eval(elem[1], data): do_else = False output.extend(self.render(elem[2], data)) elif "else" == elem[0]: if do_else: do_else = False output.extend(self.render(elem[1], data)) elif "macro" == elem[0]: data[elem[1]] = TemplateBase(elem[2], self.render, data) else: raise TemplateRenderError("Invalid parse-tree (%s)." %(elem)) return output #----------------------------------------- # template user-interface (putting it all together) class Template(TemplateBase): """Template-User-Interface. :Usage: :: t = Template(...) (<- see __init__) output = t(...) (<- see TemplateBase.__call__) :Example: see module-docstring """ def __init__(self, string=None,filename=None,parsetree=None, encoding='utf-8', data=None, escape=HTML, loader_class=LoaderFile, parser_class=Parser, renderer_class=Renderer, eval_class=EvalPseudoSandbox, escape_func=escape): """Load (+parse) a template. :Parameters: - `string,filename,parsetree`: a template-string, filename of a template to load, or a template-parsetree. (only one of these 3 is allowed) - `encoding`: encoding of the template-files (only used for "filename") - `data`: data to fill into the template by default (dictionary). This data may later be overridden when rendering the template. - `escape`: default-escaping for the template, may be overwritten by the template! - `loader_class` - `parser_class` - `renderer_class` - `eval_class` - `escapefunc` """ if [string, filename, parsetree].count(None) != 2: raise ValueError('Exactly 1 of string,filename,parsetree is necessary.') tmpl = None # load template if filename is not None: incl_load = loader_class(os.path.dirname(filename), encoding).load tmpl = incl_load(os.path.basename(filename)) if string is not None: incl_load = dummy_raise(NotImplementedError, "'include' not supported for template-strings.") tmpl = LoaderString(encoding).load(string) # eval (incl. compile-cache) templateeval = eval_class() # parse if tmpl is not None: p = parser_class(loadfunc=incl_load, testexpr=templateeval.compile, escape=escape) parsetree = p.parse(tmpl) del p # renderer renderfunc = renderer_class(templateeval.eval, escape_func).render #create template TemplateBase.__init__(self, parsetree, renderfunc, data) #=========================================