【Raspberry Piで作る】外出先で簡単に使える！画像バックアップ用デバイス

こんにちは！

みなさん，こんな悩みはないですか？
「もうそろそろSDカードがいっぱいになる…，でもパソコンにファイルを移動させてる時間はないな」，「運転しながらパソコンは使えないし…」

というわけで，信号待ちの間にできる画像バックアップ用のデバイスを作ります！

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">旅先でのSDカードバックアップ用端末づくりをしようとラズパイ買ったので実現可能か調査せねば</p>&mdash; y_k@7/6ICPC予選,7/7osc18do (@YetAnother_yk) <a href="https://twitter.com/YetAnother_yk/status/1036446465613168640?ref_src=twsrc%5Etfw">September 3, 2018</a></blockquote>
…実現可能か調査してから買ってほしいものです

この記事は [Muroran Institute of Technology Advent Calendar 2018](https://adventar.org/calendars/3589) 10日目の記事です．

## 用意するもの
* Raspberry Pi: 「たまたまセールだった&持っていない」ため「Raspberry Pi 3B+」で作業を進めますが，そんなに性能はいらないと思います
* SDカードリーダー
* 画像ファイルを保存しておくための外部デバイス
* シャットダウン制御用のUSBメモリ

## 出来上がるもの
以下のファイルを自動で吸い出すデバイスを作ります:

* SDカード上の画像

## 実際に作る
### Raspberry Piのセットアップ
1. 手前味噌ではありますが，よろしければこちらを参照ください: [【ヘッドレス】Raspberry Pi 3 セットアップ for macOS](https://qiita.com/y_k/items/2e37583d4a5dcf81dc19)
2. 上に追加して以下を実行:
  
  ```shell
  $ sudo apt install python3-pip
  ```

### 必要なモジュールのインストール

```shell
$ sudo pip3 install pyudev # USBデバイスのUUIDを使うため
```

### 下準備
今回はデバイスのUUIDにより色々と制御を行おうと思うので事前に調べておきます．
以下のコマンドで，UUIDを調べられます:

```shell
$ ls -l /dev/disk/by-uuid
```

その他色々な情報を見る:

```shell
$ udevadm info /dev/sd*1
```

ファイルの移動元のSDカード，移動先のUSBメモリ，Raspberry Piの制御用のUSBメモリのUUIDを調べてメモしておきましょう．

### シャットダウン制御用USBメモリ周り
USBメモリを抜いたら，シャットダウンコマンドを実行するようにします．

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">起動認証USBを作って，そのUSBが刺さっていなければ起動しない(=シャットダウンする)ようにすれば外出先でも使いやすい，コンソールのいらないシャットダウンを提供できそう</p>&mdash; y_k@7/6ICPC予選,7/7osc18do (@YetAnother_yk) <a href="https://twitter.com/YetAnother_yk/status/1036447660373229568?ref_src=twsrc%5Etfw">September 3, 2018</a></blockquote>

まずはスクリプトを管理するディレクトリを作成:

```bash
$ mkdir ~/scripts
```

次にシャットダウンを実行するスクリプトファイルを作成:

```bash
$ nano ~/scripts/shutdown.sh
```
```bash:~/scripts/shutdown.sh
#!/bin/bash
sudo shutdown -h now
```

次に，特定のUSBメモリを抜いたらシャットダウンするrulesファイルを作成:

```bash
$ sudo nano /etc/udev/rules.d/30-shutdown.rules
```
```bash:/etc/udev/rules.d/30-shutdown.rules
ACTION=="remove", \
  SUBSYSTEMS=="usb", \
  ENV{ID_FS_UUID}=="XXXX-XXXX", \
  RUN+="/bin/bash /home/yk/scripts/shutdown.sh"
```

`XXXX-XXXX`はメモしたUSBメモリのUUIDを入れてください．
ファイルを書き込んだら設定を反映するため再起動して，実際にUSBメモリを抜いてみてください．
少しすると無事シャットダウンが完了します．

刺さっていなくても起動するので，認証とは呼べないですが，あまり困らないのでいいことにしましょう．

### 取得元SDカード周り
取得元となるSDカードについては可能な限りその後の拡張性やメンテナンス性も考慮したいものです．
[シャットダウン制御用USBメモリ周り](#シャットダウン制御用usbメモリ周り)と同じ仕様にしたいのですが，SDカード購入する度にrulesファイルを書き換えるのは怠いですし，SDカードの枚数だけファイルの行数が増えたりするのもいただけないです．
そこで今回は[pyudev](https://pyudev.readthedocs.io)ライブラリを用います．

インストール:

```bash
$ sudo apt install python3-pyudev
$ sudo pip3 install pyudev
```

udevを監視しつつ，リストアップされてるUUIDのSDカードが接続されたら，`mount`するように処理します．
まずはUUIDのリストアップ:

```plaintext:~/scripts/source_sd
YYYY-YYYY

```

次に監視と mount するpythonスクリプトを作ります．
mount は subprocess を使ってコマンド実行でもいいですが，[sh](http://amoffat.github.io/sh/index.html)というpythonライブラリがあるようなので利用します（標準ライブラリではないので注意です）．

`sh`ライブラリのインストール:

```bash
$ sudo pip3 install sh
```

そして，マウント・アンマウント周りのスクリプト．
アンマウントはもっといい感じにしようと思っているので，とりあえず`remove`を検知したら`umount`を実行するようにしました．
まぁ，ブチ抜きすると，`sd*`には`remove`が発行されるけど，`sd*1`には発行されない都合で，思った通りには動いていないですが…

```python:~/scripts/main.py
#!/usr/bin/python3

import os, sys, pyudev, sh

source_sd = '/home/yk/scripts/source_sd'

context = pyudev.Context()
monitor = pyudev.Monitor.from_netlink(context)
monitor.filter_by('block')

if not os.path.isfile(source_sd):
  not_exist_source_sd_file()

try:
  for device in iter(monitor.poll, None):
    if 'ID_FS_UUID' in device:
      if not os.path.isfile(source_sd):
        not_exist_source_sd_file()
      with open(source_sd, 'r') as f:
        source_sd_list = [v.strip() for v in f.readlines()]
      if device.get('ID_FS_UUID') in source_sd_list:
        print('{0} is terget!'.format(device.get('ID_FS_UUID')))
        if device.action == 'add':
          if not os.path.isdir(os.path.join('/media', device.get('ID_FS_UUID'))):
            os.mkdir(os.path.join('/media', device.get('ID_FS_UUID')))
          print('{0} is mounting now...'.format(device.get('ID_FS_UUID')))
          sh.mount(device.device_node, os.path.join('/media', device.get('ID_FS_UUID')))
          print('{0} is mounted!'.format(device.get('ID_FS_UUID')))
        if device.action == 'remove':
          # 抜いてからunmountは気持ち悪い仕様ですが，少しお付き合いください (しかも思うように動作しない)
          try:
            sh.mountpoint('-q', os.path.join('/media', device.get('ID_FS_UUID'))).exit_code
          except sh.ErrorReturnCode_1:
            print('{0} is already umounted.'.format(device.get('ID_FS_UUID')))
          else:
            print('{0} is umounting now...'.format(device.get('ID_FS_UUID')))
            sh.umount(os.path.join('/media', device.get('ID_FS_UUID')))
            print('{0} is umounted!'.format(device.get('ID_FS_UUID')))
        print()
      else:
        print('{0} is not terget!'.format(device.get('ID_FS_UUID')))
    else:
      print(device)
      print('--', list(device.children))
except KeyboardInterrupt:
  print('終了します...')
  pass

def not_exist_source_sd_file():
  print('取得元SDカードのリストが存在しません')
  sys.exit()
```

英語の間違いは編集リクエストください，勉強します．

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">次のラズパイはαバージョンまで20%進みました</p>&mdash; y_k@7/6ICPC予選,7/7osc18do (@YetAnother_yk) <a href="https://twitter.com/YetAnother_yk/status/1040448690236743680?ref_src=twsrc%5Etfw">September 14, 2018</a></blockquote>

というわけで，もう少し頑張りましょう…

### 保存先USBデバイス周り
保存先には外付けのSDDを用います（余り物）．
先にPCでexFATにフォーマットしました．

とりあえずマウントしようとするも，ラズパイ(Raspbian)はexFATにデフォルトでは対応していない模様．
なので，まずは必要なパッケージをインストール:

```bash
$ sudo apt install exfat-fuse exfat-utils
```

インストールしたら再起動します．

あとは[取得元SDカード周り](#取得元SDカード周り)と同じ要領です．
まずは保存先デバイスの一覧を作成:

```plaintext:~/scripts/destination_device
ZZZZ-ZZZZ

```

次にスクリプト．
先ほどのコードを次のように編集します:

1. 取得元SDと同様に保存先デバイスのリストの読み込みを追加
2. リストアップされてるデバイスか判定するif文に保存先デバイスの判定も入れる


<details>
<summary>**クリックで展開します**</summary>
<div>

```diff:main.py
import os, sys, pyudev, sh

source_sd = '/home/yk/scripts/source_sd'
+destination_device = '/home/yk/scripts/destination_device'

context = pyudev.Context()
monitor = pyudev.Monitor.from_netlink(context)
monitor.filter_by('block')

if not os.path.isfile(source_sd):
  not_exist_source_sd_file()

if not os.path.isfile(destination_device):
  not_exist_destination_device_file()

try:
  for device in iter(monitor.poll, None):
    if 'ID_FS_UUID' in device:
      if not os.path.isfile(source_sd):
        not_exist_source_sd_file()
+      if not os.path.isfile(destination_device):
+        not_exist_destination_device_file()

      with open(source_sd, 'r') as f:
        source_sd_list = [v.strip() for v in f.readlines()]
+      with open(destination_device, 'r') as f:
+        destination_device_list = [v.strip() for v in f.readlines()]

-      if device.get('ID_FS_UUID') in source_sd_list:
+      if device.get('ID_FS_UUID') in source_sd_list or device.get('ID_FS_UUID') in destination_device_list:
        print('{0} is terget!'.format(device.get('ID_FS_UUID')))
        if device.action == 'add':
          if not os.path.isdir(os.path.join('/media', device.get('ID_FS_UUID'))):
            os.mkdir(os.path.join('/media', device.get('ID_FS_UUID')))
          print('{0} is mounting now...'.format(device.get('ID_FS_UUID')))
          sh.mount(device.device_node, os.path.join('/media', device.get('ID_FS_UUID')))
          print('{0} is mounted!'.format(device.get('ID_FS_UUID')))
        if device.action == 'remove':
          # 抜いてからunmountは気持ち悪い仕様ですが，少しお付き合いください (しかも思うように動作しない)
          try:
            sh.mountpoint('-q', os.path.join('/media', device.get('ID_FS_UUID'))).exit_code
          except sh.ErrorReturnCode_1:
            print('{0} is already umounted.'.format(device.get('ID_FS_UUID')))
          else:
            print('{0} is umounting now...'.format(device.get('ID_FS_UUID')))
            sh.umount(os.path.join('/media', device.get('ID_FS_UUID')))
            print('{0} is umounted!'.format(device.get('ID_FS_UUID')))
        print()
      else:
        print('{0} is not terget!'.format(device.get('ID_FS_UUID')))
    else:
      print(device.action, device)
      print('--', list(device.children))
except KeyboardInterrupt:
  print('終了します...')
  sys.exit()

def not_exist_source_sd_file():
  print('取得元SDカードのリストが存在しません')
  sys.exit()

+ def not_exist_destination_device_file():
+  print('保存先デバイスのリストが存在しません')
+  sys.exit()

```
</div>

無事に，取得元SDも保存先デバイスも自動でマウントされるようになりました😆．

### 実際にファイルを移動
要件は以下の通りです:

* 取得元SDからでも保存先デバイスからでも，どちらが先でも正常に実行できる
* 保存先デバイスは優先順位つき
* 保存先デバイスの空き容量が足りなければ実行しない
* （cpコマンドでファイルが破損するかは知らないけど）コピー時にファイルが破損したらやり直す
* 問題なくコピーに成功したらSDカードからファイルを削除

これらを満たすように作っていきます．

**__私が無能すぎて，ミスがありそうなので要注意です__**

#### 前提
要件として
> 取得元SDからでも保存先デバイスからでも，どちらが先でも正常に実行できる

があります．
なので，

1. 取得元SDカードが挿入されたら，キューに追加
2. 
  * 保存先デバイスがあれば，コピー開始
  * 保存先デバイスがなければ，次のデバイスが挿入されるまで待機

という風に作っていきます．

```python
from queue import Queue

source_device_queue = Queue()
```

```python3
# add source SD
if device.get('ID_FS_UUID') in source_sd_list:
  source_device_queue.put(device.get('ID_FS_UUID'))
check_device(destination_device_list)
```

```python3
class NotEnoughDiskSpaceException(IOError):
  pass

def check_device(destination_device_list):
  print('called check_device().')
  print('destination_device_list: {0}'.format(destination_device_list))
  for destination_uuid in destination_device_list:
    if not os.path.ismount(os.path.join('/media', destination_uuid)):
      print('{0} はマウントされていません'.format(destination_uuid))
      continue
    else:
      print('保存先候補が見つかりました: {0}'.format(destination_uuid))
      print(type(source_device_queue), source_device_queue)
      if not source_device_queue.empty():
        source_uuid = source_device_queue.get_nowait()
        print('移動開始: {0} -> {1}'.format(source_uuid, destination_uuid))
        try:
          move(source_uuid, destination_uuid)
        except NotEnoughDiskSpaceException:
          print('{0} は十分な空き容量がありません.'.format(destination_uuid))
          continue
        else:
          print('移動完了: {0}'.format(source_uuid))
          source_device_queue.task_done()
          if not source_device_queue.empty():
            check_device(destination_device_list)
      else:
        print('待ち取得元はありません．')
        break
  print('finished check_device().')
```
    

#### 移動
```python
from datetime import datetime
import shutil

def move(source, destination):
  print('called move().')
  print('move: {0}->{1}'.format(source, destination))
  if os.path.isfile(os.path.join('/media', source, '.backup_target_dir')):
    with open(os.path.join('/media', source, '.backup_target_dir')) as f1:
      for line in f1:
        time = datetime.now().strftime("%Y%m%d_%H%M%S")
        from_path = os.path.join('/media', source, line.strip(), '')
        to_path = os.path.join('/media', destination)
        if shutil.disk_usage(from_path).used >= shutil.disk_usage(to_path).free:
          raise NotEnoughDiskSpaceException()
        to_path = os.path.join(to_path, '{0}-{1}-{2}'.format(source, time, line.strip().replace('/', '_')))
        sh.cp('-a', from_path, to_path)
        check_md5(from_path, to_path)
        # ファイルの削除処理を入れる
  print('finished move().')
```


#### ミス確認
```python3
def check_md5(from_path, to_path):
  print('called check_md5().')
  s_md5 = {os.path.basename(i[1]): i[0] for i in list(map(lambda x: x.split(), sh.find(from_path, '-type', 'f', '-exec',  'md5sum', '{}', ';').splitlines()))}
  t_md5 = {os.path.basename(i[1]): i[0] for i in list(map(lambda x: x.split(), sh.find(to_path, '-type', 'f', '-exec',  'md5sum', '{}', ';').splitlines()))}

  for k in s_md5.keys():
    print('md5sum({0}): {1}<->{2}, {3}'.format(k, s_md5.get(k), t_md5.get(k), s_md5.get(k) == t_md5.get(k)))
    while not t_md5.get(k) or not s_md5[k] == t_md5[k]:
      if os.path.isfile(os.path.join(to_path, k)):
        os.remove(os.path.join(to_path, k))
      sh.cp('-p', os.path.join(from_path, k), to_path)
      t_md5[k] = sh.md5sum(os.path.join(to_path, k)).splitlines()[0].split()[0]
  print('finished check_md5().')
```

終わるときには全てのファイルが破損なく移動完了しています．

#### 削除
**__ソースコードにミスがあり，間違って大事なファイルが消えたとしても責任は負えません．__**

まずは保存先に残したくないファイルの削除から．

```python3
if os.path.isfile(os.path.join(from_path, '.backup_ignore')):
  with open(os.path.join(from_path, '.backup_ignore')) as f2:
    for rm_file in f2:
      os.remove(os.path.join(to_path, rm_file.strip()))
  os.remove(os.path.join(to_path, '.backup_ignore'))
```

次はバックアップ済みのファイルの削除．

```python3
for f_name in Path(to_path).glob('*'):
  print('rm', os.path.join(from_path, f_name.name))
  os.remove(os.path.join(from_path, f_name.name))
```

#### ファイル移動機能まとめ
以上で除外ファイルも考慮した，ファイル移動機能が完成しました．

<details>
<summary>**クリックでプログラム全文を展開します**</summary>
<div>

```python3
#!/usr/bin/python3

import os, sys, pyudev, sh
import shutil
from pathlib import Path
from datetime import datetime
from queue import Queue


source_sd = '/home/yk/scripts/source_sd'
destination_device = '/home/yk/scripts/destination_device'

context = pyudev.Context()
monitor = pyudev.Monitor.from_netlink(context)
monitor.filter_by('block')

source_device_queue = Queue()

class NotEnoughDiskSpaceException(IOError):
  pass

def main():
  if not os.path.isfile(source_sd):
    not_exist_source_sd_file()

  if not os.path.isfile(destination_device):
    not_exist_destination_device_file()

  try:
    for device in iter(monitor.poll, None):
      if 'ID_FS_UUID' in device:
        if not os.path.isfile(source_sd):
          not_exist_source_sd_file()
        if not os.path.isfile(destination_device):
          not_exist_destination_device_file()

        with open(source_sd, 'r') as f:
          source_sd_list = [v.strip() for v in f.readlines()]
        with open(destination_device, 'r') as f:
          destination_device_list = [v.strip() for v in f.readlines()]

        if device.get('ID_FS_UUID') in source_sd_list or device.get('ID_FS_UUID') in destination_device_list:
          print('{0} is terget!'.format(device.get('ID_FS_UUID')))
          if device.action == 'add':
            if not os.path.isdir(os.path.join('/media', device.get('ID_FS_UUID'))):
              os.mkdir(os.path.join('/media', device.get('ID_FS_UUID')))
            print('{0} is mounting now...'.format(device.get('ID_FS_UUID')))
            sh.mount(device.device_node, os.path.join('/media', device.get('ID_FS_UUID')))
            print('{0} is mounted!'.format(device.get('ID_FS_UUID')))
            if device.get('ID_FS_UUID') in source_sd_list:
              source_device_queue.put(device.get('ID_FS_UUID'))
            check_device(destination_device_list)
          if device.action == 'remove':
            # 抜いてからunmountは気持ち悪い仕様ですが，少しお付き合いください
            if not os.path.ismount(os.path.join('/media', device.get('ID_FS_UUID'))):
              print('{0} is already umounted.'.format(device.get('ID_FS_UUID')))
            else:
              print('{0} is umounting now...'.format(device.get('ID_FS_UUID')))
              sh.umount(os.path.join('/media', device.get('ID_FS_UUID')))
              print('{0} is umounted!'.format(device.get('ID_FS_UUID')))
          print()
        else:
          print('{0} is not terget!'.format(device.get('ID_FS_UUID')))
      else:
        print(device.action, device)
        print('--', list(device.children))
  except KeyboardInterrupt:
    print('終了します...')
    sys.exit()

def not_exist_source_sd_file():
  print('取得元SDカードのリストが存在しません')
  sys.exit()

def not_exist_destination_device_file():
  print('保存先デバイスのリストが存在しません')
  sys.exit()

def check_device(destination_device_list):
  print('called check_device().')
  print('destination_device_list: {0}'.format(destination_device_list))
  for destination_uuid in destination_device_list:
    if not os.path.ismount(os.path.join('/media', destination_uuid)):
      print('{0} はマウントされていません'.format(destination_uuid))
      continue
    else:
      print('保存先候補が見つかりました: {0}'.format(destination_uuid))
      print(type(source_device_queue), source_device_queue)
      if not source_device_queue.empty():
        source_uuid = source_device_queue.get_nowait()
        print('移動開始: {0} -> {1}'.format(source_uuid, destination_uuid))
        try:
          move(source_uuid, destination_uuid)
        except NotEnoughDiskSpaceException:
          print('{0} は十分な空き容量がありません.'.format(destination_uuid))
          continue
        else:
          print('移動完了: {0}'.format(source_uuid))
          source_device_queue.task_done()
          if not source_device_queue.empty():
            check_device(destination_device_list)
      else:
        print('待ち取得元はありません．')
        break
  print('finished check_device().')

def move(source, destination):
  print('called move().')
  print('move: {0}->{1}'.format(source, destination))
  if os.path.isfile(os.path.join('/media', source, '.backup_target_dir')):
    with open(os.path.join('/media', source, '.backup_target_dir')) as f1:
      for line in f1:
        time = datetime.now().strftime("%Y%m%d_%H%M%S")
        from_path = os.path.join('/media', source, line.strip(), '')
        to_path = os.path.join('/media', destination)
        if shutil.disk_usage(from_path).used >= shutil.disk_usage(to_path).free:
          raise NotEnoughDiskSpaceException()
        to_path = os.path.join(to_path, '{0}-{1}-{2}'.format(source, time, line.strip().replace('/', '_')))
        sh.cp('-a', from_path, to_path)
        check_md5(from_path, to_path)
        if os.path.isfile(os.path.join(from_path, '.backup_ignore')):
          with open(os.path.join(from_path, '.backup_ignore')) as f2:
            for rm_file in f2:
              os.remove(os.path.join(to_path, rm_file.strip()))
          os.remove(os.path.join(to_path, '.backup_ignore'))
        for f_name in Path(to_path).glob('*'):
          print(f_name)
          print('rm', os.path.join(from_path, f_name.name))
          os.remove(os.path.join(from_path, f_name.name))
  print('finished move().')

def check_md5(from_path, to_path):
  print('called check_md5().')
  s_md5 = {os.path.basename(i[1]): i[0] for i in list(map(lambda x: x.split(), sh.find(from_path, '-type', 'f', '-exec',  'md5sum', '{}', ';').splitlines()))}
  t_md5 = {os.path.basename(i[1]): i[0] for i in list(map(lambda x: x.split(), sh.find(to_path, '-type', 'f', '-exec',  'md5sum', '{}', ';').splitlines()))}

  for k in s_md5.keys():
    print('md5sum({0}): {1}<->{2}, {3}'.format(k, s_md5.get(k), t_md5.get(k), s_md5.get(k) == t_md5.get(k)))
    while not t_md5.get(k) or not s_md5[k] == t_md5[k]:
      if os.path.isfile(os.path.join(to_path, k)):
        os.remove(os.path.join(to_path, k))
      sh.cp('-p', os.path.join(from_path, k), to_path)
      t_md5[k] = sh.md5sum(os.path.join(to_path, k)).splitlines()[0].split()[0]
  print('finished check_md5().')

if __name__ == "__main__":
  main()
```

</div>

### 最後に
ひとまず，SDカードのバックアップができるようになりました！
今後，Go Proについても対応予定ですのでよろしくお願いします．

リモコンでの操作とかもできるようにしたいですね！


## 参考文献など
- [Stack Overflow: python: how to get uuid of a device using udev](https://stackoverflow.com/questions/6928566/how-to-get-uuid-of-a-device-using-udev)
- [Raspberry Pi 3 &amp; Python 開発ブログ☆彡: USB挿入でプログラムを実行する方法](http://www.raspberrypirulo.net/entry/2017/01/21/USB%E6%8C%BF%E5%85%A5%E3%81%A7%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%A0%E3%82%92%E5%AE%9F%E8%A1%8C%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95)
- [pyudev](https://pyudev.readthedocs.io)
- [sh](http://amoffat.github.io/sh/index.html)
- [もた日記: Pythonメモ : 「sh」パッケージでコマンド実行](https://wonderwall.hatenablog.com/entry/2017/07/30/083000)
- [Qiita: Raspberry Piで快適コンピューターライフ(NAS構築編)](https://qiita.com/3no3_tw/items/9baf638cdfbc986cba4f)
- [Python 3.6.5 ドキュメント: 17.7. queue — 同期キュークラス](https://docs.python.jp/3/library/queue.html)
- [エンジニアの入り口: md5sumコマンドについて詳しくまとめました 【Linuxコマンド集】](https://eng-entrance.com/linux-command-md5sum)
- [yuyunko&#39;s blog: lsでディレクトリのみ，ファイルのみ，表示する](http://yuyunko.hatenablog.com/entry/20110304/1299218939)
- [Python 3.6.5 ドキュメント: 11.10. shutil — 高水準のファイル操作](https://docs.python.jp/3/library/shutil.html)