# 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)