diff options
Diffstat (limited to 'genshi/template/base.py')
-rw-r--r-- | genshi/template/base.py | 634 |
1 files changed, 634 insertions, 0 deletions
diff --git a/genshi/template/base.py b/genshi/template/base.py new file mode 100644 index 0000000..202faae --- /dev/null +++ b/genshi/template/base.py @@ -0,0 +1,634 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2006-2010 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://genshi.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://genshi.edgewall.org/log/. + +"""Basic templating functionality.""" + +from collections import deque +import os +from StringIO import StringIO +import sys + +from genshi.core import Attrs, Stream, StreamEventKind, START, TEXT, _ensure +from genshi.input import ParseError + +__all__ = ['Context', 'DirectiveFactory', 'Template', 'TemplateError', + 'TemplateRuntimeError', 'TemplateSyntaxError', 'BadDirectiveError'] +__docformat__ = 'restructuredtext en' + + +class TemplateError(Exception): + """Base exception class for errors related to template processing.""" + + def __init__(self, message, filename=None, lineno=-1, offset=-1): + """Create the exception. + + :param message: the error message + :param filename: the filename of the template + :param lineno: the number of line in the template at which the error + occurred + :param offset: the column number at which the error occurred + """ + if filename is None: + filename = '<string>' + self.msg = message #: the error message string + if filename != '<string>' or lineno >= 0: + message = '%s (%s, line %d)' % (self.msg, filename, lineno) + Exception.__init__(self, message) + self.filename = filename #: the name of the template file + self.lineno = lineno #: the number of the line containing the error + self.offset = offset #: the offset on the line + + +class TemplateSyntaxError(TemplateError): + """Exception raised when an expression in a template causes a Python syntax + error, or the template is not well-formed. + """ + + def __init__(self, message, filename=None, lineno=-1, offset=-1): + """Create the exception + + :param message: the error message + :param filename: the filename of the template + :param lineno: the number of line in the template at which the error + occurred + :param offset: the column number at which the error occurred + """ + if isinstance(message, SyntaxError) and message.lineno is not None: + message = str(message).replace(' (line %d)' % message.lineno, '') + TemplateError.__init__(self, message, filename, lineno) + + +class BadDirectiveError(TemplateSyntaxError): + """Exception raised when an unknown directive is encountered when parsing + a template. + + An unknown directive is any attribute using the namespace for directives, + with a local name that doesn't match any registered directive. + """ + + def __init__(self, name, filename=None, lineno=-1): + """Create the exception + + :param name: the name of the directive + :param filename: the filename of the template + :param lineno: the number of line in the template at which the error + occurred + """ + TemplateSyntaxError.__init__(self, 'bad directive "%s"' % name, + filename, lineno) + + +class TemplateRuntimeError(TemplateError): + """Exception raised when an the evaluation of a Python expression in a + template causes an error. + """ + + +class Context(object): + """Container for template input data. + + A context provides a stack of scopes (represented by dictionaries). + + Template directives such as loops can push a new scope on the stack with + data that should only be available inside the loop. When the loop + terminates, that scope can get popped off the stack again. + + >>> ctxt = Context(one='foo', other=1) + >>> ctxt.get('one') + 'foo' + >>> ctxt.get('other') + 1 + >>> ctxt.push(dict(one='frost')) + >>> ctxt.get('one') + 'frost' + >>> ctxt.get('other') + 1 + >>> ctxt.pop() + {'one': 'frost'} + >>> ctxt.get('one') + 'foo' + """ + + def __init__(self, **data): + """Initialize the template context with the given keyword arguments as + data. + """ + self.frames = deque([data]) + self.pop = self.frames.popleft + self.push = self.frames.appendleft + self._match_templates = [] + self._choice_stack = [] + + # Helper functions for use in expressions + def defined(name): + """Return whether a variable with the specified name exists in the + expression scope.""" + return name in self + def value_of(name, default=None): + """If a variable of the specified name is defined, return its value. + Otherwise, return the provided default value, or ``None``.""" + return self.get(name, default) + data.setdefault('defined', defined) + data.setdefault('value_of', value_of) + + def __repr__(self): + return repr(list(self.frames)) + + def __contains__(self, key): + """Return whether a variable exists in any of the scopes. + + :param key: the name of the variable + """ + return self._find(key)[1] is not None + has_key = __contains__ + + def __delitem__(self, key): + """Remove a variable from all scopes. + + :param key: the name of the variable + """ + for frame in self.frames: + if key in frame: + del frame[key] + + def __getitem__(self, key): + """Get a variables's value, starting at the current scope and going + upward. + + :param key: the name of the variable + :return: the variable value + :raises KeyError: if the requested variable wasn't found in any scope + """ + value, frame = self._find(key) + if frame is None: + raise KeyError(key) + return value + + def __len__(self): + """Return the number of distinctly named variables in the context. + + :return: the number of variables in the context + """ + return len(self.items()) + + def __setitem__(self, key, value): + """Set a variable in the current scope. + + :param key: the name of the variable + :param value: the variable value + """ + self.frames[0][key] = value + + def _find(self, key, default=None): + """Retrieve a given variable's value and the frame it was found in. + + Intended primarily for internal use by directives. + + :param key: the name of the variable + :param default: the default value to return when the variable is not + found + """ + for frame in self.frames: + if key in frame: + return frame[key], frame + return default, None + + def get(self, key, default=None): + """Get a variable's value, starting at the current scope and going + upward. + + :param key: the name of the variable + :param default: the default value to return when the variable is not + found + """ + for frame in self.frames: + if key in frame: + return frame[key] + return default + + def keys(self): + """Return the name of all variables in the context. + + :return: a list of variable names + """ + keys = [] + for frame in self.frames: + keys += [key for key in frame if key not in keys] + return keys + + def items(self): + """Return a list of ``(name, value)`` tuples for all variables in the + context. + + :return: a list of variables + """ + return [(key, self.get(key)) for key in self.keys()] + + def update(self, mapping): + """Update the context from the mapping provided.""" + self.frames[0].update(mapping) + + def push(self, data): + """Push a new scope on the stack. + + :param data: the data dictionary to push on the context stack. + """ + + def pop(self): + """Pop the top-most scope from the stack.""" + + +def _apply_directives(stream, directives, ctxt, vars): + """Apply the given directives to the stream. + + :param stream: the stream the directives should be applied to + :param directives: the list of directives to apply + :param ctxt: the `Context` + :param vars: additional variables that should be available when Python + code is executed + :return: the stream with the given directives applied + """ + if directives: + stream = directives[0](iter(stream), directives[1:], ctxt, **vars) + return stream + + +def _eval_expr(expr, ctxt, vars=None): + """Evaluate the given `Expression` object. + + :param expr: the expression to evaluate + :param ctxt: the `Context` + :param vars: additional variables that should be available to the + expression + :return: the result of the evaluation + """ + if vars: + ctxt.push(vars) + retval = expr.evaluate(ctxt) + if vars: + ctxt.pop() + return retval + + +def _exec_suite(suite, ctxt, vars=None): + """Execute the given `Suite` object. + + :param suite: the code suite to execute + :param ctxt: the `Context` + :param vars: additional variables that should be available to the + code + """ + if vars: + ctxt.push(vars) + ctxt.push({}) + suite.execute(ctxt) + if vars: + top = ctxt.pop() + ctxt.pop() + ctxt.frames[0].update(top) + + +class DirectiveFactoryMeta(type): + """Meta class for directive factories.""" + + def __new__(cls, name, bases, d): + if 'directives' in d: + d['_dir_by_name'] = dict(d['directives']) + d['_dir_order'] = [directive[1] for directive in d['directives']] + + return type.__new__(cls, name, bases, d) + + +class DirectiveFactory(object): + """Base for classes that provide a set of template directives. + + :since: version 0.6 + """ + __metaclass__ = DirectiveFactoryMeta + + directives = [] + """A list of ``(name, cls)`` tuples that define the set of directives + provided by this factory. + """ + + def get_directive(self, name): + """Return the directive class for the given name. + + :param name: the directive name as used in the template + :return: the directive class + :see: `Directive` + """ + return self._dir_by_name.get(name) + + def get_directive_index(self, dir_cls): + """Return a key for the given directive class that should be used to + sort it among other directives on the same `SUB` event. + + The default implementation simply returns the index of the directive in + the `directives` list. + + :param dir_cls: the directive class + :return: the sort key + """ + if dir_cls in self._dir_order: + return self._dir_order.index(dir_cls) + return len(self._dir_order) + + +class Template(DirectiveFactory): + """Abstract template base class. + + This class implements most of the template processing model, but does not + specify the syntax of templates. + """ + + EXEC = StreamEventKind('EXEC') + """Stream event kind representing a Python code suite to execute.""" + + EXPR = StreamEventKind('EXPR') + """Stream event kind representing a Python expression.""" + + INCLUDE = StreamEventKind('INCLUDE') + """Stream event kind representing the inclusion of another template.""" + + SUB = StreamEventKind('SUB') + """Stream event kind representing a nested stream to which one or more + directives should be applied. + """ + + serializer = None + _number_conv = unicode # function used to convert numbers to event data + + def __init__(self, source, filepath=None, filename=None, loader=None, + encoding=None, lookup='strict', allow_exec=True): + """Initialize a template from either a string, a file-like object, or + an already parsed markup stream. + + :param source: a string, file-like object, or markup stream to read the + template from + :param filepath: the absolute path to the template file + :param filename: the path to the template file relative to the search + path + :param loader: the `TemplateLoader` to use for loading included + templates + :param encoding: the encoding of the `source` + :param lookup: the variable lookup mechanism; either "strict" (the + default), "lenient", or a custom lookup class + :param allow_exec: whether Python code blocks in templates should be + allowed + + :note: Changed in 0.5: Added the `allow_exec` argument + """ + self.filepath = filepath or filename + self.filename = filename + self.loader = loader + self.lookup = lookup + self.allow_exec = allow_exec + self._init_filters() + self._init_loader() + self._prepared = False + + if isinstance(source, basestring): + source = StringIO(source) + else: + source = source + try: + self._stream = self._parse(source, encoding) + except ParseError, e: + raise TemplateSyntaxError(e.msg, self.filepath, e.lineno, e.offset) + + def __getstate__(self): + state = self.__dict__.copy() + state['filters'] = [] + return state + + def __setstate__(self, state): + self.__dict__ = state + self._init_filters() + + def __repr__(self): + return '<%s "%s">' % (type(self).__name__, self.filename) + + def _init_filters(self): + self.filters = [self._flatten, self._include] + + def _init_loader(self): + if self.loader is None: + from genshi.template.loader import TemplateLoader + if self.filename: + if self.filepath != self.filename: + basedir = os.path.normpath(self.filepath)[:-len( + os.path.normpath(self.filename)) + ] + else: + basedir = os.path.dirname(self.filename) + else: + basedir = '.' + self.loader = TemplateLoader([os.path.abspath(basedir)]) + + @property + def stream(self): + if not self._prepared: + self._stream = list(self._prepare(self._stream)) + self._prepared = True + return self._stream + + def _parse(self, source, encoding): + """Parse the template. + + The parsing stage parses the template and constructs a list of + directives that will be executed in the render stage. The input is + split up into literal output (text that does not depend on the context + data) and directives or expressions. + + :param source: a file-like object containing the XML source of the + template, or an XML event stream + :param encoding: the encoding of the `source` + """ + raise NotImplementedError + + def _prepare(self, stream): + """Call the `attach` method of every directive found in the template. + + :param stream: the event stream of the template + """ + from genshi.template.loader import TemplateNotFound + + for kind, data, pos in stream: + if kind is SUB: + directives = [] + substream = data[1] + for _, cls, value, namespaces, pos in sorted(data[0]): + directive, substream = cls.attach(self, substream, value, + namespaces, pos) + if directive: + directives.append(directive) + substream = self._prepare(substream) + if directives: + yield kind, (directives, list(substream)), pos + else: + for event in substream: + yield event + else: + if kind is INCLUDE: + href, cls, fallback = data + if isinstance(href, basestring) and \ + not getattr(self.loader, 'auto_reload', True): + # If the path to the included template is static, and + # auto-reloading is disabled on the template loader, + # the template is inlined into the stream + try: + tmpl = self.loader.load(href, relative_to=pos[0], + cls=cls or self.__class__) + for event in tmpl.stream: + yield event + except TemplateNotFound: + if fallback is None: + raise + for event in self._prepare(fallback): + yield event + continue + elif fallback: + # Otherwise the include is performed at run time + data = href, cls, list(self._prepare(fallback)) + + yield kind, data, pos + + def generate(self, *args, **kwargs): + """Apply the template to the given context data. + + Any keyword arguments are made available to the template as context + data. + + Only one positional argument is accepted: if it is provided, it must be + an instance of the `Context` class, and keyword arguments are ignored. + This calling style is used for internal processing. + + :return: a markup event stream representing the result of applying + the template to the context data. + """ + vars = {} + if args: + assert len(args) == 1 + ctxt = args[0] + if ctxt is None: + ctxt = Context(**kwargs) + else: + vars = kwargs + assert isinstance(ctxt, Context) + else: + ctxt = Context(**kwargs) + + stream = self.stream + for filter_ in self.filters: + stream = filter_(iter(stream), ctxt, **vars) + return Stream(stream, self.serializer) + + def _flatten(self, stream, ctxt, **vars): + number_conv = self._number_conv + stack = [] + push = stack.append + pop = stack.pop + stream = iter(stream) + + while 1: + for kind, data, pos in stream: + + if kind is START and data[1]: + # Attributes may still contain expressions in start tags at + # this point, so do some evaluation + tag, attrs = data + new_attrs = [] + for name, value in attrs: + if type(value) is list: # this is an interpolated string + values = [event[1] + for event in self._flatten(value, ctxt, **vars) + if event[0] is TEXT and event[1] is not None + ] + if not values: + continue + value = ''.join(values) + new_attrs.append((name, value)) + yield kind, (tag, Attrs(new_attrs)), pos + + elif kind is EXPR: + result = _eval_expr(data, ctxt, vars) + if result is not None: + # First check for a string, otherwise the iterable test + # below succeeds, and the string will be chopped up into + # individual characters + if isinstance(result, basestring): + yield TEXT, result, pos + elif isinstance(result, (int, float, long)): + yield TEXT, number_conv(result), pos + elif hasattr(result, '__iter__'): + push(stream) + stream = _ensure(result) + break + else: + yield TEXT, unicode(result), pos + + elif kind is SUB: + # This event is a list of directives and a list of nested + # events to which those directives should be applied + push(stream) + stream = _apply_directives(data[1], data[0], ctxt, vars) + break + + elif kind is EXEC: + _exec_suite(data, ctxt, vars) + + else: + yield kind, data, pos + + else: + if not stack: + break + stream = pop() + + def _include(self, stream, ctxt, **vars): + """Internal stream filter that performs inclusion of external + template files. + """ + from genshi.template.loader import TemplateNotFound + + for event in stream: + if event[0] is INCLUDE: + href, cls, fallback = event[1] + if not isinstance(href, basestring): + parts = [] + for subkind, subdata, subpos in self._flatten(href, ctxt, + **vars): + if subkind is TEXT: + parts.append(subdata) + href = ''.join([x for x in parts if x is not None]) + try: + tmpl = self.loader.load(href, relative_to=event[2][0], + cls=cls or self.__class__) + for event in tmpl.generate(ctxt, **vars): + yield event + except TemplateNotFound: + if fallback is None: + raise + for filter_ in self.filters: + fallback = filter_(iter(fallback), ctxt, **vars) + for event in fallback: + yield event + else: + yield event + + +EXEC = Template.EXEC +EXPR = Template.EXPR +INCLUDE = Template.INCLUDE +SUB = Template.SUB |