diff --git a/.gitignore b/.gitignore index d7dc40d..cd01afa 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,9 @@ __pycache__/ *.py[cod] *$py.class -# PyCharm files +# IDE files .idea/ +.vscode/ # C extensions *.so @@ -104,4 +105,4 @@ venv.bak/ /site # mypy -.mypy_cache/ +.mypy_cache/ \ No newline at end of file diff --git a/final_task/pycalc/__init__.py b/final_task/pycalc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/final_task/pycalc/core.py b/final_task/pycalc/core.py new file mode 100644 index 0000000..386eb24 --- /dev/null +++ b/final_task/pycalc/core.py @@ -0,0 +1,300 @@ +"""The script itself.""" + +import math +import argparse +import sys +import operator +import re +from collections import namedtuple + + +class OperandsError(Exception): + pass + + +class OperatorsError(Exception): + pass + + +class WhitespaceError(Exception): + pass + + +class UnbalancedBracketsError(Exception): + pass + + +class UnexpectedCommaError(Exception): + pass + + +class EmptyExpressionError(Exception): + pass + + +class ParseError(Exception): + pass + + +class UnknownTokensError(Exception): + pass + + +sys.tracebacklimit = 0 + +op = namedtuple('op', ['prec', 'func']) + +operators = { + '^': op(4, operator.pow), + '*': op(3, operator.mul), + '/': op(3, operator.truediv), + '//': op(3, operator.floordiv), + '%': op(3, operator.mod), + '+': op(2, operator.add), + '-': op(2, operator.sub) +} + +prefix = namedtuple('prefix', ['func', 'args']) + +prefixes = { + 'sin': prefix(math.sin, 1), + 'cos': prefix(math.cos, 1), + 'tan': prefix(math.tan, 1), + 'asin': prefix(math.asin, 1), + 'acos': prefix(math.acos, 1), + 'atan': prefix(math.atan, 1), + 'sqrt': prefix(math.sqrt, 1), + 'exp': prefix(math.exp, 1), + 'log': prefix(math.log, 2), + 'log1p': prefix(math.log1p, 1), + 'log10': prefix(math.log10, 1), + 'factorial': prefix(math.factorial, 1), + 'pow': prefix(math.pow, 2), + 'abs': prefix(abs, 1), + 'round': prefix(round, 1), + 'p': prefix(operator.pos, 1), + 'n': prefix(operator.neg, 1) +} + +constants = { + 'pi': math.pi, + 'e': math.e, + 'tau': math.tau +} + +comparators = { + '<': operator.lt, + '>': operator.gt, + '<=': operator.le, + '>=': operator.ge, + '==': operator.eq, + '!=': operator.ne +} + + +def isnumber(num): + try: + float(num) + return True + except ValueError: + return False + + +def check_whitespace(expression): + """Check for whitespace breaking comparison operators and numbers in a given expression.""" + expression = expression.strip() + token = re.search(r'[><=!]\s+=|\*\s+\*|\d\.?\s+\.?\d|\/\s+\/', expression) + if token is not None: + raise WhitespaceError("ERROR: unexpected whitespace in the expression.") + return None + + +def check_brackets(expression): + """Check whether brackets are balanced in the expression.""" + symbol_count = 0 + for symbol in expression: + if symbol == '(': + symbol_count += 1 + elif symbol == ')': + symbol_count -= 1 + if symbol_count < 0: + raise UnbalancedBracketsError("ERROR: brackets are not balanced.") + if symbol_count > 0: + raise UnbalancedBracketsError("ERROR: brackets are not balanced.") + return None + + +def check_commas(expression): + """Check for commas in unexpected places.""" + token = re.search(r'[^\)ieu\d]\,|\,[^\dpet\(]|^\,|\,$', expression) + if token is not None: + raise UnexpectedCommaError("ERROR: unexpected comma.") + return None + + +def get_tokens(expression, input_queue=None): + """Recursively split expression string into tokens and store them in input_queue.""" + if expression is '': + raise EmptyExpressionError("ERROR: no expression provided.") + if input_queue is None: + expression = expression.strip().lower().replace(' ', '') + input_queue = [] + token = re.match(r'\)|\(|\d+\.?\d*|[-+*/,^]|\.\d+|\w+|\W+', expression) + try: + token.group() + except Exception: + raise ParseError("ERROR: couldn't parse this expression.") + input_queue.append(token.group()) + if len(token.group()) < len(expression): + return get_tokens(expression[token.end():], input_queue) + else: + for index, token in enumerate(input_queue): + if (input_queue[index - 1] in operators or + input_queue[index - 1] is 'p' or + input_queue[index - 1] is 'n' or + input_queue[index - 1] is '(' or + index is 0): + if token is '+': + input_queue[index] = 'p' + if token is '-': + input_queue[index] = 'n' + return input_queue + + +def convert_infix_to_postfix(input_queue): + if input_queue[-1] in operators or input_queue[-1] in prefixes: + raise OperatorsError("ERROR: trailing operators.") + output_queue = [] + operator_stack = [] + while len(input_queue): + token = input_queue[0] + if isnumber(token): + output_queue.append(float(input_queue.pop(0))) + elif token in constants: + output_queue.append(constants[token]) + input_queue.pop(0) + elif token in prefixes: + operator_stack.append(input_queue.pop(0)) + elif token is '(': + operator_stack.append(input_queue.pop(0)) + elif token is ',': + # since a comma can appear only after a logarithm or power, a check is implemented here + if 'log' not in operator_stack and 'pow' not in operator_stack: + raise UnexpectedCommaError("ERROR: unexpected comma.") + try: + while operator_stack[-1] is not '(': + output_queue.append(operator_stack.pop()) + input_queue.pop(0) + except IndexError: + raise UnexpectedCommaError("ERROR: unexpected comma.") + elif token in operators: + while True: + if not operator_stack: + operator_stack.append(input_queue.pop(0)) + break + if ((operator_stack[-1] is not '(') + and (operator_stack[-1] in prefixes + or operators[token].prec < operators[operator_stack[-1]].prec + or (operators[operator_stack[-1]].prec == operators[token].prec + and token is not '^'))): + output_queue.append(operator_stack.pop()) + else: + operator_stack.append(input_queue.pop(0)) + break + elif token is ')': + while operator_stack[-1] is not '(': + output_queue.append(operator_stack.pop()) + operator_stack.pop() + input_queue.pop(0) + else: + raise UnknownTokensError("ERROR: unknown tokens.") + while len(operator_stack): + output_queue.append(operator_stack.pop()) + return output_queue + + +def split_comparison(expression): + """ + Split given expression into two to compare them. + When given a non-comparison statement, return it without modifying. + """ + expression = re.sub(r'\s', '', expression) + token = re.findall(r'==|>=|<=|>|<|!=', expression) + if len(token) > 1: + raise OperatorsError("ERROR: more than one comparison operator.") + elif len(token) is 0: + return expression, None + else: + expressions = expression.split(token[0]) + return expressions, token[0] + + +def calculate(expression): + """Calculate a postfix notation expression.""" + stack = [] + while expression: + token = expression.pop(0) + if isnumber(token): + stack.append(token) + elif token in operators: + operand_2 = stack.pop() + operand_1 = stack.pop() + result = operators[token].func(operand_1, operand_2) + stack.append(result) + elif token in prefixes: + if prefixes[token].args is 2: + if len(stack) is 1: + operand = stack.pop() + result = prefixes[token].func(operand) + stack.append(result) + else: + operand_2 = stack.pop() + operand_1 = stack.pop() + result = prefixes[token].func(operand_1, operand_2) + stack.append(result) + else: + operand = stack.pop() + result = prefixes[token].func(operand) + stack.append(result) + if len(stack) is 1: + return stack[0] + else: + raise OperandsError("ERROR: wrong number of operands.") + + +def get_result(expression): + expression = get_tokens(expression) + expression = convert_infix_to_postfix(expression) + try: + expression = calculate(expression) + except ZeroDivisionError as err: + print("ERROR: {err}.".format(err=err)) + return expression + + +def compare(expressions, comparator): + calculated_expressions = [get_result(expr) for expr in expressions] + return comparators[comparator](expressions[0], expressions[1]) + + +def main(): # pragma: no cover + parser = argparse.ArgumentParser(description='Pure-python command-line calculator.') + parser.add_argument('EXPRESSION', action="store", help="expression string to evaluate") + args = parser.parse_args() + if args.EXPRESSION is not None: + try: + expression = args.EXPRESSION + check_whitespace(expression) + check_brackets(expression) + expressions, comparator = split_comparison(expression) + check_commas(expression) + if not comparator: + print(get_result(expressions)) + else: + print(compare(expressions, comparator)) + except Exception as exception: + print(exception) + + +if __name__ == '__main__': # pragma: no cover + main() diff --git a/final_task/pycalc/test.py b/final_task/pycalc/test.py new file mode 100644 index 0000000..450cb1e --- /dev/null +++ b/final_task/pycalc/test.py @@ -0,0 +1,115 @@ +from parameterized import parameterized +import unittest +import math +from pycalc.core import * + + +class TestCheckers(unittest.TestCase): + def test_check_brackets(self): + self.assertEqual(check_brackets('()'), None) + self.assertEqual(check_brackets('()()'), None) + + @parameterized.expand([ + ('wrong side brackets', ')('), + ('extra opening bracket', '(()'), + ('extra closing bracket', '())'), + ('just a mess', '()())))((('), + ]) + def test_check_brackets_raises(self, name, brackets): + with self.assertRaises(UnbalancedBracketsError): + check_brackets(brackets) + + @parameterized.expand([ + ('numbers', '12 34'), + ('numbers with dots', '12. 34'), + ('comparison operator', '< ='), + ('floor division', '/ /'), + ('power', '* *'), + ]) + def test_check_whitespace(self, name, whitespace): + with self.assertRaises(WhitespaceError): + check_whitespace(whitespace) + + @parameterized.expand([ + ('function', 'sin(1, 2)'), + ('on its own', ','), + ]) + def test_check_commas(self, name, comma): + with self.assertRaises(UnexpectedCommaError): + check_commas(comma) + + +class TestCoreFunctions(unittest.TestCase): + def test_get_tokens(self): + self.assertEqual(get_tokens('1+2+3'), ['1', '+', '2', '+', '3']) + with self.assertRaises(EmptyExpressionError): + get_tokens('') + + @parameterized.expand([ + ('left associativity', ['1', '+', '2', '+', '3'], [1.0, 2.0, '+', 3.0, '+']), + ('right associativity', ['1', '+', '2', '^', '3'], [1.0, 2.0, 3.0, '^', '+']) + ]) + def test_convert_infix_to_postfix(self, name, input, output): + self.assertEqual(convert_infix_to_postfix(input), output) + + @parameterized.expand([ + ('unexpected operator', OperatorsError, ['+']), + ('unexpected comma', UnexpectedCommaError, ['(', '12', ',', '12', ')']), + ('unknown token', UnknownTokensError, ['asdf']) + ]) + def test_convert_infix_to_postfix_raises(self, name, error, argument): + with self.assertRaises(error): + convert_infix_to_postfix(argument) + + @parameterized.expand([ + ('1>=2', (['1', '2'], '>=')), + ('1', ('1', None)), + ]) + def test_split_comparison(self, arguments, result): + self.assertEqual(split_comparison(arguments), result) + + def test_split_comparison_raises(self): + with self.assertRaises(OperatorsError): + split_comparison('1>=2>=3') + + +class TestEndToEnd(unittest.TestCase): + @parameterized.expand([ + (calculate([3.0, 2.0, '+']), 5.0), + (calculate([2.0, 2.0, 2.0, '^', '^']), 16.0), + ]) + def test_calculate(self, function, result): + self.assertEqual(function, result) + + def test_calculate_raises(self): + with self.assertRaises(OperandsError): + calculate([3.0, 2.0, 1.0, '+']) + + @parameterized.expand([ + (compare(['2', '1'], '>='), ), + (compare(['1', '2'], '<='), ), + ]) + def test_compare_true(self, argument): + self.assertTrue(argument) + + @parameterized.expand([ + (compare(['1', '2'], '>='), ), + (compare(['2', '1'], '<='), ), + ]) + def test_compare_false(self, argument): + self.assertFalse(argument) + + @parameterized.expand([ + ("simple", "1+2", 3.0), + ("precedence", "1+2*3", 7.0), + ("brackets", "(1+2)*3", 9.0), + ("single argument function", "sin(0)", 0.0), + ("two argument function", "log(100, 10)", 2.0), + ("right associativity", "4^3^2", 262144.0), + ]) + def test_get_result(self, name, expression, result): + self.assertEqual(get_result(expression), result) + + +if __name__ == '__main__': # pragma: no cover + unittest.main() diff --git a/final_task/setup.py b/final_task/setup.py index e69de29..d222b36 100644 --- a/final_task/setup.py +++ b/final_task/setup.py @@ -0,0 +1,15 @@ +"""pycalc script setup""" + +from setuptools import setup, find_packages # pragma: no cover + +setup( + name='pycalc', + version='1.0', + author='wenaught', + description='Command-line Python calculator.', + packages=find_packages(), + entry_points={ + 'console_scripts': [ + 'pycalc=pycalc.core:main', + ] + }) # pragma: no cover