前提
本日のお題
15. dataclasses を使った簡易家計簿
何を作る?
Entry(date, category, amount, memo) のような dataclass を作り、CLI で追加 / 集計。
学べること
-
dataclasses.dataclassの基本(型ヒント・デフォルト値) - リスト操作とフィルタ・合計
- JSON / CSV 保存との組み合わせ
面白いところ
- クラス設計の練習にもなる
- カテゴリ別集計・グラフ表示などに広げる余地がある
回答
コード
15_kakeibo.py
#!/usr/bin/env python3
"""
dataclasses を使った簡易家計簿 CLI
機能:
- 追加: add … 1件分の支出を追加
- 一覧: list … 全レコード or フィルタ付きで一覧表示
- 集計: summary … カテゴリ別の合計を集計表示
保存形式:
- カレントディレクトリの kakeibo.json に保存(変更したければ --file オプション)
使い方例:
# 1件追加
python kakeibo.py add --date 2025-12-15 --category 食費 --amount 1200 --memo 牛丼
# 一覧表示
python kakeibo.py list
python kakeibo.py list --category 食費
python kakeibo.py list --month 2025-12
# カテゴリ別集計
python kakeibo.py summary
python kakeibo.py summary --month 2025-12
"""
from __future__ import annotations
import argparse
import json
from dataclasses import dataclass, asdict
from datetime import date, datetime
from pathlib import Path
from typing import List, Dict, Any, Iterable, Optional
# ===== dataclass 定義 =====
@dataclass
class Entry:
date: date # 日付
category: str # カテゴリ(食費, 交通, 娯楽 など)
amount: int # 金額(+は支出として扱う想定)
memo: str = "" # メモ(任意)
def to_dict(self) -> Dict[str, Any]:
"""JSON 保存用に dict へ変換(date を ISO 文字列にする)。"""
d = asdict(self)
d["date"] = self.date.isoformat()
return d
@staticmethod
def from_dict(d: Dict[str, Any]) -> "Entry":
"""dict から Entry を復元。date は ISO 文字列を想定。"""
dt = datetime.strptime(d["date"], "%Y-%m-%d").date()
return Entry(
date=dt,
category=d["category"],
amount=int(d["amount"]),
memo=d.get("memo", ""),
)
# ===== データの読み書き =====
def load_entries(path: Path) -> List[Entry]:
if not path.exists():
return []
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
return [Entry.from_dict(item) for item in data]
def save_entries(path: Path, entries: List[Entry]) -> None:
data = [e.to_dict() for e in entries]
with path.open("w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
# ===== コマンド実装 =====
def cmd_add(args: argparse.Namespace) -> None:
file_path: Path = args.file
# 既存データ読み込み
entries = load_entries(file_path)
# 日付パース
try:
dt = datetime.strptime(args.date, "%Y-%m-%d").date()
except ValueError:
print(f"[ERROR] 日付の形式が不正です(YYYY-MM-DD 形式): {args.date}")
return
entry = Entry(
date=dt,
category=args.category,
amount=args.amount,
memo=args.memo or "",
)
entries.append(entry)
save_entries(file_path, entries)
print("追加しました:")
print(format_entry(entry))
def format_entry(e: Entry) -> str:
return f"{e.date.isoformat()} [{e.category}] {e.amount:>8} 円 {e.memo}"
def filter_entries(
entries: Iterable[Entry],
category: Optional[str] = None,
month: Optional[str] = None,
) -> List[Entry]:
"""
category(完全一致)と month(YYYY-MM)でフィルタ。
"""
result: List[Entry] = []
for e in entries:
if category is not None and e.category != category:
continue
if month is not None:
prefix = month + "-"
if not e.date.isoformat().startswith(prefix):
continue
result.append(e)
return result
def cmd_list(args: argparse.Namespace) -> None:
file_path: Path = args.file
entries = load_entries(file_path)
entries = filter_entries(
entries,
category=args.category,
month=args.month,
)
if not entries:
print("レコードがありません。")
return
# 日付順に並べ替え
entries.sort(key=lambda e: e.date)
print("=== 家計簿 一覧 ===")
if args.category:
print(f"カテゴリ: {args.category}")
if args.month:
print(f"対象月: {args.month}")
print()
total = 0
for e in entries:
print(format_entry(e))
total += e.amount
print()
print(f"合計: {total} 円")
def cmd_summary(args: argparse.Namespace) -> None:
file_path: Path = args.file
entries = load_entries(file_path)
entries = filter_entries(
entries,
category=None,
month=args.month,
)
if not entries:
print("レコードがありません。")
return
# カテゴリ別に合計
summary: Dict[str, int] = {}
for e in entries:
summary.setdefault(e.category, 0)
summary[e.category] += e.amount
print("=== カテゴリ別集計 ===")
if args.month:
print(f"対象月: {args.month}")
print()
total = 0
for cat, amount in sorted(summary.items(), key=lambda x: x[0]):
print(f"{cat:10s}: {amount:8d} 円")
total += amount
print("-" * 25)
print(f"{'合計':10s}: {total:8d} 円")
# ===== CLI パーサ =====
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="dataclasses を使った簡易家計簿 CLI",
)
parser.add_argument(
"--file",
type=Path,
default=Path("kakeibo.json"),
help="家計簿データの保存ファイル(デフォルト: kakeibo.json)",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# add
p_add = subparsers.add_parser("add", help="家計簿エントリを追加")
p_add.add_argument("--date", required=True, help="日付 (YYYY-MM-DD)")
p_add.add_argument("--category", required=True, help="カテゴリ(食費, 交通 など)")
p_add.add_argument("--amount", required=True, type=int, help="金額(円)")
p_add.add_argument("--memo", help="メモ(任意)")
p_add.set_defaults(func=cmd_add)
# list
p_list = subparsers.add_parser("list", help="エントリを一覧表示")
p_list.add_argument("--category", help="カテゴリでフィルタ")
p_list.add_argument("--month", help="対象月 (YYYY-MM) でフィルタ")
p_list.set_defaults(func=cmd_list)
# summary
p_summary = subparsers.add_parser("summary", help="カテゴリ別の合計を集計")
p_summary.add_argument("--month", help="対象月 (YYYY-MM) でフィルタ")
p_summary.set_defaults(func=cmd_summary)
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()
実行例
$python 15_kakeibo.py add --date 2025-12-15 --category 食費 --amount 1200 --memo 牛丼
追加しました:
2025-12-15 [食費] 1200 円 牛丼
$python 15_kakeibo.py add --date 2025-12-15 --category 交通 --amount 220 --memo バス
追加しました:
2025-12-15 [交通] 220 円 バス
$python 15_kakeibo.py add --date 2025-12-16 --category 食費 --amount 800 --memo コンビニ
追加しました:
2025-12-16 [食費] 800 円 コンビニ
$python 15_kakeibo.py list
=== 家計簿 一覧 ===
2025-12-15 [食費] 1200 円 牛丼
2025-12-15 [交通] 220 円 バス
2025-12-16 [食費] 800 円 コンビニ
合計: 2220 円
$python 15_kakeibo.py list --category 食費
=== 家計簿 一覧 ===
カテゴリ: 食費
2025-12-15 [食費] 1200 円 牛丼
2025-12-16 [食費] 800 円 コンビニ
合計: 2000 円
$python 15_kakeibo.py list --month 2025-12
=== 家計簿 一覧 ===
対象月: 2025-12
2025-12-15 [食費] 1200 円 牛丼
2025-12-15 [交通] 220 円 バス
2025-12-16 [食費] 800 円 コンビニ
合計: 2220 円
$python 15_kakeibo.py summary
=== カテゴリ別集計 ===
交通 : 220 円
食費 : 2000 円
-------------------------
合計 : 2220 円
$python 15_kakeibo.py summary --month 2025-12
=== カテゴリ別集計 ===
対象月: 2025-12
交通 : 220 円
食費 : 2000 円
-------------------------
合計 : 2220 円
感想
- Dataclass便利
- subparserは初めて知った
- 特定コマンドにしか必要ないオプションとかの設定がちゃんとできそうな気がした
- argumentparserのset_defaultsも初めて知った
- parser.set_defaults(func=xxx)で実行させる関数を切り替えられるのも良いかもしれない※親のparserでargs.func(args)と呼び出すことは必要