{
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
Breaking a Program into Small Functions
Split one useful program into cohesive calculations and presentation steps with explicit inputs, returned results, side effects, and empty-input policies.
python-foundations
functions-call-behavior
decomposition
side-effects
Course progress
0%
1. One long report is difficult to change safely
Imagine a trail game that records checkpoints reached by each player:
records = [
{"player": "Ari", "distances": [3, 4, 2]},
{"player": "Bo", "distances": [5, 1]},
{"player": "Cy", "distances": []},
]
lines = []
for record in records:
if not record["player"]:
continue
distance = sum(record["distances"])
if distance >= 8:
status = "pathfinder"
elif distance > 0:
status = "explorer"
else:
status = "ready"
lines.append(f'{record["player"]}: {distance} km — {status}')
headline = f"Trail report: {len(lines)} players"
print(headline)
for line in lines:
print(line)The block works, but validation, calculation, classification, formatting, and display are tangled together. Changing the status boundary risks disturbing the format. Reusing the totals without printing means copying part of the block.
This lesson answers four practical questions:
- Where should one function end and another begin?
- Which values should cross each function boundary?
- How does returning data differ from printing or mutating it?
- How can small functions form one understandable program?
2. Describe the transformations before extracting functions
Start with observable examples, not arbitrary function names:
| Input or situation | Required result |
|---|---|
[3, 4, 2] |
total distance 9 |
total 9 |
status "pathfinder" |
name "Ari", total 9, status "pathfinder" |
one complete report line |
| empty distance list | total 0, status "ready" |
| complete source records | unchanged after building the report |
The table reveals a data path:
This is enough decomposition for now. Unit 7 develops a fuller method for ambiguous problems and algorithm choices. Here the problem is understood; the goal is to give its parts useful boundaries.
3. A contract says more than a function name
Before writing a body, record five facts:
| Contract part | Question | Example |
|---|---|---|
| Purpose | What one useful job does it own? | Classify one distance total. |
| Inputs | What values must the caller supply? | A non-negative number. |
| Result | What value comes back? | A status string. |
| Side effects | What outside state changes? | None. |
| Boundary | What happens for zero? | Return "ready". |
The contract for classify_distance can appear as a docstring plus examples:
def classify_distance(distance):
"""Return the trail status for one non-negative distance total."""
if distance >= 8:
return "pathfinder"
if distance > 0:
return "explorer"
return "ready"
assert classify_distance(9) == "pathfinder"
assert classify_distance(6) == "explorer"
assert classify_distance(0) == "ready"The docstring states the supported domain. The assertions provide concrete acceptance evidence. Unit 13 will turn this idea into a systematic pytest test suite; these local assertions simply keep the lesson contract visible.
4. Calculation and presentation deserve different boundaries
Compare these two total functions:
Both calculate. Only total_distance gives data back to its caller:
show_total has a visible side effect but implicitly returns None. It cannot conveniently feed the next calculation. Returning a value keeps options open: the caller may compare it, store it, format it, or eventually print it.
Printing is not wrong. It belongs at the point where display is the intended effect:
The command-style name and docstring make that effect explicit.
5. Pure functions are easy to reuse, but effects still have a place
A pure function produces its result only from its inputs and does not change outside state:
The same inputs give the same result. Contrast a mutation:
Mutation is the stated job, so the contract names it. Unit 6 explains aliases, identity, and copies in detail. For now, inspect both the returned result and the supplied collection whenever a function may mutate.
Hidden global dependencies are harder to see:
A fixed module constant can be reasonable. A changing rule is clearer as an input:
The caller can now see and control the dependency.
Checkpoint: responsibilities and contracts
6. Queries return information; commands perform an effect
A query asks for information:
A command asks the program to do something visible:
The distinction is a design aid, not an absolute law. A file-writing function may both perform an effect and return the number of bytes written. What matters is that its caller can discover both parts of the promise without reading every line of its body.
For beginner programs, calculate first and place the final effect near the outside boundary:
This ordering makes accidental repeated output less likely and lets you inspect the completed string before displaying it.
7. Compose helpers through visible intermediate values
Functions become useful when one returned result becomes another input:
Trace the data, not just the output:
| Step | Expression | Result kept by build_player_line |
|---|---|---|
| 1 | record["distances"] |
[3, 4, 2] |
| 2 | total_distance(...) |
9 in distance |
| 3 | classify_distance(distance) |
"pathfinder" in status |
| 4 | formatted string | complete line returned to caller |
This could be compressed into nested calls, but intermediate names are valuable when each result has meaning or may need inspection. Concision is not the same as clarity.
8. Pass only what a helper needs
Suppose a formatter receives all records even though it uses one name and two summary values. That broad input hides its true contract. Prefer:
Now formatting can be checked without reconstructing an entire game. The caller owns the data flow:
Do not interpret this as “always use many scalar parameters.” A cohesive record can be the right input. The rule is to avoid making a function search unrelated state for the few values it actually needs.
9. Split by reasons to change, not by line count
A kitchen-sink function may validate, calculate, choose policy, format, and print. Those responsibilities change for different reasons. A report format change should not risk its distance calculation.
The opposite extreme also hurts:
If these wrappers merely rename obvious operators once, they lengthen the call chain without adding a meaningful contract. Extraction earns its place when it provides reuse, independent policy, a meaningful name, or an independently checkable transformation.
Use this decision list:
- Can the job be named as one short promise?
- Are its inputs and result narrower than the whole program?
- Might it be reused or checked separately?
- Does it change for a different reason from its neighbors?
One “yes” can be enough. A line-count limit is not required.
Checkpoint: returns and effects
10. A result can include both an answer and a reason
A plain status may not tell a caller why it was chosen. Return related evidence together when the caller needs both:
def classify_with_reason(distance):
if distance >= 8:
return {"status": "pathfinder", "reason": "distance reached 8 km"}
if distance > 0:
return {"status": "explorer", "reason": "distance is between 1 and 7 km"}
return {"status": "ready", "reason": "no distance recorded"}
result = classify_with_reason(9)
assert result["status"] == "pathfinder"
assert result["reason"] == "distance reached 8 km"The dictionary labels the fields. A two-item tuple can also be suitable when the positions are obvious. Avoid returning a long unexplained tuple that forces every caller to memorize positions.
11. Source preservation is part of the contract
The following formatter reads records without changing them:
Record a small snapshot before the call:
Equality is enough for this simple nested fixture. Unit 6 will explain why copying nested mutable data deserves more care.
12. Lab: assemble an expedition report
Build a small pipeline with these contracts:
def valid_record(record):
"""Return whether a record has a non-empty name and a distances list."""
raise NotImplementedError
def summarize_record(record):
"""Return a summary dictionary for one supported record."""
raise NotImplementedError
def format_summary(summary):
"""Return one display line for a summary dictionary."""
raise NotImplementedError
def build_expedition_report(records):
"""Return a complete report string without changing records."""
raise NotImplementedErrorUse this source and keep it unchanged:
expedition = [
{"name": "Ari", "distances": [3, 4, 2]},
{"name": "Bo", "distances": [5, 1]},
{"name": "Cy", "distances": []},
{"name": "", "distances": [100]},
]
expected_report = """Expedition report — 3 travelers
Ari: 9 km — pathfinder
Bo: 6 km — explorer
Cy: 0 km — ready"""
assert valid_record(expedition[0]) is True
assert valid_record(expedition[-1]) is False
assert summarize_record(expedition[0]) == {
"name": "Ari",
"distance": 9,
"status": "pathfinder",
}
assert format_summary({"name": "Cy", "distance": 0, "status": "ready"}) == (
"Cy: 0 km — ready"
)
before = [
{"name": "Ari", "distances": [3, 4, 2]},
{"name": "Bo", "distances": [5, 1]},
{"name": "Cy", "distances": []},
{"name": "", "distances": [100]},
]
assert build_expedition_report(expedition) == expected_report
assert expedition == before
assert build_expedition_report([]) == "Expedition report — 0 travelers"Work from the inner calculations outward. Do not print inside these four functions. After the assertions pass, display the returned report once.
Hint: make each stage return the next stage’s input
Filter with valid_record, transform each valid record with summarize_record, format each summary, and finally join the headline and lines with "\n".join(...).
Show one complete solution after attempting the lab
def valid_record(record):
"""Return whether a record has a non-empty name and a distances list."""
return bool(record.get("name")) and isinstance(record.get("distances"), list)
def summarize_record(record):
"""Return a summary dictionary for one supported record."""
distance = sum(record["distances"])
if distance >= 8:
status = "pathfinder"
elif distance > 0:
status = "explorer"
else:
status = "ready"
return {"name": record["name"], "distance": distance, "status": status}
def format_summary(summary):
"""Return one display line for a summary dictionary."""
return (
f'{summary["name"]}: {summary["distance"]} km — '
f'{summary["status"]}'
)
def build_expedition_report(records):
"""Return a complete report string without changing records."""
summaries = []
for record in records:
if valid_record(record):
summaries.append(summarize_record(record))
headline = f"Expedition report — {len(summaries)} travelers"
lines = [headline]
for summary in summaries:
lines.append(format_summary(summary))
return "\n".join(lines)Checkpoint: composition and preservation
13. Explain the design aloud
Use the completed lab to answer:
- Which helper is a query, and where would a display command belong?
- What precise value crosses each call boundary?
- Which function owns the status policy?
- Why is the source-preservation assertion useful?
- Which two helpers might change for different reasons?
Then modify the pathfinder threshold in one deliberate place. If you must edit several unrelated functions, the responsibility boundary needs another look.
Key points
TipKey points
- A useful function owns one coherent responsibility, not an arbitrary number of lines.
- Contracts state purpose, inputs, returned results, side effects, and important boundaries.
- Returned data composes naturally; printing and mutation are effects that should be explicit.
- Pure functions are especially easy to reuse, but command-style functions are appropriate when their documented job is an effect.
- Intermediate names make a function pipeline observable and easier to explain.
- Split a large function by reasons to change, while avoiding meaningless wrappers around every tiny expression.