Source code for tank.commands.tank_command

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

"""
Methods for handling of the tank command

"""

import logging

from .action_base import Action
from .interaction import RawInputCommandInteraction, YesToEverythingInteraction
from . import folders
from . import misc
from . import move_pc
from . import pc_overview
from . import path_cache
from . import update
from . import push_pc
from . import setup_project
from . import setup_project_wizard
from . import dump_config
from . import validate_config
from . import cache_apps
from . import switch
from . import app_info
from . import core_upgrade
from . import core_localize
from . import install
from . import clone_configuration
from . import copy_apps
from . import unregister_folders
from . import desktop_migration
from . import cache_yaml
from . import get_entity_commands
from . import constants


from .. import constants as constants_global
from .. import LogManager
from ..platform.engine import start_engine, get_environment_from_context
from ..errors import TankError

log = LogManager.get_logger(__name__)


###############################################################################################
# Built in actions (all in the tank_commands sub module)

BUILT_IN_ACTIONS = [
    setup_project.SetupProjectAction,
    setup_project_wizard.SetupProjectFactoryAction,
    core_upgrade.CoreUpdateAction,
    core_localize.CoreLocalizeAction,
    core_localize.ShareCoreAction,
    core_localize.AttachToCoreAction,
    dump_config.DumpConfigAction,
    validate_config.ValidateConfigAction,
    cache_apps.CacheAppsAction,
    misc.ClearCacheAction,
    switch.SwitchAppAction,
    app_info.AppInfoAction,
    misc.InteractiveShellAction,
    install.InstallAppAction,
    push_pc.PushPCAction,
    install.InstallEngineAction,
    update.AppUpdatesAction,
    folders.CreateFoldersAction,
    folders.PreviewFoldersAction,
    move_pc.MovePCAction,
    pc_overview.PCBreakdownAction,
    path_cache.SynchronizePathCache,
    path_cache.PathCacheMigrationAction,
    unregister_folders.UnregisterFoldersAction,
    clone_configuration.CloneConfigAction,
    copy_apps.CopyAppsAction,
    desktop_migration.DesktopMigration,
    cache_yaml.CacheYamlAction,
    get_entity_commands.GetEntityCommandsAction,
]


def _get_built_in_actions():
    """
    Returns a list of built in actions
    """
    actions = []
    for ClassObj in BUILT_IN_ACTIONS:
        actions.append(ClassObj())
    return actions


###############################################################################################
# Shell engine tank commands - adapter for creating an action from an app


class ShellEngineAction(Action):
    """
    Action wrapper around a shell engine command
    """

    def __init__(self, name, description, command_key):
        Action.__init__(self, name, Action.ENGINE, description, "Shell Engine")
        self._command_key = command_key

    def run_interactive(self, log, args):
        self.engine.execute_command(self._command_key, args)


def get_shell_engine_actions(engine_obj):
    """
    Returns a list of shell engine actions
    """

    actions = []
    for c in engine_obj.commands:

        # custom properties dict
        props = engine_obj.commands[c]["properties"]

        # the properties dict contains some goodies here that we can use
        # look for a short_name, if that does not exist, fall back on the command name
        # prefix will hold a prefix that guarantees uniqueness, if needed
        cmd_name = c
        if "short_name" in props:
            if props["prefix"]:
                # need a prefix to produce a unique command
                cmd_name = "%s:%s" % (props["prefix"], props["short_name"])
            else:
                # unique without a prefix
                cmd_name = props["short_name"]

        description = engine_obj.commands[c]["properties"].get(
            "description", "No description available."
        )

        actions.append(ShellEngineAction(cmd_name, description, c))

    return actions


###############################################################################################
# Complete public API definitions for accessing toolkit commands
# ------------------------------------------------------------
# - The list_commands returns a list of available command names
# - The create_command factory returns a SgtkSystemCommand class instance
#   for a given command
# - The SgtkSystemCommand class wraps around a command implementation and
#   forms the actual interface which we expose via the interface.


[docs]def list_commands(tk=None): """ Lists the system commands registered with the system. If you leave the optional tk parameter as None, a list of global commands will be returned. These commands can be executed at any point and do not require a project or a configuration to be present. Examples of such commands are the core upgrade check and the setup_project commands:: >>> import sgtk >>> sgtk.list_commands() ['setup_project', 'core'] If you do pass in a tk API handle (or alternatively use the convenience method :meth:`Sgtk.list_commands`), all commands which are available in the context of a project configuration will be returned. This includes for example commands for configuration management, anything app or engine related and validation and overview functionality. In addition to these commands, the global commands will also be returned:: >>> import sgtk >>> tk = sgtk.sgtk_from_path("/studio/project_root") >>> tk.list_commands() ['setup_project', 'core', 'localize', 'validate', 'cache_apps', 'clear_cache', 'app_info', 'install_app', 'install_engine', 'updates', 'configurations', 'clone_configuration'] :param tk: Optional Toolkit API instance :type tk: :class:`Sgtk` :returns: list of command names """ action_names = [] for a in _get_built_in_actions(): # check if this tank command has API support if a.supports_api: # if we don't have a tk API instance, we can only access GLOBAL commands if tk is None and a.mode != Action.GLOBAL: continue action_names.append(a.name) return action_names
[docs]def get_command(command_name, tk=None): """ Returns an instance of a command object that can be used to execute a command. Once you have retrieved the command instance, you can perform introspection to check for example the required parameters for the command, name, description etc. Lastly, you can execute the command by running the execute() method. In order to get a list of the available commands, use the :meth:`list_commands` method. Certain commands require a project configuration context in order to operate. This needs to be passed on in the form of a toolkit API instance via the tk parameter. See the list_command() documentation for more details. :param command_name: Name of command to execute. Get a list of all available commands using the :meth:`list_commands` method. :param tk: Optional Toolkit API instance :type tk: :class:`Sgtk` :returns: :class:`SgtkSystemCommand` """ if command_name not in list_commands(tk): # not found raise TankError( "The command '%s' does not exist. Use the sgtk.list_commands() method to " "see a list of all commands available via the API." % command_name ) for x in _get_built_in_actions(): if x.name == command_name and x.supports_api: return SgtkSystemCommand(x, tk)
[docs]class SgtkSystemCommand(object): """ Represents a toolkit system command. You can use this object to introspect command properties such as name, description, parameters etc. Execution is carried out by calling the :meth:`execute` method. For a global command which doesn't require an active configuration, execution typically looks like this:: >>> import sgtk >>> sgtk.list_commands() ['setup_project', 'core'] >>> cmd = sgtk.get_command("core") >>> cmd <tank.deploy.tank_command.SgtkSystemCommand object at 0x106d9f090> >>> cmd.execute({}) """ # this class wraps around a tank.deploy.tank_commands.action_base.Action class # and exposes the "official" interface for it. def __init__(self, internal_action_object, tk): """ Instances should be constructed using the :meth:`get_command` factory method. """ self.__internal_action_obj = internal_action_object # only commands of type GLOBAL, TK_INSTANCE are currently supported if self.__internal_action_obj.mode not in (Action.GLOBAL, Action.TK_INSTANCE): raise TankError( "The command %r is not of a type which is supported by Toolkit. " "Please contact support at %s." % (self.__internal_action_obj, constants_global.SUPPORT_URL) ) # make sure we pass a tk api for actions that require it if self.__internal_action_obj.mode == Action.TK_INSTANCE and tk is None: raise TankError( "This command requires a Toolkit API instance to execute. Please " "provide this either as a parameter to the sgtk.get_command() method " "or alternatively execute the tk.get_command() method directly from " "a Toolkit API instance." ) if tk: self.__internal_action_obj.tk = tk # set up a default logger which can be overridden via the set_logger method # for the default logger, use the standard toolkit logging standard based on __name__ self.__log = log # make sure that we have exactly one handler if len(self.__log.handlers) == 0: ch = logging.StreamHandler() ch.setLevel(logging.INFO) formatter = logging.Formatter("%(levelname)s %(message)s") ch.setFormatter(formatter) self.__log.addHandler(ch) @property def parameters(self): """ The different parameters that needs to be specified and if a parameter has any default values. For example:: { "parameter_name": { "description": "Parameter info", "default": None, "type": "str" }, ... "return_value": { "description": "Return value (optional)", "type": "str" } } """ return self.__internal_action_obj.parameters @property def description(self): """ A brief description of this command. """ return self.__internal_action_obj.description @property def name(self): """ The name of this command. """ return self.__internal_action_obj.name @property def category(self): """ The category for this command. This is typically a short string like "Admin". """ return self.__internal_action_obj.category @property def logger(self): """ The python :class:`~logging.Logger` associated with this tank command """ return self.__log
[docs] def set_logger(self, log): """ Specify a standard python log instance to send logging output to. If this is not specify, the standard output mechanism will be used. .. warning:: We strongly recommend using the :meth:`logger` property to retrieve the default logger for the tank command and attaching a handler to this rather than passing in an explicit log object via this method. This method may be deprecated at some point in the future. :param log: Standard python logging instance """ self.__log = log
[docs] def execute(self, params, interaction_interface=None): """ Execute this command. :param params: dictionary of parameters to pass to this command. the dictionary key is the name of the parameter and the value is the value you want to pass. You can query which parameters can be passed in via the parameters property. :param interaction_interface: Optional interaction interface. This will be used whenever the command needs to interact with the user. Should be an instance deriving from :class:`CommandInteraction`. :returns: Whatever the command returns. Data type and description for the return value can be introspected via the :meth:`parameters` property. """ interaction_interface = interaction_interface or YesToEverythingInteraction() self.__internal_action_obj.set_interaction_interface(interaction_interface) return self.__internal_action_obj.run_noninteractive(self.__log, params)
[docs] def terminate(self): """ Instructs the command to attempt to terminate its execution. Not all commands are able to terminate and execution normally does not terminate straight away. """ self.__internal_action_obj.terminate()
############################################################################################### # Main entry points for accessing tank commands from the tank command / shell engine def get_actions(log, tk, ctx): """ Returns a list of Action objects given the current context, api etc. tk and ctx may be none, indicating that tank is running in a 'partial' state. """ engine = None if tk is not None and ctx is not None: # we have all the necessary pieces needed to start an engine # check if there is an environment object for our context env = get_environment_from_context(tk, ctx) log.debug( "Probing for a shell engine. ctx '%s' --> environment '%s'" % (ctx, env) ) if env and constants.SHELL_ENGINE in env.get_engines(): log.debug( "Looks like the environment has a tk-shell engine. Trying to start it." ) # we have an environment and a shell engine. Looking good. engine = start_engine(constants.SHELL_ENGINE, tk, ctx) log.debug("Started engine %s" % engine) log.info("- Started Shell Engine version %s" % engine.version) log.info("- Environment: %s." % engine.environment["disk_location"]) actions = [] # get all actions regardless of current scope first all_actions = _get_built_in_actions() if engine: all_actions.extend(get_shell_engine_actions(engine)) # now only pick the ones that are working with our current state for a in all_actions: if not a.supports_tank_command: # this action does not support tank command mode continue if a.mode == Action.GLOBAL: # globals are always possible to run actions.append(a) if tk and a.mode == Action.TK_INSTANCE: # we have a PC! actions.append(a) if ctx and a.mode == Action.CTX: # we have a command that needs a context actions.append(a) if engine and a.mode == Action.ENGINE: # needs the engine actions.append(a) return (actions, engine) def run_action(log, tk, ctx, command, args): """ Find an action and start execution. This method is tightly coupled with the tank_cmd script. The command handles multiple states and contains logic for validating that the mode of the desired command is actually compatible with the state which is passed in. Because tank commands can run in environments with varying degrees of completeness (ranging from only knowing the code location to having a fully qualified context), some of the parameters deliberately overlap. :param log: Python logger to pass command output to :param tk: API instance to pass to command. For a state where no notion of a pipeline config/current project exists, this will be None. :param ctx: Context object. For a state where a current context is not known, this will be none. :param args: list of strings forming additional arguments to be passed to the command. """ engine = None # first see if we can find the action without starting the engine found_action = None for x in _get_built_in_actions(): if x.name == command: found_action = x break if found_action and found_action.wants_running_shell_engine == False: log.debug("No need to load up the engine for this command.") else: # try to load the engine. if tk is not None and ctx is not None: # we have all the necessary pieces needed to start an engine # check if there is an environment object for our context env = get_environment_from_context(tk, ctx) log.debug( "Probing for a shell engine. ctx '%s' --> environment '%s'" % (ctx, env) ) if env and constants.SHELL_ENGINE in env.get_engines(): log.debug( "Looks like the environment has a tk-shell engine. Trying to start it." ) # we have an environment and a shell engine. Looking good. engine = start_engine(constants.SHELL_ENGINE, tk, ctx) log.debug("Started engine %s" % engine) log.info("- Started Shell Engine version %s" % engine.version) log.info("- Environment: %s." % engine.environment["disk_location"]) # now keep looking for our command if ( found_action is None ): # may already be found (a core cmd which needs and engine) for x in get_shell_engine_actions(engine): if x.name == command: found_action = x break # ok we now have all the pieces we need if found_action is None: log.info("") log.info("") log.error("The action '%s' could not be found!" % command) log.info("") log.info( "In order to list all action that are available, try running the same command, " "but omit the '%s' part at the end." % command ) log.info("") else: # seed the action object with all the handles it may need found_action.tk = tk found_action.context = ctx found_action.engine = engine # now check that we actually have passed enough stuff to work with this mode if ( found_action.mode in (Action.TK_INSTANCE, Action.CTX, Action.ENGINE) and tk is None ): # we are missing a tk instance log.debug("Trying to launch %r without an Toolkit instance." % found_action) raise TankError( "The command '%s' needs a project to run. For example, if you want " "to run it for project XYZ, execute " "'tank Project XYZ %s'" % (found_action.name, found_action.name) ) if found_action.mode in (Action.CTX, Action.ENGINE) and ctx is None: # we have a command that needs a context log.debug("Trying to launch %r without a context." % found_action) raise TankError( "The command '%s' needs a work area to run." % found_action.name ) if found_action.mode == Action.ENGINE and engine is None: # we have a command that needs an engine log.debug("Trying to launch %r without an engine." % found_action) raise TankError( "The command '%s' needs the shell engine running." % found_action.name ) # ok all good log.info("- Running command %s..." % found_action.name) log.info("") log.info("") log.info("-" * 70) log.info("Command: %s" % found_action.name.replace("_", " ").capitalize()) log.info("-" * 70) log.info("") found_action.set_interaction_interface(RawInputCommandInteraction()) return found_action.run_interactive(log, args)