diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..07664f9 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +all: check test + +.PHONY: check +check: + uvx ty check + +.PHONY: test +test: + uv run pytest diff --git a/README.md b/README.md new file mode 100644 index 0000000..6039804 --- /dev/null +++ b/README.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5d3da03 --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/src/plotter/__init__.py b/src/plotter/__init__.py new file mode 100644 index 0000000..2ba274d --- /dev/null +++ b/src/plotter/__init__.py @@ -0,0 +1,2 @@ +def main() -> None: + print("Hello from plotter!") diff --git a/src/plotter/__pycache__/__init__.cpython-312.pyc b/src/plotter/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..0dcb368 Binary files /dev/null and b/src/plotter/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/plotter/__pycache__/parser.cpython-312.pyc b/src/plotter/__pycache__/parser.cpython-312.pyc new file mode 100644 index 0000000..814efeb Binary files /dev/null and b/src/plotter/__pycache__/parser.cpython-312.pyc differ diff --git a/src/plotter/parser.py b/src/plotter/parser.py new file mode 100644 index 0000000..0e48cad --- /dev/null +++ b/src/plotter/parser.py @@ -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 diff --git a/tests/__pycache__/test_parser.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_parser.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..b9cf143 Binary files /dev/null and b/tests/__pycache__/test_parser.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..f641239 --- /dev/null +++ b/tests/test_parser.py @@ -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 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..09b9a54 --- /dev/null +++ b/uv.lock @@ -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" }, +]