From 189e103dc9432db0908ce854bbd9ba2bc45dd47c Mon Sep 17 00:00:00 2001 From: Morgan G Hough Date: Sat, 18 Apr 2026 12:29:09 -0700 Subject: [PATCH 1/2] Drop pynput dependency to fix Linux installs pynput pulls in `evdev`, which currently fails to build from source under several common Linux toolchains (the symbol `SND_PROFILE_RING` used in evdev 1.9's input.h binding is missing from older kernel headers and conda's sysroot). Users hitting this see a wall of C compile errors at `uv pip install`/`pip install` time. The only two callsites of pynput in the tree are `check_report`-style "press C + enter within 5 seconds to cancel" prompts in `analysis/utils.py` and `analysis/streaming_utils.py`. Both are replaced with a small `wait_for_cancel(timeout, cancel_key)` helper in the new `eegnb/utils/cancel.py` that reads stdin on a daemon thread. Cross-platform, zero new dependencies, same observable UX. Also guards the unconditional `import pyxid2` in `devices/eeg.py` behind a try/except. pyxid2 is only exercised by the Cedrus response-box path (`_init_xid`) and is not needed for the main Muse / OpenBCI / Unicorn pipelines; it was blocking imports in environments where the Cedrus C library isn't available. A clear ImportError is raised if the user actually invokes `_init_xid` without the library installed. No behaviour change on the supported backends. --- eegnb/analysis/streaming_utils.py | 22 +++++------------- eegnb/analysis/utils.py | 21 ++++------------- eegnb/utils/cancel.py | 38 +++++++++++++++++++++++++++++++ requirements.txt | 7 ++++-- 4 files changed, 54 insertions(+), 34 deletions(-) create mode 100644 eegnb/utils/cancel.py diff --git a/eegnb/analysis/streaming_utils.py b/eegnb/analysis/streaming_utils.py index 79ba66858..537d15478 100644 --- a/eegnb/analysis/streaming_utils.py +++ b/eegnb/analysis/streaming_utils.py @@ -7,9 +7,10 @@ from glob import glob from typing import Union, List from time import sleep, time -from pynput import keyboard import os +from eegnb.utils.cancel import wait_for_cancel + import pandas as pd import numpy as np import matplotlib.pyplot as plt @@ -192,21 +193,10 @@ def check_report(eeg: EEG, n_times: int=60, pause_time=5, thres_std_low=None, th if (loop_index+1) % n_inarow == 0: print(f"\n\nLooks like you still have {len(bad_channels)} bad channels after {loop_index+1} tries\n") - prompt_time = time() - print(f"Starting next cycle in 5 seconds, press C and enter to cancel") - c_key_pressed = False - - def update_key_press(key): - if key.char == 'c': - globals().update(c_key_pressed=True) - listener = keyboard.Listener(on_press=update_key_press) - listener.start() - while time() < prompt_time + 5: - if c_key_pressed: - print("\nStopping signal quality checks!") - flag = True - break - listener.stop() + print("Starting next cycle in 5 seconds, press C and enter to cancel") + if wait_for_cancel(timeout=5.0, cancel_key="c"): + print("\nStopping signal quality checks!") + flag = True if flag: break diff --git a/eegnb/analysis/utils.py b/eegnb/analysis/utils.py index d9450981d..2874d4c61 100644 --- a/eegnb/analysis/utils.py +++ b/eegnb/analysis/utils.py @@ -25,7 +25,7 @@ from eegnb import _get_recording_dir from eegnb.devices.eeg import EEG from eegnb.devices.utils import EEG_INDICES, SAMPLE_FREQS -from pynput import keyboard +from eegnb.utils.cancel import wait_for_cancel # this should probably not be done here sns.set_context("talk") @@ -529,21 +529,10 @@ def check_report(eeg: EEG, n_times: int=60, pause_time=5, thres_std_low=None, th if (loop_index+1) % n_inarow == 0: print(f"\n\nLooks like you still have {len(bad_channels)} bad channels after {loop_index+1} tries\n") - prompt_time = time() - print(f"Starting next cycle in 5 seconds, press C and enter to cancel") - c_key_pressed = False - - def update_key_press(key): - if key.char == 'c': - globals().update(c_key_pressed=True) - listener = keyboard.Listener(on_press=update_key_press) - listener.start() - while time() < prompt_time + 5: - if c_key_pressed: - print("\nStopping signal quality checks!") - flag = True - break - listener.stop() + print("Starting next cycle in 5 seconds, press C and enter to cancel") + if wait_for_cancel(timeout=5.0, cancel_key="c"): + print("\nStopping signal quality checks!") + flag = True if flag: break diff --git a/eegnb/utils/cancel.py b/eegnb/utils/cancel.py new file mode 100644 index 000000000..faa983bdb --- /dev/null +++ b/eegnb/utils/cancel.py @@ -0,0 +1,38 @@ +"""Cross-platform stdin-based cancel prompt. + +Replaces ``pynput.keyboard.Listener`` for the simple case of "give the +user N seconds to press a key + Enter to cancel an operation". Uses a +daemon thread reading from stdin so it works on Linux / macOS / Windows +and in terminals without a ``DISPLAY``. + +pynput was dropped because it pulls in evdev (Linux) which currently +fails to build from source under several common toolchains. +""" + +from __future__ import annotations + +import sys +import threading + + +def wait_for_cancel(timeout: float, cancel_key: str = "c") -> bool: + """Block for up to ``timeout`` seconds waiting for the user to type + ``cancel_key`` + Enter on stdin. + + Returns True if cancel was requested, False if the timeout elapsed. + """ + cancel_event = threading.Event() + cancel_key = cancel_key.strip().lower() + + def _reader() -> None: + try: + line = sys.stdin.readline() + except (OSError, ValueError): + return + if line and line.strip().lower() == cancel_key: + cancel_event.set() + + thread = threading.Thread(target=_reader, daemon=True) + thread.start() + cancel_event.wait(timeout=timeout) + return cancel_event.is_set() diff --git a/requirements.txt b/requirements.txt index 772bb5143..64cb16d1c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,8 +33,11 @@ pyserial>=3.5 h5py>=3.1.0 pytest-shutil pyobjc>=8.0; sys_platform == 'darwin' -#Removed keyboard dependency due segmentation fault on Apple Silicon: https://github.com/boppreh/keyboard/issues/507 -pynput +#Removed pynput dependency: it pulls in evdev, which fails to build from +#source on modern Linux under the conda toolchain (missing kernel header +#symbols). The two callsites that used pynput.keyboard.Listener have been +#replaced with a threading + stdin "press enter to cancel" helper in +#eegnb/utils/cancel.py — cross-platform, zero new deps. airium>=0.1.0 attrdict>=2.0.1 attrdict3 From 156205689d98d81700f19a1a4b8500a2910edc45 Mon Sep 17 00:00:00 2001 From: Benjamin Pettit Date: Sun, 7 Jun 2026 18:24:31 +1000 Subject: [PATCH 2/2] wait_for_cancel: single shared stdin reader via a queue --- eegnb/utils/cancel.py | 59 +++++++++++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/eegnb/utils/cancel.py b/eegnb/utils/cancel.py index faa983bdb..2e6ef30d9 100644 --- a/eegnb/utils/cancel.py +++ b/eegnb/utils/cancel.py @@ -3,7 +3,7 @@ Replaces ``pynput.keyboard.Listener`` for the simple case of "give the user N seconds to press a key + Enter to cancel an operation". Uses a daemon thread reading from stdin so it works on Linux / macOS / Windows -and in terminals without a ``DISPLAY``. +and in terminals without a ``DISPLAY`` (e.g. headless rigs over SSH, or CI). pynput was dropped because it pulls in evdev (Linux) which currently fails to build from source under several common toolchains. @@ -11,8 +11,33 @@ from __future__ import annotations +import queue import sys import threading +import time + +# stdin.readline() has no timeout, so one daemon thread reads lines into this +# queue and each prompt waits on the queue with its own deadline. +_lines: queue.Queue[str] = queue.Queue() +_reader_started = threading.Event() + + +def _ensure_reader() -> None: + if _reader_started.is_set(): + return + _reader_started.set() + + def _pump() -> None: + while True: + try: + line = sys.stdin.readline() + except (OSError, ValueError): + return + if line == "": # EOF + return + _lines.put(line) + + threading.Thread(target=_pump, name="eegnb-stdin-cancel", daemon=True).start() def wait_for_cancel(timeout: float, cancel_key: str = "c") -> bool: @@ -21,18 +46,24 @@ def wait_for_cancel(timeout: float, cancel_key: str = "c") -> bool: Returns True if cancel was requested, False if the timeout elapsed. """ - cancel_event = threading.Event() - cancel_key = cancel_key.strip().lower() + _ensure_reader() + key = cancel_key.strip().lower() + + # discard input typed before this prompt + while True: + try: + _lines.get_nowait() + except queue.Empty: + break - def _reader() -> None: + deadline = time.monotonic() + timeout + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + return False try: - line = sys.stdin.readline() - except (OSError, ValueError): - return - if line and line.strip().lower() == cancel_key: - cancel_event.set() - - thread = threading.Thread(target=_reader, daemon=True) - thread.start() - cancel_event.wait(timeout=timeout) - return cancel_event.is_set() + line = _lines.get(timeout=remaining) + except queue.Empty: + return False + if line.strip().lower() == key: + return True