diff --git a/app.py b/app.py index af39b3a4..6b5dd717 100644 --- a/app.py +++ b/app.py @@ -12,6 +12,8 @@ A loader application that lets you add new items to the scene. """ +from types import ModuleType + import sgtk import os @@ -28,6 +30,7 @@ def init_app(self): return tk_multi_loader = self.import_module("tk_multi_loader") + self._flowam = tk_multi_loader.flowam # the manager class provides the interface for loading. We store a # reference to it to enable the create_loader_action_manager method exposed on @@ -95,3 +98,15 @@ def create_loader_manager(self, bundle=None): :returns: A :class:`tk_multi_loader.LoaderManager` instance """ return self._manager_class(bundle or self) + + @property + def flowam(self) -> ModuleType: + """ + Access to the FlowAM integration module for this app. This module provides + drop-in replacements for the standard Shotgun-based Loader models and actions, + backed by Flow Asset Management (FlowAM) instead of the ShotGrid REST API. + + :returns: The FlowAM integration module for this app + :rtype: :mod:`tk_multi_loader.flowam` + """ + return self._flowam diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 1f83ab32..8b5b1780 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -46,3 +46,4 @@ jobs: - name: tk-framework-qtwidgets - name: tk-framework-shotgunutils - name: tk-shell + tk_core_ref: ticket/sg-43461/migrate-host-base diff --git a/hooks/tk-3dsmaxplus_actions.py b/hooks/tk-3dsmaxplus_actions.py index 7903713f..33bd40fb 100644 --- a/hooks/tk-3dsmaxplus_actions.py +++ b/hooks/tk-3dsmaxplus_actions.py @@ -24,7 +24,7 @@ class MaxActions(HookBaseClass): ############################################################################################################## # public interface - to be overridden by deriving classes - def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): + def generate_actions(self, sg_publish_data, actions, ui_area): """ Returns a list of action instances for a particular publish. This method is called each time a user clicks a publish somewhere in the UI. @@ -101,7 +101,7 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): return action_instances - def execute_multiple_actions(self, actions, **kwargs): + def execute_multiple_actions(self, actions): """ Executes the specified action on a list of items. @@ -132,7 +132,7 @@ def execute_multiple_actions(self, actions, **kwargs): params = single_action["params"] self.execute_action(name, params, sg_publish_data) - def execute_action(self, name, params, sg_publish_data, **kwargs): + def execute_action(self, name, params, sg_publish_data): """ Execute a given action. The data sent to this be method will represent one of the actions enumerated by the generate_actions method. diff --git a/hooks/tk-desktop_actions.py b/hooks/tk-desktop_actions.py index 12cdabec..b47d52b5 100644 --- a/hooks/tk-desktop_actions.py +++ b/hooks/tk-desktop_actions.py @@ -31,7 +31,6 @@ def generate_actions( sg_publish_data: dict, actions: list, ui_area: str, - **kwargs, ) -> list: """ Return a list of action instances for a particular publish. @@ -77,21 +76,15 @@ def generate_actions( action_instances = [] - enable_flowam = app.get_setting("enable_flowam", False) - if enable_flowam: - am_base_obj = kwargs.get("am_base_obj") - if not am_base_obj: - raise Exception( - "FlowAM is enabled but no Asset Management base object was passed to the action hook. " - "FlowAM specific actions will not be generated." - ) + if app.context.flow_project_id: + flowam_actions = app.flowam.FlowAMActions() if "download" in actions and sg_publish_data.get("type") == "PublishedFile": version_number = sg_publish_data.get("version_number") if ( version_number is not None - and version_number != am_base_obj.DRAFT_VERSION_IDENTIFIER + and version_number != flowam_actions.DRAFT_VERSION_IDENTIFIER ): action_instances.append( { @@ -130,9 +123,9 @@ def generate_actions( if ( "reference_copy_link" in actions and sg_publish_data.get( - "version_number", am_base_obj.DRAFT_VERSION_IDENTIFIER + "version_number", flowam_actions.DRAFT_VERSION_IDENTIFIER ) - > am_base_obj.DRAFT_VERSION_IDENTIFIER + > flowam_actions.DRAFT_VERSION_IDENTIFIER ): action_instances.append( { @@ -146,7 +139,7 @@ def generate_actions( return action_instances - def execute_multiple_actions(self, actions: list, **kwargs) -> None: + def execute_multiple_actions(self, actions: list) -> None: """ Executes the specified action on a list of items. @@ -171,22 +164,19 @@ def execute_multiple_actions(self, actions: list, **kwargs) -> None: :param list actions: Action dictionaries. """ - app = self.parent - app.log_info("Executing action '%s' on the selection") # Helps to visually scope selections # Execute each action. for single_action in actions: name = single_action["name"] sg_publish_data = single_action["sg_publish_data"] params = single_action["params"] - self.execute_action(name, params, sg_publish_data, **kwargs) + self.execute_action(name, params, sg_publish_data) def execute_action( self, name: str, params: Any, sg_publish_data: dict, - **kwargs, ) -> None: """ Print out all actions. The data sent to this be method will @@ -202,7 +192,7 @@ def execute_action( "Execute action called for action %s. " "Parameters: %s. Publish Data: %s" % (name, params, sg_publish_data) ) - am_base_obj = kwargs.get("am_base_obj") + flowam_actions = app.flowam.FlowAMActions() if name == "create_generic_asset": # Right click a task the left panel @@ -213,10 +203,10 @@ def execute_action( self._launch_publisher(name, sg_publish_data) elif name == "reference_copy_link": - am_base_obj._create_reference_copy_link(sg_publish_data) + flowam_actions._create_reference_copy_link(sg_publish_data) elif name == "download": - am_base_obj._download_asset_revision(sg_publish_data) + flowam_actions._download_asset_revision(sg_publish_data) def _launch_publisher(self, action_name: str, sg_publish_data: dict) -> None: """ diff --git a/hooks/tk-flame_actions.py b/hooks/tk-flame_actions.py index a75bcea1..8a6da4ab 100644 --- a/hooks/tk-flame_actions.py +++ b/hooks/tk-flame_actions.py @@ -44,7 +44,7 @@ class FlameActionError(Exception): class FlameActions(HookBaseClass): ############################################################################################################## # public interface - to be overridden by deriving classes - def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): + def generate_actions(self, sg_publish_data, actions, ui_area): """ Returns a list of action instances for a particular publish. This method is called each time a user clicks a publish somewhere in the UI. @@ -136,7 +136,7 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): return action_instances - def execute_multiple_actions(self, actions, **kwargs): + def execute_multiple_actions(self, actions): """ Executes the specified action on a list of items. @@ -167,7 +167,7 @@ def execute_multiple_actions(self, actions, **kwargs): params = single_action["params"] self.execute_action(name, params, sg_publish_data) - def execute_action(self, name, params, sg_publish_data, **kwargs): + def execute_action(self, name, params, sg_publish_data): """ Execute a given action. The data sent to this be method will represent one of the actions enumerated by the generate_actions method. diff --git a/hooks/tk-houdini_actions.py b/hooks/tk-houdini_actions.py index 0d9ec7ac..9a8b90aa 100644 --- a/hooks/tk-houdini_actions.py +++ b/hooks/tk-houdini_actions.py @@ -16,6 +16,7 @@ import re import sgtk +from tank_vendor.flow_integration_sdk.sandbox import is_new_asset HookBaseClass = sgtk.get_hook_baseclass() @@ -25,7 +26,7 @@ class HoudiniActions(HookBaseClass): ############################################################################################################## # public interface - to be overridden by deriving classes - def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): + def generate_actions(self, sg_publish_data, actions, ui_area): """ Returns a list of action instances for a particular publish. This method is called each time a user clicks a publish somewhere in the UI. @@ -102,22 +103,18 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): # ----------------------- # FlowAM specific actions # ----------------------- - enable_flowam = app.get_setting("enable_flowam", False) - if enable_flowam: - am_base_obj = kwargs.get("am_base_obj") - if not am_base_obj: - raise Exception( - "FlowAM is enabled but no Asset Management base object was passed to the action hook. " - "FlowAM specific actions will not be generated." - ) + if self.parent.context.flow_project_id: + flowam_actions = app.flowam.FlowAMActions() if "open" in actions and sg_publish_data.get("type") == "PublishedFile": if ( - am_base_obj._is_local_draft(sg_publish_data) + flowam_actions.is_local_draft_by_revision( + sg_publish_data.get("sg_flow_revision_id") + ) or sg_publish_data.get( - "version_number", am_base_obj.DRAFT_VERSION_IDENTIFIER + "version_number", flowam_actions.DRAFT_VERSION_IDENTIFIER ) - > am_base_obj.DRAFT_VERSION_IDENTIFIER + > flowam_actions.DRAFT_VERSION_IDENTIFIER ): action_instances.append( { @@ -134,7 +131,7 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): if ( version_number is not None - and version_number != am_base_obj.DRAFT_VERSION_IDENTIFIER + and version_number != flowam_actions.DRAFT_VERSION_IDENTIFIER ): action_instances.append( { @@ -148,9 +145,9 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): if "discard_draft" in actions: draft_id = sg_publish_data.get("sg_flow_revision_id") - if am_base_obj._is_local_draft( - sg_publish_data - ) and am_base_obj._is_new_asset(draft_id): + if flowam_actions.is_local_draft_by_revision( + sg_publish_data.get("sg_flow_revision_id") + ) and is_new_asset(draft_id): action_instances.append( { "name": "discard_draft", @@ -163,9 +160,9 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): if ( "reference_copy_link" in actions and sg_publish_data.get( - "version_number", am_base_obj.DRAFT_VERSION_IDENTIFIER + "version_number", flowam_actions.DRAFT_VERSION_IDENTIFIER ) - != am_base_obj.DRAFT_VERSION_IDENTIFIER + != flowam_actions.DRAFT_VERSION_IDENTIFIER ): action_instances.append( { @@ -199,7 +196,7 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): return action_instances - def execute_multiple_actions(self, actions, **kwargs): + def execute_multiple_actions(self, actions): """ Executes the specified action on a list of items. @@ -228,9 +225,9 @@ def execute_multiple_actions(self, actions, **kwargs): name = single_action["name"] sg_publish_data = single_action["sg_publish_data"] params = single_action["params"] - self.execute_action(name, params, sg_publish_data, **kwargs) + self.execute_action(name, params, sg_publish_data) - def execute_action(self, name, params, sg_publish_data, **kwargs): + def execute_action(self, name, params, sg_publish_data): """ Execute a given action. The data sent to this be method will represent one of the actions enumerated by the generate_actions method. @@ -249,27 +246,26 @@ def execute_action(self, name, params, sg_publish_data, **kwargs): # ----------------------- # FlowAM specific actions # ----------------------- - enable_flowam = app.get_setting("enable_flowam", False) - if enable_flowam: - am_base_obj = kwargs.get("am_base_obj") + if self.parent.context.flow_project_id: + flowam_actions = app.flowam.FlowAMActions() if name == "open": - am_base_obj._do_open(sg_publish_data) + flowam_actions._do_open(sg_publish_data) if name == "reference_copy_link": - am_base_obj._create_reference_copy_link(sg_publish_data) + flowam_actions._create_reference_copy_link(sg_publish_data) if name == "discard_draft": - am_base_obj._discard_draft(sg_publish_data) + flowam_actions._discard_draft(sg_publish_data) if name == "build_new_scene": - am_base_obj._build_new_scene(sg_publish_data) + flowam_actions._build_new_scene(sg_publish_data) if name == "build_new_template": - am_base_obj._build_new_template(sg_publish_data) + flowam_actions._build_new_template(sg_publish_data) if name == "download": - am_base_obj._download_asset_revision(sg_publish_data) + flowam_actions._download_asset_revision(sg_publish_data) return diff --git a/hooks/tk-mari_actions.py b/hooks/tk-mari_actions.py index accd0ba0..64f44759 100644 --- a/hooks/tk-mari_actions.py +++ b/hooks/tk-mari_actions.py @@ -25,7 +25,7 @@ class MariActions(HookBaseClass): ############################################################################################################## # public interface - to be overridden by deriving classes - def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): + def generate_actions(self, sg_publish_data, actions, ui_area): """ Returns a list of action instances for a particular publish. This method is called each time a user clicks a publish somewhere in the UI. @@ -108,7 +108,7 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): return action_instances - def execute_multiple_actions(self, actions, **kwargs): + def execute_multiple_actions(self, actions): """ Executes the specified action on a list of items. @@ -139,7 +139,7 @@ def execute_multiple_actions(self, actions, **kwargs): params = single_action["params"] self.execute_action(name, params, sg_publish_data) - def execute_action(self, name, params, sg_publish_data, **kwargs): + def execute_action(self, name, params, sg_publish_data): """ Execute a given action. The data sent to this be method will represent one of the actions enumerated by the generate_actions method. diff --git a/hooks/tk-maya_actions.py b/hooks/tk-maya_actions.py index c7d9446b..662bbe68 100644 --- a/hooks/tk-maya_actions.py +++ b/hooks/tk-maya_actions.py @@ -19,6 +19,7 @@ import maya.cmds as cmds import maya.mel as mel import sgtk +from tank_vendor.flow_integration_sdk.sandbox import is_new_asset HookBaseClass = sgtk.get_hook_baseclass() @@ -28,7 +29,7 @@ class MayaActions(HookBaseClass): ############################################################################################################## # public interface - to be overridden by deriving classes - def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): + def generate_actions(self, sg_publish_data, actions, ui_area): """ Returns a list of action instances for a particular publish. This method is called each time a user clicks a publish somewhere in the UI. @@ -129,24 +130,20 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): # ----------------------- # FlowAM specific actions # ----------------------- - enable_flowam = app.get_setting("enable_flowam", False) - if enable_flowam: - am_base_obj = kwargs.get("am_base_obj") - if not am_base_obj: - raise Exception( - "FlowAM is enabled but no Asset Management base object was passed to the action hook. " - "FlowAM specific actions will not be generated." - ) + if self.parent.context.flow_project_id: + flowam_actions = app.flowam.FlowAMActions() if ( "open" in actions and sg_publish_data.get("type") == "PublishedFile" and ( - am_base_obj._is_local_draft(sg_publish_data) + flowam_actions.is_local_draft_by_revision( + sg_publish_data.get("sg_flow_revision_id") + ) or sg_publish_data.get( - "version_number", am_base_obj.DRAFT_VERSION_IDENTIFIER + "version_number", flowam_actions.DRAFT_VERSION_IDENTIFIER ) - > am_base_obj.DRAFT_VERSION_IDENTIFIER + > flowam_actions.DRAFT_VERSION_IDENTIFIER ) ): action_instances.append( @@ -165,7 +162,7 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): and ( sg_publish_data.get("version_number") is not None and sg_publish_data.get("version_number") - != am_base_obj.DRAFT_VERSION_IDENTIFIER + != flowam_actions.DRAFT_VERSION_IDENTIFIER ) ): action_instances.append( @@ -180,9 +177,9 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): if "discard_draft" in actions: draft_id = sg_publish_data.get("sg_flow_revision_id") - if am_base_obj._is_local_draft( - sg_publish_data - ) and am_base_obj._is_new_asset(draft_id): + if flowam_actions.is_local_draft_by_revision( + sg_publish_data.get("sg_flow_revision_id") + ) and is_new_asset(draft_id): action_instances.append( { "name": "discard_draft", @@ -195,9 +192,9 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): if ( "reference_am" in actions and sg_publish_data.get( - "version_number", am_base_obj.DRAFT_VERSION_IDENTIFIER + "version_number", flowam_actions.DRAFT_VERSION_IDENTIFIER ) - != am_base_obj.DRAFT_VERSION_IDENTIFIER + != flowam_actions.DRAFT_VERSION_IDENTIFIER ): action_instances.append( { @@ -211,9 +208,9 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): if ( "reference_copy_link" in actions and sg_publish_data.get( - "version_number", am_base_obj.DRAFT_VERSION_IDENTIFIER + "version_number", flowam_actions.DRAFT_VERSION_IDENTIFIER ) - != am_base_obj.DRAFT_VERSION_IDENTIFIER + != flowam_actions.DRAFT_VERSION_IDENTIFIER ): action_instances.append( { @@ -247,7 +244,7 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): return action_instances - def execute_multiple_actions(self, actions, **kwargs): + def execute_multiple_actions(self, actions): """ Executes the specified action on a list of items. @@ -276,9 +273,9 @@ def execute_multiple_actions(self, actions, **kwargs): name = single_action["name"] sg_publish_data = single_action["sg_publish_data"] params = single_action["params"] - self.execute_action(name, params, sg_publish_data, **kwargs) + self.execute_action(name, params, sg_publish_data) - def execute_action(self, name, params, sg_publish_data, **kwargs): + def execute_action(self, name, params, sg_publish_data): """ Execute a given action. The data sent to this be method will represent one of the actions enumerated by the generate_actions method. @@ -297,30 +294,29 @@ def execute_action(self, name, params, sg_publish_data, **kwargs): # ----------------------- # FlowAM specific actions # ----------------------- - enable_flowam = app.get_setting("enable_flowam", False) - if enable_flowam: - am_base_obj = kwargs.get("am_base_obj") + if self.parent.context.flow_project_id: + flowam_actions = app.flowam.FlowAMActions() if name == "reference_am": - am_base_obj._create_reference_am(sg_publish_data) + flowam_actions._create_reference_am(sg_publish_data) if name == "reference_copy_link": - am_base_obj._create_reference_copy_link(sg_publish_data) + flowam_actions._create_reference_copy_link(sg_publish_data) if name == "open": - am_base_obj._do_open(sg_publish_data) + flowam_actions._do_open(sg_publish_data) if name == "discard_draft": - am_base_obj._discard_draft(sg_publish_data) + flowam_actions._discard_draft(sg_publish_data) if name == "build_new_scene": - am_base_obj._build_new_scene(sg_publish_data) + flowam_actions._build_new_scene(sg_publish_data) if name == "build_new_template": - am_base_obj._build_new_template(sg_publish_data) + flowam_actions._build_new_template(sg_publish_data) if name == "download": - am_base_obj._download_asset_revision(sg_publish_data) + flowam_actions._download_asset_revision(sg_publish_data) return diff --git a/hooks/tk-motionbuilder_actions.py b/hooks/tk-motionbuilder_actions.py index da28e656..990cec55 100644 --- a/hooks/tk-motionbuilder_actions.py +++ b/hooks/tk-motionbuilder_actions.py @@ -24,7 +24,7 @@ class MotionbuilderActions(HookBaseClass): ############################################################################################################## # public interface - to be overridden by deriving classes - def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): + def generate_actions(self, sg_publish_data, actions, ui_area): """ Returns a list of action instances for a particular publish. This method is called each time a user clicks a publish somewhere in the UI. @@ -81,7 +81,7 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): return action_instances - def execute_multiple_actions(self, actions, **kwargs): + def execute_multiple_actions(self, actions): """ Executes the specified action on a list of items. @@ -112,7 +112,7 @@ def execute_multiple_actions(self, actions, **kwargs): params = single_action["params"] self.execute_action(name, params, sg_publish_data) - def execute_action(self, name, params, sg_publish_data, **kwargs): + def execute_action(self, name, params, sg_publish_data): """ Execute a given action. The data sent to this be method will represent one of the actions enumerated by the generate_actions method. diff --git a/hooks/tk-nuke_actions.py b/hooks/tk-nuke_actions.py index b3d078b9..3bc166fd 100644 --- a/hooks/tk-nuke_actions.py +++ b/hooks/tk-nuke_actions.py @@ -15,7 +15,6 @@ import glob import os import re -import sys import sgtk @@ -27,7 +26,7 @@ class NukeActions(HookBaseClass): ############################################################################################################## # public interface - to be overridden by deriving classes - def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): + def generate_actions(self, sg_publish_data, actions, ui_area): """ Returns a list of action instances for a particular publish. This method is called each time a user clicks a publish somewhere in the UI. @@ -115,14 +114,8 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): # ----------------------- # FlowAM specific actions # ----------------------- - enable_flowam = app.get_setting("enable_flowam", False) - if enable_flowam: - am_base_obj = kwargs.get("am_base_obj") - if not am_base_obj: - raise Exception( - "FlowAM is enabled but no Asset Management base object was passed to the action hook. " - "FlowAM specific actions will not be generated." - ) + if self.parent.context.flow_project_id: + flowam_actions = app.flowam.FlowAMActions() if "build_new_script" in actions: action_instances.append( @@ -147,11 +140,13 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): # 1. Local drafts (version_number == -1 and is_local_draft) # 2. Published revisions (version_number > -1) if ( - am_base_obj._is_local_draft(sg_publish_data) + flowam_actions.is_local_draft_by_revision( + sg_publish_data.get("sg_flow_revision_id") + ) or sg_publish_data.get( - "version_number", am_base_obj.DRAFT_VERSION_IDENTIFIER + "version_number", flowam_actions.DRAFT_VERSION_IDENTIFIER ) - > am_base_obj.DRAFT_VERSION_IDENTIFIER + > flowam_actions.DRAFT_VERSION_IDENTIFIER ): action_instances.append( { @@ -162,8 +157,11 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): } ) - if "discard_draft" in actions and am_base_obj._is_local_draft( - sg_publish_data + if ( + "discard_draft" in actions + and flowam_actions.is_local_draft_by_revision( + sg_publish_data.get("sg_flow_revision_id") + ) ): action_instances.append( { @@ -176,9 +174,9 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): if ( "reference_copy_link" in actions and sg_publish_data.get( - "version_number", am_base_obj.DRAFT_VERSION_IDENTIFIER + "version_number", flowam_actions.DRAFT_VERSION_IDENTIFIER ) - != am_base_obj.DRAFT_VERSION_IDENTIFIER + != flowam_actions.DRAFT_VERSION_IDENTIFIER ): action_instances.append( { @@ -192,9 +190,9 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): if ( "create_read_node" in actions and sg_publish_data.get( - "version_number", am_base_obj.DRAFT_VERSION_IDENTIFIER + "version_number", flowam_actions.DRAFT_VERSION_IDENTIFIER ) - != am_base_obj.DRAFT_VERSION_IDENTIFIER + != flowam_actions.DRAFT_VERSION_IDENTIFIER ): action_instances.append( { @@ -207,7 +205,7 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): return action_instances - def execute_multiple_actions(self, actions, **kwargs): + def execute_multiple_actions(self, actions): """ Executes the specified action on a list of items. @@ -236,9 +234,9 @@ def execute_multiple_actions(self, actions, **kwargs): name = single_action["name"] sg_publish_data = single_action["sg_publish_data"] params = single_action["params"] - self.execute_action(name, params, sg_publish_data, **kwargs) + self.execute_action(name, params, sg_publish_data) - def execute_action(self, name, params, sg_publish_data, **kwargs): + def execute_action(self, name, params, sg_publish_data): """ Execute a given action. The data sent to this be method will represent one of the actions enumerated by the generate_actions method. @@ -260,25 +258,25 @@ def execute_action(self, name, params, sg_publish_data, **kwargs): # ----------------------- use_medm_data = app.get_setting("use_medm_data", False) if use_medm_data: - am_base_obj = kwargs.get("am_base_obj") + flowam_actions = app.flowam.FlowAMActions() if name == "build_new_script": - am_base_obj._build_new_scene(sg_publish_data) + flowam_actions._build_new_scene(sg_publish_data) if name == "build_new_template": - am_base_obj._build_new_template(sg_publish_data) + flowam_actions._build_new_template(sg_publish_data) if name == "open": - am_base_obj._do_open(sg_publish_data) + flowam_actions._do_open(sg_publish_data) if name == "discard_draft": - am_base_obj._discard_draft(sg_publish_data) + flowam_actions._discard_draft(sg_publish_data) if name == "reference_copy_link": - am_base_obj._create_reference_copy_link(sg_publish_data) + flowam_actions._create_reference_copy_link(sg_publish_data) if name == "create_read_node": - am_base_obj._create_reference(sg_publish_data) + flowam_actions._create_reference(sg_publish_data) return diff --git a/hooks/tk-photoshop_actions.py b/hooks/tk-photoshop_actions.py index 328e060c..c36a361a 100644 --- a/hooks/tk-photoshop_actions.py +++ b/hooks/tk-photoshop_actions.py @@ -34,7 +34,7 @@ class PhotoshopActions(HookBaseClass): ############################################################################################################## # public interface - to be overridden by deriving classes - def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): + def generate_actions(self, sg_publish_data, actions, ui_area): """ Returns a list of action instances for a particular publish. This method is called each time a user clicks a publish somewhere in the UI. @@ -101,7 +101,7 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): return action_instances - def execute_multiple_actions(self, actions, **kwargs): + def execute_multiple_actions(self, actions): """ Executes the specified action on a list of items. @@ -132,7 +132,7 @@ def execute_multiple_actions(self, actions, **kwargs): params = single_action["params"] self.execute_action(name, params, sg_publish_data) - def execute_action(self, name, params, sg_publish_data, **kwargs): + def execute_action(self, name, params, sg_publish_data): """ Execute a given action. The data sent to this be method will represent one of the actions enumerated by the generate_actions method. diff --git a/hooks/tk-photoshopcc_actions.py b/hooks/tk-photoshopcc_actions.py index 36c4fe8d..c3725d68 100644 --- a/hooks/tk-photoshopcc_actions.py +++ b/hooks/tk-photoshopcc_actions.py @@ -29,7 +29,7 @@ class PhotoshopActions(HookBaseClass): ############################################################################################################## # public interface - to be overridden by deriving classes - def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): + def generate_actions(self, sg_publish_data, actions, ui_area): """ Returns a list of action instances for a particular publish. This method is called each time a user clicks a publish somewhere in the UI. @@ -96,7 +96,7 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): return action_instances - def execute_multiple_actions(self, actions, **kwargs): + def execute_multiple_actions(self, actions): """ Executes the specified action on a list of items. @@ -129,7 +129,7 @@ def execute_multiple_actions(self, actions, **kwargs): params = single_action["params"] self.execute_action(name, params, sg_publish_data) - def execute_action(self, name, params, sg_publish_data, **kwargs): + def execute_action(self, name, params, sg_publish_data): """ Execute a given action. The data sent to this be method will represent one of the actions enumerated by the generate_actions method. diff --git a/hooks/tk-shell_actions.py b/hooks/tk-shell_actions.py index dca3a5db..4b7848e4 100644 --- a/hooks/tk-shell_actions.py +++ b/hooks/tk-shell_actions.py @@ -23,7 +23,7 @@ class ShellActions(HookBaseClass): Stub implementation of the shell actions, used for testing. """ - def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): + def generate_actions(self, sg_publish_data, actions, ui_area): """ Return a list of action instances for a particular publish. This method is called each time a user clicks a publish somewhere in the UI. @@ -111,7 +111,7 @@ def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): ) return action_instances - def execute_multiple_actions(self, actions, **kwargs): + def execute_multiple_actions(self, actions): """ Executes the specified action on a list of items. @@ -136,8 +136,6 @@ def execute_multiple_actions(self, actions, **kwargs): :param list actions: Action dictionaries. """ - app = self.parent - app.log_info("Executing action '%s' on the selection") # Helps to visually scope selections # Execute each action. for single_action in actions: @@ -146,7 +144,7 @@ def execute_multiple_actions(self, actions, **kwargs): params = single_action["params"] self.execute_action(name, params, sg_publish_data) - def execute_action(self, name, params, sg_publish_data, **kwargs): + def execute_action(self, name, params, sg_publish_data): """ Print out all actions. The data sent to this be method will represent one of the actions enumerated by the generate_actions method. diff --git a/info.yml b/info.yml index 6e530f5d..4d6efaca 100644 --- a/info.yml +++ b/info.yml @@ -33,14 +33,6 @@ configuration: Published File Types). The legacy Published File Type filter widget cannot be used in combination with the Filter menu. - enable_flowam: - type: bool - default_value: false - description: Set to True to use Flow Asset Management data instead of Shotgun - data. When enabled, the loader fetches publish data from the Flow Asset - Management system. Requires tk-framework-flowam to be configured in the - environment. - # hooks actions_hook: type: hook @@ -207,5 +199,3 @@ documentation_url: "https://help.autodesk.com/view/SGDEV/ENU/?guid=SG_Supervisor frameworks: - {"name": "tk-framework-shotgunutils", "version": "v5.x.x", "minimum_version": "v5.8.6"} - {"name": "tk-framework-qtwidgets", "version": "v2.x.x", "minimum_version": "v2.10.6"} - # TODO: Remove the following line after SG-43459. - - {"name": "tk-framework-flowam", "version": "v1.x.x"} diff --git a/python/tk_multi_loader/__init__.py b/python/tk_multi_loader/__init__.py index c6abd81f..d793ef23 100644 --- a/python/tk_multi_loader/__init__.py +++ b/python/tk_multi_loader/__init__.py @@ -8,6 +8,7 @@ # agreement to the Shotgun Pipeline Toolkit Source Code License. All rights # not expressly granted therein are reserved by Shotgun Software Inc. +from . import flowam # noqa: F401 from .api import LoaderManager # noqa: F401 from .open_publish_form import open_publish_browser # noqa: F401 diff --git a/python/tk_multi_loader/api/manager.py b/python/tk_multi_loader/api/manager.py index 32bf1237..c6665a85 100644 --- a/python/tk_multi_loader/api/manager.py +++ b/python/tk_multi_loader/api/manager.py @@ -130,7 +130,6 @@ def get_actions_for_publish(self, sg_data, ui_area): sg_publish_data=sg_data, actions=actions, ui_area=ui_area_str, - am_base_obj=self.get_am_base_obj(), ) except Exception: self._logger.exception("Could not execute generate_actions hook.") @@ -244,7 +243,6 @@ def execute_action(self, sg_data, action): name=action["name"], params=action["params"], sg_publish_data=sg_data, - am_base_obj=self.get_am_base_obj(), ) except Exception as e: self._logger.exception( @@ -268,7 +266,6 @@ def execute_multiple_actions(self, actions): "actions_hook", "execute_multiple_actions", actions=actions, - am_base_obj=self.get_am_base_obj(), ) except Exception as e: self._logger.exception( @@ -310,7 +307,6 @@ def get_actions_for_entity(self, sg_data): sg_publish_data=sg_data, actions=actions, ui_area="main", - am_base_obj=self.get_am_base_obj(), ) # folder options only found in main ui area except Exception: self._logger.exception("Could not execute generate_actions hook.") @@ -350,10 +346,3 @@ def _fix_timestamp(sg_data): unix_timestamp, shotgun_api3.sg_timezone.LocalTimezone() ) sg_data["created_at"] = sg_timestamp - - def get_am_base_obj(self) -> "FlowAMActions | None": - """ """ - if sgtk.platform.current_bundle().get_setting("enable_flowam", False): - from ..medm import FlowAMActions - - return FlowAMActions() diff --git a/python/tk_multi_loader/build_asset_dialog.py b/python/tk_multi_loader/build_asset_dialog.py index 4628d30a..735efd22 100644 --- a/python/tk_multi_loader/build_asset_dialog.py +++ b/python/tk_multi_loader/build_asset_dialog.py @@ -12,8 +12,12 @@ import sgtk from sgtk.platform.qt import QtGui +from sgtk.flowam.create import CreateMode +from tank_vendor.flow_integration_sdk.exceptions import FlowError +from tank_vendor.flow_integration_sdk.objects import FlowProject -from .medm.template_queries import get_template_pipeline_steps, get_templates +from .flowam.template_queries import get_template_pipeline_steps, get_templates +from .flowam.create import get_template_source_path from .ui.build_asset_dialog import Ui_BuildAssetDialog # Toolkit logger @@ -53,16 +57,10 @@ def __init__( """ super().__init__(parent) - _flow = sgtk.platform.import_framework("tk-framework-flowam", "flow") - _FlowError = _flow.FlowError - _Project = _flow.data.Project - self._CreateMode = _flow.asset_management.CreateMode - self._get_template_source_path = _flow.asset_management.get_template_source_path - # Query the project entity try: - self.project = _Project(project_id) - except _FlowError as exc: + self.project = FlowProject(project_id) + except FlowError as exc: raise ValueError(f"Project not found: {project_id}") from exc self.build = None @@ -85,9 +83,9 @@ def __init__( # Populate combo box from options list self.ui.build_mode_combo_box.addItems( [ - self._CreateMode.NEW.value, - self._CreateMode.CURRENT.value, - self._CreateMode.TEMPLATE.value, + CreateMode.NEW.value, + CreateMode.CURRENT.value, + CreateMode.TEMPLATE.value, ] ) @@ -143,7 +141,7 @@ def on_build_option_changed(self, text: str) -> None: Args: text (str): The new build option selected. """ - is_template_mode = self._CreateMode(text) == self._CreateMode.TEMPLATE + is_template_mode = CreateMode(text) == CreateMode.TEMPLATE self.setUpdatesEnabled(False) self.ui.templateWidget.setVisible(is_template_mode) @@ -191,14 +189,14 @@ def on_build_clicked(self) -> None: Returns: None. """ - self.build = self._CreateMode(self.ui.build_mode_combo_box.currentText()) + self.build = CreateMode(self.ui.build_mode_combo_box.currentText()) - if self.build == self._CreateMode.TEMPLATE: + if self.build == CreateMode.TEMPLATE: self.step = self.ui.pipeline_step_combo_box.currentText() self.template = self.ui.templates_combo_box.currentText() if self.template and self.template in self.templates: template = self.templates[self.template] - self.template_source_path = self._get_template_source_path(template) + self.template_source_path = get_template_source_path(template) else: self.step = None self.template = None diff --git a/python/tk_multi_loader/build_template_dialog.py b/python/tk_multi_loader/build_template_dialog.py index 079e8970..16d838ca 100644 --- a/python/tk_multi_loader/build_template_dialog.py +++ b/python/tk_multi_loader/build_template_dialog.py @@ -12,8 +12,11 @@ import sgtk from sgtk.platform.qt import QtGui +from sgtk.flowam.create import CreateMode +from tank_vendor.flow_integration_sdk.exceptions import FlowError +from tank_vendor.flow_integration_sdk.objects import FlowProject -from .medm.template_queries import find_template_pipeline_step, get_templates +from .flowam.template_queries import find_template_pipeline_step, get_templates from .ui.build_template_dialog import Ui_BuildTemplateDialog # Toolkit logger @@ -33,15 +36,10 @@ def __init__( ) -> None: super().__init__(parent) - _flow = sgtk.platform.import_framework("tk-framework-flowam", "flow") - _FlowError = _flow.FlowError - _Project = _flow.data.Project - self._CreateMode = _flow.asset_management.CreateMode - # Query the project entity try: - self.project = _Project(project_id) - except _FlowError as exc: + self.project = FlowProject(project_id) + except FlowError as exc: raise ValueError(f"Project not found: {project_id}") from exc if not pipeline_steps: @@ -56,7 +54,7 @@ def __init__( self.ui.setupUi(self) self.ui.build_mode_combo_box.addItems( - [self._CreateMode.NEW.value, self._CreateMode.CURRENT.value] + [CreateMode.NEW.value, CreateMode.CURRENT.value] ) self.ui.pipeline_step_combo_box.addItems(pipeline_steps) @@ -84,7 +82,7 @@ def on_build_template_clicked(self) -> None: Handler for when the build template button is clicked. Gathers input data. """ - self.mode = self._CreateMode(self.ui.build_mode_combo_box.currentText()) + self.mode = CreateMode(self.ui.build_mode_combo_box.currentText()) self.step = self.ui.pipeline_step_combo_box.currentText() self.template = self.ui.template_name_line_edit.text().strip() self.description = self.ui.description_text_edit.toPlainText() diff --git a/python/tk_multi_loader/dialog.py b/python/tk_multi_loader/dialog.py index 1a43db7d..c2bac601 100644 --- a/python/tk_multi_loader/dialog.py +++ b/python/tk_multi_loader/dialog.py @@ -26,7 +26,7 @@ from .delegate_publish_thumb import SgPublishThumbDelegate from .framework_qtwidgets import ShotgunFilterMenu from .loader_action_manager import LoaderActionManager -from .medm import ( +from .flowam import ( MedmEntityModel, MedmLatestPublishModel, MedmPublishHistoryModel, @@ -97,7 +97,7 @@ def __init__(self, action_manager, parent=None): # Hold a reference to the current animation to prevent GC mid-run self._current_animation = None - # FlowAM tree view - only created when enable_flowam is enabled + # FlowAM tree view - only created when FlowAM is enabled self._medm_tree_view = None # The loader app can be invoked from other applications with a custom @@ -166,14 +166,11 @@ def __init__(self, action_manager, parent=None): self._publish_history_model = SgPublishHistoryModel(self, self._task_manager) - # FlowAM objects are only instantiated when enable_flowam is enabled. - # tk-framework-flowam is required by these classes but is not available - # in all environments (e.g. CI). Keeping these as None when FlowAM is - # disabled prevents a hard startup failure in those environments. + # FlowAM objects are only instantiated when enabled. self._medm_cache = None self._medm_thumbnail_service = None self._medm_history_model = None - if sgtk.platform.current_bundle().get_setting("enable_flowam", False): + if sgtk.platform.current_bundle().context.flow_project_id: self._medm_cache = MedmSharedCache() self._medm_thumbnail_service = MedmThumbnailService(self._medm_cache, self) @@ -377,14 +374,13 @@ def __init__(self, action_manager, parent=None): # Set up filtering app = sgtk.platform.current_bundle() - enable_flowam = app.get_setting("enable_flowam", False) if app.get_setting("use_legacy_published_file_type_filter", False): # Hide the Filter menu button. # The legacy filter functionality is always set up, since the filter menu still # requires some of that functionality. self._filter_menu = None self.ui.filter_menu_btn.hide() - elif enable_flowam: + elif app.context.flow_project_id: # Disable filter menu for Flow Asset Management mode - it expects ShotgunModel data self._filter_menu = None self.ui.filter_menu_btn.hide() @@ -430,7 +426,7 @@ def __init__(self, action_manager, parent=None): self._load_entity_presets() # Set up the FlowAM tree panel when Flow Asset Management is enabled - if enable_flowam: + if app.context.flow_project_id: self._setup_medm_tree_panel() ################################################# @@ -1997,7 +1993,6 @@ def on_action_click(act): name=act["name"], params=act["params"], sg_publish_data=sg_data, - am_base_obj=self._action_manager.get_am_base_obj(), ) action = QtGui.QAction(entity_action["caption"], view) diff --git a/python/tk_multi_loader/medm/__init__.py b/python/tk_multi_loader/flowam/__init__.py similarity index 58% rename from python/tk_multi_loader/medm/__init__.py rename to python/tk_multi_loader/flowam/__init__.py index d5e0a887..50aa667f 100644 --- a/python/tk_multi_loader/medm/__init__.py +++ b/python/tk_multi_loader/flowam/__init__.py @@ -8,13 +8,11 @@ # agreement to the Shotgun Pipeline Toolkit Source Code License. All rights # not expressly granted therein are reserved by Shotgun Software Inc. -"""FlowAM integration models for the Loader app. +"""FlowAM integration for the Loader app. -This package provides Qt models that back the loader when ``enable_flowam`` -is enabled in the app configuration. All models share a single -:class:`~medm.shared_cache.MedmSharedCache` and -:class:`~medm.thumbnail_service.MedmThumbnailService` instance injected by -the dialog at construction time. +This package provides drop-in replacements for the standard Shotgun-based +Loader models and actions, backed by Flow Asset Management (FlowAM) instead +of the ShotGrid REST API. """ from .entity_model import MedmEntityModel @@ -28,15 +26,3 @@ get_templates, ) from .thumbnail_service import MedmThumbnailService - -__all__ = [ - "FlowAMActions", - "MedmEntityModel", - "MedmLatestPublishModel", - "MedmPublishHistoryModel", - "MedmSharedCache", - "MedmThumbnailService", - "find_template_pipeline_step", - "get_template_pipeline_steps", - "get_templates", -] diff --git a/python/tk_multi_loader/flowam/create.py b/python/tk_multi_loader/flowam/create.py new file mode 100644 index 00000000..ca9ca93d --- /dev/null +++ b/python/tk_multi_loader/flowam/create.py @@ -0,0 +1,489 @@ +# Copyright (c) 2026 Shotgun Software Inc. +# +# CONFIDENTIAL AND PROPRIETARY +# +# This work is provided "AS IS" and subject to the Shotgun Pipeline Toolkit +# Source Code License included in this distribution package. See LICENSE. +# By accessing, using, copying or modifying this work you indicate your +# agreement to the Shotgun Pipeline Toolkit Source Code License. All rights +# not expressly granted therein are reserved by Shotgun Software Inc. + +""" +This module contains utilities for creating Flow AM assets. Some of these functions +create assets only in sandbox, while others create and publish them straight to +medm. +""" + +from __future__ import annotations + +import os +import tempfile +from dataclasses import dataclass +from enum import Enum +from typing import Callable + +import sgtk +from sgtk.flowam import create +from sgtk.flowam import utils as flowam_utils +from tank_vendor.flow_integration_sdk import ( + exceptions, + globals, + objects, + publish, + sandbox, + schema, + utils, +) + + +# --------------------------------- +# Classes +# --------------------------------- +@dataclass +class CreateInputs(flowam_utils.BaseInputs): + """Convenience structure to hold create inputs and allow them to be + passed easily between helper functions. + """ + + #: Entity type of SG asset. + sg_entity_type: str + #: Name of the SG asset. + #: This will be used for the AM asset name (both the container and workfile). + sg_entity_name: str + #: The name/code of the SG pipeline step associated with the current task context. + sg_pipeline_step: str + #: The AM project under which the asset should be added. + am_project_id: str + #: The name of the current SG task. + sg_task_name: str = "" + #: Description of asset. + description: str = "" + #: Determines which initial source file to use to create the asset. + #: See `CreateMode` enum for valid values. + create_mode: create.CreateMode = create.CreateMode.CURRENT + #: Relevant only in some modes. + #: * TEMPLATE -> path to the template file to be used to build the new asset + #: * GENERIC -> path to the source file to copy directly to asset + source_path: str = "" + #: Optional callback function that will be called after scene is prepared + prep_scene_callback: Callable | None = None + + def asdict(self): + """Custom asdict to handle Enums and callables.""" + + data = {} + for key, value in self.__dict__.items(): + if isinstance(value, Enum): + data[key] = value.value + elif callable(value): + data[key] = getattr(value, "__name__", str(value)) + else: + data[key] = value + return data + + def validate(self): + """Check that input combinations are valid. + + Raises: + CreateAssetError + """ + + # If sg entity name is provided, we also expect an entity type and pipeline step + if self.sg_entity_name and not self.sg_entity_type: + msg = "Incomplete sg context provided. Must provide sg_entity_type." + raise exceptions.CreateAssetError(data=self.asdict(), details=msg) + if self.sg_entity_name and not self.sg_pipeline_step: + msg = "Incomplete sg context provided. Must provide sg_pipeline_step." + raise exceptions.CreateAssetError(data=self.asdict(), details=msg) + # If create mode is TEMPLATE or GENERIC, we need a source path + if self.create_mode == create.CreateMode.TEMPLATE and not self.source_path: + msg = "No template source path provided." + raise exceptions.CreateAssetError(data=self.asdict(), details=msg) + if self.create_mode == create.CreateMode.GENERIC and not self.source_path: + msg = "No source path provided for generic asset." + raise exceptions.CreateAssetError(data=self.asdict(), details=msg) + # If pipeline step is provided, we expect task_name to be provided as well + if self.sg_pipeline_step and not self.sg_task_name: + msg = "Incomplete sg context provided. Must provide sg_task_name." + raise exceptions.CreateAssetError(data=self.asdict(), details=msg) + # prep_scene_callback is only applicable when create_mode is NEW or TEMPLATE + if ( + self.create_mode == create.CreateMode.GENERIC + and self.prep_scene_callback is not None + ): + msg = "prep_scene_callback is not applicable when create_mode is GENERIC." + raise exceptions.CreateAssetError(data=self.asdict(), details=msg) + + # There should always be a project id provided + if not self.am_project_id: + raise exceptions.CreateAssetError( + data=self.asdict(), details="No project id provided." + ) + + +@dataclass +class CreateTemplateInputs(flowam_utils.BaseInputs): + """Convenience structure to hold create inputs and allow them to be + passed easily between helper functions. + """ + + #: The name/code of the SG pipeline step that new template is for. + sg_pipeline_step: str + #: The AM project under which the template should be added. + am_project_id: str + #: The name new template asset. + template_name: str = "" + #: Description of template. + description: str = "" + #: Determines which initial source file to use to create the asset. + #: See `CreateMode` enum for valid values. + #: In the case of template creation, only NEW and CURRENT are applicable. + create_mode: create.CreateMode = create.CreateMode.CURRENT + + def validate(self): + """Check that input combinations are valid. + + Raises: + CreateAssetError + """ + + # Pipeline step value must be provided + if not self.sg_pipeline_step: + msg = "No pipeline step provided." + raise exceptions.CreateAssetError(data=self.asdict(), details=msg) + # There should always be a project id provided + if not self.am_project_id: + raise exceptions.CreateAssetError( + data=self.asdict(), details="No project id provided." + ) + # Template must have a name + if not self.template_name: + raise exceptions.CreateAssetError( + data=self.asdict(), details="No template name provided." + ) + # Create mode TEMPLATE and GENERIC are not applicable for templates. + if ( + self.create_mode == create.CreateMode.TEMPLATE + or self.create_mode == create.CreateMode.GENERIC + ): + msg = f"Invalid CreateMode provided for template creation: {self.create_mode}." + raise exceptions.CreateAssetError(data=self.asdict(), details=msg) + + +def get_template_source_path(template: objects.FlowAsset) -> str: + """Return the published source path of the given template. + Fetch binary if necessary. + + Args: + template: Template asset. + + Returns: + Full path to template file in blob storage. + """ + revision = template.get_latest_revision() + revision.fetch(component_purpose=globals.SOURCE_PURPOSE) + return revision.get_storage_component_path(component_purpose=globals.SOURCE_PURPOSE) + + +# --------------------------------- +# Workflows +# --------------------------------- +def create_dcc_workfile(inputs: CreateInputs) -> sandbox.NewDraftInfo: + """Create a DCC workfile asset in sandbox based on criteria provided in inputs. + See documentation for CreateInputs for expected inputs. + + .. note:: Inputs can be passed in as a CreateInputs object assigned to the keyword + argument _inputs_ or as a set of individual parameters. (e.g. sg_entity_name="my_name") + + Returns: + A NewDraftInfo object containing all pertinent information about + draft created for new asset, including draft id. + + Raises: + CreateAssetError + """ + app = sgtk.platform.current_bundle() + inputs.log_intro("Creating new DCC workfile asset") + inputs.validate() + + if not in_dcc_context(): + msg = "Cannot create DCC workfile without being in DCC." + raise exceptions.CreateAssetError(data=inputs.asdict(), details=msg) + + # Create any necessary hierarchy above current asset + parent = create.create_asset_hierarchy(inputs) + + # Create the workfile asset in sandbox + draft_info = _create_dcc_workfile_asset(parent, inputs) + + app.log_info("Creating DCC asset complete!") + + # Open the draft file + draft_path = draft_info.source_path + app.log_info(f"Opening draft path: {draft_path}") + flow_host().open_file(draft_path) + + app.context.set_flow_context(flow_host().current_file()) + + return draft_info + + +def create_template_workfile(inputs: CreateTemplateInputs) -> sandbox.NewDraftInfo: + """Create a DCC workfile asset in sandbox based on criteria provided in inputs. + See documentation for CreateTemplateInputs for expected inputs. + + .. note:: Inputs can be passed in as a CreateTemplateInputs object assigned to the keyword + argument _inputs_ or as a set of individual parameters. (e.g. sg_entity_name="my_name") + + Returns: + A NewDraftInfo object containing all pertinent information about + draft created for new asset, including draft id. + + Raises: + CreateAssetError + """ + app = sgtk.platform.current_bundle() + + inputs.log_intro("Creating new template asset") + inputs.validate() + + if not in_dcc_context(): + msg = "Cannot create template workfile without being in DCC." + raise exceptions.CreateAssetError(data=inputs.asdict(), details=msg) + + # Create any necessary hierarchy above current asset + parent = _create_template_hierarchy(inputs) + + # Create the workfile asset in sandbox + draft_info = _create_template_workfile_asset(parent, inputs) + + app.log_info("Creating template asset complete!") + + # Open the draft file + draft_path = draft_info.source_path + app.log_info(f"Opening draft path: {draft_path}") + flow_host().open_file(draft_path) + + app.context.set_flow_context(flow_host().current_file()) + + return draft_info + + +# --------------------------------- +# Auxiliary functions for workflows +# --------------------------------- +def flow_host() -> sgtk.flowam.host.FlowHost: + """Convenience function to return the current host.""" + return sgtk.platform.current_engine().flow_host + + +def in_dcc_context() -> bool: + """Return True if currently in a DCC context (i.e. engine is not tk-desktop).""" + engine = sgtk.platform.current_engine() + return engine.name != "tk-desktop" + + +def _has_workfile_type(parent: objects.FlowAsset, type_id: str) -> bool: + """Return True if parent asset contains a child of given type.""" + + if parent.find_children(type_id=type_id): + return True + return False + + +def _create_dcc_workfile_asset( + parent: objects.FlowAsset, inputs: CreateInputs +) -> sandbox.NewDraftInfo: + """Called when creating a new dcc workfile asset. + This function will create the workfile asset in sandbox under the given parent. + + Args: + parent: Asset to create workfile asset under. + See CreateInputs documentation. + + Returns: + The draft_info of the workfile asset created. + + Raises: + CreateAssetError + """ + app = sgtk.platform.current_bundle() + + # Determine workfile type to be created + workfile_type = flow_host().WORKFILE_TYPE + type_id = schema.get_schema_id(workfile_type) + + # Only allow one workfile of DCC type under parent + if _has_workfile_type(parent, type_id): + msg = f'A workfile of type "{workfile_type}" has already been created ' + msg += f'under pipeline step "{parent.name}". Please open the asset from ' + msg += "the Loader app to publish another revision of this asset." + raise exceptions.CreateAssetError(data=inputs.asdict(), details=msg) + + # By convention asset name will be the sg entity name + name = inputs.sg_entity_name + + # Prepare the source file and save to temporary location + # By convention the source file will be named after the asset + with tempfile.TemporaryDirectory() as temp_dir: + ext = utils.fileext(inputs.source_path) or flow_host().FILE_TYPES[0] + temp_file = utils.cleanpath(temp_dir, f"{name}.{ext}") + if inputs.create_mode == create.CreateMode.NEW: + # Clear scene + flow_host().new_scene() + elif inputs.create_mode == create.CreateMode.TEMPLATE: + # Open the template file + if not os.path.exists(inputs.source_path): + msg = f"Template path does not exist: {inputs.source_path}" + raise exceptions.CreateAssetError(data=inputs.asdict(), details=msg) + try: + flow_host().open_file(inputs.source_path) + except Exception as exc: # pylint: disable=broad-except + msg = f"Could not open template path: {inputs.source_path}" + raise exceptions.CreateAssetError( + data=inputs.asdict(), details=msg + ) from exc + + # Call prep scene callback if provided + if inputs.prep_scene_callback: + try: + inputs.prep_scene_callback() + except Exception as exc: # pylint: disable=broad-except + msg = f"Error running prep_scene_callback during scene prep: {exc}" + raise exceptions.CreateAssetError(data=inputs.asdict(), details=msg) + + app.log_info(f"Saving temp file to: {temp_file}") + flow_host().save_file(temp_file) + + # Create a new asset in sandbox with a unique draft id + app.log_info( + f'Creating a workfile asset of type "{workfile_type}" for sg entity "{inputs.sg_entity_name}" in sandbox...' + ) + desc = inputs.description + return sandbox.create_asset_in_sandbox( + name=name, + description=desc, + parent_id=parent.id, + type_ids=[type_id], + source_path=temp_file, + ) + + +def _create_template_hierarchy(inputs: CreateTemplateInputs) -> objects.FlowAsset: + """Called when creating a template asset. + This function will ensure that any hierarchical structuring above the workfile asset + is created if necessary. (These will be committed directly to remote immediately.) + + High-level structure of template organization in AM project + ----------------------------------------------------------- + + - PROJECT + - TEMPLATES FOLDER + - pipeline step 1 + - template 1 (workfile) + - template 2 (workfile) + - pipeline step 2 + - template 1 (workfile) + - template 2 (workfile) + ... + - pipeline step 3 + ... + + Args: + See CreateTemplateInputs documentation. + + Returns: + The parent asset of the workfile asset to be created. + + Raises: + CreateAssetError + """ + app = sgtk.platform.current_bundle() + + am_project_id = inputs.am_project_id + sg_pipeline_step = inputs.sg_pipeline_step + + try: + project = objects.FlowProject(am_project_id) + except exceptions.FlowError as exc: + msg = f"Invalid Flow project id provided: {am_project_id}" + raise exceptions.CreateAssetError(data=inputs.asdict(), details=msg) from exc + + # Create top-level folder if it doesn't already exist in project + folder = project.find_child(create.TEMPLATE_FOLDER) + if not folder: + app.log_info(f'Creating "{create.TEMPLATE_FOLDER}" folder...') + desc = "Folder for template assets." + folder = publish.publish_new_asset( + name=create.TEMPLATE_FOLDER, + parent_id=project.id, + components=flowam_utils.create_components_for_publish( + type_ids=[globals.FOLDER_TYPE_ID], + ), + description=desc, + ) + + # Create pipeline step if necessary + # If a pipeline step asset associated with sg pipeline step doesn't exist, create it + pipeline_step = folder.find_child(sg_pipeline_step) + if not pipeline_step: + app.log_info(f'Creating pipeline step asset for "{sg_pipeline_step}"...') + pipeline_step_type_id = schema.get_schema_id(create.PIPELINE_STEP_TYPE) + pipeline_step = publish.publish_new_asset( + name=sg_pipeline_step, + parent_id=folder.id, + components=flowam_utils.create_components_for_publish( + type_ids=[pipeline_step_type_id], + ), + ) + + return pipeline_step + + +def _create_template_workfile_asset( + parent: objects.FlowAsset, inputs: CreateTemplateInputs +) -> sandbox.NewDraftInfo: + """Called when creating a new template workfile asset. + This function will create the workfile asset in sandbox under the given parent. + + Args: + parent: Asset to create workfile asset under. + See CreateTemplateInputs documentation. + + Returns: + The draft id of the workfile asset created. + """ + app = sgtk.platform.current_bundle() + + # Determine workfile type to be created + # NOTE: templates will have dual types, both a dcc type + # and template designation + workfile_type = flow_host().WORKFILE_TYPE + workfile_type_id = schema.get_schema_id(workfile_type) + template_type_id = schema.get_schema_id(create.TEMPLATE_TYPE) + + name = inputs.template_name + + # Prepare the source file and save to temporary location + # By convention the source file will be named after the asset + with tempfile.TemporaryDirectory() as temp_dir: + ext = flow_host().FILE_TYPES[0] + temp_file = utils.cleanpath(temp_dir, f"{name}.{ext}") + if inputs.create_mode == create.CreateMode.NEW: + # Clear scene + flow_host().new_scene() + app.log_info(f"Saving temp file to: {temp_file}") + flow_host().save_file(temp_file) + + # Create a new asset in sandbox with a unique draft id + app.log_info( + f'Creating a template asset of type "{workfile_type}" for pipeline step "{inputs.sg_pipeline_step}" in sandbox...' + ) + desc = inputs.description + return sandbox.create_asset_in_sandbox( + name=name, + description=desc, + parent_id=parent.id, + type_ids=[workfile_type_id, template_type_id], # flag as template type + source_path=temp_file, + ) diff --git a/python/tk_multi_loader/medm/entity_model.py b/python/tk_multi_loader/flowam/entity_model.py similarity index 92% rename from python/tk_multi_loader/medm/entity_model.py rename to python/tk_multi_loader/flowam/entity_model.py index ebc4ecd9..973da7c0 100644 --- a/python/tk_multi_loader/medm/entity_model.py +++ b/python/tk_multi_loader/flowam/entity_model.py @@ -23,21 +23,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, List, Optional +from typing import Optional import sgtk from sgtk.platform.qt import QtCore, QtGui +from tank_vendor.flow_integration_sdk import globals, objects, exceptions, schema +from sgtk.flowam.create import PIPELINE_STEP_TYPE from .shared_cache import MedmSharedCache from .utils import is_structural_asset as _is_structural_asset_util -# Import types for type hints only - actual objects come from framework at runtime -# Framework is loaded dynamically via sgtk.platform.import_framework() -if TYPE_CHECKING: - from adsk.flow.data import Asset -else: - Asset = Any - class MedmEntityModel(QtGui.QStandardItemModel): """ @@ -90,9 +85,6 @@ def __init__( super().__init__(parent) self._app = sgtk.platform.current_bundle() - self._flow_module = sgtk.platform.import_framework( - "tk-framework-flowam", "flow" - ) self._cache = cache if cache is not None else MedmSharedCache() self._folder_icon = QtGui.QIcon(QtGui.QPixmap(":/res/icon_Folder.png")) @@ -209,9 +201,9 @@ def search_item(parent): return search_item(None) - def get_cached_children(self, asset: Asset) -> List[Asset]: + def get_cached_children(self, asset: objects.FlowAsset) -> list[objects.FlowAsset]: """ - Return child :class:`Asset` objects for *asset*. + Return child :class:`FlowAsset` objects for *asset*. Uses the internal cache when available; otherwise fetches from the FlowAM API and stores the result. This is the single entry-point that both @@ -219,7 +211,7 @@ def get_cached_children(self, asset: Asset) -> List[Asset]: that a drill-down never fetches the same level twice. :param asset: Parent FlowAM Asset whose children are needed. - :returns: List of child Asset objects (may be empty). + :returns: List of child FlowAsset objects (may be empty). """ return self._fetch_and_cache_children(asset) @@ -233,8 +225,8 @@ def _initialize_project(self) -> None: Called during __init__ to fail fast if project is unavailable. """ try: - session_project = self._flow_module.data.get_session_project() - self._project = self._flow_module.data.Project(session_project.id) + current_engine = sgtk.platform.current_engine() + self._project = objects.FlowProject(current_engine.context.flow_project_id) self._app.log_debug( f"FlowAM Entity: Initialized project '{self._project.name}'" ) @@ -266,16 +258,14 @@ def _get_structural_type_ids(self) -> set: return self._structural_type_ids try: - folder_id = self._flow_module.data.FOLDER_TYPE_ID - pipeline_step_id = self._flow_module.schema.get_schema_id( - self._flow_module.asset_management.PIPELINE_STEP_TYPE - ) + folder_id = globals.FOLDER_TYPE_ID + pipeline_step_id = schema.get_schema_id(PIPELINE_STEP_TYPE) self._structural_type_ids = {folder_id, pipeline_step_id} self._app.log_debug( f"FlowAM Entity: structural type IDs = {self._structural_type_ids}" ) - except self._flow_module.FlowError as e: + except exceptions.FlowError as e: self._app.log_warning( f"FlowAM Entity: could not resolve structural type IDs ({e}); " "non-structural assets without structural descendants will be hidden." @@ -284,7 +274,7 @@ def _get_structural_type_ids(self) -> set: return self._structural_type_ids - def _is_tree_node(self, asset: Asset) -> bool: + def _is_tree_node(self, asset: objects.FlowAsset) -> bool: """ Return ``True`` when *asset* should appear as a node in the left-hand tree view. @@ -304,7 +294,7 @@ def _is_tree_node(self, asset: Asset) -> bool: :param asset: FlowAM ``Asset`` to test. :returns: ``True`` if the asset should appear in the tree. """ - if _is_structural_asset_util(asset, self._flow_module): + if _is_structural_asset_util(asset): return True # Non-structural: show in the tree only when the asset has direct @@ -313,7 +303,7 @@ def _is_tree_node(self, asset: Asset) -> bool: children = self._fetch_and_cache_children(asset) return len(children) > 0 - def _icon_for_asset(self, asset: Asset) -> QtGui.QIcon: + def _icon_for_asset(self, asset: objects.FlowAsset) -> QtGui.QIcon: """ Return the appropriate tree icon for *asset* based on its type. @@ -326,9 +316,7 @@ def _icon_for_asset(self, asset: Asset) -> QtGui.QIcon: :returns: A :class:`QtGui.QIcon` instance. """ return ( - self._folder_icon - if _is_structural_asset_util(asset, self._flow_module) - else self._binary_icon + self._folder_icon if _is_structural_asset_util(asset) else self._binary_icon ) def _load_medm_assets(self) -> None: @@ -368,7 +356,7 @@ def _load_medm_assets(self) -> None: self.data_refresh_fail.emit(str(e)) def _add_asset_item( - self, asset: Asset, parent_item: Optional[QtGui.QStandardItem] + self, asset: objects.FlowAsset, parent_item: Optional[QtGui.QStandardItem] ) -> QtGui.QStandardItem: """ Create a single ``QStandardItem`` for *asset* and append it to the tree. @@ -438,7 +426,9 @@ def _load_children_for_item(self, item: QtGui.QStandardItem) -> None: f"FlowAM: Could not get children for '{asset.name}': {e}" ) - def _fetch_and_cache_children(self, asset: Asset) -> List[Asset]: + def _fetch_and_cache_children( + self, asset: objects.FlowAsset + ) -> list[objects.FlowAsset]: """ Return child assets for *asset*, fetching from the API only on the first call and caching the result in the shared cache for subsequent diff --git a/python/tk_multi_loader/flowam/file.py b/python/tk_multi_loader/flowam/file.py new file mode 100644 index 00000000..cd9fcede --- /dev/null +++ b/python/tk_multi_loader/flowam/file.py @@ -0,0 +1,172 @@ +# Copyright (c) 2026 Shotgun Software Inc. +# +# CONFIDENTIAL AND PROPRIETARY +# +# This work is provided "AS IS" and subject to the Shotgun Pipeline Toolkit +# Source Code License included in this distribution package. See LICENSE. +# By accessing, using, copying or modifying this work you indicate your +# agreement to the Shotgun Pipeline Toolkit Source Code License. All rights +# not expressly granted therein are reserved by Shotgun Software Inc. + +from __future__ import annotations # needed for Houdini support + +import os + +import sgtk +from tank_vendor.flow_integration_sdk import ( + globals, + objects, + sandbox, + exceptions, + schema, + utils, +) + + +class DownloadRevisionError(exceptions.FlowError): + def __init__( + self, + *args, + revision_id: str, + directory: str = "", + **kwargs, + ): + message = "Download revision failed." + super().__init__(message, *args, **kwargs) + self.revision_id = revision_id + self.directory = directory + + +def download_revision( + revision_id: str, + component_purpose: str = globals.SOURCE_PURPOSE, + directory: str = "", +) -> dict[int, str]: + """Download the requested component of the given revision + to the location specified. If no directory is provided, + a file dialog will be launched to allow the user to choose a location. + + Args: + revision_id: Id of FlowRevision to be downloaded. + component_purpose: Purpose of binary component on revision to be + downloaded. By default, the source component + will be used. + directory: An explicitly folder location to download to. + It will be created if it doesn't exist. + If not provided, the user can choose via a file dialog. + + Returns: + Dictionary of blob index to full path of downloaded file. + + Raises: + DownloadRevisionError + """ + + engine = sgtk.platform.current_engine() + + # Ensure revision id is valid + try: + revision = objects.FlowRevision.get_revision(revision_id) + except exceptions.FlowError as exc: + msg = f"Invalid revision id provided: {revision_id}" + raise DownloadRevisionError( + revision_id=revision_id, + directory=directory, + details=msg, + ) from exc + + # Ensure component exists + component = revision.find_component(purpose=component_purpose) + if not component: + msg = f'Component of purpose "{component_purpose}" does not exist on revision.' + raise DownloadRevisionError( + revision_id=revision_id, + directory=directory, + details=msg, + ) + + # Ensure directory exists + if directory: + directory = utils.cleanpath(directory) + if os.path.isfile(directory): + # Name collision between directory and existing file + # NOTE: OS will not allow a directory to be created with same name + # in this case. + msg = "A file already exists with same name of download directory." + msg += " Please choose a new input directory." + raise DownloadRevisionError( + revision_id=revision_id, + directory=directory, + details=msg, + ) + if not os.path.exists(directory): + try: + os.makedirs(directory, exist_ok=True) + except Exception as exc: # pylint: disable=broad-except + msg = f"Could not create download directory: {directory}" + raise DownloadRevisionError( + revision_id=revision_id, + directory=directory, + details=msg, + ) from exc + + elif engine.flow_host: + result = engine.flow_host.file_dialog( + title="Choose Download Location", + folder_mode=True, # select directory + ) + if not result: + engine.log_warning("Download operation cancelled.") + return {} + directory = utils.cleanpath(result[0]) + else: + msg = "No download location provided." + raise DownloadRevisionError( + revision_id=revision_id, + directory=directory, + details=msg, + ) + + # Determine if revision contains a file sequence + file_seq_comp = revision.find_component( + type_id=schema.get_schema_id(globals.FILE_SEQ_TYPE) + ) + result = component.download(directory, file_sequence=file_seq_comp is not None) + + msg = f'Download complete for "{revision.name}" - "{component.name}"!\n' + msg += "The following files were downloaded:\n" + for i, file_path in result.items(): + msg += f"\tBlob {i} -> {file_path}\n" + engine.log_info(msg) + + return result + + +def open_draft(draft_id: str): + """Open draft source file for editing if draft is local. + + Args: + draft_id: Unique id that identifies a draft location in local sandbox. + + Raises: + FlowError + InvalidDraftError + """ + engine = sgtk.platform.current_engine() + + if not engine.flow_host: + raise exceptions.FlowError("Opening a draft must be done in a host.") + + if not sandbox.is_local_draft(draft_id): + msg = f'The draft "{draft_id}" is not in local sandbox.' + raise exceptions.InvalidDraftError(draft_id=draft_id, details=msg) + + draft_info = sandbox.read_draft_info(draft_id) + draft_path = draft_info.source_path + if not os.path.exists(draft_path): + msg = f'Corrupted draft folder. The file "{draft_path}" does not exist.' + raise exceptions.InvalidDraftError(draft_id=draft_id, details=msg) + + # Open file + engine.log_info(f"Opening file: {draft_path}") + engine.flow_host.open_file(draft_path) diff --git a/python/tk_multi_loader/medm/flowam_actions.py b/python/tk_multi_loader/flowam/flowam_actions.py similarity index 74% rename from python/tk_multi_loader/medm/flowam_actions.py rename to python/tk_multi_loader/flowam/flowam_actions.py index 22a871a9..66d3ecfb 100644 --- a/python/tk_multi_loader/medm/flowam_actions.py +++ b/python/tk_multi_loader/flowam/flowam_actions.py @@ -10,16 +10,24 @@ from __future__ import annotations import functools -from types import ModuleType -from typing import Any import sgtk from sgtk import TankError from sgtk.platform.qt import QtGui +from sgtk.flowam.create import CreateMode +from tank_vendor.flow_integration_sdk import sandbox, exceptions from ..build_asset_dialog import BuildAssetDialog from ..build_template_dialog import BuildTemplateDialog from ..constants import DRAFT_VERSION_IDENTIFIER +from .create import ( + CreateInputs, + CreateTemplateInputs, + create_dcc_workfile, + create_template_workfile, +) +from .file import open_draft, download_revision +from .reference import reference_revision, copy_reference_link class FlowAMActions: @@ -39,23 +47,6 @@ def DRAFT_VERSION_IDENTIFIER(self): """ return DRAFT_VERSION_IDENTIFIER - def load_framework( - self, framework_instance_name: str, module_name: str - ) -> ModuleType: - """ - Simple wrapper around the base class implementation to - provide user feedback if the framework cannot be loaded. - - :param framework_instance_name: Name of the framework instance to load - :returns: sgtk.platform.Framework instance - """ - try: - return sgtk.platform.import_framework(framework_instance_name, module_name) - except Exception as e: - message = f"Could not load the required framework '{framework_instance_name}'.\n\nError details: {e}" - self._app.log_error(message) - QtGui.QMessageBox.critical(None, "Error", message) - def _do_open(self, sg_publish_data: dict) -> None: """ Open the given PublishedFile. @@ -71,15 +62,13 @@ def _do_open(self, sg_publish_data: dict) -> None: ) raise TankError("No Revision ID found for this item {}.".format(item_id)) - flow_module = self.load_framework("tk-framework-flowam", "flow") - - if version_number == DRAFT_VERSION_IDENTIFIER and self._is_local_draft( - sg_publish_data + if version_number == DRAFT_VERSION_IDENTIFIER and sandbox.is_local_draft( + sg_publish_data.get("sg_flow_revision_id") ): - flow_module.asset_management.open_draft(flow_revision_id) + open_draft(flow_revision_id) elif version_number > DRAFT_VERSION_IDENTIFIER: # Checkout the revision to the local sandbox - flow_module.asset_management.checkout_revision(flow_revision_id) + sandbox.checkout_revision(flow_revision_id) else: raise TankError( f"Cannot open item {sg_publish_data['name']} with version number {version_number}. " @@ -100,8 +89,7 @@ def _create_reference_am(self, sg_publish_data: dict) -> None: ) raise TankError("No Revision ID found for this item {}.".format(item_id)) - flow_module = self.load_framework("tk-framework-flowam", "flow") - flow_module.asset_management.reference_revision(flow_revision_id) + reference_revision(flow_revision_id) def _create_reference_copy_link(self, sg_publish_data: dict) -> None: """ @@ -117,8 +105,7 @@ def _create_reference_copy_link(self, sg_publish_data: dict) -> None: ) raise TankError("No Revision ID found for this item {}.".format(item_id)) - flow_module = self.load_framework("tk-framework-flowam", "flow") - path = flow_module.asset_management.copy_reference_link(flow_revision_id) + path = copy_reference_link(flow_revision_id) self._app.log_info(f"Reference path copied: {path}") @@ -130,14 +117,14 @@ def _build_new_scene(self, sg_publish_data: dict) -> None: :param sg_publish_data: Shotgun data dictionary with all the standard publish fields. """ parent_window = self._get_dialog_parent() - sg_flow_am_id = self._app.context.project.get("sg_flow_am_id") + flow_am_id = self._get_flowam_id() # Get the pipeline step from the task task = sg_publish_data.get("task") task_id = task.get("id") if task else None task_pipeline_step = self._get_task_pipeline_step(task_id) if task_id else None # Open the build scene dialog build_scene_dialog = BuildAssetDialog( - project_id=sg_flow_am_id, + project_id=flow_am_id, parent=parent_window, pipeline_step=task_pipeline_step, ) @@ -149,19 +136,18 @@ def _build_new_scene(self, sg_publish_data: dict) -> None: build_scene_dialog.exec_() def _on_build_scene_dialog_accepted( - self, dialog: Any, sg_publish_data: dict + self, dialog: BuildAssetDialog, sg_publish_data: dict ) -> None: if not dialog.build: message = "Not enough data from the build dialog." self._app.log_warning(message) return - flow_module = self.load_framework("tk-framework-flowam", "flow") parent_window = self._get_dialog_parent() - sg_flow_am_id = self._get_flowam_id() + flow_am_id = self._get_flowam_id() - if dialog.build == flow_module.asset_management.CreateMode.TEMPLATE: + if dialog.build == CreateMode.TEMPLATE: template_path = dialog.template_source_path else: template_path = "" @@ -175,25 +161,23 @@ def _on_build_scene_dialog_accepted( or {} ) - create_inputs = flow_module.asset_management.CreateInputs( + create_inputs = CreateInputs( sg_entity_type=sg_publish_data["entity"]["type"], # Asset, Shot, etc. sg_entity_name=sg_publish_data["entity"]["name"], sg_pipeline_step=(task.get("step") or {}).get( "name", "" ), # Layout, Animation, etc. sg_task_name=sg_publish_data["content"], - am_project_id=sg_flow_am_id, + am_project_id=flow_am_id, create_mode=dialog.build, source_path=template_path, prep_scene_callback=functools.partial(self._prep_scene, sg_publish_data), ) try: - draft_info = flow_module.asset_management.create_dcc_workfile(create_inputs) - self._app.log_debug( - f"Created a DCC workfile on Flow AM framework with the draft_id: {draft_info.draft_id}" - ) - except flow_module.CreateAssetError as exc: + asset = create_dcc_workfile(create_inputs) + self._app.log_debug(f"Created a DCC workfile asset: {asset}") + except exceptions.CreateAssetError as exc: self._app.log_error(f"Create asset failed: {exc}\nInput data: {exc.data}") QtGui.QMessageBox.critical( @@ -201,7 +185,6 @@ def _on_build_scene_dialog_accepted( "Error", str(exc), ) - return def _prep_scene(self, sg_publish_data: dict) -> None: """ @@ -216,44 +199,19 @@ def _prep_scene(self, sg_publish_data: dict) -> None: # TDs can override this method to add custom scene prep logic pass - def _is_local_draft(self, sg_publish_data: dict) -> bool: - """ - Check if the given PublishedFile is a local AM draft. - - :param sg_publish_data: FPTR data dictionary with all the standard entity fields. - :returns: True if it's a local draft, False otherwise. - """ - flow_module = self.load_framework("tk-framework-flowam", "flow") - - return flow_module.sandbox.is_local_draft( - sg_publish_data.get("sg_flow_revision_id") - ) - - def _is_new_asset(self, draft_id: str | None) -> bool: - """ - Check if the given draft ID corresponds to a new asset draft. - - :param draft_id: The draft ID to check. - :returns: True if it's a new asset draft, False otherwise. - """ - flow_module = self.load_framework("tk-framework-flowam", "flow") - - return flow_module.sandbox.is_new_asset(draft_id) - def _discard_draft(self, sg_publish_data: dict) -> None: """ Discard the local draft for the given PublishedFile. :param sg_publish_data: FPTR data dictionary with all the standard entity fields. """ - flow_module = self.load_framework("tk-framework-flowam", "flow") parent_window = self._get_dialog_parent() - draft_folder = flow_module.sandbox.get_draft_folder( + draft_folder = sandbox.get_draft_folder( sg_publish_data.get("sg_flow_revision_id") ) - if flow_module.sandbox.is_new_asset(sg_publish_data.get("sg_flow_revision_id")): + if sandbox.is_new_asset(sg_publish_data.get("sg_flow_revision_id")): # Case 1: new asset message = ( f"Discard the new unpublished asset {sg_publish_data.get('name')}?" @@ -262,7 +220,7 @@ def _discard_draft(self, sg_publish_data: dict) -> None: ) else: # Case 2: draft of existing asset - draft_info = flow_module.sandbox.read_draft_info( + draft_info = sandbox.read_draft_info( sg_publish_data.get("sg_flow_revision_id") ) version = draft_info.version @@ -283,9 +241,14 @@ def _discard_draft(self, sg_publish_data: dict) -> None: ) if message_response == QtGui.QMessageBox.StandardButton.Yes: - flow_module.asset_management.discard_draft( - sg_publish_data.get("sg_flow_revision_id") - ) + draft_id = sg_publish_data.get("sg_flow_revision_id") + current_engine = sgtk.platform.current_engine() + clear_scene = current_engine.context.flow_draft_id == draft_id + + sandbox.discard_draft(draft_id) + + if clear_scene: + current_engine.flow_host.new_scene(force=True) QtGui.QMessageBox.information( parent_window, @@ -336,12 +299,12 @@ def _build_new_template(self, sg_publish_data: dict) -> None: :param sg_publish_data: Shotgun data dictionary with all the standard publish fields. """ # Get the sg_flow_am_id from the Project - sg_flow_am_id = self._get_flowam_id() + flow_am_id = self._get_flowam_id() parent_window = self._get_dialog_parent() build_template_dialog = BuildTemplateDialog( - sg_flow_am_id, self._get_pipeline_steps(), parent_window + flow_am_id, self._get_pipeline_steps(), parent_window ) build_template_dialog.accepted.connect( lambda: self._on_build_template_dialog_accepted( @@ -351,7 +314,7 @@ def _build_new_template(self, sg_publish_data: dict) -> None: build_template_dialog.exec_() def _on_build_template_dialog_accepted( - self, dialog: Any, sg_publish_data: dict + self, dialog: BuildTemplateDialog, sg_publish_data: dict ) -> None: if not dialog.mode: message = "Not enough data from the build dialog." @@ -366,22 +329,28 @@ def _on_build_template_dialog_accepted( QtGui.QMessageBox.critical(parent_window, "Error", message) return - flow_module = self.load_framework("tk-framework-flowam", "flow") - - sg_flow_am_id = self._get_flowam_id() + flow_am_id = self._get_flowam_id() - create_inputs = flow_module.asset_management.CreateTemplateInputs( + create_inputs = CreateTemplateInputs( sg_pipeline_step=dialog.step, - am_project_id=sg_flow_am_id, + am_project_id=flow_am_id, template_name=dialog.template, create_mode=dialog.mode, ) - draft_info = flow_module.asset_management.create_template_workfile( - create_inputs - ) - self._app.log_debug( - f"Created a Template workfile on Flow AM framework with the draft_id: {draft_info.draft_id}" - ) + + try: + draft_info = create_template_workfile(create_inputs) + self._app.log_debug( + f"Created a Template workfile with the draft_id: {draft_info.draft_id}" + ) + except exceptions.CreateAssetError as exc: + self._app.log_error(f"Create asset failed: {exc}\nInput data: {exc.data}") + + QtGui.QMessageBox.critical( + parent_window, + "Error", + str(exc), + ) def _get_flowam_id(self) -> str: """ @@ -390,16 +359,16 @@ def _get_flowam_id(self) -> str: :returns: The Flow AM project ID or None if not found. """ parent_window = self._get_dialog_parent() - sg_flow_am_id = self._app.context.project.get("sg_flow_am_id") - if not sg_flow_am_id: + flow_am_id = self._app.context.project.get("sg_flow_am_id") + if not flow_am_id: project = self._app.shotgun.find_one( "Project", [["id", "is", self._app.context.project["id"]]], ["sg_flow_am_id"], ) - sg_flow_am_id = project.get("sg_flow_am_id") + flow_am_id = project.get("sg_flow_am_id") - if not sg_flow_am_id: + if not flow_am_id: err_details = { "Context project": self._app.context.project, "Project ID": ( @@ -407,7 +376,7 @@ def _get_flowam_id(self) -> str: if self._app.context.project else "None" ), - "sg_flow_am_id value": sg_flow_am_id, + "sg_flow_am_id value": flow_am_id, } details_str = "\n".join([f" {k}: {v}" for k, v in err_details.items()]) message = ( @@ -423,7 +392,7 @@ def _get_flowam_id(self) -> str: ) raise TankError(message) - return sg_flow_am_id + return flow_am_id def _get_dialog_parent(self) -> QtGui.QWidget | None: """ @@ -448,8 +417,7 @@ def _download_asset_revision(self, sg_publish_data: dict) -> None: ) raise TankError("No Revision ID found for this item {}.".format(item_id)) - flow_module = self.load_framework("tk-framework-flowam", "flow") - result = flow_module.asset_management.download_revision(flow_revision_id) + result = download_revision(flow_revision_id) # Notify the user about the download result if result: @@ -459,3 +427,13 @@ def _download_asset_revision(self, sg_publish_data: dict) -> None: msg_lines.append(f" • Blob {i}: {file_path}") msg = "\n".join(msg_lines) QtGui.QMessageBox.information(None, "Download Result", msg) + + def is_local_draft_by_revision(self, revision_id: str) -> bool: + """ + Helper method to determine if a given draft id represents a local draft. + + :param revision_id: Id that uniquely identifies a revision within local sandbox. + :returns: True if the given revision id represents a local draft, False otherwise. + """ + draft_id = sandbox.get_draft_id(revision_id) + return sandbox.is_local_draft(draft_id) diff --git a/python/tk_multi_loader/medm/latestpublish_model.py b/python/tk_multi_loader/flowam/latestpublish_model.py similarity index 94% rename from python/tk_multi_loader/medm/latestpublish_model.py rename to python/tk_multi_loader/flowam/latestpublish_model.py index 09caedc0..1487d965 100644 --- a/python/tk_multi_loader/medm/latestpublish_model.py +++ b/python/tk_multi_loader/flowam/latestpublish_model.py @@ -17,23 +17,18 @@ from __future__ import annotations from collections import defaultdict -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import Any, Optional import sgtk from sgtk.platform.qt import QtCore, QtGui +from tank_vendor.flow_integration_sdk import objects, sandbox +from ..constants import DRAFT_VERSION_IDENTIFIER from .shared_cache import MedmSharedCache from .thumbnail_service import MedmThumbnailService -from ..constants import DRAFT_VERSION_IDENTIFIER -from .utils import build_draft_sg_dict, resolve_publish_type +from .utils import build_draft_sg_dict from .utils import is_structural_asset as _is_structural_asset_util - -if TYPE_CHECKING: - from flow.data import Asset - from flow.sandbox import DraftInfo -else: - Asset = Any - DraftInfo = Any +from .utils import resolve_publish_type class MedmLatestPublishModel(QtGui.QStandardItemModel): @@ -101,9 +96,6 @@ def __init__( super().__init__(parent) self._app = sgtk.platform.current_bundle() - self._flow_module = sgtk.platform.import_framework( - "tk-framework-flowam", "flow" - ) self._publish_type_model = publish_type_model self._bg_task_manager = bg_task_manager @@ -211,9 +203,7 @@ def _populate_model_from_selected_item( # be the publishable leaf. Only fall back to showing it directly when # the asset is NOT a structural container. leaf_asset_fallback = None - if not children_asset_sg_dicts and not _is_structural_asset_util( - asset, self._flow_module - ): + if not children_asset_sg_dicts and not _is_structural_asset_util(asset): try: children_asset_sg_dicts = [self._asset_to_sg_dict(asset)] except Exception as e: @@ -236,15 +226,13 @@ def _populate_model_from_selected_item( assets_for_draft_lookup = [leaf_asset_fallback] # --- Pass 1: collect draft cards per asset ---------------------------- - drafts_by_asset_id: Dict[str, list] = {} + drafts_by_asset_id: dict[str, list] = {} for child_asset in assets_for_draft_lookup: try: if child_asset.id in self._cache.drafts: raw_drafts = self._cache.drafts[child_asset.id] else: - raw_drafts = self._flow_module.asset_management.get_asset_drafts( - child_asset.id - ) + raw_drafts = sandbox.get_asset_drafts(child_asset.id) self._cache.drafts[child_asset.id] = raw_drafts except Exception as e: self._app.log_debug( @@ -311,7 +299,7 @@ def _populate_model_from_selected_item( def _extract_asset_from_tree_item( self, item: QtGui.QStandardItem - ) -> Optional[Asset]: + ) -> Optional[objects.FlowAsset]: """ Extract the FlowAM Asset object from a tree view QStandardItem. @@ -326,7 +314,7 @@ def _extract_asset_from_tree_item( asset_data = item.data(QtCore.Qt.UserRole + 1) return asset_data - def _fetch_asset_children(self, asset: Asset) -> List[Dict[str, Any]]: + def _fetch_asset_children(self, asset: objects.FlowAsset) -> list[dict[str, Any]]: """ Fetch all non-structural child assets and convert to sg_data dicts. @@ -351,7 +339,7 @@ def _fetch_asset_children(self, asset: Asset) -> List[Dict[str, Any]]: self._cache.children[asset.id] = child_assets for child_asset in child_assets: - if _is_structural_asset_util(child_asset, self._flow_module): + if _is_structural_asset_util(child_asset): self._app.log_debug( f"FlowAM: Skipping structural asset '{child_asset.name}' from center panel" ) @@ -377,7 +365,7 @@ def _fetch_asset_children(self, asset: Asset) -> List[Dict[str, Any]]: ) return children_asset_sg_dicts - def _asset_to_sg_dict(self, asset: Asset) -> Dict[str, Any]: + def _asset_to_sg_dict(self, asset: objects.FlowAsset) -> dict[str, Any]: """ Convert an FlowAM Asset to a Shotgun-compatible dictionary. @@ -435,8 +423,8 @@ def _asset_to_sg_dict(self, asset: Asset) -> Dict[str, Any]: return sg_dict def _draft_to_sg_dict( - self, draft_info: DraftInfo, asset: Optional[Asset] = None - ) -> Dict[str, Any]: + self, draft_info: sandbox.DraftInfo, asset: Optional[objects.FlowAsset] = None + ) -> dict[str, Any]: """ Convert a local DraftInfo into a Shotgun-compatible dictionary suitable for display as a center-panel card. @@ -444,9 +432,9 @@ def _draft_to_sg_dict( Key conventions that V1 action hooks rely on: - ``version_number == DRAFT_VERSION_IDENTIFIER (-1)`` - identifies a local draft - ``sg_flow_revision_id`` - the draft's sandbox ID (draft_info.draft_id), used - by asset_management.open_draft() and sandbox.is_local_draft() + by open_draft() and is_local_draft() - :param draft_info: DraftInfo returned by asset_management.get_asset_drafts() + :param draft_info: DraftInfo returned by get_asset_drafts() (CheckoutDraftInfo) or get_drafts() (NewDraftInfo). :param asset: The FlowAM Asset the draft belongs to. May be ``None`` for ``NewDraftInfo`` entries whose parent asset has not been published yet. @@ -500,7 +488,7 @@ def _draft_to_sg_dict( def _fetch_new_draft_items_for_parent( self, parent_asset_id: str - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """ Return sg_dict items for ``NewDraftInfo`` entries whose ``parent_id`` matches *parent_asset_id*. @@ -520,9 +508,7 @@ def _fetch_new_draft_items_for_parent( """ if self._NEW_DRAFTS_CACHE_KEY not in self._cache.drafts: try: - all_new_drafts = self._flow_module.asset_management.get_drafts( - draft_type="new" - ) + all_new_drafts = sandbox.get_drafts(draft_type="new") except Exception as e: self._app.log_warning(f"FlowAM: Could not fetch new-asset drafts: {e}") all_new_drafts = [] @@ -549,11 +535,9 @@ def _fetch_new_draft_items_for_parent( return result def _resolve_publish_type(self, medm_type_id_str: str) -> tuple: - return resolve_publish_type( - medm_type_id_str, self._cache, self._flow_module, self._app - ) + return resolve_publish_type(medm_type_id_str, self._cache, self._app) - def _add_sg_dict_as_qt_item(self, sg_item: Dict[str, Any]) -> None: + def _add_sg_dict_as_qt_item(self, sg_item: dict[str, Any]) -> None: """ Create a QStandardItem from a Shotgun-compatible dict and add it to the model. @@ -604,7 +588,7 @@ def get_sg_data(): f"FlowAM: Added item '{qt_item.text()}' to model (row count: {self.rowCount()})" ) - def _set_tooltip(self, item: QtGui.QStandardItem, sg_item: Dict[str, Any]) -> None: + def _set_tooltip(self, item: QtGui.QStandardItem, sg_item: dict[str, Any]) -> None: """ Sets a tooltip for a publish item. @@ -641,7 +625,7 @@ def _resolve_and_download_thumbnail( on the main thread once the image bytes are available. :param qt_item: The QStandardItem to set the thumbnail on. - :param revision_id: FlowAM AssetRevision ID whose thumbnail is needed. + :param revision_id: FlowAM FlowRevision ID whose thumbnail is needed. """ self._thumbnail_service.request(qt_item, revision_id, self._apply_thumbnail) @@ -660,7 +644,7 @@ def _apply_thumbnail(self, qt_item: QtGui.QStandardItem, image_data: bytes) -> N ) qt_item.setIcon(QtGui.QIcon(scaled)) - def _calculate_sg_publish_type_counts(self) -> Dict[int, int]: + def _calculate_sg_publish_type_counts(self) -> dict[int, int]: """ Count how many items in the model have each Shotgun PublishedFileType. diff --git a/python/tk_multi_loader/medm/publishhistory_model.py b/python/tk_multi_loader/flowam/publishhistory_model.py similarity index 90% rename from python/tk_multi_loader/medm/publishhistory_model.py rename to python/tk_multi_loader/flowam/publishhistory_model.py index f2d7c880..defdc9ee 100644 --- a/python/tk_multi_loader/medm/publishhistory_model.py +++ b/python/tk_multi_loader/flowam/publishhistory_model.py @@ -17,31 +17,17 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, Optional +from typing import Any, Optional import sgtk from sgtk.platform.qt import QtCore, QtGui +from tank_vendor.flow_integration_sdk import objects, sandbox from .. import utils from .shared_cache import MedmSharedCache from .thumbnail_service import MedmThumbnailService from .utils import build_draft_sg_dict, resolve_publish_type -if TYPE_CHECKING: - from adsk.flow.am import ( - Asset, - AssetRevision, - AssetVersion, - ) - from flow.sandbox import CheckoutDraftInfo, DraftInfo, NewDraftInfo -else: - Asset = Any - AssetRevision = Any - AssetVersion = Any - CheckoutDraftInfo = Any - DraftInfo = Any - NewDraftInfo = Any - class MedmPublishHistoryModel(QtGui.QStandardItemModel): """ @@ -60,7 +46,7 @@ class MedmPublishHistoryModel(QtGui.QStandardItemModel): ASSET_ROLE = ( QtCore.Qt.UserRole + 200 ) # Stores FlowAM Asset object (shared with all FlowAM models) - VERSION_ROLE = QtCore.Qt.UserRole + 201 # Stores FlowAM AssetVersion object + VERSION_ROLE = QtCore.Qt.UserRole + 201 # Stores FlowAM FlowVersion object DRAFT_ROLE = QtCore.Qt.UserRole + 202 # Stores DraftInfo for draft rows # Signals for compatibility with ShotgunModelOverlayWidget @@ -92,9 +78,6 @@ def __init__( super().__init__(parent) self._app = sgtk.platform.current_bundle() - self._flow_module = sgtk.platform.import_framework( - "tk-framework-flowam", "flow" - ) self._bg_task_manager = bg_task_manager self._cache = cache if cache is not None else MedmSharedCache() @@ -120,7 +103,7 @@ def destroy(self) -> None: if self._owns_thumbnail_service: self._thumbnail_service.destroy() - def load_data(self, sg_data: Dict[str, Any]) -> None: + def load_data(self, sg_data: dict[str, Any]) -> None: """ Load and display all versions (version history) for the selected asset. @@ -171,9 +154,7 @@ def load_data(self, sg_data: Dict[str, Any]) -> None: if asset_id in self._cache.drafts: drafts = self._cache.drafts[asset_id] else: - drafts = self._flow_module.asset_management.get_asset_drafts( - asset_id - ) + drafts = sandbox.get_asset_drafts(asset_id) self._cache.drafts[asset_id] = drafts for draft_info in drafts: self._add_draft_as_qt_item(draft_info, medm_asset) @@ -227,12 +208,12 @@ def _initialize_project_info(self) -> None: ) def _add_version_as_qt_item( - self, asset_version: AssetVersion, asset: Asset + self, asset_version: objects.FlowVersion, asset: objects.FlowAsset ) -> None: """ - Convert an AssetVersion to a QStandardItem and add it to the history model. + Convert an FlowVersion to a QStandardItem and add it to the history model. - :param asset_version: The FlowAM AssetVersion to add + :param asset_version: The FlowAM FlowVersion to add :param asset: The parent FlowAM Asset """ version_number = asset_version.version_number @@ -265,12 +246,12 @@ def get_sg_data(): self._app.log_debug(f"FlowAM History: Added version v{version_number}") def _version_to_sg_dict( - self, version: AssetVersion, asset: Asset - ) -> Dict[str, Any]: + self, version: objects.FlowVersion, asset: objects.FlowAsset + ) -> dict[str, Any]: """ - Convert a FlowAM AssetVersion to Shotgun-compatible dictionary. + Convert a FlowAM FlowVersion to Shotgun-compatible dictionary. - :param version: The FlowAM AssetVersion + :param version: The FlowAM FlowVersion :param asset: The parent FlowAM Asset :returns: sg_data dictionary compatible with Shotgun UI """ @@ -326,8 +307,8 @@ def _version_to_sg_dict( return sg_dict def _draft_to_sg_dict( - self, draft_info: DraftInfo, asset: Optional[Asset] - ) -> Dict[str, Any]: + self, draft_info: sandbox.DraftInfo, asset: Optional[objects.FlowAsset] + ) -> dict[str, Any]: """ Convert a DraftInfo (CheckoutDraftInfo or NewDraftInfo) to a Shotgun-compatible dictionary for display in the version history list as a local draft entry. @@ -337,7 +318,7 @@ def _draft_to_sg_dict( - ``sg_flow_revision_id`` -> the draft's unique sandbox ID (draft_info.draft_id), used by asset_management.open_draft() and sandbox.is_local_draft() - :param draft_info: DraftInfo object returned by asset_management.get_asset_drafts() + :param draft_info: DraftInfo object returned by get_asset_drafts() or get_drafts(). May be CheckoutDraftInfo or NewDraftInfo. :param asset: The parent FlowAM Asset (may be None for NewDraftInfo) :returns: sg_data dictionary compatible with action hooks and Shotgun UI @@ -370,7 +351,7 @@ def _draft_to_sg_dict( return sg_dict def _add_draft_as_qt_item( - self, draft_info: DraftInfo, asset: Optional[Asset] + self, draft_info: sandbox.DraftInfo, asset: Optional[objects.FlowAsset] ) -> None: """ Convert a DraftInfo to a QStandardItem and insert it at the top of @@ -419,9 +400,7 @@ def get_sg_data(): ) def _resolve_publish_type(self, medm_type_id_str: str) -> tuple: - return resolve_publish_type( - medm_type_id_str, self._cache, self._flow_module, self._app - ) + return resolve_publish_type(medm_type_id_str, self._cache, self._app) def _resolve_and_download_thumbnail( self, qt_item: QtGui.QStandardItem, revision_id: str @@ -432,7 +411,7 @@ def _resolve_and_download_thumbnail( on the main thread once the image bytes are available. :param qt_item: The QStandardItem to set the thumbnail on. - :param revision_id: FlowAM AssetRevision ID whose thumbnail is needed. + :param revision_id: FlowAM FlowRevision ID whose thumbnail is needed. """ self._thumbnail_service.request(qt_item, revision_id, self._apply_thumbnail) diff --git a/python/tk_multi_loader/flowam/reference.py b/python/tk_multi_loader/flowam/reference.py new file mode 100644 index 00000000..83ccb18d --- /dev/null +++ b/python/tk_multi_loader/flowam/reference.py @@ -0,0 +1,148 @@ +# Copyright (c) 2026 Shotgun Software Inc. +# +# CONFIDENTIAL AND PROPRIETARY +# +# This work is provided "AS IS" and subject to the Shotgun Pipeline Toolkit +# Source Code License included in this distribution package. See LICENSE. +# By accessing, using, copying or modifying this work you indicate your +# agreement to the Shotgun Pipeline Toolkit Source Code License. All rights +# not expressly granted therein are reserved by Shotgun Software Inc. + +from __future__ import annotations # needed for Houdini support + +import os + +import sgtk +from tank_vendor.flow_integration_sdk import exceptions, globals, objects, schema, utils + + +class CreateReferenceError(exceptions.FlowError): + def __init__(self, *args, input_id: str, **kwargs): + """ + Args: + input_id: Id of revision or version being referenced. + """ + self.input_id = input_id + super().__init__(f"Could not create reference to {input_id}.", *args, **kwargs) + + +def reference_revision(revision_id: str) -> str: + """Reference the source component of the given revision into the current scene. + + Args: + revision_id: The id of the asset revision to be referenced. + This can also be a version id. + + Returns: + File path of referenced file. + + Raises: + CreateReferenceError + """ + engine = sgtk.platform.current_engine() + + if not hasattr(engine.flow_host, "create_reference"): + msg = "Referencing is not supported in current execution." + raise CreateReferenceError(input_id=revision_id, details=msg) + + # We will disallow referencing into a non-asset scene + if engine.context.flow_draft_id is None: + msg = "Please open an asset from the loader before doing a reference operation." + raise CreateReferenceError(input_id=revision_id, details=msg) + + try: + if objects.FlowVersion.is_version_id(revision_id): + input_type = "version" + revision = objects.FlowVersion(revision_id).revision + else: + input_type = "revision" + revision = objects.FlowRevision.get_revision(revision_id) + except exceptions.FlowError as exc: + msg = f"Could not retrieve {input_type} object." + raise CreateReferenceError(input_id=revision_id, details=msg) from exc + + # Fetch source component of revision + revision.fetch(component_purpose=globals.SOURCE_PURPOSE) + + # Get path to source path of revision in local storage + file_path = revision.get_storage_component_path( + component_purpose=globals.SOURCE_PURPOSE + ) + if file_path is None: + msg = "Revision does not have a source component to be referenced." + raise CreateReferenceError(input_id=revision_id, details=msg) + file_seq_comp = revision.find_component( + type_id=schema.get_schema_id(globals.FILE_SEQ_TYPE) + ) + if not file_seq_comp and not os.path.exists(file_path): + msg = f"Source file does not exist in storage: {file_path}. " + msg += "Fetching the revision was not successful!" + raise CreateReferenceError(input_id=revision_id, details=msg) + elif file_seq_comp: + # Return a file path with embedded frame padding + file_path = utils.cleanpath( + revision.get_storage_dir(), file_seq_comp.properties["fileFormat"] + ) + + # Create reference + depdata = engine.flow_host.create_reference(file_path, namespace=revision.name) + return depdata.file_path + + +def copy_reference_link(revision_id: str) -> str: + """Copy the reference link (file path) to the source component + the of given revision to application clipboard. + + Args: + revision_id: The id of the FlowRevision to be referenced. + This can also be a version id. + + Returns: + File path copied to clipboard. + + Raises: + FlowError + CreateReferenceError + """ + engine = sgtk.platform.current_engine() + + if engine.flow_host is None: + raise exceptions.FlowError("Not running in a supported host.") + + try: + if objects.FlowVersion.is_version_id(revision_id): + input_type = "version" + revision = objects.FlowVersion(revision_id).revision + else: + input_type = "revision" + revision = objects.FlowRevision.get_revision(revision_id) + except exceptions.FlowError as exc: + msg = f"Could not retrieve {input_type} object." + raise CreateReferenceError(input_id=revision_id, details=msg) from exc + + # Fetch source component of revision + revision.fetch(component_purpose=globals.SOURCE_PURPOSE) + + # Get path to source path of revision in local storage + file_path = revision.get_storage_component_path( + component_purpose=globals.SOURCE_PURPOSE + ) + if file_path is None: + msg = "Revision does not have a source component to be referenced." + raise CreateReferenceError(input_id=revision_id, details=msg) + + file_seq_comp = revision.find_component( + type_id=schema.get_schema_id(globals.FILE_SEQ_TYPE) + ) + if not file_seq_comp and not os.path.exists(file_path): + msg = f"Source file does not exist in storage: {file_path}" + raise CreateReferenceError(input_id=revision_id, details=msg) + elif file_seq_comp: + # Return a file path with embedded frame padding + file_path = utils.cleanpath( + revision.get_storage_dir(), file_seq_comp.properties["fileFormat"] + ) + + # Copy to clipboard + engine.flow_host.copy_to_clipboard(file_path) + return file_path diff --git a/python/tk_multi_loader/medm/shared_cache.py b/python/tk_multi_loader/flowam/shared_cache.py similarity index 87% rename from python/tk_multi_loader/medm/shared_cache.py rename to python/tk_multi_loader/flowam/shared_cache.py index 08c22a0c..6de82ba5 100644 --- a/python/tk_multi_loader/medm/shared_cache.py +++ b/python/tk_multi_loader/flowam/shared_cache.py @@ -25,7 +25,7 @@ from __future__ import annotations import dataclasses -from typing import Any, Dict, List, Optional +from typing import Any, Optional @dataclasses.dataclass @@ -49,7 +49,7 @@ class MedmSharedCache: so local-draft state is always up-to-date. ``versions`` - ``asset.id → list[AssetVersion]``. Cleared on refresh; a publish + ``asset.id → list[FlowVersion]``. Cleared on refresh; a publish action could add a new version. ``publish_types`` @@ -65,17 +65,17 @@ class MedmSharedCache: """ # asset.id → list[Asset] - children: Dict[str, List[Any]] = dataclasses.field(default_factory=dict) + children: dict[str, list[Any]] = dataclasses.field(default_factory=dict) # asset.id → list[DraftInfo] - drafts: Dict[str, List[Any]] = dataclasses.field(default_factory=dict) - # asset.id → list[AssetVersion] - versions: Dict[str, List[Any]] = dataclasses.field(default_factory=dict) + drafts: dict[str, list[Any]] = dataclasses.field(default_factory=dict) + # asset.id → list[FlowVersion] + versions: dict[str, list[Any]] = dataclasses.field(default_factory=dict) # medm_type_id_str → (sg_publish_type_id, display_name) - publish_types: Dict[str, tuple] = dataclasses.field(default_factory=dict) + publish_types: dict[str, tuple] = dataclasses.field(default_factory=dict) # revision_id → thumbnail URL (or None when the API returned nothing) - thumbnail_urls: Dict[str, Optional[str]] = dataclasses.field(default_factory=dict) + thumbnail_urls: dict[str, Optional[str]] = dataclasses.field(default_factory=dict) # URL → raw image bytes - thumbnail_data: Dict[str, bytes] = dataclasses.field(default_factory=dict) + thumbnail_data: dict[str, bytes] = dataclasses.field(default_factory=dict) # ------------------------------------------------------------------------- # Convenience clear helpers diff --git a/python/tk_multi_loader/medm/template_queries.py b/python/tk_multi_loader/flowam/template_queries.py similarity index 58% rename from python/tk_multi_loader/medm/template_queries.py rename to python/tk_multi_loader/flowam/template_queries.py index fa99e1fa..3092d183 100644 --- a/python/tk_multi_loader/medm/template_queries.py +++ b/python/tk_multi_loader/flowam/template_queries.py @@ -8,60 +8,55 @@ # agreement to the Shotgun Pipeline Toolkit Source Code License. All rights # not expressly granted therein are reserved by Shotgun Software Inc. -"""Template query helpers for Flow Asset Management. - -These functions mirror the template-browsing API exposed by the -``tk-framework-flowam`` ``flow.asset_management`` module, but load the -framework lazily so callers do not need a direct framework reference. -""" +"""Template query helpers for Flow Asset Management.""" from __future__ import annotations from typing import Any, Optional import sgtk +from sgtk.flowam.create import PIPELINE_STEP_TYPE, TEMPLATE_FOLDER, TEMPLATE_TYPE +from tank_vendor.flow_integration_sdk import objects, schema logger = sgtk.platform.get_logger(__name__) -def get_template_pipeline_steps(project: Any) -> list[Any]: +def get_template_pipeline_steps( + project: objects.FlowProject, +) -> list[objects.FlowAsset]: """Return pipeline steps available under the Templates folder. - :param project: Flow AM ``Project`` instance to query. + :param project: Flow AM ``FlowProject`` instance to query. :returns: List of pipeline step ``Asset`` objects found under the Templates folder, or an empty list when the folder is absent. """ - _flow = sgtk.platform.import_framework("tk-framework-flowam", "flow") - _am = _flow.asset_management - template_folder = project.find_child(_am.TEMPLATE_FOLDER) + template_folder = project.find_child(TEMPLATE_FOLDER) if not template_folder: return [] - pipeline_step_type_id = _flow.schema.get_schema_id(_am.PIPELINE_STEP_TYPE) + pipeline_step_type_id = schema.get_schema_id(PIPELINE_STEP_TYPE) return template_folder.find_children(type_id=pipeline_step_type_id) -def get_templates(pipeline_step: Any) -> list[Any]: +def get_templates(pipeline_step: objects.FlowAsset) -> list[objects.FlowAsset]: """Return template assets available under a given pipeline step. :param pipeline_step: Flow AM ``Asset`` representing a pipeline step. :returns: List of template ``Asset`` objects under the pipeline step. """ - _flow = sgtk.platform.import_framework("tk-framework-flowam", "flow") - _am = _flow.asset_management - template_type_id = _flow.schema.get_schema_id(_am.TEMPLATE_TYPE) + template_type_id = schema.get_schema_id(TEMPLATE_TYPE) return pipeline_step.find_children(type_id=template_type_id) -def find_template_pipeline_step(project: Any, pipeline_step_name: str) -> Optional[Any]: +def find_template_pipeline_step( + project: objects.FlowProject, pipeline_step_name: str +) -> Optional[objects.FlowAsset]: """Find a pipeline step by name under the Templates folder. :param project: Flow AM ``Project`` instance to query. :param pipeline_step_name: Name of the pipeline step to look for. :returns: The matching pipeline step ``Asset``, or ``None`` if not found. """ - _flow = sgtk.platform.import_framework("tk-framework-flowam", "flow") - _am = _flow.asset_management - template_folder = project.find_child(_am.TEMPLATE_FOLDER) + template_folder = project.find_child(TEMPLATE_FOLDER) if not template_folder: return None return template_folder.find_child(pipeline_step_name) diff --git a/python/tk_multi_loader/medm/thumbnail_service.py b/python/tk_multi_loader/flowam/thumbnail_service.py similarity index 96% rename from python/tk_multi_loader/medm/thumbnail_service.py rename to python/tk_multi_loader/flowam/thumbnail_service.py index 3c69f441..bf77ad08 100644 --- a/python/tk_multi_loader/medm/thumbnail_service.py +++ b/python/tk_multi_loader/flowam/thumbnail_service.py @@ -29,6 +29,7 @@ import sgtk from sgtk.platform.qt import QtCore, QtGui +from tank_vendor.flow_integration_sdk import objects if TYPE_CHECKING: from .shared_cache import MedmSharedCache @@ -71,9 +72,6 @@ def __init__( super().__init__(parent) self._app = sgtk.platform.current_bundle() - self._flow_module = sgtk.platform.import_framework( - "tk-framework-flowam", "flow" - ) # Both dicts are references into the shared cache - not owned here. self._url_cache: Dict[str, Optional[str]] = cache.thumbnail_urls @@ -101,7 +99,7 @@ def request( Otherwise a daemon thread resolves the URL and downloads the image. :param qt_item: ``QStandardItem`` whose icon should be updated. - :param revision_id: FlowAM ``AssetRevision`` ID to look up. + :param revision_id: FlowAM ``FlowRevision`` ID to look up. :param callback: ``callable(qt_item, image_data: bytes)`` that will be invoked on the **main thread** once the image bytes are available. The callback is responsible for converting bytes to a ``QPixmap`` @@ -151,7 +149,8 @@ def _resolve_and_fetch( url = self._url_cache.get(revision_id) if url is None and revision_id not in self._url_cache: try: - url = self._flow_module.asset_management.get_thumbnail_url(revision_id) + rev = objects.FlowRevision.get_revision(revision_id) + url = rev.get_thumbnail_url() except Exception as exc: self._app.log_debug( f"FlowAM ThumbnailService: URL resolve failed for {revision_id}: {exc}" diff --git a/python/tk_multi_loader/medm/utils.py b/python/tk_multi_loader/flowam/utils.py similarity index 92% rename from python/tk_multi_loader/medm/utils.py rename to python/tk_multi_loader/flowam/utils.py index 2f1c5b02..b05e6d8a 100644 --- a/python/tk_multi_loader/medm/utils.py +++ b/python/tk_multi_loader/flowam/utils.py @@ -19,10 +19,13 @@ import os from typing import Any, Dict, Optional, Tuple +from tank_vendor.flow_integration_sdk import globals, schema +from sgtk.flowam.create import CONTAINER_TYPE, PIPELINE_STEP_TYPE + from ..constants import DRAFT_VERSION_IDENTIFIER -def is_structural_asset(asset: Any, flow_module: Any) -> bool: +def is_structural_asset(asset: Any) -> bool: """Return ``True`` when *asset* is a structural container in the FlowAM hierarchy. An asset is considered structural — and therefore belongs only in the @@ -38,19 +41,16 @@ def is_structural_asset(asset: Any, flow_module: Any) -> bool: ``False`` (treat the asset as publishable). :param asset: FlowAM ``Asset`` object to test. - :param flow_module: The ``flow`` framework module imported via - ``sgtk.platform.import_framework("tk-framework-flowam", "flow")``. :returns: ``True`` if the asset is a structural container. """ try: type_ids = set(getattr(asset, "type_ids", None) or []) - if flow_module.data.FOLDER_TYPE_ID in type_ids: + if globals.FOLDER_TYPE_ID in type_ids: return True - _am = flow_module.asset_management - structural_types = (_am.CONTAINER_TYPE, _am.PIPELINE_STEP_TYPE) + structural_types = (CONTAINER_TYPE, PIPELINE_STEP_TYPE) return any( - asset.find_component(type_id=flow_module.schema.get_schema_id(ct)) + asset.find_component(type_id=schema.get_schema_id(ct)) for ct in structural_types ) except Exception: @@ -200,7 +200,6 @@ def build_draft_sg_dict( def resolve_publish_type( medm_type_id_str: str, cache: Any, - flow_module: Any, app: Any, ) -> Tuple[Optional[int], str]: """Resolve a FlowAM schema type ID to a ``(sg_publish_type_id, display_name)`` pair. @@ -214,8 +213,6 @@ def resolve_publish_type( :param medm_type_id_str: FlowAM schema type ID string. :param cache: :class:`MedmSharedCache` instance whose ``publish_types`` dict is used for caching. - :param flow_module: The ``flow`` framework module imported via - ``sgtk.platform.import_framework("tk-framework-flowam", "flow")``. :param app: The current Toolkit bundle (provides ``shotgun`` and ``log_debug``). :returns: Tuple of ``(integer_publish_type_id_or_none, human_readable_display_name)``. """ @@ -224,7 +221,7 @@ def resolve_publish_type( display_name = medm_type_id_str try: - schema_name = flow_module.schema.get_schema_display_name(medm_type_id_str) + schema_name = schema.get_schema_display_name(medm_type_id_str) if schema_name: display_name = schema_name except Exception as e: diff --git a/python/tk_multi_loader/loader_action_manager.py b/python/tk_multi_loader/loader_action_manager.py index 03b2d5e4..8ddb00a4 100644 --- a/python/tk_multi_loader/loader_action_manager.py +++ b/python/tk_multi_loader/loader_action_manager.py @@ -213,17 +213,6 @@ def get_actions_for_folder(self, sg_data): def get_actions_for_entity(self, sg_data): return self._loader_manager.get_actions_for_entity(sg_data) - def get_am_base_obj(self) -> "FlowAMActions | None": - """ - Returns the base object for asset management actions, if available. - - This is used to provide context for actions related to asset management, - such as showing details in Shotgun or Media Center. - - :returns: The base object for asset management actions, or None if not available. - """ - return self._loader_manager.get_am_base_obj() - ######################################################################################## # callbacks diff --git a/tests/fixtures/config/hooks/test_actions.py b/tests/fixtures/config/hooks/test_actions.py index ff6f01bf..4e2e2b66 100644 --- a/tests/fixtures/config/hooks/test_actions.py +++ b/tests/fixtures/config/hooks/test_actions.py @@ -14,7 +14,7 @@ class TestActions(HookBaseClass): - def generate_actions(self, sg_publish_data, actions, ui_area): + def generate_actions(self, sg_publish_data, actions, ui_area, **kwargs): """ Returns a list of action instances for a particular publish. This method is called each time a user clicks a publish somewhere in the UI. @@ -95,7 +95,7 @@ def generate_actions(self, sg_publish_data, actions, ui_area): return action_instances - def execute_multiple_actions(self, actions): + def execute_multiple_actions(self, actions, **kwargs): """ Executes the specified action on a list of items. @@ -127,9 +127,9 @@ def execute_multiple_actions(self, actions): name = single_action["name"] sg_publish_data = single_action["sg_publish_data"] params = single_action["params"] - self.execute_action(name, params, sg_publish_data) + self.execute_action(name, params, sg_publish_data, **kwargs) - def execute_action(self, name, params, sg_publish_data): + def execute_action(self, name, params, sg_publish_data, **kwargs): """ Execute a given action. The data sent to this be method will represent one of the actions enumerated by the generate_actions method.