PythonでCSVを書き出すときに空文字列とNoneを出し分ける

先日、PythonでCSVを出力する処理を書いていたのですが、空文字列と`None`をいい感じに出し分けられないかと試行錯誤したので、そのやり方をメモがてら記事にしたいと思います。

## どうしたかったか

ちょっとわかりにくいかもしれませんが、次のような出し分けがしたかったのです（「CSVでの表現」の真ん中の部分）。

| 値               | CSVでの表現  |
| ---------------- | ------------ |
| `""`（空文字列） | `1,2,"",4,5` |
| `None`           | `1,2,,4,5`   |

空文字列の時はダブルクォートだけになって、`None`の時は文字通り何もなしの表現にしたかったわけです。

しかし、ごく普通に`csv`モジュールの`csv.writer`を使って生成すると、このような出し分けはできません。

:::note warn
通常、CSVでここまでの使い分けが必要なことはあまりないと思いますが、今回はたまたまそういう要件があったのでした。

読む側が区別して読み込めるのかという話もあるので、頑張って処理するかどうかは要件に合わせて決めるとよいでしょう。
何でもかんでも本記事のように処理したほうがよい、ということではありません。
:::

## 実現方法

このStack Overflowの回答にズバリな実現方法が書いてありました。

[r - Python: write CSV with QUOTE_NONNUMERIC, except for None - Stack Overflow](https://stackoverflow.com/questions/62233980/python-write-csv-with-quote-nonnumeric-except-for-none)

以下は私が少し修正したコードですが、このような感じで処理すると空文字列と`None`の出し分けができました。

```python
class _EmptyString(int):
    def __str__(self):
        return ""

EmptyString = _EmptyString()

def replace_none_with_emptystring(row: Iterable[Any]) -> Iterable[Any]:
    """row内の値がNoneだったらEmptyStringに置き換える"""
    return [value if value is not None else EmptyString for value in row]

writer = csv.writer(dest, dialect=csv.excel, quoting=csv.QUOTE_NONNUMERIC)
writer.writerow(replace_none_with_emptystring(["空文字列->", "", None, "<-None"]))
```

```:結果
"空文字列->","",,"<-None"
```

空文字列と`None`が出し分けられていますね。

### 仕組み

正直なところ私はPythonにも`csv`モジュールの実装にも詳しくないので、実際のところはよくわからないのですが、おそらく「`int`を継承して`__str__`で空文字列を返すクラス」を使うことで、「数値型なのでクォートしない」かつ「文字列表現は空文字列」という値を作り出しているのかなと想像しました。

---

以下はおまけです。

## 通常の処理だとどうなるのか

せっかくなので、普通に`csv`モジュールを使ってCSVを生成するとどうなるかを紹介したいと思います。

### パターン1: QUOTE_NONNUMERICなし

まずは`QUOTE_NONNUMERIC`を指定せずにやってみます。
このオプションは数値以外の値をクォートするかどうかを制御するオプションで、指定しない場合はクォートされなくなります。

```python:QUOTE_NONNUMERICなし
writer = csv.writer(dest, dialect=csv.excel)
writer1.writerow([1, "QUOTE_NONNUMERICなし", "空文字列->", "", None, "<-None"])
```

```:結果
1,QUOTE_NONNUMERICなし,空文字列->,,,<-None
```

空文字列も`None`も`a,,b`のように何もない表現になりました。
加えて（当たり前ですが）通常の文字列もダブルクォートがつかなくなりました。

### パターン２: QUOTE_NONNUMERICあり

今度は`QUOTE_NONNUMERIC`を指定してみます（＝数値以外はクォートする）。
一般的にはこのオプションを指定してCSV生成することが多いのではないでしょうか（個人的な想像です）。

```python:QUOTE_NONNUMERICあり
writer = csv.writer(dest, dialect=csv.excel, quoting=csv.QUOTE_NONNUMERIC)
writer.writerow([2, "QUOTE_NONNUMERICあり", "空文字列->", "", None, "<-None"])
```

```:結果
2,"QUOTE_NONNUMERICあり","空文字列->","","","<-None"
```

空文字列は期待通り`""`になりましたが、Noneも`""`になってしまいました。

## 全部入りのサンプルコード

```python
# 空文字列とNoneを区別するCSV出力のサンプル
import csv
import sys
from typing import IO, Any, Iterable


# See: https://stackoverflow.com/questions/62233980/python-write-csv-with-quote-nonnumeric-except-for-none
class _EmptyString(int):
    def __str__(self):
        return ""


EmptyString = _EmptyString()


def replace_none_with_emptystring(row: Iterable[Any]) -> Iterable[Any]:
    """row内の値がNoneだったらEmptyStringに置き換える"""
    return [value if value is not None else EmptyString for value in row]


def write_csv(dest: IO[str]):
    """何かのCSVを書き出す処理"""

    # csv.QUOTE_NONNUMERICなしの場合
    writer1 = csv.writer(dest, dialect=csv.excel)
    writer1.writerow([1, "QUOTE_NONNUMERICなし", "空文字列->", "", None, "<-None"])
    # => 1,QUOTE_NONNUMERICなし,空文字列->,,,<-None

    # csv.QUOTE_NONNUMERICありの場合
    writer2 = csv.writer(dest, dialect=csv.excel, quoting=csv.QUOTE_NONNUMERIC)
    writer2.writerow([2, "QUOTE_NONNUMERICあり", "空文字列->", "", None, "<-None"])
    # => 2,"QUOTE_NONNUMERICあり","空文字列->","","","<-None"

    # かつ、EmptyString対応の場合
    writer2.writerow(
        replace_none_with_emptystring([3, "None対策", "空文字列->", "", None, "<-None"])
    )
    # => 3,"対策","空文字列->","",,"<-None"


def main(_argv: list[str]) -> int:
    write_csv(sys.stdout)
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
```

出力結果

```
1,QUOTE_NONNUMERICなし,空文字列->,,,<-None
2,"QUOTE_NONNUMERICあり","空文字列->","","","<-None"
3,"None対策","空文字列->","",,"<-None"
```