From 23edb39f52bacdee7594470080e009389ddc93f5 Mon Sep 17 00:00:00 2001 From: janik Date: Thu, 23 Jul 2026 13:29:22 +0700 Subject: [PATCH] Fix the two macOS field-test failures: backend order, RO board dir Second Mac run (KiCad demo board opened from the mounted installer image) got past the dialog and died twice: 1. make_output_dir crashed with OSError 30: the demos volume is a read-only filesystem. Now falls back to a temp directory named after the board, with the path printed (the Messages panel showed it is actually read there). 2. The error figure - and every other figure - could never render: matplotlib refused with Cannot load backend TkAgg ... as qt is currently running. macOS bundled Python ships tkinter, so the old tk-first probe picked TkAgg while the PySide6 dialog and progress window had already made this a Qt process. Windows never saw it because KiCad Python there has no tkinter. Qt now comes first - PySide6 is a hard dependency, so it is always there. Both covered by tests that fail on the old code: the dev env has both toolkits installed, so the backend-order assertion is exercised for real, and the RO fallback is simulated by denying mkdir under the board dir. 140 passed on the dev stack and on the Python 3.9 + numpy 2.0 / scipy 1.13 / matplotlib 3.9 / PySide6 6.10 stack that a Mac venv resolves. Co-Authored-By: Claude Fable 5 --- README.md | 5 +++- fill_resistance/plots.py | 23 +++++++++------- fill_resistance/report.py | 17 ++++++++++-- tests/test_platform_fallbacks.py | 45 ++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 12 deletions(-) create mode 100644 tests/test_platform_fallbacks.py diff --git a/README.md b/README.md index 6827078..11eb400 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,10 @@ notes* below. multi-layer pours at fine cell sizes may run for minutes (on our test setup a typical real-board run finishes in ≈ 8 s). Then read R / voltage drop / total power in the figure titles and status - bar. Outputs land in `/fill_res_results//`: + bar. Outputs land in `/fill_res_results//` + (if the board directory is not writable — e.g. a demo project opened + straight from the mounted installer image — a temp directory is used + instead and its path printed to the Messages panel): per-layer `1_raster_map` / `2_potential` / `3_current_density` / `4_power_density` PNGs, `summary.txt` (incl. the busiest vias with per-via current and dissipation, and the **current through each diff --git a/fill_resistance/plots.py b/fill_resistance/plots.py index 67cccae..e7815ad 100644 --- a/fill_resistance/plots.py +++ b/fill_resistance/plots.py @@ -1,8 +1,9 @@ """Figures: per-layer rasterized maps, potential, current density, power density, and the error figure. PNGs are saved BEFORE any window opens. -Backend: interactive if a GUI toolkit exists (tkinter, else Qt), else Agg -with os.startfile on the saved PNGs so results are never silent. +Backend: interactive if a GUI toolkit exists (Qt first, tkinter as a +fallback), else Agg with the OS default viewer on the saved PNGs so +results are never silent. """ from __future__ import annotations @@ -18,19 +19,23 @@ import numpy as np def _pick_backend(): """matplotlib.use() is lazy and 'succeeds' for backends whose GUI - toolkit is missing (KiCad's Python has no tkinter), so probe the - toolkits explicitly.""" - try: - import tkinter # noqa: F401 - return "TkAgg" - except Exception: - pass + toolkit is missing (KiCad's Windows Python has no tkinter), so probe + the toolkits explicitly. Qt MUST come first: PySide6 is a hard + dependency and the selection dialog / progress window put a Qt event + loop in this process, after which matplotlib refuses TkAgg + ("Cannot load backend 'TkAgg' ... as 'qt' is currently running") - + exactly what happened on macOS, whose bundled Python ships tkinter.""" for qt in ("PySide6", "PyQt6", "PyQt5", "PySide2"): try: __import__(qt) return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg" except Exception: continue + try: + import tkinter # noqa: F401 + return "TkAgg" + except Exception: + pass return None diff --git a/fill_resistance/report.py b/fill_resistance/report.py index 2c8244c..9d53815 100644 --- a/fill_resistance/report.py +++ b/fill_resistance/report.py @@ -1,6 +1,7 @@ """Output directory, summary.txt, geometry dump, stdout one-liner.""" from __future__ import annotations +import tempfile from datetime import datetime from pathlib import Path @@ -14,8 +15,20 @@ from .solver import Result def make_output_dir(board_dir: Path) -> Path: stamp = datetime.now().strftime("%Y%m%d-%H%M%S") - out = Path(board_dir) / config.OUTPUT_DIRNAME / stamp - out.mkdir(parents=True, exist_ok=True) + board_dir = Path(board_dir) + out = board_dir / config.OUTPUT_DIRNAME / stamp + try: + out.mkdir(parents=True, exist_ok=True) + except OSError as e: + # The board can live somewhere unwritable - e.g. the demos + # folder on the mounted KiCad installer image (read-only, and + # how the first macOS field test was run). Results still have + # to land somewhere the figures/summary can be written. + out = (Path(tempfile.gettempdir()) / config.OUTPUT_DIRNAME + / f"{board_dir.name}-{stamp}") + print(f"board directory not writable ({e}); saving results to " + f"{out}") + out.mkdir(parents=True, exist_ok=True) return out diff --git a/tests/test_platform_fallbacks.py b/tests/test_platform_fallbacks.py new file mode 100644 index 0000000..25528d8 --- /dev/null +++ b/tests/test_platform_fallbacks.py @@ -0,0 +1,45 @@ +"""Regressions from the first macOS field test. + +Two failures that only a non-Windows KiCad could produce: the board +directory was read-only (demo project opened straight from the mounted +installer image), and matplotlib picked TkAgg - macOS' bundled Python +ships tkinter, unlike KiCad's Windows Python - then refused to create +any figure because the PySide6 dialog already had a Qt event loop in +the process. +""" +import pathlib + +from fill_resistance import plots, report + + +def test_backend_prefers_qt_over_tk(): + # The dev environment has both toolkits installed, so this asserts + # the preference order for real: Qt must win, because the selection + # dialog / progress window make the process a Qt process before the + # first figure exists. + assert plots._pick_backend() in ("QtAgg", "Qt5Agg") + + +def test_output_dir_falls_back_when_board_dir_unwritable( + tmp_path, monkeypatch, capsys): + board_dir = tmp_path / "board" + board_dir.mkdir() + real_mkdir = pathlib.Path.mkdir + + def deny_under_board(self, *args, **kwargs): + if str(self).startswith(str(board_dir)): + raise OSError(30, "Read-only file system", str(self)) + return real_mkdir(self, *args, **kwargs) + + monkeypatch.setattr(pathlib.Path, "mkdir", deny_under_board) + out = report.make_output_dir(board_dir) + assert out.is_dir() + assert not str(out).startswith(str(board_dir)) + assert board_dir.name in out.name # traceable back to the board + assert "not writable" in capsys.readouterr().out + + +def test_output_dir_normal_case_unchanged(tmp_path): + out = report.make_output_dir(tmp_path) + assert out.is_dir() + assert out.parent.parent == tmp_path