Python自動化のサンプルコードはexit 0のまま4ファイル消した

目次
  1. 整理スクリプトを走らせる前に、もう1ファイル消えていた
  2. 定番サンプルを1回流したら4ファイル消えた
  3. 2回目は何も起きない。3回目でまた1つ減る
  4. dry-run を足しただけでは予告が外れる
  5. 自分のフォルダに向ける前に確かめられること
  6. この構成が向かないケース

「Python 自動化 サンプルコード」で検索して出てくるファイル整理スクリプトは、どれも骨格が同じだ。拡張子を見て、振り分け先のフォルダを決めて、shutil.move する。記事の中では確かに動いている。問題は、そのコードを自分のダウンロードフォルダに向けたとき、実行が終わるまで何が起きるか分からないことにある。終わってからでは、何を失ったかも分からない。

だから実行前後の全ファイルの SHA-256 を採って、定番サンプルを実際に流した。実行環境は次のとおり。

$ python3 -V
Python 3.14.7
# exit=0 (8 ms)
$ sw_vers -productVersion
26.6.1
# exit=0 (8 ms)

整理スクリプトを走らせる前に、もう1ファイル消えていた

テスト用のツリーは、現実のダウンロードフォルダで起きることだけを詰めて組んだ。中身は全部違うテキストにしてある。同名でもハッシュが別になるので、どれが失われたかを後から特定できる。

相対パス 中身の先頭 何を試すためのものか
report.pdf A: 2026-09-01 に取引先からもらった見積 同名衝突(3件のうち1件目)
2026-09/report.pdf B: 2026-09-05 に自分で出力した月次 同名衝突(2件目・サブフォルダ由来)
archive/report.pdf C: 2026-08-20 に保存した旧版 同名衝突(3件目・別のサブフォルダ)
photo.JPG D: カメラから取り込んだ写真 大文字拡張子
photo.jpg E: 加工済みの写真 小文字拡張子(.JPG との対)
notes.txt F: メモ 同名衝突(2件のうち1件目)
archive/notes.txt K: 旧メモ 同名衝突(2件目)
data.csv G: 集計データ 同名衝突(2件のうち1件目)
2026-09/data.csv H: 先月分の集計データ 同名衝突(2件目)
LICENSE I: 拡張子を持たないファイル 拡張子なし
.DS_Store J: 隠しファイル 先頭ドット

作るコードは、この組を順に write_text に渡すだけだ。削除もスキップも書いていない。ところが、できたファイルの数が合わなかった。

$ python3 make_fixture.py
created 10 files under fixture/downloads
af5209b9a4be  .DS_Store
7c466a713f4e  2026-09/data.csv
4ab3ab9d5520  2026-09/report.pdf
b96ffd28c78c  LICENSE
b5fcd9ba3441  archive/notes.txt
1c842fa23aff  archive/report.pdf
009092415036  data.csv
7ca5d94b0957  notes.txt
64f793da5e96  photo.JPG
7d9e76e67d07  report.pdf
# exit=0 (22 ms)

photo.jpg が無い。上の表に並べた組の数と突き合わせると、1つ足りていない。

$ python3 count_fixture.py
FILES に並べた組            : 11
うち小文字化すると同じになる : 1
...
衝突: photo.JPG と ['photo.jpg']
衝突: photo.jpg と ['photo.JPG']
# exit=0 (24 ms)

この時点ではまだ整理スクリプトを1行も動かしていない。テスト用のフォルダを作っただけで1件失われている。原因を最小構成で切り分けた。

$ python3 case_probe.py
after write #1: ['photo.JPG']
after write #2: ['photo.JPG']
upper.exists()      = True
lower.exists()      = True
upper == lower path = False
same file on disk   = True
content of photo.JPG= E: 加工済みの写真
EXT_MAP.get(Path('photo.JPG').suffix) = None
EXT_MAP.get(Path('photo.jpg').suffix) = 'images'
# exit=0 (24 ms)

Path("photo.JPG")Path("photo.jpg") は Python にとって等しくない。upper == lower pathFalse を返している。それなのに samefileTrue で、ディスク上は同じ1つのファイルだった。2回目の write_text が1回目の中身を上書きし、ファイル名だけが先に作られた photo.JPG のまま残っている。

最後の2行が、この記事全体の縮図になっている。ファイルシステムは大文字と小文字を同じものとして扱う。一方、拡張子の判定は Python 側の文字列比較なので、.JPG.jpg は別物として扱われる。同じ名前を、レイヤーごとに違う基準で比較している。 定番サンプルの EXT_MAP.get(path.suffix).JPG を見つけられず、写真だけ整理から漏れる。

この挙動は私が測った macOS 26.6.1 のものだ。既定のボリュームが大文字小文字を区別しない設定だった、ということ以上は確かめていない。Linux や、大文字小文字を区別する設定のボリュームで同じになるかは実行していない。

定番サンプルを1回流したら4ファイル消えた

書き写したスクリプトはこれだ。エラー処理も、衝突の回避も、dry-run も持たない。検索上位の記事に載っているものと骨格は同じである。

TARGET = Path("fixture/downloads")

EXT_MAP = {
    ".pdf": "documents", ".docx": "documents", ".txt": "documents",
    ".jpg": "images", ".png": "images",
    ".csv": "data", ".xlsx": "data",
}


def organize(target: Path) -> None:
    for path in target.rglob("*"):
        if not path.is_file():
            continue
        folder = EXT_MAP.get(path.suffix)
        if folder is None:
            continue
        dest_dir = target / folder
        dest_dir.mkdir(exist_ok=True)
        shutil.move(str(path), str(dest_dir / path.name))
        print(f"moved: {path} -> {dest_dir / path.name}")

読んで気になる箇所は無いと思う。私もそう思って流した。

$ python3 naive_organize.py
moved: fixture/downloads/notes.txt -> fixture/downloads/documents/notes.txt
moved: fixture/downloads/data.csv -> fixture/downloads/data/data.csv
moved: fixture/downloads/report.pdf -> fixture/downloads/documents/report.pdf
moved: fixture/downloads/archive/notes.txt -> fixture/downloads/documents/notes.txt
moved: fixture/downloads/archive/report.pdf -> fixture/downloads/documents/report.pdf
moved: fixture/downloads/documents/notes.txt -> fixture/downloads/documents/notes.txt
moved: fixture/downloads/documents/report.pdf -> fixture/downloads/documents/report.pdf
moved: fixture/downloads/2026-09/data.csv -> fixture/downloads/data/data.csv
moved: fixture/downloads/2026-09/report.pdf -> fixture/downloads/documents/report.pdf
moved: fixture/downloads/data/data.csv -> fixture/downloads/data/data.csv
done
# exit=0 (23 ms)

例外は上がっていない。終了コードも 0 だ。moved: の行だけを眺めれば、全部うまくいったように見える。

だが同じ移動先が繰り返し現れている。documents/report.pdf は4行に出てくる。さらに documents/notes.txt -> documents/notes.txt のように、自分自身へ移動している行まである。スクリプトが documents/ を作り、その後 rglob がその documents/ の中身を拾い、また documents/ へ移そうとした形だ。

実行前に採ったハッシュ一覧と突き合わせる。

$ python3 audit.py fixture/before.txt 'naive 1回目のあと'
=== naive 1回目のあと ===
before: 10 files
after : 6 distinct contents
LOST  : 4 files
  lost 1c842fa23aff  (was archive/report.pdf)
  lost 009092415036  (was data.csv)
  lost 7ca5d94b0957  (was notes.txt)
  lost 7d9e76e67d07  (was report.pdf)
...
# exit=0 (22 ms)

4ファイルが失われた。失われたのは「同名で中身の違うファイル」の側である。shutil.move は移動先に同名ファイルがあれば上書きするので、後から来たものが先に着いていたものを潰していく。この上書きの仕様自体は公式ドキュメントに書いてある。書いていないのは、自分のフォルダに向けたときにそれが何件になるか、である。

見落としやすいのは、失敗の痕跡が残らないことだ。上書きされた側のファイルは、移動が成功した扱いで消える。shutil.move は例外を投げないし、戻り値も使っていない。標準エラー出力も空だ。終了コードが 0 である以上、&& で後続処理を繋いでいても止まらない。

さらに厄介なのは、整理後のフォルダがそれらしく見えることである。.DS_StoreLICENSEphoto.JPG はそのまま残っている。EXT_MAP に無い拡張子として素通しされたためだ。ファイルが1つも残っていなければ「何かおかしい」と気づくが、手つかずのものが混ざっているフォルダは、ただの整理後の姿に見える。気づくには実行前のハッシュが要る。

2回目は何も起きない。3回目でまた1つ減る

同じスクリプトをもう一度回した。

$ python3 naive_organize.py
moved: fixture/downloads/documents/notes.txt -> fixture/downloads/documents/notes.txt
moved: fixture/downloads/documents/report.pdf -> fixture/downloads/documents/report.pdf
moved: fixture/downloads/data/data.csv -> fixture/downloads/data/data.csv
done
# exit=0 (21 ms)

自分自身への移動を3回やっただけで、ツリーは変わらなかった。消失件数も同じ4件のままで、増えていない。

ここだけを見ると「2回目以降は冪等だから安全だ」という結論になりそうだが、それは違う。消えるものが1回目で消え切っただけである。残っているのは、もう衝突する相手がいないファイルばかりだ。

自動化を名乗る以上、このスクリプトは定期実行に載せることになる。そしてダウンロードフォルダには毎日新しいファイルが増える。そこで、整理済みのツリーに report.pdf を1つ置いた。中身は documents/report.pdf と違う。

$ python3 add_newcomer.py
cb930ae7c8f5  report.pdf  L: 2026-09-15 に届いた最新の請求書
4ab3ab9d5520  documents/report.pdf  B: 2026-09-05 に自分で出力した月次
# exit=0 (24 ms)

この状態で回す。

$ python3 naive_organize.py
moved: fixture/downloads/report.pdf -> fixture/downloads/documents/report.pdf
moved: fixture/downloads/documents/notes.txt -> fixture/downloads/documents/notes.txt
moved: fixture/downloads/documents/report.pdf -> fixture/downloads/documents/report.pdf
moved: fixture/downloads/data/data.csv -> fixture/downloads/data/data.csv
done
# exit=0 (24 ms)
$ python3 audit.py fixture/before.txt '新着1件を足して3回目のあと'
=== 新着1件を足して3回目のあと ===
before: 10 files
after : 6 distinct contents
LOST  : 5 files
  lost 4ab3ab9d5520  (was 2026-09/report.pdf)
  lost 1c842fa23aff  (was archive/report.pdf)
  lost 009092415036  (was data.csv)
  lost 7ca5d94b0957  (was notes.txt)
  lost 7d9e76e67d07  (was report.pdf)
...
# exit=0 (23 ms)

4ab3ab9d5520 が新しく消えた。生き残っていた最後の report.pdf が、新着の請求書に上書きされたということだ。出力は moved: 4行と done だけで、終了コードは 0 のままである。

つまりこのスクリプトは、静止したフォルダに対しては冪等に見えて、動いているフォルダに対しては新着と同名のファイルを毎回1件ずつ潰す。定期実行に載せた瞬間に、冪等性の観測は意味を失う。

失われた件数を並べるとこうなる。

4件定番サンプル・初回5件定番サンプル・新着を足して再実行0件上書き回避を入れた版
実行後に失われたファイル数

私が測ったのは、同名衝突が起きる入力を意図的に集めたフィクスチャである。この件数がそのまま誰のフォルダにも当てはまるとは言えない。言えるのは、衝突が1件あれば1件消え、それが実行ログにも終了コードにも現れないということだ。衝突が何件あるかは、自分のフォルダを数えるまで分からない。

dry-run を足しただけでは予告が外れる

対策として足したのは次の5点で、機能は増やしていない。

足したもの 何を防ぐか
既定を dry-run にする 実行前に何が起きるかを読めるようにする
移動先に別内容があれば連番を付ける 上書きによる消失
中身が同一なら移動しない 2回目以降の無駄な操作
振り分け先のディレクトリを巡回対象から外す 自分が作った先を再帰的に巡ること
拡張子は小文字化してから引く .JPG の取りこぼし

まず dry-run を通した。

$ python3 safe_organize.py
ignore : .DS_Store  (拡張子 '' は対象外)
move   : 2026-09/data.csv -> data/data.csv
move   : 2026-09/report.pdf -> documents/report.pdf
ignore : LICENSE  (拡張子 '' は対象外)
move   : archive/notes.txt -> documents/notes.txt
move   : archive/report.pdf -> documents/report.pdf
move   : data.csv -> data/data.csv
move   : notes.txt -> documents/notes.txt
move   : photo.JPG -> images/photo.JPG
move   : report.pdf -> documents/report.pdf
DRY-RUN (--apply を付けると実行する): move=8 rename=0 skip=0 ignore=2
# exit=0 (33 ms)

rename=0 と言っている。上書き回避を入れたのだから衝突は無かった、と読める出力だ。しかし documents/report.pdf が3行に出ている以上、衝突は起きるはずである。実際に適用するとこうなった。

$ python3 safe_organize.py --apply
ignore : .DS_Store  (拡張子 '' は対象外)
move   : 2026-09/data.csv -> data/data.csv
move   : 2026-09/report.pdf -> documents/report.pdf
ignore : LICENSE  (拡張子 '' は対象外)
move   : archive/notes.txt -> documents/notes.txt
rename : archive/report.pdf -> documents/report-1.pdf  (同名・別内容)
rename : data.csv -> data/data-1.csv  (同名・別内容)
rename : notes.txt -> documents/notes-1.txt  (同名・別内容)
move   : photo.JPG -> images/photo.JPG
rename : report.pdf -> documents/report-2.pdf  (同名・別内容)
applied: move=4 rename=4 skip=0 ignore=2
# exit=0 (33 ms)

予告は move=8 rename=0、実際は move=4 rename=4 である。半分の行で予告が外れた。

原因は、衝突の判定が dest.exists() しか見ていなかったことにある。dry-run では実際に移動しないので、documents/report.pdf は何度問い合わせても空きのままだ。自分がそこへ置くと決めたことを覚えていない dry-run は、衝突を予告できない。 ここが厄介なのは、dry-run が「安全側に間違える」わけではない点である。実際より被害が小さく見える方向に外れる。

移動予定のパスと、そこへ置くファイルのハッシュを持ち回るようにした。ディスク上の状態と予定表の両方に問い合わせる。

def taken(p: Path) -> str | None:
    if p in planned:
        return planned[p]
    if p.exists():
        return digest(p)
    return None

takenNone を返せばそこは空き、ハッシュが一致すれば移動そのものが不要、違うハッシュが返れば連番を試す。dry-run でも --apply でも、planned に予定を書き込む処理は同じ経路を通る。

直ったかどうかは目で確かめるものではない。同じフィクスチャに対して dry-run と --apply の出力を行単位で突き合わせるスクリプトを書いた。予定表を持たない版と持つ版を続けて測る。

$ python3 check_dryrun.py
--- v1: 移動予定を覚えない dry-run ---
dry-run lines: 11  /  apply lines: 11
MISMATCH line 6
  dry  : move   : archive/report.pdf -> documents/report.pdf
  apply: rename : archive/report.pdf -> documents/report-1.pdf  (同名・別内容)
...
MISMATCH line 11
  dry  : move=8 rename=0 skip=0 ignore=2
  apply: move=4 rename=4 skip=0 ignore=2
mismatched lines: 5
--- v2: 移動予定を覚える dry-run ---
dry-run lines: 11  /  apply lines: 11
mismatched lines: 0
summary: v1_mismatch=5 v2_mismatch=0
# exit=0 (219 ms)

予告のズレが5行から0行になった。dry-run を「足した」時点では安全装置になっておらず、実行結果と突き合わせて初めて使えるものになった、というのが実測の順序である。

自分のフォルダに向ける前に確かめられること

冒頭の問いに戻る。コピペしたサンプルを自分のフォルダに向けたとき、何を失うかを実行前に知る手段はあるか。この記事の証跡から言えることは3つだ。

1. 実行前のハッシュ一覧が無いと、失ったことに気づけない。 定番サンプルの実行は終了コード 0 で、出力は moved: の羅列だけだった。整理後のフォルダには手つかずのファイルが残っていて、見た目からは異常を読み取れない。突き合わせて初めて4件・5件という数が出た。整理を回す前に、対象フォルダの全ファイルのハッシュを1回採っておく。事後の照合はそれだけで足りる。

2. dry-run は、実行結果と突き合わせるまで信用できない。 予定を覚えない dry-run は、11行の出力のうち5行を外した。しかも実際より被害が小さく見える方向に外している。自分で書いた dry-run を使うなら、同じ入力に対する --apply の出力と機械で比較して、差が0であることを一度確認する。この確認は1本のスクリプトで済む。

3. 対策後の消失は、同じ入力・同じ新着シナリオで0件だった。 上書き回避を入れた版を同じフィクスチャに当てた結果がこれである。

$ python3 audit.py fixture/before.txt '安全版v2を適用したあと'
=== 安全版v2を適用したあと ===
before: 10 files
after : 10 distinct contents
LOST  : 0 files
...
# exit=0 (22 ms)

定番サンプルが5件目を失った新着シナリオも、同じ版で通した。

$ python3 safe_organize.py --apply
ignore : .DS_Store  (拡張子 '' は対象外)
ignore : LICENSE  (拡張子 '' は対象外)
rename : report.pdf -> documents/report-3.pdf  (同名・別内容)
applied: move=0 rename=1 skip=0 ignore=2
# exit=0 (32 ms)

新着は report-3.pdf として残り、既存は無傷のままだった。新着を足す前の再実行では、対象外の2ファイルを読み飛ばしただけで何も動いていない。

この構成が向かないケース

  • 連番が増え続けることを許容できない用途。 上書きしない代わりに report-1.pdf report-2.pdf が積み上がる。中身が同一なら移動しない判定は入れてあるが、1バイトでも違えば別ファイルとして残る。重複排除が目的なら、この版は逆効果になる
  • 並行実行や、実行中にファイルが増減する状況。 ハッシュを採ってから移動するまでの間に対象が書き換わる可能性がある。ロックも再試行も入れていないし、測っていない
  • 大文字小文字を区別するファイルシステム。 確かめたのは macOS 26.6.1 の1環境だけである。photo.JPGphoto.jpg が別ファイルとして共存する環境では挙動が変わるはずだが、実行していない
  • 巨大なフォルダ。 私が流したのは10件規模で、所要時間は問題にならなかった。全ファイルのハッシュを2回採る設計なので、件数が増えたときの現実的な上限は測っていない

最後に、この記事が扱わなかったことを書いておく。上書きされたファイルを取り戻せるかどうかは測っていない。ゴミ箱に入るのか、バックアップから拾えるのかは確かめていないし、確かめたところで答えは環境ごとに違う。私が確かめたのは、失ったことに気づく手段は実行前にしか用意できないという一点だけだ。整理後のフォルダは、4件失ったときも5件失ったときも、同じように整理されて見えていた。

関連書籍