Python Tips: Python で FTP のアップロードを自動化したい

Python で FTP のファイルアップロード処理を行う方法についてです。

これは SSH/SCP が使えない古風なレンタルサーバーへのファイルアップロードや CMS のデプロイ等を自動化したいときに便利かと思います。

早速結論ですが、 Python で FTP を行うには ftplib という標準ライブラリを使うのがかんたんです。

ただし ftplib はじゃっかんインタフェースがあまり人間向けでないというか直感的ではありません( FTP の技術に疎い者の感想です)。

(ちなみにここでご紹介するのは ftplib の使い方のごく一部なので、以下で取り上げる使い方が各操作に対する唯一の方法というわけではありません)

早速サンプルを見てみましょう。

FTP 接続する

FTP 接続するには ftplib ライブラリのクラス FTP ```` FTP_TLS のどちらかを使います。クラス名が示すとおり FTP は通常の FTP 用、 FTP_TLS は FTP over TLS/SSL (いわゆる FTPS )用です。

いまどき通常の FTP を使うのはナニなので、ここからは FTPS を中心に説明していきます。

FTPS 接続をするには FTP_TLS クラスにホスト名・ユーザー・パスワードを渡して使用します。 FTP_TLS は組み込み関数の open() と同じような形で、コンテキストマネージャとして使用することもできます。

FTP_TLS のインスタンスの retrlines() メソッドに特定の引数を渡して実行すると、サーバーのファイル一覧を取得することができます。

# ファイル一覧をメタ情報付きで取得する
with FTP_TLS(host='example.com', user='username', passwd='password') as ftp:
    ftp.retrlines('LIST')
# ファイル一覧をファイル名だけ取得する
with FTP_TLS(host='example.com', user='username', passwd='password') as ftp:
    ftp.retrlines('NLST')
# 標準出力に次のような出力が出る:
# home
# cgi-bin
# cgi-def
# app-def
# data
# .well-known
# log

FTP でファイルをアップロードする

ファイルをアップロードするには FTP_TLS インスタンスの storbinary() メソッドを使用します。

from ftplib import FTP_TLS

def push(remote_path: str, local_path: str) -> None:
    """FTPS でファイルを 1 件アップロードする"""
    with FTP_TLS(host='example.com', user='username', passwd='password') as ftp:
        ftp.storbinary('STOR {}'.format(remote_path), open(local_path, 'rb'))

# ローカルの `.env.production` ファイルを `.env` という名前でアップロードする
push(remote_path='.env', local_path='.env.production')

storbinary() の第 1 引数は 'STOR アップロード先のファイルパス' というフォーマットの文字列で、第 2 引数はバイナリモードで開かれた読み込み可能なファイルオブジェクトです。

ちなみに、指定されたリモートパスの親ディレクトリが存在しない場合はエラーとなります。そのため、親ディレクトリが存在するかどうかわからない深い階層にファイルをアップロードしたい場合は、事前に親ディレクトリを作成する必要があります。

ディレクトリの作成には FTP_TLS インスタンスの mkd() メソッドが使えるので、たとえば次のような関数を書くと、深い階層の場所にファイルをアップロードすることができます。

from ftplib import FTP_TLS, error_perm

def push(remote_path: str, local_path: str, create_parents: bool=False) -> None:
    """FTPS でファイルを 1 件アップロードする(親ディレクトリ作成のオプション付き)"""
    with FTP_TLS(host='example.com', user='username', passwd='password') as ftp:
        if create_parents:
            parts = remote_path.split('/')
            chain = ['/'.join(parts[:n]) for n in range(len(parts))][1:]

            for part in chain:
                try:
                    ftp.mkd('{}'.format(part))
                # ディレクトリがすでに存在する場合はエラーがあがるのでキャッチしてスルー
                except error_perm as e:
                    pass

        ftp.storbinary('STOR {}'.format(remote_path), open(local_path, 'rb'))

# ローカルの `settings/prod.py` ファイルを `data/config/settings/prod.py` というパスにアップロードする
push(remote_path='data/config/settings/prod.py', local_path='settings/prod.py')

FTP でディレクトリをまるごとアップロードする

それひとつで特定のディレクトリ以下のファイルをまるごとアップロードできるようなメソッドは(私が知るかぎり) ftplib にはありません。そのため、特定のディレクトリをまるごとアップロードしたいような場合は、事前に対象ディレクトリ以下のファイルをリストアップして、それらを 1 件ずつアップロードする、という処理が必要になります。

具体的には、次のような感じでローカルのファイルを集めてその結果を上の push() 関数に渡す、といった形になるでしょうか。

from pathlib import Path
from typing import Generator, List

def get_local_files(target: str) -> Generator[Path, None, None]:
    """特定のディレクトリ以下のファイルをすべて取得する

    - リストアップの順番は DFS (深さ優先探索)
    """
    root = Path(target)
    if not root.is_dir():
        raise Exception('Only directories can be passed.')

    edges = [root]
    while edges:
        item = edges.pop()
        children = [x for x in item.iterdir()]
        dirs = [x for x in children if x.is_dir()]
        files = [x for x in children if x.is_file()]
        edges.extend(dirs)
        yield from files

for file in get_local_files(target='.'):
    push(remote_path=str(file), local_path=str(file))

今回ご紹介したのはあくまでもイメージを伝えるためのサンプルです。 FTP は操作に誤るとクリティカルな事故につながることもあるので、くれぐれもそのまま使用しないよういしてください。参考にされる際は自己責任でお願いします。

以上です。

私はとりあえず以上のことができればやりたいこととしては十分だったのでこれ以上は調べませんでしたが、冒頭に述べたとおり、ここでご説明したのは ftplib のごく一部なので、興味がある方は公式のドキュメントをご覧になってみてください。

Python で FTP を行う方法についてでした。