前提
本日のお題
10. ファイル整理ツール(拡張子ごとに仕分け)
何を作る?
1つのフォルダに散らばるファイルを、拡張子ごとにサブフォルダへ自動仕分けするスクリプト。
学べること
-
os/pathlibでのファイル運用 - 辞書で「拡張子 → カテゴリ」をマッピング
- ユーザー入力で「本当に移動していい?」確認
面白いところ
- ダウンロードフォルダが一気に片付く快感
- 実行前に「ドライラン(dry-run)」モードを作るとツール感が増す
回答
コード
10_organize_file.py
"""
ファイル整理ツール(拡張子ごとに仕分け)
1つのフォルダに散らばるファイルを、拡張子ごとにサブフォルダへ自動仕分けする。
- デフォルトではカレントディレクトリを対象にする。
- ドライランで「どう移動されるか」だけ確認することも可能。
使い方:
python organize_files.py # カレントフォルダを整理(対話的に確認)
python organize_files.py /path/to/dir # 指定フォルダを整理
python organize_files.py --dry-run # 移動せず、予定だけ表示
python organize_files.py --yes # 確認なしで実行
"""
import argparse
import shutil
from pathlib import Path
from collections import Counter
# 拡張子 → カテゴリ名
# (必要に応じて自分の使い方に合わせて追加・変更してください)
EXT_TO_CATEGORY = {
# 画像
".jpg": "images",
".jpeg": "images",
".png": "images",
".gif": "images",
".bmp": "images",
".tif": "images",
".tiff": "images",
# ドキュメント
".pdf": "documents",
".doc": "documents",
".docx": "documents",
".xls": "documents",
".xlsx": "documents",
".ppt": "documents",
".pptx": "documents",
".txt": "documents",
".md": "documents",
# アーカイブ
".zip": "archives",
".tar": "archives",
".gz": "archives",
".bz2": "archives",
".7z": "archives",
".rar": "archives",
# 音声
".mp3": "audio",
".wav": "audio",
".flac": "audio",
".m4a": "audio",
# 動画
".mp4": "videos",
".mov": "videos",
".avi": "videos",
".mkv": "videos",
# コード / スクリプト
".py": "code",
".js": "code",
".ts": "code",
".html": "code",
".css": "code",
".c": "code",
".cpp": "code",
".cc": "code",
".h": "code",
".hpp": "code",
".java": "code",
".sh": "code",
".bat": "code",
}
def decide_category(path: Path) -> str:
"""ファイルパスからカテゴリ名を決める。該当がなければ 'others' にする。"""
ext = path.suffix.lower()
return EXT_TO_CATEGORY.get(ext, "others")
def collect_targets(base_dir: Path):
"""
対象ディレクトリ直下のファイルだけを整理対象として集める。
(サブフォルダも含めたい場合は iterdir() を walk に変える)
"""
files = []
for p in base_dir.iterdir():
if p.is_file():
files.append(p)
return files
def build_plan(base_dir: Path):
"""
どのファイルをどこに移動するかの「計画」を作る。
return: list of (src_path, dst_path, category)
"""
plan = []
files = collect_targets(base_dir)
for src in files:
category = decide_category(src)
dst_dir = base_dir / category
dst = dst_dir / src.name
plan.append((src, dst, category))
return plan
def resolve_conflict(dst: Path) -> Path:
"""
移動先に同名ファイルがある場合、ファイル名に _1, _2, ... をつけて衝突を回避する。
"""
if not dst.exists():
return dst
stem = dst.stem
suffix = dst.suffix
parent = dst.parent
i = 1
while True:
cand = parent / f"{stem}_{i}{suffix}"
if not cand.exists():
return cand
i += 1
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="拡張子ごとにファイルをサブフォルダへ仕分けするツール"
)
parser.add_argument(
"dir",
nargs="?",
type=Path,
default=Path("."),
help="整理対象ディレクトリ(デフォルト: カレントディレクトリ)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="実際には移動せず、どう整理されるかだけ表示する",
)
parser.add_argument(
"--yes",
"-y",
action="store_true",
help="確認なしで実行する(注意して使う)",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
base_dir: Path = args.dir.resolve()
if not base_dir.exists() or not base_dir.is_dir():
print(f"[ERROR] ディレクトリが存在しません: {base_dir}")
return
print(f"=== ファイル整理ツール ===")
print(f"対象ディレクトリ: {base_dir}")
print(f"ドライラン: {'ON' if args.dry_run else 'OFF'}")
print()
plan = build_plan(base_dir)
if not plan:
print("[INFO] 対象となるファイルがありません。")
return
# 自分自身のスクリプトは除外しておくと安全(任意)
script_path = Path(__file__).resolve()
filtered_plan = []
for src, dst, category in plan:
if src.resolve() == script_path:
continue
filtered_plan.append((src, dst, category))
if not filtered_plan:
print("[INFO] 整理対象のファイルはありません(自分自身のみなど)。")
return
# カテゴリごとの件数をざっくり出しておく
counter = Counter()
for src, dst, category in filtered_plan:
counter[category] += 1
print("=== 整理プラン(カテゴリ別件数) ===")
for category, cnt in sorted(counter.items(), key=lambda x: x[0]):
print(f"{category:10s}: {cnt:3d} 件")
print()
print("=== 移動プレビュー ===")
for src, dst, category in filtered_plan:
# 名前衝突を考慮した実際の移動先(dry-run でも計算だけはする)
real_dst = resolve_conflict(dst)
if real_dst != dst:
print(f"[{category:10s}] {src.name} -> {real_dst.relative_to(base_dir)} (※名前衝突のためリネーム)")
else:
print(f"[{category:10s}] {src.name} -> {dst.relative_to(base_dir)}")
print()
if args.dry_run:
print("[INFO] dry-run モードのため、実際の移動は行いません。")
return
if not args.yes:
ans = input("本当にこれらのファイルを移動してよろしいですか? [y/N]: ").strip().lower()
if ans not in ("y", "yes"):
print("[INFO] キャンセルしました。ファイルは移動されません。")
return
# 実際に移動する
moved = 0
for src, dst, category in filtered_plan:
dst_dir = dst.parent
dst_dir.mkdir(parents=True, exist_ok=True)
real_dst = resolve_conflict(dst)
try:
shutil.move(str(src), str(real_dst))
moved += 1
except Exception as e:
print(f"[ERROR] 移動に失敗しました: {src} -> {real_dst} ({e})")
print()
print("=== 完了 ===")
print(f"移動したファイル数: {moved}")
if __name__ == "__main__":
main()
実行例
※ファイルは適当に準備しています。特に意味はないです。
# 実行前
tree /f 10_in
10_IN
00_kadai.md
01_number_hit.py
02_db.json
20251106195906.zip
balenaEtcher-2.1.4.Setup.exe
deeplabv3_test.png
image01.jpg
image03.gif
サブフォルダーは存在しません
# 実行
$python 10_organize_file.py 10_in
=== ファイル整理ツール ===
対象ディレクトリ: C:\Users\kokekokko\Documents\Src\chatgpt-challenge-python\10_in
ドライラン: OFF
=== 整理プラン(カテゴリ別件数) ===
archives : 1 件
code : 1 件
documents : 1 件
images : 3 件
others : 2 件
=== 移動プレビュー ===
[documents ] 00_kadai.md -> documents\00_kadai.md
[code ] 01_number_hit.py -> code\01_number_hit.py
[others ] 02_db.json -> others\02_db.json
[archives ] 20251106195906.zip -> archives\20251106195906.zip
[others ] balenaEtcher-2.1.4.Setup.exe -> others\balenaEtcher-2.1.4.Setup.exe
[images ] deeplabv3_test.png -> images\deeplabv3_test.png
[images ] image01.jpg -> images\image01.jpg
[images ] image03.gif -> images\image03.gif
本当にこれらのファイルを移動してよろしいですか? [y/N]: y
=== 完了 ===
移動したファイル数: 8
# 実行後
tree /f 10_in
10_IN
├─archives
│ 20251106195906.zip
│
├─code
│ 01_number_hit.py
│
├─documents
│ 00_kadai.md
│
├─images
│ deeplabv3_test.png
│ image01.jpg
│ image03.gif
│
└─others
02_db.json
balenaEtcher-2.1.4.Setup.exe
感想
- 特にコメントもなくやるだけ!みたいな課題でしたが、dry-runモードとか確認なし実行とか設計のお勉強になりました。