From adb292565feb98873590c2c4447addc2e3aabb23 Mon Sep 17 00:00:00 2001 From: Kien Le <kien.le@earthscope.org> Date: Thu, 12 Dec 2024 11:24:50 -0700 Subject: [PATCH] Fix PySide6.8 signal-slot threading problem --- sohstationviewer/controller/util.py | 29 ++++++++++++++- sohstationviewer/model/data_loader.py | 11 ++++-- .../multi_threaded_plotting_widget.py | 4 +++ tests/model/mseed_data/test_mseed.py | 35 +++++++++++-------- 4 files changed, 61 insertions(+), 18 deletions(-) diff --git a/sohstationviewer/controller/util.py b/sohstationviewer/controller/util.py index d42f6f323..b68469620 100644 --- a/sohstationviewer/controller/util.py +++ b/sohstationviewer/controller/util.py @@ -10,7 +10,8 @@ from pathlib import Path from typing import Tuple, List, Union, Dict from PySide6 import QtCore -from PySide6.QtWidgets import QTextBrowser +from PySide6.QtCore import QObject, Slot +from PySide6.QtWidgets import QTextBrowser, QApplication from obspy import UTCDateTime @@ -18,6 +19,32 @@ from sohstationviewer.view.util.enums import LogType from sohstationviewer.conf.dbSettings import dbConf +class DisplayTrackingInfoWrapper(QObject): + """ + QObject wrapper for the function display_tracking_info. This class allows + display_tracking_info to be used from another thread. + + Before PySide 6.8, this class was not required because functions are + always run on the main thread if they are used as slots. PySide 6.8 + changed how signals and slots are implemented, however, which causes + these functions to instead be run in the same thread as the signal which + called them. This class is used to fix that by always setting its thread + affinity to the main thread, which means all of its slots will run in the + main thread. + """ + def __init__(self): + super().__init__() + self.moveToThread(QApplication.instance().thread()) + + @Slot() + def display_tracking_info(self, tracking_box: QTextBrowser, text: str, + type: LogType = LogType.INFO): + """ + Wrapper for the function display_tracking_info. + """ + display_tracking_info(tracking_box, text, type) + + def validate_file(path2file: Union[str, Path], file_name: str): """ Check if fileName given is a file and not info file diff --git a/sohstationviewer/model/data_loader.py b/sohstationviewer/model/data_loader.py index 6e41c2591..7c420d0c0 100644 --- a/sohstationviewer/model/data_loader.py +++ b/sohstationviewer/model/data_loader.py @@ -9,7 +9,10 @@ from PySide6.QtCore import Qt from PySide6 import QtCore, QtWidgets from sohstationviewer.conf import constants -from sohstationviewer.controller.util import display_tracking_info +from sohstationviewer.controller.util import ( + display_tracking_info, + DisplayTrackingInfoWrapper, +) from sohstationviewer.model.general_data.general_data import ( GeneralData, ThreadStopped, ProcessingDataError) from sohstationviewer.model.reftek_data.reftek_reader.log_file_reader import \ @@ -64,7 +67,11 @@ class DataLoaderWorker(QtCore.QObject): # the read. Since self.run runs in a background thread, we need to use # signal-slot mechanism to ensure that display_tracking_info runs in # the main thread. - self.notification.connect(display_tracking_info) + self.tracking_info_display = DisplayTrackingInfoWrapper() + self.notification.connect( + self.tracking_info_display.display_tracking_info, + type=Qt.ConnectionType.QueuedConnection + ) self.end_msg = None # to change display tracking info to error when failed self.process_failed: bool = False diff --git a/sohstationviewer/view/plotting/plotting_widget/multi_threaded_plotting_widget.py b/sohstationviewer/view/plotting/plotting_widget/multi_threaded_plotting_widget.py index 2e37c46a5..9edc76df0 100644 --- a/sohstationviewer/view/plotting/plotting_widget/multi_threaded_plotting_widget.py +++ b/sohstationviewer/view/plotting/plotting_widget/multi_threaded_plotting_widget.py @@ -54,6 +54,10 @@ class MultiThreadedPlottingWidget(PlottingWidget): self.is_working = False self.finished.connect(self.stopped) + # TODO: Might not need to use a signal here. The only time this signal + # emitted is from this class, and the class is always in the main + # thread. The only things running in a different thread are the + # PlottingChannelProcessor, and they don't use this signal self.notification.connect( lambda msg: display_tracking_info(self.tracking_box, msg)) diff --git a/tests/model/mseed_data/test_mseed.py b/tests/model/mseed_data/test_mseed.py index 7c63df82a..c3cec5908 100644 --- a/tests/model/mseed_data/test_mseed.py +++ b/tests/model/mseed_data/test_mseed.py @@ -44,15 +44,18 @@ class TestMSeed(BaseTestCase): obj = MSeed(**args) self.assertEqual(list(obj.log_data.keys()), ['TEXT']) self.assertEqual(len(obj.log_data['TEXT']), 2) - self.assertEqual( - obj.log_data['TEXT'][0][:100], + + print(obj.log_data) + trimmed_log_data = [obj.log_data['TEXT'][i][:100] + for i in range(len(obj.log_data['TEXT']))] + expected_trimmed_log_1 = ( '\n\n** STATE OF HEALTH: XX.KC01...D.2020.130' '\n2020-05-09 00:00:09.839 UTC: I(TimingThread): timing unce') - - self.assertEqual( - obj.log_data['TEXT'][1][:100], + expected_trimmed_log_2 = ( '\n\n** STATE OF HEALTH: XX.KC01...D.2020.129' '\n2020-05-08 22:55:45.390 UTC: I(Initializations): Firmware') + self.assertTrue(expected_trimmed_log_1 in trimmed_log_data) + self.assertTrue(expected_trimmed_log_2 in trimmed_log_data) def test_read_text_with_soh(self): # text get station from soh data with TXT as channel to add to log_data @@ -69,15 +72,16 @@ class TestMSeed(BaseTestCase): self.assertEqual(len(obj.log_data['TEXT']), 0) self.assertEqual(list(obj.log_data['KC01'].keys()), ['TXT']) self.assertEqual(len(obj.log_data['KC01']['TXT']), 2) - self.assertEqual( - obj.log_data['KC01']['TXT'][0][:100], + trimmed_log_data = [obj.log_data['KC01']['TXT'][i][:100] + for i in range(len(obj.log_data['KC01']['TXT']))] + expected_trimmed_log_1 = ( '\n\n** STATE OF HEALTH: XX.KC01...D.2020.130' '\n2020-05-09 00:00:09.839 UTC: I(TimingThread): timing unce') - - self.assertEqual( - obj.log_data['KC01']['TXT'][1][:100], + expected_trimmed_log_2 = ( '\n\n** STATE OF HEALTH: XX.KC01...D.2020.129' '\n2020-05-08 22:55:45.390 UTC: I(Initializations): Firmware') + self.assertTrue(expected_trimmed_log_1 in trimmed_log_data) + self.assertTrue(expected_trimmed_log_2 in trimmed_log_data) def test_read_text_with_waveform(self): # text get station from waveform data with TXT as channel to add to @@ -96,15 +100,16 @@ class TestMSeed(BaseTestCase): self.assertEqual(len(obj.log_data['TEXT']), 0) self.assertEqual(list(obj.log_data['KC01'].keys()), ['TXT']) self.assertEqual(len(obj.log_data['KC01']['TXT']), 2) - self.assertEqual( - obj.log_data['KC01']['TXT'][0][:100], + trimmed_log_data = [obj.log_data['KC01']['TXT'][i][:100] + for i in range(len(obj.log_data['KC01']['TXT']))] + expected_trimmed_log_1 = ( '\n\n** STATE OF HEALTH: XX.KC01...D.2020.130' '\n2020-05-09 00:00:09.839 UTC: I(TimingThread): timing unce') - - self.assertEqual( - obj.log_data['KC01']['TXT'][1][:100], + expected_trimmed_log_2 = ( '\n\n** STATE OF HEALTH: XX.KC01...D.2020.129' '\n2020-05-08 22:55:45.390 UTC: I(Initializations): Firmware') + self.assertTrue(expected_trimmed_log_1 in trimmed_log_data) + self.assertTrue(expected_trimmed_log_2 in trimmed_log_data) def test_read_ascii(self): # info is text wrapped in mseed format -- GitLab