I want to build a math evaluator for a private use (no security limitations when doing eval operations) that can give me possible outcomes of mathematical statements. Assume random() here refers to range() and also built-in Python range doesn't work with float values. So while it should do normal evals as default, return values should be in either list or set.
So, it should eval basic math.
"3" -> [3]
"round(419.9)" -> [420]
"round(3.14, 1)" -> [3.1]
It should also do list evals for all possible outcomes.
"[1,2,3]" -> [1,2,3]
It should also recursively evaluate multiple lists. For further clarification, evaluator should break down expressions with list and use for loop to substitute, then call the expression evalulate_expression(for_loop_substituted_expression) which then returns list, and it should be combined to get resultant list. See the comment below
"[2,1] - 1" -> [1,0]
"[2,1] - [0,1]" -> [2, 1, 0] # With internal evaluation being i) split into multiple expression via for, ii) If code can pass above test case simply call the function recursively for "[2,1] - 0" and "[2,1] - 1" iii) Combine the results combine([2,1], [1,0]) -> [2,1,0].
"[2,1] * [1,2]" -> [4,2,1]
Then one just need to code random function as range and return list which then evaluated should give answer.
"random(3)" -> [0, 1, 2]
"random(3,4)" -> [3]
"round(random(0.1, 0.5), 1)" -> [0.1, 0.2, 0.3, 0.4]
Finally, it should also have variables and functions from stdlib math modules like
"log(e)" -> [1] # math.log(math.e)
"tau/pi" -> [2.0] # math.tau/math.pi
While this test case is out of scope of question, it would be still cool if something like this can be coded. I have certainly seen some evaluators processing this code fine whereas when I tried using sympy, ast.eval and normal eval, there were significant errors.
"2(3)" -> [6]
"2(3(3))" -> [18]
I managed to come up with code that passes some test cases but its hard to get it all correct.
import random
def evaluate_expression(expression):
# Define safe functions and constants to use within eval
def custom_function(expression, roundval=0):
if isinstance(expression, list):
return [round(item, roundval) for item in expression]
else:
return round(expression, roundval)
safe_dict = {
'random': lambda *args: list(range(int(args[0]), int(args[1]) + 1)),
'round': custom_function,
}
# Add some common mathematical constants
safe_dict.update({
'pi': 3.141592653589793,
'e': 2.718281828459045
})
# Try to evaluate the expression
try:
result = eval(expression, {"__builtins__": None}, safe_dict)
# If the result is a single number, return it in a list
if isinstance(result, (int, float)):
return [result]
# If the result is a list, return it as is
elif isinstance(result, list):
return result
else:
raise ValueError("Unsupported result type")
except (SyntaxError, NameError, TypeError, ValueError) as e:
return str(e)
These are the test cases
# Test cases
assert evaluate_expression("3") == [3]
assert evaluate_expression("round(419.9)") == [420]
assert evaluate_expression("round(3.14, 1)") == [3.1]
assert evaluate_expression("[1,2,3]") == [1,2,3]
assert evaluate_expression("[2,1] - 1") == [1,0]
assert evaluate_expression("[2,1] - [0,1]") == [2, 1, 0]
assert evaluate_expression("[2,1] * [1,2]") == [4,2,1]
assert evaluate_expression("random(3)") == [0,1,2]
assert evaluate_expression("random(3, 4)") == [3]
assert evaluate_expression("round(random(0.1, 0.5), 1)") == [0.1, 0.2, 0.3, 0.4]
assert evaluate_expression("log(e)") == [1]
assert evaluate_expression("tau/pi") == [2.0]
#out of scope
assert evaluate_expression("2(3)") == [6]
assert evaluate_expression("2(3(3))") == [18]
I think if this test case passes, something like "random(1,9) * random(1,9)" shouldn't error out and should produce "[1,2,3,4,5,6,7,8]*[1,2,3,4,5,6,7,8]" which then evaluated should generate a big list. As, a side note, I also managed to generate a custom random range generator.
def custom_random(start, end):
ten = 10**len(str(start).split('.')[-1])
if isinstance(start, int):
mul = 1
elif isinstance(start, float):
if len(str(start)) == 3:
mul = 0.1
elif len(str(start)) == 4:
mul = 0.01
if isinstance(start, int) and isinstance(end, int):
return list(range(start, end))
elif isinstance(start, float) and isinstance(end, float):
return [round(i * mul, len(str(start).split('.')[1])) for i in range(int(start * ten), int(end * ten) + 1)]
else:
raise TypeError("Unsupported input type")
print(custom_random(1, 5))
print(custom_random(3, 4))
print(custom_random(10, 50))
print(custom_random(0.1, 0.5)) #prints also 0.5 but it should not print 0.5, but only upto 0.4? but not big of bug anyways
print(custom_random(0.01, 0.05)) #prints also 0.05 but it should not print 0.05, but only upto 0.4? but not big of bug anyways
print(custom_random(0.05, 0.09))
Answers needs to pass most test cases to be accepted, out_of_scope needn't to be passed. custom_random can be improved but not necessarily it needs to. My current code passes basic tests but not expressions with two functions at once or expressions having list.
I posted an answer, I am keen to see how to update my answer and make it pass these two test cases without hurting other test cases.
assert evaluate_expression("2(3)") == [6]
assert evaluate_expression("2(3(3))") == [18]
I managed to solve it using GPT4 after 100+ prompts. It passes all test cases and is bit different than how I originally described in question to solve but one problem can be solvable in multiple different ways. The "2(3)" problem is still left which I couldn't solve.
import re
import ast
import random
def custom_random(start, end=None):
if end != None:
ten = 10**len(str(start).split('.')[-1])
if isinstance(start, int):
mul = 1
elif isinstance(start, float):
if len(str(start)) == 3:
mul = 0.1
elif len(str(start)) == 4:
mul = 0.01
l = []
if isinstance(start, int) and isinstance(end, int):
l = list(range(start, end))
elif isinstance(start, float) and isinstance(end, float):
l = [round(i * mul, len(str(start).split('.')[1])) for i in range(int(start * ten), int(end * ten) + 1)]
else:
raise TypeError("Unsupported input type")
if end in l:
l.remove(end)
return l
else:
return custom_random(0, start)
def custom_round(expression, roundval=0):
if isinstance(expression, list):
return [round(item, roundval) for item in expression]
else:
return round(expression, roundval)
def is_list_expression(expression):
try:
parsed = ast.parse(expression, mode='eval')
# Check if the expression contains only list
if isinstance(parsed.body, ast.List):
return True
# Check if the expression contains operations on lists
elif isinstance(parsed.body, ast.BinOp) and (isinstance(parsed.body.left, ast.List) or isinstance(parsed.body.right, ast.List)):
return True
# Check if the expression contains operations on lists with multiple operations
elif isinstance(parsed.body, ast.BinOp) and isinstance(parsed.body.left, ast.BinOp) and (isinstance(parsed.body.left.left, ast.List) or isinstance(parsed.body.left.right, ast.List)):
return True
else:
return False
except:
return False
class MathEvaluator:
def __init__(self):
import math
self.safe_dict = {
'random': custom_random,
'round': custom_round,
}
for name in dir(math):
if not name.startswith("__"):
self.safe_dict[name] = getattr(math, name)
self.replacer = FunctionReplacer()
self.list_evaluator = ListEvaluator()
def evaluate_expression(self, expression):
try:
expression = self.replacer.replace_with_custom(expression)
if is_list_expression(expression):
result = self.list_evaluator.list_evaluator(expression)
else:
result = eval(expression, {"__builtins__": None}, self.safe_dict)
if isinstance(result, (int, float)):
return [result]
elif isinstance(result, list):
return result
else:
raise ValueError("Unsupported result type")
except (SyntaxError, NameError, TypeError, ValueError) as e:
from traceback import format_exc
format_exc()
class FunctionReplacer:
def __init__(self):
self.patterns = {
'random': r'random\((?:\d+(?:,\d+)?)?\)',
'round': r'round\((?:\[\d+(?:,\d+)*\]|\d+)(?:,\d+)?\)',
}
self.replacement_functions = {
'random': custom_random,
'round': custom_round,
}
def replace_with_custom(self, expression):
while True:
innermost_function = None
for func_name, pattern in self.patterns.items():
match = re.search(pattern, expression)
if match:
if not innermost_function or match.start() > innermost_function[1].start():
innermost_function = (func_name, match)
if not innermost_function:
break
func_name, match = innermost_function
# Parse the matched string to an AST
parsed = ast.parse(match.group())
# Extract the function name and arguments from the AST
func_args = []
for arg in parsed.body[0].value.args:
if isinstance(arg, ast.List):
func_args.append([elem.n for elem in arg.elts])
else:
func_args.append(arg.n)
# Call the replacement function with the extracted arguments
replacement = self.replacement_functions[func_name](*func_args)
# Replace the matched function call with the replacement
expression = expression.replace(match.group(), str(replacement))
return expression
def test(self):
test_cases = {
"a(random(1))": "a([0])",
"a(round(1))": "a(1)",
"a(random(1,2))": "a([1])",
"a(b(random(3,4)))": "a(b([3]))",
"a(round(random(1,2)))": "a([1])",
}
for expression, expected_result in test_cases.items():
result = self.replace_with_custom(expression)
print(f"Test {expression} passed!")
assert result == expected_result, f"For {expression}, expected {expected_result} but got {result}"
class ListEvaluator:
def evaluate_node(self, node):
if isinstance(node, ast.Num):
return node.n
elif isinstance(node, ast.List):
return [self.evaluate_node(item) for item in node.elts]
elif isinstance(node, ast.BinOp):
left = self.evaluate_node(node.left)
right = self.evaluate_node(node.right)
if isinstance(node.op, ast.Add):
return [x + y for x, y in zip(left, right)]
elif isinstance(node.op, ast.Sub):
if isinstance(left, list) and isinstance(right, int):
return [x - right for x in left]
elif isinstance(left, list) and isinstance(right, list):
return left + [0] + right
elif isinstance(node.op, ast.Mult):
if isinstance(left, list) and isinstance(right, list):
result = []
for l_item in left:
for r_item in right:
result.append(l_item * r_item)
return result
return None
# Main function to evaluate expressions
def list_evaluator(self, expression):
try:
parsed = ast.parse(expression, mode='eval')
result = self.evaluate_node(parsed.body)
return list(set(result))
except Exception as e:
from traceback import format_exc
format_exc()
return None
def test(self):
assert self.list_evaluator("[2,1] - 1") == [0, 1]
assert self.list_evaluator("[2,1] - [0,1]") == [0, 1, 2]
assert self.list_evaluator("[2,1] * [1,2]") == [1, 2, 4]
assert self.list_evaluator("[1,2,3]*[1,2,3]") == [1, 2, 3, 4, 6, 9]
# Test cases
evaluator = MathEvaluator()
assert evaluator.evaluate_expression("3") == [3]
assert evaluator.evaluate_expression("round(419.9)") == [420]
assert evaluator.evaluate_expression("round(3.14, 1)") == [3.1]
assert evaluator.evaluate_expression("[1,2,3]") == [1,2,3]
assert evaluator.evaluate_expression("[2,1] - 1") == [0,1]
assert evaluator.evaluate_expression("[2,1] - [0,1]") == [0,1,2]
assert evaluator.evaluate_expression("[2,1] * [1,2]") == [1,2,4]
assert evaluator.evaluate_expression("random(3)") == [0,1,2]
assert evaluator.evaluate_expression("random(3, 4)") == [3]
assert evaluator.evaluate_expression("round(random(0.1, 0.5), 1)") == [0.1, 0.2, 0.3, 0.4]
assert evaluator.evaluate_expression("log(e)") == [1]
assert evaluator.evaluate_expression("tau/pi") == [2.0]