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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 20:24:11 +02:00
parent af461d3157
commit df5e1272f1
3 changed files with 46 additions and 7 deletions

View File

@ -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

View File

@ -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:

View File

@ -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")