commit 8a335a70206b19e36934a146ff38cadeef5c4675 Author: Zevaryx Date: Wed May 5 17:37:20 2021 -0600 Inital build with AutoCI/CD diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a81c8ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,138 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..297abbe --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,11 @@ +image: python:rc +cache: + paths: + - ~/.cache/pip/ + +before_script: + - python -V + +tests: + script: + - python -m unittest discover tests diff --git a/calculator/__init__.py b/calculator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/calculator/interpreter.py b/calculator/interpreter.py new file mode 100644 index 0000000..9607508 --- /dev/null +++ b/calculator/interpreter.py @@ -0,0 +1,51 @@ +from .nodes import * +from .values import Number + + +class Interpreter: + def visit(self, node): + method_name = f"visit_{type(node).__name__}" + method = getattr(self, method_name) + return method(node) + + def visit_NumberNode(self, node): + return Number(node.value) + + def visit_AddNode(self, node): + return Number( + self.visit(node.node_a).value + self.visit(node.node_b).value + ) + + def visit_SubtractNode(self, node): + return Number( + self.visit(node.node_a).value - self.visit(node.node_b).value + ) + + def visit_MultiplyNode(self, node): + return Number( + self.visit(node.node_a).value * self.visit(node.node_b).value + ) + + def visit_DivideNode(self, node): + try: + return Number( + self.visit(node.node_a).value / self.visit(node.node_b).value + ) + except: + raise Exception("Runtime Math Error") + + def visit_PowerNode(self, node): + return Number( + self.visit(node.node_a).value ** self.visit(node.node_b).value + ) + + def visit_ModuloNode(self, node): + return Number( + self.visit(node.node_a).value % self.visit(node.node_b).value + ) + + def visit_PlusNode(self, node): + return self.visit(node.node) + + def visit_MinusNode(self, node): + return Number(-self.visit(node.node).value) diff --git a/calculator/lexer.py b/calculator/lexer.py new file mode 100644 index 0000000..6c5e385 --- /dev/null +++ b/calculator/lexer.py @@ -0,0 +1,77 @@ +from .tokens import Token, TokenType +from string import digits + +WHITESPACE = " \r\n\t" + + +class Lexer: + def __init__(self, text): + self.text = iter(text) + self.advance() + + def advance(self): + try: + self.current_char = next(self.text) + except StopIteration: + self.current_char = None + + def generate_tokens(self): + while self.current_char is not None: + match self.current_char: + case x if x in WHITESPACE: + self.advance() + case x if x in digits or x == ".": + yield self.generate_number() + case "+": + self.advance() + yield Token(TokenType.PLUS) + case "-": + self.advance() + yield Token(TokenType.MINUS) + case "*": + self.advance() + yield Token(TokenType.MULTIPLY) + case "/": + self.advance() + yield Token(TokenType.DIVIDE) + case "(": + self.advance() + yield Token(TokenType.LPAREN) + case ")": + self.advance() + yield Token(TokenType.RPAREN) + case "^": + self.advance() + yield Token(TokenType.POWER) + case "%": + self.advance() + yield Token(TokenType.MODULO) + case _: + raise Exception(f"Illegal Character: '{self.current_char}'") + + def generate_number(self): + decimal_pt_count = 0 + number_str = self.current_char + self.advance() + + while self.current_char is not None and ( + self.current_char == "." or self.current_char in digits + ): + if self.current_char == ".": + decimal_pt_count += 1 + if decimal_pt_count > 1: + break + + number_str += self.current_char + self.advance() + + if number_str.startswith("."): + number_str = "0" + number_str + + if number_str.endswith("."): + number_str += "0" + + return Token( + TokenType.NUMBER, + float(number_str) if "." in number_str else int(number_str), + ) diff --git a/calculator/nodes.py b/calculator/nodes.py new file mode 100644 index 0000000..132d9a9 --- /dev/null +++ b/calculator/nodes.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass + + +@dataclass +class NumberNode: + value: float | int + + def __repr__(self): + return str(self.value) + + +@dataclass +class AddNode: + node_a: any + node_b: any + + def __repr__(self): + return f"({self.node_a} + {self.node_b})" + + +@dataclass +class SubtractNode: + node_a: any + node_b: any + + def __repr__(self): + return f"({self.node_a} - {self.node_b})" + + +@dataclass +class MultiplyNode: + node_a: any + node_b: any + + def __repr__(self): + return f"({self.node_a} * {self.node_b})" + + +@dataclass +class DivideNode: + node_a: any + node_b: any + + def __repr__(self): + return f"({self.node_a} / {self.node_b})" + + +@dataclass +class PowerNode: + node_a: any + node_b: any + + def __repr__(self): + return f"({self.node_a} ^ {self.node_b})" + + +@dataclass +class ModuloNode: + node_a: any + node_b: any + + def __repr__(self): + return f"({self.node_a} % {self.node_b})" + + +@dataclass +class PlusNode: + node: any + + def __repr__(self): + return f"(+{self.node})" + + +@dataclass +class MinusNode: + node: any + + def __repr__(self): + return f"(-{self.node})" diff --git a/calculator/parser_.py b/calculator/parser_.py new file mode 100644 index 0000000..737f219 --- /dev/null +++ b/calculator/parser_.py @@ -0,0 +1,94 @@ +from .tokens import TokenType +from .nodes import * + + +class Parser: + def __init__(self, tokens): + self.tokens = iter(tokens) + self.advance() + + def raise_error(self, e = None): + if e: + raise Exception(f"Error: {e}") + raise Exception("Invalid syntax") + + def advance(self): + try: + self.current_token = next(self.tokens) + except StopIteration: + self.current_token = None + + def parse(self): + if self.current_token is None: + return + + result = self.expr() + + if self.current_token is not None: + self.raise_error("Current token is not None") + + return result + + def expr(self): + result = self.term() + while self.current_token is not None and self.current_token.type in ( + TokenType.PLUS, + TokenType.MINUS, + ): + match self.current_token.type: + case TokenType.PLUS: + self.advance() + result = AddNode(result, self.term()) + case TokenType.MINUS: + self.advance() + result = SubtractNode(result, self.term()) + + return result + + def term(self): + result = self.factor() + + while self.current_token is not None and self.current_token.type in ( + TokenType.MULTIPLY, + TokenType.DIVIDE, + TokenType.POWER, + TokenType.MODULO, + ): + match self.current_token.type: + case TokenType.MULTIPLY: + self.advance() + result = MultiplyNode(result, self.factor()) + case TokenType.DIVIDE: + self.advance() + result = DivideNode(result, self.factor()) + case TokenType.POWER: + self.advance() + result = PowerNode(result, self.factor()) + case TokenType.MODULO: + self.advance() + result = ModuloNode(result, self.factor()) + + return result + + def factor(self): + token = self.current_token + + match token.type: + case TokenType.LPAREN: + self.advance() + result = self.expr() + if self.current_token.type != TokenType.RPAREN: + self.raise_error() + self.advance() + return result + case TokenType.NUMBER: + self.advance() + return NumberNode(token.value) + case TokenType.PLUS: + self.advance() + return PlusNode(self.factor()) + case TokenType.MINUS: + self.advance() + return MinusNode(self.factor()) + + self.raise_error("Invalid factor token") diff --git a/calculator/tokens.py b/calculator/tokens.py new file mode 100644 index 0000000..48e3a0f --- /dev/null +++ b/calculator/tokens.py @@ -0,0 +1,23 @@ +from enum import Enum +from dataclasses import dataclass + + +class TokenType(Enum): + NUMBER = 0 + PLUS = 1 + MINUS = 2 + MULTIPLY = 3 + DIVIDE = 4 + LPAREN = 5 + RPAREN = 6 + POWER = 7 + MODULO = 8 + + +@dataclass +class Token: + type: TokenType + value: any = None + + def __repr__(self): + return self.type.name + (f":{self.value}" if self.value else "") diff --git a/calculator/values.py b/calculator/values.py new file mode 100644 index 0000000..2b54301 --- /dev/null +++ b/calculator/values.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass +class Number: + value: int | float + + def __repr__(self): + return str(self.value) diff --git a/main.py b/main.py new file mode 100644 index 0000000..7518527 --- /dev/null +++ b/main.py @@ -0,0 +1,25 @@ +import sys +from calculator.interpreter import Interpreter +from calculator.lexer import Lexer +from calculator.parser_ import Parser + +if __name__ == "__main__": + MIN_PYTHON = (3, 10) + + if sys.version_info < MIN_PYTHON: + sys.exit("Python {}.{} or later is required".format(*MIN_PYTHON)) + + while True: + try: + text = input(">> ") + lexer = Lexer(text) + tokens = lexer.generate_tokens() + parser = Parser(tokens) + tree = parser.parse() + if not tree: + continue + interpreter = Interpreter() + value = interpreter.visit(tree) + print(value) + except Exception as e: + print(e) diff --git a/tests/test_interpreter.py b/tests/test_interpreter.py new file mode 100644 index 0000000..382fdd6 --- /dev/null +++ b/tests/test_interpreter.py @@ -0,0 +1,59 @@ +import unittest +from calculator.nodes import ( + NumberNode, + AddNode, + SubtractNode, + MultiplyNode, + DivideNode, + PowerNode, + ModuloNode, +) +from calculator.interpreter import Interpreter +from calculator.values import Number + + +class TestInterpreter(unittest.TestCase): + def test_numbers(self): + val = Interpreter().visit(NumberNode(42)) + self.assertEqual(val, Number(42)) + + def test_individual_operations(self): + val = Interpreter().visit(AddNode(NumberNode(21), NumberNode(21))) + self.assertEqual(val, Number(42)) + + val = Interpreter().visit(SubtractNode(NumberNode(21), NumberNode(21))) + self.assertEqual(val, Number(0)) + + val = Interpreter().visit(MultiplyNode(NumberNode(21), NumberNode(21))) + self.assertEqual(val, Number(441)) + + val = Interpreter().visit(DivideNode(NumberNode(21), NumberNode(21))) + self.assertEqual(val, Number(1)) + + val = Interpreter().visit(PowerNode(NumberNode(21), NumberNode(21))) + self.assertEqual(val, Number(5842587018385982521381124421)) + + val = Interpreter().visit(ModuloNode(NumberNode(21), NumberNode(21))) + self.assertEqual(val, Number(0)) + + with self.assertRaises(Exception): + Interpreter().visit(DivideNode(NumberNode(42), NumberNode(0))) + + def test_full_expression(self): + tree = ModuloNode( + SubtractNode( + AddNode( + PowerNode(NumberNode(10), NumberNode(2)), + AddNode( + DivideNode(NumberNode(21), NumberNode(7)), + NumberNode(7), + ), + ), + MultiplyNode(NumberNode(5), NumberNode(2)), + ), + NumberNode(100), + ) + + result = Interpreter().visit(tree) + + self.assertEqual(result, Number(0.0)) diff --git a/tests/test_lexer.py b/tests/test_lexer.py new file mode 100644 index 0000000..b69ffae --- /dev/null +++ b/tests/test_lexer.py @@ -0,0 +1,75 @@ +import unittest +from calculator.lexer import Lexer +from calculator.tokens import Token, TokenType + + +class TestLexer(unittest.TestCase): + def test_empty(self): + tokens = list(Lexer("").generate_tokens()) + self.assertEqual(tokens, []) + + def test_whitespace(self): + tokens = list(Lexer("\t\n \t\t\n\n\r\r").generate_tokens()) + self.assertEqual(tokens, []) + + def test_numbers(self): + tokens = list(Lexer("123 123.456 123. .456 .").generate_tokens()) + self.assertEqual( + tokens, + [ + Token(TokenType.NUMBER, 123), + Token(TokenType.NUMBER, 123.456), + Token(TokenType.NUMBER, 123.0), + Token(TokenType.NUMBER, 0.456), + Token(TokenType.NUMBER, 0.0), + ], + ) + + def test_operators(self): + tokens = list(Lexer("+-*/^%").generate_tokens()) + self.assertEqual( + tokens, + [ + Token(TokenType.PLUS), + Token(TokenType.MINUS), + Token(TokenType.MULTIPLY), + Token(TokenType.DIVIDE), + Token(TokenType.POWER), + Token(TokenType.MODULO), + ], + ) + + def test_parens(self): + tokens = list(Lexer("()").generate_tokens()) + self.assertEqual( + tokens, [Token(TokenType.LPAREN), Token(TokenType.RPAREN)] + ) + + def test_all(self): + tokens = list( + Lexer("(10 ^ 2 + (21 / 7 + 7) - 5 * 2) % 100").generate_tokens() + ) + self.assertEqual( + tokens, + [ + Token(TokenType.LPAREN), + Token(TokenType.NUMBER, 10), + Token(TokenType.POWER), + Token(TokenType.NUMBER, 2), + Token(TokenType.PLUS), + Token(TokenType.LPAREN), + Token(TokenType.NUMBER, 21), + Token(TokenType.DIVIDE), + Token(TokenType.NUMBER, 7), + Token(TokenType.PLUS), + Token(TokenType.NUMBER, 7), + Token(TokenType.RPAREN), + Token(TokenType.MINUS), + Token(TokenType.NUMBER, 5), + Token(TokenType.MULTIPLY), + Token(TokenType.NUMBER, 2), + Token(TokenType.RPAREN), + Token(TokenType.MODULO), + Token(TokenType.NUMBER, 100), + ], + ) diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..d43c105 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,114 @@ +import unittest +from calculator.tokens import Token, TokenType +from calculator.parser_ import Parser +from calculator.nodes import ( + NumberNode, + AddNode, + SubtractNode, + MultiplyNode, + DivideNode, + PowerNode, + ModuloNode, +) + + +class TestParser(unittest.TestCase): + def test_empty(self): + tokens = [] + node = Parser(tokens).parse() + self.assertEqual(node, None) + + def test_numbers(self): + tokens = [Token(TokenType.NUMBER, 100)] + node = Parser(tokens).parse() + self.assertEqual(node, NumberNode(100)) + + def test_individual_operations(self): + tokens = [ + Token(TokenType.NUMBER, 21), + Token(TokenType.PLUS), + Token(TokenType.NUMBER, 23), + ] + node = Parser(tokens).parse() + self.assertEqual(node, AddNode(NumberNode(21), NumberNode(23))) + + tokens = [ + Token(TokenType.NUMBER, 21), + Token(TokenType.MINUS), + Token(TokenType.NUMBER, 23), + ] + node = Parser(tokens).parse() + self.assertEqual(node, SubtractNode(NumberNode(21), NumberNode(23))) + + tokens = [ + Token(TokenType.NUMBER, 21), + Token(TokenType.MULTIPLY), + Token(TokenType.NUMBER, 23), + ] + node = Parser(tokens).parse() + self.assertEqual(node, MultiplyNode(NumberNode(21), NumberNode(23))) + + tokens = [ + Token(TokenType.NUMBER, 21), + Token(TokenType.DIVIDE), + Token(TokenType.NUMBER, 23), + ] + node = Parser(tokens).parse() + self.assertEqual(node, DivideNode(NumberNode(21), NumberNode(23))) + + tokens = [ + Token(TokenType.NUMBER, 21), + Token(TokenType.POWER), + Token(TokenType.NUMBER, 23), + ] + node = Parser(tokens).parse() + self.assertEqual(node, PowerNode(NumberNode(21), NumberNode(23))) + + tokens = [ + Token(TokenType.NUMBER, 21), + Token(TokenType.MODULO), + Token(TokenType.NUMBER, 23), + ] + node = Parser(tokens).parse() + self.assertEqual(node, ModuloNode(NumberNode(21), NumberNode(23))) + + def test_full_expression(self): + tokens = [ + Token(TokenType.LPAREN), + Token(TokenType.NUMBER, 10), + Token(TokenType.POWER), + Token(TokenType.NUMBER, 2), + Token(TokenType.PLUS), + Token(TokenType.LPAREN), + Token(TokenType.NUMBER, 21), + Token(TokenType.DIVIDE), + Token(TokenType.NUMBER, 7), + Token(TokenType.PLUS), + Token(TokenType.NUMBER, 7), + Token(TokenType.RPAREN), + Token(TokenType.MINUS), + Token(TokenType.NUMBER, 5), + Token(TokenType.MULTIPLY), + Token(TokenType.NUMBER, 2), + Token(TokenType.RPAREN), + Token(TokenType.MODULO), + Token(TokenType.NUMBER, 100), + ] + + node = Parser(tokens).parse() + self.assertEqual( + node, + ModuloNode( + SubtractNode( + AddNode( + PowerNode(NumberNode(10), NumberNode(2)), + AddNode( + DivideNode(NumberNode(21), NumberNode(7)), + NumberNode(7), + ), + ), + MultiplyNode(NumberNode(5), NumberNode(2)), + ), + NumberNode(100), + ), + )