# templateutil.py - utility for template evaluation # # Copyright 2005, 2006 Matt Mackall # # This software may be used and distributed according to the terms of the # GNU General Public License version 2 or any later version. from __future__ import absolute_import import abc import types from .i18n import _ from . import ( error, pycompat, util, ) from .utils import ( dateutil, stringutil, ) class ResourceUnavailable(error.Abort): pass class TemplateNotFound(error.Abort): pass class wrapped(object): """Object requiring extra conversion prior to displaying or processing as value Use unwrapvalue() or unwrapastype() to obtain the inner object. """ __metaclass__ = abc.ABCMeta @abc.abstractmethod def contains(self, context, mapping, item): """Test if the specified item is in self The item argument may be a wrapped object. """ @abc.abstractmethod def getmember(self, context, mapping, key): """Return a member item for the specified key The key argument may be a wrapped object. A returned object may be either a wrapped object or a pure value depending on the self type. """ @abc.abstractmethod def getmin(self, context, mapping): """Return the smallest item, which may be either a wrapped or a pure value depending on the self type""" @abc.abstractmethod def getmax(self, context, mapping): """Return the largest item, which may be either a wrapped or a pure value depending on the self type""" @abc.abstractmethod def filter(self, context, mapping, select): """Return new container of the same type which includes only the selected elements select() takes each item as a wrapped object and returns True/False. """ @abc.abstractmethod def itermaps(self, context): """Yield each template mapping""" @abc.abstractmethod def join(self, context, mapping, sep): """Join items with the separator; Returns a bytes or (possibly nested) generator of bytes A pre-configured template may be rendered per item if this container holds unprintable items. """ @abc.abstractmethod def show(self, context, mapping): """Return a bytes or (possibly nested) generator of bytes representing the underlying object A pre-configured template may be rendered if the underlying object is not printable. """ @abc.abstractmethod def tobool(self, context, mapping): """Return a boolean representation of the inner value""" @abc.abstractmethod def tovalue(self, context, mapping): """Move the inner value object out or create a value representation A returned value must be serializable by templaterfilters.json(). """ class mappable(object): """Object which can be converted to a single template mapping""" def itermaps(self, context): yield self.tomap(context) @abc.abstractmethod def tomap(self, context): """Create a single template mapping representing this""" class wrappedbytes(wrapped): """Wrapper for byte string""" def __init__(self, value): self._value = value def contains(self, context, mapping, item): item = stringify(context, mapping, item) return item in self._value def getmember(self, context, mapping, key): raise error.ParseError(_('%r is not a dictionary') % pycompat.bytestr(self._value)) def getmin(self, context, mapping): return self._getby(context, mapping, min) def getmax(self, context, mapping): return self._getby(context, mapping, max) def _getby(self, context, mapping, func): if not self._value: raise error.ParseError(_('empty string')) return func(pycompat.iterbytestr(self._value)) def filter(self, context, mapping, select): raise error.ParseError(_('%r is not filterable') % pycompat.bytestr(self._value)) def itermaps(self, context): raise error.ParseError(_('%r is not iterable of mappings') % pycompat.bytestr(self._value)) def join(self, context, mapping, sep): return joinitems(pycompat.iterbytestr(self._value), sep) def show(self, context, mapping): return self._value def tobool(self, context, mapping): return bool(self._value) def tovalue(self, context, mapping): return self._value class wrappedvalue(wrapped): """Generic wrapper for pure non-list/dict/bytes value""" def __init__(self, value): self._value = value def contains(self, context, mapping, item): raise error.ParseError(_("%r is not iterable") % self._value) def getmember(self, context, mapping, key): raise error.ParseError(_('%r is not a dictionary') % self._value) def getmin(self, context, mapping): raise error.ParseError(_("%r is not iterable") % self._value) def getmax(self, context, mapping): raise error.ParseError(_("%r is not iterable") % self._value) def filter(self, context, mapping, select): raise error.ParseError(_("%r is not iterable") % self._value) def itermaps(self, context): raise error.ParseError(_('%r is not iterable of mappings') % self._value) def join(self, context, mapping, sep): raise error.ParseError(_('%r is not iterable') % self._value) def show(self, context, mapping): if self._value is None: return b'' return pycompat.bytestr(self._value) def tobool(self, context, mapping): if self._value is None: return False if isinstance(self._value, bool): return self._value # otherwise evaluate as string, which means 0 is True return bool(pycompat.bytestr(self._value)) def tovalue(self, context, mapping): return self._value class date(mappable, wrapped): """Wrapper for date tuple""" def __init__(self, value, showfmt='%d %d'): # value may be (float, int), but public interface shouldn't support # floating-point timestamp self._unixtime, self._tzoffset = map(int, value) self._showfmt = showfmt def contains(self, context, mapping, item): raise error.ParseError(_('date is not iterable')) def getmember(self, context, mapping, key): raise error.ParseError(_('date is not a dictionary')) def getmin(self, context, mapping): raise error.ParseError(_('date is not iterable')) def getmax(self, context, mapping): raise error.ParseError(_('date is not iterable')) def filter(self, context, mapping, select): raise error.ParseError(_('date is not iterable')) def join(self, context, mapping, sep): raise error.ParseError(_("date is not iterable")) def show(self, context, mapping): return self._showfmt % (self._unixtime, self._tzoffset) def tomap(self, context): return {'unixtime': self._unixtime, 'tzoffset': self._tzoffset} def tobool(self, context, mapping): return True def tovalue(self, context, mapping): return (self._unixtime, self._tzoffset) class hybrid(wrapped): """Wrapper for list or dict to support legacy template This class allows us to handle both: - "{files}" (legacy command-line-specific list hack) and - "{files % '{file}\n'}" (hgweb-style with inlining and function support) and to access raw values: - "{ifcontains(file, files, ...)}", "{ifcontains(key, extras, ...)}" - "{get(extras, key)}" - "{files|json}" """ def __init__(self, gen, values, makemap, joinfmt, keytype=None): self._gen = gen # generator or function returning generator self._values = values self._makemap = makemap self._joinfmt = joinfmt self._keytype = keytype # hint for 'x in y' where type(x) is unresolved def contains(self, context, mapping, item): item = unwrapastype(context, mapping, item, self._keytype) return item in self._values def getmember(self, context, mapping, key): # TODO: maybe split hybrid list/dict types? if not util.safehasattr(self._values, 'get'): raise error.ParseError(_('not a dictionary')) key = unwrapastype(context, mapping, key, self._keytype) return self._wrapvalue(key, self._values.get(key)) def getmin(self, context, mapping): return self._getby(context, mapping, min) def getmax(self, context, mapping): return self._getby(context, mapping, max) def _getby(self, context, mapping, func): if not self._values: raise error.ParseError(_('empty sequence')) val = func(self._values) return self._wrapvalue(val, val) def _wrapvalue(self, key, val): if val is None: return if util.safehasattr(val, '_makemap'): # a nested hybrid list/dict, which has its own way of map operation return val return hybriditem(None, key, val, self._makemap) def filter(self, context, mapping, select): if util.safehasattr(self._values, 'get'): values = {k: v for k, v in self._values.iteritems() if select(self._wrapvalue(k, v))} else: values = [v for v in self._values if select(self._wrapvalue(v, v))] return hybrid(None, values, self._makemap, self._joinfmt, self._keytype) def itermaps(self, context): makemap = self._makemap for x in self._values: yield makemap(x) def join(self, context, mapping, sep): # TODO: switch gen to (context, mapping) API? return joinitems((self._joinfmt(x) for x in self._values), sep) def show(self, context, mapping): # TODO: switch gen to (context, mapping) API? gen = self._gen if gen is None: return self.join(context, mapping, ' ') if callable(gen): return gen() return gen def tobool(self, context, mapping): return bool(self._values) def tovalue(self, context, mapping): # TODO: make it non-recursive for trivial lists/dicts xs = self._values if util.safehasattr(xs, 'get'): return {k: unwrapvalue(context, mapping, v) for k, v in xs.iteritems()} return [unwrapvalue(context, mapping, x) for x in xs] class hybriditem(mappable, wrapped): """Wrapper for non-list/dict object to support map operation This class allows us to handle both: - "{manifest}" - "{manifest % '{rev}:{node}'}" - "{manifest.rev}" """ def __init__(self, gen, key, value, makemap): self._gen = gen # generator or function returning generator self._key = key self._value = value # may be generator of strings self._makemap = makemap def tomap(self, context): return self._makemap(self._key) def contains(self, context, mapping, item): w = makewrapped(context, mapping, self._value) return w.contains(context, mapping, item) def getmember(self, context, mapping, key): w = makewrapped(context, mapping, self._value) return w.getmember(context, mapping, key) def getmin(self, context, mapping): w = makewrapped(context, mapping, self._value) return w.getmin(context, mapping) def getmax(self, context, mapping): w = makewrapped(context, mapping, self._value) return w.getmax(context, mapping) def filter(self, context, mapping, select): w = makewrapped(context, mapping, self._value) return w.filter(context, mapping, select) def join(self, context, mapping, sep): w = makewrapped(context, mapping, self._value) return w.join(context, mapping, sep) def show(self, context, mapping): # TODO: switch gen to (context, mapping) API? gen = self._gen if gen is None: return pycompat.bytestr(self._value) if callable(gen): return gen() return gen def tobool(self, context, mapping): w = makewrapped(context, mapping, self._value) return w.tobool(context, mapping) def tovalue(self, context, mapping): return _unthunk(context, mapping, self._value) class _mappingsequence(wrapped): """Wrapper for sequence of template mappings This represents an inner template structure (i.e. a list of dicts), which can also be rendered by the specified named/literal template. Template mappings may be nested. """ def __init__(self, name=None, tmpl=None, sep=''): if name is not None and tmpl is not None: raise error.ProgrammingError('name and tmpl are mutually exclusive') self._name = name self._tmpl = tmpl self._defaultsep = sep def contains(self, context, mapping, item): raise error.ParseError(_('not comparable')) def getmember(self, context, mapping, key): raise error.ParseError(_('not a dictionary')) def getmin(self, context, mapping): raise error.ParseError(_('not comparable')) def getmax(self, context, mapping): raise error.ParseError(_('not comparable')) def filter(self, context, mapping, select): # implement if necessary; we'll need a wrapped type for a mapping dict raise error.ParseError(_('not filterable without template')) def join(self, context, mapping, sep): mapsiter = _iteroverlaymaps(context, mapping, self.itermaps(context)) if self._name: itemiter = (context.process(self._name, m) for m in mapsiter) elif self._tmpl: itemiter = (context.expand(self._tmpl, m) for m in mapsiter) else: raise error.ParseError(_('not displayable without template')) return joinitems(itemiter, sep) def show(self, context, mapping): return self.join(context, mapping, self._defaultsep) def tovalue(self, context, mapping): knownres = context.knownresourcekeys() items = [] for nm in self.itermaps(context): # drop internal resources (recursively) which shouldn't be displayed lm = context.overlaymap(mapping, nm) items.append({k: unwrapvalue(context, lm, v) for k, v in nm.iteritems() if k not in knownres}) return items class mappinggenerator(_mappingsequence): """Wrapper for generator of template mappings The function ``make(context, *args)`` should return a generator of mapping dicts. """ def __init__(self, make, args=(), name=None, tmpl=None, sep=''): super(mappinggenerator, self).__init__(name, tmpl, sep) self._make = make self._args = args def itermaps(self, context): return self._make(context, *self._args) def tobool(self, context, mapping): return _nonempty(self.itermaps(context)) class mappinglist(_mappingsequence): """Wrapper for list of template mappings""" def __init__(self, mappings, name=None, tmpl=None, sep=''): super(mappinglist, self).__init__(name, tmpl, sep) self._mappings = mappings def itermaps(self, context): return iter(self._mappings) def tobool(self, context, mapping): return bool(self._mappings) class mappedgenerator(wrapped): """Wrapper for generator of strings which acts as a list The function ``make(context, *args)`` should return a generator of byte strings, or a generator of (possibly nested) generators of byte strings (i.e. a generator for a list of byte strings.) """ def __init__(self, make, args=()): self._make = make self._args = args def contains(self, context, mapping, item): item = stringify(context, mapping, item) return item in self.tovalue(context, mapping) def _gen(self, context): return self._make(context, *self._args) def getmember(self, context, mapping, key): raise error.ParseError(_('not a dictionary')) def getmin(self, context, mapping): return self._getby(context, mapping, min) def getmax(self, context, mapping): return self._getby(context, mapping, max) def _getby(self, context, mapping, func): xs = self.tovalue(context, mapping) if not xs: raise error.ParseError(_('empty sequence')) return func(xs) @staticmethod def _filteredgen(context, mapping, make, args, select): for x in make(context, *args): s = stringify(context, mapping, x) if select(wrappedbytes(s)): yield s def filter(self, context, mapping, select): args = (mapping, self._make, self._args, select) return mappedgenerator(self._filteredgen, args) def itermaps(self, context): raise error.ParseError(_('list of strings is not mappable')) def join(self, context, mapping, sep): return joinitems(self._gen(context), sep) def show(self, context, mapping): return self.join(context, mapping, '') def tobool(self, context, mapping): return _nonempty(self._gen(context)) def tovalue(self, context, mapping): return [stringify(context, mapping, x) for x in self._gen(context)] def hybriddict(data, key='key', value='value', fmt=None, gen=None): """Wrap data to support both dict-like and string-like operations""" prefmt = pycompat.identity if fmt is None: fmt = '%s=%s' prefmt = pycompat.bytestr return hybrid(gen, data, lambda k: {key: k, value: data[k]}, lambda k: fmt % (prefmt(k), prefmt(data[k]))) def hybridlist(data, name, fmt=None, gen=None): """Wrap data to support both list-like and string-like operations""" prefmt = pycompat.identity if fmt is None: fmt = '%s' prefmt = pycompat.bytestr return hybrid(gen, data, lambda x: {name: x}, lambda x: fmt % prefmt(x)) def compatdict(context, mapping, name, data, key='key', value='value', fmt=None, plural=None, separator=' '): """Wrap data like hybriddict(), but also supports old-style list template This exists for backward compatibility with the old-style template. Use hybriddict() for new template keywords. """ c = [{key: k, value: v} for k, v in data.iteritems()] f = _showcompatlist(context, mapping, name, c, plural, separator) return hybriddict(data, key=key, value=value, fmt=fmt, gen=f) def compatlist(context, mapping, name, data, element=None, fmt=None, plural=None, separator=' '): """Wrap data like hybridlist(), but also supports old-style list template This exists for backward compatibility with the old-style template. Use hybridlist() for new template keywords. """ f = _showcompatlist(context, mapping, name, data, plural, separator) return hybridlist(data, name=element or name, fmt=fmt, gen=f) def _showcompatlist(context, mapping, name, values, plural=None, separator=' '): """Return a generator that renders old-style list template name is name of key in template map. values is list of strings or dicts. plural is plural of name, if not simply name + 's'. separator is used to join values as a string expansion works like this, given name 'foo'. if values is empty, expand 'no_foos'. if 'foo' not in template map, return values as a string, joined by 'separator'. expand 'start_foos'. for each value, expand 'foo'. if 'last_foo' in template map, expand it instead of 'foo' for last key. expand 'end_foos'. """ if not plural: plural = name + 's' if not values: noname = 'no_' + plural if context.preload(noname): yield context.process(noname, mapping) return if not context.preload(name): if isinstance(values[0], bytes): yield separator.join(values) else: for v in values: r = dict(v) r.update(mapping) yield r return startname = 'start_' + plural if context.preload(startname): yield context.process(startname, mapping) def one(v, tag=name): vmapping = {} try: vmapping.update(v) # Python 2 raises ValueError if the type of v is wrong. Python # 3 raises TypeError. except (AttributeError, TypeError, ValueError): try: # Python 2 raises ValueError trying to destructure an e.g. # bytes. Python 3 raises TypeError. for a, b in v: vmapping[a] = b except (TypeError, ValueError): vmapping[name] = v vmapping = context.overlaymap(mapping, vmapping) return context.process(tag, vmapping) lastname = 'last_' + name if context.preload(lastname): last = values.pop() else: last = None for v in values: yield one(v) if last is not None: yield one(last, tag=lastname) endname = 'end_' + plural if context.preload(endname): yield context.process(endname, mapping) def flatten(context, mapping, thing): """Yield a single stream from a possibly nested set of iterators""" if isinstance(thing, wrapped): thing = thing.show(context, mapping) if isinstance(thing, bytes): yield thing elif isinstance(thing, str): # We can only hit this on Python 3, and it's here to guard # against infinite recursion. raise error.ProgrammingError('Mercurial IO including templates is done' ' with bytes, not strings, got %r' % thing) elif thing is None: pass elif not util.safehasattr(thing, '__iter__'): yield pycompat.bytestr(thing) else: for i in thing: if isinstance(i, wrapped): i = i.show(context, mapping) if isinstance(i, bytes): yield i elif i is None: pass elif not util.safehasattr(i, '__iter__'): yield pycompat.bytestr(i) else: for j in flatten(context, mapping, i): yield j def stringify(context, mapping, thing): """Turn values into bytes by converting into text and concatenating them""" if isinstance(thing, bytes): return thing # retain localstr to be round-tripped return b''.join(flatten(context, mapping, thing)) def findsymbolicname(arg): """Find symbolic name for the given compiled expression; returns None if nothing found reliably""" while True: func, data = arg if func is runsymbol: return data elif func is runfilter: arg = data[0] else: return None def _nonempty(xiter): try: next(xiter) return True except StopIteration: return False def _unthunk(context, mapping, thing): """Evaluate a lazy byte string into value""" if not isinstance(thing, types.GeneratorType): return thing return stringify(context, mapping, thing) def evalrawexp(context, mapping, arg): """Evaluate given argument as a bare template object which may require further processing (such as folding generator of strings)""" func, data = arg return func(context, mapping, data) def evalwrapped(context, mapping, arg): """Evaluate given argument to wrapped object""" thing = evalrawexp(context, mapping, arg) return makewrapped(context, mapping, thing) def makewrapped(context, mapping, thing): """Lift object to a wrapped type""" if isinstance(thing, wrapped): return thing thing = _unthunk(context, mapping, thing) if isinstance(thing, bytes): return wrappedbytes(thing) return wrappedvalue(thing) def evalfuncarg(context, mapping, arg): """Evaluate given argument as value type""" return unwrapvalue(context, mapping, evalrawexp(context, mapping, arg)) def unwrapvalue(context, mapping, thing): """Move the inner value object out of the wrapper""" if isinstance(thing, wrapped): return thing.tovalue(context, mapping) # evalrawexp() may return string, generator of strings or arbitrary object # such as date tuple, but filter does not want generator. return _unthunk(context, mapping, thing) def evalboolean(context, mapping, arg): """Evaluate given argument as boolean, but also takes boolean literals""" func, data = arg if func is runsymbol: thing = func(context, mapping, data, default=None) if thing is None: # not a template keyword, takes as a boolean literal thing = stringutil.parsebool(data) else: thing = func(context, mapping, data) return makewrapped(context, mapping, thing).tobool(context, mapping) def evaldate(context, mapping, arg, err=None): """Evaluate given argument as a date tuple or a date string; returns a (unixtime, offset) tuple""" thing = evalrawexp(context, mapping, arg) return unwrapdate(context, mapping, thing, err) def unwrapdate(context, mapping, thing, err=None): if isinstance(thing, date): return thing.tovalue(context, mapping) # TODO: update hgweb to not return bare tuple; then just stringify 'thing' thing = unwrapvalue(context, mapping, thing) try: return dateutil.parsedate(thing) except AttributeError: raise error.ParseError(err or _('not a date tuple nor a string')) except error.ParseError: if not err: raise raise error.ParseError(err) def evalinteger(context, mapping, arg, err=None): thing = evalrawexp(context, mapping, arg) return unwrapinteger(context, mapping, thing, err) def unwrapinteger(context, mapping, thing, err=None): thing = unwrapvalue(context, mapping, thing) try: return int(thing) except (TypeError, ValueError): raise error.ParseError(err or _('not an integer')) def evalstring(context, mapping, arg): return stringify(context, mapping, evalrawexp(context, mapping, arg)) def evalstringliteral(context, mapping, arg): """Evaluate given argument as string template, but returns symbol name if it is unknown""" func, data = arg if func is runsymbol: thing = func(context, mapping, data, default=data) else: thing = func(context, mapping, data) return stringify(context, mapping, thing) _unwrapfuncbytype = { None: unwrapvalue, bytes: stringify, date: unwrapdate, int: unwrapinteger, } def unwrapastype(context, mapping, thing, typ): """Move the inner value object out of the wrapper and coerce its type""" try: f = _unwrapfuncbytype[typ] except KeyError: raise error.ProgrammingError('invalid type specified: %r' % typ) return f(context, mapping, thing) def runinteger(context, mapping, data): return int(data) def runstring(context, mapping, data): return data def _recursivesymbolblocker(key): def showrecursion(context, mapping): raise error.Abort(_("recursive reference '%s' in template") % key) showrecursion._requires = () # mark as new-style templatekw return showrecursion def runsymbol(context, mapping, key, default=''): v = context.symbol(mapping, key) if v is None: # put poison to cut recursion. we can't move this to parsing phase # because "x = {x}" is allowed if "x" is a keyword. (issue4758) safemapping = mapping.copy() safemapping[key] = _recursivesymbolblocker(key) try: v = context.process(key, safemapping) except TemplateNotFound: v = default if callable(v) and getattr(v, '_requires', None) is None: # old templatekw: expand all keywords and resources # (TODO: drop support for old-style functions. 'f._requires = ()' # can be removed.) props = {k: context._resources.lookup(context, mapping, k) for k in context._resources.knownkeys()} # pass context to _showcompatlist() through templatekw._showlist() props['templ'] = context props.update(mapping) ui = props.get('ui') if ui: ui.deprecwarn("old-style template keyword '%s'" % key, '4.8') return v(**pycompat.strkwargs(props)) if callable(v): # new templatekw try: return v(context, mapping) except ResourceUnavailable: # unsupported keyword is mapped to empty just like unknown keyword return None return v def runtemplate(context, mapping, template): for arg in template: yield evalrawexp(context, mapping, arg) def runfilter(context, mapping, data): arg, filt = data thing = evalrawexp(context, mapping, arg) intype = getattr(filt, '_intype', None) try: thing = unwrapastype(context, mapping, thing, intype) return filt(thing) except error.ParseError as e: raise error.ParseError(bytes(e), hint=_formatfiltererror(arg, filt)) def _formatfiltererror(arg, filt): fn = pycompat.sysbytes(filt.__name__) sym = findsymbolicname(arg) if not sym: return _("incompatible use of template filter '%s'") % fn return (_("template filter '%s' is not compatible with keyword '%s'") % (fn, sym)) def _iteroverlaymaps(context, origmapping, newmappings): """Generate combined mappings from the original mapping and an iterable of partial mappings to override the original""" for i, nm in enumerate(newmappings): lm = context.overlaymap(origmapping, nm) lm['index'] = i yield lm def _applymap(context, mapping, d, darg, targ): try: diter = d.itermaps(context) except error.ParseError as err: sym = findsymbolicname(darg) if not sym: raise hint = _("keyword '%s' does not support map operation") % sym raise error.ParseError(bytes(err), hint=hint) for lm in _iteroverlaymaps(context, mapping, diter): yield evalrawexp(context, lm, targ) def runmap(context, mapping, data): darg, targ = data d = evalwrapped(context, mapping, darg) return mappedgenerator(_applymap, args=(mapping, d, darg, targ)) def runmember(context, mapping, data): darg, memb = data d = evalwrapped(context, mapping, darg) if isinstance(d, mappable): lm = context.overlaymap(mapping, d.tomap(context)) return runsymbol(context, lm, memb) try: return d.getmember(context, mapping, memb) except error.ParseError as err: sym = findsymbolicname(darg) if not sym: raise hint = _("keyword '%s' does not support member operation") % sym raise error.ParseError(bytes(err), hint=hint) def runnegate(context, mapping, data): data = evalinteger(context, mapping, data, _('negation needs an integer argument')) return -data def runarithmetic(context, mapping, data): func, left, right = data left = evalinteger(context, mapping, left, _('arithmetic only defined on integers')) right = evalinteger(context, mapping, right, _('arithmetic only defined on integers')) try: return func(left, right) except ZeroDivisionError: raise error.Abort(_('division by zero is not defined')) def joinitems(itemiter, sep): """Join items with the separator; Returns generator of bytes""" first = True for x in itemiter: if first: first = False elif sep: yield sep yield x