Merge remote-tracking branch 'origin/develop' into feature/911-new-traits-based-integrator

This commit is contained in:
Ondřej Samohel 2024-11-05 16:14:40 +01:00
commit e1517d21f7
No known key found for this signature in database
GPG key ID: 02376E18990A97C6
16 changed files with 611 additions and 211 deletions

View file

@ -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 }}

View file

@ -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

View file

@ -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):

View file

@ -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

View file

@ -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

View file

@ -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

View file

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

View file

@ -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")

View file

@ -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",

View file

@ -44,6 +44,10 @@ QLabel {
background: transparent;
}
QLabel[overriden="1"] {
color: {color:font-overridden};
}
/* Inputs */
QAbstractSpinBox, QLineEdit, QPlainTextEdit, QTextEdit {
border: 1px solid {color:border};

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring AYON addon 'core' version."""
__version__ = "1.0.4+dev"
__version__ = "1.0.6+dev"

View file

@ -1,6 +1,6 @@
name = "core"
title = "Core"
version = "1.0.4+dev"
version = "1.0.6+dev"
client_dir = "ayon_core"

View file

@ -5,7 +5,7 @@
[tool.poetry]
name = "ayon-core"
version = "1.0.4+dev"
version = "1.0.6+dev"
description = ""
authors = ["Ynput Team <team@ynput.io>"]
readme = "README.md"