diff --git a/final_task/calculator/__init__.py b/final_task/calculator/__init__.py new file mode 100644 index 0000000..d0c1902 --- /dev/null +++ b/final_task/calculator/__init__.py @@ -0,0 +1 @@ +"""Init file""" diff --git a/final_task/calculator/pycalc.py b/final_task/calculator/pycalc.py new file mode 100755 index 0000000..bdd9382 --- /dev/null +++ b/final_task/calculator/pycalc.py @@ -0,0 +1,509 @@ +from re import compile +from io import StringIO +from tokenize import generate_tokens +import math +from argparse import ArgumentParser +from numbers import Number +from collections import namedtuple + + +class UnbalancedParenthesesError(Exception): + def __init__(self, message): + super(UnbalancedParenthesesError, self).__init__(message) + + +class UnknownFunctionError(Exception): + def __init__(self, message): + super(UnknownFunctionError, self).__init__(message) + + +class RedundantParameterError(Exception): + def __init__(self, message): + super(RedundantParameterError, self).__init__(message) + + +class MissingParameterError(Exception): + def __init__(self, message): + super(MissingParameterError, self).__init__(message) + + +class UnknownSymbolError(Exception): + def __init__(self, message): + super(UnknownSymbolError, self).__init__(message) + + +class UnexpectedSpaceError(Exception): + def __init__(self, message): + super(UnexpectedSpaceError, self).__init__(message) + + +class RPN: + """ + Reverse Polish notation converter and handler class + """ + + def __init__(self): + self.stack, self.output, self.expression, self.tokens = [], [], '', [] + self.postfix_ops = {'!': self.factorial} + self.prefix_ops = {a: getattr(math, a) for a in dir(math) if callable(getattr(math, a))} + other_prefix_op = { + 'log': self.logarithm, + 'log2': self.logarithm_two, + 'log10': self.logarithm_ten, + 'pow': self.power, + 'sqrt': self.square_root, + 'ln': self.logarithm_e, + 'abs': abs, + 'round': round, + 'minus': self.unary_minus, + 'plus': self.unary_plus, + } + self.prefix_ops.update(other_prefix_op) + sign = namedtuple('sign', 'priority action') + self.signs = { + '^': sign(4, self.power), + '**': sign(4, self.power), + '/': sign(3, self.divide), + '//': sign(3, self.int_divide), + '%': sign(3, self.division_rest), + '*': sign(3, lambda digit1, digit2: digit1 * digit2), + '+': sign(2, lambda digit1, digit2: digit1 + digit2), + '-': sign(2, lambda digit1, digit2: digit1 - digit2), + '(': sign(1, None), + ')': sign(1, None), + '<': sign(0, lambda digit1, digit2: digit1 < digit2), + '<=': sign(0, lambda digit1, digit2: digit1 <= digit2), + '=': sign(0, lambda digit1, digit2: digit1 == digit2), + '==': sign(0, lambda digit1, digit2: digit1 == digit2), + '!=': sign(0, lambda digit1, digit2: digit1 != digit2), + '>=': sign(0, lambda digit1, digit2: digit1 >= digit2), + '>': sign(0, lambda digit1, digit2: digit1 > digit2) + } + values = {attr: getattr(math, attr) for attr in dir(math) if isinstance(getattr(math, attr), Number)} + self.const_values = values + self.all_ops = {**self.postfix_ops, **self.prefix_ops, **self.signs} + + def clear_stack(self): + """ + Clears class attribute 'stack' + """ + self.stack = [] + + @staticmethod + def is_num(item): + """ + Checks whether this token is number + """ + try: + float(item) + return True + except (ValueError, TypeError): + pass + return False + + @staticmethod + def unary_minus(digit): + """ + Changes 'digit' to '-digit' + """ + return (-1) * digit + + @staticmethod + def unary_plus(digit): + """ + Changes 'digit' to '+digit' + """ + return digit + + @staticmethod + def factorial(digit): + """ + Calculates factorial on a digit + """ + if digit < 0: + raise ValueError('can\'t count factorial of negative number') + elif not str(digit).isdigit(): + raise ValueError('can\'t count factorial of fractional number') + else: + return math.factorial(digit) + + @staticmethod + def logarithm(digit1, digit2): + """ + Calculates logarithm of digit1 by base digit2 + """ + if digit2 == 1: + raise ZeroDivisionError('cant\'t count logarithm by base 1') + elif digit1 <= 0: + raise ValueError('can\'t count non-positive logarithm') + else: + return math.log(digit1, digit2) + + @staticmethod + def logarithm_e(digit): + """ + Calculates natural logarithm of digit by base e + """ + if digit > 0: + return math.log(digit) + else: + raise ValueError('can\'t count non-positive logarithm') + + def resolve_log(self): + """ + If log() takes two parameter leaves it the same + If log() takes one parameter changes log() to ln() in place + """ + open_parentheses_count = 0 + close_parentheses_count = 0 + for index, token in enumerate(self.tokens): + if token == 'log': + n_args = 1 + for new_token in self.tokens[index + 1:]: + if new_token == '(': + open_parentheses_count += 1 + if new_token == ',': + n_args = 2 + if new_token == ')': + close_parentheses_count += 1 + if open_parentheses_count == close_parentheses_count: + break + if n_args == 1: + self.tokens[index] = 'ln' + + @staticmethod + def logarithm_two(digit): + """ + Calculates logarithm of digit by base two + """ + if digit > 0: + return math.log2(digit) + else: + raise ValueError('can\'t count non-positive logarithm by base 2') + + @staticmethod + def logarithm_ten(digit): + """ + Calculates logarithm of digit by base ten + """ + if digit > 0: + return math.log10(digit) + else: + raise ValueError('can\'t count non-positive logarithm by base 10') + + @staticmethod + def power(digit1, digit2): + """ + Calculates digit2-power of digit1 + """ + if digit1 < 0 and not digit2.is_integer(): + raise ValueError('can\'t raise negative number to fractional power') + else: + return pow(digit1, digit2) + + @staticmethod + def square_root(digit): + """ + Calculates square root of digit + """ + if digit >= 0: + return math.sqrt(digit) + else: + raise ValueError('can\'t count square root of negative number') + + @staticmethod + def divide(digit1, digit2): + """ + Calculates digit1 divided by digit2 + """ + if digit2 == 0: + raise ZeroDivisionError('can\'t divide by zero') + else: + return digit1 / digit2 + + @staticmethod + def int_divide(digit1, digit2): + """ + Performes integer division of digit1 by digit2 + """ + if digit2 == 0: + raise ZeroDivisionError('can\'t divide by zero') + else: + return digit1 // digit2 + + @staticmethod + def division_rest(digit1, digit2): + """ + Calculates the rest of division of ditit1 by digit2 + """ + if digit2 == 0: + raise ZeroDivisionError('can\'t divide by zero') + else: + return digit1 % digit2 + + def add_implicit_multiply(self, tokens_list): + """ + Adds implicit multiplication to the new tokens list + Returns resolved list + """ + resolved_list = [] + + def add_mult_sign(): + resolved_list.append('*') + resolved_list.append(token) + + for index, token in enumerate(tokens_list): + prev = tokens_list[index - 1] + if index == 0: + resolved_list.append(token) + elif token in self.prefix_ops and self.is_num(prev): + add_mult_sign() + elif token in self.const_values and self.is_num(prev): + add_mult_sign() + elif token in self.prefix_ops and self.is_num(prev): + add_mult_sign() + elif token in self.const_values and prev in self.const_values: + add_mult_sign() + elif token in self.prefix_ops and prev == ')': + add_mult_sign() + elif self.is_num(token) and prev == ')': + add_mult_sign() + elif token == '(' and prev == ')': + add_mult_sign() + elif token == '(' and self.is_num(prev): + add_mult_sign() + else: + resolved_list.append(token) + return resolved_list + + def resolve_unary(self, tokens_list): + """ + Resolves all unary '-' and '+' operations changing them to 'minus' and 'plus' functions + Returns resolved list + """ + resolved_list = [] + for index, token in enumerate(tokens_list): + prev = tokens_list[index - 1] + if token == '-' or token == '+': + if index == 0: + resolved_list.append('minus') if token == '-' else resolved_list.append('plus') + elif prev == ')' or prev in self.const_values or self.is_num(prev): + resolved_list.append(token) + else: + resolved_list.append('minus') if token == '-' else resolved_list.append('plus') + else: + resolved_list.append(token) + return resolved_list + + def resolve_double_const(self, some_sting): + """ + Resolves constant values standing together + """ + for const1 in list(self.const_values.keys()): + a = const1 + for const2 in list(self.const_values.keys()): + b = const2 + some_sting = some_sting.replace(f'{a}{b}', f'{a} {b}') + return some_sting + + def create_tokens_list(self, some_string): + """ + Creates tokens list from math expressions string + """ + some_string = self.resolve_double_const(some_string) + line = generate_tokens(StringIO(some_string).readline) + return [token[1] for token in line if token[1]] + + def convert_to_rpn(self, text): + """ + Converts initial math expression to Reverse Polish Notation while solving log(), unary operation + and adds implicit multiplication + Return tokens list in Reverse Polish Notation + """ + self.tokens = self.create_tokens_list(text) + self.resolve_log() + self.tokens = self.resolve_unary(self.tokens) + self.tokens = self.add_implicit_multiply(self.tokens) + for item in self.tokens: + if self.is_num(item) or item in self.postfix_ops or item in self.const_values: + self.output.append(item) + elif item == '(' or item in self.prefix_ops: + self.stack.append(item) + elif item == ')': + for element in reversed(self.stack): + if element != '(': + self.output.append(self.stack.pop()) + else: + self.stack.pop() + break + elif item in self.signs: + for element in reversed(self.stack): + if self.stack[-1] in self.prefix_ops \ + or self.signs[self.stack[-1]].priority > self.signs[item].priority \ + or self.signs[self.stack[-1]].priority == self.signs[item].priority and item != '^': + self.output.append(self.stack.pop()) + self.stack.append(item) + elif item == ',': + for element in reversed(self.stack): + if element != '(': + self.output.append(self.stack.pop()) + else: + break + else: + raise UnknownFunctionError(f'wrong operation "{item}"') + for element in reversed(self.stack): + self.output.append(self.stack.pop()) + self.clear_stack() + return list(self.output) + + def pop_one(self): + """ + Pops one item from stack + """ + return float(self.stack.pop()) + + def pop_two(self): + """ + Pops two items from stack + """ + y = float(self.stack.pop()) + x = float(self.stack.pop()) + return x, y + + def handle_operations(self, rpn_expression): + """ + Handles all operations in RPN tokens list + Return result of calculation + """ + for op in rpn_expression: + # print('Stack: ', self.stack) + if op in self.const_values: + self.stack.append(self.const_values[op]) + elif op not in self.all_ops: + self.stack.append(op) + elif op in self.signs: + try: + function = self.signs[op].action + x, y = self.pop_two() + self.stack.append(function(x, y)) + except IndexError: + raise MissingParameterError(f'not enough operands for "{op}" operation') + elif op in self.prefix_ops or self.postfix_ops: + try: + if op in ('fmod', 'gcd', 'isclose', 'ldexp', 'remainder', 'log', 'pow', 'atan2'): + function = self.all_ops[op] + x, y = self.pop_two() + self.stack.append(function(x, y)) + else: + function = self.all_ops[op] + self.stack.append(function(self.pop_one())) + except IndexError: + raise MissingParameterError(f'not enough operands for "{op}" operation') + if len(self.stack) > 1: + raise RedundantParameterError('function takes more parameters that it should') + return self.stack[0] + + @staticmethod + def parse_expression(): + """ + Creates command-line arguments parser + Returns parsed argument + """ + parser = ArgumentParser(description='Pure Python command-line calculator') + parser.add_argument('EXPRESSION', help='expression string to evaluate', action='store_true') + parsed, args = parser.parse_known_args() + return args[0] + + +class Check(RPN): + def __init__(self): + super(Check, self).__init__() + + def check_for_numbers(self, text): + """ + Checks whether expression has no operands + :param text: initial math expression + """ + tokens = self.create_tokens_list(text) + numbers_count = 0 + for token in tokens: + if self.is_num(token) or token in self.const_values: + numbers_count += 1 + if numbers_count == 0: + raise MissingParameterError('no numbers or constants in expression') + + @staticmethod + def check_parentheses(text): + """ + Checks whether parentheses are balanced + """ + n = abs(text.count('(') - text.count(')')) + if text.count('(') > text.count(')'): + raise UnbalancedParenthesesError(f'expression has {n} unclosed parentheses') + elif text.count('(') < text.count(')'): + raise UnbalancedParenthesesError(f'expression has {n} redundant closing parentheses') + if n == 0: + pass + + @staticmethod + def check_for_symbols(some_string): + """ + Checks whether unsupported symbols are in the string + """ + regex = compile('[;@_#$&?|}{~":]') + if regex.search(some_string): + raise UnknownSymbolError(f'unknown symbols "{regex.search(some_string).group()}"') + else: + pass + + @staticmethod + def check_spaces(some_string): + """ + Checks whether unexpected spaces are in the string + """ + for index, char in enumerate(some_string): + try: + nxt = some_string[index + 1] + prev = some_string[index - 1] + if char == ' ': + if nxt.isdigit() and prev.isdigit(): + raise UnexpectedSpaceError('unexpected space between numbers') + elif nxt == ' ' or prev == ' ': + raise UnexpectedSpaceError('unexpected double space') + elif nxt == '.' and prev.isdigit() or nxt.isdigit() and prev == '.': + raise UnexpectedSpaceError('unexpected space between/or in fractional numbers') + elif nxt in '<>=!' and prev in '<>=!': + raise UnexpectedSpaceError(f'unexpected space in comparison operation {prev + nxt}') + elif nxt in '*/^' and prev in '*/^': + raise UnexpectedSpaceError(f'unexpected space in operation {prev + nxt}') + elif prev == '(' and nxt == ')': + raise UnexpectedSpaceError('unexpected empty parentheses') + elif prev == ')' and nxt == '.': + raise UnexpectedSpaceError('unexpected fractional number after ")"') + except IndexError: + pass + + def initial_check(self, expression): + """ + Performes initial checks of the expression for errors + """ + self.check_parentheses(expression) + self.check_for_symbols(expression) + self.check_for_numbers(expression) + self.check_spaces(expression) + + +def main(): + try: + rpn = RPN() + check = Check() + rpn.expression = rpn.parse_expression() + check.initial_check(rpn.expression) + rpn_expression = rpn.convert_to_rpn(rpn.expression) + print(rpn.handle_operations(rpn_expression)) + except Exception as e: + print(f'ERROR: {e}') + + +if __name__ == "__main__": + main() diff --git a/final_task/calculator/test.py b/final_task/calculator/test.py new file mode 100644 index 0000000..4c0ad4c --- /dev/null +++ b/final_task/calculator/test.py @@ -0,0 +1,197 @@ +from unittest import TestCase +from calculator import pycalc +import math + + +class TestRPN(TestCase): + def setUp(self): + self.rpn = pycalc.RPN() + + def test_clear_stack(self): + self.rpn.stack = ['3', '2', '*'] + self.rpn.stack.append('10') + self.rpn.clear_stack() + self.assertEqual(self.rpn.stack, []) + + def test_is_num(self): + self.assertEqual(self.rpn.is_num('5.0'), True) + self.assertEqual(self.rpn.is_num('.3'), True) + self.assertEqual(self.rpn.is_num('11.8'), True) + self.assertEqual(self.rpn.is_num('a'), False) + self.assertEqual(self.rpn.is_num('sin'), False) + + def test_unary_minus(self): + self.assertEqual(self.rpn.unary_minus(5), -5) + self.assertEqual(self.rpn.unary_minus(-5.6), 5.6) + self.assertEqual(self.rpn.unary_minus(-.8), 0.8) + + def test_unary_plus(self): + self.assertEqual(self.rpn.unary_plus(6), 6) + self.assertEqual(self.rpn.unary_plus(-88.6), -88.6) + self.assertEqual(self.rpn.unary_plus(.1), 0.1) + + def test_factorial(self): + self.assertEqual(self.rpn.factorial(10), math.factorial(10)) + with self.assertRaises(ValueError): + self.rpn.factorial(-13) + with self.assertRaises(ValueError): + self.rpn.factorial(666.6) + + def test_logarithm(self): + self.assertEqual(self.rpn.logarithm(8, 2), math.log(8, 2)) + with self.assertRaises(ZeroDivisionError): + self.rpn.logarithm(8, 1) + with self.assertRaises(ValueError): + self.rpn.logarithm(-8, -2) + + def test_logarithm_e(self): + self.assertEqual(self.rpn.logarithm_e(13), math.log(13)) + with self.assertRaises(ValueError): + self.rpn.logarithm_e(-20) + + def test_resolve_log(self): + self.rpn.tokens = ['log', '(', '8', ',', '2', ')'] + self.rpn.resolve_log() + self.assertEqual(self.rpn.tokens[0], 'log') + self.rpn.tokens = ['log', '(', '8', ')'] + self.rpn.resolve_log() + self.assertEqual(self.rpn.tokens[0], 'ln') + + def test_logarithm_two(self): + self.assertEqual(self.rpn.logarithm_two(8), math.log2(8)) + with self.assertRaises(ValueError): + self.rpn.logarithm_two(-666) + + def test_logarithm_ten(self): + self.assertEqual(self.rpn.logarithm_ten(8), math.log10(8)) + with self.assertRaises(ValueError): + self.rpn.logarithm_ten(-666) + + def test_power(self): + self.assertEqual(self.rpn.power(2, 4), math.pow(2, 4)) + self.assertEqual(self.rpn.power(10, 2), math.pow(10, 2)) + self.assertEqual(self.rpn.power(3, -2), math.pow(3, -2)) + with self.assertRaises(ValueError): + self.rpn.power(-2, 1.5) + + def test_square_root(self): + self.assertEqual(self.rpn.square_root(16), math.sqrt(16)) + self.assertEqual(self.rpn.square_root(101.1), math.sqrt(101.1)) + with self.assertRaises(ValueError): + self.rpn.square_root(-2) + + def test_divide(self): + self.assertEqual(self.rpn.divide(18, -2.5), 18 / -2.5) + with self.assertRaises(ZeroDivisionError): + self.rpn.divide(-2, 0) + + def test_int_divide(self): + self.assertEqual(self.rpn.int_divide(1813, 22), 1813 // 22) + with self.assertRaises(ZeroDivisionError): + self.rpn.int_divide(-2, 0) + + def test_division_rest(self): + self.assertEqual(self.rpn.division_rest(131, 0.8), 131 % 0.8) + with self.assertRaises(ZeroDivisionError): + self.rpn.division_rest(13, 0) + + def test_add_implicit_multiply(self): + token_list1 = ['2', '(', '10', '+', '1'')'] + token_list2 = ['8', 'sin', '(', '10', '+', '1'')'] + token_list3 = ['(', '3', ')', '(', '10', '+', '1', ')'] + token_list4 = ['e', 'pi'] + token_list5 = ['(', '3', ')', '10'] + token_list6 = ['(', '3', ')', 'sin', '(', '1', ')'] + token_list7 = ['6', 'pi'] + self.assertEqual(self.rpn.add_implicit_multiply(token_list1), ['2', '*', '(', '10', '+', '1'')']) + self.assertEqual(self.rpn.add_implicit_multiply(token_list2), ['8', '*', 'sin', '(', '10', '+', '1'')']) + self.assertEqual(self.rpn.add_implicit_multiply(token_list3), ['(', '3', ')', '*', '(', '10', '+', '1', ')']) + self.assertEqual(self.rpn.add_implicit_multiply(token_list4), ['e', '*', 'pi']) + self.assertEqual(self.rpn.add_implicit_multiply(token_list5), ['(', '3', ')', '*', '10']) + self.assertEqual(self.rpn.add_implicit_multiply(token_list6), ['(', '3', ')', '*', 'sin', '(', '1', ')']) + self.assertEqual(self.rpn.add_implicit_multiply(token_list7), ['6', '*', 'pi']) + + def test_resolve_unary(self): + token_list1 = ['+', '13'] + token_list2 = ['-', 'sin', '(', 'pi', ')'] + self.assertEqual(self.rpn.resolve_unary(token_list1), ['plus', '13']) + self.assertEqual(self.rpn.resolve_unary(token_list2), ['minus', 'sin', '(', 'pi', ')']) + + def test_resolve_double_const(self): + self.assertEqual(self.rpn.resolve_double_const('epi + pitau'), 'e pi + pi tau') + + def test_create_tokens_list(self): + result1 = ['3', '+', '2', '-', 'log', '(', '8', ',', '2', ')'] + self.assertEqual(self.rpn.create_tokens_list('3+2-log(8,2)'), result1) + result2 = ['sin', '(', 'pi', '/', '2', ')'] + self.assertEqual(self.rpn.create_tokens_list('sin(pi/2)'), result2) + + def test_convert_to_rpn(self): + expression = '-sin(pi/2)' + self.assertEqual(self.rpn.convert_to_rpn(expression), ['pi', '2', '/', 'sin', 'minus']) + expression = 'sen(pi/2)' + with self.assertRaises(pycalc.UnknownFunctionError): + self.rpn.convert_to_rpn(expression) + + def test_pop_one(self): + self.rpn.stack = [1, 2, 3, 4] + self.assertEqual(self.rpn.pop_one(), 4) + + def test_pop_two(self): + self.rpn.stack = [1, 2, 3, 4] + self.assertEqual(self.rpn.pop_one(), 4, 3) + + def test_handle_operations(self): + expression1 = '3 + 2 1' + rpn_expression1 = self.rpn.convert_to_rpn(expression1) + with self.assertRaises(pycalc.RedundantParameterError): + self.rpn.handle_operations(rpn_expression1) + + +class TestCheck(TestCase): + def setUp(self): + self.rpn = pycalc.RPN() + self.check = pycalc.Check() + + def test_check_for_numbers(self): + expression1 = 'sin+cos' + expression2 = '3+2' + with self.assertRaises(pycalc.MissingParameterError): + self.check.check_for_numbers(expression1) + self.check.check_for_numbers(expression2) + + def test_check_parentheses(self): + expression1 = '8*(3+2))' + expression2 = '(8*(3+2))' + expression3 = '(8*(3+2)' + with self.assertRaises(pycalc.UnbalancedParenthesesError): + self.check.check_parentheses(expression1) + self.check.check_parentheses(expression2) + with self.assertRaises(pycalc.UnbalancedParenthesesError): + self.check.check_parentheses(expression3) + + def test_check_for_symbols(self): + expression1 = '8#(3+2))' + expression2 = '8+(3~2))' + expression3 = '3 + 2' + with self.assertRaises(pycalc.UnknownSymbolError): + self.check.check_for_symbols(expression1) + with self.assertRaises(pycalc.UnknownSymbolError): + self.check.check_for_symbols(expression2) + self.check.check_for_symbols(expression3) + + def test_check_spaces(self): + expression1 = '1 2' + expression2 = '8 > = 7' + expression3 = '11 + sin(13)' + expression4 = '5 / / 88' + expression5 = '(88) .3' + with self.assertRaises(pycalc.UnexpectedSpaceError): + self.check.check_spaces(expression1) + with self.assertRaises(pycalc.UnexpectedSpaceError): + self.check.check_spaces(expression2) + self.check.check_spaces(expression3) + with self.assertRaises(pycalc.UnexpectedSpaceError): + self.check.check_spaces(expression4) + with self.assertRaises(pycalc.UnexpectedSpaceError): + self.check.check_spaces(expression5) diff --git a/final_task/setup.py b/final_task/setup.py index e69de29..5447a9a 100644 --- a/final_task/setup.py +++ b/final_task/setup.py @@ -0,0 +1,15 @@ +from setuptools import setup, find_packages + +setup( + name='pycalc', + version='1.0.1', + author='Pavel Kuzmich', + author_email='pavelkuz99@outlook.com', + description='Pure Python command-line calculator', + packages=find_packages(), + entry_points={ + 'console_scripts': [ + 'pycalc=calculator.pycalc:main', + ] + } +)