├── .gitignore ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── setup.cfg ├── setup.py └── step ├── __init__.py ├── step.py └── tests ├── __init__.py ├── cmd.py └── test_template.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | 3 | # Packages 4 | *.egg 5 | *.egg-info 6 | dist 7 | build 8 | eggs 9 | parts 10 | bin 11 | var 12 | sdist 13 | develop-eggs 14 | .installed.cfg 15 | 16 | # Installer logs 17 | pip-log.txt 18 | 19 | # Other 20 | TODO -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Daniele Mazzocchio 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | * Neither the name of the developer nor the names of its contributors may be 13 | used to endorse or promote products derived from this software without 14 | specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 20 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 23 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE.md 3 | include setup.py 4 | include step/* 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | step - Simple Template Engine for Python 2 | ======================================== 3 | 4 | 5 | `step` is a pure-Python module providing a very simple template engine with 6 | minimum syntax. It supports variable expansion, flow control and embedding of 7 | Python code. 8 | 9 | 10 | Installation 11 | ------------ 12 | Use pip: 13 | 14 | # pip install step-template 15 | 16 | or download the package from [GitHub](https://github.com/dotpy/step/) and run the 17 | install script: 18 | 19 | # python setup.py install 20 | 21 | 22 | Basic usage 23 | ----------- 24 | A template is a string containing any kind of textual content and a set of 25 | directives representing variables, control structures and blocks of Python code. 26 | 27 | Variables are enclosed in `{{}}` and follow the same syntax rules as Python 28 | variables; e.g.: 29 | 30 | This is variable x: {{x}} 31 | This is the third item of my_list: {{my_list[2]}} 32 | This is not a variable: \{\{x\}\} 33 | 34 | If `Template` is created with `escape=True`, all variables are auto-escaped 35 | for HTML, unless given with a leading exclamation mark; e.g. `raw x: {{!x}}`. 36 | The escape-function can be changed via `Template().builtins`. 37 | 38 | Flow control expressions are written like regular Python control structures, 39 | preceded by the `%` sign and must be closed by a `%end` tag; e.g.: 40 | 41 | %if (x > 2): 42 | x is greater than 2 43 | %else: 44 | x is {{x}} 45 | %endif 46 | 47 | All text between `<%` and `%>` is considered Python code; you can use the 48 | builtin `echo()` function to output some text from within Python code blocks; 49 | e.g.: 50 | 51 | <% 52 | import time 53 | echo("Current timestamp is {}".format(time.time())) 54 | %> 55 | <\% This is not Python code %\> 56 | 57 | You can use the special function `isdef()` to perform some actions only if a 58 | name is defined in the template namespace; e.g.: 59 | 60 | %if isdef("my_var") 61 | my_var is {{my_var}} 62 | %endif 63 | 64 | The `get()` function returns the specified value from template namespace, 65 | or given fallback if name is undefined, defaulting to `None`; e.g.: 66 | 67 | {{get("x")}} {{get("y", 2)}} 68 | 69 | The `setopt()` function allows you to enable options that modify the template 70 | output; the only supported option is 'strip', which removes leading/trailing 71 | whitespace, contiguous whitespace and empty lines and defaults to true; e.g.: 72 | 73 | <%setopt("strip", True)%> 74 | 75 | The 'strip' option can also be given as a parameter during `Template` object 76 | creation; e.g.: 77 | 78 | tmpl = step.Template(TEMPLATE_STRING, strip=True) 79 | 80 | A backslash at the end of a line will suppress the newline character. 81 | 82 | Additional postprocessing can be applied on generated content, via 83 | `Template(.., postprocess=some callable or an iterable of callables)`; e.g.: 84 | 85 | step.Template("\xff", postprocess=str.encode).expand() == b"\xc3\xbf" 86 | 87 | 88 | Documentation 89 | ------------- 90 | More examples and a detailed description of the module and its classes are 91 | available at http://www.kernel-panic.it/programming/step/. 92 | 93 | 94 | Tests 95 | ----- 96 | To run the test suite, just run `python setup.py test`. 97 | 98 | 99 | Credits 100 | ------- 101 | Copyright (c) 2012 Daniele Mazzocchio (danix@kernel-panic.it). 102 | Several improvements by Erki Suurjaak. 103 | 104 | Licensed under the BSD license (see [LICENSE.md](LICENSE.md) file). 105 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | 4 | """ 5 | This is the installation script of the step module, a light and fast template engine. You can run it by typing: 6 | 7 | python setup.py install 8 | 9 | You can also run the test suite by running: 10 | 11 | python setup.py test 12 | """ 13 | 14 | 15 | import sys 16 | from distutils.core import setup 17 | from step.tests import TestCommand 18 | 19 | 20 | __author__ = "Daniele Mazzocchio " 21 | __version__ = "0.0.4" 22 | __date__ = "Aug 02, 2023" 23 | 24 | 25 | # Python versions prior 2.2.3 don't support 'classifiers' and 'download_url' 26 | if sys.version < "2.2.3": 27 | from distutils.dist import DistributionMetadata 28 | DistributionMetadata.classifiers = None 29 | DistributionMetadata.download_url = None 30 | 31 | setup(name = "step-template", 32 | version = __version__, 33 | author = "Daniele Mazzocchio", 34 | author_email = "danix@kernel-panic.it", 35 | packages = ["step", "step.tests"], 36 | cmdclass = {"test": TestCommand}, 37 | description = "Simple Template Engine for Python", 38 | download_url = "https://github.com/dotpy/step/archive/step-%s.tar.gz" % __version__, 39 | classifiers = ["Development Status :: 5 - Production/Stable", 40 | "Environment :: Console", 41 | "Intended Audience :: Developers", 42 | "License :: OSI Approved :: BSD License", 43 | "Natural Language :: English", 44 | "Operating System :: OS Independent", 45 | "Programming Language :: Python", 46 | "Topic :: Text Processing"], 47 | url = "https://github.com/dotpy/step", 48 | license = "OSI-Approved :: BSD License", 49 | keywords = "templates templating template-engines", 50 | long_description = "step is a pure-Python module providing a very " 51 | "simple template engine with minimum syntax. It " 52 | "supports variable expansion, flow control and " 53 | "embedding of Python code.") 54 | -------------------------------------------------------------------------------- /step/__init__.py: -------------------------------------------------------------------------------- 1 | """A light and fast template engine.""" 2 | 3 | from .step import Template 4 | -------------------------------------------------------------------------------- /step/step.py: -------------------------------------------------------------------------------- 1 | """A light and fast template engine.""" 2 | 3 | import re 4 | 5 | 6 | try: text_type, string_types = unicode, (bytes, unicode) # Py2 7 | except Exception: text_type, string_types = str, (str, ) # Py3 8 | 9 | 10 | class Template(object): 11 | 12 | TRANSPILED_TEMPLATES = {} # {(template string, compile options): compilable code string} 13 | COMPILED_TEMPLATES = {} # {compilable code string: code object} 14 | # Regexes for stripping all leading and interleaving, and all or rest of trailing whitespace. 15 | RE_STRIP = re.compile("(^[ \t]+|[ \t]+$|(?<=[ \t])[ \t]+|\\A[\r\n]+|[ \t\r\n]+\\Z)", re.M) 16 | RE_STRIP_STREAM = re.compile("(^[ \t]+|[ \t]+$|(?<=[ \t])[ \t]+|\\A[\r\n]+|" 17 | "((?<=(\r\n))|(?<=[ \t\r\n]))[ \t\r\n]+\\Z)", re.M) 18 | 19 | def __init__(self, template, strip=True, escape=False, postprocess=None): 20 | """Initialize class""" 21 | super(Template, self).__init__() 22 | pp = list([postprocess] if callable(postprocess) else postprocess or []) 23 | self.template = template 24 | self.options = {"strip": strip, "escape": escape, "postprocess": pp} 25 | self.builtins = {"escape": escape_html, "setopt": self.options.__setitem__} 26 | key = (template, bool(escape)) 27 | TPLS, CODES = Template.TRANSPILED_TEMPLATES, Template.COMPILED_TEMPLATES 28 | src = TPLS.setdefault(key, TPLS.get(key) or self._process(self._preprocess(self.template))) 29 | self.code = CODES.setdefault(src, CODES.get(src) or compile(src, "", "exec")) 30 | 31 | def expand(self, namespace=None, **kw): 32 | """Return the expanded template string""" 33 | output = [] 34 | eval(self.code, self._make_namespace(namespace, output.append, **kw)) 35 | return self._postprocess("".join(map(to_unicode, output))) 36 | 37 | def stream(self, buffer, namespace=None, encoding="utf-8", buffer_size=65536, **kw): 38 | """Expand the template and stream it to a file-like buffer.""" 39 | 40 | def write_buffer(s, flush=False, cache=[""]): 41 | # Cache output as a single string and write to buffer. 42 | cache[0] += to_unicode(s) 43 | if cache[0] and (flush or buffer_size < 1 or len(cache[0]) > buffer_size): 44 | v = self._postprocess(cache[0], stream=not flush) 45 | v and buffer.write(v.encode(encoding) if encoding else v) 46 | cache[0] = "" 47 | 48 | eval(self.code, self._make_namespace(namespace, write_buffer, **kw)) 49 | write_buffer("", flush=True) # Flush any last cached bytes 50 | 51 | def _make_namespace(self, namespace, echo, **kw): 52 | """Return template namespace dictionary, containing given values and template functions.""" 53 | namespace = dict(namespace or {}, **dict(kw, **self.builtins)) 54 | namespace.update(echo=echo, get=namespace.get, isdef=namespace.__contains__) 55 | return namespace 56 | 57 | def _preprocess(self, template): 58 | """Modify template string before code conversion""" 59 | # Replace inline ('%') blocks for easier parsing 60 | o = re.compile("(?m)^[ \t]*%((if|for|while|try).+:)") 61 | c = re.compile("(?m)^[ \t]*%(((else|elif|except|finally).*:)|(end\\w+))") 62 | template = c.sub(r"<%:\g<1>%>", o.sub(r"<%\g<1>%>", template)) 63 | 64 | # Replace {{!x}} and {{x}} variables with '<%echo(x)%>'. 65 | # If auto-escaping is enabled, use echo(escape(x)) for the second. 66 | vars = r"\{\{\s*\!(.*?)\}\}", r"\{\{(.*?)\}\}" 67 | subs = [r"<%echo(\g<1>)%>\n"] * 2 68 | if self.options["escape"]: subs[1] = r"<%echo(escape(\g<1>))%>\n" 69 | for v, s in zip(vars, subs): template = re.sub(v, s, template) 70 | 71 | return template 72 | 73 | def _process(self, template): 74 | """Return the code generated from the template string""" 75 | code_blk = re.compile(r"<%(.*?)%>\n?", re.DOTALL) 76 | indent, n = 0, 0 77 | code = [] 78 | for n, blk in enumerate(code_blk.split(template)): 79 | # Replace '<\%' and '%\>' escapes 80 | blk = re.sub(r"<\\%", "<%", re.sub(r"%\\>", "%>", blk)) 81 | # Unescape '%{}' characters 82 | blk = re.sub(r"\\(%|{|})", r"\g<1>", blk) 83 | 84 | if not (n % 2): 85 | if not blk: continue 86 | # Escape backslash characters 87 | blk = re.sub(r'\\', r'\\\\', blk) 88 | # Escape double-quote characters 89 | blk = re.sub(r'"', r'\\"', blk) 90 | blk = (" " * (indent*4)) + 'echo("""{0}""")'.format(blk) 91 | else: 92 | blk = blk.rstrip() 93 | if blk.lstrip().startswith(":"): 94 | if not indent: 95 | err = "unexpected block ending" 96 | raise SyntaxError("Line {0}: {1}".format(n, err)) 97 | indent -= 1 98 | if blk.startswith(":end"): 99 | continue 100 | blk = blk.lstrip()[1:] 101 | 102 | blk = re.sub("(?m)^", " " * (indent * 4), blk) 103 | if blk.endswith(":"): 104 | indent += 1 105 | 106 | code.append(blk) 107 | 108 | if indent: 109 | err = "Reached EOF before closing block" 110 | raise EOFError("Line {0}: {1}".format(n, err)) 111 | 112 | return "\n".join(code) 113 | 114 | def _postprocess(self, output, stream=False): 115 | """Modify output string after variables and code evaluation""" 116 | if self.options["strip"]: 117 | output = (Template.RE_STRIP_STREAM if stream else Template.RE_STRIP).sub("", output) 118 | for process in self.options["postprocess"]: 119 | output = process(output) 120 | return output 121 | 122 | 123 | def escape_html(x): 124 | """Escape HTML special characters &<> and quotes "'.""" 125 | CHARS, ENTITIES = "&<>\"'", ["&", "<", ">", """, "'"] 126 | string = x if isinstance(x, string_types) else str(x) 127 | for c, e in zip(CHARS, ENTITIES): string = string.replace(c, e) 128 | return string 129 | 130 | 131 | def to_unicode(x, encoding="utf-8"): 132 | """Convert anything to Unicode.""" 133 | if isinstance(x, (bytes, bytearray)): 134 | x = text_type(x, encoding, errors="replace") 135 | elif not isinstance(x, string_types): 136 | x = text_type(str(x)) 137 | return x 138 | -------------------------------------------------------------------------------- /step/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test module for step""" 2 | 3 | from step.tests.cmd import TestCommand 4 | from step.tests.test_template import TestTemplate 5 | 6 | 7 | __all__ = ["TestCommand", 8 | "TestCollection"] 9 | -------------------------------------------------------------------------------- /step/tests/cmd.py: -------------------------------------------------------------------------------- 1 | """Command for running unit tests for the module from setup.py""" 2 | 3 | from distutils.cmd import Command 4 | from unittest import TextTestRunner, TestLoader 5 | 6 | import step.tests 7 | 8 | 9 | class TestCommand(Command): 10 | """Command for running unit tests for the module from setup.py""" 11 | 12 | user_options = [] 13 | 14 | def initialize_options(self): 15 | pass 16 | 17 | def finalize_options(self): 18 | pass 19 | 20 | def run(self): 21 | suite = TestLoader().loadTestsFromModule(step.tests) 22 | TextTestRunner(verbosity=1).run(suite) 23 | -------------------------------------------------------------------------------- /step/tests/test_template.py: -------------------------------------------------------------------------------- 1 | """Test suite for the step.Template class""" 2 | 3 | import base64 4 | import io 5 | import unittest 6 | 7 | import step 8 | 9 | 10 | class TestTemplate(unittest.TestCase): 11 | """Test case class for step.Template.""" 12 | 13 | def test_variable(self): 14 | tmpl = "Variable: {{var}}" 15 | values = {"var": 1} 16 | output = "Variable: 1" 17 | self.assertEqual(step.Template(tmpl).expand(values), output) 18 | 19 | def test_escape(self): 20 | tmpl = r"Variable: {\{var}\}" 21 | output = "Variable: {{var}}" 22 | self.assertEqual(step.Template(tmpl).expand({}), output) 23 | 24 | def test_condition(self): 25 | tmpl = r"""I have 26 | %if eggs == 1: 27 | 1 egg 28 | %else: 29 | {{eggs}} eggs 30 | %endif""" 31 | values = {"eggs": 3} 32 | output = "I have\n3 eggs" 33 | self.assertEqual(step.Template(tmpl).expand(values), output) 34 | 35 | def test_isdef(self): 36 | tmpl = r""" 37 | %if isdef("spam"): 38 | I like spam 39 | %else: 40 | I don't like spam 41 | %endif""" 42 | output = "I don't like spam" 43 | self.assertEqual(step.Template(tmpl).expand({}), output) 44 | 45 | def test_get(self): 46 | tmpl = r"""{{get("x")}} by {{get("y", 4)}}""" 47 | output = "2 by 4" 48 | self.assertEqual(step.Template(tmpl).expand({"x": 2}), output) 49 | 50 | def test_echo(self): 51 | tmpl = r""" 52 | <%if eggs == 1: 53 | echo("I have 1 egg") 54 | %>""" 55 | values = {"eggs": 1} 56 | output = "I have 1 egg" 57 | self.assertEqual(step.Template(tmpl).expand(values), output) 58 | 59 | 60 | def test_strip(self): 61 | tmpl = "\n\nName:\t\t\t{{var}}\n \t\n" 62 | values = {"var": 1} 63 | output = "Name:\t1" 64 | self.assertEqual(step.Template(tmpl).expand(values), output) 65 | 66 | 67 | def test_postprocess(self): 68 | tmpl = "something" 69 | output = tmpl.encode() 70 | self.assertEqual(step.Template(tmpl, postprocess=str.encode).expand({}), output) 71 | output = base64.b64encode(tmpl.encode()) 72 | postprocess = (str.encode, base64.b64encode) 73 | self.assertEqual(step.Template(tmpl, postprocess=postprocess).expand({}), output) 74 | 75 | 76 | def test_stream(self): 77 | tmpl = "something" 78 | bstream = io.BytesIO() 79 | step.Template(tmpl).stream(bstream) 80 | self.assertEqual(bstream.getvalue(), tmpl.encode()) 81 | sstream = io.StringIO() 82 | step.Template(tmpl).stream(sstream, encoding=None) 83 | self.assertEqual(sstream.getvalue(), tmpl) 84 | 85 | 86 | def test_stream_buffer(self): 87 | tmpl = r""" 88 | %for x in iterable: 89 | {{x}} 90 | %endfor 91 | """ 92 | class writer(list): write = list.append 93 | vals = [0, 1, 2, 3] 94 | stream = writer() 95 | step.Template(tmpl).stream(stream, encoding=None, buffer_size=1, iterable=vals) 96 | self.assertEqual(stream, ["%s\n" % v for v in vals]) 97 | stream = writer() 98 | step.Template(tmpl).stream(stream, encoding=None, iterable=vals) 99 | self.assertEqual(stream, ["\n".join(map(str, vals))]) 100 | 101 | 102 | def test_autoescape(self): 103 | HTML = """ &' """ 104 | ESCAPED = """<a href=""> &' </a>""" 105 | tmpl = """{{!%r}} {{%r}}""" % (HTML, HTML) 106 | for escape in (True, False): 107 | output = "%s %s" % (HTML, ESCAPED if escape else HTML) 108 | self.assertEqual(step.Template(tmpl, escape=escape).expand({}), output) 109 | 110 | 111 | def test_double_quote_escape(self): 112 | tmpl = r'abc""defg"""hijk""""lmn"""""' 113 | self.assertEqual(step.Template(tmpl).expand(), tmpl) 114 | 115 | 116 | def test_backslash_escape(self): 117 | tmpl_list = [ 118 | r'===\n===', 119 | r'===\f===', 120 | r'===\\===', 121 | '===\n===', 122 | '===\f===', 123 | '===\\===', 124 | '===\\n===', 125 | '===\\\n===', 126 | '===\\\\n===', 127 | ] 128 | for tmpl in tmpl_list: 129 | self.assertEqual(step.Template(tmpl).expand(), tmpl) 130 | 131 | --------------------------------------------------------------------------------