中級者向け No.35

ファイル圧縮・解凍ツール

ZIP・TAR・GZ形式でファイルを圧縮・解凍するGUIツール。zipfileとtarfileモジュールの活用を学びます。

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

1. アプリ概要

ZIP・TAR・GZ形式でファイルを圧縮・解凍するGUIツール。zipfileとtarfileモジュールの活用を学びます。

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

このアプリは「ファイル操作」カテゴリです。ファイル操作は業務スクリプトの心臓部で、I/O のパターンは他のあらゆる Python プログラムで再利用できます。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。

コピー&実行で動作確認 → コード解説で構造理解 → カスタマイズで応用、の 3 ステップで進めると、短時間で本質を押さえられます。

アプリを完成させた後は、自分の使い方に合わせて改造するのが学びを定着させる近道です。カスタマイズ章のアイデアを足がかりに、独自機能を一つ追加してみてください。

ファイル圧縮・解凍ツール 実行画面(Windows)
実行画面(Windows)
ファイル圧縮・解凍ツール 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • 「➕ ファイルを追加」ボタン(_add_files_compress())・「📂 フォルダを追加」ボタン(_add_folder_compress())・「🗑 リストをクリア」ボタン(_clear_compress_list())・「📂」ボタン(_browse_compress_out())・「▶ 圧縮実行」ボタン(_compress())・「📂 参照」ボタン(_browse_archive())・「📂 参照」ボタン(lambda: self.extract_out_var.set(filedialog.askdirectory() or self.extract_out_var.get()))・「▶ 解凍実行」ボタン(_extract())・「📂 参照」ボタン(_browse_view_archive())・「🔍 内容確認」ボタン(_view_archive())で操作
  • tk.Listbox による一覧表示(スクロールバー付き)
  • ttk.Treeview による表形式の一覧表示
  • filedialog.askdirectory() によるファイル選択ダイアログ
  • filedialog.askopenfilename() によるファイル選択ダイアログ
  • filedialog.askopenfilenames() によるファイル選択ダイアログ
  • filedialog.asksaveasfilename() によるファイル選択ダイアログ
  • messagebox.showerror()messagebox.showwarning() によるダイアログ通知
  • ウィンドウはタイトル「ファイル圧縮・解凍ツール」・サイズ 860x600 で起動

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. 完全なソースコード

💡
コードのコピー方法

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

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


class App35:
    """ファイル圧縮・解凍ツール"""

    def __init__(self, root):
        self.root = root
        self.root.title("ファイル圧縮・解凍ツール")
        self.root.geometry("860x600")
        self.root.configure(bg="#f8f9fc")
        self._files_to_compress = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#01579b", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="🗜️ ファイル圧縮・解凍ツール",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#01579b", fg="white").pack(side=tk.LEFT, padx=12)

        notebook = ttk.Notebook(self.root)
        notebook.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # 圧縮タブ
        compress_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(compress_tab, text="📦 圧縮")
        self._build_compress_tab(compress_tab)

        # 解凍タブ
        extract_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(extract_tab, text="📂 解凍")
        self._build_extract_tab(extract_tab)

        # アーカイブ閲覧タブ
        view_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(view_tab, text="🔍 内容確認")
        self._build_view_tab(view_tab)

        self.status_var = tk.StringVar(value="ファイルを選択してください")
        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 _build_compress_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}

        # ファイル/フォルダ追加
        top_f = tk.Frame(parent, bg="#f8f9fc")
        top_f.pack(fill=tk.X, padx=8, pady=6)
        ttk.Button(top_f, text="➕ ファイルを追加",
                   command=self._add_files_compress).pack(side=tk.LEFT, padx=4)
        ttk.Button(top_f, text="📂 フォルダを追加",
                   command=self._add_folder_compress).pack(side=tk.LEFT, padx=4)
        ttk.Button(top_f, text="🗑 リストをクリア",
                   command=self._clear_compress_list).pack(side=tk.LEFT, padx=4)

        # ファイルリスト
        list_f = ttk.LabelFrame(parent, text="圧縮するファイル/フォルダ", padding=4)
        list_f.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.compress_listbox = tk.Listbox(list_f, font=("Arial", 10), height=10)
        sb = ttk.Scrollbar(list_f, command=self.compress_listbox.yview)
        self.compress_listbox.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.compress_listbox.pack(fill=tk.BOTH, expand=True)

        # 設定
        setting_f = ttk.LabelFrame(parent, text="圧縮設定", padding=8)
        setting_f.pack(fill=tk.X, padx=8, pady=4)
        row1 = tk.Frame(setting_f, bg=ttk.Style().lookup("TLabelframe", "background"))
        row1.pack(fill=tk.X, pady=2)

        tk.Label(row1, text="形式:", **lbl_s).pack(side=tk.LEFT)
        self.compress_fmt_var = tk.StringVar(value="ZIP")
        ttk.Combobox(row1, textvariable=self.compress_fmt_var,
                     values=["ZIP", "TAR", "TAR.GZ", "TAR.BZ2"],
                     state="readonly", width=10).pack(side=tk.LEFT, padx=6)

        tk.Label(row1, text="圧縮レベル:", **lbl_s).pack(side=tk.LEFT, padx=(12, 0))
        self.compress_level_var = tk.IntVar(value=6)
        ttk.Spinbox(row1, from_=1, to=9,
                    textvariable=self.compress_level_var, width=4).pack(side=tk.LEFT, padx=4)

        row2 = tk.Frame(setting_f, bg=ttk.Style().lookup("TLabelframe", "background"))
        row2.pack(fill=tk.X, pady=4)
        tk.Label(row2, text="出力先:", **lbl_s).pack(side=tk.LEFT)
        self.compress_out_var = tk.StringVar()
        ttk.Entry(row2, textvariable=self.compress_out_var,
                  width=40).pack(side=tk.LEFT, padx=4, fill=tk.X, expand=True)
        ttk.Button(row2, text="📂",
                   command=self._browse_compress_out).pack(side=tk.LEFT)

        ttk.Button(parent, text="▶ 圧縮実行",
                   command=self._compress).pack(padx=8, pady=6)

        self.compress_progress = ttk.Progressbar(parent, mode="indeterminate")
        self.compress_progress.pack(fill=tk.X, padx=8, pady=2)

    def _add_files_compress(self):
        paths = filedialog.askopenfilenames()
        for p in paths:
            if p not in self._files_to_compress:
                self._files_to_compress.append(p)
                self.compress_listbox.insert(tk.END, p)
        self._suggest_output()

    def _add_folder_compress(self):
        path = filedialog.askdirectory()
        if path and path not in self._files_to_compress:
            self._files_to_compress.append(path)
            self.compress_listbox.insert(tk.END, path)
        self._suggest_output()

    def _clear_compress_list(self):
        self._files_to_compress.clear()
        self.compress_listbox.delete(0, tk.END)

    def _suggest_output(self):
        if self._files_to_compress and not self.compress_out_var.get():
            first = self._files_to_compress[0]
            base = os.path.splitext(os.path.basename(first))[0]
            folder = os.path.dirname(first)
            fmt = self.compress_fmt_var.get().lower().replace(".", "")
            ext = ".zip" if fmt == "zip" else ".tar" if fmt == "tar" else \
                  ".tar.gz" if fmt == "targz" else ".tar.bz2"
            self.compress_out_var.set(os.path.join(folder, base + ext))

    def _browse_compress_out(self):
        fmt = self.compress_fmt_var.get()
        ft = [("ZIP", "*.zip")] if fmt == "ZIP" else \
             [("TAR", "*.tar"), ("TAR.GZ", "*.tar.gz"), ("TAR.BZ2", "*.tar.bz2")]
        path = filedialog.asksaveasfilename(filetypes=ft + [("すべて", "*.*")])
        if path:
            self.compress_out_var.set(path)

    def _compress(self):
        if not self._files_to_compress:
            messagebox.showwarning("警告", "ファイルを追加してください")
            return
        out = self.compress_out_var.get().strip()
        if not out:
            messagebox.showwarning("警告", "出力先を指定してください")
            return
        self.compress_progress.start(10)
        threading.Thread(target=self._do_compress,
                         args=(list(self._files_to_compress), out),
                         daemon=True).start()

    def _do_compress(self, files, out):
        fmt = self.compress_fmt_var.get()
        level = self.compress_level_var.get()
        try:
            if fmt == "ZIP":
                with zipfile.ZipFile(out, "w",
                                     compression=zipfile.ZIP_DEFLATED,
                                     compresslevel=level) as zf:
                    for path in files:
                        if os.path.isdir(path):
                            for dirpath, _, filenames in os.walk(path):
                                for fn in filenames:
                                    fp = os.path.join(dirpath, fn)
                                    arcname = os.path.relpath(fp, os.path.dirname(path))
                                    zf.write(fp, arcname)
                        else:
                            zf.write(path, os.path.basename(path))
            else:
                mode_map = {"TAR": "w", "TAR.GZ": "w:gz", "TAR.BZ2": "w:bz2"}
                mode = mode_map[fmt]
                with tarfile.open(out, mode) as tf:
                    for path in files:
                        tf.add(path, arcname=os.path.basename(path))

            size_kb = os.path.getsize(out) // 1024
            self.root.after(0, self.compress_progress.stop)
            self.root.after(0, self.status_var.set,
                            f"圧縮完了: {out} ({size_kb}KB)")
            self.root.after(0, messagebox.showinfo, "完了",
                            f"圧縮完了!\n{out}\n({size_kb}KB)")
        except Exception as e:
            self.root.after(0, self.compress_progress.stop)
            self.root.after(0, messagebox.showerror, "エラー", str(e))

    # ── 解凍タブ ──────────────────────────────────────────────────

    def _build_extract_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}

        # ファイル選択
        row1 = tk.Frame(parent, bg="#f8f9fc")
        row1.pack(fill=tk.X, padx=8, pady=8)
        tk.Label(row1, text="アーカイブ:", **lbl_s).pack(side=tk.LEFT)
        self.extract_file_var = tk.StringVar()
        ttk.Entry(row1, textvariable=self.extract_file_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row1, text="📂 参照",
                   command=self._browse_archive).pack(side=tk.LEFT)

        # 出力先
        row2 = tk.Frame(parent, bg="#f8f9fc")
        row2.pack(fill=tk.X, padx=8, pady=4)
        tk.Label(row2, text="解凍先:", **lbl_s).pack(side=tk.LEFT)
        self.extract_out_var = tk.StringVar()
        ttk.Entry(row2, textvariable=self.extract_out_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row2, text="📂 参照",
                   command=lambda: self.extract_out_var.set(
                       filedialog.askdirectory() or self.extract_out_var.get())
                   ).pack(side=tk.LEFT)

        # オプション
        opt_f = ttk.LabelFrame(parent, text="オプション", padding=6)
        opt_f.pack(fill=tk.X, padx=8, pady=4)
        self.extract_subdir_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(opt_f, text="アーカイブ名のサブフォルダに解凍",
                         variable=self.extract_subdir_var).pack(anchor="w")
        self.extract_overwrite_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(opt_f, text="既存ファイルを上書き",
                         variable=self.extract_overwrite_var).pack(anchor="w")

        ttk.Button(parent, text="▶ 解凍実行",
                   command=self._extract).pack(padx=8, pady=6)

        self.extract_progress = ttk.Progressbar(parent, mode="indeterminate")
        self.extract_progress.pack(fill=tk.X, padx=8, pady=2)

        # ログ
        log_f = ttk.LabelFrame(parent, text="解凍ログ", padding=4)
        log_f.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.extract_log = tk.Text(log_f, height=8, font=("Courier New", 10),
                                    state=tk.DISABLED, bg="#fafafa")
        self.extract_log.pack(fill=tk.BOTH, expand=True)

    def _browse_archive(self):
        path = filedialog.askopenfilename(
            filetypes=[("アーカイブ", "*.zip *.tar *.tar.gz *.tar.bz2 *.tgz"),
                       ("すべて", "*.*")])
        if path:
            self.extract_file_var.set(path)
            if not self.extract_out_var.get():
                self.extract_out_var.set(os.path.dirname(path))

    def _extract(self):
        archive = self.extract_file_var.get().strip()
        if not archive or not os.path.exists(archive):
            messagebox.showwarning("警告", "アーカイブファイルを選択してください")
            return
        out = self.extract_out_var.get().strip() or os.path.dirname(archive)
        if self.extract_subdir_var.get():
            base = os.path.splitext(os.path.basename(archive))[0]
            if base.endswith(".tar"):
                base = base[:-4]
            out = os.path.join(out, base)
        os.makedirs(out, exist_ok=True)
        self.extract_progress.start(10)
        threading.Thread(target=self._do_extract,
                         args=(archive, out), daemon=True).start()

    def _do_extract(self, archive, out):
        try:
            extracted = 0
            if zipfile.is_zipfile(archive):
                with zipfile.ZipFile(archive, "r") as zf:
                    members = zf.namelist()
                    for m in members:
                        zf.extract(m, out)
                        extracted += 1
                        self.root.after(0, self._log_extract, f"  {m}")
            elif tarfile.is_tarfile(archive):
                with tarfile.open(archive) as tf:
                    members = tf.getmembers()
                    for m in members:
                        tf.extract(m, out)
                        extracted += 1
                        self.root.after(0, self._log_extract, f"  {m.name}")
            else:
                raise ValueError("サポートされていない形式です")

            self.root.after(0, self.extract_progress.stop)
            self.root.after(0, self._log_extract,
                            f"\n✓ 解凍完了: {extracted} ファイル → {out}")
            self.root.after(0, self.status_var.set,
                            f"解凍完了: {extracted} ファイル")
        except Exception as e:
            self.root.after(0, self.extract_progress.stop)
            self.root.after(0, messagebox.showerror, "エラー", str(e))

    def _log_extract(self, text):
        self.extract_log.config(state=tk.NORMAL)
        self.extract_log.insert(tk.END, text + "\n")
        self.extract_log.see(tk.END)
        self.extract_log.config(state=tk.DISABLED)

    # ── 内容確認タブ ──────────────────────────────────────────────

    def _build_view_tab(self, parent):
        row = tk.Frame(parent, bg="#f8f9fc")
        row.pack(fill=tk.X, padx=8, pady=8)
        tk.Label(row, text="アーカイブ:", bg="#f8f9fc",
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.view_file_var = tk.StringVar()
        ttk.Entry(row, textvariable=self.view_file_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row, text="📂 参照",
                   command=self._browse_view_archive).pack(side=tk.LEFT)
        ttk.Button(row, text="🔍 内容確認",
                   command=self._view_archive).pack(side=tk.LEFT, padx=4)

        cols = ("name", "size", "compressed", "type", "date")
        self.view_tree = ttk.Treeview(parent, columns=cols, show="headings")
        for c, h, w in [("name", "ファイル名", 280), ("size", "元サイズ", 80),
                         ("compressed", "圧縮後", 80), ("type", "種別", 60),
                         ("date", "更新日", 130)]:
            self.view_tree.heading(c, text=h)
            self.view_tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(parent, command=self.view_tree.yview)
        self.view_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.view_tree.pack(fill=tk.BOTH, expand=True, padx=8)

        self.view_info_var = tk.StringVar()
        tk.Label(parent, textvariable=self.view_info_var, bg="#f8f9fc",
                 font=("Arial", 9), fg="#555").pack(anchor="w", padx=8, pady=2)

    def _browse_view_archive(self):
        path = filedialog.askopenfilename(
            filetypes=[("アーカイブ", "*.zip *.tar *.tar.gz *.tar.bz2 *.tgz"),
                       ("すべて", "*.*")])
        if path:
            self.view_file_var.set(path)
            self._view_archive()

    def _view_archive(self):
        path = self.view_file_var.get().strip()
        if not path or not os.path.exists(path):
            messagebox.showwarning("警告", "アーカイブファイルを選択してください")
            return
        self.view_tree.delete(*self.view_tree.get_children())
        total_orig = 0
        total_comp = 0
        count = 0
        try:
            if zipfile.is_zipfile(path):
                with zipfile.ZipFile(path) as zf:
                    for info in zf.infolist():
                        kind = "📁" if info.is_dir() else "📄"
                        date_str = f"{info.date_time[0]}/{info.date_time[1]:02d}/{info.date_time[2]:02d}"
                        self.view_tree.insert(
                            "", "end",
                            values=(info.filename,
                                    self._fmt_size(info.file_size),
                                    self._fmt_size(info.compress_size),
                                    kind, date_str))
                        total_orig += info.file_size
                        total_comp += info.compress_size
                        count += 1
            elif tarfile.is_tarfile(path):
                with tarfile.open(path) as tf:
                    for m in tf.getmembers():
                        kind = "📁" if m.isdir() else "📄"
                        dt = datetime.fromtimestamp(m.mtime).strftime(
                            "%Y/%m/%d") if m.mtime else ""
                        self.view_tree.insert(
                            "", "end",
                            values=(m.name, self._fmt_size(m.size),
                                    "—", kind, dt))
                        total_orig += m.size
                        count += 1
            else:
                messagebox.showerror("エラー", "サポートされていない形式です")
                return

            ratio = (1 - total_comp / total_orig) * 100 if total_orig else 0
            self.view_info_var.set(
                f"合計: {count} アイテム  |  "
                f"元サイズ: {self._fmt_size(total_orig)}  |  "
                f"圧縮後: {self._fmt_size(total_comp)}  |  "
                f"圧縮率: {ratio:.1f}%")
            self.status_var.set(f"確認完了: {count} アイテム")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _fmt_size(self, size):
        for unit in ["B", "KB", "MB", "GB"]:
            if size < 1024:
                return f"{size:.0f}{unit}"
            size /= 1024
        return f"{size:.1f}TB"


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

5. コード解説

ファイル圧縮・解凍ツールのコードを、実際に書かれている実装に沿って解説します。

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

App35 クラスにアプリの全機能をまとめています(メソッド19個・全401行)。__init__ ではタイトル「ファイル圧縮・解凍ツール」とウィンドウサイズ 860x600 を設定します。最後に _build_ui() で画面を組み立てます。

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

ウィジェット構成

画面は tk.Frame×10・tk.Label×9・ttk.Notebookttk.Button×10・ttk.LabelFrame×4・tk.Listboxttk.Scrollbar×2・ttk.Style×2・ttk.Comboboxttk.Spinboxttk.Entry×4・ttk.Progressbar×2・ttk.Checkbutton×2・tk.Textttk.Treeview で構成しています。見出し付きの枠(LabelFrame)は「圧縮するファイル/フォルダ」「圧縮設定」「オプション」「解凍ログ」のラベルでエリアを区切っています。

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

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

「➕ ファイルを追加」ボタン(_add_files_compress())・「📂 フォルダを追加」ボタン(_add_folder_compress())・「🗑 リストをクリア」ボタン(_clear_compress_list())・「📂」ボタン(_browse_compress_out())・「▶ 圧縮実行」ボタン(_compress())・「📂 参照」ボタン(_browse_archive())・「📂 参照」ボタン(lambda: self.extract_out_var.set(filedialog.askdirectory() or self.extract_out_var.get()))・「▶ 解凍実行」ボタン(_extract())・「📂 参照」ボタン(_browse_view_archive())・「🔍 内容確認」ボタン(_view_archive())を command= で接続しています。

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

例外処理

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

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

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

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

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

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

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

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

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

    title("ファイル圧縮・解凍ツール")geometry("860x600")configure(bg="#f8f9fc") をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×10・tk.Label×9・ttk.Notebookttk.Button×10・ttk.LabelFrame×4・tk.Listboxttk.Scrollbar×2・ttk.Style×2・ttk.Comboboxttk.Spinboxttk.Entry×4・ttk.Progressbar×2・ttk.Checkbutton×2・tk.Textttk.Treeview を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

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

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_add_files_compress()_add_folder_compress()_clear_compress_list()_browse_compress_out()_compress()_browse_archive()lambda: self.extract_out_var.set(filedialog.askdirectory() or self.extract_out_var.get())_extract()_browse_view_archive()_view_archive())につなぎます。

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

    アプリの本体である _log_extract()(5行)・_view_archive()(49行)・_extract()(15行)など を実装します。

  7. 7
    動作確認する

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

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

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

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

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

💡 データのエクスポート機能

計算結果をCSV・TXTファイルに保存するエクスポート機能を追加しましょう。filedialog.asksaveasfilename()でファイル保存ダイアログが使えます。

💡 一覧の内容をファイルに書き出す

tk.Listbox に溜めた内容は、アプリを閉じると消えます(このコードに保存処理はありません)。一覧を1行ずつテキストファイルへ書き出す保存ボタンを追加しましょう。保存先の選択には、既に import されている filedialog が使えます。

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

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

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

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

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

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

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

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。「▶ 解凍実行」は、同じ名前のファイルがあっても確認なしで上書きします(課題1のとおり「既存ファイルを上書き」のチェックは読まれていません)。練習は、消えても困らない専用のフォルダを新しく作って行ってください。使うのは自分で作ったアーカイブだけにしてください(配布元の分からない ZIP・TAR は、中の名前が ../ や絶対パスになっていると、解凍先の外へファイルを書き出せてしまいます。とくに tarfile は Python 3.13 以前だとこれを止めません)。「🔍 内容確認」タブは中身を読むだけなので、先にそこで一覧を見ておくと安全です。追加のライブラリは要りません。

  1. 課題1:「既存ファイルを上書き」のチェックが効いていない

    解凍タブには「既存ファイルを上書き」というチェックがあります。ところが _do_extract() はこの値をどこでも読んでいません。チェックを外していても同じ名前のファイルは置き換わります。表示どおりに動かしてください。

    期待結果:解凍先にするフォルダへ a.txt(中身は new)を置く。同じ名前の a.txt(中身は old)だけを入れた ZIP を用意する。「アーカイブ名のサブフォルダに解凍」を外し、解凍先をそのフォルダにして、「既存ファイルを上書き」は外したまま「▶ 解凍実行」を押す。確認は出ず、a.txt の中身は old に変わる。解凍ログには a.txt✓ 解凍完了: 1 ファイル → 解凍先のパス が出る。直したあとは中身が new のまま残る。

    合格条件

    • チェックを入れたときは、これまでどおり全部が上書きされる
    • スキップしたファイルは「解凍完了: N ファイル」の N に数えない
    • どのファイルをスキップしたのか、解凍ログを見れば分かる

    ヒント①(どこを触るか): 触るのは _do_extract() の2か所です。ZIP 側の for m in members: のループと、TAR 側の for m in members: のループです。
    ヒント②(使うもの): 解凍先のパスは os.path.join(out, m)(TAR なら m.name)で作れます。os.path.exists() が真で self.extract_overwrite_var.get() が偽ならスキップし、理由をログに出します。_do_extract() は別スレッドで動くので、275行・282行と同じく self.root.after(0, self._log_extract, f" スキップ(既存): {m}") の形で呼んでください。
    つまずきやすい点: zf.extract() にも tf.extract() にも「上書きしない」という指定はありません(呼んだ時点で置き換わります)。ZIP の中のフォルダは名前が / で終わり、os.path.exists() も真になりやすいので、フォルダまでスキップと数えると件数がずれます。self.extract_overwrite_var_build_extract_tab() で作られたきり、どのメソッドからも読まれていません。

  2. 課題2:TAR を開くと圧縮率が必ず 100.0%

    「🔍 内容確認」タブで .tar.gz を開くと、下の行は 圧縮率: 100.0% になります。TAR の分岐が total_comp に何も足していないためです。正しい値か、集計できない印を出してください。

    期待結果:同じフォルダを ZIP と TAR.GZ の2つに圧縮し、「🔍 内容確認」で順に開く。ZIP は「圧縮後」列に数字が並び、圧縮率もそれらしい値になる。TAR.GZ は「圧縮後」列が全行 で、下の行は 圧縮後: 0B | 圧縮率: 100.0%。中身が1バイトも入っていないように読めるが、実際のファイルには中身がある。直したあとは、実際の圧縮率か になる。

    合格条件

    • ZIP を開いたときの表示は、これまでと同じ
    • TAR.GZ の圧縮率が 100.0% にならない
    • 元サイズの合計が0のときも、ゼロ除算で落ちない

    ヒント①(どこを触るか): 触るのは _view_archive() 末尾の1か所です。ratio を求める行と、その下の self.view_info_var.set(...) です。
    ヒント②(使うもの): TAR はメンバーごとの圧縮後サイズを持ちません。アーカイブ全体の大きさ os.path.getsize(path) を使えば、まとめての圧縮率は出せます。集計できないときに 圧縮率: — と出す手もあります。
    つまずきやすい点: total_comp の初期値は 0 で、(1 - 0 / total_orig) * 100 はいつでも 100.0 です。エラーにならないぶん気づきにくい罠。.tar(無圧縮)は元サイズの合計よりファイルが少し大きくなり、全体で見た圧縮率がマイナスになることもあります。_fmt_size() は 1024 バイト未満を 0B の形で出すため、0 と「集計できない」の区別が付きません。

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

    入力値や計算結果をファイルに保存する機能を追加しましょう。jsonやcsvモジュールを使います。

🚀
次に挑戦するアプリ

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

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

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

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

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