Source code for tank.hook

# Copyright (c) 2013 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.

"""
Defines the base class for all Tank Hooks.
"""
import os
import sys
import logging
import inspect
import threading
from .util.loader import load_plugin
from . import LogManager
from .errors import (
    TankError,
    TankFileDoesNotExistError,
    TankHookMethodDoesNotExistError,
)

log = LogManager.get_logger(__name__)


[docs]class Hook(object): """ Hooks are implemented in a python file and they all derive from a :class:`Hook` base class. If you are writing an app that loads files into Maya, Nuke or other DCCs, a hook is a good way to expose the actual loading logic, so that not only can be customized by a user, but so that you could even add support for a new DCC to your load app without having to update it. First, you would create a ``hooks/actions.py`` file in your app. This would contain a hook class:: import sgtk HookBaseClass = sgtk.get_hook_baseclass() class Actions(HookBaseClass): def list_actions(self, sg_publish_data): ''' Given some Shotgun publish data, return a list of actions that can be performed :param sg_publish_data: Dictionary of publish data from Shotgun :returns: List of action strings ''' # The base implementation implements an action to show # the item in Shotgun return ["show_in_sg"] def run_action(self, action, sg_publish_data): ''' Execute the given action :param action: name of action. One of the items returned by list_actions. :param sg_publish_data: Dictionary of publish data from Shotgun ''' if action == "show_in_sg": url = "%s/detail/%s/%d" % ( self.parent.shotgun.base_url, sg_publish_data["type"], sg_publish_data["id"] ) QtGui.QDesktopServices.openUrl(QtCore.QUrl(url)) The above code forms a generic base for your hook with a basic implementation that works everywhere. In the app manifest (``info.yml``), where we define all the basic configuration properties for the app, we define an actions hook:: configuration: actions_hook: type: hook default_value: "{self}/actions.py" description: Hook which contains all methods for action management. From the app code itself, you can now use :meth:`~sgtk.platform.Application.execute_hook_method()` to call out to the hook:: # when creating a list of items in the UI for your app - # given a shotgun publish, build a menu of avaialable actions: actions = self.execute_hook_method("actions_hook", "list_actions", sg_data) # in a callback method once a user has selected an action - # call out to the hook to execute the action self.execute_hook_method("actions_hook", "run_action", action_name, sg_data) In the configuration for your app, you can now 'glue together' different functionality for different scenarios. For example, when you install the app to run inside the Maya engine, you want to be able to import maya files into maya. We implement this by adding a custom publish hook for maya. This can either be placed with the app itself, in the ``hooks`` folder in the configuration, or in the maya engine. In this case, we'll add a ``hooks/actions.py`` to the maya engine. This file looks like this:: import sgtk HookBaseClass = sgtk.get_hook_baseclass() class MayaActions(HookBaseClass): def list_actions(self, sg_publish_data): ''' Given some Shotgun publish data, return a list of actions that can be performed :param sg_publish_data: Dictionary of publish data from Shotgun :returns: List of action strings ''' # first get base class actions actions = HookBaseClass.list_actions(sg_publish_data) # Add maya actions if sg_publish_data["published_file_type"]["name"] == "Maya Scene": actions += ["reference", "import"] def run_action(self, action, sg_publish_data): ''' Execute the given action :param action: name of action. One of the items returned by list_actions. :param sg_publish_data: Dictionary of publish data from Shotgun ''' if action == "reference": # do maya reference operation elif action == "import": # do maya import operation else: # pass on to base class return HookBaseClass.run_action(action, sg_publish_data) The above hook implements a couple of actions that are designed to work in Maya. Lastly, we need to tell the app to pick up this file. In the environment configuration for the app running inside of maya, we point it at our engine specific hook:: tk-maya: tk-multi-myapp: actions_hook: '{engine}/actions.py' When we are running the app configuration in maya, the actions hook will automatically resolve the hook code distributed with the maya engine. The base class will be automatically determined to be the default value set in the manifest, allowing for the app to carry a default base implementation that is always taken into account. .. _hook-formats: **Resolving paths to hooks** Several different path formats exist, making this a very powerful configuration mechanism: - ``{self}/path/to/foo.py`` -- looks in the ``hooks`` folder in the local app, engine of framework. - ``{self}/{engine_name}_publish.py`` -- If running in maya, looks for a ``tk-maya_publish.py`` in the ``hooks`` folder in the local app, engine of framework. If running in Nuke, it would instead look for ``BUNDLE_ROOT/hooks/tk-nuke_publish.py``. - ``{config}/path/to/foo.py`` -- Looks in the ``hooks`` folder in the configuration. - ``{$HOOK_PATH}/path/to/foo.py`` -- expression based around an environment variable. - ``{engine}/path/to/foo.py`` -- looks in the ``hooks`` folder of the current engine. - ``{tk-framework-perforce_v1.x.x}/path/to/foo.py`` -- looks in the ``hooks`` folder of a framework instance that exists in the current environment. Basically, each entry inside the frameworks section in the current environment can be specified here - all these entries are on the form frameworkname_versionpattern, for example ``tk-framework-widget_v0.1.2`` or ``tk-framework-shotgunutils_v1.3.x``. Supported legacy formats: - ``foo`` -- Equivalent to ``{config}/foo.py`` You can also provide your own inheritance chains. For example, if you wanted to add your own, project specific maya hooks to this app, you could do this by creating a hook file, placing it in your configuration's ``hooks`` folder and then configure it like this:: tk-maya: tk-multi-myapp: actions_hook: '{engine}/actions.py:{config}/maya_actions.py' This would execute your ``maya_actions.py`` hook and make sure that that hook inherits from the engine specific hook, making sure that you get both your custom actions, the engine default actions and the app's built-in actions. """ # default method to execute on hooks DEFAULT_HOOK_METHOD = "execute" def __init__(self, parent): self.__parent = parent @property def sgtk(self): """ The sgtk core API instance associated with the Hook parent. This is a convenience method for easy core API instance access. In the case of app, engine and framework hooks, this is equivalent to ``parent.sgtk`` and in the case of core hooks it simply returns ``parent``. .. note:: Some low level hooks do not have a parent defined. In such cases, ``None`` is returned. """ # local import to avoid cycles from .api import Sgtk if self.parent is None: # system hook return None if isinstance(self.parent, Sgtk): # core hook return self.parent else: # look for sgtk instance on parent try: return self.parent.sgtk except AttributeError: return None @property def tank(self): """ The sgtk core API instance associated with the Hook parent. .. deprecated:: v0.18.70 Use :meth:`sgtk` instead. """ return self.sgtk @property def parent(self): """ The parent object to the executing hook. This varies with the type of hook that is being executed. For a hook that runs inside an app or an engine, the parent object will be the :class:`~sgtk.platform.Application` or :class:`~sgtk.platform.Engine` instance. For core hooks, the parent object will be :class:`sgtk`. .. note:: If you need to access Shotgun inside your hook, you can do this by calling ``self.parent.shotgun`` since both Apps, Engines and the Core API has a ``shotgun`` property. """ return self.__parent
[docs] def get_publish_path(self, sg_publish_data): """ Returns the path on disk for a publish entity in Shotgun. Convenience method that calls :meth:`sgtk.util.resolve_publish_path`. :param sg_publish_data: Dictionaries containing Shotgun publish data. Each dictionary needs to at least contain a code, type, id and a path key. :returns: A path on disk to existing file or file sequence. :raises: :class:`~sgtk.util.PublishPathNotDefinedError` if the path isn't defined. :raises: :class:`~sgtk.util.PublishPathNotSupported` if the path cannot be resolved. """ # avoid cyclic refs from .util import resolve_publish_path return resolve_publish_path(self.sgtk, sg_publish_data)
def get_publish_paths(self, sg_publish_data_list): """ Returns several local paths on disk given a list of shotgun data dictionaries representing publishes. Convenience method that calls :meth:`sgtk.util.resolve_publish_path`. .. deprecated:: 0.18.64 Use :meth:`get_publish_path` instead. :param sg_publish_data_list: List of shotgun data dictionaries containing publish data. Each dictionary needs to at least contain a code, type, id and a path key. :returns: List of paths on disk to existing files or file sequences. :raises: :class:`~sgtk.util.PublishPathNotDefinedError` if any of the paths aren't defined. :raises: :class:`~sgtk.util.PublishPathNotSupported` if any of the paths cannot be resolved. """ # avoid cyclic refs from .util import resolve_publish_path paths = [] for sg_publish_data in sg_publish_data_list: paths.append(resolve_publish_path(self.sgtk, sg_publish_data)) return paths @property def disk_location(self): """ The folder on disk where this item is located. This can be useful if you want to write hook code to retrieve a local resource:: hook_icon = os.path.join(self.disk_location, "icon.png") """ # NOTE: this method contains complex logic to correctly handle # the following inheritance case: # # A. Hook base class > # B. general implementation (hook_b.py) > # C. custom implementation (hook_c.py) # # in (B), there is a method which returns an icon: # return os.path.join(self.disk_location, "icon.png") # # in this case, we want the code in B to return a path # relative to hook_b.py. # # However, although intended to be primarily used # from within a hook, the disk_location is a public method. # Whenever called from an external location, the # disk location is computed to be relative to # the leaf inheritance, e.g. hook_c.py in this case. # in order to handle the case above, we look for the # caller. If the caller is part of the inheritance # chain, we simply compute the path relative to # the caller method. # # however if the caller is external, we compute the path # relative to self. Self represents the leaf in the # inheritance tree. # path_to_file = None current_frame = inspect.currentframe() all_frames = inspect.getouterframes(current_frame) parent_frame = all_frames[1] if "self" in parent_frame[0].f_locals: # the calling method is a class instance method path_to_calling_file = parent_frame[1] if parent_frame[0].f_locals["self"] is self: # this call is coming from within the # class hierarchy path_to_file = path_to_calling_file # first check failed. fall back on computing # the location relative to self if path_to_file is None: path_to_file = os.path.abspath(sys.modules[self.__module__].__file__) return os.path.dirname(path_to_file) @property def logger(self): """ Standard python :class:`~logging.Logger` handle for this hook. The logger can be used to report progress back to the app in a standardized fashion and will be parented under the app/engine/framework logger:: # pattern sgtk.env.environment_name.engine_name.app_name.hook.hook_file_name # for example sgtk.env.asset.tk-maya.tk-multi-loader2.hook.filter_publishes In the case of core hooks, the logger will be parented under ``sgtk.core.hook``. For more information, see :ref:`logging` """ # see if the hook parent object exists and has a logger property # in that case make this the parent of our logger. # # note: core hooks have the tk instance as their parent and # app/engine/fw hooks have the bundle as their parent. # There are some special edge cases where we construct # parentless hooks: the PipelineConfigurationInit hook runs # before a tk instance exists and some of the older "studio # hooks" such as project_name.py and sg_connection.py also exists # with states where no parent can be defined easily. try: logger = self.parent.logger except AttributeError: # parent doesn't exist or doesn't have a logger. # in this case use the logger for this file as a # parent - e.g. 'sgtk.core.hook' log_prefix = log.name else: log_prefix = "%s.hook" % logger.name # name the logger after the name of the hook filename path_to_this_file = os.path.abspath(sys.modules[self.__module__].__file__) hook_name = os.path.splitext(os.path.basename(path_to_this_file))[0] full_log_path = "%s.%s" % (log_prefix, hook_name) return logging.getLogger(full_log_path)
[docs] def load_framework(self, framework_instance_name): """ Loads and returns a framework given an environment instance name. .. note:: This method only works for hooks that are executed from apps and frameworks. If you have complex logic and functionality and want to manage (and version it) as part of a framework rather than in a hook, you can do this by calling a configured framework from inside a hook:: import sgtk HookBaseClass = sgtk.get_hook_baseclass() class SomeHook(HookBaseClass): def some_method(self): # first get a framework handle. This object is similar to an app or engine object fw = self.load_framework("tk-framework-library_v1.x.x") # now just like with an app or an engine, if you want to access code in the python # folder, you can do import_plugin module = fw.import_module("some_module") module.do_stuff() Note how we are accessing the framework instance ``tk-framework-library_v1.x.x`` above. This needs to be defined in the currently running environment, as part of the ``frameworks`` section:: engines: # all engine and app defs here... frameworks: # define the framework that we are using in the hook tk-framework-library_v1.x.x: location: {type: git, path: 'https://github.com/foo/tk-framework-library.git', version: v1.2.6} :param framework_instance_name: Name of the framework instance to load from the environment. """ # avoid circular refs from .platform import framework try: engine = self.__parent.engine except: raise TankError( "Cannot load framework %s for %r - it does not have a " "valid engine property!" % (framework_instance_name, self.__parent) ) return framework.load_framework( engine, engine.get_env(), framework_instance_name )
def execute(self): """ Legacy support for old style hooks """ return None
class _HooksCache(object): """ A thread-safe cache of loaded hooks. This uses the hook file path and base class as the key to cache all hooks loaded by Toolkit in the current session. """ def __init__(self): """ Construction """ self._cache = {} self._cache_lock = threading.Lock() def thread_exclusive(func): """ function decorator to ensure multiple threads can't access the cache at the same time. :param func: The function to wrap :returns: The return value from func """ def inner(self, *args, **kwargs): """ Decorator inner function - executes the function within a lock. :returns: The return value from func """ lock = self._cache_lock lock.acquire() try: return func(self, *args, **kwargs) finally: lock.release() return inner @thread_exclusive def clear(self): """ Clear the hook cache """ self._cache = {} @thread_exclusive def find(self, hook_path, hook_base_class): """ Find a hook in the cache using the hook path and base class :param hook_path: The path to the hook to find :param hook_base_class: The base class for the hook to find :returns: The Hook class if found, None if not """ # The unique cache key is a tuple of the path and the base class to allow # loading of classes with different bases from the same file key = (hook_path, hook_base_class) return self._cache.get(key, None) @thread_exclusive def add(self, hook_path, hook_base_class, hook_class): """ Add the specified hook to the cache if it isn't already present :param hook_path: The path to the hook to add :param hook_base_class: The base class for the hook to add :param hook_class: The Hook class to add """ # The unique cache key is a tuple of the path and the base class to allow # loading of classes with different bases from the same file key = (hook_path, hook_base_class) if key not in self._cache: self._cache[key] = hook_class @thread_exclusive def __len__(self): """ Return the number of items currently in the hook cache """ return len(self._cache) _hooks_cache = _HooksCache() _current_hook_baseclass = threading.local() def clear_hooks_cache(): """ Clears the cache where tank keeps hook classes """ _hooks_cache.clear() def execute_hook(hook_path, parent, **kwargs): """ Executes a hook, old-school style. A hook is a python file which contains exactly one class which derives (at some point in its inheritance tree) from the Hook base class. Once the file has been loaded (and cached), the execute() method will be called and any optional arguments pass to this method will be forwarded on to that execute() method. :param hook_path: Full path to the hook python file :param parent: Parent object. This will be accessible inside the hook as self.parent, and is typically an app, engine or core object. :returns: Whatever the hook returns. """ return execute_hook_method([hook_path], parent, None, **kwargs) def execute_hook_method(hook_paths, parent, method_name, base_class=None, **kwargs): """ New style hook execution, with method arguments and support for inheritance. This method takes a list of hook paths and will load each of the classes in, while maintaining the correct state of the class returned via get_hook_baseclass(). Once all classes have been successfully loaded, the last class in the list is instantiated and the specified method is executed. Example: ["/tmp/a.py", "/tmp/b.py", "/tmp/c.py"] 1. The code in a.py is loaded in. get_hook_baseclass() will return Hook at this point. class HookA is returned from our plugin loader. 2. /tmp/b.py is loaded in. get_hook_baseclass() now returns HookA, so if the hook code in B utilises get_hook_baseclass, this will will set up an inheritance relationship with A 3. /tmp/c.py is finally loaded in, get_hook_baseclass() now returns HookB. 4. HookC class is instantiated and method method_name is executed. An optional `base_class` can be provided to override the default ``Hook`` base class. This is useful for bundles that wish to execute a hook method while providing a default implementation without the need to configure a base hook. :param hook_paths: List of full paths to hooks, in inheritance order. :param parent: Parent object. This will be accessible inside the hook as self.parent, and is typically an app, engine or core object. :param method_name: method to execute. If None, the default method will be executed. :param base_class: A python class to use as the base class for the hook class. This will override the default hook base class, ``Hook``. The class should derive from ``Hook``. :returns: Whatever the hook returns. """ hook = create_hook_instance(hook_paths, parent, base_class=base_class) # get the method method_name = method_name or Hook.DEFAULT_HOOK_METHOD try: hook_method = getattr(hook, method_name) except AttributeError: raise TankHookMethodDoesNotExistError( "Cannot execute hook '%s' - the hook class does not have a '%s' " "method!" % (hook, method_name) ) # execute the method ret_val = hook_method(**kwargs) return ret_val def create_hook_instance(hook_paths, parent, base_class=None): """ New style hook execution, with method arguments and support for inheritance. This method takes a list of hook paths and will load each of the classes in, while maintaining the correct state of the class returned via get_hook_baseclass(). Once all classes have been successfully loaded, an instance of the last class in the list is returned. Example: ["/tmp/a.py", "/tmp/b.py", "/tmp/c.py"] 1. The code in a.py is loaded in. get_hook_baseclass() will return Hook at this point (or a custom base class, if supplied, that derives from Hook). class HookA is returned from our plugin loader. 2. /tmp/b.py is loaded in. get_hook_baseclass() now returns HookA, so if the hook code in B utilises get_hook_baseclass, this will will set up an inheritance relationship with A 3. /tmp/c.py is finally loaded in, get_hook_baseclass() now returns HookB. 4. An instance of the HookC class is returned. An optional `base_class` can be provided to override the default ``Hook`` base class. This is useful for bundles that create hook instances at execution time and wish to provide default implementation without the need to configure the base hook. :param hook_paths: List of full paths to hooks, in inheritance order. :param base_class: A python class to use as the base class for the created hook. This will override the default hook base class, ``Hook``. :returns: Instance of the hook. """ if base_class: # ensure the supplied base class is a subclass of Hook if not issubclass(base_class, Hook): raise TankError( "Invalid custom hook base class. The supplied class '%s' does " "not inherit from Hook." % (Hook,) ) else: base_class = Hook # keep track of the current base class - this is used when loading hooks to dynamically # inherit from the correct base. _current_hook_baseclass.value = base_class for hook_path in hook_paths: if not os.path.exists(hook_path): raise TankFileDoesNotExistError( "Cannot execute hook '%s' - this file does not exist on disk!" % hook_path ) # look to see if we've already loaded this hook into the cache found_hook_class = _hooks_cache.find(hook_path, _current_hook_baseclass.value) if not found_hook_class: # load the hook class from the hook file and cache it - this explicitly looks for a # single class from the hook file that is derived from the current base (or 'Hook' for # backwards compatibility). # determine any alternate base classes to look for in addition to the current base: alternate_base_classes = [] if _current_hook_baseclass.value != Hook: # allow deriving from the Hook base class - this is to support the legacy method of # overriding hooks but without sub-classing them. alternate_base_classes.append(Hook) # try to load the hook class: loaded_hook_class = load_plugin( hook_path, valid_base_class=_current_hook_baseclass.value, alternate_base_classes=alternate_base_classes, ) # add it to the cache... _hooks_cache.add( hook_path, _current_hook_baseclass.value, loaded_hook_class ) # ...and find it again - this is to avoid different threads ending up using # different instances of the loaded class. found_hook_class = _hooks_cache.find( hook_path, _current_hook_baseclass.value ) # keep track of the current base class: _current_hook_baseclass.value = found_hook_class # all class construction done. _current_hook_baseclass contains the last # class we iterated over. An instance of this is what we want to return return _current_hook_baseclass.value(parent) def get_hook_baseclass(): """ Returns the base class to use for the hook currently being loaded. For more details and examples, see the :class:`Hook` documentation. """ return _current_hook_baseclass.value