• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

lhelwerd / expression-parser / 18431097776

11 Oct 2025 03:09PM UTC coverage: 96.962% (-1.7%) from 98.677%
18431097776

push

github

web-flow
Merge pull request #3 from lhelwerd/chore/update-docs

Update docs and add coverage/coveralls upload

66 of 68 branches covered (97.06%)

Branch coverage included in aggregate %.

317 of 327 relevant lines covered (96.94%)

6.79 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.67
/expression/parser.py
1
"""
2
Sandboxed expression parser.
3

4
Copyright 2017-2018 Leon Helwerda
5

6
Licensed under the Apache License, Version 2.0 (the "License");
7
you may not use this file except in compliance with the License.
8
You may obtain a copy of the License at
9

10
    http://www.apache.org/licenses/LICENSE-2.0
11

12
Unless required by applicable law or agreed to in writing, software
13
distributed under the License is distributed on an "AS IS" BASIS,
14
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
See the License for the specific language governing permissions and
16
limitations under the License.
17
"""
18

19
# Use Python 3 division
20
from __future__ import division
7✔
21

22
import ast
7✔
23

24

25
class Expression_Parser(ast.NodeVisitor):
7✔
26
    """
27
    Transformer that safely parses an expression, disallowing any complicated
28
    functions or control structures (inline if..else is allowed though).
29
    """
30

31
    # Binary operators
32
    _binary_ops = {
7✔
33
        ast.Add: lambda left, right: left + right,
34
        ast.Sub: lambda left, right: left - right,
35
        ast.Mult: lambda left, right: left * right,
36
        ast.Div: lambda left, right: left / right,
37
        ast.Mod: lambda left, right: left % right,
38
        ast.Pow: lambda left, right: left**right,
39
        ast.LShift: lambda left, right: left << right,
40
        ast.RShift: lambda left, right: left >> right,
41
        ast.BitOr: lambda left, right: left | right,
42
        ast.BitXor: lambda left, right: left ^ right,
43
        ast.BitAnd: lambda left, right: left & right,
44
        ast.FloorDiv: lambda left, right: left // right,
45
    }
46

47
    # Unary operators
48
    _unary_ops = {
7✔
49
        ast.Invert: lambda operand: ~operand,
50
        ast.Not: lambda operand: not operand,
51
        ast.UAdd: lambda operand: +operand,
52
        ast.USub: lambda operand: -operand,
53
    }
54

55
    # Comparison operators
56
    # The AST nodes may have multiple ops and right comparators, but we
57
    # evaluate each op individually.
58
    _compare_ops = {
7✔
59
        ast.Eq: lambda left, right: left == right,
60
        ast.NotEq: lambda left, right: left != right,
61
        ast.Lt: lambda left, right: left < right,
62
        ast.LtE: lambda left, right: left <= right,
63
        ast.Gt: lambda left, right: left > right,
64
        ast.GtE: lambda left, right: left >= right,
65
        ast.Is: lambda left, right: left is right,
66
        ast.IsNot: lambda left, right: left is not right,
67
        ast.In: lambda left, right: left in right,
68
        ast.NotIn: lambda left, right: left not in right,
69
    }
70

71
    # Predefined variable names
72
    _variable_names = {"True": True, "False": False, "None": None}
7✔
73

74
    # Predefined functions
75
    _function_names = {"int": int, "float": float, "bool": bool}
7✔
76

77
    def __init__(self, variables=None, functions=None, assignment=False):
7✔
78
        self._variables = None
7✔
79
        self.variables = variables
7✔
80

81
        if functions is None:
7✔
82
            self._functions = {}
7✔
83
        else:
84
            self._functions = functions
7✔
85

86
        self._assignment = False
7✔
87
        self.assignment = assignment
7✔
88

89
        self._used_variables = set()
7✔
90
        self._modified_variables = {}
7✔
91

92
    def parse(self, expression, filename="<expression>"):
7✔
93
        """
94
        Parse a string `expression` and return its result.
95
        """
96

97
        self._used_variables = set()
7✔
98
        self._modified_variables = {}
7✔
99

100
        try:
7✔
101
            # Explicit mode for clarity and to allow assignment statements when enabled
102
            return self.visit(ast.parse(expression, filename=filename, mode="exec"))
7✔
103
        except SyntaxError as error:
7✔
104
            error.filename = filename
7✔
105
            error.text = expression
7✔
106
            raise error
7✔
107
        except Exception as error:
7✔
108
            error_type = error.__class__.__name__
7✔
109
            if len(error.args) > 2:
7✔
110
                line_col = error.args[1:]
7✔
111
            else:
112
                line_col = (1, 0)
7✔
113

114
            error = SyntaxError(
7✔
115
                "{}: {}".format(error_type, error.args[0]),
116
                (filename,) + line_col + (expression,),
117
            )
118
            raise error
7✔
119

120
    @property
7✔
121
    def variables(self):
7✔
122
        """
123
        Retrieve the variables that exist in the scope of the parser.
124

125
        This property returns a copy of the dictionary.
126
        """
127

128
        return self._variables.copy()
7✔
129

130
    @variables.setter
7✔
131
    def variables(self, variables):
7✔
132
        """
133
        Set a new variable scope for the expression parser.
134

135
        If built-in keyword names `True`, `False` or `None` are used, then
136
        this property raises a `NameError`.
137
        """
138

139
        if variables is None:
7✔
140
            variables = {}
7✔
141
        else:
142
            variables = variables.copy()
7✔
143

144
        variable_names = set(variables.keys())
7✔
145
        constant_names = set(self._variable_names.keys())
7✔
146
        forbidden_variables = variable_names.intersection(constant_names)
7✔
147
        if forbidden_variables:
7✔
148
            keyword = "keyword" if len(forbidden_variables) == 1 else "keywords"
7✔
149
            forbidden = ", ".join(forbidden_variables)
7✔
150
            raise NameError("Cannot override {} {}".format(keyword, forbidden))
7✔
151

152
        self._variables = variables
7✔
153

154
    @property
7✔
155
    def assignment(self):
7✔
156
        """
157
        Retrieve whether assignments are accepted by the parser.
158
        """
159

160
        return self._assignment
7✔
161

162
    @assignment.setter
7✔
163
    def assignment(self, value):
7✔
164
        """
165
        Enable or disable parsing assignments.
166
        """
167

168
        self._assignment = bool(value)
7✔
169

170
    @property
7✔
171
    def used_variables(self):
7✔
172
        """
173
        Retrieve the names of the variables that were evaluated in the most
174
        recent call to `parse`. If `parse` failed with an exception, then
175
        this set may be incomplete.
176
        """
177

178
        return self._used_variables
7✔
179

180
    @property
7✔
181
    def modified_variables(self):
7✔
182
        """
183
        Retrieve the variables that were set or modified in the most recent call
184
        to `parse`. Since only one expression is allowed, this dictionary
185
        contains at most one element. An augmented expression such as `+=` is
186
        used, then the variable is only in this dictionary if the variable
187
        is in the scope. If `parse` failed with any other exception, then
188
        this dictionary may be incomplete. If the expression parser is set to
189
        disallow assignments, then the dictionary is always empty.
190

191
        This property returns a copy of the dictionary.
192
        """
193

194
        return self._modified_variables.copy()
7✔
195

196
    def generic_visit(self, node):
7✔
197
        """
198
        Visitor for nodes that do not have a custom visitor.
199

200
        This visitor denies any nodes that may not be part of the expression.
201
        """
202

203
        raise SyntaxError(
7✔
204
            "Node {} not allowed".format(ast.dump(node)),
205
            ("", node.lineno, node.col_offset, ""),
206
        )
207

208
    def visit_Module(self, node):
7✔
209
        """
210
        Visit the root module node.
211
        """
212

213
        if len(node.body) != 1:
7✔
214
            if len(node.body) > 1:
7✔
215
                lineno = node.body[1].lineno
7✔
216
                col_offset = node.body[1].col_offset
7✔
217
            else:
218
                lineno = 1
7✔
219
                col_offset = 0
7✔
220

221
            raise SyntaxError(
7✔
222
                "Exactly one expression must be provided", ("", lineno, col_offset, "")
223
            )
224

225
        return self.visit(node.body[0])
7✔
226

227
    def visit_Expr(self, node):
7✔
228
        """
229
        Visit an expression node.
230
        """
231

232
        return self.visit(node.value)
7✔
233

234
    def visit_BoolOp(self, node):
7✔
235
        """
236
        Visit a boolean expression node.
237

238
        Implements true short-circuit semantics and returns the deciding
239
        operand value (for 'and': first falsy, else last; for 'or': first
240
        truthy, else last), matching Python behavior.
241
        """
242

243
        op = type(node.op)
7✔
244
        if op is ast.And:
7✔
245
            last = None
7✔
246
            for value in node.values:
7✔
247
                current = self.visit(value)
7✔
248
                if not current:
7✔
249
                    return current
7✔
250
                last = current
7✔
251
            return last
7✔
252
        elif op is ast.Or:
7✔
253
            last = None
7✔
254
            for value in node.values:
7✔
255
                current = self.visit(value)
7✔
256
                if current:
7✔
257
                    return current
7✔
258
                last = current
×
259
            return last
×
260
        else:
261
            # Defensive, though BoolOp only has And/Or
262
            raise SyntaxError(
×
263
                "Unsupported boolean operator", ("", node.lineno, node.col_offset, "")
264
            )
265

266
    def visit_BinOp(self, node):
7✔
267
        """
268
        Visit a binary expression node.
269
        """
270

271
        op = type(node.op)
7✔
272
        func = self._binary_ops[op]
7✔
273
        return func(self.visit(node.left), self.visit(node.right))
7✔
274

275
    def visit_UnaryOp(self, node):
7✔
276
        """
277
        Visit a unary expression node.
278
        """
279

280
        op = type(node.op)
7✔
281
        func = self._unary_ops[op]
7✔
282
        return func(self.visit(node.operand))
7✔
283

284
    def visit_IfExp(self, node):
7✔
285
        """
286
        Visit an inline if..else expression node.
287
        """
288

289
        return (
7✔
290
            self.visit(node.body) if self.visit(node.test) else self.visit(node.orelse)
291
        )
292

293
    def visit_Compare(self, node):
7✔
294
        """
295
        Visit a comparison expression node.
296

297
        Implement correct chained comparison semantics: each comparison is
298
        evaluated pairwise with short-circuiting on first False.
299
        """
300

301
        left = self.visit(node.left)
7✔
302
        for operator, comparator in zip(node.ops, node.comparators):
7✔
303
            func = self._compare_ops[type(operator)]
7✔
304
            right = self.visit(comparator)
7✔
305
            if not func(left, right):
7✔
306
                return False
7✔
307
            left = right
7✔
308
        return True
7✔
309

310
    def visit_Call(self, node):
7✔
311
        """
312
        Visit a function call node. Only direct function names are allowed.
313
        """
314

315
        if not isinstance(node.func, ast.Name):
7✔
316
            raise SyntaxError(
7✔
317
                "Only direct function names are allowed",
318
                ("", node.lineno, node.col_offset, ""),
319
            )
320

321
        name = node.func.id
7✔
322
        if name in self._functions:
7✔
323
            func = self._functions[name]
7✔
324
        elif name in self._function_names:
7✔
325
            func = self._function_names[name]
7✔
326
        else:
327
            raise NameError(
7✔
328
                "Function '{}' is not defined".format(name),
329
                node.lineno,
330
                node.col_offset,
331
            )
332

333
        args = [self.visit(arg) for arg in node.args]
7✔
334
        keywords = dict([self.visit(keyword) for keyword in node.keywords])
7✔
335

336
        # Python 2.7 starred arguments
337
        if hasattr(node, "starargs") and hasattr(node, "kwargs"):
7✔
338
            if node.starargs is not None or node.kwargs is not None:
×
339
                raise SyntaxError(
×
340
                    "Star arguments are not supported",
341
                    ("", node.lineno, node.col_offset, ""),
342
                )
343

344
        return func(*args, **keywords)
7✔
345

346
    def visit_Assign(self, node):
7✔
347
        """
348
        Visit an assignment node.
349
        """
350

351
        if not self.assignment:
7✔
352
            raise SyntaxError(
7✔
353
                "Assignments are not allowed in this expression",
354
                ("", node.lineno, node.col_offset, ""),
355
            )
356

357
        if len(node.targets) != 1:
7✔
358
            raise SyntaxError(
7✔
359
                "Multiple-target assignments are not supported",
360
                ("", node.lineno, node.col_offset, ""),
361
            )
362
        if not isinstance(node.targets[0], ast.Name):
7✔
363
            raise SyntaxError(
7✔
364
                "Assignment target must be a variable name",
365
                ("", node.lineno, node.col_offset, ""),
366
            )
367

368
        name = node.targets[0].id
7✔
369
        self._modified_variables[name] = self.visit(node.value)
7✔
370

371
    def visit_AugAssign(self, node):
7✔
372
        """
373
        Visit an augmented assignment node.
374
        """
375

376
        if not self.assignment:
7✔
377
            raise SyntaxError(
7✔
378
                "Assignments are not allowed in this expression",
379
                ("", node.lineno, node.col_offset, ""),
380
            )
381

382
        if not isinstance(node.target, ast.Name):
7✔
383
            raise SyntaxError(
7✔
384
                "Assignment target must be a variable name",
385
                ("", node.lineno, node.col_offset, ""),
386
            )
387

388
        name = node.target.id
7✔
389
        if name not in self._variables:
7✔
390
            raise NameError(
7✔
391
                "Assignment name '{}' is not defined".format(name),
392
                node.lineno,
393
                node.col_offset,
394
            )
395

396
        op = type(node.op)
7✔
397
        func = self._binary_ops[op]
7✔
398
        self._modified_variables[name] = func(
7✔
399
            self._variables[name], self.visit(node.value)
400
        )
401

402
    def visit_Starred(self, node):
7✔
403
        """
404
        Visit a starred function keyword argument node.
405
        """
406

407
        raise SyntaxError(
7✔
408
            "Star arguments are not supported", ("", node.lineno, node.col_offset, "")
409
        )
410

411
    def visit_keyword(self, node):
7✔
412
        """
413
        Visit a function keyword argument node.
414
        """
415

416
        # Python 3.x: '**' (keyword expansion) is represented by a keyword with arg=None.
417
        # Note: ast.keyword typically does NOT have lineno/col_offset across 3.6+,
418
        # so use safe fallbacks when constructing a SyntaxError with location info.
419
        if node.arg is None:
7✔
420
            lineno = getattr(node, "lineno", 1)
7✔
421
            col = getattr(node, "col_offset", 0)
7✔
422
            raise SyntaxError("Star arguments are not supported", ("", lineno, col, ""))
7✔
423
        return (node.arg, self.visit(node.value))
7✔
424

425
    def visit_Num(self, node):
7✔
426
        """
427
        Visit a literal number node.
428
        """
429

430
        return node.n
×
431

432
    def visit_Name(self, node):
7✔
433
        """
434
        Visit a named variable node.
435
        """
436

437
        if node.id in self._variables:
7✔
438
            self._used_variables.add(node.id)
7✔
439
            return self._variables[node.id]
7✔
440

441
        if node.id in self._variable_names:
7✔
442
            return self._variable_names[node.id]
×
443

444
        raise NameError(
7✔
445
            "Name '{}' is not defined".format(node.id), node.lineno, node.col_offset
446
        )
447

448
    def visit_NameConstant(self, node):
7✔
449
        """
450
        Visit a named constant singleton node (Python 3).
451
        """
452

453
        return node.value
×
454

455
    def visit_Constant(self, node):  # Python 3.8+ unified literal node
7✔
456
        """Visit a constant literal node (numbers, strings, booleans, None, Ellipsis)."""
457
        return node.value
7✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc