0%

Final Capstone: A Community Lending Library · practice

Enforce State and Design Errors

The library refuses things correctly. It just refuses them badly.

Try checking out an item that is already on loan and read what comes back:

ItemUnavailableError: ('b1', 24)

That is Python’s default: with no constructor of its own, an stores whatever it was handed and prints the . The information is all there, and it is presented as luggage rather than as a sentence.

An exception is an object you designed

Chapter 12 made this and this is where it pays off. An exception is not a label. It is an , it reaches a caller who has to do something about it, and what it carries is a design decision.

Two audiences need different things from the same error.

A person needs a sentence: item ‘b1’ is on loan until day 24. They should not have to know what order the arguments were in.

A program needs the parts: the interface that says “try again after day 24” needs 24 as a number, not as a substring to be parsed back out of English.

A well-made exception serves both, and the way to do it is a constructor that takes the parts, keeps them as attributes, and builds the sentence itself:

class ItemUnavailableError(LibraryError):
    def __init__(self, item_id, due_day):
        super().__init__(f"item {item_id!r} is on loan until day {due_day}")
        self.item_id = item_id
        self.due_day = due_day

super().__init__(...) is what makes str(error) the sentence. The two assignments are what make the parts reachable. Neither substitutes for the other.

Why the call sites do not change

Look at what checkout already writes:

        raise ItemUnavailableError(item_id, self._loans[item_id].due_day)

It has been passing the parts all along. It never built a message, and it never should have: a deep inside the library does not know whether its caller is a web page, a test, or a , so wording is not its business.

That is why this lesson touches only errors.py. The information was already flowing; you are giving it somewhere to live.

Why store due_day as an attribute when it already appears in the message?

The family, and what the base class is for

LibraryError stays exactly as it is: a plain subclass of Exception with a docstring and nothing else.

That is not laziness. Its whole job is to be a name you can catch:

    try:
        library.checkout(item_id, member_id, today)
    except LibraryError as error:
        print(f"Sorry: {error}")

One handler, every refusal, a sentence for each. A caller who wants to say something more specific catches ItemUnavailableError first and reads error.due_day. Chapter 9’s substitution rule is what makes both work at once: every one of the four is a LibraryError, so catching the base can never miss one.

Adding a fifth kind of refusal later means adding a class. It does not mean revisiting anyone’s except clause.

What to build now

Give each of the four a constructor that takes the parts library.py already passes, builds a readable message from them, and keeps the parts as attributes.

Leave LibraryError alone.

Task

Give the four domain errors real constructors, in errors.py.

Each takes the parts library.py already passes, keeps them as attributes of those names, and builds its own message with super().__init__(...):

  • UnknownItemError(item_id): "no item with id 'b1'", attribute item_id.

  • UnknownMemberError(member_id): "no member with id 'm1'", attribute member_id.

  • ItemUnavailableError(item_id, due_day): "item 'b1' is on loan until day 24", attributes item_id and due_day.

  • LoanLimitReachedError(member_id, limit): "member 'm1' already holds 2 item(s)", attributes member_id and limit.

Use !r for the ids, so the quotes make it obvious where an id starts and ends.

Leave LibraryError exactly as it is. Its job is to be the one name that catches all four.