pythonmathrangeexpressioneval

I can use random to get a random value from a range. How do I make math evaluator that does opposite and also has access to math modules?


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

Edit

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.

Edit

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]

Solution

  • 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]