Skip to content

Commit 0201e7d

Browse files
committed
Dramatically simplify import approach in keyboard.py
This also has the side effect of making test_keyboard_module_fallback_import much nicer Towards #22
1 parent 05c5830 commit 0201e7d

File tree

2 files changed

+47
-94
lines changed

2 files changed

+47
-94
lines changed

kvm_serial/backend/keyboard.py

Lines changed: 25 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
import threading
2+
from importlib import import_module
23
from serial import Serial
34
from enum import Enum
45
from .inputhandler import InputHandler
56

67
try:
7-
from kvm_serial.backend.implementations.baseop import BaseOp
8+
import_module("kvm_serial.backend.implementations")
89
except ModuleNotFoundError:
9-
# Allow running as a script directly
10+
# Allow running as a script directly: add parent to path so
11+
# _load_implementation's fallback "backend.implementations.*" resolves
1012
import os, sys
1113

1214
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
13-
from implementations.baseop import BaseOp
15+
16+
17+
def _load_implementation(module_name, class_name):
18+
"""Load a handler class, trying package import first then script-mode fallback."""
19+
try:
20+
mod = import_module(f"kvm_serial.backend.implementations.{module_name}")
21+
except ModuleNotFoundError:
22+
mod = import_module(f"backend.implementations.{module_name}")
23+
return getattr(mod, class_name)
1424

1525

1626
class Mode(Enum):
@@ -21,6 +31,14 @@ class Mode(Enum):
2131
CURSES = 4
2232

2333

34+
_MODE_IMPLEMENTATIONS = {
35+
Mode.USB: ("pyusbop", "PyUSBOp"),
36+
Mode.PYNPUT: ("pynputop", "PynputOp"),
37+
Mode.TTY: ("ttyop", "TtyOp"),
38+
Mode.CURSES: ("cursesop", "CursesOp"),
39+
}
40+
41+
2442
class KeyboardListener(InputHandler):
2543
def __init__(
2644
self,
@@ -56,43 +74,14 @@ def stop(self):
5674
self.thread.join()
5775

5876
def run_keyboard(self):
59-
# Select operation mode
60-
keyboard_handler: BaseOp
61-
6277
if self.mode is Mode.NONE:
6378
return # noop
64-
elif self.mode is Mode.USB:
65-
try:
66-
from kvm_serial.backend.implementations.pyusbop import PyUSBOp
67-
except ModuleNotFoundError:
68-
from backend.implementations.pyusbop import PyUSBOp
69-
70-
keyboard_handler = PyUSBOp(self.serial_port, layout=self.layout)
71-
elif self.mode is Mode.PYNPUT:
72-
try:
73-
from kvm_serial.backend.implementations.pynputop import PynputOp
74-
except ModuleNotFoundError:
75-
from backend.implementations.pynputop import PynputOp
76-
77-
keyboard_handler = PynputOp(self.serial_port, layout=self.layout)
78-
elif self.mode is Mode.TTY:
79-
try:
80-
from kvm_serial.backend.implementations.ttyop import TtyOp
81-
except ModuleNotFoundError:
82-
from backend.implementations.ttyop import TtyOp
83-
84-
keyboard_handler = TtyOp(self.serial_port, layout=self.layout)
85-
elif self.mode is Mode.CURSES:
86-
try:
87-
from kvm_serial.backend.implementations.cursesop import CursesOp
88-
except ModuleNotFoundError:
89-
from backend.implementations.cursesop import CursesOp
90-
91-
keyboard_handler = CursesOp(self.serial_port, layout=self.layout)
92-
else:
79+
if self.mode not in _MODE_IMPLEMENTATIONS:
9380
raise ValueError(f"Unknown keyboard mode: {self.mode!r}")
9481

95-
keyboard_handler.run()
82+
module_name, class_name = _MODE_IMPLEMENTATIONS[self.mode]
83+
Impl = _load_implementation(module_name, class_name)
84+
Impl(self.serial_port, layout=self.layout).run()
9685

9786

9887
def keyboard_main():

tests/backend/test_keyboard.py

Lines changed: 22 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -66,68 +66,32 @@ def test_keyboard_main_invokes_listener(self, monkeypatch, sys_modules_patch):
6666

6767
def test_keyboard_module_fallback_import(self, monkeypatch, sys_modules_patch):
6868
"""
69-
Test that keyboard.py falls back to importing implementations.baseop.BaseOp
70-
if kvm_serial.backend.implementations.baseop import fails with ModuleNotFoundError.
71-
72-
This is a horrible test that by necessity has to mess around with both
73-
__import__ and sys.modules. It makes an effort to restore these afterwards.
69+
Test that _load_implementation falls back to backend.implementations.*
70+
when kvm_serial.backend.implementations.* import fails.
7471
7572
Mocks:
76-
- builtins.__import__: Raises ModuleNotFoundError for kvm_serial.backend.implementations.baseop
77-
- sys.modules: Injects a mock implementations.baseop.BaseOp
78-
- KeyboardListener: Mocked to avoid running threads
79-
- logging.basicConfig: Avoids side effects
73+
- keyboard.import_module: Raises ModuleNotFoundError for kvm_serial.*,
74+
returns a mock module for the backend.* fallback path
8075
Asserts:
81-
- The fallback import path is used (implementations.baseop.BaseOp)
82-
- KeyboardListener can still be constructed and used
76+
- The fallback import path is used and returns the expected class
8377
"""
84-
import importlib
85-
86-
# Save original sys.modules entries to restore after test
87-
orig_kvm_serial = sys.modules.get("kvm_serial")
88-
orig_baseop = sys.modules.get("kvm_serial.backend.implementations.baseop")
89-
90-
try:
91-
# Remove relevant modules from sys.modules
92-
sys.modules.pop("kvm_serial.backend.implementations.baseop", None)
93-
sys.modules.pop("kvm_serial", None)
94-
95-
# Patch __import__ to raise ModuleNotFoundError for the primary import
96-
orig_import = __import__
97-
98-
def import_side_effect(name, *args, **kwargs):
99-
if name == "kvm_serial.backend.implementations.baseop":
100-
raise ModuleNotFoundError
101-
return orig_import(name, *args, **kwargs)
102-
103-
monkeypatch.setattr("builtins.__import__", import_side_effect)
104-
105-
# Inject a mock implementations.baseop.BaseOp
106-
mock_baseop = MagicMock()
107-
sys.modules["implementations.baseop"] = MagicMock(BaseOp=mock_baseop)
108-
109-
# Patch KeyboardListener and logging.basicConfig
110-
from kvm_serial.backend import keyboard as kb_mod
111-
112-
# Ensure module is in sys.modules before reload
113-
sys.modules["kvm_serial.backend.keyboard"] = kb_mod
114-
115-
with (
116-
patch.object(kb_mod, "KeyboardListener", MagicMock()),
117-
patch("logging.basicConfig", lambda *a, **k: None),
118-
):
119-
# Reload the module to trigger the import logic
120-
importlib.reload(kb_mod)
121-
# Now, BaseOp should be the mock from implementations.baseop
122-
assert hasattr(kb_mod, "BaseOp")
123-
assert kb_mod.BaseOp is mock_baseop
124-
finally:
125-
# Restore sys.modules to its original state
126-
sys.modules.pop("implementations.baseop", None)
127-
if orig_kvm_serial is not None:
128-
sys.modules["kvm_serial"] = orig_kvm_serial
129-
if orig_baseop is not None:
130-
sys.modules["kvm_serial.backend.implementations.baseop"] = orig_baseop
78+
from importlib import import_module as real_import_module
79+
from kvm_serial.backend import keyboard as kb_mod
80+
81+
mock_module = MagicMock()
82+
mock_handler = MagicMock()
83+
mock_module.TestHandler = mock_handler
84+
85+
def import_side_effect(name, *args, **kwargs):
86+
if name.startswith("kvm_serial.backend.implementations."):
87+
raise ModuleNotFoundError(name)
88+
if name == "backend.implementations.testmod":
89+
return mock_module
90+
return real_import_module(name, *args, **kwargs)
91+
92+
with patch.object(kb_mod, "import_module", side_effect=import_side_effect):
93+
result = kb_mod._load_implementation("testmod", "TestHandler")
94+
assert result is mock_handler
13195

13296

13397
# Mock Serial

0 commit comments

Comments
 (0)