pynput icon indicating copy to clipboard operation
pynput copied to clipboard

MacOS crash when starting pynput keyboard listener after qt6

Open perroboc opened this issue 2 years ago • 6 comments

Description If I create and start a listener AFTER launching a qt6 app (pyside6), the application crashes.

Platform and pynput version macOS Monterey (12.6), M1 Pro. Pynput 1.7.6, Pyside6 6.4.0, Python 3.10. I'm also using a Latin American keyboard layout.

To Reproduce https://github.com/alvaromunoz/pynput-macos-issues/blob/ba17e4b83f1eb239454d73ba96adf7f5fe71673b/nested_crash.py

import sys

from PySide6 import (
    QtWidgets,
)

from pynput import keyboard

class MyListener():

    def __init__(self, label: QtWidgets.QLabel):

        def on_press(key):
            label.setText("You pressed {0}".format(key))

        def on_release(key):
            label.setText("You released {0}".format(key))

        self.listener = keyboard.Listener(
            on_press=on_press,
            on_release=on_release)
        
    def start(self):
        self.listener.start()
        self.listener.wait()
        print("listener has started? {0}".format(self.listener.running))

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("Listener Demo 2")

        central_widget = QtWidgets.QWidget()

        layout = QtWidgets.QVBoxLayout()
        central_widget.setLayout(layout)

        label_description = QtWidgets.QLabel("Press the button to crash")
        layout.addWidget(label_description)

        label = QtWidgets.QLabel("Press any key")
        label.setEnabled(False)
        layout.addWidget(label)

        button = QtWidgets.QPushButton("Start listener!")
        button.pressed.connect(self.start_listener)
        layout.addWidget(button)
        
        self.listener = MyListener(label)

        self.setCentralWidget(central_widget)

    def start_listener(self):
        print("beep")
        self.listener.start()
        print("boop")

app = QtWidgets.QApplication(sys.argv)

window = MainWindow()
window.show()

app.exec()

OUTPUT:

beep
zsh: trace trap

perroboc avatar Oct 24 '22 19:10 perroboc

FYI: this happens with tkinter and pyqt6, too:

tkinter:

from tkinter import *

from pynput import keyboard

class MyListener():

    def __init__(self):

        def on_press(key):
            print("You pressed {0}".format(key))

        def on_release(key):
            print("You released {0}".format(key))

        self.listener = keyboard.Listener(
            on_press=on_press,
            on_release=on_release)
        
    def start(self):
        self.listener.start()
        self.listener.wait()
        print("listener has started? {0}".format(self.listener.running))

listener = MyListener()

root = Tk()  # create parent window

def start_pynput():
    print("beep")
    listener.start()
    print("boop")

# use Button and Label widgets to create a simple TV remote
button = Button(root, text="Start Pynput", command=start_pynput)
button.pack()

root.mainloop()

pyqt6

import sys

from PyQt6 import (
    QtWidgets,
)

from pynput import keyboard

class MyListener():

    def __init__(self, label: QtWidgets.QLabel):

        def on_press(key):
            label.setText("You pressed {0}".format(key))

        def on_release(key):
            label.setText("You released {0}".format(key))

        self.listener = keyboard.Listener(
            on_press=on_press,
            on_release=on_release)
        
    def start(self):
        self.listener.start()
        self.listener.wait()
        print("listener has started? {0}".format(self.listener.running))

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("Listener Demo 2")

        central_widget = QtWidgets.QWidget()

        layout = QtWidgets.QVBoxLayout()
        central_widget.setLayout(layout)

        label_description = QtWidgets.QLabel("Press the button to crash")
        layout.addWidget(label_description)

        label = QtWidgets.QLabel("Press any key")
        label.setEnabled(False)
        layout.addWidget(label)

        button = QtWidgets.QPushButton("Start listener!")
        button.pressed.connect(self.start_listener)
        layout.addWidget(button)
        
        self.listener = MyListener(label)

        self.setCentralWidget(central_widget)

    def start_listener(self):
        print("beep")
        self.listener.start()
        print("boop")

app = QtWidgets.QApplication(sys.argv)

window = MainWindow()
window.show()

app.exec()

perroboc avatar Oct 31 '22 01:10 perroboc

Same here, pynput works fine pyperclip, PySide6 at the first place, but it shows error message as follow after pressing the button.

Error message: [1] 31161 trace trap /<path_to_current_folder/.venv/bin/python

working environment:

  • macOS Monterey 12.6, M1 pro
  • Python 3.9.13
  • PySide6 6.4.0.1
  • pyperclip 1.8.2
  • pynput 1.7.6

here's the code

# -*- coding: utf-8 -*-
import random
import string
import time

import pyperclip
from pynput import keyboard
from PySide6 import QtCore
from PySide6 import QtWidgets

class Main(QtWidgets.QMainWindow):
    signal = QtCore.Signal()

    def __init__(self):
        super().__init__()

    def build(self):
        self.signal.connect(self.get_text_from_clip)
        self.button = QtWidgets.QPushButton()
        self.button.clicked.connect(self.button_pressed)
        self.keyboard_detect_start()
        self.setCentralWidget(self.button)
        self.show()

    def keyboard_detect_start(self, key="<ctrl>+q"):
        def for_canonical(hotkey_event):
            return lambda key: hotkey_event(self.listener.canonical(key))

        hotkey = keyboard.HotKey(keyboard.HotKey.parse(key), self.signal.emit)
        self.listener = keyboard.Listener(
            on_press=for_canonical(hotkey.press),
            on_release=for_canonical(hotkey.release),
        )
        self.listener.start()

    def get_text_from_clip(self):
        controller = keyboard.Controller()
        controller.press(keyboard.Key.cmd)
        controller.press("c")
        controller.release("c")
        controller.release(keyboard.Key.cmd)
        time.sleep(0.05)
        print(pyperclip.paste().strip())

    def button_pressed(self):
        self.listener.stop()
        self.listener.join()
        time.sleep(0.5)
        new_key = random.choices(string.ascii_lowercase)[0]
        print("new_key: ", new_key)
        self.keyboard_detect_start(key=f"<ctrl>+{new_key}")


if __name__ == "__main__":
    app = QtWidgets.QApplication([])
    main = Main()
    main.build()
    app.exec()

ba361006 avatar Nov 17 '22 03:11 ba361006

@ba361006 could you please check if #512 fixes your issue?

perroboc avatar Nov 17 '22 13:11 perroboc

@alvaromunoz

Yes, #512 does fix this for me. It would be appreciated that if #512 can be merged :)

[Edited] 2022/11/22 With applying #512 , the following fail case still raises [1] 93758 trace trap <path_to_project>/.venv/bin/python error, but somehow it works by invoking print_text_from_clipboard via signal

So far I know is that the fail case stop at controller = keyboard.Controller() and raise the error mentioned above. Firstly I thought maybe Mac will limit only one thread to have control of keyboard event, so I instantiate worker and connect the signal under Main which should be at the same scope with the app, and eventually it works.

But if I simply change the way of invoking print_text_from_clipboard it fails, which is the only difference between fail and work case.

  • Fail case
# -*- coding: utf-8 -*-
import time
import platform
import pyperclip
from pynput import keyboard
from PySide6 import QtCore
from PySide6 import QtWidgets

class Worker(QtCore.QObject):
    def __init__(self):
        super().__init__()
    
    def run_method(self):
        activate_key = "<ctrl>+q"
        def for_canonical(hotkey_event):
            return lambda key: hotkey_event(self.listener.canonical(key))
        hotkey = keyboard.HotKey(keyboard.HotKey.parse(activate_key), self.print_text_from_clipboard)
        self.listener = keyboard.Listener(
            on_press=for_canonical(hotkey.press),
            on_release=for_canonical(hotkey.release),
        )
        self.listener.start()
    
    def print_text_from_clipboard(self):
        # delay is needed before getting str from clip
        if platform.system() == "Windows":
            modifier = keyboard.Key.ctrl
        elif platform.system() == "Darwin":
            modifier = keyboard.Key.cmd
        controller = keyboard.Controller()
        controller.press(modifier)
        controller.press("c")
        controller.release("c")
        controller.release(modifier)
        time.sleep(0.05)
        print("text: ", pyperclip.paste().strip())

class Main(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()

    def build(self):
        self.worker = Worker()
        
        self.button = QtWidgets.QPushButton()
        self.button.clicked.connect(self.button_pressed)
        self.setCentralWidget(self.button)
        self.show()
        
    def button_pressed(self):
        self.worker.run_method()
        
if __name__ == "__main__":
    app = QtWidgets.QApplication([])
    main = Main()
    main.build()
    app.exec()
  • Work case
# -*- coding: utf-8 -*-
import time
import platform
import pyperclip
from pynput import keyboard
from PySide6 import QtCore
from PySide6 import QtWidgets

class Worker(QtCore.QObject):
   signal = QtCore.Signal()
   def __init__(self):
       super().__init__()
   
   def run_method(self):
       activate_key = "<ctrl>+q"
       def for_canonical(hotkey_event):
           return lambda key: hotkey_event(self.listener.canonical(key))
       
       hotkey = keyboard.HotKey(keyboard.HotKey.parse(activate_key), self.signal.emit)
       self.listener = keyboard.Listener(
           on_press=for_canonical(hotkey.press),
           on_release=for_canonical(hotkey.release),
       )
       self.listener.start()

   def print_text_from_clipboard(self):
       # delay is needed before getting str from clip
       if platform.system() == "Windows":
           modifier = keyboard.Key.ctrl
       elif platform.system() == "Darwin":
           modifier = keyboard.Key.cmd
       controller = keyboard.Controller()
       controller.press(modifier)
       controller.press("c")
       controller.release("c")
       controller.release(modifier)
       time.sleep(0.05)
       print("text: ", pyperclip.paste().strip())

class Main(QtWidgets.QMainWindow):
   def __init__(self):
       super().__init__()

   def build(self):
       self.worker = Worker()
       self.worker.signal.connect(self.worker.print_text_from_clipboard)
       
       self.button = QtWidgets.QPushButton()
       self.button.clicked.connect(self.button_pressed)
       self.setCentralWidget(self.button)
       self.show()
       
   def button_pressed(self):
       self.worker.run_method()
       
if __name__ == "__main__":
   app = QtWidgets.QApplication([])
   main = Main()
   main.build()
   app.exec()

ba361006 avatar Nov 20 '22 07:11 ba361006

Just found something interesting.

Without applying changes in #512, the following code works.
It seems that you can't invoking keyboard.Listener via Signal or clicked.connect.

It reminds me that PyQt5 have some tricky stuff with QThread related to scope problem, which means where you instantiate the QThread or invoking moveToThread to your customised QObject class matters, but I can't recall the detail.

Does this code work for you? @alvaromunoz

# -*- coding: utf-8 -*-
from pynput import keyboard
from PySide6 import QtWidgets


class Main(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()

    def build(self):
        def for_canonical(hotkey_event):
            return lambda key: hotkey_event(self.listener.canonical(key))
        hotkey = keyboard.HotKey(keyboard.HotKey.parse("<ctrl>+q"), lambda: print("keyboard detected"))
        self.listener = keyboard.Listener(
            on_press=for_canonical(hotkey.press),
            on_release=for_canonical(hotkey.release),
        )
        self.listener.start()
        self.show()


if __name__ == "__main__":
    app = QtWidgets.QApplication([])
    main = Main()
    main.build()
    app.exec()

ba361006 avatar Nov 22 '22 03:11 ba361006

Same issue, MacBook Air M2 on MacOs Sonoma 14.0 Beta

9783e6 avatar Aug 28 '23 17:08 9783e6