混ぜるな危険。distutils.dir_utilとshutil

先日「could not create 'ファイルパス': No such file or directory」
なるエラーの原因がよくわからず数時間嵌っておりましたが、原因は無意識にdistutils.dir_utilとshutilまぜこぜにしてファイル操作していたためでした。

追記：stack overflowで同事象について語られていました。
https://stackoverflow.com/questions/9160227/dir-util-copy-tree-fails-after-shutil-rmtree
概要は後述・・・

## 環境情報

OS: Windows 10
Python: 3.7.7

## 概要
以下で発生します。

1. 「パスA」をツリー構成維持したまま「パスB」としてcopy_tree
＜distutils.dir_util.copy_tree('パスA', 'パスB')＞

2. 「パスB」を「パスC」としてmove
＜shutil.move('パスB', 'パスC')＞

3. 改めて「パスA」をツリー構成維持したまま「パスB」としてcopy_tree
＜distutils.dir_util.copy_tree('パスA', 'パスB')＞

**⇒「could not create 'ファイルパス': No such file or directory」発生**

ちなみに「パスA」がファイルを含まない、ディレクトリだけのツリーであればエラーは発生しませんが、copy_treeをしているにもかかわらずディレクトリが一切作成されないという状況になります。

## サンプルコードと解説

サンプルコードは以下の通りです。

```python:エラーサンプル1
from os import path
from shutil import move
from distutils.dir_util import remove_tree, mkpath, copy_tree
from pathlib import Path

try:
    remove_tree('c:/tmp01/') # <---rmしているので実行する際はお気を付けください。
except:
    pass

try:
    mkpath('c:/tmp01/test_org/test/test')
    Path('c:/tmp01/test_org/test/test/test.txt').touch()
except:
    pass

'''copy_treeしてるのにディレクトリとか作成されない'''

# distutils.dir_utilでcopy_tree
copy_tree('c:/tmp01/test_org', 'c:/tmp01/test01')
print(path.exists('c:/tmp01/test01/test'))
# -> True

# shutilでディレクトリ移動(rmtreeでもOK)
move('c:/tmp01/test01', 'c:/tmp01/test11')

# 改めてdistutils.dir_utilでcopy_tree
try:
    copy_tree('c:/tmp01/test_org', 'c:/tmp01/test01')
except Exception as e:
    # エラー発生
    # ディレクトリのないところにファイル作ろうとしてエラーになる。
    # could not create 'c:/tmp01/test01\test\test\test.txt': No such file or directory
    print(e)

print(path.exists('c:/tmp01/test01/test'))
# -> False 作成されていない！
```

エラー発生個所のcopy_treeをデバッグで追ってみたところ、copy_treeから呼び出しているdistutils.dir_util.mkpathで問題が発生していました。
distutils.dir_util.mkpathはパスが既に存在しているか否かを判定して、存在しない場合にのみパスを作成しようとするようですが、shutilでmove(もしくはrmtree)して対象パスが実際に消えていても、distutils.dir_utilが自ら作成したパスについてはshutilでパスが消されたということを認識できずに、まだ存在するものと判断してしまい、パスを作成しません。

mkpathでのサンプルコードが以下になります。

```python:エラーサンプル2
from os import path
from shutil import move
from distutils.dir_util import remove_tree, mkpath

try:
    remove_tree('c:/tmp01/') # <---rmしているので実行する際はお気を付けください。
except:
    pass

'''mkpathしてるのにパスが作成されない'''

# distutils.dir_utilでディレクトリ作成
mkpath('c:/tmp01/test01/test')
print(path.exists('c:/tmp01/test01/test'))
# -> True

# shutilでディレクトリ移動(rmtreeでもOK)
move('c:/tmp01/test01', 'c:/tmp01/test11')

# 改めてdistutils.dir_utilでディレクトリ作成
mkpath('c:/tmp01/test01/test')
print(path.exists('c:/tmp01/test01/test'))
# -> False 作成されていない！
```

## 解消法

shutilかdistutils.dir_utilどちらかに統一すれば問題にはなりません。

```python:shutilに統一して解消
from os import path
from shutil import move, copytree
from distutils.dir_util import remove_tree, mkpath

try:
    remove_tree('c:/tmp01/') # <---rmしているので実行する際はお気を付けください。
except:
    pass

try:
    mkpath('c:/tmp01/test_org/test/test')
except:
    pass

'''shutilで統一。copytree'''

# shutilでcopytree
copytree('c:/tmp01/test_org', 'c:/tmp01/test01')
print(path.exists('c:/tmp01/test01/test'))
# -> True

# shutilでディレクトリ移動(rmtreeでもOK)
move('c:/tmp01/test01', 'c:/tmp01/test11')

# 改めてshutilでcopytree
copytree('c:/tmp01/test_org', 'c:/tmp01/test01')
print(path.exists('c:/tmp01/test01/test'))
# -> True
```

```python:distutils.dir_utilに統一して解消
from os import path
from distutils.dir_util import remove_tree, mkpath, copy_tree

try:
    remove_tree('c:/tmp01/') # <---rmしているので実行する際はお気を付けください。
except:
    pass

try:
    mkpath('c:/tmp01/test_org/test/test')
except:
    pass

'''distutils.dir_utilで統一。copy_tree'''

# distutils.dir_utilでcopy_tree
copy_tree('c:/tmp01/test_org', 'c:/tmp01/test01')
print(path.exists('c:/tmp01/test01/test'))
# -> True

# distutils.dir_utilでコピー＋削除
copy_tree('c:/tmp01/test01', 'c:/tmp01/test11')
remove_tree('c:/tmp01/test01')

# 改めてdistutils.dir_utilでcopy_tree
copy_tree('c:/tmp01/test_org', 'c:/tmp01/test01')
print(path.exists('c:/tmp01/test01/test'))
# -> True
```

distutils.dir_utilはmoveに相当するものが無いようで、コピーして削除という流れにしていますので、若干微妙です。
今回の処理ならshutilで統一して解消させようかと思います。

追記：stack overflowで同事象について語られていました。
https://stackoverflow.com/questions/9160227/dir-util-copy-tree-fails-after-shutil-rmtree
distutils.dir_utilは自身で作成したディレクトリの情報をdistutils.dir_util._path_createdに格納しているので、それをクリアすればOKということみたいでした。

```python:distutils.dir_util._path_createdをクリアして解消
from os import path
from shutil import move
from distutils.dir_util import remove_tree, mkpath, copy_tree
from pathlib import Path
import distutils.dir_util

try:
    remove_tree('c:/tmp01/') # <---rmしているので実行する際はお気を付けください。
except:
    pass

try:
    mkpath('c:/tmp01/test_org/test/test')
    Path('c:/tmp01/test_org/test/test/test.txt').touch()
except:
    pass

'''copy_treeする前にキャッシュ削除'''

# distutils.dir_utilでcopy_tree
copy_tree('c:/tmp01/test_org', 'c:/tmp01/test01')
print(path.exists('c:/tmp01/test01/test'))
# -> True

# shutilでディレクトリ移動(rmtreeでもOK)
move('c:/tmp01/test01', 'c:/tmp01/test11')

# キャッシュdistutils.dir_util._path_createdをクリア
distutils.dir_util._path_created = {}

# 改めてdistutils.dir_utilでcopy_tree
copy_tree('c:/tmp01/test_org', 'c:/tmp01/test01')
print(path.exists('c:/tmp01/test01/test'))
# -> True
```


## 所感

嵌ってた原因をまとめてみると、こんなことで嵌ってるやつが私以外にいるのだろうかと思い始めてきましたが・・・だってそもそも混ぜないだろ普通・・・
from ～ importつかってたからcopy_treeとcopytreeが見分けつかなくなってたとか、無駄に関数分けて見通しが悪くなっていたとか、エラーの内容的に引数がおかしくなったんじゃないかとか疑ったりして解析に時間かかったって言い訳をしつつこの件は忘れよう。

追記：同じことで嵌ってる人がStack Overflowにいた！よかった・・・