diff --git a/lib/multimer/_hostloop.py b/lib/multimer/_hostloop.py index dec03ba1..8c5e377e 100644 --- a/lib/multimer/_hostloop.py +++ b/lib/multimer/_hostloop.py @@ -239,6 +239,14 @@ def _crashed(): """True when the script body died with an uncaught exception.""" if _state["crashed"]: return True + if _impl() != "cpython": + # MicroPython's sys.exc_info() (only some ports have it: windows does, + # unix doesn't) can still report an exception that was raised and + # handled -- appdev's own getattr(board_config, "joystick_driver", None) + # leaves one -- so it can't tell a crash from a clean end, and trusting + # it skipped the keep-alive loop at random. Without it every MicroPython + # port behaves as unix always has. + return False try: return sys.exc_info()[0] is not None except Exception: @@ -343,14 +351,30 @@ def _run_loop(): break +def _note(msg): + """One line on stderr: the exit hook has no caller to raise to.""" + try: + print("multimer: " + msg, file=sys.stderr) + except Exception: + pass + + def _exit_hook(): # Never let an exception escape: MicroPython turns an uncaught exception # in sys.atexit into "FATAL: uncaught NLR", CPython prints a traceback. + # But say why the program didn't stay up, or a skipped loop looks exactly + # like a clean exit. try: - if not _state["claimed"] and not _crashed(): + if _state["claimed"]: + pass # run_until() drove the loop itself + elif _crashed(): + _note("not keeping the program alive: the script ended with an uncaught exception") + else: _run_loop() - except BaseException: + except (KeyboardInterrupt, SystemExit): pass + except BaseException as e: + _note("the keep-alive loop stopped: " + repr(e)) _stop() try: from . import _dispatch diff --git a/tests/test_hostloop.py b/tests/test_hostloop.py index 3d6e7785..b2efaa6c 100644 --- a/tests/test_hostloop.py +++ b/tests/test_hostloop.py @@ -3,6 +3,8 @@ # SPDX-License-Identifier: MIT """multimer._hostloop: who owns the main thread after the script body ends.""" +import contextlib +import io import unittest import _env # noqa: F401 @@ -77,8 +79,40 @@ def test_crashed_script_does_not_enter_the_loop(self): multimer.every(5, lambda t: count.append(1)) self._force(_hostloop.EXIT_HOOK) _hostloop.mark_crashed() - _hostloop._exit_hook() + err = io.StringIO() + with contextlib.redirect_stderr(err): + _hostloop._exit_hook() self.assertEqual(0, len(count)) + self.assertIn("uncaught exception", err.getvalue(), "a skipped loop must say why") + + def test_micropython_ignores_a_stale_exc_info(self): + """MicroPython's sys.exc_info() can outlive a handled exception + (windows port), so it never counts as a crash there.""" + self._patch(_hostloop, "_impl", lambda: "micropython") + try: + raise AttributeError("handled") + except AttributeError: + self.assertFalse(_hostloop._crashed()) + + def test_cpython_reads_exc_info(self): + try: + raise AttributeError("in flight") + except AttributeError: + self.assertTrue(_hostloop._crashed()) + + def test_a_failing_loop_says_so(self): + multimer.keepalive() + multimer.every(5, lambda t: None) + self._force(_hostloop.EXIT_HOOK) + + def boom(): + raise RuntimeError("boom") + + self._patch(_hostloop, "_run_loop", boom) + err = io.StringIO() + with contextlib.redirect_stderr(err): + _hostloop._exit_hook() + self.assertIn("RuntimeError", err.getvalue()) def test_no_keepalive_means_the_hook_returns_at_once(self): """A bare timer script behaves like a daemon thread.""" diff --git a/tests/test_uwin32.py b/tests/test_uwin32.py index d6f020d9..732a676e 100644 --- a/tests/test_uwin32.py +++ b/tests/test_uwin32.py @@ -181,3 +181,33 @@ def test_enumeration_agrees_with_itself(self): self.assertIsInstance(w.midiOutGetDevName(i), str) for i in range(w.midiInGetNumDevs()): self.assertIsInstance(w.midiInGetDevName(i), str) + + +@unittest.skipUnless(sys.platform == "win32", "uwin32 is Windows only") +class Uwin32KeyMappingTests(unittest.TestCase): + """WinDisplay's keys arrive as the codes SDLDisplay gives for the same keys.""" + + def test_numpad_is_the_keypad(self): + import keys + import uwin32 as w + + digits = [keys.K_KP_0, keys.K_KP_1, keys.K_KP_2, keys.K_KP_3, keys.K_KP_4, + keys.K_KP_5, keys.K_KP_6, keys.K_KP_7, keys.K_KP_8, keys.K_KP_9] + for n, want in enumerate(digits): + self.assertEqual(want, w.virtual_key_to_sdl(0x60 + n), "VK_NUMPAD%d" % n) + for vk, want in ((0x6A, keys.K_KP_MULTIPLY), (0x6B, keys.K_KP_PLUS), (0x6D, keys.K_KP_MINUS), + (0x6E, keys.K_KP_PERIOD), (0x6F, keys.K_KP_DIVIDE)): + self.assertEqual(want, w.virtual_key_to_sdl(vk), hex(vk)) + + def test_main_row_punctuation_is_its_character(self): + import uwin32 as w + + for vk, ch in ((0xBA, ";"), (0xBB, "="), (0xBC, ","), (0xBD, "-"), (0xBE, "."), (0xBF, "/"), + (0xC0, "`"), (0xDB, "["), (0xDC, "\\"), (0xDD, "]"), (0xDE, "'")): + self.assertEqual(ord(ch), w.virtual_key_to_sdl(vk), hex(vk)) + + def test_letters_and_digits_are_unchanged(self): + import uwin32 as w + + self.assertEqual(ord("a"), w.virtual_key_to_sdl(0x41)) + self.assertEqual(ord("7"), w.virtual_key_to_sdl(0x37)) diff --git a/utils/uwin32.py b/utils/uwin32.py index 45c5fe70..85f5881a 100644 --- a/utils/uwin32.py +++ b/utils/uwin32.py @@ -1961,6 +1961,35 @@ def modifier_mask(): VK_PRIOR: 1073741899, VK_NEXT: 1073741902, VK_INSERT: 1073741897, + # Numeric keypad (NumLock on) -> SDL keypad keycodes, as SDLDisplay delivers + # them; without these VK_NUMPAD0..9 fell through as 'a'..'i'. + 0x60: 1073741922, # VK_NUMPAD0 -> K_KP_0 + 0x61: 1073741913, # VK_NUMPAD1 -> K_KP_1 + 0x62: 1073741914, + 0x63: 1073741915, + 0x64: 1073741916, + 0x65: 1073741917, + 0x66: 1073741918, + 0x67: 1073741919, + 0x68: 1073741920, + 0x69: 1073741921, # VK_NUMPAD9 -> K_KP_9 + 0x6A: 1073741909, # VK_MULTIPLY -> K_KP_MULTIPLY + 0x6B: 1073741911, # VK_ADD -> K_KP_PLUS + 0x6D: 1073741910, # VK_SUBTRACT -> K_KP_MINUS + 0x6E: 1073741923, # VK_DECIMAL -> K_KP_PERIOD + 0x6F: 1073741908, # VK_DIVIDE -> K_KP_DIVIDE + # Main-row punctuation (US layout), which fell through as raw VK_OEM_* codes. + 0xBA: 59, # VK_OEM_1 ; + 0xBB: 61, # VK_OEM_PLUS = + 0xBC: 44, # VK_OEM_COMMA , + 0xBD: 45, # VK_OEM_MINUS - + 0xBE: 46, # VK_OEM_PERIOD . + 0xBF: 47, # VK_OEM_2 / + 0xC0: 96, # VK_OEM_3 ` + 0xDB: 91, # VK_OEM_4 [ + 0xDC: 92, # VK_OEM_5 \ + 0xDD: 93, # VK_OEM_6 ] + 0xDE: 39, # VK_OEM_7 ' }