Advanced collections

Day 24: Exercise Solutions

Python Guru with a screen instead of a face, typing on a computer keyboard with a dark purple background to match the day 24 image.

Here are our solutions for the day 24 exercises in the 30 Days of Python series. Make sure you try the exercises yourself before checking out the solutions!

1) Ask the user for an integer between 1 and 10 (inclusive). If the number they give is outside of the specified range, raise a ValueError and inform them that their choice was invalid.

First things first, we need to actually get some user input and convert it to an integer. We haven't been told to handle any exceptions that arise at this step, but you can if you want to.

number = int(input("Please enter a number between 1 and 10: "))

In order to check the number is within a specified range, we could use a condition like this:

number = int(input("Please enter a number between 1 and 10: "))

if number > 10 or number < 1:
    pass

Alternatively, we can just define a range and see if the number is in that range:

number = int(input("Please enter a number between 1 and 10: "))

if number not in range(1, 11):
    pass

In either case, we want to raise a ValueError inside the body of the conditional statement, like so:

number = int(input("Please enter a number between 1 and 10: "))

if number not in range(1, 11):
    raise ValueError(f"The number must be between 1 and 10. You entered {number}.")

2) Below you'll find a divide function. Write exception handling so that we catch ZeroDivisionError exceptions, TypeError exceptions, and other kinds of ArithmeticError.

The function we've been given looks like this:

divide(a, b)
    print(a / b)

First, we need to handle any ZeroDivisionError exceptions, which we can do like so:

divide(a, b)
    try:
        print(a / b)
    except ZeroDivisionError:
        print("Cannot divide by zero")

Next we need to catch more general TypeError exceptions, which we can do by adding another except clause.

divide(a, b)
    try:
        print(a / b)
    except ZeroDivisionError:
        print("Cannot divide by zero")
    except TypeError:
        print("Both values must be numbers. Cannot divide {a} and {b}")

Finally we need to catch general ArithmeticError exceptions.

The other of this final except clause is important. Make sure you don't place it before the ZeroDivisionError branch, because all instances of ZeroDivisionError are instances of ArithmeticError. Remember that Python is going to check the except clauses one at a time in order, and it will use whichever matching branch it finds first.

divide(a, b)
    try:
        print(a / b)
    except ZeroDivisionError:
        print("Cannot divide by zero")
    except TypeError:
        print("Both values must be numbers. Cannot divide {a} and {b}")
    except ArithmeticError:
        print("Could not complete the division. The numbers were likely too large")

3) Below you'll find an itemgetter function that takes in a collection, and either a key or index. Catch any instances of KeyError or IndexError, and write the exception to a file called log.txt, along with the arguments that caused this issue. Once you have written to the log file, reraise the original exception.

def itemgetter(collection, identifier):
    return collection[identifier]

First things first, I'm going to write another function called log_exception which is going to take care of writing to the file. This is going to make our exception handling a little neater, because we won't have context managers embedded in except clauses, etc.

def log_exception(exception, fn, **kwargs):
    values = ", ".join(f"{key}={value!r}" for key, value in kwargs.items())

    with open("log.txt", "a") as log_file:
        log_file.write(f"Exception: {exception}, Function: {fn}, Values: {values}\n")

Everything here we've seen before, but let's quickly go over it.

Our function has three arguments: exception, fn, and **kwargs. exception is going to take in an exception object, which we're going to grab by naming the specific exception we catch. fn is going to be a string representing the function name, so that we can determine where the exception happened. **kwargs is going to collect any arguments that were passed into that function, and the parameters the values where assigned to.

On the first line of the function body we go through the values in **kwargs and we produce a string representing the keys and values as keyword arguments. For the value we have this special !r which is a way of ensuring we get a representation that matches the way a value appears in code, rather than how it appears to the users if we print them. This means we can distinguish between 4 and "4" in our logs.

Once we have the values, we join them together with commas, and then we write a line to our log file in append mode ("a").

Now let's get to the actual exception handling. In this case we want to do the same thing for both exceptions, so we have some options. We can either catch the broader LookupError, or catch both IndexError and KeyError. In this case, I'm going to go for the latter.

def log_exception(exception, fn, **kwargs):
    values = ", ".join(f"{key}={value!r}" for key, value in kwargs.items())

    with open("log.txt", "a") as log_file:
        log_file.write(f"Exception: {exception}, Function: {fn}, Values: {values}\n")

def itemgetter(collection, identifier):
    try:
        return collection[identifier]
    except (IndexError, KeyError) as ex:
        log_exception(ex, "itemgetter", collection=collection, identifier=identifier)

Now we just need to reraise the exception using the raise keyword.

def log_exception(exception, fn, **kwargs):
    values = ", ".join(f"{key}={value!r}" for key, value in kwargs.items())

    with open("log.txt", "a") as log_file:
        log_file.write(f"Exception: {exception}, Function: {fn}, Values: {values}\n")

def itemgetter(collection, identifier):
    try:
        return collection[identifier]
    except (IndexError, KeyError) as ex:
        log_exception(ex, "itemgetter", collection=collection, identifier=identifier)
        raise

One thing we can do to make this a little better is also handling TypeError exceptions, since this can happen when we pass in a non-integer as an index for a sequence.

def log_exception(exception, fn, **kwargs):
    values = ", ".join(f"{key}={value!r}" for key, value in kwargs.items())

    with open("log.txt", "a") as log_file:
        log_file.write(f"Exception: {exception}, Function: {fn}, Values: {values}\n")

def itemgetter(collection, identifier):
    try:
        return collection[identifier]
    except (IndexError, KeyError, TypeError) as ex:
        log_exception(ex, "itemgetter", collection=collection, identifier=identifier)
        raise

Note

We don't actually do logging like I've shown here in this exercise, because we don't have to. Python has a logging module which does a lot of the work for us, and produces more informative logs.

You can find information and getting started tutorials on logging here.