きり丸の技術日記

技術検証したり、資格等をここに残していきます。

途中に余計なBOMが付与されたファイルが作成された(FastAPI, StreamingResponse, Generator)

PythonのFastAPIのStreamingResponseでGeneratorを使用しながら、UTF-8 BOMのファイルをダウンロードさせるAPIを作成しました。ただ、作成したファイルを確認したところ、ファイルの途中で余計なBOM(\ufeff)が付与されてしまっていました。

今回の記事では、ファイルの途中でBOMが挟まれてしまった原因を残します。

環境

  • FastAPI
    • 0.75.2
  • (Pandas)
    • 1.5.0

理由

Generatorで処理するたびに、BOMを付与していたため。

言葉で見れば当たり前の話ですので、ソースコードで見れば納得していただけると思います。

詳細

ファイルを作成してくれるStreamingResponseを使用するソースコードは次のとおりです。ここの処理は特に問題ありません。

@router.get("/download")
async def download_task():
    return StreamingResponse(
        self.create_csv(),
        headers={"Content-Disposition": 'attachment; filename="file.txt"'},
    )

問題のソースコードは次のとおりです。Generatorを使用したいので、返却値はyieldにします。ファイルは最終的にはUTF-8 BOMでエンコードしたいので、utf_8_sigを指定していました。

async def create_csv() -> AsyncGenerator[bytes, None]:
    for i in range(100):
        df = pd.DataFrame(i)
        stream = StringIO()
        _ = df.to_csv(stream, index=False, header=False, encoding="utf_8_sig")

        yield stream.getvalue().encode("utf_8_sig")

ただし、この処理だとGeneratorが実行されるたびにUTF-8 BOMでエンコードされてしまいます。エンコードされるタイミングでBOMが付与されてしまいます。

そのため、初回のみutf_8_sigでエンコードし、それ以外はutf_8でエンコードする必要があります。

async def create_csv() -> AsyncGenerator[bytes, None]:
    for i in range(100):
        encoding = "utf_8"
        if i == 0:
            encoding = "utf_8_sig"

        df = pd.DataFrame(i)
        stream = StringIO()
        _ = df.to_csv(stream, index=False, header=False, encoding=encoding)

        yield stream.getvalue().encode(encoding)

ソースコード

終わりに

普段ファイルを作成する時は、初回に文字コードをエンコードして、そのファイルに対して追記していましたので問題は起きませんでした。

今回のようなローカルにファイルを作成せず、Streamingしながらクライアント側でファイルにする処理ということを初めて経験したため、障害調査にすごい時間かかってしまいました。

また、もともと、この処理自体は次の流れの中で使用していました。

  1. 現状をCSV化してダウンロードする
    1. 今回の問題点
  2. ローカルで加工する
  3. 加工したCSVをアップロードする

問題発覚したのがアップロードするタイミングだったため、ファイル読み込み側の不備を疑っていました。調べても「ファイル読み込む際に\ufeffもついてた!」だから「ファイルはutf-8ではなく、utf_8_sigで読み込もう!」って記事ばっかりだったので、ファイル作成時にやらかしていることに気づいたのはだいぶ後でした。

この記事を見た人が、二の轍を踏まないように祈るばかりです。