From 8c42aaa245195b8fb4afa30bfb38c1ad699be980 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Wed, 7 Feb 2024 09:43:18 +0100 Subject: [PATCH 01/12] remove framework-widget --- libs/framework-widget/release_notes.md | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 libs/framework-widget/release_notes.md diff --git a/libs/framework-widget/release_notes.md b/libs/framework-widget/release_notes.md deleted file mode 100644 index e4d4f08c11..0000000000 --- a/libs/framework-widget/release_notes.md +++ /dev/null @@ -1,6 +0,0 @@ -# ftrack Framework Widget library release Notes - -## Upcoming -YYYY-mm-dd - -* [new] Initial release. From cbd2c2c4bece4a85a20a8a6ab393b0580e476b1b Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Fri, 16 Feb 2024 10:16:08 +0100 Subject: [PATCH 02/12] update date on publisher widget and timetracker widget --- projects/connect-publisher-widget/release_notes.md | 2 +- projects/connect-timetracker-widget/release_notes.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/connect-publisher-widget/release_notes.md b/projects/connect-publisher-widget/release_notes.md index 95af9f7206..2bb839567a 100644 --- a/projects/connect-publisher-widget/release_notes.md +++ b/projects/connect-publisher-widget/release_notes.md @@ -1,7 +1,7 @@ # ftrack Connect Publisher widget release Notes ## v24.2.0 -2024-02-13 +2024-02-16 * [changed] Ported from ftrack-connect-publisher-widget repo and aligned with Connect 3 * [changed] Use poetry as build system. diff --git a/projects/connect-timetracker-widget/release_notes.md b/projects/connect-timetracker-widget/release_notes.md index f978e0d5b8..58140d1c3d 100644 --- a/projects/connect-timetracker-widget/release_notes.md +++ b/projects/connect-timetracker-widget/release_notes.md @@ -1,7 +1,7 @@ # ftrack Connect Timetracker widget release Notes ## v24.2.0 -2024-02-14 +2024-02-16 * [fix] Fixed bug with wrong plugin name reported back with debug information to Connect about dialog. * [changed] Ported from ftrack-connect-timetracker-widget repo and aligned with Connect 3 From 17ed650d956bd6d688c741fa630010e1ffcb6805 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Wed, 25 Sep 2024 10:03:42 +0200 Subject: [PATCH 03/12] WIP --- libs/framework-core/release_notes.md | 6 + .../ftrack_framework_core/client/__init__.py | 2 +- .../ftrack_framework_core/host/__init__.py | 3 +- .../registry/__init__.py | 60 +- .../ftrack_framework_core/widget/widget.py | 10 + libs/qt-style/release_notes.md | 4 + libs/qt/release_notes.md | 9 + .../widgets/accordion/accordion_widget.py | 42 ++ .../ftrack_qt/widgets/buttons/__init__.py | 1 + .../ftrack_qt/widgets/buttons/menu_button.py | 75 +++ .../headers/accordion_header_widget.py | 58 +- .../ftrack_qt/widgets/icons/status_icon.py | 6 + .../ftrack_qt/widgets/labels/__init__.py | 1 + .../widgets/labels/editable_label.py | 46 ++ libs/utils/release_notes.md | 3 + .../source/ftrack_utils/calls/__init__.py | 4 - .../source/ftrack_utils/calls/methods.py | 17 - .../ftrack_utils/decorators/__init__.py | 1 + .../ftrack_utils/decorators/threading.py | 10 + .../ftrack_utils/framework/config/tool.py | 4 + .../dialogs/generic_publisher_dialog.py | 549 ++++++++++++++++++ .../release_notes.md | 1 + .../standalone-generic-publisher.yaml | 23 + resource/style/sass/widget/_lineedit.scss | 5 + 24 files changed, 902 insertions(+), 38 deletions(-) create mode 100644 libs/qt/source/ftrack_qt/widgets/buttons/menu_button.py create mode 100644 libs/qt/source/ftrack_qt/widgets/labels/__init__.py create mode 100644 libs/qt/source/ftrack_qt/widgets/labels/editable_label.py delete mode 100644 libs/utils/source/ftrack_utils/calls/__init__.py delete mode 100644 libs/utils/source/ftrack_utils/calls/methods.py create mode 100644 projects/framework-common-extensions/dialogs/generic_publisher_dialog.py create mode 100644 projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml diff --git a/libs/framework-core/release_notes.md b/libs/framework-core/release_notes.md index cc08467ad2..99318b0ecf 100644 --- a/libs/framework-core/release_notes.md +++ b/libs/framework-core/release_notes.md @@ -1,5 +1,11 @@ # ftrack Framework Core library release Notes +## upcoming + +* [change] Registry; Support get tool config extensions by reference. +* [change] Registry; Support create references in a tool-config section. + + ## v3.0.0 2024-09-19 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 3dd8068526..97804ef4fa 100644 --- a/libs/framework-core/source/ftrack_framework_core/client/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/client/__init__.py @@ -18,7 +18,7 @@ delegate_to_main_thread_wrapper, ) from ftrack_utils.framework.config.tool import get_tool_config_by_name -from ftrack_utils.calls.methods import call_directly +from ftrack_utils.decorators.threading import call_directly class Client(object): diff --git a/libs/framework-core/source/ftrack_framework_core/host/__init__.py b/libs/framework-core/source/ftrack_framework_core/host/__init__.py index 19dc638cdb..b1988cd40a 100644 --- a/libs/framework-core/source/ftrack_framework_core/host/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/host/__init__.py @@ -16,7 +16,7 @@ with_new_session, delegate_to_main_thread_wrapper, ) -from ftrack_utils.calls.methods import call_directly +from ftrack_utils.decorators.threading import call_directly logger = logging.getLogger(__name__) @@ -310,6 +310,7 @@ def run_ui_hook_callback(self, event): client_options = event['data']['client_options'] payload = event['data']['payload'] + # TODO: we should be able to replace this to: tool_config = self.registry.get_one(extension_type='tool_config', reference=tool_config_reference) for typed_configs in self.tool_configs.values(): tool_config = None for _tool_config in typed_configs: diff --git a/libs/framework-core/source/ftrack_framework_core/registry/__init__.py b/libs/framework-core/source/ftrack_framework_core/registry/__init__.py index 1733dddec6..e390a856ea 100644 --- a/libs/framework-core/source/ftrack_framework_core/registry/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/registry/__init__.py @@ -124,13 +124,15 @@ def scan_extensions(self, paths, extension_types=None): for extension in unique_extensions: self.add(**extension) - def add(self, extension_type, name, extension, path): + def add( + self, extension_type, name, extension, path, create_reference=True + ): ''' Add the given *extension_type* with *name*, *extension* and *path to the registry ''' - if extension_type == 'tool_config': - self.augment_tool_config(extension) + if extension_type == 'tool_config' and create_reference: + self.create_unic_references(extension, skip_root=False) # We use extension_type and not type to not interfere with python # build in type self.__registry[extension_type].append( @@ -141,12 +143,12 @@ def add(self, extension_type, name, extension, path): } ) - def _get(self, extensions, name, extension, path): + def _get(self, extensions, name, extension, path, reference=None): ''' Check given *extensions* list to match given *name*, *extension* and *path* if neither provided, return all available extensions ''' - if not any([name, extension, path]): + if not any([name, extension, path, reference]): return extensions found_extensions = [] for _extension in extensions: @@ -156,10 +158,21 @@ def _get(self, extensions, name, extension, path): continue if path and _extension['path'] != path: continue + if reference and isinstance(_extension['extension'], dict): + if _extension['extension'].get('type') == 'tool_config': + if _extension['extension'].get('reference') != reference: + continue found_extensions.append(_extension) return found_extensions - def get(self, name=None, extension=None, path=None, extension_type=None): + def get( + self, + name=None, + extension=None, + path=None, + extension_type=None, + reference=None, + ): ''' Return given matching *name*, *extension*, *path* or *extension_type*. If nothing provided, return all available extensions. @@ -168,13 +181,25 @@ def get(self, name=None, extension=None, path=None, extension_type=None): if extension_type: extensions = self.registry.get(extension_type) found_extensions.extend( - self._get(extensions, name, extension, path) + self._get( + extensions=extensions, + name=name, + extension=extension, + path=path, + reference=reference, + ) ) else: for extension_type in list(self.registry.keys()): extensions = self.registry.get(extension_type) found_extensions.extend( - self._get(extensions, name, extension, path) + self._get( + extensions=extensions, + name=name, + extension=extension, + path=path, + reference=reference, + ) ) return found_extensions @@ -186,30 +211,37 @@ def get_one(self, *args, **kwargs): ''' matching_extensions = self.get(*args, **kwargs) if len(matching_extensions) == 0: - kwargs_string = ''.join([('%s=%s' % x) for x in kwargs.items()]) - raise Exception( + kwargs_string = ', '.join([('%s=%s' % x) for x in kwargs.items()]) + self.logger.warning( "Extension not found. Arguments: {}".format( (''.join(args), kwargs_string) ) ) + return None if len(matching_extensions) > 1: - kwargs_string = ''.join([('%s=%s' % x) for x in kwargs.items()]) - raise Exception( + kwargs_string = ', '.join([('%s=%s' % x) for x in kwargs.items()]) + self.logger.warning( "Multiple matching extensions found.Arguments: {}".format( (''.join(args), kwargs_string) ) ) + return None return matching_extensions[0] - def augment_tool_config(self, tool_config): + def create_unic_references(self, tool_config, skip_root=False): ''' Augment the given *tool_config* to add a reference id to it and each plugin and group ''' - tool_config['reference'] = uuid.uuid4().hex + if not skip_root: + tool_config['reference'] = uuid.uuid4().hex if 'engine' in tool_config: self._recursive_create_reference(tool_config['engine']) + else: + # if doesn't contain engine, we assume that is a portion of the tool + # config so we augment that portion + self._recursive_create_reference([tool_config]) return tool_config def _recursive_create_reference(self, tool_config_engine_portion): diff --git a/libs/framework-core/source/ftrack_framework_core/widget/widget.py b/libs/framework-core/source/ftrack_framework_core/widget/widget.py index 2016c5591a..fef638ed8e 100644 --- a/libs/framework-core/source/ftrack_framework_core/widget/widget.py +++ b/libs/framework-core/source/ftrack_framework_core/widget/widget.py @@ -92,6 +92,16 @@ def set_plugin_option(self, name, value): ''' self.plugin_options = {name: value} + def remove_plugin_option(self, name): + ''' + Updates the *name* option of the current plugin with the given *value* + ''' + try: + self._options.pop(name) + self.on_set_plugin_option(self._options) + except: + pass + def validate(self): '''Re implement this method to add validation to the widget''' return None diff --git a/libs/qt-style/release_notes.md b/libs/qt-style/release_notes.md index 087ae05bcb..0d7a27019c 100644 --- a/libs/qt-style/release_notes.md +++ b/libs/qt-style/release_notes.md @@ -1,5 +1,9 @@ # ftrack Framework QT Style library release Notes +## upcoming + +* [changed] LineEdit; Support label property. + ## v3.0.0 2024-09-19 diff --git a/libs/qt/release_notes.md b/libs/qt/release_notes.md index 5851f1112e..33b6b2386b 100644 --- a/libs/qt/release_notes.md +++ b/libs/qt/release_notes.md @@ -1,5 +1,14 @@ # ftrack QT library release Notes + +## upcoming + +* [new] MenuButton; Button supporting menu if multiple action items provided. +* [changed] Improved AccordionWidget and AccordionHeaderWidget to use EditableLabelWidget and possibility to add a remove button. Emits bin_clicked, title_changed, title_edited signals. +* [changed] StatusMaterialIconWidget clickable. +* [new] Editable label widget. + + ## v3.0.0 2024-09-19 diff --git a/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py b/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py index 8b27bc00c4..7f9e4acab9 100644 --- a/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py +++ b/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py @@ -17,15 +17,23 @@ class AccordionBaseWidget(QtWidgets.QFrame): doubleClicked = QtCore.Signal( object ) # Emitted when accordion is double clicked + bin_clicked = QtCore.Signal(object) # Emitted when bin icon is clicked show_options_overlay = QtCore.Signal(object) hide_options_overlay = QtCore.Signal() enabled_changed = QtCore.Signal(object) + title_changed = QtCore.Signal(object) + title_edited = QtCore.Signal(object) @property def title(self): '''Return the title text shown in header by default''' return self._title + @property + def editable_title(self): + '''Return the title text shown in header by default''' + return self._editable_title + @property def selectable(self): '''Return the current selection mode''' @@ -68,16 +76,29 @@ def checked(self): '''(Checkable) Return True if checked''' return self._checked + @property + def removable(self): + ''' + Return True if accordion is removable - can be deleted by user + ''' + return self._removable + + @property + def previous_title(self): + return self._header_widget.previous_title + def __init__( self, selectable=False, show_checkbox=False, checkable=False, title=None, + editable_title=False, selected=False, checked=True, collapsable=True, collapsed=True, + removable=False, parent=None, ): ''' @@ -103,7 +124,9 @@ def __init__( self._checkable = checkable self._show_checkbox = show_checkbox self._title = title + self._editable_title = editable_title self._collapsable = collapsable + self._removable = removable self._selected = selected self._checked = checked @@ -140,11 +163,13 @@ def build(self): # Create Header self._header_widget = AccordionHeaderWidget( title=self.title, + editable_title=self.editable_title, checkable=self.checkable, checked=self.checked, show_checkbox=self.show_checkbox, collapsable=self.collapsable, collapsed=self.collapsed, + removable=self.removable, ) # Add header to main widget @@ -170,12 +195,15 @@ def post_build(self): self._header_widget.arrow_clicked.connect( self._on_header_arrow_clicked ) + self._header_widget.bin_clicked.connect(self._on_header_bin_clicked) self._header_widget.show_options_overlay.connect( self._on_show_options_overlay_callback ) self._header_widget.hide_options_overlay.connect( self._on_hide_options_overlay_callback ) + self._header_widget.title_changed.connect(self._on_title_changed) + self._header_widget.title_edited.connect(self._on_title_edited) self._content_widget.setVisible(not self._collapsed) self._content_widget.setEnabled(self.checked) @@ -185,6 +213,12 @@ def _on_show_options_overlay_callback(self, widget): def _on_hide_options_overlay_callback(self): self.hide_options_overlay.emit() + def _on_title_changed(self, title): + self.title_changed.emit(title) + + def _on_title_edited(self, title): + self.title_edited.emit(title) + def add_option_widget(self, widget, section_name): self._header_widget.add_option_widget(widget, section_name) @@ -234,6 +268,10 @@ def _on_header_arrow_clicked(self, event): # This is the way to collapse self.toggle_collapsed() + def _on_header_bin_clicked(self, event): + '''Callback on header arrow user click''' + self.bin_clicked.emit(event) + def toggle_collapsed(self): '''Toggle the accordion collapsed state''' self._collapsed = not self._collapsed @@ -274,6 +312,10 @@ def update_accordion(self): 'selected' if self._selected else 'transparent', ) + def set_title(self, new_title): + self._title = new_title + self._header_widget.set_title(self.title) + def teardown(self): '''Teardown the header widget - properly cleanup the options overlay''' self._header_widget.teardown() diff --git a/libs/qt/source/ftrack_qt/widgets/buttons/__init__.py b/libs/qt/source/ftrack_qt/widgets/buttons/__init__.py index 53152c6e91..b79ce4ef36 100644 --- a/libs/qt/source/ftrack_qt/widgets/buttons/__init__.py +++ b/libs/qt/source/ftrack_qt/widgets/buttons/__init__.py @@ -4,3 +4,4 @@ ProgressStatusButtonWidget, ProgressPhaseButtonWidget, ) +from ftrack_qt.widgets.buttons.menu_button import MenuButton diff --git a/libs/qt/source/ftrack_qt/widgets/buttons/menu_button.py b/libs/qt/source/ftrack_qt/widgets/buttons/menu_button.py new file mode 100644 index 0000000000..4b79645d23 --- /dev/null +++ b/libs/qt/source/ftrack_qt/widgets/buttons/menu_button.py @@ -0,0 +1,75 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2024 ftrack + +from functools import partial + +try: + from PySide6 import QtWidgets, QtCore +except ImportError: + from PySide2 import QtWidgets, QtCore + + +class MenuButton(QtWidgets.QWidget): + item_clicked = QtCore.Signal(object) + + @property + def button_widget(self): + return self._button_widget + + @property + def menu_items(self): + return self._menu_items + + def __init__(self, button_widget, menu_items=None, parent=None): + super(MenuButton, self).__init__(parent) + + self._button_widget = button_widget + if not menu_items: + menu_items = [] + self._menu_items = menu_items + + self.pre_build() + self.build() + self.post_build() + + def pre_build(self): + layout = QtWidgets.QVBoxLayout() + self.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + + def build(self): + self.layout().addWidget(self.button_widget) + + def post_build(self): + self.button_widget.clicked.connect(self.show_menu) + + def add_item(self, item_data, label): + self.menu_items.append( + { + 'data': item_data, + 'label': label, + } + ) + + def show_menu(self): + if len(self.menu_items) == 1: + return self.menu_item_selected(self.menu_items[0]) + # Create a QMenu + menu = QtWidgets.QMenu(self.button_widget) + + # Add menu items + for _item in self.menu_items: + action = menu.addAction(_item['label']) + # Connect each action to a slot (optional) + action.triggered.connect(partial(self.menu_item_selected, _item)) + + # Show the menu below the button + menu.exec_( + self.button_widget.mapToGlobal( + QtCore.QPoint(0, self.button_widget.height()) + ) + ) + + def menu_item_selected(self, item): + # Handle the selected menu item (this is a placeholder for your custom logic) + self.item_clicked.emit(item['data']) diff --git a/libs/qt/source/ftrack_qt/widgets/headers/accordion_header_widget.py b/libs/qt/source/ftrack_qt/widgets/headers/accordion_header_widget.py index 44881d7cf9..2082f5a4cd 100644 --- a/libs/qt/source/ftrack_qt/widgets/headers/accordion_header_widget.py +++ b/libs/qt/source/ftrack_qt/widgets/headers/accordion_header_widget.py @@ -13,6 +13,7 @@ ) from ftrack_qt.widgets.lines import LineWidget from ftrack_qt.widgets.buttons import OptionsButton +from ftrack_qt.widgets.labels import EditableLabel class AccordionHeaderWidget(QtWidgets.QFrame): @@ -21,14 +22,23 @@ class AccordionHeaderWidget(QtWidgets.QFrame): clicked = QtCore.Signal(object) # User header click arrow_clicked = QtCore.Signal(object) # User header click + bin_clicked = QtCore.Signal(object) # User header click checkbox_status_changed = QtCore.Signal(object) show_options_overlay = QtCore.Signal(object) hide_options_overlay = QtCore.Signal() + title_changed = QtCore.Signal(object) + title_edited = QtCore.Signal(object) @property def title(self): + if self._title_label: + return self._title_label.text() return self._title + @property + def editable_title(self): + return self._editable_title + @property def checkable(self): return self._checkable @@ -49,18 +59,28 @@ def collapsable(self): def collapsed(self): return self._collapsed + @property + def removable(self): + return self._removable + @property def options_button(self): return self._options_button + @property + def previous_title(self): + return self._title_label.previous_text + def __init__( self, title=None, + editable_title=False, checkable=False, checked=True, show_checkbox=False, collapsable=True, collapsed=True, + removable=False, parent=None, ): ''' @@ -72,11 +92,13 @@ def __init__( super(AccordionHeaderWidget, self).__init__(parent=parent) self._title = title + self._editable_title = editable_title self._checkable = checkable self._checked = checked self._show_checkbox = show_checkbox self._collapsable = collapsable self._collapsed = collapsed + self._removable = removable self._checkbox = None self._title_label = None @@ -85,6 +107,7 @@ def __init__( self._status = None self._options_button = None self._status_icon = None + self._bin = None self.pre_build() self.build() @@ -104,7 +127,9 @@ def build(self): self._checkbox.setVisible(self.show_checkbox) # Create title - self._title_label = QtWidgets.QLabel(self.title or '') + self._title_label = EditableLabel( + text=self.title, editable=self.editable_title + ) if not self.title: self._title_label.hide() @@ -134,22 +159,31 @@ def build(self): self.update_arrow_icon(self.collapsed) self._arrow.setVisible(self.collapsable) + if self.removable: + # Create Bin + self._bin = StatusMaterialIconWidget(name='delete_outline') + self.layout().addWidget(self._checkbox) self.layout().addWidget(self._title_label) self.layout().addWidget(self._header_content_widget, 10) self.layout().addWidget(self._options_button) self.layout().addWidget(self._status_icon) self.layout().addWidget(self._arrow) + if self._bin: + self.layout().addWidget(self._bin) def post_build(self): self._checkbox.stateChanged.connect(self._on_checkbox_status_changed) self._arrow.clicked.connect(self._on_arrow_clicked) + if self._bin: + self._bin.clicked.connect(self._on_bin_clicked) self._options_button.show_overlay_signal.connect( self.on_show_options_callback ) self._options_button.hide_overlay_signal.connect( self.on_hide_options_callback ) + self._title_label.editingFinished.connect(self._on_title_edited) def on_show_options_callback(self, widget): self.show_options_overlay.emit(widget) @@ -169,6 +203,9 @@ def _on_checkbox_status_changed(self): def _on_arrow_clicked(self, event): self.arrow_clicked.emit(event) + def _on_bin_clicked(self, event): + self.bin_clicked.emit(event) + def update_arrow_icon(self, collapsed): '''Update the arrow icon based on collapse state''' if collapsed: @@ -187,6 +224,25 @@ def set_status(self, status, message): '''Set status message within header, to be implemented by child''' pass + def _on_title_changed(self): + ''' + Emit signal when title is changed + ''' + self.title_changed.emit(self._title_label.text()) + + def _on_title_edited(self): + ''' + Emit signal when title is changed + ''' + self.title_edited.emit(self._title_label.text()) + + def set_title(self, new_title): + ''' + Set the title of the header to *new_title* + ''' + self._title_label.setText(new_title) + self._on_title_changed() + def teardown(self): '''Teardown the options button - properly cleanup the options overlay''' self._options_button.teardown() diff --git a/libs/qt/source/ftrack_qt/widgets/icons/status_icon.py b/libs/qt/source/ftrack_qt/widgets/icons/status_icon.py index 50a49dc83e..ec4b4d6d52 100644 --- a/libs/qt/source/ftrack_qt/widgets/icons/status_icon.py +++ b/libs/qt/source/ftrack_qt/widgets/icons/status_icon.py @@ -18,6 +18,8 @@ class StatusMaterialIconWidget(QtWidgets.QWidget): '''Material icon widget, support status > icon encoding''' + clicked = QtCore.Signal(object) + @property def icon(self): '''Return the material icon''' @@ -81,3 +83,7 @@ def set_status(self, status, size=16): icon_name, variant=variant, color='#{}'.format(color), size=size ) return color + + def mousePressEvent(self, event): + self.clicked.emit(event) + return super(StatusMaterialIconWidget, self).mousePressEvent(event) diff --git a/libs/qt/source/ftrack_qt/widgets/labels/__init__.py b/libs/qt/source/ftrack_qt/widgets/labels/__init__.py new file mode 100644 index 0000000000..ac14df64b9 --- /dev/null +++ b/libs/qt/source/ftrack_qt/widgets/labels/__init__.py @@ -0,0 +1 @@ +from ftrack_qt.widgets.labels.editable_label import EditableLabel diff --git a/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py b/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py new file mode 100644 index 0000000000..bd1be53c6b --- /dev/null +++ b/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py @@ -0,0 +1,46 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2024 ftrack + +try: + from PySide6 import QtWidgets, QtCore +except ImportError: + from PySide2 import QtWidgets, QtCore + + +class EditableLabel(QtWidgets.QLineEdit): + '''Editable label widget.''' + + @property + def previous_text(self): + '''Return previous text.''' + return self._previous_text + + @property + def editable(self): + '''Return editable state.''' + return self._editable + + def __init__(self, text=None, editable=True, parent=None): + super(EditableLabel, self).__init__(parent) + self._previous_text = self.text() + self._editable = editable + self.setReadOnly(True) + + # Set the title line edit to be a label + self.setProperty('label', True) + self.setText(text or '') + + self.editingFinished.connect(self.on_editing_finished) + + def setText(self, event): + super(EditableLabel, self).setText(event) + + def mouseDoubleClickEvent(self, event): + if self._editable: + if self.isReadOnly(): + self._previous_text = self.text() + self.setReadOnly(False) + super(EditableLabel, self).mouseDoubleClickEvent(event) + + def on_editing_finished(self): + self.setReadOnly(True) diff --git a/libs/utils/release_notes.md b/libs/utils/release_notes.md index 4f10768d52..dcd5c5633b 100644 --- a/libs/utils/release_notes.md +++ b/libs/utils/release_notes.md @@ -1,5 +1,8 @@ # ftrack Utils library release Notes +## upcoming + +* [changed] framework tool configs; get_groups method dictionary support on filters argument.. ## v3.0.0 2024-09-19 diff --git a/libs/utils/source/ftrack_utils/calls/__init__.py b/libs/utils/source/ftrack_utils/calls/__init__.py deleted file mode 100644 index bbb0713e88..0000000000 --- a/libs/utils/source/ftrack_utils/calls/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# :coding: utf-8 -# :copyright: Copyright (c) 2024 ftrack - -from ftrack_utils.calls.methods import call_directly diff --git a/libs/utils/source/ftrack_utils/calls/methods.py b/libs/utils/source/ftrack_utils/calls/methods.py deleted file mode 100644 index 9fd5bfbb13..0000000000 --- a/libs/utils/source/ftrack_utils/calls/methods.py +++ /dev/null @@ -1,17 +0,0 @@ -# :coding: utf-8 -# :copyright: Copyright (c) 2024 ftrack - - -def call_directly(func, *args, **kwargs): - """ - Directly calls the function passed with given arguments and keyword arguments. - - Parameters: - func (callable): The function to call. - *args: Variable length argument list. - **kwargs: Arbitrary keyword arguments. - - Returns: - The return value of the function called. - """ - return func(*args, **kwargs) diff --git a/libs/utils/source/ftrack_utils/decorators/__init__.py b/libs/utils/source/ftrack_utils/decorators/__init__.py index 63cc2e2d6e..f1fffca5b3 100644 --- a/libs/utils/source/ftrack_utils/decorators/__init__.py +++ b/libs/utils/source/ftrack_utils/decorators/__init__.py @@ -7,4 +7,5 @@ from ftrack_utils.decorators.threading import ( run_in_main_thread, delegate_to_main_thread_wrapper, + call_directly, ) diff --git a/libs/utils/source/ftrack_utils/decorators/threading.py b/libs/utils/source/ftrack_utils/decorators/threading.py index d870a351a6..0a0efe67fb 100644 --- a/libs/utils/source/ftrack_utils/decorators/threading.py +++ b/libs/utils/source/ftrack_utils/decorators/threading.py @@ -8,6 +8,16 @@ task_queue = queue.Queue() +def call_directly(func): + """Decorator to directly call a function Without caring about threading.""" + + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + def run_in_main_thread(func): """Decorator to ensure a function runs on the main thread.""" diff --git a/libs/utils/source/ftrack_utils/framework/config/tool.py b/libs/utils/source/ftrack_utils/framework/config/tool.py index ca904ba8f2..a9282f0315 100644 --- a/libs/utils/source/ftrack_utils/framework/config/tool.py +++ b/libs/utils/source/ftrack_utils/framework/config/tool.py @@ -123,6 +123,10 @@ def get_groups(tool_config, filters=None, top_level_only=True): if isinstance(obj.get(k), list): if not any(x in obj[k] for x in v): candidate = False + elif isinstance(obj.get(k), dict): + for key, value in v.items(): + if obj[k].get(key) != value: + candidate = False elif isinstance(obj.get(k), str): if obj[k] != v: candidate = False diff --git a/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py b/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py new file mode 100644 index 0000000000..5173c9be9c --- /dev/null +++ b/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py @@ -0,0 +1,549 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2024 ftrack +import copy +import os +from functools import partial + +import clique + +try: + from PySide6 import QtWidgets, QtCore +except ImportError: + from PySide2 import QtWidgets, QtCore + +import ftrack_constants as constants +from ftrack_utils.framework.config.tool import get_plugins, get_groups +from ftrack_framework_qt.dialogs import BaseContextDialog +from ftrack_qt.widgets.accordion import AccordionBaseWidget +from ftrack_qt.widgets.progress import ProgressWidget +from ftrack_qt.utils.widget import build_progress_data +from ftrack_qt.utils.decorators import invoke_in_qt_main_thread +from ftrack_qt.widgets.buttons import CircularButton +from ftrack_qt.widgets.buttons import MenuButton + + +class GenericPublisherDialog(BaseContextDialog): + '''Default Framework Publisher dialog''' + + name = 'framework_generic_publisher_dialog' + tool_config_type_filter = ['publisher'] + ui_type = 'qt' + run_button_title = 'PUBLISH' + + @property + def modified_tool_config(self): + '''Return the modified tool config''' + return self._modified_tool_config + + def __init__( + self, + event_manager, + client_id, + connect_methods_callback, + connect_setter_property_callback, + connect_getter_property_callback, + dialog_options, + parent=None, + ): + ''' + Initialize Mixin class publisher dialog. It will load the qt dialog and + mix it with the framework dialog. + *event_manager*: instance of + :class:`~ftrack_framework_core.event.EventManager` + *client_id*: Id of the client that initializes the current dialog + *connect_methods_callback*: Client callback method for the dialog to be + able to execute client methods. + *connect_setter_property_callback*: Client callback property setter for + the dialog to be able to read client properties. + *connect_getter_property_callback*: Client callback property getter for + the dialog to be able to write client properties. + *dialog_options*: Dictionary of arguments passed on to configure the + current dialog. + ''' + self._scroll_area = None + self._scroll_area_widget = None + self._progress_widget = None + self._modified_tool_config = None + self._save_preset_button = None + + super(GenericPublisherDialog, self).__init__( + event_manager, + client_id, + connect_methods_callback, + connect_setter_property_callback, + connect_getter_property_callback, + dialog_options, + parent=parent, + ) + self.setWindowTitle('ftrack Publisher') + + def pre_build_ui(self): + # Make sure to remove self._scroll_area in case of reload + if self._scroll_area: + self._scroll_area.deleteLater() + # Create scroll area to add all the widgets + self._scroll_area = QtWidgets.QScrollArea() + self._scroll_area.setStyle(QtWidgets.QStyleFactory.create("plastique")) + self._scroll_area.setWidgetResizable(True) + self._scroll_area.setHorizontalScrollBarPolicy( + QtCore.Qt.ScrollBarAlwaysOff + ) + + index = self.main_layout.indexOf(self.tool_widget) + run_index = self.main_layout.indexOf(self.run_button) + if index != -1: # Ensure the widget is found in the layout + # Remove the old widget from layout + self.main_layout.takeAt(index) + # Insert the new widget at the same position + self.main_layout.insertWidget(index, self._scroll_area) + elif run_index != -1: + # In case tool_widget is not already parented make sure to add scroll + # area above the run button. + self.main_layout.insertWidget((run_index - 1), self._scroll_area) + else: # otherwise set it at the end + self.main_layout.addWidget(self._scroll_area) + + self._scroll_area.setWidget(self.tool_widget) + + def build_ui(self): + # Select the desired tool_config + + self.tool_config = None + tool_config_message = None + if self.filtered_tool_configs.get("publisher"): + if len(self.tool_config_names or []) != 1: + tool_config_message = ( + 'One(1) tool config name must be supplied to publisher!' + ) + else: + tool_config_name = self.tool_config_names[0] + for tool_config in self.filtered_tool_configs["publisher"]: + if ( + tool_config.get('name', '').lower() + == tool_config_name.lower() + ): + self.logger.debug( + f'Using tool config {tool_config_name}' + ) + if self.tool_config != tool_config: + try: + self.tool_config = tool_config + except Exception as error: + tool_config_message = error + break + if not self._progress_widget: + self._progress_widget = ProgressWidget( + 'publish', build_progress_data(tool_config) + ) + self.header.set_widget( + self._progress_widget.status_widget + ) + self.overlay_layout.addWidget( + self._progress_widget.overlay_widget + ) + break + if not self.tool_config and not tool_config_message: + tool_config_message = ( + f'Could not find tool config: "{tool_config_name}"' + ) + else: + tool_config_message = 'No publisher tool configs available!' + + if not self.tool_config: + self.logger.warning(tool_config_message) + label_widget = QtWidgets.QLabel(f'{tool_config_message}') + label_widget.setStyleSheet( + "font-style: italic; font-weight: bold;" + ) + self.tool_widget.layout().addWidget(label_widget) + self.run_button.setEnabled(False) + return + + # Create a new tool_config + self._original_tool_config = self.tool_config + self._modified_tool_config = copy.deepcopy(self._original_tool_config) + # Make sure we generate new references + self.registry.create_unic_references(self._modified_tool_config) + self.tool_config = self._modified_tool_config + + # Sync the tool_config with the host + self.sync_tool_config(self.tool_config) + + # Build context widgets + context_plugins = get_plugins( + self.tool_config, filters={'tags': ['context']} + ) + for context_plugin in context_plugins: + if not context_plugin.get('ui'): + continue + context_widget = self.init_framework_widget(context_plugin) + self.tool_widget.layout().addWidget(context_widget) + + # Build component widgets + # Build top label layout + components_layout = QtWidgets.QHBoxLayout() + components_label = QtWidgets.QLabel('Components') + self._save_preset_button = QtWidgets.QPushButton('Save Preset') + components_layout.addWidget(components_label) + components_layout.addStretch() + components_layout.addWidget(self._save_preset_button) + self.tool_widget.layout().addLayout(components_layout) + + component_groups = get_groups( + self.tool_config, filters={'tags': ['component']} + ) + + generic_groups = get_groups( + self._original_tool_config, filters={'tags': ['generic']} + ) + self._accordion_widgets_registry = [] + for _group in component_groups: + group_accordion_widget = self.add_accordion_group(_group) + self.tool_widget.layout().addWidget(group_accordion_widget) + + circular_add_button = CircularButton('add', variant='outlined') + self.menu_button = MenuButton(circular_add_button) + self.tool_widget.layout().addWidget(self.menu_button) + for _generic_group in generic_groups: + self.menu_button.add_item( + item_data=_generic_group, + label=_generic_group.get('options').get( + 'button_label', 'Add Component' + ), + ) + self.menu_button.item_clicked.connect(self._on_add_component_callback) + + spacer = QtWidgets.QSpacerItem( + 1, + 1, + QtWidgets.QSizePolicy.Minimum, + QtWidgets.QSizePolicy.Expanding, + ) + self.tool_widget.layout().addItem(spacer) + + def _get_latest_component_index(self, idx=1): + # Get the number of items in the layout + count = self.tool_widget.layout().count() + if count > 0: + # Get the last item + item = self.tool_widget.layout().itemAt(count - idx) + if item: + if item.widget() in self._accordion_widgets_registry: + # Return the widget associated with the item + return self.tool_widget.layout().indexOf(item.widget()) + else: + return self._get_latest_component_index(idx + 1) + return 0 + + def _on_add_component_callback(self, _generic_group): + # Create a new unic group + new_group = copy.deepcopy(_generic_group) + # Make sure that we don't duplicate component names + available_name = self.get_available_component_name( + new_group.get('options').get('component') + ) + new_group['options']['component'] = available_name + + # generate references for the new group + self.registry.create_unic_references(new_group, skip_root=True) + + group_accordion_widget = self.add_accordion_group(new_group) + + # Insert the new group before the add button + latest_idx = self._get_latest_component_index() + self.tool_widget.layout().insertWidget( + latest_idx + 1, group_accordion_widget + ) + + # Insert the new group into the correct position in the tool_config + self.insert_group_in_tool_config(new_group, group_accordion_widget) + + # Sync the tool_config with the host + self.sync_tool_config(self.tool_config) + + self._progress_widget.set_data(build_progress_data(self.tool_config)) + + def insert_group_in_tool_config(self, new_group, group_accordion_widget): + ''' + Insert the new group in the tool config in the right position. + ''' + current_idx = self._accordion_widgets_registry.index( + group_accordion_widget + ) + if current_idx > 0: + previous_widget = self._accordion_widgets_registry[current_idx - 1] + previous_group = get_groups( + self.tool_config, + filters={ + 'tags': ['component'], + 'options': {'component': previous_widget.title}, + }, + ) + if previous_group: + previous_group = previous_group[0] + previous_group_idx = self.tool_config['engine'].index( + previous_group + ) + self.tool_config['engine'].insert( + previous_group_idx + 1, new_group + ) + else: + self.tool_config['engine'].append(new_group) + else: + self.tool_config['engine'].insert(0, new_group) + + def get_available_component_name(self, name, skip_widget=None): + def increment_name(name): + if '_' in name and name.rsplit('_', 1)[-1].isdigit(): + base, num = name.rsplit('_', 1) + return f'{base}_{int(num) + 1}' + else: + return f'{name}_1' + + for widget in self._accordion_widgets_registry: + if widget != skip_widget: + if widget.title == name: + return self.get_available_component_name( + increment_name(name), skip_widget + ) + return name + + def add_accordion_group(self, group): + group_accordion_widget = AccordionBaseWidget( + selectable=False, + show_checkbox=True, + checkable=group.get('optional', False), + title=group.get('options').get('component'), + editable_title=group.get('options', {}).get( + 'editable_component_name', False + ), + selected=False, + checked=group.get('enabled', True), + collapsable=True, + collapsed=True, + removable=group.get('options', {}).get('removable', False), + ) + collectors = get_plugins(group, filters={'tags': ['collector']}) + self.add_collector_widgets(collectors, group_accordion_widget, group) + validators = get_plugins(group, filters={'tags': ['validator']}) + self.add_validator_widgets(validators, group_accordion_widget, group) + exporters = get_plugins(group, filters={'tags': ['exporter']}) + self.add_exporter_widgets(exporters, group_accordion_widget, group) + group_accordion_widget.hide_options_overlay.connect( + self.show_main_widget + ) + group_accordion_widget.show_options_overlay.connect( + self.show_options_widget + ) + group_accordion_widget.title_edited.connect( + self._on_component_name_edited_callback + ) + group_accordion_widget.bin_clicked.connect( + self._on_component_removed_callback + ) + group_accordion_widget.enabled_changed.connect( + partial(self._on_enable_component_changed_callback, group) + ) + + self._accordion_widgets_registry.append(group_accordion_widget) + return group_accordion_widget + + def add_collector_widgets( + self, collectors, accordion_widget, group_config=None + ): + for plugin_config in collectors: + if not plugin_config.get('ui'): + continue + widget = self.init_framework_widget(plugin_config, group_config) + accordion_widget.add_widget(widget) + if hasattr(widget, 'path_changed'): + if group_config.get('options', {}).get( + 'editable_component_name', False + ): + widget.path_changed.connect( + partial( + self._on_path_changed_callback, accordion_widget + ) + ) + + def add_validator_widgets( + self, validators, accordion_widget, group_config=None + ): + for plugin_config in validators: + if not plugin_config.get('ui'): + continue + widget = self.init_framework_widget(plugin_config, group_config) + accordion_widget.add_option_widget( + widget, section_name='Validators' + ) + + def add_exporter_widgets( + self, exporters, accordion_widget, group_config=None + ): + for plugin_config in exporters: + if not plugin_config.get('ui'): + continue + widget = self.init_framework_widget(plugin_config, group_config) + accordion_widget.add_option_widget( + widget, section_name='Exporters' + ) + + def post_build_ui(self): + if self._progress_widget: + self._progress_widget.hide_overlay_signal.connect( + self.show_main_widget + ) + self._progress_widget.show_overlay_signal.connect( + self.show_overlay_widget + ) + self._save_preset_button.clicked.connect( + self._on_save_preset_button_clicked + ) + + def _on_path_changed_callback(self, accordion_widget, new_name): + ''' + Callback to update the component name when the path is changed. + ''' + file_extension = None + try: + collection = clique.parse(new_name) + if collection: + file_extension = collection.tail + except Exception as error: + self.logger.debug( + f"{new_name} is not a clique collection. Error {error}" + ) + + if not file_extension: + file_extension = os.path.splitext(new_name)[1] or os.path.basename( + new_name + ) + + file_extension = file_extension.lstrip('.') + + extension = self.get_available_component_name(file_extension) + + group = get_groups( + self.tool_config, + filters={ + 'tags': ['component'], + 'options': {'component': accordion_widget.title}, + }, + )[0] + group['options']['component'] = extension + + accordion_widget.set_title(extension) + + # Sync the tool_config with the host + self.sync_tool_config(self.tool_config) + + def _on_component_name_edited_callback(self, new_name): + new_name = self.get_available_component_name( + new_name, skip_widget=self.sender() + ) + if self.sender().previous_title: + group = get_groups( + self.tool_config, + filters={ + 'tags': ['component'], + 'options': {'component': self.sender().previous_title}, + }, + )[0] + group['options']['component'] = new_name + + self.sender().set_title(new_name) + + # Sync the tool_config with the host + self.sync_tool_config(self.tool_config) + + def _on_component_removed_callback(self, event): + # Remove the group from the tool_config + group = get_groups( + self.tool_config, + filters={ + 'tags': ['component'], + 'options': {'component': self.sender().title}, + }, + ) + if group: + group = group[0] + self.tool_config['engine'].remove(group) + # Remove the widget from the registry + self._accordion_widgets_registry.remove(self.sender()) + # Remove the widget from the layout + self.sender().teardown() + self.sender().deleteLater() + + # Sync the tool_config with the host + self.sync_tool_config(self.tool_config) + + self._progress_widget.set_data(build_progress_data(self.tool_config)) + + def show_options_widget(self, widget): + '''Sets the given *widget* as the index 2 of the stacked widget and + remove the previous one if it exists''' + if self._stacked_widget.widget(2): + self._stacked_widget.removeWidget(self._stacked_widget.widget(2)) + self._stacked_widget.addWidget(widget) + self._stacked_widget.setCurrentIndex(2) + + def _on_enable_component_changed_callback(self, group_config, enabled): + '''Callback for when the component is enabled/disabled''' + self.set_tool_config_option( + {'enabled': enabled}, group_config['reference'] + ) + group_config['enabled'] = enabled + # Sync the tool_config with the host + self.sync_tool_config(self.tool_config) + + self._progress_widget.set_data(build_progress_data(self.tool_config)) + + def _on_run_button_clicked(self): + '''(Override) Drive the progress widget''' + self.show_overlay_widget() + self._progress_widget.run() + super(GenericPublisherDialog, 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() + # TODO: there is an error here showing the overlay widget because is not repainting all the widegts that has been parented to the self.layout() in the pre_build_ui build_ui or post_build_ui methods. + + @invoke_in_qt_main_thread + def plugin_run_callback(self, log_item): + '''(Override) Pass framework log item to the progress widget''' + self._progress_widget.update_phase_status( + log_item.reference, + log_item.status, + log_message=log_item.message, + time=log_item.execution_time, + ) + + def _on_save_preset_button_clicked(self): + '''Callback to save the current tool config as a preset''' + # Open a save dialog to get the destination + destination = QtWidgets.QFileDialog.getSaveFileName( + self, 'Save Tool Config Preset', '', 'YAML (*.yaml)' + )[0] + args = { + 'tool_config': self.tool_config, + 'destination': destination, + } + self.client_method_connection( + 'save_tool_config_in_destination', arguments=args + ) + + def closeEvent(self, event): + '''(Override) Close the context and progress widgets''' + if self._context_selector: + self._context_selector.teardown() + if self._progress_widget: + self._progress_widget.teardown() + self._progress_widget.deleteLater() + if self._accordion_widgets_registry: + for accordion in self._accordion_widgets_registry: + accordion.teardown() + super(GenericPublisherDialog, self).closeEvent(event) diff --git a/projects/framework-common-extensions/release_notes.md b/projects/framework-common-extensions/release_notes.md index 1ec000897b..967b4388b8 100644 --- a/projects/framework-common-extensions/release_notes.md +++ b/projects/framework-common-extensions/release_notes.md @@ -2,6 +2,7 @@ ## Upcoming +* [new] Generic publisher dialog added with a generic tool-config. * [fixed] StandardPublisherDialog; Fix bug were _accordion_widgets_registry wasn't initialized if no tool_config available. * [fixed] StandardOpenerDialog, StandardPublisherDialog; Fix bug were dialog creation crashed if not tool configs. Also disabled run button. * [changed] RenameFileExporterPlugin; Accept folder as destination. diff --git a/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml b/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml new file mode 100644 index 0000000000..ebd89f5333 --- /dev/null +++ b/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml @@ -0,0 +1,23 @@ +type: tool_config +name: standalone-generic-publisher +config_type: publisher +engine: + - type: plugin + tags: + - context + plugin: store_asset_context + options: + asset_type_name: script + ui: publisher_asset_version_selector + + - type: plugin + tags: + - collector + plugin: generic_collector + options: + editable_component_name: True + available_collector_widgets: + - file_browser_collector + - object_collector + + ui: generic_accordion_collector diff --git a/resource/style/sass/widget/_lineedit.scss b/resource/style/sass/widget/_lineedit.scss index b55824ee35..1577152ec3 100644 --- a/resource/style/sass/widget/_lineedit.scss +++ b/resource/style/sass/widget/_lineedit.scss @@ -7,4 +7,9 @@ QLineEdit { QLineEdit:focus { border: 1px solid $color-primary; +} + +QLineEdit[label='true']:read-only { + border: none; + background: transparent; } \ No newline at end of file From 8f5ac1192af298dd8e4176205fcd4de02c68e3db Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Wed, 25 Sep 2024 10:05:56 +0200 Subject: [PATCH 04/12] add files --- .../simpler_generic_publisher_dialog.py | 253 ++++++++++++++++++ .../plugins/generic_collector.py | 18 ++ .../widgets/generic_accordion_collector.py | 226 ++++++++++++++++ 3 files changed, 497 insertions(+) create mode 100644 projects/framework-common-extensions/dialogs/simpler_generic_publisher_dialog.py create mode 100644 projects/framework-common-extensions/plugins/generic_collector.py create mode 100644 projects/framework-common-extensions/widgets/generic_accordion_collector.py diff --git a/projects/framework-common-extensions/dialogs/simpler_generic_publisher_dialog.py b/projects/framework-common-extensions/dialogs/simpler_generic_publisher_dialog.py new file mode 100644 index 0000000000..318c89bff9 --- /dev/null +++ b/projects/framework-common-extensions/dialogs/simpler_generic_publisher_dialog.py @@ -0,0 +1,253 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2024 ftrack +import copy +import os +from functools import partial + +import clique + +try: + from PySide6 import QtWidgets, QtCore +except ImportError: + from PySide2 import QtWidgets, QtCore + +import ftrack_constants as constants +from ftrack_utils.framework.config.tool import get_plugins, get_groups +from ftrack_framework_qt.dialogs import BaseContextDialog +from ftrack_qt.widgets.progress import ProgressWidget +from ftrack_qt.utils.widget import build_progress_data +from ftrack_qt.utils.decorators import invoke_in_qt_main_thread + + +class SimplerGenericPublisherDialog(BaseContextDialog): + '''Default Framework Publisher dialog''' + + name = 'simpler_framework_generic_publisher_dialog' + tool_config_type_filter = ['publisher'] + ui_type = 'qt' + run_button_title = 'PUBLISH' + + @property + def modified_tool_config(self): + '''Return the modified tool config''' + return self._modified_tool_config + + def __init__( + self, + event_manager, + client_id, + connect_methods_callback, + connect_setter_property_callback, + connect_getter_property_callback, + dialog_options, + parent=None, + ): + ''' + Initialize Mixin class publisher dialog. It will load the qt dialog and + mix it with the framework dialog. + *event_manager*: instance of + :class:`~ftrack_framework_core.event.EventManager` + *client_id*: Id of the client that initializes the current dialog + *connect_methods_callback*: Client callback method for the dialog to be + able to execute client methods. + *connect_setter_property_callback*: Client callback property setter for + the dialog to be able to read client properties. + *connect_getter_property_callback*: Client callback property getter for + the dialog to be able to write client properties. + *dialog_options*: Dictionary of arguments passed on to configure the + current dialog. + ''' + self._scroll_area = None + self._scroll_area_widget = None + self._progress_widget = None + self._modified_tool_config = None + self._save_preset_button = None + + super(SimplerGenericPublisherDialog, self).__init__( + event_manager, + client_id, + connect_methods_callback, + connect_setter_property_callback, + connect_getter_property_callback, + dialog_options, + parent=parent, + ) + self.setWindowTitle('ftrack Publisher') + + def pre_build_ui(self): + # Make sure to remove self._scroll_area in case of reload + if self._scroll_area: + self._scroll_area.deleteLater() + # Create scroll area to add all the widgets + self._scroll_area = QtWidgets.QScrollArea() + self._scroll_area.setStyle(QtWidgets.QStyleFactory.create("plastique")) + self._scroll_area.setWidgetResizable(True) + self._scroll_area.setHorizontalScrollBarPolicy( + QtCore.Qt.ScrollBarAlwaysOff + ) + + index = self.main_layout.indexOf(self.tool_widget) + run_index = self.main_layout.indexOf(self.run_button) + if index != -1: # Ensure the widget is found in the layout + # Remove the old widget from layout + self.main_layout.takeAt(index) + # Insert the new widget at the same position + self.main_layout.insertWidget(index, self._scroll_area) + elif run_index != -1: + # In case tool_widget is not already parented make sure to add scroll + # area above the run button. + self.main_layout.insertWidget((run_index - 1), self._scroll_area) + else: # otherwise set it at the end + self.main_layout.addWidget(self._scroll_area) + + self._scroll_area.setWidget(self.tool_widget) + + def build_ui(self): + # Select the desired tool_config + + self.tool_config = None + tool_config_message = None + if self.filtered_tool_configs.get("publisher"): + if len(self.tool_config_names or []) != 1: + tool_config_message = ( + 'One(1) tool config name must be supplied to publisher!' + ) + else: + tool_config_name = self.tool_config_names[0] + for tool_config in self.filtered_tool_configs["publisher"]: + if ( + tool_config.get('name', '').lower() + == tool_config_name.lower() + ): + self.logger.debug( + f'Using tool config {tool_config_name}' + ) + if self.tool_config != tool_config: + try: + self.tool_config = tool_config + except Exception as error: + tool_config_message = error + break + if not self._progress_widget: + self._progress_widget = ProgressWidget( + 'publish', build_progress_data(tool_config) + ) + self.header.set_widget( + self._progress_widget.status_widget + ) + self.overlay_layout.addWidget( + self._progress_widget.overlay_widget + ) + break + if not self.tool_config and not tool_config_message: + tool_config_message = ( + f'Could not find tool config: "{tool_config_name}"' + ) + else: + tool_config_message = 'No publisher tool configs available!' + + if not self.tool_config: + self.logger.warning(tool_config_message) + label_widget = QtWidgets.QLabel(f'{tool_config_message}') + label_widget.setStyleSheet( + "font-style: italic; font-weight: bold;" + ) + self.tool_widget.layout().addWidget(label_widget) + self.run_button.setEnabled(False) + return + + # Build context widgets + context_plugins = get_plugins( + self.tool_config, filters={'tags': ['context']} + ) + for context_plugin in context_plugins: + if not context_plugin.get('ui'): + continue + context_widget = self.init_framework_widget(context_plugin) + self.tool_widget.layout().addWidget(context_widget) + + # Build component widgets + # Build top label layout + components_layout = QtWidgets.QHBoxLayout() + components_label = QtWidgets.QLabel('Components') + self._save_preset_button = QtWidgets.QPushButton('Save Preset') + components_layout.addWidget(components_label) + components_layout.addStretch() + components_layout.addWidget(self._save_preset_button) + self.tool_widget.layout().addLayout(components_layout) + + collector_plugins = get_plugins( + self.tool_config, filters={'tags': ['collector']} + ) + for collector_plugin in collector_plugins: + if not collector_plugin.get('ui'): + continue + collector_widget = self.init_framework_widget(collector_plugin) + self.tool_widget.layout().addWidget(collector_widget) + + spacer = QtWidgets.QSpacerItem( + 1, + 1, + QtWidgets.QSizePolicy.Minimum, + QtWidgets.QSizePolicy.Expanding, + ) + self.tool_widget.layout().addItem(spacer) + + def post_build_ui(self): + if self._progress_widget: + self._progress_widget.hide_overlay_signal.connect( + self.show_main_widget + ) + self._progress_widget.show_overlay_signal.connect( + self.show_overlay_widget + ) + self._save_preset_button.clicked.connect( + self._on_save_preset_button_clicked + ) + + def _on_run_button_clicked(self): + '''(Override) Drive the progress widget''' + self.show_overlay_widget() + self._progress_widget.run() + super(SimplerGenericPublisherDialog, 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() + # TODO: there is an error here showing the overlay widget because is not repainting all the widegts that has been parented to the self.layout() in the pre_build_ui build_ui or post_build_ui methods. + + @invoke_in_qt_main_thread + def plugin_run_callback(self, log_item): + '''(Override) Pass framework log item to the progress widget''' + self._progress_widget.update_phase_status( + log_item.reference, + log_item.status, + log_message=log_item.message, + time=log_item.execution_time, + ) + + def _on_save_preset_button_clicked(self): + '''Callback to save the current tool config as a preset''' + # Open a save dialog to get the destination + destination = QtWidgets.QFileDialog.getSaveFileName( + self, 'Save Tool Config Preset', '', 'YAML (*.yaml)' + )[0] + args = { + 'tool_config': self.tool_config, + 'destination': destination, + } + self.client_method_connection( + 'save_tool_config_in_destination', arguments=args + ) + + def closeEvent(self, event): + '''(Override) Close the context and progress widgets''' + if self._context_selector: + self._context_selector.teardown() + if self._progress_widget: + self._progress_widget.teardown() + self._progress_widget.deleteLater() + super(SimplerGenericPublisherDialog, self).closeEvent(event) diff --git a/projects/framework-common-extensions/plugins/generic_collector.py b/projects/framework-common-extensions/plugins/generic_collector.py new file mode 100644 index 0000000000..133b38a95b --- /dev/null +++ b/projects/framework-common-extensions/plugins/generic_collector.py @@ -0,0 +1,18 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2024 ftrack + +import os + +from ftrack_framework_core.plugin import BasePlugin +from ftrack_framework_core.exceptions.plugin import PluginExecutionError + + +class GenericCollectorPlugin(BasePlugin): + name = 'generic_collector' + + def run(self, store): + ''' + Get folder_path and file_name from the :obj:`self.options` + and store the collected_file in the given *store*. + ''' + print(f"the options are:{self.options}") diff --git a/projects/framework-common-extensions/widgets/generic_accordion_collector.py b/projects/framework-common-extensions/widgets/generic_accordion_collector.py new file mode 100644 index 0000000000..d87bf76e84 --- /dev/null +++ b/projects/framework-common-extensions/widgets/generic_accordion_collector.py @@ -0,0 +1,226 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2024 ftrack +import os.path + +try: + from PySide6 import QtWidgets, QtCore +except ImportError: + from PySide2 import QtWidgets, QtCore + +from functools import partial +import clique + +from ftrack_framework_qt.widgets import BaseWidget + +from ftrack_qt.widgets.accordion import AccordionBaseWidget +from ftrack_qt.widgets.buttons import CircularButton +from ftrack_qt.widgets.buttons import MenuButton +from ftrack_qt.widgets.browsers import FileBrowser + + +SUPPORTED_COLLECTORS = ['FileBrowser'] + + +class GenericAccordionCollectorWidget(BaseWidget): + '''Main class to represent a context widget on a publish process.''' + + name = 'generic_accordion_collector' + ui_type = 'qt' + + path_changed = QtCore.Signal(object) + + def __init__( + self, + event_manager, + client_id, + context_id, + plugin_config, + group_config, + on_set_plugin_option, + on_run_ui_hook, + parent=None, + ): + '''initialise PublishContextWidget with *parent*, *session*, *data*, + *name*, *description*, *options* and *context* + ''' + self._accordion_widgets_registry = [] + self._circular_add_button = None + self._menu_button = None + + super(GenericAccordionCollectorWidget, self).__init__( + event_manager, + client_id, + context_id, + plugin_config, + group_config, + on_set_plugin_option, + on_run_ui_hook, + parent, + ) + + def pre_build_ui(self): + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setAlignment(QtCore.Qt.AlignTop) + self.setLayout(layout) + self.setSizePolicy( + QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed + ) + + def build_ui(self): + '''build function widgets.''' + + self._on_add_component_callback(collector_widget_name='FileBrowser') + + self._circular_add_button = CircularButton('add', variant='outlined') + self._menu_button = MenuButton(self._circular_add_button) + for collector_widget in self.plugin_config.get('options', {}).get( + 'available_collector_widgets', 'FileBrowser' + ): + self._menu_button.add_item( + item_data=collector_widget, + label=collector_widget, + ) + + self.layout().addWidget(self._menu_button) + + def _on_add_component_callback(self, collector_widget_name): + editable_component_name = self.plugin_config.get('options', {}).get( + 'editable_component_name', True + ) + # TODO: initialize accordion without exporters button and without validators btton + accordion_widget = AccordionBaseWidget( + selectable=False, + show_checkbox=False, + checkable=False, + title="", + editable_title=editable_component_name, + selected=False, + checked=True, + collapsable=True, + collapsed=True, + removable=True, + ) + + accordion_widget.title_edited.connect( + self._on_component_name_edited_callback + ) + accordion_widget.bin_clicked.connect( + self._on_component_removed_callback + ) + + # TODO: Could be good to have the registry in the widgets as well, + # so we could query the available widgets in there. + # But for now we will hardcode it. + if collector_widget_name not in SUPPORTED_COLLECTORS: + self.logger.warning( + f"Collector widget not supported: {collector_widget_name}" + ) + QMessage_box = QtWidgets.QMessageBox() + QMessage_box.setText( + f"Collector widget not supported: {collector_widget_name}" + ) + QMessage_box.exec_() + return + widget_cls = globals()[collector_widget_name] + widget_instance = widget_cls() + accordion_widget.add_widget(widget_instance) + if hasattr(widget_instance, 'path_changed'): + if editable_component_name: + widget_instance.path_changed.connect( + partial(self._on_path_changed_callback, accordion_widget) + ) + self._accordion_widgets_registry.append(accordion_widget) + latest_idx = self._get_latest_component_index() + self.layout().insertWidget(latest_idx + 1, accordion_widget) + + def post_build_ui(self): + '''hook events''' + self._menu_button.item_clicked.connect(self._on_add_component_callback) + + def _get_latest_component_index(self, idx=1): + # Get the number of items in the layout + count = self.layout().count() + if count > 0: + # Get the last item + item = self.layout().itemAt(count - idx) + if item: + if item.widget() in self._accordion_widgets_registry: + # Return the widget associated with the item + return self.layout().indexOf(item.widget()) + else: + return self._get_latest_component_index(idx + 1) + return 0 + + def _on_component_name_edited_callback(self, new_name): + new_name = self.get_available_component_name( + new_name, skip_widget=self.sender() + ) + if self.sender().previous_title: + options = self.plugin_options.get(self.sender().previous_title) + self.remove_plugin_option(self.sender().previous_title) + self.set_plugin_option(new_name, options) + + self.sender().set_title(new_name) + + def _on_component_removed_callback(self): + self.remove_plugin_option(self.sender().title) + # Remove the widget from the registry + self._accordion_widgets_registry.remove(self.sender()) + # Remove the widget from the layout + self.sender().teardown() + self.sender().deleteLater() + + def _on_path_changed_callback(self, accordion_widget, file_path): + ''' + Callback to update the component name when the path is changed. + Updates the option dictionary with provided *asset_name* when + asset_changed of asset_selector event is triggered + ''' + self.remove_plugin_option(accordion_widget.title) + if not file_path: + return + + file_extension = None + try: + collection = clique.parse(file_path) + if collection: + file_extension = collection.tail + except Exception as error: + self.logger.debug( + f"{file_path} is not a clique collection. Error {error}" + ) + + if not file_extension: + file_extension = os.path.splitext(file_path)[ + 1 + ] or os.path.basename(file_path) + + file_extension = file_extension.lstrip('.') + + extension = self.get_available_component_name(file_extension) + + accordion_widget.set_title(extension) + + option_value = { + 'file_path': file_path, + } + self.set_plugin_option(accordion_widget.title, option_value) + + # self.path_changed.emit(file_path) + + def get_available_component_name(self, name, skip_widget=None): + def increment_name(name): + if '_' in name and name.rsplit('_', 1)[-1].isdigit(): + base, num = name.rsplit('_', 1) + return f'{base}_{int(num) + 1}' + else: + return f'{name}_1' + + for widget in self._accordion_widgets_registry: + if widget != skip_widget: + if widget.title == name: + return self.get_available_component_name( + increment_name(name), skip_widget + ) + return name From 73b2e4b95c905ae69563e1e7f9ab0ab91d17c346 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Wed, 25 Sep 2024 10:09:53 +0200 Subject: [PATCH 05/12] call direct as a decorator --- .../source/ftrack_framework_core/client/__init__.py | 2 +- .../source/ftrack_framework_core/host/__init__.py | 2 +- libs/utils/source/ftrack_utils/decorators/__init__.py | 1 + libs/utils/source/ftrack_utils/decorators/threading.py | 10 ++++++++++ 4 files changed, 13 insertions(+), 2 deletions(-) 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 3dd8068526..97804ef4fa 100644 --- a/libs/framework-core/source/ftrack_framework_core/client/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/client/__init__.py @@ -18,7 +18,7 @@ delegate_to_main_thread_wrapper, ) from ftrack_utils.framework.config.tool import get_tool_config_by_name -from ftrack_utils.calls.methods import call_directly +from ftrack_utils.decorators.threading import call_directly class Client(object): diff --git a/libs/framework-core/source/ftrack_framework_core/host/__init__.py b/libs/framework-core/source/ftrack_framework_core/host/__init__.py index 19dc638cdb..c4c7b7df9c 100644 --- a/libs/framework-core/source/ftrack_framework_core/host/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/host/__init__.py @@ -16,7 +16,7 @@ with_new_session, delegate_to_main_thread_wrapper, ) -from ftrack_utils.calls.methods import call_directly +from ftrack_utils.decorators.threading import call_directly logger = logging.getLogger(__name__) diff --git a/libs/utils/source/ftrack_utils/decorators/__init__.py b/libs/utils/source/ftrack_utils/decorators/__init__.py index 63cc2e2d6e..f1fffca5b3 100644 --- a/libs/utils/source/ftrack_utils/decorators/__init__.py +++ b/libs/utils/source/ftrack_utils/decorators/__init__.py @@ -7,4 +7,5 @@ from ftrack_utils.decorators.threading import ( run_in_main_thread, delegate_to_main_thread_wrapper, + call_directly, ) diff --git a/libs/utils/source/ftrack_utils/decorators/threading.py b/libs/utils/source/ftrack_utils/decorators/threading.py index d870a351a6..0a0efe67fb 100644 --- a/libs/utils/source/ftrack_utils/decorators/threading.py +++ b/libs/utils/source/ftrack_utils/decorators/threading.py @@ -8,6 +8,16 @@ task_queue = queue.Queue() +def call_directly(func): + """Decorator to directly call a function Without caring about threading.""" + + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + def run_in_main_thread(func): """Decorator to ensure a function runs on the main thread.""" From 3b974daf498b0bacc83f55fdd4c8f8eb8500458c Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Wed, 25 Sep 2024 10:14:47 +0200 Subject: [PATCH 06/12] Add release notes and upgrade version --- libs/framework-core/pyproject.toml | 2 +- libs/framework-core/release_notes.md | 6 ++++++ libs/utils/pyproject.toml | 2 +- libs/utils/release_notes.md | 5 +++++ .../utils/source/ftrack_utils/calls/__init__.py | 4 ---- libs/utils/source/ftrack_utils/calls/methods.py | 17 ----------------- 6 files changed, 13 insertions(+), 23 deletions(-) delete mode 100644 libs/utils/source/ftrack_utils/calls/__init__.py delete mode 100644 libs/utils/source/ftrack_utils/calls/methods.py diff --git a/libs/framework-core/pyproject.toml b/libs/framework-core/pyproject.toml index 71621ad92e..e600e5e290 100644 --- a/libs/framework-core/pyproject.toml +++ b/libs/framework-core/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ftrack-framework-core" -version = "3.0.0" +version = "3.0.1" description='ftrack Framework Core library' authors = ["ftrack Integrations Team "] readme = "README.md" diff --git a/libs/framework-core/release_notes.md b/libs/framework-core/release_notes.md index cc08467ad2..633271c90c 100644 --- a/libs/framework-core/release_notes.md +++ b/libs/framework-core/release_notes.md @@ -1,5 +1,11 @@ # ftrack Framework Core library release Notes +## v3.0.1 +2024-09-25 + +* [fix] Client, Host; Fix issue with run_in_main_thread_wrapper decorator. + + ## v3.0.0 2024-09-19 diff --git a/libs/utils/pyproject.toml b/libs/utils/pyproject.toml index 62ec2fd727..f1ca300c27 100644 --- a/libs/utils/pyproject.toml +++ b/libs/utils/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ftrack-utils" -version = "3.0.0" +version = "3.0.1" description='ftrack utils library' authors = ["ftrack Integrations Team "] readme = "README.md" diff --git a/libs/utils/release_notes.md b/libs/utils/release_notes.md index 4f10768d52..c1c2ba2c7c 100644 --- a/libs/utils/release_notes.md +++ b/libs/utils/release_notes.md @@ -1,5 +1,10 @@ # ftrack Utils library release Notes +## v3.0.1 +2024-09-25 + +* [fix] Calls, decorators; Remove calls package and move the call_directly to decorators threading, this fixes a buc when calling it from framework Client and Host. + ## v3.0.0 2024-09-19 diff --git a/libs/utils/source/ftrack_utils/calls/__init__.py b/libs/utils/source/ftrack_utils/calls/__init__.py deleted file mode 100644 index bbb0713e88..0000000000 --- a/libs/utils/source/ftrack_utils/calls/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# :coding: utf-8 -# :copyright: Copyright (c) 2024 ftrack - -from ftrack_utils.calls.methods import call_directly diff --git a/libs/utils/source/ftrack_utils/calls/methods.py b/libs/utils/source/ftrack_utils/calls/methods.py deleted file mode 100644 index 9fd5bfbb13..0000000000 --- a/libs/utils/source/ftrack_utils/calls/methods.py +++ /dev/null @@ -1,17 +0,0 @@ -# :coding: utf-8 -# :copyright: Copyright (c) 2024 ftrack - - -def call_directly(func, *args, **kwargs): - """ - Directly calls the function passed with given arguments and keyword arguments. - - Parameters: - func (callable): The function to call. - *args: Variable length argument list. - **kwargs: Arbitrary keyword arguments. - - Returns: - The return value of the function called. - """ - return func(*args, **kwargs) From f79e39514cc513e925289c38c3d17341c1ef6520 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Thu, 26 Sep 2024 10:13:45 +0200 Subject: [PATCH 07/12] WIP --- .../widgets/accordion/accordion_widget.py | 16 ++++++++++++ .../headers/accordion_header_widget.py | 14 ++++++++++ .../simpler_generic_publisher_dialog.py | 5 ---- .../plugins/generic_collector.py | 12 ++++++++- .../standalone-generic-publisher.yaml | 4 ++- .../widgets/generic_accordion_collector.py | 26 ++++++++++++++----- 6 files changed, 63 insertions(+), 14 deletions(-) diff --git a/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py b/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py index 7f9e4acab9..490d3fa2bf 100644 --- a/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py +++ b/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py @@ -49,6 +49,16 @@ def show_checkbox(self): '''Return True if accordion is checkable with a checkbox''' return self._show_checkbox + @property + def show_settings(self): + '''Return True if accordion is checkable with a checkbox''' + return self._show_settings + + @property + def show_status(self): + '''Return True if accordion is checkable with a checkbox''' + return self._show_status + @property def collapsed(self): '''Return True if accordion is collapsed - content hidden (default)''' @@ -91,6 +101,8 @@ def __init__( self, selectable=False, show_checkbox=False, + show_settings=True, + show_status=True, checkable=False, title=None, editable_title=False, @@ -123,6 +135,8 @@ def __init__( self._selectable = selectable self._checkable = checkable self._show_checkbox = show_checkbox + self._show_settings = show_settings + self._show_status = show_status self._title = title self._editable_title = editable_title self._collapsable = collapsable @@ -167,6 +181,8 @@ def build(self): checkable=self.checkable, checked=self.checked, show_checkbox=self.show_checkbox, + show_settings=self.show_settings, + show_status=self.show_status, collapsable=self.collapsable, collapsed=self.collapsed, removable=self.removable, diff --git a/libs/qt/source/ftrack_qt/widgets/headers/accordion_header_widget.py b/libs/qt/source/ftrack_qt/widgets/headers/accordion_header_widget.py index 2082f5a4cd..e64174bd68 100644 --- a/libs/qt/source/ftrack_qt/widgets/headers/accordion_header_widget.py +++ b/libs/qt/source/ftrack_qt/widgets/headers/accordion_header_widget.py @@ -51,6 +51,14 @@ def checked(self): def show_checkbox(self): return self._show_checkbox + @property + def show_settings(self): + return self._show_settings + + @property + def show_status(self): + return self._show_status + @property def collapsable(self): return self._collapsable @@ -78,6 +86,8 @@ def __init__( checkable=False, checked=True, show_checkbox=False, + show_settings=False, + show_status=False, collapsable=True, collapsed=True, removable=False, @@ -96,6 +106,8 @@ def __init__( self._checkable = checkable self._checked = checked self._show_checkbox = show_checkbox + self._show_settings = show_settings + self._show_status = show_status self._collapsable = collapsable self._collapsed = collapsed self._removable = removable @@ -150,9 +162,11 @@ def build(self): self.title, MaterialIcon('settings', color='gray') ) self._options_button.setProperty('borderless', True) + self._options_button.setVisible(self._show_settings) content_layout.addWidget(LineWidget(horizontal=True)) # add status icon self._status_icon = StatusMaterialIconWidget('check') + self._status_icon.setVisible(self._show_status) # Create Arrow self._arrow = ArrowMaterialIconWidget(None) diff --git a/projects/framework-common-extensions/dialogs/simpler_generic_publisher_dialog.py b/projects/framework-common-extensions/dialogs/simpler_generic_publisher_dialog.py index 318c89bff9..e4d50a7c75 100644 --- a/projects/framework-common-extensions/dialogs/simpler_generic_publisher_dialog.py +++ b/projects/framework-common-extensions/dialogs/simpler_generic_publisher_dialog.py @@ -27,11 +27,6 @@ class SimplerGenericPublisherDialog(BaseContextDialog): ui_type = 'qt' run_button_title = 'PUBLISH' - @property - def modified_tool_config(self): - '''Return the modified tool config''' - return self._modified_tool_config - def __init__( self, event_manager, diff --git a/projects/framework-common-extensions/plugins/generic_collector.py b/projects/framework-common-extensions/plugins/generic_collector.py index 133b38a95b..0f504c29a8 100644 --- a/projects/framework-common-extensions/plugins/generic_collector.py +++ b/projects/framework-common-extensions/plugins/generic_collector.py @@ -15,4 +15,14 @@ def run(self, store): Get folder_path and file_name from the :obj:`self.options` and store the collected_file in the given *store*. ''' - print(f"the options are:{self.options}") + components = self.options.get('components', {}) + for component_name, component_value in components.items(): + file_path = component_value.get('file_path') + if not file_path: + message = ( + "Please provide file_path in component options. \n " + "options: {}".format(self.options) + ) + raise PluginExecutionError(message) + self.logger.debug(f"Collected file_path: {file_path}.") + store['components'][component_name]['collected_path'] = file_path diff --git a/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml b/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml index ebd89f5333..b6cbe5c5cc 100644 --- a/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml +++ b/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml @@ -17,7 +17,9 @@ engine: options: editable_component_name: True available_collector_widgets: - - file_browser_collector + - FileBrowser - object_collector ui: generic_accordion_collector + + - publish_to_ftrack diff --git a/projects/framework-common-extensions/widgets/generic_accordion_collector.py b/projects/framework-common-extensions/widgets/generic_accordion_collector.py index d87bf76e84..5db2d133c6 100644 --- a/projects/framework-common-extensions/widgets/generic_accordion_collector.py +++ b/projects/framework-common-extensions/widgets/generic_accordion_collector.py @@ -92,6 +92,8 @@ def _on_add_component_callback(self, collector_widget_name): accordion_widget = AccordionBaseWidget( selectable=False, show_checkbox=False, + show_settings=False, + show_status=False, checkable=False, title="", editable_title=editable_component_name, @@ -150,21 +152,27 @@ def _get_latest_component_index(self, idx=1): return self.layout().indexOf(item.widget()) else: return self._get_latest_component_index(idx + 1) - return 0 + return -1 def _on_component_name_edited_callback(self, new_name): new_name = self.get_available_component_name( new_name, skip_widget=self.sender() ) if self.sender().previous_title: - options = self.plugin_options.get(self.sender().previous_title) - self.remove_plugin_option(self.sender().previous_title) - self.set_plugin_option(new_name, options) + current_components = self.plugin_options.get('components', {}) + component_options = current_components.get( + self.sender().previous_title + ) + if current_components.get(self.sender().previous_title): + current_components.pop(self.sender().previous_title) + current_components.update({new_name: component_options}) + self.set_plugin_option('components', current_components) self.sender().set_title(new_name) def _on_component_removed_callback(self): - self.remove_plugin_option(self.sender().title) + current_components = self.plugin_options.get('components', {}) + current_components.pop(self.sender().title) # Remove the widget from the registry self._accordion_widgets_registry.remove(self.sender()) # Remove the widget from the layout @@ -177,7 +185,9 @@ def _on_path_changed_callback(self, accordion_widget, file_path): Updates the option dictionary with provided *asset_name* when asset_changed of asset_selector event is triggered ''' - self.remove_plugin_option(accordion_widget.title) + current_components = self.plugin_options.get('components', {}) + if current_components.get(accordion_widget.title): + current_components.pop(accordion_widget.title) if not file_path: return @@ -205,7 +215,9 @@ def _on_path_changed_callback(self, accordion_widget, file_path): option_value = { 'file_path': file_path, } - self.set_plugin_option(accordion_widget.title, option_value) + + current_components.update({accordion_widget.title: option_value}) + self.set_plugin_option('components', current_components) # self.path_changed.emit(file_path) From 2986febf2d8e8d0b72aad4b18df4a8e263b96070 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Thu, 26 Sep 2024 10:47:02 +0200 Subject: [PATCH 08/12] working version --- .../plugins/generic_collector.py | 4 ++++ .../plugins/publish_to_ftrack.py | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/projects/framework-common-extensions/plugins/generic_collector.py b/projects/framework-common-extensions/plugins/generic_collector.py index 0f504c29a8..d0e344aed0 100644 --- a/projects/framework-common-extensions/plugins/generic_collector.py +++ b/projects/framework-common-extensions/plugins/generic_collector.py @@ -25,4 +25,8 @@ def run(self, store): ) raise PluginExecutionError(message) self.logger.debug(f"Collected file_path: {file_path}.") + if store.get('components') is None: + store['components'] = {} + if store['components'].get(component_name) is None: + store['components'][component_name] = {} store['components'][component_name]['collected_path'] = file_path diff --git a/projects/framework-common-extensions/plugins/publish_to_ftrack.py b/projects/framework-common-extensions/plugins/publish_to_ftrack.py index f500db9d04..8b02aa487d 100644 --- a/projects/framework-common-extensions/plugins/publish_to_ftrack.py +++ b/projects/framework-common-extensions/plugins/publish_to_ftrack.py @@ -103,12 +103,25 @@ def run(self, store): ), ) else: + component_dictionary = store['components'][component_name] + # Try to get first the exported path if doesn't exists, pick the collected_path + component_path = component_dictionary.get( + 'exported_path', + component_dictionary.get('collected_path'), + ) + # Raise error if no exported or collected path passed. + if not component_path: + message = ( + f"Please provide exported_path or collected_path in " + f"component options. \n options: {component_dictionary}" + ) + error = True + break + self._create_component( asset_version_object, component_name, - store['components'][component_name].get( - 'exported_path' - ), + component_path, ) store['components'][component_name][ 'published_to_ftrack' From 40bd90e15cb3537992d269ddc200e0501a8b0de7 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Fri, 27 Sep 2024 10:21:27 +0200 Subject: [PATCH 09/12] example of a object collector but can't work right now because of the missing registry --- .../standalone-generic-publisher.yaml | 2 +- .../widgets/generic_accordion_collector.py | 5 + .../widgets/generic_object_collector.py | 100 ++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 projects/framework-common-extensions/widgets/generic_object_collector.py diff --git a/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml b/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml index b6cbe5c5cc..9fa8a7edc3 100644 --- a/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml +++ b/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml @@ -18,7 +18,7 @@ engine: editable_component_name: True available_collector_widgets: - FileBrowser - - object_collector + - GenericObjectCollectorWidget ui: generic_accordion_collector diff --git a/projects/framework-common-extensions/widgets/generic_accordion_collector.py b/projects/framework-common-extensions/widgets/generic_accordion_collector.py index 5db2d133c6..d1bbf63a0a 100644 --- a/projects/framework-common-extensions/widgets/generic_accordion_collector.py +++ b/projects/framework-common-extensions/widgets/generic_accordion_collector.py @@ -15,7 +15,12 @@ from ftrack_qt.widgets.accordion import AccordionBaseWidget from ftrack_qt.widgets.buttons import CircularButton from ftrack_qt.widgets.buttons import MenuButton + +# TODO: Remove this from qt widgets as it needs to be loaded from the registry from ftrack_qt.widgets.browsers import FileBrowser +from ftrack_qt.widgets.browsers.generic_object_collector import ( + GenericObjectCollectorWidget, +) SUPPORTED_COLLECTORS = ['FileBrowser'] diff --git a/projects/framework-common-extensions/widgets/generic_object_collector.py b/projects/framework-common-extensions/widgets/generic_object_collector.py new file mode 100644 index 0000000000..a6c9f3b119 --- /dev/null +++ b/projects/framework-common-extensions/widgets/generic_object_collector.py @@ -0,0 +1,100 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2024 ftrack +import os.path + +try: + from PySide6 import QtWidgets, QtCore +except ImportError: + from PySide2 import QtWidgets, QtCore + +from ftrack_framework_qt.widgets import BaseWidget +from ftrack_qt.utils.decorators import invoke_in_qt_main_thread + + +class GenericObjectCollectorWidget(BaseWidget): + '''Main class to represent a context widget on a publish process.''' + + name = 'generic_object_collector' + ui_type = 'qt' + + items_changed = QtCore.Signal(object) + + def __init__( + self, + event_manager, + client_id, + context_id, + plugin_config, + group_config, + on_set_plugin_option, + on_run_ui_hook, + parent=None, + ): + '''initialise PublishContextWidget with *parent*, *session*, *data*, + *name*, *description*, *options* and *context* + ''' + self._object_list = None + self._add_button = None + self._remove_button = None + + super(GenericObjectCollectorWidget, self).__init__( + event_manager, + client_id, + context_id, + plugin_config, + group_config, + on_set_plugin_option, + on_run_ui_hook, + parent, + ) + + def pre_build_ui(self): + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setAlignment(QtCore.Qt.AlignTop) + self.setLayout(layout) + self.setSizePolicy( + QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed + ) + + def build_ui(self): + '''build function widgets.''' + + self._object_list = QtWidgets.QListWidget() + + buttons_layout = QtWidgets.QHBoxLayout() + self._add_button = QtWidgets.QPushButton('Add') + self._remove_button = QtWidgets.QPushButton('Remove') + + self.layout().addWidget(self._object_list) + buttons_layout.addWidget(self._add_button) + buttons_layout.addWidget(self._remove_button) + self.layout().addLayout(buttons_layout) + + def post_build_ui(self): + '''hook events''' + self._add_button.clicked.connect(self._on_add_object_callback) + self._remove_button.clicked.connect(self._on_remove_object_callback) + + def _on_add_object_callback(self): + # TODO: query object from scene and add the result into the list + self._query_selected_objects() + + def _on_remove_object_callback(self): + selected_items = self._object_list.selectedItems() + for item in selected_items: + self._object_list.takeItem(self._object_list.row(item)) + self.items_changed.emit(self._object_list.items()) + + def _query_selected_objects(self): + payload = {} + self.run_ui_hook(payload) + + @invoke_in_qt_main_thread + def ui_hook_callback(self, ui_hook_result): + '''Handle the result of the UI hook.''' + super(GenericObjectCollectorWidget, self).ui_hook_callback( + ui_hook_result + ) + self._object_list.addItems(ui_hook_result) + self.items_changed.emit(self._object_list.items()) From 842774fdb15c4035ddef847551063c572a416133 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Mon, 30 Sep 2024 11:23:17 +0200 Subject: [PATCH 10/12] generic collector working with a generic object collector --- .../plugins/generic_collector.py | 12 ++ .../standalone-generic-publisher.yaml | 3 - .../widgets/generic_accordion_collector.py | 158 ++++++++++++++++-- .../widgets/generic_object_collector.py | 100 ----------- 4 files changed, 155 insertions(+), 118 deletions(-) delete mode 100644 projects/framework-common-extensions/widgets/generic_object_collector.py diff --git a/projects/framework-common-extensions/plugins/generic_collector.py b/projects/framework-common-extensions/plugins/generic_collector.py index d0e344aed0..775efcc3db 100644 --- a/projects/framework-common-extensions/plugins/generic_collector.py +++ b/projects/framework-common-extensions/plugins/generic_collector.py @@ -10,6 +10,18 @@ class GenericCollectorPlugin(BasePlugin): name = 'generic_collector' + def ui_hook(self, payload): + ''' ''' + mock_items = [ + 'item1', + 'item2', + ] + result = { + 'widget': payload.get('widget'), + 'items': mock_items, + } + return result + def run(self, store): ''' Get folder_path and file_name from the :obj:`self.options` diff --git a/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml b/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml index 9fa8a7edc3..499b8cf681 100644 --- a/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml +++ b/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml @@ -16,9 +16,6 @@ engine: plugin: generic_collector options: editable_component_name: True - available_collector_widgets: - - FileBrowser - - GenericObjectCollectorWidget ui: generic_accordion_collector diff --git a/projects/framework-common-extensions/widgets/generic_accordion_collector.py b/projects/framework-common-extensions/widgets/generic_accordion_collector.py index d1bbf63a0a..97b6443d5b 100644 --- a/projects/framework-common-extensions/widgets/generic_accordion_collector.py +++ b/projects/framework-common-extensions/widgets/generic_accordion_collector.py @@ -18,12 +18,83 @@ # TODO: Remove this from qt widgets as it needs to be loaded from the registry from ftrack_qt.widgets.browsers import FileBrowser -from ftrack_qt.widgets.browsers.generic_object_collector import ( - GenericObjectCollectorWidget, -) +from ftrack_qt.utils.decorators import invoke_in_qt_main_thread -SUPPORTED_COLLECTORS = ['FileBrowser'] + +class GenericObjectCollectorWidget(QtWidgets.QWidget): + '''Main class to represent a context widget on a publish process.''' + + items_changed = QtCore.Signal(object) + collect_items = QtCore.Signal() + + def __init__( + self, + parent=None, + ): + '''initialise PublishContextWidget with *parent*, *session*, *data*, + *name*, *description*, *options* and *context* + ''' + super(GenericObjectCollectorWidget, self).__init__(parent) + self._object_list = None + self._add_button = None + self._remove_button = None + self.pre_build_ui() + self.build_ui() + self.post_build_ui() + + def pre_build_ui(self): + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setAlignment(QtCore.Qt.AlignTop) + self.setLayout(layout) + self.setSizePolicy( + QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed + ) + + def build_ui(self): + '''build function widgets.''' + + self._object_list = QtWidgets.QListWidget() + + buttons_layout = QtWidgets.QHBoxLayout() + self._add_button = QtWidgets.QPushButton('Add') + self._remove_button = QtWidgets.QPushButton('Remove') + + self.layout().addWidget(self._object_list) + buttons_layout.addWidget(self._add_button) + buttons_layout.addWidget(self._remove_button) + self.layout().addLayout(buttons_layout) + + def post_build_ui(self): + '''hook events''' + self._add_button.clicked.connect(self._on_add_object_callback) + self._remove_button.clicked.connect(self._on_remove_object_callback) + + def _on_add_object_callback(self): + self.collect_items.emit() + + def _on_remove_object_callback(self): + selected_items = self._object_list.selectedItems() + for item in selected_items: + self._object_list.takeItem(self._object_list.row(item)) + self.items_changed.emit(self._get_items()) + + def _get_items(self): + items = [] + for index in range(0, self._object_list.count()): + items.append(self._object_list.item(index)) + return items + + def add_items(self, items): + self._object_list.addItems(items) + self.items_changed.emit(self._get_items()) + + def remove_items(self, items, emit_signal=True): + for item in items: + self._object_list.takeItem(self._object_list.row(item)) + if emit_signal: + self.items_changed.emit(self._get_items()) class GenericAccordionCollectorWidget(BaseWidget): @@ -34,6 +105,9 @@ class GenericAccordionCollectorWidget(BaseWidget): path_changed = QtCore.Signal(object) + def get_available_widgets(self): + return [FileBrowser, GenericObjectCollectorWidget] + def __init__( self, event_manager, @@ -74,22 +148,23 @@ def pre_build_ui(self): def build_ui(self): '''build function widgets.''' + available_widgets = self.get_available_widgets() - self._on_add_component_callback(collector_widget_name='FileBrowser') + self._on_add_component_callback( + collector_widget_class=available_widgets[0] + ) self._circular_add_button = CircularButton('add', variant='outlined') self._menu_button = MenuButton(self._circular_add_button) - for collector_widget in self.plugin_config.get('options', {}).get( - 'available_collector_widgets', 'FileBrowser' - ): + for collector_widget in available_widgets: self._menu_button.add_item( item_data=collector_widget, - label=collector_widget, + label=collector_widget.__name__, ) self.layout().addWidget(self._menu_button) - def _on_add_component_callback(self, collector_widget_name): + def _on_add_component_callback(self, collector_widget_class): editable_component_name = self.plugin_config.get('options', {}).get( 'editable_component_name', True ) @@ -119,24 +194,33 @@ def _on_add_component_callback(self, collector_widget_name): # TODO: Could be good to have the registry in the widgets as well, # so we could query the available widgets in there. # But for now we will hardcode it. - if collector_widget_name not in SUPPORTED_COLLECTORS: + if str(collector_widget_class) not in str( + self.get_available_widgets() + ): self.logger.warning( - f"Collector widget not supported: {collector_widget_name}" + f"Collector widget not supported: {str(collector_widget_class)}" ) QMessage_box = QtWidgets.QMessageBox() QMessage_box.setText( - f"Collector widget not supported: {collector_widget_name}" + f"Collector widget not supported: {str(collector_widget_class)}" ) QMessage_box.exec_() return - widget_cls = globals()[collector_widget_name] - widget_instance = widget_cls() + widget_instance = collector_widget_class() accordion_widget.add_widget(widget_instance) if hasattr(widget_instance, 'path_changed'): if editable_component_name: widget_instance.path_changed.connect( partial(self._on_path_changed_callback, accordion_widget) ) + if hasattr(widget_instance, 'items_changed'): + widget_instance.items_changed.connect( + partial(self._on_items_changed_callback, accordion_widget) + ) + if hasattr(widget_instance, 'collect_items'): + widget_instance.collect_items.connect( + self._on_collect_items_callback + ) self._accordion_widgets_registry.append(accordion_widget) latest_idx = self._get_latest_component_index() self.layout().insertWidget(latest_idx + 1, accordion_widget) @@ -226,6 +310,50 @@ def _on_path_changed_callback(self, accordion_widget, file_path): # self.path_changed.emit(file_path) + def _on_items_changed_callback(self, accordion_widget, items_list): + ''' + Callback to update the component name when the path is changed. + Updates the option dictionary with provided *asset_name* when + asset_changed of asset_selector event is triggered + ''' + current_components = self.plugin_options.get('components', {}) + if current_components.get(accordion_widget.title): + current_components.pop(accordion_widget.title) + if not items_list: + return + + component_name = accordion_widget.title + if not component_name or component_name == '': + self.logger.warning( + f"No component name has been set. Please set a component name before continue." + ) + QMessage_box = QtWidgets.QMessageBox() + QMessage_box.setText( + f"Please set a component name before collecting objects" + ) + QMessage_box.exec_() + self.sender().remove_items(items_list, emit_signal=False) + return + + option_value = { + 'items_list': [item.text() for item in items_list], + } + + current_components.update({accordion_widget.title: option_value}) + self.set_plugin_option('components', current_components) + + def _on_collect_items_callback(self): + payload = {'widget': self.sender()} + self.run_ui_hook(payload) + + @invoke_in_qt_main_thread + def ui_hook_callback(self, ui_hook_result): + '''Handle the result of the UI hook.''' + super(GenericAccordionCollectorWidget, self).ui_hook_callback( + ui_hook_result + ) + ui_hook_result.get("widget").add_items(ui_hook_result.get("items")) + def get_available_component_name(self, name, skip_widget=None): def increment_name(name): if '_' in name and name.rsplit('_', 1)[-1].isdigit(): diff --git a/projects/framework-common-extensions/widgets/generic_object_collector.py b/projects/framework-common-extensions/widgets/generic_object_collector.py deleted file mode 100644 index a6c9f3b119..0000000000 --- a/projects/framework-common-extensions/widgets/generic_object_collector.py +++ /dev/null @@ -1,100 +0,0 @@ -# :coding: utf-8 -# :copyright: Copyright (c) 2024 ftrack -import os.path - -try: - from PySide6 import QtWidgets, QtCore -except ImportError: - from PySide2 import QtWidgets, QtCore - -from ftrack_framework_qt.widgets import BaseWidget -from ftrack_qt.utils.decorators import invoke_in_qt_main_thread - - -class GenericObjectCollectorWidget(BaseWidget): - '''Main class to represent a context widget on a publish process.''' - - name = 'generic_object_collector' - ui_type = 'qt' - - items_changed = QtCore.Signal(object) - - def __init__( - self, - event_manager, - client_id, - context_id, - plugin_config, - group_config, - on_set_plugin_option, - on_run_ui_hook, - parent=None, - ): - '''initialise PublishContextWidget with *parent*, *session*, *data*, - *name*, *description*, *options* and *context* - ''' - self._object_list = None - self._add_button = None - self._remove_button = None - - super(GenericObjectCollectorWidget, self).__init__( - event_manager, - client_id, - context_id, - plugin_config, - group_config, - on_set_plugin_option, - on_run_ui_hook, - parent, - ) - - def pre_build_ui(self): - layout = QtWidgets.QVBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - layout.setAlignment(QtCore.Qt.AlignTop) - self.setLayout(layout) - self.setSizePolicy( - QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed - ) - - def build_ui(self): - '''build function widgets.''' - - self._object_list = QtWidgets.QListWidget() - - buttons_layout = QtWidgets.QHBoxLayout() - self._add_button = QtWidgets.QPushButton('Add') - self._remove_button = QtWidgets.QPushButton('Remove') - - self.layout().addWidget(self._object_list) - buttons_layout.addWidget(self._add_button) - buttons_layout.addWidget(self._remove_button) - self.layout().addLayout(buttons_layout) - - def post_build_ui(self): - '''hook events''' - self._add_button.clicked.connect(self._on_add_object_callback) - self._remove_button.clicked.connect(self._on_remove_object_callback) - - def _on_add_object_callback(self): - # TODO: query object from scene and add the result into the list - self._query_selected_objects() - - def _on_remove_object_callback(self): - selected_items = self._object_list.selectedItems() - for item in selected_items: - self._object_list.takeItem(self._object_list.row(item)) - self.items_changed.emit(self._object_list.items()) - - def _query_selected_objects(self): - payload = {} - self.run_ui_hook(payload) - - @invoke_in_qt_main_thread - def ui_hook_callback(self, ui_hook_result): - '''Handle the result of the UI hook.''' - super(GenericObjectCollectorWidget, self).ui_hook_callback( - ui_hook_result - ) - self._object_list.addItems(ui_hook_result) - self.items_changed.emit(self._object_list.items()) From 2acbf0725feef10c2d672e169ab212468ea38707 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Mon, 30 Sep 2024 11:32:37 +0200 Subject: [PATCH 11/12] Release notes ready --- libs/framework-core/pyproject.toml | 2 +- libs/framework-core/release_notes.md | 7 +++ .../ftrack_framework_core/host/__init__.py | 1 + .../registry/__init__.py | 60 ++++++++++++++----- libs/utils/pyproject.toml | 2 +- libs/utils/release_notes.md | 6 ++ .../ftrack_utils/framework/config/tool.py | 4 ++ 7 files changed, 66 insertions(+), 16 deletions(-) diff --git a/libs/framework-core/pyproject.toml b/libs/framework-core/pyproject.toml index e600e5e290..d1e0b9f514 100644 --- a/libs/framework-core/pyproject.toml +++ b/libs/framework-core/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ftrack-framework-core" -version = "3.0.1" +version = "3.1.0" description='ftrack Framework Core library' authors = ["ftrack Integrations Team "] readme = "README.md" diff --git a/libs/framework-core/release_notes.md b/libs/framework-core/release_notes.md index 633271c90c..f975396c7e 100644 --- a/libs/framework-core/release_notes.md +++ b/libs/framework-core/release_notes.md @@ -1,5 +1,12 @@ # ftrack Framework Core library release Notes +## v3.1.0 +2024-09-30 + +* [change] Registry; Support get tool config extensions by reference. +* [change] Registry; Support create references in a tool-config section. + + ## v3.0.1 2024-09-25 diff --git a/libs/framework-core/source/ftrack_framework_core/host/__init__.py b/libs/framework-core/source/ftrack_framework_core/host/__init__.py index c4c7b7df9c..b1988cd40a 100644 --- a/libs/framework-core/source/ftrack_framework_core/host/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/host/__init__.py @@ -310,6 +310,7 @@ def run_ui_hook_callback(self, event): client_options = event['data']['client_options'] payload = event['data']['payload'] + # TODO: we should be able to replace this to: tool_config = self.registry.get_one(extension_type='tool_config', reference=tool_config_reference) for typed_configs in self.tool_configs.values(): tool_config = None for _tool_config in typed_configs: diff --git a/libs/framework-core/source/ftrack_framework_core/registry/__init__.py b/libs/framework-core/source/ftrack_framework_core/registry/__init__.py index 1733dddec6..e390a856ea 100644 --- a/libs/framework-core/source/ftrack_framework_core/registry/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/registry/__init__.py @@ -124,13 +124,15 @@ def scan_extensions(self, paths, extension_types=None): for extension in unique_extensions: self.add(**extension) - def add(self, extension_type, name, extension, path): + def add( + self, extension_type, name, extension, path, create_reference=True + ): ''' Add the given *extension_type* with *name*, *extension* and *path to the registry ''' - if extension_type == 'tool_config': - self.augment_tool_config(extension) + if extension_type == 'tool_config' and create_reference: + self.create_unic_references(extension, skip_root=False) # We use extension_type and not type to not interfere with python # build in type self.__registry[extension_type].append( @@ -141,12 +143,12 @@ def add(self, extension_type, name, extension, path): } ) - def _get(self, extensions, name, extension, path): + def _get(self, extensions, name, extension, path, reference=None): ''' Check given *extensions* list to match given *name*, *extension* and *path* if neither provided, return all available extensions ''' - if not any([name, extension, path]): + if not any([name, extension, path, reference]): return extensions found_extensions = [] for _extension in extensions: @@ -156,10 +158,21 @@ def _get(self, extensions, name, extension, path): continue if path and _extension['path'] != path: continue + if reference and isinstance(_extension['extension'], dict): + if _extension['extension'].get('type') == 'tool_config': + if _extension['extension'].get('reference') != reference: + continue found_extensions.append(_extension) return found_extensions - def get(self, name=None, extension=None, path=None, extension_type=None): + def get( + self, + name=None, + extension=None, + path=None, + extension_type=None, + reference=None, + ): ''' Return given matching *name*, *extension*, *path* or *extension_type*. If nothing provided, return all available extensions. @@ -168,13 +181,25 @@ def get(self, name=None, extension=None, path=None, extension_type=None): if extension_type: extensions = self.registry.get(extension_type) found_extensions.extend( - self._get(extensions, name, extension, path) + self._get( + extensions=extensions, + name=name, + extension=extension, + path=path, + reference=reference, + ) ) else: for extension_type in list(self.registry.keys()): extensions = self.registry.get(extension_type) found_extensions.extend( - self._get(extensions, name, extension, path) + self._get( + extensions=extensions, + name=name, + extension=extension, + path=path, + reference=reference, + ) ) return found_extensions @@ -186,30 +211,37 @@ def get_one(self, *args, **kwargs): ''' matching_extensions = self.get(*args, **kwargs) if len(matching_extensions) == 0: - kwargs_string = ''.join([('%s=%s' % x) for x in kwargs.items()]) - raise Exception( + kwargs_string = ', '.join([('%s=%s' % x) for x in kwargs.items()]) + self.logger.warning( "Extension not found. Arguments: {}".format( (''.join(args), kwargs_string) ) ) + return None if len(matching_extensions) > 1: - kwargs_string = ''.join([('%s=%s' % x) for x in kwargs.items()]) - raise Exception( + kwargs_string = ', '.join([('%s=%s' % x) for x in kwargs.items()]) + self.logger.warning( "Multiple matching extensions found.Arguments: {}".format( (''.join(args), kwargs_string) ) ) + return None return matching_extensions[0] - def augment_tool_config(self, tool_config): + def create_unic_references(self, tool_config, skip_root=False): ''' Augment the given *tool_config* to add a reference id to it and each plugin and group ''' - tool_config['reference'] = uuid.uuid4().hex + if not skip_root: + tool_config['reference'] = uuid.uuid4().hex if 'engine' in tool_config: self._recursive_create_reference(tool_config['engine']) + else: + # if doesn't contain engine, we assume that is a portion of the tool + # config so we augment that portion + self._recursive_create_reference([tool_config]) return tool_config def _recursive_create_reference(self, tool_config_engine_portion): diff --git a/libs/utils/pyproject.toml b/libs/utils/pyproject.toml index f1ca300c27..1f4dacec38 100644 --- a/libs/utils/pyproject.toml +++ b/libs/utils/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ftrack-utils" -version = "3.0.1" +version = "3.1.0" description='ftrack utils library' authors = ["ftrack Integrations Team "] readme = "README.md" diff --git a/libs/utils/release_notes.md b/libs/utils/release_notes.md index c1c2ba2c7c..d3c717bdc5 100644 --- a/libs/utils/release_notes.md +++ b/libs/utils/release_notes.md @@ -1,5 +1,11 @@ # ftrack Utils library release Notes +## v3.1.0 +2024-09-30 + +* [changed] framework tool configs; get_groups method dictionary support on filters argument.. + + ## v3.0.1 2024-09-25 diff --git a/libs/utils/source/ftrack_utils/framework/config/tool.py b/libs/utils/source/ftrack_utils/framework/config/tool.py index ca904ba8f2..a9282f0315 100644 --- a/libs/utils/source/ftrack_utils/framework/config/tool.py +++ b/libs/utils/source/ftrack_utils/framework/config/tool.py @@ -123,6 +123,10 @@ def get_groups(tool_config, filters=None, top_level_only=True): if isinstance(obj.get(k), list): if not any(x in obj[k] for x in v): candidate = False + elif isinstance(obj.get(k), dict): + for key, value in v.items(): + if obj[k].get(key) != value: + candidate = False elif isinstance(obj.get(k), str): if obj[k] != v: candidate = False From 5a8d6a8fdf2f17b521c867e9d4dd7af17f4f3263 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Mon, 30 Sep 2024 11:53:25 +0200 Subject: [PATCH 12/12] cleanup query of tool config in host --- .../source/ftrack_framework_core/host/__init__.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/libs/framework-core/source/ftrack_framework_core/host/__init__.py b/libs/framework-core/source/ftrack_framework_core/host/__init__.py index b1988cd40a..ec6309be1f 100644 --- a/libs/framework-core/source/ftrack_framework_core/host/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/host/__init__.py @@ -310,16 +310,11 @@ def run_ui_hook_callback(self, event): client_options = event['data']['client_options'] payload = event['data']['payload'] - # TODO: we should be able to replace this to: tool_config = self.registry.get_one(extension_type='tool_config', reference=tool_config_reference) - for typed_configs in self.tool_configs.values(): - tool_config = None - for _tool_config in typed_configs: - if _tool_config['reference'] == tool_config_reference: - tool_config = _tool_config - break - if tool_config: - break - else: + tool_config = self.registry.get_one( + extension_type='tool_config', reference=tool_config_reference + ) + tool_config = tool_config.get('extension') + if not tool_config: raise Exception( 'Given tool config reference {} not found on registered ' 'tool_configs. \n {}'.format(