From 89c6cb52f5209b91ba919778988701abcea4f8f3 Mon Sep 17 00:00:00 2001 From: Pier CeccoPierangioliEugenio Date: Mon, 18 Aug 2025 10:48:54 +0100 Subject: [PATCH] feat: handle stderr in TTkLog (#448) --- libs/pyTermTk/TermTk/TTkCore/log.py | 29 +++++++++++- libs/pyTermTk/TermTk/TTkCore/ttk.py | 13 +++--- tests/pytest/test_005_stderr.py | 71 +++++++++++++++++++++++++++++ tools/check.import.sh | 8 ++-- 4 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 tests/pytest/test_005_stderr.py diff --git a/libs/pyTermTk/TermTk/TTkCore/log.py b/libs/pyTermTk/TermTk/TTkCore/log.py index 6c8744ef..78d6748b 100644 --- a/libs/pyTermTk/TermTk/TTkCore/log.py +++ b/libs/pyTermTk/TermTk/TTkCore/log.py @@ -20,13 +20,15 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -__all__ = ['TTkLog'] +__all__ = ['TTkLog', 'ttk_capture_stderr'] # This code is inspired by # https://github.com/ceccopierangiolieugenio/pyCuT/blob/master/cupy/CuTCore/CuDebug.py +import sys import inspect import logging +import contextlib from collections.abc import Callable, Set class _TTkContext: @@ -111,3 +113,28 @@ class TTkLog: @staticmethod def installMessageHandler(mh: Callable): TTkLog._messageHandler.append(mh) + +class TTkStderrHandler: + def write(self, text): + TTkLog.error(text) + return len(text) + + def flush(self): + pass + + def getvalue(self): + raise NotImplementedError() + + +@contextlib.contextmanager +def ttk_capture_stderr(): + _stderr_bk = sys.stderr + try: + sys.stderr = TTkStderrHandler() + yield + except Exception as e: + sys.stderr = _stderr_bk + TTkLog.critical(f"Caught an exception: {e}") + print(f"Caught an exception: {e}",sys.stderr) + finally: + sys.stderr = _stderr_bk diff --git a/libs/pyTermTk/TermTk/TTkCore/ttk.py b/libs/pyTermTk/TermTk/TTkCore/ttk.py index 5a312bc6..7169d1b7 100644 --- a/libs/pyTermTk/TermTk/TTkCore/ttk.py +++ b/libs/pyTermTk/TermTk/TTkCore/ttk.py @@ -25,7 +25,6 @@ __all__ = ['TTk'] import os import signal import time -import queue import threading import platform @@ -36,7 +35,7 @@ from TermTk.TTkCore.TTkTerm.inputmouse import TTkMouseEvent from TermTk.TTkCore.TTkTerm.term import TTkTerm from TermTk.TTkCore.signal import pyTTkSignal, pyTTkSlot from TermTk.TTkCore.constant import TTkK -from TermTk.TTkCore.log import TTkLog +from TermTk.TTkCore.log import TTkLog, ttk_capture_stderr from TermTk.TTkCore.cfg import TTkCfg, TTkGlbl from TermTk.TTkCore.helper import TTkHelper from TermTk.TTkCore.timer import TTkTimer @@ -75,8 +74,6 @@ class _MouseCursor(): self.updated.emit() class TTk(TTkContainer): - - __slots__ = ( '_termMouse', '_termDirectMouse', '_title', @@ -143,6 +140,10 @@ class TTk(TTkContainer): self.time = curtime def mainloop(self): + with ttk_capture_stderr(): + self._mainloop_1() + + def _mainloop_1(self): try: '''Enters the main event loop and waits until :meth:`~quit` is called or the main widget is destroyed.''' TTkLog.debug( "" ) @@ -185,7 +186,7 @@ class TTk(TTkContainer): self._mouseCursor.updated.connect(self.update) self.paintChildCanvas = self._mouseCursorPaintChildCanvas - self._mainLoop() + self._mainLoop_2() finally: if platform.system() != 'Emscripten': TTkHelper.quitEvent.emit() @@ -205,7 +206,7 @@ class TTk(TTkContainer): color = self._mouseCursor._color self.getCanvas().drawChar(char=ch, pos=pos, color=color) - def _mainLoop(self): + def _mainLoop_2(self): if platform.system() == 'Emscripten': return TTkInput.start() diff --git a/tests/pytest/test_005_stderr.py b/tests/pytest/test_005_stderr.py new file mode 100644 index 00000000..df576327 --- /dev/null +++ b/tests/pytest/test_005_stderr.py @@ -0,0 +1,71 @@ +# MIT License +# +# Copyright (c) 2025 Eugenio Parodi +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import sys, os, io +import logging +import pytest +from typing import Union, Optional + +sys.path.append(os.path.join(sys.path[0],'../../libs/pyTermTk')) + +import TermTk as ttk + + +class FakeStderr(): + value = [] + +def message_handler(mode, context, message): + FakeStderr.value.append(message) + msgType = "NONE" + if mode == ttk.TTkLog.InfoMsg: msgType = "[INFO]" + elif mode == ttk.TTkLog.WarningMsg: msgType = "[WARNING]" + elif mode == ttk.TTkLog.CriticalMsg: msgType = "[CRITICAL]" + elif mode == ttk.TTkLog.FatalMsg: msgType = "[FATAL]" + elif mode == ttk.TTkLog.ErrorMsg: msgType = "[ERROR]" + print(f"{msgType} {context.file} {message}") + +def test_stderr_01(): + ttk.TTkLog.installMessageHandler(message_handler) + + print('Test',file=sys.stderr) + with ttk.ttk_capture_stderr(): + print('XXXXX Test',file=sys.stderr) + with open('pippo','r') as f: + f.read() + raise ValueError('YYYYY Test') + print('After Test') + +def test_ttk_capture_stderr(): + ttk.TTkLog.installMessageHandler(message_handler) + with ttk.ttk_capture_stderr() as fake_stderr: + print("This is an error message", file=sys.stderr) + output = FakeStderr.value + assert "This is an error message" in output + +def test_ttk_capture_stderr_exception_handling(): + ttk.TTkLog.installMessageHandler(message_handler) + with ttk.ttk_capture_stderr() as fake_stderr: + raise ValueError("Test exception") + # After the exception, sys.stderr should be restored + output = FakeStderr.value + assert any("Test exception" in _o for _o in output) + assert isinstance(sys.stderr, io.TextIOWrapper) or sys.stderr is sys.__stderr__ diff --git a/tools/check.import.sh b/tools/check.import.sh index 50f353fc..e9b5a0a0 100755 --- a/tools/check.import.sh +++ b/tools/check.import.sh @@ -15,9 +15,11 @@ __check(){ -e "signal.py:import asyncio" \ -e "signal.py:import importlib.util" \ -e "colors.py:from .colors_ansi_map" \ - -e "log.py:import inspect" \ - -e "log.py:import logging" \ - -e "log.py:from collections.abc import Callable, Set" \ + -e "TTkCore/log.py:import inspect" \ + -e "TTkCore/log.py:import logging" \ + -e "TTkCore/log.py:import sys" \ + -e "TTkCore/log.py:import contextlib" \ + -e "TTkCore/log.py:from collections.abc import Callable, Set" \ -e "term.py:import importlib.util" \ -e "term.*.py:import sys, os, signal" \ -e "term.*.py:from .term_base import TTkTermBase" \