diff --git a/speedwagon/frontend/qtwidgets/dialog/dialogs.py b/speedwagon/frontend/qtwidgets/dialog/dialogs.py index 193ef71b6..16087d2f1 100644 --- a/speedwagon/frontend/qtwidgets/dialog/dialogs.py +++ b/speedwagon/frontend/qtwidgets/dialog/dialogs.py @@ -42,6 +42,8 @@ ALREADY_STOPPED_MESSAGE = "Already stopped" DEFAULT_WINDOW_FLAGS = QtCore.Qt.WindowType(0) +module_logger = logging.getLogger(__name__) + def about_dialog_box(parent: QtWidgets.QWidget) -> None: """Launch the about speedwagon dialog box.""" @@ -249,7 +251,12 @@ class WorkflowProgressStateStopping(AbsWorkflowProgressState): def __init__(self, context: "WorkflowProgress"): super().__init__(context) - self.context.write_to_console("Stopping") + try: + self.context.attach_logger(module_logger) + module_logger.info("Stopping") + self.context.flush() + finally: + self.context.detach_logger(module_logger) self.context.banner.setText("Stopping") cancel_button: QtWidgets.QPushButton = self.context.button_box.button( @@ -288,7 +295,11 @@ def __init__(self, context: "WorkflowProgress"): close_button: QtWidgets.QPushButton = self.context.button_box.button( QtWidgets.QDialogButtonBox.StandardButton.Close ) - self.context.write_to_console("Successfully aborted") + try: + self.context.attach_logger(module_logger) + module_logger.info("Successfully aborted") + finally: + self.context.detach_logger(module_logger) self.context.banner.setText("Aborted") close_button.clicked.connect(self.context.accept) # type: ignore @@ -346,6 +357,9 @@ class WorkflowProgressGui(QtWidgets.QDialog): progress_bar: QtWidgets.QProgressBar console: QtWidgets.QTextBrowser + _WRITE_HTML_BLOCK_TO_CONSOLE_WARN_MSG = \ + "Don't use write_html_block_to_console directly" + def __init__( self, parent: typing.Optional[QtWidgets.QWidget] = None ) -> None: @@ -370,7 +384,7 @@ def __init__( self.banner: QtWidgets.QLabel # ===================================================================== self._log_handler = logging_helpers.QtSignalLogHandler(self) - self._parent_logger: typing.Optional[logging.Logger] = None + self._log_handler.setLevel(logging.INFO) self._console_data = QtGui.QTextDocument(parent=self) @@ -380,30 +394,38 @@ def __init__( self.cursor.movePosition(self.cursor.MoveOperation.End) + def flush(self) -> None: + self._log_handler.flush() + def write_html_block_to_console(self, html: str) -> None: + warnings.warn( + self._WRITE_HTML_BLOCK_TO_CONSOLE_WARN_MSG, + DeprecationWarning, + stacklevel=2 + ) self.cursor.beginEditBlock() self.cursor.insertHtml(html.strip()) self.cursor.endEditBlock() - def flush(self) -> None: - self._log_handler.flush() + def _write_html_block_to_console(self, html: str) -> None: + self.cursor.beginEditBlock() + self.cursor.insertHtml(html.strip()) + self.cursor.endEditBlock() def attach_logger(self, logger: logging.Logger) -> None: - self._parent_logger = logger self._log_handler.signals.messageSent.connect( # type: ignore - self.write_html_block_to_console + self._write_html_block_to_console ) formatter = logging_helpers.ConsoleFormatter() self._log_handler.setFormatter(formatter) - self._parent_logger.addHandler(self._log_handler) + logger.addHandler(self._log_handler) - def remove_log_handles(self) -> None: - if self._parent_logger is not None: - self._log_handler.flush() - self._parent_logger.removeHandler(self._log_handler) - self._parent_logger = None + def detach_logger(self, logger: logging.Logger) -> None: + logger.removeHandler(self._log_handler) + self._log_handler.flush() def get_console_content(self) -> str: + self._log_handler.flush() return self._console_data.toPlainText() @@ -444,8 +466,6 @@ def __init__( # ===================================================================== - self.finished.connect(self.remove_log_handles) # type: ignore - def clean_local_console(self) -> None: # CRITICAL: Running self.console.clear() seems to cause A SEGFAULT when # shutting down!!! @@ -497,7 +517,16 @@ def set_total_jobs(self, value: int) -> None: def set_current_progress(self, value: int) -> None: self.progress_bar.setValue(value) + def set_log_level(self, level: int) -> None: + self._log_handler.setLevel(level) + def write_to_console(self, text: str, level: int = logging.INFO) -> None: + warnings.warn( + "write_to_console is deprecated. " + "Use attach_logger and write to that instead.", + DeprecationWarning, + stacklevel=2 + ) cursor = QtGui.QTextCursor(self._console_data) cursor.movePosition(cursor.MoveOperation.End) cursor.beginEditBlock() diff --git a/speedwagon/frontend/qtwidgets/gui_startup.py b/speedwagon/frontend/qtwidgets/gui_startup.py index 5d9a79a50..bafbeee37 100644 --- a/speedwagon/frontend/qtwidgets/gui_startup.py +++ b/speedwagon/frontend/qtwidgets/gui_startup.py @@ -191,6 +191,7 @@ def do_work(self) -> None: ), workflow_loader_strategy=self._internal.workflow_loader_strategy, request_more_info_strategy=self.request_more_info_strategy, + log_level=logging.DEBUG, ) @@ -745,7 +746,7 @@ def __init__( sys.argv ) self._internal_values = StartQtThreaded.InternalValues( - logger=logging.getLogger(), + logger=logging.getLogger(__name__), log_data=io.StringIO(), request_window=user_interaction.QtRequestMoreInfo(self.windows), ) @@ -1080,6 +1081,10 @@ def _rejected() -> None: ) dialog_box.attach_logger(self._internal_values.logger) + callbacks.signals.finished.connect( + lambda: dialog_box.detach_logger(self._internal_values.logger) + ) + job_manager.request_more_info = ( lambda workflow, options, pretask_results, wait_condition=None: ( open_request_more_info_dialog_box( @@ -1104,7 +1109,12 @@ def _rejected() -> None: liaison=speedwagon.runner_strategies.JobManagerLiaison( callbacks=speedwagon.runner.JobRunnerCallbacks( update_progress=callbacks.update_progress, - log=callbacks.log, + log=lambda text, level=logging.INFO: ( + self._internal_values.logger.log( + level=level, + msg=text, + ) + ), status=callbacks.status, finished=callbacks.finished, error=callbacks.error, @@ -1368,7 +1378,7 @@ def __init__( ] = None self.options: typing.Optional[SettingsData] = None self.workflow: typing.Optional[AbsWorkflow] = None - self.logger = logger or logging.getLogger() + self.logger = logger or logging.getLogger(__name__) def load_json_string(self, data: str) -> None: """Load json data containing options and workflow info. @@ -1471,17 +1481,25 @@ def _run_workflow( ) ) dialog_box.attach_logger(self.logger) + dialog_box.set_log_level(logging.DEBUG) + self.logger.setLevel(logging.DEBUG) + callbacks_to_dialog_box.signals.finished.connect( + lambda: dialog_box.detach_logger(self.logger) + ) job_manager.workflow_loader_strategy = self.load_workflow_strategy - liaison = speedwagon.runner_strategies.JobManagerLiaison( callbacks=speedwagon.runner.JobRunnerCallbacks( update_progress=callbacks_to_dialog_box.update_progress, - log=callbacks_to_dialog_box.log, + log=lambda text, level=logging.INFO: self.logger.log( + level=level, msg=text + ), status=callbacks_to_dialog_box.status, finished=callbacks_to_dialog_box.finished, error=callbacks_to_dialog_box.error, - cancelling_complete=callbacks_to_dialog_box.cancelling_complete + cancelling_complete=( + callbacks_to_dialog_box.cancelling_complete + ), ), events=threaded_events, ) diff --git a/speedwagon/frontend/qtwidgets/runners.py b/speedwagon/frontend/qtwidgets/runners.py index a95284d0b..c98d0ca1a 100644 --- a/speedwagon/frontend/qtwidgets/runners.py +++ b/speedwagon/frontend/qtwidgets/runners.py @@ -16,6 +16,8 @@ USER_ABORTED_MESSAGE = "User Aborted" +module_logger = logging.getLogger(__name__) + class TaskFailed(Exception): """Task has failed.""" @@ -53,11 +55,13 @@ class WorkflowSignals(QtCore.QObject): finished = QtCore.Signal(speedwagon.runner.JobSuccess) def __init__( - self, parent: qtwidgets.dialog.dialogs.WorkflowProgress + self, parent: qtwidgets.dialog.dialogs.WorkflowProgress, ) -> None: """Create a new workprogress callback object.""" super().__init__(parent) + self._active = True self.dialog_box = parent + self.dialog_box.destroyed.connect(self._on_parent_destroyed) # self.cancel_requested.connect(self.dialog_box.cancel_requested) self.status_changed.connect(self.set_banner_text) self.progress_changed.connect(self.dialog_box.set_current_progress) @@ -69,7 +73,11 @@ def __init__( self.started.connect(self.dialog_box.show) self.status_changed.connect(self.dialog_box.flush) - self.message.connect(self.dialog_box.write_to_console) + self.message.connect(self._write_to_console) + + def _on_parent_destroyed(self): + # so dialog_box is not written to by mistake after it's deleted + self._active = False def log(self, text: str, level: int) -> None: """Log a message.""" @@ -82,7 +90,8 @@ def set_banner_text(self, text: str) -> None: def set_status(self, text: str) -> None: """Set the status of the job.""" - self.status_changed.emit(text) + if self._active: + self.status_changed.emit(text) def _error_message( self, @@ -91,8 +100,8 @@ def _error_message( traceback: Optional[str] = None, ) -> None: if message is not None: - self.dialog_box.write_to_console(message) - self.dialog_box.write_to_console(str(exc), level=logging.ERROR) + self._write_to_console(message) + self._write_to_console(str(exc), level=logging.ERROR) error = QtWidgets.QMessageBox() error.setWindowTitle("Workflow Failed") error.setIcon(QtWidgets.QMessageBox.Icon.Critical) @@ -102,6 +111,19 @@ def _error_message( error.exec() self.dialog_box.failed() + def _write_to_console( + self, + text: str, + level: int = logging.INFO + ) -> None: + if not self._active: + return + try: + self.dialog_box.attach_logger(module_logger) + module_logger.log(level, text) + finally: + self.dialog_box.detach_logger(module_logger) + @QtCore.Slot(object) def _finished(self, results: speedwagon.runner.JobSuccess) -> None: if results in [ diff --git a/speedwagon/runner.py b/speedwagon/runner.py index b6e36f5b6..484d2bcff 100644 --- a/speedwagon/runner.py +++ b/speedwagon/runner.py @@ -923,67 +923,80 @@ def run( config: JobSubmitConfig, workflow_loader_strategy: WorkflowLoaderProtocol, request_more_info_strategy: RequestMoreInfoProtocol, - async_communication: Optional[AsyncCommunication] = None + async_communication: Optional[AsyncCommunication] = None, + log_level: int = logging.INFO ) -> None: callbacks = _get_run_callbacks(async_communication) events = _get_run_events(async_communication) - + callback_log_handler = WorkerLogHandler( + callback=lambda record: callbacks.log(record.msg, record.levelno), + level=log_level, + ) with tempfile.TemporaryDirectory() as tmp_dir: try: task_scheduler = Run(tmp_dir) task_scheduler.workflow_loader_strategy = workflow_loader_strategy task_scheduler.request_more_info = request_more_info_strategy - - workflow = task_scheduler.get_workflow(workflow_name)( - global_settings=config.global_settings - ) - workflow.set_options_backend( - speedwagon.config.workflow.ReadOnlyConfigBackend( - config.workflow + task_scheduler.logger.setLevel(log_level) + with attach_logger_handlers( + task_scheduler.logger, + [ + callback_log_handler, + ], + level=log_level, + ): + + workflow = task_scheduler.get_workflow(workflow_name)( + global_settings=config.global_settings ) - ) - events.wait_for_started() - for task in task_scheduler.iter_tasks(workflow, config.job): - if async_communication: - if task.sentinel: - task.sentinel.job_aborted = False - async_communication.events.set_current_task_sentinel( - task.sentinel + workflow.set_options_backend( + speedwagon.config.workflow.ReadOnlyConfigBackend( + config.workflow ) - if async_communication.events.is_stopped(): - async_communication.callbacks.cancelling_complete() - break - - if task.name is not None: - async_communication.callbacks.status(task.name) - - if description := task.task_description(): - callbacks.log(text=description) - - # HACK: pass the task logger - task.parent_task_log_q = type( - "logger", - (object,), - { - "append": ( - lambda msg, log=callbacks.log: log( - text=msg - ) - ) - }, ) - with attach_logger_handlers( - task.logger, - [ + events.wait_for_started() + for task in task_scheduler.iter_tasks(workflow, config.job): + if async_communication: + if task.sentinel: + task.sentinel.job_aborted = False + async_communication.events.set_current_task_sentinel( + task.sentinel + ) + if async_communication.events.is_stopped(): + async_communication.callbacks.cancelling_complete() + break + + if task.name is not None: + async_communication.callbacks.status(task.name) + + if description := task.task_description(): + callbacks.log(text=description) + + # HACK: pass the task logger + task.parent_task_log_q = type( + "logger", + (object,), + { + "append": ( + lambda msg, log=callbacks.log: log( + text=msg + ) + ) + }, + ) + with attach_logger_handlers( + task.logger, + [ WorkerLogHandler( lambda record: callbacks.log( text=record.getMessage(), level=record.levelno - ) - ) - ], - ): - task.exec() + ), + ), + ], + level=log_level + ): + task.exec() callbacks.update_progress( current=task_scheduler.current_task_progress, total=task_scheduler.total_tasks diff --git a/tests/frontend/test_gui_startup.py b/tests/frontend/test_gui_startup.py index db555b247..04cdf7b99 100644 --- a/tests/frontend/test_gui_startup.py +++ b/tests/frontend/test_gui_startup.py @@ -437,7 +437,6 @@ def test_load_workflows_no_window(self, starter, monkeypatch): assert load_custom_tabs.called is False def test_save_log_opens_dialog(self, qtbot, monkeypatch, starter): - from PySide6 import QtWidgets getSaveFileName = Mock( return_value=("dummy", None) ) @@ -464,7 +463,6 @@ def test_save_log_error(self, qtbot, monkeypatch, starter): def getSaveFileName(*args, **kwargs): return save_file_return_name, None - from PySide6 import QtWidgets monkeypatch.setattr( QtWidgets.QFileDialog, "getSaveFileName", @@ -631,7 +629,6 @@ def test_submit_job_errors_on_unknown_workflow( monkeypatch, starter ): - from PySide6 import QtWidgets main_app = QtWidgets.QWidget() job_manager = Mock() workflow_name = "unknown_workflow" @@ -801,7 +798,7 @@ def constructor(*args, **kwargs): class TestWorkflowProgressCallbacks: - @pytest.fixture() + @pytest.fixture def dialog_box(self, qtbot, monkeypatch): monkeypatch.setattr( dialogs.WorkflowProgress, @@ -879,18 +876,19 @@ def test_job_finished_signal(self, dialog_box, qtbot): callbacks.finished(speedwagon.runner.JobSuccess.SUCCESS) def test_job_status_signal(self, dialog_box, qtbot): + qtbot.add_widget(dialog_box) callbacks = \ speedwagon.frontend.qtwidgets.runners.WorkflowProgressCallbacks( - dialog_box + dialog_box=dialog_box ) with qtbot.waitSignal(callbacks.signals.status_changed) as blocker: - blocker.connect(callbacks.signals.status_changed) callbacks.status("some_other_status") assert "some_other_status" in blocker.args def test_set_banner_text(self, dialog_box, qtbot): + qtbot.add_widget(dialog_box) dialog_box.banner.setText = Mock() callbacks = \ speedwagon.frontend.qtwidgets.runners.WorkflowProgressCallbacks( @@ -914,7 +912,6 @@ def test_error( exc, traceback ): - from PySide6 import QtWidgets callbacks = \ speedwagon.frontend.qtwidgets.runners.WorkflowProgressCallbacks( dialog_box @@ -929,7 +926,6 @@ def test_error( ) with qtbot.waitSignal(callbacks.signals.error) as blocker: - blocker.connect(callbacks.signals.error) callbacks.error(message, exc, traceback) assert QMessageBox.called is True @@ -949,7 +945,6 @@ def test_refresh_calls_process_events( monkeypatch, qtbot ): - from PySide6 import QtCore callbacks = \ speedwagon.frontend.qtwidgets.runners.WorkflowProgressCallbacks( dialog_box @@ -967,7 +962,6 @@ def test_refresh_calls_process_events( class TestQtRequestMoreInfo: def test_job_cancelled(self, qtbot): - from PySide6 import QtWidgets info_request = \ speedwagon.frontend.qtwidgets.user_interaction.QtRequestMoreInfo( QtWidgets.QWidget() @@ -991,11 +985,8 @@ def test_job_cancelled(self, qtbot): assert info_request.exc == exc def test_job_exception_passes_on(self, qtbot): - from PySide6 import QtWidgets info_request = \ - speedwagon.frontend.qtwidgets.user_interaction.QtRequestMoreInfo( - QtWidgets.QWidget() - ) + speedwagon.frontend.qtwidgets.user_interaction.QtRequestMoreInfo() user_is_interacting = MagicMock() workflow = Mock() diff --git a/tests/frontend/test_qt_dialogs.py b/tests/frontend/test_qt_dialogs.py index bb64dc5d5..142c540d0 100644 --- a/tests/frontend/test_qt_dialogs.py +++ b/tests/frontend/test_qt_dialogs.py @@ -798,7 +798,10 @@ def test_default_buttons(self, qtbot, button_type, expected_active): def test_get_console(self, qtbot): progress_dialog = dialogs.WorkflowProgress() qtbot.add_widget(progress_dialog) - progress_dialog.write_to_console("spam") + logger = logging.getLogger("test_logger") + logger.setLevel(logging.INFO) + progress_dialog.attach_logger(logger) + logger.info("spam") assert "spam" in progress_dialog.get_console_content() def test_start_changes_state_to_working(self, qtbot, monkeypatch): @@ -853,7 +856,7 @@ def test_remove_log_handles(self, qtbot): progress_dialog = dialogs.WorkflowProgressGui() qtbot.add_widget(progress_dialog) progress_dialog.attach_logger(logger) - progress_dialog.remove_log_handles() + progress_dialog.detach_logger(logger) logger.info("Some message") progress_dialog.flush() assert "Some message" not in progress_dialog.get_console_content() @@ -869,12 +872,14 @@ def test_attach_logger(self, qtbot): progress_dialog.flush() assert "Some message" in progress_dialog.get_console_content() finally: - progress_dialog.remove_log_handles() + progress_dialog.detach_logger(logger) def test_write_html_block_to_console(self, qtbot): progress_dialog = dialogs.WorkflowProgressGui() qtbot.add_widget(progress_dialog) - progress_dialog.write_html_block_to_console("

hello

") + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + progress_dialog.write_html_block_to_console("

hello

") assert "hello" in progress_dialog.get_console_content() diff --git a/tests/test_runner_strategies.py b/tests/test_runner_strategies.py index b118d98b7..10281e9a3 100644 --- a/tests/test_runner_strategies.py +++ b/tests/test_runner_strategies.py @@ -308,7 +308,10 @@ def refresh(self): def user_canceled(self): return False - return DummyRunner() + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + return DummyRunner() def test_basic_setters_and_getters_progress(self, dummy_runner):