pax_global_header00006660000000000000000000000064145346336500014523gustar00rootroot0000000000000052 comment=a19a26c81d4465804c7a8a9f6db6a0382f7cc259 airspeed-0.6.0/000077500000000000000000000000001453463365000133225ustar00rootroot00000000000000airspeed-0.6.0/.github/000077500000000000000000000000001453463365000146625ustar00rootroot00000000000000airspeed-0.6.0/.github/FUNDING.yml000066400000000000000000000000231453463365000164720ustar00rootroot00000000000000patreon: sanityinc airspeed-0.6.0/.github/dependabot.yml000066400000000000000000000003031453463365000175060ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: github-actions directory: "/" schedule: interval: daily open-pull-requests-limit: 10 commit-message: prefix: "chore" include: "scope" airspeed-0.6.0/.github/workflows/000077500000000000000000000000001453463365000167175ustar00rootroot00000000000000airspeed-0.6.0/.github/workflows/ci.yml000066400000000000000000000010171453463365000200340ustar00rootroot00000000000000name: ci on: [push, pull_request] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] steps: - name: checkout uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '${{ matrix.python-version }}' - name: Install dependencies run: | pip install six cachetools - name: Run tests run: | PYTHONPATH=$(pwd) python tests/__init__.py airspeed-0.6.0/.gitignore000066400000000000000000000013751453463365000153200ustar00rootroot00000000000000*.pyc *.pyo *.egg-info *.DS_Store *~ build/ dist/ tests/.coverage tests/.noseids # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .cache nosetests.xml coverage.xml # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ /.eggs/ airspeed-0.6.0/.travis.yml000066400000000000000000000001361453463365000154330ustar00rootroot00000000000000language: python python: - "2.7" - "3.4" - "3.6" - "3.7" script: python setup.py test airspeed-0.6.0/LICENCE000066400000000000000000000023431453463365000143110ustar00rootroot00000000000000Copyright (c) 2004-2015 Steve Purcell & Chris Tarttelin Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. airspeed-0.6.0/README.md000066400000000000000000000100551453463365000146020ustar00rootroot00000000000000[![Build Status](https://github.com/purcell/airspeed/actions/workflows/ci.yml/badge.svg)](https://github.com/purcell/airspeed/actions/workflows/ci.yml) [![PyPI version](https://img.shields.io/pypi/v/airspeed.svg)](https://pypi.org/project/airspeed/) [![PyPi downloads](https://img.shields.io/pypi/dm/airspeed)](https://pypi.org/project/airspeed/) Support me # Airspeed - a Python template engine ## What is Airspeed? Airspeed is a powerful and easy-to-use templating engine for Python that aims for a high level of compatibility with the popular [Velocity](http://velocity.apache.org/engine/devel/user-guide.html) library for Java. ## Selling points * Compatible with Velocity templates * Compatible with Python 2.7 and greater, including Jython * Features include macros definitions, conditionals, sub-templates and much more * Airspeed is already being put to serious use * Comprehensive set of unit tests; the entire library was written test-first * Reasonably fast * A single Python module of a few kilobytes, and not the 500kb of Velocity * Liberal licence (BSD-style) ## Why another templating engine? A number of excellent templating mechanisms already exist for Python, including [Cheetah](http://www.cheetahtemplate.org/), which has a syntax similar to Airspeed. However, in making Airspeed's syntax *identical* to that of Velocity, our goal is to allow Python programmers to prototype, replace or extend Java code that relies on Velocity. A simple example: ```python t = airspeed.Template(""" Old people: #foreach ($person in $people) #if($person.age > 70) $person.name #end #end Third person is $people[2].name """) people = [{'name': 'Bill', 'age': 100}, {'name': 'Bob', 'age': 90}, {'name': 'Mark', 'age': 25}] print t.merge(locals()) ``` You can also use "Loaders" to allow templates to include each other using the `#include` or `#parse` directives: ``` % cat /tmp/1.txt Bingo! % cat /tmp/2.txt #parse ("2.txt") % python Python 2.4.4 (#1, May 28 2007, 00:47:43) [GCC 4.0.1 (Apple Computer, Inc. build 5367)] on darwin Type "help", "copyright", "credits" or "license" for more information. >>> from airspeed import CachingFileLoader >>> loader = CachingFileLoader("/tmp") >>> template = loader.load_template("1.txt") >>> template.merge({}, loader=loader) 'Bingo!\n' ``` ### How compatible is Airspeed with Velocity? All Airspeed templates should work correctly with Velocity. The vast majority of Velocity templates will work correctly with Airspeed. ### What does and doesn't work? Airspeed currently implements a very significant subset of the Velocity functionality, including `$variables`, the `#if`, `#foreach`, `#macro`, `#include` and `#parse` directives, and `"$interpolated #strings()"`. Templates are unicode-safe. The output of templates in Airspeed is not yet 'whitespace compatible' with Velocity's rendering of the same templates, which generally does not matter for web applications. ### Where do I get it? https://github.com/purcell/airspeed ### Getting started The [Velocity User Guide](http://velocity.apache.org/engine/releases/velocity-1.7/user-guide.html) shows how to write templates. Our unit tests show how to use the templates from your code. ### Reporting bugs Please feel free to create tickets for bugs or desired features. ### Who is to blame? Airspeed was conceived by Chris Tarttelin, and implemented jointly in a test-driven manner by Steve Purcell and Chris Tarttelin. We can be contacted by e-mail by using our first names (at) pythonconsulting dot com. Extensions for compatibility with Velocity 1.7 were kindly provided by [Giannis Dzegoutanis](https://github.com/erasmospunk), and further modernization has been done by [David Black](https://github.com/dbaxa/).
[💝 Support this project and my other Open Source work](https://www.patreon.com/sanityinc) [💼 LinkedIn profile](https://uk.linkedin.com/in/stevepurcell) [✍ sanityinc.com](http://www.sanityinc.com/) [🐦 @sanityinc](https://twitter.com/sanityinc) airspeed-0.6.0/airspeed/000077500000000000000000000000001453463365000151165ustar00rootroot00000000000000airspeed-0.6.0/airspeed/__init__.py000066400000000000000000001237261453463365000172420ustar00rootroot00000000000000from __future__ import print_function import re import operator import os import string import sys import six __all__ = [ 'Template', 'TemplateError', 'TemplateExecutionError', 'TemplateSyntaxError', 'CachingFileLoader'] # A dict that maps classes to dicts of additional methods. # This allows support for methods that are available in Java-based Velocity # implementations, e.g., .size() of a list or .length() of a string. # Given a method 'm' invoked with parameters '*p' on an object of type 't', # and if __additional_methods__[t][m] exists, we will invoke and return m(t, *p) # # For example, given a template variable "$foo = [1,2,3]", "$foo.size()" will # result in calling method __additional_methods__[list]['size']($foo) __additional_methods__ = { str: { 'length': lambda self: len(self), 'replaceAll': lambda self, pattern, repl: re.sub(pattern, repl, self), 'startsWith': lambda self, prefix: self.startswith(prefix), 'matches': lambda self, pattern: re.match(pattern, self) }, list: { 'size': lambda self: len(self), 'get': lambda self, index: self[index], 'contains': lambda self, value: value in self, 'add': lambda self, value: self.append(value) }, dict: { 'isEmpty': lambda self: not bool(self), 'keySet': lambda self: self.keys(), 'put': lambda self, key, value: self.update({key: value}), } } try: dict except NameError: from UserDict import UserDict class dict(UserDict): def __init__(self): self.data = {} try: operator.__gt__ except AttributeError: operator.__gt__ = lambda a, b: a > b operator.__lt__ = lambda a, b: a < b operator.__ge__ = lambda a, b: a >= b operator.__le__ = lambda a, b: a <= b operator.__eq__ = lambda a, b: a == b operator.__ne__ = lambda a, b: a != b operator.mod = lambda a, b: a % b try: basestring def is_string(s): return isinstance(s, basestring) except NameError: def is_string(s): return isinstance(s, type('')) ############################################################################### # Public interface ############################################################################### def boolean_value(variable_value): if not variable_value: return False return not (variable_value is None) def is_valid_vtl_identifier(text): return text and text[0] in set(string.ascii_letters + '_') class Template: def __init__(self, content, filename=""): self.content = content self.filename = filename self.root_element = None def merge(self, namespace, loader=None): output = StoppableStream() self.merge_to(namespace, output, loader) return output.getvalue() def ensure_compiled(self): if not self.root_element: self.root_element = TemplateBody(self.filename, self.content) def merge_to(self, namespace, fileobj, loader=None): if loader is None: loader = NullLoader() self.ensure_compiled() self.root_element.evaluate(fileobj, namespace, loader) class TemplateError(Exception): pass class TemplateExecutionError(TemplateError): def __init__(self, element, exc_info): cause, value, traceback = exc_info self.__cause__ = value self.element = element self.start, self.end, self.filename = (element.start, element.end, element.filename) self.msg = "Error in template '%s' at position " \ "%d-%d in expression: %s\n%s: %s" % \ (self.filename, self.start, self.end, element.my_text(), cause.__name__, value) def __str__(self): return self.msg class TemplateSyntaxError(TemplateError): def __init__(self, element, expected): self.element = element self.text_understood = element.full_text()[:element.end] self.line = 1 + self.text_understood.count('\n') self.column = len( self.text_understood) - self.text_understood.rfind('\n') got = element.next_text() if len(got) > 40: got = got[:36] + ' ...' Exception.__init__( self, "line %d, column %d: expected %s in %s, got: %s ..." % (self.line, self.column, expected, self.element_name(), got)) def get_position_strings(self): error_line_start = 1 + self.text_understood.rfind('\n') if '\n' in self.element.next_text(): error_line_end = self.element.next_text().find( '\n') + self.element.end else: error_line_end = len(self.element.full_text()) error_line = self.element.full_text()[error_line_start:error_line_end] caret_pos = self.column return [error_line, ' ' * (caret_pos - 1) + '^'] def element_name(self): return re.sub( '([A-Z])', lambda m: ' ' + m.group(1).lower(), self.element.__class__.__name__).strip() class NullLoader: def load_text(self, name): raise TemplateError("no loader available for '%s'" % name) def load_template(self, name): raise self.load_text(name) class CachingFileLoader: def __init__(self, basedir, debugging=False): self.basedir = basedir self.known_templates = {} # name -> (template, file_mod_time) self.debugging = debugging if debugging: print("creating caching file loader with basedir:", basedir) def filename_of(self, name): return os.path.join(self.basedir, name) def load_text(self, name): if self.debugging: print("Loading text from", self.basedir, name) f = open(self.filename_of(name)) try: return f.read() finally: f.close() def load_template(self, name): if self.debugging: print("Loading template...", name,) mtime = os.path.getmtime(self.filename_of(name)) if name in self.known_templates: template, prev_mtime = self.known_templates[name] if mtime <= prev_mtime: if self.debugging: print("loading parsed template from cache") return template if self.debugging: print("loading text from disk") template = Template(self.load_text(name), filename=name) template.ensure_compiled() self.known_templates[name] = (template, mtime) return template class StoppableStream(six.StringIO): def __init__(self, buf=''): self.stop = False six.StringIO.__init__(self, buf) def write(self, s): if not self.stop: six.StringIO.write(self, s) ############################################################################### # Internals ############################################################################### WHITESPACE_TO_END_OF_LINE = re.compile(r'[ \t\r]*\n(.*)', re.S) class NoMatch(Exception): pass class LocalNamespace(dict): def __init__(self, parent): dict.__init__(self) self.parent = parent def __getitem__(self, key): try: return dict.__getitem__(self, key) except KeyError: return self.parent[key] def find_outermost(self, key): try: dict.__getitem__(self, key) return self except KeyError: if isinstance(self.parent, LocalNamespace): return self.parent.find_outermost(key) else: return None def set_inherited(self, key, value): ns = self.find_outermost(key) if ns is None: ns = self ns[key] = value def top(self): if hasattr(self.parent, "top"): return self.parent.top() return self.parent def __repr__(self): return dict.__repr__(self) + '->' + repr(self.parent) class _Element: def __init__(self, filename, text, start=0): self.filename = filename self._full_text = text self.start = self.end = start self.parse() def next_text(self): return self._full_text[self.end:] def my_text(self): return self._full_text[self.start:self.end] def full_text(self): return self._full_text def syntax_error(self, expected): return TemplateSyntaxError(self, expected) def identity_match(self, pattern): m = pattern.match(self._full_text, self.end) if not m: raise NoMatch() self.end = m.start(pattern.groups) return m.groups()[:-1] def next_match(self, pattern): m = pattern.match(self._full_text, self.end) if not m: return False self.end = m.start(pattern.groups) return m.groups()[:-1] def optional_match(self, pattern): m = pattern.match(self._full_text, self.end) if not m: return False self.end = m.start(pattern.groups) return True def require_match(self, pattern, expected): m = pattern.match(self._full_text, self.end) if not m: raise self.syntax_error(expected) self.end = m.start(pattern.groups) return m.groups()[:-1] def next_element(self, element_spec): if callable(element_spec): element = element_spec(self.filename, self._full_text, self.end) self.end = element.end return element else: for element_class in element_spec: try: element = element_class(self.filename, self._full_text, self.end) except NoMatch: pass else: self.end = element.end return element raise NoMatch() def require_next_element(self, element_spec, expected): if callable(element_spec): try: element = element_spec(self.filename, self._full_text, self.end) except NoMatch: raise self.syntax_error(expected) else: self.end = element.end return element else: for element_class in element_spec: try: element = element_class(self.filename, self._full_text, self.end) except NoMatch: pass else: self.end = element.end return element expected = ', '.join([cls.__name__ for cls in element_spec]) raise self.syntax_error('one of: ' + expected) def evaluate(self, *args): try: return self.evaluate_raw(*args) except TemplateExecutionError: raise except: exc_info = sys.exc_info() six.reraise(TemplateExecutionError, TemplateExecutionError(self, exc_info), exc_info[2]) class Text(_Element): PLAIN = re.compile( r'((?:[^\\\$#]+|\\[\$#])+|\$[^!\{a-z0-9_]|\$$|#$' r'|#[^\{\}a-zA-Z0-9#\*]+|\\.)(.*)$', re.S + re.I) ESCAPED_CHAR = re.compile(r'\\([\$#]\S+)') def parse(self): text, = self.identity_match(self.PLAIN) def unescape(match): return match.group(1) self.text = self.ESCAPED_CHAR.sub(unescape, text) def evaluate_raw(self, stream, namespace, loader): stream.write(self.text) class FallthroughHashText(_Element): """ Plain text starting with a # but which didn't match an earlier directive or macro. The canonical example is an HTML color spec. Note that it MUST NOT match block-ending directives. """ # because of earlier elements, this will always start with a hash PLAIN = re.compile(r'(\#(?!end|else|elseif|\{(?:end|else|elseif)\}))(.*)$', re.S) def parse(self): self.text, = self.identity_match(self.PLAIN) def evaluate_raw(self, stream, namespace, loader): stream.write(self.text) class IntegerLiteral(_Element): INTEGER = re.compile(r'(-?\d+)(.*)', re.S) def parse(self): self.value, = self.identity_match(self.INTEGER) self.value = int(self.value) def calculate(self, namespace, loader): return self.value class FloatingPointLiteral(_Element): FLOAT = re.compile(r'(-?\d+\.\d+)(.*)', re.S) def parse(self): self.value, = self.identity_match(self.FLOAT) self.value = float(self.value) def calculate(self, namespace, loader): return self.value class BooleanLiteral(_Element): BOOLEAN = re.compile(r'((?:true)|(?:false))(.*)', re.S | re.I) def parse(self): self.value, = self.identity_match(self.BOOLEAN) self.value = self.value.lower() == 'true' def calculate(self, namespace, loader): return self.value class StringLiteral(_Element): STRING = re.compile(r"'((?:\\['nrbt\\\\\\$]|[^'\\])*)'(.*)", re.S) ESCAPED_CHAR = re.compile(r"\\([nrbt'\\])") def parse(self): value, = self.identity_match(self.STRING) def unescape(match): return { 'n': '\n', 'r': '\r', 'b': '\b', 't': '\t', '"': '"', '\\': '\\', "'": "'"}.get( match.group(1), '\\' + match.group(1)) self.value = self.ESCAPED_CHAR.sub(unescape, value) def calculate(self, namespace, loader): return self.value class InterpolatedStringLiteral(StringLiteral): STRING = re.compile(r'"((?:\\["nrbt\\\\\\$]|[^"\\])*)"(.*)', re.S) ESCAPED_CHAR = re.compile(r'\\([nrbt"\\])') def parse(self): StringLiteral.parse(self) self.block = Block(self.filename, self.value, 0) def calculate(self, namespace, loader): output = StoppableStream() self.block.evaluate(output, namespace, loader) return output.getvalue() class Range(_Element): MIDDLE = re.compile(r'([ \t]*\.\.[ \t]*)(.*)$', re.S) def parse(self): self.value1 = self.next_element((FormalReference, IntegerLiteral)) self.identity_match(self.MIDDLE) self.value2 = self.next_element((FormalReference, IntegerLiteral)) def calculate(self, namespace, loader): value1 = self.value1.calculate(namespace, loader) value2 = self.value2.calculate(namespace, loader) if value2 < value1: return range(value1, value2 - 1, -1) return range(value1, value2 + 1) class ValueList(_Element): COMMA = re.compile(r'\s*,\s*(.*)$', re.S) def parse(self): self.values = [] try: value = self.next_element(Value) except NoMatch: pass else: self.values.append(value) while self.optional_match(self.COMMA): value = self.require_next_element(Value, 'value') self.values.append(value) def calculate(self, namespace, loader): return [value.calculate(namespace, loader) for value in self.values] class _EmptyValues: def calculate(self, namespace, loader): return [] class ArrayLiteral(_Element): START = re.compile(r'\[[ \t]*(.*)$', re.S) END = re.compile(r'[ \t]*\](.*)$', re.S) values = _EmptyValues() def parse(self): self.identity_match(self.START) try: self.values = self.next_element((Range, ValueList)) except NoMatch: pass self.require_match(self.END, ']') self.calculate = self.values.calculate class DictionaryLiteral(_Element): START = re.compile(r'{[ \t]*(.*)$', re.S) END = re.compile(r'[ \t]*}(.*)$', re.S) KEYVALSEP = re.compile(r'[ \t]*:[ \t]*(.*)$', re.S) PAIRSEP = re.compile(r'[ \t]*,[ \t]*(.*)$', re.S) def parse(self): self.identity_match(self.START) self.local_data = {} if self.optional_match(self.END): # it's an empty dictionary return while (True): key = self.next_element(Value) self.require_match(self.KEYVALSEP, ':') value = self.next_element(Value) self.local_data[key] = value if not self.optional_match(self.PAIRSEP): break self.require_match(self.END, '}') # Note that this delays calculation of values until it's used. # TODO confirm that that's correct. def calculate(self, namespace, loader): tmp = {} for (key, val) in self.local_data.items(): tmp[key.calculate(namespace, loader)] = val.calculate( namespace, loader) return tmp class Value(_Element): def parse(self): self.expression = self.next_element( (FormalReference, FloatingPointLiteral, IntegerLiteral, StringLiteral, InterpolatedStringLiteral, ArrayLiteral, DictionaryLiteral, ParenthesizedExpression, UnaryOperatorValue, BooleanLiteral)) def calculate(self, namespace, loader): return self.expression.calculate(namespace, loader) class NameOrCall(_Element): NAME = re.compile(r'([a-zA-Z0-9_]+)(.*)$', re.S) parameters = None index = None def parse(self): self.name, = self.identity_match(self.NAME) if not is_valid_vtl_identifier(self.name): raise NoMatch('Invalid VTL identifier %s.' % self.name) try: self.parameters = self.next_element(ParameterList) except NoMatch: try: self.index = self.next_element(ArrayIndex) except NoMatch: pass def calculate(self, current_object, loader, top_namespace): result = None try: result = current_object[self.name] except (KeyError, TypeError, AttributeError): pass if result is None and not isinstance(current_object, LocalNamespace): try: result = getattr(current_object, self.name) except AttributeError: pass if result is None: methods_for_type = __additional_methods__.get(current_object.__class__) if methods_for_type and self.name in methods_for_type: result = lambda *args: methods_for_type[self.name](current_object, *args) if result is None: return None # TODO: an explicit 'not found' exception? if isinstance(result, _FunctionDefinition): params = self.parameters and self.parameters.calculate(top_namespace, loader) or [] stream = StoppableStream() result.execute_function(stream, top_namespace, params, loader) return stream.getvalue() if self.parameters is not None: result = result(*self.parameters.calculate(top_namespace, loader)) elif self.index is not None: array_index = self.index.calculate(top_namespace, loader) # If list make sure index is an integer if isinstance( result, list) and not isinstance( array_index, six.integer_types): raise ValueError( "expected integer for array index, got '%s'" % (array_index)) try: result = result[array_index] except: result = None return result class SubExpression(_Element): DOT = re.compile(r'\.(.*)', re.S) def parse(self): try: self.identity_match(self.DOT) self.expression = self.next_element(VariableExpression) except NoMatch: self.expression = self.next_element(ArrayIndex) self.subexpression = None try: self.subexpression = self.next_element(SubExpression) except NoMatch: pass def calculate(self, current_object, loader, global_namespace): args = [current_object, loader] if not isinstance(self.expression, ArrayIndex): return self.expression.calculate(*(args + [global_namespace])) index = self.expression.calculate(*args) result = current_object[index] if self.subexpression: result = self.subexpression.calculate(result, loader, global_namespace) return result class VariableExpression(_Element): subexpression = None def parse(self): self.part = self.next_element(NameOrCall) try: self.subexpression = self.next_element(SubExpression) except NoMatch: pass def calculate(self, namespace, loader, global_namespace=None): if global_namespace is None: global_namespace = namespace value = self.part.calculate(namespace, loader, global_namespace) if self.subexpression: value = self.subexpression.calculate( value, loader, global_namespace) return value class ParameterList(_Element): START = re.compile(r'\(\s*(.*)$', re.S) COMMA = re.compile(r'\s*,\s*(.*)$', re.S) END = re.compile(r'\s*\)(.*)$', re.S) values = _EmptyValues() def parse(self): self.identity_match(self.START) try: self.values = self.next_element(ValueList) except NoMatch: pass self.require_match(self.END, ')') def calculate(self, namespace, loader): return self.values.calculate(namespace, loader) class ArrayIndex(_Element): START = re.compile(r'\[[ \t]*(.*)$', re.S) END = re.compile(r'[ \t]*\](.*)$', re.S) index = 0 def parse(self): self.identity_match(self.START) self.index = self.require_next_element( (FormalReference, IntegerLiteral, InterpolatedStringLiteral, ParenthesizedExpression), 'integer index or object key') self.require_match(self.END, ']') def calculate(self, namespace, loader): result = self.index.calculate(namespace, loader) return result class AlternateValue(_Element): START = re.compile(r'\|(.*)$', re.S) def parse(self): self.identity_match(self.START) self.expression = self.require_next_element(Value, 'expression') self.calculate = self.expression.calculate class FormalReference(_Element): START = re.compile(r'\$(!?)(\{?)(.*)$', re.S) CLOSING_BRACE = re.compile(r'\}(.*)$', re.S) def parse(self): self.silent, braces = self.identity_match(self.START) try: self.expression = self.next_element(VariableExpression) self.calculate = self.expression.calculate except NoMatch: self.expression = None self.calculate = None self.alternate = None if braces: try: self.alternate = self.next_element(AlternateValue) except NoMatch: pass self.require_match(self.CLOSING_BRACE, '}') def evaluate_raw(self, stream, namespace, loader): value = None if self.expression is not None: value = self.expression.calculate(namespace, loader) if value is None: if self.alternate is not None: value = self.alternate.calculate(namespace, loader) elif self.silent and self.expression is not None: value = '' else: value = self.my_text() if is_string(value): stream.write(value) else: stream.write(six.text_type(value)) class Null: def evaluate(self, stream, namespace, loader): pass class Comment(_Element, Null): COMMENT = re.compile( '#(?:#.*?(?:\n|$)|\\*.*?\\*#(?:[ \t]*\n)?)(.*)$', re.M + re.S) def parse(self): self.identity_match(self.COMMENT) def evaluate(self, *args): pass class BinaryOperator(_Element): BINARY_OP = re.compile( r'\s*(>=|<=|<|==|!=|>|%|\|\||&&|or|and|\+|\-|\*|\/|\%|gt|lt|ne|eq|ge' r'|le|not)\s*(.*)$', re.S) OPERATORS = {'>': operator.gt, 'gt': operator.gt, '>=': operator.ge, 'ge': operator.ge, '<': operator.lt, 'lt': operator.lt, '<=': operator.le, 'le': operator.le, '==': operator.eq, 'eq': operator.eq, '!=': operator.ne, 'ne': operator.ne, '%': operator.mod, '||': lambda a, b: boolean_value(a) or boolean_value(b), '&&': lambda a, b: boolean_value(a) and boolean_value(b), 'or': lambda a, b: boolean_value(a) or boolean_value(b), 'and': lambda a, b: boolean_value(a) and boolean_value(b), '+': operator.add, '-': operator.sub, '*': operator.mul, '/': operator.floordiv} # Based on http://introcs.cs.princeton.edu/java/11precedence/ PRECEDENCE = {'>': 7, '<': 7, '==': 8, '>=': 7, '<=': 7, '!=': 9, '||': 13, '&&': 12, 'or': 13, 'and': 12, '+': 5, '-': 5, '*': 4, '/': 4, '%': 4, 'gt': 7, 'lt': 7, 'ne': 9, 'eq': 8, 'ge': 7, 'le': 7, } # In velocity, if + is applied to one string and one numeric # argument, will convert the number into a string. # As far as I can tell, this is undocumented. # Note that this applies only to add, not to other operators def parse(self): op_string, = self.identity_match(self.BINARY_OP) self.apply_to = self.OPERATORS[op_string] self.precedence = self.PRECEDENCE[op_string] # This assumes that the self operator is "to the left" # of the argument, and thus gets higher precedence if they're # both boolean operators. # That is, the way this is used (see Expression.calculate) # it should return false if the two ops have the same precedence # that is, it's strictly greater than, not greater than or equal to # to get proper left-to-right evaluation, it should skew towards false. def greater_precedence_than(self, other): return self.precedence < other.precedence class UnaryOperatorValue(_Element): UNARY_OP = re.compile(r'\s*(!|(?:not))\s*(.*)$', re.S) OPERATORS = {'!': operator.__not__, 'not': operator.__not__} def parse(self): op_string, = self.identity_match(self.UNARY_OP) self.value = self.next_element(Value) self.op = self.OPERATORS[op_string] def calculate(self, namespace, loader): return self.op(self.value.calculate(namespace, loader)) # Note: there appears to be no way to differentiate a variable or # value from an expression, other than context. class Expression(_Element): def parse(self): self.expression = [self.next_element(Value)] while (True): try: binary_operator = self.next_element(BinaryOperator) value = self.require_next_element(Value, 'value') self.expression.append(binary_operator) self.expression.append(value) except NoMatch: break def calculate(self, namespace, loader): if not self.expression or len(self.expression) == 0: return False # TODO: how does velocity deal with an empty condition expression? opstack = [] valuestack = [self.expression[0]] terms = self.expression[1:] # use top of opstack on top 2 values of valuestack def stack_calculate(ops, values, namespace, loader): value2 = values.pop() if isinstance(value2, Value): value2 = value2.calculate(namespace, loader) value1 = values.pop() if isinstance(value1, Value): value1 = value1.calculate(namespace, loader) result = ops.pop().apply_to(value1, value2) # TODO this doesn't short circuit -- does velocity? # also note they're eval'd out of order values.append(result) while terms: # next is a binary operator if not opstack or terms[0].greater_precedence_than(opstack[-1]): opstack.append(terms[0]) valuestack.append(terms[1]) terms = terms[2:] else: stack_calculate(opstack, valuestack, namespace, loader) # now clean out the stacks while opstack: stack_calculate(opstack, valuestack, namespace, loader) if len(valuestack) != 1: print ("evaluation of expression in Condition.calculate " "is messed up: final length of stack is not one") # TODO handle this officially result = valuestack[0] if isinstance(result, Value): result = result.calculate(namespace, loader) return result class ParenthesizedExpression(_Element): START = re.compile(r'\(\s*(.*)$', re.S) END = re.compile(r'\s*\)(.*)$', re.S) def parse(self): self.identity_match(self.START) expression = self.next_element(Expression) self.require_match(self.END, ')') self.calculate = expression.calculate class Condition(_Element): def parse(self): expression = self.next_element(ParenthesizedExpression) self.optional_match(WHITESPACE_TO_END_OF_LINE) self.calculate = expression.calculate # TODO do I need to do anything else here? class End(_Element): END = re.compile(r'#(?:end|\{end\})(.*)', re.I + re.S) def parse(self): self.identity_match(self.END) self.optional_match(WHITESPACE_TO_END_OF_LINE) class ElseBlock(_Element): START = re.compile(r'#(?:else|\{else\})(.*)$', re.S + re.I) def parse(self): self.identity_match(self.START) self.block = self.require_next_element(Block, 'block') self.evaluate = self.block.evaluate class ElseifBlock(_Element): START = re.compile(r'#elseif\b\s*(.*)$', re.S + re.I) def parse(self): self.identity_match(self.START) self.condition = self.require_next_element(Condition, 'condition') self.block = self.require_next_element(Block, 'block') self.calculate = self.condition.calculate self.evaluate = self.block.evaluate class IfDirective(_Element): START = re.compile(r'#if\b\s*(.*)$', re.S + re.I) else_block = Null() def parse(self): self.identity_match(self.START) self.condition = self.next_element(Condition) self.block = self.require_next_element(Block, "block") self.elseifs = [] while True: try: self.elseifs.append(self.next_element(ElseifBlock)) except NoMatch: break try: self.else_block = self.next_element(ElseBlock) except NoMatch: pass self.require_next_element(End, '#else, #elseif or #end') def evaluate_raw(self, stream, namespace, loader): if self.condition.calculate(namespace, loader): self.block.evaluate(stream, namespace, loader) else: for elseif in self.elseifs: if elseif.calculate(namespace, loader): elseif.evaluate(stream, namespace, loader) return self.else_block.evaluate(stream, namespace, loader) # This can't deal with assignments like # set($one.two().three = something) # yet class Assignment(_Element): START = re.compile( r'\s*\(\s*\$([a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)*)\s*=\s*(.*)$', re.S + re.I) END = re.compile(r'\s*\)(?:[ \t]*\r?\n)?(.*)$', re.S + re.M) def parse(self): var_name, = self.identity_match(self.START) self.terms = var_name.split('.') self.value = self.require_next_element(Expression, "expression") self.require_match(self.END, ')') def evaluate_raw(self, stream, namespace, loader): val = self.value.calculate(namespace, loader) if len(self.terms) == 1: namespace.set_inherited(self.terms[0], val) else: cur = namespace for term in self.terms[:-1]: cur = cur[term] cur[self.terms[-1]] = val class EvaluateDirective(_Element): START = re.compile(r'#evaluate\b(.*)') OPEN_PAREN = re.compile(r'[ \t]*\(\s*(.*)$', re.S) CLOSE_PAREN = re.compile(r'[ \t]*\)(.*)$', re.S) def parse(self): self.identity_match(self.START) self.require_match(self.OPEN_PAREN, '(') self.value = self.require_next_element(Value, 'value') self.require_match(self.CLOSE_PAREN, ')') def evaluate_raw(self, stream, namespace, loader): val = self.value.calculate(namespace, loader) Template(val, "#evaluate").merge_to(namespace, stream, loader) class _FunctionDefinition(_Element): # Must be overridden to provide START and NAME patterns OPEN_PAREN = re.compile(r'[ \t]*\(\s*(.*)$', re.S) CLOSE_PAREN = re.compile(r'[ \t]*\)(.*)$', re.S) ARG_NAME = re.compile(r'[, \t]+\$([a-z][a-z_0-9]*)(.*)$', re.S + re.I) RESERVED_NAMES = [] def parse(self): self.identity_match(self.START) self.require_match(self.OPEN_PAREN, '(') self.function_name, = self.require_match(self.NAME, 'function name') if self.function_name.lower() in self.RESERVED_NAMES: raise self.syntax_error('non-reserved name') self.arg_names = [] while True: m = self.next_match(self.ARG_NAME) if not m: break self.arg_names.append(m[0]) self.require_match(self.CLOSE_PAREN, ') or arg name') self.optional_match(WHITESPACE_TO_END_OF_LINE) self.block = self.require_next_element(Block, 'block') self.require_next_element(End, 'block') def execute_function(self, stream, namespace, arg_values, loader): if len(arg_values) != len(self.arg_names): raise Exception( "function %s expected %d arguments, got %d" % (self.function_name, len(self.arg_names), len(arg_values))) local_namespace = LocalNamespace(namespace) local_namespace.update(zip(self.arg_names, arg_values)) self.block.evaluate(stream, local_namespace, loader) class MacroDefinition(_FunctionDefinition): START = re.compile(r'#macro\b(.*)', re.S + re.I) NAME = re.compile(r'\s*([a-z][a-z_0-9]*)\b(.*)', re.S + re.I) RESERVED_NAMES = ( 'if', 'else', 'elseif', 'set', 'macro', 'foreach', 'parse', 'include', 'stop', 'end', 'define') def evaluate_raw(self, stream, namespace, loader): global_ns = namespace.top() macro_key = '#' + self.function_name.lower() if macro_key in global_ns: raise Exception("cannot redefine macro {0}".format(macro_key)) global_ns[macro_key] = self class MacroCall(_Element): START = re.compile(r'#([a-z][a-z_0-9]*)\b(.*)', re.S + re.I) OPEN_PAREN = re.compile(r'[ \t]*\(\s*(.*)$', re.S) CLOSE_PAREN = re.compile(r'[ \t]*\)(.*)$', re.S) SPACE_OR_COMMA = re.compile(r'\s*(?:,|\s)\s*(.*)$', re.S) def parse(self): macro_name, = self.identity_match(self.START) self.macro_name = macro_name.lower() self.args = [] if self.macro_name in MacroDefinition.RESERVED_NAMES: raise NoMatch() if not self.optional_match(self.OPEN_PAREN): raise NoMatch() # Typically a hex colour literal while True: try: self.args.append(self.next_element(Value)) except NoMatch: break if not self.optional_match(self.SPACE_OR_COMMA): break self.require_match(self.CLOSE_PAREN, 'argument value or )') def evaluate_raw(self, stream, namespace, loader): try: macro = namespace['#' + self.macro_name] except KeyError: raise Exception('no such macro: ' + self.macro_name) arg_values = [arg.calculate(namespace, loader) for arg in self.args] macro.execute_function(stream, namespace, arg_values, loader) class DefineDefinition(_FunctionDefinition): START = re.compile(r'#define\b(.*)', re.S + re.I) NAME = re.compile(r'\s*\$([a-z][a-z_0-9]*)\b(.*)', re.S + re.I) def evaluate_raw(self, stream, namespace, loader): namespace[self.function_name] = self class IncludeDirective(_Element): START = re.compile(r'#include\b(.*)', re.S + re.I) OPEN_PAREN = re.compile(r'[ \t]*\(\s*(.*)$', re.S) CLOSE_PAREN = re.compile(r'[ \t]*\)(.*)$', re.S) def parse(self): self.identity_match(self.START) self.require_match(self.OPEN_PAREN, '(') self.name = self.require_next_element( (StringLiteral, InterpolatedStringLiteral, FormalReference), 'template name') self.require_match(self.CLOSE_PAREN, ')') def evaluate_raw(self, stream, namespace, loader): stream.write(loader.load_text(self.name.calculate(namespace, loader))) class ParseDirective(_Element): START = re.compile(r'#parse\b(.*)', re.S + re.I) OPEN_PAREN = re.compile(r'[ \t]*\(\s*(.*)$', re.S) CLOSE_PAREN = re.compile(r'[ \t]*\)(.*)$', re.S) def parse(self): self.identity_match(self.START) self.require_match(self.OPEN_PAREN, '(') self.name = self.require_next_element( (StringLiteral, InterpolatedStringLiteral, FormalReference), 'template name') self.require_match(self.CLOSE_PAREN, ')') def evaluate_raw(self, stream, namespace, loader): template = loader.load_template(self.name.calculate(namespace, loader)) # TODO: local namespace? template.merge_to(namespace, stream, loader=loader) class StopDirective(_Element): STOP = re.compile(r'#stop\b(.*)', re.S + re.I) def parse(self): self.identity_match(self.STOP) def evaluate_raw(self, stream, namespace, loader): if hasattr(stream, 'stop'): stream.stop = True # Represents a SINGLE user-defined directive class UserDefinedDirective(_Element): DIRECTIVES = [] def parse(self): self.directive = self.next_element(self.DIRECTIVES) def evaluate_raw(self, stream, namespace, loader): self.directive.evaluate(stream, namespace, loader) class SetDirective(_Element): START = re.compile(r'#set\b(.*)', re.S + re.I) def parse(self): self.identity_match(self.START) self.assignment = self.require_next_element(Assignment, 'assignment') def evaluate_raw(self, stream, namespace, loader): self.assignment.evaluate(stream, namespace, loader) class ForeachDirective(_Element): START = re.compile(r'#foreach\b(.*)$', re.S + re.I) OPEN_PAREN = re.compile(r'[ \t]*\(\s*(.*)$', re.S) IN = re.compile(r'[ \t]+in[ \t]+(.*)$', re.S) LOOP_VAR_NAME = re.compile(r'\$([a-z_][a-z0-9_]*)(.*)$', re.S + re.I) CLOSE_PAREN = re.compile(r'[ \t]*\)(.*)$', re.S) def parse(self): # Could be cleaner b/c syntax error if no '(' self.identity_match(self.START) self.require_match(self.OPEN_PAREN, '(') self.loop_var_name, = self.require_match( self.LOOP_VAR_NAME, 'loop var name') self.require_match(self.IN, 'in') self.value = self.next_element(Value) self.require_match(self.CLOSE_PAREN, ')') self.block = self.next_element(Block) self.require_next_element(End, '#end') def evaluate_raw(self, stream, namespace, loader): iterable = self.value.calculate(namespace, loader) counter = 1 try: if iterable is None: return if hasattr(iterable, 'keys'): iterable = iterable.keys() try: iter(iterable) except TypeError: raise ValueError( "value for $%s is not iterable in #foreach: %s" % (self.loop_var_name, iterable)) length = len(iterable) for item in iterable: localns = LocalNamespace(namespace) localns['velocityCount'] = counter localns['velocityHasNext'] = counter < length localns['foreach'] = { "count": counter, "index": counter - 1, "hasNext": counter < length, "first": counter == 1, "last": counter == length} localns[self.loop_var_name] = item self.block.evaluate(stream, localns, loader) counter += 1 except TypeError: raise class TemplateBody(_Element): def parse(self): self.block = self.next_element(Block) if self.next_text(): raise self.syntax_error('block element') def evaluate_raw(self, stream, namespace, loader): # Use the same namespace as the parent template, if sub-template if not isinstance(namespace, LocalNamespace): namespace = LocalNamespace(namespace) self.block.evaluate(stream, namespace, loader) class Block(_Element): def parse(self): self.children = [] while True: try: self.children.append( self.next_element( (Text, FormalReference, Comment, IfDirective, SetDirective, ForeachDirective, IncludeDirective, ParseDirective, MacroDefinition, DefineDefinition, StopDirective, UserDefinedDirective, EvaluateDirective, MacroCall, FallthroughHashText))) except NoMatch: break def evaluate_raw(self, stream, namespace, loader): for child in self.children: child.evaluate(stream, namespace, loader) airspeed-0.6.0/airspeed/api.py000066400000000000000000000020171453463365000162410ustar00rootroot00000000000000# encoding: utf-8 import os try: from airspeed import CachingFileLoader except ImportError: raise ImportError('You must install the airspeed package.') from cachetools import LRUCache __all__ = ['Airspeed'] class Airspeed(object): """The airspeed templating engine. Sample usage: >>> from cti.core import Engines >>> render = Engines() >>> render('airspeed:../tests/test.txt', dict()) ('text/plain', 'Bingo!') """ def __init__(self, cache=10, **kw): self.loaders = LRUCache(maxsize=cache) def __call__(self, data, template, mime_type="text/plain", **options): basepath = os.path.dirname(template) if basepath not in self.loaders: loader = CachingFileLoader(basepath) self.loaders[basepath] = loader else: loader = self.loaders[basepath] template = self.loaders[basepath].load_template( os.path.basename(template)) return mime_type, template.merge(data, loader=loader) airspeed-0.6.0/flake.nix000066400000000000000000000010411453463365000151200ustar00rootroot00000000000000{ description = "Airspeed"; inputs = { nixpkgs.url = "nixpkgs/nixpkgs-unstable"; flake-utils.url = "github:numtide/flake-utils"; }; outputs = { self, nixpkgs, flake-utils }@inputs: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; in { devShell = pkgs.mkShell { buildInputs = with pkgs; [ poetry poetry2nix (python37.withPackages (p: [ p.setuptools p.six ])) python37Packages.flake8 python310Packages.pylint ]; }; } ); } airspeed-0.6.0/setup.cfg000066400000000000000000000002231453463365000151400ustar00rootroot00000000000000[egg_info] [nosetests] with-coverage=1 cover-package=airspeed cover-inclusive=1 traverse-namespace=1 detailed-errors=1 where=tests with-doctest=1 airspeed-0.6.0/setup.py000077500000000000000000000027561453463365000150510ustar00rootroot00000000000000#!/usr/bin/env python2.7 # encoding: utf-8 import sys from setuptools import setup, find_packages if sys.version_info <= (2, 6): raise SystemExit("Python 2.6 or later is required.") setup( name="airspeed", version="0.6.0", description=("Airspeed is a powerful and easy-to-use templating engine" " for Python that aims for a high level of compatibility " "with the popular Velocity library for Java."), author="Steve Purcell and Chris Tarttelin", author_email="steve@pythonconsulting.com, chris@pythonconsulting.com", url="https://github.com/purcell/airspeed/", download_url="http://pypi.python.org/pypi/airspeed/", license="BSD", keywords='web.templating', install_requires=[ 'six', 'cachetools', ], test_suite='tests', tests_require=[], classifiers=[ "Environment :: Web Environment", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Software Development :: Libraries :: Python Modules"], packages=find_packages( exclude=[ 'examples', 'tests', 'tests.*', 'docs']), include_package_data=False, zip_safe=True, entry_points={ 'web.templating': [ 'airspeed = airspeed.api:Airspeed', ]}) airspeed-0.6.0/tests/000077500000000000000000000000001453463365000144645ustar00rootroot00000000000000airspeed-0.6.0/tests/__init__.py000066400000000000000000001554321453463365000166070ustar00rootroot00000000000000# -*- coding: utf-8 -*- import re import sys if sys.version_info >= (3, 0) and sys.version_info <= (3, 3): import imp elif sys.version_info >= (3, 4): import importlib from unittest import TestCase # Make these tests runnable without needing 'nose' installed try: import airspeed except ImportError: import sys import os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import airspeed import six class TemplateTestCase(TestCase): def assertRaisesExecutionError(self, exctype, func, *args, **kwargs): try: func(*args, **kwargs) self.fail("Expected TemplateExecutionError wrapping %s" % (exctype,)) except airspeed.TemplateExecutionError as e: self.assertEqual(exctype, type(e.__cause__)) def test_parser_returns_input_when_there_is_nothing_to_substitute(self): template = airspeed.Template("") self.assertEqual("", template.merge({})) def test_parser_substitutes_string_added_to_the_context(self): template = airspeed.Template("Hello $name") self.assertEqual("Hello Chris", template.merge({"name": "Chris"})) def test_dollar_left_untouched(self): template = airspeed.Template("Hello $ ") self.assertEqual("Hello $ ", template.merge({})) template = airspeed.Template("Hello $") self.assertEqual("Hello $", template.merge({})) def test_unmatched_name_does_not_get_substituted(self): template = airspeed.Template("Hello $name") self.assertEqual("Hello $name", template.merge({})) def test_silent_substitution_for_unmatched_values(self): template = airspeed.Template("Hello $!name") self.assertEqual("Hello world", template.merge({"name": "world"})) self.assertEqual("Hello ", template.merge({})) def test_formal_reference_in_an_if_condition(self): template = airspeed.Template("#if(${a.b.c})yes!#end") # reference in an if statement used to be a problem self.assertEqual("yes!", template.merge({'a': {'b': {'c': 'd'}}})) self.assertEqual("", template.merge({})) def test_silent_formal_reference_in_an_if_condition(self): # the silent modifier shouldn't make a difference here template = airspeed.Template("#if($!{a.b.c})yes!#end") self.assertEqual("yes!", template.merge({'a': {'b': {'c': 'd'}}})) self.assertEqual("", template.merge({})) # with or without curly braces template = airspeed.Template("#if($!a.b.c)yes!#end") self.assertEqual("yes!", template.merge({'a': {'b': {'c': 'd'}}})) self.assertEqual("", template.merge({})) def test_reference_function_calls_in_if_conditions(self): template = airspeed.Template("#if(${a.b.c('cheese')})yes!#end") self.assertEqual( "yes!", template.merge({'a': {'b': {'c': lambda x: "hello %s" % x}}})) self.assertEqual( "", template.merge({'a': {'b': {'c': lambda x: None}}})) self.assertEqual("", template.merge({})) def test_silent_reference_function_calls_in_if_conditions(self): # again, this shouldn't make any difference template = airspeed.Template("#if($!{a.b.c('cheese')})yes!#end") self.assertEqual( "yes!", template.merge({'a': {'b': {'c': lambda x: "hello %s" % x}}})) self.assertEqual( "", template.merge({'a': {'b': {'c': lambda x: None}}})) self.assertEqual("", template.merge({})) # with or without braces template = airspeed.Template("#if($!a.b.c('cheese'))yes!#end") self.assertEqual( "yes!", template.merge({'a': {'b': {'c': lambda x: "hello %s" % x}}})) self.assertEqual( "", template.merge({'a': {'b': {'c': lambda x: None}}})) self.assertEqual("", template.merge({})) def test_embed_substitution_value_in_braces_gets_handled(self): template = airspeed.Template("Hello ${name}.") self.assertEqual("Hello World.", template.merge({"name": "World"})) def test_unmatched_braces_raises_exception(self): template = airspeed.Template("Hello ${name.") self.assertRaises(airspeed.TemplateSyntaxError, template.merge, {}) def test_unmatched_trailing_brace_preserved(self): template = airspeed.Template("Hello $name}.") self.assertEqual("Hello World}.", template.merge({"name": "World"})) def test_formal_reference_with_alternate_literal_value(self): template = airspeed.Template("${a|'hello'}") self.assertEqual("foo", template.merge({'a': "foo"})) self.assertEqual("hello", template.merge({})) def test_formal_reference_with_alternate_expression_value(self): template = airspeed.Template("${a|$b}") self.assertEqual("hello", template.merge({'b': "hello"})) def test_can_return_value_from_an_attribute_of_a_context_object(self): template = airspeed.Template("Hello $name.first_name") class MyObj: pass o = MyObj() o.first_name = 'Chris' self.assertEqual("Hello Chris", template.merge({"name": o})) def test_can_return_value_from_a_method_of_a_context_object(self): template = airspeed.Template("Hello $name.first_name()") class MyObj: def first_name(self): return "Chris" self.assertEqual("Hello Chris", template.merge({"name": MyObj()})) def test_when_if_statement_resolves_to_true_the_content_is_returned(self): template = airspeed.Template( "Hello #if ($name)your name is ${name}#end Good to see you") self.assertEqual( "Hello your name is Steve Good to see you", template.merge({"name": "Steve"})) def test_when_if_statement_resolves_to_false_the_content_is_skipped(self): template = airspeed.Template( "Hello #if ($show_greeting)your name is ${name}#end Good to see you") self.assertEqual("Hello Good to see you", template.merge( {"name": "Steve", "show_greeting": False})) def test_when_if_statement_is_nested_inside_a_successful_enclosing_if_it_gets_evaluated( self): template = airspeed.Template( "Hello #if ($show_greeting)your name is ${name}.#if ($is_birthday) Happy Birthday.#end#end Good to see you") namespace = {"name": "Steve", "show_greeting": False} self.assertEqual("Hello Good to see you", template.merge(namespace)) namespace["show_greeting"] = True self.assertEqual( "Hello your name is Steve. Good to see you", template.merge(namespace)) namespace["is_birthday"] = True self.assertEqual( "Hello your name is Steve. Happy Birthday. Good to see you", template.merge(namespace)) def test_if_statement_considers_None_to_be_false(self): template = airspeed.Template("#if ($some_value)hide me#end") self.assertEqual('', template.merge({})) self.assertEqual('', template.merge({'some_value': None})) def test_if_statement_honours_custom_truth_value_of_objects(self): class BooleanValue(object): def __init__(self, value): self.value = value def __bool__(self): return self.value def __nonzero__(self): return self.__bool__() template = airspeed.Template("#if ($v)yes#end") self.assertEqual('', template.merge({'v': BooleanValue(False)})) self.assertEqual('yes', template.merge({'v': BooleanValue(True)})) def test_understands_boolean_literal_true(self): template = airspeed.Template("#set ($v = true)$v") self.assertEqual('True', template.merge({})) def test_understands_boolean_literal_false(self): template = airspeed.Template("#set ($v = false)$v") self.assertEqual('False', template.merge({})) def test_new_lines_in_templates_are_permitted(self): template = airspeed.Template( "hello #if ($show_greeting)${name}.\n#if($is_birthday)Happy Birthday\n#end.\n#endOff out later?") namespace = { "name": "Steve", "show_greeting": True, "is_birthday": True} self.assertEqual( "hello Steve.\nHappy Birthday\n.\nOff out later?", template.merge(namespace)) def test_foreach_with_plain_content_loops_correctly(self): template = airspeed.Template( "#foreach ($name in $names)Hello you. #end") self.assertEqual( "Hello you. Hello you. ", template.merge({"names": ["Chris", "Steve"]})) def test_foreach_skipped_when_nested_in_a_failing_if(self): template = airspeed.Template( "#if ($false_value)#foreach ($name in $names)Hello you. #end#end") self.assertEqual( "", template.merge({"false_value": False, "names": ["Chris", "Steve"]})) def test_foreach_with_expression_content_loops_correctly(self): template = airspeed.Template( "#foreach ($name in $names)Hello $you. #end") self.assertEqual("Hello You. Hello You. ", template.merge( {"you": "You", "names": ["Chris", "Steve"]})) def test_foreach_makes_loop_variable_accessible(self): template = airspeed.Template( "#foreach ($name in $names)Hello $name. #end") self.assertEqual( "Hello Chris. Hello Steve. ", template.merge({"names": ["Chris", "Steve"]})) def test_loop_variable_not_accessible_after_loop(self): template = airspeed.Template( "#foreach ($name in $names)Hello $name. #end$name") self.assertEqual("Hello Chris. Hello Steve. $name", template.merge( {"names": ["Chris", "Steve"]})) def test_loop_variables_do_not_clash_in_nested_loops(self): template = airspeed.Template( "#foreach ($word in $greetings)$word to#foreach ($word in $names) $word#end. #end") namespace = { "greetings": [ "Hello", "Goodbye"], "names": [ "Chris", "Steve"]} self.assertEqual( "Hello to Chris Steve. Goodbye to Chris Steve. ", template.merge(namespace)) def test_loop_counter_variable_available_in_loops(self): template = airspeed.Template( "#foreach ($word in $greetings)$velocityCount,#end") namespace = {"greetings": ["Hello", "Goodbye"]} self.assertEqual("1,2,", template.merge(namespace)) def test_loop_counter_variable_available_in_loops_new(self): template = airspeed.Template( "#foreach ($word in $greetings)$foreach.count,#end") namespace = {"greetings": ["Hello", "Goodbye"]} self.assertEqual("1,2,", template.merge(namespace)) def test_loop_index_variable_available_in_loops_new(self): template = airspeed.Template( "#foreach ($word in $greetings)$foreach.index,#end") namespace = {"greetings": ["Hello", "Goodbye"]} self.assertEqual("0,1,", template.merge(namespace)) def test_loop_counter_variables_do_not_clash_in_nested_loops(self): template = airspeed.Template( "#foreach ($word in $greetings)Outer $velocityCount#foreach ($word in $names), inner $velocityCount#end. #end") namespace = { "greetings": [ "Hello", "Goodbye"], "names": [ "Chris", "Steve"]} self.assertEqual( "Outer 1, inner 1, inner 2. Outer 2, inner 1, inner 2. ", template.merge(namespace)) def test_loop_counter_variables_do_not_clash_in_nested_loops_new(self): template = airspeed.Template( "#foreach ($word in $greetings)Outer $foreach.count#foreach ($word in $names), inner $foreach.count#end. #end") namespace = { "greetings": [ "Hello", "Goodbye"], "names": [ "Chris", "Steve"]} self.assertEqual( "Outer 1, inner 1, inner 2. Outer 2, inner 1, inner 2. ", template.merge(namespace)) def test_loop_index_variables_do_not_clash_in_nested_loops_new(self): template = airspeed.Template( "#foreach ($word in $greetings)Outer $foreach.index#foreach ($word in $names), inner $foreach.index#end. #end") namespace = { "greetings": [ "Hello", "Goodbye"], "names": [ "Chris", "Steve"]} self.assertEqual( "Outer 0, inner 0, inner 1. Outer 1, inner 0, inner 1. ", template.merge(namespace)) def test_has_next(self): template = airspeed.Template( "#foreach ($i in [1, 2, 3])$i. #if ($velocityHasNext)yes#end, #end") self.assertEqual("1. yes, 2. yes, 3. , ", template.merge({})) def test_has_next_new(self): template = airspeed.Template( "#foreach ($i in [1, 2, 3])$i. #if ($foreach.hasNext)yes#end, #end") self.assertEqual("1. yes, 2. yes, 3. , ", template.merge({})) def test_first(self): template = airspeed.Template( "#foreach ($i in [1, 2, 3])$i. #if ($foreach.first)yes#end, #end") self.assertEqual("1. yes, 2. , 3. , ", template.merge({})) def test_last(self): template = airspeed.Template( "#foreach ($i in [1, 2, 3])$i. #if ($foreach.last)yes#end, #end") self.assertEqual("1. , 2. , 3. yes, ", template.merge({})) def test_can_use_an_integer_variable_defined_in_template(self): template = airspeed.Template("#set ($value = 10)$value") self.assertEqual("10", template.merge({})) def test_passed_in_namespace_not_modified_by_set(self): template = airspeed.Template("#set ($value = 10)$value") namespace = {} template.merge(namespace) self.assertEqual({}, namespace) def test_can_use_a_string_variable_defined_in_template(self): template = airspeed.Template('#set ($value = "Steve")$value') self.assertEqual("Steve", template.merge({})) def test_can_use_a_single_quoted_string_variable_defined_in_template(self): template = airspeed.Template("#set ($value = 'Steve')$value") self.assertEqual("Steve", template.merge({})) def test_single_line_comments_skipped(self): template = airspeed.Template( '## comment\nStuff\nMore stuff## more comments $blah') self.assertEqual("Stuff\nMore stuff", template.merge({})) def test_multi_line_comments_skipped(self): template = airspeed.Template( 'Stuff#*\n more comments *#\n and more stuff') self.assertEqual("Stuff and more stuff", template.merge({})) def test_merge_to_stream(self): template = airspeed.Template('Hello $name!') output = six.StringIO() template.merge_to({"name": "Chris"}, output) self.assertEqual('Hello Chris!', output.getvalue()) def test_string_literal_can_contain_embedded_escaped_quotes(self): template = airspeed.Template('#set ($name = "\\"batman\\"")$name') self.assertEqual('"batman"', template.merge({})) def test_string_literal_can_contain_embedded_escaped_newlines(self): template = airspeed.Template( '#set ($name = "\\\\batman\\nand robin")$name') self.assertEqual('\\batman\nand robin', template.merge({})) def test_else_block_evaluated_when_if_expression_false(self): template = airspeed.Template('#if ($value) true #else false #end') self.assertEqual(" false ", template.merge({})) def test_curly_else(self): template = airspeed.Template('#if($value)true#{else}false#end') self.assertEqual("false", template.merge({})) def test_curly_end(self): template = airspeed.Template('#if($value)true#{end}monkey') self.assertEqual("monkey", template.merge({})) def test_too_many_end_clauses_trigger_error(self): template = airspeed.Template('#if (1)true!#end #end ') self.assertRaises(airspeed.TemplateSyntaxError, template.merge, {}) def test_can_call_function_with_one_parameter(self): def squared(number): return number * number template = airspeed.Template('$squared(8)') self.assertEqual("64", template.merge(locals())) some_var = 6 template = airspeed.Template('$squared($some_var)') self.assertEqual("36", template.merge(locals())) template = airspeed.Template('$squared($squared($some_var))') self.assertEqual("1296", template.merge(locals())) def test_can_call_function_with_two_parameters(self): def multiply(number1, number2): return number1 * number2 template = airspeed.Template('$multiply(2, 4)') self.assertEqual("8", template.merge(locals())) template = airspeed.Template('$multiply( 2 , 4 )') self.assertEqual("8", template.merge(locals())) value1, value2 = 4, 12 template = airspeed.Template('$multiply($value1,$value2)') self.assertEqual("48", template.merge(locals())) def test_extract_array_index_from_function_result(self): def get_array(): return ['p1', ['p2', 'p3']] template = airspeed.Template('$get_array()[0]') self.assertEqual("p1", template.merge(locals())) template = airspeed.Template('$get_array()[1][1]') self.assertEqual("p3", template.merge(locals())) def test_velocity_style_escaping(self): # example from Velocity docs template = airspeed.Template(r''' #set( $email = "foo" ) $email \$email \\$email \ \\ \# \$ \#end \# end \#set( $email = "foo" ) ''') self.assertEqual(r''' foo $email \\foo \ \\ \# \$ #end \# end #set( foo = "foo" ) ''', template.merge({})) # def test_velocity_style_escaping_when_var_unset(self): # example from Velocity docs # template = airspeed.Template('''\ #$email #\$email #\\$email #\\\$email''') # self.assertEquals('''\ #$email #\$email #\\$email #\\\$email''', template.merge({})) def test_true_elseif_evaluated_when_if_is_false(self): template = airspeed.Template( '#if ($value1) one #elseif ($value2) two #end') value1, value2 = False, True self.assertEqual(' two ', template.merge(locals())) def test_false_elseif_skipped_when_if_is_true(self): template = airspeed.Template( '#if ($value1) one #elseif ($value2) two #end') value1, value2 = True, False self.assertEqual(' one ', template.merge(locals())) def test_first_true_elseif_evaluated_when_if_is_false(self): template = airspeed.Template( '#if ($value1) one #elseif ($value2) two #elseif($value3) three #end') value1, value2, value3 = False, True, True self.assertEqual(' two ', template.merge(locals())) def test_illegal_to_have_elseif_after_else(self): template = airspeed.Template( '#if ($value1) one #else two #elseif($value3) three #end') self.assertRaises(airspeed.TemplateSyntaxError, template.merge, {}) def test_else_evaluated_when_if_and_elseif_are_false(self): template = airspeed.Template( '#if ($value1) one #elseif ($value2) two #else three #end') value1, value2 = False, False self.assertEqual(' three ', template.merge(locals())) def test_syntax_error_contains_line_and_column_pos(self): try: airspeed.Template('#if ( $hello )\n\n#elseif blah').merge({}) except airspeed.TemplateSyntaxError as e: self.assertEqual((3, 9), (e.line, e.column)) else: self.fail('expected error') try: airspeed.Template('#else blah').merge({}) except airspeed.TemplateSyntaxError as e: self.assertEqual((1, 1), (e.line, e.column)) else: self.fail('expected error') def test_get_position_strings_in_syntax_error(self): try: airspeed.Template('#else whatever').merge({}) except airspeed.TemplateSyntaxError as e: self.assertEqual(['#else whatever', '^'], e.get_position_strings()) else: self.fail('expected error') def test_get_position_strings_in_syntax_error_when_newline_after_error( self): try: airspeed.Template('#else whatever\n').merge({}) except airspeed.TemplateSyntaxError as e: self.assertEqual(['#else whatever', '^'], e.get_position_strings()) else: self.fail('expected error') def test_get_position_strings_in_syntax_error_when_newline_before_error( self): try: airspeed.Template('foobar\n #else whatever\n').merge({}) except airspeed.TemplateSyntaxError as e: self.assertEqual([' #else whatever', ' ^'], e.get_position_strings()) else: self.fail('expected error') def test_compare_greater_than_operator(self): for operator in ['>', 'gt']: template = airspeed.Template('#if ( $value %s 1 )yes#end' % operator) self.assertEqual('', template.merge({'value': 0})) self.assertEqual('', template.merge({'value': 1})) self.assertEqual('yes', template.merge({'value': 2})) def test_compare_greater_than_or_equal_operator(self): for operator in ['>=', 'ge']: template = airspeed.Template('#if ( $value %s 1 )yes#end' % operator) self.assertEqual('', template.merge({'value': 0})) self.assertEqual('yes', template.merge({'value': 1})) self.assertEqual('yes', template.merge({'value': 2})) def test_compare_less_than_operator(self): for operator in ['<', 'lt']: template = airspeed.Template('#if ( $value %s 1 )yes#end' % operator) self.assertEqual('yes', template.merge({'value': 0})) self.assertEqual('', template.merge({'value': 1})) self.assertEqual('', template.merge({'value': 2})) def test_compare_less_than_or_equal_operator(self): for operator in ['<=', 'le']: template = airspeed.Template('#if ( $value %s 1 )yes#end' % operator) self.assertEqual('yes', template.merge({'value': 0})) self.assertEqual('yes', template.merge({'value': 1})) self.assertEqual('', template.merge({'value': 2})) def test_compare_equality_operator(self): for operator in ['==', 'eq']: template = airspeed.Template('#if ( $value %s 1 )yes#end' % operator) self.assertEqual('', template.merge({'value': 0})) self.assertEqual('yes', template.merge({'value': 1})) self.assertEqual('', template.merge({'value': 2})) def test_or_operator(self): template = airspeed.Template('#if ( $value1 || $value2 )yes#end') self.assertEqual( '', template.merge({'value1': False, 'value2': False})) self.assertEqual( 'yes', template.merge({'value1': True, 'value2': False})) self.assertEqual( 'yes', template.merge({'value1': False, 'value2': True})) def test_or_operator_otherform(self): template = airspeed.Template('#if ( $value1 or $value2 )yes#end') self.assertEqual( '', template.merge({'value1': False, 'value2': False})) self.assertEqual( 'yes', template.merge({'value1': True, 'value2': False})) self.assertEqual( 'yes', template.merge({'value1': False, 'value2': True})) def test_or_operator_considers_not_None_values_true(self): class SomeClass: pass template = airspeed.Template('#if ( $value1 || $value2 )yes#end') self.assertEqual('', template.merge({'value1': None, 'value2': None})) self.assertEqual( 'yes', template.merge({'value1': SomeClass(), 'value2': False})) self.assertEqual( 'yes', template.merge({'value1': False, 'value2': SomeClass()})) def test_and_operator(self): template = airspeed.Template('#if ( $value1 && $value2 )yes#end') self.assertEqual( '', template.merge({'value1': False, 'value2': False})) self.assertEqual( '', template.merge({'value1': True, 'value2': False})) self.assertEqual( '', template.merge({'value1': False, 'value2': True})) self.assertEqual( 'yes', template.merge({'value1': True, 'value2': True})) def test_and_operator_otherform(self): template = airspeed.Template('#if ( $value1 and $value2 )yes#end') self.assertEqual( '', template.merge({'value1': False, 'value2': False})) self.assertEqual( '', template.merge({'value1': True, 'value2': False})) self.assertEqual( '', template.merge({'value1': False, 'value2': True})) self.assertEqual( 'yes', template.merge({'value1': True, 'value2': True})) def test_and_operator_considers_not_None_values_true(self): class SomeClass: pass template = airspeed.Template('#if ( $value1 && $value2 )yes#end') self.assertEqual('', template.merge({'value1': None, 'value2': None})) self.assertEqual( 'yes', template.merge({'value1': SomeClass(), 'value2': True})) self.assertEqual( 'yes', template.merge({'value1': True, 'value2': SomeClass()})) def test_parenthesised_value(self): template = airspeed.Template( '#if ( ($value1 == 1) && ($value2 == 2) )yes#end') self.assertEqual('', template.merge({'value1': 0, 'value2': 1})) self.assertEqual('', template.merge({'value1': 1, 'value2': 1})) self.assertEqual('', template.merge({'value1': 0, 'value2': 2})) self.assertEqual('yes', template.merge({'value1': 1, 'value2': 2})) def test_multiterm_expression(self): template = airspeed.Template( '#if ( $value1 == 1 && $value2 == 2 )yes#end') self.assertEqual('', template.merge({'value1': 0, 'value2': 1})) self.assertEqual('', template.merge({'value1': 1, 'value2': 1})) self.assertEqual('', template.merge({'value1': 0, 'value2': 2})) self.assertEqual('yes', template.merge({'value1': 1, 'value2': 2})) def test_compound_condition(self): template = airspeed.Template('#if ( ($value) )yes#end') self.assertEqual('', template.merge({'value': False})) self.assertEqual('yes', template.merge({'value': True})) def test_logical_negation_operator(self): template = airspeed.Template('#if ( !$value )yes#end') self.assertEqual('yes', template.merge({'value': False})) self.assertEqual('', template.merge({'value': True})) def test_logical_alt_negation_operator(self): template = airspeed.Template('#if ( not $value )yes#end') self.assertEqual('yes', template.merge({'value': False})) self.assertEqual('', template.merge({'value': True})) def test_logical_negation_operator_yields_true_for_None(self): template = airspeed.Template('#if ( !$value )yes#end') self.assertEqual('yes', template.merge({'value': None})) def test_logical_negation_operator_honours_custom_truth_values(self): class BooleanValue(object): def __init__(self, value): self.value = value def __bool__(self): return self.value def __nonzero__(self): return self.__bool__() template = airspeed.Template('#if ( !$v)yes#end') self.assertEqual('yes', template.merge({'v': BooleanValue(False)})) self.assertEqual('', template.merge({'v': BooleanValue(True)})) def test_compound_binary_and_unary_operators(self): template = airspeed.Template('#if ( !$value1 && !$value2 )yes#end') self.assertEqual( '', template.merge({'value1': False, 'value2': True})) self.assertEqual( '', template.merge({'value1': True, 'value2': False})) self.assertEqual('', template.merge({'value1': True, 'value2': True})) self.assertEqual( 'yes', template.merge({'value1': False, 'value2': False})) def test_cannot_define_macro_to_override_reserved_statements(self): for reserved in ( 'if', 'else', 'elseif', 'set', 'macro', 'foreach', 'parse', 'include', 'stop', 'end', 'define'): template = airspeed.Template( '#macro ( %s $value) $value #end' % reserved) self.assertRaises(airspeed.TemplateSyntaxError, template.merge, {}) def test_cannot_call_undefined_macro(self): template = airspeed.Template('#undefined()') self.assertRaises(airspeed.TemplateExecutionError, template.merge, {}) def test_define_and_use_macro_with_no_parameters(self): template = airspeed.Template('#macro ( hello)hi#end#hello ()#hello()') self.assertEqual('hihi', template.merge({'text': 'hello'})) def test_define_and_use_macro_with_one_parameter(self): template = airspeed.Template( '#macro ( bold $value)$value#end#bold ($text)') self.assertEqual( 'hello', template.merge({'text': 'hello'})) def test_define_and_use_macro_with_two_parameters_no_comma(self): template = airspeed.Template( '#macro ( bold $value $other)$value$other#end#bold ($text $monkey)') self.assertEqual('hellocheese', template.merge({'text': 'hello', 'monkey': 'cheese'})) # We use commas with our macros and it seems to work # so it's correct behavior by definition; the real # question is whether using them without a comma is a legal variant # or not. This should affect the above test; the following test # should be legal by definition def test_define_and_use_macro_with_two_parameters_with_comma(self): template = airspeed.Template( '#macro ( bold $value, $other)$value$other#end#bold ($text, $monkey)') self.assertEqual('hellocheese', template.merge({'text': 'hello', 'monkey': 'cheese'})) def test_use_of_macro_name_is_case_insensitive(self): template = airspeed.Template( '#macro ( bold $value)$value#end#BoLd ($text)') self.assertEqual( 'hello', template.merge({'text': 'hello'})) def test_define_and_use_macro_with_two_parameter(self): template = airspeed.Template( '#macro (addition $value1 $value2 )$value1+$value2#end#addition (1 2)') self.assertEqual('1+2', template.merge({})) template = airspeed.Template( '#macro (addition $value1 $value2 )$value1+$value2#end#addition( $one $two )') self.assertEqual( 'ONE+TWO', template.merge({'one': 'ONE', 'two': 'TWO'})) def test_cannot_redefine_macro(self): template = airspeed.Template( '#macro ( hello)hi#end#macro(hello)again#end') self.assertRaises( airspeed.TemplateExecutionError, template.merge, {}) # Should this be TemplateSyntaxError? def test_can_call_macro_with_newline_between_args(self): template = airspeed.Template( '#macro (hello $value1 $value2 )hello $value1 and $value2#end\n#hello (1,\n 2)') self.assertEqual('hello 1 and 2', template.merge({})) def test_use_define_with_no_parameters(self): template = airspeed.Template('#define ( $hello)hi#end$hello()$hello()') self.assertEqual('hihi', template.merge({})) def test_use_define_with_parameters(self): template = airspeed.Template('#define ( $echo $v1 $v2)$v1$v2#end$echo(1,"a")$echo("b",2)') self.assertEqual('1ab2', template.merge({'text': 'hello'})) template = airspeed.Template('#define ( $echo $v1 $v2)$v1$v2#end$echo(1,"a")$echo($echo(2,"b"),"c")') self.assertEqual('1a2bc', template.merge({})) template = airspeed.Template('#define ( $echo $v1 $v2)$v1$v2#end$echo(1,"a")$echo("b",$echo(3,"c"))') self.assertEqual('1ab3c', template.merge({})) def test_define_with_local_namespace(self): template = airspeed.Template("#define ( $showindex )$foreach.index#end#foreach($x in [1,2,3])$showindex#end") self.assertEqual('012', template.merge({})) def test_use_defined_func_multiple_times(self): template = airspeed.Template(""" #define( $myfunc )$ctx#end #set( $ctx = 'foo' ) $myfunc #set( $ctx = 'bar' ) $myfunc """) result = template.merge({}).replace("\n", "").replace(" ", "") self.assertEqual('foobar', result) def test_use_defined_func_create_json_loop(self): template = airspeed.Template(""" #define( $loop ) { #foreach($e in $map.keySet()) #set( $k = $e ) #set( $v = $map.get($k)) "$k": "$v" #if( $foreach.hasNext ) , #end #end } #end $loop #set( $map = {'foo':'bar'} ) $loop """) context = {"map": {"test": 123, "test2": "abc"}} result = re.sub(r"\s", "", template.merge(context), flags=re.MULTILINE) self.assertEqual('{"test":"123","test2":"abc"}{"foo":"bar"}', result) def test_include_directive_gives_error_if_no_loader_provided(self): template = airspeed.Template('#include ("foo.tmpl")') self.assertRaises(airspeed.TemplateError, template.merge, {}) def test_include_directive_yields_loader_error_if_included_content_not_found( self): class BrokenLoader: def load_text(self, name): raise IOError(name) template = airspeed.Template('#include ("foo.tmpl")') self.assertRaisesExecutionError(IOError, template.merge, {}, loader=BrokenLoader()) def test_valid_include_directive_include_content(self): class WorkingLoader: def load_text(self, name): if name == 'foo.tmpl': return "howdy" template = airspeed.Template('Message is: #include ("foo.tmpl")!') self.assertEqual( 'Message is: howdy!', template.merge( {}, loader=WorkingLoader())) def test_parse_directive_gives_error_if_no_loader_provided(self): template = airspeed.Template('#parse ("foo.tmpl")') self.assertRaises(airspeed.TemplateExecutionError, template.merge, {}) def test_parse_directive_yields_loader_error_if_parsed_content_not_found( self): class BrokenLoader: def load_template(self, name): raise IOError(name) template = airspeed.Template('#parse ("foo.tmpl")') self.assertRaisesExecutionError(IOError, template.merge, {}, loader=BrokenLoader()) def test_valid_parse_directive_outputs_parsed_content(self): class WorkingLoader: def load_template(self, name): if name == 'foo.tmpl': return airspeed.Template("$message", name) template = airspeed.Template('Message is: #parse ("foo.tmpl")!') self.assertEqual('Message is: hola!', template.merge({'message': 'hola'}, loader=WorkingLoader())) template = airspeed.Template('Message is: #parse ($foo)!') self.assertEqual('Message is: hola!', template.merge( {'foo': 'foo.tmpl', 'message': 'hola'}, loader=WorkingLoader())) def test_valid_parse_directive_merge_namespace(self): class WorkingLoader: def load_template(self, name): if name == 'foo.tmpl': return airspeed.Template("#set($message = 'hola')") template = airspeed.Template('#parse("foo.tmpl")Message is: $message!') self.assertEqual( 'Message is: hola!', template.merge( {}, loader=WorkingLoader())) def test_assign_range_literal(self): template = airspeed.Template( '#set($values = [1..5])#foreach($value in $values)$value,#end') self.assertEqual('1,2,3,4,5,', template.merge({})) template = airspeed.Template( '#set($values = [2..-2])#foreach($value in $values)$value,#end') self.assertEqual('2,1,0,-1,-2,', template.merge({})) def test_local_namespace_methods_are_not_available_in_context(self): template = airspeed.Template('#macro(tryme)$values#end#tryme()') self.assertEqual('$values', template.merge({})) def test_array_literal(self): template = airspeed.Template( 'blah\n#set($valuesInList = ["Hello ", $person, ", your lucky number is ", 7])\n#foreach($value in $valuesInList)$value#end\n\nblah') self.assertEqual( 'blah\nHello Chris, your lucky number is 7\nblah', template.merge({'person': 'Chris'})) # NOTE: the original version of this test incorrectly preserved # the newline at the end of the #end line def test_dictionary_literal(self): template = airspeed.Template( '#set($a = {"dog": "cat" , "horse":15})$a.dog') self.assertEqual('cat', template.merge({})) template = airspeed.Template('#set($a = {"dog": "$horse"})$a.dog') self.assertEqual('cow', template.merge({'horse': 'cow'})) def test_dictionary_literal_as_parameter(self): template = airspeed.Template('$a({"color":"blue"})') ns = {'a': lambda x: x['color'] + ' food'} self.assertEqual('blue food', template.merge(ns)) def test_nested_array_literals(self): template = airspeed.Template( '#set($values = [["Hello ", "Steve"], ["Hello", " Chris"]])#foreach($pair in $values)#foreach($word in $pair)$word#end. #end') self.assertEqual('Hello Steve. Hello Chris. ', template.merge({})) def test_when_dictionary_does_not_contain_referenced_attribute_no_substitution_occurs( self): template = airspeed.Template(" $user.name ") self.assertEqual(" $user.name ", template.merge({'user': self})) def test_when_dictionary_has_same_key_as_built_in_method(self): template = airspeed.Template(" $user.items ") self.assertEqual(" 1;2;3 ", template.merge({'user': {'items': '1;2;3'}})) def test_when_non_dictionary_object_does_not_contain_referenced_attribute_no_substitution_occurs( self): class MyObject: pass template = airspeed.Template(" $user.name ") self.assertEqual(" $user.name ", template.merge({'user': MyObject()})) def test_variables_expanded_in_double_quoted_strings(self): template = airspeed.Template( '#set($hello="hello, $name is my name")$hello') self.assertEqual( "hello, Steve is my name", template.merge({'name': 'Steve'})) def test_escaped_variable_references_not_expanded_in_double_quoted_strings( self): template = airspeed.Template( '#set($hello="hello, \\$name is my name")$hello') self.assertEqual( "hello, $name is my name", template.merge({'name': 'Steve'})) def test_macros_expanded_in_double_quoted_strings(self): template = airspeed.Template( '#macro(hi $person)$person says hello#end#set($hello="#hi($name)")$hello') self.assertEqual( "Steve says hello", template.merge({'name': 'Steve'})) def test_color_spec(self): template = airspeed.Template('') self.assertEqual('', template.merge({})) # check for a plain hash outside of a context where it could be # confused with a directive or macro call. # this is useful for cases where someone put a hash in the target # of a link, which is typical when javascript is associated with the link def test_standalone_hashes(self): template = airspeed.Template('#') self.assertEqual('#', template.merge({})) template = airspeed.Template('"#"') self.assertEqual('"#"', template.merge({})) template = airspeed.Template('bob') self.assertEqual('bob', template.merge({})) def test_large_areas_of_text_handled_without_error(self): text = "qwerty uiop asdfgh jkl zxcvbnm. 1234" * 300 template = airspeed.Template(text) self.assertEqual(text, template.merge({})) def test_foreach_with_unset_variable_expands_to_nothing(self): template = airspeed.Template('#foreach($value in $values)foo#end') self.assertEqual('', template.merge({})) def test_foreach_with_non_iterable_variable_raises_error(self): template = airspeed.Template('#foreach($value in $values)foo#end') self.assertRaises(airspeed.TemplateExecutionError, template.merge, {'values': 1}) def test_correct_scope_for_parameters_of_method_calls(self): template = airspeed.Template('$obj.get_self().method($param)') class C: def get_self(self): return self def method(self, p): if p == 'bat': return 'monkey' value = template.merge({'obj': C(), 'param': 'bat'}) self.assertEqual('monkey', value) def test_preserves_unicode_strings(self): template = airspeed.Template('$value') value = u'Grüße' self.assertEqual(value, template.merge(locals())) def test_preserves_unicode_strings_objects(self): template = airspeed.Template('$value') class Clazz: def __init__(self, value): self.value = value def __str__(self): return self.value value = Clazz(u'£12,000') self.assertEqual(six.text_type(value), template.merge(locals())) def test_can_define_macros_in_parsed_files(self): class Loader: def load_template(self, name): if name == 'foo.tmpl': return airspeed.Template('#macro(themacro)works#end') template = airspeed.Template('#parse("foo.tmpl")#themacro()') self.assertEqual('works', template.merge({}, loader=Loader())) def test_modulus_operator(self): template = airspeed.Template('#set( $modulus = ($value % 2) )$modulus') self.assertEqual('1', template.merge({'value': 3})) def test_can_assign_empty_string(self): template = airspeed.Template('#set( $v = "" )#set( $y = \'\' ).$v.$y.') self.assertEqual('...', template.merge({})) def test_can_loop_over_numeric_ranges(self): # Test for bug #15 template = airspeed.Template('#foreach( $v in [1..5] )$v\n#end') self.assertEqual('1\n2\n3\n4\n5\n', template.merge({})) def test_can_loop_over_numeric_ranges_backwards(self): template = airspeed.Template('#foreach( $v in [5..-2] )$v,#end') self.assertEqual('5,4,3,2,1,0,-1,-2,', template.merge({})) def test_ranges_over_references(self): template = airspeed.Template( "#set($start = 1)#set($end = 5)#foreach($i in [$start .. $end])$i-#end") self.assertEqual('1-2-3-4-5-', template.merge({})) def test_user_defined_directive(self): class DummyDirective(airspeed._Element): PLAIN = re.compile(r'#(monkey)man(.*)$', re.S + re.I) def parse(self): self.text, = self.identity_match(self.PLAIN) def evaluate(self, stream, namespace, loader): stream.write(self.text) airspeed.UserDefinedDirective.DIRECTIVES.append(DummyDirective) template = airspeed.Template("hello #monkeyman") self.assertEqual('hello monkey', template.merge({})) airspeed.UserDefinedDirective.DIRECTIVES.remove(DummyDirective) def test_stop_directive(self): template = airspeed.Template("hello #stop world") self.assertEqual('hello ', template.merge({})) def test_assignment_of_parenthesized_math_expression(self): template = airspeed.Template('#set($a = (5 + 4))$a') self.assertEqual('9', template.merge({})) def test_assignment_of_parenthesized_math_expression_with_reference(self): template = airspeed.Template('#set($b = 5)#set($a = ($b + 4))$a') self.assertEqual('9', template.merge({})) def test_recursive_macro(self): template = airspeed.Template( '#macro ( recur $number)#if ($number > 0)#set($number = $number - 1)#recur($number)X#end#end#recur(5)') self.assertEqual('XXXXX', template.merge({})) def test_addition_has_higher_precedence_than_comparison(self): template = airspeed.Template('#set($a = 4 > 2 + 5)$a') self.assertEqual('False', template.merge({})) def test_parentheses_work(self): template = airspeed.Template('#set($a = (5 + 4) > 2)$a') self.assertEqual('True', template.merge({})) def test_addition_has_higher_precedence_than_comparison_other_direction( self): template = airspeed.Template('#set($a = 5 + 4 > 2)$a') self.assertEqual('True', template.merge({})) # Note: this template: # template = airspeed.Template('#set($a = (4 > 2) + 5)$a') # prints 6. That's because Python automatically promotes True to 1 # and False to 0. # This is weird, but I can't say it's wrong. def test_multiplication_has_higher_precedence_than_subtraction(self): template = airspeed.Template("#set($a = 5 * 4 - 2)$a") self.assertEqual('18', template.merge({})) def test_multiplication_has_higher_precedence_than_addition_reverse(self): template = airspeed.Template("#set($a = 2 + 5 * 4)$a") self.assertEqual('22', template.merge({})) def test_parse_empty_dictionary(self): template = airspeed.Template('#set($a = {})$a') self.assertEqual('{}', template.merge({})) def test_macro_whitespace_and_newlines_ignored(self): template = airspeed.Template('''#macro ( blah ) hello## #end #blah()''') self.assertEqual('hello', template.merge({})) def test_if_whitespace_and_newlines_ignored(self): template = airspeed.Template('''#if(true) hello## #end''') self.assertEqual('hello', template.merge({})) def test_subobject_assignment(self): template = airspeed.Template("#set($outer.inner = 'monkey')") x = {'outer': {}} template.merge(x) self.assertEqual('monkey', x['outer']['inner']) def test_expressions_with_numbers_with_fractions(self): template = airspeed.Template('#set($a = 100.0 / 50)$a') self.assertEqual('2.0', template.merge({})) # TODO: is that how Velocity would format a floating point? def test_multiline_arguments_to_function_calls(self): class Thing: def func(self, arg): return 'y' template = airspeed.Template('''$x.func("multi line")''') self.assertEqual('y', template.merge({'x': Thing()})) def test_does_not_accept_dollar_digit_identifiers(self): template = airspeed.Template('$Something$0') self.assertEqual("$Something$0", template.merge({'0': 'bar'})) def test_valid_vtl_identifiers(self): template = airspeed.Template('$_x $a $A') self.assertEqual('bar z Z', template.merge({'_x': 'bar', 'a': 'z', 'A': 'Z'})) def test_invalid_vtl_identifier(self): template = airspeed.Template('$0') self.assertEqual("$0", template.merge({'0': 'bar'})) def test_array_notation_int_index(self): template = airspeed.Template('$a[1]') self.assertEqual("bar", template.merge({"a": ["foo", "bar"]})) def test_array_notation_nested_indexes(self): template = airspeed.Template('$a[1][1]') self.assertEqual("bar2", template.merge({"a": ["foo", ["bar1", "bar2"]]})) def test_array_notation_dot(self): template = airspeed.Template('$a[1].bar1') self.assertEqual("bar2", template.merge({"a": ["foo", {"bar1": "bar2"}]})) def test_array_notation_dict_index(self): template = airspeed.Template('$a["foo"]') self.assertEqual("bar", template.merge({"a": {"foo": "bar"}})) def test_array_notation_empty_array_variable(self): template = airspeed.Template('$!a[1]') self.assertEqual("", template.merge({"a": []})) def test_array_notation_variable_index(self): template = airspeed.Template('#set($i = 1)$a[ $i ]') self.assertEqual("bar", template.merge({"a": ["foo", "bar"]})) def test_array_notation_invalid_index(self): template = airspeed.Template('#set($i = "baz")$a[$i]') self.assertRaises(airspeed.TemplateExecutionError, template.merge, {"a": ["foo", "bar"]}) def test_provides_helpful_error_location(self): template = airspeed.Template(u""" #set($flag = $country.lower()) #set($url = "$host_url/images/flags/") #set($flagUrl = $url + ${flag} + ".png") """, filename="mytemplate") data = { "model": { "host_url": u"http://whatever.com", "country": None } } try: template.merge(data) self.fail("expected exception") except airspeed.TemplateExecutionError as e: self.assertEqual("mytemplate", e.filename) self.assertEqual(105, e.start) self.assertEqual(142, e.end) self.assertTrue(isinstance(e.__cause__, TypeError)) def test_outer_variable_assignable_from_foreach_block(self): template = airspeed.Template( "#set($var = 1)#foreach ($i in $items)" "$var,#set($var = $i)" "#end$var") self.assertEqual("1,2,3,4", template.merge({"items": [2, 3, 4]})) def test_no_assignment_to_outer_var_if_same_varname_in_block(self): template = airspeed.Template( "#set($i = 1)$i," "#foreach ($i in [2, 3, 4])$i,#set($i = $i)#end" "$i") self.assertEqual("1,2,3,4,1", template.merge({})) def test_nested_foreach_vars_are_scoped(self): template = airspeed.Template( "#foreach ($j in [1,2])" "#foreach ($i in [3, 4])$foreach.count,#end" "$foreach.count|#end") self.assertEqual("1,2,1|1,2,2|", template.merge({})) def test_template_cannot_modify_its_args(self): template = airspeed.Template("#set($foo = 1)") ns = {"foo": 2} template.merge(ns) self.assertEqual(2, ns["foo"]) def test_doesnt_blow_stack(self): template = airspeed.Template(""" #foreach($i in [1..$end]) $assembly## #end """) ns = {"end": 400} template.merge(ns) def test_array_size(self): template = airspeed.Template("#set($foo = [1,2,3]) $foo.size()") output = template.merge({}) self.assertEqual(output, " 3") def test_array_contains_true(self): template = airspeed.Template("#set($foo = [1,2,3]) #if($foo.contains(1))found#end") output = template.merge({}) self.assertEqual(output, " found") def test_array_contains_false(self): template = airspeed.Template("#set($foo = [1,2,3]) #if($foo.contains(10))found#end") output = template.merge({}) self.assertEqual(output, " ") def test_array_get_item(self): template = airspeed.Template("#set($foo = [1,2,3]) $foo.get(1)") output = template.merge({}) self.assertEqual(output, " 2") def test_array_add_item(self): template = airspeed.Template("#set($foo = [1,2,3])" "#set( $ignore = $foo.add('string value') )" "#foreach($item in $foo)$item,#end") output = template.merge({}) self.assertEqual(output, "1,2,3,string value,") def test_string_length(self): template = airspeed.Template("#set($foo = 'foobar123') $foo.length()") output = template.merge({}) self.assertEqual(output, " 9") def test_string_replace_all(self): template = airspeed.Template("#set($foo = 'foobar123bab') $foo.replaceAll('ba.', 'foo')") output = template.merge({}) self.assertEqual(output, " foofoo123foo") def test_string_starts_with_true(self): template = airspeed.Template("#set($foo = 'foobar123') #if($foo.startsWith('foo'))yes!#end") output = template.merge({}) self.assertEqual(output, " yes!") def test_string_starts_with_false(self): template = airspeed.Template( "#set($foo = 'nofoobar123') #if($foo.startsWith('foo'))yes!#end") output = template.merge({}) self.assertEqual(output, " ") def test_string_matches_true(self): template = airspeed.Template( "#set($foo = 'nofoobar123') #if($foo.matches('.*foo.*'))yes!#end") output = template.merge({}) self.assertEqual(output, " yes!") def test_string_matches_false(self): template = airspeed.Template( "#set($foo = 'bar') #if($foo.matches('foo'))yes!#end") output = template.merge({}) self.assertEqual(output, " ") def test_dict_put_item(self): template = airspeed.Template("#set( $ignore = $test_dict.put('k', 'new value') )" "$test_dict.k") output = template.merge({'test_dict': {'k': 'initial value'}}) self.assertEqual(output, "new value") def test_dict_isEmpty(self): template = airspeed.Template("#set( $emptyDict = {} )" "$emptyDict.isEmpty()") output = template.merge({}) self.assertEqual(output, "True") template = airspeed.Template("#set( $emptyDict = {'foo': 'bar'} )" "$emptyDict.isEmpty()") output = template.merge({}) self.assertEqual(output, "False") def test_evaluate(self): template = airspeed.Template('''#set($source1 = "abc") #set($select = "1") #set($dynamicsource = "$source$select") ## $dynamicsource is now the string '$source1' #evaluate($dynamicsource)''') output = template.merge({}) self.assertEqual(output, "abc") # TODO: # # Report locations for template errors in files included via loaders # Gobbling up whitespace (see WHITESPACE_TO_END_OF_LINE above, but need to apply in more places) # Bind #macro calls at compile time? # Scope of #set across if/elseif/else? # there seems to be some confusion about the semantics of parameter # passing to macros; an assignment in a macro body should persist past the # macro call. Confirm against Velocity. if __name__ == '__main__': if sys.version_info >= (3, 0) and sys.version_info <= (3, 3): imp.reload(airspeed) elif sys.version_info >= (3, 4): importlib.reload(airspeed) else: reload(airspeed) import unittest try: unittest.main() except SystemExit: pass