{
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
Keep Objects in Valid States
Turn domain rules into constructor and method checks, make failed updates atomic, and choose plain attributes, properties, methods, and alternate constructors deliberately.
python-foundations
object-oriented-python
invariants
properties
Course progress
0%
A field robot carries a battery rated for 100 energy units. The first class is easy to write:
It is also easy to put into impossible states:
Python accepts -20 because the class has not expressed any rule against it. This lesson turns rules such as “charge stays between zero and capacity” into a public interface that remains trustworthy after successful and failed calls.
Answer these questions as you work:
- What must always be true about a valid object?
- Which operations can change that truth?
- How can a method reject a change without committing half of it?
- When should callers read a property or call a method?
- How can one class support a second input format without duplicating setup?
1. Write the rules before hiding the fields
An invariant is a condition that must be true after construction and after every public operation finishes. For this battery:
- capacity is greater than zero;
- charge is at least zero; and
- charge is no greater than capacity.
Write representative examples before the implementation:
This list prevents an implementation from validating only the pleasant case. The boundary values zero and capacity are valid; values just outside them are not.
Now validate before assigning state:
The underscore in _charge communicates “implementation detail; use the public interface.” It does not make the value secret or inaccessible:
Python prints 60. The underscore is a collaboration convention, not a security boundary. The class must still keep every public operation correct.
Construction should not begin invalid
Try the boundaries deliberately:
The constructor rejects each impossible combination. It validates local inputs before assigning any attributes, which also makes the order easy to reason about.
Checkpoint: state the invariant precisely
2. Give every state change an intention-revealing operation
If callers assign _charge directly, they can bypass the rules. Give common changes names from the battery domain:
class Battery:
def __init__(self, capacity, charge):
if capacity <= 0:
raise ValueError("capacity must be positive")
if not 0 <= charge <= capacity:
raise ValueError("charge must be between 0 and capacity")
self._capacity = capacity
self._charge = charge
def drain(self, amount):
if amount < 0:
raise ValueError("amount cannot be negative")
if amount > self._charge:
raise ValueError("not enough charge")
self._charge -= amount
return self._charge
def recharge(self, amount):
if amount < 0:
raise ValueError("amount cannot be negative")
if self._charge + amount > self._capacity:
raise ValueError("charge would exceed capacity")
self._charge += amount
return self._chargeThe method names describe events, not storage mechanics. drain(8) is clearer than set_charge(get_charge() - 8), and the class gets one chance to validate the whole transition.
List every path that can affect the invariant:
| Path | Precondition | State after success | Failure |
|---|---|---|---|
| construction | positive capacity; charge in range | supplied valid values | ValueError; no usable object |
drain(amount) |
non-negative and available | charge decreases | ValueError; state unchanged |
recharge(amount) |
non-negative and fits | charge increases | ValueError; state unchanged |
If you later add reset, transfer_to, or a property setter, add that path to the audit. An invariant enforced in only one method is not an invariant.
3. Validate the whole operation before committing any of it
Suppose an expedition allocates energy from one battery to another. This implementation mutates too early:
If the source has enough charge but the target lacks capacity, source.drain succeeds before target.recharge fails:
The output is 40 95. Ten units vanished. Each object preserved its local range invariant, but the transfer operation violated the larger rule that energy moves as one transaction.
Put the operation with an owner that can validate both sides before mutation. For now a function is sufficient:
def transfer(source, target, amount):
"""Move energy atomically and return both new charges."""
if amount < 0:
raise ValueError("amount cannot be negative")
if amount > source._charge:
raise ValueError("source lacks charge")
if target._charge + amount > target._capacity:
raise ValueError("target lacks capacity")
source._charge -= amount
target._charge += amount
return source._charge, target._chargeCheck first, commit second:
This is atomic at the model level: the operation either makes the complete valid change or makes no change. It is not a database transaction and does not address threads or processes. It is still a powerful design rule for ordinary Python objects.
Validation is a gate before mutation. A rejected request returns to the same state.
stateDiagram-v2 [*] --> Ready Ready --> Ready: valid drain or recharge Ready --> Ready: invalid request rejected Ready --> Empty: drain remaining charge Empty --> Ready: recharge Ready --> Full: recharge to capacity Full --> Ready: drain
Checkpoint: keep failure atomic
4. Use a property when attribute syntax tells the truth
Callers need to read charge and capacity without depending on underscore names. Read-only properties expose those values:
class Battery:
def __init__(self, capacity, charge):
if capacity <= 0:
raise ValueError("capacity must be positive")
if not 0 <= charge <= capacity:
raise ValueError("charge must be between 0 and capacity")
self._capacity = capacity
self._charge = charge
@property
def capacity(self):
return self._capacity
@property
def charge(self):
return self._charge
@property
def percentage(self):
return self._charge / self._capacity * 100
@property
def is_low(self):
return self.percentage < 20The caller uses attribute syntax:
percentage and is_low are computed from source state. Storing them as separate fields would create synchronization work: every charge change would also need to update two derived fields.
There is no @charge.setter, so ordinary assignment is rejected:
This is useful because the domain operations are drain and recharge, not arbitrary replacement. A property setter would hide which kind of change the caller intends.
Do not manufacture getters and setters
This interface adds ceremony without a rule:
If any text is valid and no behavior accompanies assignment, start with self.text = text. Python can later migrate text to a property while keeping the caller’s label.text syntax.
A validating property setter can be appropriate when replacing the value is the honest operation:
The constructor deliberately assigns through the property, reusing one validation path:
Choose by meaning:
| Interface | Use when |
|---|---|
| plain attribute | direct reading/replacement is valid and needs no rule |
| read-only property | callers need attribute-like derived or protected data |
| property setter | replacing one value through attribute syntax is the honest operation |
| method | the action has domain meaning, arguments, multiple effects, or important failure |
5. Offer alternate construction without duplicating initialization
Suppose configuration stores a battery as "60/100": charge first, capacity second. Do not put parsing branches into every caller. A classmethod can provide a named alternate constructor:
class Battery:
def __init__(self, capacity, charge=0):
if capacity <= 0:
raise ValueError("capacity must be positive")
if not 0 <= charge <= capacity:
raise ValueError("charge must be between 0 and capacity")
self._capacity = capacity
self._charge = charge
@classmethod
def from_text(cls, specification):
"""Build a battery from `<charge>/<capacity>` text."""
charge_text, separator, capacity_text = specification.partition("/")
if not separator:
raise ValueError("specification must be <charge>/<capacity>")
try:
charge = int(charge_text)
capacity = int(capacity_text)
except ValueError as error:
raise ValueError("charge and capacity must be integers") from error
return cls(capacity=capacity, charge=charge)
@property
def charge(self):
return self._charge
@property
def capacity(self):
return self._capacityWhen Battery.from_text(...) is called, Python binds the receiving class to cls, just as an instance method binds an instance to self. Returning cls(...) reuses the primary constructor and supports subclasses that inherit the alternate constructor.
A module-level parse_battery(text) function could also be clear. Use a classmethod when the operation’s main promise is “construct this class through a named alternate format.” Use a function when parsing has a broader responsibility or produces several possible types.
staticmethod exists for a function stored in a class namespace without receiving self or cls. Do not use it merely because a helper is vaguely related. A module-level function is often easier to find and reuse.
6. Make failures and return values part of the interface
Validation is not complete until callers know how failure appears. Use TypeError when an operation receives the wrong kind of value and ValueError when the kind is acceptable but the value violates the domain range. Do not write a broad except Exception inside the object and silently continue with old or partial state.
For example, this helper separates an integer contract from its range:
bool is technically a subclass of int in Python, so the explicit boolean check rejects True as one unit of energy when the domain does not want that surprise. Do not add such checks everywhere by reflex; add them when the public boundary truly promises an integer count.
Return values also belong to the contract. A command-like method can return None, a new state value, or an event record. Pick one meaning and use it consistently. These two methods communicate different questions:
Callers should not have to guess whether is_above mutates or whether add returns the old value. Names, docstrings, examples, and assertions make that contract observable.
When a public operation fails, verify three things separately:
- the exception type and message identify the violated rule;
- protected state matches its before snapshot; and
- a later valid operation still succeeds.
The third check catches objects that leave behind a hidden “busy” flag or other partial transition even when visible fields appear unchanged.
7. Build an energy cell with one trustworthy interface
Implement EnergyCell:
class EnergyCell:
def __init__(self, capacity, charge=0):
"""Create a cell with positive capacity and charge in range."""
...
@classmethod
def from_percentage(cls, capacity, percentage):
"""Create a cell whose charge is the integer percentage of capacity."""
...
@property
def capacity(self):
...
@property
def charge(self):
...
@property
def percentage(self):
...
def drain(self, amount):
"""Remove available charge and return the remaining charge."""
...
def transfer_to(self, other, amount):
"""Move charge atomically and return both remaining charges."""
...Rules:
- capacity is a positive integer;
- charge remains between zero and capacity;
- percentage must be between zero and 100;
from_percentageusesint(capacity * percentage / 100);- transfer validates both cells before either changes;
- public properties are read-only; and
- every rejected operation leaves all involved state unchanged.
Use these checks:
source = EnergyCell.from_percentage(80, 75)
target = EnergyCell(50, 10)
assert source.capacity == 80
assert source.charge == 60
assert source.percentage == 75.0
assert source.drain(5) == 55
assert source.transfer_to(target, 20) == (35, 30)
assert (source.charge, target.charge) == (35, 30)
before = (source.charge, target.charge)
try:
source.transfer_to(target, 30)
except ValueError as error:
assert str(error) == "target lacks capacity"
else:
raise AssertionError("overfilling transfer should fail")
assert (source.charge, target.charge) == before
try:
source.charge = 70
except AttributeError:
pass
else:
raise AssertionError("charge should be read-only")Hint 1
Store _capacity and _charge only after validating both. Properties simply return those source fields; percentage computes rather than stores.
Hint 2
Let from_percentage validate the percentage, calculate charge, and call cls(capacity, charge). Do not duplicate the capacity/charge invariant there.
Hint 3
For transfer_to, validate amount, source charge, and other.charge + amount <= other.capacity before subtracting or adding. Commit the two assignments only after every check passes.
Compare a complete energy-cell implementation
class EnergyCell:
def __init__(self, capacity, charge=0):
if not isinstance(capacity, int) or isinstance(capacity, bool):
raise TypeError("capacity must be an integer")
if capacity <= 0:
raise ValueError("capacity must be positive")
if not 0 <= charge <= capacity:
raise ValueError("charge must be between 0 and capacity")
self._capacity = capacity
self._charge = charge
@classmethod
def from_percentage(cls, capacity, percentage):
if not 0 <= percentage <= 100:
raise ValueError("percentage must be between 0 and 100")
charge = int(capacity * percentage / 100)
return cls(capacity, charge)
@property
def capacity(self):
return self._capacity
@property
def charge(self):
return self._charge
@property
def percentage(self):
return self._charge / self._capacity * 100
def drain(self, amount):
if amount < 0:
raise ValueError("amount cannot be negative")
if amount > self._charge:
raise ValueError("not enough charge")
self._charge -= amount
return self._charge
def transfer_to(self, other, amount):
if amount < 0:
raise ValueError("amount cannot be negative")
if amount > self._charge:
raise ValueError("source lacks charge")
if other.charge + amount > other.capacity:
raise ValueError("target lacks capacity")
self._charge -= amount
other._charge += amount
return self._charge, other._charge
source = EnergyCell.from_percentage(80, 75)
target = EnergyCell(50, 10)
assert source.drain(5) == 55
assert source.transfer_to(target, 20) == (35, 30)The transfer reaches into another instance of the same class to commit the second field only after validation. A larger system could give transfer to a separate service that owns both participants. The important property is the same: one operation validates the complete transition before mutation.
Checkpoint: choose the public interface
8. Key points for trustworthy object state
- State an invariant as a rule that remains true after construction and every public operation—not as validation on only one input path.
- Validate all reasons an operation can fail before committing its first mutation when the operation must be atomic.
- Use underscore-prefixed attributes to communicate a non-public convention, not to claim enforced privacy.
- Prefer intention-revealing methods for domain actions such as draining or transferring energy.
- Use a plain attribute when unrestricted reading and assignment are honest. Use properties for attribute-like computed, read-only, or validated access.
- Compute derived state instead of storing a second value that every mutation must synchronize.
- A classmethod can name an alternate input format and return
cls(...), reusing the primary constructor. __init__initializes valid state and returnsNone.- A failed operation should leave the object—or all participating objects—in a predictable state.