1. An annotation makes an agreement visible
The readable scorer from Lesson 1 still leaves some questions unanswered. Can clues_found be text? Can the function return None? Is found_bonus_clue really a boolean or a string such as "yes"?
Annotations place those expectations in the function signature:
def score_attempt(
clues_found: int ,
elapsed_minutes: int ,
found_bonus_clue: bool ,
) -> int :
clue_points = clues_found * 30
bonus_points = 25 if found_bonus_clue else 0
overtime_minutes = max (0 , elapsed_minutes - 45 )
return max (0 , clue_points + bonus_points - overtime_minutes * 2 )
assert score_attempt(6 , 42 , True ) == 205
Before continuing, answer these questions:
Which values cross the function boundary?
What value leaves after a successful call?
Does : int reject a negative integer?
Does Python automatically convert "6" to 6?
Which tool can compare callers with this declared contract before runtime?
The signature answers the first two. It does not enforce the domain rules or convert input. Python stores annotations as metadata. A static checker, editor, or documentation tool may inspect them, but an ordinary call still receives the objects supplied by the caller.
def repeat_clue(clue: str , times: int ) -> str :
return clue * times
assert repeat_clue("★" , 3 ) == "★★★"
This call is allowed to begin at runtime even though it conflicts with the annotation:
def repeat_clue(clue: str , times: int ) -> str :
return clue * times
bad_times = "3"
Calling repeat_clue("★", bad_times) would raise TypeError at multiplication; the annotation itself does not intercept the call. MyPy can report the mismatch without executing it. The next lesson teaches that diagnostic workflow.
Annotations are inspected by static tools, while runtime execution still uses the supplied objects.
flowchart LR
A[Annotated source] --> B[Static checker]
B --> C[Type diagnostics]
A --> D[Python runtime]
E[Supplied objects] --> D
D --> F[Result or exception]
2. Annotate boundaries, not every obvious temporary value
Parameters and returned values are high-value annotation sites because other code depends on them. A function that performs an action and returns no useful value should state -> None:
def announce_team(team_name: str ) -> None :
print (f"Next team: { team_name} " )
Local values are usually inferred from their expressions:
def overtime_penalty(elapsed_minutes: int ) -> int :
overtime_minutes = max (0 , elapsed_minutes - 45 )
penalty = overtime_minutes * 2
return penalty
A checker can infer that overtime_minutes and penalty are integers. Adding : int to every local repeats information and can make the important boundary harder to see.
Annotate a local when it declares an initially empty collection or resolves an ambiguity:
def collect_team_names(records: list [dict [str , object ]]) -> list [str ]:
team_names: list [str ] = []
for record in records:
team = record.get("team" )
if isinstance (team, str ):
team_names.append(team)
return team_names
Without list[str], an empty list has no element from which a checker can infer the intended item type at that point.
Built-in collections describe their contents
Python 3.10 supports the built-in generic forms used by this course:
team_names: list [str ] = ["Moon Moths" , "Brass Bats" ]
score_by_team: dict [str , int ] = {"Moon Moths" : 205 }
position: tuple [int , str ] = (1 , "Moon Moths" )
coordinates: tuple [int , ...] = (4 , 9 , 12 )
unique_galleries: set [str ] = {"Atrium" , "Clock Hall" }
Read dict[str, int] as “a dictionary whose keys are strings and whose values are integers.” tuple[int, str] describes two fixed positions with different types; tuple[int, ...] describes any number of integer positions.
Checkpoint: annotations and runtime
{
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;
}
3. Say when a result can be absent
A search might find a matching team or find nothing. Do not annotate it as str if None is a real result:
def first_team_over(
scores: dict [str , int ],
minimum_score: int ,
) -> str | None :
for team_name, score in scores.items():
if score >= minimum_score:
return team_name
return None
str | None is a union: the result may be either type. The caller must separate those cases before using string operations:
winner = first_team_over({"Moon Moths" : 205 }, 200 )
if winner is None :
message = "No team reached the threshold"
else :
message = winner.upper()
assert message == "MOON MOTHS"
The is None branch does more than prevent an exception. It narrows the value: a static checker can treat winner as str inside the else block.
Do not use a falsey check when empty text is a valid, distinct result:
def display_search_result(result: str | None ) -> str :
if result is None :
return "No match"
if result == "" :
return "The matching label is empty"
return result
if not result would combine None and "" even though the contract gives them different meanings.
Unions should represent real cases
str | int | float | list[str] | None may be honest for a raw external value, but it is burdensome inside the core of a program. Validate or convert at the boundary so deeper functions receive a smaller, more useful type.
def parse_clue_count(raw_value: object ) -> int :
if isinstance (raw_value, bool ):
raise ValueError ("clue count cannot be boolean" )
if isinstance (raw_value, int ):
return raw_value
if isinstance (raw_value, str ) and raw_value.isdigit():
return int (raw_value)
raise ValueError ("clue count must be an integer" )
assert parse_clue_count("6" ) == 6
assert parse_clue_count(6 ) == 6
object says the boundary may receive any Python object, but only operations valid for all objects are initially allowed. The isinstance branches both validate at runtime and narrow the type for static analysis.
4. Choose the smallest honest collection interface
A parameter annotated list[Attempt] promises that the implementation may need list-specific, mutable behavior. If the function only loops, that contract is unnecessarily narrow.
from collections.abc import Iterable
def total_scores(scores: Iterable[int ]) -> int :
return sum (scores)
assert total_scores([205 , 200 ]) == 405
assert total_scores((205 , 200 )) == 405
assert total_scores(score for score in [205 , 200 ]) == 405
Iterable[int] accepts anything that can produce integers one at a time, including a generator. That flexibility creates a responsibility: an iterable may be single-use. This implementation is wrong for a generator because it tries to traverse twice:
def average_score(scores: Iterable[int ]) -> float | None :
values = list (scores)
if not values:
return None
return sum (values) / len (values)
Materializing once makes the intended two operations honest. For other needs:
Collection[T] supports iteration, len(), and membership;
Sequence[T] adds stable integer indexing and order;
Mapping[K, V] supports read-only key lookup;
MutableSequence[T] or list[T] is appropriate when mutation is part of the contract.
from collections.abc import Mapping, Sequence
def team_score(scores: Mapping[str , int ], team_name: str ) -> int :
return scores[team_name]
def first_team(team_names: Sequence[str ]) -> str | None :
if not team_names:
return None
return team_names[0 ]
Choose from the operations the function actually needs, not from the concrete object used by the initial caller.
Checkpoint: absence and collection contracts
{
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;
}
5. Give structured records a reusable shape
Repeated dict[str, object] annotations say little about available keys. A TypedDict describes a dictionary record while leaving its runtime value as an ordinary dictionary:
from typing import TypedDict
class Attempt(TypedDict):
team: str
clues_found: int
elapsed_minutes: int
bonus_clue: bool
def score_attempt(attempt: Attempt) -> int :
clue_points = attempt["clues_found" ] * 30
bonus_points = 25 if attempt["bonus_clue" ] else 0
overtime_penalty = max (0 , attempt["elapsed_minutes" ] - 45 ) * 2
return max (0 , clue_points + bonus_points - overtime_penalty)
museum_attempt: Attempt = {
"team" : "Moon Moths" ,
"clues_found" : 6 ,
"elapsed_minutes" : 42 ,
"bonus_clue" : True ,
}
assert score_attempt(museum_attempt) == 205
A checker can now report a missing team key or a string assigned to elapsed_minutes. At runtime, museum_attempt remains dict:
assert isinstance (museum_attempt, dict )
Use a dataclass when the record is a Python object with constructor behavior, methods, equality, or controlled mutability:
from dataclasses import dataclass
@dataclass (frozen= True )
class QuestResult:
team: str
score: int
elapsed_minutes: int
result = QuestResult("Moon Moths" , 205 , 42 )
assert result.team == "Moon Moths"
TypedDict is often natural near JSON-shaped dictionaries. A dataclass is usually clearer for a core domain object. Neither validates an untrusted JSON file automatically; Unit 9’s boundary validation still applies.
Use an alias to name a repeated domain type
Python 3.10-compatible aliases can use TypeAlias:
from typing import Literal, TypeAlias
Decision: TypeAlias = Literal["winner" , "runner-up" , "participant" ]
ScoreByTeam: TypeAlias = dict [str , int ]
def decision_for(position: int ) -> Decision:
if position == 1 :
return "winner"
if position == 2 :
return "runner-up"
return "participant"
Literal is useful when a value really is limited to a small closed vocabulary. Do not list hundreds of dynamic strings as literals. A class, enum, or validated string may fit better depending on the domain.
6. Keep external validation and static checking together
Suppose a JSON record enters as dict[str, object]. An annotation claiming it is already Attempt would make the checker quiet by lying about the boundary. Validate first:
def require_attempt(record: dict [str , object ]) -> Attempt:
team = record.get("team" )
clues_found = record.get("clues_found" )
elapsed_minutes = record.get("elapsed_minutes" )
bonus_clue = record.get("bonus_clue" )
if not isinstance (team, str ) or not team.strip():
raise ValueError ("team must be non-empty text" )
if isinstance (clues_found, bool ) or not isinstance (clues_found, int ):
raise ValueError ("clues_found must be an integer" )
if isinstance (elapsed_minutes, bool ) or not isinstance (elapsed_minutes, int ):
raise ValueError ("elapsed_minutes must be an integer" )
if not isinstance (bonus_clue, bool ):
raise ValueError ("bonus_clue must be boolean" )
return Attempt(
team= team,
clues_found= clues_found,
elapsed_minutes= elapsed_minutes,
bonus_clue= bonus_clue,
)
Notice the explicit boolean checks for integer fields. At runtime, bool is a subclass of int, so isinstance(True, int) is true. The domain rejects that otherwise surprising value.
Annotations document the verified result of the boundary. Runtime checks make that claim true. Tests exercise representative behavior. The three forms of evidence reinforce rather than replace one another.
Checkpoint: structured data and validation
{
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;
}
7. Lab: give the museum package an honest contract
Annotate this small package boundary without changing its behavior:
def rank_attempts(attempts):
results = []
for attempt in attempts:
results.append(
{
"team" : attempt["team" ],
"score" : score_attempt(attempt),
"elapsed_minutes" : attempt["elapsed_minutes" ],
}
)
return sorted (
results,
key= lambda result: (
- result["score" ],
result["elapsed_minutes" ],
result["team" ],
),
)
Requirements:
define Attempt with the four fields used earlier;
define a Result record containing team, score, and elapsed_minutes;
accept any reusable, ordered or unordered sequence that the function only iterates; choose and justify the abstract input type;
return list[Result];
preserve alphabetical ascending team names after equal score and elapsed time;
add an optional_winner(results) function returning Result | None;
keep runtime validation outside rank_attempts; it receives verified attempts; and
run the behavior assertions after annotating.
attempts = [
{"team" : "Moon Moths" , "clues_found" : 6 , "elapsed_minutes" : 42 , "bonus_clue" : True },
{"team" : "Brass Bats" , "clues_found" : 7 , "elapsed_minutes" : 50 , "bonus_clue" : False },
]
ranked = rank_attempts(attempts)
assert ranked[0 ]["team" ] == "Moon Moths"
assert optional_winner(ranked) == ranked[0 ]
assert optional_winner([]) is None
Hint A: choose the input interface
The function only iterates over attempts. Iterable[Attempt] is sufficient and allows a list, tuple, or generator. If you intend to promise reusable ordered input to callers, Sequence[Attempt] is also honest but stronger than necessary for this implementation.
Hint B: construct a TypedDict result
After defining class Result(TypedDict), use Result(team=..., score=..., elapsed_minutes=...). It creates an ordinary dictionary while making the intended keys visible to the checker.
Hint C: handle the empty result explicitly
Check if not results: return None before returning results[0]. The check narrows the behavior and prevents IndexError.
Show one complete annotated solution
from collections.abc import Iterable, Sequence
from typing import TypedDict
class Attempt(TypedDict):
team: str
clues_found: int
elapsed_minutes: int
bonus_clue: bool
class Result(TypedDict):
team: str
score: int
elapsed_minutes: int
def rank_attempts(attempts: Iterable[Attempt]) -> list [Result]:
results: list [Result] = []
for attempt in attempts:
results.append(
Result(
team= attempt["team" ],
score= score_attempt(attempt),
elapsed_minutes= attempt["elapsed_minutes" ],
)
)
return sorted (
results,
key= lambda result: (
- result["score" ],
result["elapsed_minutes" ],
result["team" ],
),
)
def optional_winner(results: Sequence[Result]) -> Result | None :
if not results:
return None
return results[0 ]
Iterable is enough for ranking because the function traverses once. Sequence is useful for optional_winner because it checks length/truth and indexes position zero.
Key points
Annotations communicate static expectations; Python does not automatically validate or convert arguments at runtime.
Annotate public boundaries and ambiguous empty values; let a checker infer obvious locals.
Use T | None when absence is real, then handle it explicitly.
Choose Iterable, Collection, Sequence, Mapping, or a concrete mutable type from the operations the function needs.
TypedDict describes dictionary-shaped data; a dataclass creates a runtime domain object.
Validate untrusted values before claiming a narrow type. Types, validation, and tests answer different questions.
Back to top