diff --git a/pype/api.py b/pype/api.py index b88be4cc888..b2d8739c2b3 100644 --- a/pype/api.py +++ b/pype/api.py @@ -20,6 +20,9 @@ from . import resources from .plugin import ( + PypeCreatorMixin, + Creator, + Extractor, ValidatePipelineOrder, @@ -67,6 +70,8 @@ "resources", # plugin classes + "PypeCreatorMixin", + "Creator", "Extractor", # ordering "ValidatePipelineOrder", diff --git a/pype/hosts/blender/plugin.py b/pype/hosts/blender/plugin.py index d0b81148c34..f216eb28be7 100644 --- a/pype/hosts/blender/plugin.py +++ b/pype/hosts/blender/plugin.py @@ -6,6 +6,8 @@ import bpy from avalon import api +import avalon.blender +from pype.api import PypeCreatorMixin VALID_EXTENSIONS = [".blend", ".json"] @@ -100,6 +102,10 @@ def get_local_collection_with_name(name): return None +class Creator(PypeCreatorMixin, avalon.blender.Creator): + pass + + class AssetLoader(api.Loader): """A basic AssetLoader for Blender diff --git a/pype/hosts/harmony/plugin.py b/pype/hosts/harmony/plugin.py new file mode 100644 index 00000000000..3525ad686d1 --- /dev/null +++ b/pype/hosts/harmony/plugin.py @@ -0,0 +1,6 @@ +from avalon import harmony +from pype.api import PypeCreatorMixin + + +class Creator(PypeCreatorMixin, harmony.Creator): + pass diff --git a/pype/hosts/houdini/plugin.py b/pype/hosts/houdini/plugin.py new file mode 100644 index 00000000000..864cc59f098 --- /dev/null +++ b/pype/hosts/houdini/plugin.py @@ -0,0 +1,6 @@ +from avalon import houdini +from pype.api import PypeCreatorMixin + + +class Creator(PypeCreatorMixin, houdini.Creator): + pass diff --git a/pype/hosts/maya/plugin.py b/pype/hosts/maya/plugin.py index c7416920df0..81c89017ff1 100644 --- a/pype/hosts/maya/plugin.py +++ b/pype/hosts/maya/plugin.py @@ -1,5 +1,7 @@ from avalon import api from avalon.vendor import qargparse +import avalon.maya +from pype.api import PypeCreatorMixin def get_reference_node_parents(ref): @@ -26,6 +28,10 @@ def get_reference_node_parents(ref): return parents +class Creator(PypeCreatorMixin, avalon.maya.Creator): + pass + + class ReferenceLoader(api.Loader): """A basic ReferenceLoader for Maya diff --git a/pype/hosts/nuke/plugin.py b/pype/hosts/nuke/plugin.py index 652c0396a8c..b60c00b7889 100644 --- a/pype/hosts/nuke/plugin.py +++ b/pype/hosts/nuke/plugin.py @@ -1,9 +1,13 @@ import re import avalon.api import avalon.nuke -from pype.api import config +from pype.api import ( + config, + PypeCreatorMixin +) -class PypeCreator(avalon.nuke.pipeline.Creator): + +class PypeCreator(PypeCreatorMixin, avalon.nuke.pipeline.Creator): """Pype Nuke Creator class wrapper """ def __init__(self, *args, **kwargs): diff --git a/pype/hosts/resolve/plugin.py b/pype/hosts/resolve/plugin.py index 72eec048960..23aeac2e522 100644 --- a/pype/hosts/resolve/plugin.py +++ b/pype/hosts/resolve/plugin.py @@ -3,6 +3,7 @@ from pype.hosts import resolve from avalon.vendor import qargparse from pype.api import config +import pype.api from Qt import QtWidgets, QtCore @@ -251,7 +252,7 @@ def remove(self, container): pass -class Creator(api.Creator): +class Creator(pype.api.Creator): """Creator class wrapper """ marker_color = "Purple" diff --git a/pype/hosts/tvpaint/plugin.py b/pype/hosts/tvpaint/plugin.py new file mode 100644 index 00000000000..6f069586a51 --- /dev/null +++ b/pype/hosts/tvpaint/plugin.py @@ -0,0 +1,6 @@ +from pype.api import PypeCreatorMixin +from avalon.tvpaint import pipeline + + +class Creator(PypeCreatorMixin, pipeline.Creator): + pass diff --git a/pype/lib/__init__.py b/pype/lib/__init__.py index 1167f3b5d1b..7edc360c149 100644 --- a/pype/lib/__init__.py +++ b/pype/lib/__init__.py @@ -26,6 +26,8 @@ _subprocess ) +from .profiles_filtering import filter_profiles + from .plugin_tools import ( filter_pyblish_plugins, source_hash, @@ -69,6 +71,8 @@ "launch_application", "ApplicationAction", + "filter_profiles", + "filter_pyblish_plugins", "get_unique_layer_name", "get_background_layers", diff --git a/pype/lib/profiles_filtering.py b/pype/lib/profiles_filtering.py new file mode 100644 index 00000000000..05bd07eee63 --- /dev/null +++ b/pype/lib/profiles_filtering.py @@ -0,0 +1,211 @@ +import re +import logging + +log = logging.getLogger(__name__) + + +def compile_list_of_regexes(in_list): + """Convert strings in entered list to compiled regex objects.""" + regexes = list() + if not in_list: + return regexes + + for item in in_list: + if not item: + continue + try: + regexes.append(re.compile(item)) + except TypeError: + print(( + "Invalid type \"{}\" value \"{}\"." + " Expected string based object. Skipping." + ).format(str(type(item)), str(item))) + return regexes + + +def _profile_exclusion(matching_profiles, logger): + """Find out most matching profile byt host, task and family match. + + Profiles are selectively filtered. Each item in passed argument must + contain tuple of (profile, profile's score) where score is list of + booleans. Each boolean represents existence of filter for specific key. + Profiles are looped in sequence. In each sequence are profiles split into + true_list and false_list. For next sequence loop are used profiles in + true_list if there are any profiles else false_list is used. + + Filtering ends when only one profile left in true_list. Or when all + existence booleans loops passed, in that case first profile from remainded + profiles is returned. + + Args: + matching_profiles (list): Profiles with same scores. Each item is tuple + with (profile, profile values) + + Returns: + dict: Most matching profile. + """ + + logger.info( + "Search for first most matching profile in match order:" + " Host name -> Task name -> Family." + ) + + if not matching_profiles: + return None + + if len(matching_profiles) == 1: + return matching_profiles[0][0] + + scores_len = len(matching_profiles[0][1]) + for idx in range(scores_len): + profiles_true = [] + profiles_false = [] + for profile, score in matching_profiles: + if score[idx]: + profiles_true.append((profile, score)) + else: + profiles_false.append((profile, score)) + + if profiles_true: + matching_profiles = profiles_true + else: + matching_profiles = profiles_false + + if len(matching_profiles) == 1: + return matching_profiles[0][0] + + return matching_profiles[0][0] + + +def validate_value_by_regexes(value, in_list): + """Validates in any regex from list match entered value. + + Args: + value (str): String where regexes is checked. + in_list (list): List with regexes. + + Returns: + int: Returns `0` when list is not set, is empty or contain "*". + Returns `1` when any regex match value and returns `-1` + when none of regexes match entered value. + """ + if not in_list: + return 0 + + if not isinstance(in_list, (list, tuple, set)): + in_list = [in_list] + + if "*" in in_list: + return 0 + + # If value is not set and in list has specific values then resolve value + # as not matching. + if not value: + return -1 + + regexes = compile_list_of_regexes(in_list) + for regex in regexes: + if re.match(regex, value): + return 1 + return -1 + + +def filter_profiles(profiles_data, key_values, keys_order=None, logger=None): + """ Filter profiles by entered key -> values. + + Profile if marked with score for each key/value from `key_values` with + points -1, 0 or 1. + - if profile contain the key and profile's value contain value from + `key_values` then profile gets 1 point + - if profile does not contain the key or profile's value is empty or + contain "*" then got 0 point + - if profile contain the key, profile's value is not empty and does not + contain "*" and value from `key_values` is not available in the value + then got -1 point + + If profile gets -1 point at any time then is skipped and not used for + output. Profile with higher score is returned. If there are multiple + profiles with same score then first in order is used (order of profiles + matter). + + Args: + profiles_data (list): Profile definitions as dictionaries. + key_values (dict): Mapping of Key <-> Value. Key is checked if is + available in profile and if Value is matching it's values. + keys_order (list, tuple): Order of keys from `key_values` which matters + only when multiple profiles have same score. + logger (logging.Logger): Optionally can be passed different logger. + + Returns: + dict/None: Return most matching profile or None if none of profiles + match at least one criteria. + """ + if not profiles_data: + return None + + if not logger: + logger = log + + if not keys_order: + keys_order = tuple(key_values.keys()) + else: + _keys_order = list(keys_order) + # Make all keys from `key_values` are passed + for key in key_values.keys(): + if key not in _keys_order: + _keys_order.append(key) + keys_order = tuple(_keys_order) + + matching_profiles = None + highest_profile_points = -1 + # Each profile get 1 point for each matching filter. Profile with most + # points is returned. For cases when more than one profile will match + # are also stored ordered lists of matching values. + for profile in profiles_data: + profile_points = 0 + profile_scores = [] + + for key in keys_order: + value = key_values[key] + match = validate_value_by_regexes(value, profile.get(key)) + if match == -1: + profile_value = profile.get(key) or [] + logger.debug( + "\"{}\" not found in {}".format(key, profile_value) + ) + profile_points = -1 + break + + profile_points += match + profile_scores.append(bool(match)) + + if ( + profile_points < 0 + or profile_points < highest_profile_points + ): + continue + + if profile_points > highest_profile_points: + matching_profiles = [] + highest_profile_points = profile_points + + if profile_points == highest_profile_points: + matching_profiles.append((profile, profile_scores)) + + log_parts = " | ".join([ + "{}: \"{}\"".format(*item) + for item in key_values.items() + ]) + + if not matching_profiles: + logger.warning( + "None of profiles match your setup. {}".format(log_parts) + ) + return None + + if len(matching_profiles) > 1: + logger.warning( + "More than one profile match your setup. {}".format(log_parts) + ) + + return _profile_exclusion(matching_profiles, logger) diff --git a/pype/plugin.py b/pype/plugin.py index a169e82bebd..ac76c1cbe8b 100644 --- a/pype/plugin.py +++ b/pype/plugin.py @@ -1,9 +1,10 @@ -import tempfile import os +import inspect +import tempfile import pyblish.api - +import avalon.api from pype.api import config -import inspect +from pype.lib import filter_profiles ValidatePipelineOrder = pyblish.api.ValidatorOrder + 0.05 ValidateContentsOrder = pyblish.api.ValidatorOrder + 0.1 @@ -37,6 +38,92 @@ def imprint_attributes(plugin): print("setting {}: {} on {}".format(option, value, plugin_name)) +class TaskNotSetError(KeyError): + def __init__(self, msg=None): + if not msg: + msg = "Creator's subset name template requires task name." + super(TaskNotSetError, self).__init__(msg) + + +class PypeCreatorMixin: + """Helper to override avalon's default class methods. + + Mixin class must be used as first in inheritance order to override methods. + """ + default_tempate = "{family}{Variant}" + + @classmethod + def get_subset_name( + cls, variant, task_name, asset_id, project_name, host_name=None + ): + if not cls.family: + return "" + + if not host_name: + host_name = os.environ["AVALON_APP"] + + # Use only last part of class family value split by dot (`.`) + family = cls.family.rsplit(".", 1)[-1] + + # Get settings + profiles = ( + config.get_presets(project_name) + .get("tools", {}) + .get("creator_subset_name_profiles") + ) or [] + filtering_criteria = { + "families": family, + "hosts": host_name, + "tasks": task_name + } + + matching_profile = filter_profiles(profiles, filtering_criteria) + template = None + if matching_profile: + template = matching_profile["template"] + + # Make sure template is set (matching may have empty string) + if not template: + template = cls.default_tempate + + # Simple check of task name existence for template with {task} in + # - missing task should be possible only in Standalone publisher + if not task_name and "{task" in template.lower(): + raise TaskNotSetError() + + fill_pairs = ( + ("variant", variant), + ("family", family), + ("task", task_name) + ) + fill_data = {} + for key, value in fill_pairs: + # Handle cases when value is `None` (standalone publisher) + if value is None: + continue + # Keep value as it is + fill_data[key] = value + # Both key and value are with upper case + fill_data[key.upper()] = value.upper() + + # Capitalize only first char of value + # - conditions are because of possible index errors + capitalized = "" + if value: + # Upper first character + capitalized += value[0].upper() + # Append rest of string if there is any + if len(value) > 1: + capitalized += value[1:] + fill_data[key.capitalize()] = capitalized + + return template.format(**fill_data) + + +class Creator(PypeCreatorMixin, avalon.api.Creator): + pass + + class ContextPlugin(pyblish.api.ContextPlugin): def process(cls, *args, **kwargs): imprint_attributes(cls) diff --git a/pype/plugins/aftereffects/create/create_render.py b/pype/plugins/aftereffects/create/create_render.py index 6d876e349da..b346bc60d82 100644 --- a/pype/plugins/aftereffects/create/create_render.py +++ b/pype/plugins/aftereffects/create/create_render.py @@ -1,4 +1,4 @@ -from avalon import api +import pype.api from avalon.vendor import Qt from avalon import aftereffects @@ -7,7 +7,7 @@ log = logging.getLogger(__name__) -class CreateRender(api.Creator): +class CreateRender(pype.api.Creator): """Render folder for publish.""" name = "renderDefault" diff --git a/pype/plugins/blender/create/create_action.py b/pype/plugins/blender/create/create_action.py index f5273863c49..d43738abd17 100644 --- a/pype/plugins/blender/create/create_action.py +++ b/pype/plugins/blender/create/create_action.py @@ -3,11 +3,11 @@ import bpy from avalon import api -from avalon.blender import Creator, lib +from avalon.blender import lib import pype.hosts.blender.plugin -class CreateAction(Creator): +class CreateAction(pype.hosts.blender.plugin.Creator): """Action output for character rigs""" name = "actionMain" diff --git a/pype/plugins/blender/create/create_animation.py b/pype/plugins/blender/create/create_animation.py index acfd6ac1f38..f0a165137aa 100644 --- a/pype/plugins/blender/create/create_animation.py +++ b/pype/plugins/blender/create/create_animation.py @@ -6,7 +6,7 @@ import pype.hosts.blender.plugin -class CreateAnimation(blender.Creator): +class CreateAnimation(pype.hosts.blender.plugin.Creator): """Animation output for character rigs""" name = "animationMain" diff --git a/pype/plugins/blender/create/create_camera.py b/pype/plugins/blender/create/create_camera.py index 5817985053b..0d82b2c0e1e 100644 --- a/pype/plugins/blender/create/create_camera.py +++ b/pype/plugins/blender/create/create_camera.py @@ -3,11 +3,11 @@ import bpy from avalon import api -from avalon.blender import Creator, lib +from avalon.blender import lib import pype.hosts.blender.plugin -class CreateCamera(Creator): +class CreateCamera(pype.hosts.blender.plugin.Creator): """Polygonal static geometry""" name = "cameraMain" diff --git a/pype/plugins/blender/create/create_layout.py b/pype/plugins/blender/create/create_layout.py index 010eec539b3..39beb53ddd6 100644 --- a/pype/plugins/blender/create/create_layout.py +++ b/pype/plugins/blender/create/create_layout.py @@ -3,11 +3,11 @@ import bpy from avalon import api -from avalon.blender import Creator, lib +from avalon.blender import lib import pype.hosts.blender.plugin -class CreateLayout(Creator): +class CreateLayout(pype.hosts.blender.plugin.Creator): """Layout output for character rigs""" name = "layoutMain" diff --git a/pype/plugins/blender/create/create_model.py b/pype/plugins/blender/create/create_model.py index 59905edc41a..5e32e6f5618 100644 --- a/pype/plugins/blender/create/create_model.py +++ b/pype/plugins/blender/create/create_model.py @@ -3,11 +3,11 @@ import bpy from avalon import api -from avalon.blender import Creator, lib +from avalon.blender import lib import pype.hosts.blender.plugin -class CreateModel(Creator): +class CreateModel(pype.hosts.blender.plugin.Creator): """Polygonal static geometry""" name = "modelMain" diff --git a/pype/plugins/blender/create/create_rig.py b/pype/plugins/blender/create/create_rig.py index 5c85bf969d7..5a3c879c506 100644 --- a/pype/plugins/blender/create/create_rig.py +++ b/pype/plugins/blender/create/create_rig.py @@ -3,11 +3,11 @@ import bpy from avalon import api -from avalon.blender import Creator, lib +from avalon.blender import lib import pype.hosts.blender.plugin -class CreateRig(Creator): +class CreateRig(pype.hosts.blender.plugin.Creator): """Artist-friendly rig with controls to direct motion""" name = "rigMain" diff --git a/pype/plugins/blender/create/create_setdress.py b/pype/plugins/blender/create/create_setdress.py index 06acf716e52..7c163f0e1e6 100644 --- a/pype/plugins/blender/create/create_setdress.py +++ b/pype/plugins/blender/create/create_setdress.py @@ -3,7 +3,8 @@ from avalon import api, blender import pype.hosts.blender.plugin -class CreateSetDress(blender.Creator): + +class CreateSetDress(pype.hosts.blender.plugin.Creator): """A grouped package of loaded content""" name = "setdressMain" diff --git a/pype/plugins/harmony/create/create_farm_render.py b/pype/plugins/harmony/create/create_farm_render.py index e134f28f432..a1b198b672d 100644 --- a/pype/plugins/harmony/create/create_farm_render.py +++ b/pype/plugins/harmony/create/create_farm_render.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- """Create Composite node for render on farm.""" from avalon import harmony +from pype.hosts.harmony.api import plugin -class CreateFarmRender(harmony.Creator): +class CreateFarmRender(plugin.Creator): """Composite node for publishing renders.""" name = "renderDefault" diff --git a/pype/plugins/harmony/create/create_render.py b/pype/plugins/harmony/create/create_render.py index 5034a1bb89b..b9a0987b37d 100644 --- a/pype/plugins/harmony/create/create_render.py +++ b/pype/plugins/harmony/create/create_render.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- """Create render node.""" from avalon import harmony +from pype.hosts.harmony.api import plugin -class CreateRender(harmony.Creator): +class CreateRender(plugin.Creator): """Composite node for publishing renders.""" name = "renderDefault" diff --git a/pype/plugins/harmony/create/create_template.py b/pype/plugins/harmony/create/create_template.py index babc3fe8d7e..880cc82405b 100644 --- a/pype/plugins/harmony/create/create_template.py +++ b/pype/plugins/harmony/create/create_template.py @@ -1,7 +1,8 @@ from avalon import harmony +from pype.hosts.harmony.api import plugin -class CreateTemplate(harmony.Creator): +class CreateTemplate(plugin.Creator): """Composite node for publishing to templates.""" name = "templateDefault" diff --git a/pype/plugins/houdini/create/create_alembic_camera.py b/pype/plugins/houdini/create/create_alembic_camera.py index cf8ac41b62f..a12edf2c9b9 100644 --- a/pype/plugins/houdini/create/create_alembic_camera.py +++ b/pype/plugins/houdini/create/create_alembic_camera.py @@ -1,7 +1,7 @@ -from avalon import houdini +from pype.hosts.houdini import plugin -class CreateAlembicCamera(houdini.Creator): +class CreateAlembicCamera(plugin.Creator): """Single baked camera from Alembic ROP""" name = "camera" diff --git a/pype/plugins/houdini/create/create_pointcache.py b/pype/plugins/houdini/create/create_pointcache.py index ae7e8450837..883f37a1228 100644 --- a/pype/plugins/houdini/create/create_pointcache.py +++ b/pype/plugins/houdini/create/create_pointcache.py @@ -1,7 +1,7 @@ -from avalon import houdini +from pype.hosts.houdini import plugin -class CreatePointCache(houdini.Creator): +class CreatePointCache(plugin.Creator): """Alembic ROP to pointcache""" name = "pointcache" diff --git a/pype/plugins/houdini/create/create_vbd_cache.py b/pype/plugins/houdini/create/create_vbd_cache.py index e862d5c96d0..ebbaaf3a0e8 100644 --- a/pype/plugins/houdini/create/create_vbd_cache.py +++ b/pype/plugins/houdini/create/create_vbd_cache.py @@ -1,7 +1,7 @@ -from avalon import houdini +from pype.hosts.houdini import plugin -class CreateVDBCache(houdini.Creator): +class CreateVDBCache(plugin.Creator): """OpenVDB from Geometry ROP""" name = "vbdcache" diff --git a/pype/plugins/maya/create/create_animation.py b/pype/plugins/maya/create/create_animation.py index 7bafce774ce..e0017591c7e 100644 --- a/pype/plugins/maya/create/create_animation.py +++ b/pype/plugins/maya/create/create_animation.py @@ -1,8 +1,10 @@ -import avalon.maya -from pype.hosts.maya import lib +from pype.hosts.maya import ( + lib, + plugin +) -class CreateAnimation(avalon.maya.Creator): +class CreateAnimation(plugin.Creator): """Animation output for character rigs""" name = "animationDefault" diff --git a/pype/plugins/maya/create/create_ass.py b/pype/plugins/maya/create/create_ass.py index 7fd66e8e15f..ae286dbc691 100644 --- a/pype/plugins/maya/create/create_ass.py +++ b/pype/plugins/maya/create/create_ass.py @@ -1,12 +1,14 @@ from collections import OrderedDict -import avalon.maya -from pype.hosts.maya import lib +from pype.hosts.maya import ( + lib, + plugin +) from maya import cmds -class CreateAss(avalon.maya.Creator): +class CreateAss(plugin.Creator): """Arnold Archive""" name = "ass" diff --git a/pype/plugins/maya/create/create_assembly.py b/pype/plugins/maya/create/create_assembly.py index 6d0321b7189..a7a024507b9 100644 --- a/pype/plugins/maya/create/create_assembly.py +++ b/pype/plugins/maya/create/create_assembly.py @@ -1,7 +1,7 @@ -import avalon.maya +from pype.hosts.maya import plugin -class CreateAssembly(avalon.maya.Creator): +class CreateAssembly(plugin.Creator): """A grouped package of loaded content""" name = "assembly" diff --git a/pype/plugins/maya/create/create_camera.py b/pype/plugins/maya/create/create_camera.py index acff93c03cc..160882696af 100644 --- a/pype/plugins/maya/create/create_camera.py +++ b/pype/plugins/maya/create/create_camera.py @@ -1,8 +1,10 @@ -import avalon.maya -from pype.hosts.maya import lib +from pype.hosts.maya import ( + lib, + plugin +) -class CreateCamera(avalon.maya.Creator): +class CreateCamera(plugin.Creator): """Single baked camera""" name = "cameraMain" diff --git a/pype/plugins/maya/create/create_layout.py b/pype/plugins/maya/create/create_layout.py index 7f0c82d80ec..a37ecbf4380 100644 --- a/pype/plugins/maya/create/create_layout.py +++ b/pype/plugins/maya/create/create_layout.py @@ -1,7 +1,7 @@ -import avalon.maya +from pype.hosts.maya import plugin -class CreateLayout(avalon.maya.Creator): +class CreateLayout(plugin.Creator): """A grouped package of loaded content""" name = "layoutMain" diff --git a/pype/plugins/maya/create/create_look.py b/pype/plugins/maya/create/create_look.py index 5ea64cc7e4d..04f5603d4d1 100644 --- a/pype/plugins/maya/create/create_look.py +++ b/pype/plugins/maya/create/create_look.py @@ -1,8 +1,10 @@ -import avalon.maya -from pype.hosts.maya import lib +from pype.hosts.maya import ( + lib, + plugin +) -class CreateLook(avalon.maya.Creator): +class CreateLook(plugin.Creator): """Shader connections defining shape look""" name = "look" diff --git a/pype/plugins/maya/create/create_mayaascii.py b/pype/plugins/maya/create/create_mayaascii.py index e7cc40dc24a..45912a7789e 100644 --- a/pype/plugins/maya/create/create_mayaascii.py +++ b/pype/plugins/maya/create/create_mayaascii.py @@ -1,7 +1,7 @@ -import avalon.maya +from pype.hosts.maya import plugin -class CreateMayaAscii(avalon.maya.Creator): +class CreateMayaAscii(plugin.Creator): """Raw Maya Ascii file export""" name = "mayaAscii" diff --git a/pype/plugins/maya/create/create_model.py b/pype/plugins/maya/create/create_model.py index 241e2be7f92..f27a854d9a9 100644 --- a/pype/plugins/maya/create/create_model.py +++ b/pype/plugins/maya/create/create_model.py @@ -1,7 +1,7 @@ -import avalon.maya +from pype.hosts.maya import plugin -class CreateModel(avalon.maya.Creator): +class CreateModel(plugin.Creator): """Polygonal static geometry""" name = "modelMain" diff --git a/pype/plugins/maya/create/create_pointcache.py b/pype/plugins/maya/create/create_pointcache.py index 1eb561b5ce8..a8f8033e4aa 100644 --- a/pype/plugins/maya/create/create_pointcache.py +++ b/pype/plugins/maya/create/create_pointcache.py @@ -1,8 +1,10 @@ -import avalon.maya -from pype.hosts.maya import lib +from pype.hosts.maya import ( + lib, + plugin +) -class CreatePointCache(avalon.maya.Creator): +class CreatePointCache(plugin.Creator): """Alembic pointcache for animated data""" name = "pointcache" diff --git a/pype/plugins/maya/create/create_render.py b/pype/plugins/maya/create/create_render.py index e3cd7ee64dc..d63dd75fe41 100644 --- a/pype/plugins/maya/create/create_render.py +++ b/pype/plugins/maya/create/create_render.py @@ -8,11 +8,13 @@ from maya import cmds import maya.app.renderSetup.model.renderSetup as renderSetup -from pype.hosts.maya import lib -import avalon.maya +from pype.hosts.maya import ( + lib, + plugin +) -class CreateRender(avalon.maya.Creator): +class CreateRender(plugin.Creator): """Create *render* instance. Render instances are not actually published, they hold options for diff --git a/pype/plugins/maya/create/create_rendersetup.py b/pype/plugins/maya/create/create_rendersetup.py index 98f54f2d709..815e5893fba 100644 --- a/pype/plugins/maya/create/create_rendersetup.py +++ b/pype/plugins/maya/create/create_rendersetup.py @@ -1,9 +1,11 @@ -import avalon.maya -from pype.hosts.maya import lib +from pype.hosts.maya import ( + lib, + plugin +) from maya import cmds -class CreateRenderSetup(avalon.maya.Creator): +class CreateRenderSetup(plugin.Creator): """Create rendersetup template json data""" name = "rendersetup" diff --git a/pype/plugins/maya/create/create_review.py b/pype/plugins/maya/create/create_review.py index bfeab33f5b8..618565cb56c 100644 --- a/pype/plugins/maya/create/create_review.py +++ b/pype/plugins/maya/create/create_review.py @@ -1,9 +1,11 @@ from collections import OrderedDict -import avalon.maya -from pype.hosts.maya import lib +from pype.hosts.maya import ( + lib, + plugin +) -class CreateReview(avalon.maya.Creator): +class CreateReview(plugin.Creator): """Single baked camera""" name = "reviewDefault" diff --git a/pype/plugins/maya/create/create_rig.py b/pype/plugins/maya/create/create_rig.py index ae1de4243e8..da82422f250 100644 --- a/pype/plugins/maya/create/create_rig.py +++ b/pype/plugins/maya/create/create_rig.py @@ -1,10 +1,12 @@ from maya import cmds -from pype.hosts.maya import lib -import avalon.maya +from pype.hosts.maya import ( + lib, + plugin +) -class CreateRig(avalon.maya.Creator): +class CreateRig(plugin.Creator): """Artist-friendly rig with controls to direct motion""" name = "rigDefault" diff --git a/pype/plugins/maya/create/create_setdress.py b/pype/plugins/maya/create/create_setdress.py index d5fc0012996..51971c26e65 100644 --- a/pype/plugins/maya/create/create_setdress.py +++ b/pype/plugins/maya/create/create_setdress.py @@ -1,7 +1,7 @@ -import avalon.maya +from pype.hosts.maya import plugin -class CreateSetDress(avalon.maya.Creator): +class CreateSetDress(plugin.Creator): """A grouped package of loaded content""" name = "setdressMain" diff --git a/pype/plugins/maya/create/create_unreal_staticmesh.py b/pype/plugins/maya/create/create_unreal_staticmesh.py index 5a74cb22d55..af8e3aa3af2 100644 --- a/pype/plugins/maya/create/create_unreal_staticmesh.py +++ b/pype/plugins/maya/create/create_unreal_staticmesh.py @@ -1,7 +1,7 @@ -import avalon.maya +from pype.hosts.maya import plugin -class CreateUnrealStaticMesh(avalon.maya.Creator): +class CreateUnrealStaticMesh(plugin.Creator): name = "staticMeshMain" label = "Unreal - Static Mesh" family = "unrealStaticMesh" diff --git a/pype/plugins/maya/create/create_vrayproxy.py b/pype/plugins/maya/create/create_vrayproxy.py index 010157ca9a7..f9c145ea84e 100644 --- a/pype/plugins/maya/create/create_vrayproxy.py +++ b/pype/plugins/maya/create/create_vrayproxy.py @@ -1,7 +1,7 @@ -import avalon.maya +from pype.hosts.maya import plugin -class CreateVrayProxy(avalon.maya.Creator): +class CreateVrayProxy(plugin.Creator): """Alembic pointcache for animated data""" name = "vrayproxy" diff --git a/pype/plugins/maya/create/create_vrayscene.py b/pype/plugins/maya/create/create_vrayscene.py index b8dbdaafe74..bf108a18604 100644 --- a/pype/plugins/maya/create/create_vrayscene.py +++ b/pype/plugins/maya/create/create_vrayscene.py @@ -8,11 +8,13 @@ from maya import cmds import maya.app.renderSetup.model.renderSetup as renderSetup -from pype.hosts.maya import lib -import avalon.maya +from pype.hosts.maya import ( + lib, + plugin +) -class CreateVRayScene(avalon.maya.Creator): +class CreateVRayScene(plugin.Creator): """Create Vray Scene.""" label = "VRay Scene" diff --git a/pype/plugins/maya/create/create_yeti_cache.py b/pype/plugins/maya/create/create_yeti_cache.py index a4b5cc537a7..b95fc5997ed 100644 --- a/pype/plugins/maya/create/create_yeti_cache.py +++ b/pype/plugins/maya/create/create_yeti_cache.py @@ -1,10 +1,12 @@ from collections import OrderedDict -import avalon.maya -from pype.hosts.maya import lib +from pype.hosts.maya import ( + lib, + plugin +) -class CreateYetiCache(avalon.maya.Creator): +class CreateYetiCache(plugin.Creator): """Output for procedural plugin nodes of Yeti """ name = "yetiDefault" diff --git a/pype/plugins/maya/create/create_yeti_rig.py b/pype/plugins/maya/create/create_yeti_rig.py index 0b954f500d7..5c57f56cea5 100644 --- a/pype/plugins/maya/create/create_yeti_rig.py +++ b/pype/plugins/maya/create/create_yeti_rig.py @@ -1,10 +1,12 @@ from maya import cmds -from pype.hosts.maya import lib -import avalon.maya +from pype.hosts.maya import ( + lib, + plugin +) -class CreateYetiRig(avalon.maya.Creator): +class CreateYetiRig(plugin.Creator): """Output for procedural plugin nodes ( Yeti / XGen / etc)""" label = "Yeti Rig" diff --git a/pype/plugins/nuke/create/create_backdrop.py b/pype/plugins/nuke/create/create_backdrop.py index 0d2b621e213..72f4417b4cf 100644 --- a/pype/plugins/nuke/create/create_backdrop.py +++ b/pype/plugins/nuke/create/create_backdrop.py @@ -1,9 +1,9 @@ -import avalon.nuke from avalon.nuke import lib as anlib +from pype.hosts.nuke import plugin import nuke -class CreateBackdrop(avalon.nuke.Creator): +class CreateBackdrop(plugin.Creator): """Add Publishable Backdrop""" name = "nukenodes" diff --git a/pype/plugins/nuke/create/create_camera.py b/pype/plugins/nuke/create/create_camera.py index 4c668925ad7..3c5a50cfb8f 100644 --- a/pype/plugins/nuke/create/create_camera.py +++ b/pype/plugins/nuke/create/create_camera.py @@ -1,9 +1,9 @@ -import avalon.nuke from avalon.nuke import lib as anlib +from pype.hosts.nuke import plugin import nuke -class CreateCamera(avalon.nuke.Creator): +class CreateCamera(plugin.Creator): """Add Publishable Backdrop""" name = "camera" diff --git a/pype/plugins/nuke/create/create_gizmo.py b/pype/plugins/nuke/create/create_gizmo.py index eb5b1a3fb05..2b7edaabcbf 100644 --- a/pype/plugins/nuke/create/create_gizmo.py +++ b/pype/plugins/nuke/create/create_gizmo.py @@ -1,9 +1,9 @@ -import avalon.nuke from avalon.nuke import lib as anlib +from pype.hosts.nuke import plugin import nuke -class CreateGizmo(avalon.nuke.Creator): +class CreateGizmo(plugin.Creator): """Add Publishable "gizmo" group The name is symbolically gizmo as presumably diff --git a/pype/plugins/nuke/create/create_read.py b/pype/plugins/nuke/create/create_read.py index 70db580a7e0..b2b857e0f3d 100644 --- a/pype/plugins/nuke/create/create_read.py +++ b/pype/plugins/nuke/create/create_read.py @@ -1,12 +1,12 @@ from collections import OrderedDict -import avalon.api import avalon.nuke from pype import api as pype +from pype.hosts.nuke import plugin import nuke -class CrateRead(avalon.nuke.Creator): +class CrateRead(plugin.Creator): # change this to template preset name = "ReadCopy" label = "Create Read Copy" diff --git a/pype/plugins/photoshop/create/create_image.py b/pype/plugins/photoshop/create/create_image.py index c1a7d92a2c1..54b6efad291 100644 --- a/pype/plugins/photoshop/create/create_image.py +++ b/pype/plugins/photoshop/create/create_image.py @@ -1,9 +1,9 @@ -from avalon import api +import pype.api from avalon.vendor import Qt from avalon import photoshop -class CreateImage(api.Creator): +class CreateImage(pype.api.Creator): """Image folder for publish.""" name = "imageDefault" diff --git a/pype/plugins/tvpaint/create/create_render_layer.py b/pype/plugins/tvpaint/create/create_render_layer.py index c2921cebbea..ed7c96c9047 100644 --- a/pype/plugins/tvpaint/create/create_render_layer.py +++ b/pype/plugins/tvpaint/create/create_render_layer.py @@ -1,7 +1,8 @@ from avalon.tvpaint import pipeline, lib +from pype.hosts.tvpaint.api import plugin -class CreateRenderlayer(pipeline.Creator): +class CreateRenderlayer(plugin.Creator): """Mark layer group as one instance.""" name = "render_layer" label = "RenderLayer" diff --git a/pype/plugins/tvpaint/create/create_render_pass.py b/pype/plugins/tvpaint/create/create_render_pass.py index 7e4b2a4e81d..8583f204517 100644 --- a/pype/plugins/tvpaint/create/create_render_pass.py +++ b/pype/plugins/tvpaint/create/create_render_pass.py @@ -1,7 +1,8 @@ from avalon.tvpaint import pipeline, lib +from pype.hosts.tvpaint.api import plugin -class CreateRenderPass(pipeline.Creator): +class CreateRenderPass(plugin.Creator): """Render pass is combination of one or more layers from same group. Requirement to create Render Pass is to have already created beauty diff --git a/pype/plugins/tvpaint/create/create_review.py b/pype/plugins/tvpaint/create/create_review.py index 9f7ee1396e8..cfc49a8ac6f 100644 --- a/pype/plugins/tvpaint/create/create_review.py +++ b/pype/plugins/tvpaint/create/create_review.py @@ -1,7 +1,8 @@ from avalon.tvpaint import pipeline +from pype.hosts.tvpaint.api import plugin -class CreateReview(pipeline.Creator): +class CreateReview(plugin.Creator): """Review for global review of all layers.""" name = "review" label = "Review" diff --git a/pype/tools/standalonepublish/__init__.py b/pype/tools/standalonepublish/__init__.py index 29a4e529045..3526a8044e5 100644 --- a/pype/tools/standalonepublish/__init__.py +++ b/pype/tools/standalonepublish/__init__.py @@ -1,8 +1,6 @@ from .app import ( - show, - cli + Window ) __all__ = [ - "show", - "cli" + "Window" ] diff --git a/pype/tools/standalonepublish/__main__.py b/pype/tools/standalonepublish/__main__.py index 85a574f8dcf..5b87f027f21 100644 --- a/pype/tools/standalonepublish/__main__.py +++ b/pype/tools/standalonepublish/__main__.py @@ -1,14 +1,16 @@ import os import sys -import app import ctypes import signal +import app from Qt import QtWidgets, QtGui from avalon import style from pype.api import resources +from pype.tools.standalonepublish.widgets.constants import HOST_NAME if __name__ == "__main__": + os.environ["AVALON_APP"] = HOST_NAME # Allow to change icon of running process in windows taskbar if os.name == "nt": diff --git a/pype/tools/standalonepublish/app.py b/pype/tools/standalonepublish/app.py index feba46987ff..8f522874fde 100644 --- a/pype/tools/standalonepublish/app.py +++ b/pype/tools/standalonepublish/app.py @@ -62,6 +62,8 @@ def __init__(self, pyblish_paths, parent=None): # signals widget_assets.selection_changed.connect(self.on_asset_changed) + widget_assets.task_changed.connect(self._on_task_change) + widget_assets.project_changed.connect(self.on_project_change) widget_family.stateChanged.connect(self.set_valid_family) self.widget_assets = widget_assets @@ -116,6 +118,9 @@ def get_avalon_parent(self, entity): parents.append(parent['name']) return parents + def on_project_change(self, project_name): + self.widget_family.refresh() + def on_asset_changed(self): '''Callback on asset selection changed @@ -135,6 +140,9 @@ def on_asset_changed(self): self.widget_family.change_asset(None) self.widget_family.on_data_changed() + def _on_task_change(self): + self.widget_family.on_task_change() + def keyPressEvent(self, event): ''' Handling Ctrl+V KeyPress event Can handle: diff --git a/pype/tools/standalonepublish/widgets/constants.py b/pype/tools/standalonepublish/widgets/constants.py new file mode 100644 index 00000000000..0ecc8e82e7e --- /dev/null +++ b/pype/tools/standalonepublish/widgets/constants.py @@ -0,0 +1 @@ +HOST_NAME = "standalonepublisher" diff --git a/pype/tools/standalonepublish/widgets/widget_asset.py b/pype/tools/standalonepublish/widgets/widget_asset.py index 6f041a535f3..4680e88344c 100644 --- a/pype/tools/standalonepublish/widgets/widget_asset.py +++ b/pype/tools/standalonepublish/widgets/widget_asset.py @@ -121,9 +121,11 @@ class AssetWidget(QtWidgets.QWidget): """ + project_changed = QtCore.Signal(str) assets_refreshed = QtCore.Signal() # on model refresh selection_changed = QtCore.Signal() # on view selection change current_changed = QtCore.Signal() # on view current index change + task_changed = QtCore.Signal() def __init__(self, dbcon, parent=None): super(AssetWidget, self).__init__(parent=parent) @@ -189,6 +191,9 @@ def __init__(self, dbcon, parent=None): selection = view.selectionModel() selection.selectionChanged.connect(self.selection_changed) selection.currentChanged.connect(self.current_changed) + task_view.selectionModel().selectionChanged.connect( + self._on_task_change + ) refresh.clicked.connect(self.refresh) self.selection_changed.connect(self._refresh_tasks) @@ -249,6 +254,9 @@ def on_project_change(self): project_name = self.combo_projects.currentText() if project_name in projects: self.dbcon.Session["AVALON_PROJECT"] = project_name + + self.project_changed.emit(project_name) + self.refresh() def _refresh_model(self): @@ -265,7 +273,18 @@ def _refresh_model(self): def refresh(self): self._refresh_model() + def _on_task_change(self): + try: + index = self.task_view.selectedIndexes()[0] + task_name = self.task_model.itemData(index)[0] + except Exception: + task_name = None + + self.dbcon.Session["AVALON_TASK"] = task_name + self.task_changed.emit() + def _refresh_tasks(self): + self.dbcon.Session["AVALON_TASK"] = None tasks = [] selected = self.get_selected_assets() if len(selected) == 1: @@ -275,7 +294,8 @@ def _refresh_tasks(self): if asset: tasks = asset.get('data', {}).get('tasks', []) self.task_model.set_tasks(tasks) - self.task_view.setVisible(len(tasks)>0) + self.task_view.setVisible(len(tasks) > 0) + self.task_changed.emit() def get_active_asset(self): """Return the asset id the current asset.""" diff --git a/pype/tools/standalonepublish/widgets/widget_components.py b/pype/tools/standalonepublish/widgets/widget_components.py index 7e0327f00a7..207873a92ac 100644 --- a/pype/tools/standalonepublish/widgets/widget_components.py +++ b/pype/tools/standalonepublish/widgets/widget_components.py @@ -7,6 +7,7 @@ from Qt import QtWidgets, QtCore from . import DropDataFrame +from .constants import HOST_NAME from avalon import io from pype.api import execute, Logger @@ -177,8 +178,8 @@ def set_context(project, asset, task): io.Session["current_dir"] = os.path.normpath(os.getcwd()) - os.environ["AVALON_APP"] = "standalonepublish" - io.Session["AVALON_APP"] = "standalonepublish" + os.environ["AVALON_APP"] = HOST_NAME + io.Session["AVALON_APP"] = HOST_NAME io.uninstall() diff --git a/pype/tools/standalonepublish/widgets/widget_family.py b/pype/tools/standalonepublish/widgets/widget_family.py index 1c8f2238fcd..00bc5b31f9c 100644 --- a/pype/tools/standalonepublish/widgets/widget_family.py +++ b/pype/tools/standalonepublish/widgets/widget_family.py @@ -1,10 +1,15 @@ -from collections import namedtuple +import re from Qt import QtWidgets, QtCore from . import HelpRole, FamilyRole, ExistsRole, PluginRole, PluginKeyRole from . import FamilyDescriptionWidget -from pype.api import config +from pype.api import ( + config, + Creator +) +from pype.plugin import TaskNotSetError +from avalon.tools.creator.app import SubsetAllowedSymbols class FamilyWidget(QtWidgets.QWidget): @@ -122,6 +127,9 @@ def collect_data(self): } return data + def on_task_change(self): + self.on_data_changed() + def change_asset(self, name): if name is None: name = self.NOT_SELECTED @@ -167,65 +175,113 @@ def _on_action_clicked(self, action): def _on_data_changed(self): asset_name = self.asset_name - subset_name = self.input_subset.text() + user_input_text = self.input_subset.text() item = self.list_families.currentItem() if item is None: return - assets = None + asset_doc = None if asset_name != self.NOT_SELECTED: # Get the assets from the database which match with the name - assets_db = self.dbcon.find( - filter={"type": "asset"}, - projection={"name": 1} + asset_doc = self.dbcon.find_one( + { + "type": "asset", + "name": asset_name + }, + {"_id": 1} ) - assets = [ - asset for asset in assets_db if asset_name in asset["name"] - ] # Get plugin and family plugin = item.data(PluginRole) - if plugin is None: - return - family = plugin.family.rsplit(".", 1)[-1] + # Early exit if no asset name + if not asset_name.strip(): + self._build_menu([]) + item.setData(ExistsRole, False) + print("Asset name is required ..") + self.stateChanged.emit(False) + return - # Update the result - if subset_name: - subset_name = subset_name[0].upper() + subset_name[1:] - self.input_result.setText("{}{}".format(family, subset_name)) + # Get the asset from the database which match with the name + asset_doc = self.dbcon.find_one( + {"name": asset_name, "type": "asset"}, + projection={"_id": 1} + ) + # Get plugin + plugin = item.data(PluginRole) + if asset_doc and plugin: + project_name = self.dbcon.Session["AVALON_PROJECT"] + asset_id = asset_doc["_id"] + task_name = self.dbcon.Session["AVALON_TASK"] + + # Calculate subset name with Creator plugin + try: + subset_name = plugin.get_subset_name( + user_input_text, task_name, asset_id, project_name + ) + # Force replacement of prohibited symbols + # QUESTION should Creator care about this and here should be + # only validated with schema regex? + subset_name = re.sub( + "[^{}]+".format(SubsetAllowedSymbols), + "", + subset_name + ) + self.input_result.setText(subset_name) + + except TaskNotSetError: + subset_name = "" + self.input_result.setText("Select task please") - if assets: # Get all subsets of the current asset - asset_ids = [asset["_id"] for asset in assets] - subsets = self.dbcon.find(filter={"type": "subset", - "name": {"$regex": "{}*".format(family), - "$options": "i"}, - "parent": {"$in": asset_ids}}) or [] - - # Get all subsets' their subset name, "Default", "High", "Low" - existed_subsets = [sub["name"].split(family)[-1] - for sub in subsets] - - if plugin.defaults and isinstance(plugin.defaults, list): - defaults = plugin.defaults[:] + [self.Separator] - lowered = [d.lower() for d in plugin.defaults] - for sub in [s for s in existed_subsets - if s.lower() not in lowered]: - defaults.append(sub) - else: - defaults = existed_subsets - + subset_docs = self.dbcon.find( + { + "type": "subset", + "parent": asset_id + }, + {"name": 1} + ) + existing_subset_names = set(subset_docs.distinct("name")) + + # Defaults to dropdown + defaults = [] + # Check if Creator plugin has set defaults + if ( + plugin.defaults + and isinstance(plugin.defaults, (list, tuple, set)) + ): + defaults = list(plugin.defaults) + + # Replace + compare_regex = re.compile( + subset_name.replace(user_input_text, "(.+)") + ) + subset_hints = set() + if user_input_text: + for _name in existing_subset_names: + _result = compare_regex.search(_name) + if _result: + subset_hints |= set(_result.groups()) + + subset_hints = subset_hints - set(defaults) + if subset_hints: + if defaults: + defaults.append(self.Separator) + defaults.extend(subset_hints) self._build_menu(defaults) item.setData(ExistsRole, True) + else: + subset_name = user_input_text self._build_menu([]) item.setData(ExistsRole, False) - if asset_name != self.NOT_SELECTED: - # TODO add logging into standalone_publish - print("'%s' not found .." % asset_name) + + if not plugin: + print("No registered families ..") + else: + print("Asset '%s' not found .." % asset_name) self.on_version_refresh() @@ -248,30 +304,46 @@ def on_version_refresh(self): subset_name = self.input_result.text() version = 1 + asset_doc = None + subset_doc = None + versions = None if ( asset_name != self.NOT_SELECTED and subset_name.strip() != '' ): - asset = self.dbcon.find_one({ - 'type': 'asset', - 'name': asset_name - }) - subset = self.dbcon.find_one({ - 'type': 'subset', - 'parent': asset['_id'], - 'name': subset_name - }) - if subset: - versions = self.dbcon.find({ + asset_doc = self.dbcon.find_one( + { + 'type': 'asset', + 'name': asset_name + }, + {"_id": 1} + ) + + if asset_doc: + subset_doc = self.dbcon.find_one( + { + 'type': 'subset', + 'parent': asset_doc['_id'], + 'name': subset_name + }, + {"_id": 1} + ) + + if subset_doc: + versions = self.dbcon.find( + { 'type': 'version', - 'parent': subset['_id'] - }) - if versions: - versions = sorted( - [v for v in versions], - key=lambda ver: ver['name'] - ) - version = int(versions[-1]['name']) + 1 + 'parent': subset_doc['_id'] + }, + {"name": 1} + ).distinct("name") + + if versions: + versions = sorted( + [v for v in versions], + key=lambda ver: ver['name'] + ) + version = int(versions[-1]['name']) + 1 self.version_spinbox.setValue(version) @@ -284,7 +356,10 @@ def on_data_changed(self, *args): self.schedule(self._on_data_changed, 500, channel="gui") def on_selection_changed(self, *args): - plugin = self.list_families.currentItem().data(PluginRole) + item = self.list_families.currentItem() + if not item: + return + plugin = item.data(PluginRole) if plugin is None: return @@ -308,11 +383,20 @@ def keyPressEvent(self, event): """ def refresh(self): + self.list_families.clear() + has_families = False - presets = config.get_presets().get('standalone_publish', {}) + project_name = self.dbcon.Session.get("AVALON_PROJECT") + if not project_name: + return - for key, creator in presets.get('families', {}).items(): - creator = namedtuple("Creator", creator.keys())(*creator.values()) + creators_data = ( + config.get_presets(project_name) + .get("standalone_publish", {}) + .get("families", {}) + ) + for key, creator_data in creators_data.items(): + creator = type(key, (Creator, ), creator_data) label = creator.label or creator.family item = QtWidgets.QListWidgetItem(label)