enthought / enthought/enable

enable.qt.constants.KEY_MAP is incomplete or crashes at import depending on the Qt binding (PyQt5 / PyQt6)

Open
#1,073 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
C
Stars
97
Forks
45
PR merge metrics
No merged PRs in 30d

Description

Summary

`enable/qt/constants.py` builds `KEY_MAP` using `QtCore.Qt.Key_*` (unscoped) enum access. This breaks in two different ways depending on the binding:

- Under PyQt6, unscoped access to `Qt.Key_*` was removed entirely (Riverbank requires the scoped form `Qt.Key.Key_*`). The explicit `KEY_MAP` dict literal itself raises `AttributeError` at import time, before the wildcard-completion loop even runs.
- Under PyQt5, `QtCore.Qt.Key` (the scoped enum class) does exist, but `dir(QtCore.Qt.Key)` is incomplete — some members present in `dir(QtCore.Qt)` (flat) are missing from it. `Key_Super_L` is one confirmed example.

If the wildcard loop is switched from `dir(QtCore.Qt)` to `dir(QtCore.Qt.Key)` to fix PyQt6 (which is the natural fix), it silently reintroduces the PyQt5 problem: keys missing from `dir(QtCore.Qt.Key)` never make it into KEY_MAP, so `enable/qt/base_window.py::_create_key_event` falls through to:
```py
key = chr(key_code).lower()
```

For keys like Super_L (`Qt.Key_Super_L = 0x01000134`, i.e. 16,777,524), this exceeds `chr()`'s valid range (< 0x110000) and raises an unhandled `ValueError`, which propagates out of the Qt event loop and aborts the process:
```
Traceback (most recent call last):
File ".../enable/qt/base_window.py", line 252, in keyPressEvent
self.handler.keyPressEvent(event)
File ".../enable/qt/base_window.py", line 105, in keyPressEvent
handled = self._enable_window._handle_key_event(
"key_pressed", event)
File ".../enable/abstract_window.py", line 300, in _handle_key_event
key_event = self._create_key_event(event_type, event)
File ".../enable/qt/base_window.py", line 365, in _create_key_event
key = chr(key_code).lower()
~~~^^^^^^^^^^
ValueError: chr() arg not in range(0x110000)
Abandon (core dumped)
```
In practice this is easy to trigger under GNOME on Wayland by pressing Super_L (bound to the overview/workspace-switch shortcut) while an Enable Qt window has focus — the key event reaches the focused client before/alongside the compositor action.

Reproduction

examples/demo/enable/basic_draw.py

Confirmed with QT_API=pyqt5, QT_API=pyqt6, QT_API=pyside6 — behavior differs per binding as described above (crash reproduces only where the relevant `Key_*` member is missing from whichever `dir()` is being walked).

Minimal check of the actual problem, independent of any UI:
```py
from pyface.qt import QtCore
from enable.qt.constants import KEY_MAP
print(KEY_MAP.get(QtCore.Qt.Key.Key_Super_L))
```
Prints None under PyQt5 with a `dir(QtCore.Qt.Key)`-only wildcard loop; prints 'Super_L' under PySide6/PyQt6.

Root cause

Neither `dir(QtCore.Qt)` nor `dir(QtCore.Qt.Key)` alone is a reliable, complete source of `Key_*` members across all four bindings (PyQt5, PyQt6, PySide2, PySide6). The two need to be merged.

Suggested fix

1. Use scoped access (`QtCore.Qt.Key.Key_X`) for the explicit `KEY_MAP` dict literal — required for PyQt6 compatibility.

2. For the wildcard-completion loop, walk both `dir(QtCore.Qt)` and `dir(QtCore.Qt.Key)` (when the latter exists), merging by name so a member missing from one is still picked up from the other:
```py
_KeyEnum = getattr(QtCore.Qt, "Key", None)
_seen_names = set()

def _iter_key_enum_members():
for enum_name in dir(QtCore.Qt):
if enum_name.startswith("Key_") and enum_name not in _seen_names:
_seen_names.add(enum_name)
yield enum_name, getattr(QtCore.Qt, enum_name)
if _KeyEnum is not None:
for enum_name in dir(_KeyEnum):
if enum_name.startswith("Key_") and enum_name not in _seen_names:
_seen_names.add(enum_name)
yield enum_name, getattr(_KeyEnum, enum_name)

for enum_name, enum in _iter_key_enum_members():
if enum <= 255 or enum in KEY_MAP:
continue
key_name = enum_name[len("Key_"):]
KEY_MAP[enum] = key_name
```
Tested and confirmed working (no crash, `Super_L` correctly resolved) under QT_API=pyqt5, pyqt6, and pyside6.

3. Independently of the above, `_create_key_event` in `base_window.py` should probably still guard the `chr()` fallback defensively, since any future Qt key constant outside the valid `chr()` range would hit the same unhandled `ValueError` regardless of `KEY_MAP` completeness:
```py
if key_code <= 0x10FFFF:
key = chr(key_code).lower()
else:
key = None
```
Happy to open a PR with both changes if this diagnosis looks right — let me know.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.