← 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
import re
from typing import List, Tuple

class Token:
    def __init__(self, type_: str, value: str):
        self.type = type_
        self.value = value

    def __repr__(self):
        return f"Token({self.type}, {self.value})"

class Lexer:
    def __init__(self, text: str):
        self.text = text
        self.pos = 0
        self.length = len(text)

    def peek(self) -> str:
        if self.pos < self.length:
            return self.text[self.pos]
        return ""

    def consume(self) -> str:
        char = self.peek()
        if char != "":
            self.pos += 1
        return char

    def skip_whitespace(self):
        while self.pos < self.length and self.text[self.pos].isspace():
            self.pos += 1

    def tokenize(self) -> List[Token]:
        tokens = []
        while self.pos < self.length:
            char = self.peek()
            if char.isspace():
                self.skip_whitespace()
                continue

            if char == '(':
                tokens.append(Token('LPAREN', char))
                self.consume()
            elif char == ')':
                tokens.append(Token('RPAREN', char))
                self.consume()
            elif char == '+':
                tokens.append(Token('PLUS', char))
                self.consume()
            elif char == '-':
                tokens.append(Token('MINUS', char))
                self.consume()
            elif char == '*':
                tokens.append(Token('MUL', char))
                self.consume()
            elif char == '/':
                tokens.append(Token('DIV', char))
                self.consume()
            elif char.isdigit() or char == '.':
                # Number literal
                start = self.pos
                while self.pos < self.length and (self.text[self.pos].isdigit() or self.text[self.pos] == '.'):
                    self.pos += 1
                num_str = self.text[start:self.pos]
                tokens.append(Token('NUMBER', num_str))
            else:
                raise ValueError(f"Unexpected character: {char}")
        return tokens

class Parser:
    def __init__(self, tokens: List[Token]):
        self.tokens = tokens
        self.pos = 0

    def peek_token(self) -> Token:
        if self.pos < len(self.tokens):
            return self.tokens[self.pos]
        return Token('EOF', '')

    def consume_token(self) -> Token:
        token = self.peek_token()
        self.pos += 1
        return token

    def parse_expression(self) -> float:
        return self.parse_additive()

    def parse_additive(self) -> float:
        left = self.parse_multiplicative()

        while True:
            token = self.peek_token()
            if token.type == 'PLUS':
                self.consume_token()
                right = self.parse_multiplicative()
                left = left + right
            elif token.type == 'MINUS':
                self.consume_token()
                right = self.parse_multiplicative()
                left = left - right
            else:
                break
        return left

    def parse_multiplicative(self) -> float:
        left = self.parse_primary()

        while True:
            token = self.peek_token()
            if token.type == 'MUL':
                self.consume_token()
                right = self.parse_primary()
                left = left * right
            elif token.type == 'DIV':
                self.consume_token()
                right = self.parse_primary()
                if right == 0:
                    raise ZeroDivisionError("division by zero")
                left = left / right
            else:
                break
        return left

    def parse_primary(self) -> float:
        token = self.peek_token()

        if token.type == 'NUMBER':
            self.consume_token()
            try:
                return float(token.value)
            except ValueError:
                raise ValueError(f"Invalid number literal: {token.value}")

        if token.type == 'LPAREN':
            self.consume_token()
            expr_val = self.parse_expression()
            next_token = self.peek_token()
            if next_token.type != 'RPAREN':
                raise ValueError("Missing closing parenthesis")
            self.consume_token()
            return expr_val

        if token.type == 'MINUS':
            self.consume_token()
            # Unary minus
            operand = self.parse_primary()
            return -operand

        if token.type == 'PLUS':
            self.consume_token()
            # Unary plus
            return self.parse_primary()

        raise ValueError(f"Unexpected token: {token}")

def evaluate(expr: str) -> float:
    if not expr or expr.strip() == "":
        raise ValueError("Empty expression")

    lexer = Lexer(expr)
    tokens = lexer.tokenize()

    parser = Parser(tokens)
    result = parser.parse_expression()

    # Ensure we consumed all tokens
    if parser.peek_token().type != 'EOF':
        raise ValueError("Unexpected tokens at end of expression")

    return result
```

Test output (stdout)

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