ちょっと久々の大作です。
ロリポップ!、と限定してますが、FTPS(*)をサポートするサーバであれば、似たようなものと思います。
Contents
ロリポップ! 相手に Python で FTPS
前置き
FTPではなくFTPS
生の ftp のニーズが「いまどき」ない、とは言いません。インターネットから適切に隔離された環境(例えば専用線)での業務系では稀にいまでも現役で活躍する場は、ないではないです。けどね。もはや素の ftp は、「目の敵」と言わんばかりに根絶させられてることが多いわけだわね。多くの「セキュリティに無頓着でない」サーバ保守は、ftp のポートを閉じることから始めたりするわけだわ。だので、大抵は「使えないことを前提に」考えることが多いわけだわ。
さて。「ロリポップ!」に関して、ですが。
最初、公式の説明からはこう理解してたのね:
1. 「コロリポプラン」「ロリポプラン」では ftps は使えない
2. 「チカッパプラン」「ビジネスプラン」で ftps が使える
けど、試してみて ftps 出来て、あれれ、と思って、プラン比較表を見直してみたら、どうやらアタシは、ssh 可否と ftps 可否を混同していたらしい。ftps は全てのプランで使えます。よかったよかった。
で。たとえ「素のftpが使える」としても、だ。使えるのなら、ftps 使いたいわけですね。
「ロリポップ! FTP」と WinSCP
ロリポップ! では、WEB ベースの ftp クライアント「ロリポップ! FTP」を、全てのプランで使えます。https なわけで、暗号化通信で、安心は安心なんだけれども、お世辞にも使いやすいとは言えず、まぁロリポップ! の公式見解としても、「大量ファイルや大きなファイルのやりとりにはほかのFTPクライアント使いなはれ」てことになっておる。
実際問題としては、「ロリポップ! FTP」は「大量ファイルや大きなファイルのやりとり」がなくても十分に使いにくくて、その理由ってば、「フォントがプロポーショナルフォントなので、テキストファイルの維持管理には全く向かない」とかそんなしょうもないことだったりもします。
で、そんなのやだぁ、と、Windows からだったら WinSCP とか使いたいわけなんだけれども、WinSCP は FTPS をサポートしていない。WinSCP がサポートしてるのは SFTP(FTP tunneling through SSH) ね。
「お気楽フリーソフト(特に GUI)」などを使わずに Python でやりたいのはなんでぢゃ?
基本的には「バッチ的に」というところと「自分だけにはとても便利」というカスタマイズをしたいのであろう、ということ。そうでしょ?
従って、ここでのゴールは「単に普通の FTP(S) クライアントと同等のものを賄うこと」で終わらせずに、例えば「tar.bz2に固めてアーカイブする」にしよう。
また、この投稿は、基本的には「貴方自身が貴方のためだけの」バッチを作るための情報として意図するもの。つまりは順を追って説明するだもんで、ある種「かったりぃ」です。であるから…、結論だけ知りたいせっかちな人は、この投稿の最後だけ読んでもよろしいよ。(ソフトウェア技術者でない限り、普通はそんなんだと思うし、ソフトウェア技術者だって、結局自分の本業と無関係なゾーンのもんなんか、本気になりたかねーでしょ。)
なんで FTPS、アゲイン
念を押しておきます。あのね、もし ssh 使えるなら、「FTP系」にあんまし固執しない方がいいと思う。例えばロリポップ!前提の場合、チカッパプランなら ssh 使えるので、ということは、scp が使えるわけで、ワタシなら putty とか WinSCP を素直に使うと思うね。
大前提
当然ながら Python が必要。unix/linux ユーザには説明不要と思うので、説明しません。Windows ユーザは公式サイトからどうぞ。(2.7系か 3.x 系かはまだ色々微妙で、2.7系のほうがまだやや安心ですが、この投稿の内容については 3.x でも OK です。)
Python本体だけで良いです、が、本物のサーバ相手に動かす前に検証したい人は、「Is there a public secure FTP site for testing?」「Windowsで気軽なftpsサーバ、兼、オンデマンドなftpsサーバ」も読んでみてね。後者の場合はいくつか別途インストールが必要なものがあります。
基礎的な検証の集積
まずは対話的に動かしてトライしてみるのが Python の定石
公式サイトのドキュメントをそのまんま真似てみるのである:
1 >>> from ftplib import FTP_TLS
2 >>> ftps = FTP_TLS("ftp.yourserver.jp")
3 >>> ftps.login("your-account.name", "yourpassword")
4 '230 User your-account.name logged in.'
5 >>> ftps.prot_p() # switch to secure data connection
6 '200 Protection set to Private'
7 >>> ftps.retrlines('LIST') # list directory content securely
8 drwx---r-x 7 your-account.name SomebodyUser 4096 May 16 05:27 .
9 drwx---r-x 7 your-account.name SomebodyUser 4096 May 16 05:27 ..
10 ...
11 drwxr-xr-x 2 your-account.name SomebodyUser 4096 Apr 1 17:40 images
12 -rw----r-- 1 your-account.name SomebodyUser 28070 May 8 23:56 index.html
13 ...
14 drwxr-xr-x 5 your-account.name SomebodyUser 4096 Jun 11 11:25 site-hhs
15 '226 Transfer complete'
16 >>> ftps.quit()
17 '221 Goodbye.'
18 >>>
quit()の前にあれこれ動かしてみるといい。
当然のことであるが、ここで話が終わってしまったら、「ただただ面倒くさい、なんだよ、Python、ダメじゃねーか」になるす。実際に手を動かして試してみた人はわかるはず。完成品の ftp クライアントを使うのに較べて結構ストレス溜まると思う。忘れないでね、この投稿のゴールは、「貴方のためだけに便利なバッチを作る」こと。
しばらく変更しない部分を仮で「凍結」しとく
一から対話的に試して、「ここはしばらく変えそうにない」部分と、「ここを色々変える」部分があることがわかると思う。
上の例の場合、「ftps.retrlines('LIST')
」部分が、「ごにょごにょ色々やりたいとこ」であるはずである。ので、最終的なゴールの形も想像しつつ、仮でこんなスクリプトファイルを作ってみる(custom_ftp_tls.py):
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3 from __future__ import unicode_literals
4 from ftplib import FTP_TLS
5 from getpass import getpass
6
7 def main(your_fun):
8 ftps = FTP_TLS("ftp.yourserver.jp")
9 ftps.login("your-account.name", getpass())
10 ftps.prot_p() # switch to secure data connection
11 your_fun(ftps) # ftps.retrlines('LIST')
12 ftps.quit()
パスワードについては、スクリプトファイルに書き込んでおきたくはないので、getpassモジュールを使っておく。
なお、Python 3.2 以降の場合は、以下でも良い:
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3 from __future__ import unicode_literals
4 from ftplib import FTP_TLS
5 from getpass import getpass
6
7 def main(your_fun):
8 with FTP_TLS("ftp.yourserver.jp") as ftps
9 ftps.login("your-account.name", getpass())
10 ftps.prot_p() # switch to secure data connection
11 your_fun(ftps) # ftps.retrlines('LIST')
こうしておいて、custom_ftp_tls.py と同じ場所に行く(*)と、import して使えますよ:
1 Python 2.7.9 (default, Dec 10 2014, 12:28:03) [MSC v.1500 64 bit (AMD64)] on win32
2 Type "help", "copyright", "credits" or "license" for more information.
3 >>> from custom_ftp_tls import main
4 >>> main(lambda ftps: ftps.retrlines("LIST"))
5 Password:
6 drwx---r-x 7 your-account.name SomebodyUser 4096 May 16 05:27 .
7 drwx---r-x 7 your-account.name SomebodyUser 4096 May 16 05:27 ..
8 ...
9 drwxr-xr-x 2 your-account.name SomebodyUser 4096 Apr 1 17:40 images
10 -rw----r-- 1 your-account.name SomebodyUser 28070 May 8 23:56 index.html
11 ...
12 drwxr-xr-x 5 your-account.name SomebodyUser 4096 Jun 11 11:25 site-hhs
13 >>>
よろしい? この状態にしておけば、最初の状態より「色々お試」しやすい、よね?
リモートファイルを一覧しつつそれらの情報を読み解く
前置きで説明した「ゴール」を考えると、まずは「リモートファイルを一覧しつつそれらの情報を読み解きたい」が先決でしょうな。これについては、ftplib.FTP.retrlinesの説明にこんなことが書かれています:
サーバによっては、 MLSD は機械で読めるリストとそれらのファイルに関する情報を受信します。
無論このことは Python の話というよりは FTP サーバがサポートするコマンドの話ね。こういうことが Python のドキュメントにちゃんと書かれてるのは、ありがてーでしょ? 今相手にしてる「ロリポップ!」の場合、こんななりますよ:
1 >>> from custom_ftp_tls import main
2 >>> main(lambda ftps: ftps.retrlines("MLSD"))
3 Password:
4 ...
5 modify=20150611045911;perm=flcdmpe;type=dir;unique=811U3810006;UNIX.group=1000;UNIX.mode=0755;UNIX.owner=911804; site-hhs
6 modify=20150515202751;perm=flcdmpe;type=pdir;unique=811U3810002;UNIX.group=1000;UNIX.mode=0705;UNIX.owner=911804; ..
7 ...
8 modify=20150515202751;perm=flcdmpe;type=cdir;unique=811U3810002;UNIX.group=1000;UNIX.mode=0705;UNIX.owner=911804; .
9 ...
10 modify=20150508145656;perm=adfrw;size=28070;type=file;unique=811U3810AC0;UNIX.group=1000;UNIX.mode=0604;UNIX.owner=911804; index.html
11 ...
12 modify=20150401084019;perm=flcdmpe;type=dir;unique=811U3810645;UNIX.group=1000;UNIX.mode=0755;UNIX.owner=911804; images
13 ...
「サーバによっては」なので、サーバによるのでしょう。で、この「ロリポップ!」FTPサーバが返す情報が、「機械が解読しやすい」のはわかりますかいね? LIST のひとさまが読みやすい形式は、機械が解析するにはあまり向いてなくて、思わぬ特殊ケースに悩まされることが多いんですが、この形式なら簡単そうだろ?
じゃ実際に「簡単に解析」してみよう…と言いたいところだけどその前に。
1 ftps.retrlines('MLSD')
は、これ、イキナリ標準出力に出力しちゃってますな。そして、このままでは応答を Python から再利用可能な形で受け取れてない。これには retrlines の第二引数 callback を使います。今はお試しのために、こんなんしてみました:
1 >>> from custom_ftp_tls import main
2 >>> lines = [] # the container for retriving response
3 >>> main(
4 ... lambda ftps: ftps.retrlines("MLSD",
5 ... lambda line: lines.append(line)))
6 Password:
7 >>> print("\n".join(lines))
8 ...
9 modify=20150611045911;perm=flcdmpe;type=dir;unique=811U3810006;UNIX.group=1000;UNIX.mode=0755;UNIX.owner=911804; site-hhs
10 modify=20150515202751;perm=flcdmpe;type=pdir;unique=811U3810002;UNIX.group=1000;UNIX.mode=0705;UNIX.owner=911804; ..
11 ...
12 modify=20150515202751;perm=flcdmpe;type=cdir;unique=811U3810002;UNIX.group=1000;UNIX.mode=0705;UNIX.owner=911804; .
13 ...
14 modify=20150508145656;perm=adfrw;size=28070;type=file;unique=811U3810AC0;UNIX.group=1000;UNIX.mode=0604;UNIX.owner=911804; index.html
15 ...
16 modify=20150401084019;perm=flcdmpe;type=dir;unique=811U3810645;UNIX.group=1000;UNIX.mode=0755;UNIX.owner=911804; images
17 ...
仮なので匿名関数(lambda)にしてますが、無論最終形では名前付きの関数にすることになるだろうね。
さて、「簡単に解析」ね。セミコロン区切りで、最後のファイル名を除けば「key=value」の形よね。ま、こんなだわ:
1 >>> line = "modify=20150508145656;perm=adfrw;size=28070;type=file;unique=811U3810AC0;UNIX.group=1000;UNIX.mode=0604;UNIX.owner=911804; index.html"
2 >>> spl = line.split(";")
3 >>> fname = spl.pop().strip()
4 >>> infos = dict((a.split("=") for a in spl))
5 >>> fname
6 'index.html'
7 >>> infos
8 {'UNIX.owner': '911804', 'UNIX.mode': '0604', 'modify': '20150508145656', 'perm': 'adfrw', 'UNIX.group': '1000', 'unique': '811U3810AC0', 'type': 'file', 'size': '28070'}
9 >>>
すなわち、簡単に辞書にでける、てわけですな。
ここまでの対話モードでの検証を、ひとまずスクリプトに反映しとくとこうなるね:
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3 from __future__ import unicode_literals
4 from ftplib import FTP_TLS
5 from getpass import getpass
6
7 def mlsd(ftps):
8 lines = [] # the container for retriving response
9 ftps.retrlines(
10 "MLSD",
11 lambda line: lines.append(line))
12 for line in lines:
13 spl = line.split(";")
14 fname = spl.pop().strip()
15 infos = dict((a.split("=") for a in spl))
16 # do something
17
18 def main(your_fun):
19 ftps = FTP_TLS("ftp.yourserver.jp")
20 ftps.login("your-account.name", getpass())
21 ftps.prot_p() # switch to secure data connection
22 your_fun(ftps) # ftps.retrlines('LIST')
23 ftps.quit()
これを対話的に動かしても何も興味深いことは起こらないけど、動かしたいならこうね:
1 >>> from custom_ftp_tls import main, mlsd
2 >>> main(mlsd)
3 Password:
4 >>>
さて、上の「infos」ですが、やりたいことを考えると、実際には
1. ファイルなのかフォルダなのかは知りたい
2. カレント(.)、親(..)は捨てたい
3. モードは欲しいかも(ファイルパーミッション)
くらいかなぁと思う。かなり本格的なものが必要にならない限りは。更新日付を維持したい、とか欲は出てくるかもしれないけどね。
ので、「ワタシだけのための」ものにするには、この3つだけ取り出せば良いだろうね:
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3
4 # ...省略...
5
6 def mlsd(ftps):
7 lines = [] # the container for retriving response
8 ftps.retrlines(
9 "MLSD",
10 lambda line: lines.append(line))
11 files = []
12 dirs = []
13 for line in lines:
14 spl = line.split(";")
15 fname = spl.pop().strip()
16 infos = dict((a.split("=") for a in spl))
17 if infos['type'] == 'file':
18 files.append((fname, infos['UNIX.mode']))
19 elif infos['type'] == 'dir':
20 dirs.append((fname, infos['UNIX.mode']))
21 elif infos['type'] in ('pdir', 'cdir'):
22 # parent dir, current dir (ignore)
23 pass
24 # do something
25 print(files)
26 print(dirs)
27
28 # ...省略...
ところで、「lines」に一旦詰めてから lines でループして files, dirs を作る、てのは、なんだか馬鹿らしいよね。ので、こんなんしときます:
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3
4 # ...省略...
5
6 def mlsd(ftps):
7 files = []
8 dirs = []
9 def _mlsd_each(line):
10 spl = line.split(";")
11 fname = spl.pop().strip()
12 infos = dict((a.split("=") for a in spl))
13 if infos['type'] == 'file':
14 files.append((fname, infos['UNIX.mode']))
15 elif infos['type'] == 'dir':
16 dirs.append((fname, infos['UNIX.mode']))
17 elif infos['type'] in ('pdir', 'cdir'):
18 # parent dir, current dir (ignore)
19 pass
20
21 ftps.retrlines('MLSD', _mlsd_each)
22 # do something
23 print(files)
24 print(dirs)
25
26 # ...省略...
ローカルファイルを一覧しつつそれらの情報を読み解く
リモートファイルを一覧しつつそれらの情報を読み解くのが「ダウンロード」のための準備とするならば、こちらは「アップロード」のための準備、ね。
そうなんだけれど、これは ftplib とは無関係ね。それこそ色々やりかたあります。のちのちの再帰を考えると、os.walk がいいかな:
1 >>> import os
2 >>> for root, dirs, files in os.walk("."):
3 ... for file in files:
4 ... print(os.path.join(root, file))
5 ...
os.walk はちょっと独特で、ディレクトリ(フォルダ)があれば自動的に潜っていきます。ので、「ディレクトリなら潜る」といったことは必要がないです。上のコードの場合、外側のループの「1回」が同じディレクトリ内ね。ので、このタイミングで例えばリモートにディレクトリを掘ったりとかする感じだろうね。
あとはパーミッションの情報ですけど、これは os.stat, stat ね。これで伝わるかなぁ?:
1 >>> import stat
2 >>> import os
3 >>> os.stat("aaa.py")
4 nt.stat_result(st_mode=33206, st_ino=0L, st_dev=0, st_nlink=0, st_uid=0, st_gid=0, st_size=260L, st_atime=1433994538L, st_mtime=1433994606L, st_ctime=1433994474L)
5 >>> stat.S_IMODE(os.stat("aaa.py").st_mode)
6 438
7 >>> "%03o" % stat.S_IMODE(os.stat("aaa.py").st_mode)
8 '666'
9 >>> "%03o" % stat.S_IRUSR
10 '400'
11 >>> "%03o" % stat.S_IRGRP
12 '040'
13 >>> "%03o" % stat.S_IROTH
14 '004'
15 >>> "%03o" % (stat.S_IMODE(os.stat("aaa.py").st_mode) & stat.S_IRUSR)
16 '400'
17 >>> (stat.S_IMODE(os.stat("aaa.py").st_mode) & stat.S_IRUSR) == stat.S_IRUSR
18 True
「アップロード」時にサーバ側でのパーミッションを設定したくて、なおかつローカルのパーミッションをある程度維持したければ、これらを駆使することになりますが、相手は FTP サーバが提供するコマンドなので、S_IMODE とかは実際にはいらんと思う。上ではわかりやすさのためにやってるだけ。
ダウンロード、アップロード、潜る
ひとまず最終ゴールは忘れて、基礎的なやつね。
実はダウンロードについてはこの記事でこっそりやってるんだけれど、画像の中だったりもするんでね、改めて。
まずリモートのカレントディレクトリのブツを持ってくるとこから。MLSDで一覧してるとこから直接行ってみます?:
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3 from __future__ import unicode_literals
4 from ftplib import FTP_TLS
5 from getpass import getpass
6
7 def mlsd(ftps):
8 files = []
9 dirs = []
10 def _mlsd_each(line):
11 spl = line.split(";")
12 fname = spl.pop().strip()
13 infos = dict((a.split("=") for a in spl))
14 if infos['type'] == 'file':
15 files.append((fname, infos['UNIX.mode']))
16
17 # お試しで直接...
18 # download to local cwd
19 with open(fname, "wb") as fo:
20 ftps.retrbinary("RETR %s" % fname, fo.write)
21 elif infos['type'] == 'dir':
22 dirs.append((fname, infos['UNIX.mode']))
23 elif infos['type'] in ('pdir', 'cdir'):
24 # parent dir, current dir (ignore)
25 pass
26
27 ftps.retrlines('MLSD', _mlsd_each)
28 # do something
29 #print(files)
30 #print(dirs)
31
32 # 唐突ですが、もはや「main()」の価値はないので構造変更
33 if __name__ == '__main__':
34 ftps = FTP_TLS("ftp.yourserver.jp")
35 ftps.set_debuglevel(2) # 効果がわかりにくいのでいったん冗長にね
36 ftps.login("your-account.name", getpass())
37 ftps.prot_p() # switch to secure data connection
38 mlsd(ftps) # ftps.retrlines('LIST')
39 ftps.quit()
if __name__ == '__main__':
は Python の定石で、「モジュールとしての利用ではなくスクリプトのメインとして実行された場合」の意味。先に作った main 関数が意味をなさなくなってきているので、今のうちにこうしておきます。以後対話的な検証では __main__ 部分は動かないので、同じことをしたければ全く同じ入力をする必要がある反面、「スクリプト」としてバッチ起動出来ます:
1 me@host: ~$ python custom_ftp_tls.py
2 Password:
3 *cmd* 'AUTH TLS'
4 ...(省略)...
これだけでもう「ログインして入ったディレクトリ(ホーム)のファイル全てダウンロード」ツールにはなってますな。ここで満足する人も結構いるんじゃない?
なお、retrbinary はいわゆる「バイナリ転送モード」ですんで、改行コードの変換(Unix from/to Windows)がどうしてもやりたければ、retrlines を使います。が説明はしません。(前置きで宣言した通り、「俺々ツール」のための元ネタ、がこの記事の目的ですんでね。至れて尽くせるつもりは、はなからねーです。)
サーバに何がどのように置いてあるかによっては、この時点で「色んなこと」が起こりますが、ひとまず「一つでも何かダウンロード出来たらオッケー」と考えといてください。解決はあとで出てきますんで。
次に、「潜る」のは後回しにして、ローカルのブツをリモートのカレントディレクトリにアップロード。custom_ftp_tls.py も os.walk もいったん忘れて、単一ファイルを置くだけ:
1 >>> from ftplib import FTP_TLS
2 >>> from getpass import getpass
3 >>> ftps = FTP_TLS("ftp.yourserver.jp")
4 >>> ftps.set_debuglevel(2) # 効果がわかりにくいのでいったん冗長にね
5 >>> ftps.login("your-account.name", getpass())
6 Password:
7 *cmd* 'AUTH TLS'
8 ...(省略)...
9 '230 User your-account.name logged in.'
10 >>> fname = "abcd.txt" # このファイルがローカルにいるとして…
11 >>> with open(fname, "rb") as fi:
12 ... ftps.storbinary("STOR %s" % fname, fi) # spellに注意。store でなくて stor ね
13 ...
14 *cmd* 'TYPE I'
15 ...(省略)...
16 '226 Transfer complete'
17 >>> ftps.quit()
18 *cmd* 'QUIT'
19 ...(省略)...
20 '221 Goodbye.'
21 >>>
「アスキー転送モード」についての説明はしない、のはダウンロードのときと一緒ね。storlines を使います、てだけ。あとその場合は、ローカルファイルの open はテキストモードで開いた方がいい(“rb”でなく”r”とする(かモード指定自体省略する))。
さて、潜るか…、の前に。アップロードするなんてテストをするとさ、リモートを汚しちゃうだろ? だからこのタイミングで、削除も知っといたらいい:
1 >>> from ftplib import FTP_TLS
2 >>> from getpass import getpass
3 >>> ftps = FTP_TLS("ftp.yourserver.jp")
4 >>> ftps.login("your-account.name", getpass())
5 Password:
6 '230 User your-account.name logged in.'
7 >>> ftps.prot_p() # switch to secure data connection
8 '200 Protection set to Private'
9 >>> ftps.delete("abcd.txt") # 今さっきアップロードしたばかりのヤツね
10 '250 DELE command successful'
先走っときますが、アップロードで潜るということは、つまりは(多分)ディレクトリを「掘る」必要があるはずです。ということは、「テストで汚れる」のディレクトリバージョンが出来上がり。ディレクトリの削除は rmd です。
さて。いよいよ潜りましょ。
1 >>> from ftplib import FTP_TLS
2 >>> from getpass import getpass
3 >>> ftps = FTP_TLS("ftp.yourserver.jp")
4 >>> ftps.login("your-account.name", getpass())
5 Password:
6 '230 User your-account.name logged in.'
7 >>> ftps.prot_p() # switch to secure data connection
8 '200 Protection set to Private'
9 >>> ftps.cwd("site-hhs") # リモートに site-hhs というディレクトリがあるとして
10 '250 CWD command successful'
11 >>> ftps.dir() # これまで説明してこなかったですが一番手軽なリストね
12 drwxr-xr-x 5 your-account.name SomebodyUser 4096 Jun 16 13:57 .
13 drwx---r-x 7 your-account.name SomebodyUser 4096 Jun 16 15:54 ..
14 ...
15 -rw-r--r-- 1 your-account.name SomebodyUser 418 Sep 25 2013 index.php
16 ...
17 >>>
ローカルで「潜る」のはいいよね? os.walk が勝手に潜ってくれます。
リモートで「掘る」のは mkd ね:
1 >>> from ftplib import FTP_TLS
2 >>> from getpass import getpass
3 >>> ftps = FTP_TLS("ftp.yourserver.jp")
4 >>> ftps.login("your-account.name", getpass())
5 Password:
6 '230 User your-account.name logged in.'
7 >>> ftps.prot_p() # switch to secure data connection
8 '200 Protection set to Private'
9 >>> ftps.mkd("__test")
10 '/__test'
11 >>> ftps.dir("__test")
12 drwxr-xr-x 2 your-account.name SomebodyUser 4096 Jun 16 16:10 .
13 drwx---r-x 8 your-account.name SomebodyUser 4096 Jun 16 16:10 ..
14 >>> ftps.rmd("__test") # 汚しちゃいやん
15 '250 RMD command successful'
16 >>> ftps.quit()
17 '221 Goodbye.'
18 >>>
ローカルで「掘る」には、これはいくつかあって、最もプリミティブなものは os.mkdir、あるいはちょっと高級な distutils.dir_util の create_tree とかかね。
あとはね、ファイルのパーミッション、なんだけれど、Unix⇔Windows という関係の場合は、基本的には割り切ってください。正確無比にミラーすることは原理的に不可能ですんで。Unix⇔Unix であっても、割り切る、というよりは、「サーバ側でより厳しく」したいだろうから、「完全に同じパーミッション」にするのって、案外ニーズないと思うよ(皆無とは言わないけど)。リモートから持ってくる場合は例えばこんな:
1 import os
2 # ...
3
4 # infos はこの記事で既出の辞書
5 os.chmod(fname, int(infos['UNIX.mode'], 8)) # 8進数ね
Windows NT4 以降のファイルパーミッションは、仕掛けとしては本当は POSIX のものよりも遥かに複雑で厳しい(厳重)んですが、「POSIXもどき」としてのパーミッションは完全に「お飾り」で、読み取り専用かどうか、の制御くらいしか出来ません。なんでだぁ、と叫んだって仕方がなくて、諦めるしかないよ。
アップロード時にパーミッションを操作したい場合は結構あるでしょうね。ただし、chmod は歴史的事情により「ftp サーバにとって必須のコマンドではない」という位置付けになるため、ftplib にも chmod コマンドはないですし、サーバコマンド、としては、「非標準コマンド(site コマンド)」になってます。ので、サーバコマンドを voidcmd で送信しちゃります:
1 >>> ftps.voidcmd("site chmod %03o %s" % (
2 ... (os.stat("aaa.py").st_mode & 0777), fname))
基礎が揃ったのでいよいよ「俺々バッチ」(まずはベース部分)
一通りの基礎は揃えたけれど、「あとでいーんじゃね?」は、「俺様」にとってはあります。「今のアタシ」には、必要なのは「ダウンロード + アーカイブ」であり、また、「パーミッションはあとでいーや」なのです。ので、以後はこの目標だけに従います。つまり、
1. ダウンロードだけ出来ればいい
2. ダウンロードしたものは tar.bz2 で固める
3. パーミッションは TODO LATER で別に困らぬ
こういった決断を適宜やればいいんです。「俺々バッチ」なんだからさ。
「俺々バッチ」にとっての「ベース俺々」(バージョンゼロ)
毎度「お決まりのコード」をさ、ぱたぱたと一行一行やらんといけんわけではねーでしょ。お決まり部分は「我ながら」整理しとこう。
現時点でのコードから察するに、お決まり部分は:
1. FTP_TLSを構築
2. (必要な場合は set_debuglevel)
3. login
4. prot_p
5. quit (または close)
てことだろ。あと、せっかく浮かした mlsd も、ヘルパーとして活用したいよね。
それから、上の方で「Python 3.2 では」として紹介した「with とともに」も、正直言ってしまえば「Python 2.7 でもそうしたい」のだね。
しかるに、「FTP_TLSもどき」なラッパーを作りたいのです:
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3 from __future__ import unicode_literals
4 from ftplib import FTP_TLS
5
6 #
7 # CUSTOM_FTP_TLSの最初のバージョン
8 #
9 class CUSTOM_FTP_TLS(object):
10
11 #
12 def __init__(
13 self, host, user, passwd, debuglevel=0):
14
15 self._ftps = FTP_TLS(host)
16 self._ftps.set_debuglevel(debuglevel)
17 self._ftps.login(user, passwd)
18 self._ftps.prot_p()
19
20 def __enter__(self):
21 return self
22
23 def __exit__(self, type, value, tb):
24 try:
25 self._ftps.quit()
26 except:
27 pass
28
29 def mlsd(self):
30 files = []
31 dirs = []
32 def _mlsd_each(line):
33 spl = line.split(";")
34 fname = spl.pop().strip()
35 infos = dict((a.split("=") for a in spl))
36 if infos['type'] == 'file':
37 files.append((fname, infos['UNIX.mode']))
38 elif infos['type'] == 'dir':
39 dirs.append((fname, infos['UNIX.mode']))
40 elif infos['type'] in ('pdir', 'cdir'):
41 # parent dir, current dir (ignore)
42 pass
43
44 self._ftps.retrlines('MLSD', _mlsd_each)
45 return files, dirs
1 >>> from custom_ftp_tls import CUSTOM_FTP_TLS
2 >>> from getpass import getpass
3 >>> with CUSTOM_FTP_TLS("ftp.yourserver.jp", "your-account.name", getpass()) as ftps:
4 ... ftps.mlsd()
5 ... ftps._ftps.dir() # やぁねぇ…
6 ...
7 Password:
8 ...
9 >>>
まずはファーストバージョンとしてはまずまずなんだけれど、「やぁねぇ」部分はほんとにやだろ?
ので、こうする:
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3 from __future__ import unicode_literals
4 from ftplib import FTP_TLS
5
6 #
7 #
8 #
9 class CUSTOM_FTP_TLS(object):
10
11 #
12 def __init__(
13 self, host, user, passwd, debuglevel=0):
14
15 self._ftps = FTP_TLS(host)
16 self._ftps.set_debuglevel(debuglevel)
17 self._ftps.login(user, passwd)
18 self._ftps.prot_p()
19
20 def __enter__(self):
21 return self
22
23 def __exit__(self, type, value, tb):
24 try:
25 self._ftps.quit()
26 except:
27 pass
28
29 def __getattr__(self, attrname):
30 # CUSTOM_FTP_TLS で明示的に公開していない属性を要求されると
31 # 呼ばれる。(いまのばあい、mlsd では呼ばれず、quit では呼ばれる。)
32 return getattr(self._ftps, attrname)
33
34 def mlsd(self):
35 files = []
36 dirs = []
37 def _mlsd_each(line):
38 spl = line.split(";")
39 fname = spl.pop().strip()
40 infos = dict((a.split("=") for a in spl))
41 if infos['type'] == 'file':
42 files.append((fname, infos['UNIX.mode']))
43 elif infos['type'] == 'dir':
44 dirs.append((fname, infos['UNIX.mode']))
45 elif infos['type'] in ('pdir', 'cdir'):
46 # parent dir, current dir (ignore)
47 pass
48
49 self._ftps.retrlines('MLSD', _mlsd_each)
50 return files, dirs
1 >>> from custom_ftp_tls import CUSTOM_FTP_TLS
2 >>> from getpass import getpass
3 >>> with CUSTOM_FTP_TLS("ftp.yourserver.jp", "your-account.name", getpass()) as ftps:
4 ... ftps.mlsd()
5 ... ftps.dir() # さっきより気分よろし
6 ...
7 Password:
8 ...
9 >>>
「サーバから強制切断」問題をどうにかせねばならぬ
上の方でちょいと予告した、「色々問題が起こる」のうちの一つ。
検証を少しずつ現実的なものに適用させていくと、この問題にぶち当たります。「現実的な」とは、要するに「リモートで実際に再帰して全てのファイルをダウンロードしてやろう」ってことを実際にやりはじめるてこと。一定時間接続を続けていると、サーバ側が勝手に接続を打ち切ってしまう。無論こういった振る舞いはサーバ依存です。ロリポップ!の場合、どうも5分程度で強制切断されているらしい。
この問題が厄介なのは、ftplib 層がコトの本質を隠してしまい、「本当に起こっていることとは別の見え方」が起こるってこと。しかもややこしいことに、Windows と Unix ファミリで違う。「サーバから強制切断されました」ってふうに言われりゃ苦労はせんのよ。これ、
1. Windows の場合、socket.error がエラーコード 10013 とともに起こる
2. Unix 系の場合、EOFError が起こる
です。10013 ってのは英語では「An attempt was made to access a socket in a way forbidden by its access permissions.」、日本語では「アクセス許可で禁じられた方法でソケットにアクセスしようとしました。」てなもの。意味ワカランよね。
この例外ってばさ、「あらゆるコマンド(pwdとかでさえ)で起こる」のが、「俺々バッチの状態管理」にとって頭痛の種なんですけど、理解出来ます? つまりさ、「再接続・再ログイン」したとしてだな、リカバリポイントがわかんなくなっちゃうでしょう。のでね、概ね構造としてはこんな感じに変えるのです:
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3 from __future__ import unicode_literals
4 from ftplib import FTP_TLS
5
6 #
7 #
8 #
9 class CUSTOM_FTP_TLS(object):
10
11 #
12 def __init__(
13 self, host, user, passwd, debuglevel=0):
14
15 self._host = host
16 self._user = user
17 self._passwd = passwd
18 self._debuglevel = debuglevel
19
20 self._ftps = None
21 self._pwd = "/"
22 # CUSTOM_FTP_TLS オブジェクトの内部状態としてストアした
23 # アカウント情報でもって何度も自動的にリログインしたいので、
24 # login メソッドを非公開として内部で「勝手に呼び出す」
25 # ようにしておく
26 self._login()
27
28 # __enter__、__exit__ は省略
29
30 #
31 def pwd(self):
32 # リモートの working directory を常に意識しておかないと、
33 # サーバの強制切断を見舞われてのちに、どこから復活すれば
34 # いいのかわからなくなる。ために内部状態として _pwd に
35 # ストアする、が、FTP_TLS のインターフェイスと同じにしたい
36 # ので、pwd() メソッドも提供。
37 return self._pwd
38
39 #
40 def _login(self):
41 self._ftps = FTP_TLS(self._host)
42
43 self._ftps.set_debuglevel(self._debuglevel)
44 self._ftps.login(self._user, self._passwd)
45 self._ftps.prot_p()
46 self.cwd(self._pwd)
47
48 #
49 def __getattr__(self, attrname):
50
51 def _proxy(*args, **kwarg):
52 import socket # for socket.error
53 retry = False
54 try:
55 res = getattr(self._ftps, attrname)(*args, **kwarg)
56 if attrname == "cwd":
57 # リモートで最後に成功した cwd 結果を維持。
58 self._pwd = self._ftps.pwd()
59 except socket.error as e:
60 if "10013" not in repr(e):
61 raise
62 # The socket.error with code 10013 would occur
63 # in Windows (WinSock). This does mean actually
64 # that socket object had been reused after closed
65 # by the host.
66 retry = True
67 except EOFError as e:
68 # The EOFError would occur in Unix. This does mean
69 # actually that socket object had been reused after
70 # closed by the host.
71 retry = True
72 if retry:
73 # 自動でログインからやりなおす
74 self._login()
75 res = getattr(self._ftps, attrname)(*args, **kwarg)
76 if attrname == "cwd":
77 self._pwd = self._ftps.pwd()
78 return res
79
80 return _proxy
81
82 def mlsd(self):
83 # 省略
ちょいと急激に複雑になって混乱するかもしれないけれど、呼び出し側目線で説明書くとこんな感じです:
1 from custom_ftp_tls import CUSTOM_FTP_TLS
2 from getpass import getpass
3 ftps = CUSTOM_FTP_TLS("ftp.yourserver.jp", "your-account.name", getpass())
4 ftps.cwd("/site-hhs")
5 # ↑CUSTOM_FTP_TLSクラスには cwd メソッドがないので、 __getattr__ が呼び出される。
6 # __getattr__ は、 _proxy オブジェクト(呼び出し可能オブジェクト)を返す。
7 # 結果、呼び出し側目線としては、 _proxy("/site-hhs") を呼び出すことになる。
ちょいとアレですな、コードの重複が多いので、今のうちもうちょっと整理しとこうね:
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3 # ...省略...
4
5 #
6 #
7 #
8 class CUSTOM_FTP_TLS(object):
9 # ...省略...
10
11 #
12 def __getattr__(self, attrname):
13
14 def _proxy(*args, **kwarg):
15 def _call_fn(fn):
16 ret = fn(*args, **kwarg)
17 if attrname == "cwd":
18 # リモートで最後に成功した cwd 結果を維持。
19 self._pwd = self._ftps.pwd()
20 return ret
21
22 import socket # for socket.error
23 retry = False
24 try:
25 res = _call_fn(getattr(self._ftps, attrname))
26 except socket.error as e:
27 if "10013" not in repr(e):
28 raise
29 # The socket.error with code 10013 would occur
30 # in Windows (WinSock). This does mean actually
31 # that socket object had been reused after closed
32 # by the host.
33 retry = True
34 except EOFError as e:
35 # The EOFError would occur in Unix. This does mean
36 # actually that socket object had been reused after
37 # closed by the host.
38 retry = True
39 if retry:
40 # 自動でログインからやりなおす
41 self._login()
42 res = _call_fn(getattr(self._ftps, attrname))
43 return res
44
45 return _proxy
46
47 # ...省略...
「ファイル名のエンコーディング」問題をどうにかせねばならぬ
さっきのでも、日本語ファイル名を含まないなら十分に完成度高いんだけれどもね、日本語ファイル名が含まれてると、場合によっては結構大変なことになりす。ちぅか大変です。
場合によってうまくいく…のは、「世界が全て LATAN-1 ならば」の場合。んなわけねーわけでな。
最近のサーバなら、多分サーバ側のエンコーディングは utf-8 でしょう。Windows はいまでも cp932 だろうね。(Windows 8 以降の状況をワタシは知らないの。)
これについては、さすがに逐一説明してると大変なので、もうイキナリ答えを挙げちゃいます:
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3 # ...省略...
4
5 #
6 #
7 #
8 class CUSTOM_FTP_TLS(object):
9 # ...省略...
10
11 #
12 #
13 def _login(self):
14 self._ftps = FTP_TLS(self._host)
15
16 # FTP_TLSの公開属性 encoding に、ホスト側のエンコーディングを
17 # 伝える
18 self._ftps.encoding = "utf-8"
19
20 self._ftps.set_debuglevel(self._debuglevel)
21 self._ftps.login(self._user, self._passwd)
22 self._ftps.prot_p()
23 self.cwd(self._pwd)
24
25
26 #
27 def __getattr__(self, attrname):
28 # python 2.7 での対処と python 3.x での対処が微妙に違うので、
29 # それを吸収するために、ここではヘルパー関数の _encode、_decode
30 # を作っておく。(hasattr が全て「"decode"」で問い合わせてるのは
31 # 誤りではないです。python 3.x の「str」が decode メソッドを
32 # 持たないことを調べてる。)
33
34 def _encode(s):
35 if hasattr(s, "decode"): # for python2.x
36 return s.encode("utf-8")
37 return s
38
39 def _decode(s):
40 try:
41 if hasattr(s, "decode"): # for python2.x
42 return s.decode("utf-8")
43 except UnicodeError:
44 # maybe already decoded
45 pass
46 return s
47
48 def _proxy(*args, **kwarg):
49 # FTP_TLS メソッドへ渡す文字列をエンコードして渡す
50 _args = [_encode(arg) for arg in args]
51 _kwarg = dict((k, _encode(kwarg[k])) for k in kwarg)
52
53 def _call_fn(fn):
54 # FTP_TLS メソッドのうち、callback 引数を取る
55 # 「ASCIIモード関数」については、callback に
56 # わたることになる文字列も、プログラムが都合の
57 # 良い unicode に「デコード」してしまう。
58 if hasattr("", "decode") and (
59 attrname == "retrlines" and len(_args) == 2): # for python2.x:
60
61 origcallback = _args[1]
62 def _callback_proxy(line):
63 origcallback(_decode(line))
64 _args[1] = _callback_proxy
65 ret = fn(*_args, **_kwarg)
66 if attrname == "cwd":
67 self._pwd = _decode(self._ftps.pwd())
68 return ret
69
70 import socket # for socket.error
71 retry = False
72 try:
73 res = _call_fn(getattr(self._ftps, attrname))
74 except socket.error as e:
75 if "10013" not in repr(e):
76 raise
77 # The socket.error with code 10013 would occur
78 # in Windows (WinSock). This does mean actually
79 # that socket object had been reused after closed
80 # by the host.
81 retry = True
82 except EOFError as e:
83 # The EOFError would occur in Unix. This does mean
84 # actually that socket object had been reused after
85 # closed by the host.
86 retry = True
87 if retry:
88 self._login()
89 res = _call_fn(getattr(self._ftps, attrname))
90 # 結果が文字列の場合にはデコードして返す
91 return _decode(res)
92
93 return _proxy
94
95 # ...省略...
無論、「utf-8」というリテラルを何度も登場させるのはアホなので、CUSTOM_FTP_TLS が状態変数として維持することにしようね:
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3 # ...省略...
4
5 #
6 #
7 #
8 class CUSTOM_FTP_TLS(object):
9 #
10 def __init__(
11 self, host, user, passwd,
12 host_encoding="utf-8", debuglevel=0):
13
14 self._host = host
15 self._user = user
16 self._passwd = passwd
17 self._host_encoding = host_encoding #
18 self._debuglevel = debuglevel
19
20 self._ftps = None
21 self._pwd = "/"
22 self._login()
23
24 # ...省略...
25
26 #
27 def _login(self):
28 self._ftps = FTP_TLS(self._host)
29
30 self._ftps.encoding = self._host_encoding #
31 self._ftps.set_debuglevel(self._debuglevel)
32 self._ftps.login(self._user, self._passwd)
33 self._ftps.prot_p()
34 self.cwd(self._pwd)
35
36 #
37 def __getattr__(self, attrname):
38
39 def _encode(s):
40 if hasattr(s, "decode"): # for python2.x
41 return s.encode(self._host_encoding) #
42 return s
43
44 def _decode(s):
45 try:
46 if hasattr(s, "decode"): # for python2.x
47 return s.decode(self._host_encoding) #
48 except UnicodeError:
49 # maybe already decoded
50 pass
51 return s
52
53 def _proxy(*args, **kwarg):
54 _args = [_encode(arg) for arg in args]
55 _kwarg = dict((k, _encode(kwarg[k])) for k in kwarg)
56
57 def _call_fn(fn):
58 if hasattr("", "decode") and (
59 attrname == "retrlines" and len(_args) == 2): # for python2.x:
60
61 origcallback = _args[1]
62 def _callback_proxy(line):
63 origcallback(_decode(line))
64 _args[1] = _callback_proxy
65 ret = fn(*_args, **_kwarg)
66 if attrname == "cwd":
67 self._pwd = _decode(self._ftps.pwd())
68 return ret
69
70 import socket # for socket.error
71 retry = False
72 try:
73 res = _call_fn(getattr(self._ftps, attrname))
74 except socket.error as e:
75 if "10013" not in repr(e):
76 raise
77 # The socket.error with code 10013 would occur
78 # in Windows (WinSock). This does mean actually
79 # that socket object had been reused after closed
80 # by the host.
81 retry = True
82 except EOFError as e:
83 # The EOFError would occur in Unix. This does mean
84 # actually that socket object had been reused after
85 # closed by the host.
86 retry = True
87 if retry:
88 self._login()
89 res = _call_fn(getattr(self._ftps, attrname))
90 return _decode(res)
91
92 return _proxy
93
94 # ...省略...
「俺々バッチ」にとっての「ベース俺々」ひとまず満足品
ここまでのもので、全体ではこうなってます、今:
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3 from __future__ import unicode_literals
4 from ftplib import FTP_TLS
5
6
7 # ----------------------------------------------------------
8 #
9 # core utilities
10 #
11
12 #
13 #
14 #
15 class CUSTOM_FTP_TLS(object):
16
17 #
18 def __init__(
19 self, host, user, passwd, topdir="/",
20 host_encoding="utf-8", debuglevel=0):
21
22 self._host = host
23 self._user = user
24 self._passwd = passwd
25 self._host_encoding = host_encoding
26 self._debuglevel = debuglevel
27
28 self._ftps = None
29 self._pwd = topdir
30 self._login()
31
32 def __enter__(self):
33 return self
34
35 def __exit__(self, type, value, tb):
36 try:
37 self._ftps.quit()
38 except:
39 pass
40
41 #
42 def pwd(self):
43 return self._pwd
44
45 #
46 def _login(self):
47 self._ftps = FTP_TLS(self._host)
48
49 self._ftps.encoding = self._host_encoding
50 self._ftps.set_debuglevel(self._debuglevel)
51 self._ftps.login(self._user, self._passwd)
52 self._ftps.prot_p()
53 self.cwd(self._pwd)
54
55 #
56 def __getattr__(self, attrname):
57
58 def _encode(s):
59 if hasattr(s, "decode"): # for python2.x
60 return s.encode(self._host_encoding)
61 return s
62
63 def _decode(s):
64 try:
65 if hasattr(s, "decode"): # for python2.x
66 return s.decode(self._host_encoding)
67 except UnicodeError:
68 # maybe already decoded
69 pass
70 return s
71
72 def _proxy(*args, **kwarg):
73 _args = [_encode(arg) for arg in args]
74 _kwarg = dict((k, _encode(kwarg[k])) for k in kwarg)
75
76 def _call_fn(fn):
77 if hasattr("", "decode") and (
78 attrname == "retrlines" and len(_args) == 2): # for python2.x:
79
80 origcallback = _args[1]
81 def _callback_proxy(line):
82 origcallback(_decode(line))
83 _args[1] = _callback_proxy
84 ret = fn(*_args, **_kwarg)
85 if attrname == "cwd":
86 self._pwd = _decode(self._ftps.pwd())
87 return ret
88
89 import socket # for socket.error
90 retry = False
91 try:
92 res = _call_fn(getattr(self._ftps, attrname))
93 except socket.error as e:
94 if "10013" not in repr(e):
95 raise
96 # The socket.error with code 10013 would occur
97 # in Windows (WinSock). This does mean actually
98 # that socket object had been reused after closed
99 # by the host.
100 retry = True
101 except EOFError as e:
102 # The EOFError would occur in Unix. This does mean
103 # actually that socket object had been reused after
104 # closed by the host.
105 retry = True
106 if retry:
107 self._login()
108 res = _call_fn(getattr(self._ftps, attrname))
109 return _decode(res)
110
111 return _proxy
112
113 def mlsd(self):
114 files = []
115 dirs = []
116 def _mlsd_each(line):
117 spl = line.split(";")
118 fname = spl.pop().strip()
119 infos = dict((a.split("=") for a in spl))
120 if infos['type'] == 'file':
121 files.append((fname, infos['UNIX.mode']))
122 elif infos['type'] == 'dir':
123 dirs.append((fname, infos['UNIX.mode']))
124 elif infos['type'] in ('pdir', 'cdir'):
125 # parent dir, current dir (ignore)
126 pass
127
128 self.retrlines('MLSD', _mlsd_each)
129 return files, dirs
お初登場なのは「topdir」だけですが、わかるよね、これは。ログインしたらどこに潜りたいかを制御したいわけでしょ。
いよいよ本題の俺々
再度。俺様的には
1. ダウンロードだけ出来ればいい
2. ダウンロードしたものは tar.bz2 で固める
3. パーミッションは TODO LATER で別に困らぬ
なのね。だから適用としては、これだけを目指します。ここまでくりゃぁ簡単さ、てなもんで。
まずは、ユーザアカウントをスクリプトに書き込んでるのがイヤでしょう
毎度同じ相手に使いたいんでしょ、たいがい。だから設定ファイルになってたいでしょ。
ftplib は実は netrc という標準的な設定ファイルが使えるんだけどね、これにこだわらなくていいと思う。というのも、「追い出したいのはアカウント情報だけじゃないから」なの。ので、「ConfigParser と json の MIX」が良いかなぁと思う。json なしでも悪くはないんだけれど、「貴方ならではの」拡張をし出すことを考えた場合、json と組み合わせた方が格調高い拡張性が高いのでね。
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3 from __future__ import unicode_literals
4 from ftplib import FTP_TLS
5
6
7 # ----------------------------------------------------------
8 #
9 # core utilities
10 #
11
12 #
13 #
14 #
15 class CUSTOM_FTP_TLS(object):
16 # 省略
17
18
19
20 # ----------------------------------------------------------
21 #
22 # applications
23 #
24
25 #
26 #
27 import os
28 try:
29 from configparser import ConfigParser # Python 3.x
30 except ImportError:
31 from ConfigParser import ConfigParser # Python 2.x
32 import json
33 from getpass import getpass
34 config = ConfigParser()
35 config.read([os.path.splitext(__file__)[0] + ".cfg"])
36
37 if __name__ == '__main__':
38 host = json.loads(config.get("account", "host"))
39 user = json.loads(config.get("account", "user"))
40
41 with CUSTOM_FTP_TLS(
42 host, user,
43 getpass(),
44 topdir="/",
45 host_encoding="utf-8",
46 debuglevel=0) as ftps:
47
48 # do something
49 pass
こんなだね。この書き方の場合は、設定ファイルは、custom_ftp_tls.cfg です。中身はこんなの:
1 [account]
2 host: "ftp.yourserver.jp"
3 user: "your-account.name"
(json 形式なので二重引用符が必要、てことで、「なんだよ面倒くさい」てこともあるかもしれないですけどね、リストや辞書を使いたい場合に効いてきます、json 使っておくと。)
トップディレクトリはきっと毎度違うんだろうな
どこから欲しいか、は、おそらく呼び出しのたんびに違うんだろう、てことで、これは設定ファイルよりはコマンドライン引数に与えたいな、ということで。
コマンドライン引数の扱いにはargparseを使うと良いです。こんなふうにしとこうかね:
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3 # 省略
4
5 # ----------------------------------------------------------
6 #
7 # applications
8 #
9
10 #
11 #
12 import os
13 try:
14 from configparser import ConfigParser # Python 3.x
15 except ImportError:
16 from ConfigParser import ConfigParser # Python 2.x
17 import json
18 from getpass import getpass
19 config = ConfigParser()
20 config.read([os.path.splitext(__file__)[0] + ".cfg"])
21
22 if __name__ == '__main__':
23 import argparse
24 parser = argparse.ArgumentParser()
25 parser.add_argument('-T', '--host-topdir')
26 args = parser.parse_args()
27
28 topdir = args.host_topdir if args.host_topdir else "/"
29
30 host = json.loads(config.get("account", "host"))
31 user = json.loads(config.get("account", "user"))
32
33 with CUSTOM_FTP_TLS(
34 host, user,
35 getpass(),
36 topdir=topdir,
37 host_encoding="utf-8",
38 debuglevel=0) as ftps:
39
40 # do something
41 pass
例えばこう使います:
1 me@host: ~$ python custom_ftp_tls.py -T //site-hhs
「tar.bz2に固めてアーカイブする」ための基本構造
中身を書く前に、基本構造を作ってしまいますか。
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3 # 省略
4
5 # ----------------------------------------------------------
6 #
7 # applications
8 #
9
10 #
11 #
12 import os
13 import tarfile
14 try:
15 from configparser import ConfigParser # Python 3.x
16 except ImportError:
17 from ConfigParser import ConfigParser # Python 2.x
18 import json
19 from getpass import getpass
20 config = ConfigParser()
21 config.read([os.path.splitext(__file__)[0] + ".cfg"])
22
23 #
24 #
25 def download_as_tarfile(topdir):
26 def _download():
27 # 実際に再帰してお取り寄せする子
28
29 files, dirs = ftps.mlsd()
30
31 for finfo in files:
32 fname, mode = finfo
33 # do something
34
35 for dinfo in dirs:
36 fname, mode = dinfo
37
38 ftps.cwd(fname)
39 _download() # 再帰
40 ftps.cwd("..")
41
42 topdir = os.path.normpath(topdir).replace(os.sep, "/")
43
44 host = json.loads(config.get("account", "host"))
45 user = json.loads(config.get("account", "user"))
46
47 with CUSTOM_FTP_TLS(
48 host, user,
49 getpass(),
50 topdir=topdir,
51 host_encoding="utf-8",
52 debuglevel=0) as ftps:
53
54 # 結果のアーカイブはこのファイル名にしたいの
55 tarfnameBase = "%s-%s.tar.bz2" % (
56 host, topdir.replace("/", "_"))
57
58 # 結果のアーカイブを作るす
59 with tarfile.open(
60 tarfnameBase, mode='w:bz2',
61 encoding='utf-8',
62 format=tarfile.PAX_FORMAT) as arch:
63
64 _download()
65
66
67 if __name__ == '__main__':
68 import argparse
69 parser = argparse.ArgumentParser()
70 parser.add_argument('-T', '--host-topdir')
71 args = parser.parse_args()
72
73 topdir = args.host_topdir if args.host_topdir else "/"
74 download_as_tarfile(topdir)
あとね、「俺様的カスタマイズ」にとっては、「この特定のディレクトリ以下はいらーん」は是が非でも必要なのね。設定ファイルが
1 [account]
2 host: "ftp.yourserver.jp"
3 user: "your-account.name"
4
5 [excludes]
6 dir: ["cache", "uploads", "plugins"]
みたいなのだとして、この excludes に合致するフォルダでは潜らないでくれるといいね:
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3 # 省略
4
5 # ----------------------------------------------------------
6 #
7 # applications
8 #
9
10 #
11 #
12 import os
13 import tarfile
14 try:
15 from configparser import ConfigParser # Python 3.x
16 except ImportError:
17 from ConfigParser import ConfigParser # Python 2.x
18 import json
19 from getpass import getpass
20 config = ConfigParser()
21 config.read([os.path.splitext(__file__)[0] + ".cfg"])
22
23 #
24 #
25 def download_as_tarfile(topdir):
26 def _download(exclude_dirs):
27 # 実際に再帰してお取り寄せする子
28
29 files, dirs = ftps.mlsd()
30
31 for finfo in files:
32 fname, mode = finfo
33 # do something
34
35 for dinfo in dirs:
36 fname, mode = dinfo
37 if fname not in exclude_dirs:
38 ftps.cwd(fname)
39 _download(exclude_dirs) # 再帰
40 ftps.cwd("..")
41
42 topdir = os.path.normpath(topdir).replace(os.sep, "/")
43
44 host = json.loads(config.get("account", "host"))
45 user = json.loads(config.get("account", "user"))
46 exclude_dirs = json.loads(config.get("excludes", "dir"))
47
48 with CUSTOM_FTP_TLS(
49 host, user,
50 getpass(),
51 topdir=topdir,
52 host_encoding="utf-8",
53 debuglevel=0) as ftps:
54
55 # 結果のアーカイブはこのファイル名にしたいの
56 tarfnameBase = "%s-%s.tar.bz2" % (
57 host, topdir.replace("/", "_"))
58
59 # 結果のアーカイブを作るす
60 with tarfile.open(
61 tarfnameBase, mode='w:bz2',
62 encoding='utf-8',
63 format=tarfile.PAX_FORMAT) as arch:
64
65 _download(exclude_dirs)
66
67
68 if __name__ == '__main__':
69 import argparse
70 parser = argparse.ArgumentParser()
71 parser.add_argument('-T', '--host-topdir')
72 args = parser.parse_args()
73
74 topdir = args.host_topdir if args.host_topdir else "/"
75 download_as_tarfile(topdir)
「tar.bz2に固めてアーカイブする」、完成品
もう簡単なのよ、あとは。
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3 # 省略
4
5 # ----------------------------------------------------------
6 #
7 # applications
8 #
9
10
11 #
12 #
13 import os
14 try:
15 from configparser import ConfigParser # Python 3.x
16 except ImportError:
17 from ConfigParser import ConfigParser # Python 2.x
18 import json
19 from getpass import getpass
20 import tarfile
21 import tempfile
22 config = ConfigParser()
23 config.read([os.path.splitext(__file__)[0] + ".cfg"])
24
25
26 #
27 #
28 def download_as_tarfile(topdir):
29 def _download(exclude_dirs):
30 files, dirs = ftps.mlsd()
31
32 for finfo in files:
33 fname, mode = finfo
34 # ----------------------------------
35 remote_path = os.path.join(ftps.pwd(), fname).replace(os.sep, "/")
36 local_path = os.path.relpath(remote_path, topdir)
37 with tempfile.NamedTemporaryFile(mode="wb", delete=False) as tf:
38 tempname = tf.name
39 ftps.retrbinary("RETR %s" % remote_path, tf.file.write)
40 arch.add(tempname, arcname=local_path, recursive=False)
41 os.unlink(tempname)
42 # ----------------------------------
43 logging.info("%s -> %s", remote_path, local_path)
44
45 for dinfo in dirs:
46 fname, mode = dinfo
47 if fname not in exclude_dirs:
48 ftps.cwd(fname)
49 _download(exclude_dirs)
50 ftps.cwd("..")
51
52 topdir = os.path.normpath(topdir).replace(os.sep, "/")
53
54 host = json.loads(config.get("account", "host"))
55 user = json.loads(config.get("account", "user"))
56 exclude_dirs = json.loads(config.get("excludes", "dir"))
57
58 with CUSTOM_FTP_TLS(
59 host, user,
60 getpass(),
61 topdir=topdir,
62 host_encoding="utf-8",
63 debuglevel=0) as ftps:
64
65 tarfnameBase = "%s-%s.tar.bz2" % (
66 host, topdir.replace("/", "_"))
67
68 with tarfile.open(
69 tarfnameBase, mode='w:bz2',
70 encoding='utf-8',
71 format=tarfile.PAX_FORMAT) as arch:
72
73 _download(exclude_dirs)
74
75
76 #
77 #
78 #
79 if __name__ == '__main__':
80 import sys
81 logging.basicConfig(stream=sys.stdout, level=logging.INFO)
82
83 import argparse
84 parser = argparse.ArgumentParser()
85 parser.add_argument('-T', '--host-topdir')
86 args = parser.parse_args()
87
88 topdir = args.host_topdir if args.host_topdir else "/"
89 download_as_tarfile(topdir)
新たに説明することはあまりないとは思うけど、強いて言えば tempfile の扱いくらいかね。tarfile のインターフェイスの都合、どうしてもいったんファイルを作る必要があってね、ので、NamedTemporaryFile を使ってる。難しくはないよね。
まとめ
ひとまずスクリプト全文掲載
1 # -*- coding: utf-8 -*-
2 # filename: custom_ftp_tls.py
3 from __future__ import unicode_literals
4 from ftplib import FTP_TLS
5 import logging
6
7
8 # ----------------------------------------------------------
9 #
10 # core utilities
11 #
12
13 #
14 #
15 #
16 class CUSTOM_FTP_TLS(object):
17
18 #
19 def __init__(
20 self, host, user, passwd, topdir="/",
21 host_encoding="utf-8", debuglevel=0):
22
23 self._host = host
24 self._user = user
25 self._passwd = passwd
26 self._host_encoding = host_encoding
27 self._debuglevel = debuglevel
28
29 self._ftps = None
30 self._pwd = topdir
31 self._login()
32
33 def __enter__(self):
34 return self
35
36 def __exit__(self, type, value, tb):
37 try:
38 self._ftps.quit()
39 except:
40 pass
41
42 #
43 def pwd(self):
44 return self._pwd
45
46 #
47 def _login(self):
48 self._ftps = FTP_TLS(self._host)
49
50 self._ftps.encoding = self._host_encoding
51 self._ftps.set_debuglevel(self._debuglevel)
52 self._ftps.login(self._user, self._passwd)
53 self._ftps.prot_p()
54 self.cwd(self._pwd)
55
56 #
57 def __getattr__(self, attrname):
58
59 def _encode(s):
60 if hasattr(s, "decode"): # for python2.x
61 return s.encode(self._host_encoding)
62 return s
63
64 def _decode(s):
65 try:
66 if hasattr(s, "decode"): # for python2.x
67 return s.decode(self._host_encoding)
68 except UnicodeError:
69 # maybe already decoded
70 pass
71 return s
72
73 def _proxy(*args, **kwarg):
74 _args = [_encode(arg) for arg in args]
75 _kwarg = dict((k, _encode(kwarg[k])) for k in kwarg)
76
77 def _call_fn(fn):
78 if hasattr("", "decode") and (
79 attrname == "retrlines" and len(_args) == 2): # for python2.x:
80
81 origcallback = _args[1]
82 def _callback_proxy(line):
83 origcallback(_decode(line))
84 _args[1] = _callback_proxy
85 ret = fn(*_args, **_kwarg)
86 if attrname == "cwd":
87 self._pwd = _decode(self._ftps.pwd())
88 return ret
89
90 import socket # for socket.error
91 retry = False
92 try:
93 res = _call_fn(getattr(self._ftps, attrname))
94 except socket.error as e:
95 if "10013" not in repr(e):
96 raise
97 # The socket.error with code 10013 would occur
98 # in Windows (WinSock). This does mean actually
99 # that socket object had been reused after closed
100 # by the host.
101 retry = True
102 except EOFError as e:
103 # The EOFError would occur in Unix. This does mean
104 # actually that socket object had been reused after
105 # closed by the host.
106 retry = True
107 if retry:
108 self._login()
109 res = _call_fn(getattr(self._ftps, attrname))
110 return _decode(res)
111
112 return _proxy
113
114 def mlsd(self):
115 files = []
116 dirs = []
117 def _mlsd_each(line):
118 spl = line.split(";")
119 fname = spl.pop().strip()
120 infos = dict((a.split("=") for a in spl))
121 if infos['type'] == 'file':
122 files.append((fname, infos['UNIX.mode']))
123 elif infos['type'] == 'dir':
124 dirs.append((fname, infos['UNIX.mode']))
125 elif infos['type'] in ('pdir', 'cdir'):
126 # parent dir, current dir (ignore)
127 pass
128
129 self.retrlines('MLSD', _mlsd_each)
130 return files, dirs
131
132
133 # ----------------------------------------------------------
134 #
135 # applications
136 #
137
138 #
139 #
140 import os
141 try:
142 from configparser import ConfigParser # Python 3.x
143 except ImportError:
144 from ConfigParser import ConfigParser # Python 2.x
145 import json
146 from getpass import getpass
147 import tarfile
148 import tempfile
149 config = ConfigParser()
150 config.read([os.path.splitext(__file__)[0] + ".cfg"])
151
152
153 #
154 #
155 def download_as_tarfile(topdir):
156 def _download(exclude_dirs):
157 files, dirs = ftps.mlsd()
158
159 for finfo in files:
160 fname, mode = finfo
161 remote_path = os.path.join(ftps.pwd(), fname).replace(os.sep, "/")
162 local_path = os.path.relpath(remote_path, topdir)
163 with tempfile.NamedTemporaryFile(mode="wb", delete=False) as tf:
164 tempname = tf.name
165 ftps.retrbinary("RETR %s" % remote_path, tf.file.write)
166 arch.add(tempname, arcname=local_path, recursive=False)
167 os.unlink(tempname)
168 logging.info("%s -> %s", remote_path, local_path)
169
170 for dinfo in dirs:
171 fname, mode = dinfo
172 if fname not in exclude_dirs:
173 ftps.cwd(fname)
174 _download(exclude_dirs)
175 ftps.cwd("..")
176
177 topdir = os.path.normpath(topdir).replace(os.sep, "/")
178
179 host = json.loads(config.get("account", "host"))
180 user = json.loads(config.get("account", "user"))
181 exclude_dirs = json.loads(config.get("excludes", "dir"))
182
183 with CUSTOM_FTP_TLS(
184 host, user,
185 getpass(),
186 topdir=topdir,
187 host_encoding="utf-8",
188 debuglevel=0) as ftps:
189
190 tarfnameBase = "%s-%s.tar.bz2" % (
191 host, topdir.replace("/", "_"))
192
193 with tarfile.open(
194 tarfnameBase, mode='w:bz2',
195 encoding='utf-8',
196 format=tarfile.PAX_FORMAT) as arch:
197
198 _download(exclude_dirs)
199
200
201 #
202 #
203 #
204 if __name__ == '__main__':
205 import sys
206 logging.basicConfig(stream=sys.stdout, level=logging.INFO)
207
208 import argparse
209 parser = argparse.ArgumentParser()
210 parser.add_argument('-T', '--host-topdir')
211 args = parser.parse_args()
212
213 topdir = args.host_topdir if args.host_topdir else "/"
214 download_as_tarfile(topdir)
あなたらしく
ずっと繰り返して言ってきたように、この記事は「俺様さえ良ければいい」「俺様にだけ抜群に便利であればいい」を、読者自身が実践するための基礎を伝えたいがためのものであって、最初から完成品を狙ってません。つまりは、「ここからが本番」と言えるわけね。
まず最初に手を付けたらいいと思うのは、モジュール構成かなぁ。CUSTOM_FTP_TLS だけが「汎用」なのね。残りは全部ただの適用、でしょ。なので、きっとこんな構成が使いやすいんだと思う:
1. custom_ftp_tls.py には CUSTOM_FTP_TLS だけが含まれている
2. downloaders.py なんてのがいて、そこに「色んなタイプのダウンローダがいる」
3. uploaders.py なんてのがいて、そこに「色んなタイプのアップローダがいる」
とかね。
特にアップローダなんですが、ファイルモード(パーミッション)は、ある程度ちゃんとやったほうがいいだろうね。せっかくファイルコンテンツが正しくアップロード出来てるのに、パーミッションの問題で「サーバが正しく動かない」なんてことが、不真面目にやるとすぐに起こるからね。
補足:ひとさまのものたち
あえて触れてこなかったんだけど、実際「ftpに関する便利パッケージ」に類するものって、結構あります。
こういったものを評価するのも良いとは思うんですが、「FTPとftplibの歴史が非常に長い」ことから、「俺様に最も相応しいもの」を見つけるの、結構大変だったりします。せっかく「良さげ」でも、今回のアタシの記事で書いたような問題については「ftplibと同じ」か「より酷い」状態だったりもしてね。ゆえに対処は結局似たようなものが必要だったりするのだわ。
のでね、こんだけいろんなのが入手可能でも、まぁアタシのこの記事は、結構役に立つんではないかと、そう思うよ。
補足2:tarファイルと日本語ファイル名
さらっとスルーしましたが、tar に限らず、「アーカイブファイル内のファイル名エンコーディング」て、古いものほど厄介だったりします。
zip の問題がどの程度有名なのかはわからんのですが、Windows XP 標準の zip と Vista 以降が全く振る舞いが違う上に正しい仕様化もされていないので、困ったりしますね。XP は問答無用でシステムエンコーディング(日本語版 Windows なら cp932)、zip のあるバージョンからは「問答無用で utf-8」という仕様になったので、XP で「utf-8仕様」の zip を開くと文字化けは原理的に不可避。
tar についても基本的にほぼ同じなんだけれど、拡張仕様が出来たので、ややマシです。これについては 12.5.5. Unicode に関する問題で説明されてます。そうなんだけどね、この仕様でアーカイブしたものを、古い標準の tar コマンドで開こうとすると、ファイル名が文字化けします。Windows の MSYS (Unixもどき) に付属の tar でこれ、起こります。
これに困ったら python の tarfile モジュールを使って下さい:
1 import tarfile
2 import sys
3
4 with tarfile.open(sys.argv[1], debug=1) as tf:
5 tf.extractall()
など。