feat: math expression language implementation
This commit is contained in:
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
9
Makefile
Normal file
9
Makefile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
all: check test
|
||||||
|
|
||||||
|
.PHONY: check
|
||||||
|
check:
|
||||||
|
uvx ty check
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
uv run pytest
|
||||||
4
README.md
Normal file
4
README.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# A function plotter in python
|
||||||
|
|
||||||
|
A recreational programming project.
|
||||||
|
The idea is to implement a simple math expression language and simple features to plot functions.
|
||||||
22
pyproject.toml
Normal file
22
pyproject.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[project]
|
||||||
|
name = "plotter"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
authors = [
|
||||||
|
{ name = "Domenico Testa", email = "domenico.testa@gmail.com" }
|
||||||
|
]
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
plotter = "plotter:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["uv_build>=0.9.27,<0.10.0"]
|
||||||
|
build-backend = "uv_build"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=9.0.2",
|
||||||
|
]
|
||||||
2
src/plotter/__init__.py
Normal file
2
src/plotter/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
def main() -> None:
|
||||||
|
print("Hello from plotter!")
|
||||||
BIN
src/plotter/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
src/plotter/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/plotter/__pycache__/parser.cpython-312.pyc
Normal file
BIN
src/plotter/__pycache__/parser.cpython-312.pyc
Normal file
Binary file not shown.
277
src/plotter/parser.py
Normal file
277
src/plotter/parser.py
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
# Parsing is the process of turning a sequence
|
||||||
|
# of tokens into a tree representation:
|
||||||
|
#
|
||||||
|
# Add
|
||||||
|
# Parser / \
|
||||||
|
# "1 + 2 * 3" -------> 1 Mul
|
||||||
|
# / \
|
||||||
|
# 2 3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from collections import deque
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from functools import partial
|
||||||
|
import math
|
||||||
|
import operator
|
||||||
|
|
||||||
|
# The first step is to generate a list of tokens.
|
||||||
|
# Tokens are the supported symbol classes, they have a type tag
|
||||||
|
|
||||||
|
|
||||||
|
class TokenType(Enum):
|
||||||
|
ERROR = -1
|
||||||
|
OPERATOR = 0
|
||||||
|
VARIABLE = 1
|
||||||
|
SEPARATOR = 2
|
||||||
|
FLOAT = 3
|
||||||
|
FUNCTION = 4
|
||||||
|
|
||||||
|
|
||||||
|
# The token can optionally capture a string value.
|
||||||
|
# This will be used later by the parser to build the synctatic tree.
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Token:
|
||||||
|
type: TokenType
|
||||||
|
value: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
operators: set[str] = {"+", "-", "*", "/", "^"}
|
||||||
|
functions: set[str] = {"abs", "cos", "sin", "tan", "atan", "exp", "ln", "log"}
|
||||||
|
separators: set[str] = {"(", ")"}
|
||||||
|
variables: set[str] = {"x"}
|
||||||
|
|
||||||
|
|
||||||
|
# The lexer is a generator function that yields token as it scans the input string
|
||||||
|
def lex(input: str) -> Iterator[Token]:
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
while i < len(input):
|
||||||
|
char = input[i]
|
||||||
|
|
||||||
|
# whitespace
|
||||||
|
if char.isspace():
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# separators
|
||||||
|
if char in separators:
|
||||||
|
yield Token(type=TokenType.SEPARATOR, value=char)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# operators
|
||||||
|
if char in operators:
|
||||||
|
yield Token(type=TokenType.OPERATOR, value=char)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# variables
|
||||||
|
if char in variables:
|
||||||
|
yield Token(type=TokenType.VARIABLE, value=char)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# functions
|
||||||
|
if char.isalpha():
|
||||||
|
j = i + 1
|
||||||
|
while j < len(input) and input[j].isalpha():
|
||||||
|
j += 1
|
||||||
|
|
||||||
|
name = input[i:j]
|
||||||
|
if name not in functions:
|
||||||
|
yield Token(
|
||||||
|
type=TokenType.ERROR, value=f"unknown function name '{name}'"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
yield Token(type=TokenType.FUNCTION, value=name)
|
||||||
|
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
|
||||||
|
# float numbers
|
||||||
|
if (
|
||||||
|
char.isdigit()
|
||||||
|
or (char == ".")
|
||||||
|
or (
|
||||||
|
(char == "+" and input[i + 1].isdigit())
|
||||||
|
or (char == "-" and input[i + 1].isdigit())
|
||||||
|
)
|
||||||
|
):
|
||||||
|
j = i + 1
|
||||||
|
|
||||||
|
has_dot = char == "."
|
||||||
|
while j < len(input) and (input[j].isdigit() or input[j] == "."):
|
||||||
|
has_dot = has_dot or input[j] == "."
|
||||||
|
j += 1
|
||||||
|
|
||||||
|
if has_dot:
|
||||||
|
if input[j - 1] == ".":
|
||||||
|
yield Token(
|
||||||
|
type=TokenType.ERROR, value="number after dot was expected"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
yield Token(type=TokenType.FLOAT, value=input[i:j])
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
|
||||||
|
yield Token(type=TokenType.ERROR, value="not an accepted character")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
class Expression(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def eval(self, x: float) -> float:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Atom(Expression):
|
||||||
|
token: Token
|
||||||
|
|
||||||
|
def eval(self, x: float) -> float:
|
||||||
|
if self.token.type == TokenType.VARIABLE:
|
||||||
|
return x
|
||||||
|
if self.token.type == TokenType.FLOAT:
|
||||||
|
return float(self.token.value or 0)
|
||||||
|
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FunctionExpression(Expression):
|
||||||
|
function: str
|
||||||
|
argument: Expression
|
||||||
|
|
||||||
|
_funcs = {
|
||||||
|
"abs": abs,
|
||||||
|
"sin": math.sin,
|
||||||
|
"cos": math.cos,
|
||||||
|
"tan": math.tan,
|
||||||
|
"atan": math.atan,
|
||||||
|
"exp": math.exp,
|
||||||
|
"ln": partial(math.log, base=math.e),
|
||||||
|
"log": math.log10,
|
||||||
|
}
|
||||||
|
|
||||||
|
def eval(self, x: float) -> float:
|
||||||
|
func = self._funcs.get(self.function)
|
||||||
|
if not func:
|
||||||
|
raise ValueError(f"Unknown function {self.function}")
|
||||||
|
return func(x)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InfixExpression(Expression):
|
||||||
|
operator: str
|
||||||
|
lvalue: Expression
|
||||||
|
rvalue: Expression
|
||||||
|
|
||||||
|
_ops = {
|
||||||
|
"+": operator.add,
|
||||||
|
"-": operator.sub,
|
||||||
|
"*": operator.mul,
|
||||||
|
"/": operator.truediv,
|
||||||
|
"^": operator.pow,
|
||||||
|
}
|
||||||
|
|
||||||
|
_binding_power = {
|
||||||
|
"+": (1, 2),
|
||||||
|
"-": (1, 2),
|
||||||
|
"*": (3, 4),
|
||||||
|
"/": (3, 4),
|
||||||
|
"^": (4, 5),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prefix binding power for unary minus (lower than ^, so -2^3 = -(2^3))
|
||||||
|
_prefix_binding_power = {"-": 3}
|
||||||
|
|
||||||
|
def eval(self, x: float) -> float:
|
||||||
|
op_func = self._ops.get(self.operator)
|
||||||
|
if not op_func:
|
||||||
|
raise ValueError(f"Unknown operator {self.operator}")
|
||||||
|
return op_func(self.lvalue.eval(x), self.rvalue.eval(x))
|
||||||
|
|
||||||
|
|
||||||
|
class Parser:
|
||||||
|
def __init__(self, tokens: Iterator[Token]):
|
||||||
|
self.tokens = deque(tokens)
|
||||||
|
|
||||||
|
def peek(self) -> Token | None:
|
||||||
|
return self.tokens[0] if self.tokens else None
|
||||||
|
|
||||||
|
def consume(self) -> Token:
|
||||||
|
return self.tokens.popleft()
|
||||||
|
|
||||||
|
def _parse_expression_bp(self, min_bp: int) -> Expression:
|
||||||
|
token = self.consume()
|
||||||
|
|
||||||
|
if token.type == TokenType.SEPARATOR and token.value == "(":
|
||||||
|
lhs = self._parse_expression_bp(0)
|
||||||
|
self.consume() # consume closing ')'
|
||||||
|
elif token.type == TokenType.FUNCTION and token.value is not None:
|
||||||
|
self.consume() # consume opening '('
|
||||||
|
argument = self._parse_expression_bp(0)
|
||||||
|
self.consume() # consume closing ')'
|
||||||
|
lhs = FunctionExpression(function=token.value, argument=argument)
|
||||||
|
elif token.type == TokenType.OPERATOR and token.value == "-":
|
||||||
|
# Unary minus: desugar to 0 - operand
|
||||||
|
prefix_bp = InfixExpression._prefix_binding_power["-"]
|
||||||
|
operand = self._parse_expression_bp(prefix_bp)
|
||||||
|
lhs = InfixExpression(
|
||||||
|
operator="-",
|
||||||
|
lvalue=Atom(Token(type=TokenType.FLOAT, value="0")),
|
||||||
|
rvalue=operand,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lhs = Atom(token)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
op = self.peek()
|
||||||
|
if op is None or op.value is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if op.type != TokenType.OPERATOR:
|
||||||
|
break
|
||||||
|
|
||||||
|
l_bp, r_bp = InfixExpression._binding_power[op.value]
|
||||||
|
if l_bp < min_bp:
|
||||||
|
break
|
||||||
|
|
||||||
|
self.consume()
|
||||||
|
|
||||||
|
rhs = self._parse_expression_bp(r_bp)
|
||||||
|
lhs = InfixExpression(operator=op.value, lvalue=lhs, rvalue=rhs)
|
||||||
|
|
||||||
|
return lhs
|
||||||
|
|
||||||
|
def parse_expression(self) -> Expression:
|
||||||
|
return self._parse_expression_bp(min_bp=0)
|
||||||
|
|
||||||
|
|
||||||
|
def parse(expression: str) -> Expression:
|
||||||
|
return Parser(lex(expression)).parse_expression()
|
||||||
|
|
||||||
|
|
||||||
|
def eval_in_range(
|
||||||
|
expression: str, start: float, stop: float, increment: float
|
||||||
|
) -> list[float]:
|
||||||
|
if stop < start:
|
||||||
|
raise ValueError("range must be provided in crescent order")
|
||||||
|
|
||||||
|
parsed_expression = parse(expression)
|
||||||
|
|
||||||
|
n = 1
|
||||||
|
x = start
|
||||||
|
values = []
|
||||||
|
while x < stop:
|
||||||
|
x += increment
|
||||||
|
n += 1
|
||||||
|
values.append(parsed_expression.eval(x))
|
||||||
|
return values
|
||||||
BIN
tests/__pycache__/test_parser.cpython-312-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_parser.cpython-312-pytest-9.0.2.pyc
Normal file
Binary file not shown.
252
tests/test_parser.py
Normal file
252
tests/test_parser.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from plotter.parser import (
|
||||||
|
lex,
|
||||||
|
parse,
|
||||||
|
Atom,
|
||||||
|
Token,
|
||||||
|
TokenType,
|
||||||
|
InfixExpression,
|
||||||
|
FunctionExpression,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"expression, expected",
|
||||||
|
[
|
||||||
|
("+", [Token(type=TokenType.OPERATOR, value="+")]),
|
||||||
|
("x", [Token(type=TokenType.VARIABLE, value="x")]),
|
||||||
|
(".", [Token(type=TokenType.ERROR, value="number after dot was expected")]),
|
||||||
|
("1.", [Token(type=TokenType.ERROR, value="number after dot was expected")]),
|
||||||
|
(".1", [Token(type=TokenType.FLOAT, value=".1")]),
|
||||||
|
("3.14", [Token(type=TokenType.FLOAT, value="3.14")]),
|
||||||
|
(
|
||||||
|
"2 + 3",
|
||||||
|
[
|
||||||
|
Token(type=TokenType.FLOAT, value="2"),
|
||||||
|
Token(type=TokenType.OPERATOR, value="+"),
|
||||||
|
Token(type=TokenType.FLOAT, value="3"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"x - 4.5",
|
||||||
|
[
|
||||||
|
Token(type=TokenType.VARIABLE, value="x"),
|
||||||
|
Token(type=TokenType.OPERATOR, value="-"),
|
||||||
|
Token(type=TokenType.FLOAT, value="4.5"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"x - 4.5 * 2.34 + 5.2",
|
||||||
|
[
|
||||||
|
Token(type=TokenType.VARIABLE, value="x"),
|
||||||
|
Token(type=TokenType.OPERATOR, value="-"),
|
||||||
|
Token(type=TokenType.FLOAT, value="4.5"),
|
||||||
|
Token(type=TokenType.OPERATOR, value="*"),
|
||||||
|
Token(type=TokenType.FLOAT, value="2.34"),
|
||||||
|
Token(type=TokenType.OPERATOR, value="+"),
|
||||||
|
Token(type=TokenType.FLOAT, value="5.2"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"(x - 4.5) * (2.34 + 5.2)",
|
||||||
|
[
|
||||||
|
Token(type=TokenType.SEPARATOR, value="("),
|
||||||
|
Token(type=TokenType.VARIABLE, value="x"),
|
||||||
|
Token(type=TokenType.OPERATOR, value="-"),
|
||||||
|
Token(type=TokenType.FLOAT, value="4.5"),
|
||||||
|
Token(type=TokenType.SEPARATOR, value=")"),
|
||||||
|
Token(type=TokenType.OPERATOR, value="*"),
|
||||||
|
Token(type=TokenType.SEPARATOR, value="("),
|
||||||
|
Token(type=TokenType.FLOAT, value="2.34"),
|
||||||
|
Token(type=TokenType.OPERATOR, value="+"),
|
||||||
|
Token(type=TokenType.FLOAT, value="5.2"),
|
||||||
|
Token(type=TokenType.SEPARATOR, value=")"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sin(x) * 2.0",
|
||||||
|
[
|
||||||
|
Token(type=TokenType.FUNCTION, value="sin"),
|
||||||
|
Token(type=TokenType.SEPARATOR, value="("),
|
||||||
|
Token(type=TokenType.VARIABLE, value="x"),
|
||||||
|
Token(type=TokenType.SEPARATOR, value=")"),
|
||||||
|
Token(type=TokenType.OPERATOR, value="*"),
|
||||||
|
Token(type=TokenType.FLOAT, value="2.0"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"3.14 * coz(x)",
|
||||||
|
[
|
||||||
|
Token(type=TokenType.FLOAT, value="3.14"),
|
||||||
|
Token(type=TokenType.OPERATOR, value="*"),
|
||||||
|
Token(type=TokenType.ERROR, value="unknown function name 'coz'"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"3.14 * cos(2.4 * x) ^ 0.5",
|
||||||
|
[
|
||||||
|
Token(type=TokenType.FLOAT, value="3.14"),
|
||||||
|
Token(type=TokenType.OPERATOR, value="*"),
|
||||||
|
Token(type=TokenType.FUNCTION, value="cos"),
|
||||||
|
Token(type=TokenType.SEPARATOR, value="("),
|
||||||
|
Token(type=TokenType.FLOAT, value="2.4"),
|
||||||
|
Token(type=TokenType.OPERATOR, value="*"),
|
||||||
|
Token(type=TokenType.VARIABLE, value="x"),
|
||||||
|
Token(type=TokenType.SEPARATOR, value=")"),
|
||||||
|
Token(type=TokenType.OPERATOR, value="^"),
|
||||||
|
Token(type=TokenType.FLOAT, value="0.5"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_lexer(expression, expected):
|
||||||
|
assert list(lex(expression)) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"expression, expected",
|
||||||
|
argvalues=[
|
||||||
|
("3.14", Atom(Token(type=TokenType.FLOAT, value="3.14"))),
|
||||||
|
(
|
||||||
|
"2 + 2",
|
||||||
|
InfixExpression(
|
||||||
|
operator="+",
|
||||||
|
lvalue=Atom(token=Token(type=TokenType.FLOAT, value="2")),
|
||||||
|
rvalue=Atom(token=Token(type=TokenType.FLOAT, value="2")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"2 + 3 * 4 + 5",
|
||||||
|
InfixExpression(
|
||||||
|
operator="+",
|
||||||
|
lvalue=InfixExpression(
|
||||||
|
operator="+",
|
||||||
|
lvalue=Atom(Token(type=TokenType.FLOAT, value="2")),
|
||||||
|
rvalue=InfixExpression(
|
||||||
|
operator="*",
|
||||||
|
lvalue=Atom(Token(type=TokenType.FLOAT, value="3")),
|
||||||
|
rvalue=Atom(Token(type=TokenType.FLOAT, value="4")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
rvalue=Atom(Token(type=TokenType.FLOAT, value="5")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"(2 + 3) * (4 + 5)",
|
||||||
|
InfixExpression(
|
||||||
|
operator="*",
|
||||||
|
lvalue=InfixExpression(
|
||||||
|
operator="+",
|
||||||
|
lvalue=Atom(Token(type=TokenType.FLOAT, value="2")),
|
||||||
|
rvalue=Atom(Token(type=TokenType.FLOAT, value="3")),
|
||||||
|
),
|
||||||
|
rvalue=InfixExpression(
|
||||||
|
operator="+",
|
||||||
|
lvalue=Atom(Token(type=TokenType.FLOAT, value="4")),
|
||||||
|
rvalue=Atom(Token(type=TokenType.FLOAT, value="5")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sin(x) + cos(x)",
|
||||||
|
InfixExpression(
|
||||||
|
operator="+",
|
||||||
|
lvalue=FunctionExpression(
|
||||||
|
function="sin",
|
||||||
|
argument=Atom(token=Token(type=TokenType.VARIABLE, value="x")),
|
||||||
|
),
|
||||||
|
rvalue=FunctionExpression(
|
||||||
|
function="cos",
|
||||||
|
argument=Atom(token=Token(type=TokenType.VARIABLE, value="x")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parser(expression, expected):
|
||||||
|
assert parse(expression) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# Helper to create the desugared unary minus AST (0 - operand)
|
||||||
|
def _neg(operand):
|
||||||
|
return InfixExpression(
|
||||||
|
operator="-",
|
||||||
|
lvalue=Atom(Token(type=TokenType.FLOAT, value="0")),
|
||||||
|
rvalue=operand,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"expression, expected",
|
||||||
|
argvalues=[
|
||||||
|
# Simple unary minus: -x => 0 - x
|
||||||
|
(
|
||||||
|
"-x",
|
||||||
|
_neg(Atom(Token(type=TokenType.VARIABLE, value="x"))),
|
||||||
|
),
|
||||||
|
# Double negation: --x => 0 - (0 - x)
|
||||||
|
(
|
||||||
|
"--x",
|
||||||
|
_neg(_neg(Atom(Token(type=TokenType.VARIABLE, value="x")))),
|
||||||
|
),
|
||||||
|
# Subtract a negative: 1 - -2 => 1 - (0 - 2)
|
||||||
|
(
|
||||||
|
"1 - -2",
|
||||||
|
InfixExpression(
|
||||||
|
operator="-",
|
||||||
|
lvalue=Atom(Token(type=TokenType.FLOAT, value="1")),
|
||||||
|
rvalue=_neg(Atom(Token(type=TokenType.FLOAT, value="2"))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Negate function result: -sin(x) => 0 - sin(x)
|
||||||
|
(
|
||||||
|
"-sin(x)",
|
||||||
|
_neg(
|
||||||
|
FunctionExpression(
|
||||||
|
function="sin",
|
||||||
|
argument=Atom(Token(type=TokenType.VARIABLE, value="x")),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Precedence: -2^3 should be -(2^3), not (-2)^3
|
||||||
|
# With binding power 3, unary minus binds looser than ^ (4,5)
|
||||||
|
(
|
||||||
|
"-2^3",
|
||||||
|
_neg(
|
||||||
|
InfixExpression(
|
||||||
|
operator="^",
|
||||||
|
lvalue=Atom(Token(type=TokenType.FLOAT, value="2")),
|
||||||
|
rvalue=Atom(Token(type=TokenType.FLOAT, value="3")),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Explicit parens: (-2)^3 groups the negation first
|
||||||
|
(
|
||||||
|
"(-2)^3",
|
||||||
|
InfixExpression(
|
||||||
|
operator="^",
|
||||||
|
lvalue=_neg(Atom(Token(type=TokenType.FLOAT, value="2"))),
|
||||||
|
rvalue=Atom(Token(type=TokenType.FLOAT, value="3")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unary_minus(expression, expected):
|
||||||
|
assert parse(expression) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"expression, x, expected",
|
||||||
|
[
|
||||||
|
("-x", 5, -5),
|
||||||
|
("--x", 5, 5),
|
||||||
|
("1 - -2", 0, 3),
|
||||||
|
("-2^3", 0, -8), # -(2^3) = -8
|
||||||
|
("(-2)^3", 0, -8), # (-2)^3 = -8
|
||||||
|
("-2^2", 0, -4), # -(2^2) = -4
|
||||||
|
("(-2)^2", 0, 4), # (-2)^2 = 4
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unary_minus_eval(expression, x, expected):
|
||||||
|
assert parse(expression).eval(x) == expected
|
||||||
79
uv.lock
generated
Normal file
79
uv.lock
generated
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "plotter"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [{ name = "pytest", specifier = ">=9.0.2" }]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user