Skip to content

Commit 6dab5c5

Browse files
authored
Merge pull request #21 from jmespath-community/feature/let-expression
Feature/let expression
2 parents 5028ac4 + a4c982c commit 6dab5c5

File tree

13 files changed

+174
-130
lines changed

13 files changed

+174
-130
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
bin/
55
share/
66
pyvenv.cfg
7+
.venv/
78

89
# C extensions
910
*.so

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/functions.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,6 @@ def call_function(self, function_name, resolved_args, *args, **kwargs):
8585
signature = spec['signature']
8686
self._validate_arguments(resolved_args, signature, function_name)
8787

88-
# supply extra arguments only if the function expects them
89-
90-
parameters = [parameter.name for parameter in inspect.signature(function).parameters.values()]
91-
if ('kwargs' in parameters):
92-
return function(self, *resolved_args, *args, scopes = kwargs.get('scopes'))
93-
9488
return function(self, *resolved_args)
9589

9690
def _validate_arguments(self, args, signature, function_name):
@@ -540,12 +534,6 @@ def _func_max_by(self, array, expref):
540534
else:
541535
return None
542536

543-
@signature({'types': ['object']}, {'types': ['expref']})
544-
def _func_let(self, scope, expref, *args, **kwargs):
545-
if 'scopes' in kwargs:
546-
kwargs.get('scopes').pushScope(scope)
547-
return expref.visit(expref.expression, expref.context, *args, **kwargs)
548-
549537
@signature({'types': ['array'], 'variadic': True})
550538
def _func_zip(self, *arguments):
551539
return list(map(list, zip(*arguments)))

jmespath/lexer.py

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ class Lexer(object):
1818
',': 'comma',
1919
':': 'colon',
2020
'@': 'current',
21-
'$': 'root',
2221
'(': 'lparen',
2322
')': 'rparen',
2423
'{': 'lbrace',
@@ -115,22 +114,15 @@ def tokenize(self, expression, options=None):
115114
elif self._current == '!':
116115
yield self._match_or_else('=', 'ne', 'not')
117116
elif self._current == '=':
118-
if self._next() == '=':
119-
yield {'type': 'eq', 'value': '==',
120-
'start': self._position - 1, 'end': self._position}
121-
self._next()
117+
yield self._match_or_else('=', 'eq', 'assign')
118+
elif self._current == '$':
119+
if self._peek_may_be_valid_unquoted_identifier():
120+
yield self._consume_variable()
122121
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 '='")
122+
yield {'type': 'root',
123+
'value': self._current,
124+
'start': self._position, 'end': self._position + 1}
125+
self._next()
134126
else:
135127
raise LexerError(lexer_position=self._position,
136128
lexer_value=self._current,
@@ -145,6 +137,28 @@ def _consume_number(self):
145137
buff += self._current
146138
return buff
147139

140+
def _consume_variable(self):
141+
start = self._position
142+
buff = self._current
143+
self._next()
144+
if self._current not in self.START_IDENTIFIER:
145+
raise LexerError(
146+
lexer_position=start,
147+
lexer_value=self._current,
148+
message='Invalid variable starting character %s' % self._current)
149+
buff += self._current
150+
while self._next() in self.VALID_IDENTIFIER:
151+
buff += self._current
152+
return {'type': 'variable', 'value': buff,
153+
'start': start, 'end': start + len(buff)}
154+
155+
def _peek_may_be_valid_unquoted_identifier(self):
156+
if (self._position == self._length - 1):
157+
return False
158+
else:
159+
next = self._chars[self._position + 1]
160+
return next in self.START_IDENTIFIER
161+
148162
def _peek_is_next_digit(self):
149163
if (self._position == self._length - 1):
150164
return False

jmespath/parser.py

Lines changed: 35 additions & 2 deletions
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'])
@@ -552,7 +585,7 @@ def __init__(self, expression, parsed):
552585
self.parsed = parsed
553586

554587
def search(self, value, options=None):
555-
evaluator = visitor.ScopedInterpreter(options)
588+
evaluator = visitor.TreeInterpreter(options)
556589
return evaluator.evaluate(self.parsed, value)
557590

558591
def _render_dot_file(self):

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()

0 commit comments

Comments
 (0)