[Python]ヒアドキュメントでもインデントを崩さずに書かなきゃダメだよ

textwrap.dedentを使おう。約束だ。
2021.05.07

Pythonのヒアドキュメント使ってますか? 通知など、人間が読むことを想定した文字列を格納する時なんかによく使いますよね。

しかし、こんなコードを書いてしまったりしていないでしょうか?

if task_succeeded():
    message = '''タスクが正常に完了しました。
次のタスクはxxxです。'''
else:
    message = '''タスクが失敗しました。
再実行する場合はxxxしてください。'''
send_mail(message)

ヒアドキュメントは、'''で囲まれた部分がそのまま文字列になりますので、 上記のように書くことで、messageには意図した内容が格納されます。

タスクが正常に完了しました。
次のタスクはxxxです。

確かに想定通りで、問題はありません。

しかし、プログラムを書く人、特にPythonを書く人にとっては 上記のようなコードは非常に気持ちが悪いと思います。 Pythonはインデントの有無で処理が変わるので、 それがここまで破壊されたコードを見せられたら発狂してしまう人もいるかもしれません(俺だ)。 そんなかわいそうな狂人を輩出しないためにもPythonのヒアドキュメントを使う際に使うべきメソッドがあります。

検証バージョン

以下はpython3.9.4にて実行した例です。

解決策: textwrap.dedent を使おう

解決策として、textwrap.dedentを使いましょう。 また、先頭、終端の改行を除くために[1:-1]などで文字列を削ります。

以下のような感じです。

import textwrap

if True:
    s = '''
        aiueo
          abc
         1234
    '''

print(textwrap.dedent(s)[1:-1])
# aiueo
#   abc
#  1234

dedentを使わないものと比べて比べてください!!

# dedentを使わない例!
if True:
    s = '''aiueo
  abc
 1234'''

print(s)
# aiueo
#   abc
#  1234

dedentを使った方が断然スマートですよね! コードを読む人への優しい心遣いが感じられます(個人の感想です)。

dedentを使うことで、一番浅いインデントが深さ0になる形で出力されます。 上の表示だとエビデンスとして弱いのでencodeして見てみるとこんな感じです。

print(textwrap.dedent(s)[1:-1].encode('utf8'))
# b'aiueo\n  abc\n 1234'

一番浅いインデントの行には行頭のスペースがなく、他の行もそこから相対的な深さになっています。

また、冷静に考えるとアレってなるのですが、 最終行(1234の次の行)は他の行よりも深さが浅いのですが、 スペースだけの行は特別扱いされるので、最終行の深さによって他が影響を受けることはないようです。

このように先頭行(aiueoの上の行)と最終行は(スペース以外)何も書かないで、 先頭と終端の改行コードを削除する([1:-1])と、 多くの人にとって想定したような出力になるかと思います。

おまけ

全ての行にインデントをつけたい場合

格納される文字列に、本当にインデントを付けたい場合にはindentメソッドを使います。 第二引数に先頭に挿入する文字列を指定できるので、半角スペースを何個か入れればOKです。

import textwrap

if True:
    s = '''
        aiueo
          abc

         1234
    '''

print(textwrap.indent(textwrap.dedent(s)[1:-1], '  ').encode('utf-8'))
# b'  aiueo\n    abc\n\n   1234'

なお、このとき、空行にはスペースは入りません (abcのあとは改行2つが連続している)。

インデントさせるかどうかの判定式を変える

空行にもスペースを入れたい場合もあるかもしれません(ないと思いますが)。 そんな時は、predicateを使います。

indentにはpredicate引数を渡すことができ、 ここにはindentをつける条件をつけることができます。 predicateは省略時には、「空行以外はTrue」として処理されるので、 デフォルトでは空行にはインデントがつきません。 ここが常にTrueになれば空行にもインデントがつきます。

predicateには呼び出し可能なオブジェクトを渡す必要があるので、 例えば全ての行にインデントをつけたい場合は

import textwrap

if True:
    s = '''
        aiueo
          abc

         1234
    '''

print(
    textwrap.indent(textwrap.dedent(s)[1:-1], '  ',
                    predicate=(lambda x: True)).encode('utf-8'))
# b'  aiueo\n    abc\n  \n   1234'

のように書けばOKですね。

スペースとタブが共存する場合

行頭のスペースは、タブでも問題なく動作します。 では、スペースとタブが共存している場合どうなるのでしょうか? 重箱の隅をつつくような疑問ですが、dedentメソッドの実装にコメントがありました。

Note that tabs and spaces are both treated as whitespace, but they are not equal: the lines " hello" and "\thello" are considered to have no common leading whitespace.

共通のwhitespaceが存在しないように扱われるようです。 実際やってみます。

import textwrap
if True:
    s = '''
        aaa
          bbb
           ccc
			ddd <-tab
    '''
print(textwrap.dedent(s)[1:-1].encode('utf-8'))
# b'     aaa\n      bbb\n       ccc\n\t\tddd <-tab'

想定通り、先頭のスペースもタブも一切消去されません。

結論

常に以下のような書き方をするのが良さそうです。 もし明示的にインデントを付けたい場合は、indentメソッドで行います。

import textwrap
if True:
    s = '''
        ヒアドキュメント開始行はすぐに改行して
            ここに
                  任意の
                        文字列を
                                書いていく
        最後は改行して、変数名の先頭と合わせた場所で閉じる
    '''
print(textwrap.dedent(s)[1:-1])
# ヒアドキュメント開始行はすぐに改行して
#     ここに
#           任意の
#                 文字列を
#                         書いていく
# 最後は改行して、変数名の先頭と合わせた場所で閉じる

まとめ

インデント崩しダメ、ゼッタイ。