積年の「shutil.copy はメタデータをコピーしません」の回避策

Pythonには、ファイル操作のための標準ライブラリ `shutil` が用意されていますが、その[ドキュメントの冒頭](https://docs.python.jp/3/library/shutil.html)には**物々しい警告**が書かれています。

> 警告 高水準のファイルコピー関数 (shutil.copy(), shutil.copy2()) でも、ファイルのメタデータの全てをコピーすることはできません。
>
> POSIXプラットフォームでは、これはACLやファイルのオーナー、グループが失われることを意味しています。 Mac OSでは、リソースフォーク(resource fork)やその他のメタデータが利用されません。これは、リソースが失われ、ファイルタイプや生成者コード(creator code)が正しくなくなることを意味しています。 Windowsでは、ファイルオーナー、ACL、代替データストリームがコピーされません。
>
> https://docs.python.jp/3/library/shutil.html


しかし、赤字で警告しているわりに**「ではどうすればいいか」が書かれていない**ため、読む側としては困惑するばかりです。[Python 2.6の頃から](https://docs.python.org/2.6/library/shutil.html)こうのようです。なんだかな〜。

そこで、具体的にはどんなワークアラウンドをとればいいのかを調べてみました。

# TL;DR

1. `shutil.copy`, `shutil.copy2` では、オーナーや日時がコピーされない
1. オーナーは `shutil.chown` でコピーすればOK
1. 最終手段 `subprocess.run(['cp', '-p', src, dst])` で全てをコピーできる（でも、そんな必要ある？）

# どんなメタデータがある？

OS独自・ファイルシステム独自のメタデータを調べだすとキリがなさそうなので、今回は `stat` コマンドで調べられるものだけ考えます。

Dockerで環境を作り `stat` コマンドを実行してみます。

```shell
$ docker run --rm --workdir=/root -it python /bin/bash
root@3d7f163bbb17:~# groupadd foo
root@3d7f163bbb17:~# useradd bar -G foo
root@3d7f163bbb17:~# echo hello > src
root@3d7f163bbb17:~# chown bar:foo src
root@3d7f163bbb17:~# chmod 777 src
root@3d7f163bbb17:~# stat src
  File: src
  Size: 6         	Blocks: 8          IO Block: 4096   regular file
Device: 68h/104d	Inode: 119         Links: 1
Access: (0777/-rwxrwxrwx)  Uid: ( 1000/     bar)   Gid: ( 1000/     foo)
Access: 2018-10-30 03:14:01.198071691 +0000
Modify: 2018-10-30 03:14:01.198071691 +0000
Change: 2018-10-30 03:15:41.571717800 +0000
 Birth: -
```

いわゆるメタデータとしては以下の属性があるようです。

- パーミッション（Access）
- ユーザー（Uid）
- 最終アクセス日時（Access）
- 最終修正日時（Modify）
- 最終状態変更日時（Change）

`Blocks` などはファイルの格納状況についての指標で、コピーの対象ではないので無視します。作成日時（Birth）は、今回は取得できていないので無視します。

# `shutil.copy` はどこまではコピーしてくれるのか

ドキュメントの説明によると、コピー内容は以下のようです。

- `shutil.copy`: ファイル本体 + パーミッション
- `shutil.copy2`: ファイル本体 + stat（パーミッション、最終アクセス時間、最終変更時間、その他）

実際に実行してみると、以下のことが分かります：

- ユーザー（Uid）が、コピーをしたユーザー（root）になっている
- グループ(Gid）が、コピーをしたユーザー（root）になっている
- 最終アクセス日時（Access)が現在時刻になっている
- 最終状態変更日時（Change)が現在時刻になっている

また、`shutil.copy` では最終修正日時（Modify）も現在日時になっています。

```shell
root@3d7f163bbb17:~# python -c 'import shutil; shutil.copy("src", "dst1"); shutil.copy2("src", "dst2")'
root@3d7f163bbb17:~# stat dst1 dst2
  File: dst1
  Size: 6         	Blocks: 8          IO Block: 4096   regular file
Device: 68h/104d	Inode: 597         Links: 1
Access: (0777/-rwxrwxrwx)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2018-10-30 03:18:51.788627495 +0000
Modify: 2018-10-30 03:18:51.788627495 +0000
Change: 2018-10-30 03:18:51.788627495 +0000
 Birth: -
  File: dst2
  Size: 6         	Blocks: 8          IO Block: 4096   regular file
Device: 68h/104d	Inode: 598         Links: 1
Access: (0777/-rwxrwxrwx)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2018-10-30 03:18:51.788627495 +0000
Modify: 2018-10-30 03:14:01.198071691 +0000
Change: 2018-10-30 03:18:51.788627495 +0000
 Birth: -
```

# `cp` コマンドの場合は？

`cp`コマンドでコピーしてみると、ユーザー・グループ・各種日時が変わっています。また、Linux（Posix）では、パーミッションが umask でマスクされるので、これも変更されます（`0o777 & 0o022 == 0o755`）。

```shell
root@3d7f163bbb17:~# cp src dst-cp1
root@3d7f163bbb17:~# stat dst-cp1
  File: dst-cp1
  Size: 6         	Blocks: 8          IO Block: 4096   regular file
Device: 68h/104d	Inode: 604         Links: 1
Access: (0755/-rwxr-xr-x)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2018-10-30 03:24:16.760432208 +0000
Modify: 2018-10-30 03:24:16.760432208 +0000
Change: 2018-10-30 03:24:16.760432208 +0000
 Birth: -
root@3d7f163bbb17:~# umask
0022
```

一方、`-p` オプションをつけると、メタデータもコピーしてくれます。ユーザー、グループ、パーミッション、最終アクセス日時、最終変更日時がコピーされているようです[^1]。

```shell
root@3d7f163bbb17:~# cp -p src dst-cp2
root@3d7f163bbb17:~# stat dst-cp2
  File: dst-cp2
  Size: 6         	Blocks: 8          IO Block: 4096   regular file
Device: 68h/104d	Inode: 605         Links: 1
Access: (0777/-rwxrwxrwx)  Uid: ( 1000/     bar)   Gid: ( 1000/     foo)
Access: 2018-10-30 03:18:51.788627495 +0000
Modify: 2018-10-30 03:14:01.198071691 +0000
Change: 2018-10-30 03:28:58.270658164 +0000
 Birth: -
```

[^1]: よく見ると最終アクセス日時が最初に `stat src` したときと変わっていますが、実際にアクセスがあったので仕方がありません。

# 結局、どうすればいいのか？

調べた結果から以下のことが分かります。

- `shutil.copy` と `shutil.copy2` ユーザー・オーナーをコピーしない
- `shutil.copy` と `shutil.copy2` は各種日時もコピーしない
- `shutil.copy` と `shutil.copy2` は`cp` とも `cp -p` と振る舞いが異なる

そのため、例えばシェルスクリプトを、Pythonで書き直す時、単純に `cp` を `shutil.copy` に置換すると振る舞いが変わってしまいます。

## `cp -p`を完全再現する（日時までもコピーしたい）

[os](https://docs.python.jp/3/library/os.html)や[shutil](https://docs.python.jp/3/library/shutil.html#module-shutil)を見る限り、最終アクセス日時や最終変更日時をコピーする関数はありません。`cp -p` を直接使うしかなさそうです。

```python
import subprocess
subprocess.run(['cp', '-p', 'src', 'dst-workaround-1'])
```

実行して見ると（当たり前ですが）日時もコピーできています。

しかし、ディスクバックアップツールなどの特殊なケース以外は**最終変更日時や最終アクセス日時をコピーする必要はない**と思います。

## ユーザーやグループをコピーする

[shutil.chown](https://docs.python.jp/3/library/os.html#os.chown)を使います。ユーザー・グループは文字列ででもIDででも指定できます。

また、元ファイルのユーザー・グループは `os.stat` で取得できます。

```python
import shutil, os
shutil.copy2('src', 'dst-workaround2')

# ユーザー/グループがあらかじめ分かっている場合。
shutil.chown('dst-workaround2', 'bar', 'foo')

# ユーザー/グループを、元ファイルからコピーする場合
st = os.stat('src')
shutil.chown('dst-workaround2', st.st_uid, st.st_gid)
```

## パーミッションを変更する

`shutil.copy` や `shutil.copy2` はファイルのパーミッションをコピーしてくれますが、パーミッションを変えるには[os.chmod](https://docs.python.jp/3/library/os.html#os.chmod)を使います。

```python
import shutil, os
shutil.copy2('src', 'dst-workaround3')
os.chmod('dst-workaround3', 0x755)
```
また、（あまりないと思いますが）`cp` のように、`umask` を適用したパーミッションにしたいという場合は、`os.umask` で値を取得できます。なお、`os.umask` は [POSIXのumask(2)](https://linuxjm.osdn.jp/html/LDP_man-pages/man2/umask.2.html) の仕様を反映し「値を変更して以前の値を返す」という使いにくいインターフェースになっています。

```python
import shutil, os
shutil.copy('src', 'dst-workaround4')

umask = os.umask(0) # 現在の値を取得するために、適当な値で呼び出す
os.umask(umask) # 値を元に戻す

# パーミッションを umask でマスクする。
mode = os.stat('src').st_mode & ~umask
os.chmod('dst-workaround4', mode)
```