Copy

MIDORIAck ソフトウェア材料の倉庫

Contents ♪

Python FTPクライアント
Python ftplib
Python3は標準としてFTPクライアントのモジュール「ftplib」がインストールされる。
2025.04.29
FTPサーバ 接続・切断
FTPクラスコンストラクタにサーバのホスト(URL、IPアドレス)を指定し呼び出すと、FTPサーバに接続し、成功するとFTPオブジェクトのインスタンスを生成して返す。
FTPオブジェクト = FTP(FTPサーバURL)
FTPオブジェクト = FTP(host='FTPサーバホスト', user='ユーザ名', passwd='パスワード')
userとpasswdをホストに続けて指定すると接続とログイン試行を行う。
userのデフォルトは"anonymous"である。passwdに空文字を指定すると自動生成される。
コンストラクタにユーザとパスワードを指定せずに、login()メソッドでログイン試行できる。
login(user='ユーザ名', passwd='パスワード')
FTPクラスの生成はファイルと同様なwith構文が使える。切断時はquit()メソッドを呼び出すが、with構文ではブロックの終了暗黙に呼び出されて切断と終了処理が行われる。
import ftplib
with ftplib.FTP('ftpserver.url.ne') as ftp:
    ftp.login('user', 'passwd')
    ftp.encoding = 'utf8'
    print(ftp.dir())
    # ftp.quit()        # withでは省略可
encodingプロパティにファイル名のエンコードを指定できる。デフォルトは'utf8'。
2025.04.29
FTPSサーバ 接続・切断(FTP over TLS)
FTPクラスは非暗号化のFTP接続なのでインターネットを経由した用途には向いていない。
ftplibにはTLS/SSLによる暗号化に対応したFTPS(FTP over TLS)のFTP_TLSクラスがある。次のようにFTP_TLSをインポートする。
from ftplib import FTP_TLS
コンストラクタはFTPクラスと同様である。
FTPSオブジェクト = FTP_TLS(FTPサーバURL)
FTPSオブジェクト = FTP_TLS(host='FTPSサーバホスト', user='ユーザ名', passwd='パスワード')
FTPSの場合は接続後にprot_p()メソッドを呼び出して、暗号通信を開始する必要がある。
from ftplib import FTP_TLS
with FTP_TLS('ftpserver.url.ne') as ftp:
    ftp.login('user', 'passwd')
    ftp.encoding = 'utf8'
    ftp.prot_p()
    print(ftp.dir())
2025.04.29
リモートディレクトリ移動 cwd
cwd()メソッドは、リモートのディレクトリを移動する。
cwd(リモートの移動先ディレクトリ)
from ftplib import FTP_TLS
with FTP_TLS('ftpserver.url.ne') as ftp:
    ftp.login('user', 'passwd')
    ftp.encoding = 'utf8'
    ftp.prot_p()
    ftp.cwd('ftpdir')
    print(ftp.dir())
2025.04.29
リモートファイルリスト取得 dir
dir()メソッドは、FTPのLISTコマンドによりリモートのカレントディレクトリのファイルリストを取得し文字列を返す。
リストはls -lのファイルリストのフォーマットが返る。
dir()
from ftplib import FTP_TLS
with FTP_TLS('ftpserver.url.ne') as ftp:
    ftp.login('user', 'passwd')
    ftp.encoding = 'utf8'
    ftp.prot_p()
    ftp.cwd('ftpdir')
    print(ftp.dir())
drwx------   2 midori   midori       4096 Apr 22 04:16 dir2
drwxr-xr-x   3 midori   midori       4096 Apr 22 04:46 dirA
-rw-------   1 midori   midori          9 Apr 22 06:56 test1.txt
-rw-------   1 midori   midori          9 Apr 22 06:56 test2.txt
2025.04.29
リモートファイルリスト取得 mlsd
mlsd()メソッドは、FTPのMLSDコマンドによりリモートのカレントディレクトリのファイルリストを取得し文字列を返す。
FTPのLISTコマンドの結果がlsコマンド表示の写しであるのに対し、MLSDコマンドはファイルの属性をデータとして取得できる。mlsd()メソッドの戻り値はファイル名をキーとする「種類(ファイル・ディレクトリ)、サイズ、タイムスタンプ、その他」の辞書で得られる。
ファイルリスト = mlsd(path='')
path省略時はカレントディレクトリ
ファイルリストは、ファイル名とその属性の辞書が返る。
{ファイル名: {'size': 'サイズ', 'modify': 'YYYYMMDDhhmmss.mmm', 'type': 'タイプ', ..その他..}},
...}
    ('dirname',  {'size': '0', 'modify': '20250423041630', 'type': 'dir'}),
    ('filename', {'size': '92', 'modify': '20250423041812', 'type': 'file'}),

※現実の例
('test1.txt', {'modify': '20250422065628', 'perm': 'adfrw', 'size': '9', 'type': 'file',
'unique': 'FD03U145030', 'unix.group': '1000', 'unix.groupname': 'midori',
'unix.mode': '0600', 'unix.owner': '1000', 'unix.ownername': 'midori'})
from ftplib import FTP_TLS
with FTP_TLS('ftpserver.url.ne') as ftp:
    ftp.login('user', 'passwd')
    ftp.encoding = 'utf8'
    ftp.prot_p()
    ftp.cwd('ftpdir')
    lst = ftp.mlsd()
    for item in lst:
        print(item)
MLSDコマンドをサポートしてないFTPサーバがある。vsftpdはサポートしていない。proftpdはサポートしている。さくらインターネットはサポートしていた。
2025.04.29
ファイルのダウンロード・アップロード
ファイルのダウンロードとアップロードのメソッドは以下がある。
retrlines('RETR file.txt', callback=None)       テキストファイルのダウンロード
retrbinary('RETR file.bin', callback)           バイナリファイルのダウンロード
storlines('STOR file.txt', fp)                  テキストファイルのアップロード
storbinary('STOR file.bin', fp)                 バイナリファイルのアップロード
callback:
    省略時は標準出力。f.writeのように指定すればファイルに書き込む。
    バイナリダウンロードでは必須。
fp:
    アップロードは、対象のファイルを読み出しで開いたファイルオブジェクトを指定する。
from ftplib import FTP_TLS
with FTP_TLS('ftpserver.url.ne') as ftp:
    ftp.login('user', 'passwd')
    ftp.encoding = 'utf8'
    ftp.prot_p()
    ftp.cwd('ftpdir')
    with open('down.txt', 'w') as f:
        ftp.retrlines('RETR remote.txt', f.write)
    with open('down.pdf', 'wb') as f:
        ftp.retrbinary('RETR remote.pdf', f.write)
    with open('local.txt', 'rb') as f:
        ftp.storlines('STOR up.txt', f)
    with open('local.jpg', 'rb') as f:
        ftp.storbinary('STOR up.jpg', f)
2025.04.29
リモートディレクトリ作成・削除 mkd rmd
mkd()、rmd()メソッドはそれぞれリモートのディレクトリを作成、削除する。
rmd()の場合、削除するリモートのディレクトリは空でなければならない。
mkd('ディレクトリ')
rmd('ディレクトリ')
from ftplib import FTP_TLS
with FTP_TLS('ftpserver.url.ne') as ftp:
    ftp.login('user', 'passwd')
    ftp.encoding = 'utf8'
    ftp.prot_p()
    ftp.cwd('ftpdir')
    ftp.mkd('dir1')
    ftp.rmd('dir3')
    print(ftp.dir())
drwxr-xr-x   2 midori   midori       4096 Apr 28 09:35 dir1
drwx------   2 midori   midori       4096 Apr 22 04:16 dir2
drwxr-xr-x   3 midori   midori       4096 Apr 22 04:46 dirA
-rw-------   1 midori   midori          9 Apr 22 06:56 test1.txt
-rw-------   1 midori   midori          9 Apr 22 06:56 test2.txt
2025.04.29
リモートファイル削除 delete
delete()メソッドは、リモートのファイルを削除する。
delete('ファイル')
from ftplib import FTP_TLS
with FTP_TLS('ftpserver.url.ne') as ftp:
    ftp.login('user', 'passwd')
    ftp.encoding = 'utf8'
    ftp.prot_p()
    ftp.cwd('ftpdir')
    ftp.delete('test1.txt')
    print(ftp.dir())
drwxr-xr-x   2 midori   midori       4096 Apr 28 09:35 dir1
drwx------   2 midori   midori       4096 Apr 22 04:16 dir2
drwxr-xr-x   3 midori   midori       4096 Apr 22 04:46 dirA
-rw-------   1 midori   midori          9 Apr 22 06:56 test2.txt
2025.04.29
任意のFTPコマンド送信(リモートタイムスタンプの変更の例)sendcmd
sendcmd()メソッドは、任意のFTPコマンドを送信する。
sendcmd('FTPコマンドライン')
ここではリモートのファイルのタイムスタンプを変更するMFMTコマンドを例にする。MFMTコマンドをラップしたlibftpのメソッドは執筆時点では存在しない。
from ftplib import FTP_TLS
with FTP_TLS('ftpserver.url.ne') as ftp:
    ftp.login('user', 'passwd')
    ftp.encoding = 'utf8'
    ftp.prot_p()
    ftp.cwd('ftpdir')
    ftp.sendcmd('MFMT 20250428163000 test2.txt')
    print(ftp.dir())
-rw-------   1 midori   midori          9 Apr 28 16:30 test2.txt
MFMTコマンドをサポートしてないFTPサーバがある。vsftpdはサポートしていない。proftpdはサポートしている。さくらインターネットはサポートしていた。
2025.04.29
ftplibの例外
ftplibにはメソッド失敗時の例外が定義されている。
例外を捕捉する場合は、例外の定義をインポートする必要がある。
from ftplib import FTP_TLS, all_errors, error_perm
ftplib.all_errorsはftplibの全ての例外を捕捉する。
    try:
        ftps = FTP_TLS(host, user, password)
        ftp.encoding = 'utf8'
        ftps.prot_p()
    except all_errors as e:
        print(e)
error_permは、許可されないコマンドのときに送出される。たとえばMLSDコマンドやMFMTコマンドをサポートしていないFTPサーバに送信すると、error_perm例外になる。
FTPSミラーアップローダー試作
Python3のftplibを使って、FTPS(FTP over TLS)接続によるミラーリングアップローダーを試作しました。
mirror.py
※ インデントはハードタブです。
Pythonのコマンドラインから実行する短いスクリプトです。
2025.04.29
mirror.pyの機能

FTPS接続

FTPS(FTP over TLS)で接続します。
TLS非対応のFTPSサーバには接続できません。

ミラーリング

コマンドラインに指定するローカルディレクトリとリモートディレクトリの中身のツリーが同じになるようにFTPでミラーリングします。
ローカルとリモート(FTPサーバ)の起点のディレクトリから巡回をスタートし、同じディレクトリ階層でファイル一覧を見比べて、リモートがローカルと同じになるようにFTPを実行します。
ローカルとリモートのファイル・ディレクトリの各状態に対する振る舞いは次のようになっています。
種類 ローカル リモート タイムスタンプ FTPオペレーション
ファイル ファイルあり ファイルあり 同じ なにもしない
ファイル ファイルあり ファイルあり リモート新しい なにもしない
ファイル ファイルあり ファイルあり リモート古い アップロード
ファイル ファイルあり ファイルなし  -  アップロード
ファイル ファイルなし ファイルあり  -  リモート削除
ディレクトリ ディレクトリあり ディレクトリあり  -  なにもしない
ディレクトリ ディレクトリあり ディレクトリなし  -  リモートディレクトリ作成
ディレクトリ ディレクトリなし ディレクトリあり  -  リモートディレクトリ削除
変更するのは常にリモート側であり、ダウンロードやローカル側の削除のようなミラー元のローカル側の変更操作はしません

テストラン

実際にリモートを操作せずに、ローカルとリモートを巡回して予定されるアップロードや削除の操作を表示するテストランの処理を実装しました。
デフォルトでは2パスで1パス目でテストラン、ユーザ確認後に2パス目で実際のFTPを実行するようにしています。また、オプションでテストランのみを選択できます。

シンボリックリンクはデフォルト無視

シンボリックリンクはデフォルトでは無視します。
オプションにより無視せずにシンボリックリンクの参照をたどるようにできます。
2025.04.29
使い方
オプションなしで実行すると短いヘルプが表示されます。また-h、--helpオプションで詳細なヘルプが表示されます。
$ python3 mirror.py
usage: mirror.py [-h] [-t] [-d D] [-s] url user passwd localpath remotepath
mirror.py: error: the following arguments are required: url, user, passwd, localpath, remotepath

$ python3 mirror.py --help
usage: mirror.py [-h] [-t] [-d D] [-s] url user passwd localpath remotepath

positional arguments:
  url            FTP Server URL
  user           User
  passwd         Password
  localpath      Local Path
  remotepath     Remote Path

options:
  -h, --help     show this help message and exit
  -t, --test     Test Run (without actual remote changes)
  -d D           -d LOGLEVEL: 1=Info(default), 2=Trace
  -s, --symlink  follow symlink (by default ignore symlink)
url FTPサーバURL、またはIPアドレス
user/passwd FTPサーバのアカウント
localpath/remotepath ミラーするローカルディレクトリパス、リモートディレクトリパス
localpath、remotepathの下からが同じになるようにミラーリングします。
-h --help ヘルプ表示
-t --test テストランのみ(デフォルトはテストラン+本処理の2パス)
-d ログレベル ログ表示レベル。1=Info(デフォルト)、2=トレース 
-s --symlink シンボリックリンクをたどる(デフォルトはシンボリックリンクを無視する)
実際に動かすと、このような感じになります。
$ python3 mirror.py 192.168.122.152 midori midori test ftpdir

>>>> Pass1: Test Run
TEST:  LOCAL=/home/midori/test
TEST:  ./dir1 is symbolic link (ignored)
TEST:  LOCAL=/home/midori/test/dirA
TEST:  LOCAL=/home/midori/test/dirA/dir11
TEST:  LOCAL=/home/midori/test/dirA
TEST:  LOCAL=/home/midori/test
TEST:  LOCAL=/home/midori/test/dir2
TEST:  LOCAL=/home/midori/test
TEST:  remoteUpload test1.txt 20250422041625
TEST:  remoteRmdir dir1
TEST:  LOCAL=/home/midori
3 files in local
1 files will be uploaded
0 files will be removed on remote
0 dirs will be maked on remote
1 dirs will be removed on remote
Would you like to start FTP?(y/N)y

>>>> Pass2: Run FTP
LOCAL=/home/midori/test
./dir1 is symbolic link (ignored)
LOCAL=/home/midori/test/dirA
LOCAL=/home/midori/test/dirA/dir11
LOCAL=/home/midori/test/dirA
LOCAL=/home/midori/test
LOCAL=/home/midori/test/dir2
LOCAL=/home/midori/test
remoteUpload test1.txt 20250422041625
remoteRmdir dir1
LOCAL=/home/midori
3 files in local
1 files uploaded
0 files removed on remote
0 dirs maked on remote
1 dirs removed on remote
$
2025.04.29
実装の説明
Traverse()関数がこの試作の中心になります。
ローカルとリモートを共に再帰的に巡回しながらファイルリストを見比べて、リモート側の差異に対してFTPを実行します。
ディレクトリに入るときに、Traverse()を再帰呼出ししています。
Traverse(ローカルリスト, リモートリスト):
    for ローカルリストを走査
        if それはファイル?
            if リモートにある?
                if ローカルのタイムスタンプが新しい? 
                    そのファイルをアップロード
                else
                    なにもしない
            else ない
                    そのファイルをアップロード
        else それはディレクトリ
            if リモートにある?
                リモートにそのディレクトリ作成
            ローカル・リモート共にそのディレクトリに移動
            ローカル・リモート共にリスト取得
            Traverse(ローカルリスト, リモートリスト)
            ローカル・リモート共に下のディレクトリに移動
    for リモートリストを走査:
        if それはファイル?
            if ローカルにない?
                リモートのそのファイルを削除
        else それはディレクトリ
            if ローカルにない?
                リモートのそのディレクトリに移動
                リモートのリスト取得
                Traverse(空リスト, リモートリスト)
                リモートの下のディレクトリに移動
                リモートのそのディレクトリ削除
2025.04.29
作成環境・テスト環境
Pythonバージョン Python3.13.0
FTPサーバ側テスト実績 さくらインターネットのレンタルサーバー
Linuxのproftpd ※2
Linuxのvsftpd ※1 ※2
クライアント動作実績環境 Ubuntu24.04
Windows10 WSL2
※1 vsftpdはMLSDMFMTコマンド非サポートのため、タイムスタンプの比較による上書きアップロードの判断ができません。したがって、ファイルは毎回全アップロードになるような実装になっています。
※2 Ubuntu20.04とUbuntu22.04上でFTPS対応でサーバを構築。
2025.04.29
注意事項
一般に使われているFTPクライアントは、実用に耐えられるよう異常系を考慮した作りになっており、また、ユーザの操作ミスを未然に防ぐような工夫もされています。それらに比べると当試作の実装は脆弱であります。
mirror.pyの使用は以下の点に注意してください。

リモートを削除しないオプションは実装していない

ローカル側で削除されたファイルを、リモートで削除しないというオプションは実装していません。
ローカルとリモートを再帰的に巡回しながらリモート側をローカルとの差をなくしていくという考え方を基本としています。そこにリモート側を削除しないというオプションを導入すると、双方が異なっていても正しい状態が存在することになります。その差異を放置してよいかどうかを判断するしくみはもう一段階上の問題だろうと考え、今回は見送りました。

指定ミスによるリモートの大量削除の保護は限定的

リモートを削除しないというオプションは実装しなかったので、ローカル側に空のディレクトリを誤って指定すると、リモートも忠実に空になるよう削除してしまいます。
1パス目で実際のリモート操作しないテストランを行い、予定されるオペレーションを表示してから、2パス名の本処理に入るかどうかYes・Noをユーザに確認するようにしており、意図せずリモートが大量に消えてしまう前にユーザが気が付けるようにはしましたが、それ以上の強い保護機能はありません。

中断・エラーからの再開・復帰は考慮していない

例外は捕捉するようにしていますが、それらは異常があれば全て切断して終了するだけにしています。リトライなどは実装はしていません。異常系に力を入れていないので突然エラーで止まるかもしれません。

MLSDとMFMT非サポートのFTPサーバではミラー処理が限定的

MLSDMFMTコマンドに対応したFTPサーバを想定しています。それは、リモートファイルとローカルでタイムスタンプを比較してリモート更新を判定するためです。
それらのコマンドが効かないサーバの場合は、更新判断を飛ばしてファイルは無条件で上書きアップロードとしました。
LISTコマンドのテキストからタイムスタンプに変換する方法も検討しましたが、今回は見送りました。

ファイルの更新判断はタイムスタンプのみ

リモートのファイルをローカルからアップロードして更新すべきかの判断は、双方のタイムスタンプの比較で判定しています。ファイルサイズは比較していません。
2025.04.29
さいごに
ビジネス的に重要な要件には使用をおすすめしません。信頼のあるアプリケーションを使ってください。
作成者自身が普段使っている範囲ではまあまあ使えています。レンタルサーバーへのアップロードやバックアップなどに使っています。
350行程度の短いスクリプトなので改造しやすいと思います。お試しください。