ディレクトリ同期ツール
2つのフォルダを比較して差分をハイライト表示・同期できるツール。filecmpモジュールとshutilの活用を学びます。
1. アプリ概要
2つのフォルダを比較して差分をハイライト表示・同期できるツール。filecmpモジュールとshutilの活用を学びます。
このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ) で、難易度は ★★☆ です。
このアプリは「ファイル操作」カテゴリです。ファイル操作は業務スクリプトの心臓部で、I/O のパターンは他のあらゆる Python プログラムで再利用できます。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。
コピー&実行で動作確認 → コード解説で構造理解 → カスタマイズで応用、の 3 ステップで進めると、短時間で本質を押さえられます。
アプリを完成させた後は、自分の使い方に合わせて改造するのが学びを定着させる近道です。カスタマイズ章のアイデアを足がかりに、独自機能を一つ追加してみてください。
2. 機能一覧
- ループで生成するボタン群を
_browse()に接続 - 「🔍 比較」ボタン(
_compare())・「✅ 同期実行」ボタン(_sync())で操作 ttk.Treeviewによる表形式の一覧表示filedialog.askdirectory()によるファイル選択ダイアログmessagebox.askyesno()・messagebox.showerror()・messagebox.showinfo()・messagebox.showwarning()によるダイアログ通知- ウィンドウはタイトル「ディレクトリ同期ツール」・サイズ 900x600 で起動
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. 完全なソースコード
右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import os
import shutil
import filecmp
import threading
from datetime import datetime
class App27:
"""ディレクトリ同期ツール"""
def __init__(self, root):
self.root = root
self.root.title("ディレクトリ同期ツール")
self.root.geometry("900x600")
self.root.configure(bg="#f8f9fc")
self.comparing = False
self._build_ui()
def _build_ui(self):
title_frame = tk.Frame(self.root, bg="#4a148c", pady=10)
title_frame.pack(fill=tk.X)
tk.Label(title_frame, text="🔄 ディレクトリ同期ツール",
font=("Noto Sans JP", 15, "bold"),
bg="#4a148c", fg="white").pack(side=tk.LEFT, padx=12)
# フォルダ選択
dir_frame = ttk.LabelFrame(self.root, text="比較フォルダ", padding=10)
dir_frame.pack(fill=tk.X, padx=8, pady=6)
for i, (lbl, attr) in enumerate([("左フォルダ (ソース)", "left"),
("右フォルダ (ターゲット)", "right")]):
row = tk.Frame(dir_frame, bg=ttk.Style().lookup("TLabelframe", "background"))
row.pack(fill=tk.X, pady=2)
tk.Label(row, text=f"{lbl}:", bg=row.cget("bg"),
width=22, anchor="w").pack(side=tk.LEFT)
var = tk.StringVar()
setattr(self, f"{attr}_var", var)
ttk.Entry(row, textvariable=var, width=50).pack(side=tk.LEFT, padx=4)
ttk.Button(row, text="📂",
command=lambda a=attr: self._browse(a)).pack(side=tk.LEFT)
btn_row = tk.Frame(dir_frame, bg=ttk.Style().lookup("TLabelframe", "background"))
btn_row.pack(fill=tk.X, pady=6)
ttk.Button(btn_row, text="🔍 比較",
command=self._compare).pack(side=tk.LEFT, padx=8)
self.sync_mode_var = tk.StringVar(value="left_to_right")
for val, lbl in [("left_to_right", "左→右 (上書きコピー)"),
("right_to_left", "右→左 (上書きコピー)"),
("two_way", "双方向同期")]:
ttk.Radiobutton(btn_row, text=lbl, variable=self.sync_mode_var,
value=val).pack(side=tk.LEFT, padx=6)
ttk.Button(btn_row, text="✅ 同期実行",
command=self._sync).pack(side=tk.RIGHT, padx=8)
# プログレス
self.progress_var = tk.IntVar(value=0)
self.progress = ttk.Progressbar(self.root, variable=self.progress_var)
self.progress.pack(fill=tk.X, padx=8)
# 差分結果
result_frame = ttk.LabelFrame(self.root, text="差分結果", padding=4)
result_frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=6)
cols = ("status", "path", "size_l", "date_l",
"size_r", "date_r", "action")
self.tree = ttk.Treeview(result_frame, columns=cols,
show="headings", selectmode="extended")
for c, h, w in [("status", "状態", 80), ("path", "パス", 260),
("size_l", "左サイズ", 80), ("date_l", "左更新日", 130),
("size_r", "右サイズ", 80), ("date_r", "右更新日", 130),
("action", "アクション", 80)]:
self.tree.heading(c, text=h)
self.tree.column(c, width=w, minwidth=40)
h_sb = ttk.Scrollbar(result_frame, orient=tk.HORIZONTAL,
command=self.tree.xview)
v_sb = ttk.Scrollbar(result_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.tag_configure("only_left", background="#e8f5e9")
self.tree.tag_configure("only_right", background="#fce4ec")
self.tree.tag_configure("differ", background="#fff3e0")
self.tree.tag_configure("same", foreground="#aaa")
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 _browse(self, side):
path = filedialog.askdirectory()
if path:
getattr(self, f"{side}_var").set(path)
def _compare(self):
left = self.left_var.get().strip()
right = self.right_var.get().strip()
if not left or not right:
messagebox.showwarning("警告", "両方のフォルダを選択してください")
return
if not os.path.isdir(left) or not os.path.isdir(right):
messagebox.showerror("エラー", "有効なフォルダを指定してください")
return
self.tree.delete(*self.tree.get_children())
self.progress_var.set(0)
threading.Thread(target=self._do_compare,
args=(left, right), daemon=True).start()
def _do_compare(self, left, right):
results = []
self._collect_diffs(left, right, "", results)
total = len(results)
for i, item in enumerate(results):
self.root.after(0, self._add_row, item)
pct = int((i + 1) / max(total, 1) * 100)
self.root.after(0, self.progress_var.set, pct)
self.root.after(0, self.status_var.set,
f"比較完了: {total} 件 "
f"({datetime.now().strftime('%H:%M:%S')})")
def _collect_diffs(self, left_base, right_base, rel, results):
left_dir = os.path.join(left_base, rel) if rel else left_base
right_dir = os.path.join(right_base, rel) if rel else right_base
try:
left_files = set(os.listdir(left_dir)) if os.path.isdir(left_dir) else set()
except PermissionError:
left_files = set()
try:
right_files = set(os.listdir(right_dir)) if os.path.isdir(right_dir) else set()
except PermissionError:
right_files = set()
all_files = left_files | right_files
for name in sorted(all_files):
rel_path = os.path.join(rel, name) if rel else name
left_path = os.path.join(left_base, rel_path)
right_path = os.path.join(right_base, rel_path)
l_exists = os.path.exists(left_path)
r_exists = os.path.exists(right_path)
l_is_dir = os.path.isdir(left_path) if l_exists else False
r_is_dir = os.path.isdir(right_path) if r_exists else False
if l_is_dir or r_is_dir:
self._collect_diffs(left_base, right_base, rel_path, results)
continue
if l_exists and not r_exists:
status = "左のみ"
tag = "only_left"
action = "→ コピー"
elif r_exists and not l_exists:
status = "右のみ"
tag = "only_right"
action = "← コピー"
else:
same = filecmp.cmp(left_path, right_path, shallow=False)
if same:
status = "同一"
tag = "same"
action = "-"
else:
l_newer = os.path.getmtime(left_path) > os.path.getmtime(right_path)
status = "差異あり"
tag = "differ"
action = "→ 更新" if l_newer else "← 更新"
l_info = self._file_info(left_path) if l_exists else ("", "")
r_info = self._file_info(right_path) if r_exists else ("", "")
results.append((status, rel_path,
l_info[0], l_info[1],
r_info[0], r_info[1],
action, tag))
def _file_info(self, path):
try:
stat = os.stat(path)
size = self._fmt_size(stat.st_size)
mtime = datetime.fromtimestamp(stat.st_mtime).strftime("%Y/%m/%d %H:%M")
return size, mtime
except Exception:
return "", ""
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"
def _add_row(self, item):
*vals, tag = item
self.tree.insert("", "end", values=vals, tags=(tag,))
def _sync(self):
left = self.left_var.get().strip()
right = self.right_var.get().strip()
if not left or not right:
messagebox.showwarning("警告", "フォルダを選択してください")
return
mode = self.sync_mode_var.get()
if not messagebox.askyesno("確認",
f"同期を実行しますか?\nモード: {mode}"):
return
count = 0
errors = 0
for item in self.tree.get_children():
vals = self.tree.item(item)["values"]
status, rel_path, *_, action = vals
left_path = os.path.join(left, rel_path)
right_path = os.path.join(right, rel_path)
try:
if mode == "left_to_right":
if status in ("左のみ", "差異あり"):
os.makedirs(os.path.dirname(right_path), exist_ok=True)
shutil.copy2(left_path, right_path)
count += 1
elif mode == "right_to_left":
if status in ("右のみ", "差異あり"):
os.makedirs(os.path.dirname(left_path), exist_ok=True)
shutil.copy2(right_path, left_path)
count += 1
elif mode == "two_way":
if status == "左のみ":
os.makedirs(os.path.dirname(right_path), exist_ok=True)
shutil.copy2(left_path, right_path)
count += 1
elif status == "右のみ":
os.makedirs(os.path.dirname(left_path), exist_ok=True)
shutil.copy2(right_path, left_path)
count += 1
elif status == "差異あり":
# 新しい方を古い方に上書き
if os.path.getmtime(left_path) > os.path.getmtime(right_path):
shutil.copy2(left_path, right_path)
else:
shutil.copy2(right_path, left_path)
count += 1
except Exception as e:
errors += 1
messagebox.showinfo("完了",
f"同期完了: {count} 件コピー\nエラー: {errors} 件")
self._compare()
if __name__ == "__main__":
root = tk.Tk()
app = App27(root)
root.mainloop()
5. コード解説
ディレクトリ同期ツールのコードを、実際に書かれている実装に沿って解説します。
クラス設計とコンストラクタ
App27 クラスにアプリの全機能をまとめています(メソッド10個・全252行)。__init__ ではタイトル「ディレクトリ同期ツール」とウィンドウサイズ 900x600 を設定します。最後に _build_ui() で画面を組み立てます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
ウィジェット構成
画面は tk.Frame×2(ほかにループでも生成)・tk.Label×2(ほかにループでも生成)・ttk.LabelFrame×2・ttk.Entry(ループで生成)・ttk.Button×2(ほかにループでも生成)・ttk.Radiobutton(ループで生成)・ttk.Progressbar・ttk.Treeview・ttk.Scrollbar×2 で構成しています。見出し付きの枠(LabelFrame)は「比較フォルダ」「差分結果」のラベルでエリアを区切っています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
イベント処理とボタンの接続
「🔍 比較」ボタン(_compare())・「✅ 同期実行」ボタン(_sync())を command= で接続しています。ボタンはループ内で command=lambda a=attr: self._browse(a) の形で生成しています。既定引数でループ変数を固定するのがポイントで、これを書かないと全ボタンが最後の値で動いてしまいます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
例外処理
try-except で Exception・PermissionError を捕捉しています。あわせて messagebox.askyesno()・messagebox.showerror()・messagebox.showinfo()・messagebox.showwarning() のダイアログで、ユーザーへの通知・確認を行います。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
filecmp の比較モードと MD5 ハッシュ比較という選択肢
filecmp.cmp() は既定(shallow=True)だとファイルの中身を読まず、os.stat() が返すサイズと更新時刻だけで「同じ」と判定します。バックアップからの復元やコピーツールの影響で更新時刻だけが変わって中身は同じ、あるいは逆に中身が違うのに更新時刻がたまたま揃う、といった誤判定につながるため、このコードは filecmp.cmp(left_path, right_path, shallow=False) と明示して常に中身を読んで比較しています。もう一つの選択肢として MD5 などのハッシュ値で比較する方法もあります(hashlib.md5() でチャンクごとに読み込んで求めます)。ハッシュ値を保存しておけば次回以降は再計算を省略できますが、最初の1回はファイルを丸ごと読む必要があるので、大きなファイルではコストがかかる点は shallow=False の比較と変わりません。
6. ステップバイステップガイド
このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。
-
1ファイルを作成する
新しいファイルを作成して app27.py と保存します。使うのは
datetime・filecmp・os・shutil・threading・tkinterだけなので、追加インストールは不要です。 -
2クラスの骨格を作る
App27クラスを定義し、__init__と、末尾のroot = tk.Tk()~mainloop()の最小構成を書いて、まず空のウィンドウが出ることを確認します。 -
3ウィンドウを設定する
title("ディレクトリ同期ツール")・geometry("900x600")・configure(bg="#f8f9fc")をコンストラクタで設定します。 -
4画面部品を並べる
_build_ui()の中で、tk.Frame×2(ほかにループでも生成)・tk.Label×2(ほかにループでも生成)・ttk.LabelFrame×2・ttk.Style(ループで生成)・ttk.Entry(ループで生成)・ttk.Button×2(ほかにループでも生成)・ttk.Radiobutton(ループで生成)・ttk.Progressbar・ttk.Treeview・ttk.Scrollbar×2 を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。 -
5イベントを接続する
「2. 機能一覧」で挙げた各ボタンを
command=で対応するメソッド(_compare()・_sync())につなぎます。ループ生成のボタンはcommand=lambda a=attr: self._browse(a)の形で接続します。 -
6中心になるメソッドを実装する
アプリの本体である
_add_row()(3行)・_sync()(49行)・_compare()(13行)・_browse()(4行) を実装します。 -
7動作確認する
python app27.pyで起動し、各ボタンが反応することを確認します。
7. カスタマイズアイデア
基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。
💡 ダークモードを追加する
bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。
💡 結果をファイルに保存する
このコードに保存処理はないため、表示中の数値や結果はアプリを閉じると消えます。テキストファイルへ書き出して、次回起動時に読み込む機能を追加してみましょう。
💡 入力履歴機能
以前の入力値を覚えておいてComboboxのドロップダウンで再選択できる履歴機能を追加しましょう。
💡 ハッシュ値をキャッシュして比較を速くする
このコードは比較のたびに毎回 filecmp.cmp(..., shallow=False) でファイルの中身を読み直します。ファイル数や1ファイルのサイズが大きいと比較に時間がかかるため、hashlib.md5() でファイルごとのハッシュ値を求め、パスと更新時刻をキーにして辞書やファイルへ保存しておく方法もあります。次回比較時に更新時刻が変わっていないファイルはハッシュの再計算をスキップできますが、初回の計算コストは変わらない点に注意してください。
8. よくある問題と解決法
❌ 文字のフォントが崩れる・見た目が違う
原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。
解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。
❌ 写経したら全部のボタンが同じ動きになる
原因:ループでボタンを作るとき、このコードは command=lambda a=attr: self._browse(a) のように既定引数でループ変数を固定しています。これを省略してループ変数を直接書くと、ループ終了後の最後の値が全ボタンで共有されてしまいます(Python のクロージャの有名な罠です)。
解決法:掲載コードのとおり、lambda の既定引数でその時点の値を渡してください。
❌ ウィンドウの大きさが画面に合わない
原因:geometry("900x600") の固定サイズで起動するためです。
解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。
❌ 双方向同期(two_way)で意図と違う側が上書きされることがある
原因:「双方向同期」モードは、差異があるファイルについて os.path.getmtime(left_path) > os.path.getmtime(right_path) の比較だけで、新しい側から古い側へ上書きします。バックアップソフトでの復元やクラウドストレージ経由のコピー、更新時刻の精度が粗いファイルシステム(FAT32 は2秒単位)を経由すると、実際の内容と関係なく更新時刻の前後が入れ替わり、意図と逆の側が上書きされることがあります。
解決法:双方向同期を使う前に、まず「🔍 比較」だけを実行して差分結果の「差異あり」の行を目で確認してください。不安がある場合は左→右・右→左のどちらか片方向モードで、ファイルごとに確認しながら同期する方が安全です。同期は元のファイルを上書きするため、重要なデータは事前にバックアップを取ってから実行することをおすすめします。
9. 練習問題
アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。このアプリは実際にファイルを上書きコピーします。練習は、消えても困らない専用のフォルダを2つ新しく作って行ってください。「🔍 比較」だけを押している間はファイルに触れないので、まず比較で一覧を眺めてから「✅ 同期実行」に進むと安全です。追加のライブラリは要りません。
-
課題1:一覧で選んだ行だけを同期する
差分の一覧は複数行を選べる作りですが、「✅ 同期実行」は選択を見ずに一覧の全行を処理します。1つだけコピーしたつもりでも、条件に当てはまる行はすべてコピーされます。選んだ行があるときは、その行だけを同期してください。
期待結果:左フォルダに
a.txtとb.txtを作り、右フォルダは空にして「🔍 比較」を押す。「左のみ」の行が2つ並ぶので、a.txtの行だけをクリックして「✅ 同期実行」を押し、確認ダイアログで「はい」を選ぶ。完了ダイアログは同期完了: 2 件コピーと出て、右フォルダにはb.txtもできている。直したあとは1 件コピーになり、右にできるのはa.txtだけ。合格条件
- 1行も選んでいないときは、これまでどおり一覧の全行が対象になる
- 「同一」の行を選んだときは、これまでどおり何もコピーしない
- 完了ダイアログの件数と、実際にコピーされたファイル数が一致する
ヒント①(どこを触るか): 触るのは
_sync()のfor item in self.tree.get_children():の1か所です。
ヒント②(使うもの):self.tree.selection()が選択中の行 ID をタプルで返します。targets = self.tree.selection() or self.tree.get_children()と書けば、選択が無いときは今までどおり全行になります。
つまずきやすい点:selection()は選択が無いと空のタプルを返すので、orを省くと「何も選ばなければ何もコピーしない」に動きが変わります。同期のあとは末尾のself._compare()が一覧を作り直すため、選択は消えます。比較は別スレッドで動き、行はafter()で少しずつ足されるので、押した直後の一覧は空に見えます。 -
課題2:どのファイルのコピーに失敗したのか分からない
コピーに失敗しても、完了ダイアログには件数しか出ません。
except Exception as e:で受け取った内容は、どこにも表示されないまま捨てられています。失敗したファイルが分かるようにしてください。期待結果:左フォルダに
a.txtを置いて「🔍 比較」を押し、そのあとエクスプローラーでa.txtを削除してから「✅ 同期実行」を押す。完了ダイアログは同期完了: 0 件コピーとエラー: 1 件だけで、どのファイルで失敗したかは出ない。直したあとはa.txtという名前がダイアログに並ぶ。合格条件
- エラーが0件のときのダイアログは、これまでと同じ文面
- 1件失敗しても、残りのファイルのコピーは続く
- ダイアログのエラー件数と、並べたファイル名の数が合う
ヒント①(どこを触るか): 触るのは
_sync()の2か所です。except Exception as e:の中と、末尾のmessagebox.showinfo("完了", ...)です。
ヒント②(使うもの):exceptの中で失敗したパスをリストにためて、完了の文字列に"\n".join(failed[:5])のように足します。rel_pathはループの先頭のstatus, rel_path, *_, action = valsで取り出し済みです。
つまずきやすい点: リストの初期化はcount = 0と同じ場所(ループの前)に置きます。失敗した行を全部並べるとダイアログが画面からはみ出すので、先頭の数件に絞るか件数だけを添えてください。shutil.copy2()は右側が読み取り専用ならPermissionError、比較のあとに元ファイルが消えていればFileNotFoundErrorになり、いまはどちらも同じ「エラー: N 件」にまとまります。 -
課題3:保存機能の追加
入力値や計算結果をファイルに保存する機能を追加しましょう。jsonやcsvモジュールを使います。
写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。
このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。