Merge branch 'develop' into enhancement/skip-base-classes

This commit is contained in:
Jakub Trllo 2025-12-12 12:58:08 +01:00 committed by GitHub
commit 70328e53c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1273 additions and 273 deletions

View file

@ -1,3 +1,4 @@
from __future__ import annotations
import os
import re
import logging
@ -12,6 +13,8 @@ from typing import Optional
import xml.etree.ElementTree
import clique
from .execute import run_subprocess
from .vendor_bin_utils import (
get_ffmpeg_tool_args,
@ -634,6 +637,37 @@ def should_convert_for_ffmpeg(src_filepath):
return False
def _get_attributes_to_erase(
input_info: dict, logger: logging.Logger
) -> list[str]:
"""FFMPEG does not support some attributes in metadata."""
erase_attrs: dict[str, str] = {} # Attr name to reason mapping
for attr_name, attr_value in input_info["attribs"].items():
if not isinstance(attr_value, str):
continue
# Remove attributes that have string value longer than allowed length
# for ffmpeg or when contain prohibited symbols
if len(attr_value) > MAX_FFMPEG_STRING_LEN:
reason = f"has too long value ({len(attr_value)} chars)."
erase_attrs[attr_name] = reason
continue
for char in NOT_ALLOWED_FFMPEG_CHARS:
if char not in attr_value:
continue
reason = f"contains unsupported character \"{char}\"."
erase_attrs[attr_name] = reason
break
for attr_name, reason in erase_attrs.items():
logger.info(
f"Removed attribute \"{attr_name}\" from metadata"
f" because {reason}."
)
return list(erase_attrs.keys())
def convert_input_paths_for_ffmpeg(
input_paths,
output_dir,
@ -659,7 +693,7 @@ def convert_input_paths_for_ffmpeg(
Raises:
ValueError: If input filepath has extension not supported by function.
Currently is supported only ".exr" extension.
Currently, only ".exr" extension is supported.
"""
if logger is None:
logger = logging.getLogger(__name__)
@ -684,7 +718,22 @@ def convert_input_paths_for_ffmpeg(
# Collect channels to export
input_arg, channels_arg = get_oiio_input_and_channel_args(input_info)
for input_path in input_paths:
# Find which attributes to strip
erase_attributes: list[str] = _get_attributes_to_erase(
input_info, logger=logger
)
# clique.PATTERNS["frames"] supports only `.1001.exr` not `_1001.exr` so
# we use a customized pattern.
pattern = "[_.](?P<index>(?P<padding>0*)\\d+)\\.\\D+\\d?$"
input_collections, input_remainder = clique.assemble(
input_paths,
patterns=[pattern],
assume_padded_when_ambiguous=True,
)
input_items = list(input_collections)
input_items.extend(input_remainder)
for input_item in input_items:
# Prepare subprocess arguments
oiio_cmd = get_oiio_tool_args(
"oiiotool",
@ -695,8 +744,23 @@ def convert_input_paths_for_ffmpeg(
if compression:
oiio_cmd.extend(["--compression", compression])
# Convert a sequence of files using a single oiiotool command
# using its sequence syntax
if isinstance(input_item, clique.Collection):
frames = input_item.format("{head}#{tail}").replace(" ", "")
oiio_cmd.extend([
"--framepadding", input_item.padding,
"--frames", frames,
"--parallel-frames"
])
input_item: str = input_item.format("{head}#{tail}")
elif not isinstance(input_item, str):
raise TypeError(
f"Input is not a string or Collection: {input_item}"
)
oiio_cmd.extend([
input_arg, input_path,
input_arg, input_item,
# Tell oiiotool which channels should be put to top stack
# (and output)
"--ch", channels_arg,
@ -704,38 +768,11 @@ def convert_input_paths_for_ffmpeg(
"--subimage", "0"
])
for attr_name, attr_value in input_info["attribs"].items():
if not isinstance(attr_value, str):
continue
# Remove attributes that have string value longer than allowed
# length for ffmpeg or when containing prohibited symbols
erase_reason = "Missing reason"
erase_attribute = False
if len(attr_value) > MAX_FFMPEG_STRING_LEN:
erase_reason = "has too long value ({} chars).".format(
len(attr_value)
)
erase_attribute = True
if not erase_attribute:
for char in NOT_ALLOWED_FFMPEG_CHARS:
if char in attr_value:
erase_attribute = True
erase_reason = (
"contains unsupported character \"{}\"."
).format(char)
break
if erase_attribute:
# Set attribute to empty string
logger.info((
"Removed attribute \"{}\" from metadata because {}."
).format(attr_name, erase_reason))
oiio_cmd.extend(["--eraseattrib", attr_name])
for attr_name in erase_attributes:
oiio_cmd.extend(["--eraseattrib", attr_name])
# Add last argument - path to output
base_filename = os.path.basename(input_path)
base_filename = os.path.basename(input_item)
output_path = os.path.join(output_dir, base_filename)
oiio_cmd.extend([
"-o", output_path
@ -1136,7 +1173,10 @@ def oiio_color_convert(
target_display=None,
target_view=None,
additional_command_args=None,
logger=None,
frames: Optional[str] = None,
frame_padding: Optional[int] = None,
parallel_frames: bool = False,
logger: Optional[logging.Logger] = None,
):
"""Transcode source file to other with colormanagement.
@ -1148,7 +1188,7 @@ def oiio_color_convert(
input_path (str): Path that should be converted. It is expected that
contains single file or image sequence of same type
(sequence in format 'file.FRAMESTART-FRAMEEND#.ext', see oiio docs,
eg `big.1-3#.tif`)
eg `big.1-3#.tif` or `big.1-3%d.ext` with `frames` argument)
output_path (str): Path to output filename.
(must follow format of 'input_path', eg. single file or
sequence in 'file.FRAMESTART-FRAMEEND#.ext', `output.1-3#.tif`)
@ -1169,6 +1209,13 @@ def oiio_color_convert(
both 'view' and 'display' must be filled (if 'target_colorspace')
additional_command_args (list): arguments for oiiotool (like binary
depth for .dpx)
frames (Optional[str]): Complex frame range to process. This requires
input path and output path to use frame token placeholder like
`#` or `%d`, e.g. file.#.exr
frame_padding (Optional[int]): Frame padding to use for the input and
output when using a sequence filepath.
parallel_frames (bool): If True, process frames in parallel inside
the `oiiotool` process. Only supported in OIIO 2.5.20.0+.
logger (logging.Logger): Logger used for logging.
Raises:
@ -1178,7 +1225,16 @@ def oiio_color_convert(
if logger is None:
logger = logging.getLogger(__name__)
input_info = get_oiio_info_for_input(input_path, logger=logger)
# Get oiioinfo only from first image, otherwise file can't be found
first_input_path = input_path
if frames:
frames: str
first_frame = int(re.split("[ x-]", frames, 1)[0])
first_frame = str(first_frame).zfill(frame_padding or 0)
for token in ["#", "%d"]:
first_input_path = first_input_path.replace(token, first_frame)
input_info = get_oiio_info_for_input(first_input_path, logger=logger)
# Collect channels to export
input_arg, channels_arg = get_oiio_input_and_channel_args(input_info)
@ -1191,6 +1247,22 @@ def oiio_color_convert(
"--colorconfig", config_path
)
if frames:
# If `frames` is specified, then process the input and output
# as if it's a sequence of frames (must contain `%04d` as frame
# token placeholder in filepaths)
oiio_cmd.extend([
"--frames", frames,
])
if frame_padding:
oiio_cmd.extend([
"--framepadding", str(frame_padding),
])
if parallel_frames:
oiio_cmd.append("--parallel-frames")
oiio_cmd.extend([
input_arg, input_path,
# Tell oiiotool which channels should be put to top stack
@ -1234,17 +1306,11 @@ def oiio_color_convert(
if source_view and source_display:
color_convert_args = None
ocio_display_args = None
oiio_cmd.extend([
"--ociodisplay:inverse=1:subimages=0",
source_display,
source_view,
])
if target_colorspace:
# This is a two-step conversion process since there's no direct
# display/view to colorspace command
# This could be a config parameter or determined from OCIO config
# Use temporarty role space 'scene_linear'
# Use temporary role space 'scene_linear'
color_convert_args = ("scene_linear", target_colorspace)
elif source_display != target_display or source_view != target_view:
# Complete display/view pair conversion
@ -1256,6 +1322,15 @@ def oiio_color_convert(
" No color conversion needed."
)
if color_convert_args or ocio_display_args:
# Invert source display/view so that we can go from there to the
# target colorspace or display/view
oiio_cmd.extend([
"--ociodisplay:inverse=1:subimages=0",
source_display,
source_view,
])
if color_convert_args:
# Use colorconvert for colorspace target
oiio_cmd.extend([

View file

@ -1,4 +1,5 @@
"""Package to handle compatibility checks for pipeline components."""
import ayon_api
def is_product_base_type_supported() -> bool:
@ -13,4 +14,7 @@ def is_product_base_type_supported() -> bool:
bool: True if product base types are supported, False otherwise.
"""
return False
if not hasattr(ayon_api, "is_product_base_type_supported"):
return False
return ayon_api.is_product_base_type_supported()

View file

@ -15,6 +15,7 @@ from typing import (
Any,
Callable,
)
from warnings import warn
import pyblish.logic
import pyblish.api
@ -752,13 +753,13 @@ class CreateContext:
manual_creators = {}
report = discover_creator_plugins(return_report=True)
self.creator_discover_result = report
for creator_class in report.plugins:
if inspect.isabstract(creator_class):
self.log.debug(
"Skipping abstract Creator {}".format(str(creator_class))
)
continue
for creator_class in report.abstract_plugins:
self.log.debug(
"Skipping abstract Creator '%s'",
str(creator_class)
)
for creator_class in report.plugins:
creator_identifier = creator_class.identifier
if creator_identifier in creators:
self.log.warning(
@ -772,19 +773,17 @@ class CreateContext:
creator_class.host_name
and creator_class.host_name != self.host_name
):
self.log.info((
"Creator's host name \"{}\""
" is not supported for current host \"{}\""
).format(creator_class.host_name, self.host_name))
self.log.info(
(
'Creator\'s host name "{}"'
' is not supported for current host "{}"'
).format(creator_class.host_name, self.host_name)
)
continue
# TODO report initialization error
try:
creator = creator_class(
project_settings,
self,
self.headless
)
creator = creator_class(project_settings, self, self.headless)
except Exception:
self.log.error(
f"Failed to initialize plugin: {creator_class}",
@ -792,6 +791,19 @@ class CreateContext:
)
continue
if not creator.product_base_type:
message = (
f"Provided creator {creator!r} doesn't have "
"product base type attribute defined. This will be "
"required in future."
)
warn(
message,
DeprecationWarning,
stacklevel=2
)
self.log.warning(message)
if not creator.enabled:
disabled_creators[creator_identifier] = creator
continue
@ -1289,8 +1301,12 @@ class CreateContext:
"folderPath": folder_entity["path"],
"task": task_entity["name"] if task_entity else None,
"productType": creator.product_type,
# Add product base type if supported. Fallback to product type
"productBaseType": (
creator.product_base_type or creator.product_type),
"variant": variant
}
if active is not None:
if not isinstance(active, bool):
self.log.warning(

View file

@ -1,20 +1,21 @@
# -*- coding: utf-8 -*-
import os
import copy
import collections
from typing import TYPE_CHECKING, Optional, Dict, Any
"""Creator plugins for the create process."""
from __future__ import annotations
import collections
import copy
import os
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, Dict, Optional
from ayon_core.lib import Logger, get_version_from_path
from ayon_core.pipeline.plugin_discover import (
deregister_plugin,
deregister_plugin_path,
discover,
register_plugin,
register_plugin_path,
deregister_plugin,
deregister_plugin_path
)
from ayon_core.pipeline.staging_dir import get_staging_dir_info, StagingDir
from ayon_core.pipeline.staging_dir import StagingDir, get_staging_dir_info
from .constants import DEFAULT_VARIANT_VALUE
from .product_name import get_product_name
@ -23,6 +24,7 @@ from .structures import CreatedInstance
if TYPE_CHECKING:
from ayon_core.lib import AbstractAttrDef
# Avoid cyclic imports
from .context import CreateContext, UpdateData # noqa: F401
@ -66,7 +68,6 @@ class ProductConvertorPlugin(ABC):
Returns:
logging.Logger: Logger with name of the plugin.
"""
if self._log is None:
self._log = Logger.get_logger(self.__class__.__name__)
return self._log
@ -82,9 +83,8 @@ class ProductConvertorPlugin(ABC):
Returns:
str: Converted identifier unique for all converters in host.
"""
pass
"""
@abstractmethod
def find_instances(self):
@ -94,14 +94,10 @@ class ProductConvertorPlugin(ABC):
convert.
"""
pass
@abstractmethod
def convert(self):
"""Conversion code."""
pass
@property
def create_context(self):
"""Quick access to create context.
@ -109,7 +105,6 @@ class ProductConvertorPlugin(ABC):
Returns:
CreateContext: Context which initialized the plugin.
"""
return self._create_context
@property
@ -122,7 +117,6 @@ class ProductConvertorPlugin(ABC):
Raises:
UnavailableSharedData: When called out of collection phase.
"""
return self._create_context.collection_shared_data
def add_convertor_item(self, label):
@ -131,12 +125,10 @@ class ProductConvertorPlugin(ABC):
Args:
label (str): Label of item which will show in UI.
"""
self._create_context.add_convertor_item(self.identifier, label)
def remove_convertor_item(self):
"""Remove legacy item from create context when conversion finished."""
self._create_context.remove_convertor_item(self.identifier)
@ -155,7 +147,6 @@ class BaseCreator(ABC):
create_context (CreateContext): Context which initialized creator.
headless (bool): Running in headless mode.
"""
# Label shown in UI
label = None
group_label = None
@ -219,7 +210,6 @@ class BaseCreator(ABC):
Returns:
Optional[dict[str, Any]]: Settings values or None.
"""
settings = project_settings.get(category_name)
if not settings:
return None
@ -265,7 +255,6 @@ class BaseCreator(ABC):
Args:
project_settings (dict[str, Any]): Project settings.
"""
settings_category = self.settings_category
if not settings_category:
return
@ -277,18 +266,17 @@ class BaseCreator(ABC):
project_settings, settings_category, settings_name
)
if settings is None:
self.log.debug("No settings found for {}".format(cls_name))
self.log.debug(f"No settings found for {cls_name}")
return
for key, value in settings.items():
# Log out attributes that are not defined on plugin object
# - those may be potential dangerous typos in settings
if not hasattr(self, key):
self.log.debug((
"Applying settings to unknown attribute '{}' on '{}'."
).format(
self.log.debug(
"Applying settings to unknown attribute '%s' on '%s'.",
key, cls_name
))
)
setattr(self, key, value)
def register_callbacks(self):
@ -297,23 +285,39 @@ class BaseCreator(ABC):
Default implementation does nothing. It can be overridden to register
callbacks for creator.
"""
pass
@property
def identifier(self):
"""Identifier of creator (must be unique).
Default implementation returns plugin's product type.
"""
Default implementation returns plugin's product base type,
or falls back to product type if product base type is not set.
return self.product_type
"""
identifier = self.product_base_type
if not identifier:
identifier = self.product_type
return identifier
@property
@abstractmethod
def product_type(self):
"""Family that plugin represents."""
pass
@property
def product_base_type(self) -> Optional[str]:
"""Base product type that plugin represents.
Todo (antirotor): This should be required in future - it
should be made abstract then.
Returns:
Optional[str]: Base product type that plugin represents.
If not set, it is assumed that the creator plugin is obsolete
and does not support product base type.
"""
return None
@property
def project_name(self):
@ -322,7 +326,6 @@ class BaseCreator(ABC):
Returns:
str: Name of a project.
"""
return self.create_context.project_name
@property
@ -332,7 +335,6 @@ class BaseCreator(ABC):
Returns:
Anatomy: Project anatomy object.
"""
return self.create_context.project_anatomy
@property
@ -344,13 +346,14 @@ class BaseCreator(ABC):
Default implementation use attributes in this order:
- 'group_label' -> 'label' -> 'identifier'
Keep in mind that 'identifier' use 'product_type' by default.
Keep in mind that 'identifier' uses 'product_base_type' by default.
Returns:
str: Group label that can be used for grouping of instances in UI.
Group label can be overridden by instance itself.
"""
Group label can be overridden by the instance itself.
"""
if self._cached_group_label is None:
label = self.identifier
if self.group_label:
@ -367,7 +370,6 @@ class BaseCreator(ABC):
Returns:
logging.Logger: Logger with name of the plugin.
"""
if self._log is None:
self._log = Logger.get_logger(self.__class__.__name__)
return self._log
@ -376,7 +378,8 @@ class BaseCreator(ABC):
self,
product_name: str,
data: Dict[str, Any],
product_type: Optional[str] = None
product_type: Optional[str] = None,
product_base_type: Optional[str] = None
) -> CreatedInstance:
"""Create instance and add instance to context.
@ -385,6 +388,8 @@ class BaseCreator(ABC):
data (Dict[str, Any]): Instance data.
product_type (Optional[str]): Product type, object attribute
'product_type' is used if not passed.
product_base_type (Optional[str]): Product base type, object
attribute 'product_base_type' is used if not passed.
Returns:
CreatedInstance: Created instance.
@ -392,11 +397,16 @@ class BaseCreator(ABC):
"""
if product_type is None:
product_type = self.product_type
if not product_base_type and not self.product_base_type:
product_base_type = product_type
instance = CreatedInstance(
product_type,
product_name,
data,
product_type=product_type,
product_name=product_name,
data=data,
creator=self,
product_base_type=product_base_type,
)
self._add_instance_to_context(instance)
return instance
@ -412,7 +422,6 @@ class BaseCreator(ABC):
Args:
instance (CreatedInstance): New created instance.
"""
self.create_context.creator_adds_instance(instance)
def _remove_instance_from_context(self, instance):
@ -425,7 +434,6 @@ class BaseCreator(ABC):
Args:
instance (CreatedInstance): Instance which should be removed.
"""
self.create_context.creator_removed_instance(instance)
@abstractmethod
@ -437,8 +445,6 @@ class BaseCreator(ABC):
implementation
"""
pass
@abstractmethod
def collect_instances(self):
"""Collect existing instances related to this creator plugin.
@ -464,8 +470,6 @@ class BaseCreator(ABC):
```
"""
pass
@abstractmethod
def update_instances(self, update_list):
"""Store changes of existing instances so they can be recollected.
@ -475,8 +479,6 @@ class BaseCreator(ABC):
contain changed instance and it's changes.
"""
pass
@abstractmethod
def remove_instances(self, instances):
"""Method called on instance removal.
@ -489,14 +491,11 @@ class BaseCreator(ABC):
removed.
"""
pass
def get_icon(self):
"""Icon of creator (product type).
Can return path to image file or awesome icon name.
"""
return self.icon
def get_dynamic_data(
@ -512,19 +511,18 @@ class BaseCreator(ABC):
These may be dynamically created based on current context of workfile.
"""
return {}
def get_product_name(
self,
project_name,
folder_entity,
task_entity,
variant,
host_name=None,
instance=None,
project_entity=None,
):
project_name: str,
folder_entity: dict[str, Any],
task_entity: Optional[dict[str, Any]],
variant: str,
host_name: Optional[str] = None,
instance: Optional[CreatedInstance] = None,
project_entity: Optional[dict[str, Any]] = None,
) -> str:
"""Return product name for passed context.
Method is also called on product name update. In that case origin
@ -546,11 +544,6 @@ class BaseCreator(ABC):
if host_name is None:
host_name = self.create_context.host_name
task_name = task_type = None
if task_entity:
task_name = task_entity["name"]
task_type = task_entity["taskType"]
dynamic_data = self.get_dynamic_data(
project_name,
folder_entity,
@ -566,11 +559,12 @@ class BaseCreator(ABC):
return get_product_name(
project_name,
task_name,
task_type,
host_name,
self.product_type,
variant,
folder_entity=folder_entity,
task_entity=task_entity,
product_base_type=self.product_base_type,
product_type=self.product_type,
host_name=host_name,
variant=variant,
dynamic_data=dynamic_data,
project_settings=self.project_settings,
project_entity=project_entity,
@ -583,15 +577,15 @@ class BaseCreator(ABC):
and values are stored to metadata for future usage and for publishing
purposes.
NOTE:
Convert method should be implemented which should care about updating
keys/values when plugin attributes change.
Note:
Convert method should be implemented which should care about
updating keys/values when plugin attributes change.
Returns:
list[AbstractAttrDef]: Attribute definitions that can be tweaked
for created instance.
"""
"""
return self.instance_attr_defs
def get_attr_defs_for_instance(self, instance):
@ -614,12 +608,10 @@ class BaseCreator(ABC):
Raises:
UnavailableSharedData: When called out of collection phase.
"""
return self.create_context.collection_shared_data
def set_instance_thumbnail_path(self, instance_id, thumbnail_path=None):
"""Set path to thumbnail for instance."""
self.create_context.thumbnail_paths_by_instance_id[instance_id] = (
thumbnail_path
)
@ -640,7 +632,6 @@ class BaseCreator(ABC):
Returns:
dict[str, int]: Next versions by instance id.
"""
return get_next_versions_for_instances(
self.create_context.project_name, instances
)
@ -707,7 +698,6 @@ class Creator(BaseCreator):
int: Order in which is creator shown (less == earlier). By default
is using Creator's 'order' or processing.
"""
return self.order
@abstractmethod
@ -722,11 +712,9 @@ class Creator(BaseCreator):
pre_create_data(dict): Data based on pre creation attributes.
Those may affect how creator works.
"""
# instance = CreatedInstance(
# self.product_type, product_name, instance_data
# )
pass
def get_description(self):
"""Short description of product type and plugin.
@ -734,7 +722,6 @@ class Creator(BaseCreator):
Returns:
str: Short description of product type.
"""
return self.description
def get_detail_description(self):
@ -745,7 +732,6 @@ class Creator(BaseCreator):
Returns:
str: Detailed description of product type for artist.
"""
return self.detailed_description
def get_default_variants(self):
@ -759,7 +745,6 @@ class Creator(BaseCreator):
Returns:
list[str]: Whisper variants for user input.
"""
return copy.deepcopy(self.default_variants)
def get_default_variant(self, only_explicit=False):
@ -779,7 +764,6 @@ class Creator(BaseCreator):
Returns:
str: Variant value.
"""
if only_explicit or self._default_variant:
return self._default_variant
@ -800,7 +784,6 @@ class Creator(BaseCreator):
Returns:
str: Variant value.
"""
return self.get_default_variant()
def _set_default_variant_wrap(self, variant):
@ -812,7 +795,6 @@ class Creator(BaseCreator):
Args:
variant (str): New default variant value.
"""
self._default_variant = variant
default_variant = property(
@ -965,7 +947,6 @@ class AutoCreator(BaseCreator):
def remove_instances(self, instances):
"""Skip removal."""
pass
def discover_creator_plugins(*args, **kwargs):
@ -1023,7 +1004,6 @@ def cache_and_get_instances(creator, shared_key, list_instances_func):
dict[str, dict[str, Any]]: Cached instances by creator identifier from
result of passed function.
"""
if shared_key not in creator.collection_shared_data:
value = collections.defaultdict(list)
for instance in list_instances_func():

View file

@ -1,24 +1,38 @@
"""Functions for handling product names."""
from __future__ import annotations
import warnings
from functools import wraps
from typing import Any, Optional, Union, overload
from warnings import warn
import ayon_api
from ayon_core.lib import (
StringTemplate,
filter_profiles,
prepare_template_data,
Logger,
is_func_signature_supported,
)
from ayon_core.lib.path_templates import TemplateResult
from ayon_core.settings import get_project_settings
from .constants import DEFAULT_PRODUCT_TEMPLATE
from .exceptions import TaskNotSetError, TemplateFillError
log = Logger.get_logger(__name__)
def get_product_name_template(
project_name,
product_type,
task_name,
task_type,
host_name,
default_template=None,
project_settings=None
):
project_name: str,
product_type: str,
task_name: Optional[str],
task_type: Optional[str],
host_name: str,
default_template: Optional[str] = None,
project_settings: Optional[dict[str, Any]] = None,
product_base_type: Optional[str] = None
) -> str:
"""Get product name template based on passed context.
Args:
@ -26,15 +40,21 @@ def get_product_name_template(
product_type (str): Product type for which the product name is
calculated.
host_name (str): Name of host in which the product name is calculated.
task_name (str): Name of task in which context the product is created.
task_type (str): Type of task in which context the product is created.
default_template (Union[str, None]): Default template which is used if
task_name (Optional[str]): Name of task in which context the
product is created.
task_type (Optional[str]): Type of task in which context the
product is created.
default_template (Optional[str]): Default template which is used if
settings won't find any matching possibility. Constant
'DEFAULT_PRODUCT_TEMPLATE' is used if not defined.
project_settings (Union[Dict[str, Any], None]): Prepared settings for
project_settings (Optional[dict[str, Any]]): Prepared settings for
project. Settings are queried if not passed.
"""
product_base_type (Optional[str]): Base type of product.
Returns:
str: Product name template.
"""
if project_settings is None:
project_settings = get_project_settings(project_name)
tools_settings = project_settings["core"]["tools"]
@ -43,9 +63,9 @@ def get_product_name_template(
"product_types": product_type,
"host_names": host_name,
"task_names": task_name,
"task_types": task_type
"task_types": task_type,
"product_base_types": product_base_type,
}
matching_profile = filter_profiles(profiles, filtering_criteria)
template = None
if matching_profile:
@ -69,6 +89,214 @@ def get_product_name_template(
return template
def _get_product_name_old(
project_name: str,
task_name: Optional[str],
task_type: Optional[str],
host_name: str,
product_type: str,
variant: str,
default_template: Optional[str] = None,
dynamic_data: Optional[dict[str, Any]] = None,
project_settings: Optional[dict[str, Any]] = None,
product_type_filter: Optional[str] = None,
project_entity: Optional[dict[str, Any]] = None,
product_base_type: Optional[str] = None,
) -> TemplateResult:
warnings.warn(
"Used deprecated 'task_name' and 'task_type' arguments."
" Please use new signature with 'folder_entity' and 'task_entity'.",
DeprecationWarning,
stacklevel=2
)
if not product_type:
return StringTemplate("").format({})
template = get_product_name_template(
project_name=project_name,
product_type=product_type_filter or product_type,
task_name=task_name,
task_type=task_type,
host_name=host_name,
default_template=default_template,
project_settings=project_settings,
product_base_type=product_base_type,
)
template_low = template.lower()
# Simple check of task name existence for template with {task[name]} in
if not task_name and "{task" in template_low:
raise TaskNotSetError()
task_value = {
"name": task_name,
"type": task_type,
}
if "{task}" in template_low:
task_value = task_name
# NOTE this is message for TDs and Admins -> not really for users
# TODO validate this in settings and not allow it
log.warning(
"Found deprecated task key '{task}' in product name template."
" Please use '{task[name]}' instead."
)
elif "{task[short]}" in template_low:
if project_entity is None:
project_entity = ayon_api.get_project(project_name)
task_types_by_name = {
task["name"]: task for task in
project_entity["taskTypes"]
}
task_short = task_types_by_name.get(task_type, {}).get("shortName")
task_value["short"] = task_short
if not product_base_type and "{product[basetype]}" in template.lower():
warn(
"You have Product base type in product name template, "
"but it is not provided by the creator, please update your "
"creation code to include it. It will be required in "
"the future.",
DeprecationWarning,
stacklevel=2)
fill_pairs: dict[str, Union[str, dict[str, str]]] = {
"variant": variant,
"family": product_type,
"task": task_value,
"product": {
"type": product_type,
"basetype": product_base_type or product_type,
}
}
if dynamic_data:
# Dynamic data may override default values
for key, value in dynamic_data.items():
fill_pairs[key] = value
try:
return StringTemplate.format_strict_template(
template=template,
data=prepare_template_data(fill_pairs)
)
except KeyError as exp:
msg = (
f"Value for {exp} key is missing in template '{template}'."
f" Available values are {fill_pairs}"
)
raise TemplateFillError(msg) from exp
def _backwards_compatibility_product_name(func):
"""Helper to decide which variant of 'get_product_name' to use.
The old version expected 'task_name' and 'task_type' arguments. The new
version expects 'folder_entity' and 'task_entity' arguments instead.
The function is also marked with an attribute 'version' so other addons
can check if the function is using the new signature or is using
the old signature. That should allow addons to adapt to new signature.
>>> if getattr(get_product_name, "use_entities", None):
>>> # New signature is used
>>> path = get_product_name(project_name, folder_entity, ...)
>>> else:
>>> # Old signature is used
>>> path = get_product_name(project_name, taks_name, ...)
"""
# Add attribute to function to identify it as the new function
# so other addons can easily identify it.
# >>> geattr(get_product_name, "use_entities", False)
setattr(func, "use_entities", True)
@wraps(func)
def inner(*args, **kwargs):
# ---
# Decide which variant of the function is used based on
# passed arguments.
# ---
# Entities in key-word arguments mean that the new function is used
if "folder_entity" in kwargs or "task_entity" in kwargs:
return func(*args, **kwargs)
# Using more than 7 positional arguments is not allowed
# in the new function
if len(args) > 7:
return _get_product_name_old(*args, **kwargs)
if len(args) > 1:
arg_2 = args[1]
# The second argument is a string -> task name
if isinstance(arg_2, str):
return _get_product_name_old(*args, **kwargs)
if is_func_signature_supported(func, *args, **kwargs):
return func(*args, **kwargs)
return _get_product_name_old(*args, **kwargs)
return inner
@overload
def get_product_name(
project_name: str,
folder_entity: dict[str, Any],
task_entity: Optional[dict[str, Any]],
product_base_type: str,
product_type: str,
host_name: str,
variant: str,
*,
dynamic_data: Optional[dict[str, Any]] = None,
project_settings: Optional[dict[str, Any]] = None,
project_entity: Optional[dict[str, Any]] = None,
default_template: Optional[str] = None,
product_base_type_filter: Optional[str] = None,
) -> TemplateResult:
"""Calculate product name based on passed context and AYON settings.
Subst name templates are defined in `project_settings/global/tools/creator
/product_name_profiles` where are profiles with host name, product type,
task name and task type filters. If context does not match any profile
then `DEFAULT_PRODUCT_TEMPLATE` is used as default template.
That's main reason why so many arguments are required to calculate product
name.
Args:
project_name (str): Project name.
folder_entity (Optional[dict[str, Any]]): Folder entity.
task_entity (Optional[dict[str, Any]]): Task entity.
host_name (str): Host name.
product_base_type (str): Product base type.
product_type (str): Product type.
variant (str): In most of the cases it is user input during creation.
dynamic_data (Optional[dict[str, Any]]): Dynamic data specific for
a creator which creates instance.
project_settings (Optional[dict[str, Any]]): Prepared settings
for project. Settings are queried if not passed.
project_entity (Optional[dict[str, Any]]): Project entity used when
task short name is required by template.
default_template (Optional[str]): Default template if any profile does
not match passed context. Constant 'DEFAULT_PRODUCT_TEMPLATE'
is used if is not passed.
product_base_type_filter (Optional[str]): Use different product base
type for product template filtering. Value of
`product_base_type_filter` is used when not passed.
Returns:
TemplateResult: Product name.
Raises:
TaskNotSetError: If template requires task which is not provided.
TemplateFillError: If filled template contains placeholder key which
is not collected.
"""
@overload
def get_product_name(
project_name,
task_name,
@ -81,25 +309,25 @@ def get_product_name(
project_settings=None,
product_type_filter=None,
project_entity=None,
):
) -> TemplateResult:
"""Calculate product name based on passed context and AYON settings.
Subst name templates are defined in `project_settings/global/tools/creator
/product_name_profiles` where are profiles with host name, product type,
task name and task type filters. If context does not match any profile
then `DEFAULT_PRODUCT_TEMPLATE` is used as default template.
Product name templates are defined in `project_settings/global/tools
/creator/product_name_profiles` where are profiles with host name,
product type, task name and task type filters. If context does not match
any profile then `DEFAULT_PRODUCT_TEMPLATE` is used as default template.
That's main reason why so many arguments are required to calculate product
name.
Todos:
Find better filtering options to avoid requirement of
argument 'family_filter'.
Deprecated:
This function is using deprecated signature that does not support
folder entity data to be used.
Args:
project_name (str): Project name.
task_name (Union[str, None]): Task name.
task_type (Union[str, None]): Task type.
task_name (Optional[str]): Task name.
task_type (Optional[str]): Task type.
host_name (str): Host name.
product_type (str): Product type.
variant (str): In most of the cases it is user input during creation.
@ -117,7 +345,63 @@ def get_product_name(
task short name is required by template.
Returns:
str: Product name.
TemplateResult: Product name.
"""
pass
@_backwards_compatibility_product_name
def get_product_name(
project_name: str,
folder_entity: dict[str, Any],
task_entity: Optional[dict[str, Any]],
product_base_type: str,
product_type: str,
host_name: str,
variant: str,
*,
dynamic_data: Optional[dict[str, Any]] = None,
project_settings: Optional[dict[str, Any]] = None,
project_entity: Optional[dict[str, Any]] = None,
default_template: Optional[str] = None,
product_base_type_filter: Optional[str] = None,
) -> TemplateResult:
"""Calculate product name based on passed context and AYON settings.
Product name templates are defined in `project_settings/global/tools
/creator/product_name_profiles` where are profiles with host name,
product base type, product type, task name and task type filters.
If context does not match any profile then `DEFAULT_PRODUCT_TEMPLATE`
is used as default template.
That's main reason why so many arguments are required to calculate product
name.
Args:
project_name (str): Project name.
folder_entity (Optional[dict[str, Any]]): Folder entity.
task_entity (Optional[dict[str, Any]]): Task entity.
host_name (str): Host name.
product_base_type (str): Product base type.
product_type (str): Product type.
variant (str): In most of the cases it is user input during creation.
dynamic_data (Optional[dict[str, Any]]): Dynamic data specific for
a creator which creates instance.
project_settings (Optional[dict[str, Any]]): Prepared settings
for project. Settings are queried if not passed.
project_entity (Optional[dict[str, Any]]): Project entity used when
task short name is required by template.
default_template (Optional[str]): Default template if any profile does
not match passed context. Constant 'DEFAULT_PRODUCT_TEMPLATE'
is used if is not passed.
product_base_type_filter (Optional[str]): Use different product base
type for product template filtering. Value of
`product_base_type_filter` is used when not passed.
Returns:
TemplateResult: Product name.
Raises:
TaskNotSetError: If template requires task which is not provided.
@ -126,47 +410,68 @@ def get_product_name(
"""
if not product_type:
return ""
return StringTemplate("").format({})
task_name = task_type = None
if task_entity:
task_name = task_entity["name"]
task_type = task_entity["taskType"]
template = get_product_name_template(
project_name,
product_type_filter or product_type,
task_name,
task_type,
host_name,
project_name=project_name,
product_base_type=product_base_type_filter or product_base_type,
product_type=product_type,
task_name=task_name,
task_type=task_type,
host_name=host_name,
default_template=default_template,
project_settings=project_settings
project_settings=project_settings,
)
# 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():
template_low = template.lower()
# Simple check of task name existence for template with {task[name]} in
if not task_name and "{task" in template_low:
raise TaskNotSetError()
task_value = {
"name": task_name,
"type": task_type,
}
if "{task}" in template.lower():
if "{task}" in template_low:
task_value = task_name
# NOTE this is message for TDs and Admins -> not really for users
# TODO validate this in settings and not allow it
log.warning(
"Found deprecated task key '{task}' in product name template."
" Please use '{task[name]}' instead."
)
elif "{task[short]}" in template.lower():
elif "{task[short]}" in template_low:
if project_entity is None:
project_entity = ayon_api.get_project(project_name)
task_types_by_name = {
task["name"]: task for task in
project_entity["taskTypes"]
task["name"]: task
for task in project_entity["taskTypes"]
}
task_short = task_types_by_name.get(task_type, {}).get("shortName")
task_value["short"] = task_short
fill_pairs = {
"variant": variant,
# TODO We should stop support 'family' key.
"family": product_type,
"task": task_value,
"product": {
"type": product_type
"type": product_type,
"basetype": product_base_type,
}
}
if folder_entity:
fill_pairs["folder"] = {
"name": folder_entity["name"],
"type": folder_entity["folderType"],
}
if dynamic_data:
# Dynamic data may override default values
for key, value in dynamic_data.items():
@ -178,7 +483,8 @@ def get_product_name(
data=prepare_template_data(fill_pairs)
)
except KeyError as exp:
raise TemplateFillError(
"Value for {} key is missing in template '{}'."
" Available values are {}".format(str(exp), template, fill_pairs)
msg = (
f"Value for {exp} key is missing in template '{template}'."
f" Available values are {fill_pairs}"
)
raise TemplateFillError(msg)

View file

@ -11,6 +11,8 @@ from ayon_core.lib.attribute_definitions import (
serialize_attr_defs,
deserialize_attr_defs,
)
from ayon_core.pipeline import (
AYON_INSTANCE_ID,
AVALON_INSTANCE_ID,
@ -480,6 +482,10 @@ class CreatedInstance:
data (Dict[str, Any]): Data used for filling product name or override
data from already existing instance.
creator (BaseCreator): Creator responsible for instance.
product_base_type (Optional[str]): Product base type that will be
created. If not provided then product base type is taken from
creator plugin. If creator does not have product base type then
deprecation warning is raised.
"""
# Keys that can't be changed or removed from data after loading using
@ -490,6 +496,7 @@ class CreatedInstance:
"id",
"instance_id",
"productType",
"productBaseType",
"creator_identifier",
"creator_attributes",
"publish_attributes"
@ -509,7 +516,13 @@ class CreatedInstance:
data: Dict[str, Any],
creator: "BaseCreator",
transient_data: Optional[Dict[str, Any]] = None,
product_base_type: Optional[str] = None
):
"""Initialize CreatedInstance."""
# fallback to product type for backward compatibility
if not product_base_type:
product_base_type = creator.product_base_type or product_type
self._creator = creator
creator_identifier = creator.identifier
group_label = creator.get_group_label()
@ -562,6 +575,9 @@ class CreatedInstance:
self._data["id"] = item_id
self._data["productType"] = product_type
self._data["productName"] = product_name
self._data["productBaseType"] = product_base_type
self._data["active"] = data.get("active", True)
self._data["creator_identifier"] = creator_identifier

View file

@ -122,7 +122,8 @@ def get_publish_template_name(
task_type,
project_settings=None,
hero=False,
logger=None
product_base_type: Optional[str] = None,
logger=None,
):
"""Get template name which should be used for passed context.
@ -140,17 +141,29 @@ def get_publish_template_name(
task_type (str): Task type on which is instance working.
project_settings (Dict[str, Any]): Prepared project settings.
hero (bool): Template is for hero version publishing.
product_base_type (Optional[str]): Product type for which should
be found template.
logger (logging.Logger): Custom logger used for 'filter_profiles'
function.
Returns:
str: Template name which should be used for integration.
"""
if not product_base_type:
msg = (
"Argument 'product_base_type' is not provided to"
" 'get_publish_template_name' function. This argument"
" will be required in future versions."
)
warnings.warn(msg, DeprecationWarning)
if logger:
logger.warning(msg)
template = None
filter_criteria = {
"hosts": host_name,
"product_types": product_type,
"product_base_types": product_base_type,
"task_names": task_name,
"task_types": task_type,
}
@ -179,7 +192,9 @@ class HelpContent:
self.detail = detail
def load_help_content_from_filepath(filepath):
def load_help_content_from_filepath(
filepath: str
) -> dict[str, dict[str, HelpContent]]:
"""Load help content from xml file.
Xml file may contain errors and warnings.
"""
@ -214,15 +229,20 @@ def load_help_content_from_filepath(filepath):
return output
def load_help_content_from_plugin(plugin):
def load_help_content_from_plugin(
plugin: pyblish.api.Plugin,
help_filename: Optional[str] = None,
) -> dict[str, dict[str, HelpContent]]:
cls = plugin
if not inspect.isclass(plugin):
cls = plugin.__class__
plugin_filepath = inspect.getfile(cls)
plugin_dir = os.path.dirname(plugin_filepath)
basename = os.path.splitext(os.path.basename(plugin_filepath))[0]
filename = basename + ".xml"
filepath = os.path.join(plugin_dir, "help", filename)
if help_filename is None:
basename = os.path.splitext(os.path.basename(plugin_filepath))[0]
help_filename = basename + ".xml"
filepath = os.path.join(plugin_dir, "help", help_filename)
return load_help_content_from_filepath(filepath)

View file

@ -1,7 +1,7 @@
import inspect
from abc import ABCMeta
import typing
from typing import Optional
from typing import Optional, Any
import pyblish.api
import pyblish.logic
@ -82,22 +82,51 @@ class PublishValidationError(PublishError):
class PublishXmlValidationError(PublishValidationError):
"""Raise an error from a dedicated xml file.
Can be useful to have one xml file with different possible messages that
helps to avoid flood code with dedicated artist messages.
XML files should live relative to the plugin file location:
'{plugin dir}/help/some_plugin.xml'.
Args:
plugin (pyblish.api.Plugin): Plugin that raised an error. Is used
to get path to xml file.
message (str): Exception message, can be technical, is used for
console output.
key (Optional[str]): XML file can contain multiple error messages, key
is used to get one of them. By default is used 'main'.
formatting_data (Optional[dict[str, Any]): Error message can have
variables to fill.
help_filename (Optional[str]): Name of xml file with messages. By
default, is used filename where plugin lives with .xml extension.
"""
def __init__(
self, plugin, message, key=None, formatting_data=None
):
self,
plugin: pyblish.api.Plugin,
message: str,
key: Optional[str] = None,
formatting_data: Optional[dict[str, Any]] = None,
help_filename: Optional[str] = None,
) -> None:
if key is None:
key = "main"
if not formatting_data:
formatting_data = {}
result = load_help_content_from_plugin(plugin)
result = load_help_content_from_plugin(plugin, help_filename)
content_obj = result["errors"][key]
description = content_obj.description.format(**formatting_data)
detail = content_obj.detail
if detail:
detail = detail.format(**formatting_data)
super(PublishXmlValidationError, self).__init__(
message, content_obj.title, description, detail
super().__init__(
message,
content_obj.title,
description,
detail
)

View file

@ -172,20 +172,33 @@ class ExtractOIIOTranscode(publish.Extractor):
additional_command_args = (output_def["oiiotool_args"]
["additional_command_args"])
sequence_files = self._translate_to_sequence(files_to_convert)
sequence_files = self._translate_to_sequence(
files_to_convert)
self.log.debug("Files to convert: {}".format(sequence_files))
missing_rgba_review_channels = False
for file_name in sequence_files:
if isinstance(file_name, clique.Collection):
# Convert to filepath that can be directly converted
# by oiio like `frame.1001-1025%04d.exr`
file_name: str = file_name.format(
"{head}{range}{padding}{tail}"
# Support sequences with holes by supplying
# dedicated `--frames` argument to `oiiotool`
# Create `frames` string like "1001-1002,1004,1010-1012
# Create `filename` string like "file.#.exr"
frames = file_name.format("{ranges}").replace(" ", "")
frame_padding = file_name.padding
file_name = file_name.format("{head}#{tail}")
parallel_frames = True
elif isinstance(file_name, str):
# Single file
frames = None
frame_padding = None
parallel_frames = False
else:
raise TypeError(
f"Unsupported file name type: {type(file_name)}."
" Expected str or clique.Collection."
)
self.log.debug("Transcoding file: `{}`".format(file_name))
input_path = os.path.join(original_staging_dir,
file_name)
input_path = os.path.join(original_staging_dir, file_name)
output_path = self._get_output_file_path(input_path,
new_staging_dir,
output_extension)
@ -201,6 +214,9 @@ class ExtractOIIOTranscode(publish.Extractor):
source_display=source_display,
source_view=source_view,
additional_command_args=additional_command_args,
frames=frames,
frame_padding=frame_padding,
parallel_frames=parallel_frames,
logger=self.log
)
except MissingRGBAChannelsError as exc:
@ -294,16 +310,18 @@ class ExtractOIIOTranscode(publish.Extractor):
new_repre["files"] = renamed_files
def _translate_to_sequence(self, files_to_convert):
"""Returns original list or a clique.Collection of a sequence.
"""Returns original individual filepaths or list of clique.Collection.
Uses clique to find frame sequence Collection.
If sequence not found, it returns original list.
Uses clique to find frame sequence, and return the collections instead.
If sequence not detected in input filenames, it returns original list.
Args:
files_to_convert (list): list of file names
files_to_convert (list[str]): list of file names
Returns:
list[str | clique.Collection]: List of filepaths or a list
of Collections (usually one, unless there are holes)
list[str | clique.Collection]: List of
filepaths ['fileA.exr', 'fileB.exr']
or clique.Collection for a sequence.
"""
pattern = [clique.PATTERNS["frames"]]
collections, _ = clique.assemble(
@ -314,14 +332,7 @@ class ExtractOIIOTranscode(publish.Extractor):
raise ValueError(
"Too many collections {}".format(collections))
collection = collections[0]
# TODO: Technically oiiotool supports holes in the sequence as well
# using the dedicated --frames argument to specify the frames.
# We may want to use that too so conversions of sequences with
# holes will perform faster as well.
# Separate the collection so that we have no holes/gaps per
# collection.
return collection.separate()
return collections
return files_to_convert

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>{upload_type} upload timed out</title>
<description>
## {upload_type} upload failed after retries
The connection to the AYON server timed out while uploading a file.
### How to resolve?
1. Try publishing again. Intermittent network hiccups often resolve on retry.
2. Ensure your network/VPN is stable and large uploads are allowed.
3. If it keeps failing, try again later or contact your admin.
<pre>File: {file}
Error: {error}</pre>
</description>
</error>
</root>

View file

@ -28,6 +28,7 @@ from ayon_core.pipeline.publish import (
KnownPublishError,
get_publish_template_name,
)
from ayon_core.pipeline import is_product_base_type_supported
log = logging.getLogger(__name__)
@ -367,6 +368,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
folder_entity = instance.data["folderEntity"]
product_name = instance.data["productName"]
product_type = instance.data["productType"]
product_base_type = instance.data.get("productBaseType")
self.log.debug("Product: {}".format(product_name))
# Get existing product if it exists
@ -394,14 +397,33 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
product_id = None
if existing_product_entity:
product_id = existing_product_entity["id"]
product_entity = new_product_entity(
product_name,
product_type,
folder_entity["id"],
data=data,
attribs=attributes,
entity_id=product_id
)
new_product_entity_kwargs = {
"name": product_name,
"product_type": product_type,
"folder_id": folder_entity["id"],
"data": data,
"attribs": attributes,
"entity_id": product_id,
"product_base_type": product_base_type,
}
if not is_product_base_type_supported():
new_product_entity_kwargs.pop("product_base_type")
if (
product_base_type is not None
and product_base_type != product_type):
self.log.warning((
"Product base type %s is not supported by the server, "
"but it's defined - and it differs from product type %s. "
"Using product base type as product type."
), product_base_type, product_type)
new_product_entity_kwargs["product_type"] = (
product_base_type
)
product_entity = new_product_entity(**new_product_entity_kwargs)
if existing_product_entity is None:
# Create a new product
@ -902,8 +924,12 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
# Include optional data if present in
optionals = [
"frameStart", "frameEnd", "step",
"handleEnd", "handleStart", "sourceHashes"
"frameStart", "frameEnd",
"handleEnd", "handleStart",
"step",
"resolutionWidth", "resolutionHeight",
"pixelAspect",
"sourceHashes"
]
for key in optionals:
if key in instance.data:
@ -927,6 +953,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
host_name = context.data["hostName"]
anatomy_data = instance.data["anatomyData"]
product_type = instance.data["productType"]
product_base_type = instance.data.get("productBaseType")
task_info = anatomy_data.get("task") or {}
return get_publish_template_name(
@ -936,7 +963,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
task_name=task_info.get("name"),
task_type=task_info.get("type"),
project_settings=context.data["project_settings"],
logger=self.log
logger=self.log,
product_base_type=product_base_type
)
def get_rootless_path(self, anatomy, path):

View file

@ -1,11 +1,17 @@
import os
import time
import pyblish.api
import ayon_api
from ayon_api import TransferProgress
from ayon_api.server_api import RequestTypes
import pyblish.api
from ayon_core.lib import get_media_mime_type
from ayon_core.pipeline.publish import get_publish_repre_path
from ayon_core.lib import get_media_mime_type, format_file_size
from ayon_core.pipeline.publish import (
PublishXmlValidationError,
get_publish_repre_path,
)
import requests.exceptions
class IntegrateAYONReview(pyblish.api.InstancePlugin):
@ -44,7 +50,7 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin):
if "webreview" not in repre_tags:
continue
# exclude representations with are going to be published on farm
# exclude representations going to be published on farm
if "publish_on_farm" in repre_tags:
continue
@ -75,18 +81,13 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin):
f"/projects/{project_name}"
f"/versions/{version_id}/reviewables{query}"
)
filename = os.path.basename(repre_path)
# Upload the reviewable
self.log.info(f"Uploading reviewable '{label or filename}' ...")
headers = ayon_con.get_headers(content_type)
headers["x-file-name"] = filename
self.log.info(f"Uploading reviewable {repre_path}")
ayon_con.upload_file(
# Upload with retries and clear help if it keeps failing
self._upload_with_retries(
ayon_con,
endpoint,
repre_path,
headers=headers,
request_type=RequestTypes.post,
content_type,
)
def _get_review_label(self, repre, uploaded_labels):
@ -100,3 +101,74 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin):
idx += 1
label = f"{orig_label}_{idx}"
return label
def _upload_with_retries(
self,
ayon_con: ayon_api.ServerAPI,
endpoint: str,
repre_path: str,
content_type: str,
):
"""Upload file with simple retries."""
filename = os.path.basename(repre_path)
headers = ayon_con.get_headers(content_type)
headers["x-file-name"] = filename
max_retries = ayon_con.get_default_max_retries()
# Retries are already implemented in 'ayon_api.upload_file'
# - added in ayon api 1.2.7
if hasattr(TransferProgress, "get_attempt"):
max_retries = 1
size = os.path.getsize(repre_path)
self.log.info(
f"Uploading '{repre_path}' (size: {format_file_size(size)})"
)
# How long to sleep before next attempt
wait_time = 1
last_error = None
for attempt in range(max_retries):
attempt += 1
start = time.time()
try:
output = ayon_con.upload_file(
endpoint,
repre_path,
headers=headers,
request_type=RequestTypes.post,
)
self.log.debug(f"Uploaded in {time.time() - start}s.")
return output
except (
requests.exceptions.Timeout,
requests.exceptions.ConnectionError
) as exc:
# Log and retry with backoff if attempts remain
if attempt >= max_retries:
last_error = exc
break
self.log.warning(
f"Review upload failed ({attempt}/{max_retries})"
f" after {time.time() - start}s."
f" Retrying in {wait_time}s...",
exc_info=True,
)
time.sleep(wait_time)
# Exhausted retries - raise a user-friendly validation error with help
raise PublishXmlValidationError(
self,
(
"Upload of reviewable timed out or failed after multiple"
" attempts. Please try publishing again."
),
formatting_data={
"upload_type": "Review",
"file": repre_path,
"error": str(last_error),
},
help_filename="upload_file.xml",
)

View file

@ -24,11 +24,16 @@
import os
import collections
import time
import pyblish.api
import ayon_api
from ayon_api import RequestTypes
from ayon_api import RequestTypes, TransferProgress
from ayon_api.operations import OperationsSession
import pyblish.api
import requests
from ayon_core.lib import get_media_mime_type, format_file_size
from ayon_core.pipeline.publish import PublishXmlValidationError
InstanceFilterResult = collections.namedtuple(
@ -164,25 +169,17 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin):
return os.path.normpath(filled_path)
def _create_thumbnail(self, project_name: str, src_filepath: str) -> str:
"""Upload thumbnail to AYON and return its id.
This is temporary fix of 'create_thumbnail' function in ayon_api to
fix jpeg mime type.
"""
mime_type = None
with open(src_filepath, "rb") as stream:
if b"\xff\xd8\xff" == stream.read(3):
mime_type = "image/jpeg"
"""Upload thumbnail to AYON and return its id."""
mime_type = get_media_mime_type(src_filepath)
if mime_type is None:
return ayon_api.create_thumbnail(project_name, src_filepath)
return ayon_api.create_thumbnail(
project_name, src_filepath
)
response = ayon_api.upload_file(
response = self._upload_with_retries(
f"projects/{project_name}/thumbnails",
src_filepath,
request_type=RequestTypes.post,
headers={"Content-Type": mime_type},
mime_type,
)
response.raise_for_status()
return response.json()["id"]
@ -248,3 +245,71 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin):
or instance.data.get("name")
or "N/A"
)
def _upload_with_retries(
self,
endpoint: str,
repre_path: str,
content_type: str,
):
"""Upload file with simple retries."""
ayon_con = ayon_api.get_server_api_connection()
headers = ayon_con.get_headers(content_type)
max_retries = ayon_con.get_default_max_retries()
# Retries are already implemented in 'ayon_api.upload_file'
# - added in ayon api 1.2.7
if hasattr(TransferProgress, "get_attempt"):
max_retries = 1
size = os.path.getsize(repre_path)
self.log.info(
f"Uploading '{repre_path}' (size: {format_file_size(size)})"
)
# How long to sleep before next attempt
wait_time = 1
last_error = None
for attempt in range(max_retries):
attempt += 1
start = time.time()
try:
output = ayon_con.upload_file(
endpoint,
repre_path,
headers=headers,
request_type=RequestTypes.post,
)
self.log.debug(f"Uploaded in {time.time() - start}s.")
return output
except (
requests.exceptions.Timeout,
requests.exceptions.ConnectionError
) as exc:
# Log and retry with backoff if attempts remain
if attempt >= max_retries:
last_error = exc
break
self.log.warning(
f"Review upload failed ({attempt}/{max_retries})"
f" after {time.time() - start}s."
f" Retrying in {wait_time}s...",
exc_info=True,
)
time.sleep(wait_time)
# Exhausted retries - raise a user-friendly validation error with help
raise PublishXmlValidationError(
self,
(
"Upload of thumbnail timed out or failed after multiple"
" attempts. Please try publishing again."
),
formatting_data={
"upload_type": "Thumbnail",
"file": repre_path,
"error": str(last_error),
},
help_filename="upload_file.xml",
)

View file

@ -112,6 +112,7 @@ class HierarchyPage(QtWidgets.QWidget):
self._is_visible = False
self._controller = controller
self._filters_widget = filters_widget
self._btn_back = btn_back
self._projects_combobox = projects_combobox
self._folders_widget = folders_widget
@ -136,6 +137,10 @@ class HierarchyPage(QtWidgets.QWidget):
self._folders_widget.refresh()
self._tasks_widget.refresh()
self._workfiles_page.refresh()
# Update my tasks
self._on_my_tasks_checkbox_state_changed(
self._filters_widget.is_my_tasks_checked()
)
def _on_back_clicked(self):
self._controller.set_selected_project(None)
@ -155,6 +160,7 @@ class HierarchyPage(QtWidgets.QWidget):
)
folder_ids = entity_ids["folder_ids"]
task_ids = entity_ids["task_ids"]
self._folders_widget.set_folder_ids_filter(folder_ids)
self._tasks_widget.set_task_ids_filter(task_ids)

View file

@ -527,6 +527,10 @@ class LoaderWindow(QtWidgets.QWidget):
if not self._refresh_handler.project_refreshed:
self._projects_combobox.refresh()
self._update_filters()
# Update my tasks
self._on_my_tasks_checkbox_state_changed(
self._filters_widget.is_my_tasks_checked()
)
def _on_load_finished(self, event):
error_info = event["error_info"]

View file

@ -35,6 +35,7 @@ from ayon_core.pipeline.create import (
ConvertorsOperationFailed,
ConvertorItem,
)
from ayon_core.tools.publisher.abstract import (
AbstractPublisherBackend,
CardMessageTypes,

View file

@ -221,6 +221,7 @@ class CreateContextWidget(QtWidgets.QWidget):
filters_widget.text_changed.connect(self._on_folder_filter_change)
filters_widget.my_tasks_changed.connect(self._on_my_tasks_change)
self._filters_widget = filters_widget
self._current_context_btn = current_context_btn
self._folders_widget = folders_widget
self._tasks_widget = tasks_widget
@ -290,6 +291,10 @@ class CreateContextWidget(QtWidgets.QWidget):
self._hierarchy_controller.set_expected_selection(
self._last_project_name, folder_id, task_name
)
# Update my tasks
self._on_my_tasks_change(
self._filters_widget.is_my_tasks_checked()
)
def _clear_selection(self):
self._folders_widget.set_selected_folder(None)

View file

@ -310,9 +310,6 @@ class CreateWidget(QtWidgets.QWidget):
folder_path = None
if self._context_change_is_enabled():
folder_path = self._context_widget.get_selected_folder_path()
if folder_path is None:
folder_path = self.get_current_folder_path()
return folder_path or None
def _get_folder_id(self):
@ -328,9 +325,6 @@ class CreateWidget(QtWidgets.QWidget):
folder_path = self._context_widget.get_selected_folder_path()
if folder_path:
task_name = self._context_widget.get_selected_task_name()
if not task_name:
task_name = self.get_current_task_name()
return task_name
def _set_context_enabled(self, enabled):

View file

@ -113,6 +113,7 @@ class FoldersDialog(QtWidgets.QDialog):
self._soft_reset_enabled = False
self._folders_widget.set_project_name(self._project_name)
self._on_my_tasks_change(self._filters_widget.is_my_tasks_checked())
def get_selected_folder_path(self):
"""Get selected folder path."""

View file

@ -834,6 +834,12 @@ class FoldersFiltersWidget(QtWidgets.QWidget):
self._folders_filter_input = folders_filter_input
self._my_tasks_checkbox = my_tasks_checkbox
def is_my_tasks_checked(self) -> bool:
return self._my_tasks_checkbox.isChecked()
def text(self) -> str:
return self._folders_filter_input.text()
def set_text(self, text: str) -> None:
self._folders_filter_input.setText(text)

View file

@ -205,6 +205,8 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
self._folders_widget = folder_widget
self._filters_widget = filters_widget
return col_widget
def _create_col_3_widget(self, controller, parent):
@ -343,6 +345,10 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
self._project_name = self._controller.get_current_project_name()
self._folders_widget.set_project_name(self._project_name)
# Update my tasks
self._on_my_tasks_checkbox_state_changed(
self._filters_widget.is_my_tasks_checked()
)
def _on_save_as_finished(self, event):
if event["failed"]:

View file

@ -12,6 +12,7 @@ ayon_server_version = ">=1.8.4,<2.0.0"
ayon_launcher_version = ">=1.0.2"
ayon_required_addons = {}
ayon_compatible_addons = {
"ayon_third_party": ">=1.3.0",
"ayon_ocio": ">=1.2.1",
"applications": ">=1.1.2",
"harmony": ">0.4.0",

View file

@ -0,0 +1,333 @@
"""Tests for product_name helpers."""
import pytest
from unittest.mock import patch
from ayon_core.pipeline.create.product_name import (
get_product_name_template,
get_product_name,
)
from ayon_core.pipeline.create.constants import DEFAULT_PRODUCT_TEMPLATE
from ayon_core.pipeline.create.exceptions import (
TaskNotSetError,
TemplateFillError,
)
class TestGetProductNameTemplate:
@patch("ayon_core.pipeline.create.product_name.get_project_settings")
@patch("ayon_core.pipeline.create.product_name.filter_profiles")
def test_matching_profile_with_replacements(
self,
mock_filter_profiles,
mock_get_settings,
):
"""Matching profile applies legacy replacement tokens."""
mock_get_settings.return_value = {
"core": {"tools": {"creator": {"product_name_profiles": []}}}
}
# The function should replace {task}/{family}/{asset} variants
mock_filter_profiles.return_value = {
"template": ("{task}-{Task}-{TASK}-{family}-{Family}"
"-{FAMILY}-{asset}-{Asset}-{ASSET}")
}
result = get_product_name_template(
project_name="proj",
product_type="model",
task_name="modeling",
task_type="Modeling",
host_name="maya",
)
assert result == (
"{task[name]}-{Task[name]}-{TASK[NAME]}-"
"{product[type]}-{Product[type]}-{PRODUCT[TYPE]}-"
"{folder[name]}-{Folder[name]}-{FOLDER[NAME]}"
)
@patch("ayon_core.pipeline.create.product_name.get_project_settings")
@patch("ayon_core.pipeline.create.product_name.filter_profiles")
def test_no_matching_profile_uses_default(
self,
mock_filter_profiles,
mock_get_settings,
):
mock_get_settings.return_value = {
"core": {"tools": {"creator": {"product_name_profiles": []}}}
}
mock_filter_profiles.return_value = None
assert (
get_product_name_template(
project_name="proj",
product_type="model",
task_name="modeling",
task_type="Modeling",
host_name="maya",
)
== DEFAULT_PRODUCT_TEMPLATE
)
@patch("ayon_core.pipeline.create.product_name.get_project_settings")
@patch("ayon_core.pipeline.create.product_name.filter_profiles")
def test_custom_default_template_used(
self,
mock_filter_profiles,
mock_get_settings,
):
mock_get_settings.return_value = {
"core": {"tools": {"creator": {"product_name_profiles": []}}}
}
mock_filter_profiles.return_value = None
custom_default = "{variant}_{family}"
assert (
get_product_name_template(
project_name="proj",
product_type="model",
task_name="modeling",
task_type="Modeling",
host_name="maya",
default_template=custom_default,
)
== custom_default
)
@patch("ayon_core.pipeline.create.product_name.get_project_settings")
@patch("ayon_core.pipeline.create.product_name.filter_profiles")
def test_product_base_type_added_to_filtering_when_provided(
self,
mock_filter_profiles,
mock_get_settings,
):
mock_get_settings.return_value = {
"core": {"tools": {"creator": {"product_name_profiles": []}}}
}
mock_filter_profiles.return_value = None
get_product_name_template(
project_name="proj",
product_type="model",
task_name="modeling",
task_type="Modeling",
host_name="maya",
product_base_type="asset",
)
args, kwargs = mock_filter_profiles.call_args
# args[1] is filtering_criteria
assert args[1]["product_base_types"] == "asset"
class TestGetProductName:
@patch("ayon_core.pipeline.create.product_name.get_product_name_template")
@patch("ayon_core.pipeline.create.product_name."
"StringTemplate.format_strict_template")
@patch("ayon_core.pipeline.create.product_name.prepare_template_data")
def test_empty_product_type_returns_empty(
self, mock_prepare, mock_format, mock_get_tmpl
):
assert (
get_product_name(
project_name="proj",
task_name="modeling",
task_type="Modeling",
host_name="maya",
product_type="",
variant="Main",
)
== ""
)
mock_get_tmpl.assert_not_called()
mock_format.assert_not_called()
mock_prepare.assert_not_called()
@patch("ayon_core.pipeline.create.product_name.get_product_name_template")
@patch("ayon_core.pipeline.create.product_name."
"StringTemplate.format_strict_template")
@patch("ayon_core.pipeline.create.product_name.prepare_template_data")
def test_happy_path(
self, mock_prepare, mock_format, mock_get_tmpl
):
mock_get_tmpl.return_value = "{task[name]}_{product[type]}_{variant}"
mock_prepare.return_value = {
"task": {"name": "modeling"},
"product": {"type": "model"},
"variant": "Main",
"family": "model",
}
mock_format.return_value = "modeling_model_Main"
result = get_product_name(
project_name="proj",
task_name="modeling",
task_type="Modeling",
host_name="maya",
product_type="model",
variant="Main",
)
assert result == "modeling_model_Main"
mock_get_tmpl.assert_called_once()
mock_prepare.assert_called_once()
mock_format.assert_called_once()
@patch("ayon_core.pipeline.create.product_name.get_product_name_template")
@patch("ayon_core.pipeline.create.product_name."
"StringTemplate.format_strict_template")
@patch("ayon_core.pipeline.create.product_name.prepare_template_data")
def test_product_name_with_base_type(
self, mock_prepare, mock_format, mock_get_tmpl
):
mock_get_tmpl.return_value = (
"{task[name]}_{product[basetype]}_{variant}"
)
mock_prepare.return_value = {
"task": {"name": "modeling"},
"product": {"type": "model"},
"variant": "Main",
"family": "model",
}
mock_format.return_value = "modeling_modelBase_Main"
result = get_product_name(
project_name="proj",
task_name="modeling",
task_type="Modeling",
host_name="maya",
product_type="model",
product_base_type="modelBase",
variant="Main",
)
assert result == "modeling_modelBase_Main"
mock_get_tmpl.assert_called_once()
mock_prepare.assert_called_once()
mock_format.assert_called_once()
@patch("ayon_core.pipeline.create.product_name.get_product_name_template")
def test_task_required_but_missing_raises(self, mock_get_tmpl):
mock_get_tmpl.return_value = "{task[name]}_{variant}"
with pytest.raises(TaskNotSetError):
get_product_name(
project_name="proj",
task_name="",
task_type="Modeling",
host_name="maya",
product_type="model",
variant="Main",
)
@patch("ayon_core.pipeline.create.product_name.get_product_name_template")
@patch("ayon_core.pipeline.create.product_name.ayon_api.get_project")
@patch("ayon_core.pipeline.create.product_name.StringTemplate."
"format_strict_template")
@patch("ayon_core.pipeline.create.product_name.prepare_template_data")
def test_task_short_name_is_used(
self, mock_prepare, mock_format, mock_get_project, mock_get_tmpl
):
mock_get_tmpl.return_value = "{task[short]}_{variant}"
mock_get_project.return_value = {
"taskTypes": [{"name": "Modeling", "shortName": "mdl"}]
}
mock_prepare.return_value = {
"task": {
"short": "mdl"
},
"variant": "Main"
}
mock_format.return_value = "mdl_Main"
result = get_product_name(
project_name="proj",
task_name="modeling",
task_type="Modeling",
host_name="maya",
product_type="model",
variant="Main",
)
assert result == "mdl_Main"
@patch("ayon_core.pipeline.create.product_name.get_product_name_template")
@patch("ayon_core.pipeline.create.product_name.StringTemplate."
"format_strict_template")
@patch("ayon_core.pipeline.create.product_name.prepare_template_data")
def test_template_fill_error_translated(
self, mock_prepare, mock_format, mock_get_tmpl
):
mock_get_tmpl.return_value = "{missing_key}_{variant}"
mock_prepare.return_value = {"variant": "Main"}
mock_format.side_effect = KeyError("missing_key")
with pytest.raises(TemplateFillError):
get_product_name(
project_name="proj",
task_name="modeling",
task_type="Modeling",
host_name="maya",
product_type="model",
variant="Main",
)
@patch("ayon_core.pipeline.create.product_name.warn")
@patch("ayon_core.pipeline.create.product_name.get_product_name_template")
@patch("ayon_core.pipeline.create.product_name."
"StringTemplate.format_strict_template")
@patch("ayon_core.pipeline.create.product_name.prepare_template_data")
def test_warns_when_template_needs_base_type_but_missing(
self,
mock_prepare,
mock_format,
mock_get_tmpl,
mock_warn,
):
mock_get_tmpl.return_value = "{product[basetype]}_{variant}"
mock_prepare.return_value = {
"product": {"type": "model"},
"variant": "Main",
"family": "model",
}
mock_format.return_value = "asset_Main"
_ = get_product_name(
project_name="proj",
task_name="modeling",
task_type="Modeling",
host_name="maya",
product_type="model",
variant="Main",
)
mock_warn.assert_called_once()
@patch("ayon_core.pipeline.create.product_name.get_product_name_template")
@patch("ayon_core.pipeline.create.product_name."
"StringTemplate.format_strict_template")
@patch("ayon_core.pipeline.create.product_name.prepare_template_data")
def test_dynamic_data_overrides_defaults(
self, mock_prepare, mock_format, mock_get_tmpl
):
mock_get_tmpl.return_value = "{custom}_{variant}"
mock_prepare.return_value = {"custom": "overridden", "variant": "Main"}
mock_format.return_value = "overridden_Main"
result = get_product_name(
project_name="proj",
task_name="modeling",
task_type="Modeling",
host_name="maya",
product_type="model",
variant="Main",
dynamic_data={"custom": "overridden"},
)
assert result == "overridden_Main"
@patch("ayon_core.pipeline.create.product_name.get_product_name_template")
def test_product_type_filter_is_used(self, mock_get_tmpl):
mock_get_tmpl.return_value = DEFAULT_PRODUCT_TEMPLATE
_ = get_product_name(
project_name="proj",
task_name="modeling",
task_type="Modeling",
host_name="maya",
product_type="model",
variant="Main",
product_type_filter="look",
)
args, kwargs = mock_get_tmpl.call_args
assert kwargs["product_type"] == "look"