← Home

157. Dataclasses

Structured data with dataclasses

157. Dataclasses

What You Built πŸ—ΊοΈ

VersionFeatureNew Python concepts
v0.0subprocess (naive)subprocess.run, capture_output, text, check
v0.1psutil cross-platformpsutil, NoSuchProcess, AccessDenied
v0.2threshold + filteringlist comprehensions, sorted(key=)
v0.3clean functionsreturn values, parameters, no globals
v0.4JSON + CSV reportsjson.dump, csv.DictWriter, pathlib, strftime
v0.5argparse CLIargparse, type=int, store_true
v0.6LLM API callrequests, dotenv, raise_for_status, timeout
v0.7Rich outputConsole, Table, markup
v0.8Click CLIclick.group, cli.command, CliRunner
v0.9TOML configtomllib, binary mode, merge pattern
v1.0Production polishlogging, Counter, @wraps
v1.1AI-guided killSIGTERM/SIGKILL, psutil.terminate(), AccessDenied
v1.2Cron schedulingsys.executable, crontab, schedule
bonusTextual TUIcompose(), reactive, watch_, set_interval

πŸ’‘ Fun fact: @dataclass was added in Python 3.7 (2018) in PEP 557 by Eric V. Smith. Before that, developers either wrote __init__, __repr__, and __eq__ by hand (tedious and error-prone), or used third-party libraries like attrs. The mutable default problem (processes: list = []) is so common that Python raises ValueError immediately at class definition time β€” one of the few places Python catches a bug before your code even runs.

⚠️ Watch out: field(default_factory=list) and field(default_factory=lambda: []) both create a fresh list per instance β€” but the lambda form is required for any expression that isn’t a callable. datetime.now().isoformat() is an expression, not a callable, so you MUST wrap it: field(default_factory=lambda: datetime.now().isoformat()). Forgetting the lambda is the most common @dataclass beginner mistake.

πŸ€” Think about it: asdict() recursively converts nested dataclasses to dicts. So asdict(RamSnapshot(..., processes=[ProcessInfo(...)])) will convert both the outer RamSnapshot AND each inner ProcessInfo to a dict. Why is this useful for JSON serialization? What would happen if processes contained a mix of ProcessInfo dataclasses and plain dicts? Would asdict() still work? Would json.dumps() still work?

πŸ“¦ Aryan is tired of passing dicts everywhere in the RAM manager. He wants a RamSnapshot type with real field names and defaults. His @dataclass has mutable default values set directly on fields (not via field()), forgets post_init validation, and uses a plain dict where asdict() would give him JSON-ready output.

Learning objectives

  • Use field(default_factory=list) for mutable defaults
  • [object Object]
  • Add post_init for validation
  • Use asdict() to convert to plain dict for JSON
  • Know when to prefer dataclasses over plain dicts

Key concepts

  • @dataclass β€” auto-generates init, repr, eq
  • field(default_factory=) β€” mutable/dynamic defaults
  • post_init β€” validation after init
  • asdict(instance) β€” recursive dict conversion
  • frozen=True β€” immutable dataclass

Try it

Concept detail

Structured Data with @dataclass

Basic usage

from dataclasses import dataclass, field, asdict
from datetime import datetime

@dataclass
class ProcessInfo:
    name: str
    pid: int
    rss_mb: float

proc = ProcessInfo(name='chrome', pid=812, rss_mb=1800.0)
proc.name     # 'chrome'
proc.rss_mb   # 1800.0

Defaults and default_factory

@dataclass
class RamSnapshot:
    percent: float
    used_gb: float
    total_gb: float
    # WRONG β€” shared mutable default:
    # processes: list = []
    # CORRECT:
    processes: list = field(default_factory=list)
    timestamp: str = field(default_factory=lambda: datetime.now().isoformat())

Validation with post_init

@dataclass
class ProcessInfo:
    name: str
    pid: int
    rss_mb: float

    def __post_init__(self):
        if self.rss_mb < 0:
            raise ValueError(f'rss_mb must be >= 0')
        if not self.name:
            raise ValueError('name cannot be empty')

Convert to dict / JSON

from dataclasses import asdict
import json

snap = RamSnapshot(percent=72.0, used_gb=11.5, total_gb=16.0)
d = asdict(snap)             # {'percent': 72.0, 'used_gb': 11.5, ...}
json.dumps(d, indent=2)      # valid JSON string

frozen=True β€” immutable record

@dataclass(frozen=True)
class Config:
    threshold: int = 80
    top_n: int = 5

cfg = Config()
cfg.threshold = 90   # raises FrozenInstanceError

dataclass vs dict

dictdataclass
field accessd[β€˜name’]d.name
typo detectionsilent KeyErrorAttributeError
default valuesmanualfield(default_factory=)
validationmanualpost_init
IDE autocompletenoyes

Solution

from dataclasses import dataclass, field, asdict
from datetime import datetime

@dataclass
class RamSnapshot:
    percent: float
    used_gb: float
    total_gb: float
    # field(default_factory=list) β€” fresh list per instance
    processes: list = field(default_factory=list)
    # field(default_factory=...) β€” evaluated fresh each time
    timestamp: str = field(default_factory=lambda: datetime.now().isoformat())

@dataclass
class ProcessInfo:
    name: str
    pid: int
    rss_mb: float

    def __post_init__(self):
        if self.rss_mb < 0:
            raise ValueError(f'rss_mb must be >= 0, got {self.rss_mb}')
        if self.pid <= 0:
            raise ValueError(f'pid must be > 0, got {self.pid}')

def snapshot_to_dict(snapshot: RamSnapshot) -> dict:
    return asdict(snapshot)   # recursively converts nested dataclasses too

Tests

import time
import pytest
from dataclasses import asdict, fields

def test_processes_default_is_empty_list():
    s = RamSnapshot(percent=72.0, used_gb=11.5, total_gb=16.0)
    assert s.processes == []

def test_processes_default_not_shared():
    s1 = RamSnapshot(percent=72.0, used_gb=11.5, total_gb=16.0)
    s2 = RamSnapshot(percent=80.0, used_gb=12.8, total_gb=16.0)
    s1.processes.append('chrome')
    assert s2.processes == [], 'Mutable defaults must not be shared between instances'

def test_timestamp_is_fresh_each_time():
    s1 = RamSnapshot(percent=72.0, used_gb=11.5, total_gb=16.0)
    time.sleep(1.1)
    s2 = RamSnapshot(percent=80.0, used_gb=12.8, total_gb=16.0)
    assert s1.timestamp != s2.timestamp, 'Each instance must get its own timestamp'

def test_process_info_validates_negative_rss():
    with pytest.raises(ValueError):
        ProcessInfo(name='chrome', pid=812, rss_mb=-1.0)

def test_process_info_validates_zero_pid():
    with pytest.raises(ValueError):
        ProcessInfo(name='chrome', pid=0, rss_mb=100.0)

def test_snapshot_to_dict_returns_dict():
    s = RamSnapshot(percent=72.0, used_gb=11.5, total_gb=16.0)
    d = snapshot_to_dict(s)
    assert isinstance(d, dict)
    assert d['percent'] == 72.0

def test_snapshot_to_dict_uses_asdict():
    import inspect
    src = inspect.getsource(snapshot_to_dict)
    assert 'asdict' in src, 'Use dataclasses.asdict() instead of manual dict'

Resources