129. File I/O And JSON Reports
Persistent JSON reports with pathlib and safe file writes
129. File I/O And JSON Reports
📄 Aryan wants to save each RAM session as a JSON report and load the last 7 days of history. His save function corrupts files on crash, uses the wrong path join method, and doesn’t create the output directory if it doesn’t exist.
💡 Fun fact: The atomic write pattern (write to .tmp, then rename) is used by SQLite, Firefox, and almost every database on the planet. The POSIX rename() syscall is guaranteed atomic — the OS either sees the old file or the new one, never a half-written mess. This pattern has been saving data integrity since Unix was born.
⚠️ Watch out: Path.glob() returns files in an arbitrary, OS-defined order — it is NOT alphabetical, NOT chronological, and NOT consistent between runs. Always call sorted() on the result. For ISO date filenames like session_2024-01-15.json, alphabetical sort IS chronological sort, which is exactly why we name files that way.
🤔 Think about it: Why is Path.home() / '.ram_manager' / 'reports' better than a hardcoded string like '/home/aryan/.ram_manager/reports'? What happens when someone else runs your code?
Learning objectives
- Create directories safely with Path.mkdir(parents=True, exist_ok=True)
- Implement atomic file writes using a .tmp file and Path.replace()
- Use sorted() on glob results before slicing for reliable ordering
- Convert dataclass instances to dicts with dataclasses.asdict()
- Load and slice file-based history by sorting filenames
Key concepts
- Path.mkdir(parents=True, exist_ok=True) — safe directory creation
- Path.replace() — atomic rename on POSIX
- sorted(path.glob(…)) — deterministic file ordering
- dataclasses.asdict() — dataclass to JSON-serializable dict
- with_suffix(‘.tmp’) — temporary file path
Try it
Concept detail
File I/O and JSON Reports
Reading and writing files with pathlib
from pathlib import Path
path = Path.home() / '.myapp' / 'data.json'
# Create directory (safe even if it already exists)
path.parent.mkdir(parents=True, exist_ok=True)
# Write
path.write_text(json.dumps(data, indent=2))
# Read
data = json.loads(path.read_text())
# Append pattern (read → modify → write)
existing = json.loads(path.read_text()) if path.exists() else []
existing.append(new_item)
path.write_text(json.dumps(existing, indent=2))Atomic writes — prevent corruption on crash
# WRONG: direct write — reader may see partial content if process dies
path.write_text(json.dumps(data))
# RIGHT: write to .tmp, then atomically rename
tmp = path.with_suffix('.tmp')
tmp.write_text(json.dumps(data, indent=2))
tmp.replace(path) # atomic on POSIX (Linux, macOS)Sorting glob results
# Arbitrary order — don't rely on it
for f in path.glob('*.json'):
...
# Sorted (alphabetical = chronological for ISO date filenames)
for f in sorted(path.glob('*.json')):
...
# Last N files
recent = sorted(path.glob('session_*.json'))[-7:]dataclasses.asdict()
from dataclasses import dataclass, asdict
@dataclass
class Report:
timestamp: str
value: float
r = Report('2024-01-15', 72.3)
asdict(r) # → {'timestamp': '2024-01-15', 'value': 72.3}
json.dumps(asdict(r)) # fully JSON-serializableSolution
import json
from pathlib import Path
from dataclasses import dataclass, asdict
from datetime import datetime
REPORT_DIR = Path.home() / '.ram_manager' / 'reports'
@dataclass
class SessionReport:
timestamp: str
ram_percent: float
top_processes: list
llm_advice: str
def save_report(report: SessionReport, report_dir: Path = None) -> Path:
"""Save a session report as JSON, appending to today's file."""
dir_to_use = report_dir if report_dir is not None else REPORT_DIR
dir_to_use.mkdir(parents=True, exist_ok=True) # FIX 1: create if missing
filename = dir_to_use / f'session_{report.timestamp[:10]}.json'
existing = []
if filename.exists():
existing = json.loads(filename.read_text())
existing.append(asdict(report))
# FIX 2: Atomic write — write to .tmp then rename
# rename() is atomic on POSIX — reader never sees partial content
tmp = filename.with_suffix('.tmp')
tmp.write_text(json.dumps(existing, indent=2))
tmp.replace(filename)
return filename
def load_history(days: int = 7, report_dir: Path = None) -> list:
"""Load the last N days of session reports."""
dir_to_use = report_dir if report_dir is not None else REPORT_DIR
all_sessions = []
# FIX 3+4: Sort by filename (ISO date prefix), slice to last N files
for path in sorted(dir_to_use.glob('session_*.json'))[-days:]:
all_sessions.extend(json.loads(path.read_text()))
return all_sessions
def report_summary(reports: list) -> dict:
"""Summarise a list of report dicts."""
if not reports:
return {'count': 0, 'avg_ram': 0.0, 'max_ram': 0.0}
percents = [r['ram_percent'] for r in reports]
return {
'count': len(percents),
'avg_ram': round(sum(percents) / len(percents), 1), # FIX 5: round result
'max_ram': max(percents),
}Tests
import json
import tempfile
from pathlib import Path
from datetime import date
def _make_report(percent=60.0, date_str=None):
return SessionReport(
timestamp=f'{date_str or date.today().isoformat()}T10:00:00',
ram_percent=percent,
top_processes=[{'name': 'chrome', 'rss_mb': 500}],
llm_advice='Kill Chrome to free RAM.',
)
def test_save_report_creates_directory():
with tempfile.TemporaryDirectory() as tmp:
missing_dir = Path(tmp) / 'deep' / 'nested' / 'reports'
report = _make_report()
save_report(report, report_dir=missing_dir)
assert missing_dir.exists(), 'save_report must create the directory'
def test_save_report_appends_to_existing():
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
r1 = _make_report(percent=50.0)
r2 = _make_report(percent=75.0)
save_report(r1, report_dir=tmp_path)
save_report(r2, report_dir=tmp_path)
today = date.today().isoformat()
file = tmp_path / f'session_{today}.json'
data = json.loads(file.read_text())
assert len(data) == 2
assert data[0]['ram_percent'] == 50.0
assert data[1]['ram_percent'] == 75.0
def test_save_report_atomic_no_tmp_on_success():
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
save_report(_make_report(), report_dir=tmp_path)
tmp_files = list(tmp_path.glob('*.tmp'))
assert tmp_files == [], '.tmp file should be deleted after successful save'
def test_load_history_returns_recent_days():
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
# Create 10 days of reports
for i in range(10):
d = f'2024-01-{i+1:02d}'
f = tmp_path / f'session_{d}.json'
f.write_text(json.dumps([{
'timestamp': f'{d}T00:00:00',
'ram_percent': float(i * 10),
'top_processes': [],
'llm_advice': '',
}]))
history = load_history(days=3, report_dir=tmp_path)
# Should include only the 3 most recent files' sessions (1 per file)
assert len(history) == 3
def test_report_summary_empty():
s = report_summary([])
assert s == {'count': 0, 'avg_ram': 0.0, 'max_ram': 0.0}
def test_report_summary_calculates_correctly():
reports = [
{'ram_percent': 50.0},
{'ram_percent': 70.0},
{'ram_percent': 90.0},
]
s = report_summary(reports)
assert s['count'] == 3
assert s['avg_ram'] == 70.0 # (50+70+90)/3
assert s['max_ram'] == 90.0
def test_report_summary_rounding():
reports = [{'ram_percent': 33.3}, {'ram_percent': 33.3}, {'ram_percent': 33.4}]
s = report_summary(reports)
# Without round(), result is 33.333333... — must be rounded to 1 decimal
assert isinstance(s['avg_ram'], float)
assert s['avg_ram'] == round(sum([33.3, 33.3, 33.4]) / 3, 1)