4
\$\begingroup\$

This is my third version of building a simple Python calculator. The first two versions only operate on two inputs to perform calculation, this version allows multiple inputs. However, the result will leave a .0. Other than that, is there any way I can further improve this program? I've also included docstrings to explain the function of each code block.

import operator
from functools import reduce
import sys

# Display a list of math operators that the user can choose to perform their calculation
MATH_OPERATIONS = """\n\nList of math operations:

1. Addition (+)

2. Subtraction (-)

3. Multiplication (*)

4. Division (/)

5. Exponentiation (**)

"""


# A dictionary that contain options of operations to perform the calculation
OPERATIONS = {
    1: operator.add,
    2: operator.sub,
    3: operator.mul,
    4: operator.truediv,
    5: operator.pow
}


def ask_user_yes_no(yes_no_question):
    """
    Simplifies if/else in determining the correct answers from the user input.
    Returns True if the user answer the prompt with any of the values in choice_yes.
    Returns False if the user enters any of the values in choice_no
    """
    
    choice_yes = ["yes", 'y']
    choice_no = ["no", 'n']

    while True:
        user_choice = input(yes_no_question).lower()

        if user_choice in choice_yes:
            return True
        elif user_choice in choice_no:
            return False
        else:
            print("\n\nInvalid Input. Try again.")


def count_number_input():
    """
    Takes a user input and count the amount of numbers that the user wants to calculate.

    Prints out a message if ValueError occurs.
    """

    while True:
        try:
            count_input = int(input("\nHow many number would you like to calculate?: "))
        except ValueError:
            print("\nINVALID INPUT - Field must not be blank or contained non-integer or non-numerical values.")
        else:
            return count_input


def get_number_list():
    """
    Calls count_number_input function to get how many numbers that the user wants to calculate.

    Iterates over the range of the elements in the function and then
    asks the user to input the number that they would want to calculate.

    Prints out messages if a ValueError occurs.
    """

    input_amount = count_number_input()

    while True:
        try:
            numbers_list = [float(input("\nNumbers: ")) for _ in range(input_amount)]
        except ValueError:
            print("\nInvalid input, try again.")
            print("\nPlease ensure that the prompt does not contain a null or non-integer or non-numerical values.")
        else:
            return numbers_list


def select_choice():
    """
    Prints out a list of math operations.
    Asks the user to select an option to use in the calculation.
    Check user's selection for ValueError and skip if none is found.
    Prints out a message if the user has selected an option beyond the specified range of options.
    """

    print(MATH_OPERATIONS)

    while True:
        try:
            user_choice = int(input("Select an option | 1 | 2 | 3 | 4 | 5 |: "))          
        except ValueError:
            print("\nINVALID INPUT - Field must not be blank or contained non-integer or non-numerical values.\n")
            continue
        
        if user_choice > 5:
            print("\nOption selected must be from 1 to 5 only!\n")
        else:
            return user_choice


def calculate(numbers, choice):
    """
    Applies operations across all numbers and return the result.

    Calculation:

    >>> calculate([2, 2], 1)
    4.0

    >>> calculate([5, 3], 2)
    2.0

    >>> calculate([5, 5] 3)
    25.0

    >>> calculate([9, 3] 4)
    3.0

    >>> calculate([4, 4] 5)
    256.0
    """

    return reduce(OPERATIONS[choice], numbers)


def start_program():
    """
    Starts the program by asking the user the amount of numbers that they want to calculate,
    and then perform a calculation on those numbers.

    Prints out the result of calculation.
    """

    user_number = get_number_list()

    user_choice = select_choice()

    print("\nResult: ", calculate(user_number, user_choice))


def should_calculate_again():
    """
    Calls start_program function to run the program first.

    Asks the user if they want to perform more calculation.

    Restarts the program if ask_user_yes_no returns True.
    Exits the program telling the user that the program has exited
    if ask_user_yes_no returns False.

    """

    while True:

        start_program()

        if not ask_user_yes_no("\n\nWould you like to perform more calculation? (Y/N): "):
            sys.exit("\n\n-----Program Exited-----\n")


should_calculate_again()
\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

Doc test

It looks like you've intended to include "doctests" in your """docstrings""", but it doesn't appear that you've used them.

Running the tests ...

>>> import doctest
>>> doctest.testmod()

... shows all 5 tests fail!

Test failures (wrong output)

Integer input produces integer output, not floating point:

Failed example:
    calculate([2, 2], 1)
Expected:
    4.0
Got:
    4
Failed example:
    calculate([5, 3], 2)
Expected:
    2.0
Got:
    2

Test failures (syntax errors)

You're missing commas in these examples:

Failed example:
    calculate([5, 5] 3)
Exception raised:
    Traceback (most recent call last):
      File "C:\Program Files\Python39\lib\doctest.py", line 1336, in __run
        exec(compile(example.source, filename, "single",
      File "<doctest __main__.calculate[2]>", line 1
        calculate([5, 5] 3)
                         ^
    SyntaxError: invalid syntax
Failed example:
    calculate([9, 3] 4)
Exception raised:
    Traceback (most recent call last):
      File "C:\Program Files\Python39\lib\doctest.py", line 1336, in __run
        exec(compile(example.source, filename, "single",
      File "<doctest __main__.calculate[3]>", line 1
        calculate([9, 3] 4)
                         ^
    SyntaxError: invalid syntax
Failed example:
    calculate([4, 4] 5)
Exception raised:
    Traceback (most recent call last):
      File "C:\Program Files\Python39\lib\doctest.py", line 1336, in __run
        exec(compile(example.source, filename, "single",
      File "<doctest __main__.calculate[4]>", line 1
        calculate([4, 4] 5)
                         ^
    SyntaxError: invalid syntax

Killing the interpreter

If you add a call to doctest.testmod() after the call to should_calculate_again(), you find the doctests never run. The reason: sys.exit() kills the interpreter. Any tests you intend to run cannot run, or never get a chance to report their final status.

Changing sys.exit(...) to print(...) and break statements would terminate the while loop, and should_calculate_again() would return normally.

TL;DR: You almost never need to or want to call sys.exit().

\$\endgroup\$
7
  • \$\begingroup\$ Thank you for your feedback. I didn't know about the doctest module, I thought that the docstring just explains what the code do. So, for the wrong outputs part, I'm still working on returning the right data type based on user input. I have fixed the syntax errors and changed the sys.exit() to the ones you provided. And about sys.exit(), would it still be the same if I just use exit()`? \$\endgroup\$ Aug 18, 2021 at 17:01
  • \$\begingroup\$ The docstring is describing how to use the function. After the module is imported, typing help(calculate) at the Python prompt will present the help information to the user. Including examples of how the function behaves or is used is a great addition to the documentation. As a bonus, it can double as a built-in unit test, via the doctest module. I wouldn't include every last test inside the docstring -- that makes the help text too long and unreadable. External unit tests should be used for complete code coverage. But the tests you do include in the help text should pass. \$\endgroup\$
    – AJNeufeld
    Aug 18, 2021 at 17:18
  • \$\begingroup\$ My test of exit(message) and sys.exit(message) show the same behaviour. One is probably implemented in terms of the other. The point is not to call either of those functions. Instead, let the script natural terminate by returning from the last function that was called and reaching the end of top-level script. Consider a unit test, written in Python, that sets the standard input to "2\n2.0\n3.0\3\nNo\n", captures standard output, calls should_calculate_again() and tries to check the correct output is generated. It would neither pass nor fail because the Python interpreter exited. \$\endgroup\$
    – AJNeufeld
    Aug 18, 2021 at 17:26
  • \$\begingroup\$ Hmm, still new to this doctest module. So, how much is too much to include each test in the docstring? I might've confused myself thinking that docstring is just there to explain the code because I've seen so many examples providing explanation of the function. As for your unit test example, I'm not not sure what do you mean by neither pass nor fail. You would get your result before the program asks if you want to continue or not. \$\endgroup\$ Aug 18, 2021 at 22:13
  • 1
    \$\begingroup\$ See what about exceptions for how to use docktest to test the correct exceptions are raised. But again, is the docktest example with an exception helping a user understand how to use the function??? If not, the test is better off done in a unittest instead of polluting the """docstring""". \$\endgroup\$
    – AJNeufeld
    Aug 19, 2021 at 4:15

Your Answer

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

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