ロリポップ! レンタルサーバ相手に(Python で)FTPS

ちょっと久々の大作です。

ロリポップ!、と限定してますが、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()

など。