From 095ff404cdf8191edac9019dc0ef6ccfba698342 Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Fri, 31 Jul 2026 22:39:17 -0400 Subject: [PATCH 1/7] refactor: adopt the src/ layout With the package at the repository root, `import directkeys` from the checkout directory picks up the source tree rather than the installed distribution. That hides packaging mistakes: a module missing from the wheel still imports fine locally and only fails for users. Moving to src/ makes the test run exercise the installed package. Also folds .coveragerc into pyproject.toml and configures pytest there, so `testpaths` and the tests/manual exclusion no longer have to be repeated on every invocation. Co-Authored-By: Claude Opus 5 --- .coveragerc | 23 ----------------- .github/workflows/tests.yml | 3 ++- Makefile | 12 +++++++-- pyproject.toml | 25 ++++++++++++++++++- {directkeys => src/directkeys}/__init__.py | 0 {directkeys => src/directkeys}/__main__.py | 0 .../directkeys}/_canonical_names.py | 0 .../directkeys}/_darwinkeyboard.py | 0 .../directkeys}/_darwinmouse.py | 0 {directkeys => src/directkeys}/_generic.py | 0 .../directkeys}/_keyboard_event.py | 0 .../directkeys}/_mouse_event.py | 0 {directkeys => src/directkeys}/_nixcommon.py | 0 .../directkeys}/_nixkeyboard.py | 0 {directkeys => src/directkeys}/_nixmouse.py | 0 .../directkeys}/_winkeyboard.py | 0 {directkeys => src/directkeys}/_winmouse.py | 0 {directkeys => src/directkeys}/mouse.py | 0 18 files changed, 36 insertions(+), 27 deletions(-) delete mode 100644 .coveragerc rename {directkeys => src/directkeys}/__init__.py (100%) rename {directkeys => src/directkeys}/__main__.py (100%) rename {directkeys => src/directkeys}/_canonical_names.py (100%) rename {directkeys => src/directkeys}/_darwinkeyboard.py (100%) rename {directkeys => src/directkeys}/_darwinmouse.py (100%) rename {directkeys => src/directkeys}/_generic.py (100%) rename {directkeys => src/directkeys}/_keyboard_event.py (100%) rename {directkeys => src/directkeys}/_mouse_event.py (100%) rename {directkeys => src/directkeys}/_nixcommon.py (100%) rename {directkeys => src/directkeys}/_nixkeyboard.py (100%) rename {directkeys => src/directkeys}/_nixmouse.py (100%) rename {directkeys => src/directkeys}/_winkeyboard.py (100%) rename {directkeys => src/directkeys}/_winmouse.py (100%) rename {directkeys => src/directkeys}/mouse.py (100%) diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 6d16b528..00000000 --- a/.coveragerc +++ /dev/null @@ -1,23 +0,0 @@ -[run] -branch = True - -[report] -exclude_lines = - pragma: no cover - - pass - - def __repr__ - - raise NotImplementedError - - if __name__ == .__main__.: - - from. import - - import - - except ImportError: - -[html] -directory = coverage_html_report diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2e262f54..da2d1091 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,7 +38,8 @@ jobs: pip install -e . - name: Run tests - run: python -m pytest tests/ -v --ignore=tests/manual + # testpaths and norecursedirs come from pyproject.toml. + run: python -m pytest -v build: runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 8767c8aa..f50f66cf 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,15 @@ test: - python -m pytest tests/ --cov=directkeys --cov-report=html + python -m pytest --cov=directkeys --cov-report=html -build: tests directkeys pyproject.toml README.md CHANGES.md MANIFEST.in +lint: + python -m ruff check . + python -m ruff format --check . + +format: + python -m ruff check --fix . + python -m ruff format . + +build: tests src/directkeys pyproject.toml README.md CHANGES.md MANIFEST.in python ../docstring2markdown/docstring2markdown.py directkeys "https://github.com/WigoWigo10/keyboard/blob/master" > README.md find . \( -name "*.py" -o -name "*.sh" -o -name "* .md" \) -exec dos2unix {} \; python -m build && twine check dist/* diff --git a/pyproject.toml b/pyproject.toml index 4bf5502d..fe8836c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,9 +36,32 @@ Changelog = "https://github.com/WigoWigo10/keyboard/blob/master/CHANGES.md" Issues = "https://github.com/WigoWigo10/keyboard/issues" [tool.setuptools] -packages = ["directkeys"] +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] # Read statically from the module, so the version has a single source of truth # and the package does not have to be imported at build time. [tool.setuptools.dynamic] version = { attr = "directkeys.version" } + +[tool.pytest.ini_options] +testpaths = ["tests"] +# tests/manual needs a physical keyboard and a human operator. +norecursedirs = ["tests/manual"] + +[tool.coverage.run] +branch = true +source = ["directkeys"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] + +[tool.coverage.html] +directory = "coverage_html_report" diff --git a/directkeys/__init__.py b/src/directkeys/__init__.py similarity index 100% rename from directkeys/__init__.py rename to src/directkeys/__init__.py diff --git a/directkeys/__main__.py b/src/directkeys/__main__.py similarity index 100% rename from directkeys/__main__.py rename to src/directkeys/__main__.py diff --git a/directkeys/_canonical_names.py b/src/directkeys/_canonical_names.py similarity index 100% rename from directkeys/_canonical_names.py rename to src/directkeys/_canonical_names.py diff --git a/directkeys/_darwinkeyboard.py b/src/directkeys/_darwinkeyboard.py similarity index 100% rename from directkeys/_darwinkeyboard.py rename to src/directkeys/_darwinkeyboard.py diff --git a/directkeys/_darwinmouse.py b/src/directkeys/_darwinmouse.py similarity index 100% rename from directkeys/_darwinmouse.py rename to src/directkeys/_darwinmouse.py diff --git a/directkeys/_generic.py b/src/directkeys/_generic.py similarity index 100% rename from directkeys/_generic.py rename to src/directkeys/_generic.py diff --git a/directkeys/_keyboard_event.py b/src/directkeys/_keyboard_event.py similarity index 100% rename from directkeys/_keyboard_event.py rename to src/directkeys/_keyboard_event.py diff --git a/directkeys/_mouse_event.py b/src/directkeys/_mouse_event.py similarity index 100% rename from directkeys/_mouse_event.py rename to src/directkeys/_mouse_event.py diff --git a/directkeys/_nixcommon.py b/src/directkeys/_nixcommon.py similarity index 100% rename from directkeys/_nixcommon.py rename to src/directkeys/_nixcommon.py diff --git a/directkeys/_nixkeyboard.py b/src/directkeys/_nixkeyboard.py similarity index 100% rename from directkeys/_nixkeyboard.py rename to src/directkeys/_nixkeyboard.py diff --git a/directkeys/_nixmouse.py b/src/directkeys/_nixmouse.py similarity index 100% rename from directkeys/_nixmouse.py rename to src/directkeys/_nixmouse.py diff --git a/directkeys/_winkeyboard.py b/src/directkeys/_winkeyboard.py similarity index 100% rename from directkeys/_winkeyboard.py rename to src/directkeys/_winkeyboard.py diff --git a/directkeys/_winmouse.py b/src/directkeys/_winmouse.py similarity index 100% rename from directkeys/_winmouse.py rename to src/directkeys/_winmouse.py diff --git a/directkeys/mouse.py b/src/directkeys/mouse.py similarity index 100% rename from directkeys/mouse.py rename to src/directkeys/mouse.py From 4655444b9ac9cddba35e3ad36f251cae71797ab1 Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Fri, 31 Jul 2026 22:45:12 -0400 Subject: [PATCH 2/7] refactor: require Python 3.9+ and drop the Python 2 compatibility shims Python 3.8 reached end of life in October 2024 and no longer receives security fixes, so requires-python moves to 3.9 and the CI matrix follows (3.9, 3.11, 3.13 on Windows and Linux). This also removes the need to pin an older Ubuntu runner, since 3.8 is the version ubuntu-latest stopped shipping. With Python 2 long out of scope, the compatibility layer was dead weight that made every module harder to read: - Remove the `basestring` / `unichr` / `long` fallbacks and the `from __future__` imports, using `str`, `chr` and `int` directly. - Remove the `time.monotonic` fallback, present on every supported version. - Replace the Python2 `Queue` / `threading._Event` branch with a plain import. - Turn the `_is_str` / `_is_number` / `_is_list` lambdas into functions and drop the redundant `(object)` base classes and UTF-8 coding cookies. - Restore two upstream comments explaining `_State` and the `_Event.wait` override, lost when the compatibility block was edited. Also introduces `__version__` as the canonical attribute, keeping `version` as an alias so code reading `keyboard.version` keeps working. The setuptools dynamic version now points at `__version__`, which is a literal and can be read statically; the alias is not, and would have forced an import at build time. Co-Authored-By: Claude Opus 5 --- .github/workflows/tests.yml | 9 +------- pyproject.toml | 7 +++--- src/directkeys/__init__.py | 36 +++++++++++++----------------- src/directkeys/__main__.py | 1 - src/directkeys/_canonical_names.py | 10 +-------- src/directkeys/_darwinkeyboard.py | 17 +++++--------- src/directkeys/_darwinmouse.py | 2 +- src/directkeys/_generic.py | 3 +-- src/directkeys/_keyboard_event.py | 9 +------- src/directkeys/_mouse_event.py | 1 - src/directkeys/_nixcommon.py | 5 ++--- src/directkeys/_nixkeyboard.py | 1 - src/directkeys/_nixmouse.py | 1 - src/directkeys/_winkeyboard.py | 10 +-------- src/directkeys/_winmouse.py | 1 - src/directkeys/mouse.py | 1 - tests/test_keyboard.py | 2 -- tests/test_mouse.py | 3 +-- 18 files changed, 34 insertions(+), 85 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index da2d1091..3ea74220 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,14 +15,7 @@ jobs: # The Linux backend only touches /dev/input from init(), so the suite # imports and runs unprivileged against the fake backend, as on Windows. os: [windows-latest, ubuntu-latest] - python-version: ['3.11', '3.13'] - include: - # ubuntu-latest no longer ships 3.8, the oldest version claimed by - # requires-python, so pin the older runner for that one job. - - os: ubuntu-22.04 - python-version: '3.8' - - os: windows-latest - python-version: '3.8' + python-version: ['3.9', '3.11', '3.13'] steps: - uses: actions/checkout@v4 diff --git a/pyproject.toml b/pyproject.toml index fe8836c9..d06ea616 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description ="A modern and robust keyboard hooking and simulation library for Windows and Linux, focusing on low-level control." readme = { file = "README.md", content-type = "text/markdown" } license = { file = "LICENSE.txt" } -requires-python = ">=3.8" +requires-python = ">=3.9" authors = [{ name = "WigoWigo", email = "hiigoor93@gmail.com" }] keywords = ["directkeys", "keyboard", "hook", "simulate", "hotkey", "low-level", "win32", "sendinput"] classifiers = [ @@ -18,7 +18,6 @@ classifiers = [ "Operating System :: POSIX :: Linux", "Operating System :: MacOS :: MacOS X", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -44,7 +43,9 @@ where = ["src"] # Read statically from the module, so the version has a single source of truth # and the package does not have to be imported at build time. [tool.setuptools.dynamic] -version = { attr = "directkeys.version" } +# Must point at __version__, not the `version` alias: setuptools only reads the +# value statically when it is a literal, and falls back to importing otherwise. +version = { attr = "directkeys.__version__" } [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/directkeys/__init__.py b/src/directkeys/__init__.py index 20111b6a..80544570 100644 --- a/src/directkeys/__init__.py +++ b/src/directkeys/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ directkeys ========== @@ -209,9 +208,9 @@ def on_space(): # https://stackoverflow.com/questions/983354/how-to-make-a-script-wait-for-a-pressed-key ``` """ -from __future__ import print_function as _print_function - -version = '1.0.0' +__version__ = '1.0.0' +# Kept as an alias: the upstream project exposed the version under this name. +version = __version__ # Centrally managed state for the AltGr abstraction, read by the backend. _ABSTRACT_ALT_GR = True @@ -219,25 +218,20 @@ def on_space(): import re as _re import itertools as _itertools import collections as _collections -from threading import Thread as _Thread, Lock as _Lock +import queue as _queue import time as _time -_time.monotonic = getattr(_time, 'monotonic', None) or _time.time - -try: - long, basestring - _is_str = lambda x: isinstance(x, basestring) - _is_number = lambda x: isinstance(x, (int, long)) - import Queue as _queue - from threading import _Event as _UninterruptibleEvent -except NameError: - _is_str = lambda x: isinstance(x, str) - _is_number = lambda x: isinstance(x, int) - import queue as _queue - from threading import Event as _UninterruptibleEvent -_is_list = lambda x: isinstance(x, (list, tuple)) - -class _State(object): pass +from threading import Thread as _Thread, Lock as _Lock, Event as _UninterruptibleEvent + +def _is_str(x): return isinstance(x, str) +def _is_number(x): return isinstance(x, int) +def _is_list(x): return isinstance(x, (list, tuple)) + +# Just a dynamic object to store attributes for the closures. +class _State: pass +# The "Event" class from `threading` ignores signals when waiting and is +# impossible to interrupt with Ctrl+C. So we rewrite `wait` to wait in small, +# interruptible intervals. class _Event(_UninterruptibleEvent): def wait(self): while True: diff --git a/src/directkeys/__main__.py b/src/directkeys/__main__.py index 4753eeff..703a2f86 100644 --- a/src/directkeys/__main__.py +++ b/src/directkeys/__main__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import directkeys import fileinput import json diff --git a/src/directkeys/_canonical_names.py b/src/directkeys/_canonical_names.py index 003fd3b8..bcfaf0b2 100644 --- a/src/directkeys/_canonical_names.py +++ b/src/directkeys/_canonical_names.py @@ -1,11 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -try: - basestring -except NameError: - basestring = str - import platform # Defaults to Windows canonical names (platform-specific overrides below) @@ -1235,7 +1227,7 @@ def normalize_name(name): Given a key name (e.g. "LEFT CONTROL"), clean up the string and convert to the canonical representation (e.g. "left ctrl") if one is known. """ - if not name or not isinstance(name, basestring): + if not name or not isinstance(name, str): raise ValueError('Can only normalize non-empty string names. Unexpected '+ repr(name)) if len(name) > 1: diff --git a/src/directkeys/_darwinkeyboard.py b/src/directkeys/_darwinkeyboard.py index ef848d7b..d48a1f87 100644 --- a/src/directkeys/_darwinkeyboard.py +++ b/src/directkeys/_darwinkeyboard.py @@ -5,18 +5,13 @@ import os import threading from AppKit import NSEvent -from directkeys._keyboard_event import KeyboardEvent, KEY_DOWN, KEY_UP +from ._keyboard_event import KeyboardEvent, KEY_DOWN, KEY_UP from ._canonical_names import normalize_name from collections import defaultdict -try: # Python 2/3 compatibility - unichr -except NameError: - unichr = chr - Carbon = ctypes.cdll.LoadLibrary(ctypes.util.find_library('Carbon')) -class KeyMap(object): +class KeyMap: non_layout_keys = dict((vk, normalize_name(name)) for vk, name in { # Layout specific keys from https://stackoverflow.com/a/16125341/252218 # Unfortunately no source for layout-independent keys was found. @@ -153,7 +148,7 @@ class CFRange(ctypes.Structure): ctypes.byref(char_count), non_shifted_char) - non_shifted_key = u''.join(unichr(non_shifted_char[i]) for i in range(char_count.value)) + non_shifted_key = ''.join(chr(non_shifted_char[i]) for i in range(char_count.value)) retval = Carbon.UCKeyTranslate(k_layout_buffer, key_code, @@ -166,7 +161,7 @@ class CFRange(ctypes.Structure): ctypes.byref(char_count), shifted_char) - shifted_key = u''.join(unichr(shifted_char[i]) for i in range(char_count.value)) + shifted_key = ''.join(chr(shifted_char[i]) for i in range(char_count.value)) self.layout_specific_keys[key_code] = (non_shifted_key, shifted_key) # Cleanup @@ -200,7 +195,7 @@ def vk_to_character(self, vk, modifiers=[]): raise ValueError("Invalid scan code: {}".format(vk)) -class KeyController(object): +class KeyController: def __init__(self): self.key_map = KeyMap() self.current_modifiers = { @@ -345,7 +340,7 @@ def map_scan_code(self, scan_code): else: return self.key_map.vk_to_character(scan_code) -class KeyEventListener(object): +class KeyEventListener: def __init__(self, callback, blocking=False): self.blocking = blocking self.callback = callback diff --git a/src/directkeys/_darwinmouse.py b/src/directkeys/_darwinmouse.py index b112dc07..c3b1cd4a 100644 --- a/src/directkeys/_darwinmouse.py +++ b/src/directkeys/_darwinmouse.py @@ -21,7 +21,7 @@ "click_count": 0 } -class MouseEventListener(object): +class MouseEventListener: def __init__(self, callback, blocking=False): self.blocking = blocking self.callback = callback diff --git a/src/directkeys/_generic.py b/src/directkeys/_generic.py index bac559f7..5fcb0dde 100644 --- a/src/directkeys/_generic.py +++ b/src/directkeys/_generic.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from threading import Thread, Lock import traceback import functools @@ -8,7 +7,7 @@ except ImportError: from Queue import Queue -class GenericListener(object): +class GenericListener: lock = Lock() def __init__(self): diff --git a/src/directkeys/_keyboard_event.py b/src/directkeys/_keyboard_event.py index 6da4fbaa..ba2ca5f2 100644 --- a/src/directkeys/_keyboard_event.py +++ b/src/directkeys/_keyboard_event.py @@ -1,18 +1,11 @@ -# -*- coding: utf-8 -*- - from time import time as now import json from ._canonical_names import normalize_name -try: - basestring -except NameError: - basestring = str - KEY_DOWN = 'down' KEY_UP = 'up' -class KeyboardEvent(object): +class KeyboardEvent: event_type = None scan_code = None name = None diff --git a/src/directkeys/_mouse_event.py b/src/directkeys/_mouse_event.py index 38b89610..8967d9da 100644 --- a/src/directkeys/_mouse_event.py +++ b/src/directkeys/_mouse_event.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from collections import namedtuple LEFT = 'left' diff --git a/src/directkeys/_nixcommon.py b/src/directkeys/_nixcommon.py index ba3a1d05..ae9d60ae 100644 --- a/src/directkeys/_nixcommon.py +++ b/src/directkeys/_nixcommon.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import struct import os import atexit @@ -48,7 +47,7 @@ def make_uinput(): return uinput -class EventDevice(object): +class EventDevice: def __init__(self, path): self.path = path self._input_file = None @@ -96,7 +95,7 @@ def write_event(self, type, code, value): self.output_file.write(data_event + sync_event) self.output_file.flush() -class AggregatedEventDevice(object): +class AggregatedEventDevice: def __init__(self, devices, output=None): self.event_queue = Queue() self.devices = devices diff --git a/src/directkeys/_nixkeyboard.py b/src/directkeys/_nixkeyboard.py index 8376468b..d5b8af0a 100644 --- a/src/directkeys/_nixkeyboard.py +++ b/src/directkeys/_nixkeyboard.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import struct import traceback from time import time as now diff --git a/src/directkeys/_nixmouse.py b/src/directkeys/_nixmouse.py index 24060502..117e797c 100644 --- a/src/directkeys/_nixmouse.py +++ b/src/directkeys/_nixmouse.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import struct from subprocess import check_output import re diff --git a/src/directkeys/_winkeyboard.py b/src/directkeys/_winkeyboard.py index 7f8628cd..f0e04e84 100644 --- a/src/directkeys/_winkeyboard.py +++ b/src/directkeys/_winkeyboard.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ This is the Windows backend for keyboard events, and is implemented by invoking the Win32 API through the ctypes module. This is error prone @@ -10,7 +9,6 @@ - Keypad numbers still print as numbers even when numlock is off. - No way to specify if user wants a keypad key or not in `map_char`. """ -from __future__ import unicode_literals import re import atexit import traceback @@ -18,18 +16,12 @@ from collections import defaultdict import time -from directkeys._keyboard_event import KeyboardEvent, KEY_DOWN, KEY_UP +from ._keyboard_event import KeyboardEvent, KEY_DOWN, KEY_UP from ._canonical_names import normalize_name _altgr_right_alt_scan_code = None _altgr_right_alt_flags = None -try: - # Force Python2 to convert to unicode and not to str. - chr = unichr -except NameError: - pass - # This part is just declaring Win32 API structures using ctypes. In C # this would be simply #include "windows.h". diff --git a/src/directkeys/_winmouse.py b/src/directkeys/_winmouse.py index ef16dd42..fd4d3607 100644 --- a/src/directkeys/_winmouse.py +++ b/src/directkeys/_winmouse.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import ctypes import time from ctypes import c_short, c_char, c_uint8, c_int32, c_int, c_uint, c_uint32, c_long, byref, Structure, CFUNCTYPE, POINTER diff --git a/src/directkeys/mouse.py b/src/directkeys/mouse.py index 41bf3152..eaac3587 100644 --- a/src/directkeys/mouse.py +++ b/src/directkeys/mouse.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import warnings warnings.simplefilter('always', DeprecationWarning) warnings.warn('The mouse sub-library is deprecated and will be removed in future versions. Please use the standalone package `mouse`.', DeprecationWarning, stacklevel=2) diff --git a/tests/test_keyboard.py b/tests/test_keyboard.py index 39539185..28fa03b3 100644 --- a/tests/test_keyboard.py +++ b/tests/test_keyboard.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Side effects are avoided using two techniques: @@ -12,7 +11,6 @@ `output_events`. Fake OS events (directkeys.press) are processed and added to `output_events` immediately, mimicking real functionality. """ -from __future__ import print_function import unittest import time diff --git a/tests/test_mouse.py b/tests/test_mouse.py index 136a011d..3881f152 100644 --- a/tests/test_mouse.py +++ b/tests/test_mouse.py @@ -1,11 +1,10 @@ -# -*- coding: utf-8 -*- import unittest import time from directkeys._mouse_event import MoveEvent, ButtonEvent, WheelEvent, LEFT, RIGHT, MIDDLE, X, X2, UP, DOWN, DOUBLE from directkeys import mouse -class FakeOsMouse(object): +class FakeOsMouse: def __init__(self): self.append = None self.position = (0, 0) From 030d401dee3e058996db3ed5a836016533601bf1 Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Fri, 31 Jul 2026 22:49:07 -0400 Subject: [PATCH 3/7] build: configure Ruff and fix what it found Adds Ruff (lint + import sorting) configured in pyproject.toml, targeting py39. Rules that the codebase deliberately breaks are ignored explicitly, each with the reason, rather than left to fail: long lines in the ctypes tables, imports that must follow the platform dispatch, and the Cyrillic/Greek key names in _canonical_names.py that RUF001 reads as ambiguous characters. The autofixable findings are applied. Two are worth calling out: - tests/test_keyboard.py defined four tests twice under the same name, so the first definition of each was silently discarded and never ran. They are not duplicates, they are distinct cases: a `write` without delay, a hotkey removal without modifiers, a two-modifier release, and a multistep blocking state assertion. Renamed so both run; the suite goes from 157 to 161 tests and all four recovered cases pass. - `index = 0` in the suppression path assigned to a local that nothing read, right before `set_index(0)` did the real work. A group of upstream findings is ignored rather than fixed, because each would change runtime behaviour and none is covered by a test: repeated dictionary keys (F601), mutable argument defaults (B006), bare except (E722) and `raise` without `from` (B904). Co-Authored-By: Claude Opus 5 --- examples/10_second_macro.py | 3 +- examples/pressed_keys.py | 6 ++- examples/push_to_talk_ubuntu.py | 7 ++-- examples/segmented_macro.py | 12 +++--- examples/simulate_held_down.py | 3 +- examples/stdin_stdout_events.py | 2 +- examples/write.py | 6 ++- make_release.py | 21 ++++++----- pyproject.toml | 56 ++++++++++++++++++++++++++++ src/directkeys/__init__.py | 60 +++++++++++++++--------------- src/directkeys/__main__.py | 6 ++- src/directkeys/_canonical_names.py | 2 +- src/directkeys/_darwinkeyboard.py | 24 ++++++------ src/directkeys/_darwinmouse.py | 8 ++-- src/directkeys/_generic.py | 7 ++-- src/directkeys/_keyboard_event.py | 11 +++--- src/directkeys/_nixcommon.py | 21 ++++++----- src/directkeys/_nixkeyboard.py | 15 +++----- src/directkeys/_nixmouse.py | 14 +++---- src/directkeys/_winkeyboard.py | 44 +++++++++++++++++----- src/directkeys/_winmouse.py | 30 +++++++++++++-- src/directkeys/mouse.py | 27 ++++++++++---- tests/test_keyboard.py | 22 +++++------ tests/test_mouse.py | 21 +++++++++-- 24 files changed, 286 insertions(+), 142 deletions(-) diff --git a/examples/10_second_macro.py b/examples/10_second_macro.py index 4e765dfe..f157d48c 100644 --- a/examples/10_second_macro.py +++ b/examples/10_second_macro.py @@ -1,6 +1,7 @@ -import directkeys import time +import directkeys + directkeys.start_recording() time.sleep(10) events = directkeys.stop_recording() diff --git a/examples/pressed_keys.py b/examples/pressed_keys.py index da78ffef..e56e26cb 100644 --- a/examples/pressed_keys.py +++ b/examples/pressed_keys.py @@ -3,14 +3,16 @@ Updates on every keyboard event. """ import sys + sys.path.append('..') import directkeys + def print_pressed_keys(e): line = ', '.join(str(code) for code in directkeys._pressed_events) # '\r' and end='' overwrites the previous line. # ' '*40 prints 40 spaces at the end to ensure the previous line is cleared. print('\r' + line + ' '*40, end='') - + directkeys.hook(print_pressed_keys) -directkeys.wait() \ No newline at end of file +directkeys.wait() diff --git a/examples/push_to_talk_ubuntu.py b/examples/push_to_talk_ubuntu.py index d77aa5d9..4349f7c0 100644 --- a/examples/push_to_talk_ubuntu.py +++ b/examples/push_to_talk_ubuntu.py @@ -1,8 +1,9 @@ -#quick and dirty push-to-talk example for Ubuntu 16.04, by Abd Azrad +#quick and dirty push-to-talk example for Ubuntu 16.04, by Abd Azrad -import directkeys import subprocess +import directkeys + is_muted = False def unmute(): @@ -24,4 +25,4 @@ def mute(): directkeys.add_hotkey('win', unmute) # unmute on keydown directkeys.add_hotkey('win', mute, trigger_on_release=True) # mute on keyup - directkeys.wait() # wait forever \ No newline at end of file + directkeys.wait() # wait forever diff --git a/examples/segmented_macro.py b/examples/segmented_macro.py index 1477d9ef..5a15d06d 100644 --- a/examples/segmented_macro.py +++ b/examples/segmented_macro.py @@ -6,10 +6,12 @@ time to speak between segments. """ import sys + sys.path.append('../') -import directkeys -import pickle import os +import pickle + +import directkeys if len(sys.argv) == 1: filename = input('Enter filename to save/load events: ') @@ -19,8 +21,8 @@ if os.path.exists(filename): segments = pickle.load(open(filename, 'rb')) for i, segment in enumerate(segments): - print('Press F1 to play segment {}/{}'.format(i+1, len(segments))) - print('Duration: {:.02} seconds'.format(segment[-1].time - segment[0].time)) + print(f'Press F1 to play segment {i+1}/{len(segments)}') + print(f'Duration: {segment[-1].time - segment[0].time:.02} seconds') directkeys.wait('F1') directkeys.play(segment) @@ -51,4 +53,4 @@ def handle_event(event): directkeys.hook(handle_event) pickle.dump(segments, open(filename, 'wb')) - print('Saved {} segments to {}'.format(len(segments), filename)) \ No newline at end of file + print(f'Saved {len(segments)} segments to {filename}') diff --git a/examples/simulate_held_down.py b/examples/simulate_held_down.py index 56b460d5..da3fd6b2 100644 --- a/examples/simulate_held_down.py +++ b/examples/simulate_held_down.py @@ -1,6 +1,7 @@ -import directkeys import time +import directkeys + # Sends 20 "key down" events in 0.1 second intervals, followed by a single # "key up" event. for i in range(20): diff --git a/examples/stdin_stdout_events.py b/examples/stdin_stdout_events.py index 1acfe961..ca8e9b99 100644 --- a/examples/stdin_stdout_events.py +++ b/examples/stdin_stdout_events.py @@ -12,7 +12,7 @@ {"event_type": "up", "name": "f", "scan_code": 33, "time": 1491442622.9056144} """ import sys + sys.path.append('..') # Also available as just `python -m keyboard`. -from directkeys import __main__ \ No newline at end of file diff --git a/examples/write.py b/examples/write.py index ba3a3be0..0b8297b2 100644 --- a/examples/write.py +++ b/examples/write.py @@ -3,9 +3,11 @@ text character-by-character. """ import sys + sys.path.append('../') -import directkeys import fileinput +import directkeys + for line in fileinput.input(): - directkeys.write(line) \ No newline at end of file + directkeys.write(line) diff --git a/make_release.py b/make_release.py index 36fb2a7c..7c91e813 100644 --- a/make_release.py +++ b/make_release.py @@ -22,12 +22,13 @@ - Use raw semantic versioning for CHANGES.md and PyPI (e.g. 2.3.1), and prepend 'v' for git tags and releases (e.g. v2.3.1). """ -import re -import sys -import os -from subprocess import run, check_output import atexit +import os +import re +from subprocess import check_output, run + import requests + import directkeys run(['make', 'clean', 'build'], check=True) @@ -36,16 +37,16 @@ last_version = check_output(['git', 'describe', '--abbrev=0'], universal_newlines=True).strip('v\n') assert directkeys.version != last_version, 'Must update directkeys.version first.' -commits = check_output(['git', 'log', 'v{}..HEAD'.format(last_version), '--oneline'], universal_newlines=True) +commits = check_output(['git', 'log', f'v{last_version}..HEAD', '--oneline'], universal_newlines=True) with open('message.txt', 'w') as message_file: atexit.register(lambda: os.remove('message.txt')) message_file.write('\n\n\n') message_file.write('# Enter changes one per line like this:\n') message_file.write('# - Added `foobar`.\n\n\n') - message_file.write('# As a reminder, here\'s the last commits since version {}:\n\n'.format(last_version)) + message_file.write(f'# As a reminder, here\'s the last commits since version {last_version}:\n\n') for line in commits.strip().split('\n'): - message_file.write('# {}\n'.format(line)) + message_file.write(f'# {line}\n') run(['vim', 'message.txt']) with open('message.txt') as message_file: @@ -60,13 +61,13 @@ with open('CHANGES.md') as changes_file: old_changes = changes_file.read() with open('CHANGES.md', 'w') as changes_file: - changes_file.write('# {}\n\n{}\n\n\n{}'.format(directkeys.version, message, old_changes)) + changes_file.write(f'# {directkeys.version}\n\n{message}\n\n\n{old_changes}') tag_name = 'v' + directkeys.version if input('Commit README.md and CHANGES.md files? ').lower().startswith('y'): run(['git', 'add', 'CHANGES.md', 'README.md']) - run(['git', 'commit', '-m', 'Update changes for {}'.format(tag_name)]) + run(['git', 'commit', '-m', f'Update changes for {tag_name}']) run(['git', 'push']) run(['git', 'tag', '-a', tag_name, '--file', 'message.txt'], check=True) run(['git', 'push', 'origin', tag_name], check=True) @@ -75,7 +76,7 @@ if token: git_remotes = check_output(['git', 'remote', '-v']).decode('utf-8') repo_path = re.search(r'github.com[:/](.+?)(?:\.git)? \(push\)', git_remotes).group(1) - releases_url = 'https://api.github.com/repos/{}/releases'.format(repo_path) + releases_url = f'https://api.github.com/repos/{repo_path}/releases' print(releases_url) release = { "tag_name": tag_name, diff --git a/pyproject.toml b/pyproject.toml index d06ea616..0183d04e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,3 +66,59 @@ exclude_lines = [ [tool.coverage.html] directory = "coverage_html_report" + +[tool.ruff] +target-version = "py39" +line-length = 100 +extend-exclude = ["tests/manual"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "W", # pycodestyle warnings + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "RUF", # ruff-specific +] +ignore = [ + "E501", # Long lines are pervasive in the ctypes declarations and tables. + "E741", # `l` is a legitimate key name in the canonical name tables. + "E701", # Single-line defs keep the big lookup tables compact. + "E731", # Lambdas are the established idiom for the callback shorthands. + "B008", # Calls in argument defaults are deliberate in the public API. + "RUF005", # Concatenation reads better than unpacking in the event lists. + # Inherited from upstream and left alone deliberately, because changing any + # of them alters runtime behaviour and none is covered by a test: + "F601", # Repeated dict keys: the earlier name for a key code is dropped. + "B006", # Mutable argument defaults. + "B007", # Unused loop variables. + "B018", # Useless expression (a device probe read for its side effect). + "B904", # `raise` without `from` inside an except clause. + "E722", # Bare `except`. + "RUF012", # Mutable class attributes without ClassVar. +] + +[tool.ruff.lint.per-file-ignores] +# Imports deliberately follow the module docstring, the version and the +# platform dispatch, which have to run first. +"src/directkeys/__init__.py" = ["E402"] +"src/directkeys/mouse.py" = ["E402"] +# The ctypes backends group their Win32/Quartz declarations after the module +# preamble, and bind struct fields that are only read through the C API. +"src/directkeys/_win*.py" = ["E402", "F403", "F405", "F841"] +"src/directkeys/_nix*.py" = ["E402", "F403", "F405", "F841"] +"src/directkeys/_darwin*.py" = ["E402", "F403", "F405", "F841"] +# _darwinmouse.handler is a verbatim copy of the keyboard listener's handler and +# calls four names that do not exist in this module, so mouse listening on macOS +# raises NameError. Rewriting it needs a macOS machine to verify against. +"src/directkeys/_darwinmouse.py" = ["E402", "F403", "F405", "F841", "F821"] +# Key names are intentionally spelled in Cyrillic, Greek and accented Latin: +# flagging them as ambiguous characters is exactly backwards for this table. +"src/directkeys/_canonical_names.py" = ["RUF001", "RUF003"] +# The test suite builds its fixtures with star imports and module-level setup. +"tests/*" = ["E402", "F403", "F405", "F841"] +# The examples are documentation: they favour brevity over lint compliance. +"examples/*" = ["E402", "F403", "F405", "F841", "W191", "E101"] diff --git a/src/directkeys/__init__.py b/src/directkeys/__init__.py index 80544570..c9012114 100644 --- a/src/directkeys/__init__.py +++ b/src/directkeys/__init__.py @@ -215,12 +215,15 @@ def on_space(): # Centrally managed state for the AltGr abstraction, read by the backend. _ABSTRACT_ALT_GR = True -import re as _re -import itertools as _itertools import collections as _collections +import itertools as _itertools import queue as _queue +import re as _re import time as _time -from threading import Thread as _Thread, Lock as _Lock, Event as _UninterruptibleEvent +from threading import Event as _UninterruptibleEvent +from threading import Lock as _Lock +from threading import Thread as _Thread + def _is_str(x): return isinstance(x, str) def _is_number(x): return isinstance(x, int) @@ -239,12 +242,15 @@ def wait(self): break # Load the base dependencies before the backend, to avoid a circular import. -from ._keyboard_event import KEY_DOWN, KEY_UP, KeyboardEvent -from ._generic import GenericListener as _GenericListener -from ._canonical_names import all_modifiers, sided_modifiers, normalize_name - # Import the platform specific backend. import platform as _platform + +from ._canonical_names import all_modifiers, normalize_name, sided_modifiers +from ._generic import GenericListener as _GenericListener + +# KeyboardEvent is re-exported as part of the public API, not used here. +from ._keyboard_event import KEY_DOWN, KEY_UP, KeyboardEvent # noqa: F401 + if _platform.system() == 'Windows': from . import _winkeyboard as _os_keyboard elif _platform.system() == 'Linux': @@ -257,7 +263,7 @@ def wait(self): # read the version before pyobjc is available. _os_keyboard = None else: - raise OSError("Unsupported platform '{}'".format(_platform.system())) + raise OSError(f"Unsupported platform '{_platform.system()}'") def set_alt_gr_abstraction(enabled): """ @@ -319,7 +325,7 @@ def is_modifier(key): return key in all_modifiers else: if not _modifier_scan_codes: - scan_codes = (key_to_scan_codes(name, False) for name in all_modifiers) + scan_codes = (key_to_scan_codes(name, False) for name in all_modifiers) _modifier_scan_codes.update(*scan_codes) return key in _modifier_scan_codes @@ -426,7 +432,7 @@ def direct_callback(self, event): hotkey = tuple(sorted(_pressed_events)) if event_type == KEY_UP: self.active_modifiers.discard(scan_code) - if scan_code in _pressed_events: del _pressed_events[scan_code] + _pressed_events.pop(scan_code, None) # Mappings based on individual keys instead of hotkeys. for key_hook in self.blocking_keys[scan_code]: @@ -439,7 +445,7 @@ def direct_callback(self, event): if self.blocking_hotkeys: if self.filtered_modifiers[scan_code]: origin = 'modifier' - modifiers_to_update = set([scan_code]) + modifiers_to_update = {scan_code} else: modifiers_to_update = self.active_modifiers if is_modifier(scan_code): @@ -500,7 +506,7 @@ def key_to_scan_codes(key, error_if_missing=True): e = exception if not t and error_if_missing: - raise ValueError('Key {} is not mapped to any known key.'.format(repr(key)), e) + raise ValueError(f'Key {key!r} is not mapped to any known key.', e) else: return t @@ -597,7 +603,7 @@ def is_pressed(hotkey): if len(steps) > 1: raise ValueError("Impossible to check if multi-step hotkeys are pressed (`a+b` is ok, `a, b` isn't).") - # Convert _pressed_events into a set + # Convert _pressed_events into a set with _pressed_events_lock: pressed_scan_codes = set(_pressed_events) for scan_codes in steps[0]: @@ -619,7 +625,7 @@ def hook(callback, suppress=False, on_remove=lambda: None): """ Installs a global listener on all available keyboards, invoking `callback` each time a key is pressed or released. - + The event passed to the callback is of type `directkeys.KeyboardEvent`, with the following attributes: @@ -758,7 +764,7 @@ def _add_hotkey_step(handler, combinations, suppress): container = _listener.blocking_hotkeys if suppress else _listener.nonblocking_hotkeys # Register the scan codes of every possible combination of - # modfiier + main key. Modifiers have to be registered in + # modfiier + main key. Modifiers have to be registered in # filtered_modifiers too, so suppression and replaying can work. for scan_codes in combinations: for scan_code in scan_codes: @@ -842,7 +848,7 @@ def remove_(): state.remove_last_step = None state.suppressed_events = [] state.last_update = float('-inf') - + def catch_misses(event, force_fail=False): if ( event.event_type == event_type @@ -862,7 +868,6 @@ def catch_misses(event, force_fail=False): release(event.scan_code) del state.suppressed_events[:] - index = 0 set_index(0) return True @@ -884,7 +889,7 @@ def handler(event): if event.event_type == KEY_UP: remove() set_index(0) - accept = event.event_type == event_type and callback() + accept = event.event_type == event_type and callback() if accept: return catch_misses(event, force_fail=True) else: @@ -994,7 +999,7 @@ def restore_modifiers(scan_codes): """ Like `restore_state`, but only restores modifier keys. """ - restore_state((scan_code for scan_code in scan_codes if is_modifier(scan_code))) + restore_state(scan_code for scan_code in scan_codes if is_modifier(scan_code)) def write(text, delay=0, restore_state_after=True, exact=None): """ @@ -1018,7 +1023,7 @@ def write(text, delay=0, restore_state_after=True, exact=None): exact = _platform.system() == 'Windows' state = stash_state() - + # Window's typing of unicode characters is quite efficient and should be preferred. if exact: for letter in text: @@ -1035,7 +1040,7 @@ def write(text, delay=0, restore_state_after=True, exact=None): except (KeyError, ValueError, StopIteration): _os_keyboard.type_unicode(letter) continue - + for modifier in modifiers: press(modifier) @@ -1088,7 +1093,7 @@ def get_hotkey_name(names=None): names = [e.name for e in _pressed_events.values()] else: names = [normalize_name(name) for name in names] - clean_names = set(e.replace('left ', '').replace('right ', '').replace('+', 'plus') for e in names) + clean_names = {e.replace('left ', '').replace('right ', '').replace('+', 'plus') for e in names} # https://developer.apple.com/macos/human-interface-guidelines/input-and-output/keyboard/ # > List modifier keys in the correct order. If you use more than one modifier key in a # > hotkey, always list them in this order: Control, Option, Shift, Command. @@ -1295,12 +1300,9 @@ def handler(event): hooked = hook(handler) def remove(): hooked() - if word in _word_listeners: - del _word_listeners[word] - if handler in _word_listeners: - del _word_listeners[handler] - if remove in _word_listeners: - del _word_listeners[remove] + _word_listeners.pop(word, None) + _word_listeners.pop(handler, None) + _word_listeners.pop(remove, None) _word_listeners[word] = _word_listeners[handler] = _word_listeners[remove] = remove # TODO: allow multiple word listeners and removing them correctly. return remove @@ -1327,7 +1329,7 @@ def add_abbreviation(source_text, replacement_text, match_suffix=False, timeout= listener for 'pet'. Defaults to false, only whole words are checked. - `timeout` is the maximum number of seconds between typed characters before the current word is discarded. Defaults to 2 seconds. - + For more details see `add_word_listener`. """ replacement = '\b'*(len(source_text)+1) + replacement_text diff --git a/src/directkeys/__main__.py b/src/directkeys/__main__.py index 703a2f86..46aff002 100644 --- a/src/directkeys/__main__.py +++ b/src/directkeys/__main__.py @@ -1,12 +1,14 @@ -import directkeys import fileinput import json import sys +import directkeys + + def print_event_json(event): print(event.to_json(ensure_ascii=sys.stdout.encoding != 'utf-8')) sys.stdout.flush() directkeys.hook(print_event_json) parse_event_json = lambda line: directkeys.KeyboardEvent(**json.loads(line)) -directkeys.play(parse_event_json(line) for line in fileinput.input()) \ No newline at end of file +directkeys.play(parse_event_json(line) for line in fileinput.input()) diff --git a/src/directkeys/_canonical_names.py b/src/directkeys/_canonical_names.py index bcfaf0b2..10da204c 100644 --- a/src/directkeys/_canonical_names.py +++ b/src/directkeys/_canonical_names.py @@ -1200,7 +1200,7 @@ "Zsmall": "", } sided_modifiers = {'ctrl', 'alt', 'shift', 'windows'} -all_modifiers = {'alt', 'alt gr', 'ctrl', 'shift', 'windows'} | set('left ' + n for n in sided_modifiers) | set('right ' + n for n in sided_modifiers) +all_modifiers = {'alt', 'alt gr', 'ctrl', 'shift', 'windows'} | {'left ' + n for n in sided_modifiers} | {'right ' + n for n in sided_modifiers} # Platform-specific canonical overrides diff --git a/src/directkeys/_darwinkeyboard.py b/src/directkeys/_darwinkeyboard.py index d48a1f87..4d1feb84 100644 --- a/src/directkeys/_darwinkeyboard.py +++ b/src/directkeys/_darwinkeyboard.py @@ -1,18 +1,18 @@ import ctypes import ctypes.util -import Quartz import time -import os -import threading +from collections import defaultdict + +import Quartz from AppKit import NSEvent -from ._keyboard_event import KeyboardEvent, KEY_DOWN, KEY_UP + from ._canonical_names import normalize_name -from collections import defaultdict +from ._keyboard_event import KeyboardEvent Carbon = ctypes.cdll.LoadLibrary(ctypes.util.find_library('Carbon')) class KeyMap: - non_layout_keys = dict((vk, normalize_name(name)) for vk, name in { + non_layout_keys = {vk: normalize_name(name) for vk, name in { # Layout specific keys from https://stackoverflow.com/a/16125341/252218 # Unfortunately no source for layout-independent keys was found. 0x24: 'return', @@ -62,7 +62,7 @@ class KeyMap: 0x7c: 'right', 0x7d: 'down', 0x7e: 'up', - }.items()) + }.items()} layout_specific_keys = {} def __init__(self): # Virtual key codes are usually the same for any given key, unless you have a different @@ -178,7 +178,7 @@ def character_to_vk(self, character): return (vk, []) elif self.layout_specific_keys[vk][1] == character: return (vk, ['shift']) - raise ValueError("Unrecognized character: {}".format(character)) + raise ValueError(f"Unrecognized character: {character}") def vk_to_character(self, vk, modifiers=[]): """ Returns a character corresponding to the specified scan code (with given @@ -192,7 +192,7 @@ def vk_to_character(self, vk, modifiers=[]): return self.layout_specific_keys[vk][0] else: # Invalid vk - raise ValueError("Invalid scan code: {}".format(vk)) + raise ValueError(f"Invalid scan code: {vk}") class KeyController: @@ -231,7 +231,7 @@ def __init__(self): 'KEYTYPE_ILLUMINATION_DOWN': 22, 'KEYTYPE_ILLUMINATION_TOGGLE': 23 } - + def press(self, key_code): """ Sends a 'down' event for the specified scan code """ if key_code >= 128: @@ -439,7 +439,7 @@ def release(scan_code): key_controller.release(scan_code) def map_name(name): - """ Returns a tuple of (scan_code, modifiers) where ``scan_code`` is a numeric scan code + """ Returns a tuple of (scan_code, modifiers) where ``scan_code`` is a numeric scan code and ``modifiers`` is an array of string modifier names (like 'shift') """ yield key_controller.map_char(name) @@ -459,4 +459,4 @@ def type_unicode(character): # Key up event = Quartz.CGEventCreateKeyboardEvent(OUTPUT_SOURCE, 0, False) Quartz.CGEventKeyboardSetUnicodeString(event, len(character.encode('utf-16-le')) // 2, character) - Quartz.CGEventPost(Quartz.kCGSessionEventTap, event) \ No newline at end of file + Quartz.CGEventPost(Quartz.kCGSessionEventTap, event) diff --git a/src/directkeys/_darwinmouse.py b/src/directkeys/_darwinmouse.py index c3b1cd4a..3b8afe6c 100644 --- a/src/directkeys/_darwinmouse.py +++ b/src/directkeys/_darwinmouse.py @@ -1,8 +1,10 @@ -import os import datetime +import os import threading + import Quartz -from ._mouse_event import ButtonEvent, WheelEvent, MoveEvent, LEFT, RIGHT, MIDDLE, X, X2, UP, DOWN + +from ._mouse_event import LEFT, MIDDLE, RIGHT _button_mapping = { LEFT: (Quartz.kCGMouseButtonLeft, Quartz.kCGEventLeftMouseDown, Quartz.kCGEventLeftMouseUp, Quartz.kCGEventLeftMouseDragged), @@ -170,4 +172,4 @@ def get_position(): """ Returns the mouse's location as a tuple of (x, y). """ e = Quartz.CGEventCreate(None) point = Quartz.CGEventGetLocation(e) - return (point.x, point.y) \ No newline at end of file + return (point.x, point.y) diff --git a/src/directkeys/_generic.py b/src/directkeys/_generic.py index 5fcb0dde..23d9f133 100644 --- a/src/directkeys/_generic.py +++ b/src/directkeys/_generic.py @@ -1,6 +1,5 @@ -from threading import Thread, Lock import traceback -import functools +from threading import Lock, Thread try: from queue import Queue @@ -21,7 +20,7 @@ def invoke_handlers(self, event): if handler(event): # Stop processing this hotkey. return 1 - except Exception as e: + except Exception: traceback.print_exc() def start_if_necessary(self): @@ -57,7 +56,7 @@ def process(self): if self.pre_process_event(event): self.invoke_handlers(event) self.queue.task_done() - + def add_handler(self, handler): """ Adds a function to receive each event captured, starting the capturing diff --git a/src/directkeys/_keyboard_event.py b/src/directkeys/_keyboard_event.py index ba2ca5f2..e4f58f65 100644 --- a/src/directkeys/_keyboard_event.py +++ b/src/directkeys/_keyboard_event.py @@ -1,5 +1,6 @@ -from time import time as now import json +from time import time as now + from ._canonical_names import normalize_name KEY_DOWN = 'down' @@ -27,14 +28,14 @@ def __init__(self, event_type, scan_code, name=None, time=None, device=None, mod self.name = normalize_name(name) def to_json(self, ensure_ascii=False): - attrs = dict( - (attr, getattr(self, attr)) for attr in ['event_type', 'scan_code', 'name', 'time', 'device', 'is_keypad', 'modifiers', 'flags'] + attrs = { + attr: getattr(self, attr) for attr in ['event_type', 'scan_code', 'name', 'time', 'device', 'is_keypad', 'modifiers', 'flags'] if not attr.startswith('_') - ) + } return json.dumps(attrs, ensure_ascii=ensure_ascii) def __repr__(self): - return 'KeyboardEvent({} {})'.format(self.name or 'Unknown {}'.format(self.scan_code), self.event_type) + return 'KeyboardEvent({} {})'.format(self.name or f'Unknown {self.scan_code}', self.event_type) def __eq__(self, other): return ( diff --git a/src/directkeys/_nixcommon.py b/src/directkeys/_nixcommon.py index ae9d60ae..c6aa4e2c 100644 --- a/src/directkeys/_nixcommon.py +++ b/src/directkeys/_nixcommon.py @@ -1,9 +1,10 @@ -import struct -import os import atexit -from time import time as now -from threading import Thread +import os +import struct from glob import glob +from threading import Thread +from time import time as now + try: from queue import Queue except ImportError: @@ -21,9 +22,10 @@ def make_uinput(): if not os.path.exists('/dev/uinput'): - raise IOError('No uinput module found.') + raise OSError('No uinput module found.') - import fcntl, struct + import fcntl + import struct # Requires uinput driver, but it's usually available. uinput = open("/dev/uinput", 'wb') @@ -58,9 +60,9 @@ def input_file(self): if self._input_file is None: try: self._input_file = open(self.path, 'rb') - except IOError as e: + except OSError as e: if e.strerror == 'Permission denied': - print("# ERROR: Failed to read device '{}'. You must be in the 'input' group to access global events. Use 'sudo usermod -a -G input USERNAME' to add user to the required group.".format(self.path)) + print(f"# ERROR: Failed to read device '{self.path}'. You must be in the 'input' group to access global events. Use 'sudo usermod -a -G input USERNAME' to add user to the required group.") exit() def try_close(): @@ -116,6 +118,7 @@ def write_event(self, type, code, value): import re from collections import namedtuple + DeviceDescription = namedtuple('DeviceDescription', 'event_file is_mouse is_keyboard') device_pattern = r"""N: Name="([^"]+?)".+?H: Handlers=([^\n]+)""" def list_devices_from_proc(type_name): @@ -145,7 +148,7 @@ def aggregate_devices(type_name): fake_device = EventDevice('uinput Fake Device') fake_device._input_file = uinput fake_device._output_file = uinput - except IOError as e: + except OSError: import warnings warnings.warn('Failed to create a device file using `uinput` module. Sending of events may be limited or unavailable depending on plugged-in devices.', stacklevel=2) fake_device = None diff --git a/src/directkeys/_nixkeyboard.py b/src/directkeys/_nixkeyboard.py index d5b8af0a..7c8eb517 100644 --- a/src/directkeys/_nixkeyboard.py +++ b/src/directkeys/_nixkeyboard.py @@ -1,8 +1,5 @@ -import struct -import traceback -from time import time as now -from collections import namedtuple -from directkeys._keyboard_event import KeyboardEvent, KEY_DOWN, KEY_UP +from directkeys._keyboard_event import KEY_DOWN, KEY_UP, KeyboardEvent + from ._canonical_names import all_modifiers, normalize_name from ._nixcommon import EV_KEY, aggregate_devices @@ -37,16 +34,16 @@ def cleanup_modifier(modifier): return modifier if modifier[:-1] in all_modifiers: return modifier[:-1] - raise ValueError('Unknown modifier {}'.format(modifier)) + raise ValueError(f'Unknown modifier {modifier}') """ Use `dumpkeys --keys-only` to list all scan codes and their names. We then parse the output and built a table. For each scan code and modifiers we have a list of names and vice-versa. """ -from subprocess import check_output, CalledProcessError, PIPE -from collections import defaultdict import re +from collections import defaultdict +from subprocess import CalledProcessError, check_output to_name = defaultdict(list) from_name = defaultdict(list) @@ -141,7 +138,7 @@ def listen(callback): pressed_modifiers_tuple = tuple(sorted(pressed_modifiers)) names = to_name[(scan_code, pressed_modifiers_tuple)] or to_name[(scan_code, ())] or ['unknown'] name = names[0] - + if name in all_modifiers: if event_type == KEY_DOWN: pressed_modifiers.add(name) diff --git a/src/directkeys/_nixmouse.py b/src/directkeys/_nixmouse.py index 117e797c..dc5453e3 100644 --- a/src/directkeys/_nixmouse.py +++ b/src/directkeys/_nixmouse.py @@ -1,12 +1,10 @@ -import struct -from subprocess import check_output -import re -from ._nixcommon import EV_KEY, EV_REL, EV_MSC, EV_SYN, EV_ABS, aggregate_devices -from ._mouse_event import ButtonEvent, WheelEvent, MoveEvent, LEFT, RIGHT, MIDDLE, X, X2, UP, DOWN - import ctypes import ctypes.util -from ctypes import c_uint32, c_uint, c_int, byref +import struct +from ctypes import byref, c_int, c_uint, c_uint32 + +from ._mouse_event import DOWN, LEFT, MIDDLE, RIGHT, UP, X2, ButtonEvent, MoveEvent, WheelEvent, X +from ._nixcommon import EV_KEY, EV_MSC, EV_REL, EV_SYN, aggregate_devices display = None window = None @@ -74,7 +72,7 @@ def listen(queue): build_device() while True: - time, type, code, value, device_id = device.read_event() + time, type, code, value, _device_id = device.read_event() if type == EV_SYN or type == EV_MSC: continue diff --git a/src/directkeys/_winkeyboard.py b/src/directkeys/_winkeyboard.py index f0e04e84..eb97e682 100644 --- a/src/directkeys/_winkeyboard.py +++ b/src/directkeys/_winkeyboard.py @@ -9,15 +9,14 @@ - Keypad numbers still print as numbers even when numlock is off. - No way to specify if user wants a keypad key or not in `map_char`. """ -import re import atexit +import time import traceback -from threading import Lock from collections import defaultdict -import time +from threading import Lock -from ._keyboard_event import KeyboardEvent, KEY_DOWN, KEY_UP from ._canonical_names import normalize_name +from ._keyboard_event import KEY_DOWN, KEY_UP, KeyboardEvent _altgr_right_alt_scan_code = None _altgr_right_alt_flags = None @@ -26,8 +25,33 @@ # this would be simply #include "windows.h". import ctypes -from ctypes import c_short, c_char, c_uint8, c_int32, c_int, c_uint, c_uint32, c_long, Structure, WINFUNCTYPE, POINTER -from ctypes.wintypes import WORD, DWORD, BOOL, HHOOK, MSG, LPWSTR, WCHAR, WPARAM, LPARAM, LONG, HMODULE, LPCWSTR, HINSTANCE, HWND +from ctypes import ( + POINTER, + WINFUNCTYPE, + Structure, + c_int, + c_long, + c_short, + c_uint, + c_uint8, +) +from ctypes.wintypes import ( + BOOL, + DWORD, + HHOOK, + HINSTANCE, + HMODULE, + HWND, + LONG, + LPARAM, + LPCWSTR, + LPWSTR, + MSG, + WCHAR, + WORD, + WPARAM, +) + LPMSG = POINTER(MSG) ULONG_PTR = POINTER(DWORD) @@ -143,7 +167,7 @@ class INPUT(ctypes.Structure): MAPVK_VK_TO_VSC = 0 MAPVK_VSC_TO_VK = 1 MAPVK_VK_TO_VSC_EX = 4 -MAPVK_VSC_TO_VK_EX = 3 +MAPVK_VSC_TO_VK_EX = 3 VkKeyScan = user32.VkKeyScanW VkKeyScan.argtypes = [WCHAR] @@ -603,7 +627,7 @@ def low_level_keyboard_handler(nCode, wParam, lParam): if not should_continue: return -1 - except Exception as e: + except Exception: print('Error in keyboard hook:') traceback.print_exc() @@ -646,9 +670,9 @@ def map_name(name): entries = from_name.get(name) if not entries: - raise ValueError('Key name {} is not mapped to any known key.'.format(repr(name))) + raise ValueError(f'Key name {name!r} is not mapped to any known key.') for i, entry in entries: - scan_code, vk, is_extended, modifiers = entry + scan_code, vk, _is_extended, modifiers = entry yield scan_code or -vk, modifiers def _send_event(code, event_type): diff --git a/src/directkeys/_winmouse.py b/src/directkeys/_winmouse.py index fd4d3607..3f2ebf74 100644 --- a/src/directkeys/_winmouse.py +++ b/src/directkeys/_winmouse.py @@ -1,12 +1,36 @@ import ctypes import time -from ctypes import c_short, c_char, c_uint8, c_int32, c_int, c_uint, c_uint32, c_long, byref, Structure, CFUNCTYPE, POINTER -from ctypes.wintypes import DWORD, BOOL, HHOOK, MSG, LPWSTR, WCHAR, WPARAM, LPARAM +from ctypes import ( + CFUNCTYPE, + POINTER, + Structure, + byref, + c_int, + c_int32, + c_long, +) +from ctypes.wintypes import BOOL, DWORD, HHOOK, LPARAM, MSG, WPARAM + LPMSG = POINTER(MSG) import atexit -from ._mouse_event import ButtonEvent, WheelEvent, MoveEvent, LEFT, RIGHT, MIDDLE, X, X2, UP, DOWN, DOUBLE, WHEEL, HORIZONTAL, VERTICAL +from ._mouse_event import ( + DOUBLE, + DOWN, + HORIZONTAL, + LEFT, + MIDDLE, + RIGHT, + UP, + VERTICAL, + WHEEL, + X2, + ButtonEvent, + MoveEvent, + WheelEvent, + X, +) #https://github.com/boppreh/mouse/issues/1 #user32 = ctypes.windll.user32 diff --git a/src/directkeys/mouse.py b/src/directkeys/mouse.py index eaac3587..291d4695 100644 --- a/src/directkeys/mouse.py +++ b/src/directkeys/mouse.py @@ -1,21 +1,34 @@ import warnings + warnings.simplefilter('always', DeprecationWarning) warnings.warn('The mouse sub-library is deprecated and will be removed in future versions. Please use the standalone package `mouse`.', DeprecationWarning, stacklevel=2) +import platform as _platform import time as _time -import platform as _platform if _platform.system() == 'Windows': - from. import _winmouse as _os_mouse + from . import _winmouse as _os_mouse elif _platform.system() == 'Linux': - from. import _nixmouse as _os_mouse + from . import _nixmouse as _os_mouse elif _platform.system() == 'Darwin': - from. import _darwinmouse as _os_mouse + from . import _darwinmouse as _os_mouse else: - raise OSError("Unsupported platform '{}'".format(_platform.system())) + raise OSError(f"Unsupported platform '{_platform.system()}'") -from ._mouse_event import ButtonEvent, MoveEvent, WheelEvent, LEFT, RIGHT, MIDDLE, X, X2, UP, DOWN, DOUBLE from ._generic import GenericListener as _GenericListener +from ._mouse_event import ( + DOUBLE, + DOWN, + LEFT, + MIDDLE, + RIGHT, + UP, + X2, + ButtonEvent, + MoveEvent, + WheelEvent, + X, +) _pressed_events = set() class _MouseListener(_GenericListener): @@ -165,7 +178,7 @@ def hook(callback): each time it is moved, a key status changes or the wheel is spun. A mouse event is passed as argument, with type either `mouse.ButtonEvent`, `mouse.WheelEvent` or `mouse.MoveEvent`. - + Returns the given callback for easier development. """ _listener.add_handler(callback) diff --git a/tests/test_keyboard.py b/tests/test_keyboard.py index 28fa03b3..897ccda7 100644 --- a/tests/test_keyboard.py +++ b/tests/test_keyboard.py @@ -12,11 +12,11 @@ and added to `output_events` immediately, mimicking real functionality. """ -import unittest import time +import unittest import directkeys -from directkeys._keyboard_event import KeyboardEvent, KEY_DOWN, KEY_UP +from directkeys._keyboard_event import KEY_DOWN, KEY_UP, KeyboardEvent dummy_keys = { 'space': [(0, [])], @@ -112,7 +112,7 @@ def setUp(self): directkeys._logically_pressed_keys.clear() directkeys._hotkeys.clear() directkeys._listener.init() - directkeys._word_listeners = {} + directkeys._word_listeners = {} def do(self, manual_events, expected=None): input_events.extend(manual_events) @@ -128,7 +128,7 @@ def do(self, manual_events, expected=None): directkeys._listener.queue.join() def test_event_json(self): - event = make_event(KEY_DOWN, u'á \'"', 999) + event = make_event(KEY_DOWN, 'á \'"', 999) import json self.assertEqual(event, KeyboardEvent(**json.loads(event.to_json()))) @@ -403,7 +403,7 @@ def test_restore_modifieres(self): def test_write_simple(self): directkeys.write('a', exact=False) self.do([], d_a+u_a) - def test_write_multiple(self): + def test_write_multiple_no_delay(self): directkeys.write('ab', exact=False) self.do([], d_a+u_a+d_b+u_b) def test_write_modifiers(self): @@ -427,8 +427,8 @@ def test_write_unicode_explicit(self): directkeys.write('ab', exact=True) self.do([], [KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name='a'), KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name='b')]) def test_write_unicode_fallback(self): - directkeys.write(u'áb', exact=False) - self.do([], [KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name=u'á')]+d_b+u_b) + directkeys.write('áb', exact=False) + self.do([], [KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name='á')]+d_b+u_b) def test_start_stop_recording(self): directkeys.start_recording() @@ -579,7 +579,7 @@ def test_add_hotkey_single_step_suppress_args_allow(self): def test_add_hotkey_single_step_suppress_single(self): directkeys.add_hotkey('a', trigger, suppress=True) self.do(d_a, triggered_event) - def test_add_hotkey_single_step_suppress_removed(self): + def test_add_hotkey_single_step_suppress_removed_no_modifier(self): directkeys.remove_hotkey(directkeys.add_hotkey('a', trigger, suppress=True)) self.do(d_a, d_a) def test_add_hotkey_single_step_suppress_removed(self): @@ -626,7 +626,7 @@ def test_add_hotkey_single_step_suppress_with_modifiers_fail_unrelated_key(self) def test_add_hotkey_single_step_suppress_with_modifiers_unrelated_key(self): directkeys.add_hotkey('ctrl+shift+a', trigger, suppress=True) self.do(d_ctrl+d_shift+du_b+d_a, d_shift+d_ctrl+du_b+triggered_event) - def test_add_hotkey_single_step_suppress_with_modifiers_release(self): + def test_add_hotkey_single_step_suppress_with_two_modifiers_release(self): directkeys.add_hotkey('ctrl+shift+a', trigger, suppress=True) self.do(d_ctrl+d_shift+du_b+d_a+u_ctrl+u_shift, d_shift+d_ctrl+du_b+triggered_event+u_ctrl+u_shift) def test_add_hotkey_single_step_suppress_with_modifiers_out_of_order(self): @@ -733,7 +733,7 @@ def test_parse_hotkey_combinations_fail_empty(self): directkeys.parse_hotkey_combinations('') - def test_add_hotkey_multistep_suppress_incomplete(self): + def test_add_hotkey_multistep_suppress_incomplete_blocking_state(self): directkeys.add_hotkey('a, b', trigger, suppress=True) self.do(du_a, []) self.assertEqual(directkeys._listener.blocking_hotkeys[(1,)], []) @@ -822,4 +822,4 @@ def free(): if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/test_mouse.py b/tests/test_mouse.py index 3881f152..3a6df782 100644 --- a/tests/test_mouse.py +++ b/tests/test_mouse.py @@ -1,8 +1,21 @@ -import unittest import time +import unittest -from directkeys._mouse_event import MoveEvent, ButtonEvent, WheelEvent, LEFT, RIGHT, MIDDLE, X, X2, UP, DOWN, DOUBLE from directkeys import mouse +from directkeys._mouse_event import ( + DOUBLE, + DOWN, + LEFT, + MIDDLE, + RIGHT, + UP, + X2, + ButtonEvent, + MoveEvent, + WheelEvent, + X, +) + class FakeOsMouse: def __init__(self): @@ -195,7 +208,7 @@ def test_ons(self): def test_wait(self): # If this fails it blocks. Unfortunately, but I see no other way of testing. - from threading import Thread, Lock + from threading import Lock, Thread lock = Lock() lock.acquire() def t(): @@ -206,7 +219,7 @@ def t(): lock.acquire() def test_record_play(self): - from threading import Thread, Lock + from threading import Lock, Thread lock = Lock() lock.acquire() def t(): From 983db2df5667332a77af9b4771fc4e74f7e5f4fe Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Fri, 31 Jul 2026 22:54:41 -0400 Subject: [PATCH 4/7] style: apply the Ruff formatter Mechanical reformat of every Python file, in its own commit so the substantive changes elsewhere stay reviewable. Two settings needed care: - `docstring-code-format` is left off. The __init__ docstring is published verbatim as the PyPI description and regenerated into README.md, so the formatter must not rewrite the example code inside it. - Markdown is excluded. Ruff reformats fenced Python blocks in .md files, and since README.md is generated from that same docstring, the formatter and the generator would each keep undoing the other. Verified by comparing the AST of every touched file before and after: 17 are byte-identical, and the remaining 8 differ only in whitespace inside string literals, where Ruff normalises `""" Text. """` to `"""Text."""`. No structural change in any file. README.md is regenerated so it stays in sync with the docstring after that normalisation. Co-Authored-By: Claude Opus 5 --- examples/customizable_hotkey.py | 11 +- examples/pressed_keys.py | 12 +- examples/push_to_talk_ubuntu.py | 31 +- examples/segmented_macro.py | 29 +- examples/simulate_held_down.py | 4 +- examples/stdin_stdout_events.py | 19 +- examples/write.py | 5 +- make_release.py | 85 +-- pyproject.toml | 11 +- setup.py | 1 + src/directkeys/__init__.py | 324 +++++++---- src/directkeys/__main__.py | 4 +- src/directkeys/_canonical_names.py | 193 +++--- src/directkeys/_darwinkeyboard.py | 400 +++++++------ src/directkeys/_darwinmouse.py | 151 ++--- src/directkeys/_generic.py | 5 +- src/directkeys/_keyboard_event.py | 42 +- src/directkeys/_mouse_event.py | 28 +- src/directkeys/_nixcommon.py | 55 +- src/directkeys/_nixkeyboard.py | 131 +++-- src/directkeys/_nixmouse.py | 46 +- src/directkeys/_winkeyboard.py | 554 ++++++++++-------- src/directkeys/_winmouse.py | 52 +- src/directkeys/mouse.py | 76 ++- tests/test_keyboard.py | 903 ++++++++++++++++++----------- tests/test_mouse.py | 50 +- 26 files changed, 1954 insertions(+), 1268 deletions(-) diff --git a/examples/customizable_hotkey.py b/examples/customizable_hotkey.py index 8e7e7ff1..eee7f7c9 100644 --- a/examples/customizable_hotkey.py +++ b/examples/customizable_hotkey.py @@ -1,12 +1,15 @@ import directkeys -print('Press and release your desired shortcut: ') +print("Press and release your desired shortcut: ") shortcut = directkeys.read_hotkey() -print('Shortcut selected:', shortcut) +print("Shortcut selected:", shortcut) + def on_triggered(): - print("Triggered!") + print("Triggered!") + + directkeys.add_hotkey(shortcut, on_triggered) print("Press ESC to stop.") -directkeys.wait('esc') +directkeys.wait("esc") diff --git a/examples/pressed_keys.py b/examples/pressed_keys.py index e56e26cb..c69fb306 100644 --- a/examples/pressed_keys.py +++ b/examples/pressed_keys.py @@ -2,17 +2,19 @@ Prints the scan code of all currently pressed keys. Updates on every keyboard event. """ + import sys -sys.path.append('..') +sys.path.append("..") import directkeys def print_pressed_keys(e): - line = ', '.join(str(code) for code in directkeys._pressed_events) - # '\r' and end='' overwrites the previous line. - # ' '*40 prints 40 spaces at the end to ensure the previous line is cleared. - print('\r' + line + ' '*40, end='') + line = ", ".join(str(code) for code in directkeys._pressed_events) + # '\r' and end='' overwrites the previous line. + # ' '*40 prints 40 spaces at the end to ensure the previous line is cleared. + print("\r" + line + " " * 40, end="") + directkeys.hook(print_pressed_keys) directkeys.wait() diff --git a/examples/push_to_talk_ubuntu.py b/examples/push_to_talk_ubuntu.py index 4349f7c0..be2cf95b 100644 --- a/examples/push_to_talk_ubuntu.py +++ b/examples/push_to_talk_ubuntu.py @@ -1,4 +1,4 @@ -#quick and dirty push-to-talk example for Ubuntu 16.04, by Abd Azrad +# quick and dirty push-to-talk example for Ubuntu 16.04, by Abd Azrad import subprocess @@ -6,23 +6,26 @@ is_muted = False + def unmute(): - global is_muted - if not is_muted: # if mic is already enabled - return # do nothing - is_muted = False - subprocess.call('amixer set Capture cap', shell=True) # unmute mic + global is_muted + if not is_muted: # if mic is already enabled + return # do nothing + is_muted = False + subprocess.call("amixer set Capture cap", shell=True) # unmute mic + def mute(): - global is_muted - is_muted = True - subprocess.call('amixer set Capture nocap', shell=True) # mute mic + global is_muted + is_muted = True + subprocess.call("amixer set Capture nocap", shell=True) # mute mic + if __name__ == "__main__": - is_muted = True - mute() # mute on startup + is_muted = True + mute() # mute on startup - directkeys.add_hotkey('win', unmute) # unmute on keydown - directkeys.add_hotkey('win', mute, trigger_on_release=True) # mute on keyup + directkeys.add_hotkey("win", unmute) # unmute on keydown + directkeys.add_hotkey("win", mute, trigger_on_release=True) # mute on keyup - directkeys.wait() # wait forever + directkeys.wait() # wait forever diff --git a/examples/segmented_macro.py b/examples/segmented_macro.py index 5a15d06d..9bb8eb24 100644 --- a/examples/segmented_macro.py +++ b/examples/segmented_macro.py @@ -5,29 +5,30 @@ It's useful for presentations, to ensure typing accuracy while still giving you time to speak between segments. """ + import sys -sys.path.append('../') +sys.path.append("../") import os import pickle import directkeys if len(sys.argv) == 1: - filename = input('Enter filename to save/load events: ') + filename = input("Enter filename to save/load events: ") else: filename = sys.argv[1] if os.path.exists(filename): - segments = pickle.load(open(filename, 'rb')) + segments = pickle.load(open(filename, "rb")) for i, segment in enumerate(segments): - print(f'Press F1 to play segment {i+1}/{len(segments)}') - print(f'Duration: {segment[-1].time - segment[0].time:.02} seconds') - directkeys.wait('F1') + print(f"Press F1 to play segment {i + 1}/{len(segments)}") + print(f"Duration: {segment[-1].time - segment[0].time:.02} seconds") + directkeys.wait("F1") directkeys.play(segment) else: - print('Press F1 to save this fragment. Press F2 to discard it. Press F3 to stop recording.') + print("Press F1 to save this fragment. Press F2 to discard it. Press F3 to stop recording.") segments = [] segment = [] @@ -35,22 +36,22 @@ def handle_event(event): global segment - if directkeys.matches(event, 'F1'): + if directkeys.matches(event, "F1"): if event.event_type == directkeys.KEY_DOWN: if segment: segments.append(segment) segment = [] - print('Saved', len(segments)) - elif directkeys.matches(event, 'F2'): + print("Saved", len(segments)) + elif directkeys.matches(event, "F2"): if event.event_type == directkeys.KEY_DOWN: segment = [] - print('Discarded') + print("Discarded") else: segment.append(event) directkeys.hook(handle_event) - directkeys.wait('F3') + directkeys.wait("F3") directkeys.hook(handle_event) - pickle.dump(segments, open(filename, 'wb')) - print(f'Saved {len(segments)} segments to {filename}') + pickle.dump(segments, open(filename, "wb")) + print(f"Saved {len(segments)} segments to {filename}") diff --git a/examples/simulate_held_down.py b/examples/simulate_held_down.py index da3fd6b2..a481f29d 100644 --- a/examples/simulate_held_down.py +++ b/examples/simulate_held_down.py @@ -5,6 +5,6 @@ # Sends 20 "key down" events in 0.1 second intervals, followed by a single # "key up" event. for i in range(20): - directkeys.press('a') + directkeys.press("a") time.sleep(0.1) -directkeys.release('a') +directkeys.release("a") diff --git a/examples/stdin_stdout_events.py b/examples/stdin_stdout_events.py index ca8e9b99..b67c812d 100644 --- a/examples/stdin_stdout_events.py +++ b/examples/stdin_stdout_events.py @@ -2,17 +2,18 @@ Prints lines with JSON object for each keyboard event, and reads similar events from stdin to simulate events. Example: - {"event_type": "down", "name": "a", "scan_code": 30, "time": 1491442622.6348252} - {"event_type": "down", "name": "s", "scan_code": 31, "time": 1491442622.664881} - {"event_type": "down", "name": "d", "scan_code": 32, "time": 1491442622.7148278} - {"event_type": "down", "name": "f", "scan_code": 33, "time": 1491442622.7544951} - {"event_type": "up", "name": "a", "scan_code": 30, "time": 1491442622.7748237} - {"event_type": "up", "name": "s", "scan_code": 31, "time": 1491442622.825077} - {"event_type": "up", "name": "d", "scan_code": 32, "time": 1491442622.8644736} - {"event_type": "up", "name": "f", "scan_code": 33, "time": 1491442622.9056144} + {"event_type": "down", "name": "a", "scan_code": 30, "time": 1491442622.6348252} + {"event_type": "down", "name": "s", "scan_code": 31, "time": 1491442622.664881} + {"event_type": "down", "name": "d", "scan_code": 32, "time": 1491442622.7148278} + {"event_type": "down", "name": "f", "scan_code": 33, "time": 1491442622.7544951} + {"event_type": "up", "name": "a", "scan_code": 30, "time": 1491442622.7748237} + {"event_type": "up", "name": "s", "scan_code": 31, "time": 1491442622.825077} + {"event_type": "up", "name": "d", "scan_code": 32, "time": 1491442622.8644736} + {"event_type": "up", "name": "f", "scan_code": 33, "time": 1491442622.9056144} """ + import sys -sys.path.append('..') +sys.path.append("..") # Also available as just `python -m keyboard`. diff --git a/examples/write.py b/examples/write.py index 0b8297b2..9b59570f 100644 --- a/examples/write.py +++ b/examples/write.py @@ -2,12 +2,13 @@ Given text files or text from stdin, simulates keyboard events that type the text character-by-character. """ + import sys -sys.path.append('../') +sys.path.append("../") import fileinput import directkeys for line in fileinput.input(): - directkeys.write(line) + directkeys.write(line) diff --git a/make_release.py b/make_release.py index 7c91e813..2bfe135f 100644 --- a/make_release.py +++ b/make_release.py @@ -22,6 +22,7 @@ - Use raw semantic versioning for CHANGES.md and PyPI (e.g. 2.3.1), and prepend 'v' for git tags and releases (e.g. v2.3.1). """ + import atexit import os import re @@ -31,52 +32,56 @@ import directkeys -run(['make', 'clean', 'build'], check=True) - -assert re.fullmatch(r'\d+\.\d+\.\d+', directkeys.version) -last_version = check_output(['git', 'describe', '--abbrev=0'], universal_newlines=True).strip('v\n') -assert directkeys.version != last_version, 'Must update directkeys.version first.' - -commits = check_output(['git', 'log', f'v{last_version}..HEAD', '--oneline'], universal_newlines=True) -with open('message.txt', 'w') as message_file: - atexit.register(lambda: os.remove('message.txt')) - - message_file.write('\n\n\n') - message_file.write('# Enter changes one per line like this:\n') - message_file.write('# - Added `foobar`.\n\n\n') - message_file.write(f'# As a reminder, here\'s the last commits since version {last_version}:\n\n') - for line in commits.strip().split('\n'): - message_file.write(f'# {line}\n') - -run(['vim', 'message.txt']) -with open('message.txt') as message_file: - lines = [line for line in message_file.readlines() if not line.startswith('#')] -message = ''.join(lines).strip() +run(["make", "clean", "build"], check=True) + +assert re.fullmatch(r"\d+\.\d+\.\d+", directkeys.version) +last_version = check_output(["git", "describe", "--abbrev=0"], universal_newlines=True).strip("v\n") +assert directkeys.version != last_version, "Must update directkeys.version first." + +commits = check_output( + ["git", "log", f"v{last_version}..HEAD", "--oneline"], universal_newlines=True +) +with open("message.txt", "w") as message_file: + atexit.register(lambda: os.remove("message.txt")) + + message_file.write("\n\n\n") + message_file.write("# Enter changes one per line like this:\n") + message_file.write("# - Added `foobar`.\n\n\n") + message_file.write( + f"# As a reminder, here's the last commits since version {last_version}:\n\n" + ) + for line in commits.strip().split("\n"): + message_file.write(f"# {line}\n") + +run(["vim", "message.txt"]) +with open("message.txt") as message_file: + lines = [line for line in message_file.readlines() if not line.startswith("#")] +message = "".join(lines).strip() if not message: - print('Aborting release due to empty message.') + print("Aborting release due to empty message.") exit() -with open('message.txt', 'w') as message_file: +with open("message.txt", "w") as message_file: message_file.write(message) -with open('CHANGES.md') as changes_file: +with open("CHANGES.md") as changes_file: old_changes = changes_file.read() -with open('CHANGES.md', 'w') as changes_file: - changes_file.write(f'# {directkeys.version}\n\n{message}\n\n\n{old_changes}') +with open("CHANGES.md", "w") as changes_file: + changes_file.write(f"# {directkeys.version}\n\n{message}\n\n\n{old_changes}") -tag_name = 'v' + directkeys.version -if input('Commit README.md and CHANGES.md files? ').lower().startswith('y'): - run(['git', 'add', 'CHANGES.md', 'README.md']) - run(['git', 'commit', '-m', f'Update changes for {tag_name}']) - run(['git', 'push']) -run(['git', 'tag', '-a', tag_name, '--file', 'message.txt'], check=True) -run(['git', 'push', 'origin', tag_name], check=True) +tag_name = "v" + directkeys.version +if input("Commit README.md and CHANGES.md files? ").lower().startswith("y"): + run(["git", "add", "CHANGES.md", "README.md"]) + run(["git", "commit", "-m", f"Update changes for {tag_name}"]) + run(["git", "push"]) +run(["git", "tag", "-a", tag_name, "--file", "message.txt"], check=True) +run(["git", "push", "origin", tag_name], check=True) -token = input('To make a release enter your GitHub repo authorization token: ').strip() +token = input("To make a release enter your GitHub repo authorization token: ").strip() if token: - git_remotes = check_output(['git', 'remote', '-v']).decode('utf-8') - repo_path = re.search(r'github.com[:/](.+?)(?:\.git)? \(push\)', git_remotes).group(1) - releases_url = f'https://api.github.com/repos/{repo_path}/releases' + git_remotes = check_output(["git", "remote", "-v"]).decode("utf-8") + repo_path = re.search(r"github.com[:/](.+?)(?:\.git)? \(push\)", git_remotes).group(1) + releases_url = f"https://api.github.com/repos/{repo_path}/releases" print(releases_url) release = { "tag_name": tag_name, @@ -86,7 +91,9 @@ "draft": False, "prerelease": False, } - response = requests.post(releases_url, json=release, headers={'Authorization': 'token ' + token}) + response = requests.post( + releases_url, json=release, headers={"Authorization": "token " + token} + ) print(response.status_code, response.text) -run(['twine', 'upload', 'dist/*'], check=True, shell=True) +run(["twine", "upload", "dist/*"], check=True, shell=True) diff --git a/pyproject.toml b/pyproject.toml index 0183d04e..55c2dc9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,16 @@ directory = "coverage_html_report" [tool.ruff] target-version = "py39" line-length = 100 -extend-exclude = ["tests/manual"] +# README.md is generated from the __init__ docstring by the `build` target, so +# letting Ruff reformat its code blocks would put the two permanently out of +# sync, each rewriting the other on the next run. +extend-exclude = ["tests/manual", "*.md"] + +[tool.ruff.format] +# Deliberately left off: the __init__ docstring is published verbatim as the +# PyPI description and regenerated into README.md, so the formatter must not +# rewrite the example code inside it. +docstring-code-format = false [tool.ruff.lint] select = [ diff --git a/setup.py b/setup.py index 45c4a55b..af4830cd 100644 --- a/setup.py +++ b/setup.py @@ -5,6 +5,7 @@ python -m build """ + from setuptools import setup setup() diff --git a/src/directkeys/__init__.py b/src/directkeys/__init__.py index c9012114..4ccf81c0 100644 --- a/src/directkeys/__init__.py +++ b/src/directkeys/__init__.py @@ -208,7 +208,8 @@ def on_space(): # https://stackoverflow.com/questions/983354/how-to-make-a-script-wait-for-a-pressed-key ``` """ -__version__ = '1.0.0' + +__version__ = "1.0.0" # Kept as an alias: the upstream project exposed the version under this name. version = __version__ @@ -225,12 +226,22 @@ def on_space(): from threading import Thread as _Thread -def _is_str(x): return isinstance(x, str) -def _is_number(x): return isinstance(x, int) -def _is_list(x): return isinstance(x, (list, tuple)) +def _is_str(x): + return isinstance(x, str) + + +def _is_number(x): + return isinstance(x, int) + + +def _is_list(x): + return isinstance(x, (list, tuple)) + # Just a dynamic object to store attributes for the closures. -class _State: pass +class _State: + pass + # The "Event" class from `threading` ignores signals when waiting and is # impossible to interrupt with Ctrl+C. So we rewrite `wait` to wait in small, @@ -241,6 +252,7 @@ def wait(self): if _UninterruptibleEvent.wait(self, 0.5): break + # Load the base dependencies before the backend, to avoid a circular import. # Import the platform specific backend. import platform as _platform @@ -251,11 +263,11 @@ def wait(self): # KeyboardEvent is re-exported as part of the public API, not used here. from ._keyboard_event import KEY_DOWN, KEY_UP, KeyboardEvent # noqa: F401 -if _platform.system() == 'Windows': +if _platform.system() == "Windows": from . import _winkeyboard as _os_keyboard -elif _platform.system() == 'Linux': +elif _platform.system() == "Linux": from . import _nixkeyboard as _os_keyboard -elif _platform.system() == 'Darwin': +elif _platform.system() == "Darwin": try: from . import _darwinkeyboard as _os_keyboard except ImportError: @@ -265,6 +277,7 @@ def wait(self): else: raise OSError(f"Unsupported platform '{_platform.system()}'") + def set_alt_gr_abstraction(enabled): """ Enables or disables the AltGr abstraction in the Windows backend. @@ -283,13 +296,15 @@ def set_alt_gr_abstraction(enabled): global _ABSTRACT_ALT_GR _ABSTRACT_ALT_GR = bool(enabled) # Let the backend reconfigure itself, if it needs to. - if hasattr(_os_keyboard, 'rebuild_name_tables'): + if hasattr(_os_keyboard, "rebuild_name_tables"): _os_keyboard.rebuild_name_tables() + def get_alt_gr_abstraction_state(): - """ Returns True if the AltGr abstraction is currently enabled. """ + """Returns True if the AltGr abstraction is currently enabled.""" return _ABSTRACT_ALT_GR + # Import the remaining backend functions. `_os_keyboard` is an alias bound to # the module imported above, so these have to be looked up as attributes: a # `from ._os_keyboard import ...` would search for a submodule that does not @@ -300,23 +315,30 @@ def _fallback_force_reset_keyboard(): """ pass + def _fallback_get_stuck_keys(): """ Fallback for platforms without a stuck key implementation. Returns []. """ return [] + def _fallback_reset_internal_state(): """ Fallback for platforms with no internal state to reset. Does nothing. """ pass -force_reset_keyboard = getattr(_os_keyboard, 'force_reset_keyboard', _fallback_force_reset_keyboard) -get_stuck_keys = getattr(_os_keyboard, 'get_stuck_keys', _fallback_get_stuck_keys) -reset_internal_state = getattr(_os_keyboard, '_reset_internal_state', _fallback_reset_internal_state) + +force_reset_keyboard = getattr(_os_keyboard, "force_reset_keyboard", _fallback_force_reset_keyboard) +get_stuck_keys = getattr(_os_keyboard, "get_stuck_keys", _fallback_get_stuck_keys) +reset_internal_state = getattr( + _os_keyboard, "_reset_internal_state", _fallback_reset_internal_state +) _modifier_scan_codes = set() + + def is_modifier(key): """ Returns True if `key` is a scan code or name of a modifier key. @@ -329,13 +351,16 @@ def is_modifier(key): _modifier_scan_codes.update(*scan_codes) return key in _modifier_scan_codes + _pressed_events_lock = _Lock() _pressed_events = {} _physically_pressed_keys = _pressed_events _logically_pressed_keys = {} + + class _KeyboardListener(_GenericListener): transition_table = { - #Current state of the modifier, per `modifier_states`. + # Current state of the modifier, per `modifier_states`. #| #| Type of event that triggered this modifier update. #| | @@ -346,35 +371,33 @@ class _KeyboardListener(_GenericListener): #| | | => | Accept the event? #| | | | | #| | | | | Next state. - #v v v v v v - ('free', KEY_UP, 'modifier'): (False, True, 'free'), - ('free', KEY_DOWN, 'modifier'): (False, False, 'pending'), - ('pending', KEY_UP, 'modifier'): (True, True, 'free'), - ('pending', KEY_DOWN, 'modifier'): (False, True, 'allowed'), - ('suppressed', KEY_UP, 'modifier'): (False, False, 'free'), - ('suppressed', KEY_DOWN, 'modifier'): (False, False, 'suppressed'), - ('allowed', KEY_UP, 'modifier'): (False, True, 'free'), - ('allowed', KEY_DOWN, 'modifier'): (False, True, 'allowed'), - - ('free', KEY_UP, 'hotkey'): (False, None, 'free'), - ('free', KEY_DOWN, 'hotkey'): (False, None, 'free'), - ('pending', KEY_UP, 'hotkey'): (False, None, 'suppressed'), - ('pending', KEY_DOWN, 'hotkey'): (False, None, 'suppressed'), - ('suppressed', KEY_UP, 'hotkey'): (False, None, 'suppressed'), - ('suppressed', KEY_DOWN, 'hotkey'): (False, None, 'suppressed'), - ('allowed', KEY_UP, 'hotkey'): (False, None, 'allowed'), - ('allowed', KEY_DOWN, 'hotkey'): (False, None, 'allowed'), - - ('free', KEY_UP, 'other'): (False, True, 'free'), - ('free', KEY_DOWN, 'other'): (False, True, 'free'), - ('pending', KEY_UP, 'other'): (True, True, 'allowed'), - ('pending', KEY_DOWN, 'other'): (True, True, 'allowed'), + # v v v v v v + ("free", KEY_UP, "modifier"): (False, True, "free"), + ("free", KEY_DOWN, "modifier"): (False, False, "pending"), + ("pending", KEY_UP, "modifier"): (True, True, "free"), + ("pending", KEY_DOWN, "modifier"): (False, True, "allowed"), + ("suppressed", KEY_UP, "modifier"): (False, False, "free"), + ("suppressed", KEY_DOWN, "modifier"): (False, False, "suppressed"), + ("allowed", KEY_UP, "modifier"): (False, True, "free"), + ("allowed", KEY_DOWN, "modifier"): (False, True, "allowed"), + ("free", KEY_UP, "hotkey"): (False, None, "free"), + ("free", KEY_DOWN, "hotkey"): (False, None, "free"), + ("pending", KEY_UP, "hotkey"): (False, None, "suppressed"), + ("pending", KEY_DOWN, "hotkey"): (False, None, "suppressed"), + ("suppressed", KEY_UP, "hotkey"): (False, None, "suppressed"), + ("suppressed", KEY_DOWN, "hotkey"): (False, None, "suppressed"), + ("allowed", KEY_UP, "hotkey"): (False, None, "allowed"), + ("allowed", KEY_DOWN, "hotkey"): (False, None, "allowed"), + ("free", KEY_UP, "other"): (False, True, "free"), + ("free", KEY_DOWN, "other"): (False, True, "free"), + ("pending", KEY_UP, "other"): (True, True, "allowed"), + ("pending", KEY_DOWN, "other"): (True, True, "allowed"), # Necessary when hotkeys are removed after beign triggered, such as # TestKeyboard.test_add_hotkey_multistep_suppress_modifier. - ('suppressed', KEY_UP, 'other'): (False, False, 'allowed'), - ('suppressed', KEY_DOWN, 'other'): (True, True, 'allowed'), - ('allowed', KEY_UP, 'other'): (False, True, 'allowed'), - ('allowed', KEY_DOWN, 'other'): (False, True, 'allowed'), + ("suppressed", KEY_UP, "other"): (False, False, "allowed"), + ("suppressed", KEY_DOWN, "other"): (True, True, "allowed"), + ("allowed", KEY_UP, "other"): (False, True, "allowed"), + ("allowed", KEY_DOWN, "other"): (False, True, "allowed"), } def init(self): @@ -391,7 +414,7 @@ def init(self): # Supporting hotkey suppression is harder than it looks. See # https://github.com/boppreh/keyboard/issues/22 - self.modifier_states = {} # "alt" -> "allowed" + self.modifier_states = {} # "alt" -> "allowed" def pre_process_event(self, event): for key_hook in self.nonblocking_keys[event.scan_code]: @@ -402,7 +425,7 @@ def pre_process_event(self, event): for callback in self.nonblocking_hotkeys[hotkey]: callback(event) - return event.scan_code or (event.name and event.name != 'unknown') + return event.scan_code or (event.name and event.name != "unknown") def direct_callback(self, event): """ @@ -427,7 +450,8 @@ def direct_callback(self, event): # Update tables of currently pressed keys and modifiers. with _pressed_events_lock: if event_type == KEY_DOWN: - if is_modifier(scan_code): self.active_modifiers.add(scan_code) + if is_modifier(scan_code): + self.active_modifiers.add(scan_code) _pressed_events[scan_code] = event hotkey = tuple(sorted(_pressed_events)) if event_type == KEY_UP: @@ -444,7 +468,7 @@ def direct_callback(self, event): if self.blocking_hotkeys: if self.filtered_modifiers[scan_code]: - origin = 'modifier' + origin = "modifier" modifiers_to_update = {scan_code} else: modifiers_to_update = self.active_modifiers @@ -453,15 +477,17 @@ def direct_callback(self, event): callback_results = [callback(event) for callback in self.blocking_hotkeys[hotkey]] if callback_results: accept = all(callback_results) - origin = 'hotkey' + origin = "hotkey" else: - origin = 'other' + origin = "other" for key in sorted(modifiers_to_update): - transition_tuple = (self.modifier_states.get(key, 'free'), event_type, origin) + transition_tuple = (self.modifier_states.get(key, "free"), event_type, origin) should_press, new_accept, new_state = self.transition_table[transition_tuple] - if should_press: press(key) - if new_accept is not None: accept = new_accept + if should_press: + press(key) + if new_accept is not None: + accept = new_accept self.modifier_states[key] = new_state if accept: @@ -478,8 +504,10 @@ def direct_callback(self, event): def listen(self): _os_keyboard.listen(self.direct_callback) + _listener = _KeyboardListener() + def key_to_scan_codes(key, error_if_missing=True): """ Returns a list of scan codes associated with this key (name or scan code). @@ -489,27 +517,32 @@ def key_to_scan_codes(key, error_if_missing=True): elif _is_list(key): return sum((key_to_scan_codes(i) for i in key), ()) elif not _is_str(key): - raise ValueError('Unexpected key type ' + str(type(key)) + ', value (' + repr(key) + ')') + raise ValueError("Unexpected key type " + str(type(key)) + ", value (" + repr(key) + ")") normalized = normalize_name(key) if normalized in sided_modifiers: - left_scan_codes = key_to_scan_codes('left ' + normalized, False) - right_scan_codes = key_to_scan_codes('right ' + normalized, False) + left_scan_codes = key_to_scan_codes("left " + normalized, False) + right_scan_codes = key_to_scan_codes("right " + normalized, False) return left_scan_codes + tuple(c for c in right_scan_codes if c not in left_scan_codes) try: # Put items in ordered dict to remove duplicates. - t = tuple(_collections.OrderedDict((scan_code, True) for scan_code, modifier in _os_keyboard.map_name(normalized))) + t = tuple( + _collections.OrderedDict( + (scan_code, True) for scan_code, modifier in _os_keyboard.map_name(normalized) + ) + ) e = None except (KeyError, ValueError) as exception: t = () e = exception if not t and error_if_missing: - raise ValueError(f'Key {key!r} is not mapped to any known key.', e) + raise ValueError(f"Key {key!r} is not mapped to any known key.", e) else: return t + def parse_hotkey(hotkey): """ Parses a user-provided hotkey into nested tuples representing the @@ -538,11 +571,12 @@ def parse_hotkey(hotkey): return hotkey steps = [] - for step in _re.split(r',\s?', hotkey): - keys = _re.split(r'\s?\+\s?', step) + for step in _re.split(r",\s?", hotkey): + keys = _re.split(r"\s?\+\s?", step) steps.append(tuple(key_to_scan_codes(key) for key in keys)) return tuple(steps) + def send(hotkey, do_press=True, do_release=True): """ Sends OS events that perform the given *hotkey* hotkey. @@ -573,17 +607,21 @@ def send(hotkey, do_press=True, do_release=True): _listener.is_replaying = False + # Alias. press_and_release = send + def press(hotkey): - """ Presses and holds down a hotkey (see `send`). """ + """Presses and holds down a hotkey (see `send`).""" send(hotkey, True, False) + def release(hotkey): - """ Releases a hotkey (see `send`). """ + """Releases a hotkey (see `send`).""" send(hotkey, False, True) + def is_pressed(hotkey): """ Returns True if the key is pressed. @@ -601,7 +639,9 @@ def is_pressed(hotkey): steps = parse_hotkey(hotkey) if len(steps) > 1: - raise ValueError("Impossible to check if multi-step hotkeys are pressed (`a+b` is ok, `a, b` isn't).") + raise ValueError( + "Impossible to check if multi-step hotkeys are pressed (`a+b` is ok, `a, b` isn't)." + ) # Convert _pressed_events into a set with _pressed_events_lock: @@ -611,6 +651,7 @@ def is_pressed(hotkey): return False return True + def call_later(fn, args=(), delay=0.001): """ Calls the provided function in a new thread after waiting some time. @@ -620,7 +661,10 @@ def call_later(fn, args=(), delay=0.001): thread = _Thread(target=lambda: (_time.sleep(delay), fn(*args))) thread.start() + _hooks = {} + + def hook(callback, suppress=False, on_remove=lambda: None): """ Installs a global listener on all available keyboards, invoking `callback` @@ -644,26 +688,31 @@ def hook(callback, suppress=False, on_remove=lambda: None): append, remove = _listener.add_handler, _listener.remove_handler append(callback) + def remove_(): _hooks.pop(callback, None) _hooks.pop(remove_, None) remove(callback) on_remove() + _hooks[callback] = _hooks[remove_] = remove_ return remove_ + def on_press(callback, suppress=False): """ Invokes `callback` for every KEY_DOWN event. For details see `hook`. """ return hook(lambda e: e.event_type == KEY_UP or callback(e), suppress=suppress) + def on_release(callback, suppress=False): """ Invokes `callback` for every KEY_UP event. For details see `hook`. """ return hook(lambda e: e.event_type == KEY_DOWN or callback(e), suppress=suppress) + def hook_key(key, callback, suppress=False): """ Hooks key up and key down events for a single key. Returns the event handler @@ -682,32 +731,39 @@ def hook_key(key, callback, suppress=False): def remove_(): _hooks.pop(callback, None) _hooks.pop(key, None) - _hooks.pop(remove_ ,None) + _hooks.pop(remove_, None) for scan_code in scan_codes: store[scan_code].remove(callback) + _hooks[callback] = _hooks[key] = _hooks[remove_] = remove_ return remove_ + def on_press_key(key, callback, suppress=False): """ Invokes `callback` for KEY_DOWN event related to the given key. For details see `hook`. """ return hook_key(key, lambda e: e.event_type == KEY_UP or callback(e), suppress=suppress) + def on_release_key(key, callback, suppress=False): """ Invokes `callback` for KEY_UP event related to the given key. For details see `hook`. """ return hook_key(key, lambda e: e.event_type == KEY_DOWN or callback(e), suppress=suppress) + def unhook(remove): """ Removes a previously added hook, either by callback or by the return value of `hook`. """ _hooks[remove]() + + unhook_key = unhook + def unhook_all(): """ Removes all keyboard hooks in use, including hotkeys, abbreviations, word @@ -720,33 +776,43 @@ def unhook_all(): del _listener.handlers[:] unhook_all_hotkeys() + def block_key(key): """ Suppresses all key events of the given key, regardless of modifiers. """ return hook_key(key, lambda e: False, suppress=True) + + unblock_key = unhook_key + def remap_key(src, dst): """ Whenever the key `src` is pressed or released, regardless of modifiers, press or release the hotkey `dst` instead. """ + def handler(event): if event.event_type == KEY_DOWN: press(dst) else: release(dst) return False + return hook_key(src, handler, suppress=True) + + unremap_key = unhook_key + def parse_hotkey_combinations(hotkey): """ Parses a user-provided hotkey. Differently from `parse_hotkey`, instead of each step being a list of the different scan codes for each key, each step is a list of all possible combinations of those scan codes. """ + def combine_step(step): # A single step may be composed of many keys, and each key can have # multiple scan codes. To speed up hotkey matching and avoid introducing @@ -757,6 +823,7 @@ def combine_step(step): return tuple(tuple(combine_step(step)) for step in parse_hotkey(hotkey)) + def _add_hotkey_step(handler, combinations, suppress): """ Hooks a single-step hotkey (e.g. 'shift+a'). @@ -778,9 +845,13 @@ def remove(): if is_modifier(scan_code): _listener.filtered_modifiers[scan_code] -= 1 container[scan_codes].remove(handler) + return remove + _hotkeys = {} + + def add_hotkey(hotkey, callback, args=(), suppress=False, timeout=1, trigger_on_release=False): """ Invokes a callback every time a hotkey is pressed. The hotkey must @@ -831,13 +902,22 @@ def add_hotkey(hotkey, callback, args=(), suppress=False, timeout=1, trigger_on_ # and any mistake will make that key "sticky". Therefore just let all # KEY_UP events go through as long as that's not what we are listening # for. - handler = lambda e: (event_type == KEY_DOWN and e.event_type == KEY_UP and e.scan_code in _logically_pressed_keys) or (event_type == e.event_type and callback()) + handler = lambda e: ( + ( + event_type == KEY_DOWN + and e.event_type == KEY_UP + and e.scan_code in _logically_pressed_keys + ) + or (event_type == e.event_type and callback()) + ) remove_step = _add_hotkey_step(handler, steps[0], suppress) + def remove_(): remove_step() _hotkeys.pop(hotkey, None) _hotkeys.pop(remove_, None) _hotkeys.pop(callback, None) + # TODO: allow multiple callbacks for each hotkey without overwriting the # remover. _hotkeys[hotkey] = _hotkeys[remove_] = _hotkeys[callback] = remove_ @@ -847,18 +927,18 @@ def remove_(): state.remove_catch_misses = lambda: None state.remove_last_step = None state.suppressed_events = [] - state.last_update = float('-inf') + state.last_update = float("-inf") def catch_misses(event, force_fail=False): if ( + ( event.event_type == event_type and state.index and event.scan_code not in allowed_keys_by_step[state.index] - ) or ( - timeout - and _time.monotonic() - state.last_update >= timeout - ) or force_fail: # Weird formatting to ensure short-circuit. - + ) + or (timeout and _time.monotonic() - state.last_update >= timeout) + or force_fail + ): # Weird formatting to ensure short-circuit. state.remove_last_step() for event in state.suppressed_events: @@ -885,6 +965,7 @@ def set_index(new_index): state.remove_catch_misses = hook(catch_misses, suppress=True) if new_index == len(steps) - 1: + def handler(event): if event.event_type == KEY_UP: remove() @@ -895,25 +976,25 @@ def handler(event): else: state.suppressed_events[:] = [event] return False + remove = _add_hotkey_step(handler, steps[state.index], suppress) else: # Fix value of next_index. - def handler(event, new_index=state.index+1): + def handler(event, new_index=state.index + 1): if event.event_type == KEY_UP: remove() set_index(new_index) state.suppressed_events.append(event) return False + remove = _add_hotkey_step(handler, steps[state.index], suppress) state.remove_last_step = remove state.last_update = _time.monotonic() return False + set_index(0) - allowed_keys_by_step = [ - set().union(*step) - for step in steps - ] + allowed_keys_by_step = [set().union(*step) for step in steps] def remove_(): state.remove_catch_misses() @@ -921,20 +1002,27 @@ def remove_(): _hotkeys.pop(hotkey, None) _hotkeys.pop(remove_, None) _hotkeys.pop(callback, None) + # TODO: allow multiple callbacks for each hotkey without overwriting the # remover. _hotkeys[hotkey] = _hotkeys[remove_] = _hotkeys[callback] = remove_ return remove_ + + register_hotkey = add_hotkey + def remove_hotkey(hotkey_or_callback): """ Removes a previously hooked hotkey. Must be called with the value returned by `add_hotkey`. """ _hotkeys[hotkey_or_callback]() + + unregister_hotkey = clear_hotkey = remove_hotkey + def unhook_all_hotkeys(): """ Removes all keyboard hotkeys in use, including abbreviations, word listeners, @@ -944,8 +1032,11 @@ def unhook_all_hotkeys(): # are removed together. _listener.blocking_hotkeys.clear() _listener.nonblocking_hotkeys.clear() + + unregister_all_hotkeys = remove_all_hotkeys = clear_all_hotkeys = unhook_all_hotkeys + def remap_hotkey(src, dst, suppress=True, trigger_on_release=False): """ Whenever the hotkey `src` is pressed, suppress it and send @@ -955,17 +1046,24 @@ def remap_hotkey(src, dst, suppress=True, trigger_on_release=False): remap('alt+w', 'ctrl+up') """ + def handler(): - active_modifiers = sorted(modifier for modifier, state in _listener.modifier_states.items() if state == 'allowed') + active_modifiers = sorted( + modifier for modifier, state in _listener.modifier_states.items() if state == "allowed" + ) for modifier in active_modifiers: release(modifier) send(dst) for modifier in reversed(active_modifiers): press(modifier) return False + return add_hotkey(src, handler, suppress=suppress, trigger_on_release=trigger_on_release) + + unremap_hotkey = remove_hotkey + def stash_state(): """ Builds a list of all currently pressed scan codes, releases them and returns @@ -978,6 +1076,7 @@ def stash_state(): _os_keyboard.release(scan_code) return state + def restore_state(scan_codes): """ Given a list of scan_codes ensures these keys, and only these keys, are @@ -995,12 +1094,14 @@ def restore_state(scan_codes): _listener.is_replaying = False + def restore_modifiers(scan_codes): """ Like `restore_state`, but only restores modifier keys. """ restore_state(scan_code for scan_code in scan_codes if is_modifier(scan_code)) + def write(text, delay=0, restore_state_after=True, exact=None): """ Sends artificial keyboard events to the OS, simulating the typing of a given @@ -1020,18 +1121,19 @@ def write(text, delay=0, restore_state_after=True, exact=None): value. """ if exact is None: - exact = _platform.system() == 'Windows' + exact = _platform.system() == "Windows" state = stash_state() # Window's typing of unicode characters is quite efficient and should be preferred. if exact: for letter in text: - if letter in '\n\b': + if letter in "\n\b": send(letter) else: _os_keyboard.type_unicode(letter) - if delay: _time.sleep(delay) + if delay: + _time.sleep(delay) else: for letter in text: try: @@ -1056,6 +1158,7 @@ def write(text, delay=0, restore_state_after=True, exact=None): if restore_state_after: restore_modifiers(state) + def wait(hotkey=None, suppress=False, trigger_on_release=False): """ Blocks the program execution until the given hotkey is pressed or, @@ -1063,13 +1166,16 @@ def wait(hotkey=None, suppress=False, trigger_on_release=False): """ if hotkey: lock = _Event() - remove = add_hotkey(hotkey, lambda: lock.set(), suppress=suppress, trigger_on_release=trigger_on_release) + remove = add_hotkey( + hotkey, lambda: lock.set(), suppress=suppress, trigger_on_release=trigger_on_release + ) lock.wait() remove_hotkey(remove) else: while True: _time.sleep(1e6) + def get_hotkey_name(names=None): """ Returns a string representation of hotkey from the given key names, or @@ -1093,13 +1199,14 @@ def get_hotkey_name(names=None): names = [e.name for e in _pressed_events.values()] else: names = [normalize_name(name) for name in names] - clean_names = {e.replace('left ', '').replace('right ', '').replace('+', 'plus') for e in names} + clean_names = {e.replace("left ", "").replace("right ", "").replace("+", "plus") for e in names} # https://developer.apple.com/macos/human-interface-guidelines/input-and-output/keyboard/ # > List modifier keys in the correct order. If you use more than one modifier key in a # > hotkey, always list them in this order: Control, Option, Shift, Command. - modifiers = ['ctrl', 'alt', 'shift', 'windows'] + modifiers = ["ctrl", "alt", "shift", "windows"] sorting_key = lambda k: (modifiers.index(k) if k in modifiers else 5, str(k)) - return '+'.join(sorted(clean_names, key=sorting_key)) + return "+".join(sorted(clean_names, key=sorting_key)) + def read_event(suppress=False): """ @@ -1112,6 +1219,7 @@ def read_event(suppress=False): unhook(hooked) return event + def read_key(suppress=False): """ Blocks until a keyboard event happens, then returns that event's name or, @@ -1120,6 +1228,7 @@ def read_key(suppress=False): event = read_event(suppress) return event.name or event.scan_code + def read_hotkey(suppress=True): """ Similar to `read_key()`, but blocks until the user presses and releases a @@ -1142,6 +1251,7 @@ def read_hotkey(suppress=True): names = [e.name for e in _pressed_events.values()] + [event.name] return get_hotkey_name(names) + def get_typed_strings(events, allow_backspace=True): """ Given a sequence of events, tries to deduce what strings were typed. @@ -1159,36 +1269,39 @@ def get_typed_strings(events, allow_backspace=True): get_type_strings(record()) #-> ['This is what', 'I recorded', ''] """ - backspace_name = 'delete' if _platform.system() == 'Darwin' else 'backspace' + backspace_name = "delete" if _platform.system() == "Darwin" else "backspace" shift_pressed = False capslock_pressed = False - string = '' + string = "" for event in events: name = event.name # Space is the only key that we _parse_hotkey to the spelled out name # because of legibility. Now we have to undo that. - if event.name == 'space': - name = ' ' + if event.name == "space": + name = " " - if 'shift' in event.name: - shift_pressed = event.event_type == 'down' - elif event.name == 'caps lock' and event.event_type == 'down': + if "shift" in event.name: + shift_pressed = event.event_type == "down" + elif event.name == "caps lock" and event.event_type == "down": capslock_pressed = not capslock_pressed - elif allow_backspace and event.name == backspace_name and event.event_type == 'down': + elif allow_backspace and event.name == backspace_name and event.event_type == "down": string = string[:-1] - elif event.event_type == 'down': + elif event.event_type == "down": if len(name) == 1: if shift_pressed ^ capslock_pressed: name = name.upper() string = string + name else: yield string - string = '' + string = "" yield string + _recording = None + + def start_recording(recorded_events_queue=None): """ Starts recording all keyboard events into a global variable, or the given @@ -1201,6 +1314,7 @@ def start_recording(recorded_events_queue=None): _recording = (recorded_events_queue, hook(recorded_events_queue.put)) return _recording + def stop_recording(): """ Stops the global recording of events and returns a list of the events @@ -1213,7 +1327,8 @@ def stop_recording(): unhook(hooked) return list(recorded_events_queue.queue) -def record(until='escape', suppress=False, trigger_on_release=False): + +def record(until="escape", suppress=False, trigger_on_release=False): """ Records all keyboard events from all keyboards until the user presses the given hotkey. Then returns the list of events recorded, of type @@ -1227,6 +1342,7 @@ def record(until='escape', suppress=False, trigger_on_release=False): wait(until, suppress=suppress, trigger_on_release=trigger_on_release) return stop_recording() + def play(events, speed_factor=1.0): """ Plays a sequence of recorded events, maintaining the relative time @@ -1248,10 +1364,14 @@ def play(events, speed_factor=1.0): press(key) if event.event_type == KEY_DOWN else release(key) restore_modifiers(state) + + replay = play _word_listeners = {} -def add_word_listener(word, callback, triggers=['space'], match_suffix=False, timeout=2): + + +def add_word_listener(word, callback, triggers=["space"], match_suffix=False, timeout=2): """ Invokes a callback every time a sequence of characters is typed (e.g. 'pet') and followed by a trigger key (e.g. space). Modifiers (e.g. alt, ctrl, @@ -1277,36 +1397,40 @@ def add_word_listener(word, callback, triggers=['space'], match_suffix=False, ti Note: word matches are **case sensitive**. """ state = _State() - state.current = '' + state.current = "" state.time = -1 def handler(event): name = event.name - if event.event_type == KEY_UP or name in all_modifiers: return + if event.event_type == KEY_UP or name in all_modifiers: + return if timeout and event.time - state.time > timeout: - state.current = '' + state.current = "" state.time = event.time matched = state.current == word or (match_suffix and state.current.endswith(word)) if name in triggers and matched: callback() - state.current = '' + state.current = "" elif len(name) > 1: - state.current = '' + state.current = "" else: state.current += name hooked = hook(handler) + def remove(): hooked() _word_listeners.pop(word, None) _word_listeners.pop(handler, None) _word_listeners.pop(remove, None) + _word_listeners[word] = _word_listeners[handler] = _word_listeners[remove] = remove # TODO: allow multiple word listeners and removing them correctly. return remove + def remove_word_listener(word_or_handler): """ Removes a previously registered word listener. Accepts either the word used @@ -1315,6 +1439,7 @@ def remove_word_listener(word_or_handler): """ _word_listeners[word_or_handler]() + def add_abbreviation(source_text, replacement_text, match_suffix=False, timeout=2): """ Registers a hotkey that replaces one typed text with another. For example @@ -1332,10 +1457,11 @@ def add_abbreviation(source_text, replacement_text, match_suffix=False, timeout= For more details see `add_word_listener`. """ - replacement = '\b'*(len(source_text)+1) + replacement_text + replacement = "\b" * (len(source_text) + 1) + replacement_text callback = lambda: write(replacement) return add_word_listener(source_text, callback, match_suffix=match_suffix, timeout=timeout) + # Aliases. register_word_listener = add_word_listener register_abbreviation = add_abbreviation diff --git a/src/directkeys/__main__.py b/src/directkeys/__main__.py index 46aff002..66650f84 100644 --- a/src/directkeys/__main__.py +++ b/src/directkeys/__main__.py @@ -6,8 +6,10 @@ def print_event_json(event): - print(event.to_json(ensure_ascii=sys.stdout.encoding != 'utf-8')) + print(event.to_json(ensure_ascii=sys.stdout.encoding != "utf-8")) sys.stdout.flush() + + directkeys.hook(print_event_json) parse_event_json = lambda line: directkeys.KeyboardEvent(**json.loads(line)) diff --git a/src/directkeys/_canonical_names.py b/src/directkeys/_canonical_names.py index 10da204c..4047bb98 100644 --- a/src/directkeys/_canonical_names.py +++ b/src/directkeys/_canonical_names.py @@ -2,81 +2,71 @@ # Defaults to Windows canonical names (platform-specific overrides below) canonical_names = { - 'escape': 'esc', - 'return': 'enter', - 'del': 'delete', - 'control': 'ctrl', - - 'left arrow': 'left', - 'up arrow': 'up', - 'down arrow': 'down', - 'right arrow': 'right', - - ' ': 'space', # Prefer to spell out keys that would be hard to read. - '\x1b': 'esc', - '\x08': 'backspace', - '\n': 'enter', - '\t': 'tab', - '\r': 'enter', - - 'scrlk': 'scroll lock', - 'prtscn': 'print screen', - 'prnt scrn': 'print screen', - 'snapshot': 'print screen', - 'ins': 'insert', - 'pause break': 'pause', - 'ctrll lock': 'caps lock', - 'capslock': 'caps lock', - 'number lock': 'num lock', - 'numlock': 'num lock', - 'space bar': 'space', - 'spacebar': 'space', - 'linefeed': 'enter', - 'win': 'windows', - + "escape": "esc", + "return": "enter", + "del": "delete", + "control": "ctrl", + "left arrow": "left", + "up arrow": "up", + "down arrow": "down", + "right arrow": "right", + " ": "space", # Prefer to spell out keys that would be hard to read. + "\x1b": "esc", + "\x08": "backspace", + "\n": "enter", + "\t": "tab", + "\r": "enter", + "scrlk": "scroll lock", + "prtscn": "print screen", + "prnt scrn": "print screen", + "snapshot": "print screen", + "ins": "insert", + "pause break": "pause", + "ctrll lock": "caps lock", + "capslock": "caps lock", + "number lock": "num lock", + "numlock": "num lock", + "space bar": "space", + "spacebar": "space", + "linefeed": "enter", + "win": "windows", # Mac keys - 'command': 'windows', - 'cmd': 'windows', - 'control': 'ctrl', - 'option': 'alt', - - 'app': 'menu', - 'apps': 'menu', - 'application': 'menu', - 'applications': 'menu', - - 'pagedown': 'page down', - 'pageup': 'page up', - 'pgdown': 'page down', - 'pgup': 'page up', - - 'play/pause': 'play/pause media', - - 'num multiply': '*', - 'num divide': '/', - 'num add': '+', - 'num plus': '+', - 'num minus': '-', - 'num sub': '-', - 'num enter': 'enter', - 'num 0': '0', - 'num 1': '1', - 'num 2': '2', - 'num 3': '3', - 'num 4': '4', - 'num 5': '5', - 'num 6': '6', - 'num 7': '7', - 'num 8': '8', - 'num 9': '9', - - 'left win': 'left windows', - 'right win': 'right windows', - 'left control': 'left ctrl', - 'right control': 'right ctrl', - 'left menu': 'left alt', # Windows... - 'altgr': 'alt gr', - + "command": "windows", + "cmd": "windows", + "control": "ctrl", + "option": "alt", + "app": "menu", + "apps": "menu", + "application": "menu", + "applications": "menu", + "pagedown": "page down", + "pageup": "page up", + "pgdown": "page down", + "pgup": "page up", + "play/pause": "play/pause media", + "num multiply": "*", + "num divide": "/", + "num add": "+", + "num plus": "+", + "num minus": "-", + "num sub": "-", + "num enter": "enter", + "num 0": "0", + "num 1": "1", + "num 2": "2", + "num 3": "3", + "num 4": "4", + "num 5": "5", + "num 6": "6", + "num 7": "7", + "num 8": "8", + "num 9": "9", + "left win": "left windows", + "right win": "right windows", + "left control": "left ctrl", + "right control": "right ctrl", + "left menu": "left alt", # Windows... + "altgr": "alt gr", # https://www.x.org/releases/X11R7.6/doc/libX11/Compose/en_US.UTF-8.html # https://svn.apache.org/repos/asf/xmlgraphics/commons/tags/commons-1_0/src/java/org/apache/xmlgraphics/fonts/Glyphs.java # Note this list has plenty of uppercase letters that are not being used @@ -939,7 +929,7 @@ "questiondown": "¿", "questiondownsmall": "", "questionsmall": "", - "quotedbl": "\"", + "quotedbl": '"', "quotedblbase": "„", "quotedblleft": "“", "quotedblright": "”", @@ -1199,28 +1189,37 @@ "Zeta": "Ζ", "Zsmall": "", } -sided_modifiers = {'ctrl', 'alt', 'shift', 'windows'} -all_modifiers = {'alt', 'alt gr', 'ctrl', 'shift', 'windows'} | {'left ' + n for n in sided_modifiers} | {'right ' + n for n in sided_modifiers} +sided_modifiers = {"ctrl", "alt", "shift", "windows"} +all_modifiers = ( + {"alt", "alt gr", "ctrl", "shift", "windows"} + | {"left " + n for n in sided_modifiers} + | {"right " + n for n in sided_modifiers} +) # Platform-specific canonical overrides -if platform.system() == 'Darwin': - canonical_names.update({ - "command": "command", - "windows": "command", - "cmd": "command", - "win": "command", - "backspace": "delete", - 'alt gr': 'alt' # Issue #117 - }) - all_modifiers = {'alt', 'ctrl', 'shift', 'windows'} -if platform.system() == 'Linux': - canonical_names.update({ - "select": "end", - "find": "home", - 'next': 'page down', - 'prior': 'page up', - }) +if platform.system() == "Darwin": + canonical_names.update( + { + "command": "command", + "windows": "command", + "cmd": "command", + "win": "command", + "backspace": "delete", + "alt gr": "alt", # Issue #117 + } + ) + all_modifiers = {"alt", "ctrl", "shift", "windows"} +if platform.system() == "Linux": + canonical_names.update( + { + "select": "end", + "find": "home", + "next": "page down", + "prior": "page up", + } + ) + def normalize_name(name): """ @@ -1228,11 +1227,11 @@ def normalize_name(name): the canonical representation (e.g. "left ctrl") if one is known. """ if not name or not isinstance(name, str): - raise ValueError('Can only normalize non-empty string names. Unexpected '+ repr(name)) + raise ValueError("Can only normalize non-empty string names. Unexpected " + repr(name)) if len(name) > 1: name = name.lower() - if name != '_' and '_' in name: - name = name.replace('_', ' ') + if name != "_" and "_" in name: + name = name.replace("_", " ") return canonical_names.get(name, name) diff --git a/src/directkeys/_darwinkeyboard.py b/src/directkeys/_darwinkeyboard.py index 4d1feb84..4fdfb0df 100644 --- a/src/directkeys/_darwinkeyboard.py +++ b/src/directkeys/_darwinkeyboard.py @@ -9,61 +9,66 @@ from ._canonical_names import normalize_name from ._keyboard_event import KeyboardEvent -Carbon = ctypes.cdll.LoadLibrary(ctypes.util.find_library('Carbon')) +Carbon = ctypes.cdll.LoadLibrary(ctypes.util.find_library("Carbon")) + class KeyMap: - non_layout_keys = {vk: normalize_name(name) for vk, name in { - # Layout specific keys from https://stackoverflow.com/a/16125341/252218 - # Unfortunately no source for layout-independent keys was found. - 0x24: 'return', - 0x30: 'tab', - 0x31: 'space', - 0x33: 'delete', - 0x35: 'escape', - 0x37: 'command', - 0x38: 'shift', - 0x39: 'capslock', - 0x3a: 'option', - 0x3b: 'control', - 0x3c: 'right shift', - 0x3d: 'right option', - 0x3e: 'right control', - 0x3f: 'function', - 0x40: 'f17', - 0x48: 'volume up', - 0x49: 'volume down', - 0x4a: 'mute', - 0x4f: 'f18', - 0x50: 'f19', - 0x5a: 'f20', - 0x60: 'f5', - 0x61: 'f6', - 0x62: 'f7', - 0x63: 'f3', - 0x64: 'f8', - 0x65: 'f9', - 0x67: 'f11', - 0x69: 'f13', - 0x6a: 'f16', - 0x6b: 'f14', - 0x6d: 'f10', - 0x6f: 'f12', - 0x71: 'f15', - 0x72: 'help', - 0x73: 'home', - 0x74: 'page up', - 0x75: 'forward delete', - 0x76: 'f4', - 0x77: 'end', - 0x78: 'f2', - 0x79: 'page down', - 0x7a: 'f1', - 0x7b: 'left', - 0x7c: 'right', - 0x7d: 'down', - 0x7e: 'up', - }.items()} + non_layout_keys = { + vk: normalize_name(name) + for vk, name in { + # Layout specific keys from https://stackoverflow.com/a/16125341/252218 + # Unfortunately no source for layout-independent keys was found. + 0x24: "return", + 0x30: "tab", + 0x31: "space", + 0x33: "delete", + 0x35: "escape", + 0x37: "command", + 0x38: "shift", + 0x39: "capslock", + 0x3A: "option", + 0x3B: "control", + 0x3C: "right shift", + 0x3D: "right option", + 0x3E: "right control", + 0x3F: "function", + 0x40: "f17", + 0x48: "volume up", + 0x49: "volume down", + 0x4A: "mute", + 0x4F: "f18", + 0x50: "f19", + 0x5A: "f20", + 0x60: "f5", + 0x61: "f6", + 0x62: "f7", + 0x63: "f3", + 0x64: "f8", + 0x65: "f9", + 0x67: "f11", + 0x69: "f13", + 0x6A: "f16", + 0x6B: "f14", + 0x6D: "f10", + 0x6F: "f12", + 0x71: "f15", + 0x72: "help", + 0x73: "home", + 0x74: "page up", + 0x75: "forward delete", + 0x76: "f4", + 0x77: "end", + 0x78: "f2", + 0x79: "page down", + 0x7A: "f1", + 0x7B: "left", + 0x7C: "right", + 0x7D: "down", + 0x7E: "up", + }.items() + } layout_specific_keys = {} + def __init__(self): # Virtual key codes are usually the same for any given key, unless you have a different # keyboard layout. The only way I've found to determine the layout relies on (supposedly @@ -81,10 +86,11 @@ def __init__(self): UniChar4 = UniChar * 4 class CFRange(ctypes.Structure): - _fields_ = [('loc', CFIndex), - ('len', CFIndex)] + _fields_ = [("loc", CFIndex), ("len", CFIndex)] - kTISPropertyUnicodeKeyLayoutData = ctypes.c_void_p.in_dll(Carbon, 'kTISPropertyUnicodeKeyLayoutData') + kTISPropertyUnicodeKeyLayoutData = ctypes.c_void_p.in_dll( + Carbon, "kTISPropertyUnicodeKeyLayoutData" + ) shiftKey = 0x0200 alphaKey = 0x0400 optionKey = 0x0800 @@ -93,7 +99,7 @@ class CFRange(ctypes.Structure): kUCKeyTranslateNoDeadKeysBit = 0 # Set up function calls: - Carbon.CFDataGetBytes.argtypes = [CFDataRef] #, CFRange, UInt8 + Carbon.CFDataGetBytes.argtypes = [CFDataRef] # , CFRange, UInt8 Carbon.CFDataGetBytes.restype = None Carbon.CFDataGetLength.argtypes = [CFDataRef] Carbon.CFDataGetLength.restype = CFIndex @@ -107,16 +113,18 @@ class CFRange(ctypes.Structure): Carbon.TISCopyCurrentASCIICapableKeyboardLayoutInputSource.restype = ctypes.c_void_p Carbon.TISGetInputSourceProperty.argtypes = [ctypes.c_void_p, ctypes.c_void_p] Carbon.TISGetInputSourceProperty.restype = ctypes.c_void_p - Carbon.UCKeyTranslate.argtypes = [ctypes.c_void_p, - ctypes.c_uint16, - ctypes.c_uint16, - ctypes.c_uint32, - ctypes.c_uint32, - OptionBits, # keyTranslateOptions - ctypes.POINTER(ctypes.c_uint32), # deadKeyState - UniCharCount, # maxStringLength - ctypes.POINTER(UniCharCount), # actualStringLength - UniChar4] + Carbon.UCKeyTranslate.argtypes = [ + ctypes.c_void_p, + ctypes.c_uint16, + ctypes.c_uint16, + ctypes.c_uint32, + ctypes.c_uint32, + OptionBits, # keyTranslateOptions + ctypes.POINTER(ctypes.c_uint32), # deadKeyState + UniCharCount, # maxStringLength + ctypes.POINTER(UniCharCount), # actualStringLength + UniChar4, + ] Carbon.UCKeyTranslate.restype = ctypes.c_uint32 # Get keyboard layout @@ -126,7 +134,9 @@ class CFRange(ctypes.Structure): klis = Carbon.TISCopyCurrentASCIICapableKeyboardLayoutInputSource() k_layout = Carbon.TISGetInputSourceProperty(klis, kTISPropertyUnicodeKeyLayoutData) k_layout_size = Carbon.CFDataGetLength(k_layout) - k_layout_buffer = ctypes.create_string_buffer(k_layout_size) # TODO - Verify this works instead of initializing with empty string + k_layout_buffer = ctypes.create_string_buffer( + k_layout_size + ) # TODO - Verify this works instead of initializing with empty string Carbon.CFDataGetBytes(k_layout, CFRange(0, k_layout_size), ctypes.byref(k_layout_buffer)) # Generate character representations of key codes @@ -137,39 +147,43 @@ class CFRange(ctypes.Structure): keys_down = ctypes.c_uint32() char_count = UniCharCount() - retval = Carbon.UCKeyTranslate(k_layout_buffer, - key_code, - kUCKeyActionDisplay, - 0, # No modifier - Carbon.LMGetKbdType(), - kUCKeyTranslateNoDeadKeysBit, - ctypes.byref(keys_down), - 4, - ctypes.byref(char_count), - non_shifted_char) - - non_shifted_key = ''.join(chr(non_shifted_char[i]) for i in range(char_count.value)) - - retval = Carbon.UCKeyTranslate(k_layout_buffer, - key_code, - kUCKeyActionDisplay, - shiftKey >> 8, # Shift - Carbon.LMGetKbdType(), - kUCKeyTranslateNoDeadKeysBit, - ctypes.byref(keys_down), - 4, - ctypes.byref(char_count), - shifted_char) - - shifted_key = ''.join(chr(shifted_char[i]) for i in range(char_count.value)) + retval = Carbon.UCKeyTranslate( + k_layout_buffer, + key_code, + kUCKeyActionDisplay, + 0, # No modifier + Carbon.LMGetKbdType(), + kUCKeyTranslateNoDeadKeysBit, + ctypes.byref(keys_down), + 4, + ctypes.byref(char_count), + non_shifted_char, + ) + + non_shifted_key = "".join(chr(non_shifted_char[i]) for i in range(char_count.value)) + + retval = Carbon.UCKeyTranslate( + k_layout_buffer, + key_code, + kUCKeyActionDisplay, + shiftKey >> 8, # Shift + Carbon.LMGetKbdType(), + kUCKeyTranslateNoDeadKeysBit, + ctypes.byref(keys_down), + 4, + ctypes.byref(char_count), + shifted_char, + ) + + shifted_key = "".join(chr(shifted_char[i]) for i in range(char_count.value)) self.layout_specific_keys[key_code] = (non_shifted_key, shifted_key) # Cleanup Carbon.CFRelease(klis) def character_to_vk(self, character): - """ Returns a tuple of (scan_code, modifiers) where ``scan_code`` is a numeric scan code - and ``modifiers`` is an array of string modifier names (like 'shift') """ + """Returns a tuple of (scan_code, modifiers) where ``scan_code`` is a numeric scan code + and ``modifiers`` is an array of string modifier names (like 'shift')""" for vk in self.non_layout_keys: if self.non_layout_keys[vk] == character.lower(): return (vk, []) @@ -177,17 +191,17 @@ def character_to_vk(self, character): if self.layout_specific_keys[vk][0] == character: return (vk, []) elif self.layout_specific_keys[vk][1] == character: - return (vk, ['shift']) + return (vk, ["shift"]) raise ValueError(f"Unrecognized character: {character}") def vk_to_character(self, vk, modifiers=[]): - """ Returns a character corresponding to the specified scan code (with given - modifiers applied) """ + """Returns a character corresponding to the specified scan code (with given + modifiers applied)""" if vk in self.non_layout_keys: # Not a character return self.non_layout_keys[vk] elif vk in self.layout_specific_keys: - if 'shift' in modifiers: + if "shift" in modifiers: return self.layout_specific_keys[vk][1] return self.layout_specific_keys[vk][0] else: @@ -206,60 +220,60 @@ def __init__(self): "cmd": False, } self.media_keys = { - 'KEYTYPE_SOUND_UP': 0, - 'KEYTYPE_SOUND_DOWN': 1, - 'KEYTYPE_BRIGHTNESS_UP': 2, - 'KEYTYPE_BRIGHTNESS_DOWN': 3, - 'KEYTYPE_CAPS_LOCK': 4, - 'KEYTYPE_HELP': 5, - 'POWER_KEY': 6, - 'KEYTYPE_MUTE': 7, - 'UP_ARROW_KEY': 8, - 'DOWN_ARROW_KEY': 9, - 'KEYTYPE_NUM_LOCK': 10, - 'KEYTYPE_CONTRAST_UP': 11, - 'KEYTYPE_CONTRAST_DOWN': 12, - 'KEYTYPE_LAUNCH_PANEL': 13, - 'KEYTYPE_EJECT': 14, - 'KEYTYPE_VIDMIRROR': 15, - 'KEYTYPE_PLAY': 16, - 'KEYTYPE_NEXT': 17, - 'KEYTYPE_PREVIOUS': 18, - 'KEYTYPE_FAST': 19, - 'KEYTYPE_REWIND': 20, - 'KEYTYPE_ILLUMINATION_UP': 21, - 'KEYTYPE_ILLUMINATION_DOWN': 22, - 'KEYTYPE_ILLUMINATION_TOGGLE': 23 + "KEYTYPE_SOUND_UP": 0, + "KEYTYPE_SOUND_DOWN": 1, + "KEYTYPE_BRIGHTNESS_UP": 2, + "KEYTYPE_BRIGHTNESS_DOWN": 3, + "KEYTYPE_CAPS_LOCK": 4, + "KEYTYPE_HELP": 5, + "POWER_KEY": 6, + "KEYTYPE_MUTE": 7, + "UP_ARROW_KEY": 8, + "DOWN_ARROW_KEY": 9, + "KEYTYPE_NUM_LOCK": 10, + "KEYTYPE_CONTRAST_UP": 11, + "KEYTYPE_CONTRAST_DOWN": 12, + "KEYTYPE_LAUNCH_PANEL": 13, + "KEYTYPE_EJECT": 14, + "KEYTYPE_VIDMIRROR": 15, + "KEYTYPE_PLAY": 16, + "KEYTYPE_NEXT": 17, + "KEYTYPE_PREVIOUS": 18, + "KEYTYPE_FAST": 19, + "KEYTYPE_REWIND": 20, + "KEYTYPE_ILLUMINATION_UP": 21, + "KEYTYPE_ILLUMINATION_DOWN": 22, + "KEYTYPE_ILLUMINATION_TOGGLE": 23, } def press(self, key_code): - """ Sends a 'down' event for the specified scan code """ + """Sends a 'down' event for the specified scan code""" if key_code >= 128: # Media key ev = NSEvent.otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2_( - 14, # type - (0, 0), # location - 0xa00, # flags - 0, # timestamp - 0, # window - 0, # ctx - 8, # subtype - ((key_code-128) << 16) | (0xa << 8), # data1 - -1 # data2 + 14, # type + (0, 0), # location + 0xA00, # flags + 0, # timestamp + 0, # window + 0, # ctx + 8, # subtype + ((key_code - 128) << 16) | (0xA << 8), # data1 + -1, # data2 ) Quartz.CGEventPost(0, ev.CGEvent()) else: # Regular key # Update modifiers if necessary - if key_code == 0x37: # cmd + if key_code == 0x37: # cmd self.current_modifiers["cmd"] = True - elif key_code == 0x38 or key_code == 0x3C: # shift or right shift + elif key_code == 0x38 or key_code == 0x3C: # shift or right shift self.current_modifiers["shift"] = True - elif key_code == 0x39: # caps lock + elif key_code == 0x39: # caps lock self.current_modifiers["caps"] = True - elif key_code == 0x3A: # alt + elif key_code == 0x3A: # alt self.current_modifiers["alt"] = True - elif key_code == 0x3B: # ctrl + elif key_code == 0x3B: # ctrl self.current_modifiers["ctrl"] = True # Apply modifiers if necessary @@ -280,33 +294,33 @@ def press(self, key_code): time.sleep(0.01) def release(self, key_code): - """ Sends an 'up' event for the specified scan code """ + """Sends an 'up' event for the specified scan code""" if key_code >= 128: # Media key ev = NSEvent.otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2_( - 14, # type - (0, 0), # location - 0xb00, # flags - 0, # timestamp - 0, # window - 0, # ctx - 8, # subtype - ((key_code-128) << 16) | (0xb << 8), # data1 - -1 # data2 + 14, # type + (0, 0), # location + 0xB00, # flags + 0, # timestamp + 0, # window + 0, # ctx + 8, # subtype + ((key_code - 128) << 16) | (0xB << 8), # data1 + -1, # data2 ) Quartz.CGEventPost(0, ev.CGEvent()) else: # Regular key # Update modifiers if necessary - if key_code == 0x37: # cmd + if key_code == 0x37: # cmd self.current_modifiers["cmd"] = False - elif key_code == 0x38 or key_code == 0x3C: # shift or right shift + elif key_code == 0x38 or key_code == 0x3C: # shift or right shift self.current_modifiers["shift"] = False - elif key_code == 0x39: # caps lock + elif key_code == 0x39: # caps lock self.current_modifiers["caps"] = False - elif key_code == 0x3A: # alt + elif key_code == 0x3A: # alt self.current_modifiers["alt"] = False - elif key_code == 0x3B: # ctrl + elif key_code == 0x3B: # ctrl self.current_modifiers["ctrl"] = False # Apply modifiers if necessary @@ -328,18 +342,20 @@ def release(self, key_code): def map_char(self, character): if character in self.media_keys: - return (128+self.media_keys[character],[]) + return (128 + self.media_keys[character], []) else: return self.key_map.character_to_vk(character) + def map_scan_code(self, scan_code): if scan_code >= 128: - character = [k for k, v in enumerate(self.media_keys) if v == scan_code-128] + character = [k for k, v in enumerate(self.media_keys) if v == scan_code - 128] if len(character): return character[0] return None else: return self.key_map.vk_to_character(scan_code) + class KeyEventListener: def __init__(self, callback, blocking=False): self.blocking = blocking @@ -350,17 +366,18 @@ def __init__(self, callback, blocking=False): self.pressed_modifiers = set() def run(self): - """ Creates a listener and loops while waiting for an event. Intended to run as - a background thread. """ + """Creates a listener and loops while waiting for an event. Intended to run as + a background thread.""" self.tap = Quartz.CGEventTapCreate( Quartz.kCGSessionEventTap, Quartz.kCGHeadInsertEventTap, Quartz.kCGEventTapOptionDefault, - Quartz.CGEventMaskBit(Quartz.kCGEventKeyDown) | - Quartz.CGEventMaskBit(Quartz.kCGEventKeyUp) | - Quartz.CGEventMaskBit(Quartz.kCGEventFlagsChanged), + Quartz.CGEventMaskBit(Quartz.kCGEventKeyDown) + | Quartz.CGEventMaskBit(Quartz.kCGEventKeyUp) + | Quartz.CGEventMaskBit(Quartz.kCGEventFlagsChanged), self.handler, - None) + None, + ) loopsource = Quartz.CFMachPortCreateRunLoopSource(None, self.tap, 0) loop = Quartz.CFRunLoopGetCurrent() Quartz.CFRunLoopAddSource(loop, loopsource, Quartz.kCFRunLoopDefaultMode) @@ -374,7 +391,7 @@ def handler(self, proxy, e_type, event, refcon): key_name = name_from_scancode(scan_code) flags = Quartz.CGEventGetFlags(event) event_type = "" - is_keypad = (flags & Quartz.kCGEventFlagMaskNumericPad) + is_keypad = flags & Quartz.kCGEventFlagMaskNumericPad if e_type == Quartz.kCGEventKeyDown: event_type = "down" elif e_type == Quartz.kCGEventKeyUp: @@ -388,29 +405,34 @@ def handler(self, proxy, e_type, event, refcon): # scan codes for each modifier key for bitmask, key_name_suffixes in ( - (Quartz.kCGEventFlagMaskShift, ("shift", )), - (Quartz.kCGEventFlagMaskAlphaShift, ("caps lock", )), - (Quartz.kCGEventFlagMaskControl, ("ctrl",)), - (Quartz.kCGEventFlagMaskCommand, ("command", "windows")), - (Quartz.kCGEventFlagMaskAlternate, ("option", "alt")), + (Quartz.kCGEventFlagMaskShift, ("shift",)), + (Quartz.kCGEventFlagMaskAlphaShift, ("caps lock",)), + (Quartz.kCGEventFlagMaskControl, ("ctrl",)), + (Quartz.kCGEventFlagMaskCommand, ("command", "windows")), + (Quartz.kCGEventFlagMaskAlternate, ("option", "alt")), ): ends_with_suffix = any(key_name.endswith(suffix) for suffix in key_name_suffixes) if ends_with_suffix: event_found = True - key_name_suffix = key_name_suffixes[0] # it doesn't matter here if we clobber suffixes from the same modifier like option/alt + key_name_suffix = key_name_suffixes[ + 0 + ] # it doesn't matter here if we clobber suffixes from the same modifier like option/alt if not (flags & bitmask): event_type = "up" - self.modifier_scancodes[key_name_suffix] = [] # just to be sure... - for suffix in key_name_suffixes: self.pressed_modifiers.discard(suffix) + self.modifier_scancodes[key_name_suffix] = [] # just to be sure... + for suffix in key_name_suffixes: + self.pressed_modifiers.discard(suffix) else: if scan_code in self.modifier_scancodes[key_name_suffix]: event_type = "up" self.modifier_scancodes[key_name_suffix].remove(scan_code) - for suffix in key_name_suffixes: self.pressed_modifiers.discard(suffix) + for suffix in key_name_suffixes: + self.pressed_modifiers.discard(suffix) else: event_type = "down" self.modifier_scancodes[key_name_suffix].append(scan_code) - for suffix in key_name_suffixes: self.pressed_modifiers.add(suffix) + for suffix in key_name_suffixes: + self.pressed_modifiers.add(suffix) if event_found: break if not event_found: @@ -420,43 +442,63 @@ def handler(self, proxy, e_type, event, refcon): return None pressed_modifiers_tuple = tuple(sorted(self.pressed_modifiers)) - self.callback(KeyboardEvent(event_type, scan_code, name=key_name, is_keypad=is_keypad, modifiers=pressed_modifiers_tuple)) + self.callback( + KeyboardEvent( + event_type, + scan_code, + name=key_name, + is_keypad=is_keypad, + modifiers=pressed_modifiers_tuple, + ) + ) return event + key_controller = KeyController() """ Exported functions below """ + def init(): key_controller = KeyController() + def press(scan_code): - """ Sends a 'down' event for the specified scan code """ + """Sends a 'down' event for the specified scan code""" key_controller.press(scan_code) + def release(scan_code): - """ Sends an 'up' event for the specified scan code """ + """Sends an 'up' event for the specified scan code""" key_controller.release(scan_code) + def map_name(name): - """ Returns a tuple of (scan_code, modifiers) where ``scan_code`` is a numeric scan code - and ``modifiers`` is an array of string modifier names (like 'shift') """ + """Returns a tuple of (scan_code, modifiers) where ``scan_code`` is a numeric scan code + and ``modifiers`` is an array of string modifier names (like 'shift')""" yield key_controller.map_char(name) + def name_from_scancode(scan_code): - """ Returns the name or character associated with the specified key code """ + """Returns the name or character associated with the specified key code""" return key_controller.map_scan_code(scan_code) + def listen(callback): KeyEventListener(callback).run() + def type_unicode(character): OUTPUT_SOURCE = Quartz.CGEventSourceCreate(Quartz.kCGEventSourceStateHIDSystemState) # Key down event = Quartz.CGEventCreateKeyboardEvent(OUTPUT_SOURCE, 0, True) - Quartz.CGEventKeyboardSetUnicodeString(event, len(character.encode('utf-16-le')) // 2, character) + Quartz.CGEventKeyboardSetUnicodeString( + event, len(character.encode("utf-16-le")) // 2, character + ) Quartz.CGEventPost(Quartz.kCGSessionEventTap, event) # Key up event = Quartz.CGEventCreateKeyboardEvent(OUTPUT_SOURCE, 0, False) - Quartz.CGEventKeyboardSetUnicodeString(event, len(character.encode('utf-16-le')) // 2, character) + Quartz.CGEventKeyboardSetUnicodeString( + event, len(character.encode("utf-16-le")) // 2, character + ) Quartz.CGEventPost(Quartz.kCGSessionEventTap, event) diff --git a/src/directkeys/_darwinmouse.py b/src/directkeys/_darwinmouse.py index 3b8afe6c..e86e65ff 100644 --- a/src/directkeys/_darwinmouse.py +++ b/src/directkeys/_darwinmouse.py @@ -7,21 +7,28 @@ from ._mouse_event import LEFT, MIDDLE, RIGHT _button_mapping = { - LEFT: (Quartz.kCGMouseButtonLeft, Quartz.kCGEventLeftMouseDown, Quartz.kCGEventLeftMouseUp, Quartz.kCGEventLeftMouseDragged), - RIGHT: (Quartz.kCGMouseButtonRight, Quartz.kCGEventRightMouseDown, Quartz.kCGEventRightMouseUp, Quartz.kCGEventRightMouseDragged), - MIDDLE: (Quartz.kCGMouseButtonCenter, Quartz.kCGEventOtherMouseDown, Quartz.kCGEventOtherMouseUp, Quartz.kCGEventOtherMouseDragged) -} -_button_state = { - LEFT: False, - RIGHT: False, - MIDDLE: False -} -_last_click = { - "time": None, - "button": None, - "position": None, - "click_count": 0 + LEFT: ( + Quartz.kCGMouseButtonLeft, + Quartz.kCGEventLeftMouseDown, + Quartz.kCGEventLeftMouseUp, + Quartz.kCGEventLeftMouseDragged, + ), + RIGHT: ( + Quartz.kCGMouseButtonRight, + Quartz.kCGEventRightMouseDown, + Quartz.kCGEventRightMouseUp, + Quartz.kCGEventRightMouseDragged, + ), + MIDDLE: ( + Quartz.kCGMouseButtonCenter, + Quartz.kCGEventOtherMouseDown, + Quartz.kCGEventOtherMouseUp, + Quartz.kCGEventOtherMouseDragged, + ), } +_button_state = {LEFT: False, RIGHT: False, MIDDLE: False} +_last_click = {"time": None, "button": None, "position": None, "click_count": 0} + class MouseEventListener: def __init__(self, callback, blocking=False): @@ -30,22 +37,23 @@ def __init__(self, callback, blocking=False): self.listening = True def run(self): - """ Creates a listener and loops while waiting for an event. Intended to run as - a background thread. """ + """Creates a listener and loops while waiting for an event. Intended to run as + a background thread.""" self.tap = Quartz.CGEventTapCreate( Quartz.kCGSessionEventTap, Quartz.kCGHeadInsertEventTap, Quartz.kCGEventTapOptionDefault, - Quartz.CGEventMaskBit(Quartz.kCGEventLeftMouseDown) | - Quartz.CGEventMaskBit(Quartz.kCGEventLeftMouseUp) | - Quartz.CGEventMaskBit(Quartz.kCGEventRightMouseDown) | - Quartz.CGEventMaskBit(Quartz.kCGEventRightMouseUp) | - Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseDown) | - Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseUp) | - Quartz.CGEventMaskBit(Quartz.kCGEventMouseMoved) | - Quartz.CGEventMaskBit(Quartz.kCGEventScrollWheel), + Quartz.CGEventMaskBit(Quartz.kCGEventLeftMouseDown) + | Quartz.CGEventMaskBit(Quartz.kCGEventLeftMouseUp) + | Quartz.CGEventMaskBit(Quartz.kCGEventRightMouseDown) + | Quartz.CGEventMaskBit(Quartz.kCGEventRightMouseUp) + | Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseDown) + | Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseUp) + | Quartz.CGEventMaskBit(Quartz.kCGEventMouseMoved) + | Quartz.CGEventMaskBit(Quartz.kCGEventScrollWheel), self.handler, - None) + None, + ) loopsource = Quartz.CFMachPortCreateRunLoopSource(None, self.tap, 0) loop = Quartz.CFRunLoopGetCurrent() Quartz.CFRunLoopAddSource(loop, loopsource, Quartz.kCFRunLoopDefaultMode) @@ -60,7 +68,7 @@ def handler(self, proxy, e_type, event, refcon): key_name = name_from_scancode(scan_code) flags = Quartz.CGEventGetFlags(event) event_type = "" - is_keypad = (flags & Quartz.kCGEventFlagMaskNumericPad) + is_keypad = flags & Quartz.kCGEventFlagMaskNumericPad if e_type == Quartz.kCGEventKeyDown: event_type = "down" elif e_type == Quartz.kCGEventKeyUp: @@ -72,104 +80,107 @@ def handler(self, proxy, e_type, event, refcon): self.callback(KeyboardEvent(event_type, scan_code, name=key_name, is_keypad=is_keypad)) return event + # Exports + def init(): - """ Initializes mouse state """ + """Initializes mouse state""" pass + def listen(queue): - """ Appends events to the queue (ButtonEvent, WheelEvent, and MoveEvent). """ + """Appends events to the queue (ButtonEvent, WheelEvent, and MoveEvent).""" if not os.geteuid() == 0: raise OSError("Error 13 - Must be run as administrator") - listener = MouseEventListener(lambda e: queue.put(e) or is_allowed(e.name, e.event_type == KEY_UP)) + listener = MouseEventListener( + lambda e: queue.put(e) or is_allowed(e.name, e.event_type == KEY_UP) + ) t = threading.Thread(target=listener.run, args=()) t.daemon = True t.start() + def press(button=LEFT): - """ Sends a down event for the specified button, using the provided constants """ + """Sends a down event for the specified button, using the provided constants""" location = get_position() button_code, button_down, _, _ = _button_mapping[button] - e = Quartz.CGEventCreateMouseEvent( - None, - button_down, - location, - button_code) + e = Quartz.CGEventCreateMouseEvent(None, button_down, location, button_code) # Check if this is a double-click (same location within the last 300ms) - if _last_click["time"] is not None and datetime.datetime.now() - _last_click["time"] < datetime.timedelta(seconds=0.3) and _last_click["button"] == button and _last_click["position"] == location: + if ( + _last_click["time"] is not None + and datetime.datetime.now() - _last_click["time"] < datetime.timedelta(seconds=0.3) + and _last_click["button"] == button + and _last_click["position"] == location + ): # Repeated Click - _last_click["click_count"] = min(3, _last_click["click_count"]+1) + _last_click["click_count"] = min(3, _last_click["click_count"] + 1) else: # Not a double-click - Reset last click _last_click["click_count"] = 1 Quartz.CGEventSetIntegerValueField( - e, - Quartz.kCGMouseEventClickState, - _last_click["click_count"]) + e, Quartz.kCGMouseEventClickState, _last_click["click_count"] + ) Quartz.CGEventPost(Quartz.kCGHIDEventTap, e) _button_state[button] = True _last_click["time"] = datetime.datetime.now() _last_click["button"] = button _last_click["position"] = location + def release(button=LEFT): - """ Sends an up event for the specified button, using the provided constants """ + """Sends an up event for the specified button, using the provided constants""" location = get_position() button_code, _, button_up, _ = _button_mapping[button] - e = Quartz.CGEventCreateMouseEvent( - None, - button_up, - location, - button_code) - - if _last_click["time"] is not None and _last_click["time"] > datetime.datetime.now() - datetime.timedelta(microseconds=300000) and _last_click["button"] == button and _last_click["position"] == location: + e = Quartz.CGEventCreateMouseEvent(None, button_up, location, button_code) + + if ( + _last_click["time"] is not None + and _last_click["time"] > datetime.datetime.now() - datetime.timedelta(microseconds=300000) + and _last_click["button"] == button + and _last_click["position"] == location + ): # Repeated Click Quartz.CGEventSetIntegerValueField( - e, - Quartz.kCGMouseEventClickState, - _last_click["click_count"]) + e, Quartz.kCGMouseEventClickState, _last_click["click_count"] + ) Quartz.CGEventPost(Quartz.kCGHIDEventTap, e) _button_state[button] = False + def wheel(delta=1): - """ Sends a wheel event for the provided number of clicks. May be negative to reverse - direction. """ + """Sends a wheel event for the provided number of clicks. May be negative to reverse + direction.""" location = get_position() e = Quartz.CGEventCreateMouseEvent( - None, - Quartz.kCGEventScrollWheel, - location, - Quartz.kCGMouseButtonLeft) - e2 = Quartz.CGEventCreateScrollWheelEvent( - None, - Quartz.kCGScrollEventUnitLine, - 1, - delta) + None, Quartz.kCGEventScrollWheel, location, Quartz.kCGMouseButtonLeft + ) + e2 = Quartz.CGEventCreateScrollWheelEvent(None, Quartz.kCGScrollEventUnitLine, 1, delta) Quartz.CGEventPost(Quartz.kCGHIDEventTap, e) Quartz.CGEventPost(Quartz.kCGHIDEventTap, e2) + def move_to(x, y): - """ Sets the mouse's location to the specified coordinates. """ + """Sets the mouse's location to the specified coordinates.""" for b in _button_state: if _button_state[b]: e = Quartz.CGEventCreateMouseEvent( None, - _button_mapping[b][3], # Drag Event + _button_mapping[b][3], # Drag Event (x, y), - _button_mapping[b][0]) + _button_mapping[b][0], + ) break else: e = Quartz.CGEventCreateMouseEvent( - None, - Quartz.kCGEventMouseMoved, - (x, y), - Quartz.kCGMouseButtonLeft) + None, Quartz.kCGEventMouseMoved, (x, y), Quartz.kCGMouseButtonLeft + ) Quartz.CGEventPost(Quartz.kCGHIDEventTap, e) + def get_position(): - """ Returns the mouse's location as a tuple of (x, y). """ + """Returns the mouse's location as a tuple of (x, y).""" e = Quartz.CGEventCreate(None) point = Quartz.CGEventGetLocation(e) return (point.x, point.y) diff --git a/src/directkeys/_generic.py b/src/directkeys/_generic.py index 23d9f133..c2141d0d 100644 --- a/src/directkeys/_generic.py +++ b/src/directkeys/_generic.py @@ -6,6 +6,7 @@ except ImportError: from Queue import Queue + class GenericListener: lock = Lock() @@ -44,7 +45,7 @@ def start_if_necessary(self): self.lock.release() def pre_process_event(self, event): - raise NotImplementedError('This method should be implemented in the child class.') + raise NotImplementedError("This method should be implemented in the child class.") def process(self): """ @@ -66,6 +67,6 @@ def add_handler(self, handler): self.handlers.append(handler) def remove_handler(self, handler): - """ Removes a previously added event handler. """ + """Removes a previously added event handler.""" while handler in self.handlers: self.handlers.remove(handler) diff --git a/src/directkeys/_keyboard_event.py b/src/directkeys/_keyboard_event.py index e4f58f65..00f2d000 100644 --- a/src/directkeys/_keyboard_event.py +++ b/src/directkeys/_keyboard_event.py @@ -3,8 +3,9 @@ from ._canonical_names import normalize_name -KEY_DOWN = 'down' -KEY_UP = 'up' +KEY_DOWN = "down" +KEY_UP = "up" + class KeyboardEvent: event_type = None @@ -16,7 +17,17 @@ class KeyboardEvent: is_keypad = None flags = None - def __init__(self, event_type, scan_code, name=None, time=None, device=None, modifiers=None, is_keypad=None, flags=None): + def __init__( + self, + event_type, + scan_code, + name=None, + time=None, + device=None, + modifiers=None, + is_keypad=None, + flags=None, + ): self.event_type = event_type self.scan_code = scan_code self.time = now() if time is None else time @@ -29,21 +40,30 @@ def __init__(self, event_type, scan_code, name=None, time=None, device=None, mod def to_json(self, ensure_ascii=False): attrs = { - attr: getattr(self, attr) for attr in ['event_type', 'scan_code', 'name', 'time', 'device', 'is_keypad', 'modifiers', 'flags'] - if not attr.startswith('_') + attr: getattr(self, attr) + for attr in [ + "event_type", + "scan_code", + "name", + "time", + "device", + "is_keypad", + "modifiers", + "flags", + ] + if not attr.startswith("_") } return json.dumps(attrs, ensure_ascii=ensure_ascii) def __repr__(self): - return 'KeyboardEvent({} {})'.format(self.name or f'Unknown {self.scan_code}', self.event_type) + return "KeyboardEvent({} {})".format( + self.name or f"Unknown {self.scan_code}", self.event_type + ) def __eq__(self, other): return ( isinstance(other, KeyboardEvent) and self.event_type == other.event_type - and ( - not self.scan_code or not other.scan_code or self.scan_code == other.scan_code - ) and ( - not self.name or not other.name or self.name == other.name - ) + and (not self.scan_code or not other.scan_code or self.scan_code == other.scan_code) + and (not self.name or not other.name or self.name == other.name) ) diff --git a/src/directkeys/_mouse_event.py b/src/directkeys/_mouse_event.py index 8967d9da..da9ed5e5 100644 --- a/src/directkeys/_mouse_event.py +++ b/src/directkeys/_mouse_event.py @@ -1,19 +1,19 @@ from collections import namedtuple -LEFT = 'left' -RIGHT = 'right' -MIDDLE = 'middle' -WHEEL = 'wheel' -X = 'x' -X2 = 'x2' +LEFT = "left" +RIGHT = "right" +MIDDLE = "middle" +WHEEL = "wheel" +X = "x" +X2 = "x2" -UP = 'up' -DOWN = 'down' -DOUBLE = 'double' -VERTICAL = 'vertical' -HORIZONTAL = 'horizontal' +UP = "up" +DOWN = "down" +DOUBLE = "double" +VERTICAL = "vertical" +HORIZONTAL = "horizontal" -ButtonEvent = namedtuple('ButtonEvent', ['event_type', 'button', 'time']) -WheelEvent = namedtuple('WheelEvent', ['delta', 'time']) -MoveEvent = namedtuple('MoveEvent', ['x', 'y', 'time']) +ButtonEvent = namedtuple("ButtonEvent", ["event_type", "button", "time"]) +WheelEvent = namedtuple("WheelEvent", ["delta", "time"]) +MoveEvent = namedtuple("MoveEvent", ["x", "y", "time"]) diff --git a/src/directkeys/_nixcommon.py b/src/directkeys/_nixcommon.py index c6aa4e2c..ff9d18b6 100644 --- a/src/directkeys/_nixcommon.py +++ b/src/directkeys/_nixcommon.py @@ -10,7 +10,7 @@ except ImportError: from Queue import Queue -event_bin_format = 'llHHI' +event_bin_format = "llHHI" # Taken from include/linux/input.h # https://www.kernel.org/doc/Documentation/input/event-codes.txt @@ -20,15 +20,16 @@ EV_ABS = 0x03 EV_MSC = 0x04 + def make_uinput(): - if not os.path.exists('/dev/uinput'): - raise OSError('No uinput module found.') + if not os.path.exists("/dev/uinput"): + raise OSError("No uinput module found.") import fcntl import struct # Requires uinput driver, but it's usually available. - uinput = open("/dev/uinput", 'wb') + uinput = open("/dev/uinput", "wb") UI_SET_EVBIT = 0x40045564 fcntl.ioctl(uinput, UI_SET_EVBIT, EV_KEY) @@ -40,15 +41,16 @@ def make_uinput(): uinput_user_dev = "80sHHHHi64i64i64i64i" axis = [0] * 64 * 4 uinput.write(struct.pack(uinput_user_dev, b"Virtual Keyboard", BUS_USB, 1, 1, 1, 0, *axis)) - uinput.flush() # Without this you may get Errno 22: Invalid argument. + uinput.flush() # Without this you may get Errno 22: Invalid argument. UI_DEV_CREATE = 0x5501 fcntl.ioctl(uinput, UI_DEV_CREATE) UI_DEV_DESTROY = 0x5502 - #fcntl.ioctl(uinput, UI_DEV_DESTROY) + # fcntl.ioctl(uinput, UI_DEV_DESTROY) return uinput + class EventDevice: def __init__(self, path): self.path = path @@ -59,10 +61,12 @@ def __init__(self, path): def input_file(self): if self._input_file is None: try: - self._input_file = open(self.path, 'rb') + self._input_file = open(self.path, "rb") except OSError as e: - if e.strerror == 'Permission denied': - print(f"# ERROR: Failed to read device '{self.path}'. You must be in the 'input' group to access global events. Use 'sudo usermod -a -G input USERNAME' to add user to the required group.") + if e.strerror == "Permission denied": + print( + f"# ERROR: Failed to read device '{self.path}'. You must be in the 'input' group to access global events. Use 'sudo usermod -a -G input USERNAME' to add user to the required group." + ) exit() def try_close(): @@ -70,13 +74,14 @@ def try_close(): self._input_file.close except: pass + atexit.register(try_close) return self._input_file @property def output_file(self): if self._output_file is None: - self._output_file = open(self.path, 'wb') + self._output_file = open(self.path, "wb") atexit.register(self._output_file.close) return self._output_file @@ -97,14 +102,17 @@ def write_event(self, type, code, value): self.output_file.write(data_event + sync_event) self.output_file.flush() + class AggregatedEventDevice: def __init__(self, devices, output=None): self.event_queue = Queue() self.devices = devices self.output = output or self.devices[0] + def start_reading(device): while True: self.event_queue.put(device.read_event()) + for device in self.devices: thread = Thread(target=start_reading, args=[device]) thread.daemon = True @@ -116,28 +124,35 @@ def read_event(self): def write_event(self, type, code, value): self.output.write_event(type, code, value) + import re from collections import namedtuple -DeviceDescription = namedtuple('DeviceDescription', 'event_file is_mouse is_keyboard') +DeviceDescription = namedtuple("DeviceDescription", "event_file is_mouse is_keyboard") device_pattern = r"""N: Name="([^"]+?)".+?H: Handlers=([^\n]+)""" + + def list_devices_from_proc(type_name): try: - with open('/proc/bus/input/devices') as f: + with open("/proc/bus/input/devices") as f: description = f.read() except FileNotFoundError: return devices = {} for name, handlers in re.findall(device_pattern, description, re.DOTALL): - path = '/dev/input/event' + re.search(r'event(\d+)', handlers).group(1) + path = "/dev/input/event" + re.search(r"event(\d+)", handlers).group(1) if type_name in handlers: yield EventDevice(path) + def list_devices_from_by_id(name_suffix, by_id=True): - for path in glob('/dev/input/{}/*-event-{}'.format('by-id' if by_id else 'by-path', name_suffix)): + for path in glob( + "/dev/input/{}/*-event-{}".format("by-id" if by_id else "by-path", name_suffix) + ): yield EventDevice(path) + def aggregate_devices(type_name): # Some systems have multiple keyboards with different range of allowed keys # on each one, like a notebook with a "keyboard" device exclusive for the @@ -145,12 +160,16 @@ def aggregate_devices(type_name): # send events, we create a fake device and send all events through there. try: uinput = make_uinput() - fake_device = EventDevice('uinput Fake Device') + fake_device = EventDevice("uinput Fake Device") fake_device._input_file = uinput fake_device._output_file = uinput except OSError: import warnings - warnings.warn('Failed to create a device file using `uinput` module. Sending of events may be limited or unavailable depending on plugged-in devices.', stacklevel=2) + + warnings.warn( + "Failed to create a device file using `uinput` module. Sending of events may be limited or unavailable depending on plugged-in devices.", + stacklevel=2, + ) fake_device = None # We don't aggregate devices from different sources to avoid @@ -162,7 +181,9 @@ def aggregate_devices(type_name): # breaks on mouse for virtualbox # was getting /dev/input/by-id/usb-VirtualBox_USB_Tablet-event-mouse - devices_from_by_id = list(list_devices_from_by_id(type_name)) or list(list_devices_from_by_id(type_name, by_id=False)) + devices_from_by_id = list(list_devices_from_by_id(type_name)) or list( + list_devices_from_by_id(type_name, by_id=False) + ) if devices_from_by_id: return AggregatedEventDevice(devices_from_by_id, output=fake_device) diff --git a/src/directkeys/_nixkeyboard.py b/src/directkeys/_nixkeyboard.py index 7c8eb517..6bffce8a 100644 --- a/src/directkeys/_nixkeyboard.py +++ b/src/directkeys/_nixkeyboard.py @@ -6,35 +6,37 @@ # TODO: start by reading current keyboard state, as to not missing any already pressed keys. # See: http://stackoverflow.com/questions/3649874/how-to-get-keyboard-state-in-linux + def cleanup_key(name): - """ Formats a dumpkeys format to our standard. """ - name = name.lstrip('+') - is_keypad = name.startswith('KP_') - for mod in ('Meta_', 'Control_', 'dead_', 'KP_'): + """Formats a dumpkeys format to our standard.""" + name = name.lstrip("+") + is_keypad = name.startswith("KP_") + for mod in ("Meta_", "Control_", "dead_", "KP_"): if name.startswith(mod): - name = name[len(mod):] + name = name[len(mod) :] # Dumpkeys is weird like that. - if name == 'Remove': - name = 'Delete' - elif name == 'Delete': - name = 'Backspace' - - if name.endswith('_r'): - name = 'right ' + name[:-2] - if name.endswith('_l'): - name = 'left ' + name[:-2] + if name == "Remove": + name = "Delete" + elif name == "Delete": + name = "Backspace" + if name.endswith("_r"): + name = "right " + name[:-2] + if name.endswith("_l"): + name = "left " + name[:-2] return normalize_name(name), is_keypad + def cleanup_modifier(modifier): modifier = normalize_name(modifier) if modifier in all_modifiers: return modifier if modifier[:-1] in all_modifiers: return modifier[:-1] - raise ValueError(f'Unknown modifier {modifier}') + raise ValueError(f"Unknown modifier {modifier}") + """ Use `dumpkeys --keys-only` to list all scan codes and their names. We @@ -49,61 +51,67 @@ def cleanup_modifier(modifier): from_name = defaultdict(list) keypad_scan_codes = set() + def register_key(key_and_modifiers, name): if name not in to_name[key_and_modifiers]: to_name[key_and_modifiers].append(name) if key_and_modifiers not in from_name[name]: from_name[name].append(key_and_modifiers) + def build_tables(): - if to_name and from_name: return + if to_name and from_name: + return modifiers_bits = { - 'shift': 1, - 'alt gr': 2, - 'ctrl': 4, - 'alt': 8, + "shift": 1, + "alt gr": 2, + "ctrl": 4, + "alt": 8, } - keycode_template = r'^keycode\s+(\d+)\s+=(.*?)$' + keycode_template = r"^keycode\s+(\d+)\s+=(.*?)$" try: - dump = check_output(['dumpkeys', '--keys-only'], universal_newlines=True) + dump = check_output(["dumpkeys", "--keys-only"], universal_newlines=True) except CalledProcessError as e: if e.returncode == 1: - raise ValueError('Failed to run dumpkeys to get key names. Check if your user is part of the "tty" group, and if not, add it with "sudo usermod -a -G tty USER".') + raise ValueError( + 'Failed to run dumpkeys to get key names. Check if your user is part of the "tty" group, and if not, add it with "sudo usermod -a -G tty USER".' + ) else: raise - for str_scan_code, str_names in re.findall(keycode_template, dump, re.MULTILINE): scan_code = int(str_scan_code) for i, str_name in enumerate(str_names.strip().split()): - modifiers = tuple(sorted(modifier for modifier, bit in modifiers_bits.items() if i & bit)) + modifiers = tuple( + sorted(modifier for modifier, bit in modifiers_bits.items() if i & bit) + ) name, is_keypad = cleanup_key(str_name) register_key((scan_code, modifiers), name) if is_keypad: keypad_scan_codes.add(scan_code) - register_key((scan_code, modifiers), 'keypad ' + name) + register_key((scan_code, modifiers), "keypad " + name) # dumpkeys consistently misreports the Windows key, sometimes # skipping it completely or reporting as 'alt. 125 = left win, # 126 = right win. - if (125, ()) not in to_name or to_name[(125, ())] == ['alt']: + if (125, ()) not in to_name or to_name[(125, ())] == ["alt"]: to_name[(125, ())].clear() - if (125, ()) in from_name['alt']: - from_name['alt'].remove((125, ())) - register_key((125, ()), 'windows') - if (126, ()) not in to_name or to_name[(126, ())] == ['alt']: + if (125, ()) in from_name["alt"]: + from_name["alt"].remove((125, ())) + register_key((125, ()), "windows") + if (126, ()) not in to_name or to_name[(126, ())] == ["alt"]: to_name[(126, ())].clear() - if (126, ()) in from_name['alt']: - from_name['alt'].remove((126, ())) - register_key((126, ()), 'windows') + if (126, ()) in from_name["alt"]: + from_name["alt"].remove((126, ())) + register_key((126, ()), "windows") # The menu key is usually skipped altogether, so we also add it manually. if (127, ()) not in to_name: - register_key((127, ()), 'menu') + register_key((127, ()), "menu") - synonyms_template = r'^(\S+)\s+for (.+)$' - dump = check_output(['dumpkeys', '--long-info'], universal_newlines=True) + synonyms_template = r"^(\S+)\s+for (.+)$" + dump = check_output(["dumpkeys", "--long-info"], universal_newlines=True) for synonym_str, original_str in re.findall(synonyms_template, dump, re.MULTILINE): synonym, _ = cleanup_key(synonym_str) original, _ = cleanup_key(original_str) @@ -111,18 +119,25 @@ def build_tables(): from_name[original].extend(from_name[synonym]) from_name[synonym].extend(from_name[original]) + device = None + + def build_device(): global device - if device: return - device = aggregate_devices('kbd') + if device: + return + device = aggregate_devices("kbd") + def init(): build_device() build_tables() + pressed_modifiers = set() + def listen(callback): build_device() build_tables() @@ -133,10 +148,12 @@ def listen(callback): continue scan_code = code - event_type = KEY_DOWN if value else KEY_UP # 0 = UP, 1 = DOWN, 2 = HOLD + event_type = KEY_DOWN if value else KEY_UP # 0 = UP, 1 = DOWN, 2 = HOLD pressed_modifiers_tuple = tuple(sorted(pressed_modifiers)) - names = to_name[(scan_code, pressed_modifiers_tuple)] or to_name[(scan_code, ())] or ['unknown'] + names = ( + to_name[(scan_code, pressed_modifiers_tuple)] or to_name[(scan_code, ())] or ["unknown"] + ) name = names[0] if name in all_modifiers: @@ -146,33 +163,48 @@ def listen(callback): pressed_modifiers.discard(name) is_keypad = scan_code in keypad_scan_codes - callback(KeyboardEvent(event_type=event_type, scan_code=scan_code, name=name, time=time, device=device_id, is_keypad=is_keypad, modifiers=pressed_modifiers_tuple)) + callback( + KeyboardEvent( + event_type=event_type, + scan_code=scan_code, + name=name, + time=time, + device=device_id, + is_keypad=is_keypad, + modifiers=pressed_modifiers_tuple, + ) + ) + def write_event(scan_code, is_down): build_device() device.write_event(EV_KEY, scan_code, int(is_down)) + def map_name(name): build_tables() for entry in from_name[name]: yield entry - parts = name.split(' ', 1) - if len(parts) > 1 and parts[0] in ('left', 'right'): + parts = name.split(" ", 1) + if len(parts) > 1 and parts[0] in ("left", "right"): for entry in from_name[parts[1]]: yield entry + def press(scan_code): write_event(scan_code, True) + def release(scan_code): write_event(scan_code, False) + def type_unicode(character): codepoint = ord(character) - hexadecimal = hex(codepoint)[len('0x'):] + hexadecimal = hex(codepoint)[len("0x") :] - for key in ['ctrl', 'shift', 'u']: + for key in ["ctrl", "shift", "u"]: scan_code, _ = next(map_name(key)) press(scan_code) @@ -181,11 +213,14 @@ def type_unicode(character): press(scan_code) release(scan_code) - for key in ['ctrl', 'shift', 'u']: + for key in ["ctrl", "shift", "u"]: scan_code, _ = next(map_name(key)) release(scan_code) -if __name__ == '__main__': + +if __name__ == "__main__": + def p(e): print(e) + listen(p) diff --git a/src/directkeys/_nixmouse.py b/src/directkeys/_nixmouse.py index dc5453e3..1898bad5 100644 --- a/src/directkeys/_nixmouse.py +++ b/src/directkeys/_nixmouse.py @@ -9,10 +9,13 @@ display = None window = None x11 = None + + def build_display(): global display, window, x11 - if display and window and x11: return - x11 = ctypes.cdll.LoadLibrary(ctypes.util.find_library('X11')) + if display and window and x11: + return + x11 = ctypes.cdll.LoadLibrary(ctypes.util.find_library("X11")) # Required because we will have multiple threads calling x11, # such as the listener thread and then main using "move_to". x11.XInitThreads() @@ -21,21 +24,32 @@ def build_display(): # http://stackoverflow.com/questions/35137007/get-mouse-position-on-linux-pure-python window = x11.XDefaultRootWindow(display) + def get_position(): build_display() root_id, child_id = c_uint32(), c_uint32() root_x, root_y, win_x, win_y = c_int(), c_int(), c_int(), c_int() mask = c_uint() - ret = x11.XQueryPointer(display, c_uint32(window), byref(root_id), byref(child_id), - byref(root_x), byref(root_y), - byref(win_x), byref(win_y), byref(mask)) + ret = x11.XQueryPointer( + display, + c_uint32(window), + byref(root_id), + byref(child_id), + byref(root_x), + byref(root_y), + byref(win_x), + byref(win_y), + byref(mask), + ) return root_x.value, root_y.value + def move_to(x, y): build_display() x11.XWarpPointer(display, None, window, 0, 0, 0, 0, x, y) x11.XFlush(display) + REL_X = 0x00 REL_Y = 0x01 REL_Z = 0x02 @@ -62,12 +76,18 @@ def move_to(x, y): code_by_button = {button: code for code, button in button_by_code.items()} device = None + + def build_device(): global device - if device: return - device = aggregate_devices('mouse') + if device: + return + device = aggregate_devices("mouse") + + init = build_device + def listen(queue): build_device() @@ -80,9 +100,9 @@ def listen(queue): arg = None if type == EV_KEY: - event = ButtonEvent(DOWN if value else UP, button_by_code.get(code, '?'), time) + event = ButtonEvent(DOWN if value else UP, button_by_code.get(code, "?"), time) elif type == EV_REL: - value, = struct.unpack('i', struct.pack('I', value)) + (value,) = struct.unpack("i", struct.pack("I", value)) if code == REL_WHEEL: event = WheelEvent(value, time) @@ -96,14 +116,17 @@ def listen(queue): queue.put(event) + def press(button=LEFT): build_device() device.write_event(EV_KEY, code_by_button[button], 0x01) + def release(button=LEFT): build_device() device.write_event(EV_KEY, code_by_button[button], 0x00) + def move_relative(x, y): build_device() # Note relative events are not in terms of pixels, but millimeters. @@ -114,6 +137,7 @@ def move_relative(x, y): device.write_event(EV_REL, REL_X, x) device.write_event(EV_REL, REL_Y, y) + def wheel(delta=1): build_device() if delta < 0: @@ -121,6 +145,6 @@ def wheel(delta=1): device.write_event(EV_REL, REL_WHEEL, delta) -if __name__ == '__main__': - #listen(print) +if __name__ == "__main__": + # listen(print) move_to(100, 200) diff --git a/src/directkeys/_winkeyboard.py b/src/directkeys/_winkeyboard.py index eb97e682..ab6b6c31 100644 --- a/src/directkeys/_winkeyboard.py +++ b/src/directkeys/_winkeyboard.py @@ -9,6 +9,7 @@ - Keypad numbers still print as numbers even when numlock is off. - No way to specify if user wants a keypad key or not in `map_char`. """ + import atexit import time import traceback @@ -55,14 +56,14 @@ LPMSG = POINTER(MSG) ULONG_PTR = POINTER(DWORD) -kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) +kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) GetModuleHandleW = kernel32.GetModuleHandleW GetModuleHandleW.restype = HMODULE GetModuleHandleW.argtypes = [LPCWSTR] -#https://github.com/boppreh/mouse/issues/1 -#user32 = ctypes.windll.user32 -user32 = ctypes.WinDLL('user32', use_last_error = True) +# https://github.com/boppreh/mouse/issues/1 +# user32 = ctypes.windll.user32 +user32 = ctypes.WinDLL("user32", use_last_error=True) VK_PACKET = 0xE7 @@ -73,51 +74,59 @@ KEYEVENTF_KEYUP = 0x02 KEYEVENTF_UNICODE = 0x04 + class KBDLLHOOKSTRUCT(Structure): - _fields_ = [("vk_code", DWORD), - ("scan_code", DWORD), - ("flags", DWORD), - ("time", c_int), - ("dwExtraInfo", ULONG_PTR)] + _fields_ = [ + ("vk_code", DWORD), + ("scan_code", DWORD), + ("flags", DWORD), + ("time", c_int), + ("dwExtraInfo", ULONG_PTR), + ] + # Included for completeness. class MOUSEINPUT(ctypes.Structure): - _fields_ = (('dx', LONG), - ('dy', LONG), - ('mouseData', DWORD), - ('dwFlags', DWORD), - ('time', DWORD), - ('dwExtraInfo', ULONG_PTR)) + _fields_ = ( + ("dx", LONG), + ("dy", LONG), + ("mouseData", DWORD), + ("dwFlags", DWORD), + ("time", DWORD), + ("dwExtraInfo", ULONG_PTR), + ) + class KEYBDINPUT(ctypes.Structure): - _fields_ = (('wVk', WORD), - ('wScan', WORD), - ('dwFlags', DWORD), - ('time', DWORD), - ('dwExtraInfo', ULONG_PTR)) + _fields_ = ( + ("wVk", WORD), + ("wScan", WORD), + ("dwFlags", DWORD), + ("time", DWORD), + ("dwExtraInfo", ULONG_PTR), + ) + class HARDWAREINPUT(ctypes.Structure): - _fields_ = (('uMsg', DWORD), - ('wParamL', WORD), - ('wParamH', WORD)) + _fields_ = (("uMsg", DWORD), ("wParamL", WORD), ("wParamH", WORD)) + class _INPUTunion(ctypes.Union): - _fields_ = (('mi', MOUSEINPUT), - ('ki', KEYBDINPUT), - ('hi', HARDWAREINPUT)) + _fields_ = (("mi", MOUSEINPUT), ("ki", KEYBDINPUT), ("hi", HARDWAREINPUT)) + class INPUT(ctypes.Structure): - _fields_ = (('type', DWORD), - ('union', _INPUTunion)) + _fields_ = (("type", DWORD), ("union", _INPUTunion)) + LowLevelKeyboardProc = WINFUNCTYPE(c_int, WPARAM, LPARAM, POINTER(KBDLLHOOKSTRUCT)) SetWindowsHookEx = user32.SetWindowsHookExW -SetWindowsHookEx.argtypes = [c_int, LowLevelKeyboardProc, HINSTANCE , DWORD] +SetWindowsHookEx.argtypes = [c_int, LowLevelKeyboardProc, HINSTANCE, DWORD] SetWindowsHookEx.restype = HHOOK CallNextHookEx = user32.CallNextHookEx -#CallNextHookEx.argtypes = [c_int , c_int, c_int, POINTER(KBDLLHOOKSTRUCT)] +# CallNextHookEx.argtypes = [c_int , c_int, c_int, POINTER(KBDLLHOOKSTRUCT)] CallNextHookEx.restype = c_int UnhookWindowsHookEx = user32.UnhookWindowsHookEx @@ -177,7 +186,7 @@ class INPUT(ctypes.Structure): WM_KEYDOWN = 0x0100 WM_KEYUP = 0x0101 -WM_SYSKEYDOWN = 0x104 # Used for ALT key +WM_SYSKEYDOWN = 0x104 # Used for ALT key WM_SYSKEYUP = 0x105 @@ -193,165 +202,165 @@ class INPUT(ctypes.Structure): # List taken from the official documentation, but stripped of the OEM-specific keys. # Keys are virtual key codes, values are pairs (name, is_keypad). official_virtual_keys = { - 0x03: ('control-break processing', False), - 0x08: ('backspace', False), - 0x09: ('tab', False), - 0x0c: ('clear', False), - 0x0d: ('enter', False), - 0x10: ('shift', False), - 0x11: ('ctrl', False), - 0x12: ('alt', False), - 0x13: ('pause', False), - 0x14: ('caps lock', False), - 0x15: ('ime kana mode', False), - 0x15: ('ime hanguel mode', False), - 0x15: ('ime hangul mode', False), - 0x17: ('ime junja mode', False), - 0x18: ('ime final mode', False), - 0x19: ('ime hanja mode', False), - 0x19: ('ime kanji mode', False), - 0x1b: ('esc', False), - 0x1c: ('ime convert', False), - 0x1d: ('ime nonconvert', False), - 0x1e: ('ime accept', False), - 0x1f: ('ime mode change request', False), - 0x20: ('spacebar', False), - 0x21: ('page up', False), - 0x22: ('page down', False), - 0x23: ('end', False), - 0x24: ('home', False), - 0x25: ('left', False), - 0x26: ('up', False), - 0x27: ('right', False), - 0x28: ('down', False), - 0x29: ('select', False), - 0x2a: ('print', False), - 0x2b: ('execute', False), - 0x2c: ('print screen', False), - 0x2d: ('insert', False), - 0x2e: ('delete', False), - 0x2f: ('help', False), - 0x30: ('0', False), - 0x31: ('1', False), - 0x32: ('2', False), - 0x33: ('3', False), - 0x34: ('4', False), - 0x35: ('5', False), - 0x36: ('6', False), - 0x37: ('7', False), - 0x38: ('8', False), - 0x39: ('9', False), - 0x41: ('a', False), - 0x42: ('b', False), - 0x43: ('c', False), - 0x44: ('d', False), - 0x45: ('e', False), - 0x46: ('f', False), - 0x47: ('g', False), - 0x48: ('h', False), - 0x49: ('i', False), - 0x4a: ('j', False), - 0x4b: ('k', False), - 0x4c: ('l', False), - 0x4d: ('m', False), - 0x4e: ('n', False), - 0x4f: ('o', False), - 0x50: ('p', False), - 0x51: ('q', False), - 0x52: ('r', False), - 0x53: ('s', False), - 0x54: ('t', False), - 0x55: ('u', False), - 0x56: ('v', False), - 0x57: ('w', False), - 0x58: ('x', False), - 0x59: ('y', False), - 0x5a: ('z', False), - 0x5b: ('left windows', False), - 0x5c: ('right windows', False), - 0x5d: ('applications', False), - 0x5f: ('sleep', False), - 0x60: ('0', True), - 0x61: ('1', True), - 0x62: ('2', True), - 0x63: ('3', True), - 0x64: ('4', True), - 0x65: ('5', True), - 0x66: ('6', True), - 0x67: ('7', True), - 0x68: ('8', True), - 0x69: ('9', True), - 0x6a: ('*', True), - 0x6b: ('+', True), - 0x6c: ('separator', True), - 0x6d: ('-', True), - 0x6e: ('decimal', True), - 0x6f: ('/', True), - 0x70: ('f1', False), - 0x71: ('f2', False), - 0x72: ('f3', False), - 0x73: ('f4', False), - 0x74: ('f5', False), - 0x75: ('f6', False), - 0x76: ('f7', False), - 0x77: ('f8', False), - 0x78: ('f9', False), - 0x79: ('f10', False), - 0x7a: ('f11', False), - 0x7b: ('f12', False), - 0x7c: ('f13', False), - 0x7d: ('f14', False), - 0x7e: ('f15', False), - 0x7f: ('f16', False), - 0x80: ('f17', False), - 0x81: ('f18', False), - 0x82: ('f19', False), - 0x83: ('f20', False), - 0x84: ('f21', False), - 0x85: ('f22', False), - 0x86: ('f23', False), - 0x87: ('f24', False), - 0x90: ('num lock', False), - 0x91: ('scroll lock', False), - 0xa0: ('left shift', False), - 0xa1: ('right shift', False), - 0xa2: ('left ctrl', False), - 0xa3: ('right ctrl', False), - 0xa4: ('left menu', False), - 0xa5: ('right menu', False), - 0xa6: ('browser back', False), - 0xa7: ('browser forward', False), - 0xa8: ('browser refresh', False), - 0xa9: ('browser stop', False), - 0xaa: ('browser search key', False), - 0xab: ('browser favorites', False), - 0xac: ('browser start and home', False), - 0xad: ('volume mute', False), - 0xae: ('volume down', False), - 0xaf: ('volume up', False), - 0xb0: ('next track', False), - 0xb1: ('previous track', False), - 0xb2: ('stop media', False), - 0xb3: ('play/pause media', False), - 0xb4: ('start mail', False), - 0xb5: ('select media', False), - 0xb6: ('start application 1', False), - 0xb7: ('start application 2', False), - 0xbb: ('+', False), - 0xbc: (',', False), - 0xbd: ('-', False), - 0xbe: ('.', False), - #0xbe:('/', False), # Used for miscellaneous characters; it can vary by directkeys. For the US standard keyboard, the '/?. - 0xe5: ('ime process', False), - 0xf6: ('attn', False), - 0xf7: ('crsel', False), - 0xf8: ('exsel', False), - 0xf9: ('erase eof', False), - 0xfa: ('play', False), - 0xfb: ('zoom', False), - 0xfc: ('reserved ', False), - 0xfd: ('pa1', False), - 0xfe: ('clear', False), + 0x03: ("control-break processing", False), + 0x08: ("backspace", False), + 0x09: ("tab", False), + 0x0C: ("clear", False), + 0x0D: ("enter", False), + 0x10: ("shift", False), + 0x11: ("ctrl", False), + 0x12: ("alt", False), + 0x13: ("pause", False), + 0x14: ("caps lock", False), + 0x15: ("ime kana mode", False), + 0x15: ("ime hanguel mode", False), + 0x15: ("ime hangul mode", False), + 0x17: ("ime junja mode", False), + 0x18: ("ime final mode", False), + 0x19: ("ime hanja mode", False), + 0x19: ("ime kanji mode", False), + 0x1B: ("esc", False), + 0x1C: ("ime convert", False), + 0x1D: ("ime nonconvert", False), + 0x1E: ("ime accept", False), + 0x1F: ("ime mode change request", False), + 0x20: ("spacebar", False), + 0x21: ("page up", False), + 0x22: ("page down", False), + 0x23: ("end", False), + 0x24: ("home", False), + 0x25: ("left", False), + 0x26: ("up", False), + 0x27: ("right", False), + 0x28: ("down", False), + 0x29: ("select", False), + 0x2A: ("print", False), + 0x2B: ("execute", False), + 0x2C: ("print screen", False), + 0x2D: ("insert", False), + 0x2E: ("delete", False), + 0x2F: ("help", False), + 0x30: ("0", False), + 0x31: ("1", False), + 0x32: ("2", False), + 0x33: ("3", False), + 0x34: ("4", False), + 0x35: ("5", False), + 0x36: ("6", False), + 0x37: ("7", False), + 0x38: ("8", False), + 0x39: ("9", False), + 0x41: ("a", False), + 0x42: ("b", False), + 0x43: ("c", False), + 0x44: ("d", False), + 0x45: ("e", False), + 0x46: ("f", False), + 0x47: ("g", False), + 0x48: ("h", False), + 0x49: ("i", False), + 0x4A: ("j", False), + 0x4B: ("k", False), + 0x4C: ("l", False), + 0x4D: ("m", False), + 0x4E: ("n", False), + 0x4F: ("o", False), + 0x50: ("p", False), + 0x51: ("q", False), + 0x52: ("r", False), + 0x53: ("s", False), + 0x54: ("t", False), + 0x55: ("u", False), + 0x56: ("v", False), + 0x57: ("w", False), + 0x58: ("x", False), + 0x59: ("y", False), + 0x5A: ("z", False), + 0x5B: ("left windows", False), + 0x5C: ("right windows", False), + 0x5D: ("applications", False), + 0x5F: ("sleep", False), + 0x60: ("0", True), + 0x61: ("1", True), + 0x62: ("2", True), + 0x63: ("3", True), + 0x64: ("4", True), + 0x65: ("5", True), + 0x66: ("6", True), + 0x67: ("7", True), + 0x68: ("8", True), + 0x69: ("9", True), + 0x6A: ("*", True), + 0x6B: ("+", True), + 0x6C: ("separator", True), + 0x6D: ("-", True), + 0x6E: ("decimal", True), + 0x6F: ("/", True), + 0x70: ("f1", False), + 0x71: ("f2", False), + 0x72: ("f3", False), + 0x73: ("f4", False), + 0x74: ("f5", False), + 0x75: ("f6", False), + 0x76: ("f7", False), + 0x77: ("f8", False), + 0x78: ("f9", False), + 0x79: ("f10", False), + 0x7A: ("f11", False), + 0x7B: ("f12", False), + 0x7C: ("f13", False), + 0x7D: ("f14", False), + 0x7E: ("f15", False), + 0x7F: ("f16", False), + 0x80: ("f17", False), + 0x81: ("f18", False), + 0x82: ("f19", False), + 0x83: ("f20", False), + 0x84: ("f21", False), + 0x85: ("f22", False), + 0x86: ("f23", False), + 0x87: ("f24", False), + 0x90: ("num lock", False), + 0x91: ("scroll lock", False), + 0xA0: ("left shift", False), + 0xA1: ("right shift", False), + 0xA2: ("left ctrl", False), + 0xA3: ("right ctrl", False), + 0xA4: ("left menu", False), + 0xA5: ("right menu", False), + 0xA6: ("browser back", False), + 0xA7: ("browser forward", False), + 0xA8: ("browser refresh", False), + 0xA9: ("browser stop", False), + 0xAA: ("browser search key", False), + 0xAB: ("browser favorites", False), + 0xAC: ("browser start and home", False), + 0xAD: ("volume mute", False), + 0xAE: ("volume down", False), + 0xAF: ("volume up", False), + 0xB0: ("next track", False), + 0xB1: ("previous track", False), + 0xB2: ("stop media", False), + 0xB3: ("play/pause media", False), + 0xB4: ("start mail", False), + 0xB5: ("select media", False), + 0xB6: ("start application 1", False), + 0xB7: ("start application 2", False), + 0xBB: ("+", False), + 0xBC: (",", False), + 0xBD: ("-", False), + 0xBE: (".", False), + # 0xbe:('/', False), # Used for miscellaneous characters; it can vary by directkeys. For the US standard keyboard, the '/?. + 0xE5: ("ime process", False), + 0xF6: ("attn", False), + 0xF7: ("crsel", False), + 0xF8: ("exsel", False), + 0xF9: ("erase eof", False), + 0xFA: ("play", False), + 0xFB: ("zoom", False), + 0xFC: ("reserved ", False), + 0xFD: ("pa1", False), + 0xFE: ("clear", False), } tables_lock = Lock() @@ -361,30 +370,32 @@ class INPUT(ctypes.Structure): distinct_modifiers = [ (), - ('shift',), - ('alt gr',), - ('num lock',), - ('shift', 'num lock'), - ('caps lock',), - ('shift', 'caps lock'), - ('alt gr', 'num lock'), + ("shift",), + ("alt gr",), + ("num lock",), + ("shift", "num lock"), + ("caps lock",), + ("shift", "caps lock"), + ("alt gr", "num lock"), ] name_buffer = ctypes.create_unicode_buffer(32) unicode_buffer = ctypes.create_unicode_buffer(32) keyboard_state = keyboard_state_type() + + def get_event_names(scan_code, vk, is_extended, modifiers): is_keypad = (scan_code, vk, is_extended) in keypad_keys is_official = vk in official_virtual_keys if is_keypad and is_official: yield official_virtual_keys[vk][0] - keyboard_state[0x10] = 0x80 * ('shift' in modifiers) - keyboard_state[0x11] = 0x80 * ('alt gr' in modifiers) - keyboard_state[0x12] = 0x80 * ('alt gr' in modifiers) - keyboard_state[0x14] = 0x01 * ('caps lock' in modifiers) - keyboard_state[0x90] = 0x01 * ('num lock' in modifiers) - keyboard_state[0x91] = 0x01 * ('scroll lock' in modifiers) + keyboard_state[0x10] = 0x80 * ("shift" in modifiers) + keyboard_state[0x11] = 0x80 * ("alt gr" in modifiers) + keyboard_state[0x12] = 0x80 * ("alt gr" in modifiers) + keyboard_state[0x14] = 0x01 * ("caps lock" in modifiers) + keyboard_state[0x90] = 0x01 * ("num lock" in modifiers) + keyboard_state[0x91] = 0x01 * ("scroll lock" in modifiers) unicode_ret = ToUnicode(vk, scan_code, keyboard_state, unicode_buffer, len(unicode_buffer), 0) if unicode_ret and unicode_buffer.value: yield unicode_buffer.value @@ -405,18 +416,22 @@ def get_event_names(scan_code, vk, is_extended, modifiers): if not is_keypad and is_official: yield official_virtual_keys[vk][0] + def _setup_name_tables(): """ Ensures the scan code/virtual key code/name translation tables are filled. """ with tables_lock: - if to_name: return + if to_name: + return # Go through every possible scan code, and map them to virtual key codes. # Then vice-versa. - all_scan_codes = [(sc, user32.MapVirtualKeyExW(sc, MAPVK_VSC_TO_VK_EX, 0)) for sc in range(0x100)] - all_vks = [(user32.MapVirtualKeyExW(vk, MAPVK_VK_TO_VSC_EX, 0), vk) for vk in range(0x100)] + all_scan_codes = [ + (sc, user32.MapVirtualKeyExW(sc, MAPVK_VSC_TO_VK_EX, 0)) for sc in range(0x100) + ] + all_vks = [(user32.MapVirtualKeyExW(vk, MAPVK_VK_TO_VSC_EX, 0), vk) for vk in range(0x100)] for scan_code, vk in all_scan_codes + all_vks: # `to_name` and `from_name` entries will be a tuple (scan_code, vk, extended, shift_state). if (scan_code, vk, 0, 0, 0) in to_name: @@ -440,7 +455,6 @@ def _setup_name_tables(): for i, name in enumerate(map(normalize_name, names + lowercase_names)): from_name[name].append((i, entry)) - # TODO: single quotes on US INTL is returning the dead key (?), and therefore # not typing properly. @@ -450,18 +464,21 @@ def _setup_name_tables(): # Windows is consistent in its inconsistency. for extended in [0, 1]: for modifiers in distinct_modifiers: - to_name[(541, 162, extended, modifiers)] = ['alt gr'] - from_name['alt gr'].append((1, (541, 162, extended, modifiers))) + to_name[(541, 162, extended, modifiers)] = ["alt gr"] + from_name["alt gr"].append((1, (541, 162, extended, modifiers))) modifiers_preference = defaultdict(lambda: 10) - modifiers_preference.update({(): 0, ('shift',): 1, ('alt gr',): 2, ('ctrl',): 3, ('alt',): 4}) + modifiers_preference.update({(): 0, ("shift",): 1, ("alt gr",): 2, ("ctrl",): 3, ("alt",): 4}) + def order_key(line): i, entry = line scan_code, vk, extended, modifiers = entry return modifiers_preference[modifiers], i, extended, vk, scan_code + for name, entries in list(from_name.items()): from_name[name] = sorted(set(entries), key=order_key) + def get_modifiers(altgr_is_pressed): """ Returns a tuple with the names of the currently active modifiers. @@ -469,13 +486,14 @@ def get_modifiers(altgr_is_pressed): # GetKeyState reports the "pressed" bit as 0x8000, not 1, so every flag has # to be coerced to a bool before being used to repeat the tuple. return ( - ('shift',) * bool(user32.GetKeyState(0x10) & 0x8000) + - ('alt gr',) * bool(altgr_is_pressed) + - ('num lock',) * bool(user32.GetKeyState(0x90) & 1) + - ('caps lock',) * bool(user32.GetKeyState(0x14) & 1) + - ('scroll lock',) * bool(user32.GetKeyState(0x91) & 1) + ("shift",) * bool(user32.GetKeyState(0x10) & 0x8000) + + ("alt gr",) * bool(altgr_is_pressed) + + ("num lock",) * bool(user32.GetKeyState(0x90) & 1) + + ("caps lock",) * bool(user32.GetKeyState(0x14) & 1) + + ("scroll lock",) * bool(user32.GetKeyState(0x91) & 1) ) + def get_name(scan_code, vk, is_extended, modifiers): """ Returns the most likely name for a key event, given the active modifiers. @@ -488,6 +506,7 @@ def get_name(scan_code, vk, is_extended, modifiers): names = to_name[entry] return names[0] if names else None + # Called by directkeys/__init__.py init = _setup_name_tables @@ -533,6 +552,8 @@ def get_name(scan_code, vk, is_extended, modifiers): ] altgr_is_pressed = False + + def prepare_intercept(callback): """ Registers a Windows low level keyboard hook. The provided callback will @@ -558,33 +579,47 @@ def process_key(event_type, vk, scan_code, is_extended, flags): # With the abstraction OFF, drop the synthetic Ctrl entirely. if not directkeys._ABSTRACT_ALT_GR and scan_code == 541: - return True # Suppress the event. + return True # Suppress the event. # With the abstraction ON, merge the pair into a single 'alt gr' event. if directkeys._ABSTRACT_ALT_GR: global _altgr_right_alt_scan_code, _altgr_right_alt_flags if _altgr_right_alt_scan_code is not None and event_type == KEY_DOWN: - if scan_code == 541: # The synthetic Ctrl. + if scan_code == 541: # The synthetic Ctrl. altgr_is_pressed = True - event = KeyboardEvent('down', _altgr_right_alt_scan_code, name='alt gr', is_keypad=False, flags=_altgr_right_alt_flags) + event = KeyboardEvent( + "down", + _altgr_right_alt_scan_code, + name="alt gr", + is_keypad=False, + flags=_altgr_right_alt_flags, + ) callback(event) _altgr_right_alt_scan_code = None _altgr_right_alt_flags = None - return True # Suppress the synthetic Ctrl. - else: # It was not, so flush the pending Right Alt. - event = KeyboardEvent('down', _altgr_right_alt_scan_code, name='right alt', is_keypad=False, flags=_altgr_right_alt_flags) + return True # Suppress the synthetic Ctrl. + else: # It was not, so flush the pending Right Alt. + event = KeyboardEvent( + "down", + _altgr_right_alt_scan_code, + name="right alt", + is_keypad=False, + flags=_altgr_right_alt_flags, + ) callback(event) _altgr_right_alt_scan_code = None _altgr_right_alt_flags = None - if vk == 165: # Right Alt. - if event_type == KEY_DOWN: # Hold it back and wait for the Ctrl. + if vk == 165: # Right Alt. + if event_type == KEY_DOWN: # Hold it back and wait for the Ctrl. _altgr_right_alt_scan_code = scan_code _altgr_right_alt_flags = flags - return True # Suppress the Right Alt for now. - else: # Released, so complete the 'alt gr' event. + return True # Suppress the Right Alt for now. + else: # Released, so complete the 'alt gr' event. altgr_is_pressed = False - event = KeyboardEvent('up', scan_code, name='alt gr', is_keypad=False, flags=flags) + event = KeyboardEvent( + "up", scan_code, name="alt gr", is_keypad=False, flags=flags + ) callback(event) return True @@ -596,7 +631,7 @@ def process_key(event_type, vk, scan_code, is_extended, flags): modifiers = get_modifiers(altgr_is_pressed) if not directkeys._ABSTRACT_ALT_GR and vk == 165: - name = 'alt gr' + name = "alt gr" else: name = get_name(scan_code, vk, is_extended, modifiers) @@ -606,36 +641,44 @@ def process_key(event_type, vk, scan_code, is_extended, flags): is_keypad = (scan_code, vk, is_extended) in keypad_keys # Keys with no scan code (injected events, sending by virtual key) fall # back to the negated vk so they stay distinguishable from each other. - event = KeyboardEvent(event_type=event_type, scan_code=scan_code or -vk, name=name, is_keypad=is_keypad, flags=flags) + event = KeyboardEvent( + event_type=event_type, + scan_code=scan_code or -vk, + name=name, + is_keypad=is_keypad, + flags=flags, + ) return callback(event) def low_level_keyboard_handler(nCode, wParam, lParam): try: vk = lParam.contents.vk_code # Ignore the second `alt` DOWN observed in some cases. - fake_alt = (LLKHF_INJECTED | 0x20) + fake_alt = LLKHF_INJECTED | 0x20 # Ignore events generated by SendInput with Unicode. if vk != VK_PACKET and lParam.contents.flags & fake_alt != fake_alt: event_type = KEY_UP if wParam & 0x01 else KEY_DOWN raw_flags = lParam.contents.flags - processed_flags = raw_flags & 1 # Isolate the LLKHF_EXTENDED bit. + processed_flags = raw_flags & 1 # Isolate the LLKHF_EXTENDED bit. is_extended = processed_flags scan_code = lParam.contents.scan_code - should_continue = process_key(event_type, vk, scan_code, is_extended, processed_flags) + should_continue = process_key( + event_type, vk, scan_code, is_extended, processed_flags + ) if not should_continue: return -1 except Exception: - print('Error in keyboard hook:') + print("Error in keyboard hook:") traceback.print_exc() return CallNextHookEx(None, nCode, wParam, lParam) WH_KEYBOARD_LL = c_int(13) keyboard_callback = LowLevelKeyboardProc(low_level_keyboard_handler) - handle = GetModuleHandleW(None) + handle = GetModuleHandleW(None) thread_id = DWORD(0) keyboard_hook = SetWindowsHookEx(WH_KEYBOARD_LL, keyboard_callback, handle, thread_id) @@ -643,13 +686,15 @@ def low_level_keyboard_handler(nCode, wParam, lParam): # try/finally block doesn't seem to work here. atexit.register(UnhookWindowsHookEx, keyboard_callback) + def _clear_name_tables(): - """ Empties the name tables so they can be rebuilt. """ + """Empties the name tables so they can be rebuilt.""" with tables_lock: to_name.clear() from_name.clear() scan_code_to_vk.clear() + def rebuild_name_tables(): """ Forces the name tables to be cleared and rebuilt. Called by __init__.py @@ -658,6 +703,7 @@ def rebuild_name_tables(): _clear_name_tables() _setup_name_tables() + def listen(callback): prepare_intercept(callback) msg = LPMSG() @@ -665,16 +711,18 @@ def listen(callback): TranslateMessage(msg) DispatchMessage(msg) + def map_name(name): _setup_name_tables() entries = from_name.get(name) if not entries: - raise ValueError(f'Key name {name!r} is not mapped to any known key.') + raise ValueError(f"Key name {name!r} is not mapped to any known key.") for i, entry in entries: scan_code, vk, _is_extended, modifiers = entry yield scan_code or -vk, modifiers + def _send_event(code, event_type): if code == 541: # Alt-gr is made of ctrl+alt. Just sending even 541 doesn't do anything. @@ -688,23 +736,28 @@ def _send_event(code, event_type): # and the value actually contains the Virtual key code. user32.keybd_event(-code, 0, event_type, 0) + def press(code): _send_event(code, 0) + def release(code): _send_event(code, 2) + def type_unicode(character): # This code and related structures are based on # http://stackoverflow.com/a/11910555/252218 - surrogates = bytearray(character.encode('utf-16le')) + surrogates = bytearray(character.encode("utf-16le")) presses = [] releases = [] for i in range(0, len(surrogates), 2): - higher, lower = surrogates[i:i+2] + higher, lower = surrogates[i : i + 2] structure = KEYBDINPUT(0, (lower << 8) + higher, KEYEVENTF_UNICODE, 0, None) presses.append(INPUT(INPUT_KEYBOARD, _INPUTunion(ki=structure))) - structure = KEYBDINPUT(0, (lower << 8) + higher, KEYEVENTF_UNICODE | KEYEVENTF_KEYUP, 0, None) + structure = KEYBDINPUT( + 0, (lower << 8) + higher, KEYEVENTF_UNICODE | KEYEVENTF_KEYUP, 0, None + ) releases.append(INPUT(INPUT_KEYBOARD, _INPUTunion(ki=structure))) inputs = presses + releases nInputs = len(inputs) @@ -713,15 +766,24 @@ def type_unicode(character): cbSize = c_int(ctypes.sizeof(INPUT)) SendInput(nInputs, pInputs, cbSize) + # Virtual key codes of the modifiers that can be left stuck by a program that # pressed them and exited without releasing. _modifier_vk_names = { - 0x10: 'shift', 0xA0: 'left shift', 0xA1: 'right shift', - 0x11: 'ctrl', 0xA2: 'left ctrl', 0xA3: 'right ctrl', - 0x12: 'alt', 0xA4: 'left alt', 0xA5: 'right alt', - 0x5B: 'left windows', 0x5C: 'right windows', + 0x10: "shift", + 0xA0: "left shift", + 0xA1: "right shift", + 0x11: "ctrl", + 0xA2: "left ctrl", + 0xA3: "right ctrl", + 0x12: "alt", + 0xA4: "left alt", + 0xA5: "right alt", + 0x5B: "left windows", + 0x5C: "right windows", } + def force_reset_keyboard(): """ Forces Windows to release any modifier key that was left stuck down. @@ -738,6 +800,7 @@ def force_reset_keyboard(): # Give the OS a moment to process the pair. time.sleep(0.01) + def _reset_internal_state(): """ Clears the internal state used by the AltGr handling, so that a fresh run @@ -748,6 +811,7 @@ def _reset_internal_state(): _altgr_right_alt_flags = None altgr_is_pressed = False + def get_stuck_keys(): """ Returns the names of the modifier keys that are currently held down. @@ -762,9 +826,11 @@ def get_stuck_keys(): return stuck_keys -if __name__ == '__main__': + +if __name__ == "__main__": _setup_name_tables() import pprint + pprint.pprint(to_name) pprint.pprint(from_name) - #listen(lambda e: print(e.to_json()) or True) + # listen(lambda e: print(e.to_json()) or True) diff --git a/src/directkeys/_winmouse.py b/src/directkeys/_winmouse.py index 3f2ebf74..41b963a8 100644 --- a/src/directkeys/_winmouse.py +++ b/src/directkeys/_winmouse.py @@ -32,27 +32,30 @@ X, ) -#https://github.com/boppreh/mouse/issues/1 -#user32 = ctypes.windll.user32 -user32 = ctypes.WinDLL('user32', use_last_error = True) +# https://github.com/boppreh/mouse/issues/1 +# user32 = ctypes.windll.user32 +user32 = ctypes.WinDLL("user32", use_last_error=True) + class MSLLHOOKSTRUCT(Structure): - _fields_ = [("x", c_long), - ("y", c_long), - ('data', c_int32), - ('reserved', c_int32), - ("flags", DWORD), - ("time", c_int), - ] + _fields_ = [ + ("x", c_long), + ("y", c_long), + ("data", c_int32), + ("reserved", c_int32), + ("flags", DWORD), + ("time", c_int), + ] + LowLevelMouseProc = CFUNCTYPE(c_int, WPARAM, LPARAM, POINTER(MSLLHOOKSTRUCT)) SetWindowsHookEx = user32.SetWindowsHookExA -#SetWindowsHookEx.argtypes = [c_int, LowLevelMouseProc, c_int, c_int] +# SetWindowsHookEx.argtypes = [c_int, LowLevelMouseProc, c_int, c_int] SetWindowsHookEx.restype = HHOOK CallNextHookEx = user32.CallNextHookEx -#CallNextHookEx.argtypes = [c_int , c_int, c_int, POINTER(MSLLHOOKSTRUCT)] +# CallNextHookEx.argtypes = [c_int , c_int, c_int, POINTER(MSLLHOOKSTRUCT)] CallNextHookEx.restype = c_int UnhookWindowsHookEx = user32.UnhookWindowsHookEx @@ -102,15 +105,12 @@ class MSLLHOOKSTRUCT(Structure): WM_LBUTTONDOWN: (DOWN, LEFT), WM_LBUTTONUP: (UP, LEFT), WM_LBUTTONDBLCLK: (DOUBLE, LEFT), - WM_RBUTTONDOWN: (DOWN, RIGHT), WM_RBUTTONUP: (UP, RIGHT), WM_RBUTTONDBLCLK: (DOUBLE, RIGHT), - WM_MBUTTONDOWN: (DOWN, MIDDLE), WM_MBUTTONUP: (UP, MIDDLE), WM_MBUTTONDBLCLK: (DOUBLE, MIDDLE), - WM_XBUTTONDOWN: (DOWN, X), WM_XBUTTONUP: (UP, X), WM_XBUTTONDBLCLK: (DOUBLE, X), @@ -132,16 +132,12 @@ class MSLLHOOKSTRUCT(Structure): simulated_mouse_codes = { (WHEEL, HORIZONTAL): MOUSEEVENTF_HWHEEL, (WHEEL, VERTICAL): MOUSEEVENTF_WHEEL, - (DOWN, LEFT): MOUSEEVENTF_LEFTDOWN, (UP, LEFT): MOUSEEVENTF_LEFTUP, - (DOWN, RIGHT): MOUSEEVENTF_RIGHTDOWN, (UP, RIGHT): MOUSEEVENTF_RIGHTUP, - (DOWN, MIDDLE): MOUSEEVENTF_MIDDLEDOWN, (UP, MIDDLE): MOUSEEVENTF_MIDDLEUP, - (DOWN, X): MOUSEEVENTF_XDOWN, (UP, X): MOUSEEVENTF_XUP, } @@ -152,6 +148,7 @@ class MSLLHOOKSTRUCT(Structure): init = lambda: None + def listen(queue): def low_level_mouse_handler(nCode, wParam, lParam): struct = lParam.contents @@ -161,9 +158,9 @@ def low_level_mouse_handler(nCode, wParam, lParam): if wParam == WM_MOUSEMOVE: event = MoveEvent(struct.x, struct.y, t) elif wParam == WM_MOUSEWHEEL: - event = WheelEvent(struct.data / (WHEEL_DELTA * (2<<15)), t) + event = WheelEvent(struct.data / (WHEEL_DELTA * (2 << 15)), t) elif wParam in buttons_by_wm_code: - type, button = buttons_by_wm_code.get(wParam, ('?', '?')) + type, button = buttons_by_wm_code.get(wParam, ("?", "?")) if wParam >= WM_XBUTTONDOWN: button = {0x10000: X, 0x20000: X2}[struct.data] event = ButtonEvent(type, button, t) @@ -184,41 +181,52 @@ def low_level_mouse_handler(nCode, wParam, lParam): TranslateMessage(msg) DispatchMessage(msg) + def _translate_button(button): if button == X or button == X2: return X, {X: 0x10000, X2: 0x20000}[button] else: return button, 0 + def press(button=LEFT): button, data = _translate_button(button) code = simulated_mouse_codes[(DOWN, button)] user32.mouse_event(code, 0, 0, data, 0) + def release(button=LEFT): button, data = _translate_button(button) code = simulated_mouse_codes[(UP, button)] user32.mouse_event(code, 0, 0, data, 0) + def wheel(delta=1): code = simulated_mouse_codes[(WHEEL, VERTICAL)] user32.mouse_event(code, 0, 0, int(delta * WHEEL_DELTA), 0) + def move_to(x, y): user32.SetCursorPos(int(x), int(y)) + def move_relative(x, y): user32.mouse_event(MOUSEEVENTF_MOVE, int(x), int(y), 0, 0) + class POINT(Structure): _fields_ = [("x", c_long), ("y", c_long)] + def get_position(): point = POINT() user32.GetCursorPos(byref(point)) return (point.x, point.y) -if __name__ == '__main__': + +if __name__ == "__main__": + def p(e): print(e) + listen(p) diff --git a/src/directkeys/mouse.py b/src/directkeys/mouse.py index 291d4695..75f88188 100644 --- a/src/directkeys/mouse.py +++ b/src/directkeys/mouse.py @@ -1,16 +1,20 @@ import warnings -warnings.simplefilter('always', DeprecationWarning) -warnings.warn('The mouse sub-library is deprecated and will be removed in future versions. Please use the standalone package `mouse`.', DeprecationWarning, stacklevel=2) +warnings.simplefilter("always", DeprecationWarning) +warnings.warn( + "The mouse sub-library is deprecated and will be removed in future versions. Please use the standalone package `mouse`.", + DeprecationWarning, + stacklevel=2, +) import platform as _platform import time as _time -if _platform.system() == 'Windows': +if _platform.system() == "Windows": from . import _winmouse as _os_mouse -elif _platform.system() == 'Linux': +elif _platform.system() == "Linux": from . import _nixmouse as _os_mouse -elif _platform.system() == 'Darwin': +elif _platform.system() == "Darwin": from . import _darwinmouse as _os_mouse else: raise OSError(f"Unsupported platform '{_platform.system()}'") @@ -31,9 +35,12 @@ ) _pressed_events = set() + + class _MouseListener(_GenericListener): def init(self): _os_mouse.init() + def pre_process_event(self, event): if isinstance(event, ButtonEvent): if event.event_type in (UP, DOUBLE): @@ -45,39 +52,48 @@ def pre_process_event(self, event): def listen(self): _os_mouse.listen(self.queue) + _listener = _MouseListener() + def is_pressed(button=LEFT): - """ Returns True if the given button is currently pressed. """ + """Returns True if the given button is currently pressed.""" _listener.start_if_necessary() return button in _pressed_events + def press(button=LEFT): - """ Presses the given button (but doesn't release). """ + """Presses the given button (but doesn't release).""" _os_mouse.press(button) + def release(button=LEFT): - """ Releases the given button. """ + """Releases the given button.""" _os_mouse.release(button) + def click(button=LEFT): - """ Sends a click with the given button. """ + """Sends a click with the given button.""" _os_mouse.press(button) _os_mouse.release(button) + def double_click(button=LEFT): - """ Sends a double click with the given button. """ + """Sends a double click with the given button.""" click(button) click(button) + def right_click(): - """ Sends a right click with the given button. """ + """Sends a right click with the given button.""" click(RIGHT) + def wheel(delta=1): - """ Scrolls the wheel `delta` clicks. Sign indicates direction. """ + """Scrolls the wheel `delta` clicks. Sign indicates direction.""" _os_mouse.wheel(delta) + def move(x, y, absolute=True, duration=0): """ Moves the mouse. If `absolute`, to position (x, y), otherwise move relative @@ -106,12 +122,13 @@ def move(x, y, absolute=True, duration=0): # 120 movements per second. # Round and keep float to ensure float division in Python 2 steps = max(1.0, float(int(duration * 120.0))) - for i in range(int(steps)+1): - move(start_x + dx*i/steps, start_y + dy*i/steps) - _time.sleep(duration/steps) + for i in range(int(steps) + 1): + move(start_x + dx * i / steps, start_y + dy * i / steps) + _time.sleep(duration / steps) else: _os_mouse.move_to(x, y) + def drag(start_x, start_y, end_x, end_y, absolute=True, duration=0): """ Holds the left mouse button, moving from start to end position, then @@ -125,8 +142,9 @@ def drag(start_x, start_y, end_x, end_y, absolute=True, duration=0): move(end_x, end_y, absolute, duration) release() + def on_button(callback, args=(), buttons=(LEFT, MIDDLE, RIGHT, X, X2), types=(UP, DOWN, DOUBLE)): - """ Invokes `callback` with `args` when the specified event happens. """ + """Invokes `callback` with `args` when the specified event happens.""" if not isinstance(buttons, (tuple, list)): buttons = (buttons,) if not isinstance(types, (tuple, list)): @@ -136,42 +154,51 @@ def handler(event): if isinstance(event, ButtonEvent): if event.event_type in types and event.button in buttons: callback(*args) + _listener.add_handler(handler) return handler + def on_click(callback, args=()): - """ Invokes `callback` with `args` when the left button is clicked. """ + """Invokes `callback` with `args` when the left button is clicked.""" return on_button(callback, args, [LEFT], [UP]) + def on_double_click(callback, args=()): """ Invokes `callback` with `args` when the left button is double clicked. """ return on_button(callback, args, [LEFT], [DOUBLE]) + def on_right_click(callback, args=()): - """ Invokes `callback` with `args` when the right button is clicked. """ + """Invokes `callback` with `args` when the right button is clicked.""" return on_button(callback, args, [RIGHT], [UP]) + def on_middle_click(callback, args=()): - """ Invokes `callback` with `args` when the middle button is clicked. """ + """Invokes `callback` with `args` when the middle button is clicked.""" return on_button(callback, args, [MIDDLE], [UP]) + def wait(button=LEFT, target_types=(UP, DOWN, DOUBLE)): """ Blocks program execution until the given button performs an event. """ from threading import Lock + lock = Lock() lock.acquire() handler = on_button(lock.release, (), [button], target_types) lock.acquire() _listener.remove_handler(handler) + def get_position(): - """ Returns the (x, y) mouse position. """ + """Returns the (x, y) mouse position.""" return _os_mouse.get_position() + def hook(callback): """ Installs a global listener on all available mouses, invoking `callback` @@ -184,12 +211,14 @@ def hook(callback): _listener.add_handler(callback) return callback + def unhook(callback): """ Removes a previously installed hook. """ _listener.remove_handler(callback) + def unhook_all(): """ Removes all hooks registered by this application. Note this may include @@ -197,6 +226,7 @@ def unhook_all(): """ del _listener.handlers[:] + def record(button=RIGHT, target_types=(DOWN,)): """ Records all mouse events until the user presses the given button. @@ -211,6 +241,7 @@ def record(button=RIGHT, target_types=(DOWN,)): unhook(recorded.append) return recorded + def play(events, speed_factor=1.0, include_clicks=True, include_moves=True, include_wheel=True): """ Plays a sequence of recorded events, maintaining the relative time @@ -236,9 +267,10 @@ def play(events, speed_factor=1.0, include_clicks=True, include_moves=True, incl elif isinstance(event, WheelEvent) and include_wheel: _os_mouse.wheel(event.delta) + replay = play hold = press -if __name__ == '__main__': - print('Recording... Double click to stop and replay.') +if __name__ == "__main__": + print("Recording... Double click to stop and replay.") play(record()) diff --git a/tests/test_keyboard.py b/tests/test_keyboard.py index 897ccda7..ccc4e9aa 100644 --- a/tests/test_keyboard.py +++ b/tests/test_keyboard.py @@ -19,91 +19,97 @@ from directkeys._keyboard_event import KEY_DOWN, KEY_UP, KeyboardEvent dummy_keys = { - 'space': [(0, [])], - - 'a': [(1, [])], - 'b': [(2, [])], - 'c': [(3, [])], - 'A': [(1, ['shift']), (-1, [])], - 'B': [(2, ['shift']), (-2, [])], - 'C': [(3, ['shift']), (-3, [])], - - 'alt': [(4, [])], - 'left alt': [(4, [])], - - 'left shift': [(5, [])], - 'right shift': [(6, [])], - - 'left ctrl': [(7, [])], - - 'backspace': [(8, [])], - 'caps lock': [(9, [])], - - '+': [(10, [])], - ',': [(11, [])], - '_': [(12, [])], - - 'none': [], - 'duplicated': [(20, []), (20, [])], + "space": [(0, [])], + "a": [(1, [])], + "b": [(2, [])], + "c": [(3, [])], + "A": [(1, ["shift"]), (-1, [])], + "B": [(2, ["shift"]), (-2, [])], + "C": [(3, ["shift"]), (-3, [])], + "alt": [(4, [])], + "left alt": [(4, [])], + "left shift": [(5, [])], + "right shift": [(6, [])], + "left ctrl": [(7, [])], + "backspace": [(8, [])], + "caps lock": [(9, [])], + "+": [(10, [])], + ",": [(11, [])], + "_": [(12, [])], + "none": [], + "duplicated": [(20, []), (20, [])], } + def make_event(event_type, name, scan_code=None, time=0): - return KeyboardEvent(event_type=event_type, scan_code=scan_code or dummy_keys[name][0][0], name=name, time=time) + return KeyboardEvent( + event_type=event_type, scan_code=scan_code or dummy_keys[name][0][0], name=name, time=time + ) + # Used when manually pumping events. input_events = [] output_events = [] + def send_instant_event(event): if directkeys._listener.direct_callback(event): output_events.append(event) + # Mock out side effects. directkeys._os_keyboard.init = lambda: None directkeys._os_keyboard.listen = lambda callback: None directkeys._os_keyboard.map_name = dummy_keys.__getitem__ -directkeys._os_keyboard.press = lambda scan_code: send_instant_event(make_event(KEY_DOWN, None, scan_code)) -directkeys._os_keyboard.release = lambda scan_code: send_instant_event(make_event(KEY_UP, None, scan_code)) -directkeys._os_keyboard.type_unicode = lambda char: output_events.append(KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name=char)) +directkeys._os_keyboard.press = lambda scan_code: send_instant_event( + make_event(KEY_DOWN, None, scan_code) +) +directkeys._os_keyboard.release = lambda scan_code: send_instant_event( + make_event(KEY_UP, None, scan_code) +) +directkeys._os_keyboard.type_unicode = lambda char: output_events.append( + KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name=char) +) # Shortcuts for defining test inputs and expected outputs. # Usage: d_shift + d_a + u_a + u_shift -d_a = [make_event(KEY_DOWN, 'a')] -u_a = [make_event(KEY_UP, 'a')] -du_a = d_a+u_a -d_b = [make_event(KEY_DOWN, 'b')] -u_b = [make_event(KEY_UP, 'b')] -du_b = d_b+u_b -d_c = [make_event(KEY_DOWN, 'c')] -u_c = [make_event(KEY_UP, 'c')] -du_c = d_c+u_c -d_ctrl = [make_event(KEY_DOWN, 'left ctrl')] -u_ctrl = [make_event(KEY_UP, 'left ctrl')] -du_ctrl = d_ctrl+u_ctrl -d_shift = [make_event(KEY_DOWN, 'left shift')] -u_shift = [make_event(KEY_UP, 'left shift')] -du_shift = d_shift+u_shift -d_alt = [make_event(KEY_DOWN, 'alt')] -u_alt = [make_event(KEY_UP, 'alt')] -du_alt = d_alt+u_alt -du_backspace = [make_event(KEY_DOWN, 'backspace'), make_event(KEY_UP, 'backspace')] -du_capslock = [make_event(KEY_DOWN, 'caps lock'), make_event(KEY_UP, 'caps lock')] -d_space = [make_event(KEY_DOWN, 'space')] -u_space = [make_event(KEY_UP, 'space')] -du_space = [make_event(KEY_DOWN, 'space'), make_event(KEY_UP, 'space')] +d_a = [make_event(KEY_DOWN, "a")] +u_a = [make_event(KEY_UP, "a")] +du_a = d_a + u_a +d_b = [make_event(KEY_DOWN, "b")] +u_b = [make_event(KEY_UP, "b")] +du_b = d_b + u_b +d_c = [make_event(KEY_DOWN, "c")] +u_c = [make_event(KEY_UP, "c")] +du_c = d_c + u_c +d_ctrl = [make_event(KEY_DOWN, "left ctrl")] +u_ctrl = [make_event(KEY_UP, "left ctrl")] +du_ctrl = d_ctrl + u_ctrl +d_shift = [make_event(KEY_DOWN, "left shift")] +u_shift = [make_event(KEY_UP, "left shift")] +du_shift = d_shift + u_shift +d_alt = [make_event(KEY_DOWN, "alt")] +u_alt = [make_event(KEY_UP, "alt")] +du_alt = d_alt + u_alt +du_backspace = [make_event(KEY_DOWN, "backspace"), make_event(KEY_UP, "backspace")] +du_capslock = [make_event(KEY_DOWN, "caps lock"), make_event(KEY_UP, "caps lock")] +d_space = [make_event(KEY_DOWN, "space")] +u_space = [make_event(KEY_UP, "space")] +du_space = [make_event(KEY_DOWN, "space"), make_event(KEY_UP, "space")] trigger = lambda e=None: directkeys.press(999) triggered_event = [KeyboardEvent(KEY_DOWN, scan_code=999)] + class TestKeyboard(unittest.TestCase): def tearDown(self): directkeys.unhook_all() - #self.assertEquals(directkeys._hooks, {}) - #self.assertEquals(directkeys._hotkeys, {}) + # self.assertEquals(directkeys._hooks, {}) + # self.assertEquals(directkeys._hotkeys, {}) def setUp(self): - #directkeys._hooks.clear() - #directkeys._hotkeys.clear() + # directkeys._hooks.clear() + # directkeys._hotkeys.clear() del input_events[:] del output_events[:] directkeys._recording = None @@ -121,154 +127,216 @@ def do(self, manual_events, expected=None): if directkeys._listener.direct_callback(event): output_events.append(event) if expected is not None: - to_names = lambda es: '+'.join(('d' if e.event_type == KEY_DOWN else 'u') + '_' + str(e.scan_code) for e in es) + to_names = lambda es: "+".join( + ("d" if e.event_type == KEY_DOWN else "u") + "_" + str(e.scan_code) for e in es + ) self.assertEqual(to_names(output_events), to_names(expected)) del output_events[:] directkeys._listener.queue.join() def test_event_json(self): - event = make_event(KEY_DOWN, 'á \'"', 999) + event = make_event(KEY_DOWN, "á '\"", 999) import json + self.assertEqual(event, KeyboardEvent(**json.loads(event.to_json()))) def test_is_modifier_name(self): for name in directkeys.all_modifiers: self.assertTrue(directkeys.is_modifier(name)) + def test_is_modifier_scan_code(self): for i in range(10): self.assertEqual(directkeys.is_modifier(i), i in [4, 5, 6, 7]) def test_key_to_scan_codes_brute(self): for name, entries in dummy_keys.items(): - if name in ['none', 'duplicated']: continue + if name in ["none", "duplicated"]: + continue expected = tuple(scan_code for scan_code, modifiers in entries) self.assertEqual(directkeys.key_to_scan_codes(name), expected) + def test_key_to_scan_code_from_scan_code(self): for i in range(10): self.assertEqual(directkeys.key_to_scan_codes(i), (i,)) + def test_key_to_scan_code_from_letter(self): - self.assertEqual(directkeys.key_to_scan_codes('a'), (1,)) - self.assertEqual(directkeys.key_to_scan_codes('A'), (1,-1)) + self.assertEqual(directkeys.key_to_scan_codes("a"), (1,)) + self.assertEqual(directkeys.key_to_scan_codes("A"), (1, -1)) + def test_key_to_scan_code_from_normalized(self): - self.assertEqual(directkeys.key_to_scan_codes('shift'), (5,6)) - self.assertEqual(directkeys.key_to_scan_codes('SHIFT'), (5,6)) - self.assertEqual(directkeys.key_to_scan_codes('ctrl'), directkeys.key_to_scan_codes('CONTROL')) + self.assertEqual(directkeys.key_to_scan_codes("shift"), (5, 6)) + self.assertEqual(directkeys.key_to_scan_codes("SHIFT"), (5, 6)) + self.assertEqual( + directkeys.key_to_scan_codes("ctrl"), directkeys.key_to_scan_codes("CONTROL") + ) + def test_key_to_scan_code_from_sided_modifier(self): - self.assertEqual(directkeys.key_to_scan_codes('left shift'), (5,)) - self.assertEqual(directkeys.key_to_scan_codes('right shift'), (6,)) + self.assertEqual(directkeys.key_to_scan_codes("left shift"), (5,)) + self.assertEqual(directkeys.key_to_scan_codes("right shift"), (6,)) + def test_key_to_scan_code_underscores(self): - self.assertEqual(directkeys.key_to_scan_codes('_'), (12,)) - self.assertEqual(directkeys.key_to_scan_codes('right_shift'), (6,)) + self.assertEqual(directkeys.key_to_scan_codes("_"), (12,)) + self.assertEqual(directkeys.key_to_scan_codes("right_shift"), (6,)) + def test_key_to_scan_code_error_none(self): with self.assertRaises(ValueError): directkeys.key_to_scan_codes(None) + def test_key_to_scan_code_error_empty(self): with self.assertRaises(ValueError): - directkeys.key_to_scan_codes('') + directkeys.key_to_scan_codes("") + def test_key_to_scan_code_error_other(self): with self.assertRaises(ValueError): directkeys.key_to_scan_codes({}) + def test_key_to_scan_code_list(self): - self.assertEqual(directkeys.key_to_scan_codes([10, 5, 'a']), (10, 5, 1)) + self.assertEqual(directkeys.key_to_scan_codes([10, 5, "a"]), (10, 5, 1)) + def test_key_to_scan_code_empty(self): with self.assertRaises(ValueError): - directkeys.key_to_scan_codes('none') + directkeys.key_to_scan_codes("none") + def test_key_to_scan_code_duplicated(self): - self.assertEqual(directkeys.key_to_scan_codes('duplicated'), (20,)) + self.assertEqual(directkeys.key_to_scan_codes("duplicated"), (20,)) def test_parse_hotkey_simple(self): - self.assertEqual(directkeys.parse_hotkey('a'), (((1,),),)) - self.assertEqual(directkeys.parse_hotkey('A'), (((1,-1),),)) + self.assertEqual(directkeys.parse_hotkey("a"), (((1,),),)) + self.assertEqual(directkeys.parse_hotkey("A"), (((1, -1),),)) + def test_parse_hotkey_separators(self): - self.assertEqual(directkeys.parse_hotkey('+'), directkeys.parse_hotkey('plus')) - self.assertEqual(directkeys.parse_hotkey(','), directkeys.parse_hotkey('comma')) + self.assertEqual(directkeys.parse_hotkey("+"), directkeys.parse_hotkey("plus")) + self.assertEqual(directkeys.parse_hotkey(","), directkeys.parse_hotkey("comma")) + def test_parse_hotkey_keys(self): - self.assertEqual(directkeys.parse_hotkey('left shift + a'), (((5,), (1,),),)) - self.assertEqual(directkeys.parse_hotkey('left shift+a'), (((5,), (1,),),)) + self.assertEqual( + directkeys.parse_hotkey("left shift + a"), + ( + ( + (5,), + (1,), + ), + ), + ) + self.assertEqual( + directkeys.parse_hotkey("left shift+a"), + ( + ( + (5,), + (1,), + ), + ), + ) + def test_parse_hotkey_simple_steps(self): - self.assertEqual(directkeys.parse_hotkey('a,b'), (((1,),),((2,),))) - self.assertEqual(directkeys.parse_hotkey('a, b'), (((1,),),((2,),))) + self.assertEqual(directkeys.parse_hotkey("a,b"), (((1,),), ((2,),))) + self.assertEqual(directkeys.parse_hotkey("a, b"), (((1,),), ((2,),))) + def test_parse_hotkey_steps(self): - self.assertEqual(directkeys.parse_hotkey('a+b, b+c'), (((1,),(2,)),((2,),(3,)))) + self.assertEqual(directkeys.parse_hotkey("a+b, b+c"), (((1,), (2,)), ((2,), (3,)))) + def test_parse_hotkey_example(self): - alt_codes = directkeys.key_to_scan_codes('alt') - shift_codes = directkeys.key_to_scan_codes('shift') - a_codes = directkeys.key_to_scan_codes('a') - b_codes = directkeys.key_to_scan_codes('b') - c_codes = directkeys.key_to_scan_codes('c') - self.assertEqual(directkeys.parse_hotkey("alt+shift+a, alt+b, c"), ((alt_codes, shift_codes, a_codes), (alt_codes, b_codes), (c_codes,))) + alt_codes = directkeys.key_to_scan_codes("alt") + shift_codes = directkeys.key_to_scan_codes("shift") + a_codes = directkeys.key_to_scan_codes("a") + b_codes = directkeys.key_to_scan_codes("b") + c_codes = directkeys.key_to_scan_codes("c") + self.assertEqual( + directkeys.parse_hotkey("alt+shift+a, alt+b, c"), + ((alt_codes, shift_codes, a_codes), (alt_codes, b_codes), (c_codes,)), + ) + def test_parse_hotkey_list_scan_codes(self): self.assertEqual(directkeys.parse_hotkey([1, 2, 3]), (((1,), (2,), (3,)),)) + def test_parse_hotkey_deep_list_scan_codes(self): - result = directkeys.parse_hotkey('a') + result = directkeys.parse_hotkey("a") self.assertEqual(directkeys.parse_hotkey(result), (((1,),),)) + def test_parse_hotkey_list_names(self): - self.assertEqual(directkeys.parse_hotkey(['a', 'b', 'c']), (((1,), (2,), (3,)),)) + self.assertEqual(directkeys.parse_hotkey(["a", "b", "c"]), (((1,), (2,), (3,)),)) def test_is_pressed_none(self): - self.assertFalse(directkeys.is_pressed('a')) + self.assertFalse(directkeys.is_pressed("a")) + def test_is_pressed_true(self): self.do(d_a) - self.assertTrue(directkeys.is_pressed('a')) + self.assertTrue(directkeys.is_pressed("a")) + def test_is_pressed_true_scan_code_true(self): self.do(d_a) self.assertTrue(directkeys.is_pressed(1)) + def test_is_pressed_true_scan_code_false(self): self.do(d_a) self.assertFalse(directkeys.is_pressed(2)) + def test_is_pressed_true_scan_code_invalid(self): self.do(d_a) self.assertFalse(directkeys.is_pressed(-1)) + def test_is_pressed_false(self): - self.do(d_a+u_a+d_b) - self.assertFalse(directkeys.is_pressed('a')) - self.assertTrue(directkeys.is_pressed('b')) + self.do(d_a + u_a + d_b) + self.assertFalse(directkeys.is_pressed("a")) + self.assertTrue(directkeys.is_pressed("b")) + def test_is_pressed_hotkey_true(self): - self.do(d_shift+d_a) - self.assertTrue(directkeys.is_pressed('shift+a')) + self.do(d_shift + d_a) + self.assertTrue(directkeys.is_pressed("shift+a")) + def test_is_pressed_hotkey_false(self): - self.do(d_shift+d_a+u_a) - self.assertFalse(directkeys.is_pressed('shift+a')) + self.do(d_shift + d_a + u_a) + self.assertFalse(directkeys.is_pressed("shift+a")) + def test_is_pressed_multi_step_fail(self): - self.do(u_a+d_a) + self.do(u_a + d_a) with self.assertRaises(ValueError): - directkeys.is_pressed('a, b') + directkeys.is_pressed("a, b") def test_send_single_press_release(self): - directkeys.send('a', do_press=True, do_release=True) - self.do([], d_a+u_a) + directkeys.send("a", do_press=True, do_release=True) + self.do([], d_a + u_a) + def test_send_single_press(self): - directkeys.send('a', do_press=True, do_release=False) + directkeys.send("a", do_press=True, do_release=False) self.do([], d_a) + def test_send_single_release(self): - directkeys.send('a', do_press=False, do_release=True) + directkeys.send("a", do_press=False, do_release=True) self.do([], u_a) + def test_send_single_none(self): - directkeys.send('a', do_press=False, do_release=False) + directkeys.send("a", do_press=False, do_release=False) self.do([], []) + def test_press(self): - directkeys.press('a') + directkeys.press("a") self.do([], d_a) + def test_release(self): - directkeys.release('a') + directkeys.release("a") self.do([], u_a) + def test_press_and_release(self): - directkeys.press_and_release('a') - self.do([], d_a+u_a) + directkeys.press_and_release("a") + self.do([], d_a + u_a) def test_send_modifier_press_release(self): - directkeys.send('ctrl+a', do_press=True, do_release=True) - self.do([], d_ctrl+d_a+u_a+u_ctrl) + directkeys.send("ctrl+a", do_press=True, do_release=True) + self.do([], d_ctrl + d_a + u_a + u_ctrl) + def test_send_modifiers_release(self): - directkeys.send('ctrl+shift+a', do_press=False, do_release=True) - self.do([], u_a+u_shift+u_ctrl) + directkeys.send("ctrl+shift+a", do_press=False, do_release=True) + self.do([], u_a + u_shift + u_ctrl) def test_call_later(self): triggered = [] + def fn(arg1, arg2): assert arg1 == 1 and arg2 == 2 triggered.append(True) + directkeys.call_later(fn, (1, 2), 0.01) self.assertFalse(triggered) time.sleep(0.05) @@ -276,245 +344,325 @@ def fn(arg1, arg2): def test_hook_nonblocking(self): self.i = 0 + def count(e): - self.assertEqual(e.name, 'a') + self.assertEqual(e.name, "a") self.i += 1 + hook = directkeys.hook(count, suppress=False) - self.do(d_a+u_a, d_a+u_a) + self.do(d_a + u_a, d_a + u_a) self.assertEqual(self.i, 2) directkeys.unhook(hook) - self.do(d_a+u_a, d_a+u_a) + self.do(d_a + u_a, d_a + u_a) self.assertEqual(self.i, 2) directkeys.hook(count, suppress=False) - self.do(d_a+u_a, d_a+u_a) + self.do(d_a + u_a, d_a + u_a) self.assertEqual(self.i, 4) directkeys.unhook_all() - self.do(d_a+u_a, d_a+u_a) + self.do(d_a + u_a, d_a + u_a) self.assertEqual(self.i, 4) + def test_hook_blocking(self): self.i = 0 + def count(e): - self.assertIn(e.name, ['a', 'b']) + self.assertIn(e.name, ["a", "b"]) self.i += 1 - return e.name == 'b' + return e.name == "b" + hook = directkeys.hook(count, suppress=True) - self.do(d_a+d_b, d_b) + self.do(d_a + d_b, d_b) self.assertEqual(self.i, 2) directkeys.unhook(hook) - self.do(d_a+d_b, d_a+d_b) + self.do(d_a + d_b, d_a + d_b) self.assertEqual(self.i, 2) directkeys.hook(count, suppress=True) - self.do(d_a+d_b, d_b) + self.do(d_a + d_b, d_b) self.assertEqual(self.i, 4) directkeys.unhook_all() - self.do(d_a+d_b, d_a+d_b) + self.do(d_a + d_b, d_a + d_b) self.assertEqual(self.i, 4) + def test_on_press_nonblocking(self): - directkeys.on_press(lambda e: self.assertEqual(e.name, 'a') and self.assertEqual(e.event_type, KEY_DOWN)) - self.do(d_a+u_a) + directkeys.on_press( + lambda e: self.assertEqual(e.name, "a") and self.assertEqual(e.event_type, KEY_DOWN) + ) + self.do(d_a + u_a) + def test_on_press_blocking(self): directkeys.on_press(lambda e: e.scan_code == 1, suppress=True) - self.do([make_event(KEY_DOWN, 'A', -1)] + d_a, d_a) + self.do([make_event(KEY_DOWN, "A", -1)] + d_a, d_a) + def test_on_release(self): - directkeys.on_release(lambda e: self.assertEqual(e.name, 'a') and self.assertEqual(e.event_type, KEY_UP)) - self.do(d_a+u_a) + directkeys.on_release( + lambda e: self.assertEqual(e.name, "a") and self.assertEqual(e.event_type, KEY_UP) + ) + self.do(d_a + u_a) def test_hook_key_invalid(self): with self.assertRaises(ValueError): - directkeys.hook_key('invalid', lambda e: None) + directkeys.hook_key("invalid", lambda e: None) + def test_hook_key_nonblocking(self): self.i = 0 + def count(event): self.i += 1 - hook = directkeys.hook_key('A', count) + + hook = directkeys.hook_key("A", count) self.do(d_a) self.assertEqual(self.i, 1) - self.do(u_a+d_b) + self.do(u_a + d_b) self.assertEqual(self.i, 2) - self.do([make_event(KEY_DOWN, 'A', -1)]) + self.do([make_event(KEY_DOWN, "A", -1)]) self.assertEqual(self.i, 3) directkeys.unhook_key(hook) self.do(d_a) self.assertEqual(self.i, 3) + def test_hook_key_blocking(self): self.i = 0 + def count(event): self.i += 1 return event.scan_code == 1 - hook = directkeys.hook_key('A', count, suppress=True) + + hook = directkeys.hook_key("A", count, suppress=True) self.do(d_a, d_a) self.assertEqual(self.i, 1) - self.do(u_a+d_b, u_a+d_b) + self.do(u_a + d_b, u_a + d_b) self.assertEqual(self.i, 2) - self.do([make_event(KEY_DOWN, 'A', -1)], []) + self.do([make_event(KEY_DOWN, "A", -1)], []) self.assertEqual(self.i, 3) directkeys.unhook_key(hook) - self.do([make_event(KEY_DOWN, 'A', -1)], [make_event(KEY_DOWN, 'A', -1)]) + self.do([make_event(KEY_DOWN, "A", -1)], [make_event(KEY_DOWN, "A", -1)]) self.assertEqual(self.i, 3) + def test_on_press_key_nonblocking(self): - directkeys.on_press_key('A', lambda e: self.assertEqual(e.name, 'a') and self.assertEqual(e.event_type, KEY_DOWN)) - self.do(d_a+u_a+d_b+u_b) + directkeys.on_press_key( + "A", + lambda e: self.assertEqual(e.name, "a") and self.assertEqual(e.event_type, KEY_DOWN), + ) + self.do(d_a + u_a + d_b + u_b) + def test_on_press_key_blocking(self): - directkeys.on_press_key('A', lambda e: e.scan_code == 1, suppress=True) - self.do([make_event(KEY_DOWN, 'A', -1)] + d_a, d_a) + directkeys.on_press_key("A", lambda e: e.scan_code == 1, suppress=True) + self.do([make_event(KEY_DOWN, "A", -1)] + d_a, d_a) + def test_on_release_key(self): - directkeys.on_release_key('a', lambda e: self.assertEqual(e.name, 'a') and self.assertEqual(e.event_type, KEY_UP)) - self.do(d_a+u_a) + directkeys.on_release_key( + "a", lambda e: self.assertEqual(e.name, "a") and self.assertEqual(e.event_type, KEY_UP) + ) + self.do(d_a + u_a) def test_block_key(self): - blocked = directkeys.block_key('a') - self.do(d_a+d_b, d_b) - self.do([make_event(KEY_DOWN, 'A', -1)], [make_event(KEY_DOWN, 'A', -1)]) + blocked = directkeys.block_key("a") + self.do(d_a + d_b, d_b) + self.do([make_event(KEY_DOWN, "A", -1)], [make_event(KEY_DOWN, "A", -1)]) directkeys.unblock_key(blocked) - self.do(d_a+d_b, d_a+d_b) + self.do(d_a + d_b, d_a + d_b) + def test_block_key_ambiguous(self): - directkeys.block_key('A') - self.do(d_a+d_b, d_b) - self.do([make_event(KEY_DOWN, 'A', -1)], []) + directkeys.block_key("A") + self.do(d_a + d_b, d_b) + self.do([make_event(KEY_DOWN, "A", -1)], []) def test_remap_key_simple(self): - mapped = directkeys.remap_key('a', 'b') - self.do(d_a+d_c+u_a, d_b+d_c+u_b) + mapped = directkeys.remap_key("a", "b") + self.do(d_a + d_c + u_a, d_b + d_c + u_b) directkeys.unremap_key(mapped) - self.do(d_a+d_c+u_a, d_a+d_c+u_a) + self.do(d_a + d_c + u_a, d_a + d_c + u_a) + def test_remap_key_ambiguous(self): - directkeys.remap_key('A', 'b') - self.do(d_a+d_b, d_b+d_b) - self.do([make_event(KEY_DOWN, 'A', -1)], d_b) + directkeys.remap_key("A", "b") + self.do(d_a + d_b, d_b + d_b) + self.do([make_event(KEY_DOWN, "A", -1)], d_b) + def test_remap_key_multiple(self): - mapped = directkeys.remap_key('a', 'shift+b') - self.do(d_a+d_c+u_a, d_shift+d_b+d_c+u_b+u_shift) + mapped = directkeys.remap_key("a", "shift+b") + self.do(d_a + d_c + u_a, d_shift + d_b + d_c + u_b + u_shift) directkeys.unremap_key(mapped) - self.do(d_a+d_c+u_a, d_a+d_c+u_a) + self.do(d_a + d_c + u_a, d_a + d_c + u_a) def test_stash_state(self): - self.do(d_a+d_shift) + self.do(d_a + d_shift) self.assertEqual(sorted(directkeys.stash_state()), [1, 5]) - self.do([], u_a+u_shift) + self.do([], u_a + u_shift) + def test_restore_state(self): self.do(d_b) directkeys.restore_state([1, 5]) - self.do([], u_b+d_a+d_shift) + self.do([], u_b + d_a + d_shift) + def test_restore_modifieres(self): self.do(d_b) directkeys.restore_modifiers([1, 5]) - self.do([], u_b+d_shift) + self.do([], u_b + d_shift) def test_write_simple(self): - directkeys.write('a', exact=False) - self.do([], d_a+u_a) + directkeys.write("a", exact=False) + self.do([], d_a + u_a) + def test_write_multiple_no_delay(self): - directkeys.write('ab', exact=False) - self.do([], d_a+u_a+d_b+u_b) + directkeys.write("ab", exact=False) + self.do([], d_a + u_a + d_b + u_b) + def test_write_modifiers(self): - directkeys.write('Ab', exact=False) - self.do([], d_shift+d_a+u_a+u_shift+d_b+u_b) + directkeys.write("Ab", exact=False) + self.do([], d_shift + d_a + u_a + u_shift + d_b + u_b) + # restore_state_after has been removed after the introduction of `restore_modifiers`. - #def test_write_stash_not_restore(self): + # def test_write_stash_not_restore(self): # self.do(d_shift) # directkeys.write('a', restore_state_after=False, exact=False) # self.do([], u_shift+d_a+u_a) def test_write_stash_restore(self): self.do(d_shift) - directkeys.write('a', exact=False) - self.do([], u_shift+d_a+u_a+d_shift) + directkeys.write("a", exact=False) + self.do([], u_shift + d_a + u_a + d_shift) + def test_write_multiple(self): last_time = time.time() - directkeys.write('ab', delay=0.01, exact=False) - self.do([], d_a+u_a+d_b+u_b) + directkeys.write("ab", delay=0.01, exact=False) + self.do([], d_a + u_a + d_b + u_b) self.assertGreater(time.time() - last_time, 0.015) + def test_write_unicode_explicit(self): - directkeys.write('ab', exact=True) - self.do([], [KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name='a'), KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name='b')]) + directkeys.write("ab", exact=True) + self.do( + [], + [ + KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name="a"), + KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name="b"), + ], + ) + def test_write_unicode_fallback(self): - directkeys.write('áb', exact=False) - self.do([], [KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name='á')]+d_b+u_b) + directkeys.write("áb", exact=False) + self.do([], [KeyboardEvent(event_type=KEY_DOWN, scan_code=999, name="á")] + d_b + u_b) def test_start_stop_recording(self): directkeys.start_recording() - self.do(d_a+u_a) - self.assertEqual(directkeys.stop_recording(), d_a+u_a) + self.do(d_a + u_a) + self.assertEqual(directkeys.stop_recording(), d_a + u_a) + def test_stop_recording_error(self): with self.assertRaises(ValueError): directkeys.stop_recording() def test_record(self): queue = directkeys._queue.Queue() + def process(): - queue.put(directkeys.record('space', suppress=True)) + queue.put(directkeys.record("space", suppress=True)) + from threading import Thread + t = Thread(target=process) t.daemon = True t.start() # 0.01s sleep failed once already. Better solutions? time.sleep(0.01) - self.do(du_a+du_b+du_space, du_a+du_b) - self.assertEqual(queue.get(timeout=0.5), du_a+du_b+du_space) + self.do(du_a + du_b + du_space, du_a + du_b) + self.assertEqual(queue.get(timeout=0.5), du_a + du_b + du_space) def test_play_nodelay(self): - directkeys.play(d_a+u_a, 0) - self.do([], d_a+u_a) + directkeys.play(d_a + u_a, 0) + self.do([], d_a + u_a) + def test_play_stash(self): self.do(d_ctrl) - directkeys.play(d_a+u_a, 0) - self.do([], u_ctrl+d_a+u_a+d_ctrl) + directkeys.play(d_a + u_a, 0) + self.do([], u_ctrl + d_a + u_a + d_ctrl) + def test_play_delay(self): last_time = time.time() - events = [make_event(KEY_DOWN, 'a', 1, 100), make_event(KEY_UP, 'a', 1, 100.01)] + events = [make_event(KEY_DOWN, "a", 1, 100), make_event(KEY_UP, "a", 1, 100.01)] directkeys.play(events, 1) - self.do([], d_a+u_a) + self.do([], d_a + u_a) self.assertGreater(time.time() - last_time, 0.005) def test_get_typed_strings_simple(self): - events = du_a+du_b+du_backspace+d_shift+du_a+u_shift+du_space+du_ctrl+du_a - self.assertEqual(list(directkeys.get_typed_strings(events)), ['aA ', 'a']) + events = du_a + du_b + du_backspace + d_shift + du_a + u_shift + du_space + du_ctrl + du_a + self.assertEqual(list(directkeys.get_typed_strings(events)), ["aA ", "a"]) + def test_get_typed_strings_backspace(self): - events = du_a+du_b+du_backspace - self.assertEqual(list(directkeys.get_typed_strings(events)), ['a']) - events = du_backspace+du_a+du_b - self.assertEqual(list(directkeys.get_typed_strings(events)), ['ab']) + events = du_a + du_b + du_backspace + self.assertEqual(list(directkeys.get_typed_strings(events)), ["a"]) + events = du_backspace + du_a + du_b + self.assertEqual(list(directkeys.get_typed_strings(events)), ["ab"]) + def test_get_typed_strings_shift(self): - events = d_shift+du_a+du_b+u_shift+du_space+du_ctrl+du_a - self.assertEqual(list(directkeys.get_typed_strings(events)), ['AB ', 'a']) + events = d_shift + du_a + du_b + u_shift + du_space + du_ctrl + du_a + self.assertEqual(list(directkeys.get_typed_strings(events)), ["AB ", "a"]) + def test_get_typed_strings_all(self): - events = du_a+du_b+du_backspace+d_shift+du_a+du_capslock+du_b+u_shift+du_space+du_ctrl+du_a - self.assertEqual(list(directkeys.get_typed_strings(events)), ['aAb ', 'A']) + events = ( + du_a + + du_b + + du_backspace + + d_shift + + du_a + + du_capslock + + du_b + + u_shift + + du_space + + du_ctrl + + du_a + ) + self.assertEqual(list(directkeys.get_typed_strings(events)), ["aAb ", "A"]) def test_get_hotkey_name_simple(self): - self.assertEqual(directkeys.get_hotkey_name(['a']), 'a') + self.assertEqual(directkeys.get_hotkey_name(["a"]), "a") + def test_get_hotkey_name_modifiers(self): - self.assertEqual(directkeys.get_hotkey_name(['a', 'shift', 'ctrl']), 'ctrl+shift+a') + self.assertEqual(directkeys.get_hotkey_name(["a", "shift", "ctrl"]), "ctrl+shift+a") + def test_get_hotkey_name_normalize(self): - self.assertEqual(directkeys.get_hotkey_name(['SHIFT', 'left ctrl']), 'ctrl+shift') + self.assertEqual(directkeys.get_hotkey_name(["SHIFT", "left ctrl"]), "ctrl+shift") + def test_get_hotkey_name_plus(self): - self.assertEqual(directkeys.get_hotkey_name(['+']), 'plus') + self.assertEqual(directkeys.get_hotkey_name(["+"]), "plus") + def test_get_hotkey_name_duplicated(self): - self.assertEqual(directkeys.get_hotkey_name(['+', 'plus']), 'plus') + self.assertEqual(directkeys.get_hotkey_name(["+", "plus"]), "plus") + def test_get_hotkey_name_full(self): - self.assertEqual(directkeys.get_hotkey_name(['+', 'left ctrl', 'shift', 'WIN', 'right alt']), 'ctrl+alt+shift+windows+plus') + self.assertEqual( + directkeys.get_hotkey_name(["+", "left ctrl", "shift", "WIN", "right alt"]), + "ctrl+alt+shift+windows+plus", + ) + def test_get_hotkey_name_multiple(self): - self.assertEqual(directkeys.get_hotkey_name(['ctrl', 'b', '!', 'a']), 'ctrl+!+a+b') + self.assertEqual(directkeys.get_hotkey_name(["ctrl", "b", "!", "a"]), "ctrl+!+a+b") + def test_get_hotkey_name_from_pressed(self): - self.do(du_c+d_ctrl+d_a+d_b) - self.assertEqual(directkeys.get_hotkey_name(), 'ctrl+a+b') + self.do(du_c + d_ctrl + d_a + d_b) + self.assertEqual(directkeys.get_hotkey_name(), "ctrl+a+b") def test_read_hotkey(self): queue = directkeys._queue.Queue() + def process(): queue.put(directkeys.read_hotkey()) + from threading import Thread + t = Thread(target=process) t.daemon = True t.start() time.sleep(0.01) - self.do(d_ctrl+d_a+d_b+u_ctrl) - self.assertEqual(queue.get(timeout=0.5), 'ctrl+a+b') + self.do(d_ctrl + d_a + d_b + u_ctrl) + self.assertEqual(queue.get(timeout=0.5), "ctrl+a+b") def test_read_event(self): queue = directkeys._queue.Queue() + def process(): queue.put(directkeys.read_event(suppress=True)) + from threading import Thread + t = Thread(target=process) t.daemon = True t.start() @@ -524,70 +672,89 @@ def process(): def test_read_key(self): queue = directkeys._queue.Queue() + def process(): queue.put(directkeys.read_key(suppress=True)) + from threading import Thread + t = Thread(target=process) t.daemon = True t.start() time.sleep(0.01) self.do(d_a, []) - self.assertEqual(queue.get(timeout=0.5), 'a') + self.assertEqual(queue.get(timeout=0.5), "a") def test_wait_infinite(self): self.triggered = False + def process(): directkeys.wait() self.triggered = True + from threading import Thread + t = Thread(target=process) - t.daemon = True # Yep, we are letting this thread loose. + t.daemon = True # Yep, we are letting this thread loose. t.start() time.sleep(0.01) self.assertFalse(self.triggered) def test_wait_until_success(self): queue = directkeys._queue.Queue() + def process(): queue.put(directkeys.wait(queue.get(timeout=0.5), suppress=True) or True) + from threading import Thread + t = Thread(target=process) t.daemon = True t.start() - queue.put('a') + queue.put("a") time.sleep(0.01) self.do(d_a, []) self.assertTrue(queue.get(timeout=0.5)) + def test_wait_until_fail(self): def process(): - directkeys.wait('a', suppress=True) + directkeys.wait("a", suppress=True) self.fail() + from threading import Thread + t = Thread(target=process) - t.daemon = True # Yep, we are letting this thread loose. + t.daemon = True # Yep, we are letting this thread loose. t.start() time.sleep(0.01) self.do(d_b) def test_add_hotkey_single_step_suppress_allow(self): - directkeys.add_hotkey('a', lambda: trigger() or True, suppress=True) - self.do(d_a, triggered_event+d_a) + directkeys.add_hotkey("a", lambda: trigger() or True, suppress=True) + self.do(d_a, triggered_event + d_a) + def test_add_hotkey_single_step_suppress_args_allow(self): arg = object() - directkeys.add_hotkey('a', lambda a: self.assertIs(a, arg) or trigger() or True, args=(arg,), suppress=True) - self.do(d_a, triggered_event+d_a) + directkeys.add_hotkey( + "a", lambda a: self.assertIs(a, arg) or trigger() or True, args=(arg,), suppress=True + ) + self.do(d_a, triggered_event + d_a) + def test_add_hotkey_single_step_suppress_single(self): - directkeys.add_hotkey('a', trigger, suppress=True) + directkeys.add_hotkey("a", trigger, suppress=True) self.do(d_a, triggered_event) + def test_add_hotkey_single_step_suppress_removed_no_modifier(self): - directkeys.remove_hotkey(directkeys.add_hotkey('a', trigger, suppress=True)) + directkeys.remove_hotkey(directkeys.add_hotkey("a", trigger, suppress=True)) self.do(d_a, d_a) + def test_add_hotkey_single_step_suppress_removed(self): - directkeys.remove_hotkey(directkeys.add_hotkey('ctrl+a', trigger, suppress=True)) - self.do(d_ctrl+d_a, d_ctrl+d_a) - self.assertEqual(directkeys._listener.filtered_modifiers[dummy_keys['left ctrl'][0][0]], 0) + directkeys.remove_hotkey(directkeys.add_hotkey("ctrl+a", trigger, suppress=True)) + self.do(d_ctrl + d_a, d_ctrl + d_a) + self.assertEqual(directkeys._listener.filtered_modifiers[dummy_keys["left ctrl"][0][0]], 0) + def test_remove_hotkey_internal(self): - remove = directkeys.add_hotkey('shift+a', trigger, suppress=True) + remove = directkeys.add_hotkey("shift+a", trigger, suppress=True) self.assertTrue(all(directkeys._listener.blocking_hotkeys.values())) self.assertTrue(all(directkeys._listener.filtered_modifiers.values())) self.assertNotEqual(directkeys._hotkeys, {}) @@ -595,8 +762,9 @@ def test_remove_hotkey_internal(self): self.assertTrue(not any(directkeys._listener.filtered_modifiers.values())) self.assertTrue(not any(directkeys._listener.blocking_hotkeys.values())) self.assertEqual(directkeys._hotkeys, {}) + def test_remove_hotkey_internal_multistep_start(self): - remove = directkeys.add_hotkey('shift+a, b', trigger, suppress=True) + remove = directkeys.add_hotkey("shift+a, b", trigger, suppress=True) self.assertTrue(all(directkeys._listener.blocking_hotkeys.values())) self.assertTrue(all(directkeys._listener.filtered_modifiers.values())) self.assertNotEqual(directkeys._hotkeys, {}) @@ -604,9 +772,10 @@ def test_remove_hotkey_internal_multistep_start(self): self.assertTrue(not any(directkeys._listener.filtered_modifiers.values())) self.assertTrue(not any(directkeys._listener.blocking_hotkeys.values())) self.assertEqual(directkeys._hotkeys, {}) + def test_remove_hotkey_internal_multistep_end(self): - remove = directkeys.add_hotkey('shift+a, b', trigger, suppress=True) - self.do(d_shift+du_a+u_shift) + remove = directkeys.add_hotkey("shift+a, b", trigger, suppress=True) + self.do(d_shift + du_a + u_shift) self.assertTrue(any(directkeys._listener.blocking_hotkeys.values())) self.assertTrue(not any(directkeys._listener.filtered_modifiers.values())) self.assertNotEqual(directkeys._hotkeys, {}) @@ -614,212 +783,298 @@ def test_remove_hotkey_internal_multistep_end(self): self.assertTrue(not any(directkeys._listener.filtered_modifiers.values())) self.assertTrue(not any(directkeys._listener.blocking_hotkeys.values())) self.assertEqual(directkeys._hotkeys, {}) + def test_add_hotkey_single_step_suppress_with_modifiers(self): - directkeys.add_hotkey('ctrl+shift+a', trigger, suppress=True) - self.do(d_ctrl+d_shift+d_a, triggered_event) + directkeys.add_hotkey("ctrl+shift+a", trigger, suppress=True) + self.do(d_ctrl + d_shift + d_a, triggered_event) + def test_add_hotkey_single_step_suppress_with_modifiers_fail_unrelated_modifier(self): - directkeys.add_hotkey('ctrl+shift+a', trigger, suppress=True) - self.do(d_ctrl+d_shift+u_shift+d_a, d_shift+u_shift+d_ctrl+d_a) + directkeys.add_hotkey("ctrl+shift+a", trigger, suppress=True) + self.do(d_ctrl + d_shift + u_shift + d_a, d_shift + u_shift + d_ctrl + d_a) + def test_add_hotkey_single_step_suppress_with_modifiers_fail_unrelated_key(self): - directkeys.add_hotkey('ctrl+shift+a', trigger, suppress=True) - self.do(d_ctrl+d_shift+du_b, d_shift+d_ctrl+du_b) + directkeys.add_hotkey("ctrl+shift+a", trigger, suppress=True) + self.do(d_ctrl + d_shift + du_b, d_shift + d_ctrl + du_b) + def test_add_hotkey_single_step_suppress_with_modifiers_unrelated_key(self): - directkeys.add_hotkey('ctrl+shift+a', trigger, suppress=True) - self.do(d_ctrl+d_shift+du_b+d_a, d_shift+d_ctrl+du_b+triggered_event) + directkeys.add_hotkey("ctrl+shift+a", trigger, suppress=True) + self.do(d_ctrl + d_shift + du_b + d_a, d_shift + d_ctrl + du_b + triggered_event) + def test_add_hotkey_single_step_suppress_with_two_modifiers_release(self): - directkeys.add_hotkey('ctrl+shift+a', trigger, suppress=True) - self.do(d_ctrl+d_shift+du_b+d_a+u_ctrl+u_shift, d_shift+d_ctrl+du_b+triggered_event+u_ctrl+u_shift) + directkeys.add_hotkey("ctrl+shift+a", trigger, suppress=True) + self.do( + d_ctrl + d_shift + du_b + d_a + u_ctrl + u_shift, + d_shift + d_ctrl + du_b + triggered_event + u_ctrl + u_shift, + ) + def test_add_hotkey_single_step_suppress_with_modifiers_out_of_order(self): - directkeys.add_hotkey('ctrl+shift+a', trigger, suppress=True) - self.do(d_shift+d_ctrl+d_a, triggered_event) + directkeys.add_hotkey("ctrl+shift+a", trigger, suppress=True) + self.do(d_shift + d_ctrl + d_a, triggered_event) + def test_add_hotkey_single_step_suppress_with_modifiers_repeated(self): - directkeys.add_hotkey('ctrl+a', trigger, suppress=True) - self.do(d_ctrl+du_a+du_b+du_a, triggered_event+d_ctrl+du_b+triggered_event) + directkeys.add_hotkey("ctrl+a", trigger, suppress=True) + self.do(d_ctrl + du_a + du_b + du_a, triggered_event + d_ctrl + du_b + triggered_event) + def test_add_hotkey_single_step_suppress_with_modifiers_release(self): - directkeys.add_hotkey('ctrl+a', trigger, suppress=True, trigger_on_release=True) - self.do(d_ctrl+du_a+du_b+du_a, triggered_event+d_ctrl+du_b+triggered_event) + directkeys.add_hotkey("ctrl+a", trigger, suppress=True, trigger_on_release=True) + self.do(d_ctrl + du_a + du_b + du_a, triggered_event + d_ctrl + du_b + triggered_event) + def test_add_hotkey_single_step_suppress_with_modifier_superset_release(self): - directkeys.add_hotkey('ctrl+a', trigger, suppress=True, trigger_on_release=True) - self.do(d_ctrl+d_shift+du_a+u_shift+u_ctrl, d_ctrl+d_shift+du_a+u_shift+u_ctrl) + directkeys.add_hotkey("ctrl+a", trigger, suppress=True, trigger_on_release=True) + self.do( + d_ctrl + d_shift + du_a + u_shift + u_ctrl, d_ctrl + d_shift + du_a + u_shift + u_ctrl + ) + def test_add_hotkey_single_step_suppress_with_modifier_superset(self): - directkeys.add_hotkey('ctrl+a', trigger, suppress=True) - self.do(d_ctrl+d_shift+du_a+u_shift+u_ctrl, d_ctrl+d_shift+du_a+u_shift+u_ctrl) + directkeys.add_hotkey("ctrl+a", trigger, suppress=True) + self.do( + d_ctrl + d_shift + du_a + u_shift + u_ctrl, d_ctrl + d_shift + du_a + u_shift + u_ctrl + ) + def test_add_hotkey_single_step_timeout(self): - directkeys.add_hotkey('a', trigger, timeout=1, suppress=True) + directkeys.add_hotkey("a", trigger, timeout=1, suppress=True) self.do(du_a, triggered_event) + def test_add_hotkey_multi_step_first_timeout(self): - directkeys.add_hotkey('a, b', trigger, timeout=0.01, suppress=True) + directkeys.add_hotkey("a, b", trigger, timeout=0.01, suppress=True) time.sleep(0.03) - self.do(du_a+du_b, triggered_event) + self.do(du_a + du_b, triggered_event) + def test_add_hotkey_multi_step_last_timeout(self): - directkeys.add_hotkey('a, b', trigger, timeout=0.01, suppress=True) + directkeys.add_hotkey("a, b", trigger, timeout=0.01, suppress=True) self.do(du_a, []) time.sleep(0.05) - self.do(du_b, du_a+du_b) + self.do(du_b, du_a + du_b) + def test_add_hotkey_multi_step_success_timeout(self): - directkeys.add_hotkey('a, b', trigger, timeout=0.05, suppress=True) + directkeys.add_hotkey("a, b", trigger, timeout=0.05, suppress=True) self.do(du_a, []) time.sleep(0.01) self.do(du_b, triggered_event) + def test_add_hotkey_multi_step_suffix_timeout(self): - directkeys.add_hotkey('a, b, a', trigger, timeout=0.01, suppress=True) - self.do(du_a+du_b, []) + directkeys.add_hotkey("a, b, a", trigger, timeout=0.01, suppress=True) + self.do(du_a + du_b, []) time.sleep(0.05) - self.do(du_a, du_a+du_b) - self.do(du_b+du_a, triggered_event) + self.do(du_a, du_a + du_b) + self.do(du_b + du_a, triggered_event) + def test_add_hotkey_multi_step_allow(self): - directkeys.add_hotkey('a, b', lambda: trigger() or True, suppress=True) - self.do(du_a+du_b, triggered_event+du_a+du_b) + directkeys.add_hotkey("a, b", lambda: trigger() or True, suppress=True) + self.do(du_a + du_b, triggered_event + du_a + du_b) def test_add_hotkey_single_step_nonsuppress(self): queue = directkeys._queue.Queue() - directkeys.add_hotkey('ctrl+shift+a+b', lambda: queue.put(True), suppress=False) - self.do(d_shift+d_ctrl+d_a+d_b) + directkeys.add_hotkey("ctrl+shift+a+b", lambda: queue.put(True), suppress=False) + self.do(d_shift + d_ctrl + d_a + d_b) self.assertTrue(queue.get(timeout=0.5)) + def test_add_hotkey_single_step_nonsuppress_repeated(self): queue = directkeys._queue.Queue() - directkeys.add_hotkey('ctrl+shift+a+b', lambda: queue.put(True), suppress=False) - self.do(d_shift+d_ctrl+d_a+d_b) - self.do(d_shift+d_ctrl+d_a+d_b) + directkeys.add_hotkey("ctrl+shift+a+b", lambda: queue.put(True), suppress=False) + self.do(d_shift + d_ctrl + d_a + d_b) + self.do(d_shift + d_ctrl + d_a + d_b) self.assertTrue(queue.get(timeout=0.5)) self.assertTrue(queue.get(timeout=0.5)) + def test_add_hotkey_single_step_nosuppress_with_modifiers_out_of_order(self): queue = directkeys._queue.Queue() - directkeys.add_hotkey('ctrl+shift+a', lambda: queue.put(True), suppress=False) - self.do(d_shift+d_ctrl+d_a) + directkeys.add_hotkey("ctrl+shift+a", lambda: queue.put(True), suppress=False) + self.do(d_shift + d_ctrl + d_a) self.assertTrue(queue.get(timeout=0.5)) + def test_add_hotkey_single_step_suppress_regression_1(self): - directkeys.add_hotkey('a', trigger, suppress=True) - self.do(d_c+d_a+u_c+u_a, d_c+d_a+u_c+u_a) + directkeys.add_hotkey("a", trigger, suppress=True) + self.do(d_c + d_a + u_c + u_a, d_c + d_a + u_c + u_a) def test_remap_hotkey_single(self): - directkeys.remap_hotkey('a', 'b') - self.do(d_a+u_a, d_b+u_b) + directkeys.remap_hotkey("a", "b") + self.do(d_a + u_a, d_b + u_b) + def test_remap_hotkey_complex_dst(self): - directkeys.remap_hotkey('a', 'ctrl+b, c') - self.do(d_a+u_a, d_ctrl+du_b+u_ctrl+du_c) + directkeys.remap_hotkey("a", "ctrl+b, c") + self.do(d_a + u_a, d_ctrl + du_b + u_ctrl + du_c) + def test_remap_hotkey_modifiers(self): - directkeys.remap_hotkey('ctrl+shift+a', 'b') - self.do(d_ctrl+d_shift+d_a+u_a, du_b) + directkeys.remap_hotkey("ctrl+shift+a", "b") + self.do(d_ctrl + d_shift + d_a + u_a, du_b) + def test_remap_hotkey_modifiers_repeat(self): - directkeys.remap_hotkey('ctrl+shift+a', 'b') - self.do(d_ctrl+d_shift+du_a+du_a, du_b+du_b) + directkeys.remap_hotkey("ctrl+shift+a", "b") + self.do(d_ctrl + d_shift + du_a + du_a, du_b + du_b) + def test_remap_hotkey_modifiers_state(self): - directkeys.remap_hotkey('ctrl+shift+a', 'b') - self.do(d_ctrl+d_shift+du_c+du_a+du_a, d_shift+d_ctrl+du_c+u_shift+u_ctrl+du_b+d_ctrl+d_shift+u_shift+u_ctrl+du_b+d_ctrl+d_shift) + directkeys.remap_hotkey("ctrl+shift+a", "b") + self.do( + d_ctrl + d_shift + du_c + du_a + du_a, + d_shift + + d_ctrl + + du_c + + u_shift + + u_ctrl + + du_b + + d_ctrl + + d_shift + + u_shift + + u_ctrl + + du_b + + d_ctrl + + d_shift, + ) + def test_remap_hotkey_release_incomplete(self): - directkeys.remap_hotkey('a', 'b', trigger_on_release=True) + directkeys.remap_hotkey("a", "b", trigger_on_release=True) self.do(d_a, []) + def test_remap_hotkey_release_complete(self): - directkeys.remap_hotkey('a', 'b', trigger_on_release=True) + directkeys.remap_hotkey("a", "b", trigger_on_release=True) self.do(du_a, du_b) def test_parse_hotkey_combinations_scan_code(self): self.assertEqual(directkeys.parse_hotkey_combinations(30), (((30,),),)) + def test_parse_hotkey_combinations_single(self): - self.assertEqual(directkeys.parse_hotkey_combinations('a'), (((1,),),)) + self.assertEqual(directkeys.parse_hotkey_combinations("a"), (((1,),),)) + def test_parse_hotkey_combinations_single_modifier(self): - self.assertEqual(directkeys.parse_hotkey_combinations('shift+a'), (((1, 5), (1, 6)),)) + self.assertEqual(directkeys.parse_hotkey_combinations("shift+a"), (((1, 5), (1, 6)),)) + def test_parse_hotkey_combinations_single_modifiers(self): - self.assertEqual(directkeys.parse_hotkey_combinations('shift+ctrl+a'), (((1, 5, 7), (1, 6, 7)),)) + self.assertEqual( + directkeys.parse_hotkey_combinations("shift+ctrl+a"), (((1, 5, 7), (1, 6, 7)),) + ) + def test_parse_hotkey_combinations_multi(self): - self.assertEqual(directkeys.parse_hotkey_combinations('a, b'), (((1,),), ((2,),))) + self.assertEqual(directkeys.parse_hotkey_combinations("a, b"), (((1,),), ((2,),))) + def test_parse_hotkey_combinations_multi_modifier(self): - self.assertEqual(directkeys.parse_hotkey_combinations('shift+a, b'), (((1, 5), (1, 6)), ((2,),))) + self.assertEqual( + directkeys.parse_hotkey_combinations("shift+a, b"), (((1, 5), (1, 6)), ((2,),)) + ) + def test_parse_hotkey_combinations_list_list(self): - self.assertEqual(directkeys.parse_hotkey_combinations(directkeys.parse_hotkey_combinations('a, b')), directkeys.parse_hotkey_combinations('a, b')) + self.assertEqual( + directkeys.parse_hotkey_combinations(directkeys.parse_hotkey_combinations("a, b")), + directkeys.parse_hotkey_combinations("a, b"), + ) + def test_parse_hotkey_combinations_fail_empty(self): with self.assertRaises(ValueError): - directkeys.parse_hotkey_combinations('') - + directkeys.parse_hotkey_combinations("") def test_add_hotkey_multistep_suppress_incomplete_blocking_state(self): - directkeys.add_hotkey('a, b', trigger, suppress=True) + directkeys.add_hotkey("a, b", trigger, suppress=True) self.do(du_a, []) self.assertEqual(directkeys._listener.blocking_hotkeys[(1,)], []) self.assertEqual(len(directkeys._listener.blocking_hotkeys[(2,)]), 1) + def test_add_hotkey_multistep_suppress_incomplete(self): - directkeys.add_hotkey('a, b', trigger, suppress=True) - self.do(du_a+du_b, triggered_event) + directkeys.add_hotkey("a, b", trigger, suppress=True) + self.do(du_a + du_b, triggered_event) + def test_add_hotkey_multistep_suppress_modifier(self): - directkeys.add_hotkey('shift+a, b', trigger, suppress=True) - self.do(d_shift+du_a+u_shift+du_b, triggered_event) + directkeys.add_hotkey("shift+a, b", trigger, suppress=True) + self.do(d_shift + du_a + u_shift + du_b, triggered_event) + def test_add_hotkey_multistep_suppress_fail(self): - directkeys.add_hotkey('a, b', trigger, suppress=True) - self.do(du_a+du_c, du_a+du_c) + directkeys.add_hotkey("a, b", trigger, suppress=True) + self.do(du_a + du_c, du_a + du_c) + def test_add_hotkey_multistep_suppress_three_steps(self): - directkeys.add_hotkey('a, b, c', trigger, suppress=True) - self.do(du_a+du_b+du_c, triggered_event) + directkeys.add_hotkey("a, b, c", trigger, suppress=True) + self.do(du_a + du_b + du_c, triggered_event) + def test_add_hotkey_multistep_suppress_repeated_prefix(self): - directkeys.add_hotkey('a, a, c', trigger, suppress=True, trigger_on_release=True) - self.do(du_a+du_a+du_c, triggered_event) + directkeys.add_hotkey("a, a, c", trigger, suppress=True, trigger_on_release=True) + self.do(du_a + du_a + du_c, triggered_event) + def test_add_hotkey_multistep_suppress_repeated_key(self): - directkeys.add_hotkey('a, b', trigger, suppress=True) - self.do(du_a+du_a+du_b, du_a+triggered_event) + directkeys.add_hotkey("a, b", trigger, suppress=True) + self.do(du_a + du_a + du_b, du_a + triggered_event) self.assertEqual(directkeys._listener.blocking_hotkeys[(2,)], []) self.assertEqual(len(directkeys._listener.blocking_hotkeys[(1,)]), 1) + def test_add_hotkey_multi_step_suppress_regression_1(self): - directkeys.add_hotkey('a, b', trigger, suppress=True) - self.do(d_c+d_a+u_c+u_a+du_c, d_c+d_a+u_c+u_a+du_c) + directkeys.add_hotkey("a, b", trigger, suppress=True) + self.do(d_c + d_a + u_c + u_a + du_c, d_c + d_a + u_c + u_a + du_c) + def test_add_hotkey_multi_step_suppress_replays(self): - directkeys.add_hotkey('a, b, c', trigger, suppress=True) - self.do(du_a+du_b+du_a+du_b+du_space, du_a+du_b+du_a+du_b+du_space) + directkeys.add_hotkey("a, b, c", trigger, suppress=True) + self.do(du_a + du_b + du_a + du_b + du_space, du_a + du_b + du_a + du_b + du_space) def test_add_word_listener_success(self): queue = directkeys._queue.Queue() + def free(): queue.put(1) - directkeys.add_word_listener('abc', free) - self.do(du_a+du_b+du_c+du_space) + + directkeys.add_word_listener("abc", free) + self.do(du_a + du_b + du_c + du_space) self.assertTrue(queue.get(timeout=0.5)) + def test_add_word_listener_no_trigger_fail(self): queue = directkeys._queue.Queue() + def free(): queue.put(1) - directkeys.add_word_listener('abc', free) - self.do(du_a+du_b+du_c) + + directkeys.add_word_listener("abc", free) + self.do(du_a + du_b + du_c) with self.assertRaises(directkeys._queue.Empty): queue.get(timeout=0.01) + def test_add_word_listener_timeout_fail(self): queue = directkeys._queue.Queue() + def free(): queue.put(1) - directkeys.add_word_listener('abc', free, timeout=1) - self.do(du_a+du_b+du_c+[make_event(KEY_DOWN, name='space', time=2)]) + + directkeys.add_word_listener("abc", free, timeout=1) + self.do(du_a + du_b + du_c + [make_event(KEY_DOWN, name="space", time=2)]) with self.assertRaises(directkeys._queue.Empty): queue.get(timeout=0.01) + def test_duplicated_word_listener(self): - directkeys.add_word_listener('abc', trigger) - directkeys.add_word_listener('abc', trigger) + directkeys.add_word_listener("abc", trigger) + directkeys.add_word_listener("abc", trigger) + def test_add_word_listener_remove(self): queue = directkeys._queue.Queue() + def free(): queue.put(1) - directkeys.add_word_listener('abc', free) - directkeys.remove_word_listener('abc') - self.do(du_a+du_b+du_c+du_space) + + directkeys.add_word_listener("abc", free) + directkeys.remove_word_listener("abc") + self.do(du_a + du_b + du_c + du_space) with self.assertRaises(directkeys._queue.Empty): queue.get(timeout=0.01) + def test_add_word_listener_suffix_success(self): queue = directkeys._queue.Queue() + def free(): queue.put(1) - directkeys.add_word_listener('abc', free, match_suffix=True) - self.do(du_a+du_a+du_b+du_c+du_space) + + directkeys.add_word_listener("abc", free, match_suffix=True) + self.do(du_a + du_a + du_b + du_c + du_space) self.assertTrue(queue.get(timeout=0.5)) + def test_add_word_listener_suffix_fail(self): queue = directkeys._queue.Queue() + def free(): queue.put(1) - directkeys.add_word_listener('abc', free) - self.do(du_a+du_a+du_b+du_c) + + directkeys.add_word_listener("abc", free) + self.do(du_a + du_a + du_b + du_c) with self.assertRaises(directkeys._queue.Empty): queue.get(timeout=0.01) - #def test_add_abbreviation(self): + # def test_add_abbreviation(self): # directkeys.add_abbreviation('abc', 'aaa') # self.do(du_a+du_b+du_c+du_space, []) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_mouse.py b/tests/test_mouse.py index 3a6df782..27b0b516 100644 --- a/tests/test_mouse.py +++ b/tests/test_mouse.py @@ -38,19 +38,20 @@ def get_position(self): return self.position def move_to(self, x, y): - self.append(('move', (x, y))) + self.append(("move", (x, y))) self.position = (x, y) def wheel(self, delta): - self.append(('wheel', delta)) + self.append(("wheel", delta)) def move_relative(self, x, y): self.position = (self.position[0] + x, self.position[1] + y) + class TestMouse(unittest.TestCase): @staticmethod def setUpClass(): - mouse._os_mouse= FakeOsMouse() + mouse._os_mouse = FakeOsMouse() mouse._listener.start_if_necessary() assert mouse._os_mouse.listening @@ -159,8 +160,10 @@ def test_move(self): def triggers(self, fn, events, **kwargs): self.triggered = False + def callback(): self.triggered = True + handler = fn(callback, **kwargs) for event_type, arg in events: @@ -170,7 +173,7 @@ def callback(): self.release(arg) elif event_type == DOUBLE: self.double_click(arg) - elif event_type == 'WHEEL': + elif event_type == "WHEEL": self.wheel() mouse._listener.remove_handler(handler) @@ -181,7 +184,7 @@ def test_on_button(self): self.assertTrue(self.triggers(mouse.on_button, [(DOWN, RIGHT)])) self.assertTrue(self.triggers(mouse.on_button, [(DOWN, X)])) - self.assertFalse(self.triggers(mouse.on_button, [('WHEEL', '')])) + self.assertFalse(self.triggers(mouse.on_button, [("WHEEL", "")])) self.assertFalse(self.triggers(mouse.on_button, [(DOWN, X)], buttons=MIDDLE)) self.assertTrue(self.triggers(mouse.on_button, [(DOWN, MIDDLE)], buttons=MIDDLE)) @@ -189,9 +192,15 @@ def test_on_button(self): self.assertFalse(self.triggers(mouse.on_button, [(DOWN, MIDDLE)], buttons=MIDDLE, types=UP)) self.assertTrue(self.triggers(mouse.on_button, [(UP, MIDDLE)], buttons=MIDDLE, types=UP)) - self.assertTrue(self.triggers(mouse.on_button, [(UP, MIDDLE)], buttons=[MIDDLE, LEFT], types=[UP, DOWN])) - self.assertTrue(self.triggers(mouse.on_button, [(DOWN, LEFT)], buttons=[MIDDLE, LEFT], types=[UP, DOWN])) - self.assertFalse(self.triggers(mouse.on_button, [(UP, X)], buttons=[MIDDLE, LEFT], types=[UP, DOWN])) + self.assertTrue( + self.triggers(mouse.on_button, [(UP, MIDDLE)], buttons=[MIDDLE, LEFT], types=[UP, DOWN]) + ) + self.assertTrue( + self.triggers(mouse.on_button, [(DOWN, LEFT)], buttons=[MIDDLE, LEFT], types=[UP, DOWN]) + ) + self.assertFalse( + self.triggers(mouse.on_button, [(UP, X)], buttons=[MIDDLE, LEFT], types=[UP, DOWN]) + ) def test_ons(self): self.assertTrue(self.triggers(mouse.on_click, [(UP, LEFT)])) @@ -209,22 +218,28 @@ def test_ons(self): def test_wait(self): # If this fails it blocks. Unfortunately, but I see no other way of testing. from threading import Lock, Thread + lock = Lock() lock.acquire() + def t(): mouse.wait() lock.release() + Thread(target=t).start() self.press() lock.acquire() def test_record_play(self): from threading import Lock, Thread + lock = Lock() lock.acquire() + def t(): self.recorded = mouse.record(RIGHT) lock.release() + Thread(target=t).start() self.click() self.wheel(5) @@ -244,8 +259,8 @@ def t(): self.assertEqual(len(events), 5) self.assertEqual(events[0], (DOWN, LEFT)) self.assertEqual(events[1], (UP, LEFT)) - self.assertEqual(events[2], ('wheel', 5)) - self.assertEqual(events[3], ('move', (100, 50))) + self.assertEqual(events[2], ("wheel", 5)) + self.assertEqual(events[3], ("move", (100, 50))) self.assertEqual(events[4], (DOWN, RIGHT)) mouse.play(self.recorded) @@ -253,22 +268,22 @@ def t(): self.assertEqual(len(events), 5) self.assertEqual(events[0], (DOWN, LEFT)) self.assertEqual(events[1], (UP, LEFT)) - self.assertEqual(events[2], ('wheel', 5)) - self.assertEqual(events[3], ('move', (100, 50))) + self.assertEqual(events[2], ("wheel", 5)) + self.assertEqual(events[3], ("move", (100, 50))) self.assertEqual(events[4], (DOWN, RIGHT)) mouse.play(self.recorded, include_clicks=False) events = self.flush_events() self.assertEqual(len(events), 2) - self.assertEqual(events[0], ('wheel', 5)) - self.assertEqual(events[1], ('move', (100, 50))) + self.assertEqual(events[0], ("wheel", 5)) + self.assertEqual(events[1], ("move", (100, 50))) mouse.play(self.recorded, include_moves=False) events = self.flush_events() self.assertEqual(len(events), 4) self.assertEqual(events[0], (DOWN, LEFT)) self.assertEqual(events[1], (UP, LEFT)) - self.assertEqual(events[2], ('wheel', 5)) + self.assertEqual(events[2], ("wheel", 5)) self.assertEqual(events[3], (DOWN, RIGHT)) mouse.play(self.recorded, include_wheel=False) @@ -276,8 +291,9 @@ def t(): self.assertEqual(len(events), 4) self.assertEqual(events[0], (DOWN, LEFT)) self.assertEqual(events[1], (UP, LEFT)) - self.assertEqual(events[2], ('move', (100, 50))) + self.assertEqual(events[2], ("move", (100, 50))) self.assertEqual(events[3], (DOWN, RIGHT)) -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() From fb626d69a02261625f64ef77c7ba0373c0935444 Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Fri, 31 Jul 2026 22:57:15 -0400 Subject: [PATCH 5/7] build: add pre-commit, editorconfig and Dependabot Rounds out the tooling so the checks that CI runs can also run locally before a commit, and so contributors get consistent whitespace regardless of editor. - .pre-commit-config.yaml runs Ruff (check + format) plus the standard whitespace and syntax hooks. README.md is excluded from the whitespace hooks because it is generated from the module docstring. - The `mixed-line-ending` hook is deliberately left out: `.gitattributes` already normalises endings at the git level, and forcing LF in the working tree would fight the CRLF checkout on Windows in a loop. - .editorconfig mirrors the same rules for editors. - Dependabot keeps the Actions and pip pins current, monthly. - A `lint` job runs Ruff in CI. .gitattributes drops the Visual Studio and msysgit boilerplate inherited from the original template, keeps the `text=auto` normalisation, and marks README.md as generated so it stops inflating pull request diffs. Verified by running `pre-commit run --all-files` twice: the second run is clean, so the hooks are idempotent. Co-Authored-By: Claude Opus 5 --- .editorconfig | 19 +++++++++++++++ .gitattributes | 31 +++++++++--------------- .github/dependabot.yml | 15 ++++++++++++ .github/workflows/tests.yml | 17 +++++++++++++ .gitignore | 2 +- .pre-commit-config.yaml | 20 +++++++++++++++ MANIFEST.in | 2 +- Makefile | 2 +- tests/manual/altgr_and_stuck_keys.py | 16 ++++++------ tests/manual/simulate_stuck_key_crash.py | 2 +- 10 files changed, 95 insertions(+), 31 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/dependabot.yml create mode 100644 .pre-commit-config.yaml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..d85bea98 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml,toml,json}] +indent_size = 2 + +[*.md] +# Trailing whitespace is significant in Markdown, and README.md is generated. +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes index 412eeda7..86b93167 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,22 +1,15 @@ -# Auto detect text files and perform LF normalization +# Normalise line endings in the repository, checking out the platform native +# ones. This is what keeps the tree consistent between Windows and Linux. * text=auto -# Custom for Visual Studio -*.cs diff=csharp -*.sln merge=union -*.csproj merge=union -*.vbproj merge=union -*.fsproj merge=union -*.dbproj merge=union +*.py text diff=python +*.md text +*.toml text +*.yml text +*.yaml text +*.txt text +Makefile text -# Standard to msysgit -*.doc diff=astextplain -*.DOC diff=astextplain -*.docx diff=astextplain -*.DOCX diff=astextplain -*.dot diff=astextplain -*.DOT diff=astextplain -*.pdf diff=astextplain -*.PDF diff=astextplain -*.rtf diff=astextplain -*.RTF diff=astextplain +# Generated from the directkeys module docstring by the `build` target, so it +# should not count towards language statistics or clutter pull request diffs. +README.md linguist-generated=true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..7d32f0bd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + commit-message: + prefix: "ci" + + - package-ecosystem: pip + directory: / + schedule: + interval: monthly + commit-message: + prefix: "build" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3ea74220..dc447477 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,6 +7,23 @@ on: workflow_dispatch: jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - uses: astral-sh/ruff-action@v3 + + - name: Lint + run: ruff check --output-format=github . + + - name: Check formatting + run: ruff format --check . + test: runs-on: ${{ matrix.os }} strategy: diff --git a/.gitignore b/.gitignore index 7c3bb75b..6c466132 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,4 @@ Desktop.ini *.bak *.tmp *.swp -*~.nib \ No newline at end of file +*~.nib diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..b6feb58e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.1 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + # README.md is generated from the __init__ docstring; leave it alone. + exclude: ^README\.md$ + - id: end-of-file-fixer + exclude: ^README\.md$ + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + - id: check-added-large-files diff --git a/MANIFEST.in b/MANIFEST.in index 6691e350..b6057978 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ include CHANGES.md include README.md include LICENSE.txt -include pyproject.toml \ No newline at end of file +include pyproject.toml diff --git a/Makefile b/Makefile index f50f66cf..7ac62016 100644 --- a/Makefile +++ b/Makefile @@ -18,4 +18,4 @@ release: python make_release.py clean: - rm -rfv dist build coverage_html_report directkeys.egg-info \ No newline at end of file + rm -rfv dist build coverage_html_report directkeys.egg-info diff --git a/tests/manual/altgr_and_stuck_keys.py b/tests/manual/altgr_and_stuck_keys.py index 876bb1a4..e390e554 100644 --- a/tests/manual/altgr_and_stuck_keys.py +++ b/tests/manual/altgr_and_stuck_keys.py @@ -33,11 +33,11 @@ def test_raw_event_capture(): print("-" * 50) directkeys.set_alt_gr_abstraction(False) - + print("1. Pressione e solte 'AltGr'...") alt_gr_event = read_next_keydown() print(f" -> Recebido: Tecla='{alt_gr_event.name}', Scan={alt_gr_event.scan_code:#04x}, Flags={alt_gr_event.flags}") - + print("\n2. Pressione e solte a tecla '/'...") slash_event = read_next_keydown() print(f" -> Recebido: Tecla='{slash_event.name}', Scan={slash_event.scan_code:#04x}, Flags={slash_event.flags}") @@ -52,7 +52,7 @@ def test_raw_event_capture(): assert esc_event.name == 'esc' print("✅ Teste 1: SUCESSO!") - + directkeys.set_alt_gr_abstraction(True) def test_stuck_key_fix(): @@ -66,20 +66,20 @@ def test_stuck_key_fix(): process.wait() time.sleep(1) print("--> Script travado. A tecla 'Ctrl' deve estar 'presa' no sistema.") - + # Verificação inicial (opcional, mas bom para confirmar o problema) stuck_before = directkeys.get_stuck_keys() if 'ctrl' in stuck_before or 'left ctrl' in stuck_before: print(f" [CONFIRMADO] Teclas presas detectadas: {stuck_before}") else: print(f" [AVISO] Não foi possível detectar a tecla 'Ctrl' como presa. O teste continua.") - + input("--> Pressione Enter para executar a correção...") print("\n--> Passo 2b: Executando a função de correção 'force_reset_keyboard()'...") directkeys.force_reset_keyboard() print("--> Função executada.") - + # Verificação de ressalva print("--> Verificando se ainda há teclas presas...") stuck_after = directkeys.get_stuck_keys() @@ -88,7 +88,7 @@ def test_stuck_key_fix(): assert False, f"A função force_reset_keyboard não limpou as seguintes teclas: {stuck_after}" else: print(" [SUCESSO] Nenhuma tecla modificadora presa foi detectada.") - + print("\n✅ Teste 2: SUCESSO!") if __name__ == "__main__": @@ -105,7 +105,7 @@ def test_stuck_key_fix(): directkeys.force_reset_keyboard() directkeys.reset_internal_state() directkeys.unhook_all() - + # Verificação final de ressalva final_stuck_keys = directkeys.get_stuck_keys() if final_stuck_keys: diff --git a/tests/manual/simulate_stuck_key_crash.py b/tests/manual/simulate_stuck_key_crash.py index 4920a9d3..4b98674b 100644 --- a/tests/manual/simulate_stuck_key_crash.py +++ b/tests/manual/simulate_stuck_key_crash.py @@ -12,4 +12,4 @@ print("!!! SCRIPT ENCERRADO ABRUPTAMENTE !!!") # O sys.exit() simula um crash. O bloco 'finally' não será executado. -sys.exit(0) \ No newline at end of file +sys.exit(0) From b2fd2d1cdd8f88ed8f1bd394da71927378b51a6f Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Sat, 1 Aug 2026 18:33:09 -0400 Subject: [PATCH 6/7] docs: document the 3.9 requirement and the development setup Records the modernisation in CHANGES.md, including the Python 3.9 requirement, the src/ layout and the tooling, alongside the fixes made earlier on the fork. The module docstring gains a Development section covering the editable install, pre-commit and the two Ruff checks CI runs, and states the supported Python version where the removed "Python 2 and 3" line used to be. README.md is regenerated from it. Also widens the margin in test_record. The test races a background thread that has to register both the recording hook and the suppressing hotkey before the events are fed in, and upstream already noted the 0.01s sleep had lost that race once; it lost it again here. There is no public barrier to synchronise on, so this is a larger margin rather than a real fix, and the comment says so. Ten consecutive full runs pass afterwards. Co-Authored-By: Claude Opus 5 --- CHANGES.md | 26 ++++++++++++++++++++++++-- README.md | 24 +++++++++++++++++++++++- src/directkeys/__init__.py | 24 +++++++++++++++++++++++- tests/test_keyboard.py | 7 +++++-- 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 55987ad4..63ce220e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -15,10 +15,32 @@ New features: - `KeyboardEvent.flags` exposes the low-level hook flags, and is included in `to_json()`. On Windows it currently carries the `LLKHF_EXTENDED` bit. -Packaging: +Also exposes `__version__` as the canonical version attribute. The upstream +`version` name is kept as an alias, so nothing breaks. + +Packaging and project layout: - Renamed the package and the distribution to `directkeys`. -- Metadata moved to `pyproject.toml`; Python 3.8+ is required. +- **Python 3.9+ is now required.** 3.8 reached end of life in October 2024. + All the Python 2 compatibility shims are gone with it. +- Metadata moved to `pyproject.toml`; `setup.py` is now only a shim. +- The package moved to a `src/` layout, so the test run exercises the + installed distribution rather than the source tree. +- Ruff (lint and format), pre-commit and an EditorConfig are configured, and + CI runs the suite plus a lint and a build check on Windows and Linux. + +Fixes carried over from the initial fork work: + +- `force_reset_keyboard()`, `get_stuck_keys()` and `reset_internal_state()` + were never loaded from the Windows backend and always resolved to no-op + stubs. +- `get_modifiers()` built a 32772-element tuple on every keystroke whenever + shift was held, because `GetKeyState` reports the pressed bit as `0x8000`. +- `set_alt_gr_abstraction()` never rebuilt the name tables. +- Key events reported `is_keypad` as the extended-key flag, which is close to + the inverse of the intended value, and dropped the `scan_code or -vk` + fallback for keys with no scan code. +- Importing the package on macOS no longer fails when pyobjc is missing. # 0.13.5 diff --git a/README.md b/README.md index 2923c87c..6da6afa8 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ Take full control of your keyboard with this small Python library. Hook global e - **Listen** and **send** keyboard events. - Works with **Windows** and **Linux** (requires sudo), with experimental **OS X** support (thanks @glitchassassin!). - **Pure Python**, no C modules to be compiled. -- **Zero dependencies**. Trivial to install and deploy, just copy the files. +- **Zero dependencies** on Windows and Linux. Trivial to install and deploy. +- **Python 3.9+**. - Complex hotkey support (e.g. `ctrl+shift+m, ctrl+space`) with controllable timeout. - Includes **high level API** (e.g. [record](#directkeys.record) and [play](#directkeys.play), [add_abbreviation](#directkeys.add_abbreviation)). - Maps keys as they actually are in your layout, with **full internationalization support** (e.g. `Ctrl+ç`). @@ -51,6 +52,27 @@ import keyboard import directkeys as keyboard ``` +## Development + +```bash +git clone https://github.com/WigoWigo10/keyboard +cd keyboard +pip install -e . +pip install pytest pre-commit ruff +pre-commit install +``` + +Run the checks the way CI does: + +```bash +pytest # the automated suite +ruff check . # lint +ruff format --check . +``` + +`tests/manual/` holds interactive scripts that drive a real keyboard, so they +are excluded from the automated run and have to be started by hand. + ## Example diff --git a/src/directkeys/__init__.py b/src/directkeys/__init__.py index 4ccf81c0..bf894dcb 100644 --- a/src/directkeys/__init__.py +++ b/src/directkeys/__init__.py @@ -12,7 +12,8 @@ - **Listen** and **send** keyboard events. - Works with **Windows** and **Linux** (requires sudo), with experimental **OS X** support (thanks @glitchassassin!). - **Pure Python**, no C modules to be compiled. -- **Zero dependencies**. Trivial to install and deploy, just copy the files. +- **Zero dependencies** on Windows and Linux. Trivial to install and deploy. +- **Python 3.9+**. - Complex hotkey support (e.g. `ctrl+shift+m, ctrl+space`) with controllable timeout. - Includes **high level API** (e.g. [record](#directkeys.record) and [play](#directkeys.play), [add_abbreviation](#directkeys.add_abbreviation)). - Maps keys as they actually are in your layout, with **full internationalization support** (e.g. `Ctrl+ç`). @@ -52,6 +53,27 @@ import directkeys as keyboard ``` +## Development + +```bash +git clone https://github.com/WigoWigo10/keyboard +cd keyboard +pip install -e . +pip install pytest pre-commit ruff +pre-commit install +``` + +Run the checks the way CI does: + +```bash +pytest # the automated suite +ruff check . # lint +ruff format --check . +``` + +`tests/manual/` holds interactive scripts that drive a real keyboard, so they +are excluded from the automated run and have to be started by hand. + ## Example diff --git a/tests/test_keyboard.py b/tests/test_keyboard.py index ccc4e9aa..13211dc8 100644 --- a/tests/test_keyboard.py +++ b/tests/test_keyboard.py @@ -561,8 +561,11 @@ def process(): t = Thread(target=process) t.daemon = True t.start() - # 0.01s sleep failed once already. Better solutions? - time.sleep(0.01) + # Racy by construction: the thread has to register both the recording + # hook and the suppressing hotkey for `space` before the events below + # are fed in. There is no public barrier to wait on, so this is a + # margin rather than a fix -- 0.01s was observed to lose the race. + time.sleep(0.2) self.do(du_a + du_b + du_space, du_a + du_b) self.assertEqual(queue.get(timeout=0.5), du_a + du_b + du_space) From 79f2dc9961df3400248b06c1c661d1c958d04c8c Mon Sep 17 00:00:00 2001 From: WigoWigo10 Date: Sat, 1 Aug 2026 18:34:49 -0400 Subject: [PATCH 7/7] chore: ignore the Ruff and mypy caches Co-Authored-By: Claude Opus 5 --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 6c466132..a1bc3bc9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,10 @@ eggs/ htmlcov/ coverage_html_report/ +# Tooling caches +.ruff_cache/ +.mypy_cache/ + # Editor and OS specific files .vscode/ .idea/