From f54e380ab84fc37eae22d5999c309b62a85ea234 Mon Sep 17 00:00:00 2001 From: Mike Hendricks Date: Fri, 4 Sep 2026 16:28:43 -0700 Subject: [PATCH 1/3] Replace use of QRegExp with QRegularExpression --- preditor/gui/completer.py | 17 +++++++---------- preditor/gui/loggerwindow.py | 2 +- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/preditor/gui/completer.py b/preditor/gui/completer.py index 343e3c51..65faacaf 100644 --- a/preditor/gui/completer.py +++ b/preditor/gui/completer.py @@ -5,8 +5,7 @@ import sys from enum import Enum -import Qt as Qt_py -from Qt.QtCore import QRegExp, QSortFilterProxyModel, QStringListModel, Qt +from Qt.QtCore import QRegularExpression, QSortFilterProxyModel, QStringListModel, Qt from Qt.QtGui import QCursor, QTextCursor from Qt.QtWidgets import QCompleter, QToolTip @@ -160,14 +159,12 @@ def refreshList(self, scope=None): if self._completerMode == CompleterMode.FULL_FUZZY: regExStr = ".*".join(prefix) - if Qt_py.IsPyQt6 or Qt_py.IsPySide6: - regexp = QRegExp(regExStr) - if self._sensitivity: - regexp.setPatternOptions(QRegExp.PatternOption.CaseInsensitiveOption) - self.filterModel.setFilterRegularExpression(regexp) - else: - regexp = QRegExp(regExStr, self._sensitivity) - self.filterModel.setFilterRegExp(regexp) + regexp = QRegularExpression(regExStr) + if self._sensitivity == Qt.CaseSensitivity.CaseInsensitive: + regexp.setPatternOptions( + QRegularExpression.PatternOption.CaseInsensitiveOption + ) + self.filterModel.setFilterRegularExpression(regexp) def clear(self): self.popup().hide() diff --git a/preditor/gui/loggerwindow.py b/preditor/gui/loggerwindow.py index 80abd8d3..3d89621c 100644 --- a/preditor/gui/loggerwindow.py +++ b/preditor/gui/loggerwindow.py @@ -311,7 +311,7 @@ def connectSignals(self): # Tooltips - Qt4 doesn't have a ToolTipsVisible method, so we fake it regEx = ".*" - menus = self.findChildren(QtWidgets.QMenu, QtCore.QRegExp(regEx)) + menus = self.findChildren(QtWidgets.QMenu, QtCore.QRegularExpression(regEx)) for menu in menus: menu.hovered.connect(handleMenuHovered) From 246bfa867ba2bce4a7726b779f9904136f5a28c1 Mon Sep 17 00:00:00 2001 From: Mike Hendricks Date: Fri, 4 Sep 2026 16:29:30 -0700 Subject: [PATCH 2/3] Rename QRegExp to Regex --- preditor/gui/workboxwidget.py | 2 +- preditor/scintilla/documenteditor.py | 6 +++--- preditor/scintilla/finddialog.py | 8 ++++---- preditor/scintilla/ui/finddialog.ui | 10 +++++----- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/preditor/gui/workboxwidget.py b/preditor/gui/workboxwidget.py index 266a9bcd..19d257f6 100644 --- a/preditor/gui/workboxwidget.py +++ b/preditor/gui/workboxwidget.py @@ -285,7 +285,7 @@ def searchText(self): # refresh the search text unless we are using regular expressions if ( not self._searchDialog.isVisible() - and not self._searchFlags & SearchOptions.QRegExp + and not self._searchFlags & SearchOptions.Regex ): txt = self.selectedText() if txt: diff --git a/preditor/scintilla/documenteditor.py b/preditor/scintilla/documenteditor.py index c30e8251..344a5211 100644 --- a/preditor/scintilla/documenteditor.py +++ b/preditor/scintilla/documenteditor.py @@ -49,7 +49,7 @@ class SearchOptions(EnumGroup): Backward = Enum() CaseSensitive = Enum() WholeWords = Enum() - QRegExp = Enum() + Regex = Enum() @contextmanager @@ -649,7 +649,7 @@ def set_filename(self, filename): self._filename = filename def findNext(self, text, flags): - re = (flags & SearchOptions.QRegExp) != 0 + re = (flags & SearchOptions.Regex) != 0 cs = (flags & SearchOptions.CaseSensitive) != 0 wo = (flags & SearchOptions.WholeWords) != 0 wrap = True @@ -663,7 +663,7 @@ def findNext(self, text, flags): return result def findPrev(self, text, flags): - re = (flags & SearchOptions.QRegExp) != 0 + re = (flags & SearchOptions.Regex) != 0 cs = (flags & SearchOptions.CaseSensitive) != 0 wo = (flags & SearchOptions.WholeWords) != 0 wrap = True diff --git a/preditor/scintilla/finddialog.py b/preditor/scintilla/finddialog.py index 59686925..b742cf9e 100644 --- a/preditor/scintilla/finddialog.py +++ b/preditor/scintilla/finddialog.py @@ -16,13 +16,13 @@ def __init__(self, parent): self.uiFindWholeWordsCHK.setChecked( parent.searchFlags() & SearchOptions.WholeWords ) - self.uiQRegExpCHK.setChecked(parent.searchFlags() & SearchOptions.QRegExp) + self.uiRegexCHK.setChecked(parent.searchFlags() & SearchOptions.Regex) self.uiSearchTXT.setPlainText(parent.searchText()) # update the signals self.uiCaseSensitiveCHK.clicked.connect(self.updateSearchTerms) self.uiFindWholeWordsCHK.clicked.connect(self.updateSearchTerms) - self.uiQRegExpCHK.clicked.connect(self.updateSearchTerms) + self.uiRegexCHK.clicked.connect(self.updateSearchTerms) self.uiSearchTXT.textChanged.connect(self.updateSearchTerms) self.uiFindNextBTN.clicked.connect(parent.uiFindNextACT.triggered.emit) @@ -61,8 +61,8 @@ def updateSearchTerms(self): options |= SearchOptions.CaseSensitive if self.uiFindWholeWordsCHK.isChecked(): options |= SearchOptions.WholeWords - if self.uiQRegExpCHK.isChecked(): - options |= SearchOptions.QRegExp + if self.uiRegexCHK.isChecked(): + options |= SearchOptions.Regex parent.setSearchFlags(options) parent.setSearchText(self.uiSearchTXT.toPlainText()) diff --git a/preditor/scintilla/ui/finddialog.ui b/preditor/scintilla/ui/finddialog.ui index d9b5f621..ef202d97 100644 --- a/preditor/scintilla/ui/finddialog.ui +++ b/preditor/scintilla/ui/finddialog.ui @@ -6,7 +6,7 @@ 0 0 - 436 + 421 170 @@ -115,12 +115,12 @@ - + - <html><head/><body><p>Use Qt's QRegExp engine to find using regular expressions instead of a plain text find.</p></body></html> + <html><head/><body><p>Find using regular expressions instead of plain text.</p></body></html> - QRegExp + Regex @@ -135,7 +135,7 @@ uiFindPrevBTN uiFindWholeWordsCHK uiCaseSensitiveCHK - uiQRegExpCHK + uiRegexCHK uiCloseBTN From 6ef5766fa1af0cd8e8eb44be5ff79f65ed52c947 Mon Sep 17 00:00:00 2001 From: Mike Hendricks Date: Thu, 17 Sep 2026 15:31:26 -0700 Subject: [PATCH 3/3] Handle having old_stream closed before Directors Pytest would raise a `ValueError: I/O operation on closed file` exception on its final teardown. This addresses that. --- preditor/stream/director.py | 59 ++++++++++++++++++++++++++++++------- tests/test_stream.py | 25 ++++++++++++++++ 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/preditor/stream/director.py b/preditor/stream/director.py index 5fda5497..89afd646 100644 --- a/preditor/stream/director.py +++ b/preditor/stream/director.py @@ -6,6 +6,28 @@ from ..constants import StreamType +def _open_stream(stream): + """Returns stream if it is still usable, otherwise None. + + The old_stream a Director wraps may be closed before the Director is. For + example pytest closes its capture files at the end of a test run. Using a + closed stream raises ``ValueError: I/O operation on closed file``, so treat + a closed stream the same as not having an old_stream at all. + + Note: It's possible the stream is closed after this call so its recommended to + use a try/except ValueError when accessing the returned stream. + """ + if stream is None: + return None + try: + if stream.closed: + return None + except (AttributeError, ValueError): + # Not all file like objects implement closed, assume it's usable. + pass + return stream + + class _DirectorBuffer(io.RawIOBase): """Binary buffer that forwards text writes to the manager. @@ -32,8 +54,9 @@ def __init__(self, manager, state, old_stream=None, name='nul'): self.name = name def flush(self): - if self.old_stream: - self.old_stream.flush() + old_stream = _open_stream(self.old_stream) + if old_stream: + old_stream.flush() super().flush() def writable(self): @@ -47,8 +70,9 @@ def write(self, b): msg = b.decode("utf-8", errors="replace") self.manager.write(msg, self.state) - if self.old_stream: - self.old_stream.write(msg) + old_stream = _open_stream(self.old_stream) + if old_stream: + old_stream.write(msg) return len(b) @@ -115,7 +139,7 @@ def __repr__(self): def close(self): if ( - self.old_stream + _open_stream(self.old_stream) and not self.std_stream_wrapped and self.old_stream is not sys.__stdout__ and self.old_stream is not sys.__stderr__ @@ -131,18 +155,31 @@ def write(self, msg): # These methods enable terminal features like color coding etc. def isatty(self): - if self.old_stream is not None: - return self.old_stream.isatty() + old_stream = _open_stream(self.old_stream) + if old_stream is not None: + try: + return old_stream.isatty() + except ValueError: + # The stream was closed after we checked it + return False return False @property def encoding(self): - if self.old_stream is not None: - return self.old_stream.encoding + old_stream = _open_stream(self.old_stream) + if old_stream is not None: + try: + return old_stream.encoding + except ValueError: + pass return super().encoding @property def errors(self): - if self.old_stream is not None: - return self.old_stream.errors + old_stream = _open_stream(self.old_stream) + if old_stream is not None: + try: + return old_stream.errors + except ValueError: + pass return super().errors diff --git a/tests/test_stream.py b/tests/test_stream.py index 5fb2a42e..17126a31 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -400,3 +400,28 @@ def test_newline_translation(manager): else: assert b"\r\n" not in result assert result == b"Line 1\nLine 2\n" + + +def test_closed_old_stream(manager): + """A closed old_stream is treated like there is no old_stream. + + Something else may close old_stream before the Director is closed. For + example pytest closes its capture files at the end of a test run, but + colorama still checks `isatty` on the Director while shutting down. + """ + old_stream = io.StringIO() + director = Director(manager, "test_out", old_stream=old_stream) + + old_stream.close() + + assert director.isatty() is False + assert director.encoding == "utf-8" + assert director.errors == "strict" + + # Writes are still recorded by the manager, they just aren't forwarded + director.write("Written to a closed stream") + director.flush() + assert manager.get_value() == "[test_out:Written to a closed stream]" + + # Closing the Director doesn't raise on the already closed stream + director.close()