diff --git a/final_task/README.md b/final_task/README.md index d05c634..0de8450 100644 --- a/final_task/README.md +++ b/final_task/README.md @@ -27,14 +27,14 @@ error explanation **with "ERROR: " prefix** and exit with non-zero exit code: ```shell $ pycalc '15(25+1' ERROR: brackets are not balanced -$ pycalc 'sin(-Pi/4)**1.5' -ERROR: negative number cannot be raised to a fractional power +$ pycalc 'func' +ERROR: unknown function 'func' ``` ### Mathematical operations calculator must support * Arithmetic (`+`, `-`, `*`, `/`, `//`, `%`, `^`) (`^` is a power). * Comparison (`<`, `<=`, `==`, `!=`, `>=`, `>`). -* 3 built-in python functions: `abs`, `pow`, `round`. +* 2 built-in python functions: `abs` and `round`. * All functions and constants from standard python module `math` (trigonometry, logarithms, etc.). @@ -89,9 +89,9 @@ These requirements are not mandatory for implementation, but you can get more po ```shell $ pycalc 'sin(pi/2)' 1.0 - $ pycalc -m my_module 'sin(pi/2)' + $ pycalc 'sin(pi/2)' -m my_module 42 - $ pycalc -m time 'time()/3600/24/365' + $ pycalc 'time()/3600/24/365' -m time 48.80147332327218 ``` diff --git a/final_task/pycalc/UnitTests.py b/final_task/pycalc/UnitTests.py new file mode 100644 index 0000000..24de0e0 --- /dev/null +++ b/final_task/pycalc/UnitTests.py @@ -0,0 +1,266 @@ +"""This module contains unit tests for methods and functions from all pycalc modules""" + +# import +import unittest +from .tokenizer import Tokenizer +from .addmultsigns import Multsignsadder +from .rpn import RPN +from .constsreplacer import Constsreplacer +from .rpncalculator import RPNcalculator +from .utils import is_number +from .pycalclib import Pycalclib + +# create pycalclib +pycalclib = Pycalclib(user_module='test_user_module') + + +# Tests of 'Tokenizer' class from 'tokenizer' module +class TokenizerTestCase(unittest.TestCase): + """Tests for Tokenizer class""" + + def test_extract_operators_and_pos_int_numbers(self): + """Are operators and positive int numbers extracted properly?""" + user_expr = '1+2-3*4/5^6**7//8%9' + tokenizer = Tokenizer(user_expr, pycalclib) + tokens, error_msg = tokenizer.extract_tokens() + self.assertEqual(tokens, ['1', '+', '2', '-', '3', '*', '4', '/', + '5', '^', '6', '**', '7', '//', '8', '%', '9']) + self.assertEqual(error_msg, None) + + def test_extract_operators_and_neg_int_numbers(self): + """Are operators and negative int numbers extracted properly?""" + user_expr = '-1+-2--3*-4/-5^-6**-7//-8%-9' + tokenizer = Tokenizer(user_expr, pycalclib) + tokens, error_msg = tokenizer.extract_tokens() + self.assertEqual(tokens, ['-1.0', '-', '2', '+', '3', '*', '-4', '/', '-5', + '^', '-6', '**', '-7', '//', '-8', '%', '-9']) + self.assertEqual(error_msg, None) + + def test_extract_pos_float_numbers(self): + """Are positive float numbers extracted properly?""" + user_expr = '0.1+1.55-112.12' + tokenizer = Tokenizer(user_expr, pycalclib) + tokens, error_msg = tokenizer.extract_tokens() + self.assertEqual(tokens, ['0.1', '+', '1.55', '-', '112.12']) + self.assertEqual(error_msg, None) + + def test_extract_neg_float_numbers(self): + """Are negative float numbers extracted properly?""" + user_expr = '-0.1+-1.55--112.12' + tokenizer = Tokenizer(user_expr, pycalclib) + tokens, error_msg = tokenizer.extract_tokens() + self.assertEqual(tokens, ['-0.1', '-', '1.55', '+', '112.12']) + self.assertEqual(error_msg, None) + + def test_extract_comparison_operators(self): + """Are comparison operators extracted properly?""" + user_expr = '><>=<=!===' + tokenizer = Tokenizer(user_expr, pycalclib) + tokens, error_msg = tokenizer.extract_tokens() + self.assertEqual(tokens, ['>', '<', '>=', '<=', '!=', '==']) + self.assertEqual(error_msg, None) + + def test_extract_pos_constants(self): + """Are positive constants extracted properly?""" + user_expr = 'e+pi-tau/inf*nan' + tokenizer = Tokenizer(user_expr, pycalclib) + tokens, error_msg = tokenizer.extract_tokens() + self.assertEqual(tokens, ['e', '+', 'pi', '-', 'tau', '/', 'inf', '*', 'nan']) + self.assertEqual(error_msg, None) + + def test_extract_neg_constants(self): + """Are negative constants extracted properly?""" + user_expr = '-e+-pi--tau/-inf*-nan' + tokenizer = Tokenizer(user_expr, pycalclib) + tokens, error_msg = tokenizer.extract_tokens() + self.assertEqual(tokens, ['-e', '-', 'pi', '+', 'tau', '/', '-inf', '*', '-nan']) + self.assertEqual(error_msg, None) + + def test_extract_brackets(self): + """Are brackets extracted properly?""" + user_expr = '()' + tokenizer = Tokenizer(user_expr, pycalclib) + tokens, error_msg = tokenizer.extract_tokens() + self.assertEqual(tokens, ['(', ')']) + self.assertEqual(error_msg, None) + + def test_extract_comma(self): + """Is comma extracted?""" + user_expr = 'pow(2,3)' + tokenizer = Tokenizer(user_expr, pycalclib) + tokens, error_msg = tokenizer.extract_tokens() + self.assertEqual(tokens, ['pow', '(', '2', ',', '3', ')']) + self.assertEqual(error_msg, None) + + def test_extract_functions(self): + """Are functions extracted properly?""" + user_expr = "round(sin(2)-asin(1))-abs(exp(3))" + tokenizer = Tokenizer(user_expr, pycalclib) + tokens, error_msg = tokenizer.extract_tokens() + self.assertEqual(tokens, ['round', '(', 'sin', '(', '2', ')', '-', 'asin', '(', '1', ')', ')', + '-', 'abs', '(', 'exp', '(', '3', ')', ')']) + self.assertEqual(error_msg, None) + + def test_consider_sub_signs_method(self): + """Are several subtraction and addition signs replaced by one integrated sign?""" + user_expr = '-1---2+-3+++4+-2' + tokenizer = Tokenizer(user_expr, pycalclib) + tokens, error_msg = tokenizer.extract_tokens() + self.assertEqual(tokens, ['-1.0', '-', '2', '-', '3', '+', '4', '-', '2']) + self.assertEqual(error_msg, None) + + def test_extract_tokens_error_msg(self): + """Is error_message created?""" + user_expr = "2+shikaka(3)" + tokenizer = Tokenizer(user_expr, pycalclib) + tokens, error_msg = tokenizer.extract_tokens() + self.assertEqual(error_msg, 'ERROR: invalid syntax') + + +# Tests of 'Multsignsadder' class from 'addmultsigns' module +class MultsignsadderTestCase(unittest.TestCase): + """Tests for Multsignsadder class""" + + def test_addmultsigns_add_mult_signs(self): + """Are multiplication signs added to where they implicit were to be in expression?""" + tokens = ['5', 'tau', '-', '4', 'sin', '(', '7', ')', 'sin', '(', '3', ')', '-', '9', '(', '1', '+', '10', ')'] + mult_signs_adder = Multsignsadder(tokens, pycalclib) + extd_tokens = mult_signs_adder.addmultsigns() + self.assertEqual(extd_tokens, ['5', '*', 'tau', '-', '4', '*', 'sin', '(', '7', ')', '*', 'sin', '(', '3', ')', + '-', '9', '*', '(', '1', '+', '10', ')']) + + def test_addmultsigns_dont_add_mult_signs(self): + """Aren't multiplication signs added if it's not needed?""" + tokens = ['2', '+', '3', '*', '5'] + mult_signs_adder = Multsignsadder(tokens, pycalclib) + extd_tokens = mult_signs_adder.addmultsigns() + self.assertEqual(extd_tokens, ['2', '+', '3', '*', '5']) + + def test_consider_neg_funcs_method(self): + """Are negative functions tokens replaced by '-1*function' tokens?""" + tokens = ['2', '*', '-sin', '(', '2', ')'] + mult_signs_adder = Multsignsadder(tokens, pycalclib) + mult_signs_adder.consider_neg_functions(mult_signs_adder.tokens) + self.assertEqual(mult_signs_adder.tokens, ['2', '*', '-1', '*', 'sin', '(', '2', ')']) + + def test_consider_log_args_method(self): + """Is 'e' added as a base for log function if last was entered with one argument?""" + tokens = ['log', '(', '33', ')'] + mult_signs_adder = Multsignsadder(tokens, pycalclib) + mult_signs_adder.consider_log_args(mult_signs_adder.tokens) + self.assertEqual(mult_signs_adder.tokens, ['log', '(', '33', ',', 'e', ')']) + + +# Tests of 'RPN class' from 'rpn' module +class RPNTestCase(unittest.TestCase): + """Tests for RPN class""" + + def test_is_left_associative_method(self): + """Are left associative operators recognized?""" + tokens = ['^', '**', '+', '/'] + rpn = RPN(tokens, pycalclib) + is_left_associative = [] + for token in tokens: + is_left_associative.append(rpn.is_left_associative(token)) + self.assertEqual(is_left_associative, [False, False, True, True]) + + def test_convert2rpn_method(self): + """Does 'convert2rpn' method work correctly?""" + tokens = ['-pi', '*', 'round', '(', '2.23', ')', '//', '5', '*', 'pow', '(', '2', '3', ')'] + rpn = RPN(tokens, pycalclib) + result, error_msg = rpn.convert2rpn() + self.assertEqual(result, ['-pi', '2.23', 'round', '*', '5', '//', '2', '3', 'pow', '*']) + self.assertEqual(error_msg, None) + + def test_convert2rpn_method_error_msg(self): + """Is error_message created?""" + tokens = ['(', '2', '+', '3', ')', ')'] + rpn = RPN(tokens, pycalclib) + result, error_msg = rpn.convert2rpn() + self.assertEqual(error_msg, 'ERROR: brackets are not balanced') + + +# Tests of 'Constsreplacer' class from 'constsreplacer' module +class ConstsreplacerTestCase(unittest.TestCase): + """Tests for Constsreplacer class""" + + def test_replace_constants_method(self): + """Are constants replaced and not constants aren't replaced?""" + tokens = ['e', '-e', 'pi', '-pi', 'tau', '-tau', '2', 'cos', 'inf', '-nan', '+'] + constsreplacer = Constsreplacer(tokens, pycalclib) + replaced_tokens = constsreplacer.replace_constants() + self.assertEqual(replaced_tokens, ['2.718281828459045', '-2.718281828459045', + '3.141592653589793', '-3.141592653589793', + '6.283185307179586', '-6.283185307179586', + '2', 'cos', 'inf', '-nan', '+']) + + +# Tests of 'RPNcalculator' class from 'rpncalculator' module +class RPNcalculatorTestCase(unittest.TestCase): + """Tests for RPNcalculator class""" + + def test_evaluate_method_result(self): + """Does 'evaluate' method actually evaluate RPN math expression and give out correct result?""" + rpn_tokens = ['2', 'sqrt', '3', '/', '3.14', '*', 'tan'] + rpncalculator = RPNcalculator(rpn_tokens, pycalclib) + result, error_msg = rpncalculator.evaluate() + self.assertEqual(result, 11.009005500434151) + self.assertEqual(error_msg, None) + + def test_evaluate_method_error_msg_zero_division(self): + """Is 'division by zero' error message created?""" + rpn_tokens = ['2', '0', '/'] + rpncalculator = RPNcalculator(rpn_tokens, pycalclib) + result, error_msg = rpncalculator.evaluate() + self.assertEqual(error_msg, 'ERROR: float division by zero') + + def test_evaluate_method_error_msg_neg_num_in_fract_pow(self): + """Is 'negative number cannot be raised to a fractional power' error message created?""" + rpn_tokens = [['-2', '0.5', '**'], ['-2', '0.5', '^']] + error_msgs = [] + for rpn_tokens_list in rpn_tokens: + rpncalculator = RPNcalculator(rpn_tokens_list, pycalclib) + error_msgs.append(rpncalculator.evaluate()[1]) + for error_msg in error_msgs: + self.assertEqual(error_msg, 'ERROR: negative number cannot be raised to a fractional power') + + def test_evaluate_method_error_msg_neg_num_sqrt(self): + """Is 'root can't be extracted from a negative number' error message created?""" + rpn_tokens = ['-2', 'sqrt'] + rpncalculator = RPNcalculator(rpn_tokens, pycalclib) + result, error_msg = rpncalculator.evaluate() + self.assertEqual(error_msg, "ERROR: a root can't be extracted from a negative number") + + def test_evaluate_method_error_msg_invalid_syntax(self): + """Is 'invalid syntax' error message created?""" + rpn_tokens = ['2', '+'] + rpncalculator = RPNcalculator(rpn_tokens, pycalclib) + result, error_msg = rpncalculator.evaluate() + self.assertEqual(error_msg, "ERROR: invalid syntax") + + +# Test of 'Pycalclib' class from 'pycalclib' module +class PycalclibTestCase(unittest.TestCase): + """Tests for Pycalclib class""" + + def test_consider_user_module(self): + """Are user functions added to pycalclib?""" + self.assertIn('five', pycalclib.functions) + self.assertIn('squaressum', pycalclib.functions) + + +# Tests of 'is_number' function from 'utils' module +class IsNumberTestCase(unittest.TestCase): + """Test for 'is_number' function""" + + def test_is_number_function(self): + """Does 'is_number' function distinguish tokens which are numbers from ones which are not?""" + tokens = ['.3', '-0.3', '7', 'tan'] + is_numbers = [] + for token in tokens: + is_numbers.append(is_number(token)) + self.assertEqual(is_numbers, [True, True, True, False]) + + +if __name__ == '__main__': + unittest.main() 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/__main__.py b/final_task/pycalc/__main__.py new file mode 100644 index 0000000..3fbcbfb --- /dev/null +++ b/final_task/pycalc/__main__.py @@ -0,0 +1,71 @@ +#! /usr/bin/python3 + +# import +import argparse +import sys +from .tokenizer import Tokenizer +from .addmultsigns import Multsignsadder +from .rpn import RPN +from .constsreplacer import Constsreplacer +from .rpncalculator import RPNcalculator +from .pycalclib import Pycalclib + + +def create_parser(): + """Creates parser to parse user mathematical expression""" + parser = argparse.ArgumentParser(prog='pycalc', description='pure Python command line calculator', + epilog="""Anton Charnichenka for EPAM: Introduction to Python + and Golang programming, 2018.""") + parser.add_argument('expression', help="""mathematical expression string to evaluate; + implicit multiplication is supported""") + parser.add_argument('--user_module', '-m', default='', help='additional module with user defined functions') + + return parser + + +def main(): + """Calculates user math expression""" + parser = create_parser() + namespace = parser.parse_args(sys.argv[1:]) + user_expr = namespace.expression + user_module = namespace.user_module + + # create pycalclib + pycalclib = Pycalclib(user_module) + + # calculation chain + # tokenize user's expression string + tokenizer = Tokenizer(user_expr, pycalclib) + tokens, error_msg = tokenizer.extract_tokens() + if error_msg: + print(error_msg) + sys.exit(1) + elif not tokens: + print('ERROR: no expression was entered') + sys.exit(1) + # add implicit multiplication signs to the list of extracted tokens + mult_signs_adder = Multsignsadder(tokens, pycalclib) + tokens = mult_signs_adder.addmultsigns() + # transform extracted tokens into RPN + rpn = RPN(tokens, pycalclib) + rpn_tokens, error_msg = rpn.convert2rpn() + if error_msg: + print(error_msg) + sys.exit(1) + # replace constants with their numeric equivalents + constsreplacer = Constsreplacer(rpn_tokens, pycalclib) + rpn_tokens = constsreplacer.replace_constants() + # evaluate user's expression + rpncalculator = RPNcalculator(rpn_tokens, pycalclib) + result, error_msg = rpncalculator.evaluate() + if error_msg: + print(error_msg) + sys.exit(1) + else: + print(result) + sys.exit(0) + + +# main +if __name__ == "__main__": + main() diff --git a/final_task/pycalc/addmultsigns.py b/final_task/pycalc/addmultsigns.py new file mode 100644 index 0000000..c85df14 --- /dev/null +++ b/final_task/pycalc/addmultsigns.py @@ -0,0 +1,71 @@ +"""This module contains a class that allows to take into account implicit multiplication signs""" + +# import +from .utils import is_number +from .pycalclib import Pycalclib + + +class Multsignsadder: + """A model of mult_signs_adder capable of adding implicit multiplications signs in list of tokens""" + + def __init__(self, tokens, pycalclib): + """Initialize mult_signs_adder""" + self.tokens = tokens + self.extended_tokens = [] + self.constants = pycalclib.constants + self.functions = pycalclib.functions + self.negative_functions = pycalclib.negative_functions + + def consider_neg_functions(self, tokens): + """Replaces negative functions tokens with '-1*function' tokens""" + index = 0 + while index != len(tokens)-1: + if tokens[index] in self.negative_functions: + tokens[index] = tokens[index][1:] # remove '-' sign + tokens.insert(index, '-1') + tokens.insert(index+1, '*') + index += 3 + index += 1 + + def consider_log_args(self, tokens): + """Adds 'e' as a base for log function explicitly if last was originally entered with one argument""" + for index in range(len(tokens)): + if tokens[index] == 'log': + for index2 in range(index+1, len(tokens)): + if ((tokens[index2] == ')' and index2 == len(tokens)-1) + or (tokens[index2] == ')' and index2 != len(tokens)-1 and tokens[index2+1] != ',')): + log_args = tokens[index+2:index2] + if ',' not in log_args: + tokens.insert(index2, ',') + tokens.insert(index2+1, 'e') + break + + def addmultsigns(self): + """Adds implicit multiplication signs in list of math tokens to where they are supposed to be""" + for index in range(len(self.tokens)-1): + self.extended_tokens.append(self.tokens[index]) + if ((is_number(self.tokens[index]) or self.tokens[index] in self.constants) + and ((self.tokens[index+1] in self.constants) or (self.tokens[index+1] in self.functions) + or (self.tokens[index+1] == '('))): + self.extended_tokens.append('*') + continue + elif self.tokens[index] == ')' and (self.tokens[index+1] == '(' or self.tokens[index+1] in self.functions): + self.extended_tokens.append('*') + continue + self.extended_tokens.append(self.tokens[-1]) + + self.consider_neg_functions(self.extended_tokens) + self.consider_log_args(self.extended_tokens) + + return self.extended_tokens + + +if __name__ == '__main__': + print("""This module contains class that allows to insert multiplications signs to where they where supposed + to be in a list with math tokens. For example: \n""") + test_tokens = ['-0.1', 'tan', '+', '23', '*', '-sin', '(', '3', ')', '/', '.12', 'e'] + pycalclib = Pycalclib(user_module='') + mult_signs_adder = Multsignsadder(test_tokens, pycalclib) + extended_tokens = mult_signs_adder.addmultsigns() + print('Original tokens: ', test_tokens) + print('Extended tokens: ', extended_tokens) diff --git a/final_task/pycalc/constsreplacer.py b/final_task/pycalc/constsreplacer.py new file mode 100644 index 0000000..c57a640 --- /dev/null +++ b/final_task/pycalc/constsreplacer.py @@ -0,0 +1,31 @@ +"""This module contains a class that allows to replace constants with their numeric equivalents""" + +from .pycalclib import Pycalclib + + +class Constsreplacer: + """A model of constants replacer capable of replacing constants (from math module) by their numeric equivalents""" + + def __init__(self, rpn_tokens, pycalclib): + """Initialize constsreplacer""" + self.rpn_tokens = rpn_tokens + self.constants_numeric_equivalents = pycalclib.constants_numeric_equivalents + + def replace_constants(self): + """Replaces tokens which are math module constants by their numeric equivalent""" + for index in range(len(self.rpn_tokens)): + if self.rpn_tokens[index] in self.constants_numeric_equivalents.keys(): + self.rpn_tokens[index] = str(self.constants_numeric_equivalents[self.rpn_tokens[index]]) + + return self.rpn_tokens + + +if __name__ == '__main__': + print("""This module contains class that allows to replace tokens which are math module constants by their + numeric equivalents. For example: \n""") + test_tokens = ['2', '*', 'nan', '-', '-inf', '+', '-tau', '*', '-pi', '+', 'e'] + print('RPN tokens with constants: ', test_tokens) + pycalclib = Pycalclib(user_module='') + constsreplacer = Constsreplacer(test_tokens, pycalclib) + rpn_tokens = constsreplacer.replace_constants() + print('RPN tokens after replacement of constants: ', rpn_tokens) diff --git a/final_task/pycalc/pycalclib.py b/final_task/pycalc/pycalclib.py new file mode 100644 index 0000000..812ded7 --- /dev/null +++ b/final_task/pycalc/pycalclib.py @@ -0,0 +1,117 @@ +"""This module contains a class that represents pycalclib and stores lists and dictionaries of data +that are used in other pycalc modules""" + +# import +import math +import operator +import inspect +import sys + + +class Pycalclib: + """A model of pycalclib""" + + def __init__(self, user_module): + """Initialize pycalclib""" + self.user_module = user_module + # r_strings that are used to find operators / functions / etc + self.r_one_sign_operators = [r'^\+', r'^-', r'^\*', r'^/', r'^\^', r'^%'] + self.r_two_signs_operators = [r'^//', r'^\*\*'] + self.r_comparison_operators = [r'^<=', r'^>=', r'^<', r'^>', r'^==', r'^!='] + self.r_functions = [r'^acosh', r'^acos', r'^asinh', r'^asin', r'^atan2', r'^atanh', r'^atan', r'^ceil', + r'^copysign', r'^cosh', r'^cos', r'^degrees', r'^erfc', r'^erf', r'^expm1', r'^exp', + r'^fabs', r'^factorial', r'^floor', r'^fmod', r'^gamma', r'^gcd', r'^hypot', r'^isfinite', + r'^isinf', r'^isnan', r'^ldexp', r'^lgamma', r'^log10', r'^log1p', r'^log2', r'^log', + r'^pow', r'^radians', r'^sinh', r'^sin', r'^sqrt', r'^tanh', r'^tan', r'^trunc', + r'^abs', r'^round'] + self.r_negative_functions = [r'^-acosh', r'^-acos', r'^-asinh', r'^-asin', r'^-atan2', r'^-atanh', r'^-atan', + r'^-ceil', r'^-copysign', r'^-cosh', r'^-cos', r'^-degrees', r'^-erfc', r'^-erf', + r'^-expm1', r'^-exp', r'^-fabs', r'^-factorial', r'^-floor', r'^-fmod', + r'^-gamma', r'^-gcd', r'^-hypot', r'^-isfinite', r'^-isinf', r'^-isnan', + r'^-ldexp', r'^-lgamma', r'^-log10', r'^-log1p', r'^-log2', r'^-log', r'^-pow', + r'^-radians', r'^-sinh', r'^-sin', r'-^sqrt', r'^-tanh', r'^-tan', r'^-trunc', + r'^-abs', r'^-round'] + self.r_constants = [r'^e', r'^pi', r'^tau', r'^inf', r'^nan'] + self.r_negative_constants = [r'^\-e', r'^\-pi', r'^\-tau', r'^\-inf', r'^\-nan'] + self.r_int_numbers = [r'^\d+'] + self.r_negative_int_numbers = [r'^\-\d+'] + self.r_float_numbers = [r'^\d+\.\d+|^\.\d+'] + self.r_negative_float_numbers = [r'^\-\d+\.\d+|^\-\.\d+'] + self.r_brackets = [r'^\(', r'^\)'] + self.r_comma = [r'^,'] + self.r_space = [r'^\s'] + # acceptable constants and functions + self.constants = ['e', 'pi', 'tau', 'inf', 'nan'] + self.negative_constants = ['-e', '-pi', '-tau', '-inf', '-nan'] + self.functions = ['acosh', 'acos', 'asinh', 'asin', 'atan2', 'atanh', 'atan', 'ceil', 'copysign', 'cosh', + 'cos', 'degrees', 'erfc', 'erf', 'exp', 'expm1', 'fabs', 'factorial', 'floor', 'fmod', + 'frexp', 'fsum', 'gamma', 'gcd', 'hypot', 'isclose', 'isfinite', 'isinf', 'isnan', 'ldexp', + 'lgamma', 'log10', 'log1p', 'log2', 'log', 'modf', 'pow', 'radians', 'sinh', 'sin', 'sqrt', + 'tanh', 'tan', 'trunc', 'round', 'abs'] + self.negative_functions = ['-acosh', '-acos', '-asinh', '-asin', '-atan2', '-atanh', '-atan', '-ceil', + '-copysign', '-cosh', '-cos', '-degrees', '-erfc', '-erf', '-exp', '-expm1', + '-fabs', '-factorial', '-floor', '-fmod', '-frexp', '-fsum', '-gamma', '-gcd', + '-hypot', '-isclose', '-isfinite', '-isinf', '-isnan', '-ldexp', '-lgamma', + '-log10', '-log1p', '-log2', '-log', '-modf', '-pow', '-radians', '-sinh', + '-sin', '-sqrt', '-tanh', '-tan', '-trunc', '-round', '-abs'] + # acceptable operators + self.operators = ['+', '-', '*', '/', '//', '%', '^', '**'] + # acceptable comparison operators + self.comparison_operators = ['<=', '>=', '<', '>', '==', '!='] + # operators precedence + self.precedence = {'(': 0, ')': 0, '<': 0, '>': 0, '<=': 0, '>=': 0, '==': 0, '!=': 0, '+': 1, '-': 1, + '*': 2, '/': 2, '//': 2, '%': 2, '^': 3, '**': 3} + # numeric equivalents of constants + self.constants_numeric_equivalents = {'e': math.e, '-e': -math.e, 'pi': math.pi, '-pi': -math.pi, + 'tau': math.tau, '-tau': -math.tau} + # operators actions + self.operators_dict = {'+': operator.add, '-': operator.sub, '*': operator.mul, '/': operator.truediv, + '^': operator.pow, '**': operator.pow, '//': operator.floordiv, '%': operator.mod, + '<': operator.lt, '>': operator.gt, '<=': operator.le, '>=': operator.ge, + '==': operator.eq, '!=': operator.ne} + # functions actions + # first element in a tuple associated with each key is a number of parameters for corresponding function + self.functions_dict = {'acos': (1, math.acos), 'acosh': (1, math.acosh), 'asin': (1, math.asin), + 'asinh': (1, math.asinh), 'atan': (1, math.atan), 'atan2': (2, math.atan2), + 'atanh': (1, math.atanh), 'ceil': (1, math.ceil), 'copysign': (2, math.copysign), + 'cos': (1, math.cos), 'cosh': (1, math.cosh), 'degrees': (1, math.degrees), + 'erf': (1, math.erf), 'erfc': (1, math.erfc), 'exp': (1, math.exp), + 'expm1': (1, math.expm1), 'fabs': (1, math.fabs), 'factorial': (1, math. factorial), + 'floor': (1, math.floor), 'fmod': (2, math.fmod), 'gamma': (1, math.gamma), + 'gcd': (2, math.gcd), 'hypot': (2, math.hypot), 'isfinite': (1, math.isfinite), + 'isinf': (1, math.isinf), 'isnan': (1, math.isnan), 'ldexp': (2, math.ldexp), + 'lgamma': (1, math.lgamma), 'log': (2, math.log), 'log10': (1, math.log10), + 'log1p': (1, math.log1p), 'log2': (1, math.log2), 'pow': (2, math.pow), + 'radians': (1, math.radians), 'sin': (1, math.sin), 'sinh': (1, math.sinh), + 'sqrt': (1, math.sqrt), 'tan': (1, math.tan), 'tanh': (1, math.tanh), + 'trunc': (1, math.trunc), 'abs': (1, lambda x: abs(x)), + 'round': (1, lambda x: round(x)), '-abs': (1, lambda x: -abs(x))} + + if self.user_module != '': + self.consider_user_module() + + self.r_strings = (self.r_brackets + self.r_two_signs_operators + self.r_one_sign_operators + + self.r_negative_functions + self.r_functions + self.r_comparison_operators + + self.r_negative_float_numbers + self.r_negative_int_numbers + self.r_negative_constants + + self.r_float_numbers + self.r_int_numbers + self.r_constants + self.r_space + self.r_comma) + + def consider_user_module(self): + """Adds user functions into pycalclib""" + try: + user_module = __import__(self.user_module) + except ModuleNotFoundError: + print("ERROR: '{}' module hasn't been found".format(self.user_module)) + sys.exit(0) + user_functions_info = inspect.getmembers(user_module, inspect.isfunction) + # dictionary of user functions: key - func name string, value: (func_obj, func_num_params) + user_functions = {} + for item in user_functions_info: + user_functions[item[0]] = (item[1], len(inspect.getfullargspec(item[1]).args)) + # add or replace functions in self.functions + for func in user_functions.keys(): + if func not in self.functions: + self.functions.insert(0, func) + self.negative_functions.insert(0, ('-{}'.format(func))) + self.r_functions.insert(0, (r'^{}'.format(func))) + self.r_negative_functions.insert(0, (r'^-{}'.format(func))) + self.functions_dict[func] = (user_functions[func][1], user_functions[func][0]) diff --git a/final_task/pycalc/rpn.py b/final_task/pycalc/rpn.py new file mode 100644 index 0000000..2025a48 --- /dev/null +++ b/final_task/pycalc/rpn.py @@ -0,0 +1,110 @@ +"""This module contains a class that allows to transform infix notation math tokens into RPN""" + +# import +from .utils import is_number +from .pycalclib import Pycalclib + + +class RPN: + """A model of rpn capable of converting infix to postfix (RPN) notation""" + + def __init__(self, tokens, pycalclib): + """Initialize rpn""" + self.tokens = tokens + self.operators_stack = [] + self.output_queue = [] + self.error_msg = None + self.constants = pycalclib.constants + self.negative_constants = pycalclib.negative_constants + self.operators = pycalclib.operators + self.comparison_operators = pycalclib.comparison_operators + self.precedence = pycalclib.precedence + self.functions = pycalclib.functions + self.negative_functions = pycalclib.negative_functions + + @staticmethod + def is_left_associative(operator): + """Determines whether operator is left associative""" + if operator in ['^', '**']: + return False + else: + return True + + def convert2rpn(self): + """Converts list of tokens in infix notation into RPN""" + counter = 0 + while counter != (len(self.tokens)): + current_token = self.tokens[counter] + if is_number(current_token) or (current_token in (self.constants + self.negative_constants)): + self.output_queue.append(current_token) + counter += 1 + elif current_token in self.functions or current_token in self.negative_functions: + self.operators_stack.append(current_token) + counter += 1 + elif current_token in self.operators or current_token in self.comparison_operators: + if len(self.operators_stack) == 0: + self.operators_stack.append(current_token) + counter += 1 + else: + while (len(self.operators_stack) != 0 + and ((self.operators_stack[-1] in self.functions + or self.operators_stack[-1] in self.negative_functions) + or (self.precedence[self.operators_stack[-1]] > self.precedence[current_token]) + or (self.precedence[self.operators_stack[-1]] == self.precedence[current_token] + and self.is_left_associative(self.operators_stack[-1]))) + and (self.operators_stack[-1] != "(")): + self.output_queue.append(self.operators_stack.pop()) + self.operators_stack.append(current_token) + counter += 1 + elif current_token == '(': + self.operators_stack.append(current_token) + counter += 1 + elif current_token in [')', ',']: + if len(self.operators_stack) == 0 and len(self.output_queue) == 0: + if current_token == ')': + self.error_msg = "ERROR: brackets are not balanced" + elif current_token == ',': + self.error_msg = "ERROR: incorrect usage of ','" + break + elif len(self.operators_stack) == 0 and len(self.output_queue) != 0: + if current_token == ')': + self.error_msg = "ERROR: brackets are not balanced" + elif current_token == ',': + self.error_msg = "ERROR: incorrect usage of ','" + break + else: + while len(self.operators_stack) != 0: + if self.operators_stack[-1] != '(': + self.output_queue.append(self.operators_stack.pop()) + else: + if current_token == ')': + self.operators_stack.pop() # it should be '(' + counter += 1 + break + else: + self.error_msg = "ERROR: brackets are not balanced" + break + if not self.error_msg: + # if there are tokens left in operators_stack consistently add them to output_queue + while self.operators_stack: + remaining_operator = self.operators_stack.pop() + if remaining_operator not in ['(', ')']: + self.output_queue.append(remaining_operator) + else: + self.error_msg = 'ERROR: brackets are not balanced' + + return self.output_queue, self.error_msg + + +if __name__ == '__main__': + print("""This module contains class that allows to transform a list of math tokens in infix notation into list of + tokens in RPN. For example: \n""") + test_tokens = ['-pi', '*', 'round', '(', '2.23', ')', '//', '5', '*', 'pow', '(', '2', '3', ')'] + print("Infix_tokens: ", test_tokens) + pycalclib = Pycalclib(user_module='') + rpn = RPN(test_tokens, pycalclib) + rpn_tokens, error_msg = rpn.convert2rpn() + if not error_msg: + print('RPN tokens: ', rpn_tokens) + else: + print(error_msg) diff --git a/final_task/pycalc/rpncalculator.py b/final_task/pycalc/rpncalculator.py new file mode 100644 index 0000000..4adc045 --- /dev/null +++ b/final_task/pycalc/rpncalculator.py @@ -0,0 +1,85 @@ +"""This module contains a class that allows to evaluate math expression in RPN""" + +# import +from .pycalclib import Pycalclib + + +class RPNcalculator: + """A model of RPN math expression evaluator""" + + def __init__(self, rpn_tokens, pycalclib): + """Initialize RPNcalculator object""" + + self.rpn_tokens = rpn_tokens + self.error_msg = None + self.operators_dict = pycalclib.operators_dict + self.functions_dict = pycalclib.functions_dict + self.stack = [] + + def evaluate(self): + """Evaluates math expression given in a form of RPN tokens""" + for token in self.rpn_tokens: + if token in self.operators_dict.keys(): + try: + op2, op1 = self.stack.pop(), self.stack.pop() + except IndexError: + self.error_msg = 'ERROR: invalid syntax' + break + if token in ['^', '**'] and (float(op1) < 0 and (not float(op2).is_integer())): # check pow operation + self.error_msg = 'ERROR: negative number cannot be raised to a fractional power' + break + try: + self.stack.append(self.operators_dict[token](op1, op2)) + except Exception as e: + self.error_msg = 'ERROR: {}'.format(e) + break + elif token in self.functions_dict.keys(): + func_params_num = self.functions_dict[token][0] + func_args = [] + for param_index in range(func_params_num): + try: + arg = self.stack.pop() + except IndexError: + self.error_msg = 'ERROR: invalid syntax' + break + if token == 'sqrt' and float(arg) < 0: # check sqrt function + self.error_msg = "ERROR: a root can't be extracted from a negative number" + break + if token == 'pow' and param_index == 1 and float(arg) < 0 and not func_args[-1].is_integer(): + self.error_msg = 'ERROR: negative number cannot be raised to a fractional power' + break + func_args.insert(0, arg) + if not self.error_msg: + try: + self.stack.append(self.functions_dict[token][1](*func_args)) + except Exception: + self.error_msg = 'ERROR: incorrect use of {} function'.format(token) + break + else: + break + else: + self.stack.append(float(token)) + + # final check of result and error message: + if not self.error_msg and len(self.stack) == 1: + result = self.stack.pop() + elif not self.error_msg and len(self.stack) > 1: + result = None + self.error_msg = 'ERROR: invalid syntax' + else: + result = None + + return result, self.error_msg + + +if __name__ == "__main__": + print("This module contains class that allows to evaluate math expression in form of RPN tokens. For example: \n") + test_rpn = ['2', '3', '4', '*', '-', '2', '5', '/', '-'] + print('RPN tokens math expression: ', test_rpn) + pycalclib = Pycalclib(user_module='') + rpncalculator = RPNcalculator(test_rpn, pycalclib) + result, error_msg = rpncalculator.evaluate() + if not error_msg: + print('Result: ', result) + else: + print(error_msg) diff --git a/final_task/pycalc/test_user_module.py b/final_task/pycalc/test_user_module.py new file mode 100644 index 0000000..5fba791 --- /dev/null +++ b/final_task/pycalc/test_user_module.py @@ -0,0 +1,11 @@ +"""This module contains test user functions""" + + +def five(param1): + """Returns 5""" + return 5 + + +def squaressum(param1, param2): + """Calculates sum of squares""" + return param1**2 + param2**2 diff --git a/final_task/pycalc/tokenizer.py b/final_task/pycalc/tokenizer.py new file mode 100644 index 0000000..ebdf258 --- /dev/null +++ b/final_task/pycalc/tokenizer.py @@ -0,0 +1,92 @@ +"""This module contains a class that allows to find and extract tokens from the user mathematical expression""" + +# import +import re +from .utils import is_number +from .pycalclib import Pycalclib + + +class Tokenizer: + """A model of tokenizer capable of finding and extracting tokens from string math expression""" + def __init__(self, user_expr, pycalclib): + """Initialize tokenizer""" + self.user_expr = user_expr + self.r_strings = pycalclib.r_strings + self.operators = pycalclib.operators + self.constants = pycalclib.constants + self.error_msg = None + self.tokens = [] + + def consider_sub_signs(self, tokens): + """Considers and replaces several subtraction and addition signs when they follow each other""" + index = 0 + while True: + if tokens[index] == '-' and (tokens[index+1] == '-' or tokens[index+1] == '+'): + tokens.pop(index), tokens.pop(index) + tokens.insert(index, '+') + elif tokens[index] == '+' and tokens[index+1] == '-': + tokens.pop(index), tokens.pop(index) + tokens.insert(index, '-') + elif tokens[index] == '+' and tokens[index+1] == '+': + tokens.pop(index), tokens.pop(index) + tokens.insert(index, '+') + else: + index += 1 + if index < len(tokens)-2: + continue + else: + break + + def check_first_tokens(self, tokens): + """Checks whether first two tokens are a negative number (negative constant) + and replaces them with negative number if so""" + if tokens[0] == '-' and (is_number(tokens[1]) or tokens[1] in self.constants): + if is_number(tokens[1]): + first_neg_token = str(float(tokens[1])*-1) + else: + first_neg_token = '-{}'.format(tokens[1]) + tokens.pop(0), tokens.pop(0) + tokens.insert(0, first_neg_token) + + def extract_tokens(self): + """Extracts tokens from string math expression""" + got_token = False # flag that switches to True every time some token has been found + + while len(self.user_expr) != 0: + for r_string in self.r_strings: + search_result = re.search(r_string, self.user_expr) + if search_result is not None: + if (search_result.group(0) == '-' and len(self.tokens) != 0 + and self.tokens[-1] in (['('] + self.operators[2:])): + continue + self.user_expr = self.user_expr[search_result.end():] + if search_result.group(0) != ' ': + self.tokens.append(search_result.group(0)) + got_token = True + break + else: + continue + if got_token is False: # is True when an unknown sign/signs has been found + self.error_msg = "ERROR: invalid syntax" + break + got_token = False # if one of acceptable sign/signs has been found, switch flag back to False for + # the next entrance to the 'for' loop + + if len(self.tokens) >= 2: + self.consider_sub_signs(self.tokens) + self.check_first_tokens(self.tokens) + + return self.tokens, self.error_msg + + +if __name__ == '__main__': + print("This module contains class that allows to extract tokens from math strings. For example: \n") + test_string = '1---1*-5-sin(-3)' + print("Math string: ", test_string) + pycalclib = Pycalclib(user_module='') + tokenizer = Tokenizer(test_string, pycalclib) + tokens, error_msg = tokenizer.extract_tokens() + if not error_msg: + print('Extracted tokes: ', tokens) + else: + print(error_msg) diff --git a/final_task/pycalc/utils.py b/final_task/pycalc/utils.py new file mode 100644 index 0000000..30bb743 --- /dev/null +++ b/final_task/pycalc/utils.py @@ -0,0 +1,16 @@ +"""This module contains 'is_number' function that is used in a few other pycalc modules""" + + +def is_number(token): + """Determines whether token is a number""" + try: + float(token) + return True + except ValueError: + return False + + +if __name__ == "__main__": + print("This module contains 'is_number' function that is used in a few other pycalc modules. For example: \n") + print("For '3' token 'is_number' returns -", is_number('3')) + print("For '+' token 'is_number' returns -", is_number('+')) diff --git a/final_task/setup.py b/final_task/setup.py index e69de29..b850946 100644 --- a/final_task/setup.py +++ b/final_task/setup.py @@ -0,0 +1,10 @@ +import setuptools + +setuptools.setup(name='pycalc', + version='1.0.0', + author='Anton Charnichenka', + author_email='antt0n.chern1chenk0@gmail.com', + description='pure Python command line calculator', + packages=setuptools.find_packages(), + entry_points={'console_scripts': ['pycalc = pycalc.__main__:main']}, + classifiers=["Programming Language :: Python :: 3.6", "Operating System :: Linux Mint"]) diff --git a/pycalc_checker.py b/pycalc_checker.py index f1525cd..4009851 100644 --- a/pycalc_checker.py +++ b/pycalc_checker.py @@ -33,6 +33,9 @@ "log10(100)": log10(100), "sin(pi/2)*111*6": sin(pi/2)*111*6, "2*sin(pi/2)": 2*sin(pi/2), + "pow(2, 3)": pow(2, 3), + "abs(-5)": abs(-5), + "round(123.4567890)": round(123.4567890), } ASSOCIATIVE = {