{
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
Changing Objects and Choosing Safe Keys
Predict which built-in operations mutate existing objects or return new values, then choose dictionary keys and set members from stable hash behavior.
python-foundations
mutability-identity-copying
mutability
hashability
Course progress
0%
1. One operation changes an object; another returns a replacement
supplies = ["water", "map"]
alias = supplies
mission_name = "moon trail"
append_result = supplies.append("rope")
upper_name = mission_name.upper()
assert append_result is None
assert supplies == ["water", "map", "rope"]
assert alias == ["water", "map", "rope"]
assert mission_name == "moon trail"
assert upper_name == "MOON TRAIL"list.append mutates the existing list. Its alias observes the change. Strings are immutable: str.upper cannot change the string object, so it returns another string value. The original name still reaches the original lowercase value.
This lesson answers:
- Which common objects can change in place?
- Why do many mutating methods return
None? - Why can
+=mutate one type and rebind another? - What makes a value suitable for dictionary or set lookup?
2. Mutation preserves identity while changing state
The list’s observable contents changed while its identity remained the same. Every reference to that list can observe the updated state.
Rebinding produces a different event:
List concatenation built a new list, then assignment rebound route. The name before still reaches the original object.
3. Common built-in types have different mutation contracts
Use this table as a guide, then read the documentation for the exact operation:
| Common type | Can that object change in place? | Typical transformation |
|---|---|---|
list |
yes | append, item assignment, sort |
dict |
yes | item assignment, update, pop |
set |
yes | add, discard, update |
bytearray |
yes | item assignment, extend |
int, float, complex, bool |
no | arithmetic returns a value |
str, bytes |
no | methods return a value |
tuple, frozenset |
no | operations return a value |
None |
no | used as a singleton sentinel |
User-defined instances are generally mutable unless their design prevents updates. Unit 11 develops classes and custom equality/hash behavior. For now, make decisions with built-in values whose contracts are documented.
“Immutable” does not mean a name can never change. A name may be rebound to a new immutable object:
The integer 2 did not become 3; the expression produced another integer and assignment changed the attempts binding.
4. Mutating methods usually return None
Several standard methods emphasize their effect by returning None:
items = ["map", "rope"]
mapping = {"north": "open"}
visited = {(0, 0)}
assert items.sort() is None
assert mapping.update({"east": "closed"}) is None
assert visited.add((1, 0)) is None
assert items == ["map", "rope"]
assert mapping == {"north": "open", "east": "closed"}
assert visited == {(0, 0), (1, 0)}The returned None prevents an easy confusion between the action and a new collection result. This mistake loses the useful binding:
Repair it by keeping the command separate:
Not every mutating method returns None; always check its contract. The pattern is common enough to predict cautiously, not to invent a universal rule.
Checkpoint: mutation and returned values
5. Returning alternatives preserve the source
Compare methods and expressions that return new values:
numbers = [3, 1, 2]
original_numbers = numbers
ordered = sorted(numbers)
assert ordered == [1, 2, 3]
assert numbers == [3, 1, 2]
assert ordered is not numbers
assert original_numbers is numbers
regions = {"north"}
expanded = regions | {"east"}
assert regions == {"north"}
assert expanded == {"north", "east"}sorted accepts an iterable and returns a list. Set union with | returns a set. Their in-place counterparts have different ownership effects:
Neither style is always preferable. Choose from whether the caller owns the source, whether aliases should observe the update, and whether preserving the old value is useful.
6. += follows the left operand’s type
With a list, augmented assignment normally performs in-place addition:
With a tuple, there is no in-place tuple mutation. A new tuple is returned and the target name is rebound:
Strings and numbers similarly produce new values. Do not classify syntax alone as mutating. Ask what the left operand’s type promises for that operation.
7. An immutable tuple can point to a mutable list
The tuple still contains the same two references in the same positions. Its second reference reaches a list whose state changed. Tuple immutability prevents replacing a tuple slot; it does not freeze every object reachable from the tuple.
This distinction matters for copying and hashability. A nested mutable object can leak state and can make the containing tuple unhashable.
Checkpoint: += and nested mutability
8. Hashability supports reliable lookup
Dictionaries and sets use a hash to locate candidates efficiently, then equality to confirm a matching key. A hashable object promises hash/equality behavior stable enough for its lifetime in the collection.
candidate is a distinct tuple constructed through a different path. It compares equal to (1, 0) and has compatible hash behavior, so it finds the same set member and dictionary entry.
The core invariant is: objects that compare equal must produce the same hash while used as keys. Unequal objects may still have equal hashes; the collection checks equality to resolve such collisions.
9. Common key choices depend on the complete value
| Value | Usually hashable? | Key/set-member result |
|---|---|---|
| string, integer, bytes | yes | commonly suitable |
| tuple of hashable elements | yes | commonly suitable |
| frozenset of hashable elements | yes | suitable when unordered grouping matters |
| list | no | mutable and rejected |
| dictionary | no | mutable and rejected |
| set | no | mutable and rejected |
| tuple containing a list | no | nested list makes the complete tuple unhashable |
Observe supported values:
These calls reveal unsupported boundaries when run separately:
Each uncommented call raises TypeError because the complete value is not hashable. Unit 8 develops systematic exception handling; here the error directly answers the key-suitability question.
10. Hashes are not secure or persistent IDs
hash(value) returns an integer used by hash-based collections. Do not treat it as:
- encryption;
- a password hash;
- a collision-free fingerprint;
- an object’s identity;
- a database identifier; or
- a value guaranteed to remain the same across Python runs.
For example, Python commonly randomizes string hashes between processes. The program-level contract is that a live hashable key remains findable in its collection, not that its printed hash belongs in permanent data.
11. Choose a key from the meaning of the data
A room coordinate fits a tuple because its two positions form one fixed value:
A collection of permissions might fit a frozenset when order does not matter:
Do not convert a list to a tuple only to silence an error if its meaning is supposed to change while stored. Redesign the key as a stable identifier, or keep mutable state in the dictionary value instead.
12. Lab: track an expedition state
Implement four focused operations:
def add_supply_in_place(state, supply):
"""Append supply to state and return None."""
raise NotImplementedError
def with_status(state, status):
"""Return a new outer state with status and shared untouched values."""
raise NotImplementedError
def visit_room_in_place(state, coordinate):
"""Add one hashable coordinate to visited and return None."""
raise NotImplementedError
def observation_counts(observations):
"""Return counts keyed by hashable observation labels."""
raise NotImplementedErrorUse this fixture and evidence:
state = {
"name": "Nova",
"status": "searching",
"supplies": ["water"],
"visited": {(0, 0)},
}
alias = state
assert add_supply_in_place(state, "rope") is None
assert alias["supplies"] == ["water", "rope"]
updated = with_status(state, "ready")
assert updated == {
"name": "Nova",
"status": "ready",
"supplies": ["water", "rope"],
"visited": {(0, 0)},
}
assert updated is not state
assert state["status"] == "searching"
assert visit_room_in_place(state, (1, 0)) is None
assert state["visited"] == {(0, 0), (1, 0)}
assert observation_counts(["rune", "key", "rune"]) == {
"rune": 2,
"key": 1,
}
assert observation_counts([]) == {}with_status changes only an outer immutable field, so a shallow outer copy is enough for this contract. Do not mutate supplies through updated; Lesson 3 will show why that different requirement needs a deeper ownership decision.
Hint: separate in-place commands from returned transformations
The two _in_place functions call mutating collection methods and rely on their implicit None result. with_status can use state.copy() then replace only the status slot. Count observations with a local dictionary whose string keys are hashable.
Show one complete solution after attempting the lab
def add_supply_in_place(state, supply):
"""Append supply to state and return None."""
state["supplies"].append(supply)
def with_status(state, status):
"""Return a new outer state with status and shared untouched values."""
updated = state.copy()
updated["status"] = status
return updated
def visit_room_in_place(state, coordinate):
"""Add one hashable coordinate to visited and return None."""
state["visited"].add(coordinate)
def observation_counts(observations):
"""Return counts keyed by hashable observation labels."""
counts = {}
for observation in observations:
counts[observation] = counts.get(observation, 0) + 1
return countsCheckpoint: hashable expedition state
13. Explain each update and lookup
Use your lab to answer:
- Which functions mutate an object and which return a new outer object?
- Why does a returned
Nonenot mean the in-place commands failed? - Which nested values remain shared by
with_status, and why is that acceptable only under its narrow contract? - Why do equal tuple coordinates find one set entry?
- Why would converting a changing route list into a key be a design problem even if a tuple conversion silenced
TypeError?
Then change with_status so it also appends a supply. Predict the leak through the shallow copy before running. Revert the change; Lesson 3 will implement the appropriate deeper strategy.
Key points
TipKey points
- Mutation changes an existing object’s state; rebinding makes a name reach a different object.
- Common mutating collection methods return
None; keep the command separate from the useful collection binding. - Returning alternatives such as
sortedpreserve their source, while in-place counterparts make aliases observe a change. +=follows the left operand’s type: lists commonly mutate, while tuples, strings, and numbers produce values and rebind the target.- An immutable container can still refer to mutable descendants.
- Hashability supports stable dictionary/set lookup and depends on the complete value, not merely tuple punctuation.
- A hash is not encryption, object identity, or a persistent record identifier.