1
\$\begingroup\$

I'm a beginner developer, and it's my very first project: a simple math operations calculator in Python 3 using Tkinter for GUI.

Obs: This code is an evolution of my previous code of the same project: initial code.

This calculator, in despite of only process the four basic math operations (add, subtraction, multiplication an division), tries to behave like a classic calculator about the inputs treatment.

Using examples as a classic calculator:

  • Press 2, so plus button, then 3, then equal. The result 5 will be shown. After this, keeping pressing equal will proceed the addition of 3 to the respective result.

  • When user press an operator key (+,-,* or /) after the first operand input, pressing a different operator will change the operator.

  • After the the operands input, pressing an operator key will lead to result and operator storage. (User press 2, press plus, press 3, then press minus. The result 5 will be shown and the minus operator will be stored).

I've followed the suggestions the best I could and refactored all the code.

The main problems that I tried to solve were:

  • Using eval to process calculation.

It seemed so convenient using eval at the beggining. I didn´t have to create any rules or data treatment. Also, the calculator could process big expressions including things like parenthesis.

But I was warned about the vulnerability of using eval. Then I also realized that it is a pretty lazy solution.

So, I´ve managed to include methods for data treatment and processing the calculations. The application now behaves more like a conventional calculator and removed the capacity to calculate expressions at once.

  • Too many modules:

The fact of having too many modules lead me to have problems with circular imports and incorrect import calls, as I didn´t know much about importing. After some study about it and following the suggestions gave to me, I made the code lean by using only two modules and using imports correctly.

  • Code repetition:

The blocks for buttons creation and placement had too many lines. So I´ve followed some instructions to dinamically create and place them.

  • Unecessary use of self:

As a begginer at Python and OOP, I was using self at every variable inside the classes and was warned about self on variables to be used only for a variable that will be changed later in the class outside of the method it is generated in. So, the new code removed selfs where wasn´t really necessary.

Finally, that´s the new code:

Module: main.py

import tkinter
import application


class TkinterWindow(tkinter.Tk):
    def __init__(self):
        super().__init__()
        self.geometry('640x480')


def main():
    window = TkinterWindow()
    app = application.Calculator(window)
    app.pack(fill='both', expand=1, padx=10, pady=10)
    app.mainloop()


if __name__ == '__main__':
    main()

Module: application.py

import tkinter


class Calculator(tkinter.Frame):
    def __init__(self, parent):
        super().__init__(parent)
        self.rowconfigure(0, weight=1)    # Row for display
        self.rowconfigure(1, weight=1)    # Row for buttons frame
        self.columnconfigure(0, weight=1)
        self.focus_set()

        # Variables for calculations and process control
        self.display_chars = tkinter.StringVar()
        self.display_chars.set(0)
        self.aggregator = []
        self.operator = None
        self.decimal_separator = False
        self.total = 0
        self.error = None
        self.first_operator = 0
        self.second_operator = 0
        self.first_operator_status = False
        self.new_entry = True
        self.aggregator_status = 'Inactive'

        self.place_frames()
        self.bind("<Key>", self.key_handler)
        self.bind("<Return>", self.return_key_handler)
        self.bind("<BackSpace>", self.backspace_handler)
        self.bind("<Escape>", self.esc_handler)
        ##self.debugger()

    def place_frames(self):
        display_frame = DisplayContainer(self
                                         ).grid(row=0, column=0, sticky='nsew')
        buttons_frame = ButtonsContainer(self
                                         ).grid(row=1, column=0, sticky='nsew')

    def debugger(self):
        print(f'AGGREGATOR current content: {self.aggregator}')
        print(f'AGGREGATOR current status: {self.aggregator_status}')
        print(f'new_entry: {self.new_entry}')
        print(f'decimal_separator: {self.decimal_separator}')
        print(f'Current OPERATOR: {self.operator}')
        print(f'first_operator value: {self.first_operator}')
        print(f'second_operator value: {self.second_operator}')
        print(f'first_operator_status: {self.first_operator_status}')
        print(f'TOTAL: {self.total}')
        print('--------------------------------------------------')

    def key_handler(self, event):
        '''Handles inputs comming from the keyboard.

           This function only accepts four types of keyboard inputs:
           (1) Numbers
           (2) Basic math operators: +, -, *, /
           (3) '=' key
           (4) ',' or '.' for decimal separator
           Obs: Other keys are ignored

           Cases (1) and (4) make the respective value to be shown at
           the calculator display.
           Case (2) calls the operator handling, that either stores a
           value or executes an operation.
           Case (3) calls the resolving function.
        '''
        if event.char.isdigit():
            numerical_char = event.char
            self.put_char_on_display(numerical_char)
            #self.debugger()

        elif event.char in ('+', '-', '*', '/'):
            operator_char = event.char
            self.operators_handler(operator_char)

        elif event.char == '=':
            self.resolve_operation()

        elif event.char == ',' or event.char == '.':
            # There can be only one decimal separator at time.
            #
            # Also, self.new_entry checks if it´s the initial entry
            # at first or second number. If so, considers its
            # beggining as been a decimal of 0. That´s the case
            # when user presses comma/dot at the beggining of the entry.

            if self.decimal_separator == False:

                if self.new_entry:
                    zeropoint_char = '0.'
                    self.put_char_on_display(zeropoint_char)
                    self.decimal_separator = True
                    #self.debugger()
                else:
                    decimal_char = '.'
                    self.put_char_on_display(decimal_char)
                    self.decimal_separator = True
                    #self.debugger()

    def buttons_handler(self, button):
        '''Handles inputs comming from the calculator´s buttons.
           As function key_handler does, it handles the following
           inputs:
           (1) Numbers, that are displayed or stored.
           (2) Basic math operators, that stores a value and operator.
           or process an operation, according to context.
           (3) Resolves an operation with the values and operator stored.
           (4) Sets an decimal separator 
        '''
        if button in range(10):
            numerical_char = str(button)
            self.put_char_on_display(numerical_char)
            #self.debugger()

        elif button in ('+', '-', '*', '/'):
            operator_char = str(button)
            self.operators_handler(operator_char)

        elif button == '=':
            self.resolve_operation()

        elif button == '.':
            # There can be only one decimal separator at time.
            #
            # Also, self.new_entry checks if it´s the initial entry
            # at first or second number. If so, clicking or typing the
            # comma or dot makes the program consider the beggining as
            # been a decimal of zero.
            # That´s the case when user presses comma/dot at the
            # beggining of the entry.
            if self.decimal_separator == False:

                if self.new_entry:
                    zeropoint_char = '0.'
                    self.put_char_on_display(zeropoint_char)
                    self.decimal_separator = True
                    #self.debugger()
                else:
                    decimal_char = '.'
                    self.put_char_on_display(decimal_char)
                    self.decimal_separator = True
                    #self.debugger()

    def return_key_handler(self, event):
        self.resolve_operation()

    def backspace_handler(self, event):
        if self.aggregator:
            del self.aggregator[-1]
            self.display_chars.set(self.aggregator)
            #self.debugger()

    def esc_handler(self, event):
        self.set_to_default()

    def put_char_on_display(self, char):
        '''Makes an numeric input from keyboard or button be displayed.

        This function handles two situations:
        - Checks if it is the beggining of the input, through the self.new_entry variable. If true, clears the inputs aggregator and displays the new value passed, allowing posterior values to be added.
        - If chars input is process is already in course, keep adding the values passed through the params into the inputs aggregator.

        The aggregator variable self.aggregator progressivly gets numbers or a decimal separator to form a value that will be stored when an operator or the equal sign is activated.

        '''
        if self.new_entry == True:
            self.aggregator = []
            self.aggregator.append(char)
            self.display_chars.set(self.aggregator)
            self.aggregator_status = 'Active'
            self.new_entry = False
        else:
            self.aggregator.append(char)
            self.display_chars.set(self.aggregator)
            self.aggregator_status = 'Active'

    def operators_handler(self, char):
        '''Gets an operator char and handles as context.

        Set an operator via button click or keyboard envolves three situations:

        (1) If no value is stored yet, and inputs are already in course.
        This case, makes the value in aggregator be stored as the first operand.
        (2) First operand is already stored, no new values are been inserted. The program then changes the operator, if it´s different.
        (3) If first operand is already stored, a new input is in course.
        The program stores the value as the second operand and executes the operation according to the operator set.

        '''
        # Assingn the first operand
        if self.first_operator_status == False:
            self.first_operator = self.get_values_from_aggregator()
            self.first_operator_status = True
            self.operator = char
            self.new_entry = True
            self.decimal_separator = False
            self.aggregator_status = 'Inactive'

            #self.debugger()
        else:
            # Handles user changing the operators
            if (self.first_operator_status == True and
                    self.aggregator_status == 'Inactive'):
                if char != self.operator:
                    self.operator = char

                    #self.debugger()
            else:
                # If user press any operator after typing the second value
                # the value on display will be assingned in second_operator
                # variable and resolve_operation() will be called.
                self.second_operator = self.get_values_from_aggregator()
                self.operator = char
                self.aggregator_status = 'Inactive'
                self.resolve_operation()

    def get_values_from_aggregator(self):
        '''Converts the content of aggregator in a float number and
        returns it.

        If nothing is inserted into aggregator (and it´s empty), then it means that the value is zero.
        '''
        values = ''.join(self.aggregator)
        if values:
            return float(values)
        else:
            return 0

    def resolve_operation(self):
        '''Stores value as first or second operator, then executes
        the corresponding operation. So, call the finish method
        with its result as parameter.
        '''

        if self.first_operator_status == False:
            self.first_operator = self.get_values_from_aggregator()
        else:
            self.second_operator = self.get_values_from_aggregator()

        if self.operator == '+':
            result = self.first_operator + self.second_operator
            self.finish(result)

        elif self.operator == '-':
            result = self.first_operator - self.second_operator
            self.finish(result)

        elif self.operator == '*':
            result = self.first_operator * self.second_operator
            self.finish(result)

        elif self.operator == '/':
            try:
                result = self.first_operator / self.second_operator
            except ZeroDivisionError as error:
                self.error = error
                self.finish(0)
            except:
                self.error = error
                self.finish(0)
            else:
                self.finish(result)

    def finish(self, result):
        # Displays the result and sets variables for new operations.
        self.total = result
        self.display_chars.set(f'{self.total}')
        self.aggregator = [str(self.total)]
        self.aggregator_status = 'Inactive'
        self.new_entry = True
        self.decimal_separator = False
        self.first_operator_status = False

        if self.error:
            if isinstance(self.error, ZeroDivisionError):
                self.set_to_default()
                self.display_chars.set('ZeroDivisionError')
            else:
                set_to_default()
                self.display_chars.set('Unknown Error')

        #self.debugger()

    def set_to_default(self):
        '''Sets program to its default parameters..

        This happens if an error is found or CLEAR is activated.
        '''
        self.display_chars.set(0)
        self.aggregator = []
        self.operator = None
        self.decimal_separator = False
        self.total = 0
        self.error = None
        self.first_operator = 0
        self.second_operator = 0
        self.first_operator_status = False
        self.new_entry = True
        self.aggregator_status = 'Inactive'
        #self.debugger()


class DisplayContainer(tkinter.Frame):
    def __init__(self, parent):
        super().__init__(parent)
        self.parent = parent

        tkinter.Label(
            self,
            background='lightgrey',
            relief='ridge',
            border=5,
            textvariable=self.parent.display_chars,
        ).pack(fill='both', expand=1)


class ButtonsContainer(tkinter.Frame):
    def __init__(self, parent):
        super().__init__(parent)
        self.parent = parent

        for x in range(0, 5):
            self.rowconfigure(x, weight=1)
            if x < 4:
                self.columnconfigure(x, weight=1)

        self.create_buttons()

    def create_buttons(self):
        pad = 15
        row = 0
        column = 0

        for i in range(10):
            if i == 0:
                tkinter.Button(
                    self,
                    text=i,
                    padx=pad,
                    pady=pad,
                    command=lambda n=i: self.parent.buttons_handler(n)
                ).grid(row=3, column=1, sticky='nsew')
            else:
                tkinter.Button(
                    self,
                    text=i,
                    padx=pad,
                    pady=pad,
                    command=lambda n=i: self.parent.buttons_handler(n)
                ).grid(row=row, column=column, sticky='nsew')
                if column == 2:
                    column = 0
                    row += 1
                else:
                    column += 1

        for i in [
                ['+', 0, 3], ["-", 1, 3],
                ['*', 2, 3], ['/', 3, 3],
                ['.', 3, 0], ['=', 3, 2],
                ['CLEAR', 4, 0]
        ]:
            if i[0] == 'CLEAR':
                tkinter.Button(
                    self,
                    text=i[0],
                    padx=pad,
                    pady=pad,
                    command=self.parent.set_to_default
                ).grid(row=i[1], column=i[2], columnspan=4, sticky='nsew')
            elif i[0] == '=':
                tkinter.Button(
                    self,
                    text=i[0],
                    padx=pad,
                    pady=pad,
                    command=self.parent.resolve_operation
                ).grid(row=i[1], column=i[2], sticky='nsew')
            else:
                tkinter.Button(
                    self,
                    text=i[0],
                    padx=pad,
                    pady=pad,
                    command=lambda n=i[0]: self.parent.buttons_handler(n)
                ).grid(row=i[1], column=i[2], sticky='nsew')

Difficulties that still exist:

The button layout code can be more efficient, but I did not understood 100% the suggestions of the old topic, and not feel confident to apply these changes.

\$\endgroup\$

Your Answer

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

Browse other questions tagged or ask your own question.