Pythonのkeyboardモジュールが引き起こす落とし穴とpynputへの乗り換え
はじめに
Pythonでショートカットキー(Ctrl + Qなど)を検出して特定の処理を行いたい場合、keyboardモジュールは非常に便利なライブラリです。
しかし、実際に使ってみると「思った通りに動かない」ケースに遭遇しました。
本記事ではCtrl + Q を検出してアプリケーションの状態を変更しようとした際に遭遇した罠と、その原因、そして回避策について共有します。
keyboardモジュールでの基本的な使い方
import keyboard def on_q(): print("Qが押されました") keyboard.add_hotkey('q', on_q) keyboard.wait()
Qキーが押された事だけを検出したい場合これで十分です。
では、Ctrl + Q を検出したい場合はどうでしょうか?
Ctrl + Q を検出しようとしたコード
import keyboard def on_ctrl_q(): print("Ctrl + Q が押されました") keyboard.add_hotkey('Ctrl + Q', on_ctrl_q) keyboard.wait()
一見問題なさそうに見えます。(実際にCtrl + Qを押すとon_ctrl_q()が呼ばれることもあります)
問題: Ctrl + Qがうまく検出されないケース
このコードをCLIアプリケーションの中で使っているとある問題に気付きます。
数分経ってからCtrl + Qを押しても、何も反応しない
しかもその後、Prompt.ask()(richライブラリの入力待ち)で^Qがそのまま入力されてしまいます。
原因と推察
この現象のポイントは以下です。
Ctrl+Qを押すと'\x11'という1バイト文字(制御文字)がkeyboard.Listenerでは'q'ではなく'\x11'として処理される- 時間経過後(正確にはターミナルや標準入力の状態が変化した後?)にOSや環境によっては
keyboardモジュールが正しくキーイベントをフックできなくなる - 結果として
add_hotkey('ctrl+q')が無効になり、^Qがそのままinput()やPrompt.ask()に送られてしまう
実際のログ例
pressed_keys = {<Key.ctrl_l: <162>>, '\x11'}
key = '\x11'
つまり「Ctrlを押しながらQを押した」というイベントがqではなく\x11として来るため、ショートカット検出が失敗します。
対策
- 低レベルでの制御文字検出に対応する
keyboard.add_hotkey('\x11', on_ctrl_q)
これは一部環境では有効ですが、一般的なCtrl + Qの指定と両立させる必要があります。 - より信頼性の高い方法に切り替える
- Windows限定であれば
pyHookやpynputを使用する方が安定する - CUIアプリで
Prompt.ask()などを使っている場合は、標準入力に対してmsvcrt.getch()やtty+termiosで直接読み取る方が良い場合もある
今回はpynputを使用した方法を解説します。
pynputの導入
インストール
pip install pynput
pynputでのCtrl + Q検出の実装例
以下はpynputを用いてCtrl + Qを検出し、別スレッドで指定処理を呼び出す実装のサンプルです。
from pynput import keyboard import threading import msvcrt pressed_keys = set() def on_press(key): pressed_keys.add(key) if keyboard.Key.ctrl_l in pressed_keys and key == keyboard.KeyCode.from_char('q'): print("[Ctrl + Q]を検出。メインメニューに戻ります。") flush_stdin() # フラグを立てるなりスレッド停止なりの処理をここで行う def on_release(key): pressed_keys.remove(key) listener = keyboard.Listener(on_press=on_press, on_release=on_release) listener.start() def flush_stdin(): """ これが無いと戻った時に^Qなどの制御文字がinputのバッファに残ってしまう """ while msvcrt.kbhit(): msvcrt.getch() # メイン処理(ex: Prompt.ask()など) while True: try: # 実際の処理に置き換えてください user_input = input("処理を選択してください(1-4) :") print(f"{user_input}が選択されました") except KeyboardInterrupt: break
注意点と補足
❗macOSでは権限設定が必要になることがあります。
❗グローバルフックを使っている為、必ず.start()や.join()で別スレッドでリスニングする必要があります。
💡補足 : macOSやLinuxではmsvcrtモジュールが使用できません。
flush_stdin()の代替にはselectモジュールやttyとtermiosを使う方法があります。
結論
keyboardは便利だが、Ctrl系のキーでは制御文字になることがある- CLIアプリでは
Prompt.ask()などと干渉して入力バッファが破壊される可能性がある pynputは別スレッドでリスナーを動かせるため安定しやすい- Windowsでは
msvcrtを使ってバッファクリアすることで副作用も抑えられる
参考になったログの取り方
def onpress(event): print(f"pressed key : {repr(event.name)}") print(f"event: {event}") keyboard.on_press(on_press)
おわりに
キーボードショートカットの検出は思ったより奥が深いです。
CLIアプリでの利用時にはOSの制御文字処理やライブラリの制限を理解して、慎重に設計する必要があると痛感しました。