From 48aae1eb2ffaad6b3788bd2850573c8c7212d10d Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:31:59 -0500 Subject: [PATCH] Windows: examples stay up on micropython.exe, and the numpad types multimer: on MicroPython, _crashed() no longer reads sys.exc_info(). The windows port has it (unix doesn't), and it can still report an exception that was raised and handled -- appdev's getattr(board_config, "joystick_driver", None) leaves one -- so the exit hook took a clean script end for a crash and skipped the keep-alive loop: bouncing_balls, paint and palettes_demo quit at random, silently, with code 0. Every MicroPython port now behaves as unix always has. The exit hook also says, in one line on stderr, when it skips the loop for a crash or when the loop dies on an exception, where before both looked exactly like a clean exit. uwin32: virtual_key_to_sdl() maps the numeric keypad to SDL's K_KP_* and the main-row punctuation (US layout) to its character, as SDLDisplay gives them. VK_NUMPAD9 used to arrive as 'i', so calc_graphics, which takes its digits from the keypad, couldn't be typed into on WinDisplay at all. --- lib/multimer/_hostloop.py | 28 ++++++++++++++++++++++++++-- tests/test_hostloop.py | 36 +++++++++++++++++++++++++++++++++++- tests/test_uwin32.py | 30 ++++++++++++++++++++++++++++++ utils/uwin32.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 3 deletions(-) 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 ' }