From df5e1272f1e9f84012af175f27028f9843b6fc07 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 20:24:11 +0200 Subject: [PATCH] Make _FakeTclError subclass the real tkinter.TclError Found while auditing the `_NO_WIDGETS` exemption added two commits ago: the comment claimed `_gatelock_widgetgroups` binds real `tk` only for its `contextlib.suppress(tk.TclError)` dead-copy guards. That part is true -- it constructs no widgets -- but the consequence was wrong. `_FakeTclError` subclassed `Exception`, not `tkinter.TclError`, so a fake-tk gate test raising it sailed straight through those suppressions. The "a monitor vanished mid-update" paths were therefore only ever exercised against the real error; with the fake they would have raised through the gate. Reproduced directly before fixing: a `_FAKE_TK.TclError` escaped `WidgetGroup.config` both with and without the module patched, so adding the module to the patch set would not have fixed it. `tkinter.TclError` subclasses `Exception`, so inheriting keeps every existing `except _FakeTclError` working and makes both spellings catchable. `test_fake_tclerror_is_catchable_as_the_real_one` pins it and asserts the live copy still gets configured; reverting the base class fails it. Verified: 707 Python tests / 100% branch coverage, pre-commit clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC --- app/pubspec.yaml | 2 +- diet_guard/tests/_tk_fakes.py | 15 ++++++++-- diet_guard/tests/test_gate_tk_modules.py | 36 +++++++++++++++++++++--- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/app/pubspec.yaml b/app/pubspec.yaml index e6f5fce..bb5a4b0 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.21+1 +version: 1.0.22+1 environment: sdk: ^3.12.2 diff --git a/diet_guard/tests/_tk_fakes.py b/diet_guard/tests/_tk_fakes.py index 41e252e..d1f96b0 100644 --- a/diet_guard/tests/_tk_fakes.py +++ b/diet_guard/tests/_tk_fakes.py @@ -20,6 +20,7 @@ rather than merely asserting that a widget was constructed. from __future__ import annotations +import tkinter as tk from types import SimpleNamespace from diet_guard.tests._tk_fakes_canvas import FakeCanvas, FakeScrollbar @@ -52,8 +53,18 @@ __all__ = [ ] -class _FakeTclError(Exception): - """Stand-in for ``tkinter.TclError`` (a real, catchable exception).""" +class _FakeTclError(tk.TclError): + """Stand-in for ``tkinter.TclError`` (a real, catchable exception). + + Subclasses the *real* ``tkinter.TclError`` rather than ``Exception``. + Not every module that catches a dead-widget error is in the fake-tk patch + set -- ``_gatelock_widgetgroups`` deliberately is not, since it constructs + no widgets -- so its ``contextlib.suppress(tk.TclError)`` refers to the + genuine class. With a bare ``Exception`` base the fake escaped that + suppression, meaning the "a monitor vanished mid-update" paths were only + ever exercised against the real error and would have raised through the + gate in a fake-tk test. Inheriting keeps both spellings catchable. + """ class FakeVar: diff --git a/diet_guard/tests/test_gate_tk_modules.py b/diet_guard/tests/test_gate_tk_modules.py index 53d7c08..386fe5c 100644 --- a/diet_guard/tests/test_gate_tk_modules.py +++ b/diet_guard/tests/test_gate_tk_modules.py @@ -18,10 +18,16 @@ from __future__ import annotations import ast import pathlib import tkinter as tk +from unittest.mock import MagicMock -from diet_guard import _gatelock_calendar, _gatelock_calendar_ui, _gatelock_ui +from diet_guard import ( + _gatelock_calendar, + _gatelock_calendar_ui, + _gatelock_ui, + _gatelock_widgetgroups, +) from diet_guard.tests._gate_fixtures import _GATE_TK_MODULES, fake_tk -from diet_guard.tests._tk_fakes import _FAKE_TK, _FAKE_TTK +from diet_guard.tests._tk_fakes import _FAKE_TK, _FAKE_TTK, _FakeTclError #: Modules that bind ``tk`` but build no part of the gate's widget tree, so #: they never need the shared fake. Each is exempt for a stated reason -- this @@ -29,8 +35,9 @@ from diet_guard.tests._tk_fakes import _FAKE_TK, _FAKE_TTK _NO_WIDGETS = frozenset( { # Mirror/adapter layer over widgets built elsewhere; constructs none - # of its own, but does bind ``tk`` at runtime for its ``TclError`` - # suppressions. + # of its own. It binds real ``tk`` only to name ``tk.TclError`` in + # its dead-copy suppressions, which is why ``_FakeTclError`` + # subclasses the real one -- see the last test in this file. "diet_guard._gatelock_widgetgroups", # Display-readiness probe. Builds a throwaway ``tk.Tk()`` to test # whether X is reachable -- deliberately NOT part of the gate's widget @@ -145,3 +152,24 @@ def test_fake_tk_actually_replaces_real_tkinter() -> None: # ...and is restored afterwards, so the fake cannot leak into other tests. assert _gatelock_ui.tk is tk + + +def test_fake_tclerror_is_catchable_as_the_real_one() -> None: + """The fake ``TclError`` must satisfy ``except tkinter.TclError``. + + Not every module that catches a dead-widget error is in the patch set: + ``_gatelock_widgetgroups`` is deliberately exempt (it constructs no + widgets), so its ``contextlib.suppress(tk.TclError)`` names the genuine + class. If the fake did not inherit from it, a fake-tk test raising + ``_FakeTclError`` would sail straight through that suppression -- the + "a monitor vanished mid-update" paths would be untested against the error + the tests actually raise, and would raise through the gate instead. + """ + assert issubclass(_FakeTclError, tk.TclError) + + dead, alive = MagicMock(), MagicMock() + dead.configure.side_effect = _FAKE_TK.TclError("bad window path name") + # Must not raise: the group has to skip the dead copy and configure the + # live one, exactly as it does on a real monitor going dark. + _gatelock_widgetgroups.WidgetGroup([dead, alive]).config(fg="#ff0000") + alive.configure.assert_called_once_with(fg="#ff0000")