mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 12:54:40 +01:00
Merge branch 'develop' into bugfix/YN-0273_big_resolution_thumbnail_ftrack
This commit is contained in:
commit
ec5766f656
15 changed files with 611 additions and 127 deletions
|
|
@ -1234,17 +1234,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 +1250,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([
|
||||
|
|
|
|||
|
|
@ -544,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,
|
||||
|
|
@ -564,16 +559,15 @@ 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,
|
||||
# until we make product_base_type mandatory
|
||||
product_base_type=self.product_base_type
|
||||
)
|
||||
|
||||
def get_instance_attr_defs(self):
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"""Functions for handling product names."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Union
|
||||
import warnings
|
||||
from functools import wraps
|
||||
from typing import Any, Optional, Union, overload
|
||||
from warnings import warn
|
||||
|
||||
import ayon_api
|
||||
|
|
@ -9,12 +11,17 @@ 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: str,
|
||||
|
|
@ -82,7 +89,7 @@ def get_product_name_template(
|
|||
return template
|
||||
|
||||
|
||||
def get_product_name(
|
||||
def _get_product_name_old(
|
||||
project_name: str,
|
||||
task_name: Optional[str],
|
||||
task_type: Optional[str],
|
||||
|
|
@ -94,61 +101,16 @@ def get_product_name(
|
|||
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
|
||||
):
|
||||
"""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.
|
||||
|
||||
Deprecation:
|
||||
The `product_base_type` argument is optional now, but it will be
|
||||
mandatory in future versions. It is recommended to pass it now to
|
||||
avoid issues in the future. If it is not passed, a warning will be
|
||||
raised to inform about this change.
|
||||
|
||||
Todos:
|
||||
Find better filtering options to avoid requirement of
|
||||
argument 'family_filter'.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
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.
|
||||
default_template (Optional[str]): Default template if any profile does
|
||||
not match passed context. Constant 'DEFAULT_PRODUCT_TEMPLATE'
|
||||
is used if is not passed.
|
||||
dynamic_data (Optional[Dict[str, Any]]): Dynamic data specific for
|
||||
a creator which creates instance.
|
||||
project_settings (Optional[Union[Dict[str, Any]]]): Prepared settings
|
||||
for project. Settings are queried if not passed.
|
||||
product_type_filter (Optional[str]): Use different product type for
|
||||
product template filtering. Value of `product_type` is used when
|
||||
not passed.
|
||||
project_entity (Optional[Dict[str, Any]]): Project entity used when
|
||||
task short name is required by template.
|
||||
product_base_type (Optional[str]): Base type of product.
|
||||
This will be mandatory in future versions.
|
||||
|
||||
Returns:
|
||||
str: Product name.
|
||||
|
||||
Raises:
|
||||
TaskNotSetError: If template requires task which is not provided.
|
||||
TemplateFillError: If filled template contains placeholder key which
|
||||
is not collected.
|
||||
|
||||
"""
|
||||
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 ""
|
||||
return StringTemplate("").format({})
|
||||
|
||||
template = get_product_name_template(
|
||||
project_name=project_name,
|
||||
|
|
@ -160,19 +122,26 @@ def get_product_name(
|
|||
project_settings=project_settings,
|
||||
product_base_type=product_base_type,
|
||||
)
|
||||
# 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 = {
|
||||
|
|
@ -217,3 +186,305 @@ def get_product_name(
|
|||
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,
|
||||
task_type,
|
||||
host_name,
|
||||
product_type,
|
||||
variant,
|
||||
default_template=None,
|
||||
dynamic_data=None,
|
||||
project_settings=None,
|
||||
product_type_filter=None,
|
||||
project_entity=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 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.
|
||||
|
||||
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 (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.
|
||||
default_template (Optional[str]): Default template if any profile does
|
||||
not match passed context. Constant 'DEFAULT_PRODUCT_TEMPLATE'
|
||||
is used if is not passed.
|
||||
dynamic_data (Optional[Dict[str, Any]]): Dynamic data specific for
|
||||
a creator which creates instance.
|
||||
project_settings (Optional[Union[Dict[str, Any]]]): Prepared settings
|
||||
for project. Settings are queried if not passed.
|
||||
product_type_filter (Optional[str]): Use different product type for
|
||||
product template filtering. Value of `product_type` is used when
|
||||
not passed.
|
||||
project_entity (Optional[Dict[str, Any]]): Project entity used when
|
||||
task short name is required by template.
|
||||
|
||||
Returns:
|
||||
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.
|
||||
TemplateFillError: If filled template contains placeholder key which
|
||||
is not collected.
|
||||
|
||||
"""
|
||||
if not product_type:
|
||||
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=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,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
fill_pairs = {
|
||||
"variant": variant,
|
||||
# TODO We should stop support 'family' key.
|
||||
"family": product_type,
|
||||
"task": task_value,
|
||||
"product": {
|
||||
"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():
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -192,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.
|
||||
"""
|
||||
|
|
@ -227,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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
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>
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue