Python实现下载FTP服务器的整个目录(文件夹)

  • Title(EN): Downloading FTP Directory Recursively with Python
  • Author: dog2

需求

  • 快速批量下载多个FTP服务器的上的目录(指定目录或整个目录)。

分析

  • 核心问题:针对一个FTP Server,要能够下载其指定目录。即递归遍历所有目录,针对每个目录中的子目录,创建本地相应目录;针对每个目录中的文件,下载到本地相应目录。
  • 下载所有文件的过程最好也是可并发的,以加快整个下载过程。

前车之轮

需求及实现思路已经清楚,剩下的就是编码测试了。

按照惯例,为避免重复造轮子,在开工前有必要上谷歌百度一下,确定前人是否已经造出了好用的轮子。 找到如下几个:

  1. 通过python下载FTP上的文件夹的实现代码
  2. python实现的ftp自动上传下载程序(支持目录递归操作)
  3. python实现支持目录FTP上传下载文件的方法
  4. python第三方库 ftputil
  5. python第三方库 pyftpsync

逐一测试,都不太好用。

1在现实时使用os.chdir切换本地下载目录,批量下载(多线程并发下载多个服务器目录)时会产生目录混乱。

3在下载每个文件都需要执行ls函数确认其存在于远程目录,性能很低。

使用2 4 5没有成功下载过,可能是用法不对,粗略扫了下文档和源码,也没找到正确用法。如果你知道正确用法,还请指点。

实现

既然前人的轮子都不太好用,只好再造一个。相对其他协议而言,FTP协议还是比较复杂的,因此最好基于已有的FTP库来实现,python的自带FTP库是ftplib,这里就选用它。

  • 文档
  • 源码

大概实现思路及流程已在0x01中指出,这里可能需要用到ftplib中的两个函数

  • ftplib.FTP.dir() : 用于列举目录信息,其内部实现调用的是FTP协议中的LIST请求,输出格式类似linux下的命令_ls -alh_。这里需要注意,默认情况下,该函数是不返回目录信息的字符串的,它在内部实现中调用了ftplib.FTP.retrlines()函数得到返回数据的每一行,并默认使用println函数处理每行数据,即打印至标准输出。当然,处理每行数据的函数是可以被替换的,在dir()函数的参数中指出即可。尽管如此还是难以将目录信息存入到一个字符串并返回,以供我们后续调用。在0x02中提到的3是通过是给dir函数传入自定义类的实例函数,并将目录信息存储在自定义类的实例变量中来得到这个值的。当然,也可以通过自定义函数结合全局变量的方式来得到这个值。这种实现略显蹩脚,因此在这里,我们参照原有dir函数的实现方式,在自定义类中实现一个新的dir函数,它不用再传入处理每行数据的函数,且能够返回目录信息。
  • ftplib.FTP.retrbinary() : 用于指定并发送某种FTP请求,并以二进制数据接收响应。这里使用它来执行FTP协议中的RETR命令,以下载FTP服务器上指定路径的文件。

代码

实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
#!/usr/bin/env python
# encoding: utf-8

import os
import sys
import ftplib
import traceback


class FtpDownloader(object):
PATH_TYPE_UNKNOWN = -1
PATH_TYPE_FILE = 0
PATH_TYPE_DIR = 1

def __init__(self, host, user=None, passwd=None, port=21, timeout=10):
self.conn = ftplib.FTP(
host=host,
user=user,
passwd=passwd,
timeout=timeout
)

def dir(self, *args):
'''
by defualt, ftplib.FTP.dir() does not return any value.
Instead, it prints the dir info to the stdout.
So we re-implement it in FtpDownloader, which is able to return the dir info.
'''
info = []
cmd = 'LIST'
for arg in args:
if arg:
cmd = cmd + (' ' + arg)
self.conn.retrlines(cmd, lambda x: info.append(x.strip().split()))
return info

def tree(self, rdir=None, init=True):
'''
recursively get the tree structure of a directory on FTP Server.
args:
rdir - remote direcotry path of the FTP Server.
init - flag showing whether in a recursion.
'''
if init and rdir in ('.', None):
rdir = self.conn.pwd()
tree = []
tree.append((rdir, self.PATH_TYPE_DIR))

dir_info = self.dir(rdir)
for info in dir_info:
attr = info[0] # attribute
name = info[-1]
path = os.path.join(rdir, name)
if attr.startswith('-'):
tree.append((path, self.PATH_TYPE_FILE))
elif attr.startswith('d'):
if (name == '.' or name == '..'): # skip . and ..
continue
tree.extend(self.tree(rdir=path,init=False)) # recurse
else:
tree.append(path, self.PATH_TYPE_UNKNOWN)

return tree

def downloadFile(self, rfile, lfile):
'''
download a file with path %rfile on a FTP Server and save it to locate
path %lfile.
'''
ldir = os.path.dirname(lfile)
if not os.path.exists(ldir):
os.makedirs(ldir)
f = open(lfile, 'wb')
self.conn.retrbinary('RETR %s' % rfile, f.write)
f.close()
return True

def treeStat(self, tree):
numDir = 0
numFile = 0
numUnknown = 0
for path, pathType in tree:
if pathType == self.PATH_TYPE_DIR:
numDir += 1
elif pathType == self.PATH_TYPE_FILE:
numFile += 1
elif pathType == self.PATH_TYPE_UNKNOWN:
numUnknown += 1
return numDir, numFile, numUnknown


def downloadDir(self, rdir='.', ldir='.', tree=None,
errHandleFunc=None, verbose=True):
'''
download a direcotry with path %rdir on a FTP Server and save it to
locate path %ldir.
args:
tree - the tree structure return by function FtpDownloader.tree()
errHandleFunc - error handling function when error happens in
downloading one file, such as a function that writes a log.
By default, the error is print to the stdout.
'''
if not tree:
tree = self.tree(rdir=rdir, init=True)
numDir, numFile, numUnknown = self.treeStat(tree)
if verbose:
print 'Host %s tree statistic:' % self.conn.host
print '%d directories, %d files, %d unknown type' % (
numDir,
numFile,
numUnknown
)

if not os.path.exists(ldir):
os.makedirs(ldir)
ldir = os.path.abspath(ldir)

numDownOk = 0
numDownErr = 0
for rpath, pathType in tree:
lpath = os.path.join(ldir, rpath.strip('/').strip('\\'))
if pathType == self.PATH_TYPE_DIR:
if not os.path.exists(lpath):
os.makedirs(lpath)
elif pathType == self.PATH_TYPE_FILE:
try:
self.downloadFile(rpath, lpath)
numDownOk += 1
except Exception as err:
numDownErr += 1
if errHandleFunc:
errHandleFunc(err, rpath, lpath)
elif verbose:
print 'An Error occurred when downloading '\
'remote file %s' % rpath
traceback.print_exc()
print
if verbose:
print 'Host %s: %d/%d/%d(ok/err/total) files downloaded' % (
self.conn.host,
numDownOk,
numDownErr,
numFile
)
elif pathType == self.PATH_TYPE_UNKNOWN:
if verbose:
print 'Unknown type romote path got: %s' % rpath

if verbose:
print 'Host %s directory %s download finished:' % (
self.conn.host, rdir
)
print '%d directories, %d(%d failed) files, %d unknown type.' % (
numDir,
numFile,
numDownErr,
numUnknown
)
return numDir, numFile, numUnknown, numDownErr


if __name__ == '__main__':
import sys
import traceback
from pprint import pprint as pr

flog = open('err.log', 'wb')

def run(host):
try:
fd = FtpDownloader(
host=host,
user='test',
passwd='test',
port=21,
timeout=10
)
numDir, numFile, numUnknown, numDownErr = fd.downloadDir(
rdir='.',
ldir='download',
tree=None,
errHandleFunc=None,
verbose=True
)
flog.write(
'%s\nok\n'
'%d directories, %d(%d failed) files, %d unknown type\n\n\n' % (
host,
numDir,
numFile,
numDownErr,
numUnknown
)
)
except Exception as err:
traceback.print_exc()
flog.write(
'%s\nerror\n%s\n\n\n' % (
host,
traceback.format_exc()
)
)

pr(run(sys.argv[1]))
flog.close()

也可移步至github获取代码,欢迎完善。

这里仅抛砖引玉,实现了下载单个FTP的整个目录的核心功能,可以基于该代码继续实现并发下载多个FTP服务器上的指定文件夹的功能。

值得一提的是,这里针对单个FTP服务器上的多个文件下载还是串行的,如想要实现并发下载单个FTP服务器上的多个文件,则可以先通过tree函数得到FTP服务器的目录树,然后再并发下载相应的文件。但并发下载时若多个下载线程共用一个ftplib.FTP类的实例,并调用该实例的retrbinary函数进行下载,则不同线程之间可能会相互影响,具体可以参考ftplib的源码。

当然,要解决这个问题,可以为每个下载线程创建一个独有的ftplib.FTP类的实例,但这样就加大了FTP服务器处理的并发连接数,最大连接数及下载性能还是会受限于FTP服务器,存在不确定性。

我们将实现的程序与Filezilia进行了对比测试,发现它对多个文件的下载过程也是串行了,而最终下载文件的总数及速度二者相近。