3
\$\begingroup\$

I have implemented a console calculator.

  • It has support for variables over a set of integers and the following operations: multiplication, integer division, exponentiation, addition and subtraction. You can use parentheses in expressions.

  • The SyntacticalAnalizer class implements parsing and syntactic validation of a string entered by the user, converting the expression into reverse polish notation.

  • The Interpreter class checks for NameError errors and calculates the result of the expression.

  • The SmartCalculator class contains the SyntacticAnalizer and Interpreter classes and provides the user interface.

The code passed the tests, but the state processing turned out to be very noodle-like. I would be grateful for advice on improving the code architecture. Source on GitHub

from string import ascii_letters
from collections import deque


class SyntacticalAnalyzer:
    `"""
    The name of a variable (identifier) can contain only Latin letters.
A variable can have a name consisting of more than one letter.
The case is also important; for example, n is not the same as N.
The value can be an integer number or a value of another variable.
Addition and subtraction operations are allowed.
Commands begin with a slash and can be: /exit and /help
    """`
    _digits_tags: str = '1234567890'
    _commands = ['/exit', '/help']
    left_part: str = None
    expression_stack: list = []
    operator_priority: dict = {
        '(': 0,
        '+': 2,
        '-': 2,
        '*': 3,
        '/': 3,
        '^': 4,
        ')': 0
    }
    rpn_stack: deque = deque()

    @staticmethod
    def check_ascii(name):
        for letter in name:
            if letter not in ascii_letters:
                return False
        return True

    @property
    def checked_string(self) -> str:
        return self.__checked_string

    @checked_string.setter
    def checked_string(self, value: str):
        self.__checked_string = value

    @property
    def check_result(self):
        return self.res

    def __init__(self):
        self.__checked_string: str = ''
        self._state: str = 'assignment operator'
        # scan chain bypass rules
        self.chain_rules: dict = dict(skip=False,
                                      off=False
                                      )
        # check status
        self._status = dict(checker='',
                            error=None,
                            check_res=False,
                            )
        # object passed to the wrapper class
        self.res = dict(error=None,
                        state=None,
                        command=None,
                        left=None,
                        rpn_expression=None
                        )
        # list of test functions
        self._check_chain: list = [self.check_not_empty,
                                   self.check_command_tag,
                                   self.check_command_incorrectness,
                                   self.check_equality_tag,
                                   self.check_left_part,
                                   self.check_right_part,
                                   self.to_rpn
                                   ]

    def notify(self, checker: str, check_res: bool):
        """
       Passes the function name and the result of its work to the self._status object
     @param checker: name of the function passed
     @type checker: str
     @param check_res: result of the function passed
     @type check_res: bool
     @return: None
        """
        self._status['checker'] = checker
        self._status['check_res'] = check_res

    def check_status_handler(self):
        """
        Reads the modified self._status object, sets self._state
         and modifies the self.chain_rules object
        @return: None
        """

        if self._status['checker'] == 'check_not_empty' and not self._status['check_res']:
            self._status['error'] = 'empty'
            self._state = 'empty'
        if self._status['checker'] == 'check_command_tag':
            if self._status['check_res']:
                self._state = 'command'
            else:
                self.chain_rules['skip'] = True

        if self._status['checker'] == 'check_command_incorrectness':
            if not self._status['check_res']:
                self._status['error'] = self.add_command()
                self.chain_rules['off'] = True
            else:
                self.chain_rules['off'] = True
        if self._status['checker'] == 'check_equality_tag':
            if not self._status['check_res']:
                self._state = 'expression'
                self.chain_rules['skip'] = True

        if self._status['checker'] == 'check_left_part':
            if not self._status['check_res']:
                self._status['error'] = 'Invalid identifier'

        if self._status['checker'] == 'check_right_part':
            if not self._status['check_res']:
                if self._state == 'assignment operator':
                    self._status['error'] = 'Invalid assignment'
                    self.chain_rules['skip'] = True
                else:
                    self._status['error'] = 'Invalid identifier'
                    self.chain_rules['skip'] = True
        if self._status['checker'] == 'to_rpn':
            if not self._status['check_res']:
                if self._state == 'assignment operator':
                    self._status['error'] = 'Invalid assignment'
                else:
                    self._status['error'] = 'Invalid expression'

    def perform_res(self):
        """
        Checks self._state and self._status. Fills out the dictionary self.res
        @return: None
        """
        self.res['state'] = self._state
        if self._state == 'empty':
            self.res['error'] = 'empty'
            self.chain_rules['off'] = True
        if self._state == 'command':
            if self._status['error'] is None:
                self.res['command'] = self.add_command()
            else:
                self.res['error'] = self._status['error']
        if self._state == 'assignment operator':
            if self._status['error'] is None:
                self.res['left'] = self.left_part
                self.res['rpn_expression'] = self.rpn_stack
            else:
                self.res['error'] = self._status['error']
        if self._state == 'expression':
            if self._status['error'] is None:
                self.res['rpn_expression'] = self.rpn_stack
            else:
                self.res['error'] = self._status['error']

    def clear_init_fields(self):
        """
        Clears all constructor fields before checking for a new line
        @return: None
        """
        self._state = 'assignment operator'
        self._status['checker'] = ''
        self._status['error'] = None
        self._status['check_res'] = False
        self.chain_rules['skip'] = False
        self.chain_rules['off'] = False

        for key, value in self.res.items():
            if type(value) != dict:
                self.res[key] = None
        self.expression_stack = []

    def run_check_chain(self):
        """
        Starts a string check chain. Reads objects self.chain_rules and
        self._status, if the skip == True property skips the next check,
         if the property off == True or one of the checks has completed
         with an error, terminates its work
        @return: None
        """
        j = -1
        self.clear_init_fields()

        for i, check in enumerate(self._check_chain):
            if j == i:
                self.chain_rules['skip'] = False
            if self.chain_rules['skip']:
                j = i + 1 if i + 1 < len(self._check_chain) else -1
                continue
            self.run_check(check)
            self.check_status_handler()
            if self._status['error'] is not None:
                break
            if self.chain_rules['off']:
                break
        self.perform_res()

    def run_check(self, check_func):
        """
        @type check_func: function
        """
        result = check_func()
        self.notify(check_func.__name__, result)

    def check_not_empty(self):
        return self.checked_string != ''

    def check_command_tag(self):
        return self.checked_string.startswith('/')

    def check_command_incorrectness(self):
        return self.checked_string in self._commands

    def add_command(self) -> str:
        for _command in self._commands:
            if self.checked_string == _command:
                return _command
        return 'Unknown command'

    def check_equality_tag(self) -> bool:
        return '=' in self.checked_string

    def is_variable(self, name: str) -> bool:
        return all([len(name) >= 1, self.check_ascii(name)])

    def check_left_part(self):
        if self._state == 'assignment operator':
            self.left_part = self.checked_string.split('=')[0].strip()
            return self.is_variable(self.left_part)

    @staticmethod
    def get_fragment_params(value: str, end):
        out_str = ''
        pos = 0
        sym = value[0]
        while sym not in end:
            out_str += sym
            try:
                pos += 1
                sym = value[pos]
            except IndexError:
                return out_str, None
        return out_str, pos

    @staticmethod
    def is_operator(item: str):
        item_list: list = item.strip().split(' ')
        my_str = ''.join(item_list)
        if my_str[0] in '+-':
            for el in my_str:
                if el not in '+-':
                    return False
        if my_str[0] in '/*^':
            if len(my_str) > 1:
                return False
        return True

    @staticmethod
    def is_digit(item: str):
        if item[0] == '0':
            if len(item) != 1:
                return False
            return True
        for el in item:
            if el not in '1234567890':
                return False
        return True

    @staticmethod
    def is_left_parenthesis(item: str):
        for el in item:
            if el not in '(':
                return False
        return True

    @staticmethod
    def is_right_parenthesis(item: str):
        for el in item:
            if el not in ')':
                return False
        return True

    @staticmethod
    def get_first(value: str):
        return value[0] if value else None

    @staticmethod
    def get_tag(letter: str):
        if letter in ascii_letters:
            return 'variable'
        if letter in '-+/*^':
            return 'operator'
        if letter in '1234567890':
            return 'digit'
        if letter in '()':
            return 'left parenthesis' if letter == '(' else 'right parenthesis'

    @staticmethod
    def get_end_tag(tag: str) -> str:
        if tag == 'variable':
            return ' )+-/*^'
        if tag == 'operator':
            return '(0123456789' + ascii_letters
        if tag == 'digit':
            return ' )+-/*^'
        if tag == 'left parenthesis':
            return ' 0123456789' + ascii_letters + '+-'
        if tag == 'right parenthesis':
            return ' +-/*^'

    @staticmethod
    def transform_operator(el: str):
        if '-' in el or '+' in el:
            minus_cnt = el.count('-')
            if minus_cnt:
                return '-' if minus_cnt % 2 != 0 else '+'
            return '+'
        return el

    @staticmethod
    def transform_parenthesis(el: str):
        return list(el)

    def transform_element(self, el: str, tag: str):
        if tag == 'operator':
            return self.transform_operator(el)
        if tag in ['left parenthesis', 'right parenthesis']:
            return self.transform_parenthesis(el.rstrip())
        return el.rstrip()

    @staticmethod
    def add_el(container: list, el):
        if type(el) == list:
            container += el
        else:
            container.append(el.rstrip())

    def check_right_part(self):
        next_pos = 0
        if self._state == 'assignment operator':
            input_str = self.checked_string.split('=', 1)[1].strip()
        else:
            input_str = self.checked_string.strip()
        if not input_str:
            return False
        while True:
            current: str = input_str[next_pos:]
            sym: str = self.get_first(current)
            name = self.get_tag(sym)
            end_tag = self.get_end_tag(name)
            el, offset = self.get_fragment_params(value=current, end=end_tag)

            conditions = [
                self.is_variable(el),
                self.is_operator(el),
                self.is_digit(el),
                self.is_left_parenthesis(el),
                self.is_right_parenthesis(el)
            ]
            if not any(conditions):
                return False
            el = self.transform_element(el=el, tag=name)
            if not self.expression_stack or self.expression_stack[-1] == '(':
                if el in '+-':
                    self.expression_stack.append('0')
            self.add_el(self.expression_stack, el)
            if offset is None:
                return True
            temp = current[offset:]
            offset += temp.find(temp.lstrip())
            next_pos += offset

    def to_rpn(self):
        f = False
        operators: list = []
        for item in self.expression_stack:
            if self.is_digit(item) or self.is_variable(item):
                self.rpn_stack.append(item)
            else:
                if not operators:
                    operators.append(item)
                else:
                    if item == '(' or self.operator_priority[item] > self.operator_priority[operators[-1]]:
                        operators.append(item)
                    else:
                        if not operators:
                            return False
                        while operators:
                            operator = operators.pop()
                            if operator == '(':
                                f = True
                                break
                            self.rpn_stack.append(operator)
                        if item == ')' and not f:
                            return False
                        if item != ')':
                            operators.append(item)

        if operators:
            if '(' in operators:
                return False
            else:
                while operators:
                    self.rpn_stack.append(operators.pop())
        return True

    # End of class SyntacticalAnalyzer


class Interpreter:
    bye_string = 'Bye!'
    help_string = 'The program calculates expressions using addition, subtraction, multiplication, integer division' \
                  ' and exponentiation over a set of integers, and also uses variables.'

    def __init__(self, obj):
        self.variables: dict = {}
        self.obj = obj
        self.error: str = None
        self.res: int = None
        self.rpn_stack: deque = deque()

    def execute(self):
        if not self.analysis_handler():
            return False
        return True

    def analysis_handler(self):
        """
        Читает self.obj.
        @return:
        """
        self.rpn_stack = deque()
        self.res = None
        self.error = None
        if self.obj['state'] == 'empty':
            pass
        if self.obj['state'] == 'command':
            if not self.command_handler(self.obj['command']):
                return False
        if self.obj['state'] == 'expression':
            if not self.expression_handler():
                print(self.error)
            else:
                print(self.res)

        if self.obj['state'] == 'assignment operator':
            if not self.assignment_handler():
                print(self.error)
        return True

    def command_handler(self, param: str) -> bool:
        if param == '/exit':
            print(self.bye_string)
            return False
        if param == '/help':
            print(self.help_string)
            return True

    def expression_handler(self):
        if not self.check_variables():
            return False
        self.res = self.get_expression_result()
        return True

    @staticmethod
    def calculate_this(one, two, sign):
        one, two = [int(x) for x in [one, two]]
        if sign == '+':
            return one + two
        if sign == '-':
            return one - two
        if sign == '*':
            return one * two
        if sign == '/':
            return one // two
        if sign == '^':
            return one ** two

    @staticmethod
    def is_digit(item: str):
        if item[0] == '0':
            if len(item) != 1:
                return False
            return True
        for el in item:
            if el not in '1234567890':
                return False
        return True

    def get_expression_result(self):
        result_stack: list = []

        while self.rpn_stack:
            item = self.rpn_stack.popleft()
            if self.is_digit(item):
                result_stack.append(item)
            else:
                second, first = result_stack.pop(), result_stack.pop()
                result_stack.append(self.calculate_this(first, second, item))
        return result_stack[0]

    def assignment_handler(self):
        if not self.expression_handler():
            return False
        left = self.obj['left']
        self.variables[left] = self.res
        return True

    def check_variables(self):
        self.rpn_stack = self.obj['rpn_expression']
        for i, item in enumerate(self.rpn_stack):
            if item in self.variables:
                self.rpn_stack[i] = self.variables[item]
            else:
                if item[0] in ascii_letters:
                    return False
        return True


class SmartCalculator:
    """
    The name of a variable (identifier) can contain only Latin letters.
A variable can have a name consisting of more than one letter.
The case is also important; for example, n is not the same as N.
The value can be an integer number or a value of another variable.
It should be possible to set a new value to an existing variable.
To print the value of a variable you should just type its name.
    """
    _analyzer_methods = ['run_check_chain']
    _interpreter_methods = ['execute', 'analysis_handler']

    def __init__(self):
        self._analyzer: SyntacticalAnalyzer = SyntacticalAnalyzer()
        self.analyzer_result: dict = self._analyzer.check_result
        self._interpreter: Interpreter = Interpreter(self.analyzer_result)

    def __getattr__(self, item):
        for item in self._analyzer_methods + self._interpreter_methods:
            if item in self._analyzer_methods:
                return getattr(self._analyzer, item)
            if item in self._interpreter_methods:
                return getattr(self._interpreter, item)

    def run(self):
        while True:
            self._analyzer.checked_string = input().strip()
            self._analyzer.run_check_chain()
            if self._analyzer.res['error'] is not None and self._analyzer.res['error'] != 'empty':
                print(self._analyzer.res['error'])
            else:
                if not self._interpreter.execute():
                    return None


calculator = SmartCalculator()
calculator.run()
\$\endgroup\$
3
  • \$\begingroup\$ Thank you very much Serge Ve for adding a description. The question looks great now :) \$\endgroup\$
    – Peilonrayz
    Jun 7, 2020 at 23:33
  • 1
    \$\begingroup\$ Please do not update the code in your question to incorporate feedback from answers, doing so goes against the Question + Answer style of Code Review. This is not a forum where you should keep the most updated version in your question. Please see what you may and may not do after receiving answers. \$\endgroup\$
    – Mast
    Jun 8, 2020 at 11:01
  • \$\begingroup\$ @Mast, Sorry, I have not read the rules. \$\endgroup\$
    – Serge Ve
    Jun 8, 2020 at 14:20

1 Answer 1

0
\$\begingroup\$

Just a few thoughts in case I don't have time for a more thorough review later (must sign off soon).

  • The program crashes too easily, for example if I type . or * or (3+1). But this works: 3+(1+4)
  • Don't you think it would be a good idea to use a regular expression to filter the list of allowed characters ?
  • To simplify you could use the eval function, provided that you understand that it has the potential to run arbitrary and malicious code. But you could simplify parsing a lot.
  • I have the impression that the built-in argparse module could have been useful for the purpose of parsing the input string. Because it is very flexible, you can require multiple arguments for a parameter and also create mutually-exclusive groups, which could be interesting here for example to allow only one operator out of a fixed list.
  • So, if you for example require one digit, followed by an operator, followed by another digit, that is very easy to express and validate with argparse.
  • But your needs are slightly more complex, I have to figure out all possible cases first
  • I am not sure I understand how variable assignment is supposed to work but consider the following:
    a=1
    a
    1
    b=a+1
    b

Crash:

Traceback (most recent call last):
  File "/tmp/calc.py", line 561, in 
    calculator.run()
  File "/tmp/calc.py", line 556, in run
    if not self._interpreter.execute():
  File "/tmp/calc.py", line 428, in execute
    if not self.analysis_handler():
  File "/tmp/calc.py", line 446, in analysis_handler
    if not self.expression_handler():
  File "/tmp/calc.py", line 467, in expression_handler
    self.res = self.get_expression_result()
  File "/tmp/calc.py", line 500, in get_expression_result
    if self.is_digit(item):
  File "/tmp/calc.py", line 486, in is_digit
    if item[0] == '0':
TypeError: 'int' object is not subscriptable

But this works:

a=1
a
1
b=a
b
1

So at this point I am thinking I might classify the input in two possible cases and evaluate accordingly:

  1. variable assignments
  2. regular calculations

Then it seems to me that eval can help you a lot. Just have to strictly define what you want to allow, and what should not be permitted.

\$\endgroup\$
2
  • 4
    \$\begingroup\$ eval is just too dangerous to be useful here. \$\endgroup\$ Jun 8, 2020 at 6:03
  • \$\begingroup\$ Thank you so much. Corrected the self.get_expression_result method. Now this error is gone. def get_expression_result(self): result_stack: list = [] while self.rpn_stack: item = self.rpn_stack.popleft() try: result_stack.append(int(item)) except ValueError: second, first = result_stack.pop(), result_stack.pop() result_stack.append(self.calculate_this(first, second, item)) return result_stack[0] \$\endgroup\$
    – Serge Ve
    Jun 8, 2020 at 6:56

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.