# HG changeset patch # User aflett # Date 1207003670 0 # Node ID af57b12e3dd2e0f7a9450893a0c6527689cf3da6 # Parent 52a597419c0dd90754bb7efaa374a727040f5820 merge in trunk up through r818 - fundamentally changed the way MatchSet works, but actually is more consistent now diff --git a/ChangeLog b/ChangeLog --- a/ChangeLog +++ b/ChangeLog @@ -46,6 +46,31 @@ of the serializer (ticket #146). * Assigning to a variable named `data` in a Python code block no longer breaks context lookup. + * The `Stream.render` now accepts an optional `out` parameter that can be + used to pass in a writable file-like object to use for assembling the + output, instead of building a big string and returning it. + * The XHTML serializer now strips `xml:space` attributes as they are only + allowed on very few tags. + * Match templates are now applied in a more controlled fashion: in the order + they are declared in the template source, all match templates up to (and + including) the matching template itself are applied to the matched content, + whereas the match templates declared after the matching template are only + applied to the generated content (ticket #186). + * The `TemplateLoader` class now provides an `instantiate()` method that can + be overridden by subclasses to implement advanced template instantiation + logic (ticket #204). + * The search path of the `TemplateLoader` class can now contain ''load + functions'' in addition to path strings. A load function is passed the + name of the requested template file, and should return a file-like object + and some metadata. New load functions are supplied for loading from egg + package data, and loading from different loaders depending on the path + prefix of the requested filename (ticket #182). + * Match templates can now be processed without keeping the complete matched + content in memory, which could cause excessive memory use on long pages. + The buffering can be disabled using the new `buffer` optimization hint on + the `` directive. + * Improve error reporting when accessing an attribute in a Python expression + raises an `AttributeError` (ticket #191). Version 0.4.4 diff --git a/doc/templates.txt b/doc/templates.txt --- a/doc/templates.txt +++ b/doc/templates.txt @@ -270,6 +270,12 @@ into a sandboxable template engine; there are sufficient ways to do harm even using plain expressions. +.. warning:: Unfortunately, code blocks are severely limited when running + under Python 2.3: For example, it is not possible to access + variables defined in outer scopes. If you plan to use code blocks + extensively, it is strongly recommended that you run Python 2.4 + or later. + .. _`error handling`: diff --git a/doc/text-templates.txt b/doc/text-templates.txt --- a/doc/text-templates.txt +++ b/doc/text-templates.txt @@ -215,7 +215,7 @@ .. code-block:: genshitext - {% include '%s.txt' % filename %} + {% include ${'%s.txt' % filename} %} Note that a ``TemplateNotFound`` exception is raised if an included file can't be found. @@ -349,10 +349,12 @@ comments are lines that start with two ``#`` characters, and a line-break at the end of a directive is removed automatically. -.. note:: If you're using this old syntax, it is strongly recommended to migrate - to the new syntax. Simply replace any references to ``TextTemplate`` - by ``NewTextTemplate``. On the other hand, if you want to stick with - the old syntax for a while longer, replace references to +.. note:: If you're using this old syntax, it is strongly recommended to + migrate to the new syntax. Simply replace any references to + ``TextTemplate`` by ``NewTextTemplate`` (and also change the + text templates, of course). On the other hand, if you want to stick + with the old syntax for a while longer, replace references to ``TextTemplate`` by ``OldTextTemplate``; while ``TextTemplate`` is still an alias for the old language at this point, that will change - in a future release. + in a future release. But also note that the old syntax may be + dropped entirely in a future release. diff --git a/doc/upgrade.txt b/doc/upgrade.txt --- a/doc/upgrade.txt +++ b/doc/upgrade.txt @@ -30,6 +30,18 @@ warned that lenient error handling may be removed completely in a future release. +There has also been a subtle change to how ``py:match`` templates are +processed: in previous versions, all match templates would be applied +to the content generated by the matching template, and only the +matching template itself was applied recursively to the original +content. This behavior resulted in problems with many kinds of +recursive matching, and hence was changed for 0.5: now, all match +templates declared before the matching template are applied to the +original content, and match templates declared after the matching +template are applied to the generated content. This change should not +have any effect on most applications, but you may want to check your +use of match templates to make sure. + Upgrading from Genshi 0.3.x to 0.4.x ------------------------------------ diff --git a/doc/xml-templates.txt b/doc/xml-templates.txt --- a/doc/xml-templates.txt +++ b/doc/xml-templates.txt @@ -322,6 +322,12 @@ .. _`Using XPath`: streams.html#using-xpath +Match templates are applied both to the original markup as well to the +generated markup. The order in which they are applied depends on the order +they are declared in the template source: a match template defined after +another match template is applied to the output generated by the first match +template. The match templates basically form a pipeline. + This directive can also be used as an element: .. code-block:: genshi @@ -352,6 +358,17 @@ +---------------+-----------+-----------------------------------------------+ | Attribute | Default | Description | +===============+===========+===============================================+ +| ``buffer`` | ``true`` | Whether the matched content should be | +| | | buffered in memory. Buffering can improve | +| | | performance a bit at the cost of needing more | +| | | memory during rendering. Buffering is | +| | | ''required'' for match templates that contain | +| | | more than one invocation of the ``select()`` | +| | | function. If there is only one call, and the | +| | | matched content can potentially be very long, | +| | | consider disabling buffering to avoid | +| | | excessive memory use. | ++---------------+-----------+-----------------------------------------------+ | ``once`` | ``false`` | Whether the engine should stop looking for | | | | more matching elements after the first match. | | | | Use this on match templates that match | diff --git a/genshi/core.py b/genshi/core.py --- a/genshi/core.py +++ b/genshi/core.py @@ -149,7 +149,7 @@ """ return reduce(operator.or_, (self,) + filters) - def render(self, method=None, encoding='utf-8', **kwargs): + def render(self, method=None, encoding='utf-8', out=None, **kwargs): """Return a string representation of the stream. Any additional keyword arguments are passed to the serializer, and thus @@ -161,15 +161,22 @@ the stream is used :param encoding: how the output string should be encoded; if set to `None`, this method returns a `unicode` object - :return: a `str` or `unicode` object + :param out: a file-like object that the output should be written to + instead of being returned as one big string; note that if + this is a file or socket (or similar), the `encoding` must + not be `None` (that is, the output must be encoded) + :return: a `str` or `unicode` object (depending on the `encoding` + parameter), or `None` if the `out` parameter is provided :rtype: `basestring` + :see: XMLSerializer, XHTMLSerializer, HTMLSerializer, TextSerializer + :note: Changed in 0.5: added the `out` parameter """ from genshi.output import encode if method is None: method = self.serializer or 'xml' generator = self.serialize(method=method, **kwargs) - return encode(generator, method=method, encoding=encoding) + return encode(generator, method=method, encoding=encoding, out=out) def select(self, path, namespaces=None, variables=None): """Return a new stream that contains the events matching the given diff --git a/genshi/output.py b/genshi/output.py --- a/genshi/output.py +++ b/genshi/output.py @@ -30,7 +30,7 @@ 'XHTMLSerializer', 'HTMLSerializer', 'TextSerializer'] __docformat__ = 'restructuredtext en' -def encode(iterator, method='xml', encoding='utf-8'): +def encode(iterator, method='xml', encoding='utf-8', out=None): """Encode serializer output into a string. :param iterator: the iterator returned from serializing a stream (basically @@ -39,16 +39,27 @@ representable in the specified encoding are treated :param encoding: how the output string should be encoded; if set to `None`, this method returns a `unicode` object - :return: a string or unicode object (depending on the `encoding` parameter) + :param out: a file-like object that the output should be written to + instead of being returned as one big string; note that if + this is a file or socket (or similar), the `encoding` must + not be `None` (that is, the output must be encoded) + :return: a `str` or `unicode` object (depending on the `encoding` + parameter), or `None` if the `out` parameter is provided + :since: version 0.4.1 + :note: Changed in 0.5: added the `out` parameter """ - output = u''.join(list(iterator)) if encoding is not None: errors = 'replace' if method != 'text' and not isinstance(method, TextSerializer): errors = 'xmlcharrefreplace' - return output.encode(encoding, errors) - return output + _encode = lambda string: string.encode(encoding, errors) + else: + _encode = lambda string: string + if out is None: + return _encode(u''.join(list(iterator))) + for chunk in iterator: + out.write(_encode(chunk)) def get_serializer(method='xml', **kwargs): """Return a serializer object for the given method. @@ -298,6 +309,8 @@ value = attr elif attr == u'xml:lang' and u'lang' not in attrib: buf += [' lang="', escape(value), '"'] + elif attr == u'xml:space': + continue buf += [' ', attr, '="', escape(value), '"'] if kind is EMPTY: if tag in empty_elems: diff --git a/genshi/template/base.py b/genshi/template/base.py --- a/genshi/template/base.py +++ b/genshi/template/base.py @@ -255,18 +255,53 @@ """Pop the top-most scope from the stack.""" -def _apply_directives(stream, ctxt, directives): +def _apply_directives(stream, directives, ctxt, **vars): """Apply the given directives to the stream. :param stream: the stream the directives should be applied to + :param directives: the list of directives to apply :param ctxt: the `Context` - :param directives: the list of directives to apply + :param vars: additional variables that should be available when Python + code is executed :return: the stream with the given directives applied """ if directives: - stream = directives[0](iter(stream), ctxt, directives[1:]) + stream = directives[0](iter(stream), directives[1:], ctxt, **vars) return stream +def _eval_expr(expr, ctxt, **vars): + """Evaluate the given `Expression` object. + + :param expr: the expression to evaluate + :param ctxt: the `Context` + :param vars: additional variables that should be available to the + expression + :return: the result of the evaluation + """ + if vars: + ctxt.push(vars) + retval = expr.evaluate(ctxt) + if vars: + ctxt.pop() + return retval + +def _exec_suite(suite, ctxt, **vars): + """Execute the given `Suite` object. + + :param suite: the code suite to execute + :param ctxt: the `Context` + :param vars: additional variables that should be available to the + code + """ + if vars: + ctxt.push(vars) + ctxt.push({}) + suite.execute(_ctxt2dict(ctxt)) + if vars: + top = ctxt.pop() + ctxt.pop() + ctxt.frames[0].update(top) + class TemplateMeta(type): """Meta class for templates.""" @@ -427,21 +462,24 @@ :return: a markup event stream representing the result of applying the template to the context data. """ + vars = {} if args: assert len(args) == 1 ctxt = args[0] if ctxt is None: ctxt = Context(**kwargs) + else: + vars = kwargs assert isinstance(ctxt, Context) else: ctxt = Context(**kwargs) stream = self.stream for filter_ in self.filters: - stream = filter_(iter(stream), ctxt) + stream = filter_(iter(stream), ctxt, **vars) return Stream(stream, self.serializer) - def _eval(self, stream, ctxt): + def _eval(self, stream, ctxt, **vars): """Internal stream filter that evaluates any expressions in `START` and `TEXT` events. """ @@ -461,7 +499,8 @@ else: values = [] for subkind, subdata, subpos in self._eval(substream, - ctxt): + ctxt, + **vars): if subkind is TEXT: values.append(subdata) value = [x for x in values if x is not None] @@ -471,7 +510,7 @@ yield kind, (tag, Attrs(new_attrs)), pos elif kind is EXPR: - result = data.evaluate(ctxt) + result = _eval_expr(data, ctxt, **vars) if result is not None: # First check for a string, otherwise the iterable test # below succeeds, and the string will be chopped up into @@ -483,7 +522,7 @@ elif hasattr(result, '__iter__'): substream = _ensure(result) for filter_ in filters: - substream = filter_(substream, ctxt) + substream = filter_(substream, ctxt, **vars) for event in substream: yield event else: @@ -492,28 +531,29 @@ else: yield kind, data, pos - def _exec(self, stream, ctxt): + def _exec(self, stream, ctxt, **vars): """Internal stream filter that executes Python code blocks.""" for event in stream: if event[0] is EXEC: - event[1].execute(_ctxt2dict(ctxt)) + _exec_suite(event[1], ctxt, **vars) else: yield event - def _flatten(self, stream, ctxt): + def _flatten(self, stream, ctxt, **vars): """Internal stream filter that expands `SUB` events in the stream.""" for event in stream: if event[0] is SUB: # This event is a list of directives and a list of nested # events to which those directives should be applied directives, substream = event[1] - substream = _apply_directives(substream, ctxt, directives) - for event in self._flatten(substream, ctxt): + substream = _apply_directives(substream, directives, ctxt, + **vars) + for event in self._flatten(substream, ctxt, **vars): yield event else: yield event - def _include(self, stream, ctxt): + def _include(self, stream, ctxt, **vars): """Internal stream filter that performs inclusion of external template files. """ @@ -524,20 +564,21 @@ href, cls, fallback = event[1] if not isinstance(href, basestring): parts = [] - for subkind, subdata, subpos in self._eval(href, ctxt): + for subkind, subdata, subpos in self._eval(href, ctxt, + **vars): if subkind is TEXT: parts.append(subdata) href = u''.join([x for x in parts if x is not None]) try: tmpl = self.loader.load(href, relative_to=event[2][0], cls=cls or self.__class__) - for event in tmpl.generate(ctxt): + for event in tmpl.generate(ctxt, **vars): yield event except TemplateNotFound: if fallback is None: raise for filter_ in self.filters: - fallback = filter_(iter(fallback), ctxt) + fallback = filter_(iter(fallback), ctxt, **vars) for event in fallback: yield event else: diff --git a/genshi/template/directives.py b/genshi/template/directives.py --- a/genshi/template/directives.py +++ b/genshi/template/directives.py @@ -22,7 +22,8 @@ from genshi.core import QName, Stream from genshi.path import Path from genshi.template.base import TemplateRuntimeError, TemplateSyntaxError, \ - EXPR, _apply_directives, _ctxt2dict + EXPR, _apply_directives, _eval_expr, \ + _exec_suite from genshi.template.eval import Expression, Suite, ExpressionASTTransformer, \ _parse @@ -88,13 +89,15 @@ return cls(value, template, namespaces, *pos[1:]), stream attach = classmethod(attach) - def __call__(self, stream, ctxt, directives): + def __call__(self, stream, directives, ctxt, **vars): """Apply the directive to the given stream. :param stream: the event stream - :param ctxt: the context data :param directives: a list of the remaining directives that should process the stream + :param ctxt: the context data + :param vars: additional variables that should be made available when + Python code is executed """ raise NotImplementedError @@ -167,10 +170,10 @@ """ __slots__ = [] - def __call__(self, stream, ctxt, directives): + def __call__(self, stream, directives, ctxt, **vars): def _generate(): kind, (tag, attrib), pos = stream.next() - attrs = self.expr.evaluate(ctxt) + attrs = _eval_expr(self.expr, ctxt, **vars) if attrs: if isinstance(attrs, Stream): try: @@ -186,7 +189,7 @@ for event in stream: yield event - return _apply_directives(_generate(), ctxt, directives) + return _apply_directives(_generate(), directives, ctxt, **vars) class ContentDirective(Directive): @@ -291,7 +294,7 @@ namespaces, pos) attach = classmethod(attach) - def __call__(self, stream, ctxt, directives): + def __call__(self, stream, directives, ctxt, **vars): stream = list(stream) def function(*args, **kwargs): @@ -304,14 +307,14 @@ if name in kwargs: val = kwargs.pop(name) else: - val = self.defaults.get(name).evaluate(ctxt) + val = _eval_expr(self.defaults.get(name), ctxt, **vars) scope[name] = val if not self.star_args is None: scope[self.star_args] = args if not self.dstar_args is None: scope[self.dstar_args] = kwargs ctxt.push(scope) - for event in _apply_directives(stream, ctxt, directives): + for event in _apply_directives(stream, directives, ctxt, **vars): yield event ctxt.pop() try: @@ -364,8 +367,8 @@ namespaces, pos) attach = classmethod(attach) - def __call__(self, stream, ctxt, directives): - iterable = self.expr.evaluate(ctxt) + def __call__(self, stream, directives, ctxt, **vars): + iterable = _eval_expr(self.expr, ctxt, **vars) if iterable is None: return @@ -375,7 +378,7 @@ for item in iterable: assign(scope, item) ctxt.push(scope) - for event in _apply_directives(stream, ctxt, directives): + for event in _apply_directives(stream, directives, ctxt, **vars): yield event ctxt.pop() @@ -405,9 +408,10 @@ namespaces, pos) attach = classmethod(attach) - def __call__(self, stream, ctxt, directives): - if self.expr.evaluate(ctxt): - return _apply_directives(stream, ctxt, directives) + def __call__(self, stream, directives, ctxt, **vars): + value = _eval_expr(self.expr, ctxt, **vars) + if value: + return _apply_directives(stream, directives, ctxt, **vars) return [] @@ -440,6 +444,8 @@ def attach(cls, template, stream, value, namespaces, pos): hints = [] if type(value) is dict: + if value.get('buffer', '').lower() == 'false': + hints.append('not_buffered') if value.get('once', '').lower() == 'true': hints.append('match_once') if value.get('recursive', '').lower() == 'false': @@ -449,7 +455,7 @@ stream attach = classmethod(attach) - def __call__(self, stream, ctxt, directives): + def __call__(self, stream, directives, ctxt, **vars): ctxt._match_set.add((self.path.test(ignore_context=True), self.path, list(stream), self.hints, self.namespaces, directives)) @@ -531,9 +537,9 @@ """ __slots__ = [] - def __call__(self, stream, ctxt, directives): + def __call__(self, stream, directives, ctxt, **vars): def _generate(): - if self.expr.evaluate(ctxt): + if _eval_expr(self.expr, ctxt, **vars): stream.next() # skip start tag previous = stream.next() for event in stream: @@ -542,7 +548,7 @@ else: for event in stream: yield event - return _apply_directives(_generate(), ctxt, directives) + return _apply_directives(_generate(), directives, ctxt, **vars) def attach(cls, template, stream, value, namespaces, pos): if not value: @@ -600,12 +606,12 @@ namespaces, pos) attach = classmethod(attach) - def __call__(self, stream, ctxt, directives): + def __call__(self, stream, directives, ctxt, **vars): info = [False, bool(self.expr), None] if self.expr: - info[2] = self.expr.evaluate(ctxt) + info[2] = _eval_expr(self.expr, ctxt, **vars) ctxt._choice_stack.append(info) - for event in _apply_directives(stream, ctxt, directives): + for event in _apply_directives(stream, directives, ctxt, **vars): yield event ctxt._choice_stack.pop() @@ -629,7 +635,7 @@ namespaces, pos) attach = classmethod(attach) - def __call__(self, stream, ctxt, directives): + def __call__(self, stream, directives, ctxt, **vars): info = ctxt._choice_stack and ctxt._choice_stack[-1] if not info: raise TemplateRuntimeError('"when" directives can only be used ' @@ -644,16 +650,16 @@ if info[1]: value = info[2] if self.expr: - matched = value == self.expr.evaluate(ctxt) + matched = value == _eval_expr(self.expr, ctxt, **vars) else: matched = bool(value) else: - matched = bool(self.expr.evaluate(ctxt)) + matched = bool(_eval_expr(self.expr, ctxt, **vars)) info[0] = matched if not matched: return [] - return _apply_directives(stream, ctxt, directives) + return _apply_directives(stream, directives, ctxt, **vars) class OtherwiseDirective(Directive): @@ -668,7 +674,7 @@ Directive.__init__(self, None, template, namespaces, lineno, offset) self.filename = template.filepath - def __call__(self, stream, ctxt, directives): + def __call__(self, stream, directives, ctxt, **vars): info = ctxt._choice_stack and ctxt._choice_stack[-1] if not info: raise TemplateRuntimeError('an "otherwise" directive can only be ' @@ -678,7 +684,7 @@ return [] info[0] = True - return _apply_directives(stream, ctxt, directives) + return _apply_directives(stream, directives, ctxt, **vars) class WithDirective(Directive): @@ -722,11 +728,10 @@ namespaces, pos) attach = classmethod(attach) - def __call__(self, stream, ctxt, directives): - frame = {} - ctxt.push(frame) - self.suite.execute(_ctxt2dict(ctxt)) - for event in _apply_directives(stream, ctxt, directives): + def __call__(self, stream, directives, ctxt, **vars): + ctxt.push({}) + _exec_suite(self.suite, ctxt, **vars) + for event in _apply_directives(stream, directives, ctxt, **vars): yield event ctxt.pop() diff --git a/genshi/template/eval.py b/genshi/template/eval.py --- a/genshi/template/eval.py +++ b/genshi/template/eval.py @@ -139,8 +139,7 @@ :return: the result of the evaluation """ __traceback_hide__ = 'before_and_this' - _globals = self._globals() - _globals['__data__'] = data + _globals = self._globals(data) return eval(self.code, _globals, {'__data__': data}) @@ -161,8 +160,7 @@ :param data: a mapping containing the data to execute in """ __traceback_hide__ = 'before_and_this' - _globals = self._globals() - _globals['__data__'] = data + _globals = self._globals(data) exec self.code in _globals, data @@ -248,15 +246,16 @@ class LookupBase(object): """Abstract base class for variable lookup implementations.""" - def globals(cls): + def globals(cls, data): """Construct the globals dictionary to use as the execution context for the expression or suite. """ return { + '__data__': data, '_lookup_name': cls.lookup_name, '_lookup_attr': cls.lookup_attr, '_lookup_item': cls.lookup_item, - 'UndefinedError': UndefinedError + 'UndefinedError': UndefinedError, } globals = classmethod(globals) @@ -270,18 +269,22 @@ return val lookup_name = classmethod(lookup_name) - def lookup_attr(cls, data, obj, key): + def lookup_attr(cls, obj, key): __traceback_hide__ = True - val = getattr(obj, key, UNDEFINED) - if val is UNDEFINED: - try: - val = obj[key] - except (KeyError, TypeError): - val = cls.undefined(key, owner=obj) + try: + val = getattr(obj, key) + except AttributeError: + if hasattr(obj.__class__, key): + raise + else: + try: + val = obj[key] + except (KeyError, TypeError): + val = cls.undefined(key, owner=obj) return val lookup_attr = classmethod(lookup_attr) - def lookup_item(cls, data, obj, key): + def lookup_item(cls, obj, key): __traceback_hide__ = True if len(key) == 1: key = key[0] @@ -754,12 +757,12 @@ def visitGetattr(self, node): return ast.CallFunc(ast.Name('_lookup_attr'), [ - ast.Name('__data__'), self.visit(node.expr), + self.visit(node.expr), ast.Const(node.attrname) ]) def visitSubscript(self, node): return ast.CallFunc(ast.Name('_lookup_item'), [ - ast.Name('__data__'), self.visit(node.expr), + self.visit(node.expr), ast.Tuple([self.visit(sub) for sub in node.subs]) ]) diff --git a/genshi/template/loader.py b/genshi/template/loader.py --- a/genshi/template/loader.py +++ b/genshi/template/loader.py @@ -82,7 +82,10 @@ :param search_path: a list of absolute path names that should be searched for template files, or a string containing - a single absolute path + a single absolute path; alternatively, any item on + the list may be a ''load function'' that is passed + a filename and returns a file-like object and some + metadata :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 @@ -109,7 +112,7 @@ self.search_path = search_path if self.search_path is None: self.search_path = [] - elif isinstance(self.search_path, basestring): + elif not isinstance(self.search_path, (list, tuple)): self.search_path = [self.search_path] self.auto_reload = auto_reload @@ -130,9 +133,9 @@ def load(self, filename, relative_to=None, cls=None, 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 ignored. + 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 ignored. If the requested template is not found, a `TemplateNotFound` exception is raised. Otherwise, a `Template` object is returned that represents @@ -155,24 +158,25 @@ :param encoding: the encoding of the template to load; defaults to the ``default_encoding`` of the loader instance :return: the loaded `Template` instance - :raises TemplateNotFound: if a template with the given name could not be - found + :raises TemplateNotFound: if a template with the given name could not + be found """ if cls is None: cls = self.default_class - 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) + cachekey = 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]: + tmpl = self._cache[cachekey] + if not self.auto_reload: + return tmpl + mtime = self._mtime[cachekey] + if mtime and mtime == os.path.getmtime(tmpl.filepath): return tmpl except KeyError, OSError: pass @@ -190,41 +194,135 @@ # template is on the search path dirname = os.path.dirname(relative_to) if dirname not in search_path: - search_path = search_path + [dirname] + search_path = list(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) + for loadfunc in search_path: + if isinstance(loadfunc, basestring): + loadfunc = directory(loadfunc) try: - fileobj = open(filepath, 'U') + dirname, filename, fileobj, mtime = loadfunc(filename) + except IOError: + continue + else: 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 + # so that nested includes work properly without a # search path filename = os.path.join(dirname, filename) dirname = '' - tmpl = cls(fileobj, basedir=dirname, filename=filename, - loader=self, encoding=encoding, - lookup=self.variable_lookup, - allow_exec=self.allow_exec) + tmpl = self.instantiate(cls, fileobj, dirname, + filename, encoding=encoding) if self.callback: self.callback(tmpl) - self._cache[filename] = tmpl - self._mtime[filename] = os.path.getmtime(filepath) + self._cache[cachekey] = tmpl + self._mtime[cachekey] = mtime finally: - fileobj.close() + if hasattr(fileobj, 'close'): + fileobj.close() return tmpl - except IOError: - continue raise TemplateNotFound(filename, search_path) finally: self._lock.release() + + def instantiate(self, cls, fileobj, dirname, filename, encoding=None): + """Instantiate and return the `Template` object based on the given + class and parameters. + + This function is intended for subclasses to override if they need to + implement special template instantiation logic. Code that just uses + the `TemplateLoader` should use the `load` method instead. + + :param cls: the class of the template object to instantiate + :param fileobj: a readable file-like object containing the template + source + :param dirname: the name of the base directory containing the template + file + :param filename: the name of the template file, relative to the given + base directory + :param encoding: the encoding of the template to load; defaults to the + ``default_encoding`` of the loader instance + :return: the loaded `Template` instance + :rtype: `Template` + """ + if encoding is None: + encoding = self.default_encoding + return cls(fileobj, basedir=dirname, filename=filename, loader=self, + encoding=encoding, lookup=self.variable_lookup, + allow_exec=self.allow_exec) + + def directory(path): + """Loader factory for loading templates from a local directory. + + :param path: the path to the local directory containing the templates + :return: the loader function to load templates from the given directory + :rtype: ``function`` + """ + def _load_from_directory(filename): + filepath = os.path.join(path, filename) + fileobj = open(filepath, 'U') + return path, filename, fileobj, os.path.getmtime(filepath) + return _load_from_directory + directory = staticmethod(directory) + + def package(name, path): + """Loader factory for loading templates from egg package data. + + :param name: the name of the package containing the resources + :param path: the path inside the package data + :return: the loader function to load templates from the given package + :rtype: ``function`` + """ + from pkg_resources import resource_stream + def _load_from_package(filename): + filepath = os.path.join(path, filename) + return path, filename, resource_stream(name, filepath), None + return _load_from_package + package = staticmethod(package) + + def prefixed(**delegates): + """Factory for a load function that delegates to other loaders + depending on the prefix of the requested template path. + + The prefix is stripped from the filename when passing on the load + request to the delegate. + + >>> load = prefixed( + ... app1 = lambda filename: ('app1', filename, None, None), + ... app2 = lambda filename: ('app2', filename, None, None) + ... ) + >>> print load('app1/foo.html') + ('', 'app1/foo.html', None, None) + >>> print load('app2/bar.html') + ('', 'app2/bar.html', None, None) + + :param delegates: mapping of path prefixes to loader functions + :return: the loader function + :rtype: ``function`` + """ + def _dispatch_by_prefix(filename): + for prefix, delegate in delegates.items(): + if filename.startswith(prefix): + if isinstance(delegate, basestring): + delegate = directory(delegate) + path, _, fileobj, mtime = delegate( + filename[len(prefix):].lstrip('/\\') + ) + dirname = path[len(prefix):].rstrip('/\\') + return dirname, filename, fileobj, mtime + raise TemplateNotFound(filename, delegates.keys()) + return _dispatch_by_prefix + prefixed = staticmethod(prefixed) + +directory = TemplateLoader.directory +package = TemplateLoader.package +prefixed = TemplateLoader.prefixed diff --git a/genshi/template/markup.py b/genshi/template/markup.py --- a/genshi/template/markup.py +++ b/genshi/template/markup.py @@ -226,7 +226,7 @@ assert len(streams) == 1 return streams[0] - def _match(self, stream, ctxt, match_set=None): + def _match(self, stream, ctxt, match_set=None, **vars): """Internal stream filter that applies any defined match templates to the stream. """ @@ -263,10 +263,26 @@ (test, path, template, hints, namespaces, directives) = \ match_template if test(event, namespaces, ctxt) is True: + post_match_templates = \ + match_set.after_template(match_template) + if 'match_once' in hints: + + # need to save this before we nuke + # match_template from match_set + pre_match_templates = \ + match_set.before_template(match_template, False) + + # forcibly remove this template from this and + # all child match sets match_set.remove(match_template) del match_candidates[idx] idx -= 1 + else: + inclusive = True + if 'not_recursive' in hints: + inclusive=False + pre_match_templates = match_set.before_template(match_template, inclusive) # Let the remaining match templates know about the event so # they get a chance to update their internal state @@ -276,38 +292,37 @@ # Consume and store all events until an end event # corresponding to this start event is encountered inner = _strip(stream) - if 'match_once' not in hints \ - and 'not_recursive' not in hints: - inner = self._match(inner, ctxt, - MatchSet.single_match(match_template)) - content = list(self._include(chain([event], inner, tail), - ctxt)) + if pre_match_templates: + inner = self._match(inner, ctxt, pre_match_templates) + content = self._include(chain([event], inner, tail), ctxt) + if 'not_buffered' not in hints: + content = list(content) # Now tell all the match templates about the # END event (tail[0]) - for test in [mt[0] for mt in match_candidates]: - test(tail[0], namespaces, ctxt, updateonly=True) + if tail: + for test in [mt[0] for mt in match_candidates]: + 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)) + vars = dict(select=select) # Recursively process the output - template = _apply_directives(template, ctxt, directives) - remaining = match_set - if 'match_once' not in hints: - # match has not been removed, so we need an - # exclusion matchset - remaining = match_set.with_exclusion(match_template) - - body = self._exec(self._eval(self._flatten(template, ctxt), - ctxt), ctxt) - for event in self._match(body, ctxt, remaining): + template = _apply_directives(template, directives, ctxt, + **vars) + for event in self._match( + self._exec( + self._eval( + self._flatten(template, ctxt, **vars), + ctxt, **vars), + ctxt, **vars), + ctxt, post_match_templates, + **vars): yield event - ctxt.pop() break else: # no matches diff --git a/genshi/template/match.py b/genshi/template/match.py --- a/genshi/template/match.py +++ b/genshi/template/match.py @@ -2,6 +2,7 @@ from genshi.path import CHILD, LocalNameTest from copy import copy +from itertools import ifilter def is_simple_path(path): """ @@ -35,24 +36,25 @@ If the path is more complex like "xyz[k=z]" then then that match will always be returned by ``find_matches``. """ - def __init__(self, parent=None, exclude=None): + def __init__(self, parent=None, + min_index=None, + max_index=None): """ If a parent is given, it means this is a wrapper around another set. - If exclude is given, it means include everything in the - parent, but exclude a specific match template. """ self.parent = parent - self.current_index = 0 - if parent is None: # merely for indexing. Note that this is shared between # all MatchSets that share the same root parent. We don't # have to worry about exclusions here self.match_order = {} + self.min_index = None + self.max_index = None + # tag_templates are match templates whose path are simply # a tag, like "body" or "img" self.tag_templates = {} @@ -61,23 +63,23 @@ # as ones with complex paths like "[class=container]" self.other_templates = [] - # exclude is a list of templates to ignore when iterating - # through templates - self.exclude = [] - if exclude is not None: - self.exclude.append(exclude) else: # We have a parent: Just copy references to member - # variables in parent so that there's no performance loss, - # but make our own exclusion set, so we don't have to - # chain exclusions across a chain of MatchSets + # variables in parent so that there's no performance loss + self.max_index = parent.max_index + self.min_index = parent.min_index self.match_order = parent.match_order self.tag_templates = parent.tag_templates self.other_templates = parent.other_templates + + if max_index is not None: + assert self.max_index is None or max_index <= self.max_index + self.max_index = max_index + + if min_index is not None: + assert self.min_index is None or min_index > self.min_index + self.min_index = min_index - self.exclude = copy(parent.exclude) - if exclude is not None: - self.exclude.append(exclude) def add(self, match_template): """ @@ -91,7 +93,6 @@ path = match_template[1] - self.current_index += 1 if is_simple_path(path): # special cache of tag tag_name = path.paths[0][0][1].name @@ -133,15 +134,22 @@ return match_set single_match = classmethod(single_match) - def with_exclusion(self, exclude): + def before_template(self, match_template, inclusive): + cls = type(self) + max_index = self.match_order[id(match_template)] + if not inclusive: + max_index -= 1 + return cls(parent=self, max_index=max_index) + + def after_template(self, match_template): """ - Factory for creating a MatchSet based on another MatchSet, but - with certain templates excluded + Factory for creating a MatchSet that only matches templates after + the given match """ - cls = self.__class__ - new_match_set = cls(parent=self, exclude=exclude) - return new_match_set - + cls = type(self) + min_index = self.match_order[id(match_template)] + 1 + return cls(parent=self, min_index=min_index) + def find_raw_matches(self, event): """ Return a list of all valid templates that can be used for the given event. Ordering is funky because we first check @@ -167,8 +175,20 @@ """ # remove exclusions - matches = filter(lambda template: template not in self.exclude, - self.find_raw_matches(event)) + def can_match(template): + # make sure that + if (self.min_index is not None and + self.match_order[id(template)] < self.min_index): + return False + + if (self.max_index is not None and + self.match_order[id(template)] > self.max_index): + return False + + return True + + matches = ifilter(can_match, + self.find_raw_matches(event)) # sort the results according to the order they were added return sorted(matches, key=lambda v: self.match_order[id(v)]) @@ -177,6 +197,21 @@ """ allow this to behave as a list """ + + # this is easy - before the first element there is nothing + if self.max_index == -1: + return False + + # this isn't always right because match_order may shrink, but + # you'll never get a false-negative + if self.min_index == len(self.match_order): + return False + + # check for a range that is completely constrained + if self.min_index is not None and self.max_index is not None: + if self.min_index >= self.max_index: + return False + return bool(self.tag_templates or self.other_templates) def __str__(self): @@ -184,8 +219,7 @@ if self.parent: parent = ": child of 0x%x" % id(self.parent) - exclude = "" - if self.exclude: - exclude = " / excluding %d items" % len(self.exclude) - - return "" % (id(self), len(self.tag_templates), len(self.other_templates), parent, exclude) + return "" % ( + id(self), len(self.tag_templates), len(self.other_templates), + self.min_index, self.max_index, + parent) diff --git a/genshi/template/tests/__init__.py b/genshi/template/tests/__init__.py --- a/genshi/template/tests/__init__.py +++ b/genshi/template/tests/__init__.py @@ -16,7 +16,7 @@ def suite(): from genshi.template.tests import base, directives, eval, interpolation, \ - loader, markup, plugin, text + loader, markup, plugin, text, match suite = unittest.TestSuite() suite.addTest(base.suite()) suite.addTest(directives.suite()) @@ -26,6 +26,7 @@ suite.addTest(markup.suite()) suite.addTest(plugin.suite()) suite.addTest(text.suite()) + suite.addTest(match.suite()) return suite if __name__ == '__main__': diff --git a/genshi/template/tests/directives.py b/genshi/template/tests/directives.py --- a/genshi/template/tests/directives.py +++ b/genshi/template/tests/directives.py @@ -631,6 +631,32 @@ """, str(tmpl.generate())) + def test_recursive_match_3(self): + tmpl = MarkupTemplate(""" + + ${select('*|text()')} + + +
    ${select('*')}
+
+ + ${select('*|text()')} + + + + + 1 + 2 + + +
+ """) + self.assertEqual(""" + +
    12
+
+
""", str(tmpl.generate())) + def test_not_match_self(self): """ See http://genshi.edgewall.org/ticket/77 diff --git a/genshi/template/tests/eval.py b/genshi/template/tests/eval.py --- a/genshi/template/tests/eval.py +++ b/genshi/template/tests/eval.py @@ -342,11 +342,16 @@ def test_getattr_exception(self): class Something(object): - def prop(self): + def prop_a(self): raise NotImplementedError - prop = property(prop) + prop_a = property(prop_a) + def prop_b(self): + raise AttributeError + prop_b = property(prop_b) self.assertRaises(NotImplementedError, - Expression('s.prop').evaluate, {'s': Something()}) + Expression('s.prop_a').evaluate, {'s': Something()}) + self.assertRaises(AttributeError, + Expression('s.prop_b').evaluate, {'s': Something()}) def test_getitem_undefined_string(self): class Something(object): diff --git a/genshi/template/tests/loader.py b/genshi/template/tests/loader.py --- a/genshi/template/tests/loader.py +++ b/genshi/template/tests/loader.py @@ -104,6 +104,34 @@
Included
""", tmpl.generate().render()) + def test_relative_include_samesubdir(self): + file1 = open(os.path.join(self.dirname, 'tmpl1.html'), 'w') + try: + file1.write("""
Included tmpl1.html
""") + finally: + file1.close() + + os.mkdir(os.path.join(self.dirname, 'sub')) + file2 = open(os.path.join(self.dirname, 'sub', 'tmpl1.html'), 'w') + try: + file2.write("""
Included sub/tmpl1.html
""") + finally: + file2.close() + + file3 = open(os.path.join(self.dirname, 'sub', 'tmpl2.html'), 'w') + try: + file3.write(""" + + """) + finally: + file3.close() + + loader = TemplateLoader([self.dirname]) + tmpl = loader.load('sub/tmpl2.html') + self.assertEqual(""" +
Included sub/tmpl1.html
+ """, tmpl.generate().render()) + def test_relative_include_without_search_path(self): file1 = open(os.path.join(self.dirname, 'tmpl1.html'), 'w') try: @@ -172,6 +200,67 @@
Included
""", tmpl2.generate().render()) + def test_relative_absolute_template_preferred(self): + file1 = open(os.path.join(self.dirname, 'tmpl1.html'), 'w') + try: + file1.write("""
Included
""") + finally: + file1.close() + + os.mkdir(os.path.join(self.dirname, 'sub')) + file2 = open(os.path.join(self.dirname, 'sub', 'tmpl1.html'), 'w') + try: + file2.write("""
Included from sub
""") + finally: + file2.close() + + file3 = open(os.path.join(self.dirname, 'sub', 'tmpl2.html'), 'w') + try: + file3.write(""" + + """) + finally: + file3.close() + + loader = TemplateLoader() + tmpl = loader.load(os.path.abspath(os.path.join(self.dirname, 'sub', + 'tmpl2.html'))) + self.assertEqual(""" +
Included from sub
+ """, tmpl.generate().render()) + + def test_abspath_caching(self): + abspath = os.path.join(self.dirname, 'abs') + os.mkdir(abspath) + file1 = open(os.path.join(abspath, 'tmpl1.html'), 'w') + try: + file1.write(""" + + """) + finally: + file1.close() + + file2 = open(os.path.join(abspath, 'tmpl2.html'), 'w') + try: + file2.write("""
Included from abspath.
""") + finally: + file2.close() + + searchpath = os.path.join(self.dirname, 'searchpath') + os.mkdir(searchpath) + file3 = open(os.path.join(searchpath, 'tmpl2.html'), 'w') + try: + file3.write("""
Included from searchpath.
""") + finally: + file3.close() + + loader = TemplateLoader(searchpath) + tmpl1 = loader.load(os.path.join(abspath, 'tmpl1.html')) + self.assertEqual(""" +
Included from searchpath.
+ """, tmpl1.generate().render()) + assert 'tmpl2.html' in loader._cache + def test_load_with_default_encoding(self): f = open(os.path.join(self.dirname, 'tmpl.html'), 'w') try: @@ -219,6 +308,108 @@

Hello, hello

""", tmpl.generate().render()) + def test_prefix_delegation_to_directories(self): + """ + Test prefix delegation with the following layout: + + templates/foo.html + sub1/templates/tmpl1.html + sub2/templates/tmpl2.html + + Where sub1 and sub2 are prefixes, and both tmpl1.html and tmpl2.html + incldue foo.html. + """ + dir1 = os.path.join(self.dirname, 'templates') + os.mkdir(dir1) + file1 = open(os.path.join(dir1, 'foo.html'), 'w') + try: + file1.write("""
Included foo
""") + finally: + file1.close() + + dir2 = os.path.join(self.dirname, 'sub1', 'templates') + os.makedirs(dir2) + file2 = open(os.path.join(dir2, 'tmpl1.html'), 'w') + try: + file2.write(""" + from sub1 + """) + finally: + file2.close() + + dir3 = os.path.join(self.dirname, 'sub2', 'templates') + os.makedirs(dir3) + file3 = open(os.path.join(dir3, 'tmpl2.html'), 'w') + try: + file3.write("""
tmpl2
""") + finally: + file3.close() + + loader = TemplateLoader([dir1, TemplateLoader.prefixed( + sub1 = os.path.join(dir2), + sub2 = os.path.join(dir3) + )]) + tmpl = loader.load('sub1/tmpl1.html') + self.assertEqual(""" +
Included foo
from sub1 + """, tmpl.generate().render()) + + def test_prefix_delegation_to_directories_with_subdirs(self): + """ + Test prefix delegation with the following layout: + + templates/foo.html + sub1/templates/tmpl1.html + sub1/templates/tmpl2.html + sub1/templates/bar/tmpl3.html + + Where sub1 is a prefix, and tmpl1.html includes all the others. + """ + dir1 = os.path.join(self.dirname, 'templates') + os.mkdir(dir1) + file1 = open(os.path.join(dir1, 'foo.html'), 'w') + try: + file1.write("""
Included foo
""") + finally: + file1.close() + + dir2 = os.path.join(self.dirname, 'sub1', 'templates') + os.makedirs(dir2) + file2 = open(os.path.join(dir2, 'tmpl1.html'), 'w') + try: + file2.write(""" + from sub1 + from sub1 + from sub1 + """) + finally: + file2.close() + + file3 = open(os.path.join(dir2, 'tmpl2.html'), 'w') + try: + file3.write("""
tmpl2
""") + finally: + file3.close() + + dir3 = os.path.join(self.dirname, 'sub1', 'templates', 'bar') + os.makedirs(dir3) + file4 = open(os.path.join(dir3, 'tmpl3.html'), 'w') + try: + file4.write("""
bar/tmpl3
""") + finally: + file4.close() + + loader = TemplateLoader([dir1, TemplateLoader.prefixed( + sub1 = os.path.join(dir2), + sub2 = os.path.join(dir3) + )]) + tmpl = loader.load('sub1/tmpl1.html') + self.assertEqual(""" +
Included foo
from sub1 +
tmpl2
from sub1 +
bar/tmpl3
from sub1 + """, tmpl.generate().render()) + def suite(): suite = unittest.TestSuite() diff --git a/genshi/template/tests/markup.py b/genshi/template/tests/markup.py --- a/genshi/template/tests/markup.py +++ b/genshi/template/tests/markup.py @@ -611,6 +611,22 @@ """, tmpl.generate().render()) + def test_with_in_match(self): + xml = (""" + +

${select('text()')}

+ ${select('.')} +
+

${foo}

+ """) + tmpl = MarkupTemplate(xml, filename='test.html') + self.assertEqual(""" + +

bar

+

bar

+ + """, tmpl.generate().render()) + def test_nested_include_matches(self): # See ticket #157 dirname = tempfile.mkdtemp(suffix='genshi_test') diff --git a/genshi/template/tests/match.py b/genshi/template/tests/match.py new file mode 100644 --- /dev/null +++ b/genshi/template/tests/match.py @@ -0,0 +1,141 @@ + +import unittest + +from genshi.core import START, QName +from genshi.path import Path +from genshi.template.match import MatchSet + +class MatchSetTestCase(unittest.TestCase): + + def make_template(self, path): + + template = (path.test(ignore_context=True), + path, [], set(), [], []) + return template + + def make_tag_event(self, tag): + return (START, (QName(unicode(tag)), None), 0) + + def test_simple_match(self): + + m = MatchSet() + t1 = self.make_template(Path("tag")) + m.add(t1) + + t2 = self.make_template(Path("tag2")) + m.add(t2) + + result = m.find_matches(self.make_tag_event("tag")) + + assert t1 in result + assert t2 not in result + + def test_after(self): + + m = MatchSet() + t1 = self.make_template(Path("tag")) + m.add(t1) + t2 = self.make_template(Path("tag2")) + m.add(t2) + + m2 = m.after_template(t1) + + result = m2.find_matches(self.make_tag_event("tag")) + + assert t1 not in result + + result = m2.find_matches(self.make_tag_event("tag2")) + + assert t2 in result + + def test_before_exclusive(self): + + m = MatchSet() + t1 = self.make_template(Path("tag")) + m.add(t1) + t2 = self.make_template(Path("tag2")) + m.add(t2) + + m2 = m.before_template(t2, False) + + result = m2.find_matches(self.make_tag_event("tag2")) + + assert t2 not in result + + result = m2.find_matches(self.make_tag_event("tag")) + + assert t1 in result + + m3 = m.before_template(t1, False) + + assert not m3 + + result = m3.find_matches(self.make_tag_event("tag")) + + assert t1 not in result + + + def test_before_inclusive(self): + + m = MatchSet() + t1 = self.make_template(Path("tag")) + m.add(t1) + t2 = self.make_template(Path("tag2")) + m.add(t2) + + t3 = self.make_template(Path("tag3")) + m.add(t3) + + m2 = m.before_template(t2, True) + + result = m2.find_matches(self.make_tag_event("tag2")) + + assert t2 in result + + result = m2.find_matches(self.make_tag_event("tag")) + + assert t1 in result + + def test_remove(self): + + m = MatchSet() + t1 = self.make_template(Path("tag")) + m.add(t1) + t2 = self.make_template(Path("tag2")) + m.add(t2) + + m.remove(t1) + + result = m.find_matches(self.make_tag_event("tag")) + + assert t1 not in result + + result = m.find_matches(self.make_tag_event("tag2")) + + assert t2 in result + + def test_empty_range(self): + m = MatchSet() + t1 = self.make_template(Path("tag")) + m.add(t1) + t2 = self.make_template(Path("tag2")) + m.add(t2) + + m2 = m.after_template(t1) + m3 = m2.before_template(t2, False) + + assert not m3 + + + + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(MatchSetTestCase, 'test')) + return suite + +test_suite = suite() +if __name__ == '__main__': + unittest.main(defaultTest='suite') + diff --git a/genshi/template/tests/text.py b/genshi/template/tests/text.py --- a/genshi/template/tests/text.py +++ b/genshi/template/tests/text.py @@ -232,6 +232,27 @@ Included ----- Included data above this line -----""", tmpl.generate().render()) + def test_include_expr(self): + file1 = open(os.path.join(self.dirname, 'tmpl1.txt'), 'w') + try: + file1.write("Included") + finally: + file1.close() + + file2 = open(os.path.join(self.dirname, 'tmpl2.txt'), 'w') + try: + file2.write("""----- Included data below this line ----- + {% include ${'%s.txt' % ('tmpl1',)} %} + ----- Included data above this line -----""") + finally: + file2.close() + + loader = TemplateLoader([self.dirname]) + tmpl = loader.load('tmpl2.txt', cls=NewTextTemplate) + self.assertEqual("""----- Included data below this line ----- + Included + ----- Included data above this line -----""", tmpl.generate().render()) + def suite(): suite = unittest.TestSuite() diff --git a/genshi/template/text.py b/genshi/template/text.py --- a/genshi/template/text.py +++ b/genshi/template/text.py @@ -28,11 +28,12 @@ import re +from genshi.core import TEXT from genshi.template.base import BadDirectiveError, Template, \ TemplateSyntaxError, EXEC, INCLUDE, SUB from genshi.template.eval import Suite from genshi.template.directives import * -from genshi.template.directives import Directive, _apply_directives +from genshi.template.directives import Directive from genshi.template.interpolation import interpolate __all__ = ['NewTextTemplate', 'OldTextTemplate', 'TextTemplate'] @@ -188,7 +189,11 @@ if command == 'include': pos = (self.filename, lineno, 0) - stream.append((INCLUDE, (value.strip(), None, []), pos)) + value = list(interpolate(value, self.basedir, self.filename, + lineno, 0, lookup=self.lookup)) + if len(value) == 1 and value[0][0] is TEXT: + value = value[0][1] + stream.append((INCLUDE, (value, None, []), pos)) elif command == 'python': if not self.allow_exec: diff --git a/genshi/tests/core.py b/genshi/tests/core.py --- a/genshi/tests/core.py +++ b/genshi/tests/core.py @@ -14,6 +14,10 @@ import doctest import pickle from StringIO import StringIO +try: + from cStringIO import StringIO as cStringIO +except ImportError: + cStringIO = StringIO import unittest from genshi import core @@ -35,6 +39,18 @@ xml = XML('
  • Über uns
  • ') self.assertEqual('
  • Über uns
  • ', xml.render(encoding='ascii')) + def test_render_output_stream_utf8(self): + xml = XML('
  • Über uns
  • ') + strio = cStringIO() + self.assertEqual(None, xml.render(out=strio)) + self.assertEqual('
  • Über uns
  • ', strio.getvalue()) + + def test_render_output_stream_unicode(self): + xml = XML('
  • Über uns
  • ') + strio = StringIO() + self.assertEqual(None, xml.render(encoding=None, out=strio)) + self.assertEqual(u'
  • Über uns
  • ', strio.getvalue()) + def test_pickle(self): xml = XML('
  • Foo
  • ') buf = StringIO() diff --git a/genshi/tests/output.py b/genshi/tests/output.py --- a/genshi/tests/output.py +++ b/genshi/tests/output.py @@ -228,7 +228,7 @@ def test_xml_space(self): text = ' Do not mess \n\n with me ' output = XML(text).render(XHTMLSerializer) - self.assertEqual(text, output) + self.assertEqual(' Do not mess \n\n with me ', output) def test_empty_script(self): text = """