{
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
Use Dataclasses for Value-Like Objects
Replace repetitive field-based methods with dataclasses while controlling validation, defaults, representation, equality, frozen updates, and mutable boundaries.
python-foundations
object-oriented-python
dataclasses
equality
Course progress
0%
A map waypoint has a label, row, and column. Two waypoints with the same three values should compare equal, and a printed waypoint should reveal those values. You can implement all of that in a regular class. The question is whether the repetition expresses a unique design or merely repeats a common field-based pattern.
In this lesson you will decide:
- which methods a dataclass should generate;
- which rules still belong to you;
- how equality follows field declarations;
- why mutable defaults need factories;
- when representation should include or exclude a field; and
- what “frozen” protects—and what it does not.
1. Write the repetitive class once
Begin without a decorator:
class Waypoint:
def __init__(self, label, row, column):
self.label = label
self.row = row
self.column = column
def __repr__(self):
return (
f"Waypoint(label={self.label!r}, "
f"row={self.row!r}, column={self.column!r})"
)
def __eq__(self, other):
if not isinstance(other, Waypoint):
return NotImplemented
return (
self.label,
self.row,
self.column,
) == (
other.label,
other.row,
other.column,
)The methods are useful:
__repr__ supplies a developer-oriented representation used by repr, the interactive prompt, and containers. !r asks each field for its representation so strings retain quotes. __eq__ defines value equality for ==.
None of this code validates what makes a coordinate meaningful. Most of it copies field names into predictable positions. That is the repetitive part a dataclass can generate.
2. Let the field declarations drive generated methods
Apply @dataclass:
The annotated lines declare dataclass fields in order. With its default settings, the decorator generates an initializer, representation, and equality method similar to the manual versions.
The signature exposes label, row, and column in declaration order. The representation identifies the class and its field values. Equality is True because all compared fields match.
The annotations are metadata. They improve documentation, editor support, and later static checking, but a dataclass does not enforce them at runtime:
Python creates the instance unless you add runtime validation. Unit 14 explores static type checking; this lesson will add domain validation with __post_init__.
Generated equality has an exact-type boundary
Dataclass equality requires both objects to have the identical concrete class:
The result is False. Matching field names and values do not erase the different meanings of Waypoint and Destination.
Within the same class, every field with compare=True participates in declaration order. Adding a field can therefore change equality. That is an API decision, not a harmless formatting change.
Checkpoint: inspect generated behavior
3. Put defaults after required fields
Fields without defaults must come before fields with defaults:
The constructor now allows both forms:
Placing name: str after discovered: bool = False would make a required parameter follow a defaulted one. The decorator reports a TypeError, matching the parameter-order rule for ordinary functions.
4. Give every instance its own mutable default
A route plan needs a list of stops. This definition is rejected by modern Python dataclasses:
The direct list default would be created once while the class is defined, not once per instance. It is the dataclass version of the shared class-list bug from Lesson 1.
Use field(default_factory=list):
The factory is the callable list, not the result list(). The generated initializer calls it when no value is supplied:
The second list remains empty and the identity check is False.
A factory can also build a non-empty default when that default must be fresh:
Keep the factory deterministic and side-effect free. A factory that reads a file or asks for input would hide important work inside ordinary construction.
Checkpoint: control field defaults
5. Validate generated initialization with __post_init__
The generated __init__ assigns fields, then calls __post_init__ when that method exists. Use it for domain relationships among fields:
@dataclass
class RouteSegment:
start: str
end: str
distance_km: float
def __post_init__(self):
self.start = self.start.strip()
self.end = self.end.strip()
if not self.start or not self.end:
raise ValueError("start and end cannot be blank")
if self.start == self.end:
raise ValueError("start and end must differ")
if self.distance_km <= 0:
raise ValueError("distance_km must be positive")Dataclasses remove method boilerplate; they do not remove invariant design. __post_init__ still needs precise exceptions and boundary examples.
If normalization changes the meaning supplied by the caller, document it. A different design may reject surrounding spaces instead. The class should not silently invent policy just because a hook is available.
6. Shape diagnostic and user-facing representations
Field representations are helpful until one field is noisy or sensitive:
The generated representation omits access_code. This avoids casual display; it is not encryption. The value still exists in memory and can be accessed by code.
Use compare=False only when a field genuinely does not contribute to value meaning:
Two readings with the same sensor and value compare equal even if their notes differ. That may be correct for a transient import note; it would be wrong if the timestamp or unit were essential to the reading’s meaning.
__repr__ serves developers and diagnosis. Add __str__ when a distinct user-facing display is useful:
The diagnostic representation retains class and field names. The user-facing text reads as a sentence. Do not make __repr__ vague solely to look pretty; debugging needs evidence.
7. Freeze field rebinding without promising deep immutability
Coordinates are useful immutable values:
Ordinary field assignment raises FrozenInstanceError:
Create a changed value with dataclasses.replace:
The old value remains unchanged. This style works well for coordinates, configuration values, and result records.
“Frozen” is shallow. A frozen field can still refer to a mutable object:
The field cannot be rebound normally, but the list object itself remains mutable. Use an immutable field value such as a tuple when the whole value must remain stable:
With equality enabled, a mutable dataclass is normally unhashable because changing compared fields after dictionary/set placement would break lookup. A frozen dataclass can receive a generated hash when all compared fields are hashable. A frozen dataclass containing a list is still unhashable. Connect this to Unit 6: hashability depends on the complete value graph, not the decorator’s name alone.
8. Choose between a record and an entity
Use this decision table as a starting point:
| Question | Likely choice |
|---|---|
| Is it only a short local bundle of values? | tuple or dictionary |
Do named fields, useful repr, and field equality express its meaning? |
dataclass |
| Should updates produce a new value? | frozen dataclass plus replace or a method returning a new value |
| Does one identity evolve through several guarded operations? | regular stateful class, possibly a dataclass only if generated field behavior remains honest |
| Would generated equality accidentally claim two entities are interchangeable? | regular class with identity semantics or deliberate equality |
A player in a game may have identity and evolving state; two players with the same name and row are not necessarily interchangeable. A Position(2, 4) is a value; any other Position(2, 4) can mean the same coordinate. That difference matters more than the number of fields.
Do not generate ordering until the domain defines it
@dataclass(order=True) can generate <, <=, >, and >= from the same field tuple used for equality. That convenience is safe only when declaration order matches the domain’s one natural ordering.
Minutes followed by seconds forms the intended comparison key. Now consider a map tile declared as terrain, row, column, danger. Field-tuple ordering would put terrain spelling before location or danger, but the domain has no obvious reason to call forest less than river. Leave ordering disabled and use an explicit key for the particular question:
An explicit key names the current ordering purpose and can change without changing every comparison of the class.
Keep class settings out of generated fields
Only annotated attributes are normally treated as dataclass fields. An unannotated class setting can document a shared fixed rule:
MAX_DANGER does not appear in the initializer, representation, or equality tuple. Later typing lessons introduce ClassVar, which marks that intent for type checkers. For now, inspect the generated signature whenever a class mixes field declarations and shared settings.
The decorator derives mechanical behavior from field declarations. Domain validation, user display, and ordering policy remain explicit design choices.
flowchart LR fields[Annotated fields] --> init[Generated initializer] fields --> repr[Generated representation] fields --> equality[Generated equality] rules[Programmer rules] --> post[Post init validation] rules --> display[Optional user display] rules --> ordering[Explicit ordering decision]
9. Model a safe tile and a changing route plan
Create two types with different semantics:
@dataclass(frozen=True)
class MapTile:
terrain: str
row: int
column: int
danger: int = 0
def __post_init__(self):
"""Normalize terrain and require non-negative coordinates and danger 0–5."""
...
def __str__(self):
"""Return `<terrain> at (<row>, <column>)`."""
...
@dataclass
class RoutePlan:
name: str
tiles: list[MapTile] = field(default_factory=list)
def add(self, tile):
"""Append a MapTile once and return the number of tiles."""
...
@property
def total_danger(self):
"""Return the sum of danger values without storing duplicate state."""
...Checks:
forest = MapTile(" Forest ", 1, 2, danger=2)
same_forest = MapTile("forest", 1, 2, danger=2)
bridge = MapTile("bridge", 1, 3, danger=1)
assert forest == same_forest
assert str(forest) == "forest at (1, 2)"
north = RoutePlan("North")
south = RoutePlan("South")
assert north.tiles is not south.tiles
assert north.add(forest) == 1
assert north.add(bridge) == 2
assert north.add(forest) == 2
assert north.total_danger == 3
assert south.tiles == []
safer = replace(forest, danger=0)
assert safer == MapTile("forest", 1, 2, danger=0)
assert forest.danger == 2
for bad_values in [
("", 0, 0, 0),
("forest", -1, 0, 0),
("forest", 0, 0, 6),
]:
try:
MapTile(*bad_values)
except ValueError:
pass
else:
raise AssertionError(f"should reject {bad_values}")Hint 1
Because MapTile is frozen, normalization in __post_init__ must use object.__setattr__(self, "terrain", cleaned) after validation. This is a controlled initialization technique, not permission for later mutation.
Hint 2
Validate terrain after stripping, require both coordinates to be non-negative, and use 0 <= danger <= 5. RoutePlan.add can use membership, which invokes the tile’s generated equality.
Hint 3
Create each route’s list with default_factory=list. Compute total_danger with sum(tile.danger for tile in self.tiles) rather than storing a second total field.
Compare a complete value-and-entity solution
from dataclasses import dataclass, field, replace
@dataclass(frozen=True)
class MapTile:
terrain: str
row: int
column: int
danger: int = 0
def __post_init__(self):
cleaned = self.terrain.strip().casefold()
if not cleaned:
raise ValueError("terrain cannot be blank")
if self.row < 0 or self.column < 0:
raise ValueError("coordinates cannot be negative")
if not 0 <= self.danger <= 5:
raise ValueError("danger must be between 0 and 5")
object.__setattr__(self, "terrain", cleaned)
def __str__(self):
return f"{self.terrain} at ({self.row}, {self.column})"
@dataclass
class RoutePlan:
name: str
tiles: list[MapTile] = field(default_factory=list)
def add(self, tile):
if not isinstance(tile, MapTile):
raise TypeError("tile must be a MapTile")
if tile not in self.tiles:
self.tiles.append(tile)
return len(self.tiles)
@property
def total_danger(self):
return sum(tile.danger for tile in self.tiles)
forest = MapTile(" Forest ", 1, 2, danger=2)
bridge = MapTile("bridge", 1, 3, danger=1)
north = RoutePlan("North")
assert north.add(forest) == 1
assert north.add(bridge) == 2
assert north.total_danger == 3
assert replace(forest, danger=0).danger == 0MapTile behaves as a value: fields define equality and changes produce another tile. RoutePlan owns a changing sequence and a meaningful add operation. Both use dataclass support, but for different reasons.
Checkpoint: choose value semantics deliberately
10. Key points for dataclass design
- Use a dataclass when declared fields naturally drive initialization, representation, and equality. It is not merely a shorter class syntax.
- Type annotations describe fields but do not enforce runtime types by themselves.
- Generated equality compares fields marked for comparison and requires the same concrete class.
- Use
field(default_factory=...)for a fresh mutable default per instance. __post_init__owns validation and normalization after generated assignment.repr=Falseandcompare=Falsechange important behavior; use them only with a stated reason.__repr__serves diagnosis, while__str__may provide a separate user-facing display.frozen=Trueprevents normal field rebinding, not mutation inside a referenced list or dictionary.- Use
replaceto derive changed frozen values. Hashability still requires all compared fields to be hashable. - A value-like dataclass and a stateful entity answer different design needs, even when both use
@dataclass.