diff --git a/libs/framework-core/source/ftrack_framework_core/client/__init__.py b/libs/framework-core/source/ftrack_framework_core/client/__init__.py index 6395975bc8..593623b689 100644 --- a/libs/framework-core/source/ftrack_framework_core/client/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/client/__init__.py @@ -173,18 +173,24 @@ def registry(self): '''Return registry object''' return self._registry + @property + def worker(self): + '''Return background worker thread class''' + return self._worker + @property def tool_config_options(self): return self._tool_config_options - def __init__( - self, - event_manager, - registry, - ): + def __init__(self, event_manager, registry, worker=None): ''' Initialise Client with instance of - :class:`~ftrack_framework_core.event.EventManager` + :class:`~ftrack_framework_core.event.EventManager` and instance of + :class:`~ftrack_framework_core.registry.Registry`, with optional background + thread *worker* class. + + The worker class must take method and arguments on initialization and + have a run method to execute the given method with the given arguments. ''' # TODO: double check logger initialization and standardize it around all files. # Setting logger @@ -198,8 +204,13 @@ def __init__( # Set the event manager self._event_manager = event_manager - # Setting init variables to 0 + # Set the registry self._registry = registry + + # Set the worker + self._worker = worker + + # Setting init variables to 0 self._host_context_changed_subscribe_id = None self.__instanced_dialogs = {} self._dialog = None diff --git a/libs/framework-core/source/ftrack_framework_core/widget/dialog.py b/libs/framework-core/source/ftrack_framework_core/widget/dialog.py index 28d7ee4105..9db99bfdfc 100644 --- a/libs/framework-core/source/ftrack_framework_core/widget/dialog.py +++ b/libs/framework-core/source/ftrack_framework_core/widget/dialog.py @@ -468,3 +468,15 @@ class is not registrable. } return data + + def _run_with_worker(self, target, args=None, kwargs=None): + '''Run the given *target* method with a background thread worker, + if client has been initialized with one. Enables running a tool in a background thread, + enabling real time progress updates.''' + worker_class = self.client_property_getter_connection('worker') + if worker_class: + worker = worker_class(target, args=args, kwargs=kwargs) + worker.start() + return worker + else: + target(*args, **kwargs) diff --git a/libs/framework-qt/source/ftrack_framework_qt/dialogs/base_context_dialog.py b/libs/framework-qt/source/ftrack_framework_qt/dialogs/base_context_dialog.py index 385de54d93..75133c4b3f 100644 --- a/libs/framework-qt/source/ftrack_framework_qt/dialogs/base_context_dialog.py +++ b/libs/framework-qt/source/ftrack_framework_qt/dialogs/base_context_dialog.py @@ -1,6 +1,5 @@ # :coding: utf-8 # :copyright: Copyright (c) 2014-2023 ftrack -import copy from Qt import QtWidgets, QtCore @@ -11,6 +10,7 @@ from ftrack_qt.widgets.selectors import ContextSelector from ftrack_qt.utils.layout import recursive_clear_layout +from ftrack_qt.utils.decorators import invoke_in_qt_main_thread class BaseContextDialog(FrameworkDialog, StyledDialog): @@ -140,7 +140,15 @@ def post_build(self): self._on_context_selected_callback ) # Connect run_tool_config button - self._run_button.clicked.connect(self._on_run_button_clicked) + self._run_button.clicked.connect(self._on_run_button_clicked_sync) + + @invoke_in_qt_main_thread + def rebuild_ui(self): + '''Rebuild the UI''' + self.clean_ui() + self.pre_build_ui() + self.build_ui() + self.post_build_ui() def _on_context_selected_callback(self, context_id): '''Emit signal with the new context_id''' @@ -156,17 +164,22 @@ def _on_client_context_changed_callback(self, event=None): self.selected_context_id = self.context_id # Clean the UI every time a new context is set as the current tool # config might not be available anymore - self.clean_ui() - self.pre_build_ui() - self.build_ui() - self.post_build_ui() + self.rebuild_ui() + + def _on_run_button_clicked_sync(self): + '''Run button clicked, enable running tool in background thread if + client has a Worker class defined. Otherwise run in the main thread.''' + + # Always store worker in self._worker to avoid garbage collection while + # thread is running + self._worker = self._run_with_worker(self._on_run_button_clicked) def _on_run_button_clicked(self): ''' Run button from the UI has been clicked. - Tell client to run the current tool config + Tell client to run the current tool config. This method might execute + in a background thread. ''' - self.run_tool_config(self.tool_config['reference']) # FrameworkDialog overrides diff --git a/libs/qt/source/ftrack_qt/utils/threading/__init__.py b/libs/qt/source/ftrack_qt/utils/threading/__init__.py index be4303fd56..4de5abb9db 100644 --- a/libs/qt/source/ftrack_qt/utils/threading/__init__.py +++ b/libs/qt/source/ftrack_qt/utils/threading/__init__.py @@ -1,9 +1,61 @@ # :coding: utf-8 # :copyright: Copyright (c) 2014-2023 ftrack +import sys from Qt import QtCore, QtWidgets +class Worker(QtCore.QThread): + '''Perform work in a background thread.''' + + def __init__(self, function, args=None, kwargs=None, parent=None): + '''Execute *function* in separate thread. + + *args* should be a list of positional arguments and *kwargs* a + mapping of keyword arguments to pass to the function on execution. + + Store function call as self.result. If an exception occurs + store as self.error. + + Example:: + + try: + worker = Worker(theQuestion, [42]) + worker.start() + + while worker.isRunning(): + app = QtGui.QApplication.instance() + app.processEvents() + + if worker.error: + raise worker.error[1], None, worker.error[2] + + except Exception as error: + traceback.print_exc() + QtGui.QMessageBox.critical( + None, + 'Error', + 'An unhandled error occurred:' + '\\n{0}'.format(error) + ) + + ''' + super(Worker, self).__init__(parent=parent) + self.setObjectName(str(function)) + self.function = function + self.args = args or [] + self.kwargs = kwargs or {} + self.result = None + self.error = None + + def run(self): + '''Execute function and store result.''' + try: + self.result = self.function(*self.args, **self.kwargs) + except Exception: + self.error = sys.exc_info() + + class InvokeEvent(QtCore.QEvent): '''Event.''' diff --git a/libs/qt/source/ftrack_qt/widgets/progress/progress_widget.py b/libs/qt/source/ftrack_qt/widgets/progress/progress_widget.py index 4549189666..1fbac9c5ac 100644 --- a/libs/qt/source/ftrack_qt/widgets/progress/progress_widget.py +++ b/libs/qt/source/ftrack_qt/widgets/progress/progress_widget.py @@ -10,6 +10,7 @@ ProgressStatusButtonWidget, ProgressPhaseButtonWidget, ) +from ftrack_qt.utils.decorators import invoke_in_qt_main_thread class ProgressWidget(QtWidgets.QWidget): @@ -169,6 +170,7 @@ def reset_statuses(self, new_status=None, status_message=''): phase_widget.update_status(new_status, status_message, None) phase_widget.hide_log() + @invoke_in_qt_main_thread def run(self, main_widget): '''Run progress widget on top of *main_widget*, with *action*''' self.reset_statuses() diff --git a/projects/framework-common-extensions/dialogs/standard_opener_dialog.py b/projects/framework-common-extensions/dialogs/standard_opener_dialog.py index c923351d1c..85ce6b55f6 100644 --- a/projects/framework-common-extensions/dialogs/standard_opener_dialog.py +++ b/projects/framework-common-extensions/dialogs/standard_opener_dialog.py @@ -169,7 +169,8 @@ def post_build_ui(self): pass def _on_run_button_clicked(self): - '''(Override) Drive the progress widget''' + '''(Override) Run open tool, and drive the progress widget. Might be run in + a background thread.''' self._progress_widget.run(self) super(StandardOpenerDialog, self)._on_run_button_clicked() diff --git a/projects/framework-common-extensions/dialogs/standard_publisher_dialog.py b/projects/framework-common-extensions/dialogs/standard_publisher_dialog.py index 497d36f7f3..d156cdb4c2 100644 --- a/projects/framework-common-extensions/dialogs/standard_publisher_dialog.py +++ b/projects/framework-common-extensions/dialogs/standard_publisher_dialog.py @@ -211,16 +211,12 @@ def post_build_ui(self): pass def _on_run_button_clicked(self): - '''(Override) Refresh context widget(s) upon publish''' + '''(Override) Publish, followed by a refresh of the UI. Might be run in + a background thread.''' self._progress_widget.run(self) super(StandardPublisherDialog, self)._on_run_button_clicked() - # TODO: This will not work in remote mode (async mode) as plugin events - # will arrive after this point of execution. if self._progress_widget.status == constants.status.SUCCESS_STATUS: - self.clean_ui() - self.pre_build_ui() - self.build_ui() - self.post_build_ui() + self.rebuild_ui() @invoke_in_qt_main_thread def plugin_run_callback(self, log_item): diff --git a/projects/framework-photoshop/source/ftrack_framework_photoshop/__init__.py b/projects/framework-photoshop/source/ftrack_framework_photoshop/__init__.py index c6501e4baa..81cb6ce7eb 100644 --- a/projects/framework-photoshop/source/ftrack_framework_photoshop/__init__.py +++ b/projects/framework-photoshop/source/ftrack_framework_photoshop/__init__.py @@ -22,6 +22,7 @@ from ftrack_framework_core.configure_logging import configure_logging from ftrack_qt.utils.decorators import invoke_in_qt_main_thread +from ftrack_qt.utils.threading import Worker from .rpc_cep import PhotoshopRPCCEP from . import process_util @@ -81,7 +82,7 @@ def bootstrap_integration(framework_extensions_path): Host(event_manager, registry=registry_instance) - client = Client(event_manager, registry=registry_instance) + client = Client(event_manager, registry=registry_instance, worker=Worker) # Init tools dcc_config = registry_instance.get_one( diff --git a/tests/framework/manual/standalone_ui_test.py b/tests/framework/manual/standalone_ui_test.py index ec27d27419..87f4612553 100644 --- a/tests/framework/manual/standalone_ui_test.py +++ b/tests/framework/manual/standalone_ui_test.py @@ -35,6 +35,7 @@ from ftrack_framework_core import host, event, registry import ftrack_constants.framework as constants +from ftrack_qt.utils.threading import Worker session = ftrack_api.Session(auto_connect_event_hub=False) event_manager = event.EventManager( @@ -60,7 +61,9 @@ from ftrack_framework_core import client -client_class = client.Client(event_manager, registry=registry_instance) +client_class = client.Client( + event_manager, registry=registry_instance, worker=Worker +) app = QtWidgets.QApplication.instance() if not app: