fix(gui_2): graceful fallback when tkinter.filedialog is unloadable
Bug: on Python installs where the tkinter package imports but the
filedialog sub-module fails to load (e.g., missing Tcl/Tk runtime,
embedded Python), every call to filedialog.askopenfilename raised
'AttributeError: module tkinter has no attribute filedialog' at the
frame the Project Settings window's 'Add Project' button was clicked.
Fix: _LazyModule._resolve() now catches AttributeError on the
getattr() attempt, falls back to importlib.import_module('tkinter.filedialog')
(which surfaces the real ImportError cleanly), and finally falls back
to a new _FiledialogStub class that exposes askopenfilename,
askopenfilenames, askdirectory, asksaveasfilename returning safe
empty sentinels (str and tuple). The stub sets available=False so
future UI can detect it and offer an ImGui-based path input.
Tests:
- tests/test_lazymodule_filedialog_fallback.py: 5 unit tests using
a deliberately-missing sub-module to deterministically exercise
the fallback path on any Python install
- tests/test_live_gui_filedialog_regression.py: live_gui smoke test
that opens the Project Settings window via the Hook API and
asserts no AttributeError in the running app's log
This commit is contained in:
+24
-1
@@ -60,7 +60,14 @@ class _LazyModule:
|
||||
if self._attr_name is None:
|
||||
self._cached = mod
|
||||
else:
|
||||
self._cached = getattr(mod, self._attr_name)
|
||||
try:
|
||||
self._cached = getattr(mod, self._attr_name)
|
||||
except AttributeError:
|
||||
sub_mod_name = f"{self._module_name}.{self._attr_name}"
|
||||
try:
|
||||
self._cached = _importlib.import_module(sub_mod_name)
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
self._cached = _FiledialogStub()
|
||||
return self._cached
|
||||
|
||||
def __getattr__(self, name: str) -> _Any:
|
||||
@@ -69,6 +76,22 @@ class _LazyModule:
|
||||
def __call__(self, *args: _Any, **kwargs: _Any) -> _Any:
|
||||
return self._resolve()(*args, **kwargs)
|
||||
|
||||
class _FiledialogStub:
|
||||
"""No-op replacement for tkinter.filedialog on Python installs where
|
||||
the Tcl/Tk runtime is missing (e.g. embedded Python, slim Docker images).
|
||||
All dialog functions return safe empty sentinels so call sites that do
|
||||
`if p and p not in app.x: app.x.append(p)` treat a missing dialog as a
|
||||
no-op. Exposes a `available` flag so the UI can detect the stub and
|
||||
offer an ImGui-based path input as an alternative.
|
||||
[C: src/gui_2.py:_LazyModule._resolve]
|
||||
"""
|
||||
available: bool = False
|
||||
def askopenfilename(self, *args: _Any, **kwargs: _Any) -> str: return ""
|
||||
def askopenfilenames(self, *args: _Any, **kwargs: _Any) -> tuple: return ()
|
||||
def askdirectory(self, *args: _Any, **kwargs: _Any) -> str: return ""
|
||||
def asksaveasfilename(self, *args: _Any, **kwargs: _Any) -> str: return ""
|
||||
|
||||
|
||||
# Heavy modules that were previously top-level imports (now lazy):
|
||||
np = _LazyModule("numpy") # was: import numpy as np
|
||||
filedialog = _LazyModule("tkinter", "filedialog") # was: from tkinter import filedialog
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Live-GUI smoke test for the tkinter.filedialog AttributeError regression.
|
||||
|
||||
On Python installs where the Tcl/Tk runtime is missing, the lazy
|
||||
`tkinter.filedialog` import raises AttributeError, which previously
|
||||
crashed the Project Settings window and the Add Project button.
|
||||
The unit-level test in `test_lazymodule_filedialog_fallback.py`
|
||||
deterministically exercises the fallback path; this live test verifies
|
||||
the same fix in the actual running app: opening the Project Settings
|
||||
window via the Hook API must not produce an AttributeError, and the
|
||||
app must remain responsive (proving no crash on attribute resolution).
|
||||
"""
|
||||
import time
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from src.api_hook_client import ApiHookClient
|
||||
|
||||
|
||||
def test_live_gui_project_settings_opens_without_filedialog_crash(live_gui) -> None:
|
||||
"""
|
||||
Regression: the Project Settings window's render call chain ends
|
||||
in `render_projects_panel` → `filedialog.askopenfilename(...)` on
|
||||
the "Add Project" click frame. Before the fix, every frame the
|
||||
Project Settings window was open on a broken tkinter install would
|
||||
log `AttributeError: module 'tkinter' has no attribute 'filedialog'`.
|
||||
The fix in `_LazyModule._resolve()` falls back to a `_FiledialogStub`
|
||||
that returns empty strings.
|
||||
|
||||
This test:
|
||||
1. Opens the Project Settings window via the Hook API
|
||||
2. Waits several render frames
|
||||
3. Verifies the window opened (state is reflected back via get_value)
|
||||
4. Verifies the app is still responsive (status endpoint returns 200)
|
||||
5. Verifies no AttributeError was logged (the bug would print to
|
||||
the GUI's stderr, which the live_gui fixture captures to a log)
|
||||
"""
|
||||
process, gui_script = live_gui
|
||||
client = ApiHookClient()
|
||||
|
||||
log_path = Path(f"logs/{Path(gui_script).name.replace('.', '_')}_test.log")
|
||||
log_offset_before = log_path.stat().st_size if log_path.exists() else 0
|
||||
|
||||
client.set_value('show_windows["Project Settings"]', True)
|
||||
time.sleep(2.0)
|
||||
|
||||
opened = client.get_value('show_windows["Project Settings"]')
|
||||
assert opened is True, f"Project Settings window did not open: {opened}"
|
||||
|
||||
status = client.get_status()
|
||||
assert status is not None, "App status endpoint returned None — app is not responsive"
|
||||
assert status.get("status") == "ok", f"App status not ok: {status}"
|
||||
|
||||
time.sleep(1.0)
|
||||
if log_path.exists():
|
||||
with log_path.open("r", encoding="utf-8", errors="ignore") as f:
|
||||
f.seek(log_offset_before)
|
||||
new_log = f.read()
|
||||
assert "AttributeError: module 'tkinter' has no attribute 'filedialog'" not in new_log, (
|
||||
"GUI logged 'AttributeError: module tkinter has no attribute filedialog' "
|
||||
"after opening Project Settings. The _LazyModule fallback to _FiledialogStub "
|
||||
"is not working in the live app."
|
||||
)
|
||||
assert "AttributeError: module 'tkinter' has no attribute 'filedialog'" not in new_log
|
||||
if "AttributeError" in new_log:
|
||||
pytest.fail(f"App logged unexpected AttributeError: {new_log[max(0, new_log.find('AttributeError')-200):new_log.find('AttributeError')+200]}")
|
||||
Reference in New Issue
Block a user