Python exceptions practice — four programs that don’t break: an enrolment form, a calculator, a grade reader and fractions (and the “12,5” that refused to be a number)

When I built my first “bulletproof” enrolment form, I gave it to a classmate to break. It took her ten seconds: for the entry grade she typed 12,5, with a decimal comma, and the program answered with a bare could not convert string to float, no explanation. It didn’t crash (I had wrapped it in a try), but it didn’t say anything useful either. That is today’s lesson: a robust program is not only one that doesn’t stop; it is one that knows what failed and says so.

In the Python exceptions theory you saw try, except, else, finally, raise and custom exceptions. Today we use them in four complete programs, all of medium level: an enrolment form that keeps asking until the data is valid, a calculator that survives anything you type, a grade reader that reads a file with broken lines and a fractions class with its own family of exceptions. The data looks like real data, with gaps, commas, text where numbers should be and files that don’t exist, because that is exactly where exceptions prove their worth.

Before you start

Make sure you are clear on:

  • That when a line in the try fails, the rest of the try is skipped and Python enters the first matching except.
  • The difference between else (only if there was no exception) and finally (always).
  • That raise ValueError('message') throws an exception of yours and the caller’s except ValueError catches it.
  • How to create a custom exception: class MyError(Exception):.

If any of these feels shaky, review the theory before going on.

None of the programs uses input(): what the person would type goes in a list (TYPED) and the read_line() function prints it as it would appear in the terminal, so you see real output with good and bad data. On your computer, delete TYPED and read_line() and write input(...) wherever it says read_line(...).

Python exceptions practice — Program 1: Robust data validator

The admin office wants an enrolment form: name, age (16 to 99), entry grade (5 to 14, the Spanish university entry scale) and mode (in-person or online). What makes it tricky is that each value can fail in two ways: not being the right type ('twelve' is not an integer) or being the right type but out of range (12 years old). The program must ask again until the value is valid, without ever breaking.

The idea

One function per data type (ask_int, ask_float, ask_option), all with the same pattern: a while True with a try inside. If the value is valid, return (which leaves the loop). If not, the except warns and the loop goes round again. The trick is the range: instead of an if with its own print, we raise a ValueError, so that both failures (int()‘s and the range one) end up in the same except.

Before programming, think about how many ways each value can be wrong. For the age, two (not a number, out of range). For the mode, only one (it is none of the options), so there is no conversion that can fail there.

The code

# What the person would type (on your computer, replace read_line() with input()):
TYPED = iter(['Laura Pérez', 'twelve', '12', '22', '15', '12,5', '12.5', 'virtual', 'In-person'])


def read_line(prompt):
    text = next(TYPED)     # the next 'typed' value
    print(prompt + text)   # we show it as it would look in the terminal
    return text


def ask_int(prompt, minimum, maximum):
    while True:                                      # keeps asking until the value is valid
        try:
            value = int(read_line(prompt))           # ValueError if it is not an integer
            if not minimum <= value <= maximum:
                raise ValueError(f'must be between {minimum} and {maximum}')
            return value                             # good value: we leave the loop
        except ValueError as err:                    # both cases end up here
            print(f'  Error: {err}')


def ask_float(prompt, minimum, maximum):
    while True:
        try:
            value = float(read_line(prompt))
            if not minimum <= value <= maximum:
                raise ValueError(f'must be between {minimum} and {maximum}')
            return value
        except ValueError as err:
            print(f'  Error: {err}')


def ask_option(prompt, options):
    while True:
        try:
            option = read_line(prompt).strip().lower()   # ' In-person' -> 'in-person'
            if option not in options:
                raise ValueError(f'choose one of these: {", ".join(options)}')
            return option
        except ValueError as err:
            print(f'  Error: {err}')


print('=== ENROLMENT FORM ===')
name = read_line('Name: ').strip() or 'No name'
age = ask_int('Age (16-99): ', 16, 99)
grade = ask_float('Entry grade (5-14): ', 5.0, 14.0)
mode = ask_option('Mode (in-person/online): ', ['in-person', 'online'])
print('--- Form saved ---')
print(f'Name:      {name}')
print(f'Age:       {age} years')
print(f'Grade:     {grade:.2f}')
print(f'Mode:      {mode}')

Let’s zoom in on the function that does the heavy lifting:

def ask_int(prompt, minimum, maximum):
    while True:                                      # keeps asking until the value is valid
        try:
            value = int(read_line(prompt))           # ValueError if it is not an integer
            if not minimum <= value <= maximum:
                raise ValueError(f'must be between {minimum} and {maximum}')
            return value                             # good value: we leave the loop
        except ValueError as err:                    # both cases end up here
            print(f'  Error: {err}')

The while True has only one exit: return value, which is reached only if both previous checks pass. int(read_line(prompt)) raises ValueError if the text is not an integer; the chained comparison minimum <= value <= maximum checks the range and, if it fails, raise ValueError(...) throws ours. Both fall into except ValueError as err and err is printed, which is each one’s message.

Output

=== ENROLMENT FORM ===
Name: Laura Pérez
Age (16-99): twelve
  Error: invalid literal for int() with base 10: 'twelve'
Age (16-99): 12
  Error: must be between 16 and 99
Age (16-99): 22
Entry grade (5-14): 15
  Error: must be between 5.0 and 14.0
Entry grade (5-14): 12,5
  Error: could not convert string to float: '12,5'
Entry grade (5-14): 12.5
Mode (in-person/online): virtual
  Error: choose one of these: in-person, online
Mode (in-person/online): In-person
--- Form saved ---
Name:      Laura Pérez
Age:       22 years
Grade:     12.50
Mode:      in-person

Let’s check every lap of the age loop with a trace:

LapTypedint()16 ≤ value ≤ 99?What happens
1'twelve'raises ValueError(not reached)Python’s message, another lap
2'12'12No: 12 < 16Our raise, another lap
3'22'22Yesreturn 22, leaves the loop

The grade follows the same pattern with float(): 15 is above 14, 12,5 is not a number for Python (it uses a decimal point) and 12.5 gets in. The mode accepts In-person because .strip().lower() turns it into in-person before checking.

Retry loop with tryFour steps: read, int, range check and return; both failures go back to reading through except ValueError. Below, the three typed values.ask_int('Age (16-99): ', 16, 99)read_line()int(text)16 ≤ value ≤ 99?return valueexcept ValueErrorexcept ValueError (our raise)typedwhat happensresult'doce'int() failsagain'12'12 < 16: raiseagain'22'22 is finereturn 22
Both errors (int()'s and ours) end in the same except, which asks again: only return leaves the loop.

The diagram is the same loop seen from above: two red arrows going back to read_line() and a single green exit.

Questions you might ask

Why raise a ValueError for the range when I could use a print and a continue? It would work, but you would have two places handling errors. With the raise, everything that makes the value invalid goes through the same except and the loop is shorter. And if tomorrow you want to log the failures to a file, you change one line.

Why is the try inside the while and not outside? If the try wrapped the whole loop, the first error would take you out of the loop and the value would never be asked for again. Inside, each lap has its own try: a failure only spoils that lap.

Why is the message for 'twelve' so technical? Because it is Python’s (invalid literal for int() with base 10). We print err as it is. That is what happened to my classmate with 12,5. In “Try it yourself” you replace it with a friendlier one.

Why does ask_option have a try if nothing in it raises Python exceptions? Because our own raise ValueError does. A try catches Python’s exceptions and yours alike: to it they are all the same.

What if the name arrives empty? read_line('Name: ').strip() would give '', which is falsy, and or 'No name' sets a default. No exception is needed here: nothing “fails”, a value is just missing.

Putting return value outside the try, after the except. If int() fails, value never gets created and the return would raise UnboundLocalError. Return the value inside the try, right when you know it is good.

Try it yourself

  • Make ask_float accept the comma: replace float(read_line(prompt)) with float(read_line(prompt).replace(',', '.')) and check that 12,5 gets in at the first go.
  • Split the two messages: catch int()‘s ValueError in an inner try to print “the age must be a whole number”, and keep the range raise with its own message.

Python exceptions practice — Program 2: Robust calculator

A one-line calculator: you type 10 / 3 and it answers = 3.333…, until you type exit. What makes it tricky is that anything can arrive: numbers that aren’t numbers, operators that don’t exist, half lines, divisions by zero and results so big they don’t fit in a float. Each error must come out with its own message and the calculator must keep working.

The idea

We split the responsibilities. calculate(line) only calculates: it splits the line, checks the format and the operator and, if something is off, raises. It prints nothing. The main loop is the only one that catches, with one except per error family. The operations live in a dictionary of functions, so we don’t need one if per operator.

Before programming, write down which errors Python raises on its own and which ones you have to raise. Dividing by zero or float('ten') are raised by Python. That the line has three pieces or that ^ is not a valid operator are not: those are your rules.

The code

# What the person would type (on your computer, replace read_line() with input()):
TYPED = iter(['10 / 0', 'ten + 3', '10 ^ 3', '8 //', '2 ** 10000', '7 % 0', '2 ** 10', 'exit'])


def read_line(prompt):
    text = next(TYPED)     # the next 'typed' value
    print(prompt + text)   # we show it as it would look in the terminal
    return text


OPERATIONS = {                           # symbol -> function that does the sum
    '+': lambda a, b: a + b,
    '-': lambda a, b: a - b,
    '*': lambda a, b: a * b,
    '/': lambda a, b: a / b,
    '//': lambda a, b: a // b,
    '%': lambda a, b: a % b,
    '**': lambda a, b: a ** b,
}


def calculate(line):
    parts = line.split()
    if len(parts) != 3:                  # '8 //' only has two pieces
        raise ValueError('format: number operator number')
    a, op, b = parts
    if op not in OPERATIONS:
        raise ValueError(f'unknown operator: {op}')
    return OPERATIONS[op](float(a), float(b))   # float() can fail too


print('=== CALCULATOR (type "exit" to quit) ===')
while True:
    line = read_line('> ')
    if line == 'exit':
        print('See you')
        break
    try:
        print('  =', calculate(line))
    except ZeroDivisionError as err:     # raised by Python, not by us
        print('  Math error:', err)
    except OverflowError as err:         # result too big for a float
        print('  Too large:', err)
    except ValueError as err:            # our raises and float()'s
        print('  Error:', err)

The function that decides whether the line is valid:

def calculate(line):
    parts = line.split()
    if len(parts) != 3:                 # '8 //' only has two pieces
        raise ValueError('format: number operator number')
    a, op, b = parts
    if op not in OPERATIONS:
        raise ValueError(f'unknown operator: {op}')
    return OPERATIONS[op](float(a), float(b))   # float() can fail too

line.split() cuts at the spaces: '10 / 0' gives ['10', '/', '0']. If there aren’t three pieces, raise ValueError. If the operator is not in OPERATIONS, another raise ValueError. And the last line does three things that can fail: float(a), float(b) and the operation itself. None of them is protected here, on purpose: they climb up to the loop.

Output

=== CALCULATOR (type "exit" to quit) ===
> 10 / 0
  Math error: float division by zero
> ten + 3
  Error: could not convert string to float: 'ten'
> 10 ^ 3
  Error: unknown operator: ^
> 8 //
  Error: format: number operator number
> 2 ** 10000
  Too large: (34, 'Numerical result out of range')
> 7 % 0
  Math error: float modulo by zero
> 2 ** 10
  = 1024.0
> exit
See you

Each line ends in a different except:

TypedWho raisesExceptionexcept that catches it
10 / 0Python, in a / bZeroDivisionErrorMath error
ten + 3Python, in float('ten')ValueErrorError
10 ^ 3Us: unknown operatorValueErrorError
8 //Us: two piecesValueErrorError
2 ** 10000Python: does not fit in a floatOverflowErrorToo large
7 % 0Python, in a % bZeroDivisionErrorMath error
2 ** 10nobodynoneResult: 2¹⁰ = 1024.0

Notice the ZeroDivisionError messages: they change with the operation (float division by zero, float modulo by zero). Python writes them, which is why we print them with err instead of inventing our own.

Questions you might ask

Why don’t we check b == 0 before dividing, as the old article did? Because Python already raises ZeroDivisionError on its own, with a correct message, for /, // and %. Repeating that check by hand is code that can get it wrong (forgetting %, say) without adding anything.

Why does OverflowError have its own except instead of going with ZeroDivisionError? Both are children of ArithmeticError and we could catch them together with except ArithmeticError, but the message for the user is different: “you can’t divide by zero” is not the same as “the number is too big”.

Why doesn’t calculate have any try? Because it doesn’t know what to do with the error: printing it is the loop’s job. If tomorrow you use calculate from a graphical interface, you will want to show the error in a window, not in the terminal. The function raises; whoever uses it decides.

Does the order of the except clauses matter here? Not between these three, because none is the parent of another (ValueError is not a child of ArithmeticError). But if you added except ArithmeticError before except ZeroDivisionError, the latter would never run.

Putting the result print and calculate in the same try together with more code (reading, checking exit…). The more lines in the try, the harder it is to know what raised the ValueError. Here the try has a single line.

Try it yourself

  • Add square roots with 'sqrt' as a one-number operator (sqrt 16): you will need to accept two pieces for that case and raise ValueError if the number is negative.
  • Also catch KeyboardInterrupt in the loop so that Ctrl+C prints “See you” instead of the Traceback.

Python exceptions practice — Program 3: Robust file reader

The admin office keeps a group’s grades in a text file, one per line. The real file is never clean: there are empty lines, someone wrote eight in letters and there is an 11 that is not a valid grade. On top of that, the file may not exist (the 2027 records aren’t there yet). The program must read what it can, warn about what it can’t and, whatever happens, close the file.

The idea

Two levels of try. The outer one wraps all the work with the file: it catches the file not existing (FileNotFoundError) or not having permission (PermissionError) and, in its finally, closes it. The inner one goes inside the loop and wraps only the conversion of each line: if a line is bad, it warns and moves on to the next, without interrupting the reading. So that the finally can ask whether there is anything to close, file starts as None.

Before programming, separate the errors that affect the whole file (it doesn’t exist) from those that affect a single line ('eight'). Each group goes in a try at its own level.

The code

# We create the sample file (in the admin office it would already exist)
with open('grades.txt', 'w', encoding='utf-8') as f:
    f.write('8.5\neight\n11\n6\n\n9.5\n4\n7\n')


def read_grades(filename):
    file = None                          # so it exists even if open() fails
    try:
        file = open(filename, encoding='utf-8')
        grades = []
        for num, line in enumerate(file, start=1):
            line = line.strip()
            if not line:
                continue                 # empty line: neither an error nor a grade
            try:                         # try INSIDE the loop: a bad line does not stop the reading
                grade = float(line)
            except ValueError:
                print(f'  Line {num} ignored: {line!r} is not a number')
                continue
            if not 0 <= grade <= 10:
                print(f'  Line {num} ignored: {grade} is out of range')
                continue
            grades.append(grade)
        return grades
    except FileNotFoundError:
        print(f'  Error: file {filename!r} does not exist')
        return []
    except PermissionError:
        print(f'  Error: no permission to read {filename!r}')
        return []
    finally:                             # runs with return, with an error and without one
        if file is not None:
            file.close()
            print('  (file closed)')


def report(grades):
    if not grades:
        print('  No grades to analyse')
        return
    passed = sum(1 for n in grades if n >= 5)
    print(f'  {len(grades)} grades | average {sum(grades) / len(grades):.2f} | '
          f'max {max(grades)} | min {min(grades)} | passed {passed}/{len(grades)}')


for name in ['grades.txt', 'grades_2027.txt']:
    print('Reading', name)
    report(read_grades(name))

The heart of the program is the loop, with its inner try:

for num, line in enumerate(file, start=1):
    line = line.strip()
    if not line:
        continue                 # empty line: neither an error nor a grade
    try:                         # try INSIDE the loop: a bad line does not stop the reading
        grade = float(line)
    except ValueError:
        print(f'  Line {num} ignored: {line!r} is not a number')
        continue
    if not 0 <= grade <= 10:
        print(f'  Line {num} ignored: {grade} is out of range')
        continue
    grades.append(grade)

enumerate(file, start=1) gives the line number (starting at 1, as a person counts) along with its text. The try wraps only float(line): it is the only thing that can fail. The range is checked with a normal if, because nothing needs raising here: warning and moving on is enough.

Output

Reading grades.txt
  Line 2 ignored: 'eight' is not a number
  Line 3 ignored: 11.0 is out of range
  (file closed)
  5 grades | average 7.00 | max 9.5 | min 4.0 | passed 4/5
Reading grades_2027.txt
  Error: file 'grades_2027.txt' does not exist
  No grades to analyse

Trace of the loop over grades.txt:

LineTextWhat happensgrades afterwards
18.5Stored[8.5]
2eightValueError, ignored[8.5]
311Out of range, ignored[8.5]
46Stored[8.5, 6.0]
5(empty)continue without warning[8.5, 6.0]
69.5Stored[8.5, 6.0, 9.5]
74Stored[8.5, 6.0, 9.5, 4.0]
87Stored[8.5, 6.0, 9.5, 4.0, 7.0]

Check: 8.5 + 6 + 9.5 + 4 + 7 = 35, and 35 / 5 = 7.00. Everyone passes except the 4: 4/5. Notice that “(file closed)” comes out before the report: finally runs when leaving read_grades, just before return grades hands over the list. With grades_2027.txt, open() fails, file is still None and the finally has nothing to close.

Questions you might ask

Why file = None before the try? Because if open() fails, the variable file would never be created and the finally would raise UnboundLocalError when trying file.close(). In the exercises you’ll see it fail for real.

Isn’t it easier to use with open(...)? Yes, and it is what you will do almost always: with closes the file on its own when the block ends, even if there is an exception. Here we write it with finally so you can see what with does underneath. In the contact book exercise we use with.

Why an if for the range and not a raise? Because we are in the same place where the error is handled. Raising an exception to catch it two lines below would be going the long way round. raise makes sense when the one who detects and the one who handles are different functions, as in program 1.

What would happen without the inner try? Line 2 (eight) would raise ValueError, which is neither FileNotFoundError nor PermissionError, so the outer try wouldn’t catch it: the finally would close the file and the program would stop with the Traceback. Five valid grades lost because of one bad line.

Catching except Exception in the outer try “so it never fails”. A bad line would end up there, an empty list would be returned and you would never know the file had five good grades.

Try it yourself

  • Rewrite read_grades with with open(...) as file: and check that the output is the same (apart from the “file closed” message, which is no longer needed).
  • Count how many lines were ignored and return it along with the grades: return grades, ignored.

Python exceptions practice — Program 4: Class with custom exceptions

A Rational class for fractions such as 3/4. What makes it tricky is that a fraction with denominator 0 doesn’t exist and that adding it only makes sense with another fraction. We want the class itself to prevent that by raising its own exceptions, organised in a family, and the main program to ask for data until it has two valid fractions and adds them. We use a class because that is how it is done in FP2; if you haven’t seen classes yet, keep the idea and come back after the next topic, Python classes.

The idea

Three exceptions: RationalError as the parent, and ZeroDenominatorError and WrongTypeError as children. The __init__ constructor checks the data and raises the right one; if everything is fine, it stores the fraction simplified with the greatest common divisor (gcd). The main program catches the specific child when it wants a specific message, and the parent when any member of the family will do.

Before programming, draw the exception tree on paper. Once you are clear who is whose parent, the order of the except clauses follows.

The code

# What the person would type (on your computer, replace read_line() with input()):
TYPED = iter(['3', '0', 'three', '3', '4', '1', '2'])


def read_line(prompt):
    text = next(TYPED)     # the next 'typed' value
    print(prompt + text)   # we show it as it would look in the terminal
    return text

from math import gcd


class RationalError(Exception):
    """Base of the rational number errors."""


class ZeroDenominatorError(RationalError):
    """The denominator is 0."""


class WrongTypeError(RationalError):
    """Numerator or denominator are not integers."""


class Rational:
    def __init__(self, num, den):
        if not (isinstance(num, int) and isinstance(den, int)):
            raise WrongTypeError('numerator and denominator must be integers')
        if den == 0:
            raise ZeroDenominatorError('the denominator cannot be zero')
        m = gcd(num, den)                 # we simplify: 10/8 -> 5/4
        self.num, self.den = num // m, den // m

    def __str__(self):
        return f'{self.num}/{self.den}'

    def __add__(self, other):             # r1 + r2 calls r1.__add__(r2)
        if not isinstance(other, Rational):
            raise WrongTypeError('only rationals can be added')
        return Rational(self.num * other.den + other.num * self.den, self.den * other.den)


def ask_rational(n):
    num = int(read_line(f'Numerator {n}: '))          # ValueError if it is not an integer
    den = int(read_line(f'Denominator {n}: '))
    return Rational(num, den)                         # RationalError if it is not valid


print('=== ADDING FRACTIONS ===')
while True:
    try:
        r1 = ask_rational(1)
        r2 = ask_rational(2)
        break
    except ZeroDenominatorError as err:
        print('  Rational error:', err)
    except ValueError:
        print('  Error: type whole numbers')
print(f'{r1} + {r2} = {r1 + r2}')
try:
    print(r1 + 0.5)
except RationalError as err:              # the base catches any child
    print(f'  {type(err).__name__}: {err}')

The constructor is the gatekeeper: no bad fraction ever comes to exist.

def __init__(self, num, den):
    if not (isinstance(num, int) and isinstance(den, int)):
        raise WrongTypeError('numerator and denominator must be integers')
    if den == 0:
        raise ZeroDenominatorError('the denominator cannot be zero')
    m = gcd(num, den)                # we simplify: 10/8 -> 5/4
    self.num, self.den = num // m, den // m

If num or den are not integers, WrongTypeError; if den is 0, ZeroDenominatorError. Only if both checks pass is gcd computed and the attributes stored. That way, any Rational in your program is valid: there is no need to check again in every method.

Output

=== ADDING FRACTIONS ===
Numerator 1: 3
Denominator 1: 0
  Rational error: the denominator cannot be zero
Numerator 1: three
  Error: type whole numbers
Numerator 1: 3
Denominator 1: 4
Numerator 2: 1
Denominator 2: 2
3/4 + 1/2 = 5/4
  WrongTypeError: only rationals can be added

The sum, by hand: 3/4 + 1/2 = (3·2 + 1·4) / (4·2) = 10/8, and since gcd(10, 8) = 2, it becomes 5/4. The old article showed 10/8 because it didn’t simplify.

Path of ErrorDenominadorCeroThe call goes down from the main loop to pedir_racional and Racional; the raise climbs back, crosses pedir_racional and the first matching except catches it.The journey of a raise in rational.pywhile True: try:main loopask_rational(1)no tryRational(3, 0)__init__calls (going down)raise ZeroDenominatorErrorback: ask_rational does not catch it, it keeps climbingexcept ZeroDenominatorErroris it this type? Yes: it runsexcept ValueErrornot even checkedThen the while asks for the data again: the program did not break.
A function without try does not stop the exception: it lets it through to its caller.

The diagram follows the first attempt: Rational(3, 0) raises ZeroDenominatorError inside __init__; ask_rational has no try, so the exception goes straight through it and the loop’s except catches it and asks again. The second attempt fails earlier, at int('three'), and except ValueError catches it.

Questions you might ask

Why is the last sum (r1 + 0.5) caught by except RationalError if it raises WrongTypeError? Because WrongTypeError is a child of RationalError. Catching the parent is the way to say “any problem with fractions”, and type(err).__name__ tells us which one it was.

Why don’t we catch WrongTypeError in the loop? Because it can’t happen there: int() always returns an integer or raises ValueError. Catching something that can’t happen only confuses whoever reads the code.

What is the parent class for if we never raise it? For catching as a group. A program using your class can write a single except RationalError and be covered for any fraction error, without knowing every child. And if tomorrow you add a new child, that except already covers it.

And __add__? It is the method Python calls when you write r1 + r2. You’ll see it in depth in the operator overloading topic; here it is enough to know that it can raise exceptions like any other method.

Checking the denominator in the main program instead of in __init__. It works until someone creates a Rational(1, 0) somewhere else. Validation has to live where the object is born.

Try it yourself

  • Add DivisionByZeroError (a child of RationalError) and a __truediv__ method that raises it when dividing by a fraction worth 0.
  • Make Rational(2, -4) keep the sign in the numerator (-1/2) and check the output of print(Rational(2, -4)).

Common mistakes in this practice

  • The try outside the loop: the first error leaves the while and the value is never asked for again.
  • A return after the except: if the try failed, the variable you return doesn’t exist (UnboundLocalError).
  • Repeating what Python already checks: if b == 0: raise ZeroDivisionError is extra code; Python raises it on its own.
  • An except Exception in the file reader: one bad line empties the whole reading without telling you why.
  • Using the file variable in finally without initialising it: if open() fails, file.close() blows up.
  • Catching the parent before the child: the specific except never runs and nobody warns you.

Watch it step by step in Python Tutor

In Python Tutor (choose Python 3) you can see very clearly how a custom exception travels. Paste this program, a minimal version of program 4:

class RationalError(Exception):
    pass


class ZeroDenominatorError(RationalError):
    pass


def create(num, den):
    if den == 0:
        raise ZeroDenominatorError('zero denominator')
    return (num, den)


for den in [4, 0]:
    try:
        r = create(3, den)
        print('Created:', r)
    except RationalError as err:
        print('Caught:', type(err).__name__, err)
    finally:
        print('Next')

Output:

Created: (3, 4)
Next
Caught: ZeroDenominatorError zero denominator
Next

What to watch:

  1. At the start both classes are created in the global frame: they are objects like any other.
  2. In the first lap (den = 4), create returns the tuple (3, 4) and the arrow goes from try to finally, without touching except.
  3. In the second lap, at the raise step, the create frame disappears and the arrow jumps to except RationalError, even though the exception is a ZeroDenominatorError.
  4. The variable r still holds (3, 4) from the previous lap: the second lap’s assignment never finished.
  5. “Next” is printed in both laps: that is the finally.

Summary and next step

  • A loop with a try inside and a return when the value is valid is the pattern for asking until the data is right.
  • raise in the function that detects the problem and except in the one that knows what to do: calculate raises, the loop catches.
  • Let Python raise what it already knows how to raise (ZeroDivisionError, OverflowError) and use its message.
  • Two levels of try with files: the outer for the whole file, the inner for each line.
  • finally (or with) guarantees the file is closed; initialise the variable to None if you use finally.
  • Custom exceptions in families let you catch the specific one or the whole group with a single except.

Checklist: have you practised it?

  • Can you explain why the try in ask_int goes inside the while?
  • Can you say, for each calculator line, who raises the exception, Python or us?
  • Do you understand why “(file closed)” comes out before the report?
  • Could you add a new child to the RationalError family and catch it?
  • Have you run the four programs and checked the outputs?
  • Have you tried the “Try it yourself” tasks without looking at the theory?

If you answered yes to almost all of them, you are ready for the Python exceptions exercises, where I no longer give you the code. See you there!

Next step: exercisesPython exceptions exercises — a unit converter, passwords with their own family of errors and a contact book that always saves (and the finally that closed a file that didn’t exist)

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *