← run

py-05-calc

1.000
10/10 tests· algorithms
Challenge · difficulty 5/5
# Arithmetic Expression Evaluator

Implement a file **`solution.py`** containing a function:

```python
def evaluate(expr: str) -> float:
    ...
```

Evaluate an arithmetic expression given as a string and return its value as a
`float`.

Supported syntax:

- Binary operators `+`, `-`, `*`, `/` with standard precedence
  (`*` and `/` bind tighter than `+` and `-`) and left-to-right associativity.
- Parentheses `(` ... `)` for grouping.
- Unary plus and minus, e.g. `-3`, `-(2 + 1)`, `2 * -3`.
- Integer and float literals, e.g. `42`, `3.14`, `.5`, `10.`.
- Arbitrary surrounding / internal whitespace, which is ignored.

Requirements:

- Return the numeric result as a `float`. For example
  `evaluate("1 + 2 * 3") == 7.0` and `evaluate("(1 + 2) * 3") == 9.0`.
- Implement a real parser/evaluator that respects precedence and parentheses.
  **Do not** use `eval`, `exec`, or similar (the harness may inspect for this).
- Raise `ValueError` on malformed input. Malformed includes: empty / whitespace-only
  input, unbalanced parentheses, a missing operand or operator (e.g. `"1 +"`,
  `"1 2"`, `"* 3"`), and unexpected characters.
- Division by zero should raise an error (a `ZeroDivisionError` or `ValueError`
  is acceptable).

Examples:

```python
evaluate("2 + 3 * 4")      # 14.0
evaluate("(2 + 3) * 4")    # 20.0
evaluate("10 / 4")         # 2.5
evaluate("-3 + 2")         # -1.0
evaluate("2 * (1 + -1.5)") # -1.0
```
tests/test_calc.py
import pytest

from solution import evaluate


def test_simple_addition():
    assert evaluate("1 + 2") == 3.0


def test_precedence():
    assert evaluate("2 + 3 * 4") == 14.0
    assert evaluate("1 + 2 * 3") == 7.0


def test_parentheses_override_precedence():
    assert evaluate("(2 + 3) * 4") == 20.0
    assert evaluate("(1 + 2) * 3") == 9.0


def test_division_is_float():
    assert evaluate("10 / 4") == 2.5
    assert isinstance(evaluate("4 / 2"), float)


def test_unary_minus():
    assert evaluate("-3 + 2") == -1.0
    assert evaluate("2 * -3") == -6.0
    assert evaluate("-(2 + 1)") == -3.0


def test_float_and_whitespace():
    assert evaluate("  3.14 + .86 ") == pytest.approx(4.0)
    assert evaluate("10. / 4") == 2.5


def test_left_associativity():
    assert evaluate("10 - 2 - 3") == 5.0
    assert evaluate("100 / 5 / 2") == 10.0


def test_nested_parens():
    assert evaluate("2 * (1 + (3 - 1) * 2)") == 10.0


def test_malformed_raises_value_error():
    for bad in ["", "   ", "1 +", "* 3", "1 2", "(1 + 2", "1 + 2)", "1 + * 2", "3 $ 4"]:
        with pytest.raises(ValueError):
            evaluate(bad)


def test_division_by_zero_raises():
    with pytest.raises((ValueError, ZeroDivisionError)):
        evaluate("1 / 0")
System prompt
You are an expert programmer. Solve the task exactly as specified. Output your solution as fenced code blocks using the required file name(s) and the exact function/type signatures requested. Prefer correctness; do not include prose outside code unless asked.
Proposed solution
```python
def evaluate(expr: str) -> float:
    if not expr or expr.strip() == "":
        raise ValueError("Empty expression")
    
    # Tokenize the expression
    tokens = []
    i = 0
    n = len(expr)
    
    while i < n:
        c = expr[i]
        
        # Skip whitespace
        if c.isspace():
            i += 1
            continue
        
        # Check for numbers (integers and floats)
        if c.isdigit() or c == '.':
            start = i
            has_dot = False
            while i < n and (expr[i].isdigit() or expr[i] == '.'):
                if expr[i] == '.':
                    if has_dot:
                        raise ValueError("Invalid number format")
                    has_dot = True
                i += 1
            
            num_str = expr[start:i]
            try:
                # Handle cases like ".5" or "10."
                if num_str == '.':
                    raise ValueError("Invalid number format")
                value = float(num_str)
                tokens.append(('NUM', value))
            except ValueError:
                raise ValueError(f"Invalid number: {num_str}")
            continue
        
        # Check for operators and parentheses
        if c in '+-*/':
            tokens.append(('OP', c))
            i += 1
            continue
        
        if c == '(':
            tokens.append(('LPAREN', c))
            i += 1
            continue
        
        if c == ')':
            tokens.append(('RPAREN', c))
            i += 1
            continue
        
        # Invalid character
        raise ValueError(f"Unexpected character: {c}")
    
    if not tokens:
        raise ValueError("Empty expression")
    
    # Parse and evaluate using recursive descent parser
    pos = [0]  # Use list to allow modification in nested functions
    
    def peek():
        if pos[0] < len(tokens):
            return tokens[pos[0]]
        return None
    
    def consume():
        token = tokens[pos[0]]
        pos[0] += 1
        return token
    
    def parse_expression():
        """Parse addition and subtraction (lowest precedence)"""
        left = parse_term()
        
        while peek() and peek()[0] == 'OP' and peek()[1] in '+-':
            op = consume()[1]
            right = parse_term()
            if op == '+':
                left = left + right
            else:  # op == '-'
                left = left - right
        
        return left
    
    def parse_term():
        """Parse multiplication and division (higher precedence)"""
        left = parse_factor()
        
        while peek() and peek()[0] == 'OP' and peek()[1] in '*/':
            op = consume()[1]
            right = parse_factor()
            if op == '*':
                left = left * right
            else:  # op == '/'
                if right == 0:
                    raise ZeroDivisionError("Division by zero")
                left = left / right
        
        return left
    
    def parse_factor():
        """Parse unary operators and primary expressions"""
        token = peek()
        
        # Handle unary plus/minus
        if token and token[0] == 'OP' and token[1] in '+-':
            op = consume()[1]
            operand = parse_factor()  # Recursive call for nested unary operators
            if op == '+':
                return operand
            else:  # op == '-'
                return -operand
        
        # Handle parentheses
        if token and token[0] == 'LPAREN':
            consume()  # Consume '('
            result = parse_expression()
            if not peek() or peek()[0] != 'RPAREN':
                raise ValueError("Unbalanced parentheses")
            consume()  # Consume ')'
            return result
        
        # Handle numbers
        if token and token[0] == 'NUM':
            return consume()[1]
        
        # Error cases
        if token is None:
            raise ValueError("Unexpected end of expression")
        elif token[0] == 'RPAREN':
            raise ValueError("Unbalanced parentheses")
        else:
            raise ValueError(f"Unexpected token: {token}")
    
    try:
        result = parse_expression()
        
        # Check for leftover tokens
        if pos[0] < len(tokens):
            raise ValueError("Unexpected tokens at end of expression")
        
        return float(result)
    except ZeroDivisionError:
        raise
    except Exception as e:
        if isinstance(e, ValueError):
            raise
        else:
            raise ValueError(f"Parse error: {str(e)}")
```

Test output (stdout)

..........                                                               [100%]
10 passed in 0.02s