diff --git a/.gitignore b/.gitignore index d7dc40d..4c1b077 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,15 @@ __pycache__/ # PyCharm files .idea/ +htmlcov/ +__pycache__/ # C extensions *.so +.coverage + +*.pyc # Distribution / packaging .Python build/ diff --git a/.travis.yml b/.travis.yml index baca138..21dd972 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,7 @@ install: script: - cd final_task - pip install . - - nosetests --cover-branches --with-coverage . + - coverage run -m unittest discover - pycodestyle --max-line-length=120 . - python ./../pycalc_checker.py - cd - \ No newline at end of file diff --git a/final_task/libs/__init__.py b/final_task/libs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/final_task/libs/element.py b/final_task/libs/element.py new file mode 100644 index 0000000..577b367 --- /dev/null +++ b/final_task/libs/element.py @@ -0,0 +1,408 @@ +#!/usr/bin/python3 + +import math + +from inspect import getmembers + + +class BaseExpressionException(Exception): + """ Common base class for all exceptions """ + pass + + +class NoExpressionException(BaseExpressionException): + """ Class exception for no expression. """ + pass + + +class BracketsAreNotBalanced(BaseExpressionException): + """ Class exception for expression when brackets are not balanced. """ + pass + + +class DoubleOperationException(BaseExpressionException): + """ Class exception for expression with double operation. """ + pass + + +class ExpressionFormatException(BaseExpressionException): + """ Class exception for expression with not correct format. """ + pass + + +class UnsupportedMathematicalOperationException(ExpressionFormatException): + """ Class exception for expression with not correct mathematical operations. """ + pass + + +class UnsupportedMathematicalFunctionException(ExpressionFormatException): + """ Class exception for expression with unsupported mathematical function. """ + pass + + +class Element: + """ + Base class for parsing and calculation the mathematical expression. Check the expression for the number of brackets. + Perform the transformation of the expression, depending on the number of brackets. If brackets an odd number raise + exception. Parse the expression into the components, separate mathematical operations and numbers. And create new + expression. Validate format expression. Validate first negative numbers in expression. Validate mathematical + operations and calculate nested expressions. Calculate high priority math operations.Calculate low priority math + operations. + """ + + MATH_ACTIONS = ("+", "-", "*", "/", "%", "^",) + COMPARISON_OPERATIONS = (">", "<", "=", "!",) + + def __init__(self, expression, func=None): + """ + Class constructor + :param expression: mathematical expression as string + """ + # Validate on expression and raise exception if not true + if not expression: + raise NoExpressionException("The expression was not passed") + + # Validate function and constant function in expression + self._mathematical_functions = { + name: val for name, val in getmembers(math) if type(val).__name__ == "builtin_function_or_method" + } + self._mathematical_functions["abs"] = abs + self._mathematical_functions["round"] = round + + # Validate mathematical constants in expression + self._mathematical_constants = { + name: val for name, val in getmembers(math) if type(val).__name__ == "float" + } + + # Check function in expression + self._func = None + if func: + func = func.strip() + if func not in self._mathematical_functions: + raise UnsupportedMathematicalFunctionException("We do not support '{}' function".format(func)) + self._func = self._mathematical_functions.get(func) + + self._expression = [] + + bracket_level = 0 + item = [] + last_mathematical_action = None + bracket_content = [] + self._comparison_operation = False + self._multivalue = False + + # Validate expression on comparison operation and raise exception if it has not valid format + previous_is_comparison = False + for i, v in enumerate(expression): + if v in self.COMPARISON_OPERATIONS: + self._comparison_operation = True + if item: + self._expression.append(Element("".join(item))) + item.clear() + + if previous_is_comparison: + self._expression[-1] += v + else: + self._expression.append(v) + + previous_is_comparison = True + else: + previous_is_comparison = False + item.append(v) + + if self._comparison_operation: + if item: + self._expression.append(Element("".join(item))) + return + else: + raise ExpressionFormatException("After comparison operation expression or number are expected") + + # Look for commas in expression + start_index = 0 + multivalue_items = [] + for i, c in enumerate(expression): + # increase bracket level + if c == "(": + bracket_level += 1 + # decrease bracket level + elif c == ")": + bracket_level -= 1 + elif c == "," and bracket_level == 0: + self._multivalue = True + multivalue_items.append(Element(expression[start_index:i])) + start_index = i + 1 + if self._multivalue: + self._expression = multivalue_items + self._expression.append(Element(expression[start_index:])) + return + + # Validate format expression and raise exception if it is not valid + item = [] + bracket_closed = False + for i in expression: + if bracket_closed and bracket_level == 0: + if i == " ": + continue + if i not in self.MATH_ACTIONS and i != ")": + raise ExpressionFormatException("After bracket closed 'math sign' or " + "another bracket close are expected") + bracket_closed = False + + # Validate and count brackets + + if i == "(": + bracket_level += 1 + if bracket_level == 1: + continue + + # Validate and sorted data in brackets + + elif i == ")": + bracket_level -= 1 + bracket_closed = True + if bracket_level < 0: + raise BracketsAreNotBalanced("Closed non-opened bracket.") + if bracket_level == 0: + if bracket_content: + if item: + self._expression.append(Element(expression="".join(bracket_content), func="".join(item))) + item.clear() + else: + self._expression.append(Element("".join(bracket_content))) + bracket_content.clear() + else: + raise ExpressionFormatException("Empty brackets.") + continue + + if bracket_level > 0: + bracket_content.append(i) + else: + if i in self.MATH_ACTIONS: + if item: + item = "".join(item).strip() + if item: + if item in self._mathematical_constants: + item = self._mathematical_constants[item] + try: + self._expression.append(float(item)) + except ValueError: + raise ExpressionFormatException("Could not convert string to float: '{}'".format(item)) + item = [] + + # Handle double mathematical operation + if last_mathematical_action == i: + self._expression[-1] += i + else: + self._expression.append(i) + last_mathematical_action = i + + else: + item.append(i) + last_mathematical_action = None + if bracket_level != 0: + raise BracketsAreNotBalanced() + + # Add item after parsing + if item: + item = "".join(item) + if item in self._mathematical_constants: + item = self._mathematical_constants[item] + try: + self._expression.append(float(item)) + except ValueError: + raise ExpressionFormatException("Could not convert string to float: '{}'".format(item)) + + # Conversation to string expression + def __str__(self): + """ + String representation of the class + :return: string representation of the class + """ + result = [] + for i in self._expression: + result.append(str(i)) + return "{cls_name}{{{data}}}".format( + cls_name=self.__class__.__name__, + data=", ".join(result) + ) + + # Calculate comparison expression + def _calculate_boolean_expression(self): + boolean_value = True + for i, v in enumerate(self._expression): + if isinstance(v, str): + if i <= 0: + raise ExpressionFormatException("Comparison could be at the first position") + if v == ">=": + if not self._expression[i - 1] >= self._expression[i + 1]: + boolean_value = False + elif v == "<=": + if not self._expression[i - 1] <= self._expression[i + 1]: + boolean_value = False + elif v == "==": + if not self._expression[i - 1] == self._expression[i + 1]: + boolean_value = False + elif v == "<": + if not self._expression[i - 1] < self._expression[i + 1]: + boolean_value = False + elif v == ">": + if not self._expression[i - 1] > self._expression[i + 1]: + boolean_value = False + elif v in ("!=", "<>",): + if not self._expression[i - 1] != self._expression[i + 1]: + boolean_value = False + else: + raise UnsupportedMathematicalOperationException("We do not support '{}' operation".format(v)) + + if not boolean_value: + return boolean_value + return boolean_value + + # Calculate mathematical expression + def _calculate_mathematical_expression(self): + operation = None + first_negative = False + + # Validate first negative numbers in expression + if self._expression[0] == "-": + first_negative = True + del self._expression[0] + + i = len(self._expression) - 1 + while i >= 0: + el = self._expression[i] + if el == "^": + self._expression.pop(i) + power = self._expression.pop(i) + self._expression[i - 1] **= power + i -= 1 + + # Calculate high priority mathematical operations + new_expression = [] + for i in self._expression: + if i in ("*", "/", "%", "//", "**"): + operation = i + elif operation: + if operation == "*": + new_expression[-1] *= i + elif operation == "/": + new_expression[-1] /= i + elif operation == "%": + new_expression[-1] %= i + elif operation == "//": + new_expression[-1] //= i + elif operation == "**": + raise UnsupportedMathematicalOperationException("We do not support '{}' operation".format(i)) + operation = None + else: + if first_negative: + i = -i + first_negative = False + new_expression.append(i) + + self._expression = new_expression + + # Calculate low priority math operations + value = 0 + operation = None + + for i in self._expression: + if isinstance(i, str): + if i in ("+", "-",): + operation = i + elif operation: + if operation == "+": + value += i + elif operation == "-": + value -= i + operation = None + else: + value = i + + # Validate on mathematical function + if self._func: + value = self._func(value) + + return value + + # Calculate value expression + def value(self): + """ + Method for expression calculation + :return: calculate value + """ + # Validate unary operation + for i, v in enumerate(self._expression): + if isinstance(v, Element): + self._expression[i] = v.value() + if isinstance(v, str): + if v.startswith("-"): + if len(v) > 1: + if len(v) % 2 == 0: + self._expression[i] = "+" + else: + self._expression[i] = "-" + if v.startswith("+"): + self._expression[i] = "+" + + # Validate negative item in expression + expression = [] + last_operation = None + sign = None + + for i, v in enumerate(self._expression): + if isinstance(v, str): + if last_operation: + if last_operation in ("+", "-",): + if v in ("+", "-"): + if last_operation == v: + last_operation = "+" + else: + last_operation = "-" + else: + raise DoubleOperationException("'{so}' operation follows '{fo}'".format( + so=last_operation, + fo=v + )) + else: + if v not in ("+", "-"): + raise DoubleOperationException("'{so}' operation follows '{fo}'".format( + so=last_operation, + fo=v + )) + if sign: + if sign == v: + sign = "+" + else: + sign = "-" + else: + sign = v + else: + last_operation = v + continue + + if last_operation: + expression.append(last_operation) + last_operation = None + if sign == "-": + v = -v + sign = None + expression.append(v) + + if last_operation or sign: + raise ExpressionFormatException("Expression finishes with mathematical operation.") + + self._expression = expression + + # Evaluate comparison expression + if self._comparison_operation: + return self._calculate_boolean_expression() + + # Evaluate multi-value expression + if self._multivalue: + try: + return self._func(*self._expression) + except TypeError: + raise ExpressionFormatException("Expected 2 arguments: '{}'".format(self._func)) + # Value mathematical expression + return self._calculate_mathematical_expression() diff --git a/final_task/pycalc b/final_task/pycalc new file mode 100755 index 0000000..d133a30 --- /dev/null +++ b/final_task/pycalc @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 + +import argparse +from libs.element import Element, BaseExpressionException + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description="") + parser.add_argument("-m MODULE ", "--use-modules", metavar="MODULE...", nargs="?", help="additional modules to use") + parser.add_argument("EXPRESSION", help="expression string to evaluate", type=str) + + args = parser.parse_args() + + try: + expression = Element(expression=args.EXPRESSION) + print(expression.value()) + except BaseExpressionException as exc: + print("ERROR: {}".format(str(exc))) diff --git a/final_task/setup.py b/final_task/setup.py index e69de29..923231b 100644 --- a/final_task/setup.py +++ b/final_task/setup.py @@ -0,0 +1,11 @@ +from setuptools import setup, find_packages + +setup( + name='pycalc', + version='0.1', + author='Elena Volkova', + author_email='volkovaelen87@gmail.com', + description='Python command-line calculator', + packages=find_packages(), + scripts=['pycalc'] +) diff --git a/final_task/tests/__init__.py b/final_task/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/final_task/tests/test_bracket.py b/final_task/tests/test_bracket.py new file mode 100644 index 0000000..6132738 --- /dev/null +++ b/final_task/tests/test_bracket.py @@ -0,0 +1,14 @@ +from unittest import TestCase + +from libs.element import Element + + +class TestBracketsElementSimple(TestCase): + + def test_bracket_calculation(self): + expression = Element(expression="(2+8)//2+(6-3)") + self.assertEqual(expression.value(), 8) + + def test_nesting_of_elements_in_brackets(self): + expression = Element(expression="2+(3*((5-1)-2))") + self.assertEqual(expression.value(), 8) diff --git a/final_task/tests/test_comparison_operation.py b/final_task/tests/test_comparison_operation.py new file mode 100644 index 0000000..0b7815e --- /dev/null +++ b/final_task/tests/test_comparison_operation.py @@ -0,0 +1,55 @@ + +from unittest import TestCase + +from libs.element import Element + + +class TestComparisonFunctionElement(TestCase): + + def test_more_or_equal(self): + expression = Element(expression="3+14>=2-1") + self.assertEqual(expression.value(), True) + + def test_less_or_equal(self): + expression = Element(expression="2-1<=5+4") + self.assertEqual(expression.value(), True) + + def test_less(self): + expression = Element(expression="2<4") + self.assertEqual(expression.value(), True) + + def test_equal(self): + expression = Element(expression="9==5+4") + self.assertEqual(expression.value(), True) + + def test_more(self): + expression = Element(expression="10>5+4") + self.assertEqual(expression.value(), True) + + def test_not_equal(self): + expression = Element(expression="1!=5") + self.assertEqual(expression.value(), True) + + def test_negative_more_or_equal(self): + expression = Element(expression="3+14>=20-1") + self.assertEqual(expression.value(), False) + + def test_negative_less_or_equal(self): + expression = Element(expression="12-1<=5+4") + self.assertEqual(expression.value(), False) + + def test_negative_less(self): + expression = Element(expression="8<4") + self.assertEqual(expression.value(), False) + + def test_negative_equal(self): + expression = Element(expression="9==5-4") + self.assertEqual(expression.value(), False) + + def test_negative_more(self): + expression = Element(expression="10>5+14") + self.assertEqual(expression.value(), False) + + def test_negative_not_equal(self): + expression = Element(expression="15<>15") + self.assertEqual(expression.value(), False) diff --git a/final_task/tests/test_negatives.py b/final_task/tests/test_negatives.py new file mode 100644 index 0000000..6cddd30 --- /dev/null +++ b/final_task/tests/test_negatives.py @@ -0,0 +1,108 @@ +from unittest import TestCase + +from libs.element import Element, NoExpressionException, BracketsAreNotBalanced, \ + DoubleOperationException, ExpressionFormatException, UnsupportedMathematicalOperationException, \ + UnsupportedMathematicalFunctionException + + +class TestNegativesElementSimple(TestCase): + + def test_no_expression(self): + with self.assertRaises(NoExpressionException): + expression = Element(expression="") + expression.value() + + def test_format_expression(self): + with self.assertRaises(ExpressionFormatException): + expression = Element(expression="(5+2)4*5") + expression.value() + + def test_brackets_are_not_balanced(self): + with self.assertRaises(BracketsAreNotBalanced): + expression = Element(expression="((8-3)//5*2") + expression.value() + + def test_double_operation(self): + with self.assertRaises(DoubleOperationException): + expression = Element(expression="(3*2)-/6+2") + expression.value() + + def test_empty_bracket(self): + with self.assertRaises(ExpressionFormatException): + expression = Element(expression="(2+4)/()") + expression.value() + + def test_unsupported_operation(self): + with self.assertRaises(UnsupportedMathematicalOperationException): + expression = Element(expression="10--4*3") + expression.value() + + def test_brackets_are_not_balanced_second(self): + with self.assertRaises(BracketsAreNotBalanced): + expression = Element(expression="8-3)//5*2") + expression.value() + + def test_comparison_format_exception(self): + with self.assertRaises(ExpressionFormatException): + expression = Element(expression="2*4>") + expression.value() + + def test_unsupported_comparison_operation(self): + with self.assertRaises(UnsupportedMathematicalOperationException): + expression = Element(expression="10<<=4*3") + expression.value() + + def test_unsupported_operation(self): + with self.assertRaises(DoubleOperationException): + expression = Element(expression="1+/5*3") + expression.value() + + def test_unsupported_trigonometric_operation(self): + with self.assertRaises(UnsupportedMathematicalFunctionException): + expression = Element(expression="iii(10)") + expression.value() + + def test_not_mathematical_constant(self): + with self.assertRaises(ExpressionFormatException): + expression = Element(expression="li") + expression.value() + + def test_exponentiation(self): + with self.assertRaises(UnsupportedMathematicalOperationException): + expression = Element(expression="2**6") + expression.value() + + def test_expected_arguments(self): + with self.assertRaises(ExpressionFormatException): + expression = Element(expression="pow(2,5,6)") + expression.value() + + def test_comma_without_func(self): + with self.assertRaises(ExpressionFormatException): + expression = Element(expression="2+3,4") + expression.value() + + def test_bad_expression(self): + with self.assertRaises(ExpressionFormatException): + expression = Element(expression="--+1-") + expression.value() + + def test_expression_bad(self): + with self.assertRaises(ExpressionFormatException): + expression = Element(expression="2-") + expression.value() + + def test_convert_string_to_float(self): + with self.assertRaises(ExpressionFormatException): + expression = Element(expression="21 + 2(3 * 4))") + expression.value() + + def test_first_comparison(self): + with self.assertRaises(ExpressionFormatException): + expression = Element(expression="<=4+6") + expression.value() + + def test_unsupported_operation(self): + with self.assertRaises(DoubleOperationException): + expression = Element(expression="4/*5-3") + expression.value() diff --git a/final_task/tests/test_simple.py b/final_task/tests/test_simple.py new file mode 100644 index 0000000..5c03d6c --- /dev/null +++ b/final_task/tests/test_simple.py @@ -0,0 +1,66 @@ +from unittest import TestCase + +from libs.element import Element + + +class TestElementSimple(TestCase): + + def test_sum(self): + expression = Element(expression="5+3") + self.assertEqual(expression.value(), 8) + + def test_div(self): + expression = Element(expression="5/2") + self.assertEqual(expression.value(), 2.5) + + def test_double_mod(self): + expression = Element(expression="9//3//3") + self.assertEqual(expression.value(), 1) + + def test_math_operator_priority(self): + expression = Element(expression="5/2+0.1*5") + self.assertEqual(expression.value(), 3) + + def test_mul(self): + expression = Element(expression="5*5-10") + self.assertEqual(expression.value(), 15) + + def test_double_mul(self): + expression = Element(expression="5*5*4") + self.assertEqual(expression.value(), 100) + + def test_modulo(self): + expression = Element(expression="5%3") + self.assertEqual(expression.value(), 2) + + def test_first_negative_value(self): + expression = Element(expression="-2*4-6/2") + self.assertEqual(expression.value(), -11) + + def test_exponentiation(self): + expression = Element(expression="2*3//2") + self.assertEqual(expression.value(), 3) + + def test_str(self): + expression = Element(expression="2+3*((5-1)-2) ") + self.assertTrue(str(expression), 8) + + def test_mathematical_constant(self): + expression = Element(expression="pi") + self.assertEqual(expression.value(), 3.141592653589793) + + def test_mathematical_power(self): + expression = Element(expression="2^3") + self.assertEqual(expression.value(), 8) + + def test_two_mathematical_constant(self): + expression = Element(expression="pi*e") + self.assertEqual(expression.value(), 8.539734222673566) + + def test_unary_operation(self): + expression = Element(expression="2*4-----3+++-4*-+-+-3") + self.assertEqual(expression.value(), 17) + + def test_negative_exponentiation(self): + expression = Element(expression="2^-8") + self.assertEqual(expression.value(), 0.00390625) diff --git a/final_task/tests/test_trigonometric_operation.py b/final_task/tests/test_trigonometric_operation.py new file mode 100644 index 0000000..9a34c4c --- /dev/null +++ b/final_task/tests/test_trigonometric_operation.py @@ -0,0 +1,35 @@ +import math +from unittest import TestCase + +from libs.element import Element + + +class TestTrigonometricFunctionElement(TestCase): + + def test_sin(self): + expression = Element(expression="sin({})".format(math.pi / 2)) + self.assertEqual(expression.value(), 1.0) + + def test_cos(self): + expression = Element(expression="cos({})".format(math.pi * 2)) + self.assertEqual(expression.value(), 1.0) + + def test_tan(self): + expression = Element(expression="tan(0)") + self.assertEqual(expression.value(), 0) + + def test_log10(self): + expression = Element(expression="log10(1)") + self.assertEqual(expression.value(), 0.0) + + def test_log2(self): + expression = Element(expression="log2(2)") + self.assertEqual(expression.value(), 1.0) + + def test_log(self): + expression = Element(expression="log(1)") + self.assertEqual(expression.value(), 0.0) + + def test_abs(self): + expression = Element(expression="abs(10)") + self.assertEqual(expression.value(), 10.0) diff --git a/gitlab-ci.yml b/gitlab-ci.yml new file mode 100644 index 0000000..69a48b6 --- /dev/null +++ b/gitlab-ci.yml @@ -0,0 +1,11 @@ +image: python:3.6-alpine + +before_script: + - cd final_task + +test: + stage: test + script: + - pip install coverage + - coverage run --source=libs -m unittest discover + - coverage report