# HG changeset patch # User cmlenz # Date 1163001015 0 # Node ID 5f2c7782cd8a14ada00f69ef0b104dde2471c04c # Parent e14a0332cfdc0319821f1ae3ff078d7fc11909c9 Refactoring: `genshi.template` is now a package, it was getting way to crowded in that file. diff --git a/ChangeLog b/ChangeLog --- a/ChangeLog +++ b/ChangeLog @@ -24,6 +24,8 @@ * Fix XPath traversal in match templates. Previously, `div/p` would be treated the same as `div//p`, i.e. it would match all descendants and not just the immediate children. + * Split up the `genshi.template` module into multiple modules inside the new + `genshi.template` package. Version 0.3.4 http://svn.edgewall.org/repos/genshi/tags/0.3.4/ diff --git a/UPGRADE.txt b/UPGRADE.txt --- a/UPGRADE.txt +++ b/UPGRADE.txt @@ -1,6 +1,16 @@ Upgrading Genshi ================ +Upgrading from Genshi 0.3.x to 0.4.x +------------------------------------ + +The `genshi.template` module has been refactored into a package with +multiple modules. While code using the normal templating APIs should +continue to work without problems, you should make sure to remove any +leftover traces of the `template.py` file on the installation path. +This is not necessary when Genshi was installed as a Python egg. + + Upgrading from Markup --------------------- diff --git a/genshi/__init__.py b/genshi/__init__.py --- a/genshi/__init__.py +++ b/genshi/__init__.py @@ -17,39 +17,6 @@ The design is centered around the concept of streams of markup events (similar in concept to SAX parsing events) which can be processed in a uniform manner independently of where or how they are produced. - - -Generating content ------------------- - -Literal XML and HTML text can be used to easily produce markup streams -via helper functions in the `genshi.input` module: - ->>> from genshi.input import XML ->>> doc = XML('My document') - -This results in a `Stream` object that can be used in a number of way. - ->>> doc.render(method='html', encoding='utf-8') -'My document' - ->>> from genshi.input import HTML ->>> doc = HTML('My document</HTML>') ->>> doc.render(method='html', encoding='utf-8') -'<html lang="en"><head><title>My document' - ->>> title = doc.select('head/title') ->>> title.render(method='html', encoding='utf-8') -'My document' - - -Markup streams can also be generated programmatically using the -`genshi.builder` module: - ->>> from genshi.builder import tag ->>> doc = tag.doc(tag.title('My document'), lang='en') ->>> doc.generate().render(method='html') -'My document' """ from genshi.core import * diff --git a/genshi/eval.py b/genshi/eval.py deleted file mode 100644 --- a/genshi/eval.py +++ /dev/null @@ -1,427 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright (C) 2006 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/. - -"""Support for "safe" evaluation of Python expressions.""" - -import __builtin__ -from compiler import ast, parse -from compiler.pycodegen import ExpressionCodeGenerator -import new - -__all__ = ['Expression', 'Undefined'] - - -class Expression(object): - """Evaluates Python expressions used in templates. - - >>> data = dict(test='Foo', items=[1, 2, 3], dict={'some': 'thing'}) - >>> Expression('test').evaluate(data) - 'Foo' - - >>> Expression('items[0]').evaluate(data) - 1 - >>> Expression('items[-1]').evaluate(data) - 3 - >>> Expression('dict["some"]').evaluate(data) - 'thing' - - Similar to e.g. Javascript, expressions in templates can use the dot - notation for attribute access to access items in mappings: - - >>> Expression('dict.some').evaluate(data) - 'thing' - - This also works the other way around: item access can be used to access - any object attribute (meaning there's no use for `getattr()` in templates): - - >>> class MyClass(object): - ... myattr = 'Bar' - >>> data = dict(mine=MyClass(), key='myattr') - >>> Expression('mine.myattr').evaluate(data) - 'Bar' - >>> Expression('mine["myattr"]').evaluate(data) - 'Bar' - >>> Expression('mine[key]').evaluate(data) - 'Bar' - - All of the standard Python operators are available to template expressions. - Built-in functions such as `len()` are also available in template - expressions: - - >>> data = dict(items=[1, 2, 3]) - >>> Expression('len(items)').evaluate(data) - 3 - """ - __slots__ = ['source', 'code'] - - def __init__(self, source, filename=None, lineno=-1): - """Create the expression, either from a string, or from an AST node. - - @param source: either a string containing the source code of the - expression, or an AST node - @param filename: the (preferably absolute) name of the file containing - the expression - @param lineno: the number of the line on which the expression was found - """ - if isinstance(source, basestring): - self.source = source - self.code = _compile(_parse(source), self.source, filename=filename, - lineno=lineno) - else: - assert isinstance(source, ast.Node) - self.source = '?' - self.code = _compile(ast.Expression(source), filename=filename, - lineno=lineno) - - def __repr__(self): - return 'Expression(%r)' % self.source - - def evaluate(self, data, nocall=False): - """Evaluate the expression against the given data dictionary. - - @param data: a mapping containing the data to evaluate against - @param nocall: if true, the result of the evaluation is not called if - if it is a callable - @return: the result of the evaluation - """ - retval = eval(self.code, {'data': data, - '_lookup_name': _lookup_name, - '_lookup_attr': _lookup_attr, - '_lookup_item': _lookup_item}, - {'data': data}) - if not nocall and type(retval) is not Undefined and callable(retval): - retval = retval() - return retval - - -class Undefined(object): - """Represents a reference to an undefined variable. - - Unlike the Python runtime, template expressions can refer to an undefined - variable without causing a `NameError` to be raised. The result will be an - instance of the `Undefined´ class, which is treated the same as `False` in - conditions, and acts as an empty collection in iterations: - - >>> foo = Undefined('foo') - >>> bool(foo) - False - >>> list(foo) - [] - >>> print foo - undefined - - However, calling an undefined variable, or trying to access an attribute - of that variable, will raise an exception that includes the name used to - reference that undefined variable. - - >>> foo('bar') - Traceback (most recent call last): - ... - NameError: Variable "foo" is not defined - - >>> foo.bar - Traceback (most recent call last): - ... - NameError: Variable "foo" is not defined - """ - __slots__ = ['_name'] - - def __init__(self, name): - self._name = name - - def __call__(self, *args, **kwargs): - __traceback_hide__ = True - self.throw() - - def __getattr__(self, name): - __traceback_hide__ = True - self.throw() - - def __iter__(self): - return iter([]) - - def __nonzero__(self): - return False - - def __repr__(self): - return 'undefined' - - def throw(self): - __traceback_hide__ = True - raise NameError('Variable "%s" is not defined' % self._name) - - -def _parse(source, mode='eval'): - if isinstance(source, unicode): - source = '\xef\xbb\xbf' + source.encode('utf-8') - return parse(source, mode) - -def _compile(node, source=None, filename=None, lineno=-1): - tree = ExpressionASTTransformer().visit(node) - if isinstance(filename, unicode): - # unicode file names not allowed for code objects - filename = filename.encode('utf-8', 'replace') - elif not filename: - filename = '' - tree.filename = filename - if lineno <= 0: - lineno = 1 - - gen = ExpressionCodeGenerator(tree) - gen.optimized = True - code = gen.getCode() - - # We'd like to just set co_firstlineno, but it's readonly. So we need to - # clone the code object while adjusting the line number - return new.code(0, code.co_nlocals, code.co_stacksize, - code.co_flags | 0x0040, code.co_code, code.co_consts, - code.co_names, code.co_varnames, filename, - '' % (repr(source or '?').replace("'", '"')), - lineno, code.co_lnotab, (), ()) - -BUILTINS = __builtin__.__dict__.copy() -BUILTINS['Undefined'] = Undefined - -def _lookup_name(data, name, locals_=None): - __traceback_hide__ = True - val = Undefined - if locals_: - val = locals_.get(name, val) - if val is Undefined: - val = data.get(name, val) - if val is Undefined: - val = BUILTINS.get(name, val) - if val is not Undefined or name == 'Undefined': - return val - else: - return val - else: - return val - return val(name) - -def _lookup_attr(data, obj, key): - __traceback_hide__ = True - if type(obj) is Undefined: - obj.throw() - if hasattr(obj, key): - return getattr(obj, key) - try: - return obj[key] - except (KeyError, TypeError): - return Undefined(key) - -def _lookup_item(data, obj, key): - __traceback_hide__ = True - if type(obj) is Undefined: - obj.throw() - if len(key) == 1: - key = key[0] - try: - return obj[key] - except (KeyError, IndexError, TypeError), e: - if isinstance(key, basestring): - val = getattr(obj, key, Undefined) - if val is Undefined: - val = Undefined(key) - return val - raise - - -class ASTTransformer(object): - """General purpose base class for AST transformations. - - Every visitor method can be overridden to return an AST node that has been - altered or replaced in some way. - """ - _visitors = {} - - def visit(self, node, *args, **kwargs): - v = self._visitors.get(node.__class__) - if not v: - v = getattr(self, 'visit%s' % node.__class__.__name__) - self._visitors[node.__class__] = v - return v(node, *args, **kwargs) - - def visitExpression(self, node, *args, **kwargs): - node.node = self.visit(node.node, *args, **kwargs) - return node - - # Functions & Accessors - - def visitCallFunc(self, node, *args, **kwargs): - node.node = self.visit(node.node, *args, **kwargs) - node.args = [self.visit(x, *args, **kwargs) for x in node.args] - if node.star_args: - node.star_args = self.visit(node.star_args, *args, **kwargs) - if node.dstar_args: - node.dstar_args = self.visit(node.dstar_args, *args, **kwargs) - return node - - def visitLambda(self, node, *args, **kwargs): - node.code = self.visit(node.code, *args, **kwargs) - node.filename = '' # workaround for bug in pycodegen - return node - - def visitGetattr(self, node, *args, **kwargs): - node.expr = self.visit(node.expr, *args, **kwargs) - return node - - def visitSubscript(self, node, *args, **kwargs): - node.expr = self.visit(node.expr, *args, **kwargs) - node.subs = [self.visit(x, *args, **kwargs) for x in node.subs] - return node - - # Operators - - def _visitBoolOp(self, node, *args, **kwargs): - node.nodes = [self.visit(x, *args, **kwargs) for x in node.nodes] - return node - visitAnd = visitOr = visitBitand = visitBitor = _visitBoolOp - - def _visitBinOp(self, node, *args, **kwargs): - node.left = self.visit(node.left, *args, **kwargs) - node.right = self.visit(node.right, *args, **kwargs) - return node - visitAdd = visitSub = _visitBinOp - visitDiv = visitFloorDiv = visitMod = visitMul = visitPower = _visitBinOp - visitLeftShift = visitRightShift = _visitBinOp - - def visitCompare(self, node, *args, **kwargs): - node.expr = self.visit(node.expr, *args, **kwargs) - node.ops = [(op, self.visit(n, *args, **kwargs)) for op, n in node.ops] - return node - - def _visitUnaryOp(self, node, *args, **kwargs): - node.expr = self.visit(node.expr, *args, **kwargs) - return node - visitUnaryAdd = visitUnarySub = visitNot = visitInvert = _visitUnaryOp - visitBackquote = _visitUnaryOp - - # Identifiers, Literals and Comprehensions - - def _visitDefault(self, node, *args, **kwargs): - return node - visitAssName = visitAssTuple = _visitDefault - visitConst = visitName = _visitDefault - - def visitDict(self, node, *args, **kwargs): - node.items = [(self.visit(k, *args, **kwargs), - self.visit(v, *args, **kwargs)) for k, v in node.items] - return node - - def visitGenExpr(self, node, *args, **kwargs): - node.code = self.visit(node.code, *args, **kwargs) - node.filename = '' # workaround for bug in pycodegen - return node - - def visitGenExprFor(self, node, *args, **kwargs): - node.assign = self.visit(node.assign, *args, **kwargs) - node.iter = self.visit(node.iter, *args, **kwargs) - node.ifs = [self.visit(x, *args, **kwargs) for x in node.ifs] - return node - - def visitGenExprIf(self, node, *args, **kwargs): - node.test = self.visit(node.test, *args, **kwargs) - return node - - def visitGenExprInner(self, node, *args, **kwargs): - node.expr = self.visit(node.expr, *args, **kwargs) - node.quals = [self.visit(x, *args, **kwargs) for x in node.quals] - return node - - def visitKeyword(self, node, *args, **kwargs): - node.expr = self.visit(node.expr, *args, **kwargs) - return node - - def visitList(self, node, *args, **kwargs): - node.nodes = [self.visit(n, *args, **kwargs) for n in node.nodes] - return node - - def visitListComp(self, node, *args, **kwargs): - node.expr = self.visit(node.expr, *args, **kwargs) - node.quals = [self.visit(x, *args, **kwargs) for x in node.quals] - return node - - def visitListCompFor(self, node, *args, **kwargs): - node.assign = self.visit(node.assign, *args, **kwargs) - node.list = self.visit(node.list, *args, **kwargs) - node.ifs = [self.visit(x, *args, **kwargs) for x in node.ifs] - return node - - def visitListCompIf(self, node, *args, **kwargs): - node.test = self.visit(node.test, *args, **kwargs) - return node - - def visitSlice(self, node, *args, **kwargs): - node.expr = self.visit(node.expr, locals_=True, *args, **kwargs) - if node.lower is not None: - node.lower = self.visit(node.lower, *args, **kwargs) - if node.upper is not None: - node.upper = self.visit(node.upper, *args, **kwargs) - return node - - def visitSliceobj(self, node, *args, **kwargs): - node.nodes = [self.visit(x, *args, **kwargs) for x in node.nodes] - return node - - def visitTuple(self, node, *args, **kwargs): - node.nodes = [self.visit(n, *args, **kwargs) for n in node.nodes] - return node - - -class ExpressionASTTransformer(ASTTransformer): - """Concrete AST transformer that implements the AST transformations needed - for template expressions. - """ - - def visitConst(self, node, locals_=False): - if isinstance(node.value, str): - return ast.Const(node.value.decode('utf-8')) - return node - - def visitGenExprIf(self, node, *args, **kwargs): - node.test = self.visit(node.test, locals_=True) - return node - - def visitGenExprInner(self, node, *args, **kwargs): - node.expr = self.visit(node.expr, locals_=True) - node.quals = [self.visit(x) for x in node.quals] - return node - - def visitGetattr(self, node, locals_=False): - return ast.CallFunc(ast.Name('_lookup_attr'), [ - ast.Name('data'), self.visit(node.expr, locals_=locals_), - ast.Const(node.attrname) - ]) - - def visitLambda(self, node, locals_=False): - node.code = self.visit(node.code, locals_=True) - node.filename = '' # workaround for bug in pycodegen - return node - - def visitListComp(self, node, locals_=False): - node.expr = self.visit(node.expr, locals_=True) - node.quals = [self.visit(qual, locals_=True) for qual in node.quals] - return node - - def visitName(self, node, locals_=False): - func_args = [ast.Name('data'), ast.Const(node.name)] - if locals_: - func_args.append(ast.CallFunc(ast.Name('locals'), [])) - return ast.CallFunc(ast.Name('_lookup_name'), func_args) - - def visitSubscript(self, node, locals_=False): - return ast.CallFunc(ast.Name('_lookup_item'), [ - ast.Name('data'), self.visit(node.expr, locals_=locals_), - ast.Tuple([self.visit(sub, locals_=locals_) for sub in node.subs]) - ]) diff --git a/genshi/plugin.py b/genshi/plugin.py deleted file mode 100644 --- a/genshi/plugin.py +++ /dev/null @@ -1,156 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright (C) 2006 Edgewall Software -# Copyright (C) 2006 Matthew Good -# 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 support for the template engine plugin API used by TurboGears and -CherryPy/Buffet. -""" - -from pkg_resources import resource_filename - -from genshi.eval import Undefined -from genshi.input import ET, HTML, XML -from genshi.output import DocType -from genshi.template import Context, MarkupTemplate, Template, TemplateLoader, \ - TextTemplate - - -class ConfigurationError(Exception): - """Exception raised when invalid plugin options are encountered.""" - - -class AbstractTemplateEnginePlugin(object): - """Implementation of the plugin API.""" - - template_class = None - extension = None - - def __init__(self, extra_vars_func=None, options=None): - self.get_extra_vars = extra_vars_func - if options is None: - options = {} - self.options = options - - self.default_encoding = options.get('genshi.default_encoding', 'utf-8') - auto_reload = options.get('genshi.auto_reload', '1').lower() \ - in ('1', 'yes', 'true') - search_path = options.get('genshi.search_path', '').split(':') - try: - max_cache_size = int(options.get('genshi.max_cache_size', 25)) - except ValueError: - raise ConfigurationError('Invalid value for max_cache_size: "%s"' % - max_cache_size) - - self.loader = TemplateLoader(filter(None, search_path), - auto_reload=auto_reload, - max_cache_size=max_cache_size) - - def load_template(self, templatename, template_string=None): - """Find a template specified in python 'dot' notation, or load one from - a string. - """ - if template_string is not None: - return self.template_class(template_string) - - divider = templatename.rfind('.') - if divider >= 0: - package = templatename[:divider] - basename = templatename[divider + 1:] + self.extension - templatename = resource_filename(package, basename) - - return self.loader.load(templatename, cls=self.template_class) - - def _get_render_options(self, format=None): - if format is None: - format = self.default_format - kwargs = {'method': format} - if self.default_encoding: - kwargs['encoding'] = self.default_encoding - return kwargs - - def render(self, info, format=None, fragment=False, template=None): - """Render the template to a string using the provided info.""" - kwargs = self._get_render_options(format=format) - return self.transform(info, template).render(**kwargs) - - def transform(self, info, template): - """Render the output to an event stream.""" - if not isinstance(template, Template): - template = self.load_template(template) - ctxt = Context(**info) - - # Some functions for Kid compatibility - def defined(name): - return ctxt.get(name, Undefined) is not Undefined - ctxt['defined'] = defined - def value_of(name, default=None): - return ctxt.get(name, default) - ctxt['value_of'] = value_of - - return template.generate(ctxt) - - -class MarkupTemplateEnginePlugin(AbstractTemplateEnginePlugin): - """Implementation of the plugin API for markup templates.""" - - template_class = MarkupTemplate - extension = '.html' - - doctypes = {'html': DocType.HTML, 'html-strict': DocType.HTML_STRICT, - 'html-transitional': DocType.HTML_TRANSITIONAL, - 'xhtml': DocType.XHTML, 'xhtml-strict': DocType.XHTML_STRICT, - 'xhtml-transitional': DocType.XHTML_TRANSITIONAL} - - def __init__(self, extra_vars_func=None, options=None): - AbstractTemplateEnginePlugin.__init__(self, extra_vars_func, options) - - doctype = options.get('genshi.default_doctype') - if doctype and doctype not in self.doctypes: - raise ConfigurationError('Unknown doctype "%s"' % doctype) - self.default_doctype = self.doctypes.get(doctype) - - format = options.get('genshi.default_format', 'html') - if format not in ('html', 'xhtml', 'xml', 'text'): - raise ConfigurationError('Unknown output format "%s"' % format) - self.default_format = format - - def _get_render_options(self, format=None): - kwargs = super(MarkupTemplateEnginePlugin, - self)._get_render_options(format) - if self.default_doctype: - kwargs['doctype'] = self.default_doctype - return kwargs - - def transform(self, info, template): - """Render the output to an event stream.""" - data = {'ET': ET, 'HTML': HTML, 'XML': XML} - if self.get_extra_vars: - data.update(self.get_extra_vars()) - data.update(info) - return super(MarkupTemplateEnginePlugin, self).transform(data, template) - - -class TextTemplateEnginePlugin(AbstractTemplateEnginePlugin): - """Implementation of the plugin API for text templates.""" - - template_class = TextTemplate - extension = '.txt' - default_format = 'text' - - def transform(self, info, template): - """Render the output to an event stream.""" - data = {} - if self.get_extra_vars: - data.update(self.get_extra_vars()) - data.update(info) - return super(TextTemplateEnginePlugin, self).transform(data, template) diff --git a/genshi/template.py b/genshi/template/__init__.py rename from genshi/template.py rename to genshi/template/__init__.py --- a/genshi/template.py +++ b/genshi/template/__init__.py @@ -13,1396 +13,9 @@ """Implementation of the template engine.""" -from itertools import chain -try: - from collections import deque -except ImportError: - class deque(list): - def appendleft(self, x): self.insert(0, x) - def popleft(self): return self.pop(0) -import compiler -import os -import re -from StringIO import StringIO -try: - import threading -except ImportError: - import dummy_threading as threading - -from genshi.core import Attrs, Namespace, Stream, StreamEventKind, _ensure -from genshi.core import START, END, START_NS, END_NS, TEXT, COMMENT -from genshi.eval import Expression, _parse -from genshi.input import XMLParser -from genshi.path import Path -from genshi.util import LRUCache - -__all__ = ['BadDirectiveError', 'MarkupTemplate', 'Template', 'TemplateError', - 'TemplateSyntaxError', 'TemplateNotFound', 'TemplateLoader', - 'TextTemplate'] - - -class TemplateError(Exception): - """Base exception class for errors related to template processing.""" - - -class TemplateSyntaxError(TemplateError): - """Exception raised when an expression in a template causes a Python syntax - error.""" - - def __init__(self, message, filename='', lineno=-1, offset=-1): - if isinstance(message, SyntaxError) and message.lineno is not None: - message = str(message).replace(' (line %d)' % message.lineno, '') - self.msg = message - message = '%s (%s, line %d)' % (self.msg, filename, lineno) - TemplateError.__init__(self, message) - self.filename = filename - self.lineno = lineno - self.offset = offset - - -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='', lineno=-1): - message = 'bad directive "%s"' % name - TemplateSyntaxError.__init__(self, message, filename, lineno) - - -class TemplateRuntimeError(TemplateError): - """Exception raised when an the evualation of a Python expression in a - template causes an error.""" - - def __init__(self, message, filename='', lineno=-1, offset=-1): - self.msg = message - message = '%s (%s, line %d)' % (self.msg, filename, lineno) - TemplateError.__init__(self, message) - self.filename = filename - self.lineno = lineno - self.offset = offset - - -class TemplateNotFound(TemplateError): - """Exception raised when a specific template file could not be found.""" - - def __init__(self, name, search_path): - TemplateError.__init__(self, 'Template "%s" not found' % name) - self.search_path = search_path - - -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): - self.frames = deque([data]) - self.pop = self.frames.popleft - self.push = self.frames.appendleft - self._match_templates = [] - - def __repr__(self): - return repr(list(self.frames)) - - def __setitem__(self, key, value): - """Set a variable in the current scope.""" - self.frames[0][key] = value - - def _find(self, key, default=None): - """Retrieve a given variable's value and the frame it was found in. - - Intented for internal use by directives. - """ - 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. - """ - for frame in self.frames: - if key in frame: - return frame[key] - return default - __getitem__ = get - - def push(self, data): - """Push a new scope on the stack.""" - - def pop(self): - """Pop the top-most scope from the stack.""" - - -class Directive(object): - """Abstract base class for template directives. - - A directive is basically a callable that takes three positional arguments: - `ctxt` is the template data context, `stream` is an iterable over the - events that the directive applies to, and `directives` is is a list of - other directives on the same stream that need to be applied. - - Directives can be "anonymous" or "registered". Registered directives can be - applied by the template author using an XML attribute with the - corresponding name in the template. Such directives should be subclasses of - this base class that can be instantiated with the value of the directive - attribute as parameter. - - Anonymous directives are simply functions conforming to the protocol - described above, and can only be applied programmatically (for example by - template filters). - """ - __slots__ = ['expr'] - - def __init__(self, value, namespaces=None, filename=None, lineno=-1, - offset=-1): - try: - self.expr = value and Expression(value, filename, lineno) or None - except SyntaxError, err: - err.msg += ' in expression "%s" of "%s" directive' % (value, - self.tagname) - raise TemplateSyntaxError(err, filename, lineno, - offset + (err.offset or 0)) - - def __call__(self, stream, ctxt, directives): - raise NotImplementedError - - def __repr__(self): - expr = '' - if self.expr is not None: - expr = ' "%s"' % self.expr.source - return '<%s%s>' % (self.__class__.__name__, expr) - - def tagname(self): - """Return the local tag name of the directive as it is used in - templates. - """ - return self.__class__.__name__.lower().replace('directive', '') - tagname = property(tagname) - - -def _apply_directives(stream, ctxt, directives): - """Apply the given directives to the stream.""" - if directives: - stream = directives[0](iter(stream), ctxt, directives[1:]) - return stream - -def _assignment(ast): - """Takes the AST representation of an assignment, and returns a function - that applies the assignment of a given value to a dictionary. - """ - def _names(node): - if isinstance(node, (compiler.ast.AssTuple, compiler.ast.Tuple)): - return tuple([_names(child) for child in node.nodes]) - elif isinstance(node, (compiler.ast.AssName, compiler.ast.Name)): - return node.name - def _assign(data, value, names=_names(ast)): - if type(names) is tuple: - for idx in range(len(names)): - _assign(data, value[idx], names[idx]) - else: - data[names] = value - return _assign - - -class AttrsDirective(Directive): - """Implementation of the `py:attrs` template directive. - - The value of the `py:attrs` attribute should be a dictionary or a sequence - of `(name, value)` tuples. The items in that dictionary or sequence are - added as attributes to the element: - - >>> tmpl = MarkupTemplate('''
    - ...
  • Bar
  • - ...
''') - >>> print tmpl.generate(foo={'class': 'collapse'}) -
    -
  • Bar
  • -
- >>> print tmpl.generate(foo=[('class', 'collapse')]) -
    -
  • Bar
  • -
- - If the value evaluates to `None` (or any other non-truth value), no - attributes are added: - - >>> print tmpl.generate(foo=None) -
    -
  • Bar
  • -
- """ - __slots__ = [] - - def __call__(self, stream, ctxt, directives): - def _generate(): - kind, (tag, attrib), pos = stream.next() - attrs = self.expr.evaluate(ctxt) - if attrs: - attrib = Attrs(attrib[:]) - if isinstance(attrs, Stream): - try: - attrs = iter(attrs).next() - except StopIteration: - attrs = [] - elif not isinstance(attrs, list): # assume it's a dict - attrs = attrs.items() - for name, value in attrs: - if value is None: - attrib.remove(name) - else: - attrib.set(name, unicode(value).strip()) - yield kind, (tag, attrib), pos - for event in stream: - yield event - - return _apply_directives(_generate(), ctxt, directives) - - -class ContentDirective(Directive): - """Implementation of the `py:content` template directive. - - This directive replaces the content of the element with the result of - evaluating the value of the `py:content` attribute: - - >>> tmpl = MarkupTemplate('''
    - ...
  • Hello
  • - ...
''') - >>> print tmpl.generate(bar='Bye') -
    -
  • Bye
  • -
- """ - __slots__ = [] - - def __call__(self, stream, ctxt, directives): - def _generate(): - yield stream.next() - yield EXPR, self.expr, (None, -1, -1) - event = stream.next() - for next in stream: - event = next - yield event - - return _apply_directives(_generate(), ctxt, directives) - - -class DefDirective(Directive): - """Implementation of the `py:def` template directive. - - This directive can be used to create "Named Template Functions", which - are template snippets that are not actually output during normal - processing, but rather can be expanded from expressions in other places - in the template. - - A named template function can be used just like a normal Python function - from template expressions: - - >>> tmpl = MarkupTemplate('''
- ...

- ... ${greeting}, ${name}! - ...

- ... ${echo('Hi', name='you')} - ...
''') - >>> print tmpl.generate(bar='Bye') -
-

- Hi, you! -

-
- - If a function does not require parameters, the parenthesis can be omitted - both when defining and when calling it: - - >>> tmpl = MarkupTemplate('''
- ...

- ... Hello, world! - ...

- ... ${helloworld} - ...
''') - >>> print tmpl.generate(bar='Bye') -
-

- Hello, world! -

-
- """ - __slots__ = ['name', 'args', 'defaults'] - - ATTRIBUTE = 'function' - - def __init__(self, args, namespaces=None, filename=None, lineno=-1, - offset=-1): - Directive.__init__(self, None, namespaces, filename, lineno, offset) - ast = _parse(args).node - self.args = [] - self.defaults = {} - if isinstance(ast, compiler.ast.CallFunc): - self.name = ast.node.name - for arg in ast.args: - if isinstance(arg, compiler.ast.Keyword): - self.args.append(arg.name) - self.defaults[arg.name] = Expression(arg.expr, filename, - lineno) - else: - self.args.append(arg.name) - else: - self.name = ast.name - - def __call__(self, stream, ctxt, directives): - stream = list(stream) - - def function(*args, **kwargs): - scope = {} - args = list(args) # make mutable - for name in self.args: - if args: - scope[name] = args.pop(0) - else: - if name in kwargs: - val = kwargs.pop(name) - else: - val = self.defaults.get(name).evaluate(ctxt) - scope[name] = val - ctxt.push(scope) - for event in _apply_directives(stream, ctxt, directives): - yield event - ctxt.pop() - try: - function.__name__ = self.name - except TypeError: - # Function name can't be set in Python 2.3 - pass - - # Store the function reference in the bottom context frame so that it - # doesn't get popped off before processing the template has finished - # FIXME: this makes context data mutable as a side-effect - ctxt.frames[-1][self.name] = function - - return [] - - def __repr__(self): - return '<%s "%s">' % (self.__class__.__name__, self.name) - - -class ForDirective(Directive): - """Implementation of the `py:for` template directive for repeating an - element based on an iterable in the context data. - - >>> tmpl = MarkupTemplate('''
    - ...
  • ${item}
  • - ...
''') - >>> print tmpl.generate(items=[1, 2, 3]) -
    -
  • 1
  • 2
  • 3
  • -
- """ - __slots__ = ['assign', 'filename'] - - ATTRIBUTE = 'each' - - def __init__(self, value, namespaces=None, filename=None, lineno=-1, - offset=-1): - if ' in ' not in value: - raise TemplateSyntaxError('"in" keyword missing in "for" directive', - filename, lineno, offset) - assign, value = value.split(' in ', 1) - ast = _parse(assign, 'exec') - self.assign = _assignment(ast.node.nodes[0].expr) - self.filename = filename - Directive.__init__(self, value.strip(), namespaces, filename, lineno, - offset) - - def __call__(self, stream, ctxt, directives): - iterable = self.expr.evaluate(ctxt) - if iterable is None: - return - - assign = self.assign - scope = {} - stream = list(stream) - try: - iterator = iter(iterable) - for item in iterator: - assign(scope, item) - ctxt.push(scope) - for event in _apply_directives(stream, ctxt, directives): - yield event - ctxt.pop() - except TypeError, e: - raise TemplateRuntimeError(str(e), self.filename, *stream[0][2][1:]) - - def __repr__(self): - return '<%s>' % self.__class__.__name__ - - -class IfDirective(Directive): - """Implementation of the `py:if` template directive for conditionally - excluding elements from being output. - - >>> tmpl = MarkupTemplate('''
- ... ${bar} - ...
''') - >>> print tmpl.generate(foo=True, bar='Hello') -
- Hello -
- """ - __slots__ = [] - - ATTRIBUTE = 'test' - - def __call__(self, stream, ctxt, directives): - if self.expr.evaluate(ctxt): - return _apply_directives(stream, ctxt, directives) - return [] - - -class MatchDirective(Directive): - """Implementation of the `py:match` template directive. - - >>> tmpl = MarkupTemplate('''
- ... - ... Hello ${select('@name')} - ... - ... - ...
''') - >>> print tmpl.generate() -
- - Hello Dude - -
- """ - __slots__ = ['path', 'namespaces'] - - ATTRIBUTE = 'path' - - def __init__(self, value, namespaces=None, filename=None, lineno=-1, - offset=-1): - Directive.__init__(self, None, namespaces, filename, lineno, offset) - self.path = Path(value, filename, lineno) - if namespaces is None: - namespaces = {} - self.namespaces = namespaces.copy() - - def __call__(self, stream, ctxt, directives): - ctxt._match_templates.append((self.path.test(ignore_context=True), - self.path, list(stream), self.namespaces, - directives)) - return [] - - def __repr__(self): - return '<%s "%s">' % (self.__class__.__name__, self.path.source) - - -class ReplaceDirective(Directive): - """Implementation of the `py:replace` template directive. - - This directive replaces the element with the result of evaluating the - value of the `py:replace` attribute: - - >>> tmpl = MarkupTemplate('''
- ... Hello - ...
''') - >>> print tmpl.generate(bar='Bye') -
- Bye -
- - This directive is equivalent to `py:content` combined with `py:strip`, - providing a less verbose way to achieve the same effect: - - >>> tmpl = MarkupTemplate('''
- ... Hello - ...
''') - >>> print tmpl.generate(bar='Bye') -
- Bye -
- """ - __slots__ = [] - - def __call__(self, stream, ctxt, directives): - yield EXPR, self.expr, (None, -1, -1) - - -class StripDirective(Directive): - """Implementation of the `py:strip` template directive. - - When the value of the `py:strip` attribute evaluates to `True`, the element - is stripped from the output - - >>> tmpl = MarkupTemplate('''
- ...
foo
- ...
''') - >>> print tmpl.generate() -
- foo -
- - Leaving the attribute value empty is equivalent to a truth value. - - This directive is particulary interesting for named template functions or - match templates that do not generate a top-level element: - - >>> tmpl = MarkupTemplate('''
- ...
- ... ${what} - ...
- ... ${echo('foo')} - ...
''') - >>> print tmpl.generate() -
- foo -
- """ - __slots__ = [] - - def __call__(self, stream, ctxt, directives): - def _generate(): - if self.expr: - strip = self.expr.evaluate(ctxt) - else: - strip = True - if strip: - stream.next() # skip start tag - previous = stream.next() - for event in stream: - yield previous - previous = event - else: - for event in stream: - yield event - - return _apply_directives(_generate(), ctxt, directives) - - -class ChooseDirective(Directive): - """Implementation of the `py:choose` directive for conditionally selecting - one of several body elements to display. - - If the `py:choose` expression is empty the expressions of nested `py:when` - directives are tested for truth. The first true `py:when` body is output. - If no `py:when` directive is matched then the fallback directive - `py:otherwise` will be used. - - >>> tmpl = MarkupTemplate('''
- ... 0 - ... 1 - ... 2 - ...
''') - >>> print tmpl.generate() -
- 1 -
- - If the `py:choose` directive contains an expression, the nested `py:when` - directives are tested for equality to the `py:choose` expression: - - >>> tmpl = MarkupTemplate('''
- ... 1 - ... 2 - ...
''') - >>> print tmpl.generate() -
- 2 -
- - Behavior is undefined if a `py:choose` block contains content outside a - `py:when` or `py:otherwise` block. Behavior is also undefined if a - `py:otherwise` occurs before `py:when` blocks. - """ - __slots__ = ['matched', 'value'] - - ATTRIBUTE = 'test' - - def __call__(self, stream, ctxt, directives): - frame = dict({'_choose.matched': False}) - if self.expr: - frame['_choose.value'] = self.expr.evaluate(ctxt) - ctxt.push(frame) - for event in _apply_directives(stream, ctxt, directives): - yield event - ctxt.pop() - - -class WhenDirective(Directive): - """Implementation of the `py:when` directive for nesting in a parent with - the `py:choose` directive. - - See the documentation of `py:choose` for usage. - """ - __slots__ = ['filename'] - - ATTRIBUTE = 'test' - - def __init__(self, value, namespaces=None, filename=None, lineno=-1, - offset=-1): - Directive.__init__(self, value, namespaces, filename, lineno, offset) - self.filename = filename - - def __call__(self, stream, ctxt, directives): - matched, frame = ctxt._find('_choose.matched') - if not frame: - raise TemplateRuntimeError('"when" directives can only be used ' - 'inside a "choose" directive', - self.filename, *stream.next()[2][1:]) - if matched: - return [] - if not self.expr and '_choose.value' not in frame: - raise TemplateRuntimeError('either "choose" or "when" directive ' - 'must have a test expression', - self.filename, *stream.next()[2][1:]) - if '_choose.value' in frame: - value = frame['_choose.value'] - if self.expr: - matched = value == self.expr.evaluate(ctxt) - else: - matched = bool(value) - else: - matched = bool(self.expr.evaluate(ctxt)) - frame['_choose.matched'] = matched - if not matched: - return [] - - return _apply_directives(stream, ctxt, directives) - - -class OtherwiseDirective(Directive): - """Implementation of the `py:otherwise` directive for nesting in a parent - with the `py:choose` directive. - - See the documentation of `py:choose` for usage. - """ - __slots__ = ['filename'] - - def __init__(self, value, namespaces=None, filename=None, lineno=-1, - offset=-1): - Directive.__init__(self, None, namespaces, filename, lineno, offset) - self.filename = filename - - def __call__(self, stream, ctxt, directives): - matched, frame = ctxt._find('_choose.matched') - if not frame: - raise TemplateRuntimeError('an "otherwise" directive can only be ' - 'used inside a "choose" directive', - self.filename, *stream.next()[2][1:]) - if matched: - return [] - frame['_choose.matched'] = True - - return _apply_directives(stream, ctxt, directives) - - -class WithDirective(Directive): - """Implementation of the `py:with` template directive, which allows - shorthand access to variables and expressions. - - >>> tmpl = MarkupTemplate('''
- ... $x $y $z - ...
''') - >>> print tmpl.generate(x=42) -
- 42 7 52 -
- """ - __slots__ = ['vars'] - - ATTRIBUTE = 'vars' - - def __init__(self, value, namespaces=None, filename=None, lineno=-1, - offset=-1): - Directive.__init__(self, None, namespaces, filename, lineno, offset) - self.vars = [] - value = value.strip() - try: - ast = _parse(value, 'exec').node - for node in ast.nodes: - if isinstance(node, compiler.ast.Discard): - continue - elif not isinstance(node, compiler.ast.Assign): - raise TemplateSyntaxError('only assignment allowed in ' - 'value of the "with" directive', - filename, lineno, offset) - self.vars.append(([_assignment(n) for n in node.nodes], - Expression(node.expr, filename, lineno))) - except SyntaxError, err: - err.msg += ' in expression "%s" of "%s" directive' % (value, - self.tagname) - raise TemplateSyntaxError(err, filename, lineno, - offset + (err.offset or 0)) - - def __call__(self, stream, ctxt, directives): - frame = {} - ctxt.push(frame) - for targets, expr in self.vars: - value = expr.evaluate(ctxt, nocall=True) - for assign in targets: - assign(frame, value) - for event in _apply_directives(stream, ctxt, directives): - yield event - ctxt.pop() - - def __repr__(self): - return '<%s>' % (self.__class__.__name__) - - -class TemplateMeta(type): - """Meta class for templates.""" - - 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 Template(object): - """Abstract template base class. - - This class implements most of the template processing model, but does not - specify the syntax of templates. - """ - __metaclass__ = TemplateMeta - - EXPR = StreamEventKind('EXPR') # an expression - SUB = StreamEventKind('SUB') # a "subprogram" - - def __init__(self, source, basedir=None, filename=None, loader=None, - encoding=None): - """Initialize a template from either a string or a file-like object.""" - if isinstance(source, basestring): - self.source = StringIO(source) - else: - self.source = source - self.basedir = basedir - self.filename = filename - if basedir and filename: - self.filepath = os.path.join(basedir, filename) - else: - self.filepath = filename - - self.filters = [self._flatten, self._eval] - - self.stream = self._parse(encoding) - - def __repr__(self): - return '<%s "%s">' % (self.__class__.__name__, self.filename) - - def _parse(self, 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. - """ - raise NotImplementedError - - _FULL_EXPR_RE = re.compile(r'(?>> tmpl = MarkupTemplate('''
    - ...
  • ${item}
  • - ...
''') - >>> print tmpl.generate(items=[1, 2, 3]) -
    -
  • 1
  • 2
  • 3
  • -
- """ - NAMESPACE = Namespace('http://genshi.edgewall.org/') - - directives = [('def', DefDirective), - ('match', MatchDirective), - ('when', WhenDirective), - ('otherwise', OtherwiseDirective), - ('for', ForDirective), - ('if', IfDirective), - ('choose', ChooseDirective), - ('with', WithDirective), - ('replace', ReplaceDirective), - ('content', ContentDirective), - ('attrs', AttrsDirective), - ('strip', StripDirective)] - - def __init__(self, source, basedir=None, filename=None, loader=None, - encoding=None): - """Initialize a template from either a string or a file-like object.""" - Template.__init__(self, source, basedir=basedir, filename=filename, - loader=loader, encoding=encoding) - - self.filters.append(self._match) - if loader: - from genshi.filters import IncludeFilter - self.filters.append(IncludeFilter(loader)) - - def _parse(self, encoding): - """Parse the template from an XML document.""" - stream = [] # list of events of the "compiled" template - dirmap = {} # temporary mapping of directives to elements - ns_prefix = {} - depth = 0 - - for kind, data, pos in XMLParser(self.source, filename=self.filename, - encoding=encoding): - - if kind is START_NS: - # Strip out the namespace declaration for template directives - prefix, uri = data - ns_prefix[prefix] = uri - if uri != self.NAMESPACE: - stream.append((kind, data, pos)) - - elif kind is END_NS: - uri = ns_prefix.pop(data, None) - if uri and uri != self.NAMESPACE: - stream.append((kind, data, pos)) - - elif kind is START: - # Record any directive attributes in start tags - tag, attrib = data - directives = [] - strip = False - - if tag in self.NAMESPACE: - cls = self._dir_by_name.get(tag.localname) - if cls is None: - raise BadDirectiveError(tag.localname, self.filepath, - pos[1]) - value = attrib.get(getattr(cls, 'ATTRIBUTE', None), '') - directives.append(cls(value, ns_prefix, self.filepath, - pos[1], pos[2])) - strip = True - - new_attrib = [] - for name, value in attrib: - if name in self.NAMESPACE: - cls = self._dir_by_name.get(name.localname) - if cls is None: - raise BadDirectiveError(name.localname, - self.filepath, pos[1]) - directives.append(cls(value, ns_prefix, self.filepath, - pos[1], pos[2])) - else: - if value: - value = list(self._interpolate(value, self.basedir, - *pos)) - if len(value) == 1 and value[0][0] is TEXT: - value = value[0][1] - else: - value = [(TEXT, u'', pos)] - new_attrib.append((name, value)) - - if directives: - index = self._dir_order.index - directives.sort(lambda a, b: cmp(index(a.__class__), - index(b.__class__))) - dirmap[(depth, tag)] = (directives, len(stream), strip) - - stream.append((kind, (tag, Attrs(new_attrib)), pos)) - depth += 1 - - elif kind is END: - depth -= 1 - stream.append((kind, data, pos)) - - # If there have have directive attributes with the corresponding - # start tag, move the events inbetween into a "subprogram" - if (depth, data) in dirmap: - directives, start_offset, strip = dirmap.pop((depth, data)) - substream = stream[start_offset:] - if strip: - substream = substream[1:-1] - stream[start_offset:] = [(SUB, (directives, substream), - pos)] - - elif kind is TEXT: - for kind, data, pos in self._interpolate(data, self.basedir, - *pos): - stream.append((kind, data, pos)) - - elif kind is COMMENT: - if not data.lstrip().startswith('!'): - stream.append((kind, data, pos)) - - else: - stream.append((kind, data, pos)) - - return stream - - def _match(self, stream, ctxt, match_templates=None): - """Internal stream filter that applies any defined match templates - to the stream. - """ - if match_templates is None: - match_templates = ctxt._match_templates - - tail = [] - def _strip(stream): - depth = 1 - while 1: - event = stream.next() - if event[0] is START: - depth += 1 - elif event[0] is END: - depth -= 1 - if depth > 0: - yield event - else: - tail[:] = [event] - break - - for event in stream: - - # We (currently) only care about start and end events for matching - # We might care about namespace events in the future, though - if not match_templates or (event[0] is not START and - event[0] is not END): - yield event - continue - - for idx, (test, path, template, namespaces, directives) in \ - enumerate(match_templates): - - if test(event, namespaces, ctxt) is True: - - # Let the remaining match templates know about the event so - # they get a chance to update their internal state - for test in [mt[0] for mt in match_templates[idx + 1:]]: - test(event, namespaces, ctxt, updateonly=True) - - # Consume and store all events until an end event - # corresponding to this start event is encountered - content = chain([event], self._match(_strip(stream), ctxt), - tail) - for filter_ in self.filters[3:]: - content = filter_(content, ctxt) - content = list(content) - - for test in [mt[0] for mt in match_templates]: - test(tail[0], namespaces, ctxt, updateonly=True) - - # Make the select() function available in the body of the - # match template - def select(path): - return Stream(content).select(path, namespaces, ctxt) - ctxt.push(dict(select=select)) - - # Recursively process the output - template = _apply_directives(template, ctxt, directives) - for event in self._match(self._eval(self._flatten(template, - ctxt), - ctxt), ctxt, - match_templates[:idx] + - match_templates[idx + 1:]): - yield event - - ctxt.pop() - break - - else: # no matches - yield event - - -class TextTemplate(Template): - """Implementation of a simple text-based template engine. - - >>> tmpl = TextTemplate('''Dear $name, - ... - ... We have the following items for you: - ... #for item in items - ... * $item - ... #end - ... - ... All the best, - ... Foobar''') - >>> print tmpl.generate(name='Joe', items=[1, 2, 3]).render('text') - Dear Joe, - - We have the following items for you: - * 1 - * 2 - * 3 - - All the best, - Foobar - """ - directives = [('def', DefDirective), - ('when', WhenDirective), - ('otherwise', OtherwiseDirective), - ('for', ForDirective), - ('if', IfDirective), - ('choose', ChooseDirective), - ('with', WithDirective)] - - _DIRECTIVE_RE = re.compile(r'^\s*(? offset: - text = source[offset:start] - for kind, data, pos in self._interpolate(text, self.basedir, - self.filename, lineno): - stream.append((kind, data, pos)) - lineno += len(text.splitlines()) - - text = source[start:end].lstrip()[1:] - lineno += len(text.splitlines()) - directive = text.split(None, 1) - if len(directive) > 1: - command, value = directive - else: - command, value = directive[0], None - - if command == 'end': - depth -= 1 - if depth in dirmap: - directive, start_offset = dirmap.pop(depth) - substream = stream[start_offset:] - stream[start_offset:] = [(SUB, ([directive], substream), - (self.filepath, lineno, 0))] - elif command != '#': - cls = self._dir_by_name.get(command) - if cls is None: - raise BadDirectiveError(command) - directive = cls(value, None, self.filepath, lineno, 0) - dirmap[depth] = (directive, len(stream)) - depth += 1 - - offset = end - - if offset < len(source): - text = source[offset:].replace('\\#', '#') - for kind, data, pos in self._interpolate(text, self.basedir, - self.filename, lineno): - stream.append((kind, data, pos)) - - return stream - - -class TemplateLoader(object): - """Responsible for loading templates from files on the specified search - path. - - >>> import tempfile - >>> fd, path = tempfile.mkstemp(suffix='.html', prefix='template') - >>> os.write(fd, '

$var

') - 11 - >>> os.close(fd) - - The template loader accepts a list of directory paths that are then used - when searching for template files, in the given order: - - >>> loader = TemplateLoader([os.path.dirname(path)]) - - The `load()` method first checks the template cache whether the requested - template has already been loaded. If not, it attempts to locate the - template file, and returns the corresponding `Template` object: - - >>> template = loader.load(os.path.basename(path)) - >>> isinstance(template, MarkupTemplate) - True - - Template instances are cached: requesting a template with the same name - results in the same instance being returned: - - >>> loader.load(os.path.basename(path)) is template - True - - >>> os.remove(path) - """ - def __init__(self, search_path=None, auto_reload=False, - default_encoding=None, max_cache_size=25): - """Create the template laoder. - - @param search_path: a list of absolute path names that should be - searched for template files, or a string containing a single - absolute path - @param auto_reload: whether to check the last modification time of - template files, and reload them if they have changed - @param default_encoding: the default encoding to assume when loading - templates; defaults to UTF-8 - @param max_cache_size: the maximum number of templates to keep in the - cache - """ - self.search_path = search_path - if self.search_path is None: - self.search_path = [] - elif isinstance(self.search_path, basestring): - self.search_path = [self.search_path] - self.auto_reload = auto_reload - self.default_encoding = default_encoding - self._cache = LRUCache(max_cache_size) - self._mtime = {} - self._lock = threading.Lock() - - def load(self, filename, relative_to=None, cls=MarkupTemplate, - encoding=None): - """Load the template with the given name. - - If the `filename` parameter is relative, this method searches the search - path trying to locate a template matching the given name. If the file - name is an absolute path, the search path is not bypassed. - - If requested template is not found, a `TemplateNotFound` exception is - raised. Otherwise, a `Template` object is returned that represents the - parsed template. - - Template instances are cached to avoid having to parse the same - template file more than once. Thus, subsequent calls of this method - with the same template file name will return the same `Template` - object (unless the `auto_reload` option is enabled and the file was - changed since the last parse.) - - If the `relative_to` parameter is provided, the `filename` is - interpreted as being relative to that path. - - @param filename: the relative path of the template file to load - @param relative_to: the filename of the template from which the new - template is being loaded, or `None` if the template is being loaded - directly - @param cls: the class of the template object to instantiate - @param encoding: the encoding of the template to load; defaults to the - `default_encoding` of the loader instance - """ - if encoding is None: - encoding = self.default_encoding - if relative_to and not os.path.isabs(relative_to): - filename = os.path.join(os.path.dirname(relative_to), filename) - filename = os.path.normpath(filename) - - self._lock.acquire() - try: - # First check the cache to avoid reparsing the same file - try: - tmpl = self._cache[filename] - if not self.auto_reload or \ - os.path.getmtime(tmpl.filepath) == self._mtime[filename]: - return tmpl - except KeyError: - pass - - search_path = self.search_path - isabs = False - - if os.path.isabs(filename): - # Bypass the search path if the requested filename is absolute - search_path = [os.path.dirname(filename)] - isabs = True - - elif relative_to and os.path.isabs(relative_to): - # Make sure that the directory containing the including - # template is on the search path - dirname = os.path.dirname(relative_to) - if dirname not in search_path: - search_path = search_path + [dirname] - isabs = True - - elif not search_path: - # Uh oh, don't know where to look for the template - raise TemplateError('Search path for templates not configured') - - for dirname in search_path: - filepath = os.path.join(dirname, filename) - try: - fileobj = open(filepath, 'U') - try: - if isabs: - # If the filename of either the included or the - # including template is absolute, make sure the - # included template gets an absolute path, too, - # so that nested include work properly without a - # search path - filename = os.path.join(dirname, filename) - dirname = '' - tmpl = cls(fileobj, basedir=dirname, filename=filename, - loader=self, encoding=encoding) - finally: - fileobj.close() - self._cache[filename] = tmpl - self._mtime[filename] = os.path.getmtime(filepath) - return tmpl - except IOError: - continue - - raise TemplateNotFound(filename, search_path) - - finally: - self._lock.release() +from genshi.template.core import Context, Template, TemplateError, \ + TemplateRuntimeError, TemplateSyntaxError, \ + BadDirectiveError +from genshi.template.loader import TemplateLoader, TemplateNotFound +from genshi.template.markup import MarkupTemplate +from genshi.template.text import TextTemplate diff --git a/genshi/template/core.py b/genshi/template/core.py new file mode 100644 --- /dev/null +++ b/genshi/template/core.py @@ -0,0 +1,381 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2006 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/. + +try: + from collections import deque +except ImportError: + class deque(list): + def appendleft(self, x): self.insert(0, x) + def popleft(self): return self.pop(0) +import os +import re +from StringIO import StringIO + +from genshi.core import Attrs, Stream, StreamEventKind, START, TEXT, _ensure +from genshi.template.eval import Expression + +__all__ = ['Context', 'Template', 'TemplateError', 'TemplateRuntimeError', + 'TemplateSyntaxError', 'BadDirectiveError'] + + +class TemplateError(Exception): + """Base exception class for errors related to template processing.""" + + +class TemplateRuntimeError(TemplateError): + """Exception raised when an the evualation of a Python expression in a + template causes an error.""" + + def __init__(self, message, filename='', lineno=-1, offset=-1): + self.msg = message + message = '%s (%s, line %d)' % (self.msg, filename, lineno) + TemplateError.__init__(self, message) + self.filename = filename + self.lineno = lineno + self.offset = offset + + +class TemplateSyntaxError(TemplateError): + """Exception raised when an expression in a template causes a Python syntax + error.""" + + def __init__(self, message, filename='', lineno=-1, offset=-1): + if isinstance(message, SyntaxError) and message.lineno is not None: + message = str(message).replace(' (line %d)' % message.lineno, '') + self.msg = message + message = '%s (%s, line %d)' % (self.msg, filename, lineno) + TemplateError.__init__(self, message) + self.filename = filename + self.lineno = lineno + self.offset = offset + + +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='', lineno=-1): + message = 'bad directive "%s"' % name + TemplateSyntaxError.__init__(self, message, filename, lineno) + + +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): + self.frames = deque([data]) + self.pop = self.frames.popleft + self.push = self.frames.appendleft + self._match_templates = [] + + def __repr__(self): + return repr(list(self.frames)) + + def __setitem__(self, key, value): + """Set a variable in the current scope.""" + self.frames[0][key] = value + + def _find(self, key, default=None): + """Retrieve a given variable's value and the frame it was found in. + + Intented for internal use by directives. + """ + 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. + """ + for frame in self.frames: + if key in frame: + return frame[key] + return default + __getitem__ = get + + def push(self, data): + """Push a new scope on the stack.""" + + def pop(self): + """Pop the top-most scope from the stack.""" + + +class Directive(object): + """Abstract base class for template directives. + + A directive is basically a callable that takes three positional arguments: + `ctxt` is the template data context, `stream` is an iterable over the + events that the directive applies to, and `directives` is is a list of + other directives on the same stream that need to be applied. + + Directives can be "anonymous" or "registered". Registered directives can be + applied by the template author using an XML attribute with the + corresponding name in the template. Such directives should be subclasses of + this base class that can be instantiated with the value of the directive + attribute as parameter. + + Anonymous directives are simply functions conforming to the protocol + described above, and can only be applied programmatically (for example by + template filters). + """ + __slots__ = ['expr'] + + def __init__(self, value, namespaces=None, filename=None, lineno=-1, + offset=-1): + try: + self.expr = value and Expression(value, filename, lineno) or None + except SyntaxError, err: + err.msg += ' in expression "%s" of "%s" directive' % (value, + self.tagname) + raise TemplateSyntaxError(err, filename, lineno, + offset + (err.offset or 0)) + + def __call__(self, stream, ctxt, directives): + raise NotImplementedError + + def __repr__(self): + expr = '' + if self.expr is not None: + expr = ' "%s"' % self.expr.source + return '<%s%s>' % (self.__class__.__name__, expr) + + def tagname(self): + """Return the local tag name of the directive as it is used in + templates. + """ + return self.__class__.__name__.lower().replace('directive', '') + tagname = property(tagname) + + +def _apply_directives(stream, ctxt, directives): + """Apply the given directives to the stream.""" + if directives: + stream = directives[0](iter(stream), ctxt, directives[1:]) + return stream + + +class TemplateMeta(type): + """Meta class for templates.""" + + 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 Template(object): + """Abstract template base class. + + This class implements most of the template processing model, but does not + specify the syntax of templates. + """ + __metaclass__ = TemplateMeta + + EXPR = StreamEventKind('EXPR') # an expression + SUB = StreamEventKind('SUB') # a "subprogram" + + def __init__(self, source, basedir=None, filename=None, loader=None, + encoding=None): + """Initialize a template from either a string or a file-like object.""" + if isinstance(source, basestring): + self.source = StringIO(source) + else: + self.source = source + self.basedir = basedir + self.filename = filename + if basedir and filename: + self.filepath = os.path.join(basedir, filename) + else: + self.filepath = filename + + self.filters = [self._flatten, self._eval] + + self.stream = self._parse(encoding) + + def __repr__(self): + return '<%s "%s">' % (self.__class__.__name__, self.filename) + + def _parse(self, 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. + """ + raise NotImplementedError + + _FULL_EXPR_RE = re.compile(r'(?>> from genshi.template import MarkupTemplate + >>> tmpl = MarkupTemplate('''
    + ...
  • Bar
  • + ...
''') + >>> print tmpl.generate(foo={'class': 'collapse'}) +
    +
  • Bar
  • +
+ >>> print tmpl.generate(foo=[('class', 'collapse')]) +
    +
  • Bar
  • +
+ + If the value evaluates to `None` (or any other non-truth value), no + attributes are added: + + >>> print tmpl.generate(foo=None) +
    +
  • Bar
  • +
+ """ + __slots__ = [] + + def __call__(self, stream, ctxt, directives): + def _generate(): + kind, (tag, attrib), pos = stream.next() + attrs = self.expr.evaluate(ctxt) + if attrs: + attrib = Attrs(attrib[:]) + if isinstance(attrs, Stream): + try: + attrs = iter(attrs).next() + except StopIteration: + attrs = [] + elif not isinstance(attrs, list): # assume it's a dict + attrs = attrs.items() + for name, value in attrs: + if value is None: + attrib.remove(name) + else: + attrib.set(name, unicode(value).strip()) + yield kind, (tag, attrib), pos + for event in stream: + yield event + + return _apply_directives(_generate(), ctxt, directives) + + +class ContentDirective(Directive): + """Implementation of the `py:content` template directive. + + This directive replaces the content of the element with the result of + evaluating the value of the `py:content` attribute: + + >>> from genshi.template import MarkupTemplate + >>> tmpl = MarkupTemplate('''
    + ...
  • Hello
  • + ...
''') + >>> print tmpl.generate(bar='Bye') +
    +
  • Bye
  • +
+ """ + __slots__ = [] + + def __call__(self, stream, ctxt, directives): + def _generate(): + yield stream.next() + yield EXPR, self.expr, (None, -1, -1) + event = stream.next() + for next in stream: + event = next + yield event + + return _apply_directives(_generate(), ctxt, directives) + + +class DefDirective(Directive): + """Implementation of the `py:def` template directive. + + This directive can be used to create "Named Template Functions", which + are template snippets that are not actually output during normal + processing, but rather can be expanded from expressions in other places + in the template. + + A named template function can be used just like a normal Python function + from template expressions: + + >>> from genshi.template import MarkupTemplate + >>> tmpl = MarkupTemplate('''
+ ...

+ ... ${greeting}, ${name}! + ...

+ ... ${echo('Hi', name='you')} + ...
''') + >>> print tmpl.generate(bar='Bye') +
+

+ Hi, you! +

+
+ + If a function does not require parameters, the parenthesis can be omitted + both when defining and when calling it: + + >>> tmpl = MarkupTemplate('''
+ ...

+ ... Hello, world! + ...

+ ... ${helloworld} + ...
''') + >>> print tmpl.generate(bar='Bye') +
+

+ Hello, world! +

+
+ """ + __slots__ = ['name', 'args', 'defaults'] + + ATTRIBUTE = 'function' + + def __init__(self, args, namespaces=None, filename=None, lineno=-1, + offset=-1): + Directive.__init__(self, None, namespaces, filename, lineno, offset) + ast = _parse(args).node + self.args = [] + self.defaults = {} + if isinstance(ast, compiler.ast.CallFunc): + self.name = ast.node.name + for arg in ast.args: + if isinstance(arg, compiler.ast.Keyword): + self.args.append(arg.name) + self.defaults[arg.name] = Expression(arg.expr, filename, + lineno) + else: + self.args.append(arg.name) + else: + self.name = ast.name + + def __call__(self, stream, ctxt, directives): + stream = list(stream) + + def function(*args, **kwargs): + scope = {} + args = list(args) # make mutable + for name in self.args: + if args: + scope[name] = args.pop(0) + else: + if name in kwargs: + val = kwargs.pop(name) + else: + val = self.defaults.get(name).evaluate(ctxt) + scope[name] = val + ctxt.push(scope) + for event in _apply_directives(stream, ctxt, directives): + yield event + ctxt.pop() + try: + function.__name__ = self.name + except TypeError: + # Function name can't be set in Python 2.3 + pass + + # Store the function reference in the bottom context frame so that it + # doesn't get popped off before processing the template has finished + # FIXME: this makes context data mutable as a side-effect + ctxt.frames[-1][self.name] = function + + return [] + + def __repr__(self): + return '<%s "%s">' % (self.__class__.__name__, self.name) + + +class ForDirective(Directive): + """Implementation of the `py:for` template directive for repeating an + element based on an iterable in the context data. + + >>> from genshi.template import MarkupTemplate + >>> tmpl = MarkupTemplate('''
    + ...
  • ${item}
  • + ...
''') + >>> print tmpl.generate(items=[1, 2, 3]) +
    +
  • 1
  • 2
  • 3
  • +
+ """ + __slots__ = ['assign', 'filename'] + + ATTRIBUTE = 'each' + + def __init__(self, value, namespaces=None, filename=None, lineno=-1, + offset=-1): + if ' in ' not in value: + raise TemplateSyntaxError('"in" keyword missing in "for" directive', + filename, lineno, offset) + assign, value = value.split(' in ', 1) + ast = _parse(assign, 'exec') + self.assign = _assignment(ast.node.nodes[0].expr) + self.filename = filename + Directive.__init__(self, value.strip(), namespaces, filename, lineno, + offset) + + def __call__(self, stream, ctxt, directives): + iterable = self.expr.evaluate(ctxt) + if iterable is None: + return + + assign = self.assign + scope = {} + stream = list(stream) + try: + iterator = iter(iterable) + for item in iterator: + assign(scope, item) + ctxt.push(scope) + for event in _apply_directives(stream, ctxt, directives): + yield event + ctxt.pop() + except TypeError, e: + raise TemplateRuntimeError(str(e), self.filename, *stream[0][2][1:]) + + def __repr__(self): + return '<%s>' % self.__class__.__name__ + + +class IfDirective(Directive): + """Implementation of the `py:if` template directive for conditionally + excluding elements from being output. + + >>> from genshi.template import MarkupTemplate + >>> tmpl = MarkupTemplate('''
+ ... ${bar} + ...
''') + >>> print tmpl.generate(foo=True, bar='Hello') +
+ Hello +
+ """ + __slots__ = [] + + ATTRIBUTE = 'test' + + def __call__(self, stream, ctxt, directives): + if self.expr.evaluate(ctxt): + return _apply_directives(stream, ctxt, directives) + return [] + + +class MatchDirective(Directive): + """Implementation of the `py:match` template directive. + + >>> from genshi.template import MarkupTemplate + >>> tmpl = MarkupTemplate('''
+ ... + ... Hello ${select('@name')} + ... + ... + ...
''') + >>> print tmpl.generate() +
+ + Hello Dude + +
+ """ + __slots__ = ['path', 'namespaces'] + + ATTRIBUTE = 'path' + + def __init__(self, value, namespaces=None, filename=None, lineno=-1, + offset=-1): + Directive.__init__(self, None, namespaces, filename, lineno, offset) + self.path = Path(value, filename, lineno) + if namespaces is None: + namespaces = {} + self.namespaces = namespaces.copy() + + def __call__(self, stream, ctxt, directives): + ctxt._match_templates.append((self.path.test(ignore_context=True), + self.path, list(stream), self.namespaces, + directives)) + return [] + + def __repr__(self): + return '<%s "%s">' % (self.__class__.__name__, self.path.source) + + +class ReplaceDirective(Directive): + """Implementation of the `py:replace` template directive. + + This directive replaces the element with the result of evaluating the + value of the `py:replace` attribute: + + >>> from genshi.template import MarkupTemplate + >>> tmpl = MarkupTemplate('''
+ ... Hello + ...
''') + >>> print tmpl.generate(bar='Bye') +
+ Bye +
+ + This directive is equivalent to `py:content` combined with `py:strip`, + providing a less verbose way to achieve the same effect: + + >>> tmpl = MarkupTemplate('''
+ ... Hello + ...
''') + >>> print tmpl.generate(bar='Bye') +
+ Bye +
+ """ + __slots__ = [] + + def __call__(self, stream, ctxt, directives): + yield EXPR, self.expr, (None, -1, -1) + + +class StripDirective(Directive): + """Implementation of the `py:strip` template directive. + + When the value of the `py:strip` attribute evaluates to `True`, the element + is stripped from the output + + >>> from genshi.template import MarkupTemplate + >>> tmpl = MarkupTemplate('''
+ ...
foo
+ ...
''') + >>> print tmpl.generate() +
+ foo +
+ + Leaving the attribute value empty is equivalent to a truth value. + + This directive is particulary interesting for named template functions or + match templates that do not generate a top-level element: + + >>> tmpl = MarkupTemplate('''
+ ...
+ ... ${what} + ...
+ ... ${echo('foo')} + ...
''') + >>> print tmpl.generate() +
+ foo +
+ """ + __slots__ = [] + + def __call__(self, stream, ctxt, directives): + def _generate(): + if self.expr: + strip = self.expr.evaluate(ctxt) + else: + strip = True + if strip: + stream.next() # skip start tag + previous = stream.next() + for event in stream: + yield previous + previous = event + else: + for event in stream: + yield event + + return _apply_directives(_generate(), ctxt, directives) + + +class ChooseDirective(Directive): + """Implementation of the `py:choose` directive for conditionally selecting + one of several body elements to display. + + If the `py:choose` expression is empty the expressions of nested `py:when` + directives are tested for truth. The first true `py:when` body is output. + If no `py:when` directive is matched then the fallback directive + `py:otherwise` will be used. + + >>> from genshi.template import MarkupTemplate + >>> tmpl = MarkupTemplate('''
+ ... 0 + ... 1 + ... 2 + ...
''') + >>> print tmpl.generate() +
+ 1 +
+ + If the `py:choose` directive contains an expression, the nested `py:when` + directives are tested for equality to the `py:choose` expression: + + >>> tmpl = MarkupTemplate('''
+ ... 1 + ... 2 + ...
''') + >>> print tmpl.generate() +
+ 2 +
+ + Behavior is undefined if a `py:choose` block contains content outside a + `py:when` or `py:otherwise` block. Behavior is also undefined if a + `py:otherwise` occurs before `py:when` blocks. + """ + __slots__ = ['matched', 'value'] + + ATTRIBUTE = 'test' + + def __call__(self, stream, ctxt, directives): + frame = dict({'_choose.matched': False}) + if self.expr: + frame['_choose.value'] = self.expr.evaluate(ctxt) + ctxt.push(frame) + for event in _apply_directives(stream, ctxt, directives): + yield event + ctxt.pop() + + +class WhenDirective(Directive): + """Implementation of the `py:when` directive for nesting in a parent with + the `py:choose` directive. + + See the documentation of `py:choose` for usage. + """ + __slots__ = ['filename'] + + ATTRIBUTE = 'test' + + def __init__(self, value, namespaces=None, filename=None, lineno=-1, + offset=-1): + Directive.__init__(self, value, namespaces, filename, lineno, offset) + self.filename = filename + + def __call__(self, stream, ctxt, directives): + matched, frame = ctxt._find('_choose.matched') + if not frame: + raise TemplateRuntimeError('"when" directives can only be used ' + 'inside a "choose" directive', + self.filename, *stream.next()[2][1:]) + if matched: + return [] + if not self.expr and '_choose.value' not in frame: + raise TemplateRuntimeError('either "choose" or "when" directive ' + 'must have a test expression', + self.filename, *stream.next()[2][1:]) + if '_choose.value' in frame: + value = frame['_choose.value'] + if self.expr: + matched = value == self.expr.evaluate(ctxt) + else: + matched = bool(value) + else: + matched = bool(self.expr.evaluate(ctxt)) + frame['_choose.matched'] = matched + if not matched: + return [] + + return _apply_directives(stream, ctxt, directives) + + +class OtherwiseDirective(Directive): + """Implementation of the `py:otherwise` directive for nesting in a parent + with the `py:choose` directive. + + See the documentation of `py:choose` for usage. + """ + __slots__ = ['filename'] + + def __init__(self, value, namespaces=None, filename=None, lineno=-1, + offset=-1): + Directive.__init__(self, None, namespaces, filename, lineno, offset) + self.filename = filename + + def __call__(self, stream, ctxt, directives): + matched, frame = ctxt._find('_choose.matched') + if not frame: + raise TemplateRuntimeError('an "otherwise" directive can only be ' + 'used inside a "choose" directive', + self.filename, *stream.next()[2][1:]) + if matched: + return [] + frame['_choose.matched'] = True + + return _apply_directives(stream, ctxt, directives) + + +class WithDirective(Directive): + """Implementation of the `py:with` template directive, which allows + shorthand access to variables and expressions. + + >>> from genshi.template import MarkupTemplate + >>> tmpl = MarkupTemplate('''
+ ... $x $y $z + ...
''') + >>> print tmpl.generate(x=42) +
+ 42 7 52 +
+ """ + __slots__ = ['vars'] + + ATTRIBUTE = 'vars' + + def __init__(self, value, namespaces=None, filename=None, lineno=-1, + offset=-1): + Directive.__init__(self, None, namespaces, filename, lineno, offset) + self.vars = [] + value = value.strip() + try: + ast = _parse(value, 'exec').node + for node in ast.nodes: + if isinstance(node, compiler.ast.Discard): + continue + elif not isinstance(node, compiler.ast.Assign): + raise TemplateSyntaxError('only assignment allowed in ' + 'value of the "with" directive', + filename, lineno, offset) + self.vars.append(([_assignment(n) for n in node.nodes], + Expression(node.expr, filename, lineno))) + except SyntaxError, err: + err.msg += ' in expression "%s" of "%s" directive' % (value, + self.tagname) + raise TemplateSyntaxError(err, filename, lineno, + offset + (err.offset or 0)) + + def __call__(self, stream, ctxt, directives): + frame = {} + ctxt.push(frame) + for targets, expr in self.vars: + value = expr.evaluate(ctxt, nocall=True) + for assign in targets: + assign(frame, value) + for event in _apply_directives(stream, ctxt, directives): + yield event + ctxt.pop() + + def __repr__(self): + return '<%s>' % (self.__class__.__name__) diff --git a/genshi/template/eval.py b/genshi/template/eval.py new file mode 100644 --- /dev/null +++ b/genshi/template/eval.py @@ -0,0 +1,427 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2006 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/. + +"""Support for "safe" evaluation of Python expressions.""" + +import __builtin__ +from compiler import ast, parse +from compiler.pycodegen import ExpressionCodeGenerator +import new + +__all__ = ['Expression', 'Undefined'] + + +class Expression(object): + """Evaluates Python expressions used in templates. + + >>> data = dict(test='Foo', items=[1, 2, 3], dict={'some': 'thing'}) + >>> Expression('test').evaluate(data) + 'Foo' + + >>> Expression('items[0]').evaluate(data) + 1 + >>> Expression('items[-1]').evaluate(data) + 3 + >>> Expression('dict["some"]').evaluate(data) + 'thing' + + Similar to e.g. Javascript, expressions in templates can use the dot + notation for attribute access to access items in mappings: + + >>> Expression('dict.some').evaluate(data) + 'thing' + + This also works the other way around: item access can be used to access + any object attribute (meaning there's no use for `getattr()` in templates): + + >>> class MyClass(object): + ... myattr = 'Bar' + >>> data = dict(mine=MyClass(), key='myattr') + >>> Expression('mine.myattr').evaluate(data) + 'Bar' + >>> Expression('mine["myattr"]').evaluate(data) + 'Bar' + >>> Expression('mine[key]').evaluate(data) + 'Bar' + + All of the standard Python operators are available to template expressions. + Built-in functions such as `len()` are also available in template + expressions: + + >>> data = dict(items=[1, 2, 3]) + >>> Expression('len(items)').evaluate(data) + 3 + """ + __slots__ = ['source', 'code'] + + def __init__(self, source, filename=None, lineno=-1): + """Create the expression, either from a string, or from an AST node. + + @param source: either a string containing the source code of the + expression, or an AST node + @param filename: the (preferably absolute) name of the file containing + the expression + @param lineno: the number of the line on which the expression was found + """ + if isinstance(source, basestring): + self.source = source + self.code = _compile(_parse(source), self.source, filename=filename, + lineno=lineno) + else: + assert isinstance(source, ast.Node) + self.source = '?' + self.code = _compile(ast.Expression(source), filename=filename, + lineno=lineno) + + def __repr__(self): + return 'Expression(%r)' % self.source + + def evaluate(self, data, nocall=False): + """Evaluate the expression against the given data dictionary. + + @param data: a mapping containing the data to evaluate against + @param nocall: if true, the result of the evaluation is not called if + if it is a callable + @return: the result of the evaluation + """ + retval = eval(self.code, {'data': data, + '_lookup_name': _lookup_name, + '_lookup_attr': _lookup_attr, + '_lookup_item': _lookup_item}, + {'data': data}) + if not nocall and type(retval) is not Undefined and callable(retval): + retval = retval() + return retval + + +class Undefined(object): + """Represents a reference to an undefined variable. + + Unlike the Python runtime, template expressions can refer to an undefined + variable without causing a `NameError` to be raised. The result will be an + instance of the `Undefined´ class, which is treated the same as `False` in + conditions, and acts as an empty collection in iterations: + + >>> foo = Undefined('foo') + >>> bool(foo) + False + >>> list(foo) + [] + >>> print foo + undefined + + However, calling an undefined variable, or trying to access an attribute + of that variable, will raise an exception that includes the name used to + reference that undefined variable. + + >>> foo('bar') + Traceback (most recent call last): + ... + NameError: Variable "foo" is not defined + + >>> foo.bar + Traceback (most recent call last): + ... + NameError: Variable "foo" is not defined + """ + __slots__ = ['_name'] + + def __init__(self, name): + self._name = name + + def __call__(self, *args, **kwargs): + __traceback_hide__ = True + self.throw() + + def __getattr__(self, name): + __traceback_hide__ = True + self.throw() + + def __iter__(self): + return iter([]) + + def __nonzero__(self): + return False + + def __repr__(self): + return 'undefined' + + def throw(self): + __traceback_hide__ = True + raise NameError('Variable "%s" is not defined' % self._name) + + +def _parse(source, mode='eval'): + if isinstance(source, unicode): + source = '\xef\xbb\xbf' + source.encode('utf-8') + return parse(source, mode) + +def _compile(node, source=None, filename=None, lineno=-1): + tree = ExpressionASTTransformer().visit(node) + if isinstance(filename, unicode): + # unicode file names not allowed for code objects + filename = filename.encode('utf-8', 'replace') + elif not filename: + filename = '' + tree.filename = filename + if lineno <= 0: + lineno = 1 + + gen = ExpressionCodeGenerator(tree) + gen.optimized = True + code = gen.getCode() + + # We'd like to just set co_firstlineno, but it's readonly. So we need to + # clone the code object while adjusting the line number + return new.code(0, code.co_nlocals, code.co_stacksize, + code.co_flags | 0x0040, code.co_code, code.co_consts, + code.co_names, code.co_varnames, filename, + '' % (repr(source or '?').replace("'", '"')), + lineno, code.co_lnotab, (), ()) + +BUILTINS = __builtin__.__dict__.copy() +BUILTINS['Undefined'] = Undefined + +def _lookup_name(data, name, locals_=None): + __traceback_hide__ = True + val = Undefined + if locals_: + val = locals_.get(name, val) + if val is Undefined: + val = data.get(name, val) + if val is Undefined: + val = BUILTINS.get(name, val) + if val is not Undefined or name == 'Undefined': + return val + else: + return val + else: + return val + return val(name) + +def _lookup_attr(data, obj, key): + __traceback_hide__ = True + if type(obj) is Undefined: + obj.throw() + if hasattr(obj, key): + return getattr(obj, key) + try: + return obj[key] + except (KeyError, TypeError): + return Undefined(key) + +def _lookup_item(data, obj, key): + __traceback_hide__ = True + if type(obj) is Undefined: + obj.throw() + if len(key) == 1: + key = key[0] + try: + return obj[key] + except (KeyError, IndexError, TypeError), e: + if isinstance(key, basestring): + val = getattr(obj, key, Undefined) + if val is Undefined: + val = Undefined(key) + return val + raise + + +class ASTTransformer(object): + """General purpose base class for AST transformations. + + Every visitor method can be overridden to return an AST node that has been + altered or replaced in some way. + """ + _visitors = {} + + def visit(self, node, *args, **kwargs): + v = self._visitors.get(node.__class__) + if not v: + v = getattr(self, 'visit%s' % node.__class__.__name__) + self._visitors[node.__class__] = v + return v(node, *args, **kwargs) + + def visitExpression(self, node, *args, **kwargs): + node.node = self.visit(node.node, *args, **kwargs) + return node + + # Functions & Accessors + + def visitCallFunc(self, node, *args, **kwargs): + node.node = self.visit(node.node, *args, **kwargs) + node.args = [self.visit(x, *args, **kwargs) for x in node.args] + if node.star_args: + node.star_args = self.visit(node.star_args, *args, **kwargs) + if node.dstar_args: + node.dstar_args = self.visit(node.dstar_args, *args, **kwargs) + return node + + def visitLambda(self, node, *args, **kwargs): + node.code = self.visit(node.code, *args, **kwargs) + node.filename = '' # workaround for bug in pycodegen + return node + + def visitGetattr(self, node, *args, **kwargs): + node.expr = self.visit(node.expr, *args, **kwargs) + return node + + def visitSubscript(self, node, *args, **kwargs): + node.expr = self.visit(node.expr, *args, **kwargs) + node.subs = [self.visit(x, *args, **kwargs) for x in node.subs] + return node + + # Operators + + def _visitBoolOp(self, node, *args, **kwargs): + node.nodes = [self.visit(x, *args, **kwargs) for x in node.nodes] + return node + visitAnd = visitOr = visitBitand = visitBitor = _visitBoolOp + + def _visitBinOp(self, node, *args, **kwargs): + node.left = self.visit(node.left, *args, **kwargs) + node.right = self.visit(node.right, *args, **kwargs) + return node + visitAdd = visitSub = _visitBinOp + visitDiv = visitFloorDiv = visitMod = visitMul = visitPower = _visitBinOp + visitLeftShift = visitRightShift = _visitBinOp + + def visitCompare(self, node, *args, **kwargs): + node.expr = self.visit(node.expr, *args, **kwargs) + node.ops = [(op, self.visit(n, *args, **kwargs)) for op, n in node.ops] + return node + + def _visitUnaryOp(self, node, *args, **kwargs): + node.expr = self.visit(node.expr, *args, **kwargs) + return node + visitUnaryAdd = visitUnarySub = visitNot = visitInvert = _visitUnaryOp + visitBackquote = _visitUnaryOp + + # Identifiers, Literals and Comprehensions + + def _visitDefault(self, node, *args, **kwargs): + return node + visitAssName = visitAssTuple = _visitDefault + visitConst = visitName = _visitDefault + + def visitDict(self, node, *args, **kwargs): + node.items = [(self.visit(k, *args, **kwargs), + self.visit(v, *args, **kwargs)) for k, v in node.items] + return node + + def visitGenExpr(self, node, *args, **kwargs): + node.code = self.visit(node.code, *args, **kwargs) + node.filename = '' # workaround for bug in pycodegen + return node + + def visitGenExprFor(self, node, *args, **kwargs): + node.assign = self.visit(node.assign, *args, **kwargs) + node.iter = self.visit(node.iter, *args, **kwargs) + node.ifs = [self.visit(x, *args, **kwargs) for x in node.ifs] + return node + + def visitGenExprIf(self, node, *args, **kwargs): + node.test = self.visit(node.test, *args, **kwargs) + return node + + def visitGenExprInner(self, node, *args, **kwargs): + node.expr = self.visit(node.expr, *args, **kwargs) + node.quals = [self.visit(x, *args, **kwargs) for x in node.quals] + return node + + def visitKeyword(self, node, *args, **kwargs): + node.expr = self.visit(node.expr, *args, **kwargs) + return node + + def visitList(self, node, *args, **kwargs): + node.nodes = [self.visit(n, *args, **kwargs) for n in node.nodes] + return node + + def visitListComp(self, node, *args, **kwargs): + node.expr = self.visit(node.expr, *args, **kwargs) + node.quals = [self.visit(x, *args, **kwargs) for x in node.quals] + return node + + def visitListCompFor(self, node, *args, **kwargs): + node.assign = self.visit(node.assign, *args, **kwargs) + node.list = self.visit(node.list, *args, **kwargs) + node.ifs = [self.visit(x, *args, **kwargs) for x in node.ifs] + return node + + def visitListCompIf(self, node, *args, **kwargs): + node.test = self.visit(node.test, *args, **kwargs) + return node + + def visitSlice(self, node, *args, **kwargs): + node.expr = self.visit(node.expr, locals_=True, *args, **kwargs) + if node.lower is not None: + node.lower = self.visit(node.lower, *args, **kwargs) + if node.upper is not None: + node.upper = self.visit(node.upper, *args, **kwargs) + return node + + def visitSliceobj(self, node, *args, **kwargs): + node.nodes = [self.visit(x, *args, **kwargs) for x in node.nodes] + return node + + def visitTuple(self, node, *args, **kwargs): + node.nodes = [self.visit(n, *args, **kwargs) for n in node.nodes] + return node + + +class ExpressionASTTransformer(ASTTransformer): + """Concrete AST transformer that implements the AST transformations needed + for template expressions. + """ + + def visitConst(self, node, locals_=False): + if isinstance(node.value, str): + return ast.Const(node.value.decode('utf-8')) + return node + + def visitGenExprIf(self, node, *args, **kwargs): + node.test = self.visit(node.test, locals_=True) + return node + + def visitGenExprInner(self, node, *args, **kwargs): + node.expr = self.visit(node.expr, locals_=True) + node.quals = [self.visit(x) for x in node.quals] + return node + + def visitGetattr(self, node, locals_=False): + return ast.CallFunc(ast.Name('_lookup_attr'), [ + ast.Name('data'), self.visit(node.expr, locals_=locals_), + ast.Const(node.attrname) + ]) + + def visitLambda(self, node, locals_=False): + node.code = self.visit(node.code, locals_=True) + node.filename = '' # workaround for bug in pycodegen + return node + + def visitListComp(self, node, locals_=False): + node.expr = self.visit(node.expr, locals_=True) + node.quals = [self.visit(qual, locals_=True) for qual in node.quals] + return node + + def visitName(self, node, locals_=False): + func_args = [ast.Name('data'), ast.Const(node.name)] + if locals_: + func_args.append(ast.CallFunc(ast.Name('locals'), [])) + return ast.CallFunc(ast.Name('_lookup_name'), func_args) + + def visitSubscript(self, node, locals_=False): + return ast.CallFunc(ast.Name('_lookup_item'), [ + ast.Name('data'), self.visit(node.expr, locals_=locals_), + ast.Tuple([self.visit(sub, locals_=locals_) for sub in node.subs]) + ]) diff --git a/genshi/template/loader.py b/genshi/template/loader.py new file mode 100644 --- /dev/null +++ b/genshi/template/loader.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2006 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/. + +"""Template loading and caching.""" + +import os +try: + import threading +except ImportError: + import dummy_threading as threading + +from genshi.template.core import TemplateError +from genshi.template.markup import MarkupTemplate +from genshi.util import LRUCache + +__all__ = ['TemplateLoader', 'TemplateNotFound'] + + +class TemplateNotFound(TemplateError): + """Exception raised when a specific template file could not be found.""" + + def __init__(self, name, search_path): + TemplateError.__init__(self, 'Template "%s" not found' % name) + self.search_path = search_path + + +class TemplateLoader(object): + """Responsible for loading templates from files on the specified search + path. + + >>> import tempfile + >>> fd, path = tempfile.mkstemp(suffix='.html', prefix='template') + >>> os.write(fd, '

$var

') + 11 + >>> os.close(fd) + + The template loader accepts a list of directory paths that are then used + when searching for template files, in the given order: + + >>> loader = TemplateLoader([os.path.dirname(path)]) + + The `load()` method first checks the template cache whether the requested + template has already been loaded. If not, it attempts to locate the + template file, and returns the corresponding `Template` object: + + >>> template = loader.load(os.path.basename(path)) + >>> isinstance(template, MarkupTemplate) + True + + Template instances are cached: requesting a template with the same name + results in the same instance being returned: + + >>> loader.load(os.path.basename(path)) is template + True + + >>> os.remove(path) + """ + def __init__(self, search_path=None, auto_reload=False, + default_encoding=None, max_cache_size=25): + """Create the template laoder. + + @param search_path: a list of absolute path names that should be + searched for template files, or a string containing a single + absolute path + @param auto_reload: whether to check the last modification time of + template files, and reload them if they have changed + @param default_encoding: the default encoding to assume when loading + templates; defaults to UTF-8 + @param max_cache_size: the maximum number of templates to keep in the + cache + """ + self.search_path = search_path + if self.search_path is None: + self.search_path = [] + elif isinstance(self.search_path, basestring): + self.search_path = [self.search_path] + self.auto_reload = auto_reload + self.default_encoding = default_encoding + self._cache = LRUCache(max_cache_size) + self._mtime = {} + self._lock = threading.Lock() + + def load(self, filename, relative_to=None, cls=MarkupTemplate, + encoding=None): + """Load the template with the given name. + + If the `filename` parameter is relative, this method searches the search + path trying to locate a template matching the given name. If the file + name is an absolute path, the search path is not bypassed. + + If requested template is not found, a `TemplateNotFound` exception is + raised. Otherwise, a `Template` object is returned that represents the + parsed template. + + Template instances are cached to avoid having to parse the same + template file more than once. Thus, subsequent calls of this method + with the same template file name will return the same `Template` + object (unless the `auto_reload` option is enabled and the file was + changed since the last parse.) + + If the `relative_to` parameter is provided, the `filename` is + interpreted as being relative to that path. + + @param filename: the relative path of the template file to load + @param relative_to: the filename of the template from which the new + template is being loaded, or `None` if the template is being loaded + directly + @param cls: the class of the template object to instantiate + @param encoding: the encoding of the template to load; defaults to the + `default_encoding` of the loader instance + """ + if encoding is None: + encoding = self.default_encoding + if relative_to and not os.path.isabs(relative_to): + filename = os.path.join(os.path.dirname(relative_to), filename) + filename = os.path.normpath(filename) + + self._lock.acquire() + try: + # First check the cache to avoid reparsing the same file + try: + tmpl = self._cache[filename] + if not self.auto_reload or \ + os.path.getmtime(tmpl.filepath) == self._mtime[filename]: + return tmpl + except KeyError: + pass + + search_path = self.search_path + isabs = False + + if os.path.isabs(filename): + # Bypass the search path if the requested filename is absolute + search_path = [os.path.dirname(filename)] + isabs = True + + elif relative_to and os.path.isabs(relative_to): + # Make sure that the directory containing the including + # template is on the search path + dirname = os.path.dirname(relative_to) + if dirname not in search_path: + search_path = search_path + [dirname] + isabs = True + + elif not search_path: + # Uh oh, don't know where to look for the template + raise TemplateError('Search path for templates not configured') + + for dirname in search_path: + filepath = os.path.join(dirname, filename) + try: + fileobj = open(filepath, 'U') + try: + if isabs: + # If the filename of either the included or the + # including template is absolute, make sure the + # included template gets an absolute path, too, + # so that nested include work properly without a + # search path + filename = os.path.join(dirname, filename) + dirname = '' + tmpl = cls(fileobj, basedir=dirname, filename=filename, + loader=self, encoding=encoding) + finally: + fileobj.close() + self._cache[filename] = tmpl + self._mtime[filename] = os.path.getmtime(filepath) + return tmpl + except IOError: + continue + + raise TemplateNotFound(filename, search_path) + + finally: + self._lock.release() diff --git a/genshi/template/markup.py b/genshi/template/markup.py new file mode 100644 --- /dev/null +++ b/genshi/template/markup.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2006 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/. + +"""Markup templating engine.""" + +from itertools import chain + +from genshi.core import Attrs, Namespace, Stream +from genshi.core import START, END, START_NS, END_NS, TEXT, COMMENT +from genshi.filters import IncludeFilter +from genshi.input import XMLParser +from genshi.template.core import BadDirectiveError, Template, _apply_directives +from genshi.template.core import SUB +from genshi.template.directives import * + + +class MarkupTemplate(Template): + """Implementation of the template language for XML-based templates. + + >>> tmpl = MarkupTemplate('''
    + ...
  • ${item}
  • + ...
''') + >>> print tmpl.generate(items=[1, 2, 3]) +
    +
  • 1
  • 2
  • 3
  • +
+ """ + NAMESPACE = Namespace('http://genshi.edgewall.org/') + + directives = [('def', DefDirective), + ('match', MatchDirective), + ('when', WhenDirective), + ('otherwise', OtherwiseDirective), + ('for', ForDirective), + ('if', IfDirective), + ('choose', ChooseDirective), + ('with', WithDirective), + ('replace', ReplaceDirective), + ('content', ContentDirective), + ('attrs', AttrsDirective), + ('strip', StripDirective)] + + def __init__(self, source, basedir=None, filename=None, loader=None, + encoding=None): + """Initialize a template from either a string or a file-like object.""" + Template.__init__(self, source, basedir=basedir, filename=filename, + loader=loader, encoding=encoding) + + self.filters.append(self._match) + if loader: + self.filters.append(IncludeFilter(loader)) + + def _parse(self, encoding): + """Parse the template from an XML document.""" + stream = [] # list of events of the "compiled" template + dirmap = {} # temporary mapping of directives to elements + ns_prefix = {} + depth = 0 + + for kind, data, pos in XMLParser(self.source, filename=self.filename, + encoding=encoding): + + if kind is START_NS: + # Strip out the namespace declaration for template directives + prefix, uri = data + ns_prefix[prefix] = uri + if uri != self.NAMESPACE: + stream.append((kind, data, pos)) + + elif kind is END_NS: + uri = ns_prefix.pop(data, None) + if uri and uri != self.NAMESPACE: + stream.append((kind, data, pos)) + + elif kind is START: + # Record any directive attributes in start tags + tag, attrib = data + directives = [] + strip = False + + if tag in self.NAMESPACE: + cls = self._dir_by_name.get(tag.localname) + if cls is None: + raise BadDirectiveError(tag.localname, self.filepath, + pos[1]) + value = attrib.get(getattr(cls, 'ATTRIBUTE', None), '') + directives.append(cls(value, ns_prefix, self.filepath, + pos[1], pos[2])) + strip = True + + new_attrib = [] + for name, value in attrib: + if name in self.NAMESPACE: + cls = self._dir_by_name.get(name.localname) + if cls is None: + raise BadDirectiveError(name.localname, + self.filepath, pos[1]) + directives.append(cls(value, ns_prefix, self.filepath, + pos[1], pos[2])) + else: + if value: + value = list(self._interpolate(value, self.basedir, + *pos)) + if len(value) == 1 and value[0][0] is TEXT: + value = value[0][1] + else: + value = [(TEXT, u'', pos)] + new_attrib.append((name, value)) + + if directives: + index = self._dir_order.index + directives.sort(lambda a, b: cmp(index(a.__class__), + index(b.__class__))) + dirmap[(depth, tag)] = (directives, len(stream), strip) + + stream.append((kind, (tag, Attrs(new_attrib)), pos)) + depth += 1 + + elif kind is END: + depth -= 1 + stream.append((kind, data, pos)) + + # If there have have directive attributes with the corresponding + # start tag, move the events inbetween into a "subprogram" + if (depth, data) in dirmap: + directives, start_offset, strip = dirmap.pop((depth, data)) + substream = stream[start_offset:] + if strip: + substream = substream[1:-1] + stream[start_offset:] = [(SUB, (directives, substream), + pos)] + + elif kind is TEXT: + for kind, data, pos in self._interpolate(data, self.basedir, + *pos): + stream.append((kind, data, pos)) + + elif kind is COMMENT: + if not data.lstrip().startswith('!'): + stream.append((kind, data, pos)) + + else: + stream.append((kind, data, pos)) + + return stream + + def _match(self, stream, ctxt, match_templates=None): + """Internal stream filter that applies any defined match templates + to the stream. + """ + if match_templates is None: + match_templates = ctxt._match_templates + + tail = [] + def _strip(stream): + depth = 1 + while 1: + event = stream.next() + if event[0] is START: + depth += 1 + elif event[0] is END: + depth -= 1 + if depth > 0: + yield event + else: + tail[:] = [event] + break + + for event in stream: + + # We (currently) only care about start and end events for matching + # We might care about namespace events in the future, though + if not match_templates or (event[0] is not START and + event[0] is not END): + yield event + continue + + for idx, (test, path, template, namespaces, directives) in \ + enumerate(match_templates): + + if test(event, namespaces, ctxt) is True: + + # Let the remaining match templates know about the event so + # they get a chance to update their internal state + for test in [mt[0] for mt in match_templates[idx + 1:]]: + test(event, namespaces, ctxt, updateonly=True) + + # Consume and store all events until an end event + # corresponding to this start event is encountered + content = chain([event], self._match(_strip(stream), ctxt), + tail) + for filter_ in self.filters[3:]: + content = filter_(content, ctxt) + content = list(content) + + for test in [mt[0] for mt in match_templates]: + test(tail[0], namespaces, ctxt, updateonly=True) + + # Make the select() function available in the body of the + # match template + def select(path): + return Stream(content).select(path, namespaces, ctxt) + ctxt.push(dict(select=select)) + + # Recursively process the output + template = _apply_directives(template, ctxt, directives) + for event in self._match(self._eval(self._flatten(template, + ctxt), + ctxt), ctxt, + match_templates[:idx] + + match_templates[idx + 1:]): + yield event + + ctxt.pop() + break + + else: # no matches + yield event diff --git a/genshi/template/plugin.py b/genshi/template/plugin.py new file mode 100644 --- /dev/null +++ b/genshi/template/plugin.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2006 Edgewall Software +# Copyright (C) 2006 Matthew Good +# 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 support for the template engine plugin API used by TurboGears and +CherryPy/Buffet. +""" + +from pkg_resources import resource_filename + +from genshi.eval import Undefined +from genshi.input import ET, HTML, XML +from genshi.output import DocType +from genshi.template.core import Context, Template +from genshi.template.loader import TemplateLoader +from genshi.template.markup import MarkupTemplate +from genshi.template.text import TextTemplate + + +class ConfigurationError(Exception): + """Exception raised when invalid plugin options are encountered.""" + + +class AbstractTemplateEnginePlugin(object): + """Implementation of the plugin API.""" + + template_class = None + extension = None + + def __init__(self, extra_vars_func=None, options=None): + self.get_extra_vars = extra_vars_func + if options is None: + options = {} + self.options = options + + self.default_encoding = options.get('genshi.default_encoding', 'utf-8') + auto_reload = options.get('genshi.auto_reload', '1').lower() \ + in ('1', 'yes', 'true') + search_path = options.get('genshi.search_path', '').split(':') + try: + max_cache_size = int(options.get('genshi.max_cache_size', 25)) + except ValueError: + raise ConfigurationError('Invalid value for max_cache_size: "%s"' % + max_cache_size) + + self.loader = TemplateLoader(filter(None, search_path), + auto_reload=auto_reload, + max_cache_size=max_cache_size) + + def load_template(self, templatename, template_string=None): + """Find a template specified in python 'dot' notation, or load one from + a string. + """ + if template_string is not None: + return self.template_class(template_string) + + divider = templatename.rfind('.') + if divider >= 0: + package = templatename[:divider] + basename = templatename[divider + 1:] + self.extension + templatename = resource_filename(package, basename) + + return self.loader.load(templatename, cls=self.template_class) + + def _get_render_options(self, format=None): + if format is None: + format = self.default_format + kwargs = {'method': format} + if self.default_encoding: + kwargs['encoding'] = self.default_encoding + return kwargs + + def render(self, info, format=None, fragment=False, template=None): + """Render the template to a string using the provided info.""" + kwargs = self._get_render_options(format=format) + return self.transform(info, template).render(**kwargs) + + def transform(self, info, template): + """Render the output to an event stream.""" + if not isinstance(template, Template): + template = self.load_template(template) + ctxt = Context(**info) + + # Some functions for Kid compatibility + def defined(name): + return ctxt.get(name, Undefined) is not Undefined + ctxt['defined'] = defined + def value_of(name, default=None): + return ctxt.get(name, default) + ctxt['value_of'] = value_of + + return template.generate(ctxt) + + +class MarkupTemplateEnginePlugin(AbstractTemplateEnginePlugin): + """Implementation of the plugin API for markup templates.""" + + template_class = MarkupTemplate + extension = '.html' + + doctypes = {'html': DocType.HTML, 'html-strict': DocType.HTML_STRICT, + 'html-transitional': DocType.HTML_TRANSITIONAL, + 'xhtml': DocType.XHTML, 'xhtml-strict': DocType.XHTML_STRICT, + 'xhtml-transitional': DocType.XHTML_TRANSITIONAL} + + def __init__(self, extra_vars_func=None, options=None): + AbstractTemplateEnginePlugin.__init__(self, extra_vars_func, options) + + doctype = options.get('genshi.default_doctype') + if doctype and doctype not in self.doctypes: + raise ConfigurationError('Unknown doctype "%s"' % doctype) + self.default_doctype = self.doctypes.get(doctype) + + format = options.get('genshi.default_format', 'html') + if format not in ('html', 'xhtml', 'xml', 'text'): + raise ConfigurationError('Unknown output format "%s"' % format) + self.default_format = format + + def _get_render_options(self, format=None): + kwargs = super(MarkupTemplateEnginePlugin, + self)._get_render_options(format) + if self.default_doctype: + kwargs['doctype'] = self.default_doctype + return kwargs + + def transform(self, info, template): + """Render the output to an event stream.""" + data = {'ET': ET, 'HTML': HTML, 'XML': XML} + if self.get_extra_vars: + data.update(self.get_extra_vars()) + data.update(info) + return super(MarkupTemplateEnginePlugin, self).transform(data, template) + + +class TextTemplateEnginePlugin(AbstractTemplateEnginePlugin): + """Implementation of the plugin API for text templates.""" + + template_class = TextTemplate + extension = '.txt' + default_format = 'text' + + def transform(self, info, template): + """Render the output to an event stream.""" + data = {} + if self.get_extra_vars: + data.update(self.get_extra_vars()) + data.update(info) + return super(TextTemplateEnginePlugin, self).transform(data, template) diff --git a/genshi/template/tests/__init__.py b/genshi/template/tests/__init__.py new file mode 100644 --- /dev/null +++ b/genshi/template/tests/__init__.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2006 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/. + +import doctest +import unittest + + +def suite(): + from genshi.template.tests import core, directives, eval, loader, markup, \ + text + suite = unittest.TestSuite() + suite.addTest(core.suite()) + suite.addTest(directives.suite()) + suite.addTest(eval.suite()) + suite.addTest(loader.suite()) + suite.addTest(markup.suite()) + suite.addTest(text.suite()) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/genshi/template/tests/core.py b/genshi/template/tests/core.py new file mode 100644 --- /dev/null +++ b/genshi/template/tests/core.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2006 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/. + +import doctest +import unittest + +from genshi.core import Stream +from genshi.template.core import Template + + +class TemplateTestCase(unittest.TestCase): + """Tests for basic template processing, expression evaluation and error + reporting. + """ + + def test_interpolate_string(self): + parts = list(Template._interpolate('bla')) + self.assertEqual(1, len(parts)) + self.assertEqual(Stream.TEXT, parts[0][0]) + self.assertEqual('bla', parts[0][1]) + + def test_interpolate_simple(self): + parts = list(Template._interpolate('${bla}')) + self.assertEqual(1, len(parts)) + self.assertEqual(Template.EXPR, parts[0][0]) + self.assertEqual('bla', parts[0][1].source) + + def test_interpolate_escaped(self): + parts = list(Template._interpolate('$${bla}')) + self.assertEqual(1, len(parts)) + self.assertEqual(Stream.TEXT, parts[0][0]) + self.assertEqual('${bla}', parts[0][1]) + + def test_interpolate_short(self): + parts = list(Template._interpolate('$bla')) + self.assertEqual(1, len(parts)) + self.assertEqual(Template.EXPR, parts[0][0]) + self.assertEqual('bla', parts[0][1].source) + + def test_interpolate_short_starting_with_underscore(self): + parts = list(Template._interpolate('$_bla')) + self.assertEqual(1, len(parts)) + self.assertEqual(Template.EXPR, parts[0][0]) + self.assertEqual('_bla', parts[0][1].source) + + def test_interpolate_short_containing_underscore(self): + parts = list(Template._interpolate('$foo_bar')) + self.assertEqual(1, len(parts)) + self.assertEqual(Template.EXPR, parts[0][0]) + self.assertEqual('foo_bar', parts[0][1].source) + + def test_interpolate_short_starting_with_dot(self): + parts = list(Template._interpolate('$.bla')) + self.assertEqual(1, len(parts)) + self.assertEqual(Stream.TEXT, parts[0][0]) + self.assertEqual('$.bla', parts[0][1]) + + def test_interpolate_short_containing_dot(self): + parts = list(Template._interpolate('$foo.bar')) + self.assertEqual(1, len(parts)) + self.assertEqual(Template.EXPR, parts[0][0]) + self.assertEqual('foo.bar', parts[0][1].source) + + def test_interpolate_short_starting_with_digit(self): + parts = list(Template._interpolate('$0bla')) + self.assertEqual(1, len(parts)) + self.assertEqual(Stream.TEXT, parts[0][0]) + self.assertEqual('$0bla', parts[0][1]) + + def test_interpolate_short_containing_digit(self): + parts = list(Template._interpolate('$foo0')) + self.assertEqual(1, len(parts)) + self.assertEqual(Template.EXPR, parts[0][0]) + self.assertEqual('foo0', parts[0][1].source) + + def test_interpolate_mixed1(self): + parts = list(Template._interpolate('$foo bar $baz')) + self.assertEqual(3, len(parts)) + self.assertEqual(Template.EXPR, parts[0][0]) + self.assertEqual('foo', parts[0][1].source) + self.assertEqual(Stream.TEXT, parts[1][0]) + self.assertEqual(' bar ', parts[1][1]) + self.assertEqual(Template.EXPR, parts[2][0]) + self.assertEqual('baz', parts[2][1].source) + + def test_interpolate_mixed2(self): + parts = list(Template._interpolate('foo $bar baz')) + self.assertEqual(3, len(parts)) + self.assertEqual(Stream.TEXT, parts[0][0]) + self.assertEqual('foo ', parts[0][1]) + self.assertEqual(Template.EXPR, parts[1][0]) + self.assertEqual('bar', parts[1][1].source) + self.assertEqual(Stream.TEXT, parts[2][0]) + self.assertEqual(' baz', parts[2][1]) + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocTestSuite(Template.__module__)) + suite.addTest(unittest.makeSuite(TemplateTestCase, 'test')) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/genshi/template/tests/directives.py b/genshi/template/tests/directives.py new file mode 100644 --- /dev/null +++ b/genshi/template/tests/directives.py @@ -0,0 +1,910 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2006 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/. + +import doctest +import sys +import unittest + +from genshi.template import directives, MarkupTemplate, TextTemplate, \ + TemplateRuntimeError + + +class AttrsDirectiveTestCase(unittest.TestCase): + """Tests for the `py:attrs` template directive.""" + + def test_combined_with_loop(self): + """ + Verify that the directive has access to the loop variables. + """ + tmpl = MarkupTemplate(""" + + """) + items = [{'id': 1, 'class': 'foo'}, {'id': 2, 'class': 'bar'}] + self.assertEqual(""" + + """, str(tmpl.generate(items=items))) + + def test_update_existing_attr(self): + """ + Verify that an attribute value that evaluates to `None` removes an + existing attribute of that name. + """ + tmpl = MarkupTemplate(""" + + """) + self.assertEqual(""" + + """, str(tmpl.generate())) + + def test_remove_existing_attr(self): + """ + Verify that an attribute value that evaluates to `None` removes an + existing attribute of that name. + """ + tmpl = MarkupTemplate(""" + + """) + self.assertEqual(""" + + """, str(tmpl.generate())) + + +class ChooseDirectiveTestCase(unittest.TestCase): + """Tests for the `py:choose` template directive and the complementary + directives `py:when` and `py:otherwise`.""" + + def test_multiple_true_whens(self): + """ + Verify that, if multiple `py:when` bodies match, only the first is + output. + """ + tmpl = MarkupTemplate("""
+ 1 + 2 + 3 +
""") + self.assertEqual("""
+ 1 +
""", str(tmpl.generate())) + + def test_otherwise(self): + tmpl = MarkupTemplate("""
+ hidden + hello +
""") + self.assertEqual("""
+ hello +
""", str(tmpl.generate())) + + def test_nesting(self): + """ + Verify that `py:choose` blocks can be nested: + """ + tmpl = MarkupTemplate(""" +
+
+ 2 + 3 +
+
+
""") + self.assertEqual(""" +
+
+ 3 +
+
+
""", str(tmpl.generate())) + + def test_complex_nesting(self): + """ + Verify more complex nesting. + """ + tmpl = MarkupTemplate(""" +
+
+ OK + FAIL +
+
+
""") + self.assertEqual(""" +
+
+ OK +
+
+
""", str(tmpl.generate())) + + def test_complex_nesting_otherwise(self): + """ + Verify more complex nesting using otherwise. + """ + tmpl = MarkupTemplate(""" +
+
+ FAIL + OK +
+
+
""") + self.assertEqual(""" +
+
+ OK +
+
+
""", str(tmpl.generate())) + + def test_when_with_strip(self): + """ + Verify that a when directive with a strip directive actually strips of + the outer element. + """ + tmpl = MarkupTemplate(""" +
+ foo +
+
""") + self.assertEqual(""" + foo + """, str(tmpl.generate())) + + def test_when_outside_choose(self): + """ + Verify that a `when` directive outside of a `choose` directive is + reported as an error. + """ + tmpl = MarkupTemplate(""" +
+ """) + self.assertRaises(TemplateRuntimeError, str, tmpl.generate()) + + def test_otherwise_outside_choose(self): + """ + Verify that an `otherwise` directive outside of a `choose` directive is + reported as an error. + """ + tmpl = MarkupTemplate(""" +
+ """) + self.assertRaises(TemplateRuntimeError, str, tmpl.generate()) + + def test_when_without_test(self): + """ + Verify that an `when` directive that doesn't have a `test` attribute + is reported as an error. + """ + tmpl = MarkupTemplate(""" +
+ foo +
+
""") + self.assertRaises(TemplateRuntimeError, str, tmpl.generate()) + + def test_when_without_test_but_with_choose_value(self): + """ + Verify that an `when` directive that doesn't have a `test` attribute + works as expected as long as the parent `choose` directive has a test + expression. + """ + tmpl = MarkupTemplate(""" +
+ foo +
+
""") + self.assertEqual(""" + foo + """, str(tmpl.generate(foo='Yeah'))) + + def test_otherwise_without_test(self): + """ + Verify that an `otherwise` directive can be used without a `test` + attribute. + """ + tmpl = MarkupTemplate(""" +
+ foo +
+
""") + self.assertEqual(""" + foo + """, str(tmpl.generate())) + + def test_as_element(self): + """ + Verify that the directive can also be used as an element. + """ + tmpl = MarkupTemplate(""" + + 1 + 2 + 3 + + """) + self.assertEqual(""" + 1 + """, str(tmpl.generate())) + + def test_in_text_template(self): + """ + Verify that the directive works as expected in a text template. + """ + tmpl = TextTemplate("""#choose + #when 1 == 1 + 1 + #end + #when 2 == 2 + 2 + #end + #when 3 == 3 + 3 + #end + #end""") + self.assertEqual(""" 1\n""", str(tmpl.generate())) + + +class DefDirectiveTestCase(unittest.TestCase): + """Tests for the `py:def` template directive.""" + + def test_function_with_strip(self): + """ + Verify that a named template function with a strip directive actually + strips of the outer element. + """ + tmpl = MarkupTemplate(""" +
+ ${what} +
+ ${echo('foo')} +
""") + self.assertEqual(""" + foo + """, str(tmpl.generate())) + + def test_exec_in_replace(self): + tmpl = MarkupTemplate("""
+

+ ${greeting}, ${name}! +

+
+
""") + self.assertEqual("""
+

+ hello, world! +

+
""", str(tmpl.generate())) + + def test_as_element(self): + """ + Verify that the directive can also be used as an element. + """ + tmpl = MarkupTemplate(""" + + ${what} + + ${echo('foo')} + """) + self.assertEqual(""" + foo + """, str(tmpl.generate())) + + def test_nested_defs(self): + """ + Verify that a template function defined inside a conditional block can + be called from outside that block. + """ + tmpl = MarkupTemplate(""" + + ${what} + + + ${what} + + ${echo('foo')} + """) + self.assertEqual(""" + foo + """, str(tmpl.generate(semantic=True))) + + def test_function_with_default_arg(self): + """ + Verify that keyword arguments work with `py:def` directives. + """ + tmpl = MarkupTemplate(""" + ${what} + ${echo('foo')} + """) + self.assertEqual(""" + foo + """, str(tmpl.generate())) + + def test_invocation_in_attribute(self): + tmpl = MarkupTemplate(""" + ${what or 'something'} +

bar

+
""") + self.assertEqual(""" +

bar

+
""", str(tmpl.generate())) + + def test_invocation_in_attribute_none(self): + tmpl = MarkupTemplate(""" + ${None} +

bar

+
""") + self.assertEqual(""" +

bar

+
""", str(tmpl.generate())) + + def test_function_raising_typeerror(self): + def badfunc(): + raise TypeError + tmpl = MarkupTemplate(""" +
+ ${badfunc()} +
+
+ """) + self.assertRaises(TypeError, list, tmpl.generate(badfunc=badfunc)) + + def test_def_in_matched(self): + tmpl = MarkupTemplate(""" + ${select('*')} + + + ${maketitle(True)} + + """) + self.assertEqual(""" + True + """, str(tmpl.generate())) + + def test_in_text_template(self): + """ + Verify that the directive works as expected in a text template. + """ + tmpl = TextTemplate(""" + #def echo(greeting, name='world') + ${greeting}, ${name}! + #end + ${echo('Hi', name='you')} + """) + self.assertEqual(""" Hi, you! + """, str(tmpl.generate())) + + +class ForDirectiveTestCase(unittest.TestCase): + """Tests for the `py:for` template directive.""" + + def test_loop_with_strip(self): + """ + Verify that the combining the `py:for` directive with `py:strip` works + correctly. + """ + tmpl = MarkupTemplate(""" +
+ ${item} +
+
""") + self.assertEqual(""" + 1 + 2 + 3 + 4 + 5 + """, str(tmpl.generate(items=range(1, 6)))) + + def test_as_element(self): + """ + Verify that the directive can also be used as an element. + """ + tmpl = MarkupTemplate(""" + + ${item} + + """) + self.assertEqual(""" + 1 + 2 + 3 + 4 + 5 + """, str(tmpl.generate(items=range(1, 6)))) + + def test_multi_assignment(self): + """ + Verify that assignment to tuples works correctly. + """ + tmpl = MarkupTemplate(""" + +

key=$k, value=$v

+
+
""") + self.assertEqual(""" +

key=a, value=1

+

key=b, value=2

+
""", str(tmpl.generate(items=dict(a=1, b=2).items()))) + + def test_nested_assignment(self): + """ + Verify that assignment to nested tuples works correctly. + """ + tmpl = MarkupTemplate(""" + +

$idx: key=$k, value=$v

+
+
""") + self.assertEqual(""" +

0: key=a, value=1

+

1: key=b, value=2

+
""", str(tmpl.generate(items=enumerate(dict(a=1, b=2).items())))) + + def test_not_iterable(self): + """ + Verify that assignment to nested tuples works correctly. + """ + tmpl = MarkupTemplate(""" + + $item + + """, filename='test.html') + try: + list(tmpl.generate(foo=12)) + except TemplateRuntimeError, e: + self.assertEqual('test.html', e.filename) + if sys.version_info[:2] >= (2, 4): + self.assertEqual(2, e.lineno) + + +class IfDirectiveTestCase(unittest.TestCase): + """Tests for the `py:if` template directive.""" + + def test_loop_with_strip(self): + """ + Verify that the combining the `py:if` directive with `py:strip` works + correctly. + """ + tmpl = MarkupTemplate(""" + ${bar} + """) + self.assertEqual(""" + Hello + """, str(tmpl.generate(foo=True, bar='Hello'))) + + def test_as_element(self): + """ + Verify that the directive can also be used as an element. + """ + tmpl = MarkupTemplate(""" + ${bar} + """) + self.assertEqual(""" + Hello + """, str(tmpl.generate(foo=True, bar='Hello'))) + + +class MatchDirectiveTestCase(unittest.TestCase): + """Tests for the `py:match` template directive.""" + + def test_with_strip(self): + """ + Verify that a match template can produce the same kind of element that + it matched without entering an infinite recursion. + """ + tmpl = MarkupTemplate(""" + +
${select('text()')}
+
+ Hey Joe +
""") + self.assertEqual(""" +
Hey Joe
+
""", str(tmpl.generate())) + + def test_without_strip(self): + """ + Verify that a match template can produce the same kind of element that + it matched without entering an infinite recursion. + """ + tmpl = MarkupTemplate(""" + +
${select('text()')}
+
+ Hey Joe +
""") + self.assertEqual(""" + +
Hey Joe
+
+
""", str(tmpl.generate())) + + def test_as_element(self): + """ + Verify that the directive can also be used as an element. + """ + tmpl = MarkupTemplate(""" + +
${select('text()')}
+
+ Hey Joe +
""") + self.assertEqual(""" +
Hey Joe
+
""", str(tmpl.generate())) + + def test_recursive_match_1(self): + """ + Match directives are applied recursively, meaning that they are also + applied to any content they may have produced themselves: + """ + tmpl = MarkupTemplate(""" + +
+ ${select('*')} +
+
+ + + + + +
""") + self.assertEqual(""" + +
+ + +
+
+
+
+
+
+
""", str(tmpl.generate())) + + def test_recursive_match_2(self): + """ + When two or more match templates match the same element and also + themselves output the element they match, avoiding recursion is even + more complex, but should work. + """ + tmpl = MarkupTemplate(""" + +