textwrap.dedentとf-stringは相性が悪い

# はじめに

Pythonでヒアドキュメントをする際によく使うのが`textwrap.dedent`ですが、これをf-string(`f"..."`)を組み合わせ、簡易テンプレート記法として用いると意図しない挙動をすることがあります。この記事ではその現象の理由とその解決方法を紹介します。

# 問題となるコード

例として、ユーザのプロフィールをmarkdownのテキストとして出力するといいうコードを考えます。素朴に実装すると以下のようなコードになると思います。

```python:presenter-buggy.py
import textwrap
from dataclasses import dataclass

@dataclass
class UserDto:
    name: str
    profile: str

def render_user_page(user: UserDto) -> str:
    return textwrap.dedent(f"""
        ## ユーザ名
        
        {user.name}

        ## プロフィール
        
        {user.profile}
        """)
```

とりあえず動作確認をしてみましょう。

```python
>>> user = UserDto("autotaker", "私はエンジニアです。")
>>> print(render_user_page(user))

## ユーザ名

autotaker

## プロフィール

私はエンジニアです。

```

正しく動いているように見えます。
しかし、実はこのコードには問題があります。profileに複数行のテキストを指定してみましょう。

```python
>>> user = UserDto("autotaker", "私はエンジニアです。\n趣味はボードゲームです。")
>>> print(render_user_page(user))

        ## ユーザ名

        autotaker

        ## プロフィール

        私はエンジニアです。
趣味はボードゲームです。

```

おや、`textwrap.dedent`でインデントを揃えているはずなのに、インデントが壊れてしまいました。

# 解説

## ヒアドキュメントについて

ヒアドキュメントとは、複数行の文字列を一度に記述するための方法で、Pythonではダブルクオートやシングルクオートを3つ続ける(""" または ''')ことで記述できます。通常、Pythonのコード内で複数行のテキストを直接書く際に用いられます。

例えば、以下のように書くことで、改行を含むテキストをそのまま表現できます。

```python
text = """これはヒアドキュメントの例です。
複数行のテキストをそのまま書くことができます。"""
```

しかし、ヒアドキュメントを用いると、そのテキストブロック内でのインデントもそのまま保持されてしまうため、コードの読みやすさを保ちつつインデントを整える必要がある場合には工夫が必要です。

## f-stringについて

f-stringはPython 3.6から導入された文字列フォーマット機能で、文字列の中に変数や式を直接埋め込むことができます。f-stringはプレースホルダー部分（{}）の中に変数や式を記述し、それを文字列として評価・埋め込みます。

例えば、以下のようにして変数を文字列に挿入できます。

```python
name = "Alice"
greeting = f"Hello, {name}!"
print(greeting)  # Hello, Alice!
```

f-stringは便利ですが、テキストの前後に改行やインデントがあると、予期せぬフォーマットが発生する場合があります。この問題が特に顕著になるのが、ヒアドキュメントと組み合わせた場合です。

## textwrap.dedentについて

textwrap.dedentは、Pythonの標準ライブラリで提供されている関数で、複数行の文字列に含まれるインデントを除去するために使用します。ヒアドキュメントを使用する際のインデントを整えるのに便利です。

例えば、以下のコードでインデントを整えることができます。

```python
import textwrap

text = """
    This is a sample text.
    With multiple lines and indentation.
"""
print(textwrap.dedent(text))
>>> 
>>> This is a sample text.
>>> With multiple lines and indentation.
```

上記のコードでは、すべての行の先頭のインデントが除去されて出力されます。
インデントの除去は以下のようなアルゴリズムで行われます。

1. 文字列を改行で区切ります
1. 空行でない行でインデント幅（先頭の空白の数）を調べます
1. インデント幅の最小値を全体のインデント幅だとして、インデントを除去します。


## なぜ期待通りに動かないのか

f-stringとtextwrap.dedentを組み合わせた場合、f-stringが最初に評価されてしまうため、変数が埋め込まれた後のテキストに対してdedentが適用されることになります。そのため、埋め込まれた変数に改行が含まれていると、意図した通りにインデントが除去されないことがあるのです。

例えば、user.profileに複数行のテキストが含まれている場合、f-stringにより変数が展開された後のテキストに対してdedentが適用されるため、profileフィールドの改行部分にはインデントが取り除かれずに残ってしまいます。

変数展開前の文字列は以下のようになります。
```txt

        ## ユーザ名

        {user.name}

        ## プロフィール

        {user.profile}
```

f-stringで変数展開すると、

```

        ## ユーザ名

        autotaker

        ## プロフィール

        私はエンジニアです。
趣味はボードゲームです。
```
となり、この文字列にtextwrap.dedentを適用します。
しかし、この文字列のインデント量は0なので、インデントが除去されません。

```

        ## ユーザ名

        autotaker

        ## プロフィール

        私はエンジニアです。
趣味はボードゲームです。
```

# 解決策

f-stringは使わず、textwrap.dedentでインデントを整形したのちに`.format(**kwargs)`を使うと良いです。

```python:presenter_valid.py
import textwrap
from dataclasses import dataclass

@dataclass
class UserDto:
    name: str
    profile: str

def render_user_page(user: UserDto) -> str:
    return textwrap.dedent("""\
        ## ユーザ名
        
        {name}

        ## プロフィール
        
        {profile}
        """).format(name=user.name, profile=user.profile)
```

このように
1. textwrap.dedentで整形
1. `.format(**kwargs)`で変数埋め込み

の順序にすると変数に改行が含まれていても期待通りに動作します。なお、先頭の空行を取り除くために、ヒアドキュメントは`"""`ではなく`"""\`で始めるとよいです。

念のため動作確認をしておきましょう。

```python
>>> user = UserDto("autotaker", "私はエンジニアです。\n趣味はボードゲームです。")
>>> print(render_user_page(user))
## ユーザ名

autotaker

## プロフィール

私はエンジニアです。
趣味はボードゲームです。
 
```

期待通りにインデントが取り除かれていることがわかります。

# まとめ
この記事では、Pythonでヒアドキュメントを使用する際に発生しがちなtextwrap.dedentとf-stringの組み合わせによるインデント問題について解説しました。

ヒアドキュメントを使用する際は以下の点に注意すると良いです。

1. **f-stringによるインデントの問題**:
   f-stringとtextwrap.dedentを組み合わせると、f-stringが先に評価されるため、変数展開後のインデントが期待通りに取り除かれない場合があります
2. **解決策としての`.format(**kwargs)`の利用**:
   f-stringではなく、まずtextwrap.dedentでインデントを整形し、その後に`.format(**kwargs)`を用いて変数を埋め込むと、複数行のテキストが含まれていてもインデントを正しく整えることができます
3. **ヒアドキュメントの開始に`"""\`を用いる**:
   先頭の空行を除去するために、ヒアドキュメントの開始部分を"""\として記述すると、出力結果がより見やすくなります

なお、テンプレートを使用する場合は脆弱性を予防するため、エスケープ処理が必要なユースケースかどうかを検討することも重要です。

ユースケースによってはstring.Templateやjinja2の利用を検討するのもよいでしょう。

https://docs.python.org/ja/3/library/string.html#template-strings

https://qiita.com/simonritchie/items/cc2021ac6860e92de25d