Asking for Validated Input

Written on

In many cases we will want to ask the user for input. Generally we want to make sure the user has entered something helpful. In this post we will describe one way to accomplish this. We will break this task down into three parts:

  1. Asking for the input
  2. Rejecting invalid input
  3. How to ask again

Asking for a number

Let's begin by asking the user for any number between 1 and 10 inclusive.

some_number = float(input("Please enter a number between 1 and 10 inclusive >> "))

This is fine but it's generally bad practice to hard-code numbers into our code. We will alter the above to become

lower = 1
higher = 10

question_to_ask = "Please enter a number between {} and {} >> ".format(lower, higher)
some_number = float(input(question_to_ask))

In this we have used string formatting. Using the variables lower and higher we can now test the user's input

lower = 1
higher = 10

question_to_ask = "Please enter a number between {} and {} >> ".format(lower, higher)
some_number = float(input(question_to_ask))

if some_number < lower or some_number > higher:
    print("This number isn't within the range!")
else:
    print("This number is within the range!")

Great! This will correctly make sure the user enters a number within the range. Let's tidy this up into a function like so

def ask_for_a_number(lower, higher):
    """
    Asks user for a number between lower and higher inclusive
    """

    question_to_ask = "Please enter a number between {} and {} >> ".format(lower, higher)
    some_number = float(input(question_to_ask))

    if some_number < lower or some_number > higher:
        print("This number isn't within the range!")
    else:
        print("This number is within the range!")
        return some_number


answer = ask_for_a_number(1, 10)
print("The user entered {}".format(answer))

Lovely. Our code successfully asks for a number and tests whether it is within the specified range. Even better we've cleverly put the return inside the IF so only valid numbers will be returned. Not entering a valid number will cause

>>>
  Please enter a number between 1 and 10 >> 11
  This number isn't within the range!
  The user entered None

None can be useful but for our purposes this isn't good enough. We want to ask the user again.

Asking Again (and again and again ...)

What we need is a loop. Let's say we're pretty vindictive and the user won't be allowed to continue unless they give us valid input. So we want an infinite loop. We then want to escape this loop if and only if the user gives us a valid answer. Let's add a loop to our above code

def ask_for_a_number(lower, higher):
    """
    Asks user for a number between lower and higher inclusive
    """

    while True:
        question_to_ask = "Please enter a number between {} and {} >> ".format(lower, higher)
        some_number = float(input(question_to_ask))

        if some_number < lower or some_number > higher:
            print("This number isn't within the range!")
        else:
            return some_number

answer = ask_for_a_number(1, 10)
print("The user entered {}".format(answer))

Great all done. When users enter numbers outside this range the next iteration of the loop will begin. In the above the only way to leave the loop is the return statement.

This is good enough for some purposes but not ours! We want to reject all invalid inputs. To do this we will need to cover a new concept.

It's better to ask for forgiveness than permission

We cannot force the user to enter just numbers (short of removing most of the keyboard keys). Therefore we need to catch when they accidentally or otherwise enter something silly. For instance using our current function if the user enters potato we will get

>>>
  Please enter a number between 1 and 10 >> potato
Traceback (most recent call last):
File "/home/mark/tmp.py", line 15, in <module>
answer = ask_for_a_number(1, 10)
File "/home/mark/tmp.py", line 8, in ask_for_a_number
some_number = float(input(question_to_ask))
ValueError: could not convert string to float: 'potato'

Hopefully the unability for float() to turn potato into a number doesn't surprise you. What we need is some way to catch this error and tell the computer its just the user being and idiot and ignore them. For that we need the TRY-EXCEPT. Consider the simple example below

try:
  infinite_division = 10 / 0
except ZeroDivisionError:
  print("That's crazy stop that!")

Here the code inside the try indent is run. If it fails with a ZeroDivisionError, it will run the except block. In this way the program won't crash and it will keep going. For our code we need to reject inputs that aren't numbers. As we showed above, this is a ValueError. So we will alter our code above to take this account

def ask_for_a_number(lower, higher):
  """
  Asks user for a number between lower and higher inclusive
  """

  while True:
      question_to_ask = "Please enter a number between {} and {} >> ".format(lower, higher)
      some_number = input(question_to_ask)
      try:
          some_number = float(some_number)
      except ValueError:
          print("{} is not a number!".format(some_number))
          continue

      if some_number < lower or some_number > higher:
          print("This number isn't within the range!")
      else:
          return some_number

answer = ask_for_a_number(1, 10)
print("The user entered {}".format(answer))

Note that we've moved the float conversion and added the try except cases. The above is now essentially complete. We've rejected invalid inputs - both by tests and by type. Whilst not perfect (the above has at least one bug!)

Let's explore the Try-Except a bit more with a different input from the user.

Asking the user for a valid date

The biggest advantage of using Try-Except is that we don't actually care how the error is thrown. We just care that we caught it. In this way we can use other people's code for our purposes if they have raised errors. For instance let's say we want to ask the user for a date but it must be a weekday. So for example we want both 8/11/2017 and 11/11/2017 to pass as dates. However we want 11/11/2017 to fail as it is a Saturday. Whilst we could do this from scratch, this would be far far too much work for such a simple task.

In many cases in Python there will be a library to help us. There are two options available.

datetime is part of the standard library and so we may prefer to use this as it will just work. In this case if we're sure of the format the user will enter the dates we may simply use something like

from datetime import datetime

new_date = '11/11/2017'

new_date = datetime.strptime('11/11/2017', '%d/%m/%Y')
print(new_date)

With dateutil the Parser will guess the format rather than use having to specify it. For example

from dateutil import parser

new_date = '11/11/2017'

new_date = parser.parse(new_date)
print(new_date)

Even though we will need to install dateutil, it is going to be much easier from a user perspective to use. Okay so how do we figure out what error we need to catch to prevent an invalid date being passed? Simplest way is simply to trigger the error!

from dateutil import parser

new_date = '29/02/2017'  # Hint - it's not a leap year!

new_date = parser.parse(new_date)
print(new_date)

will give something like

>>>
  Traceback (most recent call last):
  File "/home/mark/tmp.py", line 5, in <module>
    new_date = parser.parse(new_date)
  File "/usr/lib/python3/dist-packages/dateutil/parser.py", line 1008, in parse
    return DEFAULTPARSER.parse(timestr, **kwargs)
  File "/usr/lib/python3/dist-packages/dateutil/parser.py", line 404, in parse
    ret = default.replace(repl**)
  ValueError: day is out of range for month

Great so we can alter our code to become

from dateutil import parser

new_date = '29/02/2017'  # Hint - it's not a leap year!

try:
    new_date = parser.parse(new_date)
    print(new_date)
except ValueError as e:
    print("Invalid date!:", e)

Note how we use the as e to catch the specific error thrown by the parser. This way we can tell the user precisely what they did wrong!

So let's ask the user for a date and get a try-except clause to trigger on a ValueError. Here's code that will perform this task

from dateutil import parser

def ask_for_a_weekday_date():
    """
    Asks for a valid date that happens to be a weekday
    """
    question_to_ask = "Please enter a date >> "

    while True:
      new_date = input(question_to_ask)
      try:
        new_date = parser.parse(new_date)
      except ValueError as e:
        print("Invalid date!:", e)
        continue

      if new_date.weekday() < 5:  # 5 and 6 correspond to saturday and sunday
        return new_date
      else:
        print("This isn't a weekday!")

Here we are using the weekday() function. This will return 0 to 6 depending on the day of the week, pretty neat!

Summary

Ensuring the user enters valid input is essential for keeping our code from crashing in various inconvenient ways. In this post we've covered a pattern to perform this task and explored this using numbers and dates.

Questions

  1. The validate_number function will fail for some values of lower and higher. How can we alter the code to prevent this?
  2. In pseudo-code can you outline the abstract pattern for input, rejecting invalid types and rejecting input that fails tests?
  3. Write code that will reject invalid IP addresses.
  4. Ask for N letter works containing a vowel.
  5. (Harder) Write the abstract function that takes invalid types and tests as inputs.