為什麼我的 sys.stdout.write 不會傳回輸出的字元數
Tags: python
Published: 2023-01-28 15:53:07

正常來說, [sys](https://docs.python.org/3/library/sys.html) 下的 [stdout](https://docs.python.org/3/library/sys.html?highlight=stdout#sys.stdout) 因為是 [File 物件](https://docs.python.org/3/glossary.html#term-file-object), 所以他的 [write()](https://docs.python.org/3/library/io.html#io.TextIOBase.write) 應該要傳回輸出的字元數, 像是這樣才對：

```python
>>> import sys
>>> sys.stdout.write("hello")
hello5
```
輸出結果中最後的 '5' 是因為輸出 5 個字元, 所以 Python shell 會把整個運算式, 也就是 `write()` 的傳回值顯示出來。

如果查看 sys.stdout 的型別, 可以看到他是 [TextIOWrapper](https://docs.python.org/3/library/io.html#io.TextIOWrapper)：

```python
>>> type(sys.stdout)
<class '_io.TextIOWrapper'>
```

這個類別衍生自 [TextIOBase](https://docs.python.org/3/library/io.html#io.TextIOBase), 因此上述的執行結果完全正確。

## Thonny IDE 下的怪異現象

如果你把相同的程式拿到 Thonny 的 IDE 的互動窗格下測試, 就會發生異常狀況：

```python
>>> import sys
>>> sys.stdout.write("hello")
hello
>>> a = sys.stdout.write("hello")
hello
>>> print(a)
None
>>>
```

你會發現不會印出字元數量的 5, 而且若是觀察 `write()` 的傳回值, 會發現是 `None`, 也就是沒有傳回值。

檢查一下 `sys.stdout` 的型別：

```python
>>> type(sys.stdout)
<class 'thonny.plugins.cpython_backend.cp_back.FakeOutputStream'>
```

你會發現它根本不是剛剛看到的 `TextIOWrapper` 類別, 甚至應該要是最原始輸出的 `sys.__stdout__` 也被改掉了：

```python
>>> type(sys.__stdout__)
<class 'thonny.plugins.cpython_backend.cp_back.FakeOutputStream'>
>>>
```

這個 [`FakeOutputStrem`](https://github.com/thonny/thonny/blob/c4b5eb112e78bb53f745cb6eebf66add6464a2ac/thonny/plugins/cpython_backend/cp_back.py#L1020) 類別是為了搭配 Thonny IDE 運作而特別撰寫的類別, 他的 [`write`](https://github.com/thonny/thonny/blob/c4b5eb112e78bb53f745cb6eebf66add6464a2ac/thonny/plugins/cpython_backend/cp_back.py#L1025) 根本不會傳回值：

```python
def write(self, data):
    try:
        self._backend._enter_io_function()
        # click may send bytes instead of strings
        if isinstance(data, bytes):
            data = data.decode(errors="replace")

        if data != "":
            self._backend._send_output(data=data, stream_name=self._stream_name)
            self._processed_symbol_count += len(data)
    finally:
        self._backend._exit_io_function()
```

我可以理解為了 IDE 的運作以客製的類別取代原本的 `TextIOWrapper` 類別, 但我實在不明白為什麼不傳回字元數符合一致的介面？

補充：上述測試是在 Thonny 4.0.2 進行, 根據[官方的回應](https://github.com/thonny/thonny/issues/2629#issuecomment-1407473888), 這個問題會在 4.0.3 版本修正, 傳回輸出的字元數。

## IPython 在 Windows 平台上的特殊處理

如果你慣用 IPython, 那麼在 Windwos 平台上, IPython 也會有和 Thonny 類似的處理：

```python
In [1]: import sys

In [2]: sys.stdout.write("hello")
hello
```

不過 `sys.__stdout__` 卻沒有被改過, 可以正常運作：

```python
In [3]: sys.__stdout__.write("hello")
Out[3]: hello5
```

如果觀察兩者的所屬類別, 就可以看到差異：

```python
In [4]: type(sys.stdout)
Out[4]: colorama.ansitowin32.StreamWrapper

In [5]: type(sys.__stdout__)
Out[5]: _io.TextIOWrapper
```

sys.stdout 被重新導向到奇怪的 [`colorama.ansitowin32.StreamWrapper`](https://github.com/tartley/colorama/blob/21c4b94fe21ce29c85c896ace828da24b7527641/colorama/ansitowin32.py#L16) 類別了, 他的 [`write`](https://github.com/tartley/colorama/blob/21c4b94fe21ce29c85c896ace828da24b7527641/colorama/ansitowin32.py#L46) 一樣是不會傳回值：

```python
def write(self, text):
    self.__convertor.write(text)
```

從同一檔案中其他的[註解](https://github.com/tartley/colorama/blob/21c4b94fe21ce29c85c896ace828da24b7527641/colorama/ansitowin32.py#L74)看來：

>Implements a `write()` method which, on Windows, will strip ANSI character sequences from the text, and if outputting to a tty, will convert them into win32 function calls.

主要是為了拿掉 Windows 之前不支援的 [ANSI 序列碼](https://zh.wikipedia.org/zh-tw/ANSI%E8%BD%AC%E4%B9%89%E5%BA%8F%E5%88%97), 並轉換成對應的 Win32 系統函式。不過我實在不懂為什麼不維持一致, 傳回字元數呢？

## Colab 也和 IPython 一樣

以 web 為介面的 Colab 因為沒有終端機, 應該也會修改 `sys.stdout`, 我們來測試一下 (本文測試的是 2023/1/12 版本的 Colab)：

![](https://i.imgur.com/22f5wud.png)

果不其然, 跟 IPython 類似, `sys.stdout` 被改成 [`ipykernel.iostream.OutStream`](https://github.com/ipython/ipykernel/blob/main/ipykernel/iostream.py#L293), 他的 [`write()`](https://github.com/ipython/ipykernel/blob/1a50cda50837de89cc2a4047fc02b3aff254d48a/ipykernel/iostream.py#L490) 是正確的：

```python
def write(self, string: str) -> int:
    """Write to current stream after encoding if necessary
    Returns
    -------
    len : int
        number of items from input parameter written to stream.
    """
    ...
        else:
            self._schedule_flush()

    return len(string)
```

不過這個方法是在 [2021/6/14 的版本](https://github.com/ipython/ipykernel/commit/1a50cda50837de89cc2a4047fc02b3aff254d48a#diff-50efd52d050465d24b5b923d8b8ee556f91142cf44a9209244db2a4a75dd7f6eR531)才開始傳回字元數, 因此可以推斷 Colab 上的版本是比較舊的, 沒有傳回字元數。這可以由[舊版本的 `write()`](https://github.com/ipython/ipykernel/blob/d7567b04230060c5b5cecd84fcffdb03baed57f3/ipykernel/iostream.py#L490) 並沒有 DOC 字串來證實：

```python
def write(self, string):
    if self.echo is not None:
      ...
```

![](https://i.imgur.com/XhPtDlZ.png)

另外, 根據這個版本的修改記錄, 原來的程式有為了 Python 2 所設計處理輸出內容並非字串的狀況, 因此無法計算字元數：

>Remove the piece of logic that handle `not isinstance(str)` it is a leftover from Python 2, in pure Python, sys.stdout.write only accepts str, therefore we have no reason not to do the same.

因為新的 Python 3 版本限定只會輸出字串, 所以就改成和標準程式庫一樣傳回輸出的字元數了。

## Jupyter Lab 採用的是新版的程式碼

既然 Colab 會有問題, 那麼系出同源的 Jupyter Lab 會不會也有一樣的狀況呢？我們來試看看：

![](https://i.imgur.com/dAEAejF.png)

你可以看到雖然 Jupyter Lab 也和 Colab 一樣將 `sys.stdout` 改成 `ipykernel.iostream.OutStream`, 但是顯然他用的是會傳回字元數的版本, 這可以從他的 `__doc__` 是有內容的來證實。

## 結語

本文探討的雖然是個小細節, 不過如果沒注意到, 可能會在測試程式時百思不得其解, 造成莫大的困擾。