中級者向け No.08

CSVデータビューア・エディタ

CSVファイルを表形式で表示・編集・フィルタリング・ソートできるアプリ。pandasとTtk Treeviewの組み合わせを学びます。

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

1. アプリ概要

CSVファイルを表形式で表示・編集・フィルタリング・ソートできるアプリ。pandasとTtk Treeviewの組み合わせを学びます。

このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ)・pandas で、難易度は ★★☆ です。

このアプリは「データ処理」カテゴリです。データ分析・処理アプリは Python の真骨頂と言える領域で、生データから意味ある可視化・要約までを一通り体験できます。tkinter(標準ライブラリ)・pandas を活かして実装するこの構造は、他のアプリにも応用が効きます。

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

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

CSVデータビューア・エディタ 実行画面(Windows)
実行画面(Windows)
CSVデータビューア・エディタ 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • ループで生成するボタン群(それぞれ _open_csv()_save_csv()_save_as() を実行)
  • 「➕ 行追加」ボタン(_add_row())・「🗑️ 行削除 (Del)」ボタン(_delete_row())・「📊 統計情報」ボタン(_show_stats())で操作
  • キー <KeyRelease> から _filter() を実行
  • イベント <<ComboboxSelected>> から _filter() を実行
  • マウス操作 <Double-1> から _on_double_click() を実行
  • キー <Delete> から _delete_row() を実行
  • キー <Return> から 処理 を実行
  • キー <Escape> から lambda e: entry.destroy() を実行
  • <FocusOut> イベント(ウィンドウ側からの通知)で 処理 を自動実行
  • ttk.Treeview による表形式の一覧表示
  • CSV ファイルへの書き出し
  • filedialog.askopenfilename() によるファイル選択ダイアログ
  • filedialog.asksaveasfilename() によるファイル選択ダイアログ
  • messagebox.askyesno()messagebox.showerror()messagebox.showinfo() によるダイアログ通知
  • ウィンドウはタイトル「CSVデータビューア・エディタ」・サイズ 900x600 で起動

3. 事前準備・環境

ℹ️
動作確認環境

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

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

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

4. 完全なソースコード

💡
コードのコピー方法

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

📦 必要なライブラリをインストール(初回のみ)
pip install pandas
app08.py
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import csv
import os

try:
    import pandas as pd
    PANDAS_AVAILABLE = True
except ImportError:
    pd = None  # pandas なしでも動くよう、後述の SimpleDf に切り替える
    PANDAS_AVAILABLE = False


class App08:
    """CSVデータビューア・エディタ"""

    def __init__(self, root):
        self.root = root
        self.root.title("CSVデータビューア・エディタ")
        self.root.geometry("900x600")
        self.root.configure(bg="#f8f9fc")
        self.df = None
        self.filepath = None
        self._build_ui()

    def _build_ui(self):
        title_frame = tk.Frame(self.root, bg="#3776ab", pady=10)
        title_frame.pack(fill=tk.X)
        tk.Label(title_frame, text="📊 CSVデータビューア・エディタ",
                 font=("Noto Sans JP", 15, "bold"),
                 bg="#3776ab", fg="white").pack(side=tk.LEFT, padx=12)

        # ツールバー
        toolbar = tk.Frame(self.root, bg="#e8eef5", pady=5)
        toolbar.pack(fill=tk.X)
        for text, cmd in [("📂 CSVを開く", self._open_csv),
                          ("💾 保存", self._save_csv),
                          ("💾 名前を付けて保存", self._save_as)]:
            ttk.Button(toolbar, text=text, command=cmd).pack(side=tk.LEFT, padx=4, pady=2)

        tk.Label(toolbar, text="  🔍 検索:",
                 bg="#e8eef5").pack(side=tk.LEFT, padx=(16, 4))
        self.search_var = tk.StringVar()
        search_entry = ttk.Entry(toolbar, textvariable=self.search_var, width=16)
        search_entry.pack(side=tk.LEFT)
        search_entry.bind("<KeyRelease>", lambda e: self._filter())

        tk.Label(toolbar, text="  列:", bg="#e8eef5").pack(side=tk.LEFT, padx=(12, 4))
        self.filter_col_var = tk.StringVar(value="すべて")
        self.filter_col_cb = ttk.Combobox(toolbar, textvariable=self.filter_col_var,
                                           values=["すべて"], state="readonly", width=12)
        self.filter_col_cb.pack(side=tk.LEFT)
        self.filter_col_cb.bind("<<ComboboxSelected>>", lambda e: self._filter())

        # グリッド
        grid_frame = tk.Frame(self.root, bg="#f8f9fc")
        grid_frame.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        self.tree = ttk.Treeview(grid_frame, show="headings", selectmode="browse")
        h_sb = ttk.Scrollbar(grid_frame, orient=tk.HORIZONTAL,
                             command=self.tree.xview)
        v_sb = ttk.Scrollbar(grid_frame, orient=tk.VERTICAL,
                             command=self.tree.yview)
        self.tree.configure(xscrollcommand=h_sb.set, yscrollcommand=v_sb.set)
        v_sb.pack(side=tk.RIGHT, fill=tk.Y)
        h_sb.pack(side=tk.BOTTOM, fill=tk.X)
        self.tree.pack(fill=tk.BOTH, expand=True)
        self.tree.bind("<Double-1>", self._on_double_click)
        self.tree.bind("<Delete>", self._delete_row)

        # 行操作パネル
        row_frame = tk.Frame(self.root, bg="#e8eef5", pady=4)
        row_frame.pack(fill=tk.X)
        ttk.Button(row_frame, text="➕ 行追加",
                   command=self._add_row).pack(side=tk.LEFT, padx=8)
        ttk.Button(row_frame, text="🗑️ 行削除 (Del)",
                   command=self._delete_row).pack(side=tk.LEFT, padx=4)
        ttk.Button(row_frame, text="📊 統計情報",
                   command=self._show_stats).pack(side=tk.LEFT, padx=4)

        self.status_var = tk.StringVar(value="CSVファイルを開いてください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    def _open_csv(self):
        path = filedialog.askopenfilename(
            filetypes=[("CSVファイル", "*.csv"), ("テキストファイル", "*.txt"),
                       ("すべて", "*.*")])
        if not path:
            return
        try:
            self._load_file(path)
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _load_file(self, path):
        self.filepath = path
        if PANDAS_AVAILABLE:
            encodings = ["utf-8", "cp932", "latin-1"]
            for enc in encodings:
                try:
                    self.df = pd.read_csv(path, encoding=enc)
                    break
                except UnicodeDecodeError:
                    continue
        else:
            # pandas なし: csv モジュールで読込
            rows = []
            headers = []
            for enc in ["utf-8", "cp932", "latin-1"]:
                try:
                    with open(path, newline="", encoding=enc) as f:
                        reader = csv.reader(f)
                        headers = next(reader)
                        rows = list(reader)
                    break
                except UnicodeDecodeError:
                    continue
            # DataFrameを手動で構築
            class SimpleDf:
                def __init__(self, headers, rows):
                    self.columns = headers
                    self._rows = rows
                def iterrows(self):
                    for i, r in enumerate(self._rows):
                        yield i, r
                def __len__(self):
                    return len(self._rows)
                def to_csv(self, path, index=False, encoding="utf-8"):
                    with open(path, "w", newline="", encoding=encoding) as f:
                        w = csv.writer(f)
                        w.writerow(self.columns)
                        w.writerows(self._rows)
            self.df = SimpleDf(headers, rows)
        self._setup_columns()
        self._fill_rows(self.df)
        self.root.title(f"CSVビューア — {os.path.basename(path)}")
        n = len(self.df) if PANDAS_AVAILABLE else len(self.df._rows)
        self.status_var.set(f"{os.path.basename(path)}  |  "
                            f"{n} 行 × {len(self.df.columns)} 列")

    def _setup_columns(self):
        cols = list(self.df.columns)
        self.tree.configure(columns=cols)
        for c in cols:
            self.tree.heading(c, text=c,
                              command=lambda col=c: self._sort_by(col))
            self.tree.column(c, width=max(80, len(str(c))*10), minwidth=40)
        self.filter_col_cb.configure(values=["すべて"] + cols)
        self.filter_col_var.set("すべて")

    def _fill_rows(self, df):
        self.tree.delete(*self.tree.get_children())
        if PANDAS_AVAILABLE:
            for _, row in df.iterrows():
                self.tree.insert("", "end", values=list(row.astype(str)))
        else:
            for _, row in df.iterrows():
                self.tree.insert("", "end",
                                 values=[str(v) for v in row])

    def _filter(self):
        if self.df is None:
            return
        q = self.search_var.get().strip().lower()
        col = self.filter_col_var.get()
        self.tree.delete(*self.tree.get_children())
        if PANDAS_AVAILABLE:
            if not q:
                filtered = self.df
            elif col == "すべて":
                mask = self.df.astype(str).apply(
                    lambda s: s.str.lower().str.contains(q, na=False)).any(axis=1)
                filtered = self.df[mask]
            else:
                filtered = self.df[
                    self.df[col].astype(str).str.lower().str.contains(q, na=False)]
            for _, row in filtered.iterrows():
                self.tree.insert("", "end", values=list(row.astype(str)))
            self.status_var.set(f"{len(filtered)} 件表示 / 全 {len(self.df)} 件")
        else:
            for _, row in self.df.iterrows():
                vals = [str(v) for v in row]
                if not q or any(q in v.lower() for v in vals):
                    self.tree.insert("", "end", values=vals)

    def _sort_by(self, col):
        if self.df is None or not PANDAS_AVAILABLE:
            return
        try:
            self.df = self.df.sort_values(col)
        except Exception:
            self.df = self.df.sort_values(col, key=lambda s: s.astype(str))
        self._fill_rows(self.df)

    def _on_double_click(self, event):
        """セルをダブルクリックで編集"""
        region = self.tree.identify_region(event.x, event.y)
        if region != "cell":
            return
        row_id = self.tree.identify_row(event.y)
        col_id = self.tree.identify_column(event.x)
        col_idx = int(col_id.replace("#", "")) - 1
        bbox = self.tree.bbox(row_id, col_id)
        if not bbox:
            return
        x, y, w, h = bbox
        val = self.tree.item(row_id)["values"][col_idx]
        entry = tk.Entry(self.tree, font=("Arial", 11))
        entry.insert(0, str(val))
        entry.select_range(0, tk.END)
        entry.place(x=x, y=y, width=w, height=h)
        entry.focus_set()

        def confirm(e=None):
            new_val = entry.get()
            values = list(self.tree.item(row_id)["values"])
            values[col_idx] = new_val
            self.tree.item(row_id, values=values)
            entry.destroy()

        entry.bind("<Return>", confirm)
        entry.bind("<Escape>", lambda e: entry.destroy())
        entry.bind("<FocusOut>", confirm)

    def _add_row(self):
        if self.df is None:
            return
        cols = self.tree.cget("columns")
        n = len(cols)
        self.tree.insert("", "end", values=[""] * n)

    def _delete_row(self, event=None):
        sel = self.tree.selection()
        if sel:
            if messagebox.askyesno("確認", "選択行を削除しますか?"):
                for item in sel:
                    self.tree.delete(item)

    def _show_stats(self):
        if self.df is None or not PANDAS_AVAILABLE:
            messagebox.showinfo("情報", "pandasが必要です")
            return
        desc = self.df.describe(include="all").to_string()
        win = tk.Toplevel(self.root)
        win.title("統計情報")
        win.geometry("600x400")
        txt = tk.Text(win, font=("Courier New", 10), wrap=tk.NONE)
        txt.pack(fill=tk.BOTH, expand=True)
        txt.insert("1.0", desc)
        txt.config(state=tk.DISABLED)

    def _save_csv(self):
        if not self.filepath:
            self._save_as()
            return
        self._write_csv(self.filepath)

    def _save_as(self):
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSVファイル", "*.csv"), ("すべて", "*.*")])
        if path:
            self.filepath = path
            self._write_csv(path)

    def _write_csv(self, path):
        try:
            cols = self.tree.cget("columns")
            with open(path, "w", newline="", encoding="utf-8-sig") as f:
                writer = csv.writer(f)
                writer.writerow(cols)
                for item in self.tree.get_children():
                    writer.writerow(self.tree.item(item)["values"])
            self.status_var.set(f"保存済み: {path}")
        except Exception as e:
            messagebox.showerror("エラー", str(e))


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

5. コード解説

CSVデータビューア・エディタのコードを、実際に書かれている実装に沿って解説します。

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

App08 クラスにアプリの全機能をまとめています(メソッド15個・全284行)。__init__ ではタイトル「CSVデータビューア・エディタ」とウィンドウサイズ 900x600 を設定します。あわせて状態を保持する変数(self.dfself.filepath)を初期化し、最後に _build_ui() で画面を組み立てます。

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

ウィジェット構成

画面は tk.Frame×4・tk.Label×4・ttk.Button×3(ほかにループでも生成)・ttk.Entryttk.Comboboxttk.Treeviewttk.Scrollbar×2・tk.Entrytk.Topleveltk.Text で構成しています。

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

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

「➕ 行追加」ボタン(_add_row())・「🗑️ 行削除 (Del)」ボタン(_delete_row())・「📊 統計情報」ボタン(_show_stats())を command= で接続しています。ボタンはラベルと処理をペアで並べたデータをループで回して生成し、それぞれ _open_csv()_save_csv()_save_as()command= に渡しています。ボタンを増やしたいときはこのペアを1行足すだけで済みます。また bind("<KeyRelease>", ...) から _filter()bind("<<ComboboxSelected>>", ...) から _filter()bind("<Double-1>", ...) から _on_double_click()bind("<Delete>", ...) から _delete_row()bind("<Return>", ...) から 処理、bind("<Escape>", ...) から lambda e: entry.destroy()bind("<FocusOut>", ...) から 処理 を呼べるようにイベントも登録しています。

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

例外処理

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

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

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

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

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

    新しいファイルを作成して app08.py と保存します。外部ライブラリ pandas を使います。先に pip install pandas を実行してください。

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

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

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

    title("CSVデータビューア・エディタ")geometry("900x600")configure(bg="#f8f9fc") をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×4・tk.Label×4・ttk.Button×3(ほかにループでも生成)・ttk.Entryttk.Comboboxttk.Treeviewttk.Scrollbar×2・tk.Entrytk.Topleveltk.Text を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

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

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_add_row()_delete_row()_show_stats())につなぎます。ループ生成のボタンは、ラベルと処理(_open_csv()_save_csv()_save_as())をペアで並べたデータをループで回し、command= に渡して接続します。bind("<KeyRelease>", ...)bind("<<ComboboxSelected>>", ...)bind("<Double-1>", ...)bind("<Delete>", ...)bind("<Return>", ...)bind("<Escape>", ...)bind("<FocusOut>", ...) の登録も忘れずに。

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

    アプリの本体である _on_double_click()(29行)・_filter()(24行)・_show_stats()(12行)など を実装します。

  7. 7
    動作確認する

    python app08.py で起動し、各ボタンが反応すること・キー操作(<KeyRelease><Delete><Return><Escape>)が効くこと・マウス操作(クリック・ホイールなど)が効くことを確認します。

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

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。

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

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

💡 配色をまとめて管理する

背景色 #f8f9fc をはじめ、色コードが各所に直書きされています。色を1つの辞書にまとめると、テーマの切り替えが1か所の変更で済むようになります。

💡 画面の文言を自分好みに変える

ラベルやボタンの文言を自分の好みに書き換えてみましょう。文言を変えるだけでも、どのコードがどの表示に対応しているかが立体的に見えてきます。

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

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

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

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

<KeyRelease> キーを押しても反応しない

原因:bind("<KeyRelease>", ...) はウィジェット単位で登録されるため、ウィンドウ(またはバインド先)にキーボードフォーカスが無いとイベントが届きません。

解決法:一度ウィンドウ内をクリックしてフォーカスを与えてから、キーを押してください。

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

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

解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。

  1. 課題1:数値の列を右寄せにする

    読み込んだ CSV のうち、数値だけの列を右寄せ表示にします。文字が混じる列は左寄せのままにしてください。

    期待結果:「商品,単価,数量」の CSV を開くと、単価と数量の値が右端に揃い、商品名は左寄せのまま表示される。

    合格条件

    • 別の CSV を開き直すと、その CSV の数値列だけが右寄せになる
    • 数字と文字が混ざる列(例: 「A-1」「B-2」)は左寄せのまま
    • 見出し(ヘッダー)の表示は今までどおり

    ヒント①(どこを触るか): 列の設定は _setup_columns()self.tree.column(c, width=…, minwidth=40) の1行です。ここに anchor を渡します。
    ヒント②(使うもの): 右寄せは anchor="e"、左寄せは "w"。数値列かどうかは pd.api.types.is_numeric_dtype(self.df[c]) で判定できます。
    つまずきやすい点: pandas が入っていない環境では self.df がコード内で定義している簡易クラス SimpleDf に差し替わり、self.df[列名] の書き方ができません。このとき pdNone なので pd.api.types.is_numeric_dtype() も呼べません。判定の条件に PANDAS_AVAILABLE and … を付けておいてください。

  2. 課題2:セルの編集を DataFrame に書き戻す

    いまのコードはセルを編集しても画面(Treeview)だけが変わり、self.df は古い値のままです。編集した内容が self.df にも反映されるようにします。

    期待結果:セルをダブルクリックして「東京」を「大阪」に直して Enter を押したあと、検索欄に「大阪」と入力するとその行がヒットする(いまのコードでは編集前の値に戻ってしまう)。

    合格条件

    • 編集 → 検索 → 検索欄を空にする、の順に操作しても編集した値が残る
    • 編集後に「📊 統計情報」を押すと新しい値を含んだ結果になる
    • 見出しをクリックして並び替えたあとに編集しても、正しい行が書き換わる

    ヒント①(どこを触るか): 編集を確定しているのは _on_double_click() の中の confirm() です。ここが self.tree.item(row_id, values=values) しか呼んでいません。
    ヒント②(使うもの): 画面の行と DataFrame の行を対応づけるには、_fill_rows()self.tree.insert("", "end", values=…)iid=str(インデックス) を足します(iterrows() の第1返り値が DataFrame のインデックスです)。書き戻しは self.df.at[インデックス, 列名]
    つまずきやすい点: _fill_rows() は毎回 Treeview を全消ししてから入れ直すので、検索や並び替えのあとは画面の行番号と DataFrame の並びが一致しません。行番号ではなく iid で対応づけてください。

  3. 課題3:新しいボタンを追加する

    既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と command= の接続がセットで身に付きます。

🚀
次に挑戦するアプリ

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

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

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

📖
次のレベルへ進む参考書

このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。