Skip to content

Several bugs with pynput.keyboard uinput backend #657

@ntczkjfg

Description

@ntczkjfg

Description
I came across several bugs in the uinput backend of pynput.keyboard while trying to run code like this:

from pynput import keyboard

kb = keyboard.Controller()

# Simulate Ctrl+V press
with kb.pressed(keyboard.Key.ctrl):
    kb.tap('v')

All of the bugs are related to the required_modifiers variable received by the Controller class's _handle function, from the _to_vk_and_modifiers function. In particular, two eventually trace back to when self._char_table is defined in the __init__ function of the Layout class. We have:

        self._char_table = {
            as_char(key): (
                vk,
                set()
                    | {Key.shift} if i & 1 else set()
                    | {Key.alt_gr} if i & 2 else set())
            for vk, keys in self._vk_table.items()
            for i, key in enumerate(keys)
            if key is not None and as_char(key) is not None}

I found this pretty difficult to parse - but here is some selected values for vk, keys, i, and key, when it's building self._char_table for 'v':

vk = 47, keys = <normal: 'v', shifted: 'V', alternative: 'v', shifted alternative: 'V'>
        i = 0, key = 'v'
        i = 1, key = 'V'
        i = 2, key = 'v'
        i = 3, key = 'V'

Now for the actual bugs:

Bug 1: Incorrect modifiers set for shifted alternative characters, due to order of operations precedence issue

When i == 3, we have bool(i & 1) == True and bool(i & 2) == True - and I believe the code would therefore like the set created to be {Key.shift, Key.alt_gr}. However, there's an order of operations issue going on, and it only comes out as {Key.shift}. This can be fixed by adding additional parenthesis as such:

        self._char_table = {
            as_char(key): (
                vk,
                set()
                    | ({Key.shift} if i & 1 else set())
                    | ({Key.alt_gr} if i & 2 else set()))
            for vk, keys in self._vk_table.items()
            for i, key in enumerate(keys)
            if key is not None and as_char(key) is not None}

Test case: x = (set() | {'x'} if True else set() | {'y'} if True else set()) will yield x == {'x'} instead of the desired x == {'x', 'y'}. I believe Python interprets it as x = (set() | {'x'} if True else (set() | {'y'} if True else set())). The fix is equivalent to x = (set() | ({'x'} if True else set()) | ({'y'} if True else set())).

Bug 2: Earlier dict entries are overwritten by later ones, favoring unnecessary modifiers

Because a given key in keys can repeat (we have two copies of both 'v' and 'V' for the V key from above, for example), earlier entries in self._char_table get overwritten by later ones with more modifiers. So the code tries to set the following entries to self._char_table, in this order:

key: value
'v': set()
'V': {Key.shift}
'v': {Key.alt_gr}
'V': {Key.shift, Key.alt_gr} # Using the above fix to bug # 1

And so even though we should have self._char_table['v'] == set() and self._char_table['V'] == {Key.shift}, we actually get self._char_table['v'] == {Key.alt_gr} and self._char_table['V'] == {Key.shift, Key.alt_gr} (again, with the fix to bug # 1 incorporated). My suggested fix to this is to iterate over keys backwards, so if a key repeats, then the one with fewer modifiers is always preferred:

        self._char_table = {
            as_char(key): (
                vk,
                set()
                    | ({Key.shift} if i & 1 else set())
                    | ({Key.alt_gr} if i & 2 else set()))
            for vk, keys in self._vk_table.items()
            for i, key in reversed(list(enumerate(keys)))
            if key is not None and as_char(key) is not None}

(incorporates fix to both bug #1 and bug #2)

With this fix we do get self._char_table['v'] == set() and also self._char_table['V'] == {Key.shift}.

Bug 3: Conditional check required_modifiers is not None will evaluate to True, even when it's the empty set

In the Controller class, in the _handle function, we get required_modifiers, which will be the set created above for a given key. Note above that the set will always be, at a minimum, set() - but in _handle, we have the check required_modifiers is not None - which will always evaluate to True for this. (required_modifiers can also be None, from some other branches of the self._to_vk_and_modifiers function, so a potential fix must account for both cases)

This is a problem because after that check, it runs code that releases any modifiers which are currently pressed, which are not part of required_modifiers. So the effect is that if a key doesn't require any modifiers, this code releases all modifiers anyways. This goes back to my original code sample, where I hold down ctrl and press v - this bug will make it release ctrl before pressing v, even within the with kb.pressed(keyboard.Key.ctrl): block, defeating the point of the block entirely.

The suggested fix here is simple: Just remove the is not None from the check. So the conditional becomes:

if is_press and required_modifiers:

Bug 4: Too many modifiers released

That said, let's say there are required modifiers - like what if we ran this code?

with kb.pressed(keyboard.Key.ctrl):
    kb.tap('V')

Then, we will have required_modifiers == {Key.shift}, correctly. But the code within that conditional from bug # 3 will release the ctrl key before pressing 'V'.

I'm less confident about this suggested fix, but I think it would make more sense if the only keys we could release in this case are Key.shift and Key.alt_gr - or maybe even just get rid of the to_release code all together. I might do it like this:

                to_release = {
                    getattr(evdev.ecodes, key.value._kernel_name)
                    for key in (modifiers - required_modifiers)} & {Key.shift, Key.alt_gr}

It might also be worth adding new code to specifically disallow releasing a key within that key's with kb.pressed block.

With those four changes, the uinput backend to pynput.keyboard works fine for me as a standard keyboard controller - albeit with just standard keyboard keys, not arbitrary unicode.

Platform and pynput version
Fedora Linux 42
KDE 6.4.1 (Wayland)
Pynput 1.8.1

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions