Python 与 FTP 服务器 – ftputil 模块,文件上传下载

前一篇文章分析了用 Python 内置的模块 ftplib 实现上传下载等功能,本篇文章就来看看另一个高水平的 FTP 库 ———— ftputil。它的官网:ftputil。

项目需求与分析

在之前 ftplib 的文章已经分析过,而且已经说明在本次文章中要处理一些其他问题,不再详述。

ftputil

ftputil 是第三方模块,是 ftplib 的扩展,完善了许多方法,os 中操作文件的主要方法,ftputil 几乎都有,包括 shutil 的部分方法,目前 v4.0.0 中只缺少一个 copytree() 主要方法,比较可惜,不过上传下载暂时用不到,不过相比 ftplib 已是好了很多。部分方法如下:

ftp.path.exists()
ftp.path.isdir()
ftp.path.isfile()
ftp.path.getsize()
ftp.makedirs()
ftp.listdir()
ftp.chdir()
ftp.rmtree()
ftp.rename()

本以为 ftputil 的代码实现会轻松许多,因为提供了许多方法,用起来也比较方便,和 os 相似。不过最后还是大意了,发现一些方法存在一些 bug,这里先来了解一下上传下载中用到的方法。

在 Windows 系统下,使用 os 的方法操作文件,不区分文件名与文件夹名大小写,这应该和 Windows 系统不区分大小写相关。但是 ftputil 提供的方法部分区分大小写。假设 FTP 上有一个路径为 /test_dir/dir1/a.txt,根据测试,ftp.path.exists(’/test_dir/dir1’)、ftp.path.isdir(’/test_dir/dir1’)、ftp.listdir(’/test_dir/dir1’) 这三个方法,都可以返回 True,但是如果将路径改为 /test_dir/Dir1 或者 /Test_dir/dir1 都返回 False。ftp.path.isfile() 对文件的操作与上述方法一样,但 ftp.makedirs()、ftp.path.basename()、ftp.chdir() 三个方法却不区分,大小写没有区别。而 ftp.path.getsize() 方法,对目录不区分大小写,但是对文件区分大小写,即 /test_dir/dir1/test_dir/Dir1/Test_dir/dir1 是一个目录,而 /test_dir/dir1/a.txt/test_dir/dir1/A.txt 却是两个文件。

测试得知,ftp.makedirs()、ftp.path.basename()、ftp.chdir() 这三个方法不区分大小写。而 ftp.path.getsize() 文件夹名不区分大小写,文件名区分大小写。

总之,对于 Windows 建立的 FTP 来说,部分方法无法使用,可以利用上述的三个不区分大小写的方法,重写部分方法。

功能实现

代码如下:

import os
import ftplib
import ftputil

def ftp_path_isdir(path, ftp):
    """ 同 os.path.isdir """
    try:
        ftp.chdir(path)
        return True
    except:
        return False

def ftp_path_isfile(file, ftp):
    """ 同 os.path.isfile """
    try:
        filename = ftp.path.basename(file)
        if ftp_path_isdir(file.replace(filename, ''), ftp):
            ftp.path.getsize(file)
            return True
        return False
    except:
        return False

def get_local_path(local_path, file_list=None):
    """ 给定一个本地文件夹,递归列出所有文件 """
    files = os.listdir(local_path)
    for file in files:
        temp_path = local_path + os.sep + file
        if not os.path.isdir(temp_path):  # 文件
            file_list.append(temp_path)
        else:  # 文件夹
            get_local_path(temp_path, file_list)
    return file_list

def get_remote_path(ftp, remote_path, file_list=None):
    """ 给定一个 ftp 文件夹,递归列出所有文件 """
    ftp.chdir(remote_path.replace('\\', '/'))  # 解决 ftp.listdir 大小写无法区分问题
    files = ftp.listdir(remote_path.replace('\\', '/'))
    for file in files:
        temp_path = remote_path + '\\' + file
        if not ftp_path_isdir(temp_path.replace('\\', '/'), ftp):  # 文件
            file_list.append(temp_path)
        else:  # 路径
            get_remote_path(ftp, temp_path, file_list)
    return file_list

def upload_tracker(block, src):
    """ 上传回调函数,实现上传进度 """
    global file_write, total_size
    file_write += 64 * 1024
    progress = round((file_write / total_size) * 100)
    if progress >= 100:
        print('Upload ' + src + ' ' + '100%')
    else:
        print('Upload ' + src + ' ' + '%3s%%' % str(progress))

def upload_file(ftp, src, dst):
    """上传文件,src, dst 均以文件名结尾"""
    global file_write, total_size
    file_write = 0
    total_size = os.path.getsize(os.path.abspath(src))
    ftp.upload(src, dst.replace('\\', '/'), lambda block: upload_tracker(block, src))

def download_file(ftp, src, dst):
    """ 下载文件,src、dst 均以文件名结尾 """
    global file_write, total_size
    file_write = 0
    total_size = ftp.path.getsize(src.replace('\\', '/'))
    ftp.download(src.replace('\\', '/'), dst, lambda block: download_tracker(block, src))


def download_tracker(block, src):
    """ 下载回调函数,实现下载进度 """
    global file_write, total_size
    file_write += 64 * 1024
    progress = round((file_write / total_size) * 100)
    if progress >= 100:
        print('Download ' + src + ' ' + '100%')
    else:
        print('Download ' + src + ' ' + '%3s%%' % str(progress))


def upload(ftp, data):
    """ 处理上传,可以是文件列表或文件夹列表,不可以混合上传。 """
    if not ftp_path_isdir(data['remote_path'].replace('\\', '/'), ftp):
        ftp.makedirs(data['remote_path'].replace('\\', '/'))
    is_file, is_dir = False, False
    if 'local_path' in data and data['local_path']:
        # 循环遍历,判断 local_path 中的路径类型,文件还是文件夹
        for local_path in data['local_path']:
            if os.path.isfile(local_path):
                is_file = True
                break
            elif os.path.isdir(local_path):
                is_dir = True
                break
    if is_file:  # 文件
        for filepath in data['local_path']:
            dst = data['remote_path'] + '\\' +  filepath.split('\\')[-1]
            if os.path.exists(filepath):
                upload_file(ftp, filepath, dst)
    elif is_dir:  # 文件夹
        filt_list = []
        for local_path in data['local_path']:
            if os.path.exists(local_path):
                file_list.extend(get_local_path(local_path, []))
        for filepath in file_list:
            dst = data['remote_path'] + '\\' +  filepath.split('\\')[-1]
            if os.path.exists(filepath):
                upload_file(ftp, filepath, dst)


def download(ftp, data):
    """ 处理下载,可以下载文件列表或者文件夹列表,而不可混合下载。 """
    if not os.path.exists(data['local_path']):
        os.makedirs(data['local_path'])
    is_file, is_dir = False, False
    if 'remote_path' in data and data['remote_path']:
        # 循环遍历,判断 remote_path 中的路径类型,文件还是文件夹
        for remote_path in data['remote_path']:
            if ftp_path_isfile(remote_path.replace('\\', '/'), ftp):
                is_file = True
                break
            elif ftp_path_isdir(remote_path.replace('\\', '/'), ftp):
                is_dir = True
                break
    if is_file:  # 文件
        for filepath in data['remote_path']:
            dst = data['local_path'] + '\\' + filepath.split('\\')[-1]
            if ftp_path_isfile(filepath.replace('\\', '/'), ftp):
                download_file(ftp, filepath, dst)
    elif is_dir:  # 文件夹
        file_list = []
        for remote_path in data['remote_path']:
            if ftp_path_isdir(remote_path.replace('\\', '/'), ftp):
                file_list.extend(get_remote_path(ftp, remote_path, []))
        for filepath in file_list:
            dst = data['local_path'] + '\\' + filepath.split('\\')[-1]
            if ftp_path_isfile(filepath.replace('\\', '/'), ftp):
                download_file(ftp, filepath, dst)


class DefaultFTP(ftplib.FTP):
    """ 修改 ftplib 的默认编码 """
    encoding = 'gbk'

if __name__ == '__main__':
    host, username, password = '***.***.***.***', '******', '********'
    ftp = ftputil.FTPHost(host, username, password, session_factory=DefaultFTP)

    data = {"action": "upload", "local_path": ["C:\\TencentFile\\ChenNan_Mod.ma"], "remote_path": "\\test1\\t1\\a1"}
    # data = {"action": "download", "local_path": "C:\\test\\download\\c", "remote_path": ["\\test1\\t1\\a2", "\\test1\\t1\\a1"]}
    print('Start...')
    if data["action"] == 'upload': upload(ftp, data)
    if data["action"] == 'download': download(ftp, data)
    print('End...')
    ftp.close()

以上就是全部代码,重要的地方添加了一些注释。本次代码测试环境为 Windows 下的 FTP 服务器。

断点续传

断点续传的功能未实现,断点续传涉及到很多问题,要根据具体情况解决,原理并不难。这里简单说一下,利用 try/except 语法,try 中上传出错,进入到 except 中,获取已经上传文件的大小,上传方法中添加偏移量,从中断处继续上传。本文中,ftp.upload() 方法,并未提供偏移量参数,可以使用 ftp.open() 方法,与 open 方法相似,但是没有提供回调方法,需要重新写上传进度功能。

如果使用 ftplib,其中的 ftp.storbinary() 上传方法,既提供了回调方法,也有偏移量,可以酌情考虑。