Тебе повезло, я недавно такое делал.
Нельзя просто взять и передать питоновскую функцию в WinAPI, так как у них совершенно разные способы вызова. Тебе сначалу нужно описать тип данных - указатель на функцию. Примерно так.
import ctypes
import ctypes.wintypes as w
LRESULT = w.LPARAM
WNDPROC = ctypes.WINFUNCTYPE(LRESULT, w.HWND, w.UINT, w.WPARAM, w.LPARAM)
Вот после этого ты можешь предоставить свою оконную функцию вида
def wnd_proc(hwnd: w.HWND, message: w.UINT, wParam: w.WPARAM, lParam: w.LPARAM) -> LRESULT:
А потом уже указываешь это в классе окна:
wndСlass.lpfnWndProc = WNDPROC(wnd_proc)
Но тут есть еще один подвох - оконных сообщений много, и их набор отличается для разных версий винды. Есть базовый, более-менее статичный набор, но помимо них может прийти много чего.
Тут на помощь приходит DefWindowProc().
def wnd_proc(hwnd, message, wParam, lParam):
if message == SOME_MESSAGE_YOU_WANT: # отлавливаешь интересующие тебя сообщения
DoStuff() # и обрабатываешь их
return 0 # не забудь вернуть 0 как признак успеха!
# а все остальное отдаёшь в DefWindowProc()
return user32.DefWindowProcW(hwnd, message, wParam, lParam)
Обрати внимание, что как многие функции WinAPI, DefWindowProc() существует в двух видах - с сууфиксом A (однобайтовая кодировка ANSI) и с суффиком W (wide char, двухбайтовый вариант юникода). Смешивать не рекомендую, выбери один суффикс и придерживайся его во всей программе.
И ещё подвох, который меня чуть с ума не свёл - переменная с классом окна ДОЛЖНА существовать, пока существует окно. Иными словами, её не стоит делать локальной в методе - иначе сборщик мусора питона её потом соберёт, что будет неприятным сюрпризом для WinAPI. Поймаешь крэш приложения.