中級者向け No.097

データバックアップツール

指定フォルダをZip圧縮してバックアップ先に保存するツール。スケジュール設定と世代管理機能付き。

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

1. アプリ概要

指定フォルダをまるごと Zip 圧縮し、接頭辞_日時.zip という名前でバックアップ先に保存するツールです。保存先に貯まった zip は指定した世代数だけ残して古いものを削除し、間隔を決めて自動実行することもできます。

このアプリはutilカテゴリの実践的なPythonアプリです。使用ライブラリは tkinter・zipfile・datetime・threading(すべて標準ライブラリ)、難易度は ★★☆ です。追加インストールは要りません。

処理は4段階です。①バックアップ元と保存先・接頭辞・世代数を登録する → ②os.walk() で集めたファイルを zipfile.ZipFile で1つの zip に書き込む → ③保存先の zip を数え、上限を超えた分を古い順に削除する → ④after() で指定間隔ごとに②③を繰り返す。各段階の結果はログ欄に1行ずつ出るので、どこまで進んだかを目で追えます。

コードを読む前に実行することをおすすめします。動いている挙動を先に把握しておくと、解説で出てくる関数や処理がどこに対応するかが頭に入りやすくなります。

⚠️
世代管理はファイルを削除します(試すときは空のフォルダで)

世代管理は保存先のファイルを os.remove() で消します。ゴミ箱には入らず、取り消せません。誤削除を防ぐため、このコードは自分が作った命名規則(接頭辞_YYYYmmdd_HHMMSS.zip)に完全一致する zip だけを削除対象にし、削除の前に候補をログへ出し、アプリ起動後の最初の1回は確認ダイアログを出します。

それでも、保存先にはこのツール専用の空フォルダを指定してください。他のツールのバックアップ置き場や、大切なファイルが入ったフォルダを保存先にするのは避けましょう。世代数に 0 を入れると削除処理そのものを行いません。

「⏹ 停止」で途中終了した回や、コピー中に OSError でファイルをスキップした回も、その時点までの内容で zip は作成され、世代管理はその不完全な zip も1世代として数えます。世代数の上限を超えると、正常な古い zip が先に削除されることがあります。停止・スキップが出た直後は、そのログの zip を確認し、不完全なら手動で削除するか、世代数を一時的に増やしてください。

このツールは学習用サンプルで、復元できることを保証するものではありません。作った zip は一度展開して中身を確かめ、大切なデータは OS 標準のバックアップやクラウド等、別の手段と併用してください(免責事項)。

データバックアップツール 実行画面(Windows)
実行画面(Windows)
データバックアップツール 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

このアプリが実際に行うこと(掲載コードで実装済み):

  • フォルダ選択: バックアップ元と保存先を filedialog.askdirectory() で選び、接頭辞・残す世代数・圧縮方式とセットでタスクとして登録(複数タスクを一覧で保持)
  • Zip 圧縮: zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) でバックアップ元のファイルを1つの zip に格納。os.walk() でサブフォルダもたどり、os.path.relpath() の相対パスを保つので展開すると元の階層が戻る(「無圧縮(そのまま格納)」を選ぶと zipfile.ZIP_STORED
  • タイムスタンプ付きファイル名: datetime.now().strftime("%Y%m%d_%H%M%S")接頭辞_20260909_101304.zip の形に。同じ秒に2回実行しても上書きしないよう、重複時は末尾に連番を足す
  • 世代管理: 保存先の zip が指定世代数を超えたら、古いものから削除。対象は上の命名規則に完全一致するファイルだけで、削除候補をログに出し、起動後の初回は確認ダイアログで承認を求める(世代数 0 で削除しない)
  • スケジュール実行: 間隔(秒/分)を指定して root.after() で有効タスクを定期実行。このアプリを起動している間だけ動作します(OS のタスクスケジューラや cron に登録するものではありません。PC 起動時の自動実行が必要ならタスクスケジューラ側から python app097.py を呼びます)
  • ログ表示: 開始・作成した zip 名とファイル数・サイズ・世代管理の判定・削除・エラーを時刻付きで色分け表示。進捗バーは圧縮したファイル数で進む
  • UI を止めない実行: 圧縮は threading.Thread の別スレッドで走らせ、画面の更新と削除の確認は root.after(0, ...) でメインスレッドに戻す

画面部品の対応は次のとおりです。

  • ループで生成するボタン群を lambda v=var: v.set(filedialog.askdirectory() or v.get()) で接続
  • 「+ バックアップ追加」ボタン(_add_task())・「🗑 タスク削除」ボタン(_delete_task())・「▶ 選択をバックアップ」ボタン(_run_selected())・「▶▶ 全てバックアップ」ボタン(_run_all())・「⏹ 停止」ボタン(lambda: setattr(self, '_running', False))・「⏱ スケジュール開始」ボタン(_toggle_schedule())・「追加」ボタンで操作
  • ttk.Treeview による表形式の一覧表示
  • filedialog.askdirectory() によるファイル選択ダイアログ
  • messagebox.askyesno()messagebox.showinfo()messagebox.showwarning() によるダイアログ通知
  • ウィンドウはタイトル「データバックアップツール」・サイズ 980x730 で起動

3. 事前準備・環境

ℹ️
動作確認環境

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

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

Zip 圧縮・世代管理・スケジュールについては、一時フォルダに作った 3 ファイル(サブフォルダ込み)を対象に次の結果を確認しました: 作成した zip の namelist()a.txt / b.csv / sub/c.txt と一致・格納方式は ZIP_DEFLATED・世代数 2 で 3 回実行すると最も古い zip が 1 個削除され、命名規則に一致しない同フォルダ内のファイル(important_note.txtother_backup.zip)は残る・間隔 1 秒のスケジュールが after() で発火してバックアップが 1 回実行される。

  • Python 3.10 以上(確認は 3.12)
  • OS: Windows 11(実機で起動・基本操作を確認)/Linux Mint 22.3(仮想マシンで起動を確認)
  • 追加インストール不要(tkinter・zipfile・datetime・threading・os・re はすべて標準ライブラリ)

4. 完全なソースコード

💡
コードのコピー方法

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

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


class App097:
    """データバックアップツール(Zip圧縮・世代管理・スケジュール実行)"""

    COMPRESS_MODES = {
        "標準(Deflate圧縮)": zipfile.ZIP_DEFLATED,
        "無圧縮(そのまま格納)": zipfile.ZIP_STORED,
    }
    SCHEDULE_UNITS = {"分": 60_000, "秒": 1_000}
    STAMP_FORMAT = "%Y%m%d_%H%M%S"

    def __init__(self, root, auto_confirm_delete=False):
        self.root = root
        self.root.title("データバックアップツール")
        self.root.geometry("980x730")
        self.root.minsize(980, 730)
        self.root.configure(bg="#1e1e1e")
        # [{"src","dst","prefix","keep","mode","enabled","last_run"}]
        self._tasks = []
        self._running = False
        self._schedule_job = None
        self._schedule_runs = 0
        # 世代管理の削除確認を自動承認するフラグ(自動テスト用。既定は必ずダイアログ)
        self.auto_confirm_delete = auto_confirm_delete
        self._delete_confirmed = False
        self._build_ui()

    # ------------------------------------------------------------------
    # 画面の組み立て
    # ------------------------------------------------------------------
    def _build_ui(self):
        header = tk.Frame(self.root, bg="#252526", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="💾 データバックアップツール(Zip・世代管理・スケジュール)",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#252526", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)

        # ツールバー
        tb = tk.Frame(self.root, bg="#2d2d2d", pady=4)
        tb.pack(fill=tk.X)
        tk.Button(tb, text="+ バックアップ追加", command=self._add_task,
                  bg="#1565c0", fg="white", relief=tk.FLAT,
                  font=("Arial", 10), padx=12, pady=4,
                  activebackground="#0d47a1", bd=0).pack(side=tk.LEFT, padx=4)
        tk.Button(tb, text="🗑 タスク削除", command=self._delete_task,
                  bg="#c62828", fg="white", relief=tk.FLAT,
                  font=("Arial", 10), padx=12, pady=4,
                  activebackground="#b71c1c", bd=0).pack(side=tk.LEFT, padx=2)
        tk.Button(tb, text="▶ 選択をバックアップ", command=self._run_selected,
                  bg="#2e7d32", fg="white", relief=tk.FLAT,
                  font=("Arial", 10, "bold"), padx=14, pady=4,
                  activebackground="#1b5e20", bd=0).pack(side=tk.LEFT, padx=8)
        tk.Button(tb, text="▶▶ 全てバックアップ", command=self._run_all,
                  bg="#f57c00", fg="white", relief=tk.FLAT,
                  font=("Arial", 10), padx=14, pady=4,
                  activebackground="#e65100", bd=0).pack(side=tk.LEFT, padx=2)
        tk.Button(tb, text="⏹ 停止", command=lambda: setattr(self, "_running", False),
                  bg="#3c3c3c", fg="white", relief=tk.FLAT,
                  font=("Arial", 10), padx=12, pady=4,
                  activebackground="#505050", bd=0).pack(side=tk.LEFT, padx=4)

        # スケジュール(アプリ起動中のみ動作)
        sch = tk.Frame(self.root, bg="#2d2d2d", pady=4)
        sch.pack(fill=tk.X)
        tk.Label(sch, text="スケジュール間隔:", bg="#2d2d2d", fg="#ccc",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(8, 4))
        self.interval_var = tk.StringVar(value="30")
        ttk.Spinbox(sch, from_=1, to=1440, width=5,
                    textvariable=self.interval_var).pack(side=tk.LEFT)
        self.unit_var = tk.StringVar(value="分")
        ttk.Combobox(sch, textvariable=self.unit_var,
                     values=list(self.SCHEDULE_UNITS),
                     state="readonly", width=4).pack(side=tk.LEFT, padx=4)
        self.schedule_btn = tk.Button(sch, text="⏱ スケジュール開始",
                                      command=self._toggle_schedule,
                                      bg="#00838f", fg="white", relief=tk.FLAT,
                                      font=("Arial", 10), padx=12, pady=3,
                                      activebackground="#006064", bd=0)
        self.schedule_btn.pack(side=tk.LEFT, padx=8)
        tk.Label(sch, text="※ アプリを起動している間だけ実行されます(OSのタスクスケジューラではありません)",
                 bg="#2d2d2d", fg="#888", font=("Arial", 8)).pack(side=tk.LEFT)

        # タスクリスト
        task_f = tk.Frame(self.root, bg="#1e1e1e")
        task_f.pack(fill=tk.X, padx=8, pady=4)
        tk.Label(task_f, text="バックアップタスク", bg="#1e1e1e", fg="#888",
                 font=("Arial", 9)).pack(anchor="w")
        cols = ("enabled", "src", "dst", "prefix", "keep", "mode", "last_run", "status")
        self.task_tree = ttk.Treeview(task_f, columns=cols,
                                      show="headings", height=6)
        for col, text, width in [
            ("enabled",  "有効",       50),
            ("src",      "バックアップ元", 200),
            ("dst",      "保存先",     200),
            ("prefix",   "接頭辞",     110),
            ("keep",     "世代",       50),
            ("mode",     "圧縮",       140),
            ("last_run", "最終実行",   130),
            ("status",   "状態",       110),
        ]:
            self.task_tree.heading(col, text=text)
            self.task_tree.column(col, width=width, anchor="w")
        tsb = ttk.Scrollbar(task_f, command=self.task_tree.yview)
        self.task_tree.configure(yscrollcommand=tsb.set)
        tsb.pack(side=tk.RIGHT, fill=tk.Y)
        self.task_tree.pack(fill=tk.X)
        self.task_tree.tag_configure("done", foreground="#26a69a")
        self.task_tree.tag_configure("error", foreground="#ef5350")

        # ログ
        tk.Label(self.root, text="ログ", bg="#1e1e1e", fg="#888",
                 font=("Arial", 8)).pack(anchor="w", padx=8, pady=(8, 0))
        log_f = tk.Frame(self.root, bg="#0d1117")
        log_f.pack(fill=tk.BOTH, expand=True, padx=8, pady=2)
        self.log_text = tk.Text(log_f, bg="#0d1117", fg="#8b949e",
                                font=("Courier New", 9), relief=tk.FLAT,
                                state=tk.DISABLED)
        lsb2 = ttk.Scrollbar(log_f, command=self.log_text.yview)
        self.log_text.configure(yscrollcommand=lsb2.set)
        lsb2.pack(side=tk.RIGHT, fill=tk.Y)
        self.log_text.pack(fill=tk.BOTH, expand=True)
        self.log_text.tag_configure("zip", foreground="#4fc3f7")
        self.log_text.tag_configure("warn", foreground="#ffb74d")
        self.log_text.tag_configure("error", foreground="#ef5350")
        self.log_text.tag_configure("info", foreground="#8b949e")

        self.progress = ttk.Progressbar(self.root, mode="determinate")
        self.progress.pack(fill=tk.X, padx=8)
        self.status_var = tk.StringVar(value="バックアップタスクを追加してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#252526", fg="#858585", font=("Arial", 9),
                 anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)

    def _log(self, msg, tag="info"):
        self.log_text.configure(state=tk.NORMAL)
        ts = datetime.now().strftime("%H:%M:%S")
        self.log_text.insert(tk.END, f"[{ts}] {msg}\n", tag)
        self.log_text.see(tk.END)
        self.log_text.configure(state=tk.DISABLED)

    # ------------------------------------------------------------------
    # タスクの追加・削除
    # ------------------------------------------------------------------
    def _add_task(self):
        dlg = tk.Toplevel(self.root)
        dlg.title("バックアップ追加")
        dlg.configure(bg="#252526")
        dlg.grab_set()

        src_var    = tk.StringVar()
        dst_var    = tk.StringVar()
        prefix_var = tk.StringVar(value="backup")
        keep_var   = tk.StringVar(value="5")
        mode_var   = tk.StringVar(value=list(self.COMPRESS_MODES)[0])
        en_var     = tk.BooleanVar(value=True)

        for label, var in [("バックアップ元:", src_var), ("保存先:", dst_var)]:
            row = tk.Frame(dlg, bg="#252526")
            row.pack(fill=tk.X, padx=12, pady=4)
            tk.Label(row, text=label, bg="#252526", fg="#ccc",
                     width=14, anchor="w").pack(side=tk.LEFT)
            ttk.Entry(row, textvariable=var, width=32).pack(side=tk.LEFT, padx=4)
            ttk.Button(row, text="参照",
                       command=lambda v=var: v.set(
                           filedialog.askdirectory() or v.get())
                       ).pack(side=tk.LEFT)

        for label, var, width in [("ファイル接頭辞:", prefix_var, 14),
                                  ("残す世代数:", keep_var, 6)]:
            row = tk.Frame(dlg, bg="#252526")
            row.pack(fill=tk.X, padx=12, pady=4)
            tk.Label(row, text=label, bg="#252526", fg="#ccc",
                     width=14, anchor="w").pack(side=tk.LEFT)
            ttk.Entry(row, textvariable=var, width=width).pack(side=tk.LEFT, padx=4)

        row = tk.Frame(dlg, bg="#252526")
        row.pack(fill=tk.X, padx=12, pady=4)
        tk.Label(row, text="圧縮方式:", bg="#252526", fg="#ccc",
                 width=14, anchor="w").pack(side=tk.LEFT)
        ttk.Combobox(row, textvariable=mode_var,
                     values=list(self.COMPRESS_MODES),
                     state="readonly", width=22).pack(side=tk.LEFT, padx=4)

        tk.Checkbutton(dlg, text="有効", variable=en_var,
                       bg="#252526", fg="#ccc", selectcolor="#3c3c3c",
                       activebackground="#252526").pack(padx=12, pady=2, anchor="w")
        tk.Label(dlg, text="世代管理は「接頭辞_日時.zip」に一致するファイルだけを削除します",
                 bg="#252526", fg="#888", font=("Arial", 8),
                 wraplength=380, justify="left").pack(padx=12, pady=(0, 4), anchor="w")

        def _ok():
            src = src_var.get().strip()
            dst = dst_var.get().strip()
            prefix = prefix_var.get().strip()
            if not src or not dst or not prefix:
                messagebox.showwarning("警告", "バックアップ元・保存先・接頭辞は必須です")
                return
            if not re.fullmatch(r"[A-Za-z0-9_-]+", prefix):
                messagebox.showwarning("警告", "接頭辞は半角英数字・ハイフン・アンダースコアで入力してください")
                return
            try:
                keep = int(keep_var.get())
            except ValueError:
                messagebox.showwarning("警告", "残す世代数は整数で入力してください")
                return
            self.add_task(src, dst, prefix=prefix, keep=keep,
                          mode=mode_var.get(), enabled=en_var.get())
            dlg.destroy()

        ttk.Button(dlg, text="追加", command=_ok).pack(pady=8)

    def add_task(self, src, dst, prefix="backup", keep=5, mode=None, enabled=True):
        """タスクを1件追加する(画面の「追加」ボタンとテストから共通で使う)"""
        task = {"src": src, "dst": dst, "prefix": prefix,
                "keep": max(0, int(keep)),
                "mode": mode or list(self.COMPRESS_MODES)[0],
                "enabled": bool(enabled), "last_run": "", "status": "待機"}
        self._tasks.append(task)
        iid = self.task_tree.insert("", tk.END, values=self._row_values(task, "待機"))
        self.status_var.set(f"タスク {len(self._tasks)} 件")
        return iid

    def _delete_task(self):
        sel = self.task_tree.selection()
        if not sel:
            return
        idx = self.task_tree.index(sel[0])
        if idx < len(self._tasks):
            del self._tasks[idx]
        self.task_tree.delete(sel[0])

    def _row_values(self, task, status):
        return ("✔" if task["enabled"] else "✘",
                task["src"], task["dst"], task["prefix"], task["keep"],
                task["mode"], task["last_run"], status)

    def _update_row(self, iid, task, status, tag):
        self.task_tree.item(iid, values=self._row_values(task, status), tags=(tag,))

    # ------------------------------------------------------------------
    # 実行
    # ------------------------------------------------------------------
    def _run_selected(self):
        sel = self.task_tree.selection()
        if not sel:
            messagebox.showinfo("情報", "タスクを選択してください")
            return
        idx = self.task_tree.index(sel[0])
        self._start_backup([self._tasks[idx]], [sel[0]])

    def _run_all(self, from_schedule=False):
        enabled = [(t, iid) for t, iid in zip(
            self._tasks, self.task_tree.get_children()) if t["enabled"]]
        if not enabled:
            if from_schedule:
                self._log("スケジュール: 有効なタスクがないので何もしません", "warn")
            else:
                messagebox.showwarning("警告", "有効なタスクがありません")
            return
        tasks, iids = zip(*enabled)
        self._start_backup(list(tasks), list(iids))

    def _start_backup(self, tasks, iids):
        """バックアップを別スレッドで開始する(UIを固めないため)"""
        if self._running:
            self._log("実行中です。完了を待ってください", "warn")
            return
        threading.Thread(target=self._do_backup, args=(tasks, iids),
                         daemon=True).start()

    def _do_backup(self, tasks, iids):
        self._running = True
        ok = ng = 0
        for task, iid in zip(tasks, iids):
            if not self._running:
                break
            if not os.path.isdir(task["src"]):
                self.root.after(0, self._log,
                                f"エラー: バックアップ元が存在しません: {task['src']}", "error")
                self.root.after(0, self._update_row, iid, task, "✘ 元がない", "error")
                ng += 1
                continue
            try:
                os.makedirs(task["dst"], exist_ok=True)
                self.root.after(0, self._log,
                                f"開始: {task['src']} → {task['dst']}  [{task['mode']}]", "info")
                zip_path, count = self._create_zip(task)
            except Exception as e:
                self.root.after(0, self._log, f"エラー: {task['src']}: {e}", "error")
                self.root.after(0, self._update_row, iid, task, "✘ 失敗", "error")
                ng += 1
                continue
            size_mb = os.path.getsize(zip_path) / (1024 * 1024)
            task["last_run"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            self.root.after(0, self._log,
                            f"作成: {os.path.basename(zip_path)}"
                            f"({count} ファイル / {size_mb:.2f} MB)", "zip")
            self.root.after(0, self._update_row, iid, task, f"✔ {count} ファイル", "done")
            self.root.after(0, self._apply_retention, task)
            ok += 1
        self._running = False
        self.root.after(0, self.status_var.set,
                        f"バックアップ完了: 成功 {ok} / 失敗 {ng}")

    def _create_zip(self, task):
        """バックアップ元を丸ごと1つの zip に圧縮する。戻り値は (zipのパス, 格納ファイル数)"""
        src = task["src"]
        files = []
        for dirpath, _, filenames in os.walk(src):
            for fn in filenames:
                files.append(os.path.join(dirpath, fn))
        self.root.after(0, self.progress.configure,
                        {"maximum": max(len(files), 1), "value": 0})

        zip_path = self._zip_path(task)
        compression = self.COMPRESS_MODES.get(task["mode"], zipfile.ZIP_DEFLATED)
        written = 0
        with zipfile.ZipFile(zip_path, "w", compression) as zf:
            for i, path in enumerate(files):
                if not self._running:
                    break
                arcname = os.path.relpath(path, src)
                try:
                    zf.write(path, arcname)
                    written += 1
                except OSError as e:
                    self.root.after(0, self._log, f"スキップ: {arcname}: {e}", "error")
                self.root.after(0, self.progress.configure, {"value": i + 1})
        return zip_path, written

    def _zip_path(self, task):
        """接頭辞+タイムスタンプのファイル名を作る(同秒に重なったら連番を足す)"""
        stamp = datetime.now().strftime(self.STAMP_FORMAT)
        base = f"{task['prefix']}_{stamp}"
        path = os.path.join(task["dst"], base + ".zip")
        n = 2
        while os.path.exists(path):
            path = os.path.join(task["dst"], f"{base}_{n}.zip")
            n += 1
        return path

    # ------------------------------------------------------------------
    # 世代管理(削除を伴うのでメインスレッドで確認してから実行する)
    # ------------------------------------------------------------------
    @staticmethod
    def _zip_pattern(prefix):
        """自分が作った命名規則(接頭辞_YYYYmmdd_HHMMSS[_連番].zip)だけに一致する正規表現"""
        return re.compile(r"^" + re.escape(prefix) + r"_\d{8}_\d{6}(_\d+)?\.zip$")

    def list_generations(self, task):
        """保存先にある「自分が作った zip」を古い順に返す(他のファイルは対象外)"""
        pattern = self._zip_pattern(task["prefix"])
        names = [n for n in os.listdir(task["dst"])
                 if pattern.match(n)
                 and os.path.isfile(os.path.join(task["dst"], n))]
        return sorted(names)

    def _apply_retention(self, task):
        keep = task["keep"]
        if keep <= 0:
            self._log("世代管理: 世代数が0のため削除しません", "info")
            return
        try:
            names = self.list_generations(task)
        except OSError as e:
            self._log(f"エラー: 保存先を読めません: {e}", "error")
            return
        old = names[:-keep] if len(names) > keep else []
        if not old:
            self._log(f"世代管理: {len(names)} 世代(上限 {keep})。削除対象はありません", "info")
            return
        self._log(f"世代管理: {len(names)} 世代のうち古い {len(old)} 個が上限 {keep} を超えています", "warn")
        for n in old:
            self._log(f"  削除候補: {n}", "warn")
        if not self._confirm_delete(task, old):
            self._log("世代管理: 削除を中止しました(ファイルはそのまま残ります)", "info")
            return
        for n in old:
            try:
                os.remove(os.path.join(task["dst"], n))
                self._log(f"削除: {n}", "warn")
            except OSError as e:
                self._log(f"エラー: 削除できません {n}: {e}", "error")

    def _confirm_delete(self, task, names):
        """初回だけ確認ダイアログを出す(auto_confirm_delete=True のときは自動承認)"""
        if self.auto_confirm_delete or self._delete_confirmed:
            return True
        listed = "\n".join(names[:10])
        if len(names) > 10:
            listed += f"\n… ほか {len(names) - 10} 個"
        ok = messagebox.askyesno(
            "世代管理による削除の確認",
            f"保存先: {task['dst']}\n\n"
            f"次の古いバックアップを削除します。\n{listed}\n\n"
            "削除してよろしいですか?(このアプリを起動している間は再確認しません)")
        if ok:
            self._delete_confirmed = True
        return ok

    # ------------------------------------------------------------------
    # スケジュール(after による定期実行・アプリ起動中のみ)
    # ------------------------------------------------------------------
    def _interval_ms(self):
        try:
            n = int(self.interval_var.get())
        except ValueError:
            n = 0
        if n <= 0:
            messagebox.showwarning("警告", "間隔は1以上の整数で入力してください")
            return None
        return n * self.SCHEDULE_UNITS[self.unit_var.get()]

    def _toggle_schedule(self):
        if self._schedule_job is not None:
            self.stop_schedule()
            return
        self.start_schedule()

    def start_schedule(self):
        interval = self._interval_ms()
        if interval is None:
            return False
        self._schedule_job = self.root.after(interval, self._on_schedule_tick)
        self.schedule_btn.configure(text="⏹ スケジュール停止")
        self._log(f"スケジュール開始: {self.interval_var.get()}{self.unit_var.get()}ごとに"
                  "有効なタスクを実行します(アプリを閉じると止まります)", "info")
        return True

    def stop_schedule(self):
        if self._schedule_job is not None:
            self.root.after_cancel(self._schedule_job)
            self._schedule_job = None
        self.schedule_btn.configure(text="⏱ スケジュール開始")
        self._log("スケジュール停止", "info")

    def _on_schedule_tick(self):
        self._schedule_job = None
        self._schedule_runs += 1
        self._log(f"スケジュール実行 {self._schedule_runs} 回目", "info")
        self._run_all(from_schedule=True)
        interval = self._interval_ms()
        if interval is not None:
            self._schedule_job = self.root.after(interval, self._on_schedule_tick)


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

5. コード解説

データバックアップツール(Zip圧縮・世代管理・スケジュール実行)のコードを、実際に書かれている実装に沿って解説します。

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

App097 クラスにアプリの全機能をまとめています(メソッド23個・全458行)。__init__ ではタイトル「データバックアップツール」とウィンドウサイズ 980x730 を設定します。あわせて状態を保持する変数(self._runningself._schedule_jobself._schedule_runsself._delete_confirmed)を初期化し、最後に _build_ui() で画面を組み立てます。

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

ウィジェット構成

画面は tk.Frame×6(ほかにループでも生成)・tk.Label×8(ほかにループでも生成)・tk.Button×6・ttk.Spinboxttk.Combobox×2・ttk.Treeviewttk.Scrollbar×2・tk.Textttk.Progressbartk.Toplevelttk.Entry(ループで生成)・ttk.Button(ループで生成)・tk.Checkbutton で構成しています。

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

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

「+ バックアップ追加」ボタン(_add_task())・「🗑 タスク削除」ボタン(_delete_task())・「▶ 選択をバックアップ」ボタン(_run_selected())・「▶▶ 全てバックアップ」ボタン(_run_all())・「⏹ 停止」ボタン(lambda: setattr(self, '_running', False))・「⏱ スケジュール開始」ボタン(_toggle_schedule())・「追加」ボタンを command= で接続しています。ボタンはループ内で command=lambda v=var: v.set(filedialog.askdirectory() or v.get()) の形で生成しています。既定引数でループ変数を固定するのがポイントで、これを書かないと全ボタンが最後の値で動いてしまいます。

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

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

クラスの先頭でデータを定数として定義しています:COMPRESS_MODES = {'標準(Deflate圧縮)': zipfile.ZIP_DEFLATED, '無圧縮(そのまま格納)': zipfile.ZIP_STORED}SCHEDULE_UNITS = {'分': 60000, '秒': 1000}。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。

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

例外処理

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

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

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

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

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

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

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

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

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

    title("データバックアップツール")geometry("980x730")configure(bg="#1e1e1e")minsize(980, 730) をコンストラクタで設定します。

  4. 4
    画面部品を並べる

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

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

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_add_task()_delete_task()_run_selected()_run_all()lambda: setattr(self, '_running', False)_toggle_schedule()_ok)につなぎます。ループ生成のボタンは command=lambda v=var: v.set(filedialog.askdirectory() or v.get()) の形で接続します。

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

    アプリの本体である _log()(6行)・_update_row()(2行)・_apply_retention()(26行)など を実装します。

  7. 7
    動作確認する

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

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

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

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

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

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

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

💡 設定ダイアログ

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

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

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

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

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

❌ 写経したら全部のボタンが同じ動きになる

原因:ループでボタンを作るとき、このコードは command=lambda v=var: v.set(filedialog.askdirectory() or v.get()) のように既定引数でループ変数を固定しています。これを省略してループ変数を直接書くと、ループ終了後の最後の値が全ボタンで共有されてしまいます(Python のクロージャの有名な罠です)。

解決法:掲載コードのとおり、lambda の既定引数でその時点の値を渡してください。

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

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

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

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。このアプリは実際に zip を作り、世代管理では os.remove() でファイルを消します。練習は、消えても困らない専用フォルダを新しく作って行ってください。残す世代数に 0 を入れておけば、練習中に削除は起きません。追加のライブラリは要りません。

  1. 課題1:保存先がバックアップ元の中にあるときは登録させない

    保存先をバックアップ元の中に作ると、次の zip に前の zip が入ります。実行するたびに中身が二重、三重と膨らみます。タスクを追加する時点で気づけるようにしてください。

    期待結果:練習用フォルダにテキストファイルを3つ置き、その中に空の backup フォルダを作る。バックアップ元を練習用フォルダ、保存先を backup にして登録する。タスクの行を選んで「▶ 選択をバックアップ」を2回押すと、ログの 作成: の行が (3 ファイル から (4 ファイル に増える。直したあとは、追加のときに警告が出て登録できない。

    合格条件

    • 保存先がバックアップ元の外にあるときは、これまでどおり登録できる
    • 逆向き(バックアップ元が保存先の中)は、これまでどおり登録できる
    • 登録できなかったときは、タスク一覧に行が増えない

    ヒント①(どこを触るか): 触るのは _add_task() の中の _ok() の1か所です。世代数を確かめている try: の下に足します。
    ヒント②(使うもの): os.path.abspath() で両方を絶対パスに直し、os.path.commonpath([src, dst]) == src で「中にあるか」を判定できます。警告は同じ関数にある messagebox.showwarning("警告", ...) が手本です。
    つまずきやすい点: dst.startswith(src) だけで判定すると、C:\work\dataC:\work\data2 も「中にある」と誤判定します。commonpath() は違うドライブ同士だと ValueError: Paths don't have the same drive を投げるので、try で受けてください。すでに登録してあるタスクは、この直しでは変わりません。

  2. 課題2:スケジュールが止まったらボタンの表示も戻す

    実行中に間隔の欄が不正になると、次の予約が入らずスケジュールは止まります。それなのにボタンは「⏹ スケジュール停止」のままです。止まったことが分かるようにしてください。

    期待結果:単位を 、間隔を 5 にして「⏱ スケジュール開始」を押す。1回実行されたあとに間隔の欄を 0 に書き換えると、次の実行の直後に「間隔は1以上の整数で入力してください」が出て、以後は実行されなくなる。それでもボタンは ⏹ スケジュール停止 のまま。直したあとは ⏱ スケジュール開始 に戻り、ログに スケジュール停止 が出る。

    合格条件

    • 間隔が正しい間は、これまでどおり繰り返し実行される
    • 戻ったボタンを押し直せば、また動き出す
    • 間隔が不正なときの警告ダイアログは、続けて2回出ない

    ヒント①(どこを触るか): 触るのは _on_schedule_tick() の1か所です。末尾の if interval is not None:else: を足します。
    ヒント②(使うもの): else: の中で self.stop_schedule() を呼べば、ボタンの文字とログがまとめて揃います。stop_schedule()self._schedule_jobNone のとき取り消しを呼ばない作りです。
    つまずきやすい点: _on_schedule_tick() が先頭で self._schedule_job = None に戻している点に注意してください。ここを見落として self.root.after_cancel(self._schedule_job) を直接呼ぶと、None を渡すことになり ValueError: id must be a valid identifier returned from after or after_idle が出ます。_interval_ms() は警告を出してから None を返すので、else: でもう一度警告を出すと2回続けて出ます。

  3. 課題3:保存機能の追加

    処理結果をファイルに保存する機能を追加しましょう。

🚀
次に挑戦するアプリ

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

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

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

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

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