import os import copy import collections from abc import ( ABCMeta, abstractmethod, abstractproperty ) import six from openpype.settings import get_system_settings, get_project_settings from openpype.lib import Logger from openpype.pipeline.plugin_discover import ( discover, register_plugin, register_plugin_path, deregister_plugin, deregister_plugin_path ) from .subset_name import get_subset_name from .legacy_create import LegacyCreator class CreatorError(Exception): """Should be raised when creator failed because of known issue. Message of error should be user readable. """ def __init__(self, message): super(CreatorError, self).__init__(message) @six.add_metaclass(ABCMeta) class SubsetConvertorPlugin(object): """Helper for conversion of instances created using legacy creators. Conversion from legacy creators would mean to loose legacy instances, convert them automatically or write a script which must user run. All of these solutions are workign but will happen without asking or user must know about them. This plugin can be used to show legacy instances in Publisher and give user ability to run conversion script. Convertor logic should be very simple. Method 'find_instances' is to look for legacy instances in scene a possibly call pre-implemented 'add_convertor_item'. User will have ability to trigger conversion which is executed by calling 'convert' which should call 'remove_convertor_item' when is done. It does make sense to add only one or none legacy item to create context for convertor as it's not possible to choose which instace are converted and which are not. Convertor can use 'collection_shared_data' property like creators. Also can store any information to it's object for conversion purposes. Args: create_context """ _log = None def __init__(self, create_context): self._create_context = create_context @property def log(self): """Logger of the plugin. Returns: logging.Logger: Logger with name of the plugin. """ if self._log is None: self._log = Logger.get_logger(self.__class__.__name__) return self._log @abstractproperty def identifier(self): """Converted identifier. Returns: str: Converted identifier unique for all converters in host. """ pass @abstractmethod def find_instances(self): """Look for legacy instances in the scene. Should call 'add_convertor_item' if there is at least one instance to convert. """ pass @abstractmethod def convert(self): """Conversion code.""" pass @property def create_context(self): """Quick access to create context.""" return self._create_context @property def collection_shared_data(self): """Access to shared data that can be used during 'find_instances'. Retruns: Dict[str, Any]: Shared data. Raises: UnavailableSharedData: When called out of collection phase. """ return self._create_context.collection_shared_data def add_convertor_item(self, label): """Add item to CreateContext. Args: label (str): Label of item which will show in UI. """ self._create_context.add_convertor_item(self.identifier, label) def remove_convertor_item(self): """Remove legacy item from create context when conversion finished.""" self._create_context.remove_convertor_item(self.identifier) @six.add_metaclass(ABCMeta) class BaseCreator: """Plugin that create and modify instance data before publishing process. We should maybe find better name as creation is only one part of it's logic and to avoid expectations that it is the same as `avalon.api.Creator`. Single object should be used for multiple instances instead of single instance per one creator object. Do not store temp data or mid-process data to `self` if it's not Plugin specific. """ # Label shown in UI label = None group_label = None # Cached group label after first call 'get_group_label' _cached_group_label = None # Variable to store logger _log = None # Creator is enabled (Probably does not have reason of existence?) enabled = True # Creator (and family) icon # - may not be used if `get_icon` is reimplemented icon = None # Instance attribute definitions that can be changed per instance # - returns list of attribute definitions from # `openpype.pipeline.attribute_definitions` instance_attr_defs = [] # Filtering by host name - can be used to be filtered by host name # - used on all hosts when set to 'None' for Backwards compatibility # - was added afterwards # QUESTION make this required? host_name = None def __init__( self, project_settings, system_settings, create_context, headless=False ): # Reference to CreateContext self.create_context = create_context self.project_settings = project_settings # Creator is running in headless mode (without UI elemets) # - we may use UI inside processing this attribute should be checked self.headless = headless self.apply_settings(project_settings, system_settings) def apply_settings(self, project_settings, system_settings): """Method called on initialization of plugin to apply settings.""" pass @property def identifier(self): """Identifier of creator (must be unique). Default implementation returns plugin's family. """ return self.family @abstractproperty def family(self): """Family that plugin represents.""" pass @property def project_name(self): """Family that plugin represents.""" return self.create_context.project_name @property def host(self): return self.create_context.host def get_group_label(self): """Group label under which are instances grouped in UI. Default implementation use attributes in this order: - 'group_label' -> 'label' -> 'identifier' Keep in mind that 'identifier' use 'family' by default. Returns: str: Group label that can be used for grouping of instances in UI. Group label can be overriden by instance itself. """ if self._cached_group_label is None: label = self.identifier if self.group_label: label = self.group_label elif self.label: label = self.label self._cached_group_label = label return self._cached_group_label @property def log(self): """Logger of the plugin. Returns: logging.Logger: Logger with name of the plugin. """ if self._log is None: self._log = Logger.get_logger(self.__class__.__name__) return self._log def _add_instance_to_context(self, instance): """Helper method to add instance to create context. Instances should be stored to DCC workfile metadata to be able reload them and also stored to CreateContext in which is creator plugin existing at the moment to be able use it without refresh of CreateContext. Args: instance (CreatedInstance): New created instance. """ self.create_context.creator_adds_instance(instance) def _remove_instance_from_context(self, instance): """Helper method to remove instance from create context. Instances must be removed from DCC workfile metadat aand from create context in which plugin is existing at the moment of removement to propagate the change without restarting create context. Args: instance (CreatedInstance): Instance which should be removed. """ self.create_context.creator_removed_instance(instance) @abstractmethod def create(self): """Create new instance. Replacement of `process` method from avalon implementation. - must expect all data that were passed to init in previous implementation """ pass @abstractmethod def collect_instances(self): """Collect existing instances related to this creator plugin. The implementation differs on host abilities. The creator has to collect metadata about instance and create 'CreatedInstance' object which should be added to 'CreateContext'. Example: ```python def collect_instances(self): # Getting existing instances is different per host implementation for instance_data in pipeline.list_instances(): # Process only instances that were created by this creator creator_id = instance_data.get("creator_identifier") if creator_id == self.identifier: # Create instance object from existing data instance = CreatedInstance.from_existing( instance_data, self ) # Add instance to create context self._add_instance_to_context(instance) ``` """ pass @abstractmethod def update_instances(self, update_list): """Store changes of existing instances so they can be recollected. Args: update_list(List[UpdateData]): Gets list of tuples. Each item contain changed instance and it's changes. """ pass @abstractmethod def remove_instances(self, instances): """Method called on instance removement. Can also remove instance metadata from context but should return 'True' if did so. Args: instance(List[CreatedInstance]): Instance objects which should be removed. """ pass def get_icon(self): """Icon of creator (family). Can return path to image file or awesome icon name. """ return self.icon def get_dynamic_data( self, variant, task_name, asset_doc, project_name, host_name, instance ): """Dynamic data for subset name filling. These may be get dynamically created based on current context of workfile. """ return {} def get_subset_name( self, variant, task_name, asset_doc, project_name, host_name=None, instance=None ): """Return subset name for passed context. CHANGES: Argument `asset_id` was replaced with `asset_doc`. It is easier to query asset before. In some cases would this method be called multiple times and it would be too slow to query asset document on each callback. NOTE: Asset document is not used yet but is required if would like to use task type in subset templates. Method is also called on subset name update. In that case origin instance is passed in. Args: variant(str): Subset name variant. In most of cases user input. task_name(str): For which task subset is created. asset_doc(dict): Asset document for which subset is created. project_name(str): Project name. host_name(str): Which host creates subset. instance(CreatedInstance|None): Object of 'CreatedInstance' for which is subset name updated. Passed only on subset name update. """ dynamic_data = self.get_dynamic_data( variant, task_name, asset_doc, project_name, host_name, instance ) return get_subset_name( self.family, variant, task_name, asset_doc, project_name, host_name, dynamic_data=dynamic_data, project_settings=self.project_settings ) def get_instance_attr_defs(self): """Plugin attribute definitions. Attribute definitions of plugin that hold data about created instance and values are stored to metadata for future usage and for publishing purposes. NOTE: Convert method should be implemented which should care about updating keys/values when plugin attributes change. Returns: List[AbtractAttrDef]: Attribute definitions that can be tweaked for created instance. """ return self.instance_attr_defs @property def collection_shared_data(self): """Access to shared data that can be used during creator's collection. Retruns: Dict[str, Any]: Shared data. Raises: UnavailableSharedData: When called out of collection phase. """ return self.create_context.collection_shared_data def set_instance_thumbnail_path(self, instance_id, thumbnail_path=None): """Set path to thumbnail for instance.""" self.create_context.thumbnail_paths_by_instance_id[instance_id] = ( thumbnail_path ) class Creator(BaseCreator): """Creator that has more information for artist to show in UI. Creation requires prepared subset name and instance data. """ # GUI Purposes # - default_variants may not be used if `get_default_variants` is overriden default_variants = [] # Default variant used in 'get_default_variant' default_variant = None # Short description of family # - may not be used if `get_description` is overriden description = None # Detailed description of family for artists # - may not be used if `get_detail_description` is overriden detailed_description = None # It does make sense to change context on creation # - in some cases it may confuse artists because it would not be used # e.g. for buld creators create_allow_context_change = True # A thumbnail can be passed in precreate attributes # - if is set to True is should expect that a thumbnail path under key # PRE_CREATE_THUMBNAIL_KEY can be sent in data with precreate data # - is disabled by default because the feature was added in later stages # and creators who would not expect PRE_CREATE_THUMBNAIL_KEY could # cause issues with instance data create_allow_thumbnail = False # Precreate attribute definitions showed before creation # - similar to instance attribute definitions pre_create_attr_defs = [] @abstractmethod def create(self, subset_name, instance_data, pre_create_data): """Create new instance and store it. Ideally should be stored to workfile using host implementation. Args: subset_name(str): Subset name of created instance. instance_data(dict): Base data for instance. pre_create_data(dict): Data based on pre creation attributes. Those may affect how creator works. """ # instance = CreatedInstance( # self.family, subset_name, instance_data # ) pass def get_description(self): """Short description of family and plugin. Returns: str: Short description of family. """ return self.description def get_detail_description(self): """Description of family and plugin. Can be detailed with markdown or html tags. Returns: str: Detailed description of family for artist. """ return self.detailed_description def get_default_variants(self): """Default variant values for UI tooltips. Replacement of `defatults` attribute. Using method gives ability to have some "logic" other than attribute values. By default returns `default_variants` value. Returns: List[str]: Whisper variants for user input. """ return copy.deepcopy(self.default_variants) def get_default_variant(self): """Default variant value that will be used to prefill variant input. This is for user input and value may not be content of result from `get_default_variants`. Can return `None`. In that case first element from `get_default_variants` should be used. """ return self.default_variant def get_pre_create_attr_defs(self): """Plugin attribute definitions needed for creation. Attribute definitions of plugin that define how creation will work. Values of these definitions are passed to `create` method. Note: Convert method should be implemented which should care about updating keys/values when plugin attributes change. Returns: List[AbtractAttrDef]: Attribute definitions that can be tweaked for created instance. """ return self.pre_create_attr_defs class HiddenCreator(BaseCreator): @abstractmethod def create(self, instance_data, source_data): pass class AutoCreator(BaseCreator): """Creator which is automatically triggered without user interaction. Can be used e.g. for `workfile`. """ def remove_instances(self, instances): """Skip removement.""" pass def discover_creator_plugins(): return discover(BaseCreator) def discover_convertor_plugins(): return discover(SubsetConvertorPlugin) def discover_legacy_creator_plugins(): from openpype.lib import Logger log = Logger.get_logger("CreatorDiscover") plugins = discover(LegacyCreator) project_name = os.environ.get("AVALON_PROJECT") system_settings = get_system_settings() project_settings = get_project_settings(project_name) for plugin in plugins: try: plugin.apply_settings(project_settings, system_settings) except Exception: log.warning( "Failed to apply settings to creator {}".format( plugin.__name__ ), exc_info=True ) return plugins def get_legacy_creator_by_name(creator_name, case_sensitive=False): """Find creator plugin by name. Args: creator_name (str): Name of creator class that should be returned. case_sensitive (bool): Match of creator plugin name is case sensitive. Set to `False` by default. Returns: Creator: Return first matching plugin or `None`. """ # Lower input creator name if is not case sensitive if not case_sensitive: creator_name = creator_name.lower() for creator_plugin in discover_legacy_creator_plugins(): _creator_name = creator_plugin.__name__ # Lower creator plugin name if is not case sensitive if not case_sensitive: _creator_name = _creator_name.lower() if _creator_name == creator_name: return creator_plugin return None def register_creator_plugin(plugin): if issubclass(plugin, BaseCreator): register_plugin(BaseCreator, plugin) elif issubclass(plugin, LegacyCreator): register_plugin(LegacyCreator, plugin) elif issubclass(plugin, SubsetConvertorPlugin): register_plugin(SubsetConvertorPlugin, plugin) def deregister_creator_plugin(plugin): if issubclass(plugin, BaseCreator): deregister_plugin(BaseCreator, plugin) elif issubclass(plugin, LegacyCreator): deregister_plugin(LegacyCreator, plugin) elif issubclass(plugin, SubsetConvertorPlugin): deregister_plugin(SubsetConvertorPlugin, plugin) def register_creator_plugin_path(path): register_plugin_path(BaseCreator, path) register_plugin_path(LegacyCreator, path) register_plugin_path(SubsetConvertorPlugin, path) def deregister_creator_plugin_path(path): deregister_plugin_path(BaseCreator, path) deregister_plugin_path(LegacyCreator, path) deregister_plugin_path(SubsetConvertorPlugin, path) def cache_and_get_instances(creator, shared_key, list_instances_func): """Common approach to cache instances in shared data. This is helper function which does not handle cases when a 'shared_key' is used for different list instances functions. The same approach of caching instances into 'collection_shared_data' is not required but is so common we've decided to unify it to some degree. Function 'list_instances_func' is called only if 'shared_key' is not available in 'collection_shared_data' on creator. Args: creator (Creator): Plugin which would like to get instance data. shared_key (str): Key under which output of function will be stored. list_instances_func (Function): Function that will return instance data if data were not yet stored under 'shared_key'. Returns: Dict[str, Dict[str, Any]]: Cached instances by creator identifier from result of passed function. """ if shared_key not in creator.collection_shared_data: value = collections.defaultdict(list) for instance in list_instances_func(): identifier = instance.get("creator_identifier") value[identifier].append(instance) creator.collection_shared_data[shared_key] = value return creator.collection_shared_data[shared_key]