# --------------------------------------------------------------------------
# Default node classes and associated helpers. Additional node classes can
# be registered using the `@register` decorator:
#
# @ibis.nodes.register('tag')
#
# Node classes can be given block scope by specifying the required end tag:
#
# @ibis.nodes.register('tag', 'endtag')
# --------------------------------------------------------------------------
import ast
import operator
import re
import itertools
import collections
from . import config
from . import utils
from . import filters
from .errors import (
TemplateSyntaxError,
InvalidFilter,
FilterError,
CallError,
UnpackingError,
)
# Dictionary of registered node classes.
nodemap = { 'endtags': [] }
def register(tag, end_tag=None):
""" Decorator function for registering node classes. """
def register_node_class(node_class):
node_class.end_tag = end_tag
nodemap[tag] = node_class
if end_tag:
nodemap['endtags'].append(end_tag)
return node_class
return register_node_class
class Expression:
""" Helper class for evaluating expression strings.
An Expression object is initialized with an expression string
parsed from a template. An expression string can contain a variable
name or a Python literal, optionally followed by a sequence of filters.
The Expression object handles the rather convoluted process of parsing
the string, evaluating the literal or resolving the variable, calling
the variable if it resolves to a callable, and applying the filters to
the resulting object. The consumer simply needs to call the expression's
.eval() method and supply an appropriate Context object.
Examples of valid expression syntax include:
foo.bar.baz|default:'bam'|escape
'foo', 'bar', 'baz'|random
Arguments can be passed to callables using colon or bracket syntax:
foo.bar.baz:'bam'|filter:25:'text'
foo.bar.baz('bam')|filter(25, 'text')
"""
re_callable = re.compile(r'^([a-zA-Z_][a-zA-Z0-9_.]*)[(](.*)[)]$')
def __init__(self, expr):
self.expr = expr.strip()
self.filters = []
elements = utils.splitc(expr, '|', strip=True)
self._parse_expression(elements[0])
self._parse_filters(elements[1:])
if self.is_literal:
self.literal = self._apply_filters(self.literal)
def eval(self, context):
if self.is_literal:
return self.literal
else:
return self._resolve_variable(context)
def _parse_expression(self, expr):
try:
self.literal = ast.literal_eval(expr)
self.is_literal = True
except:
self.varstr, self.varargs = self._parse_callable(expr)
self.is_literal = False
def _resolve_variable(self, context):
obj = context.resolve(self.varstr)
if callable(obj):
try:
obj = obj(*self.varargs)
except:
raise CallError("error calling [%s]" % self.varstr)
return self._apply_filters(obj)
def _parse_callable(self, callable):
match = self.re_callable.match(callable)
if match:
name = match.group(1)
args = utils.splitc(match.group(2), ',', True, True)
else:
elements = utils.splitc(callable, ':', True)
name = elements[0]
args = elements[1:]
for index, arg in enumerate(args):
try:
args[index] = ast.literal_eval(arg)
except:
msg = "unparsable argument: [%s] in [%s]" % (arg, self.expr)
raise TemplateSyntaxError(msg) from None
return name, args
def _parse_filters(self, filterlist):
for filter in filterlist:
name, args = self._parse_callable(filter)
if name in filters.filtermap:
self.filters.append((name, filters.filtermap[name], args))
else:
msg = "[%s] is not a recognised filter"
raise InvalidFilter(msg % name)
def _apply_filters(self, obj):
for name, func, args in self.filters:
try:
obj = func(obj, *args)
except:
msg = "error applying filter [%s] in [%s]"
raise FilterError(msg % (name, self.expr))
return obj
@register('node')
class Node:
""" Base class for all node objects. """
def __init__(self, token=None, children=None):
self.token = token
self.children = children or []
self.process_token(token)
def __iter__(self):
for child in self.children:
yield child
def __repr__(self):
return self.repr()
def render(self, context):
""" Render the node as a string. """
return ''.join(child.render(context) for child in self.children)
def process_token(self, token):
""" Subclasses can override this method to process token content
for arguments, etc. """
pass
def exit_scope(self):
""" Subclasses can override this method to process the content of
block-scoped nodes. """
pass
def split_children(self, delimiter_class):
""" Splits child nodes on the first instance of a delimiter class. """
for index, child in enumerate(self):
if isinstance(child, delimiter_class):
return self.children[:index], child, self.children[index+1:]
return self.children, None, []
def repr(self, depth=0):
""" Basic tree-printing capability for debugging. """
output = ' ' * depth + '\n' % self.__class__.__name__
for child in self.children:
output += child.repr(depth + 1)
return output
@register('root')
class RootNode(Node):
""" Root node of a template tree. """
pass
@register('text')
class TextNode(Node):
""" Plain text. """
def render(self, context):
return self.token.content
@register('print')
class PrintNode(Node):
""" Evaluates an expression and prints its result.
{{ }}
Multiple expressions can be listed separated by 'or' or '||'.
The first expression to resolve to a truthy value will be
printed. (If none of the expressions are truthy the final value
will be printed regardless.)
{{ or or }}
Alternatively, print statements can use the ternary operator: ?? ::
{{ ?? :: }}
If is truthy, will be printed, otherwise
will be printed.
Note that *either* 'or'-chaining or the ternary operator can be used
in a single print statement, but not both.
"""
escape = False
def process_token(self, token):
# Check for a ternary operator.
chunks = utils.splitre(token.content, (r'\?\?', r'\:\:'), True)
if len(chunks) == 5 and chunks[1] == '??' and chunks[3] == '::':
self.is_ternary = True
self.testexpr = Expression(chunks[0])
self.iftrue = Expression(chunks[2])
self.iffalse = Expression(chunks[4])
# Look for a list of 'or' separated expressions.
else:
self.is_ternary = False
exprs = utils.splitre(token.content, (r'\s+or\s+', r'\|\|'))
self.exprs = [Expression(e) for e in exprs]
def render(self, context):
if self.is_ternary:
if self.testexpr.eval(context):
content = self.iftrue.eval(context)
else:
content = self.iffalse.eval(context)
else:
for expr in self.exprs:
content = expr.eval(context)
if content:
break
if self.escape:
return filters.filtermap['escape'](str(content))
else:
return str(content)
@register('eprint')
class EscapedPrintNode(PrintNode):
""" Print node with automatic escaping. """
escape = True
@register('for', 'endfor')
class ForNode(Node):
""" Implements for/empty looping over an iterable expression.
{% for in %} ... [ {% empty %} ... ] {% endfor %}
Supports unpacking into multiple loop variables:
{% for , in %}
"""
regex = re.compile(r'for\s+(\w+(?:,\s*\w+)*)\s+in\s+(.+)')
def process_token(self, token):
match = self.regex.match(token.content)
if match is None:
msg = "malformed [for] tag: [%s]" % token.content
raise TemplateSyntaxError(msg)
self.loopvars = [var.strip() for var in match.group(1).split(',')]
self.expr = Expression(match.group(2))
def render(self, context):
items = self.expr.eval(context)
if items and hasattr(items, '__iter__'):
items = list(items)
length = len(items)
unpack = len(self.loopvars) > 1
output = []
for index, item in enumerate(items):
context.push()
if unpack:
try:
unpacked = dict(zip(self.loopvars, item))
except TypeError:
msg = 'cannot unpack [%s] in [%s]' % (
repr(item), self.token.content
)
raise UnpackingError(msg)
else:
context.update(unpacked)
else:
context[self.loopvars[0]] = item
context['loop'] = {
'index': index,
'count': index + 1,
'length': length,
'is_first': index == 0,
'first': index == 0, # deprecated - will be removed
'is_last': index == length - 1,
'last': index == length - 1, # deprecated - will be removed
'parent': context.get('loop'),
}
output.append(self.for_branch.render(context))
context.pop()
return ''.join(output)
else:
return self.empty_branch.render(context)
def exit_scope(self):
fornodes, emptynode, emptynodes = self.split_children(EmptyNode)
self.for_branch = Node(None, fornodes)
self.empty_branch = Node(None, emptynodes)
@register('empty')
class EmptyNode(Node):
""" Delimiter node to implement for/empty branching. """
pass
@register('if', 'endif')
class IfNode(Node):
""" Implements if/elif/else branching.
{% if [not] %} ... {% endif %}
{% if [not] %} ... {% endif %}
{% if %} ... {% elif %} ... {% else %} ... {% endif %}
Supports 'and' and 'or' conjunctions; 'and' has higher precedence so:
if a and b or c and d
is treated as:
if (a and b) or (c and d)
Note that explicit brackets are not supported.
"""
condition = collections.namedtuple('Condition', 'negated lhs op rhs')
re_condition = re.compile(r'''
(not\s+)?(.+?)\s+(==|!=||=|not[ ]in|in)\s+(.+)
|
(not\s+)?(.+)
''', re.VERBOSE
)
operators = {
'==': operator.eq,
'!=': operator.ne,
'': operator.gt,
'=': operator.ge,
'in': lambda a, b: a in b,
'not in': lambda a, b: a not in b,
}
def process_token(self, token):
try:
tag, conditions = token.content.split(None, 1)
except ValueError:
msg = "malformed [%s] tag: [%s]" % (token.tag, token.content)
raise TemplateSyntaxError(msg) from None
self.condition_groups = [
[
self.parse_condition(condstr)
for condstr in utils.splitre(or_block, (r'\s+and\s+', r'&&'))
]
for or_block in utils.splitre(conditions, (r'\s+or\s+', r'\|\|'))
]
def parse_condition(self, condstr):
match = self.re_condition.match(condstr)
if match.group(2):
return self.condition(
negated = bool(match.group(1)),
lhs = Expression(match.group(2)),
op = self.operators[match.group(3)],
rhs = Expression(match.group(4)),
)
else:
return self.condition(
negated = bool(match.group(5)),
lhs = Expression(match.group(6)),
op = None,
rhs = None,
)
def eval_condition(self, cond, context):
try:
if cond.op:
result = cond.op(cond.lhs.eval(context), cond.rhs.eval(context))
else:
result = operator.truth(cond.lhs.eval(context))
except:
# We treat an exception during evaluation as a false result.
# We should probably raise an exception of our own here instead.
result = False
if cond.negated:
result = not result
return result
def render(self, context):
for condition_group in self.condition_groups:
for condition in condition_group:
is_true = self.eval_condition(condition, context)
if not is_true:
break
if is_true:
break
if is_true:
return self.true_branch.render(context)
else:
return self.false_branch.render(context)
def exit_scope(self):
ifnodes, elifnode, elifnodes = self.split_children(ElifNode)
if elifnode:
self.true_branch = Node(None, ifnodes)
self.false_branch = IfNode(elifnode.token, elifnodes)
self.false_branch.exit_scope()
return
ifnodes, elsenode, elsenodes = self.split_children(ElseNode)
self.true_branch = Node(None, ifnodes)
self.false_branch = Node(None, elsenodes)
@register('elif')
class ElifNode(Node):
""" Delimiter node to implement if/elif branching. """
pass
@register('else')
class ElseNode(Node):
""" Delimiter node to implement if/else branching. """
pass
@register('cycle')
class CycleNode(Node):
""" Cycles over an iterable expression.
{% cycle %}
Each time the node is evaluated it will render the next value in the
sequence, looping once it reaches the end; e.g.
{% cycle 'odd', 'even' %}
will alternate continuously between printing 'odd' and 'even'.
"""
def process_token(self, token):
try:
tag, arg = token.content.split(None, 1)
except ValueError:
msg = "malformed [cycle] tag: [%s]" % token.content
raise TemplateSyntaxError(msg) from None
self.expr = Expression(arg)
def render(self, context):
# We store our state info on the context object to avoid a threading
# mess if the template is being simultaneously rendered by multiple
# threads.
if not self in context.stash:
items = self.expr.eval(context)
if not hasattr(items, '__iter__'):
items = ''
context.stash[self] = itertools.cycle(items)
iterator = context.stash[self]
return str(next(iterator, ''))
@register('include')
class IncludeNode(Node):
""" Includes a sub-template.
{% include %}
Requires a template ID which can be supplied as either a string literal
or a variable resolving to a string. This ID will be passed to the
registered template loader.
"""
def process_token(self, token):
try:
tag, arg = token.content.split(None, 1)
except ValueError:
msg = "malformed [include] tag: [%s]" % token.content
raise TemplateSyntaxError(msg) from None
expr = Expression(arg)
if expr.is_literal:
template = config.loader(expr.literal)
self.children.append(template.root)
else:
self.expr = expr
def render(self, context):
if self.children:
return ''.join(child.render(context) for child in self)
else:
template_id = self.expr.eval(context)
template = config.loader(template_id)
return template.root.render(context)
@register('extends')
class ExtendsNode(Node):
""" Specifies a parent template.
Indicates that the current template inherits from or 'extends' the
specified parent template.
{% extends "parent.txt" %}
Requires a template ID to pass to the registered template loader.
This must be supplied as a string literal (not a variable)
as the parent template must be loaded at compile-time.
"""
def process_token(self, token):
try:
tag, arg = token.content.split(None, 1)
except ValueError:
msg = "malformed [extends] tag: [%s]" % token.content
raise TemplateSyntaxError(msg) from None
expr = Expression(arg)
if expr.is_literal:
template = config.loader(expr.literal)
self.children.append(template.root)
else:
msg = "malformed [extends] tag: [%s]" % token.content
raise TemplateSyntaxError(msg) from None
@register('block', 'endblock')
class BlockNode(Node):
""" Implements template inheritance.
{% block title %} ... {% endblock %}
A block tag defines a titled block of content that can be overridden
by similarly titled blocks in child templates.
"""
def process_token(self, token):
self.title = token.content[5:].strip()
def render(self, context):
# We only want to render the first block of any given title
# that we encounter in the node tree, although we want to substitute
# the content of the last block of that title in its place.
block_list = context.template.registry[self.title]
if block_list[0] is self:
return self.render_block(context, block_list[:])
else:
return ''
def render_block(self, context, block_list):
# A call to {{ super }} inside a block renders and returns the
# content of the block's immediate ancestor. That ancestor may
# itself contain a {{ super }} call, so we start at the end of the
# list and recursively work our way backwards, popping off nodes
# as we go.
if block_list:
last_block = block_list.pop()
context.push()
context['super'] = lambda: self.render_block(context, block_list)
output = ''.join(child.render(context) for child in last_block)
context.pop()
return output
else:
return ''
@register('spaceless', 'endspaceless')
class SpacelessNode(Node):
""" Strips all whitespace between HTML tags.
{% spaceless %} ... {% endspaceless %}
"""
def render(self, context):
output = ''.join(child.render(context) for child in self)
return filters.filtermap['spaceless'](output).strip()
@register('trim', 'endtrim')
class TrimNode(Node):
""" Trims leading and trailing whitespace.
{% trim %} ... {% endtrim %}
"""
def render(self, context):
return ''.join(child.render(context) for child in self).strip()
@register('with', 'endwith')
class WithNode(Node):
""" Caches a complex expression under a simpler alias.
{% with = %} ... {% endwith %}
"""
def process_token(self, token):
try:
alias, expr = token.content[4:].split('=', 1)
except ValueError:
msg = "malformed [with] tag: [%s]" % token.content
raise TemplateSyntaxError(msg) from None
self.alias = alias.strip()
self.expr = Expression(expr.strip())
def render(self, context):
context.push()
context[self.alias] = self.expr.eval(context)
rendered = ''.join(child.render(context) for child in self)
context.pop()
return rendered