diff --git a/.github/workflows/upload_to_ynput_cloud.yml b/.github/workflows/upload_to_ynput_cloud.yml new file mode 100644 index 0000000000..7745a8e016 --- /dev/null +++ b/.github/workflows/upload_to_ynput_cloud.yml @@ -0,0 +1,16 @@ +name: 📤 Upload to Ynput Cloud + +on: + workflow_dispatch: + release: + types: [published] + +jobs: + call-upload-to-ynput-cloud: + uses: ynput/ops-repo-automation/.github/workflows/upload_to_ynput_cloud.yml@main + secrets: + CI_EMAIL: ${{ secrets.CI_EMAIL }} + CI_USER: ${{ secrets.CI_USER }} + YNPUT_BOT_TOKEN: ${{ secrets.YNPUT_BOT_TOKEN }} + YNPUT_CLOUD_URL: ${{ secrets.YNPUT_CLOUD_URL }} + YNPUT_CLOUD_TOKEN: ${{ secrets.YNPUT_CLOUD_TOKEN }} diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 4877a45118..34956fd33f 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -327,8 +327,8 @@ class UISeparatorDef(UIDef): class UILabelDef(UIDef): type = "label" - def __init__(self, label, key=None): - super().__init__(label=label, key=key) + def __init__(self, label, key=None, *args, **kwargs): + super().__init__(label=label, key=key, *args, **kwargs) def _def_type_compare(self, other: "UILabelDef") -> bool: return self.label == other.label @@ -523,7 +523,10 @@ class TextDef(AbstractAttrDef): def serialize(self): data = super().serialize() - data["regex"] = self.regex.pattern + regex = None + if self.regex is not None: + regex = self.regex.pattern + data["regex"] = regex data["multiline"] = self.multiline data["placeholder"] = self.placeholder return data diff --git a/client/ayon_core/lib/log.py b/client/ayon_core/lib/log.py index 36c39f9d84..0c2fe5e2d4 100644 --- a/client/ayon_core/lib/log.py +++ b/client/ayon_core/lib/log.py @@ -1,6 +1,5 @@ import os import sys -import uuid import getpass import logging import platform @@ -11,12 +10,12 @@ import copy from . import Terminal -# Check for `unicode` in builtins -USE_UNICODE = hasattr(__builtins__, "unicode") - class LogStreamHandler(logging.StreamHandler): - """ StreamHandler class designed to handle utf errors in python 2.x hosts. + """StreamHandler class. + + This was originally designed to handle UTF errors in python 2.x hosts, + however currently solely remains for backwards compatibility. """ @@ -25,49 +24,27 @@ class LogStreamHandler(logging.StreamHandler): self.enabled = True def enable(self): - """ Enable StreamHandler + """Enable StreamHandler - Used to silence output + Make StreamHandler output again """ self.enabled = True def disable(self): - """ Disable StreamHandler + """Disable StreamHandler - Make StreamHandler output again + Used to silence output """ self.enabled = False def emit(self, record): - if not self.enable: + if not self.enabled or self.stream is None: return try: msg = self.format(record) msg = Terminal.log(msg) stream = self.stream - if stream is None: - return - fs = "%s\n" - # if no unicode support... - if not USE_UNICODE: - stream.write(fs % msg) - else: - try: - if (isinstance(msg, unicode) and # noqa: F821 - getattr(stream, 'encoding', None)): - ufs = u'%s\n' - try: - stream.write(ufs % msg) - except UnicodeEncodeError: - stream.write((ufs % msg).encode(stream.encoding)) - else: - if (getattr(stream, 'encoding', 'utf-8')): - ufs = u'%s\n' - stream.write(ufs % unicode(msg)) # noqa: F821 - else: - stream.write(fs % msg) - except UnicodeError: - stream.write(fs % msg.encode("UTF-8")) + stream.write(f"{msg}\n") self.flush() except (KeyboardInterrupt, SystemExit): raise @@ -141,8 +118,6 @@ class Logger: process_data = None # Cached process name or ability to set different process name _process_name = None - # TODO Remove 'mongo_process_id' in 1.x.x - mongo_process_id = uuid.uuid4().hex @classmethod def get_logger(cls, name=None): diff --git a/client/ayon_core/lib/path_tools.py b/client/ayon_core/lib/path_tools.py index 5c81fbfebf..31baac168c 100644 --- a/client/ayon_core/lib/path_tools.py +++ b/client/ayon_core/lib/path_tools.py @@ -1,7 +1,6 @@ import os import re import logging -import platform import clique @@ -38,31 +37,7 @@ def create_hard_link(src_path, dst_path): dst_path(str): Full path to a file where a link of source will be added. """ - # Use `os.link` if is available - # - should be for all platforms with newer python versions - if hasattr(os, "link"): - os.link(src_path, dst_path) - return - - # Windows implementation of hardlinks - # - used in Python 2 - if platform.system().lower() == "windows": - import ctypes - from ctypes.wintypes import BOOL - CreateHardLink = ctypes.windll.kernel32.CreateHardLinkW - CreateHardLink.argtypes = [ - ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_void_p - ] - CreateHardLink.restype = BOOL - - res = CreateHardLink(dst_path, src_path, None) - if res == 0: - raise ctypes.WinError() - return - # Raises not implemented error if gets here - raise NotImplementedError( - "Implementation of hardlink for current environment is missing." - ) + os.link(src_path, dst_path) def collect_frames(files): @@ -210,7 +185,7 @@ def get_last_version_from_path(path_dir, filter): assert isinstance(filter, list) and ( len(filter) != 0), "`filter` argument needs to be list and not empty" - filtred_files = list() + filtered_files = list() # form regex for filtering pattern = r".*".join(filter) @@ -218,10 +193,10 @@ def get_last_version_from_path(path_dir, filter): for file in os.listdir(path_dir): if not re.findall(pattern, file): continue - filtred_files.append(file) + filtered_files.append(file) - if filtred_files: - sorted(filtred_files) - return filtred_files[-1] + if filtered_files: + filtered_files.sort() + return filtered_files[-1] return None diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index adbb03a820..6bfd64b822 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -12,6 +12,7 @@ from typing import ( Iterable, Tuple, List, + Set, Dict, Any, Callable, @@ -252,8 +253,10 @@ class CreateContext: # Shared data across creators during collection phase self._collection_shared_data = None - # Context validation cache - self._folder_id_by_folder_path = {} + # Entities cache + self._folder_entities_by_path = {} + self._task_entities_by_id = {} + self._task_ids_by_folder_path = {} self._task_names_by_folder_path = {} self.thumbnail_paths_by_instance_id = {} @@ -356,12 +359,12 @@ class CreateContext: return self._host_is_valid @property - def host_name(self): + def host_name(self) -> str: if hasattr(self.host, "name"): return self.host.name return os.environ["AYON_HOST_NAME"] - def get_current_project_name(self): + def get_current_project_name(self) -> Optional[str]: """Project name which was used as current context on context reset. Returns: @@ -370,7 +373,7 @@ class CreateContext: return self._current_project_name - def get_current_folder_path(self): + def get_current_folder_path(self) -> Optional[str]: """Folder path which was used as current context on context reset. Returns: @@ -379,7 +382,7 @@ class CreateContext: return self._current_folder_path - def get_current_task_name(self): + def get_current_task_name(self) -> Optional[str]: """Task name which was used as current context on context reset. Returns: @@ -388,7 +391,7 @@ class CreateContext: return self._current_task_name - def get_current_task_type(self): + def get_current_task_type(self) -> Optional[str]: """Task type which was used as current context on context reset. Returns: @@ -403,7 +406,7 @@ class CreateContext: self._current_task_type = task_type return self._current_task_type - def get_current_project_entity(self): + def get_current_project_entity(self) -> Optional[Dict[str, Any]]: """Project entity for current context project. Returns: @@ -419,26 +422,21 @@ class CreateContext: self._current_project_entity = project_entity return copy.deepcopy(self._current_project_entity) - def get_current_folder_entity(self): + def get_current_folder_entity(self) -> Optional[Dict[str, Any]]: """Folder entity for current context folder. Returns: - Union[dict[str, Any], None]: Folder entity. + Optional[dict[str, Any]]: Folder entity. """ if self._current_folder_entity is not _NOT_SET: return copy.deepcopy(self._current_folder_entity) - folder_entity = None + folder_path = self.get_current_folder_path() - if folder_path: - project_name = self.get_current_project_name() - folder_entity = ayon_api.get_folder_by_path( - project_name, folder_path - ) - self._current_folder_entity = folder_entity + self._current_folder_entity = self.get_folder_entity(folder_path) return copy.deepcopy(self._current_folder_entity) - def get_current_task_entity(self): + def get_current_task_entity(self) -> Optional[Dict[str, Any]]: """Task entity for current context task. Returns: @@ -447,18 +445,12 @@ class CreateContext: """ if self._current_task_entity is not _NOT_SET: return copy.deepcopy(self._current_task_entity) - task_entity = None + + folder_path = self.get_current_folder_path() task_name = self.get_current_task_name() - if task_name: - folder_entity = self.get_current_folder_entity() - if folder_entity: - project_name = self.get_current_project_name() - task_entity = ayon_api.get_task_by_name( - project_name, - folder_id=folder_entity["id"], - task_name=task_name - ) - self._current_task_entity = task_entity + self._current_task_entity = self.get_task_entity( + folder_path, task_name + ) return copy.deepcopy(self._current_task_entity) def get_current_workfile_path(self): @@ -566,8 +558,13 @@ class CreateContext: # Give ability to store shared data for collection phase self._collection_shared_data = {} - self._folder_id_by_folder_path = {} + + self._folder_entities_by_path = {} + self._task_entities_by_id = {} + + self._task_ids_by_folder_path = {} self._task_names_by_folder_path = {} + self._event_hub.clear_callbacks() def reset_finalization(self): @@ -1468,6 +1465,260 @@ class CreateContext: if failed_info: raise CreatorsCreateFailed(failed_info) + def get_folder_entities(self, folder_paths: Iterable[str]): + """Get folder entities by paths. + + Args: + folder_paths (Iterable[str]): Folder paths. + + Returns: + Dict[str, Optional[Dict[str, Any]]]: Folder entities by path. + + """ + output = { + folder_path: None + for folder_path in folder_paths + } + remainder_paths = set() + for folder_path in output: + # Skip invalid folder paths (folder name or empty path) + if not folder_path or "/" not in folder_path: + continue + + if folder_path not in self._folder_entities_by_path: + remainder_paths.add(folder_path) + continue + + output[folder_path] = self._folder_entities_by_path[folder_path] + + if not remainder_paths: + return output + + found_paths = set() + for folder_entity in ayon_api.get_folders( + self.project_name, + folder_paths=remainder_paths, + ): + folder_path = folder_entity["path"] + found_paths.add(folder_path) + output[folder_path] = folder_entity + self._folder_entities_by_path[folder_path] = folder_entity + + # Cache empty folder entities + for path in remainder_paths - found_paths: + self._folder_entities_by_path[path] = None + + return output + + def get_task_entities( + self, + task_names_by_folder_paths: Dict[str, Set[str]] + ) -> Dict[str, Dict[str, Optional[Dict[str, Any]]]]: + """Get task entities by folder path and task name. + + Entities are cached until reset. + + Args: + task_names_by_folder_paths (Dict[str, Set[str]]): Task names by + folder path. + + Returns: + Dict[str, Dict[str, Dict[str, Any]]]: Task entities by folder path + and task name. + + """ + output = {} + for folder_path, task_names in task_names_by_folder_paths.items(): + if folder_path is None: + continue + output[folder_path] = { + task_name: None + for task_name in task_names + if task_name is not None + } + + missing_folder_paths = set() + for folder_path, output_task_entities_by_name in output.items(): + if not output_task_entities_by_name: + continue + + if folder_path not in self._task_ids_by_folder_path: + missing_folder_paths.add(folder_path) + continue + + all_tasks_filled = True + task_ids = self._task_ids_by_folder_path[folder_path] + task_entities_by_name = {} + for task_id in task_ids: + task_entity = self._task_entities_by_id.get(task_id) + if task_entity is None: + all_tasks_filled = False + continue + task_entities_by_name[task_entity["name"]] = task_entity + + any_missing = False + for task_name in set(output_task_entities_by_name): + task_entity = task_entities_by_name.get(task_name) + if task_entity is None: + any_missing = True + continue + + output_task_entities_by_name[task_name] = task_entity + + if any_missing and not all_tasks_filled: + missing_folder_paths.add(folder_path) + + if not missing_folder_paths: + return output + + folder_entities_by_path = self.get_folder_entities( + missing_folder_paths + ) + folder_path_by_id = {} + for folder_path, folder_entity in folder_entities_by_path.items(): + if folder_entity is not None: + folder_path_by_id[folder_entity["id"]] = folder_path + + if not folder_path_by_id: + return output + + task_entities_by_parent_id = collections.defaultdict(list) + for task_entity in ayon_api.get_tasks( + self.project_name, + folder_ids=folder_path_by_id.keys() + ): + folder_id = task_entity["folderId"] + task_entities_by_parent_id[folder_id].append(task_entity) + + for folder_id, task_entities in task_entities_by_parent_id.items(): + folder_path = folder_path_by_id[folder_id] + task_ids = set() + task_names = set() + for task_entity in task_entities: + task_id = task_entity["id"] + task_name = task_entity["name"] + task_ids.add(task_id) + task_names.add(task_name) + self._task_entities_by_id[task_id] = task_entity + + output[folder_path][task_name] = task_entity + self._task_ids_by_folder_path[folder_path] = task_ids + self._task_names_by_folder_path[folder_path] = task_names + + return output + + def get_folder_entity( + self, + folder_path: Optional[str], + ) -> Optional[Dict[str, Any]]: + """Get folder entity by path. + + Entities are cached until reset. + + Args: + folder_path (Optional[str]): Folder path. + + Returns: + Optional[Dict[str, Any]]: Folder entity. + + """ + if not folder_path: + return None + return self.get_folder_entities([folder_path]).get(folder_path) + + def get_task_entity( + self, + folder_path: Optional[str], + task_name: Optional[str], + ) -> Optional[Dict[str, Any]]: + """Get task entity by name and folder path. + + Entities are cached until reset. + + Args: + folder_path (Optional[str]): Folder path. + task_name (Optional[str]): Task name. + + Returns: + Optional[Dict[str, Any]]: Task entity. + + """ + if not folder_path or not task_name: + return None + + output = self.get_task_entities({folder_path: {task_name}}) + return output.get(folder_path, {}).get(task_name) + + def get_instances_folder_entities( + self, instances: Optional[Iterable["CreatedInstance"]] = None + ) -> Dict[str, Optional[Dict[str, Any]]]: + if instances is None: + instances = self._instances_by_id.values() + instances = list(instances) + output = { + instance.id: None + for instance in instances + } + if not instances: + return output + + folder_paths = { + instance.get("folderPath") + for instance in instances + } + folder_paths.discard(None) + folder_entities_by_path = self.get_folder_entities(folder_paths) + for instance in instances: + folder_path = instance.get("folderPath") + output[instance.id] = folder_entities_by_path.get(folder_path) + return output + + def get_instances_task_entities( + self, instances: Optional[Iterable["CreatedInstance"]] = None + ): + """Get task entities for instances. + + Args: + instances (Optional[Iterable[CreatedInstance]]): Instances to + get task entities. If not provided all instances are used. + + Returns: + Dict[str, Optional[Dict[str, Any]]]: Task entity by instance id. + + """ + if instances is None: + instances = self._instances_by_id.values() + instances = list(instances) + + output = { + instance.id: None + for instance in instances + } + if not instances: + return output + + filtered_instances = [] + task_names_by_folder_path = collections.defaultdict(set) + for instance in instances: + folder_path = instance.get("folderPath") + task_name = instance.get("task") + if not folder_path or not task_name: + continue + filtered_instances.append(instance) + task_names_by_folder_path[folder_path].add(task_name) + + task_entities_by_folder_path = self.get_task_entities( + task_names_by_folder_path + ) + for instance in filtered_instances: + folder_path = instance["folderPath"] + task_name = instance["task"] + output[instance.id] = ( + task_entities_by_folder_path[folder_path][task_name] + ) + + return output + def get_instances_context_info( self, instances: Optional[Iterable["CreatedInstance"]] = None ) -> Dict[str, InstanceContextInfo]: @@ -1508,15 +1759,16 @@ class CreateContext: if instance.has_promised_context: context_info.folder_is_valid = True context_info.task_is_valid = True + # NOTE missing task type continue # TODO allow context promise folder_path = context_info.folder_path if not folder_path: continue - if folder_path in self._folder_id_by_folder_path: - folder_id = self._folder_id_by_folder_path[folder_path] - if folder_id is None: + if folder_path in self._folder_entities_by_path: + folder_entity = self._folder_entities_by_path[folder_path] + if folder_entity is None: continue context_info.folder_is_valid = True @@ -1535,72 +1787,78 @@ class CreateContext: # Backwards compatibility for cases where folder name is set instead # of folder path - folder_names = set() folder_paths = set() - for folder_path in task_names_by_folder_path.keys(): + task_names_by_folder_name = {} + task_names_by_folder_path_clean = {} + for folder_path, task_names in task_names_by_folder_path.items(): if folder_path is None: - pass - elif "/" in folder_path: - folder_paths.add(folder_path) - else: - folder_names.add(folder_path) + continue - folder_paths_by_id = {} - if folder_paths: + clean_task_names = { + task_name + for task_name in task_names + if task_name + } + + if "/" not in folder_path: + task_names_by_folder_name[folder_path] = clean_task_names + continue + + folder_paths.add(folder_path) + if not clean_task_names: + continue + + task_names_by_folder_path_clean[folder_path] = clean_task_names + + folder_paths_by_name = collections.defaultdict(list) + if task_names_by_folder_name: for folder_entity in ayon_api.get_folders( project_name, - folder_paths=folder_paths, - fields={"id", "path"} + folder_names=task_names_by_folder_name.keys(), + fields={"name", "path"} ): - folder_id = folder_entity["id"] - folder_path = folder_entity["path"] - folder_paths_by_id[folder_id] = folder_path - self._folder_id_by_folder_path[folder_path] = folder_id - - folder_entities_by_name = collections.defaultdict(list) - if folder_names: - for folder_entity in ayon_api.get_folders( - project_name, - folder_names=folder_names, - fields={"id", "name", "path"} - ): - folder_id = folder_entity["id"] folder_name = folder_entity["name"] folder_path = folder_entity["path"] - folder_paths_by_id[folder_id] = folder_path - folder_entities_by_name[folder_name].append(folder_entity) - self._folder_id_by_folder_path[folder_path] = folder_id + folder_paths_by_name[folder_name].append(folder_path) - tasks_entities = ayon_api.get_tasks( - project_name, - folder_ids=folder_paths_by_id.keys(), - fields={"name", "folderId"} + folder_path_by_name = {} + for folder_name, paths in folder_paths_by_name.items(): + if len(paths) != 1: + continue + path = paths[0] + folder_path_by_name[folder_name] = path + folder_paths.add(path) + clean_task_names = task_names_by_folder_name[folder_name] + if not clean_task_names: + continue + folder_task_names = task_names_by_folder_path_clean.setdefault( + path, set() + ) + folder_task_names |= clean_task_names + + folder_entities_by_path = self.get_folder_entities(folder_paths) + task_entities_by_folder_path = self.get_task_entities( + task_names_by_folder_path_clean ) - task_names_by_folder_path = collections.defaultdict(set) - for task_entity in tasks_entities: - folder_id = task_entity["folderId"] - folder_path = folder_paths_by_id[folder_id] - task_names_by_folder_path[folder_path].add(task_entity["name"]) - self._task_names_by_folder_path.update(task_names_by_folder_path) - for instance in to_validate: folder_path = instance["folderPath"] task_name = instance.get("task") if folder_path and "/" not in folder_path: - folder_entities = folder_entities_by_name.get(folder_path) - if len(folder_entities) == 1: - folder_path = folder_entities[0]["path"] - instance["folderPath"] = folder_path + new_folder_path = folder_path_by_name.get(folder_path) + if new_folder_path: + folder_path = new_folder_path + instance["folderPath"] = new_folder_path - if folder_path not in task_names_by_folder_path: + folder_entity = folder_entities_by_path.get(folder_path) + if not folder_entity: continue context_info = info_by_instance_id[instance.id] context_info.folder_is_valid = True if ( not task_name - or task_name in task_names_by_folder_path[folder_path] + or task_name in task_entities_by_folder_path[folder_path] ): context_info.task_is_valid = True return info_by_instance_id diff --git a/client/ayon_core/pipeline/delivery.py b/client/ayon_core/pipeline/delivery.py index 2a2adf984a..366c261e08 100644 --- a/client/ayon_core/pipeline/delivery.py +++ b/client/ayon_core/pipeline/delivery.py @@ -383,6 +383,13 @@ def get_representations_delivery_template_data( continue template_data = repre_entity["context"] + # Bug in 'ayon_api', 'get_representations_hierarchy' did not fully + # convert representation entity. Fixed in 'ayon_api' 1.0.10. + if isinstance(template_data, str): + con = ayon_api.get_server_api_connection() + repre_entity = con._representation_conversion(repre_entity) + template_data = repre_entity["context"] + template_data.update(copy.deepcopy(general_template_data)) template_data.update(get_folder_template_data( repre_hierarchy.folder, project_name @@ -402,5 +409,9 @@ def get_representations_delivery_template_data( "version": version_entity["version"], }) _merge_data(template_data, repre_entity["context"]) + + # Remove roots from template data to auto-fill them with anatomy data + template_data.pop("root", None) + output[repre_id] = template_data return output diff --git a/client/ayon_core/plugins/publish/extract_hierarchy_to_ayon.py b/client/ayon_core/plugins/publish/extract_hierarchy_to_ayon.py index 60c92aa8b1..a169affc66 100644 --- a/client/ayon_core/plugins/publish/extract_hierarchy_to_ayon.py +++ b/client/ayon_core/plugins/publish/extract_hierarchy_to_ayon.py @@ -154,7 +154,9 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): # TODO check if existing entity have 'task' type if task_entity is None: task_entity = entity_hub.add_new_task( - task_info["type"], + task_type=task_info["type"], + # TODO change 'parent_id' to 'folder_id' when ayon api + # is updated parent_id=entity.id, name=task_name ) @@ -182,7 +184,7 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): folder_type = "Folder" child_entity = entity_hub.add_new_folder( - folder_type, + folder_type=folder_type, parent_id=entity.id, name=child_name ) diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index acdc5276f7..180cb8bbf1 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -458,7 +458,18 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, return new_instance @classmethod - def get_attribute_defs(cls): + def get_attr_defs_for_instance(cls, create_context, instance): + # Filtering of instance, if needed, can be customized + if not cls.instance_matches_plugin_families(instance): + return [] + + # Attributes logic + publish_attributes = instance["publish_attributes"].get( + cls.__name__, {}) + + visible = publish_attributes.get("contribution_enabled", True) + variant_visible = visible and publish_attributes.get( + "contribution_apply_as_variant", True) return [ UISeparatorDef("usd_container_settings1"), @@ -484,7 +495,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, "the contribution itself will be added to the " "department layer." ), - default="usdAsset"), + default="usdAsset", + visible=visible), EnumDef("contribution_target_product_init", label="Initialize as", tooltip=( @@ -495,7 +507,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, "setting will do nothing." ), items=["asset", "shot"], - default="asset"), + default="asset", + visible=visible), # Asset layer, e.g. model.usd, look.usd, rig.usd EnumDef("contribution_layer", @@ -507,7 +520,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, "the list) will contribute as a stronger opinion." ), items=list(cls.contribution_layers.keys()), - default="model"), + default="model", + visible=visible), BoolDef("contribution_apply_as_variant", label="Add as variant", tooltip=( @@ -518,13 +532,16 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, "appended to as a sublayer to the department layer " "instead." ), - default=True), + default=True, + visible=visible), TextDef("contribution_variant_set_name", label="Variant Set Name", - default="{layer}"), + default="{layer}", + visible=variant_visible), TextDef("contribution_variant", label="Variant Name", - default="{variant}"), + default="{variant}", + visible=variant_visible), BoolDef("contribution_variant_is_default", label="Set as default variant selection", tooltip=( @@ -535,10 +552,41 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, "The behavior is unpredictable if multiple instances " "for the same variant set have this enabled." ), - default=False), + default=False, + visible=variant_visible), UISeparatorDef("usd_container_settings3"), ] + @classmethod + def register_create_context_callbacks(cls, create_context): + create_context.add_value_changed_callback(cls.on_values_changed) + + @classmethod + def on_values_changed(cls, event): + """Update instance attribute definitions on attribute changes.""" + + # Update attributes if any of the following plug-in attributes + # change: + keys = ["contribution_enabled", "contribution_apply_as_variant"] + + for instance_change in event["changes"]: + instance = instance_change["instance"] + if not cls.instance_matches_plugin_families(instance): + continue + value_changes = instance_change["changes"] + plugin_attribute_changes = ( + value_changes.get("publish_attributes", {}) + .get(cls.__name__, {})) + + if not any(key in plugin_attribute_changes for key in keys): + continue + + # Update the attribute definitions + new_attrs = cls.get_attr_defs_for_instance( + event["create_context"], instance + ) + instance.set_publish_plugin_attr_defs(cls.__name__, new_attrs) + class CollectUSDLayerContributionsHoudiniLook(CollectUSDLayerContributions): """ @@ -551,9 +599,12 @@ class CollectUSDLayerContributionsHoudiniLook(CollectUSDLayerContributions): label = CollectUSDLayerContributions.label + " (Look)" @classmethod - def get_attribute_defs(cls): - defs = super(CollectUSDLayerContributionsHoudiniLook, - cls).get_attribute_defs() + def get_attr_defs_for_instance(cls, create_context, instance): + # Filtering of instance, if needed, can be customized + if not cls.instance_matches_plugin_families(instance): + return [] + + defs = super().get_attr_defs_for_instance(create_context, instance) # Update default for department layer to look layer_def = next(d for d in defs if d.key == "contribution_layer") diff --git a/client/ayon_core/style/data.json b/client/ayon_core/style/data.json index 7389387d97..24629ec085 100644 --- a/client/ayon_core/style/data.json +++ b/client/ayon_core/style/data.json @@ -60,7 +60,11 @@ "icon-alert-tools": "#AA5050", "icon-entity-default": "#bfccd6", "icon-entity-disabled": "#808080", + "font-entity-deprecated": "#666666", + + "font-overridden": "#91CDFC", + "overlay-messages": { "close-btn": "#D3D8DE", "bg-success": "#458056", diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 3d84d917a4..0d1d4f710e 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -44,6 +44,10 @@ QLabel { background: transparent; } +QLabel[overriden="1"] { + color: {color:font-overridden}; +} + /* Inputs */ QAbstractSpinBox, QLineEdit, QPlainTextEdit, QTextEdit { border: 1px solid {color:border}; diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index a6ae93cecd..7fad2b8176 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -366,7 +366,7 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): @abstractmethod def get_creator_attribute_definitions( self, instance_ids: Iterable[str] - ) -> List[Tuple[AbstractAttrDef, List[str], List[Any]]]: + ) -> List[Tuple[AbstractAttrDef, Dict[str, Dict[str, Any]]]]: pass @abstractmethod @@ -383,7 +383,7 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): ) -> List[Tuple[ str, List[AbstractAttrDef], - Dict[str, List[Tuple[str, Any]]] + Dict[str, List[Tuple[str, Any, Any]]] ]]: pass diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 9c13d8ae2f..8763d79712 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -769,7 +769,7 @@ class CreateModel: def get_creator_attribute_definitions( self, instance_ids: List[str] - ) -> List[Tuple[AbstractAttrDef, List[str], List[Any]]]: + ) -> List[Tuple[AbstractAttrDef, Dict[str, Dict[str, Any]]]]: """Collect creator attribute definitions for multuple instances. Args: @@ -796,12 +796,23 @@ class CreateModel: if found_idx is None: idx = len(output) - output.append((attr_def, [instance_id], [value])) + output.append(( + attr_def, + { + instance_id: { + "value": value, + "default": attr_def.default + } + } + )) _attr_defs[idx] = attr_def else: - _, ids, values = output[found_idx] - ids.append(instance_id) - values.append(value) + _, info_by_id = output[found_idx] + info_by_id[instance_id] = { + "value": value, + "default": attr_def.default + } + return output def set_instances_publish_attr_values( @@ -835,7 +846,7 @@ class CreateModel: ) -> List[Tuple[ str, List[AbstractAttrDef], - Dict[str, List[Tuple[str, Any]]] + Dict[str, List[Tuple[str, Any, Any]]] ]]: """Collect publish attribute definitions for passed instances. @@ -865,21 +876,21 @@ class CreateModel: attr_defs = attr_val.attr_defs if not attr_defs: continue + plugin_attr_defs = all_defs_by_plugin_name.setdefault( plugin_name, [] ) - plugin_attr_defs.append(attr_defs) - plugin_values = all_plugin_values.setdefault(plugin_name, {}) + plugin_attr_defs.append(attr_defs) + for attr_def in attr_defs: if isinstance(attr_def, UIDef): continue - attr_values = plugin_values.setdefault(attr_def.key, []) - - value = attr_val[attr_def.key] - attr_values.append((item_id, value)) + attr_values.append( + (item_id, attr_val[attr_def.key], attr_def.default) + ) attr_defs_by_plugin_name = {} for plugin_name, attr_defs in all_defs_by_plugin_name.items(): @@ -893,7 +904,7 @@ class CreateModel: output.append(( plugin_name, attr_defs_by_plugin_name[plugin_name], - all_plugin_values + all_plugin_values[plugin_name], )) return output diff --git a/client/ayon_core/tools/publisher/widgets/product_attributes.py b/client/ayon_core/tools/publisher/widgets/product_attributes.py index 61d5ca111d..b0b2640640 100644 --- a/client/ayon_core/tools/publisher/widgets/product_attributes.py +++ b/client/ayon_core/tools/publisher/widgets/product_attributes.py @@ -1,6 +1,10 @@ +import typing +from typing import Dict, List, Any + from qtpy import QtWidgets, QtCore -from ayon_core.lib.attribute_definitions import UnknownDef +from ayon_core.lib.attribute_definitions import AbstractAttrDef, UnknownDef +from ayon_core.tools.utils import set_style_property from ayon_core.tools.attribute_defs import create_widget_for_attr_def from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend from ayon_core.tools.publisher.constants import ( @@ -8,6 +12,49 @@ from ayon_core.tools.publisher.constants import ( INPUTS_LAYOUT_VSPACING, ) +if typing.TYPE_CHECKING: + from typing import Union + + +def _set_label_overriden(label: QtWidgets.QLabel, overriden: bool): + set_style_property( + label, + "overriden", + "1" if overriden else "" + ) + + +class _CreateAttrDefInfo: + """Helper class to store information about create attribute definition.""" + def __init__( + self, + attr_def: AbstractAttrDef, + instance_ids: List["Union[str, None]"], + defaults: List[Any], + label_widget: "Union[None, QtWidgets.QLabel]", + ): + self.attr_def: AbstractAttrDef = attr_def + self.instance_ids: List["Union[str, None]"] = instance_ids + self.defaults: List[Any] = defaults + self.label_widget: "Union[None, QtWidgets.QLabel]" = label_widget + + +class _PublishAttrDefInfo: + """Helper class to store information about publish attribute definition.""" + def __init__( + self, + attr_def: AbstractAttrDef, + plugin_name: str, + instance_ids: List["Union[str, None]"], + defaults: List[Any], + label_widget: "Union[None, QtWidgets.QLabel]", + ): + self.attr_def: AbstractAttrDef = attr_def + self.plugin_name: str = plugin_name + self.instance_ids: List["Union[str, None]"] = instance_ids + self.defaults: List[Any] = defaults + self.label_widget: "Union[None, QtWidgets.QLabel]" = label_widget + class CreatorAttrsWidget(QtWidgets.QWidget): """Widget showing creator specific attributes for selected instances. @@ -51,8 +98,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget): self._controller: AbstractPublisherFrontend = controller self._scroll_area = scroll_area - self._attr_def_id_to_instances = {} - self._attr_def_id_to_attr_def = {} + self._attr_def_info_by_id: Dict[str, _CreateAttrDefInfo] = {} self._current_instance_ids = set() # To store content of scroll area to prevent garbage collection @@ -81,8 +127,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget): prev_content_widget.deleteLater() self._content_widget = None - self._attr_def_id_to_instances = {} - self._attr_def_id_to_attr_def = {} + self._attr_def_info_by_id = {} result = self._controller.get_creator_attribute_definitions( self._current_instance_ids @@ -97,9 +142,19 @@ class CreatorAttrsWidget(QtWidgets.QWidget): content_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING) row = 0 - for attr_def, instance_ids, values in result: + for attr_def, info_by_id in result: widget = create_widget_for_attr_def(attr_def, content_widget) + default_values = [] if attr_def.is_value_def: + values = [] + for item in info_by_id.values(): + values.append(item["value"]) + # 'set' cannot be used for default values because they can + # be unhashable types, e.g. 'list'. + default = item["default"] + if default not in default_values: + default_values.append(default) + if len(values) == 1: value = values[0] if value is not None: @@ -108,8 +163,10 @@ class CreatorAttrsWidget(QtWidgets.QWidget): widget.set_value(values, True) widget.value_changed.connect(self._input_value_changed) - self._attr_def_id_to_instances[attr_def.id] = instance_ids - self._attr_def_id_to_attr_def[attr_def.id] = attr_def + attr_def_info = _CreateAttrDefInfo( + attr_def, list(info_by_id), default_values, None + ) + self._attr_def_info_by_id[attr_def.id] = attr_def_info if not attr_def.visible: continue @@ -121,8 +178,14 @@ class CreatorAttrsWidget(QtWidgets.QWidget): col_num = 2 - expand_cols label = None + is_overriden = False if attr_def.is_value_def: + is_overriden = any( + item["value"] != item["default"] + for item in info_by_id.values() + ) label = attr_def.label or attr_def.key + if label: label_widget = QtWidgets.QLabel(label, self) tooltip = attr_def.tooltip @@ -138,6 +201,8 @@ class CreatorAttrsWidget(QtWidgets.QWidget): ) if not attr_def.is_label_horizontal: row += 1 + attr_def_info.label_widget = label_widget + _set_label_overriden(label_widget, is_overriden) content_layout.addWidget( widget, row, col_num, 1, expand_cols @@ -165,12 +230,19 @@ class CreatorAttrsWidget(QtWidgets.QWidget): break def _input_value_changed(self, value, attr_id): - instance_ids = self._attr_def_id_to_instances.get(attr_id) - attr_def = self._attr_def_id_to_attr_def.get(attr_id) - if not instance_ids or not attr_def: + attr_def_info = self._attr_def_info_by_id.get(attr_id) + if attr_def_info is None: return + + if attr_def_info.label_widget is not None: + defaults = attr_def_info.defaults + is_overriden = len(defaults) != 1 or value not in defaults + _set_label_overriden(attr_def_info.label_widget, is_overriden) + self._controller.set_instances_create_attr_values( - instance_ids, attr_def.key, value + attr_def_info.instance_ids, + attr_def_info.attr_def.key, + value ) @@ -223,9 +295,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): self._controller: AbstractPublisherFrontend = controller self._scroll_area = scroll_area - self._attr_def_id_to_instances = {} - self._attr_def_id_to_attr_def = {} - self._attr_def_id_to_plugin_name = {} + self._attr_def_info_by_id: Dict[str, _PublishAttrDefInfo] = {} # Store content of scroll area to prevent garbage collection self._content_widget = None @@ -254,9 +324,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): self._content_widget = None - self._attr_def_id_to_instances = {} - self._attr_def_id_to_attr_def = {} - self._attr_def_id_to_plugin_name = {} + self._attr_def_info_by_id = {} result = self._controller.get_publish_attribute_definitions( self._current_instance_ids, self._context_selected @@ -275,9 +343,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): content_layout.addStretch(1) row = 0 - for plugin_name, attr_defs, all_plugin_values in result: - plugin_values = all_plugin_values[plugin_name] - + for plugin_name, attr_defs, plugin_values in result: for attr_def in attr_defs: widget = create_widget_for_attr_def( attr_def, content_widget @@ -290,6 +356,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): widget.setVisible(False) visible_widget = False + label_widget = None if visible_widget: expand_cols = 2 if attr_def.is_value_def and attr_def.is_label_horizontal: @@ -324,35 +391,58 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): widget.value_changed.connect(self._input_value_changed) - attr_values = plugin_values[attr_def.key] - multivalue = len(attr_values) > 1 + instance_ids = [] values = [] - instances = [] - for instance, value in attr_values: + default_values = [] + is_overriden = False + for (instance_id, value, default_value) in ( + plugin_values.get(attr_def.key, []) + ): + instance_ids.append(instance_id) values.append(value) - instances.append(instance) + if not is_overriden and value != default_value: + is_overriden = True + # 'set' cannot be used for default values because they can + # be unhashable types, e.g. 'list'. + if default_value not in default_values: + default_values.append(default_value) - self._attr_def_id_to_attr_def[attr_def.id] = attr_def - self._attr_def_id_to_instances[attr_def.id] = instances - self._attr_def_id_to_plugin_name[attr_def.id] = plugin_name + multivalue = len(values) > 1 + + self._attr_def_info_by_id[attr_def.id] = _PublishAttrDefInfo( + attr_def, + plugin_name, + instance_ids, + default_values, + label_widget, + ) if multivalue: widget.set_value(values, multivalue) else: widget.set_value(values[0]) + if label_widget is not None: + _set_label_overriden(label_widget, is_overriden) + self._scroll_area.setWidget(content_widget) self._content_widget = content_widget def _input_value_changed(self, value, attr_id): - instance_ids = self._attr_def_id_to_instances.get(attr_id) - attr_def = self._attr_def_id_to_attr_def.get(attr_id) - plugin_name = self._attr_def_id_to_plugin_name.get(attr_id) - if not instance_ids or not attr_def or not plugin_name: + attr_def_info = self._attr_def_info_by_id.get(attr_id) + if attr_def_info is None: return + if attr_def_info.label_widget is not None: + defaults = attr_def_info.defaults + is_overriden = len(defaults) != 1 or value not in defaults + _set_label_overriden(attr_def_info.label_widget, is_overriden) + self._controller.set_instances_publish_attr_values( - instance_ids, plugin_name, attr_def.key, value + attr_def_info.instance_ids, + attr_def_info.plugin_name, + attr_def_info.attr_def.key, + value ) def _on_instance_attr_defs_change(self, event): diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 8a7065c93c..2b2af81e18 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.0.4+dev" +__version__ = "1.0.6+dev" diff --git a/package.py b/package.py index 7c5bffe81f..59f0e82be0 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.0.4+dev" +version = "1.0.6+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index fe0d7ebbfa..ed7fa6cc55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.0.4+dev" +version = "1.0.6+dev" description = "" authors = ["Ynput Team "] readme = "README.md"