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/argparser.py b/final_task/pycalc/argparser.py new file mode 100755 index 0000000..500f549 --- /dev/null +++ b/final_task/pycalc/argparser.py @@ -0,0 +1,23 @@ +"""Handles all console input operations.""" + +import argparse + + +class Argparser: + """Provides correct data console input""" + + def __init__(self): + self._parser = argparse.ArgumentParser( + description='Pure-python command-line calculator') + + self._parser.add_argument('-m', '--use-modules', metavar='MODULE', + nargs='+', help="additional modules to use", + dest="modules") + + self._parser.add_argument(metavar='EXPRESSION', type=str, nargs=1, + help="expression string to evaluate", + dest="expression") + + def parse_input(self): + args = self._parser.parse_args() + return args diff --git a/final_task/pycalc/calculator.py b/final_task/pycalc/calculator.py new file mode 100755 index 0000000..4b466ab --- /dev/null +++ b/final_task/pycalc/calculator.py @@ -0,0 +1,335 @@ +"""Calculator core module.""" + +import math +import re + + +class Calculator: + """Handles all operations related to the + conversion and evaluation of expressions.""" + + BINARIES = ( + ('^', lambda a, b: a ** b), # ** + ('//', lambda a, b: a // b), + ('/', lambda a, b: a / b), + ('*', lambda a, b: a * b), + ('%', lambda a, b: a % b), + ('+', lambda a, b: a + b), + ('<=', lambda a, b: a <= b), + ('>=', lambda a, b: a >= b), + ('<', lambda a, b: a < b), + ('>', lambda a, b: a > b), + ('!=', lambda a, b: a != b), + ('==', lambda a, b: a == b) + ) + + BUILTINS = ( + ('abs', lambda a: abs(a)), + ('round', lambda a: round(a)) + ) + + IMPLICIT_MUL_PATTERNS = ( + r'[0-9][A-Za-z][^\-+]', r'[\)\]][0-9]', r'[0-9][\[\(]', r'\)\(|\]\[') + + def __init__(self, validator, modules=None): + self._validator = validator + self._modules = [math] + self.import_modules(modules) + self._constants = self.get_reserved(_callable=False) + self._functions = self.get_reserved(_callable=True) + + def calc_start(self, expression): + """Entry point of calculating. Prepares the expression for + calculating, calculates it and returns the result.""" + + self._validator.validate(expression) + + expression = self.transform(expression) + expression = self.handle_subtraction(expression) + expression = self.replace_constants(expression) + expression = self.calculate_functions(expression) + + result = self.calculate(expression) + return self.convert(result) + + def import_modules(self, modules): + if modules is not None: + for m in modules: + new_module = __import__(m) + self._modules.insert(0, new_module) + + @staticmethod + def transform(expression): + """Removes and replaces extra and ambiguous symbols.""" + + expression = expression.lower() + expression = expression.strip("'") + expression = expression.strip('"') + expression = expression.replace(' ', '') + expression = expression.replace('**', '^') + + return expression + + def find_left_num(self, expression, sign_pos): + """Returns a number that lays left from the sign_pos.""" + + # Any number (including exponential ones), near the end of the string + pattern = r'([0-9\[\]\.\-]|(e\+)|(e\-))+$' + num = re.search(pattern, expression[:sign_pos]) + if num is None: + self._validator.assert_error("please, check your expression.") + return num.group(0) + + def find_right_num(self, expression, sign_pos): + """Returns a number that lays right from the sign_pos.""" + + # Any number (including exponential ones) near the start of the string + pattern = r'^([0-9\[\]\.\-]|(e\+)|(e\-))+' + num = re.search(pattern, expression[sign_pos + 1:]) + if num is None: + self._validator.assert_error("please, check your expression.") + return num.group(0) + + def calculate(self, expression): + """Calculates the expression step-by-step + according to the sign priority.""" + + expression = self.calculate_nested(expression) + expression = self.handle_implicit_multiplication(expression) + expression = self.handle_extra_signs(expression) + + for sign, func in self.BINARIES: + while True: + sign_pos = self.find_sign_pos(expression, sign) + if sign_pos == -1: + break + + left = self.find_left_num(expression, sign_pos) + if sign == '^' and left.find(']') == -1: + left = left.replace('-', '') + + slen = len(sign) - 1 + right = self.find_right_num(expression, sign_pos + slen) + + result = self.calculate_elementary( + expression[sign_pos:sign_pos + slen + 1], left, right) + expression = expression.replace( + left + sign + right, str(result), 1) + + return expression.strip('[]') + + def find_sign_pos(self, expression, sign): + """Returns the position of given sign (-1 if not found).""" + + if sign == '^': + sign_pos = expression.rfind(sign) + elif sign == '+': + sign_pos = expression.find(sign) + while sign_pos != -1 and expression[sign_pos - 1] == 'e': + sign_pos = expression[sign_pos + 2:].find(sign) + else: + sign_pos = expression.find(sign) + + return sign_pos + + def calculate_functions(self, expression): + """Substitutes functions with the result of their calculation.""" + + # List reversion here makes it possible to calculate nested functions + pattern = r'[A-Za-z_]+[A-Za-z0-9_]*' # Any word, may end with a digit + func_name_list = re.findall(pattern, expression)[::-1] + + for func_name in func_name_list: + if func_name in ('False', 'True'): + continue + + func = self.get_reserved_by_name(func_name) + fpos = expression.rfind(func_name) + args, arg_end = self.get_func_args(expression, func_name, fpos) + + result = '' + try: + if args is not None: + converted_args = self.convert_arguments(args) + result = func(*converted_args) + else: + result = func() + except TypeError: + self._validator.assert_error( + "please, check function " + func_name + ".") + + expression = expression.replace( + expression[fpos:arg_end], '[' + str(result) + ']', 1 + ) + + return expression + + def get_func_args(self, expression, func_name, func_pos): + arg_start = func_pos + len(func_name) + arg_end = arg_start + expression[arg_start:].find(')') + arguments = expression[arg_start:arg_end] + + while arguments.count('(') != arguments.count(')'): + arg_end += 1 + expression[arg_end:].find(')') + arguments = expression[arg_start:arg_end] + + argument_list = arguments[1:-1].split(',') + if '' in argument_list: + argument_list = None + + return argument_list, arg_end + + def get_reserved(self, _callable=False): + """Returns a list of all functions and + constants found in imported modules.""" + + result = [] + for m in self._modules: + for d in dir(m): + obj = getattr(m, d) + if callable(obj) is _callable and not d.startswith('_'): + result.append(d) + + if _callable: + for func_name, _ in self.BUILTINS: + result.append(func_name) + + return result + + def replace_constants(self, expression): + const_pattern = '|'.join(self._constants) + func_pattern = r'\(|'.join(self._functions) + r'\(' + pattern = r'[A-Za-z_]+[A-Za-z0-9_]*[\(]*' + + cases = re.finditer(pattern, expression) + for case in cases: + c_str = case.group() + c_pos = case.start() + + # Upper is used to prevent replacing parts of functions + replaced = c_str + funcs = re.findall(func_pattern, replaced) + for f in funcs: + replaced = replaced.replace(f, f.upper()) + + constants = re.findall(const_pattern, c_str) + for const in constants: + obj = self.get_reserved_by_name(const) + replaced = replaced.replace(const, '(' + str(obj) + ')') + + expression = expression[:c_pos] \ + + expression[c_pos:].replace(c_str, replaced, 1) + + return expression.lower() + + def get_reserved_by_name(self, requested_name): + """Returns function (or constant) object, exits if not found.""" + + for m in self._modules: + if hasattr(m, requested_name): + obj = getattr(m, requested_name) + return obj + + for name, obj in self.BUILTINS: + if name == requested_name: + return obj + + self._validator.assert_error( + "no such reserved name " + requested_name + ".") + + @staticmethod + def convert(a): + if not isinstance(a, str): + return a + + if a in ('True', 'False'): + a = True if a == 'True' else False + return a + + try: + a = int(a) + except ValueError: + a = float(a) + + return a + + def convert_arguments(self, args): + converted_args = [] + for a in args: + result_a = self.calc_start(a) + converted_args.append(self.convert(result_a)) + + return converted_args + + def handle_subtraction(self, expression): + """Modifies subtractions in given expression + to make them calculator friendly.""" + + pattern = r'[0-9\]]\-' # Any digit followed my minus + cases = re.findall(pattern, expression) + for c in cases: + expression = expression.replace(c, c[0] + '+' + c[1]) + + return expression + + def handle_implicit_multiplication(self, expression): + for p in self.IMPLICIT_MUL_PATTERNS: + cases = re.findall(p, expression) + for c in cases: + expression = expression.replace(c, c[0] + '*' + c[1]) + + return expression + + def handle_extra_signs(self, expression): + """Gets rid of extra pluses and minuses in given expression.""" + + pattern = r'[-+]{2,}' # Two or more pluses and minuses + cases = re.findall(pattern, expression) + for c in cases: + if c.count('-') % 2 == 0: + expression = expression.replace(c, '+', 1) + else: + expression = expression.replace(c, '-', 1) + + expression = self.handle_subtraction(expression) + return expression + + def calculate_elementary(self, operation, *args): + result = None + + args = list(re.sub(r'[\[\]]', '', a) for a in args) # Get rid of [ ] + args = list(self.handle_extra_signs(a) for a in args) + + converted_args = [] + try: + converted_args = list(self.convert(a) for a in args) + except ValueError: + self._validator.assert_error("please, check your expression.") + + self._validator.check(operation, *converted_args) + for o, func in self.BINARIES: + if o == operation: + result = func(*converted_args) + break + + return result + + def calculate_nested(self, expression): + while True: + nested = self.get_nested(expression) + if nested is None: + break + nested_result = self.calculate(nested[1:-1]) + expression = expression.replace( + nested, '[' + str(nested_result) + ']', 1) + + return expression + + @staticmethod + def get_nested(expression): + """Finds and returns nested expression (with no + nested inside it) if it exists, else returns None.""" + + # From '(' to ')' blocking any parentheses inside + nested = re.search(r'\([^()]*\)', expression) + return nested.group(0) if nested is not None else None diff --git a/final_task/pycalc/custom_module.py b/final_task/pycalc/custom_module.py new file mode 100644 index 0000000..0d1e6e1 --- /dev/null +++ b/final_task/pycalc/custom_module.py @@ -0,0 +1,7 @@ +"""Custom module is created to test custom function support feature.""" + +my_const = 123.456 + + +def cube_volume(arg1): + return arg1*arg1*arg1 diff --git a/final_task/pycalc/pycalc.py b/final_task/pycalc/pycalc.py new file mode 100755 index 0000000..291c656 --- /dev/null +++ b/final_task/pycalc/pycalc.py @@ -0,0 +1,22 @@ +"""Entry point of pycalc project.""" + +from .argparser import Argparser +from .calculator import Calculator +from .validator import Validator + + +def main(): + argparser = Argparser() + validator = Validator() + + args = argparser.parse_input() + expression = args.expression[0] + modules = args.modules + calc = Calculator(validator, modules) + result = calc.calc_start(expression) + + print(result) + + +if __name__ == "__main__": + main() diff --git a/final_task/pycalc/tester.py b/final_task/pycalc/tester.py new file mode 100755 index 0000000..bffafde --- /dev/null +++ b/final_task/pycalc/tester.py @@ -0,0 +1,138 @@ +"""Unittests for calculator.""" + +import math +import unittest +from .validator import Validator +from .calculator import Calculator + + +class ValidatorTester(unittest.TestCase): + """Tests error cases.""" + + val = Validator() + + def test_validate(self): + with self.assertRaises(SystemExit): + self.val.validate('((') + + with self.assertRaises(SystemExit): + self.val.validate('') + + with self.assertRaises(SystemExit): + self.val.validate('1 2 3 4 5 + 6') + + with self.assertRaises(SystemExit): + self.val.validate('123 > = 5') + + def test_check(self): + with self.assertRaises(SystemExit): + self.val.check('*', None, 1) + + with self.assertRaises(SystemExit): + self.val.check('/', 10, 0) + + with self.assertRaises(SystemExit): + self.val.check('^', -1, 1.5) + + +class CalculatorTester(unittest.TestCase): + val = Validator() + calc = Calculator(val) + + def test_calc_start(self): + tests = ( + ('2', 2), + ('-2', -2), + ('2', 2), + ('-2', -2), + ('2+2', 2+2), + ('2-2', 2-2), + ('2*2', 2*2), + ('2/2', 2/2), + ('15%2', 15 % 2), + ('2**16', 2**16), + ('2*log10(100)*3', 12.0), + ('pow(2,2)sqrt(625)', 100.0), + ('pow(2, 256)', 2 ** 256), + ('eexp(e)', 41.193555674716116), + ('2+2*2', 2+2*2), + ('2/2*2', 2/2*2), + ('2*2/2', 2*2/2), + ('5*(5+5)', 5*(5+5)), + ('5-(5+5)', 5-(5+5)), + ('5(5+5)5', 5*(5+5)*5), + ('2(2+2)(2+2)2', 2*(2+2)*(2+2)*2), + ('2(2((2+2)2))2', 2*(2*((2+2)*2))*2), + ('(10-20)(20+10)log10(100)(sqrt(25)+5)', -6000), + ('32-32*2+50-5**2*2', 32-32*2+50-5**2*2), + ('9*(2+150*(650/5)-190+445/(20-25))+12+90/5-173036/2*2', 1.0) + ) + + for test, result in tests: + self.assertEqual(self.calc.calc_start(test), result) + + def test_find_left_num(self): + result = self.calc.find_left_num('-125.125+10', 8) + self.assertEqual(result, '-125.125') + + def test_find_right_num(self): + result = self.calc.find_right_num('-125.125+10', 8) + self.assertEqual(result, '10') + + def test_find_sign_pos(self): + result = self.calc.find_sign_pos('12345678^87654321', '^') + self.assertEqual(result, 8) + + def test_calculate_functions(self): + result = self.calc.calculate_functions('log10(100)*2') + self.assertEqual(result, '[2.0]*2') + + def test_get_func_args(self): + result = self.calc.get_func_args( + '2*2*2*any_func(5,(6+7),((8+9)+10))', 'any_func', 6 + ) + self.assertEqual(result, (['5', '(6+7)', '((8+9)+10)'], 34)) + + def test_replace_constants(self): + result = self.calc.replace_constants('2pietau') + self.assertEqual( + result, + '2(3.141592653589793)(2.718281828459045)(6.283185307179586)' + ) + + def test_get_reserved_by_name(self): + result = self.calc.get_reserved_by_name('log') + self.assertEqual(result, math.log) + + def test_convert(self): + self.assertEqual(self.calc.convert('2'), 2) + self.assertEqual(self.calc.convert('.1'), 0.1) + self.assertEqual(self.calc.convert('True'), True) + + def test_convert_arguments(self): + result = self.calc.convert_arguments(['2', 'log10(100)', '(2+2)']) + self.assertEqual(result, [2, 2.0, 4]) + + def test_handle_subtraction(self): + result = self.calc.handle_subtraction('2-1000*2(-100-50)') + self.assertEqual(result, '2+-1000*2(-100+-50)') + + def test_handle_implicit_multiplication(self): + result = self.calc.handle_implicit_multiplication('2(10)(10)5') + self.assertEqual(result, '2*(10)*(10)*5') + + def test_handle_extra_signs(self): + result = self.calc.handle_extra_signs('1----1+++++++1-+-+-+-+---+2') + self.assertEqual(result, '1+1+1+-2') + + def test_calculate_elementary(self): + result = self.calc.calculate_elementary('*', '1', '2') + self.assertEqual(result, 2) + + def test_calculate_nested(self): + result = self.calc.calculate_nested('((2*(((10+10)))))') + self.assertEqual(result, '[40]') + + def test_get_nested(self): + result = self.calc.get_nested('(10*10(10*10)(10*10(20*20)))') + self.assertEqual(result, '(10*10)') diff --git a/final_task/pycalc/validator.py b/final_task/pycalc/validator.py new file mode 100755 index 0000000..26ce71f --- /dev/null +++ b/final_task/pycalc/validator.py @@ -0,0 +1,44 @@ +""" + Validator checks for possible error + before and during the calculation. +""" + +import re + + +class Validator: + def validate(self, expression): + """Fully validates given expression for user error""" + + if len(expression) == 0: + self.assert_error("cannot calculate empty expression.") + + if expression.count('(') != expression.count(')'): + self.assert_error("brackets are not balanced.") + + pattern = r'[0-9\.]+\s+[0-9\.]+' + if re.search(pattern, expression) is not None: + self.assert_error("ambiguous spaces between numbers.") + + pattern = r'[<>=*\/]\s+[<>=*\/]' + if re.search(pattern, expression) is not None: + self.assert_error("ambiguous spaces between signs.") + + def check(self, sign, left, right): + """Rapidly checks mathematical errors.""" + + if left is None or right is None: + self.assert_error("please, check your expression.") + + if sign in ('/', '//', '%') and right == 0: + self.assert_error("got a zero division error.") + + if sign == '^' and left < 0 and isinstance(right, float): + self.assert_error( + "negative number cannot be raised to fractional power." + ) + + @staticmethod + def assert_error(error_text, exitcode=1): + print("ERROR: " + error_text) + exit(exitcode) diff --git a/final_task/setup.py b/final_task/setup.py index e69de29..eb0f8eb 100644 --- a/final_task/setup.py +++ b/final_task/setup.py @@ -0,0 +1,13 @@ +from setuptools import setup, find_packages + +setup( + name="Pycalc project", + version='1.0', + author="Alexander Gutyra", + author_email="gutyra13@gmail.com", + description="Simple pure-Python calculator with custom modules support.", + packages=find_packages(), + entry_points={ + 'console_scripts': ['pycalc = pycalc.pycalc:main'] + } +)