# -*- coding: utf-8 -*- import ast import logging import os.path import re import traceback from collections import OrderedDict, Sized, Mapping, defaultdict from functools import reduce from itertools import tee, count from textwrap import dedent import itertools from lxml import etree, html import werkzeug from werkzeug.utils import escape as _escape from odoo.tools import pycompat, freehash try: import builtins builtin_defaults = {name: getattr(builtins, name) for name in dir(builtins)} except ImportError: # pylint: disable=bad-python3-import import __builtin__ builtin_defaults = {name: getattr(__builtin__, name) for name in dir(__builtin__)} try: import astor except ImportError: astor = None unsafe_eval = eval _logger = logging.getLogger(__name__) # in Python 2, arguments (within the ast.arguments structure) are expressions # (since they can be tuples), generally # ast.Name(id: identifyer, ctx=ast.Param()), whereas in Python 3 they are # ast.arg(arg: identifier, annotation: expr?) provide a toplevel arg() # function which matches ast.arg producing the relevant ast.Name in Python 2. arg = getattr(ast, 'arg', lambda arg, annotation: ast.Name(id=arg, ctx=ast.Param())) # also Python 3's arguments has grown *2* new mandatory arguments, kwonlyargs # and kw_defaults for keyword-only arguments and their default values (if any) # so add a shim for *that* based on the signature of Python 3 I guess? arguments = ast.arguments if pycompat.PY2: arguments = lambda args, vararg, kwonlyargs, kw_defaults, kwarg, defaults: ast.arguments(args=args, vararg=vararg, kwarg=kwarg, defaults=defaults) #################################### ### qweb tools ### #################################### class Contextifier(ast.NodeTransformer): """ For user-provided template expressions, replaces any ``name`` by :sampe:`values.get('{name}')` so all variable accesses are performed on the values rather than in the "native" values """ # some people apparently put lambdas in template expressions. Turns out # the AST -> bytecode compiler does *not* appreciate parameters of lambdas # being converted from names to subscript expressions, and most likely the # reference to those parameters inside the lambda's body should probably # remain as-is. Because we're transforming an AST, the structure should # be lexical, so just store a set of "safe" parameter names and recurse # through the lambda using a new NodeTransformer def __init__(self, params=()): super(Contextifier, self).__init__() self._safe_names = tuple(params) def visit_Name(self, node): if node.id in self._safe_names: return node return ast.copy_location( # values.get(name) ast.Call( func=ast.Attribute( value=ast.Name(id='values', ctx=ast.Load()), attr='get', ctx=ast.Load() ), args=[ast.Str(node.id)], keywords=[], starargs=None, kwargs=None ), node ) def visit_Lambda(self, node): args = node.args # assume we don't have any tuple parameter, just names if pycompat.PY2: names = [arg.id for arg in args.args] else: names = [arg.arg for arg in args.args] if args.vararg: names.append(args.vararg) if args.kwarg: names.append(args.kwarg) # remap defaults in case there's any return ast.copy_location(ast.Lambda( args=arguments( args=args.args, defaults=[self.visit(default) for default in args.defaults], vararg=args.vararg, kwarg=args.kwarg, # assume we don't have any, not sure it's even possible to # handle that cross-version kwonlyargs=[], kw_defaults=[], ), body=Contextifier(self._safe_names + tuple(names)).visit(node.body) ), node) # "lambda problem" also exists with comprehensions def _visit_comp(self, node): # CompExp(?, comprehension* generators) # comprehension = (expr target, expr iter, expr* ifs) # collect names in generators.target names = tuple( node.id for gen in node.generators for node in ast.walk(gen.target) if isinstance(node, ast.Name) ) transformer = Contextifier(self._safe_names + names) # copy node newnode = ast.copy_location(type(node)(), node) # then visit the comp ignoring those names, transformation is # probably expensive but shouldn't be many comprehensions for field, value in ast.iter_fields(node): # map transformation of comprehensions if isinstance(value, list): setattr(newnode, field, [transformer.visit(v) for v in value]) else: # set transformation of key/value/expr fields setattr(newnode, field, transformer.visit(value)) return newnode visit_GeneratorExp = visit_ListComp = visit_SetComp = visit_DictComp = _visit_comp class QWebException(Exception): def __init__(self, message, error=None, path=None, html=None, name=None, astmod=None): self.error = error self.message = message self.path = path self.html = html self.name = name self.stack = traceback.format_exc() if astmod: if astor: self.code = astor.to_source(astmod) else: self.code = "Please install astor to display the compiled code" self.stack += "\nInstall `astor` for compiled source information." else: self.code = None if self.error: self.message = "%s\n%s: %s" % (self.message, self.error.__class__.__name__, self.error) if self.name: self.message = "%s\nTemplate: %s" % (self.message, self.name) if self.path: self.message = "%s\nPath: %s" % (self.message, self.path) if self.html: self.message = "%s\nNode: %s" % (self.message, self.html) super(QWebException, self).__init__(message) def __str__(self): message = "%s\n%s\n%s" % (self.error, self.stack, self.message) if self.code: message = "%s\nCompiled code:\n%s" % (message, self.code) return message def __repr__(self): return str(self) # Avoid DeprecationWarning while still remaining compatible with werkzeug pre-0.9 escape = (lambda text: _escape(text, quote=True)) if getattr(werkzeug, '__version__', '0.0') < '0.9.0' else _escape def foreach_iterator(base_ctx, enum, name): ctx = base_ctx.copy() if not enum: return if isinstance(enum, int): enum = range(enum) size = None if isinstance(enum, Sized): ctx["%s_size" % name] = size = len(enum) if isinstance(enum, Mapping): enum = enum.items() else: enum = pycompat.izip(*tee(enum)) value_key = '%s_value' % name index_key = '%s_index' % name first_key = '%s_first' % name last_key = '%s_last' % name parity_key = '%s_parity' % name even_key = '%s_even' % name odd_key = '%s_odd' % name for index, (item, value) in enumerate(enum): ctx[name] = item ctx[value_key] = value ctx[index_key] = index ctx[first_key] = index == 0 if size is not None: ctx[last_key] = index + 1 == size if index % 2: ctx[parity_key] = 'odd' ctx[even_key] = False ctx[odd_key] = True else: ctx[parity_key] = 'even' ctx[even_key] = True ctx[odd_key] = False yield ctx # copy changed items back into source context (?) # FIXME: maybe values could provide a ChainMap-style clone? for k in list(base_ctx): base_ctx[k] = ctx[k] _FORMAT_REGEX = re.compile( # ( ruby-style )|( jinja-style ) r'(?:#\{(.+?)\})|(?:\{\{(.+?)\}\})') class frozendict(dict): """ An implementation of an immutable dictionary. """ def __delitem__(self, key): raise NotImplementedError("'__delitem__' not supported on frozendict") def __setitem__(self, key, val): raise NotImplementedError("'__setitem__' not supported on frozendict") def clear(self): raise NotImplementedError("'clear' not supported on frozendict") def pop(self, key, default=None): raise NotImplementedError("'pop' not supported on frozendict") def popitem(self): raise NotImplementedError("'popitem' not supported on frozendict") def setdefault(self, key, default=None): raise NotImplementedError("'setdefault' not supported on frozendict") def update(self, *args, **kwargs): raise NotImplementedError("'update' not supported on frozendict") def __hash__(self): return hash(frozenset((key, freehash(val)) for key, val in self.items())) #################################### ### QWeb ### #################################### class QWeb(object): _void_elements = frozenset([ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr']) _name_gen = count() def render(self, template, values=None, **options): """ render(template, values, **options) Render the template specified by the given name. :param template: template identifier :param dict values: template values to be used for rendering :param options: used to compile the template (the dict available for the rendering is frozen) * ``load`` (function) overrides the load method * ``profile`` (float) profile the rendering (use astor lib) (filter profile line with time ms >= profile) """ body = [] self.compile(template, options)(self, body.append, values or {}) return u''.join(body).encode('utf8') def compile(self, template, options): """ Compile the given template into a rendering function:: render(qweb, append, values) where ``qweb`` is a QWeb instance, ``append`` is a unary function to collect strings into a result, and ``values`` are the values to render. """ if options is None: options = {} _options = dict(options) options = frozendict(options) element, document = self.get_template(template, options) name = element.get('t-name', 'unknown') _options['template'] = template _options['ast_calls'] = [] _options['root'] = element.getroottree() _options['last_path_node'] = None if not options.get('nsmap'): _options['nsmap'] = {} # generate ast astmod = self._base_module() try: body = self._compile_node(element, _options) ast_calls = _options['ast_calls'] _options['ast_calls'] = [] def_name = self._create_def(_options, body, prefix='template_%s' % name.replace('.', '_')) _options['ast_calls'] += ast_calls except QWebException as e: raise e except Exception as e: path = _options['last_path_node'] node = element.getroottree().xpath(path) raise QWebException("Error when compiling AST", e, path, etree.tostring(node[0], encoding='unicode'), name) astmod.body.extend(_options['ast_calls']) if 'profile' in options: self._profiling(astmod, _options) ast.fix_missing_locations(astmod) # compile ast try: # noinspection PyBroadException ns = {} unsafe_eval(compile(astmod, '