{
const scripts = Array.from(
document.querySelectorAll("script.fcpython-ojs-quiz-config")
);
const script = scripts.find(
(node) => node.dataset.fcpythonRendered !== "true"
);
if (!script) {
return html`<div class="fcpython-quiz fcpython-quiz-warning">
Quiz configuration was not found.
</div>`;
}
script.dataset.fcpythonRendered = "true";
const quiz = JSON.parse(script.textContent);
const container = html`<div class="fcpython-quiz"></div>`;
const title = document.createElement("h3");
title.textContent = quiz.title;
container.appendChild(title);
const instructions = document.createElement("p");
instructions.textContent = quiz.instructions;
container.appendChild(instructions);
const progress = document.createElement("div");
progress.className = "fcpython-quiz-progress";
const counter = document.createElement("p");
counter.className = "fcpython-quiz-counter";
counter.setAttribute("aria-live", "polite");
progress.appendChild(counter);
const tabs = document.createElement("div");
tabs.className = "fcpython-quiz-steps";
tabs.setAttribute("role", "tablist");
tabs.setAttribute("aria-label", "Quiz questions");
progress.appendChild(tabs);
container.appendChild(progress);
const questions = document.createElement("div");
questions.className = "fcpython-quiz-questions";
const feedbackNodes = [];
const questionPanels = [];
const stepButtons = [];
let currentQuestion = 0;
function setStepStatus(questionIndex, status) {
const step = stepButtons[questionIndex];
const question = quiz.questions[questionIndex];
const statuses = {
answered: "answered",
correct: "correct",
incorrect: "incorrect",
unanswered: "not answered",
};
step.classList.remove(
"is-answered",
"is-correct",
"is-incorrect",
"is-unanswered"
);
if (status) {
step.classList.add(`is-${status}`);
}
const statusLabel = status ? `, ${statuses[status]}` : "";
step.setAttribute(
"aria-label",
`Question ${questionIndex + 1}: ${question.prompt}${statusLabel}`
);
}
function showQuestion(questionIndex, focusPanel = false) {
currentQuestion = Math.max(
0,
Math.min(questionIndex, quiz.questions.length - 1)
);
questionPanels.forEach((panel, index) => {
panel.hidden = index !== currentQuestion;
});
stepButtons.forEach((step, index) => {
const isCurrent = index === currentQuestion;
step.classList.toggle("is-current", isCurrent);
step.setAttribute("aria-selected", String(isCurrent));
step.tabIndex = isCurrent ? 0 : -1;
});
counter.textContent = `Question ${currentQuestion + 1} of ${quiz.questions.length}`;
const selected = questionPanels[currentQuestion].querySelector(
"input[type='radio']:checked"
);
const isLastQuestion = currentQuestion === quiz.questions.length - 1;
previous.hidden = currentQuestion === 0;
next.hidden = isLastQuestion || !selected;
check.hidden = !isLastQuestion;
if (focusPanel) {
questionPanels[currentQuestion].focus();
}
}
quiz.questions.forEach((question, questionIndex) => {
const tabId = `${quiz.id}-question-tab-${questionIndex + 1}`;
const panelId = `${quiz.id}-question-panel-${questionIndex + 1}`;
const step = document.createElement("button");
step.type = "button";
step.className = "fcpython-quiz-step";
step.id = tabId;
step.textContent = String(questionIndex + 1);
step.setAttribute("role", "tab");
step.setAttribute("aria-controls", panelId);
step.setAttribute("aria-selected", "false");
step.tabIndex = -1;
step.addEventListener("click", () => showQuestion(questionIndex));
step.addEventListener("keydown", (event) => {
let destination = null;
if (event.key === "ArrowRight") {
destination = (questionIndex + 1) % quiz.questions.length;
} else if (event.key === "ArrowLeft") {
destination =
(questionIndex - 1 + quiz.questions.length) % quiz.questions.length;
} else if (event.key === "Home") {
destination = 0;
} else if (event.key === "End") {
destination = quiz.questions.length - 1;
}
if (destination !== null) {
event.preventDefault();
showQuestion(destination);
stepButtons[destination].focus();
}
});
stepButtons.push(step);
tabs.appendChild(step);
setStepStatus(questionIndex, "");
const panel = document.createElement("div");
panel.className = "fcpython-quiz-panel";
panel.id = panelId;
panel.setAttribute("role", "tabpanel");
panel.setAttribute("aria-labelledby", tabId);
panel.tabIndex = -1;
const fieldset = document.createElement("fieldset");
fieldset.className = "fcpython-quiz-question";
const legend = document.createElement("legend");
legend.textContent = question.prompt;
fieldset.appendChild(legend);
question.options.forEach((option, optionIndex) => {
const label = document.createElement("label");
label.className = "fcpython-quiz-option";
const input = document.createElement("input");
input.type = "radio";
input.name = `${quiz.id}-${question.id}`;
input.value = String(optionIndex);
input.addEventListener("change", () => {
setStepStatus(questionIndex, "answered");
feedbackNodes[questionIndex].textContent = "";
feedbackNodes[questionIndex].className = "fcpython-quiz-feedback";
score.textContent = "";
showQuestion(questionIndex);
});
const text = document.createElement("span");
text.textContent = option;
label.appendChild(input);
label.appendChild(text);
fieldset.appendChild(label);
});
const feedback = document.createElement("p");
feedback.className = "fcpython-quiz-feedback";
feedback.setAttribute("aria-live", "polite");
feedbackNodes.push(feedback);
fieldset.appendChild(feedback);
panel.appendChild(fieldset);
questionPanels.push(panel);
questions.appendChild(panel);
});
container.appendChild(questions);
const actions = document.createElement("div");
actions.className = "fcpython-quiz-actions";
const previous = document.createElement("button");
previous.type = "button";
previous.className = "fcpython-quiz-secondary";
previous.textContent = "Previous";
previous.addEventListener("click", () => {
showQuestion(currentQuestion - 1, true);
});
const next = document.createElement("button");
next.type = "button";
next.textContent = "Next question";
next.addEventListener("click", () => {
showQuestion(currentQuestion + 1, true);
});
const check = document.createElement("button");
check.type = "button";
check.textContent = "Check answers";
const reset = document.createElement("button");
reset.type = "button";
reset.className = "fcpython-quiz-secondary fcpython-quiz-reset";
reset.textContent = "Reset";
const score = document.createElement("p");
score.className = "fcpython-quiz-score";
score.setAttribute("aria-live", "polite");
check.addEventListener("click", () => {
let correctCount = 0;
let firstQuestionToReview = null;
quiz.questions.forEach((question, questionIndex) => {
const selected = container.querySelector(
`input[name="${quiz.id}-${question.id}"]:checked`
);
const feedback = feedbackNodes[questionIndex];
if (!selected) {
feedback.textContent = "Choose an answer before checking.";
feedback.className = "fcpython-quiz-feedback";
setStepStatus(questionIndex, "unanswered");
if (firstQuestionToReview === null) {
firstQuestionToReview = questionIndex;
}
return;
}
const selectedIndex = Number(selected.value);
if (selectedIndex === question.answer_index) {
correctCount += 1;
feedback.textContent = `✅ Correct. ${question.explanation}`;
feedback.className = "fcpython-quiz-feedback is-correct";
setStepStatus(questionIndex, "correct");
} else {
const answer = question.options[question.answer_index];
feedback.textContent = `❌ Not yet. Correct answer: ${answer}. ${question.explanation}`;
feedback.className = "fcpython-quiz-feedback is-incorrect";
setStepStatus(questionIndex, "incorrect");
if (firstQuestionToReview === null) {
firstQuestionToReview = questionIndex;
}
}
});
score.textContent = `Score: ${correctCount}/${quiz.questions.length}`;
if (firstQuestionToReview !== null) {
showQuestion(firstQuestionToReview, true);
}
});
reset.addEventListener("click", () => {
container.querySelectorAll("input[type='radio']").forEach((input) => {
input.checked = false;
});
feedbackNodes.forEach((feedback) => {
feedback.textContent = "";
feedback.className = "fcpython-quiz-feedback";
});
stepButtons.forEach((_, questionIndex) => {
setStepStatus(questionIndex, "");
});
score.textContent = "";
showQuestion(0, true);
});
actions.appendChild(previous);
actions.appendChild(next);
actions.appendChild(check);
actions.appendChild(reset);
container.appendChild(actions);
container.appendChild(score);
showQuestion(0);
return container;
}
FreeCampus Python
Raise Useful Errors and Handle Expected Failures
Raise precise built-in exceptions, catch only anticipated failures at informed boundaries, and preserve causes while adding useful context.
python-foundations
errors-exceptions-debugging
exception-handling
validation
Course progress
0%
A text adventure receives commands such as "OPEN chest" and "TAKE key". Malformed input is expected: a player may omit the target or type an unsupported action. A programming defect is not expected: if movement code uses an undefined name, the game should not pretend the player merely mistyped a command.
Good exception design preserves that difference. It answers four questions:
- What does this function promise to return?
- Which inputs violate that promise, and which exception explains why?
- Where does the program know enough to recover, retry, report, or continue?
- Which failures must remain visible because this layer cannot handle them?
1. Return normal results and raise exceptional failures
Start with a command parser whose normal result is one normalized tuple:
Line by line:
split()turns whitespace-separated input into parts.- The function checks the supported shape before unpacking.
raise ValueError(...)stops this function call when the value violates its contract, even though the input is still a string.- The normal path returns normalized data with one predictable shape.
Do not use a magic return such as None, False, or an empty tuple unless that value is a normal, documented result. A caller can accidentally pass a magic value deeper into the program and lose the original explanation.
Here, not finding a key is an ordinary search result, so None is reasonable. In contrast, a command with three words violates the parser’s accepted shape, so ValueError makes the failed contract explicit.
2. Choose an exception that describes the broken contract
For foundation-level interfaces, built-in exceptions cover many useful cases:
| Exception | Use it when | Example message |
|---|---|---|
TypeError |
the object type is unsupported | command must be a string |
ValueError |
the type is acceptable but its value or shape is unsupported | steps must be positive |
KeyError |
a required mapping key is absent and mapping-style lookup is the contract | Python’s normal missing-key message |
IndexError |
a requested sequence position is outside the supported range | Python’s normal invalid-index message |
RuntimeError |
the operation cannot proceed because of runtime state and no more specific exception fits | quest has not started |
Validate type and value separately when the distinction helps the caller:
parse_repeat_count(3) violates the type contract. parse_repeat_count("0") has the accepted type but violates the range rule. parse_repeat_count("many") lets int raise its own ValueError; a later section adds command context.
Messages should help the reader act without leaking secrets. Mention the field, rule, or position; avoid dumping credentials or an entire private record.
Checkpoint: design the function contract
3. Exceptions travel upward until a caller handles them
When a function raises, its normal path stops. Python leaves that frame and returns control to its caller only if a matching except block exists there. Otherwise, unwinding continues up the call stack.
A low-level parser can report the violation while an outer interaction boundary decides what the player should see.
flowchart BT parser["parse_quest_command raises ValueError"] --> turn["play_turn has no matching handler"] turn --> loop["game loop catches anticipated ValueError"] loop --> report["show feedback and request another command"]
Unexpected exceptions continue past this boundary and remain visible to the developer.
Use a small example to observe propagation:
decode_steps does not need to catch ValueError merely to raise the same thing. build_move cannot recover either. The outer boundary can turn an anticipated conversion failure into player feedback.
Catch an exception only where you can make a responsible decision:
- ask for another value;
- skip one independently invalid item while recording why;
- translate a technical failure into domain context;
- release a resource and re-raise; or
- end one operation cleanly.
If a layer can do none of those, let the exception propagate.
4. Keep the try block as narrow as the claim
This handler is too broad in two ways:
The misspelled .apend raises AttributeError, a programming defect. The broad handler relabels it as player error. The program appears resilient while losing the very evidence needed to repair it.
A better boundary surrounds only the anticipated operation:
Now the except claim is precise: “invalid command shape is expected here.” If inventory code raises AttributeError, the defect stays visible.
WarningA passing program can still be hiding failures
except Exception: pass does not prove recovery. It proves only that many failures were silenced. Handle a specific anticipated type, retain evidence, and keep the protected region narrow.
5. Match specific exceptions before general ones
Handlers are tested from top to bottom. Put more specific responses first:
This example catches two anticipated failures from a deliberately small region. If the result is needed as data rather than UI text, a better design may let the exceptions propagate instead of returning mixed result shapes. Boundary design depends on who calls the function.
You can group exceptions only when the same response is truly appropriate:
Do not group types simply to write fewer lines. Ask whether the caller should react identically and whether the new message remains accurate for each cause.
6. Use else and finally for different jobs
An else suite runs only when the try suite finishes without a matching exception. It keeps success work outside the protected region:
If string formatting in else had a defect, this handler would not mislabel it as an integer-conversion problem.
A finally suite runs whether the protected operation returns, raises, or is handled. Use it for cleanup that must happen:
def demonstrate_cleanup(raw_count):
events = []
try:
events.append("start conversion")
count = int(raw_count)
except ValueError:
events.append("conversion rejected")
else:
events.append(f"converted {count}")
finally:
events.append("close turn record")
return events
print(demonstrate_cleanup("3"))
print(demonstrate_cleanup("many"))Avoid return in finally. A return there can replace a normal return or suppress an active exception, erasing evidence:
Later units use context managers such as with for resource cleanup. The same principle applies: cleanup should not disguise the operation’s outcome.
Checkpoint: place the boundary precisely
7. Add context and preserve the original cause
Low-level exceptions know technical details. Outer layers know domain details. Exception chaining preserves both:
def parse_damage(raw_damage, turn_number):
"""Return positive damage or raise a turn-specific ValueError."""
try:
damage = int(raw_damage)
except ValueError as error:
raise ValueError(
f"turn {turn_number}: damage must be an integer; got {raw_damage!r}"
) from error
if damage <= 0:
raise ValueError(
f"turn {turn_number}: damage must be positive; got {damage}"
)
return damageThere are two ValueError paths, but only conversion chains an underlying cause. A nonpositive integer was parsed successfully; the function itself rejects the domain value, so there is no hidden conversion exception to invent.
Inspect the chain:
Use a bare raise inside a handler when you record evidence but cannot actually recover:
Bare raise preserves the current exception and traceback. raise error can alter traceback details; creating an unrelated new exception discards the causal link unless you chain it explicitly.
8. Assertions check developer assumptions, not public input
An assertion is excellent for an internal condition that should be true if the program is correct:
Do not make assert the only validation for player input:
Assertions can be disabled with Python optimization settings, and AssertionError does not communicate the public input category as precisely. Use explicit exceptions for an interface contract; use assertions to make developer assumptions executable.
9. EAFP and LBYL are choices, not slogans
Python code often uses EAFP: attempt the operation and handle an anticipated exception (“easier to ask forgiveness than permission”). LBYL checks a condition first (“look before you leap”). Neither style means catch everything.
LBYL can clearly express a domain rule:
EAFP can avoid duplicating an operation’s own validation:
Choose based on clarity, supported races or state changes, and the contract. Do not probe with an unreliable check and then assume the later operation cannot fail. Do not catch a broad type merely to call the code “Pythonic.”
Custom exception classes become valuable when callers need to distinguish domain failures that built-in types cannot express cleanly. Unit 11 introduces classes; this unit deliberately practices precise built-in exceptions first.
10. Build a reliable quest command boundary
Use this input set:
Implement the contract in stages:
SUPPORTED_ACTIONS = {"OPEN", "TAKE", "MOVE"}
def parse_quest_command(text, turn_number):
"""Return normalized (action, target) or raise contextual ValueError."""
if not isinstance(text, str):
raise TypeError(f"turn {turn_number}: command must be text")
parts = text.split()
if len(parts) != 2:
raise ValueError(
f"turn {turn_number}: command needs exactly two words"
)
action, target = parts
action = action.upper()
target = target.lower()
if action not in SUPPORTED_ACTIONS:
raise ValueError(
f"turn {turn_number}: unsupported action {action!r}"
)
return action, targetCheck the parser independently:
assert parse_quest_command("open Chest", 1) == ("OPEN", "chest")
assert parse_quest_command("TAKE key", 2) == ("TAKE", "key")
try:
parse_quest_command("TAKE", 3)
except ValueError as error:
assert "turn 3" in str(error)
assert "two words" in str(error)
else:
raise AssertionError("one-word command should be rejected")Then add the boundary that can continue after anticipated command mistakes:
def collect_quest_commands(commands):
"""Return accepted commands and player-readable rejection messages."""
accepted = []
rejected = []
for turn_number, text in enumerate(commands, start=1):
try:
command = parse_quest_command(text, turn_number)
except (TypeError, ValueError) as error:
rejected.append(str(error))
else:
accepted.append(command)
return accepted, rejected
accepted, rejected = collect_quest_commands(quest_commands)
assert accepted == [
("OPEN", "chest"),
("MOVE", "north"),
("TAKE", "key"),
]
assert len(rejected) == 2
assert all("turn" in message for message in rejected)Try these controlled changes:
- replace one string with
None; confirmTypeErroris reported with its turn; - add extra spaces around a valid command; explain why
split()accepts it; - introduce an
AttributeErrorafter parsing and verify this boundary does not hide it; - change the supported-action set and rerun all checks.
Show one way to verify the unexpected-defect boundary
Use a temporary collaborator that fails after parsing, then confirm the failure is not converted into a command rejection:
def apply_parsed_command(command):
"""Represent later game behavior with a deliberate unexpected defect."""
action, target = command
raise AttributeError(f"unfinished handler for {action} {target}")
def run_one_turn(text, turn_number):
try:
command = parse_quest_command(text, turn_number)
except (TypeError, ValueError) as error:
return f"command rejected: {error}"
return apply_parsed_command(command)
try:
run_one_turn("OPEN chest", 1)
except AttributeError as error:
print("unexpected defect remained visible:", error)The parsing handler is still narrow. Remove the deliberate defect after the experiment; it is evidence about the boundary, not finished game behavior.
Checkpoint: preserve the useful failure
11. Key points for debugging
- Return ordinary outcomes; raise when a function cannot honor its documented contract.
- Choose a specific built-in exception and a safe message that names the broken rule or location.
- Let exceptions propagate until a boundary knows how to recover, report, retry, skip, clean up, or terminate responsibly.
- Keep
tryregions narrow and handlers specific so unrelated defects remain visible. - Use
elsefor success work andfinallyfor unavoidable cleanup; do not erase results or exceptions with afinallyreturn. - Chain contextual exceptions with
raise ... from error, and use bareraisewhen recording evidence before re-raising the current exception. - Use explicit validation for public input and assertions for developer assumptions.
References and next steps
- Python tutorial: Handling Exceptions
- Python tutorial: Raising Exceptions
- Python language reference: The
trystatement - Python built-in exceptions
The next lesson begins with a program that raises no exception at all. You will use expected-versus-actual evidence, state traces, minimal cases, and controlled experiments to locate the first wrong decision.