mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 12:54:40 +01:00
Merge branch 'develop' into enhancement/skip-base-classes
This commit is contained in:
commit
70328e53c6
23 changed files with 1273 additions and 273 deletions
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
21
client/ayon_core/plugins/publish/help/upload_file.xml
Normal file
21
client/ayon_core/plugins/publish/help/upload_file.xml
Normal 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>
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from ayon_core.pipeline.create import (
|
|||
ConvertorsOperationFailed,
|
||||
ConvertorItem,
|
||||
)
|
||||
|
||||
from ayon_core.tools.publisher.abstract import (
|
||||
AbstractPublisherBackend,
|
||||
CardMessageTypes,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
333
tests/client/ayon_core/pipeline/create/test_product_name.py
Normal file
333
tests/client/ayon_core/pipeline/create/test_product_name.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue