0%

Errors as Part of Program Design · practice

Create a Domain Exception

Every error so far has been a built-in. Built-ins are the right default, and there is one thing they cannot do.

The problem with borrowing a type

A quiz program raises for a blank response, and also uses int(), which raises ValueError for unparseable text. A caller wanting to handle the first but not the second has no way to tell them apart:

try:
    run_quiz(quiz, responses)
except ValueError:
    ...   # a blank response? a malformed number? something else entirely?

The only way to know is to read the message, and matching on message text is a habit that breaks the first time someone improves the wording.

The fix is a type of your own:

Try it
check("   ")

class QuizError(Exception) is the whole definition. It needs no body beyond a docstring, because what it carries is its identity: a caller can now write except QuizError and catch exactly the failures this program chose to signal, and nothing else.

A small family

One exception type says “this program refused”. A few say what kind of refusal:

Try it

QuestionNotFound inherits from QuizError, so except QuizError catches it. That is what Chapter 9’s substitutability buys here: a caller who cares about the distinction catches the specific one, and a caller who does not catches the base and gets both.

This is a small family. Two or three under one base is almost always enough. A hierarchy with fifteen exception classes is a design nobody can hold in their head, and each one has to earn its place by being caught somewhere different from its siblings.

Why does InvalidResponse inherit from QuizError rather than directly from Exception?

When not to invent one

A custom exception is worth defining when a caller will plausibly want to catch this and not something else. That is the whole test.

Do not define one where a built-in already says it exactly. A wrong type is a TypeError, everywhere, in every Python program ever written. InvalidTypeError teaches your callers a private word for a public idea.

Do not define one per raise site. EmptyPromptError, BlankAnswerError, and NegativePointsError are three names for “this question is not valid”, and nobody will ever catch them separately.

A useful sequence: start with the built-in, and promote to your own type the first time you find yourself needing to catch it apart from something else.

Where they live

An exception type is part of a ’s public interface: callers have to name it to catch it. Put them where callers can reach them without effort, which for the Chapter 10 layout means errors.py at the top of the package, imported by the domain modules that raise them.

That is also why the capstone’s required layout has an errors.py. It is not a filing convention; it is the place a caller looks to find out what this program can refuse to do.

Task

Give the quiz program a small family, and use built-ins everywhere a built-in already says it.

Define QuizError(Exception) as the base, with InvalidResponse and QuestionNotFound under it. Three classes, each needing only a docstring.

Then use them in QuestionBank:

  • question_at(index) raises QuestionNotFound when the is outside the bank, and TypeError unless the index’s type is exactly int. True and False are not positions. A wrong type is a TypeError in every Python program, so it stays one.

  • respond(index, response) raises InvalidResponse for a blank response, and otherwise compares the response after stripping surrounding whitespace and lowercasing it. Return the points for a match, or 0 for a wrong answer.

Finally, run(bank, answers) returns a (score, refused) pair. Here refused is a of exception messages. It catches QuizError once per answer and records each message with str(error), which is the point of having a base. It must not catch TypeError: a wrong type is a bug in the calling code, not a refusal this program is making.