初心者向け No.064

お小遣い帳

収入・支出を入力して残高を管理するシンプルな家計アプリ。カテゴリ別集計とCSVエクスポート機能付き。

🎯 難易度: ★★☆ 📦 ライブラリ: tkinter(標準ライブラリ) ⏱️ 制作時間: 30〜90分

1. アプリ概要

収入・支出を入力して残高を管理するシンプルな家計アプリ。カテゴリ別集計とCSVエクスポート機能付き。

このアプリはorganizersカテゴリの実践的なPythonアプリです。使用ライブラリは tkinter(標準ライブラリ)、難易度は ★★☆ です。

このアプリは「整理ツール」カテゴリです。整理・分類系のツールはデータ構造とユーザー操作を結びつける書き方を学ぶ好機です。設計次第で書きやすさが大きく変わります。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。

動かしながら読むことが理解の最短経路です。まずはコードをコピーして実行し、想定どおりに動くことを確認したうえで解説と照らし合わせてください。

カスタマイズでは「機能追加」「UI 改善」「エラー耐性」の三方向で考えると視野が広がります。練習問題にもそれぞれの方向の具体例を用意しています。

お小遣い帳 実行画面(Windows)
実行画面(Windows)
お小遣い帳 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • 「追加」ボタン(add_record())・「選択行を削除」ボタン(delete_selected())・「CSV保存」ボタン(save_csv())・「CSV読込」ボタン(load_csv())・「全消去」ボタン(clear_all())で操作
  • ttk.Treeview による表形式の一覧表示
  • CSV ファイルへの書き出し
  • filedialog.askopenfilename() によるファイル選択ダイアログ
  • filedialog.asksaveasfilename() によるファイル選択ダイアログ
  • messagebox.askyesno()messagebox.showerror()messagebox.showinfo() によるダイアログ通知
  • ウィンドウはタイトル「お小遣い帳」・サイズ 760x552 で起動

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)

Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は標準ライブラリの範囲で動作する想定ですが、未検証です。

  • Python 3.10 以上
  • OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

追加インストール不要(標準ライブラリのみ使用)
app064.py
import csv
import datetime as _dt
import tkinter as tk
from tkinter import ttk, messagebox, filedialog


class App064:
    """お小遣い帳"""

    CATEGORIES = ["食費", "交通費", "娯楽", "学用品", "おこづかい", "お祝い", "その他"]

    def __init__(self, root):
        self.root = root
        self.root.title("お小遣い帳")
        self.root.geometry("760x552")
        self.root.minsize(760, 552)
        self.root.configure(bg="#f8f9fc")
        # 取引データ: list of dict(date, type, category, amount, memo)
        self.records = []
        self._build_ui()
        self._refresh_view()

    def _build_ui(self):
        """UIを構築する"""
        title_frame = tk.Frame(self.root, bg="#3776ab", pady=12)
        title_frame.pack(fill=tk.X)
        tk.Label(
            title_frame, text="お小遣い帳",
            font=("Noto Sans JP", 16, "bold"),
            bg="#3776ab", fg="white"
        ).pack()

        main = tk.Frame(self.root, bg="#f8f9fc", padx=16, pady=12)
        main.pack(fill=tk.BOTH, expand=True)

        # 入力フォーム
        form = ttk.LabelFrame(main, text="取引を入力", padding=10)
        form.pack(fill=tk.X, pady=(0, 8))

        ttk.Label(form, text="日付(YYYY-MM-DD):").grid(row=0, column=0, sticky="w")
        self.date_var = tk.StringVar(value=_dt.date.today().isoformat())
        ttk.Entry(form, textvariable=self.date_var, width=12).grid(row=0, column=1, padx=4)

        ttk.Label(form, text="種別:").grid(row=0, column=2, sticky="w")
        self.type_var = tk.StringVar(value="支出")
        ttk.Combobox(form, textvariable=self.type_var,
                     values=["収入", "支出"], width=6, state="readonly"
                     ).grid(row=0, column=3, padx=4)

        ttk.Label(form, text="カテゴリ:").grid(row=0, column=4, sticky="w")
        self.cat_var = tk.StringVar(value=self.CATEGORIES[0])
        ttk.Combobox(form, textvariable=self.cat_var,
                     values=self.CATEGORIES, width=10
                     ).grid(row=0, column=5, padx=4)

        ttk.Label(form, text="金額:").grid(row=1, column=0, sticky="w", pady=(6, 0))
        self.amount_var = tk.StringVar()
        ttk.Entry(form, textvariable=self.amount_var, width=12
                  ).grid(row=1, column=1, padx=4, pady=(6, 0))

        ttk.Label(form, text="メモ:").grid(row=1, column=2, sticky="w", pady=(6, 0))
        self.memo_var = tk.StringVar()
        ttk.Entry(form, textvariable=self.memo_var, width=30
                  ).grid(row=1, column=3, columnspan=3, padx=4, pady=(6, 0), sticky="ew")

        # 操作ボタン
        btns = tk.Frame(main, bg="#f8f9fc")
        btns.pack(fill=tk.X, pady=4)
        ttk.Button(btns, text="追加", command=self.add_record).pack(side=tk.LEFT, padx=2)
        ttk.Button(btns, text="選択行を削除", command=self.delete_selected).pack(side=tk.LEFT, padx=2)
        ttk.Button(btns, text="CSV保存", command=self.save_csv).pack(side=tk.LEFT, padx=2)
        ttk.Button(btns, text="CSV読込", command=self.load_csv).pack(side=tk.LEFT, padx=2)
        ttk.Button(btns, text="全消去", command=self.clear_all).pack(side=tk.LEFT, padx=2)

        # 取引一覧
        list_frame = ttk.LabelFrame(main, text="取引履歴", padding=4)
        list_frame.pack(fill=tk.BOTH, expand=True, pady=(4, 4))
        cols = ("date", "type", "category", "amount", "memo")
        self.tree = ttk.Treeview(list_frame, columns=cols, show="headings", height=10)
        for c, w, t in [("date", 90, "日付"), ("type", 60, "種別"),
                         ("category", 90, "カテゴリ"), ("amount", 90, "金額"),
                         ("memo", 240, "メモ")]:
            self.tree.heading(c, text=t)
            self.tree.column(c, width=w, anchor="center")
        self.tree.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)
        sb = ttk.Scrollbar(list_frame, command=self.tree.yview)
        self.tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)

        # サマリー表示
        summary = ttk.LabelFrame(main, text="集計", padding=8)
        summary.pack(fill=tk.X)
        self.summary_var = tk.StringVar()
        tk.Label(summary, textvariable=self.summary_var, bg="#f8f9fc",
                 font=("Noto Sans JP", 10), justify=tk.LEFT
                 ).pack(anchor="w")

    def add_record(self):
        """取引を追加する"""
        date = self.date_var.get().strip()
        ttype = self.type_var.get().strip()
        category = self.cat_var.get().strip() or "その他"
        amount_s = self.amount_var.get().strip()
        memo = self.memo_var.get().strip()
        try:
            _dt.date.fromisoformat(date)
        except ValueError:
            messagebox.showerror("エラー", "日付は YYYY-MM-DD 形式で入力してください")
            return
        try:
            amount = int(amount_s)
            if amount <= 0:
                raise ValueError
        except ValueError:
            messagebox.showerror("エラー", "金額は正の整数で入力してください")
            return
        if ttype not in ("収入", "支出"):
            messagebox.showerror("エラー", "種別は 収入/支出 を選択してください")
            return
        self.records.append({
            "date": date, "type": ttype, "category": category,
            "amount": amount, "memo": memo,
        })
        self.amount_var.set("")
        self.memo_var.set("")
        self._refresh_view()

    def delete_selected(self):
        """選択された行を削除する"""
        sel = self.tree.selection()
        if not sel:
            messagebox.showinfo("情報", "削除する行を選択してください")
            return
        for item in sel:
            idx = int(self.tree.item(item, "tags")[0])
            self.records[idx] = None
        self.records = [r for r in self.records if r is not None]
        self._refresh_view()

    def clear_all(self):
        """全データを消去する"""
        if not self.records:
            return
        if messagebox.askyesno("確認", "全ての取引を削除しますか?"):
            self.records.clear()
            self._refresh_view()

    def save_csv(self):
        """CSVファイルとして保存する"""
        if not self.records:
            messagebox.showinfo("情報", "保存する取引がありません")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSVファイル", "*.csv")],
            initialfile="allowance.csv",
        )
        if not path:
            return
        try:
            with open(path, "w", newline="", encoding="utf-8-sig") as f:
                w = csv.DictWriter(f, fieldnames=["date", "type", "category", "amount", "memo"])
                w.writeheader()
                w.writerows(self.records)
            messagebox.showinfo("完了", f"保存しました: {path}")
        except OSError as e:
            messagebox.showerror("エラー", f"保存に失敗しました: {e}")

    def load_csv(self):
        """CSVファイルから読み込む"""
        path = filedialog.askopenfilename(filetypes=[("CSVファイル", "*.csv")])
        if not path:
            return
        try:
            with open(path, "r", encoding="utf-8-sig") as f:
                reader = csv.DictReader(f)
                loaded = []
                for row in reader:
                    try:
                        loaded.append({
                            "date": row["date"],
                            "type": row["type"],
                            "category": row.get("category", "その他"),
                            "amount": int(row["amount"]),
                            "memo": row.get("memo", ""),
                        })
                    except (KeyError, ValueError):
                        continue
            self.records.extend(loaded)
            self._refresh_view()
            messagebox.showinfo("完了", f"{len(loaded)} 件読み込みました")
        except (OSError, csv.Error) as e:
            messagebox.showerror("エラー", f"読み込みに失敗しました: {e}")

    def _refresh_view(self):
        """ツリービューと集計を更新する"""
        self.tree.delete(*self.tree.get_children())
        income = 0
        expense = 0
        cat_sum = {}
        # 日付昇順表示
        ordered = sorted(enumerate(self.records), key=lambda x: x[1]["date"])
        for idx, r in ordered:
            sign = "+" if r["type"] == "収入" else "-"
            self.tree.insert("", tk.END, values=(
                r["date"], r["type"], r["category"],
                f"{sign}{r['amount']:,}円", r["memo"]
            ), tags=(str(idx),))
            if r["type"] == "収入":
                income += r["amount"]
            else:
                expense += r["amount"]
                cat_sum[r["category"]] = cat_sum.get(r["category"], 0) + r["amount"]
        balance = income - expense
        cat_lines = ", ".join(f"{k}:{v:,}円" for k, v in sorted(cat_sum.items(), key=lambda x: -x[1]))
        self.summary_var.set(
            f"収入合計: {income:,}円  支出合計: {expense:,}円  残高: {balance:,}円\n"
            f"支出カテゴリ別: {cat_lines or '(なし)'}"
        )


if __name__ == "__main__":
    root = tk.Tk()
    app = App064(root)
    root.mainloop()

5. コード解説

お小遣い帳のコードを、実際に書かれている実装に沿って解説します。

クラス設計とコンストラクタ

App064 クラスにアプリの全機能をまとめています(メソッド8個・全225行)。__init__ ではタイトル「お小遣い帳」とウィンドウサイズ 760x552 を設定します。そのあと _build_ui()_refresh_view() の順に呼び出して初期状態を作ります。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Frame×3・tk.Label×2・ttk.LabelFrame×3・ttk.Label×5・ttk.Entry×3・ttk.Combobox×2・ttk.Button×5・ttk.Treeviewttk.Scrollbar で構成しています。見出し付きの枠(LabelFrame)は「取引を入力」「取引履歴」「集計」のラベルでエリアを区切っています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「追加」ボタン(add_record())・「選択行を削除」ボタン(delete_selected())・「CSV保存」ボタン(save_csv())・「CSV読込」ボタン(load_csv())・「全消去」ボタン(clear_all())を command= で接続しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

クラス定数によるデータ定義

クラスの先頭でデータを定数として定義しています:CATEGORIES = ['食費', '交通費', '娯楽', '学用品', 'おこづかい', 'お祝い', 'その他']。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

例外処理

try-except で KeyErrorOSErrorValueErrorcsv.Error を捕捉しています。あわせて messagebox.askyesno()messagebox.showerror()messagebox.showinfo() のダイアログで、ユーザーへの通知・確認を行います。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app064.py と保存します。使うのは csvdatetimetkinter だけなので、追加インストールは不要です。

  2. 2
    クラスの骨格を作る

    App064 クラスを定義し、__init__ と、末尾の root = tk.Tk()mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("お小遣い帳")geometry("760x552")configure(bg="#f8f9fc")minsize(760, 552) をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×3・tk.Label×2・ttk.LabelFrame×3・ttk.Label×5・ttk.Entry×3・ttk.Combobox×2・ttk.Button×5・ttk.Treeviewttk.Scrollbar を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(add_record()delete_selected()save_csv()load_csv()clear_all())につなぎます。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である add_record()(29行)・load_csv()(25行)・save_csv()(20行)など を実装します。

  7. 7
    動作確認する

    python app064.py で起動し、各ボタンが反応することを確認します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 クラス定数を書き換えて動きを変える

データはクラス定数(CATEGORIES)にまとまっています。中身を書き換えて動きが変わることを確かめましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点に注意してください。

💡 設定ダイアログ

フォントサイズや色などの設定をユーザーが変更できるオプションダイアログを追加しましょう。

8. よくある問題と解決法

❌ 文字のフォントが崩れる・見た目が違う

原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。

解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。

❌ ウィンドウの大きさが画面に合わない

原因:geometry("760x552") の固定サイズで起動するためです。

解決法:geometry()minsize() の両方の数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。ただし minsize(760, 552) を指定しているため、起動時の大きさより小さくは縮められません(縮めると画面部品が表示されなくなるのを防ぐためです)。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。このアプリは入力した金額を記録して合計するだけで、節約や貯金のしかたを判断する機能はありません。練習に実際の家計の数字は要りません。1000円・1500円のような仮の金額で両方の課題を確かめられます(「CSV保存」を押すと、入力した金額とメモがそのままファイルに残ります)。

  1. 課題1:残高がマイナスのときに集計欄を赤くする

    いまは残高がいくらでも同じ文字色です。使いすぎがひと目で分かるよう、マイナスのときだけ色を変えましょう。

    期待結果:収入 1000 円と支出 1500 円を追加する。集計欄の1行目「収入合計: 1,000円 支出合計: 1,500円 残高: -500円」が赤い文字で出る。残高が0以上に戻ると濃いグレーに戻る

    合格条件

    • 残高がマイナスのときだけ赤くなる
    • 残高が0以上のときは赤以外の色(例: #333)で出る
    • 集計の文字そのものは今までどおり2行で出る

    ヒント①(どこを触るか): 触るのは2か所です。_build_ui() の集計ラベル(94〜96行)と、_refresh_view()self.summary_var.set(...)(216〜219行)の後です。
    ヒント②(使うもの): 色を変えるにはラベル自体が要ります。残高は214行の balance にあります。94〜96行は tk.Label(...).pack() まで続けて書いている形です。self.summary_label = tk.Label(...) と受けてから、次の行で .pack(anchor="w") を呼ぶ形に分けます。
    つまずきやすい点: self.summary_label = tk.Label(...).pack(anchor="w") と1行で書くと、pack() は何も返さないので変数の中身は None です。色を変えるところで AttributeError: 'NoneType' object has no attribute 'config' が出ます。生成と pack() は2行に分けてください。

  2. 課題2:同じCSVを読み直しても件数が二重にならないようにする

    いまの「CSV読込」は、画面にある取引にファイルの中身を足します。同じファイルを2回読むと同じ取引が2組並びます。読み込む前に、置き換えるかどうかを聞きましょう。

    期待結果:3件の取引を保存し、そのまま同じファイルを読み込む。いまは6件に増える。確認を足すと「いまの取引を消して読み込みますか?」が出て、「はい」で3件、「いいえ」で6件になる

    合格条件

    • 「はい」を選ぶと、読み込んだ3件だけが一覧に残る
    • 「いいえ」を選ぶと今までどおり足され、6件になる
    • 読み込みでエラーのダイアログが出たときは、いまの取引が消えない

    ヒント①(どこを触るか): 触るのは1か所です。load_csv()self.records.extend(loaded)(189行)の手前です。
    ヒント②(使うもの): messagebox は4行目で読み込み済みで、askyesno は「はい」で True を返します。144行の clear_all() が同じ形の手本です。中身を空にするのは self.records.clear() です。
    つまずきやすい点: 空にする処理を with open(...)(175行)より前に置くと、読み込みが途中で失敗したときに取引だけが消えます。189行の手前なら、失敗したときはそこまで処理が届かないので取引は残ります。なお中身が文字として読めるファイルは、CSVでなくてもエラーにならず「0 件読み込みました」になります。「はい」を選ぶと一覧だけが空になるので、置き換え前に「CSV保存」で控えを取っておくと安心です。

  3. 課題3:キー操作に対応させる

    ボタンから呼んでいる add_record() を、キーボードのキーからも呼べるようにしてみましょう。tkinter では bind を使ってキーイベントとメソッドを結び付けられます。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリに挑戦しましょう。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
独学を1冊で体系化するなら

写経しながら「なぜこう書くのか」が気になり始めたら、入門書を1冊通して読むと断片的な知識がつながります。Python入門書のおすすめ2冊(当サイトの参考書ランキング総合1〜2位)で、独学者向けの最初の1冊を比較しています。