###### tags: `Python` # 為什麼我的 sys.stdout.write 不會傳回輸出的字元數 正常來說, [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__` 是有內容的來證實。 ## 結語 本文探討的雖然是個小細節, 不過如果沒注意到, 可能會在測試程式時百思不得其解, 造成莫大的困擾。