Skip to content

Added type stub for keyboard #8666

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Sep 5, 2022
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyrightconfig.stricter.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"stubs/invoke",
"stubs/jmespath",
"stubs/jsonschema",
"stubs/keyboard",
"stubs/ldap3",
"stubs/Markdown",
"stubs/mock",
Expand Down
17 changes: 17 additions & 0 deletions stubs/keyboard/@tests/stubtest_allowlist.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# scan_code *should* never be None in real use. This is also according to docs.
keyboard.KeyboardEvent.scan_code
keyboard._keyboard_event.KeyboardEvent.scan_code
# Defaults don't align with possible values
keyboard.mouse.on_button
keyboard.mouse.wait
# Private modules and tests
keyboard.__main__
keyboard._darwinkeyboard
keyboard._darwinmouse
keyboard._keyboard_tests
keyboard._mouse_tests
keyboard._nixcommon
keyboard._nixkeyboard
keyboard._nixmouse
keyboard._winkeyboard
keyboard._winmouse
4 changes: 4 additions & 0 deletions stubs/keyboard/METADATA.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version = "0.13.*"

[tool.stubtest]
ignore_missing_stub = false
169 changes: 169 additions & 0 deletions stubs/keyboard/keyboard/__init__.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
from collections import Counter, defaultdict, deque
from collections.abc import Callable, Generator, Iterable, Sequence
from queue import Queue
from threading import Event as _UninterruptibleEvent
from typing import Optional
from typing_extensions import Literal, TypeAlias

from ._canonical_names import all_modifiers as all_modifiers, sided_modifiers as sided_modifiers
from ._generic import GenericListener as _GenericListener
from ._keyboard_event import KEY_DOWN as KEY_DOWN, KEY_UP as KEY_UP, KeyboardEvent as KeyboardEvent

_Key: TypeAlias = int | str
_ScanCodeList: TypeAlias = list[int] | tuple[int, ...]
_ParseableHotkey: TypeAlias = _Key | list[int | _ScanCodeList] | tuple[int | _ScanCodeList, ...]
_Callback: TypeAlias = Callable[[KeyboardEvent], Optional[bool]] | Callable[[], Optional[bool]]
# Can't use ParamSpecArgs on `args`, only on `*args`
# _P = ParamSpec("_P")
_P: TypeAlias = tuple[object, ...]

version: str

class _Event(_UninterruptibleEvent):
def wait(self) -> None: ... # type: ignore[override] # Actual implementation

def is_modifier(key: _Key | None) -> bool: ...

class _KeyboardListener(_GenericListener):
transition_table: dict[
tuple[Literal["free"], Literal["up"], Literal["modifier"]]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a little excessive. How do we help users by duplicating this whole table at the type level?

| tuple[Literal["free"], Literal["down"], Literal["modifier"]]
| tuple[Literal["pending"], Literal["up"], Literal["modifier"]]
| tuple[Literal["pending"], Literal["down"], Literal["modifier"]]
| tuple[Literal["suppressed"], Literal["up"], Literal["modifier"]]
| tuple[Literal["suppressed"], Literal["down"], Literal["modifier"]]
| tuple[Literal["allowed"], Literal["up"], Literal["modifier"]]
| tuple[Literal["allowed"], Literal["down"], Literal["modifier"]]
| tuple[Literal["free"], Literal["up"], Literal["hotkey"]]
| tuple[Literal["free"], Literal["down"], Literal["hotkey"]]
| tuple[Literal["pending"], Literal["up"], Literal["hotkey"]]
| tuple[Literal["pending"], Literal["down"], Literal["hotkey"]]
| tuple[Literal["suppressed"], Literal["up"], Literal["hotkey"]]
| tuple[Literal["suppressed"], Literal["down"], Literal["hotkey"]]
| tuple[Literal["allowed"], Literal["up"], Literal["hotkey"]]
| tuple[Literal["allowed"], Literal["down"], Literal["hotkey"]]
| tuple[Literal["free"], Literal["up"], Literal["other"]]
| tuple[Literal["free"], Literal["down"], Literal["other"]]
| tuple[Literal["pending"], Literal["up"], Literal["other"]]
| tuple[Literal["pending"], Literal["down"], Literal["other"]]
| tuple[Literal["suppressed"], Literal["up"], Literal["other"]]
| tuple[Literal["suppressed"], Literal["down"], Literal["other"]]
| tuple[Literal["allowed"], Literal["up"], Literal["other"]]
| tuple[Literal["allowed"], Literal["down"], Literal["other"]],
tuple[Literal[False], Literal[True], Literal["free"]]
| tuple[Literal[False], Literal[False], Literal["pending"]]
| tuple[Literal[True], Literal[True], Literal["free"]]
| tuple[Literal[False], Literal[True], Literal["allowed"]]
| tuple[Literal[False], Literal[False], Literal["free"]]
| tuple[Literal[False], Literal[False], Literal["suppressed"]]
| tuple[Literal[False], None, Literal["free"]]
| tuple[Literal[False], None, Literal["suppressed"]]
| tuple[Literal[False], None, Literal["allowed"]]
| tuple[Literal[True], Literal[True], Literal["allowed"]]
| tuple[Literal[False], Literal[False], Literal["allowed"]],
]
active_modifiers: set[int]
blocking_hooks: list[_Callback]
blocking_keys: defaultdict[int, list[_Callback]]
nonblocking_keys: defaultdict[int, list[_Callback]]
blocking_hotkeys: defaultdict[tuple[int, ...], list[_Callback]]
nonblocking_hotkeys: defaultdict[tuple[int, ...], list[_Callback]]
filtered_modifiers: Counter[int]
is_replaying: bool
modifier_states: dict[_Key, str]
def init(self) -> None: ...
def pre_process_event(self, event): ...
def direct_callback(self, event): ...
def listen(self) -> None: ...

def key_to_scan_codes(key: _ParseableHotkey, error_if_missing: bool = ...) -> tuple[int, ...]: ...
def parse_hotkey(hotkey: _ParseableHotkey) -> tuple[tuple[tuple[int, ...], ...], ...]: ...
def send(hotkey: _ParseableHotkey, do_press: bool = ..., do_release: bool = ...) -> None: ...

press_and_release = send

def press(hotkey: _ParseableHotkey) -> None: ...
def release(hotkey: _ParseableHotkey) -> None: ...

# is_pressed cannot check multi-step hotkeys, so not using _ParseableHotkey

def is_pressed(hotkey: _Key | _ScanCodeList) -> bool: ...
def call_later(fn: Callable[..., None], args: _P = ..., delay: float = ...) -> None: ...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be expressible with PEP 646's TypeVarTuple, though we can't use that yet because mypy doesn't support it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to know. I hadn't looked into 3.11's variadic generics yet. That's cool. So this would be dependant on python/mypy#12280

def hook(callback: _Callback, suppress: bool = ..., on_remove: Callable[[], None] = ...) -> Callable[[], None]: ...
def on_press(callback: _Callback, suppress: bool = ...) -> Callable[[], None]: ...
def on_release(callback: _Callback, suppress: bool = ...) -> Callable[[], None]: ...
def hook_key(key: _ParseableHotkey, callback: _Callback, suppress: bool = ...) -> Callable[[], None]: ...
def on_press_key(key: _ParseableHotkey, callback: _Callback, suppress: bool = ...) -> Callable[[], None]: ...
def on_release_key(key: _ParseableHotkey, callback: _Callback, suppress: bool = ...) -> Callable[[], None]: ...
def unhook(remove: _Callback) -> None: ...

unhook_key = unhook

def unhook_all() -> None: ...
def block_key(key: _ParseableHotkey) -> Callable[[], None]: ...

unblock_key = unhook_key

def remap_key(src: _ParseableHotkey, dst: _ParseableHotkey) -> Callable[[], None]: ...

unremap_key = unhook_key

def parse_hotkey_combinations(hotkey: _ParseableHotkey) -> tuple[tuple[tuple[int, ...], ...], ...]: ...
def add_hotkey(
hotkey: _ParseableHotkey,
callback: Callable[..., bool | None],
args: _P = ...,
suppress: bool = ...,
timeout: float = ...,
trigger_on_release: bool = ...,
) -> Callable[[], None]: ...

register_hotkey = add_hotkey

def remove_hotkey(hotkey_or_callback: _ParseableHotkey | _Callback) -> None: ...

unregister_hotkey = remove_hotkey
clear_hotkey = remove_hotkey

def unhook_all_hotkeys() -> None: ...

unregister_all_hotkeys = unhook_all_hotkeys
remove_all_hotkeys = unhook_all_hotkeys
clear_all_hotkeys = unhook_all_hotkeys

def remap_hotkey(
src: _ParseableHotkey, dst: _ParseableHotkey, suppress: bool = ..., trigger_on_release: bool = ...
) -> Callable[[], None]: ...

unremap_hotkey = remove_hotkey

def stash_state() -> list[int]: ...
def restore_state(scan_codes: Iterable[int]) -> None: ...
def restore_modifiers(scan_codes: Iterable[int]) -> None: ...
def write(text: str, delay: float = ..., restore_state_after: bool = ..., exact: bool | None = ...) -> None: ...
def wait(hotkey: _ParseableHotkey | None = ..., suppress: bool = ..., trigger_on_release: bool = ...) -> None: ...
def get_hotkey_name(names: Iterable[str] | None = ...) -> str: ...
def read_event(suppress: bool = ...) -> KeyboardEvent: ...
def read_key(suppress: bool = ...) -> _Key: ...
def read_hotkey(suppress: bool = ...) -> str: ...
def get_typed_strings(events: Iterable[KeyboardEvent], allow_backspace: bool = ...) -> Generator[str, None, None]: ...
def start_recording(
recorded_events_queue: Queue[KeyboardEvent] | None = ...,
) -> tuple[Queue[KeyboardEvent], Callable[[], None]]: ...
def stop_recording() -> list[deque[KeyboardEvent]]: ...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's just a list of KeyboardEvents.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, I got confused into thinking it was a list of deque, but it's just converting

def record(until: str = ..., suppress: bool = ..., trigger_on_release: bool = ...) -> list[deque[KeyboardEvent]]: ...
def play(events: Iterable[KeyboardEvent], speed_factor: float = ...) -> None: ...

replay = play

def add_word_listener(
word: str, callback: _Callback, triggers: Sequence[str] = ..., match_suffix: bool = ..., timeout: float = ...
) -> Callable[[], None]: ...
def remove_word_listener(word_or_handler: str | _Callback) -> None: ...
def add_abbreviation(
source_text: str, replacement_text: str, match_suffix: bool = ..., timeout: float = ...
) -> Callable[[], None]: ...

register_word_listener = add_word_listener
register_abbreviation = add_abbreviation
remove_abbreviation = remove_word_listener
6 changes: 6 additions & 0 deletions stubs/keyboard/keyboard/_canonical_names.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
basestring = str
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remove this, it's clearly not meant as a part of the API.

canonical_names: dict[str, str]
sided_modifiers: set[str]
all_modifiers: set[str]

def normalize_name(name: str) -> str: ...
23 changes: 23 additions & 0 deletions stubs/keyboard/keyboard/_generic.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from collections.abc import Callable
from queue import Queue
from threading import Lock, Thread
from typing_extensions import Literal, TypeAlias

from ._keyboard_event import KeyboardEvent
from ._mouse_event import _MouseEvent

_Event: TypeAlias = KeyboardEvent | _MouseEvent

class GenericListener:
lock: Lock
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
lock: Lock
lock: ClassVar[Lock]

handlers: list[Callable[[_Event], bool | None]]
listening: bool
queue: Queue[_Event]
listening_thread: Thread | None
processing_thread: Thread | None
def invoke_handlers(self, event: _Event) -> Literal[1] | None: ...
def start_if_necessary(self) -> None: ...
def pre_process_event(self, event: _Event) -> None: ...
def process(self) -> None: ...
def add_handler(self, handler: Callable[[_Event], bool | None]) -> None: ...
def remove_handler(self, handler: Callable[[_Event], bool | None]) -> None: ...
29 changes: 29 additions & 0 deletions stubs/keyboard/keyboard/_keyboard_event.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from typing_extensions import Literal

from ._canonical_names import canonical_names as canonical_names, normalize_name as normalize_name

basestring = str
KEY_DOWN: Literal["down"]
KEY_UP: Literal["up"]

class KeyboardEvent:
event_type: Literal["down", "up"] | None
scan_code: int
name: str | None
time: float | None
device: str | None
modifiers: tuple[str, ...] | None
is_keypad: bool | None

def __init__(
self,
event_type: Literal["down", "up"] | None,
scan_code: int,
name: str | None = ...,
time: float | None = ...,
device: str | None = ...,
modifiers: tuple[str, ...] | None = ...,
is_keypad: bool | None = ...,
) -> None: ...
def to_json(self, ensure_ascii: bool = ...) -> str: ...
def __eq__(self, other: object) -> bool: ...
43 changes: 43 additions & 0 deletions stubs/keyboard/keyboard/_mouse_event.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import sys
from typing import NamedTuple
from typing_extensions import Literal, TypeAlias

_MouseEvent: TypeAlias = ButtonEvent | WheelEvent | MoveEvent # noqa: Y047 # Used outside

LEFT: Literal["left"]
RIGHT: Literal["right"]
MIDDLE: Literal["middle"]
X: Literal["x"]
X2: Literal["x2"]

UP: Literal["up"]
DOWN: Literal["down"]
DOUBLE: Literal["double"]
WHEEL: Literal["wheel"]

VERTICAL: Literal["vertical"]
HORIZONTAL: Literal["horizontal"]

if sys.platform == "linux" or sys.platform == "win32":
_MouseButton: TypeAlias = Literal["left", "right", "middle", "x", "x2"]
else:
_MouseButton: TypeAlias = Literal["left", "right", "middle"]

if sys.platform == "win32":
_MouseEventType: TypeAlias = Literal["up", "down", "double", "wheel"]
else:
_MouseEventType: TypeAlias = Literal["up", "down"]

class ButtonEvent(NamedTuple):
event_type: _MouseEventType
button: _MouseButton
time: float

class WheelEvent(NamedTuple):
delta: int
time: float

class MoveEvent(NamedTuple):
x: int
y: int
time: float
76 changes: 76 additions & 0 deletions stubs/keyboard/keyboard/mouse.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import sys
from collections.abc import Callable, Iterable
from ctypes import c_long
from typing_extensions import Literal, TypeAlias

from ._generic import GenericListener as _GenericListener
from ._mouse_event import (
DOUBLE as DOUBLE,
DOWN as DOWN,
LEFT as LEFT,
MIDDLE as MIDDLE,
RIGHT as RIGHT,
UP as UP,
X2 as X2,
ButtonEvent as ButtonEvent,
MoveEvent as MoveEvent,
WheelEvent as WheelEvent,
X as X,
_MouseButton,
_MouseEvent,
_MouseEventType,
)

# Can't use ParamSpecArgs on `args`, only on `*args`
# _P = ParamSpec("_P")
_P: TypeAlias = tuple[object, ...]
_Callback: TypeAlias = Callable[[_MouseEvent], bool | None]

class _MouseListener(_GenericListener):
def init(self) -> None: ...
def pre_process_event( # type: ignore[override] # Mouse specific events and return
self, event: _MouseEvent
) -> Literal[True]: ...
def listen(self) -> None: ...

def is_pressed(button: _MouseButton = ...): ...
def press(button: _MouseButton = ...) -> None: ...
def release(button: _MouseButton = ...) -> None: ...
def click(button: _MouseButton = ...) -> None: ...
def double_click(button: _MouseButton = ...) -> None: ...
def right_click() -> None: ...
def wheel(delta: int = ...) -> None: ...
def move(x: int | c_long, y: int | c_long, absolute: bool = ..., duration: float = ...) -> None: ...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def move(x: int | c_long, y: int | c_long, absolute: bool = ..., duration: float = ...) -> None: ...
def move(x: SupportsInt, y: SupportsInt, absolute: bool = ..., duration: float = ...) -> None: ...

It calls int() on x and y. Strictly a few more types are allowed:

def __new__(cls: type[Self], __x: str | ReadableBuffer | SupportsInt | SupportsIndex | SupportsTrunc = ...) -> Self: ...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't know about that one. Thanks!

def drag(start_x: int, start_y: int, end_x: int, end_y: int, absolute: bool = ..., duration: float = ...) -> None: ...
def on_button(
callback: Callable[..., None],
args: _P = ...,
buttons: list[_MouseButton] | tuple[_MouseButton, ...] | _MouseButton = ...,
types: list[_MouseEventType] | tuple[_MouseEventType, ...] | _MouseEventType = ...,
) -> _Callback: ...
def on_click(callback: Callable[..., None], args: _P = ...) -> _Callback: ...
def on_double_click(callback: Callable[..., None], args: _P = ...) -> _Callback: ...
def on_right_click(callback: Callable[..., None], args: _P = ...) -> _Callback: ...
def on_middle_click(callback: Callable[..., None], args: _P = ...) -> _Callback: ...
def wait(button: _MouseButton = ..., target_types: tuple[_MouseEventType] = ...) -> None: ...

if sys.platform == "win32":
def get_position() -> tuple[c_long, c_long]: ...

else:
def get_position() -> tuple[int, int]: ...

def hook(callback: _Callback) -> _Callback: ...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could use a bound TypeVar to signify that the exact same type is returned.

def unhook(callback: _Callback) -> None: ...
def unhook_all() -> None: ...
def record(button: _MouseButton = ..., target_types: tuple[_MouseEventType] = ...) -> _MouseEvent: ...
def play(
events: Iterable[_MouseEvent],
speed_factor: float = ...,
include_clicks: bool = ...,
include_moves: bool = ...,
include_wheel: bool = ...,
) -> None: ...

replay = play
hold = press