JEPA4Japan · tutorials

Dataclasses and Enums

1,286 words 6 min read #Python

Create concise data models and replace ambiguous magic values with named choices.

Course progress Course outline 24 of 24 lessons available

A dataclass is a printed record card

A normal class can spend many lines receiving fields, displaying them, and comparing them. @dataclass prints that routine record-card machinery for you.

  1. Name fields title and minutes
  2. Dataclass generates routine methods
  3. Record readable and comparable
A dataclass gives a group of fields a named shape.
from dataclasses import dataclass


@dataclass
class Reading:
    title: str
    minutes: int


first = Reading("Enums", 25)
second = Reading("Enums", 25)

print(first)
print(first == second)
print(first is second)
Reading(title='Enums', minutes=25)
True
False

By default, the decorator generates __init__(), a useful __repr__(), and field-by-field __eq__() for the same class. Equal values are still two objects, so is remains false. Field annotations also do not validate runtime arguments automatically; Chapter 16 explains that job split.

Give every card its own mutable pocket

Fields without defaults must come before fields with defaults because the generated initializer follows field order:

@dataclass
class Article:
    title: str
    minutes: int
    completed: bool = False

Putting a required field after completed would raise TypeError when the class is created. Simple immutable defaults such as False are fine. A mutable list, dictionary, or set needs a factory:

  1. Aki's card needs a tag pocket
  2. Mina's card needs another pocket
  3. Factory makes a fresh list each time
  4. No sharing one card's tags stay private
default_factory prevents accidental shared mutable state.
from dataclasses import dataclass, field


@dataclass
class Notebook:
    owner: str
    tags: list[str] = field(default_factory=list)


mine = Notebook("Aki")
yours = Notebook("Mina")
mine.tags.append("python")

print(mine.tags, yours.tags)
print(mine.tags is yours.tags)
['python'] []
False

Pass the factory itself—list, without ()—so the generated initializer calls it for every new instance.

Check the card after its fields arrive

The generated initializer calls __post_init__() after assigning fields. Use it for small validation, normalization, or derived fields:

from dataclasses import dataclass, field


@dataclass
class Session:
    topic: str
    minutes: int
    label: str = field(init=False)

    def __post_init__(self):
        self.topic = self.topic.strip()
        if not self.topic or self.minutes <= 0:
            raise ValueError("topic and minutes must be valid")
        self.label = f"{self.topic} ({self.minutes} min)"


print(Session("  Python  ", 30).label)
Python (30 min)

init=False keeps label out of constructor arguments. Keep file access and big workflows out of __post_init__() so construction stays predictable.

@dataclass(frozen=True) blocks normal field reassignment. Use dataclasses.replace() to make a changed copy. But frozen is a shallow promise:

@dataclass(frozen=True)
class FrozenPocket:
    tags: list[str] = field(default_factory=list)


pocket = FrozenPocket()
pocket.tags.append("still changes")
print(pocket.tags)
['still changes']

A frozen dataclass normally gets a hash when equality is generated, but hashing still fails if a compared field contains an unhashable list. Deeply stable values need immutable fields such as strings, numbers, tuples, and frozen sets. unsafe_hash=True is not a magic fix for changing data.

An Enum is a menu with fixed choices

An Enum replaces loose strings with known member objects.

  1. Menu LOW, NORMAL, HIGH
  2. Member Priority.HIGH
  3. Stored value "high"
An enum turns one choice from a closed vocabulary into a named object.
from enum import Enum


class Priority(Enum):
    LOW = "low"
    NORMAL = "normal"
    HIGH = "high"


choice = Priority("high")
print(choice)
print(choice.name)
print(choice.value)
print(choice is Priority.HIGH)
print(choice == "high")
Priority.HIGH
HIGH
high
True
False

Members are singletons inside their Enum, so identity comparison with is is conventional. Priority("high") looks up by value; Priority["HIGH"] looks up by name. Unknown input raises ValueError or KeyError. Plain Enum members are not ordered with < merely because their values might be; use an explicit key or rank.

Tiny project: Prioritized Reading Queue

Create reading_queue.py. This standard-library program combines a frozen item dataclass, an Enum, validation, and a queue whose list comes from default_factory.

from dataclasses import dataclass, field
from enum import Enum


class Priority(Enum):
    LOW = "low"
    NORMAL = "normal"
    HIGH = "high"


RANK = {Priority.LOW: 1, Priority.NORMAL: 2, Priority.HIGH: 3}


@dataclass(frozen=True)
class ReadingItem:
    title: str
    minutes: int
    priority: Priority = Priority.NORMAL

    def __post_init__(self):
        clean = self.title.strip()
        if not clean:
            raise ValueError("title must not be blank")
        if (
            isinstance(self.minutes, bool)
            or not isinstance(self.minutes, int)
            or self.minutes <= 0
        ):
            raise ValueError("minutes must be a positive integer")
        if not isinstance(self.priority, Priority):
            raise TypeError("priority must be a Priority")
        object.__setattr__(self, "title", clean)


@dataclass
class ReadingQueue:
    name: str
    minute_limit: int = 45
    items: list[ReadingItem] = field(default_factory=list)

    def add(self, item):
        self.items.append(item)

    def plan(self):
        ordered = sorted(
            self.items,
            key=lambda item: (-RANK[item.priority], item.minutes, item.title),
        )
        chosen = []
        used = 0
        for item in ordered:
            if used + item.minutes <= self.minute_limit:
                chosen.append(item)
                used += item.minutes
        return chosen


queue = ReadingQueue("Tonight")
for title, minutes, raw_priority in [
    ("  Dataclasses  ", 25, "high"),
    ("Enum boundaries", 15, "normal"),
    ("Hashing notes", 10, "high"),
]:
    queue.add(ReadingItem(title, minutes, Priority(raw_priority)))

plan = queue.plan()
print(f"{queue.name}:")
for number, item in enumerate(plan, start=1):
    print(f"{number}. [{item.priority.name}] {item.title} - {item.minutes} min")
print(f"Total: {sum(item.minutes for item in plan)} min")
print(f"Unique items: {len(set(plan))}")
print(f"Fresh queue is empty: {ReadingQueue('Tomorrow').items == []}")

Run python3 reading_queue.py and check:

Tonight:
1. [HIGH] Hashing notes - 10 min
2. [HIGH] Dataclasses - 25 min
Total: 35 min
Unique items: 2
Fresh queue is empty: True

The frozen items are hashable because all compared fields are hashable. Boundary reminder: annotations do not enforce Priority; __post_init__() does. Also, a negative limit is outside this tiny queue’s contract and should be validated before accepting user input.

Three tiny missions

  1. Factory proof. Make two queues, add to one, and prove the other’s item list stays empty.
  2. Frozen copy. Use dataclasses.replace() to make a completed version of an item without changing the original.
  3. Enum boundary. Parse one valid and one unknown priority value, giving the unknown choice a clear error message.

You are ready for Chapter 16 when…

  • you know what default @dataclass generates and what is still means;
  • you put required fields before default fields;
  • you use default_factory for independent mutable defaults;
  • you validate or derive small values in __post_init__();
  • you can explain why frozen is shallow and hashing depends on every field;
  • you distinguish an Enum member, its name, and its value;
  • you compare members by identity and parse outside text at a boundary;
  • you can run the queue and see Fresh queue is empty: True.

Next, you will add type hints and protocols that make these data and behavior contracts visible to static tools.