diff options
-rw-r--r-- | src/zope/tales/DEPENDENCIES.cfg | 3 | ||||
-rw-r--r-- | src/zope/tales/__init__.py | 18 | ||||
-rw-r--r-- | src/zope/tales/engine.py | 37 | ||||
-rw-r--r-- | src/zope/tales/expressions.py | 324 | ||||
-rw-r--r-- | src/zope/tales/interfaces.py | 116 | ||||
-rw-r--r-- | src/zope/tales/pythonexpr.py | 77 | ||||
-rw-r--r-- | src/zope/tales/tales.py | 760 | ||||
-rw-r--r-- | src/zope/tales/tests/__init__.py | 2 | ||||
-rw-r--r-- | src/zope/tales/tests/simpleexpr.py | 30 | ||||
-rw-r--r-- | src/zope/tales/tests/test_expressions.py | 330 | ||||
-rw-r--r-- | src/zope/tales/tests/test_tales.py | 183 | ||||
-rw-r--r-- | src/zope/tales/tests/test_traverser.py | 120 |
12 files changed, 2000 insertions, 0 deletions
diff --git a/src/zope/tales/DEPENDENCIES.cfg b/src/zope/tales/DEPENDENCIES.cfg new file mode 100644 index 0000000..e4327ee --- /dev/null +++ b/src/zope/tales/DEPENDENCIES.cfg @@ -0,0 +1,3 @@ +zope.interface +zope.tal +zope.testing diff --git a/src/zope/tales/__init__.py b/src/zope/tales/__init__.py new file mode 100644 index 0000000..d998090 --- /dev/null +++ b/src/zope/tales/__init__.py @@ -0,0 +1,18 @@ +############################################################################## +# +# Copyright (c) 2001, 2002 Zope Corporation and Contributors. +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# +############################################################################## +"""Template Attribute Language - Expression Syntax + +$Id$ +""" + diff --git a/src/zope/tales/engine.py b/src/zope/tales/engine.py new file mode 100644 index 0000000..4affe46 --- /dev/null +++ b/src/zope/tales/engine.py @@ -0,0 +1,37 @@ +############################################################################## +# +# Copyright (c) 2002 Zope Corporation and Contributors. +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# +############################################################################## +"""Expression engine configuration and registration. + +Each expression engine can have its own expression types and base names. + +$Id$ +""" +from zope.tales.tales import ExpressionEngine +from zope.tales.expressions import PathExpr, StringExpr, NotExpr, DeferExpr +from zope.tales.expressions import SimpleModuleImporter +from zope.tales.pythonexpr import PythonExpr + +def Engine(): + e = ExpressionEngine() + reg = e.registerType + for pt in PathExpr._default_type_names: + reg(pt, PathExpr) + reg('string', StringExpr) + reg('python', PythonExpr) + reg('not', NotExpr) + reg('defer', DeferExpr) + e.registerBaseName('modules', SimpleModuleImporter()) + return e + +Engine = Engine() diff --git a/src/zope/tales/expressions.py b/src/zope/tales/expressions.py new file mode 100644 index 0000000..c1add70 --- /dev/null +++ b/src/zope/tales/expressions.py @@ -0,0 +1,324 @@ +############################################################################## +# +# Copyright (c) 2001, 2002 Zope Corporation and Contributors. +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# +############################################################################## +"""Basic Page Template expression types. + +$Id$ +""" +import re, types + +from zope.interface import implements +from zope.tales.tales import _valid_name, _parse_expr, NAME_RE, Undefined +from zope.tales.interfaces import ITALESExpression, ITALESFunctionNamespace + +Undefs = (Undefined, AttributeError, LookupError, TypeError) + +_marker = object() +namespace_re = re.compile(r'(\w+):(.+)') + +def simpleTraverse(object, path_items, econtext): + """Traverses a sequence of names, first trying attributes then items. + """ + + for name in path_items: + next = getattr(object, name, _marker) + if next is not _marker: + object = next + elif hasattr(object, '__getitem__'): + object = object[name] + else: + # Allow AttributeError to propagate + object = getattr(object, name) + return object + + +class SubPathExpr(object): + + def __init__(self, path, traverser, engine): + self._traverser = traverser + self._engine = engine + + # Parse path + compiledpath = [] + currentpath = [] + for element in str(path).strip().split('/'): + if not element: + raise engine.getCompilerError()( + 'Path element may not be empty in %r' % path) + if element.startswith('?'): + if currentpath: + compiledpath.append(tuple(currentpath)) + currentpath = [] + if not _valid_name(element[1:]): + raise engine.getCompilerError()( + 'Invalid variable name "%s"' % element[1:]) + compiledpath.append(element[1:]) + else: + match = namespace_re.match(element) + if match: + if currentpath: + compiledpath.append(tuple(currentpath)) + currentpath = [] + namespace, functionname = match.groups() + if not _valid_name(namespace): + raise engine.getCompilerError()( + 'Invalid namespace name "%s"' % namespace) + try: + compiledpath.append( + self._engine.getFunctionNamespace(namespace)) + except KeyError: + raise engine.getCompilerError()( + 'Unknown namespace "%s"' % namespace) + currentpath.append(functionname) + else: + currentpath.append(element) + + if currentpath: + compiledpath.append(tuple(currentpath)) + + first = compiledpath[0] + base = first[0] + + if callable(first): + # check for initial function + raise engine.getCompilerError()( + 'Namespace function specified in first subpath element') + elif isinstance(first, basestring): + # check for initial ? + raise engine.getCompilerError()( + 'Dynamic name specified in first subpath element') + + if base and not _valid_name(base): + raise engine.getCompilerError()( + 'Invalid variable name "%s"' % element) + self._base = base + compiledpath[0] = first[1:] + self._compiled_path = tuple(compiledpath) + + def _eval(self, econtext, + isinstance=isinstance): + vars = econtext.vars + + compiled_path = self._compiled_path + + base = self._base + if base == 'CONTEXTS' or not base: # Special base name + ob = econtext.contexts + else: + ob = vars[base] + if isinstance(ob, DeferWrapper): + ob = ob() + + for element in compiled_path: + if isinstance(element, tuple): + ob = self._traverser(ob, element, econtext) + elif isinstance(element, basestring): + val = vars[element] + # If the value isn't a string, assume it's a sequence + # of path names. + if isinstance(val, basestring): + val = (val,) + ob = self._traverser(ob, val, econtext) + elif callable(element): + ob = element(ob) + # TODO: Once we have n-ary adapters, use them. + if ITALESFunctionNamespace.providedBy(ob): + ob.setEngine(econtext) + else: + raise ValueError(repr(element)) + return ob + + +class PathExpr(object): + """One or more subpath expressions, separated by '|'.""" + implements(ITALESExpression) + + # _default_type_names contains the expression type names this + # class is usually registered for. + _default_type_names = ( + 'standard', + 'path', + 'exists', + 'nocall', + ) + + def __init__(self, name, expr, engine, traverser=simpleTraverse): + self._s = expr + self._name = name + self._hybrid = False + paths = expr.split('|') + self._subexprs = [] + add = self._subexprs.append + for i in range(len(paths)): + path = paths[i].lstrip() + if _parse_expr(path): + # This part is the start of another expression type, + # so glue it back together and compile it. + add(engine.compile('|'.join(paths[i:]).lstrip())) + self._hybrid = True + break + add(SubPathExpr(path, traverser, engine)._eval) + + def _exists(self, econtext): + for expr in self._subexprs: + try: + expr(econtext) + except Undefs: + pass + else: + return 1 + return 0 + + def _eval(self, econtext): + for expr in self._subexprs[:-1]: + # Try all but the last subexpression, skipping undefined ones. + try: + ob = expr(econtext) + except Undefs: + pass + else: + break + else: + # On the last subexpression allow exceptions through, and + # don't autocall if the expression was not a subpath. + ob = self._subexprs[-1](econtext) + if self._hybrid: + return ob + + if self._name == 'nocall': + return ob + + # Call the object if it is callable. Note that checking for + # callable() isn't safe because the object might be security + # proxied (and security proxies report themselves callable, no + # matter what the underlying object is). We therefore check + # for the __call__ attribute, but not with hasattr as that + # eats babies, err, exceptions. In addition to that, we + # support calling old style classes which don't have a + # __call__. + if (getattr(ob, '__call__', _marker) is not _marker + or isinstance(ob, types.ClassType)): + return ob() + return ob + + def __call__(self, econtext): + if self._name == 'exists': + return self._exists(econtext) + return self._eval(econtext) + + def __str__(self): + return '%s expression (%s)' % (self._name, `self._s`) + + def __repr__(self): + return '<PathExpr %s:%s>' % (self._name, `self._s`) + + + +_interp = re.compile(r'\$(%(n)s)|\${(%(n)s(?:/[^}]*)*)}' % {'n': NAME_RE}) + +class StringExpr(object): + implements(ITALESExpression) + + def __init__(self, name, expr, engine): + self._s = expr + if '%' in expr: + expr = expr.replace('%', '%%') + self._vars = vars = [] + if '$' in expr: + # Use whatever expr type is registered as "path". + path_type = engine.getTypes()['path'] + parts = [] + for exp in expr.split('$$'): + if parts: parts.append('$') + m = _interp.search(exp) + while m is not None: + parts.append(exp[:m.start()]) + parts.append('%s') + vars.append(path_type( + 'path', m.group(1) or m.group(2), engine)) + exp = exp[m.end():] + m = _interp.search(exp) + if '$' in exp: + raise engine.getCompilerError()( + '$ must be doubled or followed by a simple path') + parts.append(exp) + expr = ''.join(parts) + self._expr = expr + + def __call__(self, econtext): + vvals = [] + for var in self._vars: + v = var(econtext) + vvals.append(v) + return self._expr % tuple(vvals) + + def __str__(self): + return 'string expression (%s)' % `self._s` + + def __repr__(self): + return '<StringExpr %s>' % `self._s` + + +class NotExpr(object): + implements(ITALESExpression) + + def __init__(self, name, expr, engine): + self._s = expr = expr.lstrip() + self._c = engine.compile(expr) + + def __call__(self, econtext): + return int(not econtext.evaluateBoolean(self._c)) + + def __repr__(self): + return '<NotExpr %s>' % `self._s` + + +class DeferWrapper(object): + def __init__(self, expr, econtext): + self._expr = expr + self._econtext = econtext + + def __str__(self): + return str(self()) + + def __call__(self): + return self._expr(self._econtext) + + +class DeferExpr(object): + implements(ITALESExpression) + + def __init__(self, name, expr, compiler): + self._s = expr = expr.lstrip() + self._c = compiler.compile(expr) + + def __call__(self, econtext): + return DeferWrapper(self._c, econtext) + + def __repr__(self): + return '<DeferExpr %s>' % `self._s` + + +class SimpleModuleImporter(object): + """Minimal module importer with no security.""" + + def __getitem__(self, module): + mod = self._get_toplevel_module(module) + path = module.split('.') + for name in path[1:]: + mod = getattr(mod, name) + return mod + + def _get_toplevel_module(self, module): + # This can be overridden to add security proxies. + return __import__(module) diff --git a/src/zope/tales/interfaces.py b/src/zope/tales/interfaces.py new file mode 100644 index 0000000..c33606b --- /dev/null +++ b/src/zope/tales/interfaces.py @@ -0,0 +1,116 @@ +############################################################################## +# +# Copyright (c) 2003 Zope Corporation and Contributors. +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# +############################################################################## +"""Interface that describes the 'macros' attribute of a PageTemplate. + +$Id$ +""" +from zope.interface import Interface + +try: + from zope import tal +except ImportError: + tal = None + + +class ITALESFunctionNamespace(Interface): + """Function namespaces can be used in TALES path expressions to extract + information in non-default ways.""" + + def setEngine(engine): + """Sets the engine that is used to evaluate TALES expressions.""" + +class ITALESExpression(Interface): + """TALES expression + + These are expression handlers that handle a specific type of + expression in TALES, e.g. path or string expression. + """ + + def __call__(econtext): + """Evaluate expression according to the given execution + context 'econtext' and return computed value. + """ + +if tal is not None: + from zope.tal.interfaces import ITALIterator + + class ITALESIterator(ITALIterator): + """TAL Iterator provided by TALES + + Values of this iterator are assigned to items in the repeat namespace. + + For example, with a TAL statement like: tal:repeat="item items", + an iterator will be assigned to "repeat/item". The iterator + provides a number of handy methods useful in writing TAL loops. + + The results are undefined of calling any of the methods except + 'length' before the first iteration. + """ + + def index(): + """Return the position (starting with "0") within the iteration + """ + + def number(): + """Return the position (starting with "1") within the iteration + """ + + def even(): + """Return whether the current position is even + """ + + def odd(): + """Return whether the current position is odd + """ + + def parity(): + """Return 'odd' or 'even' depending on the position's parity + + Useful for assigning CSS class names to table rows. + """ + + def start(): + """Return whether the current position is the first position + """ + + def end(): + """Return whether the current position is the last position + """ + + def letter(): + """Return the position (starting with "a") within the iteration + """ + + def Letter(): + """Return the position (starting with "A") within the iteration + """ + + def roman(): + """Return the position (starting with "i") within the iteration + """ + + def Roman(): + """Return the position (starting with "I") within the iteration + """ + + def item(): + """Return the item at the current position + """ + + def length(): + """Return the length of the sequence + + Note that this may fail if the TAL iterator was created on a Python + iterator. + """ diff --git a/src/zope/tales/pythonexpr.py b/src/zope/tales/pythonexpr.py new file mode 100644 index 0000000..7fab3bb --- /dev/null +++ b/src/zope/tales/pythonexpr.py @@ -0,0 +1,77 @@ +############################################################################## +# +# Copyright (c) 2001, 2002 Zope Corporation and Contributors. +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# +############################################################################## +"""Generic Python Expression Handler + +$Id$ +""" + +class PythonExpr(object): + def __init__(self, name, expr, engine): + text = '\n'.join(expr.splitlines()) # normalize line endings + text = '(' + text + ')' # Put text in parens so newlines don't matter + self.text = text + try: + code = self._compile(text, '<string>') + except SyntaxError, e: + raise engine.getCompilerError()(str(e)) + self._code = code + self._varnames = code.co_names + + def _compile(self, text, filename): + return compile(text, filename, 'eval') + + def _bind_used_names(self, econtext, builtins): + # Construct a dictionary of globals with which the Python + # expression should be evaluated. + names = {} + vars = econtext.vars + marker = self + if not isinstance(builtins, dict): + builtins = builtins.__dict__ + for vname in self._varnames: + val = vars.get(vname, marker) + if val is not marker: + names[vname] = val + elif vname not in builtins: + # Fall back to using expression types as variable values. + val = econtext._engine.getTypes().get(vname, marker) + if val is not marker: + val = ExprTypeProxy(vname, val, econtext) + names[vname] = val + + names['__builtins__'] = builtins + return names + + def __call__(self, econtext): + __traceback_info__ = self.text + vars = self._bind_used_names(econtext, __builtins__) + return eval(self._code, vars) + + def __str__(self): + return 'Python expression "%s"' % self.text + + def __repr__(self): + return '<PythonExpr %s>' % self.text + + +class ExprTypeProxy(object): + '''Class that proxies access to an expression type handler''' + def __init__(self, name, handler, econtext): + self._name = name + self._handler = handler + self._econtext = econtext + + def __call__(self, text): + return self._handler(self._name, text, + self._econtext._engine)(self._econtext) diff --git a/src/zope/tales/tales.py b/src/zope/tales/tales.py new file mode 100644 index 0000000..ac5f6bf --- /dev/null +++ b/src/zope/tales/tales.py @@ -0,0 +1,760 @@ +############################################################################## +# +# Copyright (c) 2001, 2002 Zope Corporation and Contributors. +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# +############################################################################## +"""TALES + +An implementation of a TAL expression engine + +$Id$ +""" +__docformat__ = "reStructuredText" +import re + +from zope.interface import implements + +try: + from zope import tal +except ImportError: + tal = None + +if tal: + from zope.tal.interfaces import ITALExpressionEngine + from zope.tal.interfaces import ITALExpressionCompiler + from zope.tal.interfaces import ITALExpressionErrorInfo + from zope.tales.interfaces import ITALESIterator + + +NAME_RE = r"[a-zA-Z][a-zA-Z0-9_]*" +_parse_expr = re.compile(r"(%s):" % NAME_RE).match +_valid_name = re.compile('%s$' % NAME_RE).match + + +class TALESError(Exception): + """Error during TALES evaluation""" + +class Undefined(TALESError): + '''Exception raised on traversal of an undefined path''' + +class CompilerError(Exception): + '''TALES Compiler Error''' + +class RegistrationError(Exception): + '''Expression type or base name registration Error''' + + +_default = object() + +class Iterator(object): + """TALES Iterator + """ + + if tal: + implements(ITALESIterator) + + def __init__(self, name, seq, context): + """Construct an iterator + + Iterators are defined for a name, a sequence, or an iterator and a + context, where a context simply has a setLocal method: + + >>> context = Context(ExpressionEngine(), {}) + >>> it = Iterator('foo', ("apple", "pear", "orange"), context) + + A local variable is not set until the iterator is used: + + >>> int("foo" in context.vars) + 0 + + We can create an iterator on an empty sequence: + + >>> it = Iterator('foo', (), context) + + An iterator works as well: + + >>> it = Iterator('foo', {"apple":1, "pear":1, "orange":1}, context) + >>> it.next() + True + + >>> it = Iterator('foo', {}, context) + >>> it.next() + False + + >>> it = Iterator('foo', iter((1, 2, 3)), context) + >>> it.next() + True + >>> it.next() + True + + """ + + self._seq = seq + self._iter = i = iter(seq) + self._nextIndex = 0 + self._name = name + self._setLocal = context.setLocal + + # This is tricky. We want to know if we are on the last item, + # but we can't know that without trying to get it. :( + self._last = False + try: + self._next = i.next() + except StopIteration: + self._done = True + else: + self._done = False + + def next(self): + """Advance the iterator, if possible. + + >>> context = Context(ExpressionEngine(), {}) + >>> it = Iterator('foo', ("apple", "pear", "orange"), context) + >>> bool(it.next()) + True + >>> context.vars['foo'] + 'apple' + >>> bool(it.next()) + True + >>> context.vars['foo'] + 'pear' + >>> bool(it.next()) + True + >>> context.vars['foo'] + 'orange' + >>> bool(it.next()) + False + + >>> it = Iterator('foo', {"apple":1, "pear":1, "orange":1}, context) + >>> bool(it.next()) + True + >>> bool(it.next()) + True + >>> bool(it.next()) + True + >>> bool(it.next()) + False + + >>> it = Iterator('foo', (), context) + >>> bool(it.next()) + False + + >>> it = Iterator('foo', {}, context) + >>> bool(it.next()) + False + + + If we can advance, set a local variable to the new value. + """ + # Note that these are *NOT* Python iterators! + if self._done: + return False + self._item = v = self._next + try: + self._next = self._iter.next() + except StopIteration: + self._done = True + self._last = True + + self._nextIndex += 1 + self._setLocal(self._name, v) + return True + + def index(self): + """Get the iterator index + + >>> context = Context(ExpressionEngine(), {}) + >>> it = Iterator('foo', ("apple", "pear", "orange"), context) + >>> int(bool(it.next())) + 1 + >>> it.index() + 0 + >>> int(bool(it.next())) + 1 + >>> it.index() + 1 + >>> int(bool(it.next())) + 1 + >>> it.index() + 2 + """ + index = self._nextIndex - 1 + if index < 0: + raise TypeError("No iteration position") + return index + + def number(self): + """Get the iterator position + + >>> context = Context(ExpressionEngine(), {}) + >>> it = Iterator('foo', ("apple", "pear", "orange"), context) + >>> int(bool(it.next())) + 1 + >>> it.number() + 1 + >>> int(bool(it.next())) + 1 + >>> it.number() + 2 + >>> int(bool(it.next())) + 1 + >>> it.number() + 3 + """ + return self._nextIndex + + def even(self): + """Test whether the position is even + + >>> context = Context(ExpressionEngine(), {}) + >>> it = Iterator('foo', ("apple", "pear", "orange"), context) + >>> it.next() + True + >>> it.even() + True + >>> it.next() + True + >>> it.even() + False + >>> it.next() + True + >>> it.even() + True + """ + return not ((self._nextIndex - 1) % 2) + + def odd(self): + """Test whether the position is odd + + >>> context = Context(ExpressionEngine(), {}) + >>> it = Iterator('foo', ("apple", "pear", "orange"), context) + >>> it.next() + True + >>> it.odd() + False + >>> it.next() + True + >>> it.odd() + True + >>> it.next() + True + >>> it.odd() + False + """ + return bool((self._nextIndex - 1) % 2) + + def parity(self): + """Return 'odd' or 'even' depending on the position's parity + + >>> context = Context(ExpressionEngine(), {}) + >>> it = Iterator('foo', ("apple", "pear", "orange"), context) + >>> it.next() + True + >>> it.parity() + 'odd' + >>> it.next() + True + >>> it.parity() + 'even' + >>> it.next() + True + >>> it.parity() + 'odd' + """ + if self._nextIndex % 2: + return 'odd' + return 'even' + + def letter(self, base=ord('a'), radix=26): + """Get the iterator position as a lower-case letter + + >>> context = Context(ExpressionEngine(), {}) + >>> it = Iterator('foo', ("apple", "pear", "orange"), context) + >>> it.next() + True + >>> it.letter() + 'a' + >>> it.next() + True + >>> it.letter() + 'b' + >>> it.next() + True + >>> it.letter() + 'c' + """ + index = self._nextIndex - 1 + if index < 0: + raise TypeError("No iteration position") + s = '' + while 1: + index, off = divmod(index, radix) + s = chr(base + off) + s + if not index: return s + + def Letter(self): + """Get the iterator position as an upper-case letter + + >>> context = Context(ExpressionEngine(), {}) + >>> it = Iterator('foo', ("apple", "pear", "orange"), context) + >>> it.next() + True + >>> it.Letter() + 'A' + >>> it.next() + True + >>> it.Letter() + 'B' + >>> it.next() + True + >>> it.Letter() + 'C' + """ + return self.letter(base=ord('A')) + + def Roman(self, rnvalues=( + (1000,'M'),(900,'CM'),(500,'D'),(400,'CD'), + (100,'C'),(90,'XC'),(50,'L'),(40,'XL'), + (10,'X'),(9,'IX'),(5,'V'),(4,'IV'),(1,'I')) ): + """Get the iterator position as an upper-case roman numeral + + >>> context = Context(ExpressionEngine(), {}) + >>> it = Iterator('foo', ("apple", "pear", "orange"), context) + >>> it.next() + True + >>> it.Roman() + 'I' + >>> it.next() + True + >>> it.Roman() + 'II' + >>> it.next() + True + >>> it.Roman() + 'III' + """ + n = self._nextIndex + s = '' + for v, r in rnvalues: + rct, n = divmod(n, v) + s = s + r * rct + return s + + def roman(self): + """Get the iterator position as a lower-case roman numeral + + >>> context = Context(ExpressionEngine(), {}) + >>> it = Iterator('foo', ("apple", "pear", "orange"), context) + >>> it.next() + True + >>> it.roman() + 'i' + >>> it.next() + True + >>> it.roman() + 'ii' + >>> it.next() + True + >>> it.roman() + 'iii' + """ + return self.Roman().lower() + + def start(self): + """Test whether the position is the first position + + >>> context = Context(ExpressionEngine(), {}) + >>> it = Iterator('foo', ("apple", "pear", "orange"), context) + >>> it.next() + True + >>> it.start() + True + >>> it.next() + True + >>> it.start() + False + >>> it.next() + True + >>> it.start() + False + + >>> it = Iterator('foo', {}, context) + >>> it.start() + False + >>> it.next() + False + >>> it.start() + False + """ + return self._nextIndex == 1 + + def end(self): + """Test whether the position is the last position + + >>> context = Context(ExpressionEngine(), {}) + >>> it = Iterator('foo', ("apple", "pear", "orange"), context) + >>> it.next() + True + >>> it.end() + False + >>> it.next() + True + >>> it.end() + False + >>> it.next() + True + >>> it.end() + True + + >>> it = Iterator('foo', {}, context) + >>> it.end() + False + >>> it.next() + False + >>> it.end() + False + """ + return self._last + + def item(self): + """Get the iterator value + + >>> context = Context(ExpressionEngine(), {}) + >>> it = Iterator('foo', ("apple", "pear", "orange"), context) + >>> it.next() + True + >>> it.item() + 'apple' + >>> it.next() + True + >>> it.item() + 'pear' + >>> it.next() + True + >>> it.item() + 'orange' + + >>> it = Iterator('foo', {1:2}, context) + >>> it.next() + True + >>> it.item() + 1 + + """ + if self._nextIndex == 0: + raise TypeError("No iteration position") + return self._item + + def length(self): + """Get the length of the iterator sequence + + >>> context = Context(ExpressionEngine(), {}) + >>> it = Iterator('foo', ("apple", "pear", "orange"), context) + >>> it.length() + 3 + + You can even get the length of a mapping: + + >>> it = Iterator('foo', {"apple":1, "pear":2, "orange":3}, context) + >>> it.length() + 3 + + But you can't get the length of an iterable which doesn't + support len(): + + >>> class MyIter(object): + ... def __init__(self, seq): + ... self._next = iter(seq).next + ... def __iter__(self): + ... return self + ... def next(self): + ... return self._next() + >>> it = Iterator('foo', MyIter({"apple":1, "pear":2}), context) + >>> it.length() + Traceback (most recent call last): + ... + TypeError: len() of unsized object + + """ + return len(self._seq) + + +class ErrorInfo(object): + """Information about an exception passed to an on-error handler.""" + if tal: + implements(ITALExpressionErrorInfo) + + def __init__(self, err, position=(None, None)): + if isinstance(err, Exception): + self.type = err.__class__ + self.value = err + else: + self.type = err + self.value = None + self.lineno = position[0] + self.offset = position[1] + + +class ExpressionEngine(object): + '''Expression Engine + + An instance of this class keeps a mutable collection of expression + type handlers. It can compile expression strings by delegating to + these handlers. It can provide an expression Context, which is + capable of holding state and evaluating compiled expressions. + ''' + if tal: + implements(ITALExpressionCompiler) + + def __init__(self): + self.types = {} + self.base_names = {} + self.namespaces = {} + self.iteratorFactory = Iterator + + def registerFunctionNamespace(self, namespacename, namespacecallable): + """Register a function namespace + + namespace - a string containing the name of the namespace to + be registered + + namespacecallable - a callable object which takes the following + parameter: + + context - the object on which the functions + provided by this namespace will + be called + + This callable should return an object which + can be traversed to get the functions provided + by the this namespace. + + example: + + class stringFuncs(object): + + def __init__(self,context): + self.context = str(context) + + def upper(self): + return self.context.upper() + + def lower(self): + return self.context.lower() + + engine.registerFunctionNamespace('string',stringFuncs) + """ + self.namespaces[namespacename] = namespacecallable + + + def getFunctionNamespace(self, namespacename): + """ Returns the function namespace """ + return self.namespaces[namespacename] + + def registerType(self, name, handler): + if not _valid_name(name): + raise RegistrationError('Invalid expression type name "%s".' % name) + types = self.types + if name in types: + raise RegistrationError( + 'Multiple registrations for Expression type "%s".' % name) + types[name] = handler + + def getTypes(self): + return self.types + + def registerBaseName(self, name, object): + if not _valid_name(name): + raise RegistrationError('Invalid base name "%s".' % name) + base_names = self.base_names + if name in base_names: + raise RegistrationError( + 'Multiple registrations for base name "%s".' % name) + base_names[name] = object + + def getBaseNames(self): + return self.base_names + + def compile(self, expression): + m = _parse_expr(expression) + if m: + type = m.group(1) + expr = expression[m.end():] + else: + type = "standard" + expr = expression + try: + handler = self.types[type] + except KeyError: + raise CompilerError('Unrecognized expression type "%s".' % type) + return handler(type, expr, self) + + def getContext(self, contexts=None, **kwcontexts): + if contexts is not None: + if kwcontexts: + kwcontexts.update(contexts) + else: + kwcontexts = contexts + return Context(self, kwcontexts) + + def getCompilerError(self): + return CompilerError + + +class Context(object): + '''Expression Context + + An instance of this class holds context information that it can + use to evaluate compiled expressions. + ''' + + if tal: + implements(ITALExpressionEngine) + + position = (None, None) + source_file = None + + def __init__(self, engine, contexts): + self._engine = engine + self.contexts = contexts + self.setContext('nothing', None) + self.setContext('default', _default) + + self.repeat_vars = rv = {} + # Wrap this, as it is visible to restricted code + self.setContext('repeat', rv) + self.setContext('loop', rv) # alias + + self.vars = vars = contexts.copy() + self._vars_stack = [vars] + + # Keep track of what needs to be popped as each scope ends. + self._scope_stack = [] + + def setContext(self, name, value): + # Hook to allow subclasses to do things like adding security proxies + self.contexts[name] = value + + def beginScope(self): + self.vars = vars = self.vars.copy() + self._vars_stack.append(vars) + self._scope_stack.append([]) + + def endScope(self): + self._vars_stack.pop() + self.vars = self._vars_stack[-1] + + scope = self._scope_stack.pop() + # Pop repeat variables, if any + i = len(scope) + while i: + i = i - 1 + name, value = scope[i] + if value is None: + del self.repeat_vars[name] + else: + self.repeat_vars[name] = value + + def setLocal(self, name, value): + self.vars[name] = value + + def setGlobal(self, name, value): + for vars in self._vars_stack: + vars[name] = value + + def getValue(self, name, default=None): + value = default + for vars in self._vars_stack: + value = vars.get(name, default) + if value is not default: + break + return value + + def setRepeat(self, name, expr): + expr = self.evaluate(expr) + if not expr: + return self._engine.iteratorFactory(name, (), self) + it = self._engine.iteratorFactory(name, expr, self) + old_value = self.repeat_vars.get(name) + self._scope_stack[-1].append((name, old_value)) + self.repeat_vars[name] = it + return it + + def evaluate(self, expression): + if isinstance(expression, str): + expression = self._engine.compile(expression) + __traceback_supplement__ = ( + TALESTracebackSupplement, self, expression) + return expression(self) + + evaluateValue = evaluate + + def evaluateBoolean(self, expr): + return not not self.evaluate(expr) + + def evaluateText(self, expr): + text = self.evaluate(expr) + if text is self.getDefault() or text is None: + return text + if isinstance(text, basestring): + # text could already be something text-ish, e.g. a Message object + return text + return unicode(text) + + def evaluateStructure(self, expr): + return self.evaluate(expr) + evaluateStructure = evaluate + + def evaluateMacro(self, expr): + # TODO: Should return None or a macro definition + return self.evaluate(expr) + evaluateMacro = evaluate + + def createErrorInfo(self, err, position): + return ErrorInfo(err, position) + + def getDefault(self): + return _default + + def setSourceFile(self, source_file): + self.source_file = source_file + + def setPosition(self, position): + self.position = position + + def translate(self, msgid, domain=None, mapping=None, default=None): + # custom Context implementations are supposed to customize + # this to call whichever translation routine they want to use + return unicode(msgid) + + +class TALESTracebackSupplement(object): + """Implementation of zope.exceptions.ITracebackSupplement""" + + def __init__(self, context, expression): + self.context = context + self.source_url = context.source_file + self.line = context.position[0] + self.column = context.position[1] + self.expression = repr(expression) + + def getInfo(self, as_html=0): + import pprint + data = self.context.contexts.copy() + if 'modules' in data: + del data['modules'] # the list is really long and boring + s = pprint.pformat(data) + if not as_html: + return ' - Names:\n %s' % s.replace('\n', '\n ') + else: + from cgi import escape + return '<b>Names:</b><pre>%s</pre>' % (escape(s)) + return None diff --git a/src/zope/tales/tests/__init__.py b/src/zope/tales/tests/__init__.py new file mode 100644 index 0000000..b711d36 --- /dev/null +++ b/src/zope/tales/tests/__init__.py @@ -0,0 +1,2 @@ +# +# This file is necessary to make this directory a package. diff --git a/src/zope/tales/tests/simpleexpr.py b/src/zope/tales/tests/simpleexpr.py new file mode 100644 index 0000000..19f43ad --- /dev/null +++ b/src/zope/tales/tests/simpleexpr.py @@ -0,0 +1,30 @@ +############################################################################## +# +# Copyright (c) 2003 Zope Corporation and Contributors. +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# +############################################################################## +"""Simple TALES Expression + +$Id$ +""" + +class SimpleExpr(object): + '''Simple example of an expression type handler + + for testing + ''' + def __init__(self, name, expr, engine): + self._name = name + self._expr = expr + def __call__(self, econtext): + return self._name, self._expr + def __repr__(self): + return '<SimpleExpr %s %s>' % (self._name, `self._expr`) diff --git a/src/zope/tales/tests/test_expressions.py b/src/zope/tales/tests/test_expressions.py new file mode 100644 index 0000000..d3b4793 --- /dev/null +++ b/src/zope/tales/tests/test_expressions.py @@ -0,0 +1,330 @@ +############################################################################## +# +# Copyright (c) 2001, 2002 Zope Corporation and Contributors. +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# +############################################################################## +"""Default TALES expression implementations tests. + +$Id$ +""" +import unittest + +from zope.tales.engine import Engine +from zope.tales.interfaces import ITALESFunctionNamespace +from zope.tales.tales import Undefined +from zope.interface import implements + +class Data(object): + + def __init__(self, **kw): + self.__dict__.update(kw) + + def __repr__(self): return self.name + + __str__ = __repr__ + +class ErrorGenerator: + + def __getitem__(self, name): + import __builtin__ + if name == 'Undefined': + e = Undefined + else: + e = getattr(__builtin__, name, None) + if e is None: + e = SystemError + raise e('mess') + +class ExpressionTestBase(unittest.TestCase): + + def setUp(self): + # Test expression compilation + d = Data( + name = 'xander', + y = Data( + name = 'yikes', + z = Data(name = 'zope') + ) + ) + at = Data( + name = 'yikes', + _d = d + ) + self.context = Data( + vars = dict( + x = d, + y = Data(z = 3), + b = 'boot', + B = 2, + adapterTest = at, + dynamic = 'z', + ErrorGenerator = ErrorGenerator(), + ) + ) + + self.engine = Engine + + +class ExpressionTests(ExpressionTestBase): + + def testSimple(self): + expr = self.engine.compile('x') + context=self.context + self.assertEqual(expr(context), context.vars['x']) + + def testPath(self): + expr = self.engine.compile('x/y') + context=self.context + self.assertEqual(expr(context), context.vars['x'].y) + + def testLongPath(self): + expr = self.engine.compile('x/y/z') + context=self.context + self.assertEqual(expr(context), context.vars['x'].y.z) + + def testOrPath(self): + expr = self.engine.compile('path:a|b|c/d/e') + context=self.context + self.assertEqual(expr(context), 'boot') + + for e in 'Undefined', 'AttributeError', 'LookupError', 'TypeError': + expr = self.engine.compile('path:ErrorGenerator/%s|b|c/d/e' % e) + context=self.context + self.assertEqual(expr(context), 'boot') + + def testDynamic(self): + expr = self.engine.compile('x/y/?dynamic') + context=self.context + self.assertEqual(expr(context),context.vars['x'].y.z) + + def testBadInitalDynamic(self): + from zope.tales.tales import CompilerError + try: + self.engine.compile('?x') + except CompilerError,e: + self.assertEqual(e.args[0], + 'Dynamic name specified in first subpath element') + else: + self.fail('Engine accepted first subpath element as dynamic') + + def testOldStyleClassIsCalled(self): + class AnOldStyleClass: + pass + self.context.vars['oldstyleclass'] = AnOldStyleClass + expr = self.engine.compile('oldstyleclass') + self.assert_(isinstance(expr(self.context), AnOldStyleClass)) + + def testString(self): + expr = self.engine.compile('string:Fred') + context=self.context + self.assertEqual(expr(context), 'Fred') + + def testStringSub(self): + expr = self.engine.compile('string:A$B') + context=self.context + self.assertEqual(expr(context), 'A2') + + def testStringSubComplex(self): + expr = self.engine.compile('string:a ${x/y} b ${y/z} c') + context=self.context + self.assertEqual(expr(context), 'a yikes b 3 c') + + def testPython(self): + expr = self.engine.compile('python: 2 + 2') + context=self.context + self.assertEqual(expr(context), 4) + + def testPythonCallableIsntCalled(self): + self.context.vars['acallable'] = lambda: 23 + expr = self.engine.compile('python: acallable') + self.assertEqual(expr(self.context), self.context.vars['acallable']) + + def testPythonNewline(self): + expr = self.engine.compile('python: 2 \n+\n 2\n') + context=self.context + self.assertEqual(expr(context), 4) + + def testPythonDosNewline(self): + expr = self.engine.compile('python: 2 \r\n+\r\n 2\r\n') + context=self.context + self.assertEqual(expr(context), 4) + + def testPythonErrorRaisesCompilerError(self): + self.assertRaises(self.engine.getCompilerError(), + self.engine.compile, 'python: splat.0') + + def testHybridPathExpressions(self): + def eval(expr): + e = self.engine.compile(expr) + return e(self.context) + self.context.vars['one'] = 1 + self.context.vars['acallable'] = lambda: 23 + + self.assertEqual(eval('foo | python:1+1'), 2) + self.assertEqual(eval('foo | python:acallable'), + self.context.vars['acallable']) + self.assertEqual(eval('foo | string:x'), 'x') + self.assertEqual(eval('foo | string:$one'), '1') + self.assert_(eval('foo | exists:x')) + + def testEmptyPathSegmentRaisesCompilerError(self): + CompilerError = self.engine.getCompilerError() + def check(expr): + self.assertRaises(CompilerError, self.engine.compile, expr) + + # path expressions on their own: + check('/ab/cd | c/d | e/f') + check('ab//cd | c/d | e/f') + check('ab/cd/ | c/d | e/f') + check('ab/cd | /c/d | e/f') + check('ab/cd | c//d | e/f') + check('ab/cd | c/d/ | e/f') + check('ab/cd | c/d | /e/f') + check('ab/cd | c/d | e//f') + check('ab/cd | c/d | e/f/') + + # path expressions embedded in string: expressions: + check('string:${/ab/cd}') + check('string:${ab//cd}') + check('string:${ab/cd/}') + check('string:foo${/ab/cd | c/d | e/f}bar') + check('string:foo${ab//cd | c/d | e/f}bar') + check('string:foo${ab/cd/ | c/d | e/f}bar') + check('string:foo${ab/cd | /c/d | e/f}bar') + check('string:foo${ab/cd | c//d | e/f}bar') + check('string:foo${ab/cd | c/d/ | e/f}bar') + check('string:foo${ab/cd | c/d | /e/f}bar') + check('string:foo${ab/cd | c/d | e//f}bar') + check('string:foo${ab/cd | c/d | e/f/}bar') + + +class FunctionTests(ExpressionTestBase): + + def setUp(self): + ExpressionTestBase.setUp(self) + + # a test namespace + class TestNameSpace(object): + implements(ITALESFunctionNamespace) + + def __init__(self, context): + self.context = context + + def setEngine(self, engine): + self._engine = engine + + def engine(self): + return self._engine + + def upper(self): + return str(self.context).upper() + + def __getitem__(self,key): + if key=='jump': + return self.context._d + raise KeyError,key + + self.TestNameSpace = TestNameSpace + self.engine.registerFunctionNamespace('namespace', self.TestNameSpace) + + ## framework-ish tests + + def testSetEngine(self): + expr = self.engine.compile('adapterTest/namespace:engine') + self.assertEqual(expr(self.context), self.context) + + def testGetFunctionNamespace(self): + self.assertEqual( + self.engine.getFunctionNamespace('namespace'), + self.TestNameSpace + ) + + def testGetFunctionNamespaceBadNamespace(self): + self.assertRaises(KeyError, + self.engine.getFunctionNamespace, + 'badnamespace') + + ## compile time tests + + def testBadNamespace(self): + # namespace doesn't exist + from zope.tales.tales import CompilerError + try: + self.engine.compile('adapterTest/badnamespace:title') + except CompilerError,e: + self.assertEqual(e.args[0],'Unknown namespace "badnamespace"') + else: + self.fail('Engine accepted unknown namespace') + + def testBadInitialNamespace(self): + # first segment in a path must not have modifier + from zope.tales.tales import CompilerError + self.assertRaises(CompilerError, + self.engine.compile, + 'namespace:title') + + # In an ideal world ther ewould be another test here to test + # that a nicer error was raised when you tried to use + # something like: + # standard:namespace:upper + # ...as a path. + # However, the compilation stage of PathExpr currently + # allows any expression type to be nested, so something like: + # standard:standard:context/attribute + # ...will work fine. + # When that is changed so that only expression types which + # should be nested are nestable, then the additional test + # should be added here. + + def testInvalidNamespaceName(self): + from zope.tales.tales import CompilerError + try: + self.engine.compile('adapterTest/1foo:bar') + except CompilerError,e: + self.assertEqual(e.args[0], + 'Invalid namespace name "1foo"') + else: + self.fail('Engine accepted invalid namespace name') + + def testBadFunction(self): + from zope.tales.tales import CompilerError + # namespace is fine, adapter is not defined + try: + expr = self.engine.compile('adapterTest/namespace:title') + expr(self.context) + except KeyError,e: + self.assertEquals(e.args[0],'title') + else: + self.fail('Engine accepted unknown function') + + ## runtime tests + + def testNormalFunction(self): + expr = self.engine.compile('adapterTest/namespace:upper') + self.assertEqual(expr(self.context), 'YIKES') + + def testFunctionOnFunction(self): + expr = self.engine.compile('adapterTest/namespace:jump/namespace:upper') + self.assertEqual(expr(self.context), 'XANDER') + + def testPathOnFunction(self): + expr = self.engine.compile('adapterTest/namespace:jump/y/z') + context = self.context + self.assertEqual(expr(context), context.vars['x'].y.z) + +def test_suite(): + return unittest.TestSuite(( + unittest.makeSuite(ExpressionTests), + unittest.makeSuite(FunctionTests), + )) + +if __name__ == '__main__': + unittest.TextTestRunner().run(test_suite()) diff --git a/src/zope/tales/tests/test_tales.py b/src/zope/tales/tests/test_tales.py new file mode 100644 index 0000000..31fbda1 --- /dev/null +++ b/src/zope/tales/tests/test_tales.py @@ -0,0 +1,183 @@ +############################################################################## +# +# Copyright (c) 2001, 2002 Zope Corporation and Contributors. +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# +############################################################################## +"""TALES Tests + +$Id$ +""" +import unittest + +from zope.tales import tales +from zope.tales.tests.simpleexpr import SimpleExpr +from zope.testing.doctestunit import DocTestSuite + + +class TALESTests(unittest.TestCase): + + def testIterator0(self): + # Test sample Iterator class + context = Harness(self) + it = tales.Iterator('name', (), context) + self.assert_( not it.next(), "Empty iterator") + context._complete_() + + def testIterator1(self): + # Test sample Iterator class + context = Harness(self) + it = tales.Iterator('name', (1,), context) + context._assert_('setLocal', 'name', 1) + self.assert_( it.next() and not it.next(), "Single-element iterator") + context._complete_() + + def testIterator2(self): + # Test sample Iterator class + context = Harness(self) + it = tales.Iterator('text', 'text', context) + for c in 'text': + context._assert_('setLocal', 'text', c) + for c in 'text': + self.assert_(it.next(), "Multi-element iterator") + self.assert_( not it.next(), "Multi-element iterator") + context._complete_() + + def testRegisterType(self): + # Test expression type registration + e = tales.ExpressionEngine() + e.registerType('simple', SimpleExpr) + self.assert_( e.getTypes()['simple'] == SimpleExpr) + + def testRegisterTypeUnique(self): + # Test expression type registration uniqueness + e = tales.ExpressionEngine() + e.registerType('simple', SimpleExpr) + try: + e.registerType('simple', SimpleExpr) + except tales.RegistrationError: + pass + else: + self.assert_( 0, "Duplicate registration accepted.") + + def testRegisterTypeNameConstraints(self): + # Test constraints on expression type names + e = tales.ExpressionEngine() + for name in '1A', 'A!', 'AB ': + try: + e.registerType(name, SimpleExpr) + except tales.RegistrationError: + pass + else: + self.assert_( 0, 'Invalid type name "%s" accepted.' % name) + + def testCompile(self): + # Test expression compilation + e = tales.ExpressionEngine() + e.registerType('simple', SimpleExpr) + ce = e.compile('simple:x') + self.assert_( ce(None) == ('simple', 'x'), ( + 'Improperly compiled expression %s.' % `ce`)) + + def testGetContext(self): + # Test Context creation + tales.ExpressionEngine().getContext() + tales.ExpressionEngine().getContext(v=1) + tales.ExpressionEngine().getContext(x=1, y=2) + + def getContext(self, **kws): + e = tales.ExpressionEngine() + e.registerType('simple', SimpleExpr) + return apply(e.getContext, (), kws) + + def testContext0(self): + # Test use of Context + se = self.getContext().evaluate('simple:x') + self.assert_( se == ('simple', 'x'), ( + 'Improperly evaluated expression %s.' % `se`)) + + def testVariables(self): + # Test variables + ctxt = self.getContext() + ctxt.beginScope() + ctxt.setLocal('v1', 1) + ctxt.setLocal('v2', 2) + + c = ctxt.vars + self.assert_( c['v1'] == 1, 'Variable "v1"') + + ctxt.beginScope() + ctxt.setLocal('v1', 3) + ctxt.setGlobal('g', 1) + + c = ctxt.vars + self.assert_( c['v1'] == 3, 'Inner scope') + self.assert_( c['v2'] == 2, 'Outer scope') + self.assert_( c['g'] == 1, 'Global') + + ctxt.endScope() + + c = ctxt.vars + self.assert_( c['v1'] == 1, "Uncovered local") + self.assert_( c['g'] == 1, "Global from inner scope") + + ctxt.endScope() + + +class Harness(object): + def __init__(self, testcase): + self._callstack = [] + self._testcase = testcase + + def _assert_(self, name, *args, **kwargs): + self._callstack.append((name, args, kwargs)) + + def _complete_(self): + self._testcase.assert_(len(self._callstack) == 0, + "Harness methods called") + + def __getattr__(self, name): + return HarnessMethod(self, name) + +class HarnessMethod(object): + + def __init__(self, harness, name): + self._harness = harness + self._name = name + + def __call__(self, *args, **kwargs): + name = self._name + self = self._harness + + cs = self._callstack + self._testcase.assert_( + len(cs), + 'Unexpected harness method call "%s".' % name + ) + self._testcase.assert_( + cs[0][0] == name, + 'Harness method name "%s" called, "%s" expected.' % + (name, cs[0][0]) + ) + + name, aargs, akwargs = self._callstack.pop(0) + self._testcase.assert_(aargs == args, "Harness method arguments") + self._testcase.assert_(akwargs == kwargs, + "Harness method keyword args") + + +def test_suite(): + suite = unittest.makeSuite(TALESTests) + suite.addTest(DocTestSuite("zope.tales.tales")) + return suite + + +if __name__ == '__main__': + unittest.TextTestRunner().run(test_suite()) diff --git a/src/zope/tales/tests/test_traverser.py b/src/zope/tales/tests/test_traverser.py new file mode 100644 index 0000000..eb70cd6 --- /dev/null +++ b/src/zope/tales/tests/test_traverser.py @@ -0,0 +1,120 @@ +""" +Tests for zope.tales.expressions.simpleTraverse + +$Id:$ +""" + +from unittest import TestCase, TestSuite, makeSuite, main +from zope.tales.expressions import simpleTraverse + + +class AttrTraversable(object): + """Traversable by attribute access""" + attr = 'foo' + +class ItemTraversable(object): + """Traversable by item access""" + def __getitem__(self, name): + if name == 'attr': + return 'foo' + raise KeyError, name + +class AllTraversable(AttrTraversable, ItemTraversable): + """Traversable by attribute and item access""" + pass + + +_marker = object() + +def getitem(ob, name, default=_marker): + """Helper a la getattr(ob, name, default).""" + try: + item = ob[name] + except KeyError: + if default is not _marker: + return default + raise KeyError, name + else: + return item + + +class TraverserTests(TestCase): + + def testGetItem(self): + # getitem helper should behave like __getitem__ + ob = {'attr': 'foo'} + self.assertEqual(getitem(ob, 'attr', None), 'foo') + self.assertEqual(getitem(ob, 'attr'), 'foo') + self.assertEqual(getitem(ob, 'missing_attr', None), None) + self.assertRaises(KeyError, getitem, ob, 'missing_attr') + self.assertRaises(TypeError, getitem, object(), 'attr') + + def testAttrTraversable(self): + # An object without __getitem__ should raise AttributeError + # for missing attribute. + ob = AttrTraversable() + self.assertEqual(getattr(ob, 'attr', None), 'foo') + self.assertRaises(AttributeError, getattr, ob, 'missing_attr') + + def testItemTraversable(self): + # An object with __getitem__ (but without attr) should raise + # KeyError for missing attribute. + ob = ItemTraversable() + self.assertEqual(getitem(ob, 'attr', None), 'foo') + self.assertRaises(KeyError, getitem, ob, 'missing_attr') + + def testAllTraversable(self): + # An object with attr and __getitem__ should raise either + # exception, depending on method of access. + ob = AllTraversable() + self.assertEqual(getattr(ob, 'attr', None), 'foo') + self.assertRaises(AttributeError, getattr, ob, 'missing_attr') + self.assertEqual(getitem(ob, 'attr', None), 'foo') + self.assertRaises(KeyError, getitem, ob, 'missing_attr') + + def testTraverseEmptyPath(self): + # simpleTraverse should return the original object if the path is emtpy + ob = object() + self.assertEqual(simpleTraverse(ob, [], None), ob) + + def testTraverseByAttr(self): + # simpleTraverse should find attr through attribute access + ob = AttrTraversable() + self.assertEqual(simpleTraverse(ob, ['attr'], None), 'foo') + + def testTraverseByMissingAttr(self): + # simpleTraverse should raise AttributeError + ob = AttrTraversable() + # Here lurks the bug (unexpected NamError raised) + self.assertRaises(AttributeError, simpleTraverse, ob, ['missing_attr'], None) + + def testTraverseByItem(self): + # simpleTraverse should find attr through item access + ob = ItemTraversable() + self.assertEqual(simpleTraverse(ob, ['attr'], None), 'foo') + + def testTraverseByMissingItem(self): + # simpleTraverse should raise KeyError + ob = ItemTraversable() + self.assertRaises(KeyError, simpleTraverse, ob, ['missing_attr'], None) + + def testTraverseByAll(self): + # simpleTraverse should find attr through attribute access + ob = AllTraversable() + self.assertEqual(simpleTraverse(ob, ['attr'], None), 'foo') + + def testTraverseByMissingAll(self): + # simpleTraverse should raise KeyError (because ob implements __getitem__) + ob = AllTraversable() + self.assertRaises(KeyError, simpleTraverse, ob, ['missing_attr'], None) + + +def test_suite(): + return TestSuite(( + makeSuite(TraverserTests), + )) + + +if __name__ == '__main__': + main(defaultTest='test_suite') + |