Search code examples
pythontkinterwinapi

Trying to understand this error: Fatal Python error: PyEval_RestoreThread: the function must be called with the GIL held


I have a small tkinter application where I've been playing with implementing a minimal "drag and drop", mostly as a learning experiment. All I really care about is the file path from the dropped files. Everything was actually working fine until I tried to pack a label widget after the drag and drop. Minimal working example below. The offending line is called out with a comment.

I don't typically struggle too much with debugging, but I just have no idea where to even go from here. I know what the GIL is, but not why or how it's an issue here.

The full error:

Fatal Python error: PyEval_RestoreThread: the function must be called with the GIL held, after Python initialization and before Python finalization, but the GIL is released (the 
current Python thread state is NULL)
Python runtime state: initialized

Current thread 0x000030e0 (most recent call first):
  File "C:\<User>\Python\Lib\tkinter\__init__.py", line 1504 in mainloop
  File "c:\Users\<User>\Desktop\<directory>\ui.py", line 80 in __init__
  File "c:\Users\<User>\Desktop\<directory>\ui.py", line 83 in <module>

Extension modules: _win32sysloader, win32api, win32comext.shell.shell, win32trace (total: 4)

ui.py

import tkinter as tk
from tkinter import ttk

import pythoncom

from dnd import DropTarget


class ScrollFrame(ttk.Labelframe):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.canvas = tk.Canvas(self, highlightthickness=0)
        self.frame = ttk.Frame(self.canvas, padding=(10, 0))
        self.scrollbar = ttk.Scrollbar(
            self, orient='vertical', command=self.canvas.yview
        )
        self.canvas.configure(yscrollcommand=self.scrollbar.set)
        self.canvas.create_window((0, 0), window=self.frame, anchor='n')

        self.canvas.pack(side='left', anchor='n', fill='both', expand=True)
        self.scrollbar.pack(side='right', fill='y')

        self.frame.bind('<Configure>', self.on_resize)
        self.frame.bind(
            '<Enter>',
            lambda _: self.canvas.bind_all('<MouseWheel>', self.on_scroll)
        )
        self.frame.bind(
            '<Leave>',
            lambda _: self.canvas.unbind_all('<MouseWheel>')
        )

    def on_resize(self, _):
        self.canvas.configure(scrollregion=self.canvas.bbox('all'))

    def on_scroll(self, e):
        if self.canvas.winfo_height() < self.frame.winfo_height():
            self.canvas.yview_scroll(-e.delta // 120, 'units')


class TrackList(ScrollFrame):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.track_list = set()

        hwnd = self.winfo_id()
        pythoncom.OleInitialize()
        pythoncom.RegisterDragDrop(
            hwnd,
            pythoncom.WrapObject(
                DropTarget(self),
                pythoncom.IID_IDropTarget,
                pythoncom.IID_IDropTarget
            )
        )

    def add_tracks(self, tracks):
        for track in tracks:
            if track not in self.track_list:
                p = ttk.Label(self.frame, text=str(track))
                print(p['text'])
                p.pack() # This is the offending line
        self.track_list.update(tracks)


class UI(ttk.Frame):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.tracks = TrackList(
            self,
            labelwidget=ttk.Label(text='Track List')
        ).pack()


class App(tk.Tk):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.ui = UI(self, name='ui')
        self.ui.pack(fill='both', expand=True)
        self.mainloop()


App()

dnd.py

from pathlib import Path

from pythoncom import TYMED_HGLOBAL, IID_IDropTarget
from pywintypes import com_error
from win32con import CF_HDROP
from win32comext.shell import shell
from win32com.server.policy import DesignatedWrapPolicy


def get_drop_paths(drop):
    try:
        data = CF_HDROP, None, 1, -1, TYMED_HGLOBAL
        path_data = drop.GetData(data)
    except com_error:
        return
    paths = []
    query = shell.DragQueryFile
    for i in range(query(path_data.data_handle, -1)):
        fpath = Path(query(path_data.data_handle, i))
        if fpath.is_dir():
            paths += list(fpath.iterdir())
        else:
            paths.append(fpath)

    return paths


class DropTarget(DesignatedWrapPolicy):
    _public_methods_ = ['DragEnter', 'DragOver', 'DragLeave', 'Drop']
    _com_interface_ = [IID_IDropTarget]

    def __init__(self, widget):
        self._wrap_(self)
        self.widget = widget

    def DragEnter(self, *args):
        ...

    def DragOver(self, *args):
        ...

    def DragLeave(self, *args):
        ...

    def Drop(self, data_object, *args):
        paths = get_drop_paths(data_object)
        self.widget.add_tracks(paths)

I've tried researching the issue with little to show for my efforts. What little I've found suggests it has something to do with C API calls (which I have little knowledge in) - perhaps something with the win32api - and mostly occuring with GUIs (as in my case).


Solution

  • From your code, it looks like this is what is happening:

    • You drop a file which triggers stuff inside DesignatedWrapPolicy
    • That calls DropTarget.Drop
    • That calls TrackList.add_tracks
    • That calls p.pack() but because that is inside a frame and the frame's size changes, its <Configure> binding should be executed.

    I think that at some point, tkinter is trying to call your ScrollFrame.on_resize function but because it's inside the DesignatedWrapPolicy it freaks out and fails.

    To solve that, you can schedule a call to TrackList.add_tracks in the future, when DesignatedWrapPolicy isn't in the call stack. To do that, change self.widget.add_tracks(paths) to self.widget.after(1, self.widget.add_tracks, paths)

    Note: all of this is just speculation because I have no idea how DesignatedWrapPolicy works.