Skip to content

Commit 93c5496

Browse files
authored
test: relax log widget test for robustness, and update example (#439)
* fix: fix log widget example and tests * catch exception * cleanup * fix log * try fix coverage
1 parent e60e9c4 commit 93c5496

4 files changed

Lines changed: 69 additions & 34 deletions

File tree

examples/core_log_widget.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1+
from pymmcore_plus import CMMCorePlus
12
from qtpy.QtWidgets import QApplication
23

34
from pymmcore_widgets import CoreLogWidget
45

56
app = QApplication([])
6-
wdg = CoreLogWidget()
7+
core = CMMCorePlus()
8+
9+
wdg = CoreLogWidget(mmcore=core)
10+
wdg.clear()
711
wdg.show()
12+
13+
core.loadSystemConfiguration()
814
app.exec()

pyproject.toml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ dependencies = [
5353
'superqt[quantity,cmap,iconify] >=0.7.1',
5454
'useq-schema >=0.7.2',
5555
'vispy >=0.15.0',
56-
"pyopengl >=3.1.9; platform_system == 'Darwin'"
56+
"pyopengl >=3.1.9; platform_system == 'Darwin'",
5757
]
5858

5959
[project.optional-dependencies]
@@ -75,7 +75,7 @@ test = [
7575
"pyyaml>=6.0.2",
7676
"zarr >=2.15,<3",
7777
"numcodecs >0.14.0,<0.16; python_version >= '3.13'",
78-
"numcodecs >0.12.0,<0.16"
78+
"numcodecs >0.12.0,<0.16",
7979
]
8080
dev = [
8181
{ include-group = "docs" },
@@ -144,7 +144,7 @@ filterwarnings = [
144144
"error",
145145
"ignore:distutils Version classes are deprecated",
146146
"ignore:Failed to disconnect:RuntimeWarning:",
147-
"ignore:'count' is passed as positional argument::vispy"
147+
"ignore:'count' is passed as positional argument::vispy",
148148
]
149149

150150
# https://mypy.readthedocs.io/en/stable/config_file.html
@@ -173,6 +173,10 @@ exclude_lines = [
173173
"raise NotImplementedError",
174174
]
175175
show_missing = true
176+
177+
[tool.coverage.paths]
178+
source = ["src/", "*/pymmcore-widgets/pymmcore-widgets/src", "*/site-packages/"]
179+
176180
[tool.coverage.run]
177181
source = ['pymmcore_widgets']
178182

src/pymmcore_widgets/_log.py

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,19 @@
22

33
import os
44
from collections import deque
5+
from contextlib import suppress
56
from typing import TYPE_CHECKING
67

78
from pymmcore_plus import CMMCorePlus
8-
from qtpy.QtCore import QFileSystemWatcher, QObject, QTimer, QUrl, Signal
9+
from qtpy.QtCore import (
10+
QFileSystemWatcher,
11+
QObject,
12+
QSize,
13+
QTimer,
14+
QTimerEvent,
15+
QUrl,
16+
Signal,
17+
)
918
from qtpy.QtGui import QCloseEvent, QDesktopServices, QFontDatabase, QPalette
1019
from qtpy.QtWidgets import (
1120
QApplication,
@@ -43,26 +52,38 @@ def __init__(
4352
# unless the file is flushed from cache to disk. This does NOT happen
4453
# when CMMCorePlus.logMessage() is called. So we need to poll the file for
4554
# Windows' sake.
46-
self._timer = QTimer(self)
47-
self._timer.setInterval(self._interval)
48-
self._timer.timeout.connect(self._read_new)
55+
self._timer_id: int | None = None
4956

5057
# Watcher for rotation/truncate events
5158
self._watcher = QFileSystemWatcher(self)
5259
self._watcher.addPath(self._path)
5360
self._watcher.fileChanged.connect(self._on_file_changed)
5461

62+
def __del__(self) -> None:
63+
"""Ensure file is closed when object is deleted."""
64+
with suppress(RuntimeError):
65+
self._stop()
66+
67+
def timerEvent(self, event: QTimerEvent | None) -> None:
68+
if event and event.timerId() == self._timer_id:
69+
self._read_new()
70+
5571
def start(self) -> None:
5672
"""Open the file and start polling."""
57-
self._file = open(self._path, encoding="utf-8", errors="replace")
58-
self._file.seek(0, os.SEEK_END)
59-
self._timer.start()
73+
if self._timer_id is None:
74+
self._file = open(self._path, encoding="utf-8", errors="replace")
75+
self._file.seek(0, os.SEEK_END)
76+
self._timer_id = self.startTimer(self._interval)
6077

6178
def _stop(self) -> None:
6279
"""Stop polling and close the file."""
63-
self._timer.stop()
64-
if self._file:
65-
self._file.close()
80+
if self._timer_id is not None:
81+
self.killTimer(self._timer_id)
82+
self._timer_id = None
83+
if self._file is not None:
84+
with suppress(Exception):
85+
self._file.close()
86+
self._file = None
6687
self.finished.emit()
6788

6889
def _on_file_changed(self, path: str) -> None:
@@ -151,17 +172,27 @@ def __init__(
151172
layout.setContentsMargins(0, 0, 0, 0)
152173
layout.addLayout(file_layout)
153174
layout.addWidget(self._log_view)
154-
self.setLayout(layout)
155175

156176
# --- Connections ---
157177
self._reader.new_lines.connect(self._append_line)
158-
self._clear_btn.clicked.connect(self._log_view.clear)
178+
self._clear_btn.clicked.connect(self.clear)
159179
self._log_btn.clicked.connect(self._open_native)
160180
self._reader.start()
161181

162-
def __del__(self) -> None:
163-
"""Stop reader before deletion."""
164-
self._reader._stop()
182+
# scroll left to begin
183+
def _scroll_left() -> None:
184+
if sb := self._log_view.horizontalScrollBar():
185+
sb.setValue(0)
186+
187+
QTimer.singleShot(0, _scroll_left)
188+
189+
def clear(self) -> None:
190+
"""Clear the log view."""
191+
self._log_view.clear()
192+
193+
def sizeHint(self) -> QSize:
194+
hint = super().sizeHint()
195+
return hint.expandedTo(QSize(1000, 800))
165196

166197
def _append_line(self, line: str) -> None:
167198
"""Append a line, respecting pause/follow settings."""
@@ -170,8 +201,6 @@ def _append_line(self, line: str) -> None:
170201
def closeEvent(self, event: QCloseEvent | None) -> None:
171202
"""Clean up thread on close."""
172203
self._reader._stop()
173-
# self._thread.quit()
174-
# self._thread.wait()
175204
super().closeEvent(event)
176205

177206
def _open_native(self) -> None:

tests/test_core_log_widget.py

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,15 @@ def test_core_log_widget_init(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None:
2020
log_path = global_mmcore.getPrimaryLogFile()
2121
assert log_path == wdg._log_path.text()
2222

23-
# Assert log content is in the widget TextEdit
24-
# This is a bit tricky because more can be appended to the log file.
25-
with open(log_path) as f:
26-
log_content = [s.strip() for s in f.readlines()]
27-
# Trim down to the final 5000 lines if necessary
28-
# (this is all that will fit in the Log Widget)
29-
max_lines = wdg._log_view.maximumBlockCount()
30-
if len(log_content) > max_lines:
31-
log_content = log_content[-max_lines:]
32-
edit_content = [s.strip() for s in wdg._log_view.toPlainText().splitlines()]
33-
min_length = min(len(log_content), len(edit_content))
34-
for i in range(min_length):
35-
assert log_content[i] == edit_content[i]
23+
wdg.clear()
24+
with qtbot.waitSignal(global_mmcore.events.systemConfigurationLoaded):
25+
global_mmcore.loadSystemConfiguration()
26+
27+
def _check_log() -> None:
28+
if "Finished initializing" not in wdg._log_view.toPlainText():
29+
raise AssertionError("CoreLogWidget did not finish initializing.")
30+
31+
qtbot.waitUntil(_check_log, timeout=1000)
3632

3733

3834
def test_core_log_widget_update(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None:

0 commit comments

Comments
 (0)