s3とローカルとの間でファイルの簡易同期

s3上のファイルを頻繁に扱う場合の作業方法について

・s3上のファイルをマウントして直接作業する
　同一ディレクトリ上に一時ファイルなどが生成されるような場合に適さない（一時ファイルの更新が重い）

・ローカルで作業して、定期的にAWS CLIのS3 syncで同期
　単純に更新日時を元に同期するので、複数人で編集の競合が発生した場合に困る。

バージョン管理みたいな多機能は望まないが、スクリプトでシンプルに同期&編集競合を検出したい。

ということでスクリプトを書く。

```c#

# -*- coding: utf-8 -*-
import boto3, sys, time, os, datetime, json
import pprint

# 前回同期時の「localファイル一覧」「s3上のファイル一覧」を覚えておく。
# s3上とローカル、「更新日時の新しい方」ではなく、「前回から変更があったか？」で判定させる。(更新日時の新旧は信用しない)
# 誤削除が怖いので、「片方にのみ存在するファイル」について、削除に関する処理は行わない(前回の存在有無に関わらず)。片方にのみ存在する場合は、更新日時と無関係に相手側にも複製。
# ①s3上のファイル:存在 & localファイル:存在しない = ダウンロード(前回の状況に関わらず)
# ②s3上のファイルとlocalファイルが一致 = 処理不要(前回の状況に関わらず)
# ③s3上:前回存在しなかったファイルが存在,local:同名のファイルが存在するが異なる
# 　または逆にlocal:前回存在しなかったファイルが存在,s3:同名のファイルが存在するが異なる = 競合扱い
# ④s3上のファイル:前回から変更 & localファイル:前回と同様 = ダウンロードして上書き
# ⑤s3上のファイル:前回と同様 & localファイル:前回から変更 = アップロードして上書き
# ⑥s3上のファイル:前回から変更 & localファイル:前回から変更 = 編集の競合と判定して別フォルダにダウンロード
# ⑦s3もlocalも前回と一致しているがs3とlocalで異なる（前回正常に同期できておらず中途半端な状態）
# ⑧s3上のファイル:存在しない & localファイル:存在 = アップロード
# 競合フォルダのファイルは、次回競合時に問答無用で上書きするので、連続実行には注意(競合フォルダに残っているから、、、という前提で、既存ファイルを消す運用はしないこと）
AWS_KEY_ID=''
AWS_SECRET_KEY=''
REGION='ap-south-1'
outdir='C:\\work\\s3workdir\\'  # このフォルダ上のファイルを同期する
conflictdir='C:\\work\\conflict\\'  # 競合したファイルの保管先
bucketname='バケット名'
s3_prefix='s3dir/' #s3バケット上の同期先ディレクトリ

if __name__ == '__main__':
    s3_client = boto3.client('s3', aws_access_key_id= AWS_KEY_ID, aws_secret_access_key=AWS_SECRET_KEY, region_name='ap-south-1' )

    
    s3_bucket = boto3.resource('s3', aws_access_key_id= AWS_KEY_ID, aws_secret_access_key=AWS_SECRET_KEY, region_name=REGION ).Bucket(bucketname)
    
    if not os.path.isdir(outdir): os.mkdir(outdir)
    if not os.path.isdir(conflictdir): os.mkdir(conflictdir)

    def get_s3_files():
      s3_files = {}
      for object in s3_bucket.objects.filter(Prefix=s3_prefix):
        if not object.key.endswith("/") and os.path.basename(object.key) != "__lastdata.json": #s3上に"__lastdata.json"は無いはずだが念のため
          s3_files[os.path.basename(object.key)]={"modified":object.last_modified.timestamp()}
      return s3_files

    def get_local_files():
      local_files = {}
      for fname in os.listdir(outdir):
        if fname != "__lastdata.json":
          local_files[fname]={"modified":os.stat(outdir+fname).st_mtime}
      return local_files

    s3_files, local_files = get_s3_files(), get_local_files()

    if "__lastdata.json" in os.listdir(outdir):
      with open(outdir+"__lastdata.json", "r") as json_file:
        lastdata=json.load(json_file)
        s3_files_old, local_files_old = lastdata["s3"], lastdata["local"]
        #pprint.pprint(s3_files_old)
        #pprint.pprint(local_files_old)
    else: s3_files_old, local_files_old = {}, {}

    for s3_file in s3_files:
      s3ftime = s3_files[s3_file]["modified"]
      osfilename = outdir+s3_file
      # print(s3_file +" "+ str(s3ftime) +" "+ str(s3_files_old[s3_file]["modified"])+" "+ str(local_files[s3_file]["modified"])+" "+ str(local_files_old[s3_file]["modified"])) #デバッグ用
      if s3_file not in local_files:  # ①s3上のファイル:新規 & localファイル:存在しない = ダウンロード
        print(s3_file+"をダウンロード(localに存在しない)")
        s3_bucket.download_file(s3_prefix+s3_file, osfilename)
        os.utime(osfilename, (s3ftime, s3ftime))
      elif s3_files[s3_file] == local_files[s3_file]: pass # ②s3上のファイルとlocalファイルが一致 = 処理不要(前回の状況に関わらず)
      elif s3_file not in s3_files_old or s3_file not in local_files_old: # ③s3上:前回存在しなかったファイルが存在,local:同名のファイルが存在するが異なる またはまたは逆にlocal:前回存在しなかったファイルが存在,s3:同名のファイルが存在するが異なる = 競合扱い
        print(s3_file+"が前回と異なりますが、どちらを残すか判定できないため"+conflictdir+"にダウンロードします")
        s3_bucket.download_file(s3_prefix+s3_file, conflictdir+s3_file)
        os.utime(conflictdir+s3_file, (s3ftime, s3ftime))
      elif s3_files[s3_file] != s3_files_old[s3_file] and local_files[s3_file] == local_files_old[s3_file]: # ④s3上のファイル:前回から変更 & localファイル:前回と同様 = ダウンロードして上書き
        print(s3_file+"をダウンロード(s3側変更/ローカル変更なし)：s3old:"+str(datetime.datetime.fromtimestamp(s3_files_old[s3_file]["modified"]))+" s3new"+str(datetime.datetime.fromtimestamp(s3ftime)))
        s3_bucket.download_file(s3_prefix+s3_file, osfilename)
        os.utime(osfilename, (s3ftime, s3ftime))
      elif s3_files[s3_file] == s3_files_old[s3_file] and local_files[s3_file] != local_files_old[s3_file]: # ⑤s3上のファイル:前回と同様 & localファイル:前回から変更 = アップロードして上書き
          print(s3_file+"をアップロード(s3側変更なし/ローカル変更)")
          s3_bucket.upload_file(osfilename,s3_prefix+s3_file)
          # アップロードされたファイル(更新日時はアップロード時点)と手元のファイルの更新日時を一致させるため、手元のファイルの更新日時をs3にあわせて更新
          for i in s3_bucket.objects.filter(Prefix=s3_prefix):
            if os.path.basename(i.key) == s3_file: os.utime(osfilename, (i.last_modified.timestamp(), i.last_modified.timestamp()))
      elif s3_files[s3_file] != s3_files_old[s3_file] and local_files[s3_file] != local_files_old[s3_file]: # ⑥s3上のファイル:前回から変更 & localファイル:前回から変更 = 編集の競合と判定して別フォルダにダウンロード
        print(s3_file+"がs3/ローカル両方で以前と異なるため"+conflictdir+"にダウンロードします")
        s3_bucket.download_file(s3_prefix+s3_file, conflictdir+s3_file)
        os.utime(conflictdir+s3_file, (s3ftime, s3ftime))
      elif s3_files[s3_file] == s3_files_old[s3_file] and local_files[s3_file] == local_files_old[s3_file]: # ⑦s3もlocalも前回と一致しているがs3とlocalで異なる（前回正常に同期できておらず中途半端な状態）
        print(s3_file+"は、s3/localともに前回から変更がありませんが、内容が異なります。"+conflictdir+"にダウンロードします")
        s3_bucket.download_file(s3_prefix+s3_file, conflictdir+s3_file)
        os.utime(conflictdir+s3_file, (s3ftime, s3ftime))
      else: print("ERROR : "+s3_file+"の状態エラー(なにかスクリプトの中ミスってる)")
      if s3_file in local_files: del local_files[s3_file]

    for newfile in local_files: # ⑧s3上のファイル:存在しない & localファイル:存在 = アップロード
      print(newfile+"をアップロード(s3に存在しない)")
      s3_bucket.upload_file(outdir+newfile,s3_prefix+newfile)
      for i in s3_bucket.objects.filter(Prefix=s3_prefix):
        if os.path.basename(i.key) == newfile: os.utime(outdir+newfile, (i.last_modified.timestamp(), i.last_modified.timestamp()))

    with open(outdir+"__lastdata.json", 'w') as outfile:
      json.dump({"s3":get_s3_files(),"local":get_local_files()}, outfile)

```

要改善点：
・ファイルの更新日時の変更を元に検出しているので、「更新日時は変わっていないが中身は変わっている」場合に不整合が生じる
・逆に、更新日時だけ変わっており、中身が変わっていない場合にも、無駄に競合扱いになる。（ダウンロードだけして内容の全比較とか入れれば対応は難しく無さそう。）
・サブディレクトリの再帰的な同期は未実装
・厳密にトランザクションとか処理しているわけでは無いので、同時に複数人が同じファイルの更新を同期仕様としたら厳しい。
・誤削除防止のため、「ファイルを削除する」という処理は行わないようにしているので、ファイルを削除したい場合は、ツールを使っている全員のローカルからも消してもらう必要がある。


ということで、せいぜい4-5人程度の編集者。同じファイルを同時更新する頻度も高くないような環境で、編集競合の軽い防止＆同期の手間削減といった程度の使い方に限られそうです。