From a0f6a3f37971c30390f5a1b99d81f1a582ab5122 Mon Sep 17 00:00:00 2001 From: Aleks Berland Date: Mon, 25 Aug 2025 19:09:20 -0400 Subject: [PATCH 01/35] Implement upload retries for reviewable files and add user-friendly error handling in case of timeout. Update validation help documentation for upload failures. --- .../publish/help/validate_publish_dir.xml | 18 +++++ .../plugins/publish/integrate_review.py | 68 +++++++++++++++++-- 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/plugins/publish/help/validate_publish_dir.xml b/client/ayon_core/plugins/publish/help/validate_publish_dir.xml index 9f62b264bf..0449e61fa2 100644 --- a/client/ayon_core/plugins/publish/help/validate_publish_dir.xml +++ b/client/ayon_core/plugins/publish/help/validate_publish_dir.xml @@ -1,5 +1,23 @@ + +Review upload timed out + +## Review upload failed after retries + +The connection to the AYON server timed out while uploading a reviewable file. + +### How to repair? + +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}
+ +
+
Source directory not collected diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index 0a6b24adb4..c7ac5038d3 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -1,11 +1,14 @@ import os +import time -import pyblish.api import ayon_api +import pyblish.api from ayon_api.server_api import RequestTypes - from ayon_core.lib import get_media_mime_type -from ayon_core.pipeline.publish import get_publish_repre_path +from ayon_core.pipeline.publish import ( + PublishXmlValidationError, + get_publish_repre_path, +) class IntegrateAYONReview(pyblish.api.InstancePlugin): @@ -82,11 +85,12 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): 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, + headers, ) def _get_review_label(self, repre, uploaded_labels): @@ -100,3 +104,55 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): idx += 1 label = f"{orig_label}_{idx}" return label + + def _upload_with_retries( + self, + ayon_con, + endpoint, + repre_path, + headers, + max_retries: int = 3, + backoff_seconds: int = 2, + ): + """Upload file with simple exponential backoff retries. + + If all retries fail we raise a PublishXmlValidationError with a help key + to guide the user to retry publish. + """ + last_error = None + for attempt in range(1, max_retries + 1): + try: + ayon_con.upload_file( + endpoint, + repre_path, + headers=headers, + request_type=RequestTypes.post, + ) + return + except Exception as exc: # noqa: BLE001 - bubble after retries + last_error = exc + # Log and retry with backoff if attempts remain + if attempt < max_retries: + wait = backoff_seconds * (2 ** (attempt - 1)) + self.log.warning( + f"Review upload failed (attempt {attempt}/{max_retries}): {exc}. " + f"Retrying in {wait}s..." + ) + try: + time.sleep(wait) + except Exception: # Sleep errors are highly unlikely; continue + pass + else: + # 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." + ), + key="upload_timeout", + formatting_data={ + "file": repre_path, + "error": str(last_error), + }, + ) From 32c022cd4daeb4027f88021a0a5ea2163734f9de Mon Sep 17 00:00:00 2001 From: Aleks Berland Date: Tue, 26 Aug 2025 09:55:47 -0400 Subject: [PATCH 02/35] Refactor upload retry logic to handle only transient network issues and improve error handling --- .../plugins/publish/integrate_review.py | 53 +++++++++++-------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index c7ac5038d3..f9fa862320 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -9,6 +9,10 @@ from ayon_core.pipeline.publish import ( PublishXmlValidationError, get_publish_repre_path, ) +from requests import exceptions as req_exc + +# Narrow retryable failures to transient network issues +RETRYABLE_EXCEPTIONS = (req_exc.Timeout, req_exc.ConnectionError) class IntegrateAYONReview(pyblish.api.InstancePlugin): @@ -47,7 +51,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 @@ -120,7 +124,8 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): to guide the user to retry publish. """ last_error = None - for attempt in range(1, max_retries + 1): + for attempt in range(max_retries): + attempt_num = attempt + 1 try: ayon_con.upload_file( endpoint, @@ -129,30 +134,36 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): request_type=RequestTypes.post, ) return - except Exception as exc: # noqa: BLE001 - bubble after retries + except RETRYABLE_EXCEPTIONS as exc: last_error = exc # Log and retry with backoff if attempts remain - if attempt < max_retries: - wait = backoff_seconds * (2 ** (attempt - 1)) + if attempt_num < max_retries: + wait = backoff_seconds * (2 ** attempt) self.log.warning( - f"Review upload failed (attempt {attempt}/{max_retries}): {exc}. " - f"Retrying in {wait}s..." + "Review upload failed (attempt %s/%s). Retrying in %ss...", + attempt_num, max_retries, wait, + exc_info=True, ) try: time.sleep(wait) - except Exception: # Sleep errors are highly unlikely; continue + except Exception: pass else: - # 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." - ), - key="upload_timeout", - formatting_data={ - "file": repre_path, - "error": str(last_error), - }, - ) + break + except Exception: + # Non retryable failures bubble immediately + raise + + # 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." + ), + key="upload_timeout", + formatting_data={ + "file": repre_path, + "error": str(last_error), + }, + ) From 07650130c601d9cbb5d3370bc5faaff54333bfbd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:11:24 +0200 Subject: [PATCH 03/35] initial support to use folder in product name template --- .../ayon_core/pipeline/create/product_name.py | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index ecffa4a340..58cf251f9d 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -1,14 +1,19 @@ +import warnings + import ayon_api from ayon_core.lib import ( StringTemplate, filter_profiles, prepare_template_data, + Logger, ) 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, @@ -81,6 +86,8 @@ def get_product_name( project_settings=None, product_type_filter=None, project_entity=None, + folder_entity=None, + task_entity=None, ): """Calculate product name based on passed context and AYON settings. @@ -98,8 +105,8 @@ def get_product_name( Args: project_name (str): Project name. - task_name (Union[str, None]): Task name. - task_type (Union[str, None]): Task type. + task_name (Union[str, None]): Task name. Deprecated use 'task_entity'. + task_type (Union[str, None]): Task type. Deprecated use 'task_entity'. host_name (str): Host name. product_type (str): Product type. variant (str): In most of the cases it is user input during creation. @@ -115,6 +122,8 @@ def get_product_name( not passed. project_entity (Optional[Dict[str, Any]]): Project entity used when task short name is required by template. + folder_entity (Optional[Dict[str, Any]]): Folder entity. + task_entity (Optional[Dict[str, Any]]): Task entity. Returns: str: Product name. @@ -139,17 +148,36 @@ def get_product_name( ) # 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(): + if task_name and not task_entity: + warnings.warn( + "Used deprecated 'task' argument. Please use" + " 'task_entity' instead.", + DeprecationWarning, + stacklevel=2 + ) + + if task_entity: + task_name = task_entity["name"] + task_type = task_entity["taskType"] + + template_low = template.lower() + 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 = { @@ -167,6 +195,12 @@ def get_product_name( "type": product_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(): From fc7ca39f39465f5c70be3ac7a3f38b75c2e2967b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:13:19 +0200 Subject: [PATCH 04/35] move comment to correct place --- client/ayon_core/pipeline/create/product_name.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 58cf251f9d..d2d161a789 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -146,8 +146,6 @@ def get_product_name( default_template=default_template, 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 task_name and not task_entity: warnings.warn( "Used deprecated 'task' argument. Please use" @@ -161,6 +159,7 @@ def get_product_name( task_type = task_entity["taskType"] 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() From f7e9f6e7c9f7914d623bdbb874b73f4818f0e7bf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:17:51 +0200 Subject: [PATCH 05/35] use kwargs in default implementation --- client/ayon_core/pipeline/create/creator_plugins.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 7573589b82..56fa431090 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -566,14 +566,16 @@ 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, + host_name=host_name, + product_type=self.product_type, + variant=variant, dynamic_data=dynamic_data, project_settings=self.project_settings, project_entity=project_entity, + task_name=task_name, + task_type=task_type, ) def get_instance_attr_defs(self): From 348e11f9680bd7c754ac04854c9d162471f48bca Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:40:12 +0200 Subject: [PATCH 06/35] wrap get_product_name function --- .../ayon_core/pipeline/create/product_name.py | 309 +++++++++++++----- 1 file changed, 235 insertions(+), 74 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index d2d161a789..1b22ff4523 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -1,4 +1,8 @@ +from __future__ import annotations + import warnings +from functools import wraps +from typing import Optional, Any import ayon_api from ayon_core.lib import ( @@ -6,7 +10,9 @@ from ayon_core.lib import ( 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 @@ -74,68 +80,27 @@ def get_product_name_template( return template -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, - folder_entity=None, - task_entity=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. - - Todos: - Find better filtering options to avoid requirement of - argument 'family_filter'. - - Args: - project_name (str): Project name. - task_name (Union[str, None]): Task name. Deprecated use 'task_entity'. - task_type (Union[str, None]): Task type. Deprecated use 'task_entity'. - 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. - folder_entity (Optional[Dict[str, Any]]): Folder entity. - task_entity (Optional[Dict[str, Any]]): Task entity. - - 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. - - """ +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, +) -> 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, @@ -146,17 +111,6 @@ def get_product_name( default_template=default_template, project_settings=project_settings ) - if task_name and not task_entity: - warnings.warn( - "Used deprecated 'task' argument. Please use" - " 'task_entity' instead.", - DeprecationWarning, - stacklevel=2 - ) - - if task_entity: - task_name = task_entity["name"] - task_type = task_entity["taskType"] template_low = template.lower() # Simple check of task name existence for template with {task[name]} in @@ -194,6 +148,106 @@ def get_product_name( "type": 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: + raise TemplateFillError( + "Value for {} key is missing in template '{}'." + " Available values are {}".format(str(exp), template, fill_pairs) + ) + + +def _get_product_name( + project_name: str, + folder_entity: dict[str, Any], + task_entity: Optional[dict[str, Any]], + 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, + # Ignore unused kwargs passed to 'get_product_name' + task_name: Optional[str] = None, + task_type: Optional[str] = None, +) -> TemplateResult: + """Future replacement of 'get_product_name' function.""" + # Future warning when 'task_name' and 'task_type' are deprecated + # if task_name is None: + # warnings.warn( + # "Still using deprecated 'task_name' argument. Please use" + # " 'task_entity' only.", + # DeprecationWarning, + # stacklevel=2 + # ) + + 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, + product_type_filter or product_type, + task_name, + task_type, + 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 + } + } if folder_entity: fill_pairs["folder"] = { "name": folder_entity["name"], @@ -212,6 +266,113 @@ def get_product_name( ) except KeyError as exp: raise TemplateFillError( - "Value for {} key is missing in template '{}'." - " Available values are {}".format(str(exp), template, fill_pairs) + f"Value for {exp} key is missing in template '{template}'." + f" Available values are {fill_pairs}" ) + + +def _get_product_name_decorator(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. + """ + @wraps(_get_product_name) + 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 6 positional arguments is not allowed + # in the new function + if len(args) > 6: + return func(*args, **kwargs) + + if len(args) > 1: + arg_2 = args[1] + # Second argument is dictionary -> folder entity + if isinstance(arg_2, dict): + return func(*args, **kwargs) + + if is_func_signature_supported(func, *args, **kwargs): + return func(*args, **kwargs) + return _get_product_name_old(*args, **kwargs) + + return inner + + +def get_product_name( + project_name: str, + folder_entity: dict[str, Any], + task_entity: Optional[dict[str, Any]], + 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, +) -> 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. + + Todos: + Find better filtering options to avoid requirement of + argument 'family_filter'. + + 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_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. + + Raises: + TaskNotSetError: If template requires task which is not provided. + TemplateFillError: If filled template contains placeholder key which + is not collected. + + """ + return _get_product_name( + project_name, + folder_entity, + task_entity, + host_name, + product_type, + variant, + default_template=default_template, + dynamic_data=dynamic_data, + project_settings=project_settings, + product_type_filter=product_type_filter, + project_entity=project_entity, + ) From 31b023b0fac2452af6bd3bc78d977d03ec802441 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:47:14 +0200 Subject: [PATCH 07/35] use only new signature --- client/ayon_core/pipeline/create/creator_plugins.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 56fa431090..931b33afd4 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -546,11 +546,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, @@ -574,8 +569,6 @@ class BaseCreator(ABC): dynamic_data=dynamic_data, project_settings=self.project_settings, project_entity=project_entity, - task_name=task_name, - task_type=task_type, ) def get_instance_attr_defs(self): From 16b45846094c5616f90412d7c4130f3767839d59 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:50:57 +0200 Subject: [PATCH 08/35] mark the function with an attribute to know if entities are expected in arguments --- client/ayon_core/pipeline/create/product_name.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 1b22ff4523..f4ec4199d5 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -277,6 +277,11 @@ def _get_product_name_decorator(func): The old version expected 'task_name' and 'task_type' arguments. The new version expects 'folder_entity' and 'task_entity' arguments instead. """ + # 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) + func.use_entities = True + @wraps(_get_product_name) def inner(*args, **kwargs): # --- From a35b179ed1122f435bb5c83b509c0f957f2a4bcf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:59:46 +0200 Subject: [PATCH 09/35] remove the private variant of the function --- .../ayon_core/pipeline/create/product_name.py | 209 +++++++----------- 1 file changed, 84 insertions(+), 125 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index f4ec4199d5..687d152e89 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -166,7 +166,48 @@ def _get_product_name_old( ) -def _get_product_name( +def _get_product_name_decorator(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. + """ + # 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) + 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 6 positional arguments is not allowed + # in the new function + if len(args) > 6: + 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 + + +@_get_product_name_decorator +def get_product_name( project_name: str, folder_entity: dict[str, Any], task_entity: Optional[dict[str, Any]], @@ -179,20 +220,50 @@ def _get_product_name( project_settings: Optional[dict[str, Any]] = None, product_type_filter: Optional[str] = None, project_entity: Optional[dict[str, Any]] = None, - # Ignore unused kwargs passed to 'get_product_name' - task_name: Optional[str] = None, - task_type: Optional[str] = None, ) -> TemplateResult: - """Future replacement of 'get_product_name' function.""" - # Future warning when 'task_name' and 'task_type' are deprecated - # if task_name is None: - # warnings.warn( - # "Still using deprecated 'task_name' argument. Please use" - # " 'task_entity' only.", - # DeprecationWarning, - # stacklevel=2 - # ) + """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. + + Todos: + Find better filtering options to avoid requirement of + argument 'family_filter'. + + 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_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. + + 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({}) @@ -269,115 +340,3 @@ def _get_product_name( f"Value for {exp} key is missing in template '{template}'." f" Available values are {fill_pairs}" ) - - -def _get_product_name_decorator(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. - """ - # 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) - func.use_entities = True - - @wraps(_get_product_name) - 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 6 positional arguments is not allowed - # in the new function - if len(args) > 6: - return func(*args, **kwargs) - - if len(args) > 1: - arg_2 = args[1] - # Second argument is dictionary -> folder entity - if isinstance(arg_2, dict): - return func(*args, **kwargs) - - if is_func_signature_supported(func, *args, **kwargs): - return func(*args, **kwargs) - return _get_product_name_old(*args, **kwargs) - - return inner - - -def get_product_name( - project_name: str, - folder_entity: dict[str, Any], - task_entity: Optional[dict[str, Any]], - 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, -) -> 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. - - Todos: - Find better filtering options to avoid requirement of - argument 'family_filter'. - - 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_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. - - Raises: - TaskNotSetError: If template requires task which is not provided. - TemplateFillError: If filled template contains placeholder key which - is not collected. - - """ - return _get_product_name( - project_name, - folder_entity, - task_entity, - host_name, - product_type, - variant, - default_template=default_template, - dynamic_data=dynamic_data, - project_settings=project_settings, - product_type_filter=product_type_filter, - project_entity=project_entity, - ) From 5fd5b73e913eb45b2810b3ba7d63531d20758362 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 17:05:19 +0200 Subject: [PATCH 10/35] fix type hints --- client/ayon_core/pipeline/create/product_name.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 687d152e89..ede3141537 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -39,10 +39,10 @@ def get_product_name_template( 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 + 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. """ @@ -237,22 +237,22 @@ def get_product_name( Args: project_name (str): Project name. - folder_entity (Optional[Dict[str, Any]]): Folder entity. - task_entity (Optional[Dict[str, Any]]): Task entity. + folder_entity (Optional[dict[str, Any]]): Folder entity. + task_entity (Optional[dict[str, Any]]): Task entity. 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 + dynamic_data (Optional[dict[str, Any]]): Dynamic data specific for a creator which creates instance. - project_settings (Optional[Union[Dict[str, Any]]]): Prepared settings + project_settings (Optional[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 + project_entity (Optional[dict[str, Any]]): Project entity used when task short name is required by template. Returns: From 882c0bcc6aed066026e67bfe1b4c211038c08576 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:58:26 +0200 Subject: [PATCH 11/35] rename decorator and add more information to the example --- client/ayon_core/pipeline/create/product_name.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index ede3141537..45b77d1a95 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -166,11 +166,21 @@ def _get_product_name_old( ) -def _get_product_name_decorator(func): +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. @@ -206,7 +216,7 @@ def _get_product_name_decorator(func): return inner -@_get_product_name_decorator +@_backwards_compatibility_product_name def get_product_name( project_name: str, folder_entity: dict[str, Any], From d7433f84d796abb04d0a0aed5fa3eee134ecaf02 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:58:34 +0200 Subject: [PATCH 12/35] use setattr --- client/ayon_core/pipeline/create/product_name.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 45b77d1a95..ee07f939bc 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -185,7 +185,7 @@ def _backwards_compatibility_product_name(func): # 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) - func.use_entities = True + setattr(func, "use_entities", True) @wraps(func) def inner(*args, **kwargs): From d6431a49908f3bc5bd14b39f2c0c18ce6f7e3137 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:17:13 +0100 Subject: [PATCH 13/35] added overload functionality --- .../ayon_core/pipeline/create/product_name.py | 117 +++++++++++++++++- 1 file changed, 112 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index ee07f939bc..a85b12f0df 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -2,7 +2,7 @@ from __future__ import annotations import warnings from functools import wraps -from typing import Optional, Any +from typing import Optional, Any, overload import ayon_api from ayon_core.lib import ( @@ -216,7 +216,7 @@ def _backwards_compatibility_product_name(func): return inner -@_backwards_compatibility_product_name +@overload def get_product_name( project_name: str, folder_entity: dict[str, Any], @@ -241,9 +241,116 @@ def get_product_name( 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'. + 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_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[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. + + 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 deprecate 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]], + 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, +) -> 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. Args: project_name (str): Project name. From cf28f96eda987207bdaa6161d2cfd19d46aad264 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Dec 2025 17:49:11 +0100 Subject: [PATCH 14/35] fix formatting in docstring --- client/ayon_core/pipeline/create/product_name.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index b0bb2d3430..89ae7ef85b 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -367,13 +367,15 @@ def get_product_name( ) -> 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. + 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. + name. Args: project_name (str): Project name. From fb2df3397063a5f06184cb2484d2c849d4d9d948 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:39:13 +0100 Subject: [PATCH 15/35] added option to define different help file --- client/ayon_core/pipeline/publish/lib.py | 17 +++++--- .../pipeline/publish/publish_plugins.py | 41 ++++++++++++++++--- 2 files changed, 47 insertions(+), 11 deletions(-) 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 ) From f0bd2b7e98cb74cab1002874ac8b8d88d611957b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:39:44 +0100 Subject: [PATCH 16/35] use different help file for integrate review --- .../plugins/publish/help/upload_file.xml | 21 +++++++++++++++++++ .../publish/help/validate_publish_dir.xml | 18 ---------------- .../plugins/publish/integrate_review.py | 7 ++++--- 3 files changed, 25 insertions(+), 21 deletions(-) create mode 100644 client/ayon_core/plugins/publish/help/upload_file.xml 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/help/validate_publish_dir.xml b/client/ayon_core/plugins/publish/help/validate_publish_dir.xml index 0449e61fa2..9f62b264bf 100644 --- a/client/ayon_core/plugins/publish/help/validate_publish_dir.xml +++ b/client/ayon_core/plugins/publish/help/validate_publish_dir.xml @@ -1,23 +1,5 @@ - -Review upload timed out - -## Review upload failed after retries - -The connection to the AYON server timed out while uploading a reviewable file. - -### How to repair? - -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}
- -
-
Source directory not collected diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index f9fa862320..4d091aa17a 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -158,12 +158,13 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): raise PublishXmlValidationError( self, ( - "Upload of reviewable timed out or failed after multiple attempts." - " Please try publishing again." + "Upload of reviewable timed out or failed after multiple" + " attempts. Please try publishing again." ), - key="upload_timeout", formatting_data={ + "upload_type": "Review", "file": repre_path, "error": str(last_error), }, + help_filename="upload_file.xml", ) From 699673bbf2e55a0f35d9ca966baaec1a0e7705dd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:40:13 +0100 Subject: [PATCH 17/35] slightly modified upload --- .../plugins/publish/integrate_review.py | 67 ++++++++----------- 1 file changed, 29 insertions(+), 38 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index 4d091aa17a..06cc2f55b4 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -9,10 +9,7 @@ from ayon_core.pipeline.publish import ( PublishXmlValidationError, get_publish_repre_path, ) -from requests import exceptions as req_exc - -# Narrow retryable failures to transient network issues -RETRYABLE_EXCEPTIONS = (req_exc.Timeout, req_exc.ConnectionError) +import requests.exceptions class IntegrateAYONReview(pyblish.api.InstancePlugin): @@ -82,19 +79,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}") # Upload with retries and clear help if it keeps failing self._upload_with_retries( ayon_con, endpoint, repre_path, - headers, + content_type, ) def _get_review_label(self, repre, uploaded_labels): @@ -111,48 +102,48 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): def _upload_with_retries( self, - ayon_con, - endpoint, - repre_path, - headers, - max_retries: int = 3, - backoff_seconds: int = 2, + ayon_con: ayon_api.ServerAPI, + endpoint: str, + repre_path: str, + content_type: str, ): """Upload file with simple exponential backoff retries. If all retries fail we raise a PublishXmlValidationError with a help key to guide the user to retry publish. """ + # How long to sleep before next attempt + wait_time = 1 + 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() last_error = None for attempt in range(max_retries): - attempt_num = attempt + 1 + attempt += 1 try: - ayon_con.upload_file( + return ayon_con.upload_file( endpoint, repre_path, headers=headers, request_type=RequestTypes.post, ) - return - except RETRYABLE_EXCEPTIONS as exc: - last_error = exc + + except ( + requests.exceptions.Timeout, + requests.exceptions.ConnectionError + ): # Log and retry with backoff if attempts remain - if attempt_num < max_retries: - wait = backoff_seconds * (2 ** attempt) - self.log.warning( - "Review upload failed (attempt %s/%s). Retrying in %ss...", - attempt_num, max_retries, wait, - exc_info=True, - ) - try: - time.sleep(wait) - except Exception: - pass - else: - break - except Exception: - # Non retryable failures bubble immediately - raise + if attempt >= max_retries: + raise + + self.log.warning( + f"Review upload failed ({attempt}/{max_retries})." + 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( From 989c54001c73bf56caa10780227e463faf7b1a45 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:53:30 +0100 Subject: [PATCH 18/35] added retries in thumbnail integration --- .../plugins/publish/integrate_thumbnail.py | 82 +++++++++++++++++-- 1 file changed, 73 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_thumbnail.py b/client/ayon_core/plugins/publish/integrate_thumbnail.py index 067c3470e8..7d36a1c7eb 100644 --- a/client/ayon_core/plugins/publish/integrate_thumbnail.py +++ b/client/ayon_core/plugins/publish/integrate_thumbnail.py @@ -24,11 +24,19 @@ import os import collections +import time import pyblish.api import ayon_api from ayon_api import RequestTypes from ayon_api.operations import OperationsSession +try: + from ayon_api.utils import get_media_mime_type +except ImportError: + from ayon_core.lib import get_media_mime_type +import requests + +from ayon_core.pipeline.publish import PublishXmlValidationError InstanceFilterResult = collections.namedtuple( @@ -170,19 +178,16 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): 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" - + 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 +253,62 @@ 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 exponential backoff retries. + + If all retries fail we raise a PublishXmlValidationError with a help key + to guide the user to retry publish. + """ + # How long to sleep before next attempt + wait_time = 1 + filename = os.path.basename(repre_path) + + ayon_con = ayon_api.get_server_api_connection() + headers = ayon_con.get_headers(content_type) + max_retries = ayon_con.get_default_max_retries() + last_error = None + for attempt in range(max_retries): + attempt += 1 + try: + return ayon_con.upload_file( + endpoint, + repre_path, + headers=headers, + request_type=RequestTypes.post, + ) + + except ( + requests.exceptions.Timeout, + requests.exceptions.ConnectionError + ): + # Log and retry with backoff if attempts remain + if attempt >= max_retries: + raise + + self.log.warning( + f"Review upload failed ({attempt}/{max_retries})." + 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", + ) From 9b35dd6cfc91ae99d03474888bd80d97a45a47d1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:16:16 +0100 Subject: [PATCH 19/35] remove unused variable --- client/ayon_core/plugins/publish/integrate_thumbnail.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate_thumbnail.py b/client/ayon_core/plugins/publish/integrate_thumbnail.py index 7d36a1c7eb..233ab751f6 100644 --- a/client/ayon_core/plugins/publish/integrate_thumbnail.py +++ b/client/ayon_core/plugins/publish/integrate_thumbnail.py @@ -267,7 +267,6 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): """ # How long to sleep before next attempt wait_time = 1 - filename = os.path.basename(repre_path) ayon_con = ayon_api.get_server_api_connection() headers = ayon_con.get_headers(content_type) From e0597ac6de2d5ddf6161fbb2687518d59ee7edb1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:20:02 +0100 Subject: [PATCH 20/35] remove unnecessary imports --- client/ayon_core/plugins/publish/integrate_thumbnail.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_thumbnail.py b/client/ayon_core/plugins/publish/integrate_thumbnail.py index 233ab751f6..e68a4179ae 100644 --- a/client/ayon_core/plugins/publish/integrate_thumbnail.py +++ b/client/ayon_core/plugins/publish/integrate_thumbnail.py @@ -26,16 +26,13 @@ import os import collections import time -import pyblish.api import ayon_api from ayon_api import RequestTypes from ayon_api.operations import OperationsSession -try: - from ayon_api.utils import get_media_mime_type -except ImportError: - from ayon_core.lib import get_media_mime_type +import pyblish.api import requests +from ayon_core.lib import get_media_mime_type from ayon_core.pipeline.publish import PublishXmlValidationError From 647d91e288aaa4e2ad1facab2e907b0936c97632 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:26:02 +0100 Subject: [PATCH 21/35] update docstring --- client/ayon_core/plugins/publish/integrate_review.py | 6 +----- client/ayon_core/plugins/publish/integrate_thumbnail.py | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index 06cc2f55b4..1b236d9070 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -107,11 +107,7 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): repre_path: str, content_type: str, ): - """Upload file with simple exponential backoff retries. - - If all retries fail we raise a PublishXmlValidationError with a help key - to guide the user to retry publish. - """ + """Upload file with simple retries.""" # How long to sleep before next attempt wait_time = 1 filename = os.path.basename(repre_path) diff --git a/client/ayon_core/plugins/publish/integrate_thumbnail.py b/client/ayon_core/plugins/publish/integrate_thumbnail.py index e68a4179ae..aef95525cb 100644 --- a/client/ayon_core/plugins/publish/integrate_thumbnail.py +++ b/client/ayon_core/plugins/publish/integrate_thumbnail.py @@ -257,11 +257,7 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): repre_path: str, content_type: str, ): - """Upload file with simple exponential backoff retries. - - If all retries fail we raise a PublishXmlValidationError with a help key - to guide the user to retry publish. - """ + """Upload file with simple retries.""" # How long to sleep before next attempt wait_time = 1 From faff50ce333eeff2e1d69be4f50b8b5a1f72d3a2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:03:19 +0100 Subject: [PATCH 22/35] don't use custom retries if are already handled by ayon api --- .../plugins/publish/integrate_review.py | 18 +++++++++++++----- .../plugins/publish/integrate_thumbnail.py | 17 +++++++++++------ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index 1b236d9070..cdb16b5ac3 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -2,8 +2,10 @@ import os import time import ayon_api -import pyblish.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 ( PublishXmlValidationError, @@ -108,13 +110,18 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): content_type: str, ): """Upload file with simple retries.""" - # How long to sleep before next attempt - wait_time = 1 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 + + # How long to sleep before next attempt + wait_time = 1 last_error = None for attempt in range(max_retries): attempt += 1 @@ -129,10 +136,11 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): except ( requests.exceptions.Timeout, requests.exceptions.ConnectionError - ): + ) as exc: # Log and retry with backoff if attempts remain if attempt >= max_retries: - raise + last_error = exc + break self.log.warning( f"Review upload failed ({attempt}/{max_retries})." diff --git a/client/ayon_core/plugins/publish/integrate_thumbnail.py b/client/ayon_core/plugins/publish/integrate_thumbnail.py index aef95525cb..12c5e483e3 100644 --- a/client/ayon_core/plugins/publish/integrate_thumbnail.py +++ b/client/ayon_core/plugins/publish/integrate_thumbnail.py @@ -27,7 +27,7 @@ import collections import time 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 @@ -258,12 +258,16 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): content_type: str, ): """Upload file with simple retries.""" - # How long to sleep before next attempt - wait_time = 1 - 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 + + # How long to sleep before next attempt + wait_time = 1 last_error = None for attempt in range(max_retries): attempt += 1 @@ -278,10 +282,11 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): except ( requests.exceptions.Timeout, requests.exceptions.ConnectionError - ): + ) as exc: # Log and retry with backoff if attempts remain if attempt >= max_retries: - raise + last_error = exc + break self.log.warning( f"Review upload failed ({attempt}/{max_retries})." From dde471332fc2b01c88e23277e49924e4a46b6f7d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:33:19 +0100 Subject: [PATCH 23/35] added more logs --- .../ayon_core/plugins/publish/integrate_review.py | 15 ++++++++++++--- .../plugins/publish/integrate_thumbnail.py | 15 ++++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index cdb16b5ac3..6d7e98ae66 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -6,7 +6,7 @@ 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.lib import get_media_mime_type, format_file_size from ayon_core.pipeline.publish import ( PublishXmlValidationError, get_publish_repre_path, @@ -120,18 +120,26 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): 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: - return ayon_con.upload_file( + output = ayon_con.upload_file( endpoint, repre_path, headers=headers, request_type=RequestTypes.post, ) + self.log.info(f"Uploade in {time.time() - start}s.") + return output except ( requests.exceptions.Timeout, @@ -143,7 +151,8 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): break self.log.warning( - f"Review upload failed ({attempt}/{max_retries})." + f"Review upload failed ({attempt}/{max_retries})" + f" after {time.time() - start}s." f" Retrying in {wait_time}s...", exc_info=True, ) diff --git a/client/ayon_core/plugins/publish/integrate_thumbnail.py b/client/ayon_core/plugins/publish/integrate_thumbnail.py index 12c5e483e3..36b79570f0 100644 --- a/client/ayon_core/plugins/publish/integrate_thumbnail.py +++ b/client/ayon_core/plugins/publish/integrate_thumbnail.py @@ -32,7 +32,7 @@ from ayon_api.operations import OperationsSession import pyblish.api import requests -from ayon_core.lib import get_media_mime_type +from ayon_core.lib import get_media_mime_type, format_file_size from ayon_core.pipeline.publish import PublishXmlValidationError @@ -266,18 +266,26 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): 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: - return ayon_con.upload_file( + output = ayon_con.upload_file( endpoint, repre_path, headers=headers, request_type=RequestTypes.post, ) + self.log.info(f"Uploade in {time.time() - start}s.") + return output except ( requests.exceptions.Timeout, @@ -289,7 +297,8 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): break self.log.warning( - f"Review upload failed ({attempt}/{max_retries})." + f"Review upload failed ({attempt}/{max_retries})" + f" after {time.time() - start}s." f" Retrying in {wait_time}s...", exc_info=True, ) From 5c17102d16e2af49448798f94dee1bc26ca11f7a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:38:51 +0100 Subject: [PATCH 24/35] remove outdated docstring info --- client/ayon_core/plugins/publish/integrate_thumbnail.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_thumbnail.py b/client/ayon_core/plugins/publish/integrate_thumbnail.py index 36b79570f0..a55a9ac6d8 100644 --- a/client/ayon_core/plugins/publish/integrate_thumbnail.py +++ b/client/ayon_core/plugins/publish/integrate_thumbnail.py @@ -169,12 +169,7 @@ 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. - - """ + """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( From ab78158d6ef48b3a28c5ff3ddf0bc01445398d27 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:17:00 +0100 Subject: [PATCH 25/35] Fixed typo and use debug level Co-authored-by: Roy Nieterau --- client/ayon_core/plugins/publish/integrate_review.py | 2 +- client/ayon_core/plugins/publish/integrate_thumbnail.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index 6d7e98ae66..b0cc41acc9 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -138,7 +138,7 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): headers=headers, request_type=RequestTypes.post, ) - self.log.info(f"Uploade in {time.time() - start}s.") + self.log.debug(f"Uploaded in {time.time() - start}s.") return output except ( diff --git a/client/ayon_core/plugins/publish/integrate_thumbnail.py b/client/ayon_core/plugins/publish/integrate_thumbnail.py index a55a9ac6d8..60b3a97639 100644 --- a/client/ayon_core/plugins/publish/integrate_thumbnail.py +++ b/client/ayon_core/plugins/publish/integrate_thumbnail.py @@ -279,7 +279,7 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): headers=headers, request_type=RequestTypes.post, ) - self.log.info(f"Uploade in {time.time() - start}s.") + self.log.debug(f"Uploaded in {time.time() - start}s.") return output except ( From f3a2cad425da75d51d38d77dffaa94aab28fb984 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:20:28 +0100 Subject: [PATCH 26/35] refresh my tasks filters on refresh --- client/ayon_core/tools/launcher/ui/hierarchy_page.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py index 3c8be4679e..57524e8155 100644 --- a/client/ayon_core/tools/launcher/ui/hierarchy_page.py +++ b/client/ayon_core/tools/launcher/ui/hierarchy_page.py @@ -120,6 +120,8 @@ class HierarchyPage(QtWidgets.QWidget): self._project_name = None + self._my_tasks_filter_enabled = False + # Post init projects_combobox.set_listen_to_selection_change(self._is_visible) @@ -136,6 +138,9 @@ class HierarchyPage(QtWidgets.QWidget): self._folders_widget.refresh() self._tasks_widget.refresh() self._workfiles_page.refresh() + self._on_my_tasks_checkbox_state_changed( + self._my_tasks_filter_enabled + ) 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._my_tasks_filter_enabled = enabled self._folders_widget.set_folder_ids_filter(folder_ids) self._tasks_widget.set_task_ids_filter(task_ids) From 9ade73fb27a5f11fe063cbe7fd5bcf007a47df10 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:28:35 +0100 Subject: [PATCH 27/35] refresh my tasks in all tools --- client/ayon_core/tools/launcher/ui/hierarchy_page.py | 1 + client/ayon_core/tools/loader/ui/window.py | 4 ++++ .../tools/publisher/widgets/create_context_widgets.py | 5 +++++ client/ayon_core/tools/publisher/widgets/folders_dialog.py | 1 + client/ayon_core/tools/utils/folders_widget.py | 6 ++++++ client/ayon_core/tools/workfiles/widgets/window.py | 6 ++++++ 6 files changed, 23 insertions(+) diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py index 57524e8155..575666b64d 100644 --- a/client/ayon_core/tools/launcher/ui/hierarchy_page.py +++ b/client/ayon_core/tools/launcher/ui/hierarchy_page.py @@ -138,6 +138,7 @@ 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._my_tasks_filter_enabled ) 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/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"]: From 8076615a5f87b131bbcac282b8148a8acc569e3b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:01:41 +0100 Subject: [PATCH 28/35] use same approach in launcher as in other tools --- client/ayon_core/tools/launcher/ui/hierarchy_page.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py index 575666b64d..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 @@ -120,8 +121,6 @@ class HierarchyPage(QtWidgets.QWidget): self._project_name = None - self._my_tasks_filter_enabled = False - # Post init projects_combobox.set_listen_to_selection_change(self._is_visible) @@ -140,7 +139,7 @@ class HierarchyPage(QtWidgets.QWidget): self._workfiles_page.refresh() # Update my tasks self._on_my_tasks_checkbox_state_changed( - self._my_tasks_filter_enabled + self._filters_widget.is_my_tasks_checked() ) def _on_back_clicked(self): @@ -161,7 +160,7 @@ class HierarchyPage(QtWidgets.QWidget): ) folder_ids = entity_ids["folder_ids"] task_ids = entity_ids["task_ids"] - self._my_tasks_filter_enabled = enabled + self._folders_widget.set_folder_ids_filter(folder_ids) self._tasks_widget.set_task_ids_filter(task_ids) From f0e603fe7c73d18910e3a0fb2e708a60e9b284ac Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 10 Dec 2025 00:30:16 +0100 Subject: [PATCH 29/35] Do not invert source display/view if it already matches target display/view --- client/ayon_core/lib/transcoding.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index b4a3e77f5a..1f9005d92b 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1234,17 +1234,21 @@ 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 source_display != target_display or source_view != target_view: + # Undo source display/view if we have a source display/view + # that does not match the target display/view + 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 From b1be956994acd10e1c0c52103e79a33187ac06e7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 10 Dec 2025 00:38:49 +0100 Subject: [PATCH 30/35] Also invert if target_colorspace, which means - always invert source display/view if we have any target colorspace or a display/view that differs from the source display/view --- client/ayon_core/lib/transcoding.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 1f9005d92b..f1c1cd7aa6 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1234,16 +1234,6 @@ def oiio_color_convert( if source_view and source_display: color_convert_args = None ocio_display_args = None - - if source_display != target_display or source_view != target_view: - # Undo source display/view if we have a source display/view - # that does not match the target display/view - 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 @@ -1260,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([ From d4e5f96b3b49d7a0e2bf2041d67657e24005a392 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:46:48 +0100 Subject: [PATCH 31/35] upodate overload function --- .../ayon_core/pipeline/create/product_name.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 89ae7ef85b..2b1255c2b3 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -243,15 +243,16 @@ def get_product_name( project_name: str, folder_entity: dict[str, Any], task_entity: Optional[dict[str, Any]], - host_name: str, + product_base_type: str, product_type: str, + host_name: 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, + default_template: Optional[str] = None, + product_base_type_filter: Optional[str] = None, ) -> TemplateResult: """Calculate product name based on passed context and AYON settings. @@ -268,20 +269,21 @@ def get_product_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. - 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[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. + 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. From bceb645a80da8a2f671d42e2a8d6b5feaea42b5a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:47:14 +0100 Subject: [PATCH 32/35] fix typo Co-authored-by: Roy Nieterau --- client/ayon_core/pipeline/create/product_name.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 2b1255c2b3..a0bfc18eba 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -321,7 +321,7 @@ def get_product_name( name. Deprecated: - This function is using deprecate signature that does not support + This function is using deprecated signature that does not support folder entity data to be used. Args: From 17b09d608bfc33b6bfa7904adfb9abbf3d7b3df8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:03:30 +0100 Subject: [PATCH 33/35] unify indentation --- client/ayon_core/pipeline/create/product_name.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index a0bfc18eba..d32de54774 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -370,8 +370,8 @@ def get_product_name( """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. + /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. From aff0ecf4362982640cc60c3921885f9314970fe8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 10 Dec 2025 20:29:10 +0100 Subject: [PATCH 34/35] Fix #1598: Do not fallback to current task name --- client/ayon_core/tools/publisher/widgets/create_widget.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/create_widget.py b/client/ayon_core/tools/publisher/widgets/create_widget.py index d98bc95eb2..f2afdfffd9 100644 --- a/client/ayon_core/tools/publisher/widgets/create_widget.py +++ b/client/ayon_core/tools/publisher/widgets/create_widget.py @@ -328,9 +328,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): From b6709f98590841cc80a616635c7f9c4f77f1ed1f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 11 Dec 2025 15:36:45 +0100 Subject: [PATCH 35/35] Also remove fallback for current folder in `_get_folder_path` --- client/ayon_core/tools/publisher/widgets/create_widget.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/create_widget.py b/client/ayon_core/tools/publisher/widgets/create_widget.py index f2afdfffd9..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):