Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: CI

on:
push:
branches: [master]
pull_request:
branches: [master]

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: '3.13'
- run: pip install ruff
- name: ruff format --check
run: |
ruff format --check \
--config 'format.quote-style="preserve"' \
--config 'line-length=80' \
lib/pynput tests

test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python: ['3.10', '3.11', '3.12', '3.13']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
- name: Install pynput
run: pip install -e .
- name: Install Linux backend deps
if: runner.os == 'Linux'
run: pip install evdev python-xlib
- name: Run headless test subset
env:
# Tests in this subset don't synthesize OS input and don't wait
# for human input via self.confirm() — safe to run on a CI runner.
# The rest of the suite needs a real interactive session.
PYNPUT_TESTS: >-
tests.keyboard_hotkey_tests.KeyboardHotKeyTest.test_parse_valid
tests.keyboard_hotkey_tests.KeyboardHotKeyTest.test_parse_invalid
tests.keyboard_hotkey_tests.KeyboardHotKeyTest.test_activate_single
tests.keyboard_hotkey_tests.KeyboardHotKeyTest.test_activate_combo
tests.mouse_controller_tests.MouseControllerTest.test_buttons
tests.keyboard_controller_tests.KeyboardControllerTest.test_keys
tests.keyboard_controller_tests.KeyboardControllerTest.test_press_invalid
tests.keyboard_controller_tests.KeyboardControllerTest.test_release_invalid
shell: bash
run: |
if [ "$RUNNER_OS" = "Linux" ]; then
xvfb-run -a python -m unittest $PYNPUT_TESTS
else
python -m unittest $PYNPUT_TESTS
fi
2 changes: 1 addition & 1 deletion lib/pynput/_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

__author__ = u'Moses Palmér'
__author__ = 'Moses Palmér'
__version__ = (1, 8, 2)
63 changes: 37 additions & 26 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,11 @@ def notify(message, delay=None, columns=50):
if lines:
lines.append('')
for word in line.split():
if not lines or not lines[-1] \
or len(lines[-1]) + 1 + len(word) > max_length:
if (
not lines
or not lines[-1]
or len(lines[-1]) + 1 + len(word) > max_length
):
lines.append(word)
else:
lines[-1] += ' ' + word
Expand All @@ -79,7 +82,6 @@ def notify(message, delay=None, columns=50):
time.sleep(delay)



#: A decorator to make a test run only on macOS
darwin = functools.partial(_backend, 'darwin')

Expand Down Expand Up @@ -119,7 +121,8 @@ def tearDownClass(self):
remaining = [
listener
for listener in self.listeners
if not (listener.join(0.5) or listener.is_alive)]
if not (listener.join(0.5) or listener.is_alive)
]
for listener in remaining:
listener.join()

Expand Down Expand Up @@ -152,6 +155,7 @@ def assert_event(self, failure_message, **kwargs):

:param kwargs: Arguments to pass to the listener constructor.
"""

def wrapper(name, callback):
def inner(*a):
if callback(*a):
Expand All @@ -160,9 +164,12 @@ def inner(*a):

return inner if callback else None

with self.listener(**{
with self.listener(
**{
name: wrapper(name, callback)
for name, callback in kwargs.items()}) as listener:
for name, callback in kwargs.items()
}
) as listener:
time.sleep(0.1)
listener.success = False
yield
Expand All @@ -172,9 +179,7 @@ def inner(*a):
if listener.success:
break

self.assertTrue(
listener.success,
failure_message)
self.assertTrue(listener.success, failure_message)

def assert_stop(self, failure_message, **callbacks):
"""Asserts that a listener stop within :attr:`STOP_MAX_WAIT` seconds.
Expand All @@ -195,9 +200,7 @@ def assert_stop(self, failure_message, **callbacks):
success = True
break

self.assertTrue(
success,
failure_message)
self.assertTrue(success, failure_message)

def assert_cumulative(self, failure_message, **callbacks):
"""Asserts that the callback returns true for at least two thirds of
Expand All @@ -211,9 +214,7 @@ def assert_cumulative(self, failure_message, **callbacks):
occurred.
"""
# The lists of accumulated events
events = {
name: []
for name in callbacks}
events = {name: [] for name in callbacks}

def wrapper(name, callback):
def inner(*a):
Expand All @@ -222,19 +223,26 @@ def inner(*a):

total_length = len(cache)
if total_length > self.CHANGE_MIN_EVENTS:
change_length = len([
None
for i, b in enumerate(cache[1:])
if callback(cache[i], b)])
change_length = len(
[
None
for i, b in enumerate(cache[1:])
if callback(cache[i], b)
]
)

if change_length > (2 * total_length) / 3:
return False

return inner if callback else None

self.assert_stop(failure_message, **{
name: wrapper(name, callback)
for name, callback in callbacks.items()})
self.assert_stop(
failure_message,
**{
name: wrapper(name, callback)
for name, callback in callbacks.items()
},
)

def confirm(self, statement, *fmt):
"""Asks the user to confirm a statement.
Expand All @@ -251,10 +259,13 @@ def confirm(self, statement, *fmt):
response = input(message)
if response.lower() in valid_responses:
self.assertIn(
response.lower(), accept_responses,
'User declined statement "%s"' % message)
response.lower(),
accept_responses,
'User declined statement "%s"' % message,
)
return
else:
print(
'Please respond %s' % ', '.join(
'"%s"' % r for r in valid_responses))
'Please respond %s'
% ', '.join('"%s"' % r for r in valid_responses)
)
Loading