Programming with Python › Files, Errors, and Object-Oriented Python › Day 69
Hands-on lab — Day 69: Dataclasses and Type Hints
- ← Back to the Day 69 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/programming-with-python/day-069-dataclasses-and-type-hints/
Commands
Setup
cd labs/sections/programming-with-python/day-069-dataclasses-and-type-hints
python3 --version Run
cat examples/records.py
python3 examples/demo.py
python3 examples/inspect_runtime.py
bash examples/check_types.sh # optional; skips cleanly with no type checker
python3 examples/scoring.py
python3 starter/demo.py Test
bash tests/run_tests.sh File tree
examples/check_types.sh examples/demo.py examples/inspect_runtime.py examples/mini_dataclass.py examples/records.py examples/scoring.py expected-output/sample-run.txt expected-output/test-run.txt metadata.yml README.md requirements/README.md security.md starter/demo.py starter/mini_dataclass.py starter/records.py tests/run_tests.sh troubleshooting.md
Lab README
Day 069 lab — Records That Write Themselves
Lesson
- Lesson title: Dataclasses and Type Hints
- Day number: 69 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-069-dataclasses-and-type-hints
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-069-dataclasses-and-type-hintswhen the site is running.
Purpose
On Day 67 you wrote a record class by hand, and on Day 68 you gave it dunder
methods. If you did that honestly you noticed the tedium: for a class holding
three pieces of data you wrote __init__, __repr__ and __eq__, and typed
the same field names into each one.
This lab measures that tedium and then deletes it. You start with
HandWrittenRecord — 22 lines, 18 of them code, with the name prompt
appearing in five distinct places — and rebuild it as a five-line dataclass
that behaves identically. Then you meet the parts @dataclass does not
generate: a per-instance list default, validation, a derived field, and a
frozen variant that is safe to use as a dictionary key.
The second half is about the annotations themselves. examples/scoring.py
contains two deliberate contradictions between what its annotations promise
and what its code does, and examples/inspect_runtime.py proves the point
that makes them possible: Python stores every annotation and checks
none of them. You assign a string to a field annotated float and watch
nothing happen.
Finally you rebuild @dataclass yourself. About forty lines, reading
__annotations__ and generating three methods, checked field-for-field
against the real decorator. After that it stops being magic.
Learning objectives
- Convert a hand-written record class into a dataclass and confirm the
generated
__repr__and__eq__behave the same as the ones you wrote. - Trigger the mutable default trap in both its forms — silently shared in a
plain function signature, refused with a
ValueErrorin a dataclass — and fix it withfield(default_factory=list). - Add
__post_init__validation and a derived field that is not a constructor parameter. - Build a frozen, ordered dataclass and use it as a dictionary key, then
watch
FrozenInstanceErrorrefuse an assignment. - Round-trip records through JSON with
asdict, rebuild them, and prove the trip was lossless using the generated__eq__. - Demonstrate at runtime that annotations are stored in
__annotations__and read bydataclasses.fields(), yet never enforced against any value. - Implement a
mini_dataclassdecorator from scratch and verify it against the real@dataclass.
Prerequisites
- The Day 69 lesson (read it first — it walks these exact cases).
- Day 68: dunder methods, especially
__eq__and__hash__, and why defining__eq__costs you the inherited hash. - Day 67: classes,
__init__, and attributes. - Day 66: raising
ValueErrorandTypeErrordeliberately. - Day 65:
json.dumpsandjson.loads. - Days 57-63: functions, decorators, and modules.
- A text editor and a terminal. Nothing beyond this course is assumed.
Supported operating systems
- macOS — fully supported (tested on macOS 26.5.1, Apple Silicon, Python 3.14.0).
- Linux — fully supported (any distribution with Python 3 and bash).
- Windows — use WSL and follow the Linux path, or substitute
pythonforpython3. Nothing in this lab touches paths, encodings, or line endings, so behaviour is identical everywhere.
Hardware requirements
Any computer that runs Python 3. Every file here is a few kilobytes of source and the lab creates no data files at all. No special memory, disk, or GPU.
Required software
python3(3.10 or newer — the lab useslist[str]field annotations and modern dataclass behaviour; tested on 3.14.0).bashfor the test runner (preinstalled on macOS and Linux).- Standard library only —
dataclasses,json,sys, andpathlib. Nothing to install. Seerequirements/README.md. - Optional and not required: a static type checker (mypy or pyright) for
one optional step. None is installed in this lab's environment, and
examples/check_types.shhandles its absence cleanly.
Free and open-source options
Everything here is free and open source: Python, bash, and the standard
library. No account, API key, network access, or purchase is needed.
dataclasses and typing ship with Python itself. The optional type
checkers, mypy and pyright, are both free and open source too — pyright is
also the engine behind the Pylance extension for VS Code — but this lab
deliberately requires neither.
Installation
None beyond Python itself. Move into this directory and you are ready:
cd labs/sections/programming-with-python/day-069-dataclasses-and-type-hints
python3 --version # confirm Python 3.10+ is available
File structure
day-069-dataclasses-and-type-hints/
├── README.md ← you are here
├── metadata.yml ← machine-readable lab metadata
├── starter/
│ ├── records.py ← YOUR working file (exercises 1–6)
│ ├── mini_dataclass.py ← YOUR working file (exercise 7)
│ └── demo.py ← given driver; runs YOUR code, exercise by exercise
├── examples/
│ ├── records.py ← complete reference: hand-written vs dataclass, validation, JSON
│ ├── mini_dataclass.py ← complete reference: @dataclass rebuilt in ~40 lines
│ ├── demo.py ← guided tour of the reference (exercises 1–5 and 7)
│ ├── inspect_runtime.py ← exercise 6: what Python stores, and what it ignores
│ ├── scoring.py ← an annotated file with two deliberate type errors
│ └── check_types.sh ← OPTIONAL: runs a type checker if one exists, skips cleanly if not
├── tests/
│ └── run_tests.sh ← behaviour checks; exits 0 only if all pass
├── expected-output/
│ ├── sample-run.txt ← real captured session with the reference
│ └── test-run.txt ← real captured run of the test suite
├── requirements/
│ └── README.md ← dependency statement (Python 3 only)
├── troubleshooting.md
└── security.md
This lab writes no files at all. There is nothing to clean up but Python's own bytecode cache.
How to run
From this directory:
## 1. Read the two record classes side by side — count the lines.
cat examples/records.py
## 2. See the finished reference: exercises 1-5 and 7 in one guided tour.
python3 examples/demo.py
## 3. Exercise 6 — the proof that annotations are stored but never enforced.
python3 examples/inspect_runtime.py
## 4. OPTIONAL. Runs a type checker over examples/scoring.py if one is
## installed, and explains what it would find if not. Exits 0 either way.
bash examples/check_types.sh
## 5. See which of scoring.py's two planted bugs Python itself notices.
python3 examples/scoring.py
## 6. Your task: complete exercises 1-6 in starter/records.py and
## exercise 7 in starter/mini_dataclass.py, then run your version.
python3 starter/demo.py
## 7. Check your work.
bash tests/run_tests.sh
What the commands do
cat examples/records.py— showsHandWrittenRecord(22 lines, 18 of code) immediately aboveEvalRecord, the dataclass that replaces it. The comment above the hand-written class asks you to count how many times the namepromptappears; the answer is five distinct places, seven occurrences.python3 examples/demo.py— runs six labelled sections: the hand-written class beside the dataclass; the mutable default trap in both forms;__post_init__validation and the derivedprompt_length; a frozenRunKeyused as a dictionary key and sorted; a JSON round trip viaasdictwithreplace()making a validated copy; and the from-scratchmini_dataclasscompared against the real one. Every line is deterministic — no clocks, no random numbers, no memory addresses — so it matchesexpected-output/sample-run.txtcharacter for character.python3 examples/inspect_runtime.py— printsEvalRecord.__annotations__and theFieldobjects fromdataclasses.fields(), then assigns a string to a field annotatedfloatand an integer to one annotatedlist[str], and calls a function annotated(number: int) -> intwith the string'ab'. Nothing raises. That is the whole lesson in one script.bash examples/check_types.sh— looks formypyorpyrighton yourPATH, then for an importablemypymodule. If it finds one it runs it overexamples/scoring.pyand reports the result. If it finds none it says so and describes the two planted errors without inventing a message. It exits 0 in both cases, because this lab requires no checker.python3 examples/scoring.py— runs the file with the two planted bugs. Only one of them crashes. That asymmetry is the argument for a checker: one bug costs you a traceback, the other costs you a wrong value that travels silently.python3 starter/demo.py— the same tour, driven by the functions you write. Each exercise runs on its own, so an unfinished one prints a note instead of stopping the script.bash tests/run_tests.sh— real behaviour assertions, not file-existence checks: generated__repr__and__eq__, the mutable-defaultValueError,__post_init__validation, frozen hashing andFrozenInstanceError, a JSON round trip, runtime introspection, and the from-scratch decorator. Exits 0 only if every check passes.
Expected output
See expected-output/sample-run.txt — a
real captured session. The heart of it:
$ python3 examples/demo.py
1. Hand-written class vs dataclass
----------------------------------
hand-written repr: HandWrittenRecord(prompt='2+2?', expected='4', score=0.0)
dataclass repr: EvalRecord(prompt='2+2?', expected='4', score=0.0, tags=[])
hand-written equal by value: True
dataclass equal by value: True
dataclass differs when a field differs: False
2. The mutable default trap
---------------------------
plain function, call 1: ['math']
plain function, call 2: ['math', 'logic'] <- the list was shared
dataclass refuses it: ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory
default_factory gives each instance its own list:
first.tags = ['math']
second.tags = []
Note the contrast in section 2: the plain function shares one list and says nothing, while the dataclass refuses at class-creation time and names the fix.
The frozen record, and the proof it is hashable:
4. A frozen dataclass is hashable and orderable
-----------------------------------------------
key: RunKey(suite='arithmetic', seed=7)
equal keys hash the same: True
usable as a dict key: 12
sorted by suite then seed: [RunKey(suite='arith', seed=1), RunKey(suite='arith', seed=9), RunKey(suite='geo', seed=2)]
assignment refused: FrozenInstanceError: cannot assign to field 'seed'
And the runtime proof that annotations are recorded but never applied:
$ python3 examples/inspect_runtime.py
dataclasses.fields(EvalRecord):
prompt: str = (required)
expected: str = (required)
score: float = 0.0
tags: list[str] = factory list()
prompt_length: int = 0
What Python does NOT check
--------------------------
assigned a str to .score -> 'not a number'
assigned an int to .tags -> 17
double.__annotations__ names: ['number', 'return']
double('ab') -> 'abab'
The optional checker step, captured on the authoring machine where no checker is installed — a normal result for a lab that installs nothing:
$ bash examples/check_types.sh
No static type checker is installed, so this optional step is skipped.
This lab needs no installs and no network, so that is a normal result.
A checker reads examples/scoring.py WITHOUT running it and reports
the two planted contradictions between the annotations and the code:
* label() promises to return str but returns record.score, a float
* main() passes one EvalRecord where mean_score wants list[EvalRecord]
Everything is deterministic, so your output will match — except that
check_types.sh will print the checker's own report instead if you happen to
have mypy or pyright installed.
Validation steps
python3 examples/demo.pyexits 0 and section 1 shows the hand-written and dataclass reprs differing only in the class name and the extratagsfield.- Section 2 shows the plain function returning
['math', 'logic']on its second call — the shared list — and the dataclass raisingValueErrorwith a message containinguse default_factory. - Section 3 reports
derived prompt_length: 18and rejects both the blank prompt and the score of 3.0, with the offending3.0in the message. - Section 4 reports
equal keys hash the same: True,usable as a dict key: 12, and refuses the assignment withFrozenInstanceError. - Section 5 reports
rebuilt == original: True— a value-equality assertion that only works because@dataclassgenerated__eq__. - Section 7 reports
same repr shape (fields and formatting): True, meaning your from-scratch decorator formats identically to the real one. python3 examples/inspect_runtime.pyexits 0 having assigned a string to afloatfield and an integer to alist[str]field with no error.bash examples/check_types.shexits 0 and states clearly whether a checker was found.- Complete exercises 1-7 in
starter/, runpython3 starter/demo.py, and confirm it matches the reference. - Run the tests (next section) — every check must pass.
Tests
bash tests/run_tests.sh
Expected final line while the starter is unfinished:
33 checks, 0 failure(s). The suite always holds the reference in
examples/ to a strict standard; while your starter/ files still contain
NotImplementedError it checks their structure only. Once you finish all
seven exercises, the suite runs the same strict behaviour checks against your
files too, so the check count rises. The command exits 0 on success and
non-zero on any failure, so it can run in CI. A full captured run is in
expected-output/test-run.txt.
Cleanup
This lab creates no data files and no output directory — there is nothing to
delete. Python may leave a bytecode cache behind; the test runner sets
PYTHONDONTWRITEBYTECODE=1 so it does not, but if you ran the scripts
directly you can remove it:
rm -rf examples/__pycache__ starter/__pycache__
To reset your work, restore the starter from git: git checkout -- starter/.
Troubleshooting
See troubleshooting.md for the full list: the
ValueError on a mutable default (which is the exercise working), TypeError: non-default argument follows default argument, a __post_init__ that never
runs, TypeError: unhashable type on a record used as a dict key,
FrozenInstanceError when you meant to update a field, the very common
"my annotation does not work" (it is not supposed to), what to do when
check_types.sh reports no checker installed, and the NotImplementedError
the starter raises by design.
Security notes
See security.md. Short version: a type hint is not a
validation and must never be treated as one at a trust boundary — a checker
reasons about your source, never about the value that actually arrived, so
__post_init__ is the only gate that runs. The generated __repr__ prints
every field, so a secret in a dataclass is a secret in your logs unless you
mark that field repr=False. frozen=True prevents accidental mutation, not
a determined attacker. And never rebuild objects from untrusted data with
anything that executes it.
Extension exercises
- Make the mini decorator refuse mutable defaults. The real
@dataclassraisesValueErrorfor alist,dictorsetdefault. Add that check tomini_dataclass, then add your owndefault_factorysupport, and confirm your version behaves like the real one on both cases. - Add
frozen=Trueto the mini decorator. Install a__setattr__that raises after construction (the tricky part is letting__init__itself assign), and generate a__hash__from the field values. - Write a runtime checker. Build a
@checkeddecorator that walksdataclasses.fields(self)after__init__and raisesTypeErrorfor any value failingisinstanceagainst its annotation. Handlestr,int,floatandbool, then decide deliberately what to do aboutlist[str], whereisinstance(value, list)works but the element type does not. Writing that limitation down is the shortest explanation of why pydantic is a substantial library rather than a decorator. - Compare the four record types. Implement the same three-field record as
a hand-written class, a dataclass, a
typing.NamedTuple, and acollections.namedtuple. For each, record the lines of code, whether instances are mutable, hashable, and unpackable, whether they compare equal to a plain tuple, and whatreprprints. Then say which you would pick for a dictionary key and which for a mutable working record.
Navigation
- Previous day: Day 68 — Inheritance, Composition, and Dunder Methods
(
labs/sections/programming-with-python/day-068-inheritance-composition-and-dunder-methods/). - Next day: Day 70 — Modeling a Domain with Objects
(
labs/sections/programming-with-python/day-070-modeling-a-domain-with-objects/, to be written). - Week 10 project: the Expense Tracker. The typed, validated record you build here is exactly the shape its expense entries want to be.
Expected output
sample-run.txt
$ python3 examples/demo.py
1. Hand-written class vs dataclass
----------------------------------
hand-written repr: HandWrittenRecord(prompt='2+2?', expected='4', score=0.0)
dataclass repr: EvalRecord(prompt='2+2?', expected='4', score=0.0, tags=[])
hand-written equal by value: True
dataclass equal by value: True
dataclass differs when a field differs: False
2. The mutable default trap
---------------------------
plain function, call 1: ['math']
plain function, call 2: ['math', 'logic'] <- the list was shared
dataclass refuses it: ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory
default_factory gives each instance its own list:
first.tags = ['math']
second.tags = []
3. __post_init__ validation and a derived field
-----------------------------------------------
valid record: EvalRecord(prompt='Capital of France?', expected='Paris', score=1.0, tags=['geo'])
derived prompt_length: 18
rejected (blank prompt): ValueError: prompt must not be empty
rejected (score out of range): ValueError: score must be between 0.0 and 1.0, got 3.0
4. A frozen dataclass is hashable and orderable
-----------------------------------------------
key: RunKey(suite='arithmetic', seed=7)
equal keys hash the same: True
usable as a dict key: 12
sorted by suite then seed: [RunKey(suite='arith', seed=1), RunKey(suite='arith', seed=9), RunKey(suite='geo', seed=2)]
assignment refused: FrozenInstanceError: cannot assign to field 'seed'
5. JSON round trip with asdict and a rebuild function
-----------------------------------------------------
asdict of one record: {'prompt': '2+2?', 'expected': '4', 'score': 0.5, 'tags': ['math'], 'prompt_length': 4}
astuple of one record: ('2+2?', '4', 0.5, ['math'], 4)
JSON text, first 5 lines:
[
{
"expected": "4",
"prompt": "2+2?",
"prompt_length": 4,
rebuilt == original: True
replace() makes a validated copy: EvalRecord(prompt='2+2?', expected='4', score=0.9, tags=['math'])
7. mini_dataclass generates the same shapes
-------------------------------------------
mini repr: MiniRecord(prompt='2+2?', expected='4', score=0.0)
real repr: RealRecord(prompt='2+2?', expected='4', score=0.0)
same repr shape (fields and formatting): True
mini equality works: True and differs: False
keyword arguments work: MiniRecord(prompt='a', expected='b', score=0.5)
missing argument: TypeError: MiniRecord() missing required argument 'expected'
Exercise 6 lives in examples/inspect_runtime.py; the optional
type-checker step lives in examples/check_types.sh.
$ python3 examples/inspect_runtime.py
What Python STORES
------------------
EvalRecord.__annotations__ names: ['prompt', 'expected', 'score', 'tags', 'prompt_length']
dataclasses.fields(EvalRecord):
prompt: str = (required)
expected: str = (required)
score: float = 0.0
tags: list[str] = factory list()
prompt_length: int = 0
What Python does NOT check
--------------------------
assigned a str to .score -> 'not a number'
assigned an int to .tags -> 17
double.__annotations__ names: ['number', 'return']
double('ab') -> 'abab'
Nothing above raised. Annotations are documentation the
interpreter records and a type checker reads; only __post_init__
(or an explicit isinstance check) enforces anything at runtime.
$ bash examples/check_types.sh
No static type checker is installed, so this optional step is skipped.
This lab needs no installs and no network, so that is a normal result.
A checker reads examples/scoring.py WITHOUT running it and reports
the two planted contradictions between the annotations and the code:
* label() promises to return str but returns record.score, a float
* main() passes one EvalRecord where mean_score wants list[EvalRecord]
Python itself reports neither on import: run
python3 examples/inspect_runtime.py
to see what the interpreter actually stores and what it never checks.
test-run.txt
$ bash tests/run_tests.sh
Testing records.py in <repo>/labs/sections/programming-with-python/day-069-dataclasses-and-type-hints/examples ...
ok: generated __repr__ names every field
ok: generated __eq__ compares by value, not identity
ok: hand-written and dataclass versions agree on equality
ok: a bare [] default raises ValueError at class creation
ok: default_factory gives every instance its own list
ok: __post_init__ rejects a blank prompt
ok: __post_init__ rejects an out-of-range score
ok: __post_init__ computes the derived prompt_length
ok: frozen RunKey is hashable and works as a dict key
ok: frozen RunKey refuses assignment and sorts by field order
ok: records survive a JSON round trip unchanged
ok: replace() copies a record and revalidates it
ok: annotation_names reads __annotations__ in declaration order
ok: describe_fields reports types, defaults and factories
ok: annotations are stored but never enforced at runtime
Testing mini_dataclass.py in <repo>/labs/sections/programming-with-python/day-069-dataclasses-and-type-hints/examples ...
ok: mini __repr__ matches the real one field for field
ok: mini __init__ handles positional, keyword and default values
ok: mini __init__ rejects bad calls the way the real one does
ok: mini __eq__ behaves like the real generated __eq__
Testing the runnable scripts ...
ok: examples/demo.py runs and reports the expected results
ok: examples/inspect_runtime.py shows stored-but-unenforced annotations
ok: examples/check_types.sh runs or skips cleanly (optional step)
ok: starter/demo.py runs without crashing
Testing the starter files ...
ok: records.py is valid Python
ok: mini_dataclass.py is valid Python
Note: the starter still has unfinished exercises — testing structure only.
ok: starter defines EvalRecord
ok: starter defines RunKey
ok: starter defines records_to_json
ok: starter defines records_from_json
ok: starter defines rescore
ok: starter defines describe_fields
ok: starter defines annotation_names
ok: starter defines mini_dataclass
33 checks, 0 failure(s).
exit: 0
Source files
examples/check_types.sh (1944 bytes)
#!/usr/bin/env bash
# OPTIONAL exercise — needs a type checker that is NOT part of this lab.
#
# Nothing in this lab requires an install or a network connection. This
# script looks for a static type checker and, if it finds one, runs it over
# examples/scoring.py (which contains two deliberate type errors). If it
# finds none, it explains what you would have seen and exits 0 so the lab
# still completes cleanly.
#
# Run from the lab directory: bash examples/check_types.sh
set -u
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
target="${lab_dir}/examples/scoring.py"
find_checker() {
for name in mypy pyright; do
if command -v "${name}" >/dev/null 2>&1; then
echo "${name}"
return 0
fi
done
if python3 -c "import mypy" >/dev/null 2>&1; then
echo "python3 -m mypy"
return 0
fi
return 1
}
if checker="$(find_checker)"; then
echo "Type checker found: ${checker}"
echo "Checking examples/scoring.py (it contains two deliberate errors) ..."
echo
# A checker exits non-zero when it finds errors. Here that is the SUCCESS
# case, so the exit status is reported rather than propagated.
${checker} "${target}"
echo
echo "(A non-zero exit above is expected: the file has two planted errors.)"
else
echo "No static type checker is installed, so this optional step is skipped."
echo
echo "This lab needs no installs and no network, so that is a normal result."
echo "A checker reads examples/scoring.py WITHOUT running it and reports"
echo "the two planted contradictions between the annotations and the code:"
echo " * label() promises to return str but returns record.score, a float"
echo " * main() passes one EvalRecord where mean_score wants list[EvalRecord]"
echo
echo "Python itself reports neither on import: run"
echo " python3 examples/inspect_runtime.py"
echo "to see what the interpreter actually stores and what it never checks."
fi
exit 0
examples/demo.py (5006 bytes)
"""A guided tour of the reference solution: exercises 1-5 and 7.
Run from the lab directory: python3 examples/demo.py
Every line of output is deterministic — no clocks, no random numbers, no
memory addresses — so you can compare it against
expected-output/sample-run.txt character for character.
"""
import sys
from dataclasses import FrozenInstanceError, asdict, astuple, dataclass, field
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mini_dataclass import MiniRecord, RealRecord # noqa: E402
from records import ( # noqa: E402
EvalRecord,
HandWrittenRecord,
RunKey,
records_from_json,
records_to_json,
rescore,
)
def rule(title: str) -> None:
print()
print(title)
print("-" * len(title))
def exercise_1_hand_written_vs_dataclass() -> None:
rule("1. Hand-written class vs dataclass")
hand = HandWrittenRecord("2+2?", "4")
auto = EvalRecord("2+2?", "4")
print("hand-written repr:", repr(hand))
print("dataclass repr: ", repr(auto))
print("hand-written equal by value:", hand == HandWrittenRecord("2+2?", "4"))
print("dataclass equal by value: ", auto == EvalRecord("2+2?", "4"))
print("dataclass differs when a field differs:", auto == EvalRecord("2+2?", "5"))
def exercise_2_mutable_default() -> None:
rule("2. The mutable default trap")
def add_tag(tag, bucket=[]):
"""A plain function with a shared list default — silently wrong."""
bucket.append(tag)
return bucket
print("plain function, call 1:", add_tag("math"))
print("plain function, call 2:", add_tag("logic"), "<- the list was shared")
try:
@dataclass
class Broken:
name: str
tags: list[str] = []
except ValueError as err:
print("dataclass refuses it: ValueError:", err)
first = EvalRecord("a?", "a")
second = EvalRecord("b?", "b")
first.tags.append("math")
print("default_factory gives each instance its own list:")
print(" first.tags =", first.tags)
print(" second.tags =", second.tags)
def exercise_3_post_init() -> None:
rule("3. __post_init__ validation and a derived field")
good = EvalRecord("Capital of France?", "Paris", 1.0, ["geo"])
print("valid record:", good)
print("derived prompt_length:", good.prompt_length)
for bad_args, note in [
((" ", "4", 0.5), "blank prompt"),
(("2+2?", "4", 3.0), "score out of range"),
]:
try:
EvalRecord(*bad_args)
except ValueError as err:
print(f"rejected ({note}): ValueError: {err}")
def exercise_4_frozen() -> None:
rule("4. A frozen dataclass is hashable and orderable")
key = RunKey("arithmetic", 7)
print("key:", key)
print("equal keys hash the same:", hash(key) == hash(RunKey("arithmetic", 7)))
counts = {RunKey("arithmetic", 7): 12, RunKey("geography", 1): 4}
print("usable as a dict key:", counts[RunKey("arithmetic", 7)])
print("sorted by suite then seed:", sorted([RunKey("geo", 2), RunKey("arith", 9), RunKey("arith", 1)]))
try:
key.seed = 8
except FrozenInstanceError as err:
print("assignment refused: FrozenInstanceError:", err)
def exercise_5_json_round_trip() -> None:
rule("5. JSON round trip with asdict and a rebuild function")
records = [
EvalRecord("2+2?", "4", 0.5, ["math"]),
EvalRecord("Capital of France?", "Paris", 1.0, ["geo", "easy"]),
]
print("asdict of one record:", asdict(records[0]))
print("astuple of one record:", astuple(records[0]))
text = records_to_json(records)
print("JSON text, first 5 lines:")
for line in text.splitlines()[:5]:
print(" " + line)
rebuilt = records_from_json(text)
print("rebuilt == original:", rebuilt == records)
print("replace() makes a validated copy:", rescore(records[0], 0.9))
def exercise_7_mini_dataclass() -> None:
rule("7. mini_dataclass generates the same shapes")
mini = MiniRecord("2+2?", "4")
real = RealRecord("2+2?", "4")
print("mini repr:", repr(mini))
print("real repr:", repr(real))
same_shape = repr(mini).split("(", 1)[1] == repr(real).split("(", 1)[1]
print("same repr shape (fields and formatting):", same_shape)
print("mini equality works:", mini == MiniRecord("2+2?", "4"),
"and differs:", mini == MiniRecord("2+2?", "5"))
print("keyword arguments work:", MiniRecord(prompt="a", expected="b", score=0.5))
try:
MiniRecord("only-one")
except TypeError as err:
print("missing argument: TypeError:", err)
if __name__ == "__main__":
exercise_1_hand_written_vs_dataclass()
exercise_2_mutable_default()
exercise_3_post_init()
exercise_4_frozen()
exercise_5_json_round_trip()
exercise_7_mini_dataclass()
print()
print("Exercise 6 lives in examples/inspect_runtime.py; the optional")
print("type-checker step lives in examples/check_types.sh.")
examples/inspect_runtime.py (1852 bytes)
"""Exercise 6 (required): what the interpreter stores, and what it ignores.
Annotations are real objects that Python keeps in `__annotations__`, and
@dataclass reads them to decide what the fields are. What Python never does
is check a value against one. This script proves both halves.
Run from the lab directory: python3 examples/inspect_runtime.py
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from records import EvalRecord, annotation_names, describe_fields # noqa: E402
def show_stored() -> None:
print("What Python STORES")
print("------------------")
print("EvalRecord.__annotations__ names:", annotation_names(EvalRecord))
print()
print("dataclasses.fields(EvalRecord):")
for line in describe_fields(EvalRecord):
print(" " + line)
print()
def show_ignored() -> None:
print("What Python does NOT check")
print("--------------------------")
# Every annotation below is contradicted, and every line runs happily.
wrong = EvalRecord(prompt="2+2?", expected="4", score=0.5, tags=["math"])
wrong.score = "not a number" # annotated float
wrong.tags = 17 # annotated list[str]
print("assigned a str to .score ->", repr(wrong.score))
print("assigned an int to .tags ->", repr(wrong.tags))
print()
def double(number: int) -> int:
return number * 2
print("double.__annotations__ names:", list(double.__annotations__))
print("double('ab') ->", repr(double("ab")))
print()
print("Nothing above raised. Annotations are documentation the")
print("interpreter records and a type checker reads; only __post_init__")
print("(or an explicit isinstance check) enforces anything at runtime.")
if __name__ == "__main__":
show_stored()
show_ignored()
examples/mini_dataclass.py (2991 bytes)
"""Reference solution: a decorator that does what @dataclass does.
`@dataclass` is not magic. It is a decorator that reads the class's
`__annotations__`, works out the field names and their defaults, builds
`__init__`, `__repr__` and `__eq__` as ordinary functions, and attaches them
to the class. This file does the same thing in about forty lines so you can
see the whole trick.
The real `@dataclass` builds its methods by generating source text and
running `exec` on it (which is how it gets a genuine parameter list, and why
`help()` shows a real signature). This version builds closures instead:
simpler to read, and behaviourally the same for the cases the lab checks.
"""
from dataclasses import dataclass
def mini_dataclass(cls):
"""Attach a generated __init__, __repr__ and __eq__ to `cls`.
Fields are the names annotated in the class body, in declaration order.
A name that also has a value in the class body (`score: float = 0.0`)
becomes a default; everything else is required.
"""
names = list(getattr(cls, "__annotations__", {}))
defaults = {name: getattr(cls, name) for name in names if name in vars(cls)}
def __init__(self, *args, **kwargs):
if len(args) > len(names):
raise TypeError(
f"{cls.__name__}() takes at most {len(names)} arguments"
)
values = dict(zip(names, args))
for key, value in kwargs.items():
if key not in names:
raise TypeError(
f"{cls.__name__}() got an unexpected keyword argument {key!r}"
)
if key in values:
raise TypeError(
f"{cls.__name__}() got multiple values for argument {key!r}"
)
values[key] = value
for name in names:
if name in values:
setattr(self, name, values[name])
elif name in defaults:
setattr(self, name, defaults[name])
else:
raise TypeError(
f"{cls.__name__}() missing required argument {name!r}"
)
def __repr__(self):
inner = ", ".join(f"{name}={getattr(self, name)!r}" for name in names)
return f"{cls.__name__}({inner})"
def __eq__(self, other):
if other.__class__ is not cls:
return NotImplemented
return [getattr(self, name) for name in names] == [
getattr(other, name) for name in names
]
cls.__init__ = __init__
cls.__repr__ = __repr__
cls.__eq__ = __eq__
cls.__hash__ = None # matches @dataclass: eq without frozen drops __hash__
return cls
@mini_dataclass
class MiniRecord:
"""Three fields, built by the mini decorator."""
prompt: str
expected: str
score: float = 0.0
@dataclass
class RealRecord:
"""The same three fields, built by the real @dataclass — the control."""
prompt: str
expected: str
score: float = 0.0
examples/records.py (6202 bytes)
"""Reference solution: evaluation records built with dataclasses.
This is the completed version of `starter/records.py`. Every function and
class here is pure logic plus small, explicit validation — there is no
input or output in this module, so it can be imported and tested with plain
function calls.
The domain is a tiny "evaluation record": a prompt, the answer we expect,
a score between 0.0 and 1.0, and some tags. It is deliberately the shape of
the records you keep when you measure how well a system answers questions.
"""
from dataclasses import (
MISSING,
asdict,
dataclass,
field,
fields,
replace,
)
import json
# --- Exercise 1: the hand-written class, kept for comparison ----------------
# This is what you write WITHOUT @dataclass. Count the lines, and count how
# many times the word `prompt` appears: once in the parameter list, once on
# the left of the assignment, once on the right, once in __repr__, and once
# in __eq__.
class HandWrittenRecord:
"""A record class with __init__, __repr__ and __eq__ written by hand."""
def __init__(self, prompt, expected, score=0.0):
self.prompt = prompt
self.expected = expected
self.score = score
def __repr__(self):
return (
f"HandWrittenRecord(prompt={self.prompt!r}, "
f"expected={self.expected!r}, score={self.score!r})"
)
def __eq__(self, other):
if not isinstance(other, HandWrittenRecord):
return NotImplemented
return (self.prompt, self.expected, self.score) == (
other.prompt,
other.expected,
other.score,
)
# --- Exercises 1-3: the same record as a dataclass --------------------------
@dataclass
class EvalRecord:
"""One evaluation record: a prompt, the expected answer, a score, tags.
`tags` uses `field(default_factory=list)` because a bare `[]` default
would be shared by every instance — and a dataclass refuses it outright
with a ValueError.
`prompt_length` is derived in __post_init__, so it is not an __init__
parameter (`init=False`) and is left out of the repr (`repr=False`).
"""
prompt: str
expected: str
score: float = 0.0
tags: list[str] = field(default_factory=list)
prompt_length: int = field(init=False, repr=False, default=0)
def __post_init__(self) -> None:
"""Validate the fields and compute the derived one."""
if not self.prompt.strip():
raise ValueError("prompt must not be empty")
if not self.expected.strip():
raise ValueError("expected must not be empty")
if not 0.0 <= self.score <= 1.0:
raise ValueError(
f"score must be between 0.0 and 1.0, got {self.score}"
)
self.prompt_length = len(self.prompt)
# --- Exercise 4: a frozen dataclass, usable as a dict key -------------------
@dataclass(frozen=True, order=True)
class RunKey:
"""Identifies one evaluation run. Frozen, so hashable and orderable.
`frozen=True` blocks attribute assignment after construction, which is
what makes a generated __hash__ safe: the hash can never go stale.
`order=True` generates __lt__/__le__/__gt__/__ge__ from the fields in
declaration order, so a list of RunKeys sorts by suite then seed.
"""
suite: str
seed: int
# --- Exercise 5: JSON round-tripping ---------------------------------------
def records_to_json(records: list[EvalRecord]) -> str:
"""Serialise records to indented, key-sorted JSON text.
`asdict` walks the dataclass recursively and returns plain dicts and
lists, which is exactly what `json.dumps` knows how to write. Sorting
the keys makes the output deterministic, so a test can compare it.
"""
return json.dumps([asdict(record) for record in records], indent=2, sort_keys=True)
def records_from_json(text: str) -> list[EvalRecord]:
"""Rebuild records from JSON text produced by `records_to_json`.
There is no automatic reverse of `asdict`: JSON gives you dicts, and you
decide how to turn each dict back into an object. `prompt_length` is
skipped on purpose — it is derived, so __post_init__ recomputes it.
"""
rebuilt = []
for row in json.loads(text):
rebuilt.append(
EvalRecord(
prompt=row["prompt"],
expected=row["expected"],
score=row["score"],
tags=list(row["tags"]),
)
)
return rebuilt
def rescore(record: EvalRecord, new_score: float) -> EvalRecord:
"""Return a copy of `record` with a different score.
`replace` calls the class's __init__ with the changed fields, so
__post_init__ runs again and the new score is validated.
"""
return replace(record, score=new_score)
# --- Exercise 6: what the interpreter does and does not enforce -------------
def format_annotation(annotation: object) -> str:
"""Render an annotation readably: `str`, `float`, `list[str]`.
A plain class prints as its short name; a parameterised generic such as
`list[str]` is already readable via str(), so it is used as-is.
"""
if isinstance(annotation, type):
return annotation.__name__
return str(annotation)
def describe_fields(cls: type) -> list[str]:
"""Describe a dataclass's fields as readable lines.
Reads the two things @dataclass itself reads: the class's
`__annotations__` and the `Field` objects that `dataclasses.fields()`
returns. Nothing here checks any value against any annotation — that is
the point of the exercise.
"""
lines = []
for spec in fields(cls):
if spec.default is not MISSING:
default = repr(spec.default)
elif spec.default_factory is not MISSING:
default = f"factory {spec.default_factory.__name__}()"
else:
default = "(required)"
lines.append(f"{spec.name}: {format_annotation(spec.type)} = {default}")
return lines
def annotation_names(cls: type) -> list[str]:
"""Return the annotated names on `cls`, in declaration order."""
return list(cls.__annotations__)
examples/scoring.py (1390 bytes)
"""A fully annotated module that contains two deliberate type errors.
This file exists so you can see what a static type checker adds. Read the
annotations: they say exactly what each function takes and returns. Two of
the statements below contradict those annotations. Python itself does not
care — annotations are stored, never enforced — so one bug only shows up
when the program crashes at runtime and the other never shows up at all.
Run `bash examples/check_types.sh` to have a type checker point at both
before you run anything. If no checker is installed, that script says so and
exits cleanly; nothing in this lab requires one.
"""
from dataclasses import dataclass, field
@dataclass
class EvalRecord:
prompt: str
expected: str
score: float = 0.0
tags: list[str] = field(default_factory=list)
def mean_score(records: list[EvalRecord]) -> float:
"""Average score across a list of records."""
return sum(record.score for record in records) / len(records)
def label(record: EvalRecord) -> str:
"""BUG 1: the annotation promises str, the body returns a float."""
return record.score
def main() -> None:
records = [EvalRecord("2+2?", "4", 0.5), EvalRecord("Capital?", "Paris", 1.0)]
# BUG 2: mean_score wants a list of records; this passes a single record.
print(mean_score(records[0]))
if __name__ == "__main__":
main()
metadata.yml (1047 bytes)
lesson_id: D069
day: 69
kind: python-program
languages: [python]
setup_commands:
- cd labs/sections/programming-with-python/day-069-dataclasses-and-type-hints
- python3 --version
run_commands:
- cat examples/records.py
- python3 examples/demo.py
- python3 examples/inspect_runtime.py
- 'bash examples/check_types.sh # optional; skips cleanly with no type checker'
- python3 examples/scoring.py
- python3 starter/demo.py
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- rm -rf examples/__pycache__ starter/__pycache__
- 'git checkout -- starter/ # optional: reset your work'
requires_network: false
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-19'
executed_on: 'macOS 26.5.1 (Apple Silicon), Python 3.14.0, bash 3.2 — bash tests/run_tests.sh -> 33 checks, 0 failure(s), exit 0 (starter exercises unfinished; the suite adds strict checks against starter/ once they are complete). No static type checker installed, so examples/check_types.sh took its documented skip path and exited 0.'
requirements/README.md (2686 bytes)
# Dependencies — Day 069 lab
**Python 3 only. No third-party packages, no network, no API key.**
- `python3` (**3.10 or newer**; tested on 3.14.0). You set this up on Day 43.
- `bash` for the test runner (preinstalled on macOS and Linux).
- Standard library only: `dataclasses` and `json` in the lab files, plus
`sys` and `pathlib` so the drivers can import the module sitting beside
them. There is deliberately no `requirements.txt`.
Check your Python is present and new enough:
```bash
python3 --version
```
If that prints `Python 3.10` or higher, you are ready.
## Why 3.10 and not 3.8
The lab files annotate their fields with **builtin generics** such as
`list[str]` rather than `typing.List[str]`, which needs Python 3.9 or newer.
The floor is set one version higher, at 3.10, so that everything the lesson
shows alongside the lab also runs unchanged — the `X | None` spelling for
optional values, and the `slots` and `kw_only` options to `@dataclass`. None
of those three appear in the lab files themselves, but you will want to try
them, and a 3.10 floor means the code you read is the code you would write
today rather than a compatibility variant.
## The optional type checker
One step in this lab — `examples/check_types.sh` — is marked optional
precisely because it needs software this lab does not install. It looks for
`mypy` or `pyright`, and:
- If it finds one, it runs it over `examples/scoring.py` and shows the result.
- **If it finds none, it says so, describes the two planted errors, and exits
0.** That is the normal path here. No checker is installed in this
environment, and nothing in the lab or its tests requires one.
If you want to try a checker on your own machine later, both are free and
open source and install with pip:
```bash
python3 -m pip install mypy # then: python3 -m mypy examples/scoring.py
```
Do that in a virtual environment (Day 43) rather than into your system
Python. It is genuinely optional — the required exercise,
`examples/inspect_runtime.py`, demonstrates the same point from the runtime
side and needs nothing but the standard library.
## Why the standard library is enough
This is the lesson in miniature. `dataclasses` and `typing` are in the box
because defining a record and describing its shape are things every Python
program does. You will meet **pydantic** later, and it is excellent — it adds
the runtime validation that a dataclass deliberately omits — but reaching for
a dependency before you understand what `@dataclass` generates means you
cannot debug it when the generation is not what you assumed. Exercise 7 has
you rebuild the decorator yourself for exactly that reason.
starter/demo.py (5211 bytes)
"""Runs YOUR starter code, exercise by exercise. Given — do not edit.
Each exercise is run on its own, so an unfinished one prints a note instead
of stopping the whole script. Work through starter/records.py and
starter/mini_dataclass.py until every section prints real results, then
compare with `python3 examples/demo.py`.
Run from the lab directory: python3 starter/demo.py
"""
import sys
from dataclasses import FrozenInstanceError, asdict, astuple, dataclass
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import mini_dataclass as mini_module # noqa: E402
import records as records_module # noqa: E402
def rule(title: str) -> None:
print()
print(title)
print("-" * len(title))
def exercise_1() -> None:
rule("1. Hand-written class vs dataclass")
hand = records_module.HandWrittenRecord("2+2?", "4")
auto = records_module.EvalRecord("2+2?", "4")
print("hand-written repr:", repr(hand))
print("dataclass repr: ", repr(auto))
print("hand-written equal by value:", hand == records_module.HandWrittenRecord("2+2?", "4"))
print("dataclass equal by value: ", auto == records_module.EvalRecord("2+2?", "4"))
def exercise_2() -> None:
rule("2. The mutable default trap")
def add_tag(tag, bucket=[]):
bucket.append(tag)
return bucket
print("plain function, call 1:", add_tag("math"))
print("plain function, call 2:", add_tag("logic"), "<- the list was shared")
try:
@dataclass
class Broken:
name: str
tags: list[str] = []
except ValueError as err:
print("dataclass refuses it: ValueError:", err)
first = records_module.EvalRecord("a?", "a")
second = records_module.EvalRecord("b?", "b")
first.tags.append("math")
print(" first.tags =", first.tags)
print(" second.tags =", second.tags)
def exercise_3() -> None:
rule("3. __post_init__ validation and a derived field")
good = records_module.EvalRecord("Capital of France?", "Paris", 1.0, ["geo"])
print("valid record:", good)
print("derived prompt_length:", good.prompt_length)
for bad_args, note in [((" ", "4", 0.5), "blank prompt"), (("2+2?", "4", 3.0), "score out of range")]:
try:
records_module.EvalRecord(*bad_args)
print(f"NOT rejected ({note}) — __post_init__ is not validating yet")
except ValueError as err:
print(f"rejected ({note}): ValueError: {err}")
def exercise_4() -> None:
rule("4. A frozen dataclass is hashable and orderable")
key = records_module.RunKey("arithmetic", 7)
print("key:", key)
print("equal keys hash the same:", hash(key) == hash(records_module.RunKey("arithmetic", 7)))
counts = {records_module.RunKey("arithmetic", 7): 12}
print("usable as a dict key:", counts[records_module.RunKey("arithmetic", 7)])
try:
key.seed = 8
print("assignment ALLOWED — the class is not frozen yet")
except FrozenInstanceError as err:
print("assignment refused: FrozenInstanceError:", err)
def exercise_5() -> None:
rule("5. JSON round trip with asdict and a rebuild function")
records = [
records_module.EvalRecord("2+2?", "4", 0.5, ["math"]),
records_module.EvalRecord("Capital of France?", "Paris", 1.0, ["geo"]),
]
print("asdict of one record:", asdict(records[0]))
print("astuple of one record:", astuple(records[0]))
text = records_module.records_to_json(records)
for line in text.splitlines()[:5]:
print(" " + line)
print("rebuilt == original:", records_module.records_from_json(text) == records)
print("replace() makes a validated copy:", records_module.rescore(records[0], 0.9))
def exercise_6() -> None:
rule("6. What the interpreter stores, and what it ignores")
print("annotation names:", records_module.annotation_names(records_module.EvalRecord))
for line in records_module.describe_fields(records_module.EvalRecord):
print(" " + line)
def exercise_7() -> None:
rule("7. mini_dataclass generates the same shapes")
small = mini_module.MiniRecord("2+2?", "4")
real = mini_module.RealRecord("2+2?", "4")
print("mini repr:", repr(small))
print("real repr:", repr(real))
same_shape = repr(small).split("(", 1)[1] == repr(real).split("(", 1)[1]
print("same repr shape (fields and formatting):", same_shape)
print("mini equality works:", small == mini_module.MiniRecord("2+2?", "4"),
"and differs:", small == mini_module.MiniRecord("2+2?", "5"))
try:
mini_module.MiniRecord("only-one")
print("missing argument NOT rejected yet")
except TypeError as err:
print("missing argument: TypeError:", err)
if __name__ == "__main__":
for number, run in enumerate(
[exercise_1, exercise_2, exercise_3, exercise_4, exercise_5, exercise_6, exercise_7],
start=1,
):
try:
run()
except NotImplementedError as err:
print()
print(f"Exercise {number}: not finished yet — {err}")
print()
print("Optional: bash examples/check_types.sh (skips cleanly with no checker).")
starter/mini_dataclass.py (2597 bytes)
"""YOUR WORKING FILE — exercise 7: build @dataclass from scratch.
@dataclass is an ordinary decorator. It reads the class's `__annotations__`,
works out the field names and their defaults, builds `__init__`, `__repr__`
and `__eq__` as normal functions, and attaches them to the class. Write that
yourself here, in about forty lines, and the code generation stops being
mysterious.
Reference solution: `examples/mini_dataclass.py`.
"""
from dataclasses import dataclass
# --- EXERCISE 7 ------------------------------------------------------------
# Fill in the three generated methods. Steps:
#
# a. `names = list(getattr(cls, "__annotations__", {}))` — the field names,
# in declaration order. Then build `defaults`: for every name that also
# has a value in the class body (`score: float = 0.0`), record
# getattr(cls, name). Hint: `name in vars(cls)` tests for that.
#
# b. __init__(self, *args, **kwargs):
# - raise TypeError if len(args) > len(names)
# - `values = dict(zip(names, args))`
# - for each keyword: TypeError if the key is not a field name, or if
# it is already filled by a positional argument; otherwise store it
# - for each name in order: set it from `values`, else from
# `defaults`, else raise TypeError naming the missing argument
#
# c. __repr__(self): return f"{cls.__name__}(...)" where ... is the fields
# joined with ", " as `name=value!r` — use an f-string with `!r`.
#
# d. __eq__(self, other): return NotImplemented unless
# `other.__class__ is cls`; otherwise compare the two lists of field
# values.
#
# e. Attach all three to `cls`, set `cls.__hash__ = None` (the real
# @dataclass does the same when it generates __eq__ without frozen=True),
# and return `cls`.
def mini_dataclass(cls):
"""Attach a generated __init__, __repr__ and __eq__ to `cls`."""
def unfinished__init__(self, *args, **kwargs):
raise NotImplementedError(
"Exercise 7: read cls.__annotations__ and generate __init__, "
"__repr__ and __eq__."
)
# Replace this stub with the real generation described above. It is here
# so the module still imports while the exercise is unfinished.
cls.__init__ = unfinished__init__
return cls
@mini_dataclass
class MiniRecord:
"""Three fields, built by your decorator."""
prompt: str
expected: str
score: float = 0.0
@dataclass
class RealRecord:
"""The same three fields, built by the real @dataclass — the control."""
prompt: str
expected: str
score: float = 0.0
starter/records.py (6473 bytes)
"""YOUR WORKING FILE — exercises 1 to 6.
Complete the numbered exercises below, in order. Each unfinished piece
raises NotImplementedError on purpose, so an empty function can never be
mistaken for a working one. The reference solution is in
`examples/records.py`; use it only when you are genuinely stuck.
After each exercise, check your progress from the lab directory:
python3 starter/demo.py
bash tests/run_tests.sh
"""
from dataclasses import (
MISSING,
asdict,
dataclass,
field,
fields,
replace,
)
import json
# --- Given: the hand-written class, for comparison --------------------------
# Read this before you write anything. Count the lines, and count how many
# times `prompt` appears: parameter list, left of the assignment, right of
# the assignment, __repr__, __eq__ — five times, for one field.
class HandWrittenRecord:
"""A record class with __init__, __repr__ and __eq__ written by hand."""
def __init__(self, prompt, expected, score=0.0):
self.prompt = prompt
self.expected = expected
self.score = score
def __repr__(self):
return (
f"HandWrittenRecord(prompt={self.prompt!r}, "
f"expected={self.expected!r}, score={self.score!r})"
)
def __eq__(self, other):
if not isinstance(other, HandWrittenRecord):
return NotImplemented
return (self.prompt, self.expected, self.score) == (
other.prompt,
other.expected,
other.score,
)
# --- EXERCISE 1: convert the class above into a dataclass -------------------
# EXERCISE 2: give `tags` a per-instance list default.
# EXERCISE 3: add __post_init__ validation and a derived field.
#
# 1. Decorate EvalRecord with @dataclass. Declare four fields with
# annotations, in this order:
# prompt: str (required)
# expected: str (required)
# score: float default 0.0
# tags: list[str] default: a NEW empty list per instance
# Verify first that `tags: list[str] = []` raises ValueError when the
# class is defined — then replace it with field(default_factory=list).
# 2. Add a fifth field that is computed, not passed in:
# prompt_length: int = field(init=False, repr=False, default=0)
# 3. Write __post_init__ so it raises ValueError when `prompt` is blank
# (use .strip()), when `expected` is blank, or when `score` is outside
# 0.0 to 1.0 inclusive — the message must contain the offending score —
# and otherwise sets self.prompt_length to len(self.prompt).
class EvalRecord:
"""One evaluation record: a prompt, the expected answer, a score, tags."""
def __init__(self, *args, **kwargs):
raise NotImplementedError(
"Exercises 1-3: make EvalRecord a @dataclass with annotated "
"fields, field(default_factory=list) for tags, and __post_init__ "
"validation."
)
# --- EXERCISE 4: a frozen dataclass ----------------------------------------
# Decorate RunKey with @dataclass(frozen=True, order=True) and declare two
# annotated fields: `suite: str` and `seed: int`, in that order.
# frozen=True blocks attribute assignment (raising FrozenInstanceError) and
# therefore lets Python generate a safe __hash__, so instances work as dict
# keys and set members. order=True generates the comparison methods, so a
# list of RunKeys sorts by suite and then by seed.
class RunKey:
"""Identifies one evaluation run. Should be frozen, hashable, orderable."""
def __init__(self, *args, **kwargs):
raise NotImplementedError(
"Exercise 4: make RunKey a @dataclass(frozen=True, order=True) "
"with fields suite: str and seed: int."
)
# --- EXERCISE 5: JSON round trip -------------------------------------------
def records_to_json(records: list) -> str:
"""Serialise records to indented, key-sorted JSON text.
Build a list of plain dicts with `asdict(record)` for each record, then
return `json.dumps(rows, indent=2, sort_keys=True)`. Sorting the keys
keeps the output deterministic so the tests can compare it.
"""
raise NotImplementedError("Exercise 5a: use asdict + json.dumps.")
def records_from_json(text: str) -> list:
"""Rebuild records from JSON text produced by `records_to_json`.
`json.loads(text)` gives a list of dicts. For each one, construct an
EvalRecord from the `prompt`, `expected`, `score` and `tags` keys. Do
NOT pass `prompt_length`: it is derived, so __post_init__ recomputes it
(and it is not an __init__ parameter at all).
"""
raise NotImplementedError("Exercise 5b: json.loads + rebuild each record.")
def rescore(record, new_score: float):
"""Return a copy of `record` with a different score.
Use `dataclasses.replace`. It calls __init__ with the changed field, so
__post_init__ runs again and the new score is validated for free.
"""
raise NotImplementedError("Exercise 5c: use dataclasses.replace.")
# --- EXERCISE 6: inspect what the interpreter stores ------------------------
def format_annotation(annotation: object) -> str:
"""Render an annotation readably: `str`, `float`, `list[str]`.
Given. A plain class prints as its short name; a parameterised generic
such as list[str] already prints readably via str().
"""
if isinstance(annotation, type):
return annotation.__name__
return str(annotation)
def describe_fields(cls: type) -> list:
"""Describe a dataclass's fields as readable lines.
Loop over `dataclasses.fields(cls)`. For each Field object `spec`,
append one string shaped like:
"prompt: str = (required)"
"score: float = 0.0"
"tags: list[str] = factory list()"
Use `format_annotation(spec.type)` for the type part. For the default:
if `spec.default is not MISSING` use `repr(spec.default)`; else if
`spec.default_factory is not MISSING` use
f"factory {spec.default_factory.__name__}()"; else use "(required)".
"""
raise NotImplementedError("Exercise 6a: read dataclasses.fields(cls).")
def annotation_names(cls: type) -> list:
"""Return the annotated names on `cls`, in declaration order.
One line: `list(cls.__annotations__)`. This is the very same dictionary
@dataclass itself reads to decide what the fields are.
"""
raise NotImplementedError("Exercise 6b: read cls.__annotations__.")
tests/run_tests.sh (11472 bytes)
#!/usr/bin/env bash
# Tests for the Day 069 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# Everything here runs with python3 and bash only: no installs, no network,
# no type checker required. Each check is a real assertion about behaviour —
# generated __repr__ and __eq__, the mutable-default ValueError,
# __post_init__ validation, frozen hashing and FrozenInstanceError, a JSON
# round trip, runtime introspection of __annotations__ and
# dataclasses.fields(), and the from-scratch mini_dataclass decorator.
#
# The reference in examples/ is always tested strictly. The learner's
# starter/ is tested structurally while exercises are unfinished, and to the
# same strict standard once they are complete.
# Exits 0 only if every check passes.
set -u
export PYTHONDONTWRITEBYTECODE=1
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
failures=0
checks=0
check() {
local label="$1" ok="$2"
checks=$((checks + 1))
if [ "${ok}" = "yes" ]; then
echo " ok: ${label}"
else
echo " FAIL: ${label}"
failures=$((failures + 1))
fi
}
# assert_py <label> <module_dir> <python-body>
# Runs an assertion body with <module_dir> on the import path. A clean exit
# (every assert held) is a pass; any exception is a failure.
assert_py() {
local label="$1" module_dir="$2" body="$3"
local out
if out="$(PYTHONPATH="${module_dir}" python3 -c "${body}" 2>&1)"; then
check "${label}" "yes"
else
check "${label}" "no"
echo " ${out##*$'\n'}"
fi
}
run_record_checks() {
local dir="$1"
echo "Testing records.py in ${dir} ..."
assert_py "generated __repr__ names every field" "${dir}" \
"from records import EvalRecord
text = repr(EvalRecord('2+2?', '4'))
assert text.startswith('EvalRecord('), text
for name in ('prompt', 'expected', 'score', 'tags'):
assert name + '=' in text, text
assert 'prompt_length' not in text, 'prompt_length is repr=False'"
assert_py "generated __eq__ compares by value, not identity" "${dir}" \
"from records import EvalRecord
a, b = EvalRecord('2+2?', '4'), EvalRecord('2+2?', '4')
assert a is not b
assert a == b
assert a != EvalRecord('2+2?', '5')
assert a != EvalRecord('2+3?', '4')"
assert_py "hand-written and dataclass versions agree on equality" "${dir}" \
"from records import EvalRecord, HandWrittenRecord
assert HandWrittenRecord('q', 'a') == HandWrittenRecord('q', 'a')
assert EvalRecord('q', 'a') == EvalRecord('q', 'a')"
assert_py "a bare [] default raises ValueError at class creation" "${dir}" \
"from dataclasses import dataclass
try:
@dataclass
class Broken:
name: str
tags: list = []
except ValueError as err:
assert 'default_factory' in str(err), err
else:
raise AssertionError('a mutable default was accepted')"
assert_py "default_factory gives every instance its own list" "${dir}" \
"from records import EvalRecord
first, second = EvalRecord('a?', 'a'), EvalRecord('b?', 'b')
assert first.tags == [] and second.tags == []
assert first.tags is not second.tags
first.tags.append('math')
assert first.tags == ['math']
assert second.tags == []"
assert_py "__post_init__ rejects a blank prompt" "${dir}" \
"from records import EvalRecord
try:
EvalRecord(' ', '4')
except ValueError as err:
assert 'prompt' in str(err), err
else:
raise AssertionError('blank prompt was accepted')"
assert_py "__post_init__ rejects an out-of-range score" "${dir}" \
"from records import EvalRecord
try:
EvalRecord('2+2?', '4', 3.0)
except ValueError as err:
assert '3.0' in str(err), err
else:
raise AssertionError('score 3.0 was accepted')
EvalRecord('2+2?', '4', 0.0)
EvalRecord('2+2?', '4', 1.0)"
assert_py "__post_init__ computes the derived prompt_length" "${dir}" \
"from records import EvalRecord
assert EvalRecord('Capital of France?', 'Paris').prompt_length == 18"
assert_py "frozen RunKey is hashable and works as a dict key" "${dir}" \
"from records import RunKey
key = RunKey('arithmetic', 7)
assert hash(key) == hash(RunKey('arithmetic', 7))
counts = {key: 12}
assert counts[RunKey('arithmetic', 7)] == 12
assert len({RunKey('a', 1), RunKey('a', 1)}) == 1"
assert_py "frozen RunKey refuses assignment and sorts by field order" "${dir}" \
"from dataclasses import FrozenInstanceError
from records import RunKey
key = RunKey('arithmetic', 7)
try:
key.seed = 8
except FrozenInstanceError:
pass
else:
raise AssertionError('assignment to a frozen dataclass succeeded')
assert sorted([RunKey('geo', 2), RunKey('arith', 9), RunKey('arith', 1)]) == [
RunKey('arith', 1), RunKey('arith', 9), RunKey('geo', 2)]"
assert_py "records survive a JSON round trip unchanged" "${dir}" \
"import json
from records import EvalRecord, records_from_json, records_to_json
originals = [EvalRecord('2+2?', '4', 0.5, ['math']),
EvalRecord('Capital of France?', 'Paris', 1.0, ['geo', 'easy'])]
text = records_to_json(originals)
rows = json.loads(text)
assert isinstance(rows, list) and len(rows) == 2
assert rows[0]['prompt'] == '2+2?' and rows[0]['tags'] == ['math']
rebuilt = records_from_json(text)
assert rebuilt == originals
assert rebuilt[0] is not originals[0]
assert rebuilt[1].prompt_length == originals[1].prompt_length"
assert_py "replace() copies a record and revalidates it" "${dir}" \
"from records import EvalRecord, rescore
original = EvalRecord('2+2?', '4', 0.5, ['math'])
copy = rescore(original, 0.9)
assert copy.score == 0.9 and original.score == 0.5
assert copy.prompt == original.prompt
try:
rescore(original, 4.0)
except ValueError:
pass
else:
raise AssertionError('replace() skipped __post_init__ validation')"
assert_py "annotation_names reads __annotations__ in declaration order" "${dir}" \
"from records import EvalRecord, annotation_names
assert annotation_names(EvalRecord) == [
'prompt', 'expected', 'score', 'tags', 'prompt_length']"
assert_py "describe_fields reports types, defaults and factories" "${dir}" \
"from records import EvalRecord, describe_fields
lines = describe_fields(EvalRecord)
assert lines[0] == 'prompt: str = (required)', lines[0]
assert lines[2] == 'score: float = 0.0', lines[2]
assert lines[3] == 'tags: list[str] = factory list()', lines[3]"
assert_py "annotations are stored but never enforced at runtime" "${dir}" \
"from records import EvalRecord
record = EvalRecord('2+2?', '4', 0.5)
record.score = 'not a number'
record.tags = 17
assert record.score == 'not a number'
assert record.tags == 17"
}
run_mini_checks() {
local dir="$1"
echo "Testing mini_dataclass.py in ${dir} ..."
assert_py "mini __repr__ matches the real one field for field" "${dir}" \
"from mini_dataclass import MiniRecord, RealRecord
mini = repr(MiniRecord('2+2?', '4'))
real = repr(RealRecord('2+2?', '4'))
assert mini.startswith('MiniRecord('), mini
assert mini.split('(', 1)[1] == real.split('(', 1)[1], (mini, real)"
assert_py "mini __init__ handles positional, keyword and default values" "${dir}" \
"from mini_dataclass import MiniRecord
assert repr(MiniRecord('q', 'a')) == repr(MiniRecord(prompt='q', expected='a'))
assert MiniRecord('q', 'a').score == 0.0
assert MiniRecord('q', 'a', 0.5).score == 0.5
assert MiniRecord('q', expected='a', score=0.5).score == 0.5"
assert_py "mini __init__ rejects bad calls the way the real one does" "${dir}" \
"from mini_dataclass import MiniRecord, RealRecord
for cls in (MiniRecord, RealRecord):
try:
cls('only-one')
except TypeError:
pass
else:
raise AssertionError(cls.__name__ + ' accepted a missing argument')
try:
cls('q', 'a', 0.5, 'extra')
except TypeError:
pass
else:
raise AssertionError(cls.__name__ + ' accepted too many arguments')
try:
cls('q', 'a', nope=1)
except TypeError:
pass
else:
raise AssertionError(cls.__name__ + ' accepted an unknown keyword')"
assert_py "mini __eq__ behaves like the real generated __eq__" "${dir}" \
"from mini_dataclass import MiniRecord, RealRecord
assert MiniRecord('q', 'a') == MiniRecord('q', 'a')
assert RealRecord('q', 'a') == RealRecord('q', 'a')
assert not (MiniRecord('q', 'a') == MiniRecord('q', 'b'))
assert not (RealRecord('q', 'a') == RealRecord('q', 'b'))
assert MiniRecord('q', 'a') != 'a string'
assert RealRecord('q', 'a') != 'a string'"
}
run_script_checks() {
echo "Testing the runnable scripts ..."
local out code
out="$(cd "${lab_dir}" && python3 examples/demo.py 2>&1)"
code=$?
if [ "${code}" -eq 0 ] \
&& printf '%s' "${out}" | grep -qF "use default_factory" \
&& printf '%s' "${out}" | grep -qF "rebuilt == original: True" \
&& printf '%s' "${out}" | grep -qF "same repr shape (fields and formatting): True"; then
check "examples/demo.py runs and reports the expected results" "yes"
else
check "examples/demo.py runs and reports the expected results" "no"
echo " (exit ${code})"
fi
out="$(cd "${lab_dir}" && python3 examples/inspect_runtime.py 2>&1)"
code=$?
if [ "${code}" -eq 0 ] \
&& printf '%s' "${out}" | grep -qF "tags: list[str] = factory list()" \
&& printf '%s' "${out}" | grep -qF "double('ab') -> 'abab'"; then
check "examples/inspect_runtime.py shows stored-but-unenforced annotations" "yes"
else
check "examples/inspect_runtime.py shows stored-but-unenforced annotations" "no"
echo " (exit ${code})"
fi
out="$(cd "${lab_dir}" && bash examples/check_types.sh 2>&1)"
code=$?
if [ "${code}" -eq 0 ] && printf '%s' "${out}" | grep -qE "Type checker found|No static type checker is installed"; then
check "examples/check_types.sh runs or skips cleanly (optional step)" "yes"
else
check "examples/check_types.sh runs or skips cleanly (optional step)" "no"
echo " (exit ${code})"
fi
out="$(cd "${lab_dir}" && python3 starter/demo.py 2>&1)"
code=$?
if [ "${code}" -eq 0 ]; then
check "starter/demo.py runs without crashing" "yes"
else
check "starter/demo.py runs without crashing" "no"
echo " (exit ${code})"
fi
}
# --- Reference: always tested strictly ---
run_record_checks "${lab_dir}/examples"
run_mini_checks "${lab_dir}/examples"
run_script_checks
# --- Learner starter ---
echo "Testing the starter files ..."
starter_records="${lab_dir}/starter/records.py"
starter_mini="${lab_dir}/starter/mini_dataclass.py"
for path in "${starter_records}" "${starter_mini}"; do
if python3 -c "import sys; compile(open(sys.argv[1]).read(), sys.argv[1], 'exec')" "${path}" 2>/dev/null; then
check "$(basename "${path}") is valid Python" "yes"
else
check "$(basename "${path}") is valid Python" "no"
fi
done
if grep -q 'NotImplementedError' "${starter_records}" || grep -q 'NotImplementedError' "${starter_mini}"; then
echo "Note: the starter still has unfinished exercises — testing structure only."
for name in EvalRecord RunKey records_to_json records_from_json rescore describe_fields annotation_names; do
if grep -qE "^(class|def) ${name}\b" "${starter_records}"; then
check "starter defines ${name}" "yes"
else
check "starter defines ${name}" "no"
fi
done
if grep -qE '^def mini_dataclass\b' "${starter_mini}"; then
check "starter defines mini_dataclass" "yes"
else
check "starter defines mini_dataclass" "no"
fi
else
run_record_checks "${lab_dir}/starter"
run_mini_checks "${lab_dir}/starter"
fi
echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]
Troubleshooting
Troubleshooting — Day 069 lab
python: command not found
Use python3 explicitly, as every command in this lab does. On macOS and
most Linux systems, bare python may be missing or point to an old version.
Check with python3 --version; this lab needs 3.10 or newer.
TypeError: 'type' object is not subscriptable
Your Python is older than 3.9, so it cannot read list[str] as an
annotation. Check python3 --version. If you are stuck on an older Python,
from typing import List and List[str] means the same thing — but the
lab and the lesson both use the modern spelling, so upgrading is the better
answer.
The starter raises NotImplementedError when I run it
That is expected until you finish the seven exercises in
starter/records.py and starter/mini_dataclass.py. Each unfinished piece
raises NotImplementedError on purpose so an empty function can never be
mistaken for a working one. starter/demo.py runs each exercise separately
and prints a note for the unfinished ones rather than stopping, so you can
work through them in order and watch the notes disappear.
ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory
This is exercise 2 working, not a bug. You wrote tags: list[str] = []
and the dataclass refused it at class-creation time, before any instance
existed. The reason is that one list would be shared by every instance. The
message names the fix:
tags: list[str] = field(default_factory=list)
Notice what the equivalent mistake does in a plain function signature —
def add_tag(tag, bucket=[]) — and see section 2 of examples/demo.py:
Python permits it, shares one list across every call, and never warns you.
The dataclass version is the loud, helpful form of the same trap.
TypeError: non-default argument 'x' follows default argument
The generated __init__ is an ordinary function signature, so fields with
defaults must come after fields without them. Either reorder the
declarations so the required ones come first, or pass kw_only=True to
@dataclass, which makes every field keyword-only and removes the ordering
constraint entirely.
__post_init__ never runs
Three usual causes. Check the spelling — two underscores on each side.
Check you did not also write your own __init__, which replaces the
generated one and with it the call to __post_init__. And check the class
actually has the @dataclass decorator; without it the class body is just
annotations and nothing is generated at all.
TypeError: unhashable type: 'RunKey'
The class generated __eq__ but is not frozen, so it has no __hash__.
This is Day 68's rule: defining __eq__ removes the inherited hash, because
two objects that compare equal must hash equal and an identity hash cannot
promise that. Add frozen=True:
@dataclass(frozen=True, order=True)
class RunKey:
suite: str
seed: int
Now assignment is blocked, so the field values can never change, so a
generated hash can never go stale — and @dataclass supplies one.
FrozenInstanceError: cannot assign to field 'seed'
If you were testing exercise 4, this is the guarantee working. If you genuinely need a changed record, build a copy rather than mutating:
from dataclasses import replace
updated = replace(key, seed=8)
replace calls __init__ with the merged fields, so __post_init__ runs
and the new value is validated.
My annotation "does not work" — a string went into a float field
It is not supposed to do anything. This is the central point of the day, not
a misconfiguration. Python evaluates each annotation once, stores it in
__annotations__ for tools to read, and never compares any value against
it. Prove it to yourself:
python3 examples/inspect_runtime.py
You will see a string assigned to a field annotated float, an integer
assigned to one annotated list[str], and a function annotated
(number: int) -> int returning 'abab' when called with 'ab' — all with
nothing raised. If you need enforcement at runtime, write it in
__post_init__, or use a library such as pydantic that does it for you.
bash examples/check_types.sh says no static type checker is installed
That is a normal, expected result. This lab installs nothing and uses no
network, and no checker is installed in this environment. The script detects
that, describes the two errors a checker would find in examples/scoring.py,
and exits 0. Nothing in the lab or its tests requires a checker.
If you want one on your own machine, both mypy and pyright are free and open source. Install into a virtual environment (Day 43), not your system Python:
python3 -m pip install mypy
python3 -m mypy examples/scoring.py
Two notes if you do. First, a checker exits non-zero when it finds
errors — for scoring.py that is the success case, since the errors are
planted deliberately, and check_types.sh reports the status rather than
propagating it. Second, this README quotes no verbatim checker message
anywhere, because none was produced on the authoring machine; what a checker
prints, and in what wording, is for you to read from your own run.
python3 examples/scoring.py crashes — is that the type error?
Partly, and the partial answer is the interesting one. scoring.py contains
two planted contradictions. main() passes a single EvalRecord where
mean_score is annotated to take list[EvalRecord], and that one does
crash, because the function tries to iterate a single record. The other —
label() declared -> str while returning record.score, a float — does
not crash at all; the function simply returns the wrong type and any
caller carries on with it. One bug costs you a traceback, the other costs
you a wrong value that travels silently. That asymmetry is the argument for
running a checker.
ModuleNotFoundError: No module named 'records'
The drivers import the module sitting beside them. Run them by path from the
lab directory (python3 examples/demo.py, python3 starter/demo.py) rather
than copying one file elsewhere, and the import resolves — each driver puts
its own directory on sys.path for exactly this reason.
starter/demo.py uses my old code after I edited a file
A stale bytecode cache, or an editor that has not saved. Save, then remove the cache and re-run:
rm -rf starter/__pycache__ examples/__pycache__
python3 starter/demo.py
The test suite fails but the demo looks fine
Read the first failing FAIL: line — each check names the behaviour it
tested. The most common causes are a describe_fields whose strings do not
match the required format exactly (the suite compares
'tags: list[str] = factory list()' character for character), an
annotation_names that sorts or filters instead of returning declaration
order, or a mini_dataclass.__eq__ that returns False rather than
NotImplemented when handed an object of a different class.
The test count is not the number I expected
The suite reports 33 checks, 0 failure(s). while your starter still
contains NotImplementedError, because it checks the starter's structure
only at that stage. Once every exercise is complete it runs the full strict
behaviour suite against your files as well as the reference, so the count
rises. Both are correct results; what must always be true is
0 failure(s). and an exit status of 0.
Security notes
Security notes — Day 069 lab
-
What the lab does: defines classes, builds objects from literal values written into the source, serialises a couple of them to a JSON string in memory, and prints. It creates no files, makes no network connections, needs no privileges, and reads nothing outside its own directory. The test runner sets
PYTHONDONTWRITEBYTECODE=1so it does not even leave a bytecode cache behind. -
A type hint is not a validation, and must never be treated as one at a trust boundary. This is the security lesson of the day.
score: floatis a claim about what should be there, recorded and never checked. A static checker cannot help either, because it reasons about your source code and never sees the value that actually arrived from a file, a request, or a model's response. Runpython3 examples/inspect_runtime.pyand watch a string land in a field annotatedfloatwith nothing raised. Annotate for clarity; validate for safety; never confuse the two. -
__post_init__is the gate that actually runs. If a record has a rule, that is where it belongs, because it executes on every construction — including the ones built from data you did not write:def __post_init__(self) -> None: if not 0.0 <= self.score <= 1.0: raise ValueError(f"score must be between 0.0 and 1.0, got {self.score}")Validate once, at the edge, as the object is built. After that the rest of your program can trust the object instead of re-checking it at every use — and code that re-checks everywhere is code that will eventually forget to.
-
Record(**row)is only as safe as its validation. Unpacking a dictionary that came from a file or a network response straight into a constructor is a convenient pattern and a common one, but every field arrives unchecked. Two things go wrong: unexpected keys raiseTypeError(noisy, therefore fine), and expected keys holding wrong-typed values pass straight through (silent, therefore dangerous).__post_init__is what closes the second gap. For anything genuinely untrusted, a library such as pydantic — which enforces the annotations at runtime — is the right tool. -
The generated
__repr__prints every field, and reprs end up in logs. A dataclass repr appears in tracebacks, debugger output, and any log line that formats the object. Put an API key, a token, a password, or a person's details in a dataclass and you have arranged for it to be printed the next time anything goes wrong — often into a log you forward somewhere else. The fix is one argument per field:from dataclasses import dataclass, field @dataclass class Client: endpoint: str api_key: str = field(repr=False)Make a habit of asking, for every field you declare, whether you would be comfortable seeing it in a log line — because eventually you will.
-
frozen=Trueis a correctness guarantee, not a security boundary. It stops accidental mutation, which is genuinely valuable: it is what makes a generated hash safe, and what stops one part of your program quietly changing a record another part is holding. It is not a defence against code that is actively trying to get past it, and it does not deep-freeze anything — a frozen dataclass holding a list still has a list you can append to. Immutability here means "the field cannot be reassigned", not "the contents cannot change". -
Never rebuild objects from untrusted data with anything that executes it.
asdicthas no automatic reverse, and that is a feature: you write the rebuild, so you decide which fields are accepted. Do that withjson.loadsand explicit field access, asrecords_from_jsondoes inexamples/records.py. Never useeval(), and never usepickleon data from outside your program — unpickling runs code by design, so a pickle file from an untrusted source is an executable, not a document. This matters more than it sounds: shared model and dataset artifacts are a place people still meet pickles in the wild. -
Reading before running: every file in this lab is short and commented. Read
examples/records.py,examples/mini_dataclass.py, andtests/run_tests.shbefore running them. Running unread scripts is one of the most common ways developers get compromised; the course's rule is that every lab script is small enough to read and understand first. Note in particular thatexamples/check_types.shwill execute a type checker if it finds one on yourPATH— read that script and satisfy yourself about what it invokes and with what arguments before you install a checker and re-run it.