Monitoring clipboard contents on Windows with Python

I was looking for ways to listen to the clipboard and get called for updates as its content changes. I found a couple of ways to achieve this in Python. Some solutions poll for changes, others use ctypes and Win32 APIs.

Working with C bindings in Python is frustrating. The debugger doesn't work well with pointers and C types. You have to build your own C structs to unpack a pointer. Win32 APIs are no exception, as they're written in C/C++ and Python is probably not the best language to use them. But still, it works well enough for our purposes.

We'll explore how do to it in Python using Win32 APIs, or alternatively integrating a utility I've written in C# that retrieves clipboard contents or streams updates to it. I had written this earlier when I didn't know much about Win32 APIs or how to use them, but it still functions well, so I'm leaving it here in this post as reference.

To make working with Win32 APIs easier, we need to install pywin32 package which provides most of the primitives and types for Win32 APIs, though it's not a strict dependency.

Monitoring clipboard updates

Windows provides a couple of methods for data exchange between applications. Clipboard is one of them. All applications have access to it. But we first need to create a primitive "application" that Windows recognizes. We subscribe it for the clipboard updates.

Windows uses windows (hah!) as the building block of applications. I've written about how windows and messaging works on Windows in another post where I explored USB hotplugging events, which might be worth reading.

Let's create a window, and set print function as its window procedure:

import win32api, win32gui

def create_window() -> int:
    """
    Create a window for listening to messages
    :return: window hwnd
    """
    wc = win32gui.WNDCLASS()
    wc.lpfnWndProc = print
    wc.lpszClassName = 'demo'
    wc.hInstance = win32api.GetModuleHandle(None)
    class_atom = win32gui.RegisterClass(wc)
    return win32gui.CreateWindow(class_atom, 'demo', 0, 0, 0, 0, 0, 0, 0, wc.hInstance, None)

if __name__ == '__main__':
    hwnd = create_window()
    win32gui.PumpMessages()

When we run it doesn't do much except to dump messages sent by Windows to console. We receive the first message WM_DWMNCRENDERINGCHANGED, which doesn't concern us.

We need to register this window as a "clipboard format listener" using AddClipboardFormatListener API, to get notified by Windows whenever the contents of the clipboard change.

import ctypes
# ...

if __name__ == '__main__':
    hwnd = create_window()
    ctypes.windll.user32.AddClipboardFormatListener(hwnd)
    win32gui.PumpMessages()

Now when we run this, it still prints the previous message, but when you copy something to the clipboard it receives another message:

2033456 799 1 0
2033456 797 8 0

Decoding the second message:

Value Hex Message
797 0x031D WM_CLIPBOARDUPDATE 🥳

We've received a WM_CLIPBOARDUPDATE message notifying us that the clipboard content has changed. Now we can build our script around it.

import threading
import ctypes
import win32api, win32gui

class Clipboard:
    def _create_window(self) -> int:
        """
        Create a window for listening to messages
        :return: window hwnd
        """
        wc = win32gui.WNDCLASS()
        wc.lpfnWndProc = self._process_message
        wc.lpszClassName = self.__class__.__name__
        wc.hInstance = win32api.GetModuleHandle(None)
        class_atom = win32gui.RegisterClass(wc)
        return win32gui.CreateWindow(class_atom, self.__class__.__name__, 0, 0, 0, 0, 0, 0, 0, wc.hInstance, None)

    def _process_message(self, hwnd: int, msg: int, wparam: int, lparam: int):
        WM_CLIPBOARDUPDATE = 0x031D
        if msg == WM_CLIPBOARDUPDATE:
            print('clipboard updated!')
        return 0

    def listen(self):
        def runner():
            hwnd = self._create_window()
            ctypes.windll.user32.AddClipboardFormatListener(hwnd)
            win32gui.PumpMessages()

        th = threading.Thread(target=runner, daemon=True)
        th.start()
        while th.is_alive():
            th.join(0.25)

if __name__ == '__main__':
    clipboard = Clipboard()
    clipboard.listen()

One thing we need to watch out for is that because win32gui.PumpMessages() is blocking, we cannot stop the script using Ctrl + C. So we run it inside a thread, which lets KeyboardInterrupt to bubble up and terminate the script.

When we run it, and copy something (text, files) and check the console, we can see it prints clipboard updated!.

Now that we have the notification working, let's retrieve the what's actually in the clipboard.

Getting clipboard contents

Windows clipboard has a concept called "clipboard format". When you copy something, (depending on application) the payload is also attached a bunch of metadata, allowing it to be used in various contexts. For example, when you copy a table from a webpage, you have the option to paste it as plain text, or paste it in Excel and have it formatted as a table. You can copy files, images, screenshots into the clipboard and each payload gets stored formatted (again, depending on how the application sets the clipboard content).

Therefore, if we want to get the clipboard contents, we need to specify which format we want in. For now, we'll be dealing with:

Format Value Description
CF_UNICODETEXT 13 Unicode text format
CF_TEXT 1 Text format for ANSI text
CF_HDROP 15 List of files
CF_BITMAP 2 Images e.g. screenshots

To read the clipboard, we'll use OpenClipboard to set a lock first. This ensures other programs can't modify the clipboard while we're trying to read it. We need to release the lock with CloseClipboard once we're done.

Then we'll call IsClipboardFormatAvailable to query a format, then get its contents using GetClipboardData, or fallback to other formats.

from pathlib import Path
from dataclasses import dataclass
from typing import Union, List, Optional

import win32clipboard, win32con

@dataclass
class Clip:
    type: str
    value: Union[str, List[Path]]
    
def read_clipboard() -> Optional[Clip]:
    try:
        win32clipboard.OpenClipboard()
        if win32clipboard.IsClipboardFormatAvailable(win32con.CF_HDROP):
            data: tuple = win32clipboard.GetClipboardData(win32con.CF_HDROP)
            return Clip('files', [Path(f) for f in data])
        elif win32clipboard.IsClipboardFormatAvailable(win32con.CF_UNICODETEXT):
            data: str = win32clipboard.GetClipboardData(win32con.CF_UNICODETEXT)
            return Clip('text', data)
        elif win32clipboard.IsClipboardFormatAvailable(win32con.CF_TEXT):
            data: bytes = win32clipboard.GetClipboardData(win32con.CF_TEXT)
            return Clip('text', data.decode())
        elif win32clipboard.IsClipboardFormatAvailable(win32con.CF_BITMAP):
            # TODO: handle screenshots
            pass
        return None
    finally:
        win32clipboard.CloseClipboard()

if __name__ == '__main__':
    print(read_clipboard())

When we run it, and try copying some text or files, it prints the contents to the console:

Clip(type='text', value='read_clipboard')
Clip(type='files', value=[WindowsPath('C:/Python39/vcruntime140_1.dll'), WindowsPath('C:/Python39/python.exe')])

Now let's bring it all together:

Clipboard listener in Python

I've placed read_clipboard inside Clipboard class, which creates a window and subscribes to clipboard updates. When the clipboard content changes, it triggers suitable callbacks with the parsed content.

For convenience, you can enable trigger_at_start to trigger callbacks with the current clipboard content immediately after listening.

import ctypes
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Union, List, Optional

import win32api, win32clipboard, win32con, win32gui

class Clipboard:
    @dataclass
    class Clip:
        type: str
        value: Union[str, List[Path]]

    def __init__(
            self,
            trigger_at_start: bool = False,
            on_text: Callable[[str], None] = None,
            on_update: Callable[[Clip], None] = None,
            on_files: Callable[[str], None] = None,
    ):
        self._trigger_at_start = trigger_at_start
        self._on_update = on_update
        self._on_files = on_files
        self._on_text = on_text

    def _create_window(self) -> int:
        """
        Create a window for listening to messages
        :return: window hwnd
        """
        wc = win32gui.WNDCLASS()
        wc.lpfnWndProc = self._process_message
        wc.lpszClassName = self.__class__.__name__
        wc.hInstance = win32api.GetModuleHandle(None)
        class_atom = win32gui.RegisterClass(wc)
        return win32gui.CreateWindow(class_atom, self.__class__.__name__, 0, 0, 0, 0, 0, 0, 0, wc.hInstance, None)

    def _process_message(self, hwnd: int, msg: int, wparam: int, lparam: int):
        WM_CLIPBOARDUPDATE = 0x031D
        if msg == WM_CLIPBOARDUPDATE:
            self._process_clip()
        return 0

    def _process_clip(self):
        clip = self.read_clipboard()
        if not clip:
            return

        if self._on_update:
            self._on_update(clip)
        if clip.type == 'text' and self._on_text:
            self._on_text(clip.value)
        elif clip.type == 'files' and self._on_text:
            self._on_files(clip.value)
    
    @staticmethod
    def read_clipboard() -> Optional[Clip]:
        try:
            win32clipboard.OpenClipboard()

            def get_formatted(fmt):
                if win32clipboard.IsClipboardFormatAvailable(fmt):
                    return win32clipboard.GetClipboardData(fmt)
                return None

            if files := get_formatted(win32con.CF_HDROP):
                return Clipboard.Clip('files', [Path(f) for f in files])
            elif text := get_formatted(win32con.CF_UNICODETEXT):
                return Clipboard.Clip('text', text)
            elif text_bytes := get_formatted(win32con.CF_TEXT):
                return Clipboard.Clip('text', text_bytes.decode())
            elif bitmap_handle := get_formatted(win32con.CF_BITMAP):
                # TODO: handle screenshots
                pass

            return None
        finally:
            win32clipboard.CloseClipboard()

    def listen(self):
        if self._trigger_at_start:
            self._process_clip()

        def runner():
            hwnd = self._create_window()
            ctypes.windll.user32.AddClipboardFormatListener(hwnd)
            win32gui.PumpMessages()

        th = threading.Thread(target=runner, daemon=True)
        th.start()
        while th.is_alive():
            th.join(0.25)


if __name__ == '__main__':
    clipboard = Clipboard(on_update=print, trigger_at_start=True)
    clipboard.listen()

When we run it and copy some text, or some files, it dumps the clipboard content just as we want it.

Clipboard.Clip(type='text', value='Clipboard')
Clipboard.Clip(type='files', value=[WindowsPath('C:/Python39/python.exe')])

I haven't managed to retrieve bitmap from the clipboard when taking a screenshot yet, though it shouldn't be too difficult.

It should prove useful for the use case where when you take a screenshot, you can save it automatically as PNG, upload it and copy its URL to clipboard, ready for pasting.

Using dumpclip: polling for changes

Before I could navigate around Win32 APIs easily, I used higher level APIs provided in C# to listen to the clipboard. On that end, I created a mini utility called dumpclip that prints the clipboard content to console as JSON or streams clipboard updates.

The first version of dumpclip had a single function: dumping the clipboard content to console as JSON.

> dumpclip.v1.exe
{"text":"monitor"}

Calling it from Python is quite straightforward using subprocess module. But that also meant polling for changes every second.

import json
import subprocess
import threading
from time import sleep
from typing import Callable


def get_clipboard() -> dict:
    proc = subprocess.run(
        ["dumpclip.v1.exe"],
        stdout=subprocess.PIPE,
        text=True,
    )
    if proc.returncode != 0:
        return {}
    return json.loads(proc.stdout)


def monitor_clipboard(on_change: Callable[[dict], None]) -> None:
    def monitor():
        old = None
        while True:
            new = get_clipboard()
            if old != new:
                on_change(new)
                old = new
            sleep(1)

    th = threading.Thread(target=monitor)
    th.start()

    th.join()


if __name__ == "__main__":
    monitor_clipboard(on_change=print)

It's functional, but we can do better.

Using dumpclip: streaming clipboard updates

The second iteration of dumpclip involved using Win32 APIs. I've used AddClipboardFormatListener to register a callback for clipboard changes in C#, then retrieved & dumped its content as the new content came in.

> dumpclip.v2.exe --listen
{"text":"ClipboardChanged"}
{"text":"monitor"}
{"files":["D:\\path\\to\\file.ext"]}
...

This worked much better. I can process its stdout stream, and trigger a callback directly, instead of polling for changes. But dumpclip launched in listener mode never terminates. We need to read its stdout in real-time.

To stream stdout of a process, we need to launch it with subprocess.Popen and pipe its output to subprocess.PIPE. Then we can read its stdout in a separate thread. Because, the main thread that launches the process will be waiting for the process to terminate (although it never will).

import json
import subprocess
import threading
from typing import Callable

def monitor_clipboard(on_change: Callable[[dict], None]) -> None:
    def read_stdout(proc: subprocess.Popen):
        for line in iter(proc.stdout.readline, ""):
            if line.strip():
                payload = json.loads(line)
                on_change(payload)

    proc = subprocess.Popen(
        ["dumpclip.v2.exe", "--listen"],
        text=True,
        stdout=subprocess.PIPE,
    )
    th = threading.Thread(
        target=read_stdout,
        args=(proc,),
    )
    th.start()
    try:
        proc.wait()
    except KeyboardInterrupt:
        proc.kill()
        raise

if __name__ == "__main__":
    monitor_clipboard(on_change=print)

Because the process doesn't terminate, the thread that consumes its output doesn't stop, either. It keeps processing the output as new content comes in, and idles if there's nothing to consume, as proc.stdout.readline() call is blocking. When the process gets killed, proc.stdout stops blocking and the thread terminates.

To prevent blocking interrupt signal and to allow the script and the process terminate, we need to .wait() the subprocess. This allows KeyboardInterrupt to bubble up and terminate the script (and its subprocesses) when we hit Ctrl + C.

Using dumpclip: async workflow

Just for kicks, I wanted to implement the same operation in async. It turned out to be more straightforward to write and consume. One caveat is that you have to create a wrapper async function to use async/await keywords, so I had to add a main function to do that.

import asyncio
import json
from pathlib import Path
from typing import AsyncIterable

async def monitor_clipboard() -> AsyncIterable[dict]:
    proc = await asyncio.subprocess.create_subprocess_exec(
        "dumpclip.exe",
        "--listen",
        cwd=str(Path(__file__).parent.resolve()),
        stdout=asyncio.subprocess.PIPE,
    )

    while True:
        if raw_bytes := await proc.stdout.readline():
            text = raw_bytes.decode().strip()
            if text:
                yield json.loads(text)

if __name__ == "__main__":
    async def main():
        async for clip in monitor_clipboard():
            print(clip)

    asyncio.get_event_loop().run_until_complete(main())

That's it.
Cheers ✌

If you've found this post useful, consider sharing it.

Last updated: