Evaluate mathematical expression

https://www.codewars.com/kata/52a78825cdfc2cfc87000005/solutions/python
This commit is contained in:
Thibaud Gasser 2024-03-06 16:40:24 +01:00
parent 4f3056c84b
commit 498dcb79ad
3 changed files with 233 additions and 0 deletions

View File

@ -0,0 +1,81 @@
https://www.codewars.com/kata/52a78825cdfc2cfc87000005/fork/python
# Instructions
Given a mathematical expression as a string you must return the result as a number.
## Numbers
Number may be both whole numbers and/or decimal numbers. The same goes for the returned result.
## Operators
You need to support the following mathematical operators:
* Multiplication `*`
* Division `/` (as floating point division)
* Addition `+`
* Subtraction `-`
Operators are always evaluated from left-to-right, and `*` and `/` must be evaluated before `+` and `-`.
## Parentheses
You need to support multiple levels of nested parentheses, ex. `(2 / (2 + 3.33) * 4) - -6`
## Whitespace
There may or may not be whitespace between numbers and operators.
An addition to this rule is that the minus sign (`-`) used for negating numbers and parentheses will *never* be separated by whitespace. I.e all of the following are **valid** expressions.
```
1-1 // 0
1 -1 // 0
1- 1 // 0
1 - 1 // 0
1- -1 // 2
1 - -1 // 2
1--1 // 2
6 + -(4) // 2
6 + -( -4) // 10
```
And the following are **invalid** expressions
```
1 - - 1 // Invalid
1- - 1 // Invalid
6 + - (4) // Invalid
6 + -(- 4) // Invalid
```
## Validation
You do not need to worry about validation - you will only receive **valid** mathematical expressions following the above rules.
## Restricted APIs
```if:javascript
NOTE: Both `eval` and `Function` are disabled.
```
```if:php
NOTE: `eval` is disallowed in your solution.
```
```if:python
NOTE: `eval` and `exec` are disallowed in your solution.
```
```if:clojure
NOTE: `eval` and `import` are disallowed in your solution.
```
```if:java
NOTE: To keep up the difficulty of the kata, use of some classes and functions is disallowed. Their names cannot appear in the solution file, even in comments and variable names.
```
```if:rust
NOTE: `std::process::Command` is disallowed in your solution.
```

View File

@ -0,0 +1,105 @@
import re
from enum import Enum
from dataclasses import dataclass
class TokenType(Enum):
INTEGER = 1
FLOATING = 2
PLUS = 3,
MINUS = 4,
MULT = 5,
DIV = 6,
OPEN_PAREN = 7,
CLOSE_PAREN = 8
@dataclass
class Token:
type: TokenType
value: str | int
def tokenize(expr):
scanner = re.Scanner((
(r"\d+", lambda scanner,token: Token(TokenType.INTEGER, token)),
(r"\d+\.\d", lambda scanner,token: Token(TokenType.FLOATING, token)),
(r"\+", lambda scanner,token: Token(TokenType.PLUS, token)),
(r"\-", lambda scanner,token: Token(TokenType.MINUS, token)),
(r"\*", lambda scanner,token: Token(TokenType.MULT, token)),
(r"\/", lambda scanner,token: Token(TokenType.DIV, token)),
(r"\(", lambda scanner,token: Token(TokenType.OPEN_PAREN, token)),
(r"\)", lambda scanner,token: Token(TokenType.CLOSE_PAREN, token)),
(r"\s+", None)
))
tokens, unknown = scanner.scan(expr)
assert len(unknown) == 0, f"Unknown token: {unknown}"
return tokens
class Parser:
def __init__(self, tokens):
self.tokens = tokens
self.pos = 0
def _accept(self, token_type) -> Token | None:
if self.pos < len(self.tokens) and self.tokens[self.pos].type == token_type:
token = self.tokens[self.pos]
self.pos += 1
return token
return None
def parse_expr(self):
tree = self.parse_expr_plus()
assert self.pos == len(self.tokens), "Not all tokens where consumed!"
return tree
def parse_expr_plus(self):
lhs = self.parse_expr_mult()
while ((token := self._accept(TokenType.PLUS)) is not None
or (token := self._accept(TokenType.MINUS)) is not None):
rhs = self.parse_expr_mult()
if token.type == TokenType.PLUS:
lhs += rhs
elif token.type == TokenType.MINUS:
lhs -= rhs
else:
raise SyntaxError(f"Unexpected token {token[0]}")
return lhs
def parse_expr_mult(self):
lhs = self.parse_primary()
while ((token := self._accept(TokenType.MULT)) is not None
or (token := self._accept(TokenType.DIV)) is not None):
if token.type == TokenType.MULT:
lhs *= self.parse_primary()
elif token.type == TokenType.DIV:
lhs /= self.parse_primary()
else:
raise SyntaxError(f"Unexpected token {token[0]}")
return lhs
def parse_primary(self):
assert self.pos <= len(self.tokens)
sign = 1
if self._accept(TokenType.MINUS) is not None:
sign *= -1
if (token := self._accept(TokenType.INTEGER)) is not None:
return sign * int(token.value)
elif (token := self._accept(TokenType.OPEN_PAREN)) is not None:
expr = self.parse_expr_plus()
if self._accept(TokenType.CLOSE_PAREN) is None:
raise SyntaxError(f"Expected token {TokenType.CLOSE_PAREN}")
return sign * expr
assert False, f"Unexpected token type for parse_primary: {self.tokens[self.pos][0]}"
def calc(expression):
p = Parser(tokenize(expression))
try:
return p.parse_expr()
except:
print(expression)
print(p.tokens)
raise

View File

@ -0,0 +1,47 @@
from solution import calc
import codewars_test as test
import random
@test.describe("Fixed tests")
def _():
@test.it("Tests")
def __():
cases = (
("1 + 1", 2),
("8/16", 0.5),
("3 -(-1)", 4),
("2 + -2", 0),
("10- 2- -5", 13),
("(((10)))", 10),
("3 * 5", 15),
("-7 * -(6 / 3)", 14)
)
for x, y in cases:
test.assert_equals(calc(x), y)
@test.describe("Random tests")
def _():
choice = random.choice
randint = random.randint
@test.it("Tests")
def __():
for i in range(100):
try:
s = "{}{} {} {}{} {} {}{} {} {}{} {} {}{} {} {}{} {} {}{} {} {}{}".format(choice(["-", ""]), randint(1, 100), choice(["+", "-", "*", "/"]), choice(["-", ""]), randint(1, 100), choice(["+", "-", "*", "/"]), choice(["-", ""]), randint(1, 100), choice(["+", "-", "*", "/"]), choice(["-", ""]), randint(1, 100), choice(["+", "-", "*", "/"]), choice(["-", ""]), randint(1, 100), choice(["+", "-", "*", "/"]), choice(["-", ""]), randint(1, 100), choice(["+", "-", "*", "/"]), choice(["-", ""]), randint(1, 100), choice(["+", "-", "*", "/"]), choice(["-", ""]), randint(1, 100))
test.assert_approx_equals(calc(s), eval(s), 1e-6, s)
except ZeroDivisionError:
test.pass_()
for i in range(100):
try:
s = "{}({}{}) {} ({}{} {} {}{} {} {}({})) {} ({}{} {} {}((({}({}{} {} {}{})))) {} {}{})".format(choice(["-", ""]), choice(["-", ""]), randint(1, 100), choice(["+", "-", "*", "/"]), choice(["-", ""]), randint(1, 100), choice(["+", "-", "*", "/"]), choice(["-", ""]), randint(1, 100), choice(["+", "-", "*", "/"]), choice(["-", ""]), randint(1, 100), choice(["+", "-", "*", "/"]), choice(["-", ""]), randint(1, 100), choice(["+", "-", "*", "/"]), choice(["-", ""]), choice(["-", ""]), choice(["-", ""]), randint(1, 100), choice(["+", "-", "*", "/"]), choice(["-", ""]), randint(1, 100), choice(["+", "-", "*", "/"]), choice(["-", ""]), randint(1, 100))
test.assert_approx_equals(calc(s), eval(s), 1e-6, s)
except ZeroDivisionError:
test.pass_()
for i in range(100):
s = "{}{}- {}{}- {}{}- {}{}".format(choice(["-", ""]), randint(1, 100), choice(["-", ""]), randint(1, 100), choice(["-", ""]), randint(1, 100), choice(["-", ""]), randint(1, 100))
test.assert_approx_equals(calc(s), eval(s), 1e-6, s)