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()
Mast
13.8k12 gold badges57 silver badges127 bronze badges
asked Jun 7, 2020 at 22:02
\$\endgroup\$
2
  • 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\$ Commented Jun 8, 2020 at 11:01
  • \$\begingroup\$ @Mast, Sorry, I have not read the rules. \$\endgroup\$ Commented Jun 8, 2020 at 14:20

1 Answer 1

1
\$\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.

answered Jun 8, 2020 at 2:14
\$\endgroup\$
2
  • 4
    \$\begingroup\$ eval is just too dangerous to be useful here. \$\endgroup\$ Commented 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\$ Commented Jun 8, 2020 at 6:56

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.