docstringのインデントを削る の実装メモ

## 概要
CPythonコア開発者の@methane さんが以下のようなツイートをされていて、@methane さん曰く「簡単そう」ということなのでGWの空き時間で実装してみました。そのメモと結果をまとめます。


<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">Pythonのメモリ消費をケチるアイデア、docstringをコンパイル時にdedentするって案があって割とコスパ良いと予想してるんだけど、だれかやってみたい人いない？</p>&mdash; Inada Naoki (@methane) <a href="https://twitter.com/methane/status/1118473578209398784?ref_src=twsrc%5Etfw">2019年4月17日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

で、これのほぼ答え的なヒントがScrapbox.ioに残されているので、それを頼りに実装してみます。

[docstringのインデントを削る - CPython Development](https://scrapbox.io/pydev-ja/docstring%E3%81%AE%E3%82%A4%E3%83%B3%E3%83%87%E3%83%B3%E3%83%88%E3%82%92%E5%89%8A%E3%82%8B)

**結論だけ先に書くとまだ検証（コード修正）が必要な部分があります。一旦ここまでのメモを書き出しています（2019/05/04）**

## 前提
OSはLinux（dockerのubuntu:latest）

~~CPythonのターゲットは https://github.com/python/cpython からcloneしてきた4/30現在の最新リビジョンだった [06d04e77ca36133e82bf6c363c09ba82e07a9c75](https://github.com/python/cpython/commit/06d04e77ca36133e82bf6c363c09ba82e07a9c75) を使っています。~~
（もう少し付け加えるとforkしたリポジトリを使っています。）

macOSXでmake testが失敗するので、 [98a1e06c47f655c7601b130cf8d549de9f08369e](https://github.com/python/cpython/commit/98a1e06c47f655c7601b130cf8d549de9f08369e) を使っています。

Python3.8.0a3ベースです。

```shell-session
$ git clone git@github.com:hhatto/cpython.git
$ cd cpython
$ git branch 98a1e06c47f655c7601b130cf8d549de9f08369e
```

## CPythonのビルド

```shell-session
$ ./configure --prefix=$HOME/.mydevpython --enable-optimizations
$ make
$ make install
```

リリースビルド用に`--enable-optimizations`は付けておいた方が良さそう。
`--prefix`は環境に応じて変えます。

### ビルドに関するメモ
* CPythonのコンパイラのコードを触った際は `profile-run-stamp` ファイルは削除する


## 実装

### インデントを削る処理を探す
まずは下記のインデントを削る処理を探します。

> help(func) とかをしたときにどうやって docstring からインデントを削っているかは、 `_sitebuiltins.py` の Helper クラスから追っかけていけばわかるはず。

と言うことなので、愚直に辿ってみます。

```
Lib/_sitebuiltins.py:103 pydoc.help
  Lib/pydoc.py:1938 Helper.helpのdoc()
    Lib/pydoc.py:1676 render_doc()
      Lib/pydoc.py:1669 renderer.document()
        このrendererはTextDoc()
        Lib/pydoc.py:1063
        ざっと見ていってgetdoc()というのがいかにもそれっぽい...
        Lib/pydoc.py:92 getdoc()
          inspect.getdoc()が本丸か...
          Lib/inspect.py:605 cleandoc()
            Lib/inspect.py:624
```

で、この`Lib/inspect.py`の`cleandoc()`は以下のようなコードになっています。

```Python:Lib/inspect.py
def cleandoc(doc):
    """Clean up indentation from docstrings.

    Any whitespace that can be uniformly removed from the second line
    onwards is removed."""
    try:
        lines = doc.expandtabs().split('\n')
    except UnicodeError:
        return None
    else:
        # Find minimum indentation of any non-blank lines after first line.
        margin = sys.maxsize
        for line in lines[1:]:
            content = len(line.lstrip())
            if content:
                indent = len(line) - content
                margin = min(margin, indent)
        # Remove indentation.
        if lines:
            lines[0] = lines[0].lstrip()
        if margin < sys.maxsize:
            for i in range(1, len(lines)): lines[i] = lines[i][margin:]
        # Remove any trailing or leading blank lines.
        while lines and not lines[-1]:
            lines.pop()
        while lines and not lines[0]:
            lines.pop(0)
        return '\n'.join(lines)
```

これをCで実装すれば良いということになります。

### 愚直に実装したコード

`inspect.py`の`cleandoc()`をほぼCPython APIで実装した感じ。
`PyObject_CallMethod()`を多用しているので効率はあまり良くないと思う。
本当はPyObjectから生の文字列を取り出して、C側で文字列処理を頑張った方が良さそう。

`Lib/inspect.py`の`cleandoc()`の`# Remove any trailing or leading blank lines.`のコメントが付いている箇所はdoctestの機能に影響があるようで、1行目を除いた先頭と末尾の空行を削除する処理は含めない形に変更しました。（2019/05/05追記）

```c:Python/compile.c
static PyObject *
cleanup_docstring(PyObject *docstring)
{
    PyObject *expanded = PyObject_CallMethod(docstring, "expandtabs", NULL);
    PyObject *lines = PyObject_CallMethod(expanded, "split", "s", "\n");
    Py_DECREF(expanded);
    Py_ssize_t lines_num = PySequence_Length(lines);
    long margin = PY_SSIZE_T_MAX;

    // Find minimum indentation of any non-blank lines after first line.
    if (lines_num > 1) {
        for (Py_ssize_t i=1; i<lines_num; i++) {
            PyObject *line = PySequence_GetItem(lines, i);
            PyObject *lstrip_line = PyObject_CallMethod(line, "lstrip", NULL);
            Py_ssize_t content_len = PyUnicode_GET_LENGTH(lstrip_line);

            if (content_len) {
                Py_ssize_t line_len = PyUnicode_GET_LENGTH(line);
                Py_ssize_t indent_len = line_len - content_len;
                if (margin > indent_len) {
                    margin = indent_len;
                }
            }

            Py_DECREF(line);
            Py_DECREF(lstrip_line);
        }
    }

    // Remove indentation.
    if (lines_num >= 1) {
        PyObject *first_line = PySequence_GetItem(lines, 0);
        PyObject *first_lstrip_line = PyObject_CallMethod(first_line, "lstrip", NULL);
        PySequence_SetItem(lines, 0, first_lstrip_line);

        Py_DECREF(first_line);
    }
    if (margin < PY_SSIZE_T_MAX) {
        for (Py_ssize_t i=1; i<lines_num; i++) {
            PyObject *line = PySequence_GetItem(lines, i);
            Py_ssize_t line_len = PyUnicode_GET_LENGTH(line);

            PyObject *strip_margin_line = PyUnicode_Substring(line, margin, line_len);
            PySequence_SetItem(lines, i, strip_margin_line);

            Py_DECREF(line);
        }
    }

    PyObject *rt = PyUnicode_FromFormat("\n");
    PyObject *ret = PyObject_CallMethod(rt, "join", "O", lines);
    Py_DECREF(lines);
    Py_DECREF(rt);

    return ret;
}
```

### .pycコンパイル処理にdocstringを設定している処理を辿る

Cでdocstringのインデントを削る処理は実装できたので、あとはどこに処理を差し込むかを調べます。

> コンパイラのどの部分で実装すればいいかは、 `-OO` オプションがあったときに docstring を捨てている部分を探せば簡単にたどり着ける。

1つは既に答えを書いてくださっていて、 https://github.com/python/cpython/blob/06d04e77ca36133e82bf6c363c09ba82e07a9c75/Python/compile.c#L1700 にあります。
もう1つ該当箇所があって、  https://github.com/python/cpython/blob/06d04e77ca36133e82bf6c363c09ba82e07a9c75/Python/compile.c#L2121 がそれ。

```c:Python/compile.c
    if (c->c_optimize < 2) {
        docstring = _PyAST_GetDocString(stmts);
        if (docstring) {
            i = 1;
            st = (stmt_ty)asdl_seq_GET(stmts, 0);
            assert(st->kind == Expr_kind);
            VISIT(c, expr, st->v.Expr.value);
            if (!compiler_nameop(c, __doc__, Store))
                return 0;
        }
    }
```

```c:Python/compile.c
    if (c->c_optimize < 2) {
        docstring = _PyAST_GetDocString(body);
    }
    if (compiler_add_const(c, docstring ? docstring : Py_None) < 0) {
        compiler_exit_scope(c);
        return 0;
    }
```

この2箇所のはず...
どちらも`docstring`にインデントを含んだdocstringが入っているので、ここでインデントを削ります。

```diff
     if (c->c_optimize < 2) {
         docstring = _PyAST_GetDocString(stmts);
         if (docstring) {
             i = 1;
             st = (stmt_ty)asdl_seq_GET(stmts, 0);
             assert(st->kind == Expr_kind);
+            st->v.Expr.value->v.Constant.value = cleanup_docstring(st->v.Expr.value->v.Constant.value);
             VISIT(c, expr, st->v.Expr.value);
             if (!compiler_nameop(c, __doc__, Store))
                 return 0;
         }
     }
```

```diff
     if (c->c_optimize < 2) {
         docstring = _PyAST_GetDocString(body);
+        if (docstring) {
+            docstring = cleanup_docstring(docstring);
+        }
     }
```

これで実装は終わりです。
ビルドして検証してみます。

## 検証結果
メモリ使用量と.pycのサイズをチェックします。
.pycの作成処理込みで調べたいので、`lib/python3.8/__pycache__`をディレクトリごと削除してから検証します。

### 実装前の結果

```shell-session
$ ~/.mypython/bin/python3
Python 3.8.0a3+ (heads/master:06d04e77ca, May  4 2019, 14:10:48)
[GCC 7.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>
```

初回起動

```shell-session
# CPython起動直後
$ ps aux | grep python | grep -v grep
root     42938  0.2  0.4  21856  9188 pts/0    S+   14:18   0:00 /root/.mypython/bin/python3

# zipfileとftplibをimport後
$ ps aux | grep python | grep -v grep
root     42938  0.3  0.6  43764 12588 pts/0    S+   14:18   0:00 /root/.mypython/bin/python3
```

.pycのサイズ

```shell-session
$ ls -ltr lib/python3.8/__pycache__/zipfile.cpython-38.pyc
-rw-r--r-- 1 root root 49927 May  4 14:19 lib/python3.8/__pycache__/zipfile.cpython-38.pyc
$ ls -ltr lib/python3.8/__pycache__/ftplib.cpython-38.pyc
-rw-r--r-- 1 root root 27472 May  4 14:19 lib/python3.8/__pycache__/ftplib.cpython-38.pyc
```

2回目の起動

```shell-session
# CPython起動直後
$ ps aux | grep python | grep -v grep
root     42947  0.2  0.3  20496  7500 pts/0    S+   14:22   0:00 /root/.mypython/bin/python3

# zipfileとftplibをimport後
$ ps aux | grep python | grep -v grep
root     42947  0.1  0.4  41676 10224 pts/0    S+   14:22   0:00 /root/.mypython/bin/python3
```

2回目以降の起動時のメモリ使用量をvalgrindでプロファイルした結果

```text
    MB
1.190^                                                                 #
     |                                                       @       ::#::::
     |                                                       @  :::@:::#::::
     |                                                       @:::::@:::#:::::
     |                                                       @:::::@:::#:::::
     |                                                       @:::::@:::#:::::
     |                                                  @:@@:@:::::@:::#::::@
     |                                            :@:::@@:@@:@:::::@:::#::::@
     |                                  @:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
     |                             :  @@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
     |                            ::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
     |                 @@:::::::::::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
     |             ::::@@: :::::: ::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
     |            :::::@@: :::::: ::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
     |          @@:::::@@: :::::: ::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
     |        ::@ :::::@@: :::::: ::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
     |      ::::@ :::::@@: :::::: ::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
     |      ::::@ :::::@@: :::::: ::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
     |   @:@::::@ :::::@@: :::::: ::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
     | @:@:@::::@ :::::@@: :::::: ::::@@@:::@:::@@:@:: @@:@@:@:::::@:::#::::@:
   0 +----------------------------------------------------------------------->MB
     0                                                                   242.4
```

### docstringを削った後の結果

```shell-session
$ ~/.mydevpython/bin/python3
Python 3.8.0a3+ (heads/cleanup-docstring-in-pyc:c89c0ee355, May  4 2019, 14:59:24)
[GCC 7.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>
```

初回起動

```shell-session
# CPython起動直後
$ ps aux | grep python | grep -v grep
root     63788  1.0  0.4  21644  9008 pts/0    S+   15:01   0:00 /root/.mydevpython/bin/python3

# zipfileとftplibをimport後
$ ps aux | grep python | grep -v grep
root     63788  1.0  0.6  44028 12788 pts/0    S+   15:01   0:00 /root/.mydevpython/bin/python3
```

.pycのサイズ

```shell-session
$ cd $HOME/.mydevpython/lib/python3.8/
$ ls -ltr __pycache__/zipfile.cpython-38.pyc
-rw-r--r-- 1 root root 49349 May  4 15:01 __pycache__/zipfile.cpython-38.pyc
$ ls -ltr __pycache__/ftplib.cpython-38.pyc
-rw-r--r-- 1 root root 26478 May  4 15:01 __pycache__/ftplib.cpython-38.pyc
```

2回目の起動

```shell-session
# CPython起動直後
$ # ps aux | grep python | grep -v grep
root     63798  0.1  0.3  20504  7596 pts/0    S+   15:04   0:00 /root/.mydevpython/bin/python3

# zipfileとftplibをimport後
$ ps aux | grep python | grep -v grep
root     63798  0.1  0.4  41388 10156 pts/0    S+   15:04   0:00 /root/.mydevpython/bin/python3
```

2回目以降の起動時のメモリ使用量をvalgrindでプロファイルした結果

```text
    MB
1.166^                                                       #
     |                                                       #       ::::::@
     |                                                       #  :::@:::::::@
     |                                                       #:::::@:::::::@
     |                                                       #:::::@:::::::@
     |                                                     @@#:::::@:::::::@
     |                                                  @ :@ #:::::@:::::::@:
     |                                           @ :::::@::@ #:::::@:::::::@::
     |                                  @::@:::::@::::: @::@ #:::::@:::::::@::
     |                            @  :@@@::@: :::@::::: @::@ #:::::@:::::::@::
     |                  @        @@:::@@@::@: :::@::::: @::@ #:::::@:::::::@::
     |                 @@:::@::::@@: :@@@::@: :::@::::: @::@ #:::::@:::::::@::
     |             @:::@@:: @::::@@: :@@@::@: :::@::::: @::@ #:::::@:::::::@::
     |            :@:::@@:: @::::@@: :@@@::@: :::@::::: @::@ #:::::@:::::::@::
     |          :::@:::@@:: @::::@@: :@@@::@: :::@::::: @::@ #:::::@:::::::@::
     |        ::: :@:::@@:: @::::@@: :@@@::@: :::@::::: @::@ #:::::@:::::::@::
     |     :::::: :@:::@@:: @::::@@: :@@@::@: :::@::::: @::@ #:::::@:::::::@::
     |     : :::: :@:::@@:: @::::@@: :@@@::@: :::@::::: @::@ #:::::@:::::::@::
     |  ::@: :::: :@:::@@:: @::::@@: :@@@::@: :::@::::: @::@ #:::::@:::::::@::
     | :: @: :::: :@:::@@:: @::::@@: :@@@::@: :::@::::: @::@ #:::::@:::::::@::
   0 +----------------------------------------------------------------------->MB
     0                                                                   243.9
```

.pycは小さくなっていますが、メモリ使用量は有意な差と言えるかどうか...（ピーク使用量は減っていてプログラム全体のライフサイクルの中でのメモリ使用量は増えています。）

一旦は.pycのファイルサイズが小さくなったので良しとします。

## 追加検証
methaneさんからコメントをもらってSQLAlchemyで検証してみると良いかも、ということでSQLAlchemyのリポジトリにあるexampleなコードで検証してみます。

[examples/association/basic_association.py](https://github.com/zzzeek/sqlalchemy/blob/cdd01a06f7cf5e9ce35544a02c182eca4015992c/examples/association/basic_association.py)を検証コードに使用します。

```shell-session
$ valgrind --tool=massif --time-unit=B --stacks=yes --max-stackframe=2000028 python examples/association/basic_association.py
$ ms_print massif.out.XXXX
```

インデント削る前

```text
    MB
4.671^                                                                     #
     |                                                                 @@@:#:
     |                                                            @@:@:@@@:#:
     |                                                       @:::@@@:@:@@@:#:
     |                                                   @:@:@:: @@@:@:@@@:#:
     |                                              :::::@:@:@:: @@@:@:@@@:#:
     |                                              : :::@:@:@:: @@@:@:@@@:#::
     |                                            @:: :::@:@:@:: @@@:@:@@@:#::
     |                                    @:::::@:@:: :::@:@:@:: @@@:@:@@@:#::
     |                                 :::@: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
     |                          @@::::@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
     |                         :@ :: :@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
     |                   :::::::@ :: :@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
     |                @::: ::: :@ :: :@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
     |              ::@::: ::: :@ :: :@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
     |            @:::@::: ::: :@ :: :@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
     |         :::@:::@::: ::: :@ :: :@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
     |       :@: :@:::@::: ::: :@ :: :@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
     |   :::::@: :@:::@::: ::: :@ :: :@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
     | @@: :::@: :@:::@::: ::: :@ :: :@:: @: :: @:@:: :::@:@:@:: @@@:@:@@@:#::
   0 +----------------------------------------------------------------------->GB
     0                                                                   1.435
```

インデント削った後

```text
    MB
4.471^                                                                     #
     |                                                                 @:@:#:
     |                                                            @@:::@:@:#:
     |                                                        @::@@@: :@:@:#:
     |                                                  ::@@@:@: @@@: :@:@:#:
     |                                              ::::: @@ :@: @@@: :@:@:#:
     |                                              : ::: @@ :@: @@@: :@:@:#:
     |                                             :: ::: @@ :@: @@@: :@:@:#:
     |                                     :::::::::: ::: @@ :@: @@@: :@:@:#::
     |                                  @::::: ::: :: ::: @@ :@: @@@: :@:@:#::
     |                            @@::::@: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
     |                         @::@@::: @: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
     |                    :::::@: @@::: @: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
     |                 :@@:: : @: @@::: @: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
     |              ::@:@ :: : @: @@::: @: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
     |           @::: @:@ :: : @: @@::: @: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
     |         ::@::: @:@ :: : @: @@::: @: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
     |      ::@: @::: @:@ :: : @: @@::: @: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
     |   :::: @: @::: @:@ :: : @: @@::: @: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
     | :::::: @: @::: @:@ :: : @: @@::: @: ::: ::: :: ::: @@ :@: @@@: :@:@:#::
   0 +----------------------------------------------------------------------->GB
     0                                                                   1.400
```

ピークで約200KB、トータルで約35MBの削減という感じでしょうか。

.pycのサイズも念の為共有。

```
# 改修前
$ ls -ltr ~/.virtualenvs/mypython38nottrim/lib/python3.8/site-packages/sqlalchemy/__pycache__/
total 128
-rw-r--r-- 1 root root 12456 May  5 14:47 interfaces.cpython-38.pyc
-rw-r--r-- 1 root root 17378 May  5 14:47 exc.cpython-38.pyc
-rw-r--r-- 1 root root  4257 May  5 14:47 __init__.cpython-38.pyc
-rw-r--r-- 1 root root 54294 May  5 14:47 events.cpython-38.pyc
-rw-r--r-- 1 root root  4239 May  5 14:47 processors.cpython-38.pyc
-rw-r--r-- 1 root root  2889 May  5 14:47 inspection.cpython-38.pyc
-rw-r--r-- 1 root root  2406 May  5 14:47 types.cpython-38.pyc
-rw-r--r-- 1 root root  1976 May  5 14:47 schema.cpython-38.pyc
-rw-r--r-- 1 root root  6569 May  5 14:47 log.cpython-38.pyc

# 改修後
$ ls -ltr ~/.virtualenvs/mydevpython38nottrim/lib/python3.8/site-packages/sqlalchemy/__pycache__/
total 116
-rw-r--r-- 1 root root 11921 May  5 14:11 interfaces.cpython-38.pyc
-rw-r--r-- 1 root root  4260 May  5 14:11 __init__.cpython-38.pyc
-rw-r--r-- 1 root root 17049 May  5 14:11 exc.cpython-38.pyc
-rw-r--r-- 1 root root 48761 May  5 14:11 events.cpython-38.pyc
-rw-r--r-- 1 root root  4242 May  5 14:11 processors.cpython-38.pyc
-rw-r--r-- 1 root root  2836 May  5 14:11 inspection.cpython-38.pyc
-rw-r--r-- 1 root root  2409 May  5 14:11 types.cpython-38.pyc
-rw-r--r-- 1 root root  1979 May  5 14:11 schema.cpython-38.pyc
-rw-r--r-- 1 root root  6476 May  5 14:11 log.cpython-38.pyc
```
## その他
ここまで書いておいてうまくいったように見えるのですが、一点問題がありまして、CPythonビルドで`make`コマンド実行してビルド&テストする際にdoctestのテスト(8/420)がコケます。
なぜこのテストが失敗するかまでは調べられていません。
doctest用のコード(テキスト)を整形する際にインデントありのdocstringに依存する処理があったりするんでしょうか...？調査した結果を追記しようと思います。

### doctestのテストが失敗する件の調査（2019/05/05追記）
doctestのテストがエラーになる件を調べてみました。
以下が発生したエラーです。

```shell-session
$ make
   :
   :
./python -m test.regrtest --pgo || true
Run tests sequentially
0:00:00 load avg: 1.13 [  1/421] test_grammar
0:00:00 load avg: 1.13 [  2/421] test_opcodes -- test_grammar passed
0:00:00 load avg: 1.13 [  3/421] test_dict -- test_opcodes passed
0:00:01 load avg: 1.13 [  4/421] test_builtin -- test_dict passed
0:00:02 load avg: 1.13 [  5/421] test_exceptions -- test_builtin passed
0:00:03 load avg: 1.12 [  6/421] test_types -- test_exceptions passed
0:00:03 load avg: 1.12 [  7/421] test_unittest -- test_types passed
0:00:09 load avg: 1.11 [  8/421] test_doctest -- test_unittest passed
**********************************************************************
File "/root/work/cpython/Lib/test/test_doctest.py", line 1227, in test.test_doctest.test_DocTestRunner.optionflags
Failed example:
    doctest.DocTestRunner(verbose=False).run(test)
    # doctest: +ELLIPSIS
Expected:
    **********************************************************************
    File ..., line 2, in f
    Failed example:
        print(1, 2, 3)
    Expected:
          1   2
         3
    Got:
        1 2 3
    TestResults(failed=1, attempted=1)
Got:
    **********************************************************************
    File "/root/work/cpython/Lib/test/test_doctest.py", line 2, in f
    Failed example:
        print(1, 2, 3)
    Expected:
         1   2
        3
    Got:
        1 2 3
    TestResults(failed=1, attempted=1)
**********************************************************************
1 items had failures:
   1 of  65 in test.test_doctest.test_DocTestRunner.optionflags
***Test Failed*** 1 failures.
test test_doctest failed
```

これはやはりインデントがあることを前提にテストが書かれているのでは...
`Lib/test/test_doctest.py`のL.1227を見てみると、以下のようなコードになっています。

```python:Lib/test/test_doctest.py(1219)
The NORMALIZE_WHITESPACE flag causes all sequences of whitespace to be
treated as equal:

    >>> def f(x):
    ...     '>>> print(1, 2, 3)\n  1   2\n 3'

    >>> # Without the flag:
    >>> test = doctest.DocTestFinder().find(f)[0]
    >>> doctest.DocTestRunner(verbose=False).run(test)
    ... # doctest: +ELLIPSIS
    **********************************************************************
    File ..., line 2, in f
    Failed example:
        print(1, 2, 3)
    Expected:
          1   2
         3
    Got:
        1 2 3
    TestResults(failed=1, attempted=1)
```

インデントを削る際に1行目は無視するので、2行目以降の空白から一番小さいインデントを判断して削除する空白の数を決めます。
この場合、最終行の3の前に1つの空白があるので、これを削るべき空白と判断して手前の2行の先頭空白を削除してしまいます。結果、テストが失敗することになります。
この動きを変えるとなると互換性の問題が出そうなので、こういう感じのdoctestがあると対応が難しそうですね...

検証としては一旦ここまでかなと思います。