Python (および任意の言語) でプログレスバーを表示する方法と、その理解

## 概要

Python (および任意の言語) でこんな感じの処理進行度を雑に表示したい。

```
Reading file   123 / 4000 ...
````

## TL;DR

```py
import sys
import time


def start_progress():
    sys.stdout.write("\033[?7l")


def print_progress(text: str):
    sys.stdout.write(f"\r\033[2K{text}")
    sys.stdout.flush()


def end_progress():
    sys.stdout.write("\033[?7h\n")


n = 20

start_progress()

for i in range(n):
    print_progress(f"Processing {i:3} /  {n}...")
    # なんか処理をする
    time.sleep(0.1)

end_progress()

print("Done!")
```

## 詳細

### 行頭に戻る

一度文字列を表示し、再度同じ行に文字列を上書きするには、 `\r` を使えばいいです。

ただし、一回目の表示の後改行が入ると、当然ながら次の行に進んでしまいます。  
Python の場合 `print` 関数に `end=""` を渡すことで、改行を抑制できます。

```py
print("Hello World!", end="")
print("\rHELLO WORLD!!")
```

```:出力
>>> print("Hello World!", end=""); print("\rHELLO WORLD!!")
HELLO WORLD!!
```

### 標準出力を flush させる

実際のプログラムで使う場合、この `\r` を使った出力を数回以上繰り返すことになります。

しかしこのままでは、出力がバッファリングされてしまい、途中経過が出力されない可能性があります。

したがって、 標準出力のバッファを `.flush()` して、強制的に現在の文字列を表示させます。  
また、ついでに `print` を `sys.stdout.write` で置き換えます [^sys-stdout]。

[^sys-stdout]: [`print` の実装](https://github.com/python/cpython/blob/120729d862f0ef9979508899f7f63a9f3d9623cb/Python/bltinmodule.c#L2059) を見ると `print` はかなり薄いラッパーです。とはいえ、文字列フォーマット等は呼び出し側の責務ということにして、 str 型だけを使える `sys.stdout.write` を使っていいのではないかと思います。

```py
import sys
sys.stdout.write("Hello World!")
sys.stdout.flush()
sys.stdout.write("\rHELLO WORLD!!")
```

```:出力
$ python3 -c 'import sys; sys.stdout.write("Hello World!"); sys.stdout.flush(); sys.stdout.write("\rHELLO WORLD!!")'
HELLO WORLD!!
```

### 以前の出力内容を消去する

固定長の文字列を表示するだけなら、このコードで問題ありません。

しかし、二つ目の文字列が一つ目より短かった場合、その長さの差分の文字列が消えないまま残ってしまいます。

```py
sys.stdout.write("Hello World!")
sys.stdout.flush()
sys.stdout.write("\rHi :)\n")
```

```:出力
$ python3 -c 'import sys; sys.stdout.write("Hello World!"); sys.stdout.flush(); sys.stdout.write("\rHi :)\n")'
Hi :) World!
     ^~~~~~~ ここが残ってしまっている
```

これに対して、`\r` と同時に以前の出力内容を消去することで対処します。

これには、エスケープシーケンス `\033[2K` が使えます。

:::note
エスケープシーケンス (escape sequence) とは、コンピュータシステムにおいて、通常の文字列では表せない特殊な文字や機能を、規定された特別な文字の並びにより表したもの。
[Wikipedia - エスケープシーケンス](https://ja.wikipedia.org/wiki/%E3%82%A8%E3%82%B9%E3%82%B1%E3%83%BC%E3%83%97%E3%82%B7%E3%83%BC%E3%82%B1%E3%83%B3%E3%82%B9)
:::

```py
sys.stdout.write("Hello World!")
sys.stdout.flush()
sys.stdout.write("\r\033[2KHi :)\n")
#                   ^------ この部分
```

```:出力
$ python3 -c 'import sys; sys.stdout.write("Hello World!"); sys.stdout.flush(); sys.stdout.write("\r\033[2KHi :)\n")'
Hi :)
```

エスケープシーケンス `\033[2K` は、現在カーソルがある行の文字を全て消去します。  
これと `\r` を組み合わせることで、前回の出力内容をリセットした上でカーソルを行頭に戻すことができます。

### 長い行の自動折り返しを抑制する

まだ問題があります。

可変長かつ長い文字列（ファイルの絶対パスなど）を表示することを想定してみます。

```py:progress.py
def print_progress(text: str):
    sys.stdout.write(f"\r\033[2K{text}")
    sys.stdout.flush()


print_progress("FILE_A.txt")
print_progress("super long file naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaame")
print_progress("FILE_B.txt")
```

```:出力
$ python3 progress.py
super long file naaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
FILE_B.txt
```

長いファイル名の一部が残ってしまいました。  
その理由は、三つ目の表示を消すことで理解できます。

```:出力
$ python3 progress.py
super long file naaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaame
```

このように、長い文字列を表示した際に、行の折り返しが発生してしまっています。  
先ほどの実行では、 `FILE_B` の表示が、折り返した後の行 `aaaaaaaaaaame` の部分から再開してしまっていたのです。  
（ここでは文字幅 32 列のターミナルで実行した出力を再現しています。）

実用上で問題になるということはないでしょうが、せっかくなら綺麗に一行で完結させたいです。

そのためには、また別のエスケープシーケンス `\033[?7l` と `\033[?7h` を使います。

```py:progress.py
def print_progress(text: str):
    sys.stdout.write(f"\r\033[2K{text}")
    sys.stdout.flush()


sys.stdout.write("\033[?7l")

print_progress("FILE_A.txt")
print_progress("super long file naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaame")
print_progress("FILE_B.txt")

sys.stdout.write("\033[?7h")
```

```:出力
$ python3 progress.py
FILE_B.txt
```

折り返しが起こるような長いファイル名が残ることなく、綺麗に表示できました。

#### エスケープシーケンスの意味

`\033[?7l` / `h` の構成要素を紐解くと、以下のようになります。

- `\033[`: CSI (Control Sequence Introducer)[^csi]。シーケンスの始まりを意味する
- `?`: これがプライベートシーケンス ＝ ターミナル開発者が独自に定義できるシーケンスであることを意味する
- `7l` / `7h`: なんらかのプライベートシーケンス

[^csi]: https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_(Control_Sequence_Introducer)_sequences

つまり、 `\033[?7l` / `h` はターミナル製作者が定義したプライベートシーケンスで、その中の `7l` / `7h` を呼び出している、ということになります。

では「`7l` `7h` で折り返しを設定する」と定義したのが誰かというと、 VT100 というターミナル[^terminal]です。  
[Wikipedia](https://en.wikipedia.org/wiki/ANSI_escape_code#History) によると、それが ANSI をサポートした最初の著名なターミナルでもあるそうです。

> The first popular video terminal to support these sequences was the Digital VT100, introduced in 1978.

[^terminal]: こんにちターミナルと呼ばれているものは正確には Terminal Emulator であり、当時物理的なデバイスとして存在した Terminal を Emulate するものです。この辺りについては参考資料もご覧ください。

`7l` / `7h` の具体的な定義は、 VT100 のドキュメントで確認することができます。

https://vt100.net/docs/vt100-ug/chapter3.html#DECAWM

> **DECAWM – Autowrap Mode (DEC Private)**
> 
> This is a private parameter applicable to set mode (SM) and reset mode (RM) control sequences. The reset state causes any displayable characters received when the cursor is at the right margin to replace any previous characters there. The set state causes these characters to advance to the start of the next line, doing a scroll up if required and permitted.

> **DEC Private Modes**
>
> | Parameter | Mode Mnemonic | Mode Function |
> | --- | --- | --- |
> | 7 | DECAWM | Auto wrap |

Set / reset を指定するとのことで、 `h` / `l` はそれぞれ High / Low の略でしょう。

さて、VT100 は広く普及し、多くのターミナルエミュレータも VT100 のプライベートシーケンスに追従しているのだそうです。  
筆者が使っている iTerm2 も例に漏れず、以下の箇所で `7l` / `7h` を処理しています。

https://github.com/gnachman/iTerm2/blob/257d82349ac98f264f4c3995bddea030522ff8bc/sources/VT100Terminal.m#L536-L538

```
            case 7:
                self.wraparoundMode = mode;
                break;
```

注意点として、一度 `l` を指定すると、セッションが終了するまでずっと autowrap がオフになります。  
プログレスバーの表示が終わったら再度 `h` を指定してオンに戻しておくのがいいでしょう。

まとめると、 `\033[?7` は VT100 が定義したエスケープシーケンスであり、 Autowrap mode を設定するものです。  
`h` で有効化 (デフォルト)、 `l` で無効化できます。  
今回は行末での折り返し (Autowrap) をオフにしたいので、 `l` を指定することで目的が達成できます。

## ソースコード全体

ここまでの内容を関数にまとめます。

```py:progress.py
import sys
import time


def start_progress():
    sys.stdout.write("\033[?7l")


def print_progress(text: str):
    sys.stdout.write(f"\r\033[2K{text}")
    sys.stdout.flush()


def end_progress():
    sys.stdout.write("\033[?7h\n")


n = 20

# Autowrap mode をオフにする
start_progress()

for i in range(n):
    # 進行度を一段階表示する
    print_progress(f"Processing {i:3} /  {n}...")
    time.sleep(0.1)

# Autowrap mode をオンにする
end_progress()

print("Done!")
```

実行すると、 0.1 秒ごとに進行度が更新され、 2 秒後に終了します。

```:出力
$ python3 progress.py
Processing   0 /  20...  # だんだん数字が変わる
Processing  19 /  20...  # ←終了時点
Done!
```

これで期待通りのプログレスバーを出力できました。

## まとめ

エスケープシーケンスやターミナルエミュレータまわりのことは見て見ぬ振りを続けていましたが、勉強するいい機会になりました。

今回は Python で書きましたが、やっていることはただ文字列を出力しているだけなので、 Bash でも C でも、任意の言語で同じことができます。

## 参考資料

https://www.mizdra.net/entry/2019/12/04/130157

https://vt100.net/docs/vt100-ug/chapter3.html

https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_(Control_Sequence_Introducer)_sequences

https://ja.wikipedia.org/wiki/%E3%82%A8%E3%82%B9%E3%82%B1%E3%83%BC%E3%83%97%E3%82%B7%E3%83%BC%E3%82%B1%E3%83%B3%E3%82%B9

https://qiita.com/sudo00/items/2b2eec07d3099b5ad664

https://www.danroo.com/tool/post-1620/