某PCショップ店員の覚書

勤務中に作成したプログラムやスクリプトのまとめ

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として来るため、ショートカット検出が失敗します。

対策

  1. 低レベルでの制御文字検出に対応する
    keyboard.add_hotkey('\x11', on_ctrl_q)
    これは一部環境では有効ですが、一般的なCtrl + Qの指定と両立させる必要があります。
  2. より信頼性の高い方法に切り替える
  3. Windows限定であればpyHookpynputを使用する方が安定する
  4. 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()で別スレッドでリスニングする必要があります。

💡補足 : macOSLinuxではmsvcrtモジュールが使用できません。
flush_stdin()の代替にはselectモジュールやttytermiosを使う方法があります。

結論

  • 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の制御文字処理やライブラリの制限を理解して、慎重に設計する必要があると痛感しました。