Bug 1328727: vendor json-e 2.1.1; r?jonasfj draft
authorDustin J. Mitchell <dustin@mozilla.com>
Fri, 21 Jul 2017 16:12:25 +0000
changeset 613222 76f4e240da7ceec9afb7bb31663c61ee2821e999
parent 613041 417eea89f792059e88be5e217e6f1a5cecf2d3d0
child 613223 9db1cdfdd41c0eea2fae3487ec8cf898060b5703
push id69756
push userdmitchell@mozilla.com
push dateFri, 21 Jul 2017 19:09:34 +0000
reviewersjonasfj
bugs1328727
milestone56.0a1
Bug 1328727: vendor json-e 2.1.1; r?jonasfj MozReview-Commit-ID: D5ZbZtMAlkN
build/virtualenv_packages.txt
third_party/python/json-e/MANIFEST.in
third_party/python/json-e/PKG-INFO
third_party/python/json-e/jsone/__init__.py
third_party/python/json-e/jsone/builtins.py
third_party/python/json-e/jsone/interpreter.py
third_party/python/json-e/jsone/prattparser.py
third_party/python/json-e/jsone/render.py
third_party/python/json-e/jsone/shared.py
third_party/python/json-e/jsone/six.py
third_party/python/json-e/setup.cfg
third_party/python/json-e/setup.py
--- a/build/virtualenv_packages.txt
+++ b/build/virtualenv_packages.txt
@@ -17,16 +17,17 @@ mozilla.pth:third_party/python/pystache
 mozilla.pth:third_party/python/pyyaml/lib
 mozilla.pth:third_party/python/requests
 mozilla.pth:third_party/python/slugid
 mozilla.pth:third_party/python/py
 mozilla.pth:third_party/python/pytest
 mozilla.pth:third_party/python/pytoml
 mozilla.pth:third_party/python/redo
 mozilla.pth:third_party/python/voluptuous
+mozilla.pth:third_party/python/json-e
 mozilla.pth:build
 objdir:build
 mozilla.pth:build/pymake
 mozilla.pth:config
 mozilla.pth:dom/bindings
 mozilla.pth:dom/bindings/parser
 mozilla.pth:layout/tools/reftest
 mozilla.pth:other-licenses/ply/
new file mode 100644
--- /dev/null
+++ b/third_party/python/json-e/MANIFEST.in
@@ -0,0 +1,2 @@
+include jsone *.py
+recursive-exclude test *
new file mode 100644
--- /dev/null
+++ b/third_party/python/json-e/PKG-INFO
@@ -0,0 +1,10 @@
+Metadata-Version: 1.0
+Name: json-e
+Version: 2.1.1
+Summary: A data-structure parameterization system written for embedding context in JSON objects
+Home-page: https://taskcluster.github.io/json-e/
+Author: Dustin J. Mitchell
+Author-email: dustin@mozilla.com
+License: MPL2
+Description: UNKNOWN
+Platform: UNKNOWN
new file mode 100644
--- /dev/null
+++ b/third_party/python/json-e/jsone/__init__.py
@@ -0,0 +1,20 @@
+from __future__ import absolute_import, print_function, unicode_literals
+
+import re
+from .render import renderValue
+from .shared import JSONTemplateError, DeleteMarker
+from .builtins import builtins
+
+_context_re = re.compile(r'[a-zA-Z_][a-zA-Z0-9_]*$')
+
+
+def render(template, context):
+    if not all(_context_re.match(c) for c in context):
+        raise JSONTemplateError('top level keys of context must follow '
+                                '/[a-zA-Z_][a-zA-Z0-9_]*/')
+    full_context = builtins.copy()
+    full_context.update(context)
+    rv = renderValue(template, full_context)
+    if rv is DeleteMarker:
+        return None
+    return rv
new file mode 100644
--- /dev/null
+++ b/third_party/python/json-e/jsone/builtins.py
@@ -0,0 +1,128 @@
+from __future__ import absolute_import, print_function, unicode_literals
+
+import math
+from .interpreter import ExpressionError
+from .shared import string, fromNow
+
+builtins = {}
+
+
+def builtin(name, variadic=None, argument_tests=None, minArgs=None):
+    def wrap(fn):
+        def bad(reason=None):
+            raise ExpressionError((reason or 'invalid arguments to {}').format(name))
+        if variadic:
+            def invoke(args):
+                if minArgs:
+                    if len(args) < minArgs:
+                        bad("too few arguments to {}")
+                for arg in args:
+                    if not variadic(arg):
+                        bad()
+                return fn(*args)
+
+        elif argument_tests:
+            def invoke(args):
+                if len(args) != len(argument_tests):
+                    bad()
+                for t, arg in zip(argument_tests, args):
+                    if not t(arg):
+                        bad()
+                return fn(*args)
+
+        else:
+            def invoke(args):
+                return fn(*args)
+
+        builtins[name] = invoke
+        return fn
+    return wrap
+
+
+def is_number(v):
+    return isinstance(v, (int, float)) and not isinstance(v, bool)
+
+
+def is_string(v):
+    return isinstance(v, string)
+
+
+def is_string_or_array(v):
+    return isinstance(v, (string, list))
+
+
+def anything(v):
+    return isinstance(v, (string, int, float, list, dict)) or v is None or callable(v)
+
+# ---
+
+
+builtin('min', variadic=is_number, minArgs=1)(min)
+builtin('max', variadic=is_number, minArgs=1)(max)
+builtin('sqrt', argument_tests=[is_number])(math.sqrt)
+builtin('abs', argument_tests=[is_number])(abs)
+
+
+@builtin('ceil', argument_tests=[is_number])
+def ceil(v):
+    return int(math.ceil(v))
+
+
+@builtin('floor', argument_tests=[is_number])
+def floor(v):
+    return int(math.floor(v))
+
+
+@builtin('lowercase', argument_tests=[is_string])
+def lowercase(v):
+    return v.lower()
+
+
+@builtin('uppercase', argument_tests=[is_string])
+def lowercase(v):
+    return v.upper()
+
+
+builtin('len', argument_tests=[is_string_or_array])(len)
+
+
+@builtin('str', argument_tests=[anything])
+def to_str(v):
+    if isinstance(v, bool):
+        return {True: 'true', False: 'false'}[v]
+    elif isinstance(v, list):
+        return ','.join(to_str(e) for e in v)
+    else:
+        return str(v)
+
+
+@builtin('str', argument_tests=[anything])
+def to_str(v):
+    if isinstance(v, bool):
+        return {True: 'true', False: 'false'}[v]
+    elif isinstance(v, list):
+        return ','.join(to_str(e) for e in v)
+    elif v is None:
+        return 'null'
+    else:
+        return str(v)
+
+
+builtin('fromNow', argument_tests=[is_string])(fromNow)
+
+@builtin('typeof', argument_tests=[anything])
+def typeof(v):
+    if isinstance(v, bool):
+        return 'boolean'
+    elif isinstance(v, string):
+        return 'string'
+    elif isinstance(v, (int, float)):
+        return 'number'
+    elif isinstance(v, list):
+        return 'array'
+    elif isinstance(v, dict):
+        return 'object'
+    elif v is None:
+        return None
+    elif callable(v):
+        return 'function'
new file mode 100644
--- /dev/null
+++ b/third_party/python/json-e/jsone/interpreter.py
@@ -0,0 +1,289 @@
+from __future__ import absolute_import, print_function, unicode_literals
+
+from .prattparser import PrattParser, infix, prefix
+from .shared import JSONTemplateError, string
+import operator
+import json
+
+OPERATORS = {
+    '-': operator.sub,
+    '*': operator.mul,
+    '/': operator.truediv,
+    '**': operator.pow,
+    '==': operator.eq,
+    '!=': operator.ne,
+    '<=': operator.le,
+    '<': operator.lt,
+    '>': operator.gt,
+    '>=': operator.ge,
+    '&&': lambda a, b: bool(a and b),
+    '||': lambda a, b: bool(a or b),
+}
+
+
+class ExpressionError(JSONTemplateError):
+
+    @classmethod
+    def expectation(cls, operator, expected):
+        return cls('{} expected {}'.format(operator, expected))
+
+
+class ExpressionEvaluator(PrattParser):
+
+    ignore = '\\s+'
+    patterns = {
+        'number': '[0-9]+(?:\\.[0-9]+)?',
+        'identifier': '[a-zA-Z_][a-zA-Z_0-9]*',
+        'string': '\'[^\']*\'|"[^"]*"',
+        # avoid matching these as prefixes of identifiers e.g., `insinutations`
+        'true': 'true(?![a-zA-Z_0-9])',
+        'false': 'false(?![a-zA-Z_0-9])',
+        'in': 'in(?![a-zA-Z_0-9])',
+        'null': 'null(?![a-zA-Z_0-9])',
+    }
+    tokens = [
+        '**', '+', '-', '*', '/', '[', ']', '.', '(', ')', '{', '}', ':', ',',
+        '>=', '<=', '<', '>', '==', '!=', '!', '&&', '||', 'true', 'false', 'in',
+        'null', 'number', 'identifier', 'string',
+    ]
+    precedence = [
+        ['in'],
+        ['||'],
+        ['&&'],
+        ['==', '!='],
+        ['>=', '<=', '<', '>'],
+        ['+', '-'],
+        ['*', '/'],
+        ['**-right-associative'],
+        ['**'],
+        ['[', '.'],
+        ['('],
+        ['unary'],
+    ]
+
+    def __init__(self, context):
+        super(ExpressionEvaluator, self).__init__()
+        self.context = context
+
+    def parse(self, expression):
+        if not isinstance(expression, string):
+            raise ExpressionError('expression to be evaluated must be a string')
+        return super(ExpressionEvaluator, self).parse(expression)
+
+    @prefix('number')
+    def number(self, token, pc):
+        v = token.value
+        return float(v) if '.' in v else int(v)
+
+    @prefix("!")
+    def bang(self, token, pc):
+        return not pc.parse('unary')
+
+    @prefix("-")
+    def uminus(self, token, pc):
+        v = pc.parse('unary')
+        if not isNumber(v):
+            raise ExpressionError.expectation('unary -', 'number')
+        return -v
+
+    @prefix("+")
+    def uplus(self, token, pc):
+        v = pc.parse('unary')
+        if not isNumber(v):
+            raise ExpressionError.expectation('unary +', 'number')
+        return v
+
+    @prefix("identifier")
+    def identifier(self, token, pc):
+        try:
+            return self.context[token.value]
+        except KeyError:
+            raise ExpressionError('no context value named "{}"'.format(token.value))
+
+    @prefix("null")
+    def null(self, token, pc):
+        return None
+
+    @prefix("[")
+    def array_bracket(self, token, pc):
+        return parseList(pc, ',', ']')
+
+    @prefix("(")
+    def grouping_paren(self, token, pc):
+        rv = pc.parse()
+        pc.require(')')
+        return rv
+
+    @prefix("{")
+    def object_brace(self, token, pc):
+        return parseObject(pc)
+
+    @prefix("string")
+    def string(self, token, pc):
+        return parseString(token.value)
+
+    @prefix("true")
+    def true(self, token, pc):
+        return True
+
+    @prefix("false")
+    def false(self, token, ps):
+        return False
+
+    @infix("+")
+    def plus(self, left, token, pc):
+        if not isinstance(left, (string, int, float)) or isinstance(left, bool):
+            raise ExpressionError.expectation('+', 'number or string')
+        right = pc.parse(token.kind)
+        if not isinstance(right, (string, int, float)) or isinstance(right, bool):
+            raise ExpressionError.expectation('+', 'number or string')
+        if type(right) != type(left) and \
+                (isinstance(left, string) or isinstance(right, string)):
+            raise ExpressionError.expectation('+', 'matching types')
+        return left + right
+
+    @infix('-', '*', '/', '**')
+    def arith(self, left, token, pc):
+        op = token.kind
+        if not isNumber(left):
+            raise ExpressionError.expectation(op, 'number')
+        right = pc.parse({'**': '**-right-associative'}.get(op))
+        if not isNumber(right):
+            raise ExpressionError.expectation(op, 'number')
+        return OPERATORS[op](left, right)
+
+    @infix("[")
+    def index_slice(self, left, token, pc):
+        a = None
+        b = None
+        is_interval = False
+        if pc.attempt(':'):
+            a = 0
+            is_interval = True
+        else:
+            a = pc.parse()
+            if pc.attempt(':'):
+                is_interval = True
+
+        if is_interval and not pc.attempt(']'):
+            b = pc.parse()
+            pc.require(']')
+
+        if not is_interval:
+            pc.require(']')
+
+        return accessProperty(left, a, b, is_interval)
+
+    @infix(".")
+    def property_dot(self, left, token, pc):
+        if not isinstance(left, dict):
+            raise ExpressionError.expectation('.', 'object')
+        k = pc.require('identifier').value
+        try:
+            return left[k]
+        except KeyError:
+            raise ExpressionError('{} not found in {}'.format(k, json.dumps(left)))
+
+    @infix("(")
+    def function_call(self, left, token, pc):
+        if not callable(left):
+            raise ExpressionError('function call', 'callable')
+        args = parseList(pc, ',', ')')
+        return left(args)
+
+    @infix('==', '!=', '||', '&&')
+    def equality_and_logic(self, left, token, pc):
+        op = token.kind
+        right = pc.parse(op)
+        return OPERATORS[op](left, right)
+
+    @infix('<=', '<', '>', '>=')
+    def inequality(self, left, token, pc):
+        op = token.kind
+        right = pc.parse(op)
+        if type(left) != type(right) or \
+                not (isinstance(left, (int, float, string)) and not isinstance(left, bool)):
+            raise ExpressionError.expectation(op, 'matching types')
+        return OPERATORS[op](left, right)
+
+    @infix("in")
+    def contains(self, left, token, pc):
+        right = pc.parse(token.kind)
+        if isinstance(right, dict):
+            if not isinstance(left, string):
+                raise ExpressionError.expectation('in-object', 'string on left side')
+        elif isinstance(right, string):
+            if not isinstance(left, string):
+                raise ExpressionError.expectation('in-string', 'string on left side')
+        elif not isinstance(right, list):
+            raise ExpressionError.expectation('in', 'Array, string, or object on right side')
+        try:
+            return left in right
+        except TypeError:
+            raise ExpressionError.expectation('in', 'scalar value, collection')
+
+
+def isNumber(v):
+    return isinstance(v, (int, float)) and not isinstance(v, bool)
+
+
+def parseString(v):
+    return v[1:-1]
+
+
+def parseList(pc, separator, terminator):
+    rv = []
+    if not pc.attempt(terminator):
+        while True:
+            rv.append(pc.parse())
+            if not pc.attempt(separator):
+                break
+        pc.require(terminator)
+    return rv
+
+
+def parseObject(pc):
+    rv = {}
+    if not pc.attempt('}'):
+        while True:
+            k = pc.require('identifier', 'string')
+            if k.kind == 'string':
+                k = parseString(k.value)
+            else:
+                k = k.value
+            pc.require(':')
+            v = pc.parse()
+            rv[k] = v
+            if not pc.attempt(','):
+                break
+        pc.require('}')
+    return rv
+
+
+def accessProperty(value, a, b, is_interval):
+    if isinstance(value, (list, string)):
+        if is_interval:
+            if b is None:
+                b = len(value)
+            try:
+                return value[a:b]
+            except TypeError:
+                raise ExpressionError.expectation('[..]', 'integer')
+        else:
+            try:
+                return value[a]
+            except IndexError:
+                raise ExpressionError('index out of bounds')
+            except TypeError:
+                raise ExpressionError.expectation('[..]', 'integer')
+
+    if not isinstance(value, dict):
+        raise ExpressionError.expectation('[..]', 'object, array, or string')
+    if not isinstance(a, string):
+        raise ExpressionError.expectation('[..]', 'string index')
+
+    try:
+        return value[a]
+    except KeyError:
+        return None
+        #raise ExpressionError('{} not found in {}'.format(a, json.dumps(value)))
new file mode 100644
--- /dev/null
+++ b/third_party/python/json-e/jsone/prattparser.py
@@ -0,0 +1,189 @@
+from __future__ import absolute_import, print_function, unicode_literals
+
+import re
+from collections import namedtuple
+from .shared import JSONTemplateError
+from .six import with_metaclass, viewitems
+
+
+class SyntaxError(JSONTemplateError):
+
+    @classmethod
+    def unexpected(cls, got, exp):
+        exp = ', '.join(sorted(exp))
+        return cls('Found {}, expected {}'.format(got, exp))
+
+
+Token = namedtuple('Token', ['kind', 'value', 'start', 'end'])
+
+
+def prefix(*kinds):
+    """Decorate a method as handling prefix tokens of the given kinds"""
+    def wrap(fn):
+        try:
+            fn.prefix_kinds.extend(kinds)
+        except AttributeError:
+            fn.prefix_kinds = list(kinds)
+        return fn
+    return wrap
+
+
+def infix(*kinds):
+    """Decorate a method as handling infix tokens of the given kinds"""
+    def wrap(fn):
+        try:
+            fn.infix_kinds.extend(kinds)
+        except AttributeError:
+            fn.infix_kinds = list(kinds)
+        return fn
+    return wrap
+
+
+class PrattParserMeta(type):
+
+    def __init__(cls, name, bases, body):
+        # set up rules based on decorated methods
+        infix_rules = cls.infix_rules = {}
+        prefix_rules = cls.prefix_rules = {}
+        for prop, value in viewitems(body):
+            if hasattr(value, 'prefix_kinds'):
+                for kind in value.prefix_kinds:
+                    prefix_rules[kind] = value
+                delattr(cls, prop)
+            if hasattr(value, 'infix_kinds'):
+                for kind in value.infix_kinds:
+                    infix_rules[kind] = value
+                delattr(cls, prop)
+
+        # build a regular expression to generate a sequence of tokens
+        token_patterns = [
+            '({})'.format(cls.patterns.get(t, re.escape(t)))
+            for t in cls.tokens]
+        if cls.ignore:
+            token_patterns.append('(?:{})'.format(cls.ignore))
+        cls.token_re = re.compile('^(?:' + '|'.join(token_patterns) + ')')
+
+        # build a map from token kind to precedence level
+        cls.precedence_map = {
+            kind: prec + 1
+            for (prec, row) in enumerate(cls.precedence)
+            for kind in row
+        }
+
+
+class PrattParser(with_metaclass(PrattParserMeta, object)):
+
+    # regular expression for ignored input (e.g., whitespace)
+    ignore = None
+
+    # regular expressions for tokens that do not match themselves
+    patterns = {}
+
+    # all token kinds (note that order matters - the first matching token
+    # will be returned)
+    tokens = []
+
+    # precedence of tokens, as a list of lists, from lowest to highest
+    precedence = []
+
+    def parse(self, source):
+        pc = ParseContext(self, source, self._generate_tokens(source))
+        result = pc.parse()
+        # if there are any tokens remaining, that's an error..
+        token = pc.attempt()
+        if token:
+            raise SyntaxError.unexpected(token.kind, self.infix_rules)
+        return result
+
+    def parseUntilTerminator(self, source, terminator):
+        pc = ParseContext(self, source, self._generate_tokens(source))
+        result = pc.parse()
+        token = pc.attempt()
+        if token.kind != terminator:
+            raise SyntaxError.unexpected(token.kind, [terminator])
+        return (result, token.start)
+
+    def _generate_tokens(self, source):
+        offset = 0
+        while True:
+            start = offset
+            remainder = source[offset:]
+            mo = self.token_re.match(remainder)
+            if not mo:
+                if remainder:
+                    raise SyntaxError("Unexpected input: '{}'".format(remainder))
+                break
+            offset += mo.end()
+
+            # figure out which token matched (note that idx is 0-based)
+            indexes = list(filter(lambda x: x[1] is not None, enumerate(mo.groups())))
+            if indexes:
+                idx = indexes[0][0]
+                yield Token(
+                    kind=self.tokens[idx],
+                    value=mo.group(idx + 1),  # (mo.group is 1-based)
+                    start=start,
+                    end=offset)
+
+
+class ParseContext(object):
+
+    def __init__(self, parser, source, token_generator):
+        self.parser = parser
+        self.source = source
+
+        self._tokens = token_generator
+        self._error = None
+
+        self._advance()
+
+    def _advance(self):
+        try:
+            self.next_token = next(self._tokens)
+        except StopIteration:
+            self.next_token = None
+        except SyntaxError as exc:
+            self._error = exc
+
+    def attempt(self, *kinds):
+        """Try to get the next token if it matches one of the kinds given,
+        otherwise returning None. If no kinds are given, any kind is
+        accepted."""
+        if self._error:
+            raise self._error
+        token = self.next_token
+        if not token:
+            return None
+        if kinds and token.kind not in kinds:
+            return None
+        self._advance()
+        return token
+
+    def require(self, *kinds):
+        """Get the next token, raising an exception if it doesn't match one of
+        the given kinds, or the input ends. If no kinds are given, returns the
+        next token of any kind."""
+        token = self.attempt()
+        if not token:
+            raise SyntaxError('Unexpected end of input')
+        if kinds and token.kind not in kinds:
+            raise SyntaxError.unexpected(token.kind, kinds)
+        return token
+
+    def parse(self, precedence=None):
+        parser = self.parser
+        precedence = parser.precedence_map[precedence] if precedence else 0
+        token = self.require()
+        prefix_rule = parser.prefix_rules.get(token.kind)
+        if not prefix_rule:
+            raise SyntaxError.unexpected(token.kind, parser.prefix_rules)
+        left = prefix_rule(parser, token, self)
+        while self.next_token:
+            kind = self.next_token.kind
+            if kind not in parser.infix_rules:
+                break
+            if precedence >= parser.precedence_map[kind]:
+                break
+            token = self.require()
+            left = parser.infix_rules[kind](parser, left, token, self)
+        return left
new file mode 100644
--- /dev/null
+++ b/third_party/python/json-e/jsone/render.py
@@ -0,0 +1,254 @@
+from __future__ import absolute_import, print_function, unicode_literals
+
+import re
+import json as json
+from .shared import JSONTemplateError, DeleteMarker, string
+from . import shared
+from . import builtins
+from .interpreter import ExpressionEvaluator
+from .six import viewitems
+
+operators = {}
+
+
+def operator(name):
+    def wrap(fn):
+        operators[name] = fn
+        return fn
+    return wrap
+
+
+def evaluateExpression(expr, context):
+    evaluator = ExpressionEvaluator(context)
+    return evaluator.parse(expr)
+
+
+_interpolation_start_re = re.compile(r'\$?\${')
+def interpolate(string, context):
+    mo = _interpolation_start_re.search(string)
+    if not mo:
+        return string
+
+    result = []
+    evaluator = ExpressionEvaluator(context)
+
+    while True:
+        result.append(string[:mo.start()])
+        if mo.group() != '$${':
+            string = string[mo.end():]
+            parsed, offset = evaluator.parseUntilTerminator(string, '}')
+            if isinstance(parsed, (list, dict)):
+                raise JSONTemplateError('cannot interpolate array/object: ' + string)
+            result.append(builtins.to_str(parsed))
+            string = string[offset + 1:]
+        else:  # found `$${`
+            result.append('${')
+            string = string[mo.end():]
+
+        mo = _interpolation_start_re.search(string)
+        if not mo:
+            result.append(string)
+            break
+
+    return ''.join(result)
+
+
+@operator('$eval')
+def eval(template, context):
+    return evaluateExpression(renderValue(template['$eval'], context), context)
+
+
+@operator('$flatten')
+def flatten(template, context):
+    value = renderValue(template['$flatten'], context)
+    if not isinstance(value, list):
+        raise JSONTemplateError('$flatten value must evaluate to an array of arrays')
+
+    def gen():
+        for e in value:
+            if isinstance(e, list):
+                for e2 in e:
+                    yield e2
+            else:
+                yield e
+    return list(gen())
+
+
+@operator('$flattenDeep')
+def flattenDeep(template, context):
+    value = renderValue(template['$flattenDeep'], context)
+    if not isinstance(value, list):
+        raise JSONTemplateError('$flatten value must evaluate to an array')
+
+    def gen(value):
+        if isinstance(value, list):
+            for e in value:
+                for sub in gen(e):
+                    yield sub
+        else:
+            yield value
+
+    return list(gen(value))
+
+
+@operator('$fromNow')
+def fromNow(template, context):
+    offset = renderValue(template['$fromNow'], context)
+    if not isinstance(offset, string):
+        raise JSONTemplateError("$fromnow expects a string")
+    return shared.fromNow(offset)
+
+
+@operator('$if')
+def ifConstruct(template, context):
+    condition = evaluateExpression(template['$if'], context)
+    try:
+        if condition:
+            rv = template['then']
+        else:
+            rv = template['else']
+    except KeyError:
+        return DeleteMarker
+    return renderValue(rv, context)
+
+
+@operator('$json')
+def jsonConstruct(template, context):
+    value = renderValue(template['$json'], context)
+    return json.dumps(value, separators=(',', ':'))
+
+
+@operator('$let')
+def let(template, context):
+    variables = renderValue(template['$let'], context)
+    if not isinstance(variables, dict):
+        raise JSONTemplateError("$let value must evaluate to an object")
+    subcontext = context.copy()
+    subcontext.update(variables)
+    try:
+        in_expression = template['in']
+    except KeyError:
+        raise JSONTemplateError("$let operator requires an `in` clause")
+    return renderValue(in_expression, subcontext)
+
+
+@operator('$map')
+def map(template, context):
+    value = renderValue(template['$map'], context)
+    if not isinstance(value, list) and not isinstance(value, dict):
+        raise JSONTemplateError("$map value must evaluate to an array or object")
+
+    is_obj = isinstance(value, dict)
+
+    each_keys = [k for k in template if k.startswith('each(')]
+    if len(each_keys) != 1:
+        raise JSONTemplateError("$map requires exactly one other property, each(..)")
+    each_key = each_keys[0]
+    each_var = each_key[5:-1]
+    each_template = template[each_key]
+
+    if is_obj:
+        value = [{'key': v[0], 'val': v[1]} for v in value.items()]
+
+    def gen():
+        subcontext = context.copy()
+        for elt in value:
+            subcontext[each_var] = elt
+            elt = renderValue(each_template, subcontext)
+            if elt is not DeleteMarker:
+                yield elt
+
+    if is_obj:
+        v = dict()
+        for e in gen():
+            v.update(e)
+        return v
+    else:
+        return list(gen())
+
+
+@operator('$merge')
+def merge(template, context):
+    value = renderValue(template['$merge'], context)
+    if not isinstance(value, list) or not all(isinstance(e, dict) for e in value):
+        raise JSONTemplateError("$reverse value must evaluate to an array of objects")
+    v = dict()
+    for e in value:
+        v.update(e)
+    return v
+
+
+@operator('$reverse')
+def reverse(template, context):
+    value = renderValue(template['$reverse'], context)
+    if not isinstance(value, list):
+        raise JSONTemplateError("$reverse value must evaluate to an array")
+    return list(reversed(value))
+
+
+@operator('$sort')
+def sort(template, context):
+    value = renderValue(template['$sort'], context)
+    if not isinstance(value, list):
+        raise JSONTemplateError("$sort value must evaluate to an array")
+
+    # handle by(..) if given, applying the schwartzian transform
+    by_keys = [k for k in template if k.startswith('by(')]
+    if len(by_keys) == 1:
+        by_key = by_keys[0]
+        by_var = by_key[3:-1]
+        by_expr = template[by_key]
+
+        def xform():
+            subcontext = context.copy()
+            for e in value:
+                subcontext[by_var] = e
+                yield evaluateExpression(by_expr, subcontext), e
+        to_sort = list(xform())
+    elif len(by_keys) == 0:
+        to_sort = [(e, e) for e in value]
+    else:
+        raise JSONTemplateError('only one by(..) is allowed')
+
+    # check types
+    try:
+        eltype = type(to_sort[0][0])
+    except IndexError:
+        return []
+    if eltype in (list, dict, bool, type(None)):
+        raise JSONTemplateError('$sort values must be sortable')
+    if not all(isinstance(e[0], eltype) for e in to_sort):
+        raise JSONTemplateError('$sorted values must all have the same type')
+
+    # unzip the schwartzian transform
+    return list(e[1] for e in sorted(to_sort))
+
+
+def renderValue(template, context):
+    if isinstance(template, string):
+        return interpolate(template, context)
+
+    elif isinstance(template, dict):
+        matches = [k for k in template if k in operators]
+        if matches:
+            if len(matches) > 1:
+                raise JSONTemplateError("only one operator allowed")
+            return operators[matches[0]](template, context)
+
+        def updated():
+            for k, v in viewitems(template):
+                if k.startswith('$$') and k[1:] in operators:
+                    k = k[1:]
+                else:
+                    k = interpolate(k, context)
+                v = renderValue(v, context)
+                if v is not DeleteMarker:
+                    yield k, v
+        return dict(updated())
+
+    elif isinstance(template, list):
+        rendered = (renderValue(e, context) for e in template)
+        return [e for e in rendered if e is not DeleteMarker]
+
+    else:
+        return template
new file mode 100644
--- /dev/null
+++ b/third_party/python/json-e/jsone/shared.py
@@ -0,0 +1,91 @@
+from __future__ import absolute_import, print_function, unicode_literals
+
+import re
+import datetime
+
+# this will be overridden in tests
+utcnow = datetime.datetime.utcnow
+
+
+class DeleteMarker:
+    pass
+
+
+class JSONTemplateError(Exception):
+    pass
+
+
+# Regular expression matching: X days Y hours Z minutes
+# todo: support hr, wk, yr
+FROMNOW_RE = re.compile(''.join([
+    '^(\s*(?P<years>\d+)\s*y(ears?)?)?',
+    '(\s*(?P<months>\d+)\s*mo(nths?)?)?',
+    '(\s*(?P<weeks>\d+)\s*w(eeks?)?)?',
+    '(\s*(?P<days>\d+)\s*d(ays?)?)?',
+    '(\s*(?P<hours>\d+)\s*h(ours?)?)?',
+    '(\s*(?P<minutes>\d+)\s*m(in(utes?)?)?)?\s*',
+    '(\s*(?P<seconds>\d+)\s*s(ec(onds?)?)?)?\s*$',
+]))
+
+
+def fromNow(offset):
+    # copied from taskcluster-client.py
+    # We want to handle past dates as well as future
+    future = True
+    offset = offset.lstrip()
+    if offset.startswith('-'):
+        future = False
+        offset = offset[1:].lstrip()
+    if offset.startswith('+'):
+        offset = offset[1:].lstrip()
+
+    # Parse offset
+    m = FROMNOW_RE.match(offset)
+    if m is None:
+        raise ValueError("offset string: '%s' does not parse" % offset)
+
+    # In order to calculate years and months we need to calculate how many days
+    # to offset the offset by, since timedelta only goes as high as weeks
+    days = 0
+    hours = 0
+    minutes = 0
+    seconds = 0
+    if m.group('years'):
+        # forget leap years, a year is 365 days
+        years = int(m.group('years'))
+        days += 365 * years
+    if m.group('months'):
+        # assume "month" means 30 days
+        months = int(m.group('months'))
+        days += 30 * months
+    days += int(m.group('days') or 0)
+    hours += int(m.group('hours') or 0)
+    minutes += int(m.group('minutes') or 0)
+    seconds += int(m.group('seconds') or 0)
+
+    # Offset datetime from utc
+    delta = datetime.timedelta(
+        weeks=int(m.group('weeks') or 0),
+        days=days,
+        hours=hours,
+        minutes=minutes,
+        seconds=seconds,
+    )
+
+    return stringDate(utcnow() + delta if future else utcnow() - delta)
+
+
+datefmt_re = re.compile(r'(\.[0-9]{3})[0-9]*(\+00:00)?')
+
+
+def stringDate(date):
+    # Convert to isoFormat
+    string = date.isoformat()
+    string = datefmt_re.sub(r'\1Z', string)
+    return string
+
+# the base class for strings, regardless of python version
+try:
+    string = basestring
+except NameError:
+    string = str
new file mode 100644
--- /dev/null
+++ b/third_party/python/json-e/jsone/six.py
@@ -0,0 +1,20 @@
+import sys
+import operator
+
+# https://github.com/benjaminp/six/blob/2c3492a9f16d294cd5e6b43d6110c5a3a2e58b4c/six.py#L818
+def with_metaclass(meta, *bases):
+    """Create a base class with a metaclass."""
+    # This requires a bit of explanation: the basic idea is to make a dummy
+    # metaclass for one level of class instantiation that replaces itself with
+    # the actual metaclass.
+    class metaclass(meta):
+
+        def __new__(cls, name, this_bases, d):
+            return meta(name, bases, d)
+    return type.__new__(metaclass, 'temporary_class', (), {})
+
+# https://github.com/benjaminp/six/blob/2c3492a9f16d294cd5e6b43d6110c5a3a2e58b4c/six.py#L578
+if sys.version_info[0] == 3:
+    viewitems = operator.methodcaller("items")
+else:
+    viewitems = operator.methodcaller("viewitems")
new file mode 100644
--- /dev/null
+++ b/third_party/python/json-e/setup.cfg
@@ -0,0 +1,4 @@
+[egg_info]
+tag_build = 
+tag_date = 0
+
new file mode 100644
--- /dev/null
+++ b/third_party/python/json-e/setup.py
@@ -0,0 +1,24 @@
+import json
+import os
+from setuptools import setup, find_packages
+
+package_json = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'package.json')
+with open(package_json) as f:
+    version = json.load(f)['version']
+
+setup(name='json-e',
+    version=version,
+    description='A data-structure parameterization system written for embedding context in JSON objects',
+    author='Dustin J. Mitchell',
+    url='https://taskcluster.github.io/json-e/',
+    author_email='dustin@mozilla.com',
+    packages=['jsone'],
+    test_suite='nose.collector',
+    license='MPL2',
+    tests_require=[
+        "hypothesis",
+        "nose",
+        "PyYAML",
+        "python-dateutil",
+    ]
+)