markdown

はじめに (対象読者・この記事でわかること)

この記事は、Python でサブプロセスを起動して対話的な bash セッションを扱いたいが、色付きプロンプトや ls --color=auto のような ANSI エスケープシーケンスが「文字化け」して見える、あるいはまったく効かない、と悩んでいる開発者を対象にしています。

読み進めることで以下がわかります。

  • Python の subprocess モジュールがデフォルトで無効にしている「疑似端末 (pseudo-terminal)」の仕組み
  • pty モジュールを使って bash を本物の端末だと思わせる方法
  • エスケープシーケンスを正しく送受信するための最小構成のコード
  • 実用上の落とし穴(バッファリング、パディング、EOF の扱い)と回避策

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。

  • Python での subprocess.runPopen の基礎
  • Linux での標準入出力・標準エラー出力の概念
  • エスケープシーケンスがターミナルに与える影響の概略(カーソル移動、色、クリアなど)

なぜエスケープシーケンスが効かないのか

bash がカラフルな出力をするかどうかは「接続先端末が色を解釈できるかどうか」で決まります。Python の subprocess.Popen はデフォルトでパイプを使い、標準入出力を「ファイル」として扱うため、bash は「誰かがファイルにリダイレクトしてる」と判断し、エスケープシーケンスを出力しません。結果、ls --color=autoPS1 も白黒モードになってしまいます。

疑似端末 (pseudo-terminal, 以降 pty) を使うと、bash に「本物の端末に接続している」と思わせることができます。pty を経由すると bash は環境変数 TERM に従った制御文字を出力するため、Python 側でそれを正しく読み取れば色付きログを取得できます。

Python から pty を使って bash にエスケープシーケンスを送受信する

ステップ1: 最小構成で動かす

Python 標準ライブラリの pty モジュールは fork() ベースの API を提供しています。まずは単一コマンドを実行して、エスケープシーケンスが返ってくることを確認しましょう。

Python
import pty, os, subprocess, select cmd = ["bash", "--norc", "-c", "ls --color=auto /"] pid, fd = pty.fork() if pid == 0: # 子プロセス側 os.execvp(cmd[0], cmd) else: # 親プロセス側 output = b"" while True: ready, _, _ = select.select([fd], [], [], 0.5) if not ready: break chunk = os.read(fd, 1024) if not chunk: break output += chunk os.waitpid(pid, 0) print("=== 生バイト列 ===") print(output) print("=== デコード後 ===") print(output.decode(errors="replace"))

実行すると / 以下のファイル一覧がカラフルに見えれば成功です。b"\x1b[0m\x1b[01;34m" のような文字列が含まれていれば、エスケープシーケンスが取得できています。

ステップ2: 対話的セッションを長時間維持する

実用的には、コマンドを逐次送りたいので、子プロセスを常駐させたまま読み書きできるようにします。pty.openpty() を使ってマスタ/スレーブ端末のファイルディスクリプタを生成し、スレーブ側を bash に接続します。

Python
import os, pty, select, termios, tty master, slave = pty.openpty() # 端末設定を生モードに近づける tty.setraw(master) # 環境変数を引き継ぎつつ bash を起動 p = subprocess.Popen( ["bash", "-i"], stdin=slave, stdout=slave, stderr=slave, env={**os.environ, "TERM": "xterm-256color"} ) os.close(slave) # 親は不要 def write_cmd(cmd: str): os.write(master, (cmd.rstrip("\n") + "\n").encode()) def read_until_prompt(prompt=b"$ ", timeout=1.0): buf = b"" while not buf.endswith(prompt): rlist, _, _ = select.select([master], [], [], timeout) if not rlist: break buf += os.read(master, 1024) return buf # 使用例 print(read_until_prompt()) # 初期プロンプト write_cmd("export PS1='\\[\\e[32m\\]mysh>\\[\\e[0m\\] '") print(read_until_prompt(b"mysh>")) write_cmd("ls --color /etc") colored = read_until_prompt(b"mysh>") os.close(master) p.wait()

ハマった点やエラー解決

  1. バッファリングで出力が届かない
    ls などは標準出力が端末でないとバッファリング戦略が変わります。stdbuf -oL ls / のように stdbuf で強制的に行バッファリングにするか、script コマンドで包むと回避できます。

  2. read がブロックして戻らない
    プロンプト文字列が $ とは限らない(環境変数 PS1 次第)。タイムアウトを設定し、部分的な出力を蓄積して endswith で判定するか、正規表現で柔軟にマッチさせましょう。

  3. 色が一部だけ効く
    ls --color=auto は「出力先が端末か否か」で変わるため、python 経由でも pty を使えば「端末扱い」になります。しかし grep --color=auto は「パイプ継ぎ手か否か」も判定してしまうので、grep --color=always を明示する必要があります。

解決策

上記コードスニペットでは tty.setraw() で生モードにしてエスケープシーケンスをそのまま通過させ、かつ select で非ブロック読み出しを行うことで、長時間のセッションでも安定動作を確認しています。バイト列のデコードは errors="replace" で安全側に倒しておけば、UTF-8 途中のマルチバイトが分断されても例外が飛びません。

まとめ

本記事では、Python の subprocess だけでは bash に色付き出力をさせられない理由と、標準ライブラリの pty を使って疑似端末を構成する方法を解説しました。

  • bash は「端末に接続しているか」でエスケープシーケンスを出し分ける
  • pty モジュールでマスタ/スレーブ端末を作り、bash を騙して色を出力させる
  • 生バイト列を正しく読み書きすれば、カラフルかつ対話的なログを取得できる

この知識があれば、リモートサーバの操作ログを色付きで保存したり、独自のターミナルエミュレータを Python で高速にプロトタイピングしたりと応用が広がります。次回は、取得したエスケープシーケンスを HTML/CSS に変換して Web 上にリアルタイム表示する「ブラウザターミナル」の作り方を紹介します。

参考資料

  • Python 公式ドキュメント pty モジュール
    https://docs.python.org/ja/3/library/pty.html
  • man 7 pty
    https://man7.org/linux/man-pages/man7/pty.7.html
  • The TTY Demystified
    https://www.linusakesson.net/programming/tty/
  • stackoverflow: How to read colored output from bash using python subprocess
    https://stackoverflow.com/questions/42188871/