1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Day15. dataclasses を使った簡易家計簿 - 勝手にChatGPTチャレンジ (Python)

Last updated at Posted at 2025-12-15

前提

本日のお題


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)と呼び出すことは必要
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?