157. Dataclasses
Structured data with dataclasses
157. Dataclasses
What You Built πΊοΈ
| Version | Feature | New Python concepts |
|---|---|---|
| v0.0 | subprocess (naive) | subprocess.run, capture_output, text, check |
| v0.1 | psutil cross-platform | psutil, NoSuchProcess, AccessDenied |
| v0.2 | threshold + filtering | list comprehensions, sorted(key=) |
| v0.3 | clean functions | return values, parameters, no globals |
| v0.4 | JSON + CSV reports | json.dump, csv.DictWriter, pathlib, strftime |
| v0.5 | argparse CLI | argparse, type=int, store_true |
| v0.6 | LLM API call | requests, dotenv, raise_for_status, timeout |
| v0.7 | Rich output | Console, Table, markup |
| v0.8 | Click CLI | click.group, cli.command, CliRunner |
| v0.9 | TOML config | tomllib, binary mode, merge pattern |
| v1.0 | Production polish | logging, Counter, @wraps |
| v1.1 | AI-guided kill | SIGTERM/SIGKILL, psutil.terminate(), AccessDenied |
| v1.2 | Cron scheduling | sys.executable, crontab, schedule |
| bonus | Textual TUI | compose(), 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.0Defaults 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 stringfrozen=True β immutable record
@dataclass(frozen=True)
class Config:
threshold: int = 80
top_n: int = 5
cfg = Config()
cfg.threshold = 90 # raises FrozenInstanceErrordataclass vs dict
| dict | dataclass | |
|---|---|---|
| field access | d[βnameβ] | d.name |
| typo detection | silent KeyError | AttributeError |
| default values | manual | field(default_factory=) |
| validation | manual | post_init |
| IDE autocomplete | no | yes |
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 tooTests
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'