Programming with PythonFiles, Errors, and Object-Oriented Python › Day 69

Hands-on lab — Day 69: 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

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 ValueError in a dataclass — and fix it with field(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 FrozenInstanceError refuse 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 by dataclasses.fields(), yet never enforced against any value.
  • Implement a mini_dataclass decorator 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 ValueError and TypeError deliberately.
  • Day 65: json.dumps and json.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 python for python3. 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 uses list[str] field annotations and modern dataclass behaviour; tested on 3.14.0).
  • bash for the test runner (preinstalled on macOS and Linux).
  • Standard library only — dataclasses, json, sys, and pathlib. Nothing to install. See requirements/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.sh handles 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 — shows HandWrittenRecord (22 lines, 18 of code) immediately above EvalRecord, the dataclass that replaces it. The comment above the hand-written class asks you to count how many times the name prompt appears; 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 derived prompt_length; a frozen RunKey used as a dictionary key and sorted; a JSON round trip via asdict with replace() making a validated copy; and the from-scratch mini_dataclass compared against the real one. Every line is deterministic — no clocks, no random numbers, no memory addresses — so it matches expected-output/sample-run.txt character for character.
  • python3 examples/inspect_runtime.py — prints EvalRecord.__annotations__ and the Field objects from dataclasses.fields(), then assigns a string to a field annotated float and an integer to one annotated list[str], and calls a function annotated (number: int) -> int with the string 'ab'. Nothing raises. That is the whole lesson in one script.
  • bash examples/check_types.sh — looks for mypy or pyright on your PATH, then for an importable mypy module. If it finds one it runs it over examples/scoring.py and 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-default ValueError, __post_init__ validation, frozen hashing and FrozenInstanceError, 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

  1. python3 examples/demo.py exits 0 and section 1 shows the hand-written and dataclass reprs differing only in the class name and the extra tags field.
  2. Section 2 shows the plain function returning ['math', 'logic'] on its second call — the shared list — and the dataclass raising ValueError with a message containing use default_factory.
  3. Section 3 reports derived prompt_length: 18 and rejects both the blank prompt and the score of 3.0, with the offending 3.0 in the message.
  4. Section 4 reports equal keys hash the same: True, usable as a dict key: 12, and refuses the assignment with FrozenInstanceError.
  5. Section 5 reports rebuilt == original: True — a value-equality assertion that only works because @dataclass generated __eq__.
  6. Section 7 reports same repr shape (fields and formatting): True, meaning your from-scratch decorator formats identically to the real one.
  7. python3 examples/inspect_runtime.py exits 0 having assigned a string to a float field and an integer to a list[str] field with no error.
  8. bash examples/check_types.sh exits 0 and states clearly whether a checker was found.
  9. Complete exercises 1-7 in starter/, run python3 starter/demo.py, and confirm it matches the reference.
  10. 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

  1. Make the mini decorator refuse mutable defaults. The real @dataclass raises ValueError for a list, dict or set default. Add that check to mini_dataclass, then add your own default_factory support, and confirm your version behaves like the real one on both cases.
  2. Add frozen=True to 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.
  3. Write a runtime checker. Build a @checked decorator that walks dataclasses.fields(self) after __init__ and raises TypeError for any value failing isinstance against its annotation. Handle str, int, float and bool, then decide deliberately what to do about list[str], where isinstance(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.
  4. Compare the four record types. Implement the same three-field record as a hand-written class, a dataclass, a typing.NamedTuple, and a collections.namedtuple. For each, record the lines of code, whether instances are mutable, hashable, and unpackable, whether they compare equal to a plain tuple, and what repr prints. Then say which you would pick for a dictionary key and which for a mutable working record.
  • 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=1 so 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: float is 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. Run python3 examples/inspect_runtime.py and watch a string land in a field annotated float with 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 raise TypeError (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=True is 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. asdict has no automatic reverse, and that is a feature: you write the rebuild, so you decide which fields are accepted. Do that with json.loads and explicit field access, as records_from_json does in examples/records.py. Never use eval(), and never use pickle on 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, and tests/run_tests.sh before 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 that examples/check_types.sh will execute a type checker if it finds one on your PATH — read that script and satisfy yourself about what it invokes and with what arguments before you install a checker and re-run it.