Skip to content

Commit 7092885

Browse files
jameslsspringcomp
authored andcommitted
Implement lexical scoping with let expressions
See jmespath/jmespath.jep#18 for more details.
1 parent a9e3559 commit 7092885

File tree

8 files changed

+384
-20
lines changed

8 files changed

+384
-20
lines changed

jmespath/ast.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ def arithmetic(operator, left, right):
1010
return {'type': 'arithmetic', 'children': [left, right], 'value': operator}
1111

1212

13+
def assign(name, expr):
14+
return {'type': 'assign', 'children': [expr], 'value': name}
15+
16+
1317
def comparator(name, first, second):
1418
return {'type': 'comparator', 'children': [first, second], 'value': name}
1519

@@ -58,6 +62,10 @@ def key_val_pair(key_name, node):
5862
return {"type": "key_val_pair", 'children': [node], "value": key_name}
5963

6064

65+
def let_expression(bindings, expr):
66+
return {'type': 'let_expression', 'children': [*bindings, expr]}
67+
68+
6169
def literal(literal_value):
6270
return {'type': 'literal', 'value': literal_value, 'children': []}
6371

@@ -100,3 +108,7 @@ def slice(start, end, step):
100108

101109
def value_projection(left, right):
102110
return {'type': 'value_projection', 'children': [left, right]}
111+
112+
113+
def variable_ref(name):
114+
return {"type": "variable_ref", "children": [], "value": name}

jmespath/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,9 @@ def __init__(self):
133133

134134
class UnknownFunctionError(JMESPathError):
135135
pass
136+
137+
138+
class UndefinedVariable(JMESPathError):
139+
def __init__(self, varname):
140+
self.varname = varname
141+
super().__init__(f"Reference to undefined variable: {self.varname}")

jmespath/lexer.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -115,22 +115,9 @@ def tokenize(self, expression, options=None):
115115
elif self._current == '!':
116116
yield self._match_or_else('=', 'ne', 'not')
117117
elif self._current == '=':
118-
if self._next() == '=':
119-
yield {'type': 'eq', 'value': '==',
120-
'start': self._position - 1, 'end': self._position}
121-
self._next()
122-
else:
123-
if self._current is None:
124-
# If we're at the EOF, we never advanced
125-
# the position so we don't need to rewind
126-
# it back one location.
127-
position = self._position
128-
else:
129-
position = self._position - 1
130-
raise LexerError(
131-
lexer_position=position,
132-
lexer_value='=',
133-
message="Unknown token '='")
118+
yield self._match_or_else('=', 'eq', 'assign')
119+
elif self._current == '$':
120+
yield self._consume_variable()
134121
else:
135122
raise LexerError(lexer_position=self._position,
136123
lexer_value=self._current,
@@ -145,6 +132,21 @@ def _consume_number(self):
145132
buff += self._current
146133
return buff
147134

135+
def _consume_variable(self):
136+
start = self._position
137+
buff = self._current
138+
self._next()
139+
if self._current not in self.START_IDENTIFIER:
140+
raise LexerError(
141+
lexer_position=start,
142+
lexer_value=self._current,
143+
message='Invalid variable starting character %s' % self._current)
144+
buff += self._current
145+
while self._next() in self.VALID_IDENTIFIER:
146+
buff += self._current
147+
return {'type': 'variable', 'value': buff,
148+
'start': start, 'end': start + len(buff)}
149+
148150
def _peek_is_next_digit(self):
149151
if (self._position == self._length - 1):
150152
return False

jmespath/parser.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
class Parser(object):
3939
BINDING_POWER = {
4040
'eof': 0,
41+
'variable': 0,
42+
'assign': 0,
4143
'unquoted_identifier': 0,
4244
'quoted_identifier': 0,
4345
'literal': 0,
@@ -145,8 +147,39 @@ def _expression(self, binding_power=0):
145147
def _token_nud_literal(self, token):
146148
return ast.literal(token['value'])
147149

150+
def _token_nud_variable(self, token):
151+
return ast.variable_ref(token['value'][1:])
152+
148153
def _token_nud_unquoted_identifier(self, token):
149-
return ast.field(token['value'])
154+
if token['value'] == 'let' and \
155+
self._current_token() == 'variable':
156+
return self._parse_let_expression()
157+
else:
158+
return ast.field(token['value'])
159+
160+
def _parse_let_expression(self):
161+
bindings = []
162+
while True:
163+
var_token = self._lookahead_token(0)
164+
# Strip off the '$'.
165+
varname = var_token['value'][1:]
166+
self._advance()
167+
self._match('assign')
168+
assign_expr = self._expression()
169+
bindings.append(ast.assign(varname, assign_expr))
170+
if self._is_in_keyword(self._lookahead_token(0)):
171+
self._advance()
172+
break
173+
else:
174+
self._match('comma')
175+
expr = self._expression()
176+
return ast.let_expression(bindings, expr)
177+
178+
def _is_in_keyword(self, token):
179+
return (
180+
token['type'] == 'unquoted_identifier' and
181+
token['value'] == 'in'
182+
)
150183

151184
def _token_nud_quoted_identifier(self, token):
152185
field = ast.field(token['value'])

jmespath/scope.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from collections import deque
2+
3+
4+
class ScopedChainDict:
5+
"""Dictionary that can delegate lookups to multiple dicts.
6+
7+
This provides a basic get/set dict interface that is
8+
backed by multiple dicts. Each dict is searched from
9+
the top most (most recently pushed) scope dict until
10+
a match is found.
11+
12+
"""
13+
def __init__(self, *scopes):
14+
# The scopes are evaluated starting at the top of the stack (the most
15+
# recently pushed scope via .push_scope()). If we use a normal list()
16+
# and push/pop scopes by adding/removing to the end of the list, we'd
17+
# have to always call reversed(self._scopes) whenever we resolve a key,
18+
# because the end of the list is the top of the stack.
19+
# To avoid this, we're using a deque so we can append to the front of
20+
# the list via .appendleft() in constant time, and iterate over scopes
21+
# without having to do so with a reversed() call each time.
22+
self._scopes = deque(scopes)
23+
24+
def __getitem__(self, key):
25+
for scope in self._scopes:
26+
if key in scope:
27+
return scope[key]
28+
raise KeyError(key)
29+
30+
def get(self, key, default=None):
31+
try:
32+
return self[key]
33+
except KeyError:
34+
return default
35+
36+
def push_scope(self, scope):
37+
self._scopes.appendleft(scope)
38+
39+
def pop_scope(self):
40+
self._scopes.popleft()

jmespath/visitor.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import operator
22

3+
from jmespath import exceptions
34
from jmespath import functions
45
from jmespath.compat import string_type
56
from jmespath.compat import with_str_method
7+
from jmespath.scope import ScopedChainDict
68
from numbers import Number
79

810

@@ -142,6 +144,7 @@ def __init__(self, options=None):
142144
self._functions = self._options.custom_functions
143145
else:
144146
self._functions = functions.Functions()
147+
self._scope = ScopedChainDict()
145148

146149
def default_visit(self, node, *args, **kwargs):
147150
raise NotImplementedError(node['type'])
@@ -347,6 +350,27 @@ def visit_projection(self, node, value):
347350
collected.append(current)
348351
return collected
349352

353+
def visit_let_expression(self, node, value):
354+
*bindings, expr = node['children']
355+
scope = {}
356+
for assign in bindings:
357+
scope.update(self.visit(assign, value))
358+
self._scope.push_scope(scope)
359+
result = self.visit(expr, value)
360+
self._scope.pop_scope()
361+
return result
362+
363+
def visit_assign(self, node, value):
364+
name = node['value']
365+
value = self.visit(node['children'][0], value)
366+
return {name: value}
367+
368+
def visit_variable_ref(self, node, value):
369+
try:
370+
return self._scope[node['value']]
371+
except KeyError:
372+
raise exceptions.UndefinedVariable(node['value'])
373+
350374
def visit_value_projection(self, node, value):
351375
base = self.visit(node['children'][0], value)
352376
try:

0 commit comments

Comments
 (0)