【Python3.x】BottleとJavaScriptを利用した非同期通信のファイルアップロード

BottlleはPythonで構築できる簡易Webサーバとして非常に重宝してるわけですが、非同期でのPOST通信のやり方が理解できたため、記載しておきます。
今回はテキストファイルのアップロードをさせます。
Windows10で実装していますが、Linuxの場合はファイルパス部分を適宜書き換えてください。

1.Bottleのインストール

engetu21.hatenablog.com
以前書いた方法(Bottleのファイルをダウンロードして使用)でもいいんですが、pipで入れた方がスマートなので、以下のコマンドで実施します。

python -m pip install bottle
python -m pip list

bottle 0.12.25

2.Pythonファイルの作成

HTMLによる画面とPOSTの受信部を記述したPythonファイルを用意します。
JavaScriptも使うため、静的ファイルのパス等も記述します。

■C:\tmp\testApp\web.py

from bottle import route, request, run, static_file

# JavaSciptファイルパス
JQUERY_FILE = "/static/js/jquery-3.6.3.min.js"
JS_FILE     = "/static/js/function.js"
CSS_FILE    = "/static/css/style.css"

# 静的ファイルパス
STATIC_PATH = "C:/tmp/testApp/web/static//"

# 初回画面のGET受信部
@route('/test-gamen', method='GET')
def test_gamen() :
    string = "初回画面だよ"
    # HTML生成
    return(html_create(string))

# 初回画面
def html_create(string) :

    return f'''
    <html>
    <head>
        <title>{string}</title>
        <script type="text/javascript" src="{JQUERY_FILE}"></script>
        <script type="text/javascript" src="{JS_FILE}"></script>
        <link rel="stylesheet" href="{CSS_FILE}" type="text/css">
    </head>
    <body>
    <h1>{string}</h1>
    <form enctype="multipart/form-data">
        ファイルを選択してください: <input type="file" id="files" name="files[]" accept=".txt">
        <br>
        <input type="hidden" id="hidden_data" value="TESTtestTESTtest" />
        <input type="button" value="登録" id="set_file" />
    </form>
    <br>
    <div id="result_area"></div>

    <div id="overlay">
      <div class="cv-spinner">
        <span class="spinner"></span>
      </div>
    </div>
    </body>
    </html>
    '''

### POST受信処理
@route('/post-uke-<string>', method='POST')
def post_uke(string):
    try:
        upload = request.files.get('file')

        # アップロードファイルのファイル名取得(JavaScriptで事前に変更している)
        save_path = upload.filename

        # ヘッダからデータを取得
        header = request.headers.raw("x-original-header")

        # アップロードされたバイナリデータをテキストファイルとして書き出し
        # 上書きを許容
        upload.save("C:/tmp/testApp/file", overwrite=True)

    except Exception as e:
        return f"<span style='color:red'>テキストファイルアップロード失敗<br>エラー要因:{str(e)}</span><br>"

    try:
        # 対象ファイルのパス設定
        path = r"C:\tmp\testApp\file" +"\\" + save_path

        # ファイル取り込処理
        with open(path, encoding = 'UTF-8') as f:
            s = f.read()
        return f"<span style='color:blue'>{header}<br>{string}<br>{s}</span><br>"

    except Exception as e:
        return f"<span style='color:red'>テキストファイル読み込み失敗<br>エラー要因:{str(e)}</span><br>"

# 静的ファイルのパス設定
@route('/static/<filepath:path>')
def server_static(filepath):
    return static_file(filepath, root=f'{STATIC_PATH}')


if __name__ == '__main__':
    run(host='0.0.0.0', port=9000, debug=False, reloader=True)

基本的な動きですが、ブラウザ→Python(GET)→JavaScriptPython(POST)となります。
POSTの送信はJavaScript内で記述していますので、後述のソースをご覧ください。

ここでは、Pythonプログラムでのいくつか注意点を記載します。

from bottle import route, request, run, static_file

末尾のstatic_fileを指定しないと静的ファイルを利用できないので注意。

<form enctype="multipart/form-data">
        ファイルを選択してください: <input type="file" id="files" name="files[]" accept=".txt">
        <br>
        <input type="hidden" id="hidden_data" value="TESTtestTESTtest" />
        <input type="button" value="登録" id="set_file" />
    </form>

このform内で設定している各種タグの「id」はJavaScript側でのデータ参照の際に使用できます。
JavaScript側のソースと照らし合わせて見るとわかるかと。

@route('/post-uke-<string>', method='POST')
def post_uke(string):

ですが、この部分はPOSTのURLを可変にしたい場合に有効です。
例えば、以下のPOSTをJavaScript側から受け取ったとします。
post-uke-aaa
post-uke-bbb

この場合、2つとも@routeで定義してもいいのですが、今回のように<>で定義することで、「post-uke-」+ 何らかの文字列、という形で受信できます。
この際、@routeでアノテーションしているdefにて引数を指定します。
この引数はPOSTのURLで記述されている<>の中の文字列と同じものを指定します。
今回の場合は「string」という文字列を指定しています。

post-uke-aaa
post-uke-bbb
は許容したい、でも
post-uke-ccc
は受信したくない場合は?というと、
if string != "ccc" といった形で、条件分岐による処理を記述すればOK。
あるいは、aaaとbbbのみを許容し、elseでそれ以外は非許容とすればよい。

URLの可変部分については

/<iii>/<lll>/<mmm>

という形もできます。
その場合のdefの書き方は、
def post_uke(iii, lll, mmm):
です。

# アップロードされたバイナリデータをテキストファイルとして書き出し
# 上書きを許容
upload.save("C:/tmp/testApp/file", overwrite=True)

アップロードされたテキストファイルは、バイナリデータでサーバに送られてくるため、
POST受信時に書き出しを行うようにします。

# 静的ファイルのパス設定
@route('/static/<filepath:path>')
def server_static(filepath):
    return static_file(filepath, root=f'{STATIC_PATH}')

静的ファイルを呼び出す際に必須な記述です。たしかBottleの公式ページに記載されていたと思います。

3.JavaScriptファイルの作成

ブラウザでボタンを押された際の挙動はJavaScriptで制御しています。
ファイル名を変更する処理をしていますが、これはファイル名に日本語しか入っていない場合、
アップロード時にファイル名が消えるだったか文字化けする現象があるためです。
本来はファイル名のエンコード処理が必要になるのですが、
割と面倒なので、ファイル名を一旦丸ごと変えるようにしています。
そこらへんが嫌な人は以下のサイトを参考にすれば、解消すると思います。
https://qiita.com/shimashima0109/items/d4d3f4ace8889456f822
https://magazine.techacademy.jp/magazine/23190

また、jqueryを使用しているため、jquery自体のファイルは以下から取得し、C:\tmp\testApp\web\static\js\に格納してください。
記載したプログラム上は、jquery-3.6.3.min.jsを使用しています。
https://jquery.com/download/

■C:\tmp\testApp\web\static\js\function.js

$(function() {
    //_/_/_/_/_/_/_/ ファイル登録処理 _/_/_/_/_/_/_/
    $('#set_file').click(function() 
    {
      // メッセージ部分初期化
      $('#result_area').html(``)

      // 連続クリック抑止(スピナーを表示してるから多分いらない)
      $('#set_file').disabled = true;

      // アップロードファイルの取得
      const files = $('#files').prop('files');

      // アップロードされたファイルが日本語、記号のみの場合、ファイル名が正常に認識されない(除外される?)ため、
      // ファイル名を差し替えを行う
      const newName = "ChangedName.txt"
      const fd = new FormData();
      fd.append('file', files[0], newName);   // files[0]はpythonプログラムの「input type="file"」で指定しているnameのゼロ番目(だったはず)

      const hidden= $('#hidden_data').val()  // hiddenのデータは#とhiddenタグでつけたidを指定することで取得することが可能

      // こうやればファイル名を取得することが可能
      const file_name = files[0].name;
      //console.log(file_name);

      post_url = 'post-uke-' + file_name

      // Ajax実行前にクルクル(スピナー)を表示
      $(document).ajaxSend(function() {
          $("#overlay").fadeIn(300);
        });

      // post実行
          $.ajax(post_url,
          {
              type: 'post',
              headers: { 'x-original-header': hidden},
              data: fd,
              processData: false,
              contentType: false,
              dataType: 'text'
          })

      // 登録成功時にはページに結果を反映
      .done(function(data) 
      {
        setTimeout(function(){
            $("#overlay").fadeOut(300);
        },500);

        //console.log(data); 
        $('#result_area').html(`${data}`)

       })
      // 登録失敗時には、その旨をダイアログ表示
      .fail(function(XMLHttpRequest, status, e) 
      {
        window.alert('ファイル登録に失敗しました\nエラー内容:' + e);
      });
    });
});

基本的にはコメントで記載している通りなので、特に解説することはないのですが、
POSTにファイルを送る場合、ボディ部(データ部)はファイルのバイナリデータで埋まってしまうため、何らかのデータをPOSTに付け足したい!といった場合は、オリジナルのヘッダを用意して設定するか、POSTのURLで情報を送る必要があると思われます。(他にもやり方があるかも)

4.CSSファイルの作成

スピナー(くるくる)を表示するために定義しています。
これはネットでの拾い物となるため、それをそのまま利用しました。
なので、解説することは何もないです。

■C:\tmp\testApp\web\static\css\style.css

#overlay{ 
  position: fixed;
  top: 0;
  z-index: 100;
  width: 100%;
  height:100%;
  display: none;
  background: rgba(0,0,0,0.6);
}
.cv-spinner {
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;  
}
.spinner {
  width: 40px;
  height: 40px;
  border: 4px #ddd solid;
  border-top: 4px #2e93e6 solid;
  border-radius: 50%;
  animation: sp-anime 0.8s infinite linear;
}
@keyframes sp-anime {
  100% { 
    transform: rotate(360deg); 
  }
}
.is-hide{
  display:none;
}

5.画面確認

作成したPythonファイルを実行します。
> cd C:\tmp\testApp
> python web.py

ブラウザで以下のURLにアクセス。
http://localhost:9000/test-gamen

aaa.txtというファイルを指定します。中身は以下の通り。

ああああああああああああああああああああああああああ
いいいいいいいいいいいいいいいいいいいいいいいいいい

これをアップロードすると、以下のように表示されます。

青文字1行目はform内でhiddenで設定した文字列です。これはPOSTのヘッダに載せたものを表示しています。ページの右クリックでソースを表示してhiddenの値は確認できます。
青文字2行目はアップロードされたファイルの名前です。これはPOSTのURLとして付与された文字列を表示しています。
青文字3行目はテキストファイルの中身です。特に工夫せずそのまま表示していますが、忠実に再現するなら、改行コードをbrタグに変更してやるなどの対応が必要です。
 
というわけで、Bottleで簡易的にPOSTを使いたい、ファイルアップロード画面を作りたいといった場合にはこんな感じでできると思います。
元々作っていたのはExcelファイルアップロード画面で、今回は簡単に説明できるようテキストファイルとしました。
Excel取り込みの方はDataFrameを利用するのでより複雑なのですが、それに関してはまた記事にしていこうかなと思います。