diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index b4a3e77f5a..f1c1cd7aa6 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -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([ diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index a034451d83..4f0baa7beb 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -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): diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index c4c27edc3b..d32de54774 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -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) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 7365ffee09..e512a0116f 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -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) diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index cc6887e762..90b8e90a3c 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -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 ) diff --git a/client/ayon_core/plugins/publish/help/upload_file.xml b/client/ayon_core/plugins/publish/help/upload_file.xml new file mode 100644 index 0000000000..8c270c7b19 --- /dev/null +++ b/client/ayon_core/plugins/publish/help/upload_file.xml @@ -0,0 +1,21 @@ + + + +{upload_type} upload timed out + +## {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. + +
File: {file}
+Error: {error}
+ +
+
+
diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index 0a6b24adb4..b0cc41acc9 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -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", + ) diff --git a/client/ayon_core/plugins/publish/integrate_thumbnail.py b/client/ayon_core/plugins/publish/integrate_thumbnail.py index 067c3470e8..60b3a97639 100644 --- a/client/ayon_core/plugins/publish/integrate_thumbnail.py +++ b/client/ayon_core/plugins/publish/integrate_thumbnail.py @@ -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", + ) diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py index 3c8be4679e..9d5cb8e8d0 100644 --- a/client/ayon_core/tools/launcher/ui/hierarchy_page.py +++ b/client/ayon_core/tools/launcher/ui/hierarchy_page.py @@ -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) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index a6807a1ebb..e4677a62d9 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -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"] diff --git a/client/ayon_core/tools/publisher/widgets/create_context_widgets.py b/client/ayon_core/tools/publisher/widgets/create_context_widgets.py index 49d236353f..405445c8eb 100644 --- a/client/ayon_core/tools/publisher/widgets/create_context_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/create_context_widgets.py @@ -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) diff --git a/client/ayon_core/tools/publisher/widgets/create_widget.py b/client/ayon_core/tools/publisher/widgets/create_widget.py index d98bc95eb2..db93632471 100644 --- a/client/ayon_core/tools/publisher/widgets/create_widget.py +++ b/client/ayon_core/tools/publisher/widgets/create_widget.py @@ -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): diff --git a/client/ayon_core/tools/publisher/widgets/folders_dialog.py b/client/ayon_core/tools/publisher/widgets/folders_dialog.py index e0d9c098d8..824ed728c9 100644 --- a/client/ayon_core/tools/publisher/widgets/folders_dialog.py +++ b/client/ayon_core/tools/publisher/widgets/folders_dialog.py @@ -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.""" diff --git a/client/ayon_core/tools/utils/folders_widget.py b/client/ayon_core/tools/utils/folders_widget.py index f506af5352..ea278da6cb 100644 --- a/client/ayon_core/tools/utils/folders_widget.py +++ b/client/ayon_core/tools/utils/folders_widget.py @@ -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) diff --git a/client/ayon_core/tools/workfiles/widgets/window.py b/client/ayon_core/tools/workfiles/widgets/window.py index 811fe602d1..bb3fd19ae1 100644 --- a/client/ayon_core/tools/workfiles/widgets/window.py +++ b/client/ayon_core/tools/workfiles/widgets/window.py @@ -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"]: