# Copyright (c) 2018 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.
import os
import re
import imp
import sys
import errno
import cPickle
import inspect
import traceback
# handle imports
path_to_sgtk = sys.argv[1]
# prepend sgtk to sys.path to make sure
# know exactly what version of sgtk we are running.
sys.path.insert(0, path_to_sgtk)
import sgtk
# we should now be able to import QT - this is a
# requirement for the external config module
qt_importer = sgtk.util.qt_importer.QtImporter()
LOGGER_NAME = "tk-framework-shotgunutils.multi_context.external_runner"
logger = sgtk.LogManager.get_logger(LOGGER_NAME)
[docs]class EngineStartupError(Exception):
    """
    Indicates that bootstrapping into the engine failed.
    """ 
class QtTaskRunner(qt_importer.QtCore.QObject):
    """
    Wrapper class for a callback payload.
    This is used in conjunction with a QT event loop, allowing a
    given callback to be run inside an event loop. By the end of
    the execution, if the callback payload has not created any
    windows, a ``completed`` signal is emitted. This allows for
    a pattern where the QT event loop can be explicitly torn down
    for callback payloads which don't start up new dialogs.
    In the case of dialogs, QT defaults to a "terminate event loop
    when the last window closes", ensuring a graceful termination
    when the dialog is closed.
    Typically used like this::
        task_runner = QtTaskRunner(callback_payload)
        # start up our QApplication
        qt_application = qt_importer.QtGui.QApplication([])
        # Set up automatic execution of our task runner as soon
        # as the event loop starts up.
        qt_importer.QtCore.QTimer.singleShot(0, task_runner.execute_command)
        # and ask the main app to exit when the task emits its finished signal
        task_runner.completed.connect(qt_application.quit)
        # start the application loop. This will block the process until the task
        # has completed - this is either triggered by a main window closing or
        # byt the finished signal being called from the task class above.
        qt_application.exec_()
        # check if any errors were raised.
        if task_runner.failure_detected:
            # exit with error
            sys.exit(1)
        else:
            sys.exit(0)
    """
    # emitted when the taskrunner has completed non-ui work
    completed = qt_importer.QtCore.Signal()
    # statuses
    (SUCCESS, GENERAL_ERROR, ERROR_ENGINE_NOT_STARTED) = range(3)
    def __init__(self, callback):
        """
        :param callback: Callback to execute
        """
        qt_importer.QtCore.QObject.__init__(self)
        self._callback = callback
        self._status = self.SUCCESS
    @property
    def status(self):
        """
        Status of execution
        """
        return self._status
    def execute_command(self):
        """
        Execute the callback given by the constructor.
        For details and example, see the class introduction.
        """
        # note that because pyside has its own exception wrapper around
        # exec we need to catch and log any exceptions here.
        try:
            self._callback()
        except EngineStartupError as e:
            self._status = self.ERROR_ENGINE_NOT_STARTED
            # log details to log file
            logger.exception("Could not start engine.")
            # push message to stdout
            print "Engine could not be started: %s. For details, see log files." % e
        except Exception as e:
            self._status = self.GENERAL_ERROR
            # log details to log file
            logger.exception("Could not bootstrap configuration.")
            # push it to stdout so that the parent process will get it
            print "A general error was raised:"
            print traceback.format_exc()
        finally:
            # broadcast that we have finished this command
            qt_app = qt_importer.QtCore.QCoreApplication.instance()
            if len(qt_app.topLevelWidgets()) == 0:
                # no windows opened. we are done!
                self.completed.emit()
            elif not [w for w in qt_app.topLevelWidgets() if w.isVisible()]:
                # There are windows, but they're all hidden, which means we should
                # be safe to shut down.
                self.completed.emit()
def _get_core_python_path(engine):
    """
    Computes the path to the core for a given engine.
    :param engine: Toolkit Engine to inspect
    :returns: Path to the current core.
    """
    sgtk_file = inspect.getfile(engine.sgtk.__class__)
    tank_folder = os.path.dirname(sgtk_file)
    python_folder = os.path.dirname(tank_folder)
    return python_folder
def _write_cache_file(path, data):
    """
    Writes a cache to disk given a path and some data.
    :param str path: Path to a cache file on disk.
    :param data: Data to save.
    """
    logger.debug("Saving cache to disk: %s" % path)
    old_umask = os.umask(0)
    try:
        # try to create the cache folder with as open permissions as possible
        cache_dir = os.path.dirname(path)
        if not os.path.exists(cache_dir):
            try:
                os.makedirs(cache_dir, 0o775)
            except OSError as e:
                # Race conditions are perfectly possible on some network storage setups
                # so make sure that we ignore any file already exists errors, as they
                # are not really errors!
                if e.errno != errno.EEXIST:
                    # re-raise
                    raise
        # now write the file to disk
        try:
            with open(path, "wb") as fh:
                cPickle.dump(data, fh)
            # and ensure the cache file has got open permissions
            os.chmod(path, 0666)
        except Exception as e:
            logger.debug("Could not write '%s'. Details: %s" % (path, e), exec_info=True)
        else:
            logger.debug("Completed save of %s. Size %s bytes" % (path, os.path.getsize(path)))
    finally:
        os.umask(old_umask)
def _import_py_file(python_path, name):
    """
    Helper which imports a Python file and returns it.
    :param str python_path: path where module is located
    :param str name: name of py file (without extension)
    :returns: Python object
    """
    mfile, pathname, description = imp.find_module(name, [python_path])
    try:
        module = imp.load_module(name, mfile, pathname, description)
    finally:
        if mfile:
            mfile.close()
    return module
[docs]def start_engine(
    configuration_uri,
    pipeline_config_id,
    plugin_id,
    engine_name,
    entity_type,
    entity_id,
    bundle_cache_fallback_paths,
    pre_cache
):
    """
    Bootstraps into an engine.
    :param str configuration_uri: URI to bootstrap (for when pipeline config id is unknown).
    :param int pipeline_config_id: Associated pipeline config id
    :param str plugin_id: Plugin id to use for bootstrap
    :param str engine_name: Engine name to launch
    :param str entity_type: Entity type to launch
    :param str entity_id: Entity id to launch
    :param list bundle_cache_fallback_paths: List of bundle cache paths to include.
    :param bool pre_cache: If set to True, starting up the command
        will also include a full caching of all necessary
        dependencies for all contexts and engines.
    """
    # log to file.
    sgtk.LogManager().initialize_base_file_handler(engine_name)
    logger.debug("")
    logger.debug("-=" * 60)
    logger.debug("Preparing ToolkitManager for command cache bootstrap.")
    # Setup the bootstrap manager.
    manager = sgtk.bootstrap.ToolkitManager()
    manager.plugin_id = plugin_id
    manager.bundle_cache_fallback_paths = bundle_cache_fallback_paths
    if pre_cache:
        logger.debug("Will request a full environment caching before startup.")
        manager.caching_policy = manager.CACHE_FULL
    if pipeline_config_id:
        # we have a pipeline config id to launch.
        manager.do_shotgun_config_lookup = True
        manager.pipeline_configuration = pipeline_config_id
    else:
        # launch a base uri. no need to look in sg for overrides.
        manager.do_shotgun_config_lookup = False
        manager.base_configuration = configuration_uri
    logger.debug("Starting %s using entity %s %s", engine_name, entity_type, entity_id)
    try:
        engine = manager.bootstrap_engine(
            engine_name,
            entity={"type": entity_type, "id": entity_id}
        )
    #
    # NOTE: At this point, the core has been swapped, and can be as old
    #       as v0.15.x. Beyond this point, all sgtk operatinos need to
    #       be backwards compatible with v0.15.
    #
    except Exception as e:
        # qualify this exception and re-raise
        # note: we cannot probe for TankMissingEngineError here,
        # because older cores may not raise that exception type.
        logger.debug("Could not launch engine.", exc_info=True)
        raise EngineStartupError(e)
    logger.debug("Engine %s started using entity %s %s", engine, entity_type, entity_id)
    # add the core path to the PYTHONPATH so that downstream processes
    # can make use of it
    sgtk_path = _get_core_python_path(engine)
    sgtk.util.prepend_path_to_env_var("PYTHONPATH", sgtk_path)
    return engine 
[docs]def cache_commands(engine, entity_type, entity_id, cache_path):
    """
    Caches registered commands for the given engine.
    If the engine is None, an empty list of actions is cached.
    :param str entity_type: Entity type
    :param str entity_id: Entity id
    :param engine: Engine instance or None
    :param str cache_path: Path to write cached data to
    """
    # import modules from shotgun-utils fw for serialization
    utils_folder = os.path.abspath(
        os.path.join(os.path.dirname(__file__), "..", )
    )
    external_command_utils = _import_py_file(utils_folder, "external_command_utils")
    cache_data = {
        "generation": external_command_utils.FORMAT_GENERATION,
        "commands": []
    }
    if engine is None:
        logger.debug("No engine running - caching empty list of commands.")
    else:
        logger.debug("Processing engine commands...")
        for cmd_name, data in engine.commands.iteritems():
            logger.debug("Processing command: %s" % cmd_name)
            # note: we are baking the current operating system into the cache,
            #       meaning that caches cannot be shared freely across OS platforms.
            if external_command_utils.enabled_on_current_os(data["properties"]):
                cache_data["commands"].append(
                    external_command_utils.serialize_command(
                        engine.name,
                        entity_type,
                        cmd_name,
                        data["properties"]
                    )
                )
        logger.debug("Engine commands processed.")
    _write_cache_file(cache_path, cache_data)
    logger.debug("Cache complete.") 
[docs]def main():
    """
    Main method, executed from inside a QT event loop.
    """
    # unpack file with arguments payload
    arg_data_file = sys.argv[2]
    with open(arg_data_file, "rb") as fh:
        arg_data = cPickle.load(fh)
    # Set the PYTHONPATH if requested. This is an important step, as our parent
    # process might have polluted PYTHONPATH with data required for this
    # external_runner script to run properly, but that would cause problems
    # when a process is spawned from this script, like when launching a DCC.
    if "pythonpath" in arg_data:
        if arg_data["pythonpath"] is None and "PYTHONPATH" in os.environ:
            del os.environ["PYTHONPATH"]
        else:
            os.environ["PYTHONPATH"] = arg_data["pythonpath"]
    # Add application icon
    qt_application.setWindowIcon(
        qt_importer.QtGui.QIcon(arg_data["icon_path"])
    )
    action = arg_data["action"]
    engine = None
    if action == "cache_actions":
        try:
            engine = start_engine(
                arg_data["configuration_uri"],
                arg_data["pipeline_config_id"],
                arg_data["plugin_id"],
                arg_data["engine_name"],
                arg_data["entity_type"],
                arg_data["entity_id"],
                arg_data["bundle_cache_fallback_paths"],
                arg_data.get("pre_cache") or False,
            )
        except Exception as e:
            # catch the special case where a shotgun engine has falled back
            # to its legacy mode, looking for a shotgun_entitytype.yml file
            # and cannot find it. In this case, we shouldn't handle that as
            # an error but as an indication that the given entity type and
            # entity id doesn't have any actions defined, and thus produce
            # an empty list.
            #
            # Because this operation needs to be backwards compatible, we
            # have to parse the exception message in order to extract the
            # relevant state. The error to look for is on the following form:
            # TankMissingEnvironmentFile: Missing environment file: /path/to/env/shotgun_camera.yml
            #
            if re.match("^Missing environment file:.*shotgun_[a-zA-Z0-9]+\.yml$", str(e)):
                logger.debug(
                    "Bootstrap returned legacy fallback exception '%s'. "
                    "An empty list of actions will be cached for the "
                    "given entity type.", str(e)
                )
            else:
                # bubble the error
                raise
        cache_commands(
            engine,
            arg_data["entity_type"],
            arg_data["entity_id"],
            arg_data["cache_path"]
        )
    elif action == "execute_command":
        engine = start_engine(
            arg_data["configuration_uri"],
            arg_data["pipeline_config_id"],
            arg_data["plugin_id"],
            arg_data["engine_name"],
            arg_data["entity_type"],
            arg_data["entity_ids"][0],
            arg_data["bundle_cache_fallback_paths"],
            False,
        )
        callback_name = arg_data["callback_name"]
        supports_multi_select = arg_data["supports_multiple_selection"]
        # try to set the process icon to be the tk app icon
        if engine.commands[callback_name]["properties"].get("app"):
            # not every command has an associated app
            qt_application.setWindowIcon(
                qt_importer.QtGui.QIcon(
                    engine.commands[callback_name]["properties"]["app"].icon_256
                )
            )
        # Now execute the payload command payload
        #
        # multi-select actions use an old execution methodology
        # and are only used by the tk-shotgun engine, where they
        # will continue to be supported but not added in other places.
        if supports_multi_select:
            # multi select commands are expected to be routed via
            # a special method using the following interface:
            # execute_old_style_command(cmd_name, entity_type, entity_ids)
            engine.execute_old_style_command(
                callback_name,
                arg_data["entity_type"],
                arg_data["entity_ids"],
            )
        else:
            # standard route - just run the callback
            engine.commands[callback_name]["callback"]()
    else:
        raise RuntimeError("Unknown action '%s'" % action) 
if __name__ == "__main__":
    """
    Main script entry point
    """
    task_runner = QtTaskRunner(main)
    # For qt5, we may get this error:
    #
    # RuntimeError: Qt WebEngine seems to be initialized from a plugin.
    # Please set Qt::AA_ShareOpenGLContexts using QCoreApplication::setAttribute
    # before constructing QGuiApplication.
    if hasattr(qt_importer.QtCore.Qt, "AA_ShareOpenGLContexts"):
        qt_importer.QtGui.QApplication.setAttribute(
            qt_importer.QtCore.Qt.AA_ShareOpenGLContexts
        )
    # we don't want this process to have any traces of
    # any previous environment
    if "TANK_CURRENT_PC" in os.environ:
        del os.environ["TANK_CURRENT_PC"]
    # start up our QApp now
    qt_application = qt_importer.QtGui.QApplication([])
    # when the QApp starts, initialize our task code
    qt_importer.QtCore.QTimer.singleShot(0, task_runner.execute_command)
    # and ask the main app to exit when the task emits its finished signal
    task_runner.completed.connect(qt_application.quit)
    # start the application loop. This will block the process until the task
    # has completed - this is either triggered by a main window closing or
    # byt the finished signal being called from the task class above.
    qt_application.exec_()
    # Make sure we have a clean shutdown.
    if sgtk.platform.current_engine():
        sgtk.platform.current_engine().destroy()
    if task_runner.status == task_runner.SUCCESS:
        sys.exit(0)
    elif task_runner.status == task_runner.ERROR_ENGINE_NOT_STARTED:
        sys.exit(2)
    # for all general errors
    sys.exit(1)