# # ftplibで作る # FTPミラーアップロードクライアント # Apr 2025 # midoriack.com # from ftplib import FTP_TLS, all_errors, error_perm #import ssl import os import datetime import sys import argparse ENCODING = 'utf8' LOG_INFO = 1 LOG_TRACE = 2 debug_level = LOG_INFO # デバッグログレベル mode_test = False # テストモード follow_symlink = False # シンボリックリンクのリンク先をたどる(デフォルトは無視する) test_mkdir_cnt = 0 # テストモードの架空ディレクトリ作成カウンタ # テストモードで作成したつもりのディレクトリは全て空とするために remote_remove_cnt = 0 # リモート削除ファイルカウンタ remote_upload_cnt = 0 # リモートアップロードファイルカウンタ remote_mkdir_cnt = 0 # リモートディレクトリ作成カウンタ remote_rmdir_cnt = 0 # リモートディレクトリ削除カウンタ local_file_cnt = 0 # ローカルのファイル数カウンタ def debug(level, *args): if debug_level >= level: if mode_test: print("TEST: ", *args) else: print(*args) def cnvtimetoutc(tm): ''' Unix時刻をyyyymmddhhmmssの文字列に変換(UTC) ''' #dt = datetime.datetime.utcfromtimestamp(tm) dt = datetime.datetime.fromtimestamp(tm, datetime.timezone.utc) return f"{dt.year:04}{dt.month:02}{dt.day:02}{dt.hour:02}{dt.minute:02}{dt.second:02}" def remoteOpenFTP(host, user, password): ''' リモートFTPサーバへ接続(TLS) ''' debug(LOG_TRACE, "In. " + sys._getframe().f_code.co_name) try: ftps = FTP_TLS(host, user, password) ftps.encoding = ENCODING ftps.prot_p() #debug(LOG_INFO, ftps.pwd()) #debug(LOG_INFO, ftps.retrlines('LIST')) except all_errors as e: remoteAbortFTP(sys._getframe().f_code.co_name, None, e) return ftps def remoteCloseFTP(ftps): ''' FTPサーバから切断 ''' ftps.quit() def remoteAbortFTP(funcname, ftps, e): ''' FTPサーバから切断し強制終了 ''' debug(LOG_INFO, "Abort FTP Exception at", funcname + "():", e) if ftps: ftps.quit() sys.exit() def remoteGetList(ftps): ''' リモートの現在ディレクトリのファイルリストを取得 {Filename:(IsDir, Timestamp), Filename:(..), ..} IsDir: True/False Timestamp: UTC (string yyyymmddhhmmss) ''' remotelist = {} if mode_test and test_mkdir_cnt > 0: debug(LOG_TRACE, f"test_mkdir_cnt={test_mkdir_cnt}") return remotelist try: #lst = ftps.mlsd('./') #for entry in lst: for entry in ftps.mlsd('./'): if entry[0][0] != '.': # except dotfile typ = entry[1]['type'] if typ == 'dir' or typ == 'file': remotelist[entry[0]] = ((typ == 'dir'), entry[1]['modify']) except error_perm as e: # vsftpdはMLSDコマンドをサポートしていない debug(LOG_INFO, "Warning: FTP server does not supprted MLSD, so all files will be uploaded") try: lst = [] ftps.dir(lst.append) for entry in lst: isdir = True if entry.split(" ")[0][0] == "d" else False # TBD dir()から得るのタイムスタンプの書式からyymmddhhmmssへのパースが未実装 remotelist[entry.split()[-1]] = (isdir, "197001010000") except all_errors as e: remoteAbortFTP(sys._getframe().f_code.co_name, ftps, e) except all_errors as e: remoteAbortFTP(sys._getframe().f_code.co_name, ftps, e) return remotelist def remoteUpload(filename, tms, ftps): ''' リモートへファイルをアップロード tms: タイムスタンプ(アップロードファイルのタイムスタンプを設定する) ''' global remote_upload_cnt debug(LOG_INFO, f"remoteUpload {filename} {tms}") if mode_test: remote_upload_cnt += 1 return try: with open(filename,'rb') as f: ftps.storbinary(f'STOR {filename}', f) try: ftps.sendcmd(f'MFMT {tms} {filename}') except error_perm as e: # vsftpdはMFMTコマンドをサポートしていない pass remote_upload_cnt += 1 except all_errors as e: remoteAbortFTP(sys._getframe().f_code.co_name, ftps, e) def remoteMkdir(dirname, ftps): ''' リモートのディレクトリを作成する ''' global test_mkdir_cnt, remote_mkdir_cnt debug(LOG_INFO, f"remoteMkdir {dirname}") if mode_test: remote_mkdir_cnt += 1 test_mkdir_cnt += 1 debug(LOG_TRACE, f"test_mkdir_cnt={test_mkdir_cnt}") return try: ftps.mkd(dirname) remote_mkdir_cnt += 1 except all_errors as e: remoteAbortFTP(sys._getframe().f_code.co_name, ftps, e) def remoteChdir(dirname, ftps): ''' リモートのカレントディレクトリを変更する ''' global test_mkdir_cnt debug(LOG_TRACE, f"remoteChdir {dirname}") if mode_test and test_mkdir_cnt > 0: if dirname == "..": test_mkdir_cnt -= 1 debug(LOG_TRACE, f"test_mkdir_cnt={test_mkdir_cnt}") return try: ftps.cwd(dirname) except all_errors as e: remoteAbortFTP(sys._getframe().f_code.co_name, ftps, e) def remoteRmdir(dirname, ftps): ''' リモートの(空の)ディレクトリを削除する ''' global remote_rmdir_cnt debug(LOG_INFO, f"remoteRmdir {dirname}") if mode_test: remote_rmdir_cnt += 1 return try: ftps.rmd(dirname) remote_rmdir_cnt += 1 except all_errors as e: remoteAbortFTP(sys._getframe().f_code.co_name, ftps, e) def remoteRmfile(filename, ftps): ''' リモートのファイルを削除する ''' global remote_remove_cnt debug(LOG_INFO, f"remoteRmfile {filename}") if mode_test: remote_remove_cnt += 1 return try: ftps.delete(filename) remote_remove_cnt += 1 except all_errors as e: remoteAbortFTP(sys._getframe().f_code.co_name, ftps, e) ID_ISDIR = 0 ID_MTIME = 1 ID_SIZE = 2 def localGetList(curdir): ''' ローカルのカレントディレクトリのリストを取得 {Filename:(IsDir, Timestamp, Size), Filename:(..), ..} IsDir: True/False Timestamp: UTC (string yyyymmddhhmmss) ''' global local_file_cnt files = {} ls = os.listdir(curdir) for entry in ls: if entry[0] != '.': pathname = os.path.join(curdir, entry) if os.path.islink(pathname): if follow_symlink: st = os.stat(pathname) files[entry] = (os.path.isdir(pathname), cnvtimetoutc(st.st_mtime), st.st_size) local_file_cnt += 1 else: debug(LOG_INFO, pathname + " is symbolic link (ignored)") elif os.path.isfile(pathname): st = os.stat(pathname) files[entry] = (os.path.isdir(pathname), cnvtimetoutc(st.st_mtime), st.st_size) local_file_cnt += 1 elif os.path.isdir(pathname): st = os.stat(pathname) files[entry] = (os.path.isdir(pathname), cnvtimetoutc(st.st_mtime), st.st_size) return files def localChdir(dirname): ''' ローカル側 ディレクトリ移動 ''' try: os.chdir(dirname) except FileNotFoundError as e: raise e debug(LOG_INFO, f"LOCAL={os.getcwd()}") def Traverse(locallist, remotelist, ftps): ''' ディレクトリツリーを再帰的に探索しながらFTPを処理する locallist: localGetList()で取得したローカル側の現在のディレクトリの一覧(dict) remotelist: remoteGetList()で取得したリモート側の現在のディレクトリの一覧(dict) ''' debug(LOG_TRACE, f"0) Traverse") # ローカルの新規・更新 for lc in locallist: debug(LOG_TRACE, f"1) Local={lc}") if not locallist[lc][ID_ISDIR]: # is File? debug(LOG_TRACE, f"1-1) Local={lc} is File") if lc in remotelist: debug(LOG_TRACE, f"1-1) Local={lc} is in remote") lmodtime = locallist[lc][ID_MTIME] rmodtime = remotelist[lc][ID_MTIME] if int(lmodtime) > int(rmodtime): # localに更新あり debug(LOG_TRACE, f"1-1-1) Local={lc} updated") remoteUpload(lc, lmodtime, ftps) else: debug(LOG_TRACE, f"1-1-2) NC") else: debug(LOG_TRACE, f"1-2) Local={lc} is new") lmodtime = locallist[lc][ID_MTIME] remoteUpload(lc, lmodtime, ftps) else: debug(LOG_TRACE, f"1-2) Local={lc} is Dir") if lc not in remotelist: # ディレクトリがリモートに存在しない debug(LOG_TRACE, f"1-2-1) Local={lc} mkdir on remote") remoteMkdir(lc, ftps) localChdir(lc) remoteChdir(lc, ftps) llist = localGetList('./') rlist = remoteGetList(ftps) Traverse(llist, rlist, ftps) localChdir("..") remoteChdir("..", ftps) # ローカルの削除検出 for rt in remotelist: debug(LOG_TRACE, f"2) Remote={rt}") if not remotelist[rt][ID_ISDIR]: # is File? debug(LOG_TRACE, f"2-1) Remote={rt} is File") if rt not in locallist: debug(LOG_TRACE, f"2-1-1) Remote={rt} is not in local") remoteRmfile(rt, ftps) else: debug(LOG_TRACE, f"2-2) Remote={rt} is Dir") if rt not in locallist: debug(LOG_TRACE, f"2-2-1) Remote={rt} is not in local") remoteChdir(rt, ftps) rlist = remoteGetList(ftps) Traverse([], rlist, ftps) remoteChdir("..", ftps) remoteRmdir(rt, ftps) argpar = argparse.ArgumentParser() argpar.add_argument("url", help="FTP Server URL") argpar.add_argument("user", help="User") argpar.add_argument("passwd", help="Password") argpar.add_argument("localpath", help="Local Path") argpar.add_argument("remotepath", help="Remote Path") argpar.add_argument("-t", "--test", help="Test Run (without actual remote changes)", action="store_true") argpar.add_argument("-d", help="-d LOGLEVEL: 1=Info(default), 2=Trace", default=1, type=int) argpar.add_argument("-s", "--symlink", help="follow symlink (by default ignore symlink)", action="store_true") args = argpar.parse_args() mode_test_only = True if args.test else False debug_level = args.d follow_symlink = True if args.symlink else False if follow_symlink: print("Warning:") print("following symlink is not recommended as it may result in unexpected behavior.") yn = input("Do you want to continue?(y/N)") if yn != "y" and yn != "Y": print("Aborted by user") sys.exit() for run in (True, False): mode_test = run print("\n>>>> Pass1: Test Run" if run else "\n>>>> Pass2: Run FTP") remote_remove_cnt = 0 remote_upload_cnt = 0 remote_mkdir_cnt = 0 remote_rmdir_cnt = 0 local_file_cnt = 0 orgdir = os.getcwd() ftps = remoteOpenFTP(args.url, args.user, args.passwd) try: localChdir(args.localpath) except Exception as e: remoteAbortFTP("main", ftps, e) remoteChdir(args.remotepath, ftps) locallist = localGetList('./') remotelist = remoteGetList(ftps) Traverse(locallist, remotelist, ftps) remoteCloseFTP(ftps) localChdir(orgdir) if run: # if Pass1 Test print(f"{local_file_cnt} files in local") print(f"{remote_upload_cnt} files will be uploaded") print(f"{remote_remove_cnt} files will be removed on remote") print(f"{remote_mkdir_cnt} dirs will be maked on remote") print(f"{remote_rmdir_cnt} dirs will be removed on remote") if mode_test_only: break else: yn = input("Would you like to start FTP?(y/N)") if yn != "y" and yn != "Y": print("Aborted by user") break else: print(f"{local_file_cnt} files in local") print(f"{remote_upload_cnt} files uploaded") print(f"{remote_remove_cnt} files removed on remote") print(f"{remote_mkdir_cnt} dirs maked on remote") print(f"{remote_rmdir_cnt} dirs removed on remote")