From cb125a192f0728562d5e76d2b510370d65c4f1f8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 31 Mar 2025 23:14:17 +0200 Subject: [PATCH 01/99] Optimize oiio tool conversion for ffmpeg. - Prepare attributes to remove list just once. - Process sequences as a single `oiiotool` call --- client/ayon_core/lib/transcoding.py | 203 +++++++--------------------- 1 file changed, 50 insertions(+), 153 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 1fda014bd8..39995083c0 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -10,6 +10,8 @@ from typing import Optional import xml.etree.ElementTree +import clique + from .execute import run_subprocess from .vendor_bin_utils import ( get_ffmpeg_tool_args, @@ -526,135 +528,36 @@ def should_convert_for_ffmpeg(src_filepath): return False -# Deprecated since 2022 4 20 -# - Reason - Doesn't convert sequences right way: Can't handle gaps, reuse -# first frame for all frames and changes filenames when input -# is sequence. -# - use 'convert_input_paths_for_ffmpeg' instead -def convert_for_ffmpeg( - first_input_path, - output_dir, - input_frame_start=None, - input_frame_end=None, - logger=None -): - """Convert source file to format supported in ffmpeg. - - Currently can convert only exrs. - - Args: - first_input_path (str): Path to first file of a sequence or a single - file path for non-sequential input. - output_dir (str): Path to directory where output will be rendered. - Must not be same as input's directory. - input_frame_start (int): Frame start of input. - input_frame_end (int): Frame end of input. - logger (logging.Logger): Logger used for logging. - - Raises: - ValueError: If input filepath has extension not supported by function. - Currently is supported only ".exr" extension. - """ - if logger is None: - logger = logging.getLogger(__name__) - - logger.warning(( - "DEPRECATED: 'ayon_core.lib.transcoding.convert_for_ffmpeg' is" - " deprecated function of conversion for FFMpeg. Please replace usage" - " with 'ayon_core.lib.transcoding.convert_input_paths_for_ffmpeg'" - )) - - ext = os.path.splitext(first_input_path)[1].lower() - if ext != ".exr": - raise ValueError(( - "Function 'convert_for_ffmpeg' currently support only" - " \".exr\" extension. Got \"{}\"." - ).format(ext)) - - is_sequence = False - if input_frame_start is not None and input_frame_end is not None: - is_sequence = int(input_frame_end) != int(input_frame_start) - - input_info = get_oiio_info_for_input(first_input_path, logger=logger) - - # Change compression only if source compression is "dwaa" or "dwab" - # - they're not supported in ffmpeg - compression = input_info["attribs"].get("compression") - if compression in ("dwaa", "dwab"): - compression = "none" - - # Prepare subprocess arguments - oiio_cmd = get_oiio_tool_args( - "oiiotool", - # Don't add any additional attributes - "--nosoftwareattrib", - ) - # Add input compression if available - if compression: - oiio_cmd.extend(["--compression", compression]) - - # Collect channels to export - input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) - - oiio_cmd.extend([ - input_arg, first_input_path, - # Tell oiiotool which channels should be put to top stack (and output) - "--ch", channels_arg, - # Use first subimage - "--subimage", "0" - ]) - - # Add frame definitions to arguments - if is_sequence: - oiio_cmd.extend([ - "--frames", "{}-{}".format(input_frame_start, input_frame_end) - ]) - +def _get_attributes_to_erase(input_info: dict, logger: logging.Logger = None) -> list[str]: + """FFMPEG does not support some attributes in metadata.""" + erase_attr_names = [] + reasons = [] for attr_name, attr_value in input_info["attribs"].items(): if not isinstance(attr_value, str): continue # Remove attributes that have string value longer than allowed length # for ffmpeg or when contain prohibited symbols - erase_reason = "Missing reason" - erase_attribute = False if len(attr_value) > MAX_FFMPEG_STRING_LEN: - erase_reason = "has too long value ({} chars).".format( - len(attr_value) - ) - erase_attribute = True + reason = f"has too long value ({len(attr_value)} chars)." + erase_attr_names.append(attr_name) + reasons.append(reason) - if not erase_attribute: - for char in NOT_ALLOWED_FFMPEG_CHARS: - if char in attr_value: - erase_attribute = True - erase_reason = ( - "contains unsupported character \"{}\"." - ).format(char) - break + for char in NOT_ALLOWED_FFMPEG_CHARS: + if char not in attr_value: + continue + reason = f"contains unsupported character \"{char}\"." + erase_attr_names.append(attr_name) + reasons.append(reason) - if erase_attribute: + if logger is not None: + for attr_name, reason in zip(erase_attr_names, reasons): # Set attribute to empty string - logger.info(( - "Removed attribute \"{}\" from metadata because {}." - ).format(attr_name, erase_reason)) - oiio_cmd.extend(["--eraseattrib", attr_name]) - - # Add last argument - path to output - if is_sequence: - ext = os.path.splitext(first_input_path)[1] - base_filename = "tmp.%{:0>2}d{}".format( - len(str(input_frame_end)), ext - ) - else: - base_filename = os.path.basename(first_input_path) - output_path = os.path.join(output_dir, base_filename) - oiio_cmd.extend([ - "-o", output_path - ]) - - logger.debug("Conversion command: {}".format(" ".join(oiio_cmd))) - run_subprocess(oiio_cmd, logger=logger) + logger.info( + f"Removed attribute \"{attr_name}\" from metadata" + f" because {reason}." + ) + return erase_attr_names def convert_input_paths_for_ffmpeg( @@ -664,7 +567,7 @@ def convert_input_paths_for_ffmpeg( ): """Convert source file to format supported in ffmpeg. - Currently can convert only exrs. The input filepaths should be files + Currently, can convert only exrs. The input filepaths should be files with same type. Information about input is loaded only from first found file. @@ -682,7 +585,7 @@ def convert_input_paths_for_ffmpeg( Raises: ValueError: If input filepath has extension not supported by function. - Currently is supported only ".exr" extension. + Currently, only ".exr" extension is supported. """ if logger is None: logger = logging.getLogger(__name__) @@ -707,7 +610,19 @@ def convert_input_paths_for_ffmpeg( # Collect channels to export input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) - for input_path in input_paths: + # Find which attributes to strip + erase_attributes: list[str] = _get_attributes_to_erase( + input_info, logger=logger + ) + + input_collections, input_remainders = clique.assemble( + input_paths, + patterns=[clique.PATTERNS["frames"]], + assume_padded_when_ambiguous=True, + ) + process_inputs = list(input_collections) + process_inputs.extend(input_remainders) + for _input in process_inputs: # Prepare subprocess arguments oiio_cmd = get_oiio_tool_args( "oiiotool", @@ -718,8 +633,17 @@ def convert_input_paths_for_ffmpeg( if compression: oiio_cmd.extend(["--compression", compression]) + # Convert a sequence of files using a single oiiotool command + # using its sequence syntax + if isinstance(_input, clique.Collection): + oiio_cmd.extend([ + "--framepadding", _input.padding, + "--frames", _input.format("{ranges}"), + ]) + _input: str = _input.format("{head}#{tail}") + oiio_cmd.extend([ - input_arg, input_path, + input_arg, _input, # Tell oiiotool which channels should be put to top stack # (and output) "--ch", channels_arg, @@ -727,38 +651,11 @@ def convert_input_paths_for_ffmpeg( "--subimage", "0" ]) - for attr_name, attr_value in input_info["attribs"].items(): - if not isinstance(attr_value, str): - continue - - # Remove attributes that have string value longer than allowed - # length for ffmpeg or when containing prohibited symbols - erase_reason = "Missing reason" - erase_attribute = False - if len(attr_value) > MAX_FFMPEG_STRING_LEN: - erase_reason = "has too long value ({} chars).".format( - len(attr_value) - ) - erase_attribute = True - - if not erase_attribute: - for char in NOT_ALLOWED_FFMPEG_CHARS: - if char in attr_value: - erase_attribute = True - erase_reason = ( - "contains unsupported character \"{}\"." - ).format(char) - break - - if erase_attribute: - # Set attribute to empty string - logger.info(( - "Removed attribute \"{}\" from metadata because {}." - ).format(attr_name, erase_reason)) - oiio_cmd.extend(["--eraseattrib", attr_name]) + for attr_name in erase_attributes: + oiio_cmd.extend(["--eraseattrib", attr_name]) # Add last argument - path to output - base_filename = os.path.basename(input_path) + base_filename = os.path.basename(_input) output_path = os.path.join(output_dir, base_filename) oiio_cmd.extend([ "-o", output_path From c7c2a4a7eccc543d0262701f9b868a73bc76766e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 1 Apr 2025 09:07:02 +0200 Subject: [PATCH 02/99] Cleanup --- client/ayon_core/lib/transcoding.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 39995083c0..2238b24c3b 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -528,10 +528,11 @@ def should_convert_for_ffmpeg(src_filepath): return False -def _get_attributes_to_erase(input_info: dict, logger: logging.Logger = None) -> list[str]: +def _get_attributes_to_erase( + input_info: dict, logger: logging.Logger +) -> list[str]: """FFMPEG does not support some attributes in metadata.""" - erase_attr_names = [] - reasons = [] + erase_attrs: dict[str, str] = {} # Attr name to reason mapping for attr_name, attr_value in input_info["attribs"].items(): if not isinstance(attr_value, str): continue @@ -540,24 +541,23 @@ def _get_attributes_to_erase(input_info: dict, logger: logging.Logger = None) -> # for ffmpeg or when contain prohibited symbols if len(attr_value) > MAX_FFMPEG_STRING_LEN: reason = f"has too long value ({len(attr_value)} chars)." - erase_attr_names.append(attr_name) - reasons.append(reason) + erase_attrs[attr_name] = reason + continue for char in NOT_ALLOWED_FFMPEG_CHARS: if char not in attr_value: continue reason = f"contains unsupported character \"{char}\"." - erase_attr_names.append(attr_name) - reasons.append(reason) + erase_attrs[attr_name] = reason + break if logger is not None: - for attr_name, reason in zip(erase_attr_names, reasons): - # Set attribute to empty string + for attr_name, reason in erase_attrs.items(): logger.info( f"Removed attribute \"{attr_name}\" from metadata" f" because {reason}." ) - return erase_attr_names + return list(erase_attrs.keys()) def convert_input_paths_for_ffmpeg( @@ -615,14 +615,14 @@ def convert_input_paths_for_ffmpeg( input_info, logger=logger ) - input_collections, input_remainders = clique.assemble( + input_collections, input_remainder = clique.assemble( input_paths, patterns=[clique.PATTERNS["frames"]], assume_padded_when_ambiguous=True, ) - process_inputs = list(input_collections) - process_inputs.extend(input_remainders) - for _input in process_inputs: + input_items = list(input_collections) + input_items.extend(input_remainder) + for _input in input_items: # Prepare subprocess arguments oiio_cmd = get_oiio_tool_args( "oiiotool", From b43969da1c020af1fc209804d39363ef4d3c30c1 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 1 Apr 2025 09:08:12 +0200 Subject: [PATCH 03/99] add `from __future__ import annotations` --- client/ayon_core/lib/transcoding.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 2238b24c3b..f249213f2a 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import re import logging From 04c14cab7a9b5109d35d44ed9f441f15b0e7da1c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 1 Apr 2025 09:09:50 +0200 Subject: [PATCH 04/99] Remove deprecated function import --- client/ayon_core/lib/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 92c3966e77..8d8cc6af49 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -98,7 +98,6 @@ from .profiles_filtering import ( from .transcoding import ( get_transcode_temp_directory, should_convert_for_ffmpeg, - convert_for_ffmpeg, convert_input_paths_for_ffmpeg, get_ffprobe_data, get_ffprobe_streams, @@ -198,7 +197,6 @@ __all__ = [ "get_transcode_temp_directory", "should_convert_for_ffmpeg", - "convert_for_ffmpeg", "convert_input_paths_for_ffmpeg", "get_ffprobe_data", "get_ffprobe_streams", From 98e0ec105156d096f9dde519016760d68ae68e6d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Apr 2025 19:44:39 +0200 Subject: [PATCH 05/99] Improve parallelization for ExtractReview and ExtractOIIOTranscode - Support ExtractReview convert to FFMPEG in one `oiiotool` call for sequences - Support sequences with holes in both plug-ins by using dedicated `--frames` argument to `oiiotool` for more complex frame patterns. - Add `--parallel-frames` argument to `oiiotool` to allow parallelizing more of the OIIO tool process, improving throughput. Note: This requires OIIO 2.5.2.0 or higher. See https://github.com/AcademySoftwareFoundation/OpenImageIO/commit/f40f9800c83e2c596c127777bea1e468564fbb10 --- client/ayon_core/lib/transcoding.py | 57 ++++++++++++++++++- .../publish/extract_color_transcode.py | 48 ++++++++++------ 2 files changed, 85 insertions(+), 20 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 1fda014bd8..948bea3685 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -10,6 +10,8 @@ from typing import Optional import xml.etree.ElementTree +import clique + from .execute import run_subprocess from .vendor_bin_utils import ( get_ffmpeg_tool_args, @@ -707,7 +709,29 @@ def convert_input_paths_for_ffmpeg( # Collect channels to export input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) - for input_path in input_paths: + # Process input files + # If a sequence of files is detected we process it in one go + # with the dedicated --frames argument for faster processing + collections, remainder = clique.assemble( + input_paths, patterns=clique.PATTERNS["frame"]) + process_queue = collections + remainder + + for input_item in process_queue: + if isinstance(input_item, clique.Collection): + # Support sequences with holes by supplying dedicated `--frames` + # Create `frames` string like "1001-1002,1004,1010-1012 + frames: str = input_item.format("{ranges}").replace(" ", "") + # Create `filename` string like "file.%04d.exr" + input_path = input_item.format("{head}{padding}{tail}") + elif isinstance(input_item, str): + # Single filepath + frames = None + input_path = input_item + else: + raise TypeError( + f"Input is not a string or Collection: {input_item}" + ) + # Prepare subprocess arguments oiio_cmd = get_oiio_tool_args( "oiiotool", @@ -718,6 +742,14 @@ def convert_input_paths_for_ffmpeg( if compression: oiio_cmd.extend(["--compression", compression]) + if frames: + oiio_cmd.extend([ + "--frames", frames, + # TODO: Handle potential toggle for parallel frames + # to support older OIIO releases. + "--parallel-frames" + ]) + oiio_cmd.extend([ input_arg, input_path, # Tell oiiotool which channels should be put to top stack @@ -1106,6 +1138,8 @@ def convert_colorspace( view=None, display=None, additional_command_args=None, + frames=None, + parallel_frames=False, logger=None, ): """Convert source file from one color space to another. @@ -1114,7 +1148,7 @@ def convert_colorspace( input_path (str): Path that should be converted. It is expected that contains single file or image sequence of same type (sequence in format 'file.FRAMESTART-FRAMEEND#.ext', see oiio docs, - eg `big.1-3#.tif`) + eg `big.1-3#.tif` or `big.%04d.ext` with `frames` argument) output_path (str): Path to output filename. (must follow format of 'input_path', eg. single file or sequence in 'file.FRAMESTART-FRAMEEND#.ext', `output.1-3#.tif`) @@ -1128,6 +1162,11 @@ def convert_colorspace( both 'view' and 'display' must be filled (if 'target_colorspace') additional_command_args (list): arguments for oiiotool (like binary depth for .dpx) + frames (Optional[str]): Complex frame range to process. This requires + input path and output path to use frame token placeholder like + e.g. file.%04d.exr + parallel_frames (bool): If True, process frames in parallel inside + the `oiiotool` process. Only supported in OIIO 2.5.20.0+. logger (logging.Logger): Logger used for logging. Raises: ValueError: if misconfigured @@ -1145,9 +1184,21 @@ def convert_colorspace( "oiiotool", # Don't add any additional attributes "--nosoftwareattrib", - "--colorconfig", config_path + "--colorconfig", config_path, ) + if frames: + # If `frames` is specified, then process the input and output + # as if it's a sequence of frames (must contain `%04d` as frame + # token placeholder in filepaths) + oiio_cmd.extend([ + "--frames", frames, + ]) + if parallel_frames: + oiio_cmd.extend([ + "--parallel-frames" + ]) + oiio_cmd.extend([ input_arg, input_path, # Tell oiiotool which channels should be put to top stack diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 1f2c2a89af..25ac747302 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -159,9 +159,18 @@ class ExtractOIIOTranscode(publish.Extractor): files_to_convert) self.log.debug("Files to convert: {}".format(files_to_convert)) for file_name in files_to_convert: + # Handle special case for sequences where we specify + # the --frames argument to oiiotool + frames = None + parallel_frames = False + if isinstance(file_name, tuple): + file_name, frames = file_name + # TODO: Handle potential toggle for parallel frames + # to support older OIIO releases. + parallel_frames = True + self.log.debug("Transcoding file: `{}`".format(file_name)) - input_path = os.path.join(original_staging_dir, - file_name) + input_path = os.path.join(original_staging_dir, file_name) output_path = self._get_output_file_path(input_path, new_staging_dir, output_extension) @@ -175,7 +184,9 @@ class ExtractOIIOTranscode(publish.Extractor): view, display, additional_command_args, - self.log + frames=frames, + parallel_frames=parallel_frames, + logger=self.log ) # cleanup temporary transcoded files @@ -256,17 +267,22 @@ class ExtractOIIOTranscode(publish.Extractor): new_repre["files"] = renamed_files def _translate_to_sequence(self, files_to_convert): - """Returns original list or list with filename formatted in single - sequence format. + """Returns original individual filepaths or list of a single two-tuple + representating sequence filename with its frames. Uses clique to find frame sequence, in this case it merges all frames - into sequence format (FRAMESTART-FRAMEEND#) and returns it. - If sequence not found, it returns original list + into sequence format (`%04d`) together with all its frames to support + both regular sequences and sequences with holes. + + If sequence not detected in input filenames, it returns original list. Args: - files_to_convert (list): list of file names + files_to_convert (list[str]): list of file names Returns: - (list) of [file.1001-1010#.exr] or [fileA.exr, fileB.exr] + list[Union[str, tuple[str, str]]: List of + or filepaths ['fileA.exr', 'fileB.exr'] + or sequence with frames [('file.%04d.exr', '1001-1002,1004')] + """ pattern = [clique.PATTERNS["frames"]] collections, _ = clique.assemble( @@ -279,15 +295,13 @@ class ExtractOIIOTranscode(publish.Extractor): "Too many collections {}".format(collections)) collection = collections[0] - frames = list(collection.indexes) - if collection.holes(): - return files_to_convert - frame_str = "{}-{}#".format(frames[0], frames[-1]) - file_name = "{}{}{}".format(collection.head, frame_str, - collection.tail) - - files_to_convert = [file_name] + # Support sequences with holes by supplying dedicated `--frames` + # Create `frames` string like "1001-1002,1004,1010-1012 + frames: str = collection.format("{ranges}").replace(" ", "") + # Create `filename` string like "file.%04d.exr" + filename = collection.format("{head}{padding}{tail}") + return [(filename, frames)] return files_to_convert From 0aa0673b5769d2b2ca4474d0bf1b1a8b94daeb3b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Apr 2025 19:50:59 +0200 Subject: [PATCH 06/99] Use correct variable --- client/ayon_core/lib/transcoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index fe42429851..806d7481f2 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -640,7 +640,7 @@ def convert_input_paths_for_ffmpeg( frames = _input.format("{head}#{tail}").replace(" ", "") oiio_cmd.extend([ "--framepadding", _input.padding, - "--frames", _input.format("{ranges}"), + "--frames", frames, "--parallel-frames" ]) _input: str = _input.format("{head}#{tail}") From 01174c9b1181f046f25f1bcb00f6641e4d60c024 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Apr 2025 09:10:44 +0200 Subject: [PATCH 07/99] Provide more sensible return type for `_translate_to_sequence` --- .../publish/extract_color_transcode.py | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 25ac747302..52b2af6128 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -159,15 +159,21 @@ class ExtractOIIOTranscode(publish.Extractor): files_to_convert) self.log.debug("Files to convert: {}".format(files_to_convert)) for file_name in files_to_convert: - # Handle special case for sequences where we specify - # the --frames argument to oiiotool - frames = None - parallel_frames = False - if isinstance(file_name, tuple): - file_name, frames = file_name - # TODO: Handle potential toggle for parallel frames - # to support older OIIO releases. + if isinstance(file_name, clique.Collection): + # Support sequences with holes by supplying + # dedicated `--frames` argument to `oiiotool` + # Create `filename` string like "file.%04d.exr" + file_name = file_name.format("{head}{padding}{tail}") + # Create `frames` string like "1001-1002,1004,1010-1012 + frames: str = file_name.format("{ranges}").replace( + " ", "") parallel_frames = True + elif isinstance(file_name, str): + # Single file + frames = None + parallel_frames = False + else: + raise TypeError("Unsupported files to ") self.log.debug("Transcoding file: `{}`".format(file_name)) input_path = os.path.join(original_staging_dir, file_name) @@ -279,9 +285,9 @@ class ExtractOIIOTranscode(publish.Extractor): Args: files_to_convert (list[str]): list of file names Returns: - list[Union[str, tuple[str, str]]: List of - or filepaths ['fileA.exr', 'fileB.exr'] - or sequence with frames [('file.%04d.exr', '1001-1002,1004')] + list[str | clique.Collection]: List of + filepaths ['fileA.exr', 'fileB.exr'] + or clique.Collection for a sequence. """ pattern = [clique.PATTERNS["frames"]] @@ -294,14 +300,7 @@ class ExtractOIIOTranscode(publish.Extractor): raise ValueError( "Too many collections {}".format(collections)) - collection = collections[0] - - # Support sequences with holes by supplying dedicated `--frames` - # Create `frames` string like "1001-1002,1004,1010-1012 - frames: str = collection.format("{ranges}").replace(" ", "") - # Create `filename` string like "file.%04d.exr" - filename = collection.format("{head}{padding}{tail}") - return [(filename, frames)] + return collections return files_to_convert From 849a999744853e6390021759822f0fbb932ff58c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Apr 2025 09:11:44 +0200 Subject: [PATCH 08/99] Fix TypeError message --- client/ayon_core/plugins/publish/extract_color_transcode.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 52b2af6128..8988db59ec 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -173,7 +173,10 @@ class ExtractOIIOTranscode(publish.Extractor): frames = None parallel_frames = False else: - raise TypeError("Unsupported files to ") + raise TypeError( + f"Unsupported file name type: {type(file_name)}." + " Expected str or clique.Collection." + ) self.log.debug("Transcoding file: `{}`".format(file_name)) input_path = os.path.join(original_staging_dir, file_name) From ea5f1c81d61e049f306ef555f539fe24188f31a9 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Apr 2025 12:21:34 +0200 Subject: [PATCH 09/99] Fix passing sequence to `oiiotool` --- client/ayon_core/lib/transcoding.py | 13 +++++++++++-- .../plugins/publish/extract_color_transcode.py | 15 +++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 806d7481f2..41835fc8e4 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1011,6 +1011,7 @@ def convert_colorspace( display=None, additional_command_args=None, frames=None, + frame_padding=None, parallel_frames=False, logger=None, ): @@ -1020,7 +1021,7 @@ def convert_colorspace( input_path (str): Path that should be converted. It is expected that contains single file or image sequence of same type (sequence in format 'file.FRAMESTART-FRAMEEND#.ext', see oiio docs, - eg `big.1-3#.tif` or `big.%04d.ext` with `frames` argument) + eg `big.1-3#.tif` or `big.1-3%d.ext` with `frames` argument) output_path (str): Path to output filename. (must follow format of 'input_path', eg. single file or sequence in 'file.FRAMESTART-FRAMEEND#.ext', `output.1-3#.tif`) @@ -1036,9 +1037,11 @@ def convert_colorspace( depth for .dpx) frames (Optional[str]): Complex frame range to process. This requires input path and output path to use frame token placeholder like - e.g. file.%04d.exr + `#` or `%d`, e.g. file.#.exr parallel_frames (bool): If True, process frames in parallel inside the `oiiotool` process. Only supported in OIIO 2.5.20.0+. + frame_padding (Optional[int]): Frame padding to use for the input and + output when using a sequence filepath. logger (logging.Logger): Logger used for logging. Raises: ValueError: if misconfigured @@ -1066,6 +1069,12 @@ def convert_colorspace( oiio_cmd.extend([ "--frames", frames, ]) + + if frame_padding: + oiio_cmd.extend([ + "--framepadding", frame_padding, + ]) + if parallel_frames: oiio_cmd.extend([ "--parallel-frames" diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 8988db59ec..9d315052a2 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -162,15 +162,16 @@ class ExtractOIIOTranscode(publish.Extractor): if isinstance(file_name, clique.Collection): # Support sequences with holes by supplying # dedicated `--frames` argument to `oiiotool` - # Create `filename` string like "file.%04d.exr" - file_name = file_name.format("{head}{padding}{tail}") + # Create `filename` string like "file.#.exr" # Create `frames` string like "1001-1002,1004,1010-1012 - frames: str = file_name.format("{ranges}").replace( - " ", "") + file_name = file_name.format("{head}#{tail}") + frames = file_name.format("{ranges}").replace(" ", "") + frame_padding = file_name.padding parallel_frames = True elif isinstance(file_name, str): # Single file frames = None + frame_padding = None parallel_frames = False else: raise TypeError( @@ -194,6 +195,7 @@ class ExtractOIIOTranscode(publish.Extractor): display, additional_command_args, frames=frames, + frame_padding=frame_padding, parallel_frames=parallel_frames, logger=self.log ) @@ -279,10 +281,7 @@ class ExtractOIIOTranscode(publish.Extractor): """Returns original individual filepaths or list of a single two-tuple representating sequence filename with its frames. - Uses clique to find frame sequence, in this case it merges all frames - into sequence format (`%04d`) together with all its frames to support - both regular sequences and sequences with holes. - + Uses clique to find frame sequence, and return the collections instead. If sequence not detected in input filenames, it returns original list. Args: From 7bf2bfd6b16368f4aeb1fd5bd797a869d460c071 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Apr 2025 12:22:22 +0200 Subject: [PATCH 10/99] Improve docstring --- client/ayon_core/plugins/publish/extract_color_transcode.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 9d315052a2..13678610aa 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -278,8 +278,7 @@ class ExtractOIIOTranscode(publish.Extractor): new_repre["files"] = renamed_files def _translate_to_sequence(self, files_to_convert): - """Returns original individual filepaths or list of a single two-tuple - representating sequence filename with its frames. + """Returns original individual filepaths or list of clique.Collection. Uses clique to find frame sequence, and return the collections instead. If sequence not detected in input filenames, it returns original list. From 422febf4419bcdf165b8c81297c383e9ca171096 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Apr 2025 12:26:50 +0200 Subject: [PATCH 11/99] Fix variable usage --- client/ayon_core/plugins/publish/extract_color_transcode.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 13678610aa..c549ff8a63 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -162,11 +162,11 @@ class ExtractOIIOTranscode(publish.Extractor): if isinstance(file_name, clique.Collection): # Support sequences with holes by supplying # dedicated `--frames` argument to `oiiotool` - # Create `filename` string like "file.#.exr" # Create `frames` string like "1001-1002,1004,1010-1012 - file_name = file_name.format("{head}#{tail}") + # Create `filename` string like "file.#.exr" frames = file_name.format("{ranges}").replace(" ", "") frame_padding = file_name.padding + file_name = file_name.format("{head}#{tail}") parallel_frames = True elif isinstance(file_name, str): # Single file From 537dac603358bea4b8b07806f6ce2854919aeab5 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Apr 2025 12:47:20 +0200 Subject: [PATCH 12/99] Fix `get_oiio_info_for_input` call for sequences in `convert_colorspace` --- client/ayon_core/lib/transcoding.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 41835fc8e4..06ea353aa2 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1049,7 +1049,16 @@ def convert_colorspace( if logger is None: logger = logging.getLogger(__name__) - input_info = get_oiio_info_for_input(input_path, logger=logger) + # Get oiioinfo only from first image, otherwise file can't be found + first_input_path = input_path + if frames: + assert isinstance(frames, str) # for type hints + first_frame = int(frames.split(" x-,")[0]) + first_frame = str(first_frame).zfill(frame_padding or 0) + for token in ["#", "%d"]: + first_input_path = first_input_path.replace(token, first_frame) + + input_info = get_oiio_info_for_input(first_input_path, logger=logger) # Collect channels to export input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) From ec9c6c510a8d0fdb1143f2708141736a50d06dec Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Apr 2025 14:14:27 +0200 Subject: [PATCH 13/99] Split on any of the characters as intended, instead of on literal ` x-` --- client/ayon_core/lib/transcoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 06ea353aa2..7073ba6b89 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1053,7 +1053,7 @@ def convert_colorspace( first_input_path = input_path if frames: assert isinstance(frames, str) # for type hints - first_frame = int(frames.split(" x-,")[0]) + first_frame = int(re.split("[ x-]", frames, 1)[0]) first_frame = str(first_frame).zfill(frame_padding or 0) for token in ["#", "%d"]: first_input_path = first_input_path.replace(token, first_frame) From 3248faff40225ecd884609e05bcaeafbd6c7a825 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Apr 2025 14:57:40 +0200 Subject: [PATCH 14/99] Fix `int` -> `str` frame padding argument to subprocess --- client/ayon_core/lib/transcoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 7073ba6b89..82038ed543 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1081,7 +1081,7 @@ def convert_colorspace( if frame_padding: oiio_cmd.extend([ - "--framepadding", frame_padding, + "--framepadding", str(frame_padding), ]) if parallel_frames: From 204625b5c855d98f3eaf8f8d4336b3a524b12b56 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 20 May 2025 23:55:29 +0200 Subject: [PATCH 15/99] Update client/ayon_core/lib/transcoding.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/lib/transcoding.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 82038ed543..e60d9d75c8 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -552,12 +552,11 @@ def _get_attributes_to_erase( erase_attrs[attr_name] = reason break - if logger is not None: - for attr_name, reason in erase_attrs.items(): - logger.info( - f"Removed attribute \"{attr_name}\" from metadata" - f" because {reason}." - ) + for attr_name, reason in erase_attrs.items(): + logger.info( + f"Removed attribute \"{attr_name}\" from metadata" + f" because {reason}." + ) return list(erase_attrs.keys()) From bcdeba18ac65d401b9b90add03f4c8bdce3be1ee Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 2 Jun 2025 09:29:04 +0200 Subject: [PATCH 16/99] :wrench: implementation WIP --- client/ayon_core/pipeline/create/context.py | 18 +++++++++++ .../pipeline/create/creator_plugins.py | 32 ++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index f0d9fa8927..e6cc4393c5 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -18,6 +18,7 @@ from typing import ( Callable, Union, ) +from warnings import warn import pyblish.logic import pyblish.api @@ -31,6 +32,7 @@ from ayon_core.host import IPublishHost, IWorkfileHost from ayon_core.pipeline import Anatomy from ayon_core.pipeline.template_data import get_template_data from ayon_core.pipeline.plugin_discover import DiscoverResult +from ayon_core.pipeline import is_supporting_product_base_type from .exceptions import ( CreatorError, @@ -1194,6 +1196,22 @@ class CreateContext: "productType": creator.product_type, "variant": variant } + + # Add product base type if supported. + # TODO (antirotor): Once all creators support product base type + # remove this check. + if is_supporting_product_base_type(): + + if hasattr(creator, "product_base_type"): + instance_data["productBaseType"] = creator.product_base_type + else: + warn( + f"Creator {creator_identifier} does not support " + "product base type. This will be required in future.", + DeprecationWarning, + stacklevel=2, + ) + if active is not None: if not isinstance(active, bool): self.log.warning( diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index cbc06145fb..ad4b777db5 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -3,6 +3,7 @@ import os import copy import collections from typing import TYPE_CHECKING, Optional, Dict, Any +from warnings import warn from abc import ABC, abstractmethod @@ -16,6 +17,7 @@ from ayon_core.pipeline.plugin_discover import ( deregister_plugin_path ) from ayon_core.pipeline.staging_dir import get_staging_dir_info, StagingDir +from ayon_core.pipeline import is_supporting_product_base_type from .constants import DEFAULT_VARIANT_VALUE from .product_name import get_product_name @@ -308,6 +310,9 @@ class BaseCreator(ABC): Default implementation returns plugin's product type. """ + if is_supporting_product_base_type(): + return self.product_base_type + return self.product_type @property @@ -317,6 +322,16 @@ class BaseCreator(ABC): pass + @property + @abstractmethod + def product_base_type(self): + """Base product type that plugin represents. + + This is used to group products in UI. + """ + + pass + @property def project_name(self): """Current project name. @@ -378,7 +393,8 @@ class BaseCreator(ABC): self, product_name: str, data: Dict[str, Any], - product_type: Optional[str] = None + product_type: Optional[str] = None, + product_base_type: Optional[str] = None ) -> CreatedInstance: """Create instance and add instance to context. @@ -387,6 +403,8 @@ class BaseCreator(ABC): data (Dict[str, Any]): Instance data. product_type (Optional[str]): Product type, object attribute 'product_type' is used if not passed. + product_base_type (Optional[str]): Product base type, object + attribute 'product_type' is used if not passed. Returns: CreatedInstance: Created instance. @@ -394,6 +412,18 @@ class BaseCreator(ABC): """ if product_type is None: product_type = self.product_type + + if is_supporting_product_base_type() and not product_base_type: + if not self.product_base_type: + warn( + f"Creator {self.identifier} does not support " + "product base type. This will be required in future.", + DeprecationWarning, + stacklevel=2, + ) + else: + product_base_type = self.product_base_type + instance = CreatedInstance( product_type, product_name, From 9e730a6b5b1ecdf1cc67ea8609034e43929ab303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 3 Jun 2025 10:58:43 +0200 Subject: [PATCH 17/99] :wrench: changes in Plugin anc CreateInstance WIP --- .../pipeline/create/creator_plugins.py | 63 +++++++++++-------- .../ayon_core/pipeline/create/structures.py | 20 ++++++ 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index ad4b777db5..bb824f52e3 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -17,7 +17,7 @@ from ayon_core.pipeline.plugin_discover import ( deregister_plugin_path ) from ayon_core.pipeline.staging_dir import get_staging_dir_info, StagingDir -from ayon_core.pipeline import is_supporting_product_base_type +from ayon_core.pipeline.compatibility import is_supporting_product_base_type from .constants import DEFAULT_VARIANT_VALUE from .product_name import get_product_name @@ -308,12 +308,15 @@ class BaseCreator(ABC): """Identifier of creator (must be unique). Default implementation returns plugin's product type. + """ - + identifier = self.product_type if is_supporting_product_base_type(): - return self.product_base_type + identifier = self.product_base_type + if self.product_type: + identifier = f"{identifier}.{self.product_type}" + return identifier - return self.product_type @property @abstractmethod @@ -323,14 +326,19 @@ class BaseCreator(ABC): pass @property - @abstractmethod - def product_base_type(self): + def product_base_type(self) -> Optional[str]: """Base product type that plugin represents. - This is used to group products in UI. - """ + Todo (antirotor): This should be required in future - it + should be made abstract then. - pass + Returns: + Optional[str]: Base product type that plugin represents. + If not set, it is assumed that the creator plugin is obsolete + and does not support product base type. + + """ + return None @property def project_name(self): @@ -361,13 +369,14 @@ class BaseCreator(ABC): Default implementation use attributes in this order: - 'group_label' -> 'label' -> 'identifier' - Keep in mind that 'identifier' use 'product_type' by default. + + Keep in mind that 'identifier' uses 'product_base_type' by default. Returns: str: Group label that can be used for grouping of instances in UI. - Group label can be overridden by instance itself. + Group label can be overridden by the instance itself. + """ - if self._cached_group_label is None: label = self.identifier if self.group_label: @@ -413,22 +422,26 @@ class BaseCreator(ABC): if product_type is None: product_type = self.product_type - if is_supporting_product_base_type() and not product_base_type: - if not self.product_base_type: - warn( - f"Creator {self.identifier} does not support " - "product base type. This will be required in future.", - DeprecationWarning, - stacklevel=2, - ) - else: - product_base_type = self.product_base_type + if ( + is_supporting_product_base_type() + and not product_base_type + and not self.product_base_type + ): + warn( + f"Creator {self.identifier} does not support " + "product base type. This will be required in future.", + DeprecationWarning, + stacklevel=2, + ) + else: + product_base_type = self.product_base_type instance = CreatedInstance( - product_type, - product_name, - data, + product_type=product_type, + product_name=product_name, + data=data, creator=self, + product_base_type=product_base_type ) self._add_instance_to_context(instance) return instance diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index d7ba6b9c24..389ce25961 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -3,6 +3,7 @@ import collections from uuid import uuid4 import typing from typing import Optional, Dict, List, Any +import warnings from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, @@ -10,6 +11,9 @@ from ayon_core.lib.attribute_definitions import ( serialize_attr_defs, deserialize_attr_defs, ) + +from ayon_core.pipeline.compatibility import is_supporting_product_base_type + from ayon_core.pipeline import ( AYON_INSTANCE_ID, AVALON_INSTANCE_ID, @@ -471,6 +475,7 @@ class CreatedInstance: "id", "instance_id", "productType", + "productBaseType", "creator_identifier", "creator_attributes", "publish_attributes" @@ -490,7 +495,17 @@ class CreatedInstance: data: Dict[str, Any], creator: "BaseCreator", transient_data: Optional[Dict[str, Any]] = None, + product_base_type: Optional[str] = None ): + + if is_supporting_product_base_type() and product_base_type is None: + warnings.warn( + f"Creator {creator!r} doesn't support " + "product base type. This will be required in future.", + DeprecationWarning, + stacklevel=2 + ) + self._creator = creator creator_identifier = creator.identifier group_label = creator.get_group_label() @@ -540,6 +555,11 @@ class CreatedInstance: self._data["id"] = item_id self._data["productType"] = product_type self._data["productName"] = product_name + + if is_supporting_product_base_type(): + data.pop("productBaseType", None) + self._data["productBaseType"] = product_base_type + self._data["active"] = data.get("active", True) self._data["creator_identifier"] = creator_identifier From d237e5f54cd466957699a350e9f8978a743eac7f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 3 Jun 2025 17:25:32 +0200 Subject: [PATCH 18/99] :art: add support for product base type to basic creator logic --- client/ayon_core/pipeline/create/context.py | 2 +- .../pipeline/create/creator_plugins.py | 23 ++-- .../ayon_core/pipeline/create/product_name.py | 117 ++++++++++++------ .../ayon_core/pipeline/create/structures.py | 26 ++-- .../tools/publisher/models/create.py | 7 ++ 5 files changed, 119 insertions(+), 56 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index e6cc4393c5..f267450543 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -32,7 +32,7 @@ from ayon_core.host import IPublishHost, IWorkfileHost from ayon_core.pipeline import Anatomy from ayon_core.pipeline.template_data import get_template_data from ayon_core.pipeline.plugin_discover import DiscoverResult -from ayon_core.pipeline import is_supporting_product_base_type +from ayon_core.pipeline.compatibility import is_supporting_product_base_type from .exceptions import ( CreatorError, diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index bb824f52e3..155a443b53 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -317,7 +317,6 @@ class BaseCreator(ABC): identifier = f"{identifier}.{self.product_type}" return identifier - @property @abstractmethod def product_type(self): @@ -562,14 +561,15 @@ class BaseCreator(ABC): def get_product_name( self, - project_name, - folder_entity, - task_entity, - variant, - host_name=None, - instance=None, - project_entity=None, - ): + project_name: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + variant: str, + host_name: Optional[str] = None, + instance: Optional[CreatedInstance] = None, + project_entity: Optional[dict[str, Any]] = None, + product_base_type: Optional[str] = None, + ) -> str: """Return product name for passed context. Method is also called on product name update. In that case origin @@ -586,8 +586,12 @@ class BaseCreator(ABC): for which is product name updated. Passed only on product name update. project_entity (Optional[dict[str, Any]]): Project entity. + product_base_type (Optional[str]): Product base type. """ + if is_supporting_product_base_type() and (instance and hasattr(instance, "product_base_type")): # noqa: E501 + product_base_type = instance.product_base_type + if host_name is None: host_name = self.create_context.host_name @@ -619,6 +623,7 @@ class BaseCreator(ABC): dynamic_data=dynamic_data, project_settings=self.project_settings, project_entity=project_entity, + product_base_type=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 ecffa4a340..3fdf786b0e 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -1,9 +1,16 @@ +"""Functions for handling product names.""" +from __future__ import annotations + +from typing import Any, Optional, Union +from warnings import warn + import ayon_api from ayon_core.lib import ( StringTemplate, filter_profiles, prepare_template_data, ) +from ayon_core.pipeline.compatibility import is_supporting_product_base_type from ayon_core.settings import get_project_settings from .constants import DEFAULT_PRODUCT_TEMPLATE @@ -11,14 +18,15 @@ from .exceptions import TaskNotSetError, TemplateFillError def get_product_name_template( - project_name, - product_type, - task_name, - task_type, - host_name, - default_template=None, - project_settings=None -): + project_name: str, + product_type: str, + task_name: str, + task_type: str, + host_name: str, + default_template: Optional[str] = None, + project_settings: Optional[dict[str, Any]] = None, + product_base_type: Optional[str] = None +) -> str: """Get product name template based on passed context. Args: @@ -28,13 +36,17 @@ 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 are queried if not passed. - """ + product_base_type (Optional[str]): Base type of product. + Returns: + str: Product name template. + + """ if project_settings is None: project_settings = get_project_settings(project_name) tools_settings = project_settings["core"]["tools"] @@ -46,6 +58,15 @@ def get_product_name_template( "task_types": task_type } + if is_supporting_product_base_type(): + if product_base_type: + filtering_criteria["product_base_types"] = product_base_type + else: + warn( + "Product base type is not provided, please update your" + "creation code to include it. It will be required in " + "the future.", DeprecationWarning, stacklevel=2) + matching_profile = filter_profiles(profiles, filtering_criteria) template = None if matching_profile: @@ -70,17 +91,18 @@ def get_product_name_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, + project_name: str, + task_name: str, + task_type: str, + host_name: str, + product_type: str, + variant: str, + default_template: Optional[str] = None, + dynamic_data: Optional[dict[str, Any]] = None, + project_settings: Optional[dict[str, Any]] = None, + product_type_filter: Optional[str] = None, + project_entity: Optional[dict[str, Any]] = None, + product_base_type: Optional[str] = None ): """Calculate product name based on passed context and AYON settings. @@ -92,14 +114,20 @@ def get_product_name( 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 (Union[str, None]): Task name. - task_type (Union[str, None]): Task type. + task_name (str): Task name. + task_type (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. @@ -115,6 +143,8 @@ def get_product_name( 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. @@ -129,13 +159,14 @@ def get_product_name( return "" template = get_product_name_template( - project_name, - product_type_filter or product_type, - task_name, - task_type, - host_name, + project_name=project_name, + product_type=product_type_filter or product_type, + task_name=task_name, + task_type=task_type, + host_name=host_name, default_template=default_template, - project_settings=project_settings + 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 @@ -147,7 +178,7 @@ def get_product_name( "type": task_type, } if "{task}" in template.lower(): - task_value = task_name + task_value["name"] = task_name elif "{task[short]}" in template.lower(): if project_entity is None: @@ -159,14 +190,25 @@ def get_product_name( task_short = task_types_by_name.get(task_type, {}).get("shortName") task_value["short"] = task_short - fill_pairs = { + # look what we have to do to make mypy happy. We should stop using + # those undefined dict based types. + product: dict[str, str] = {"type": product_type} + if is_supporting_product_base_type(): + if product_base_type: + product["baseType"] = product_base_type + elif "{product[basetype]}" in template.lower(): + warn( + "You have Product base type in product name template," + "but it is not provided by the creator, please update your" + "creation code to include it. It will be required in " + "the future.", DeprecationWarning, stacklevel=2) + fill_pairs: dict[str, Union[str, dict[str, str]]] = { "variant": variant, "family": product_type, "task": task_value, - "product": { - "type": product_type - } + "product": product, } + if dynamic_data: # Dynamic data may override default values for key, value in dynamic_data.items(): @@ -178,7 +220,8 @@ def get_product_name( data=prepare_template_data(fill_pairs) ) except KeyError as exp: - raise TemplateFillError( - "Value for {} key is missing in template '{}'." - " Available values are {}".format(str(exp), template, fill_pairs) + msg = ( + f"Value for {exp} key is missing in template '{template}'." + f" Available values are {fill_pairs}" ) + raise TemplateFillError(msg) from exp diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 389ce25961..a6b57c29ca 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -3,7 +3,7 @@ import collections from uuid import uuid4 import typing from typing import Optional, Dict, List, Any -import warnings +from warnings import warn from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, @@ -465,6 +465,10 @@ class CreatedInstance: data (Dict[str, Any]): Data used for filling product name or override data from already existing instance. creator (BaseCreator): Creator responsible for instance. + product_base_type (Optional[str]): Product base type that will be + created. If not provided then product base type is taken from + creator plugin. If creator does not have product base type then + deprecation warning is raised. """ # Keys that can't be changed or removed from data after loading using @@ -497,14 +501,18 @@ class CreatedInstance: transient_data: Optional[Dict[str, Any]] = None, product_base_type: Optional[str] = None ): - - if is_supporting_product_base_type() and product_base_type is None: - warnings.warn( - f"Creator {creator!r} doesn't support " - "product base type. This will be required in future.", - DeprecationWarning, - stacklevel=2 - ) + """Initialize CreatedInstance.""" + if is_supporting_product_base_type(): + if not hasattr(creator, "product_base_type"): + warn( + f"Provided creator {creator!r} doesn't have " + "product base type attribute defined. This will be " + "required in future.", + DeprecationWarning, + stacklevel=2 + ) + elif not product_base_type: + product_base_type = creator.product_base_type self._creator = creator creator_identifier = creator.identifier diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 900168eaef..862bd1ea03 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -34,6 +34,8 @@ from ayon_core.pipeline.create import ( ConvertorsOperationFailed, ConvertorItem, ) +from ayon_core.pipeline.compatibility import is_supporting_product_base_type + from ayon_core.tools.publisher.abstract import ( AbstractPublisherBackend, CardMessageTypes, @@ -631,12 +633,17 @@ class CreateModel: "instance": instance, "project_entity": project_entity, } + + if is_supporting_product_base_type() and hasattr(creator, "product_base_type"): # noqa: E501 + kwargs["product_base_type"] = creator.product_base_type + # Backwards compatibility for 'project_entity' argument # - 'get_product_name' signature changed 24/07/08 if not is_func_signature_supported( creator.get_product_name, *args, **kwargs ): kwargs.pop("project_entity") + kwargs.pop("product_base_type") return creator.get_product_name(*args, **kwargs) def create( From 67db5c123ff72b41b53209310c6bae339de1d8ed Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 4 Jun 2025 11:24:22 +0200 Subject: [PATCH 19/99] :dog: linter fixes --- .../pipeline/create/creator_plugins.py | 99 +++++-------------- 1 file changed, 25 insertions(+), 74 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 155a443b53..040ed073f2 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -1,32 +1,32 @@ -# -*- coding: utf-8 -*- -import os -import copy +"""Creator plugins for the create process.""" import collections -from typing import TYPE_CHECKING, Optional, Dict, Any +import copy +import os +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Dict, Optional from warnings import warn -from abc import ABC, abstractmethod - -from ayon_core.settings import get_project_settings from ayon_core.lib import Logger, get_version_from_path +from ayon_core.pipeline.compatibility import is_supporting_product_base_type from ayon_core.pipeline.plugin_discover import ( + deregister_plugin, + deregister_plugin_path, discover, register_plugin, register_plugin_path, - deregister_plugin, - deregister_plugin_path ) -from ayon_core.pipeline.staging_dir import get_staging_dir_info, StagingDir -from ayon_core.pipeline.compatibility import is_supporting_product_base_type +from ayon_core.pipeline.staging_dir import StagingDir, get_staging_dir_info +from ayon_core.settings import get_project_settings from .constants import DEFAULT_VARIANT_VALUE -from .product_name import get_product_name -from .utils import get_next_versions_for_instances from .legacy_create import LegacyCreator +from .product_name import get_product_name from .structures import CreatedInstance +from .utils import get_next_versions_for_instances if TYPE_CHECKING: from ayon_core.lib import AbstractAttrDef + # Avoid cyclic imports from .context import CreateContext, UpdateData # noqa: F401 @@ -70,7 +70,6 @@ class ProductConvertorPlugin(ABC): Returns: logging.Logger: Logger with name of the plugin. """ - if self._log is None: self._log = Logger.get_logger(self.__class__.__name__) return self._log @@ -86,9 +85,8 @@ class ProductConvertorPlugin(ABC): Returns: str: Converted identifier unique for all converters in host. - """ - pass + """ @abstractmethod def find_instances(self): @@ -98,14 +96,10 @@ class ProductConvertorPlugin(ABC): convert. """ - pass - @abstractmethod def convert(self): """Conversion code.""" - pass - @property def create_context(self): """Quick access to create context. @@ -113,7 +107,6 @@ class ProductConvertorPlugin(ABC): Returns: CreateContext: Context which initialized the plugin. """ - return self._create_context @property @@ -126,7 +119,6 @@ class ProductConvertorPlugin(ABC): Raises: UnavailableSharedData: When called out of collection phase. """ - return self._create_context.collection_shared_data def add_convertor_item(self, label): @@ -135,12 +127,10 @@ class ProductConvertorPlugin(ABC): Args: label (str): Label of item which will show in UI. """ - self._create_context.add_convertor_item(self.identifier, label) def remove_convertor_item(self): """Remove legacy item from create context when conversion finished.""" - self._create_context.remove_convertor_item(self.identifier) @@ -159,7 +149,6 @@ class BaseCreator(ABC): create_context (CreateContext): Context which initialized creator. headless (bool): Running in headless mode. """ - # Label shown in UI label = None group_label = None @@ -223,7 +212,6 @@ class BaseCreator(ABC): Returns: Optional[dict[str, Any]]: Settings values or None. """ - settings = project_settings.get(category_name) if not settings: return None @@ -269,7 +257,6 @@ class BaseCreator(ABC): Args: project_settings (dict[str, Any]): Project settings. """ - settings_category = self.settings_category if not settings_category: return @@ -281,18 +268,17 @@ class BaseCreator(ABC): project_settings, settings_category, settings_name ) if settings is None: - self.log.debug("No settings found for {}".format(cls_name)) + self.log.debug(f"No settings found for {cls_name}") return for key, value in settings.items(): # Log out attributes that are not defined on plugin object # - those may be potential dangerous typos in settings if not hasattr(self, key): - self.log.debug(( - "Applying settings to unknown attribute '{}' on '{}'." - ).format( + self.log.debug( + "Applying settings to unknown attribute '%' on '%'.", key, cls_name - )) + ) setattr(self, key, value) def register_callbacks(self): @@ -301,7 +287,6 @@ class BaseCreator(ABC): Default implementation does nothing. It can be overridden to register callbacks for creator. """ - pass @property def identifier(self): @@ -322,8 +307,6 @@ class BaseCreator(ABC): def product_type(self): """Family that plugin represents.""" - pass - @property def product_base_type(self) -> Optional[str]: """Base product type that plugin represents. @@ -346,7 +329,6 @@ class BaseCreator(ABC): Returns: str: Name of a project. """ - return self.create_context.project_name @property @@ -356,7 +338,6 @@ class BaseCreator(ABC): Returns: Anatomy: Project anatomy object. """ - return self.create_context.project_anatomy @property @@ -368,13 +349,13 @@ class BaseCreator(ABC): Default implementation use attributes in this order: - 'group_label' -> 'label' -> 'identifier' - + Keep in mind that 'identifier' uses 'product_base_type' by default. Returns: str: Group label that can be used for grouping of instances in UI. Group label can be overridden by the instance itself. - + """ if self._cached_group_label is None: label = self.identifier @@ -392,7 +373,6 @@ class BaseCreator(ABC): Returns: logging.Logger: Logger with name of the plugin. """ - if self._log is None: self._log = Logger.get_logger(self.__class__.__name__) return self._log @@ -456,7 +436,6 @@ class BaseCreator(ABC): Args: instance (CreatedInstance): New created instance. """ - self.create_context.creator_adds_instance(instance) def _remove_instance_from_context(self, instance): @@ -469,7 +448,6 @@ class BaseCreator(ABC): Args: instance (CreatedInstance): Instance which should be removed. """ - self.create_context.creator_removed_instance(instance) @abstractmethod @@ -481,8 +459,6 @@ class BaseCreator(ABC): implementation """ - pass - @abstractmethod def collect_instances(self): """Collect existing instances related to this creator plugin. @@ -508,8 +484,6 @@ class BaseCreator(ABC): ``` """ - pass - @abstractmethod def update_instances(self, update_list): """Store changes of existing instances so they can be recollected. @@ -519,8 +493,6 @@ class BaseCreator(ABC): contain changed instance and it's changes. """ - pass - @abstractmethod def remove_instances(self, instances): """Method called on instance removal. @@ -533,14 +505,11 @@ class BaseCreator(ABC): removed. """ - pass - def get_icon(self): """Icon of creator (product type). Can return path to image file or awesome icon name. """ - return self.icon def get_dynamic_data( @@ -556,7 +525,6 @@ class BaseCreator(ABC): These may be dynamically created based on current context of workfile. """ - return {} def get_product_name( @@ -633,15 +601,15 @@ class BaseCreator(ABC): and values are stored to metadata for future usage and for publishing purposes. - NOTE: - Convert method should be implemented which should care about updating - keys/values when plugin attributes change. + Note: + Convert method should be implemented which should care about + updating keys/values when plugin attributes change. Returns: list[AbstractAttrDef]: Attribute definitions that can be tweaked for created instance. - """ + """ return self.instance_attr_defs def get_attr_defs_for_instance(self, instance): @@ -664,12 +632,10 @@ class BaseCreator(ABC): Raises: UnavailableSharedData: When called out of collection phase. """ - return self.create_context.collection_shared_data def set_instance_thumbnail_path(self, instance_id, thumbnail_path=None): """Set path to thumbnail for instance.""" - self.create_context.thumbnail_paths_by_instance_id[instance_id] = ( thumbnail_path ) @@ -690,7 +656,6 @@ class BaseCreator(ABC): Returns: dict[str, int]: Next versions by instance id. """ - return get_next_versions_for_instances( self.create_context.project_name, instances ) @@ -757,7 +722,6 @@ class Creator(BaseCreator): int: Order in which is creator shown (less == earlier). By default is using Creator's 'order' or processing. """ - return self.order @abstractmethod @@ -772,11 +736,9 @@ class Creator(BaseCreator): pre_create_data(dict): Data based on pre creation attributes. Those may affect how creator works. """ - # instance = CreatedInstance( # self.product_type, product_name, instance_data # ) - pass def get_description(self): """Short description of product type and plugin. @@ -784,7 +746,6 @@ class Creator(BaseCreator): Returns: str: Short description of product type. """ - return self.description def get_detail_description(self): @@ -795,7 +756,6 @@ class Creator(BaseCreator): Returns: str: Detailed description of product type for artist. """ - return self.detailed_description def get_default_variants(self): @@ -809,7 +769,6 @@ class Creator(BaseCreator): Returns: list[str]: Whisper variants for user input. """ - return copy.deepcopy(self.default_variants) def get_default_variant(self, only_explicit=False): @@ -829,7 +788,6 @@ class Creator(BaseCreator): Returns: str: Variant value. """ - if only_explicit or self._default_variant: return self._default_variant @@ -850,7 +808,6 @@ class Creator(BaseCreator): Returns: str: Variant value. """ - return self.get_default_variant() def _set_default_variant_wrap(self, variant): @@ -862,7 +819,6 @@ class Creator(BaseCreator): Args: variant (str): New default variant value. """ - self._default_variant = variant default_variant = property( @@ -1012,7 +968,6 @@ class AutoCreator(BaseCreator): def remove_instances(self, instances): """Skip removal.""" - pass def discover_creator_plugins(*args, **kwargs): @@ -1036,9 +991,7 @@ def discover_legacy_creator_plugins(): plugin.apply_settings(project_settings) except Exception: log.warning( - "Failed to apply settings to creator {}".format( - plugin.__name__ - ), + "Failed to apply settings to creator %s", plugin.__name__, exc_info=True ) return plugins @@ -1055,7 +1008,6 @@ def get_legacy_creator_by_name(creator_name, case_sensitive=False): Returns: Creator: Return first matching plugin or `None`. """ - # Lower input creator name if is not case sensitive if not case_sensitive: creator_name = creator_name.lower() @@ -1127,7 +1079,6 @@ def cache_and_get_instances(creator, shared_key, list_instances_func): dict[str, dict[str, Any]]: Cached instances by creator identifier from result of passed function. """ - if shared_key not in creator.collection_shared_data: value = collections.defaultdict(list) for instance in list_instances_func(): From fce1ef248d4292b80a479eb156326aa002d4c48f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 4 Jun 2025 11:28:07 +0200 Subject: [PATCH 20/99] :dog: some more linter fixes --- 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 3fdf786b0e..1f0e8f3ba5 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -117,8 +117,8 @@ def get_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. + 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 From dfd8fe6e8cd9fbd5fe6e930189065623183b3020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 6 Jun 2025 10:33:25 +0200 Subject: [PATCH 21/99] :bug: report correctly skipped abstract creators --- client/ayon_core/pipeline/create/context.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index f267450543..fcc18555b5 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -731,13 +731,12 @@ class CreateContext: manual_creators = {} report = discover_creator_plugins(return_report=True) self.creator_discover_result = report - for creator_class in report.plugins: - if inspect.isabstract(creator_class): - self.log.debug( - "Skipping abstract Creator {}".format(str(creator_class)) - ) - continue + for creator_class in report.abstract_plugins: + self.log.debug( + f"Skipping abstract Creator '%s'", str(creator_class) + ) + for creator_class in report.plugins: creator_identifier = creator_class.identifier if creator_identifier in creators: self.log.warning( From 8fbb8c93c12b75dd29e1ca16e6e5567bf846baec Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 6 Jun 2025 14:47:41 +0200 Subject: [PATCH 22/99] Allow more frames patterns --- client/ayon_core/lib/transcoding.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index f249213f2a..3f30f771c1 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -616,9 +616,12 @@ def convert_input_paths_for_ffmpeg( input_info, logger=logger ) + # clique.PATTERNS["frames"] supports only `.1001.exr` not `_1001.exr` so + # we use a customized pattern. + pattern = "[_.](?P(?P0*)\\d+)\\.\\D+\\d?$" input_collections, input_remainder = clique.assemble( input_paths, - patterns=[clique.PATTERNS["frames"]], + patterns=[pattern], assume_padded_when_ambiguous=True, ) input_items = list(input_collections) From fa8c05488943dcd4f8195b4bcffc83e9cd45c37a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 9 Jun 2025 13:54:41 +0200 Subject: [PATCH 23/99] :recycle: refactor support feature check function name --- client/ayon_core/pipeline/create/context.py | 4 ++-- client/ayon_core/pipeline/create/creator_plugins.py | 8 ++++---- client/ayon_core/pipeline/create/product_name.py | 6 +++--- client/ayon_core/pipeline/create/structures.py | 6 +++--- client/ayon_core/tools/publisher/models/create.py | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index fcc18555b5..a5a9d5a64a 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -32,7 +32,7 @@ from ayon_core.host import IPublishHost, IWorkfileHost from ayon_core.pipeline import Anatomy from ayon_core.pipeline.template_data import get_template_data from ayon_core.pipeline.plugin_discover import DiscoverResult -from ayon_core.pipeline.compatibility import is_supporting_product_base_type +from ayon_core.pipeline.compatibility import is_product_base_type_supported from .exceptions import ( CreatorError, @@ -1199,7 +1199,7 @@ class CreateContext: # Add product base type if supported. # TODO (antirotor): Once all creators support product base type # remove this check. - if is_supporting_product_base_type(): + if is_product_base_type_supported(): if hasattr(creator, "product_base_type"): instance_data["productBaseType"] = creator.product_base_type diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 040ed073f2..78fb723567 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Dict, Optional from warnings import warn from ayon_core.lib import Logger, get_version_from_path -from ayon_core.pipeline.compatibility import is_supporting_product_base_type +from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.pipeline.plugin_discover import ( deregister_plugin, deregister_plugin_path, @@ -296,7 +296,7 @@ class BaseCreator(ABC): """ identifier = self.product_type - if is_supporting_product_base_type(): + if is_product_base_type_supported(): identifier = self.product_base_type if self.product_type: identifier = f"{identifier}.{self.product_type}" @@ -402,7 +402,7 @@ class BaseCreator(ABC): product_type = self.product_type if ( - is_supporting_product_base_type() + is_product_base_type_supported() and not product_base_type and not self.product_base_type ): @@ -557,7 +557,7 @@ class BaseCreator(ABC): product_base_type (Optional[str]): Product base type. """ - if is_supporting_product_base_type() and (instance and hasattr(instance, "product_base_type")): # noqa: E501 + if is_product_base_type_supported() and (instance and hasattr(instance, "product_base_type")): # noqa: E501 product_base_type = instance.product_base_type if host_name is None: diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 1f0e8f3ba5..ab7de0c9e8 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -10,7 +10,7 @@ from ayon_core.lib import ( filter_profiles, prepare_template_data, ) -from ayon_core.pipeline.compatibility import is_supporting_product_base_type +from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.settings import get_project_settings from .constants import DEFAULT_PRODUCT_TEMPLATE @@ -58,7 +58,7 @@ def get_product_name_template( "task_types": task_type } - if is_supporting_product_base_type(): + if is_product_base_type_supported(): if product_base_type: filtering_criteria["product_base_types"] = product_base_type else: @@ -193,7 +193,7 @@ def get_product_name( # look what we have to do to make mypy happy. We should stop using # those undefined dict based types. product: dict[str, str] = {"type": product_type} - if is_supporting_product_base_type(): + if is_product_base_type_supported(): if product_base_type: product["baseType"] = product_base_type elif "{product[basetype]}" in template.lower(): diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index a6b57c29ca..aad85a546a 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -12,7 +12,7 @@ from ayon_core.lib.attribute_definitions import ( deserialize_attr_defs, ) -from ayon_core.pipeline.compatibility import is_supporting_product_base_type +from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.pipeline import ( AYON_INSTANCE_ID, @@ -502,7 +502,7 @@ class CreatedInstance: product_base_type: Optional[str] = None ): """Initialize CreatedInstance.""" - if is_supporting_product_base_type(): + if is_product_base_type_supported(): if not hasattr(creator, "product_base_type"): warn( f"Provided creator {creator!r} doesn't have " @@ -564,7 +564,7 @@ class CreatedInstance: self._data["productType"] = product_type self._data["productName"] = product_name - if is_supporting_product_base_type(): + if is_product_base_type_supported(): data.pop("productBaseType", None) self._data["productBaseType"] = product_base_type diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 862bd1ea03..77e50dc788 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -34,7 +34,7 @@ from ayon_core.pipeline.create import ( ConvertorsOperationFailed, ConvertorItem, ) -from ayon_core.pipeline.compatibility import is_supporting_product_base_type +from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.tools.publisher.abstract import ( AbstractPublisherBackend, @@ -634,7 +634,7 @@ class CreateModel: "project_entity": project_entity, } - if is_supporting_product_base_type() and hasattr(creator, "product_base_type"): # noqa: E501 + if is_product_base_type_supported() and hasattr(creator, "product_base_type"): # noqa: E501 kwargs["product_base_type"] = creator.product_base_type # Backwards compatibility for 'project_entity' argument From da286e3cfb5e8fb76a7328e5aab5de685316bb95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 10 Jun 2025 11:23:37 +0200 Subject: [PATCH 24/99] :recycle: remove check for attribute --- client/ayon_core/pipeline/create/context.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index a5a9d5a64a..a37fefc1f9 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -1201,15 +1201,14 @@ class CreateContext: # remove this check. if is_product_base_type_supported(): - if hasattr(creator, "product_base_type"): - instance_data["productBaseType"] = creator.product_base_type - else: - warn( - f"Creator {creator_identifier} does not support " - "product base type. This will be required in future.", - DeprecationWarning, - stacklevel=2, + instance_data["productBaseType"] = creator.product_base_type + if creator.product_base_type is None: + msg = ( + f"Creator {creator_identifier} does not set " + "product base type. This will be required in future." ) + warn(msg, DeprecationWarning, stacklevel=2) + self.log.warning(msg) if active is not None: if not isinstance(active, bool): From e2a413f20eb64001703c2ff5220d232fbdf0d0b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 10 Jun 2025 11:40:43 +0200 Subject: [PATCH 25/99] :dog: remove unneeded f-string --- client/ayon_core/pipeline/create/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index a37fefc1f9..17a5dea7dc 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -733,7 +733,7 @@ class CreateContext: self.creator_discover_result = report for creator_class in report.abstract_plugins: self.log.debug( - f"Skipping abstract Creator '%s'", str(creator_class) + "Skipping abstract Creator '%s'", str(creator_class) ) for creator_class in report.plugins: From 50045d71bd58466e4e1b94209fda8cedafa69d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 10 Jun 2025 12:16:49 +0200 Subject: [PATCH 26/99] :sparkles: support product base types in the integrator --- client/ayon_core/pipeline/publish/lib.py | 6 +++++- client/ayon_core/plugins/publish/integrate.py | 9 +++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 49ecab2221..fba7f1d84b 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -106,7 +106,8 @@ def get_publish_template_name( task_type, project_settings=None, hero=False, - logger=None + logger=None, + product_base_type: Optional[str] = None ): """Get template name which should be used for passed context. @@ -126,6 +127,8 @@ def get_publish_template_name( hero (bool): Template is for hero version publishing. logger (logging.Logger): Custom logger used for 'filter_profiles' function. + product_base_type (Optional[str]): Product type for which should + be found template. Returns: str: Template name which should be used for integration. @@ -135,6 +138,7 @@ def get_publish_template_name( filter_criteria = { "hosts": host_name, "product_types": product_type, + "product_base_types": product_base_type, "task_names": task_name, "task_types": task_type, } diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index f1e066018c..41e71207e7 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -368,6 +368,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): folder_entity = instance.data["folderEntity"] product_name = instance.data["productName"] product_type = instance.data["productType"] + product_base_type = instance.data.get("productBaseType") + self.log.debug("Product: {}".format(product_name)) # Get existing product if it exists @@ -401,7 +403,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): folder_entity["id"], data=data, attribs=attributes, - entity_id=product_id + entity_id=product_id, + product_base_type=product_base_type ) if existing_product_entity is None: @@ -917,6 +920,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): host_name = context.data["hostName"] anatomy_data = instance.data["anatomyData"] product_type = instance.data["productType"] + product_base_type = instance.data.get("productBaseType") task_info = anatomy_data.get("task") or {} return get_publish_template_name( @@ -926,7 +930,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): task_name=task_info.get("name"), task_type=task_info.get("type"), project_settings=context.data["project_settings"], - logger=self.log + logger=self.log, + product_base_type=product_base_type ) def get_rootless_path(self, anatomy, path): From a0f6a3f37971c30390f5a1b99d81f1a582ab5122 Mon Sep 17 00:00:00 2001 From: Aleks Berland Date: Mon, 25 Aug 2025 19:09:20 -0400 Subject: [PATCH 27/99] 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 28/99] 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 2597469b30bfa1fc386218d0aa3a51154445ec52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 3 Sep 2025 15:16:27 +0200 Subject: [PATCH 29/99] :fire: remove deprecated code --- .../pipeline/create/creator_plugins.py | 46 ------------------- 1 file changed, 46 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 95db3f260f..26dbc5f3d3 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -977,52 +977,6 @@ def discover_convertor_plugins(*args, **kwargs): return discover(ProductConvertorPlugin, *args, **kwargs) -def discover_legacy_creator_plugins(): - from ayon_core.pipeline import get_current_project_name - - log = Logger.get_logger("CreatorDiscover") - - plugins = discover(LegacyCreator) - project_name = get_current_project_name() - project_settings = get_project_settings(project_name) - for plugin in plugins: - try: - plugin.apply_settings(project_settings) - except Exception: - log.warning( - "Failed to apply settings to creator %s", plugin.__name__, - exc_info=True - ) - return plugins - - -def get_legacy_creator_by_name(creator_name, case_sensitive=False): - """Find creator plugin by name. - - Args: - creator_name (str): Name of creator class that should be returned. - case_sensitive (bool): Match of creator plugin name is case sensitive. - Set to `False` by default. - - Returns: - Creator: Return first matching plugin or `None`. - """ - # Lower input creator name if is not case sensitive - if not case_sensitive: - creator_name = creator_name.lower() - - for creator_plugin in discover_legacy_creator_plugins(): - _creator_name = creator_plugin.__name__ - - # Lower creator plugin name if is not case sensitive - if not case_sensitive: - _creator_name = _creator_name.lower() - - if _creator_name == creator_name: - return creator_plugin - return None - - def register_creator_plugin(plugin): if issubclass(plugin, BaseCreator): register_plugin(BaseCreator, plugin) From 51965a9de160caf8fa334043b5f62adfaab1d374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 3 Sep 2025 15:18:50 +0200 Subject: [PATCH 30/99] :fire: remove unused import --- client/ayon_core/pipeline/create/creator_plugins.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 26dbc5f3d3..480ef28432 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -16,7 +16,6 @@ from ayon_core.pipeline.plugin_discover import ( register_plugin_path, ) from ayon_core.pipeline.staging_dir import StagingDir, get_staging_dir_info -from ayon_core.settings import get_project_settings from .constants import DEFAULT_VARIANT_VALUE from .product_name import get_product_name 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 31/99] 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 32/99] 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 33/99] 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 34/99] 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 35/99] 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 36/99] 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 37/99] 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 38/99] 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 f147d28c528f67635c9aafe1d03fa7f8a51cbafd Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 17 Oct 2025 17:36:47 +0200 Subject: [PATCH 39/99] =?UTF-8?q?=E2=9A=97=EF=B8=8F=20add=20tests=20for=20?= =?UTF-8?q?product=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipeline/create/test_product_name.py | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 tests/client/ayon_core/pipeline/create/test_product_name.py diff --git a/tests/client/ayon_core/pipeline/create/test_product_name.py b/tests/client/ayon_core/pipeline/create/test_product_name.py new file mode 100644 index 0000000000..b4507e39f1 --- /dev/null +++ b/tests/client/ayon_core/pipeline/create/test_product_name.py @@ -0,0 +1,372 @@ +"""Tests for product_name helpers.""" +import pytest +from unittest.mock import patch + +from ayon_core.pipeline.create.product_name import ( + get_product_name_template, + get_product_name, +) +from ayon_core.pipeline.create.constants import DEFAULT_PRODUCT_TEMPLATE +from ayon_core.pipeline.create.exceptions import ( + TaskNotSetError, + TemplateFillError, +) + + +class TestGetProductNameTemplate: + @patch("ayon_core.pipeline.create.product_name.get_project_settings") + @patch("ayon_core.pipeline.create.product_name.filter_profiles") + @patch("ayon_core.pipeline.create.product_name." + "is_product_base_type_supported") + def test_matching_profile_with_replacements( + self, + mock_is_supported, + mock_filter_profiles, + mock_get_settings, + ): + """Matching profile applies legacy replacement tokens.""" + mock_get_settings.return_value = { + "core": {"tools": {"creator": {"product_name_profiles": []}}} + } + # The function should replace {task}/{family}/{asset} variants + mock_filter_profiles.return_value = { + "template": ("{task}-{Task}-{TASK}-{family}-{Family}" + "-{FAMILY}-{asset}-{Asset}-{ASSET}") + } + mock_is_supported.return_value = False + + result = get_product_name_template( + project_name="proj", + product_type="model", + task_name="modeling", + task_type="Modeling", + host_name="maya", + ) + assert result == ( + "{task[name]}-{Task[name]}-{TASK[NAME]}-" + "{product[type]}-{Product[type]}-{PRODUCT[TYPE]}-" + "{folder[name]}-{Folder[name]}-{FOLDER[NAME]}" + ) + + @patch("ayon_core.pipeline.create.product_name.get_project_settings") + @patch("ayon_core.pipeline.create.product_name.filter_profiles") + @patch("ayon_core.pipeline.create.product_name." + "is_product_base_type_supported") + def test_no_matching_profile_uses_default( + self, + mock_is_supported, + mock_filter_profiles, + mock_get_settings, + ): + mock_get_settings.return_value = { + "core": {"tools": {"creator": {"product_name_profiles": []}}} + } + mock_filter_profiles.return_value = None + mock_is_supported.return_value = False + + assert ( + get_product_name_template( + project_name="proj", + product_type="model", + task_name="modeling", + task_type="Modeling", + host_name="maya", + ) + == DEFAULT_PRODUCT_TEMPLATE + ) + + @patch("ayon_core.pipeline.create.product_name.get_project_settings") + @patch("ayon_core.pipeline.create.product_name.filter_profiles") + @patch("ayon_core.pipeline.create.product_name." + "is_product_base_type_supported") + def test_custom_default_template_used( + self, + mock_is_supported, + mock_filter_profiles, + mock_get_settings, + ): + mock_get_settings.return_value = { + "core": {"tools": {"creator": {"product_name_profiles": []}}} + } + mock_filter_profiles.return_value = None + mock_is_supported.return_value = False + + custom_default = "{variant}_{family}" + assert ( + get_product_name_template( + project_name="proj", + product_type="model", + task_name="modeling", + task_type="Modeling", + host_name="maya", + default_template=custom_default, + ) + == custom_default + ) + + @patch("ayon_core.pipeline.create.product_name.warn") + @patch("ayon_core.pipeline.create.product_name.get_project_settings") + @patch("ayon_core.pipeline.create.product_name.filter_profiles") + @patch("ayon_core.pipeline.create.product_name." + "is_product_base_type_supported") + def test_product_base_type_warns_when_supported_and_missing( + self, + mock_is_supported, + mock_filter_profiles, + mock_get_settings, + mock_warn, + ): + mock_get_settings.return_value = { + "core": {"tools": {"creator": {"product_name_profiles": []}}} + } + mock_filter_profiles.return_value = None + mock_is_supported.return_value = True + + get_product_name_template( + project_name="proj", + product_type="model", + task_name="modeling", + task_type="Modeling", + host_name="maya", + ) + mock_warn.assert_called_once() + + @patch("ayon_core.pipeline.create.product_name.get_project_settings") + @patch("ayon_core.pipeline.create.product_name.filter_profiles") + @patch("ayon_core.pipeline.create.product_name." + "is_product_base_type_supported") + def test_product_base_type_added_to_filtering_when_provided( + self, + mock_is_supported, + mock_filter_profiles, + mock_get_settings, + ): + mock_get_settings.return_value = { + "core": {"tools": {"creator": {"product_name_profiles": []}}} + } + mock_filter_profiles.return_value = None + mock_is_supported.return_value = True + + get_product_name_template( + project_name="proj", + product_type="model", + task_name="modeling", + task_type="Modeling", + host_name="maya", + product_base_type="asset", + ) + args, kwargs = mock_filter_profiles.call_args + # args[1] is filtering_criteria + assert args[1]["product_base_types"] == "asset" + + +class TestGetProductName: + @patch("ayon_core.pipeline.create.product_name.get_product_name_template") + @patch("ayon_core.pipeline.create.product_name." + "StringTemplate.format_strict_template") + @patch("ayon_core.pipeline.create.product_name.prepare_template_data") + def test_empty_product_type_returns_empty( + self, mock_prepare, mock_format, mock_get_tmpl + ): + assert ( + get_product_name( + project_name="proj", + task_name="modeling", + task_type="Modeling", + host_name="maya", + product_type="", + variant="Main", + ) + == "" + ) + mock_get_tmpl.assert_not_called() + mock_format.assert_not_called() + mock_prepare.assert_not_called() + + @patch("ayon_core.pipeline.create.product_name.get_product_name_template") + @patch("ayon_core.pipeline.create.product_name." + "StringTemplate.format_strict_template") + @patch("ayon_core.pipeline.create.product_name.prepare_template_data") + def test_happy_path( + self, mock_prepare, mock_format, mock_get_tmpl + ): + mock_get_tmpl.return_value = "{task[name]}_{product[type]}_{variant}" + mock_prepare.return_value = { + "task": {"name": "modeling"}, + "product": {"type": "model"}, + "variant": "Main", + "family": "model", + } + mock_format.return_value = "modeling_model_Main" + + result = get_product_name( + project_name="proj", + task_name="modeling", + task_type="Modeling", + host_name="maya", + product_type="model", + variant="Main", + ) + assert result == "modeling_model_Main" + mock_get_tmpl.assert_called_once() + mock_prepare.assert_called_once() + mock_format.assert_called_once() + + @patch("ayon_core.pipeline.create.product_name.get_product_name_template") + @patch("ayon_core.pipeline.create.product_name." + "StringTemplate.format_strict_template") + @patch("ayon_core.pipeline.create.product_name.prepare_template_data") + def test_product_name_with_base_type( + self, mock_prepare, mock_format, mock_get_tmpl + ): + mock_get_tmpl.return_value = "{task[name]}_{product[basetype]}_{variant}" + mock_prepare.return_value = { + "task": {"name": "modeling"}, + "product": {"type": "model"}, + "variant": "Main", + "family": "model", + } + mock_format.return_value = "modeling_modelBase_Main" + + result = get_product_name( + project_name="proj", + task_name="modeling", + task_type="Modeling", + host_name="maya", + product_type="model", + product_base_type="modelBase", + variant="Main", + ) + assert result == "modeling_modelBase_Main" + mock_get_tmpl.assert_called_once() + mock_prepare.assert_called_once() + mock_format.assert_called_once() + + @patch("ayon_core.pipeline.create.product_name.get_product_name_template") + def test_task_required_but_missing_raises(self, mock_get_tmpl): + mock_get_tmpl.return_value = "{task[name]}_{variant}" + with pytest.raises(TaskNotSetError): + get_product_name( + project_name="proj", + task_name="", + task_type="Modeling", + host_name="maya", + product_type="model", + variant="Main", + ) + + @patch("ayon_core.pipeline.create.product_name.get_product_name_template") + @patch("ayon_core.pipeline.create.product_name.ayon_api.get_project") + @patch("ayon_core.pipeline.create.product_name.StringTemplate." + "format_strict_template") + @patch("ayon_core.pipeline.create.product_name.prepare_template_data") + def test_task_short_name_is_used( + self, mock_prepare, mock_format, mock_get_project, mock_get_tmpl + ): + mock_get_tmpl.return_value = "{task[short]}_{variant}" + mock_get_project.return_value = { + "taskTypes": [{"name": "Modeling", "shortName": "mdl"}] + } + mock_prepare.return_value = {"task": {"short": "mdl"}, "variant": "Main"} + mock_format.return_value = "mdl_Main" + + result = get_product_name( + project_name="proj", + task_name="modeling", + task_type="Modeling", + host_name="maya", + product_type="model", + variant="Main", + ) + assert result == "mdl_Main" + + @patch("ayon_core.pipeline.create.product_name.get_product_name_template") + @patch("ayon_core.pipeline.create.product_name.StringTemplate." + "format_strict_template") + @patch("ayon_core.pipeline.create.product_name.prepare_template_data") + def test_template_fill_error_translated( + self, mock_prepare, mock_format, mock_get_tmpl + ): + mock_get_tmpl.return_value = "{missing_key}_{variant}" + mock_prepare.return_value = {"variant": "Main"} + mock_format.side_effect = KeyError("missing_key") + with pytest.raises(TemplateFillError): + get_product_name( + project_name="proj", + task_name="modeling", + task_type="Modeling", + host_name="maya", + product_type="model", + variant="Main", + ) + + @patch("ayon_core.pipeline.create.product_name.warn") + @patch("ayon_core.pipeline.create.product_name." + "is_product_base_type_supported") + @patch("ayon_core.pipeline.create.product_name.get_product_name_template") + @patch("ayon_core.pipeline.create.product_name." + "StringTemplate.format_strict_template") + @patch("ayon_core.pipeline.create.product_name.prepare_template_data") + def test_warns_when_template_needs_base_type_but_missing( + self, + mock_prepare, + mock_format, + mock_get_tmpl, + mock_is_supported, + mock_warn, + ): + mock_get_tmpl.return_value = "{product[basetype]}_{variant}" + mock_is_supported.return_value = True + mock_prepare.return_value = { + "product": {"type": "model"}, + "variant": "Main", + "family": "model", + } + mock_format.return_value = "asset_Main" + + _ = get_product_name( + project_name="proj", + task_name="modeling", + task_type="Modeling", + host_name="maya", + product_type="model", + variant="Main", + ) + mock_warn.assert_called_once() + + @patch("ayon_core.pipeline.create.product_name.get_product_name_template") + @patch("ayon_core.pipeline.create.product_name." + "StringTemplate.format_strict_template") + @patch("ayon_core.pipeline.create.product_name.prepare_template_data") + def test_dynamic_data_overrides_defaults( + self, mock_prepare, mock_format, mock_get_tmpl + ): + mock_get_tmpl.return_value = "{custom}_{variant}" + mock_prepare.return_value = {"custom": "overridden", "variant": "Main"} + mock_format.return_value = "overridden_Main" + + result = get_product_name( + project_name="proj", + task_name="modeling", + task_type="Modeling", + host_name="maya", + product_type="model", + variant="Main", + dynamic_data={"custom": "overridden"}, + ) + assert result == "overridden_Main" + + @patch("ayon_core.pipeline.create.product_name.get_product_name_template") + def test_product_type_filter_is_used(self, mock_get_tmpl): + mock_get_tmpl.return_value = DEFAULT_PRODUCT_TEMPLATE + _ = get_product_name( + project_name="proj", + task_name="modeling", + task_type="Modeling", + host_name="maya", + product_type="model", + variant="Main", + product_type_filter="look", + ) + args, kwargs = mock_get_tmpl.call_args + assert kwargs["product_type"] == "look" From 0ca2d25ef651775b7f27b4d6cacc18a40c41bdb1 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 17 Oct 2025 17:41:50 +0200 Subject: [PATCH 40/99] =?UTF-8?q?=E2=9A=97=EF=B8=8F=20fix=20linting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ayon_core/pipeline/create/test_product_name.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/client/ayon_core/pipeline/create/test_product_name.py b/tests/client/ayon_core/pipeline/create/test_product_name.py index b4507e39f1..a8a8566aa8 100644 --- a/tests/client/ayon_core/pipeline/create/test_product_name.py +++ b/tests/client/ayon_core/pipeline/create/test_product_name.py @@ -219,7 +219,9 @@ class TestGetProductName: def test_product_name_with_base_type( self, mock_prepare, mock_format, mock_get_tmpl ): - mock_get_tmpl.return_value = "{task[name]}_{product[basetype]}_{variant}" + mock_get_tmpl.return_value = ( + "{task[name]}_{product[basetype]}_{variant}" + ) mock_prepare.return_value = { "task": {"name": "modeling"}, "product": {"type": "model"}, @@ -267,7 +269,12 @@ class TestGetProductName: mock_get_project.return_value = { "taskTypes": [{"name": "Modeling", "shortName": "mdl"}] } - mock_prepare.return_value = {"task": {"short": "mdl"}, "variant": "Main"} + mock_prepare.return_value = { + "task": { + "short": "mdl" + }, + "variant": "Main" + } mock_format.return_value = "mdl_Main" result = get_product_name( 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 41/99] 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 42/99] 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 04527b00616908377547a6c1a71a88c1a2db7f76 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 21 Nov 2025 19:06:36 +0100 Subject: [PATCH 43/99] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20change=20usage=20of?= =?UTF-8?q?=20product=5Fbase=5Ftypes=20in=20plugins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipeline/create/creator_plugins.py | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 480ef28432..93dd763ed9 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -1,4 +1,6 @@ """Creator plugins for the create process.""" +from __future__ import annotations + import collections import copy import os @@ -7,7 +9,6 @@ from typing import TYPE_CHECKING, Any, Dict, Optional from warnings import warn from ayon_core.lib import Logger, get_version_from_path -from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.pipeline.plugin_discover import ( deregister_plugin, deregister_plugin_path, @@ -274,7 +275,7 @@ class BaseCreator(ABC): # - those may be potential dangerous typos in settings if not hasattr(self, key): self.log.debug( - "Applying settings to unknown attribute '%' on '%'.", + "Applying settings to unknown attribute '%s' on '%s'.", key, cls_name ) setattr(self, key, value) @@ -293,11 +294,9 @@ class BaseCreator(ABC): Default implementation returns plugin's product type. """ - identifier = self.product_type - if is_product_base_type_supported(): - identifier = self.product_base_type - if self.product_type: - identifier = f"{identifier}.{self.product_type}" + identifier = self.product_base_type + if not identifier: + identifier = self.product_type return identifier @property @@ -399,19 +398,14 @@ class BaseCreator(ABC): if product_type is None: product_type = self.product_type - if ( - is_product_base_type_supported() - and not product_base_type - and not self.product_base_type - ): + if not product_base_type and not self.product_base_type: warn( f"Creator {self.identifier} does not support " "product base type. This will be required in future.", DeprecationWarning, stacklevel=2, ) - else: - product_base_type = self.product_base_type + product_base_type = product_type instance = CreatedInstance( product_type=product_type, @@ -534,7 +528,6 @@ class BaseCreator(ABC): host_name: Optional[str] = None, instance: Optional[CreatedInstance] = None, project_entity: Optional[dict[str, Any]] = None, - product_base_type: Optional[str] = None, ) -> str: """Return product name for passed context. @@ -552,11 +545,11 @@ class BaseCreator(ABC): for which is product name updated. Passed only on product name update. project_entity (Optional[dict[str, Any]]): Project entity. - product_base_type (Optional[str]): Product base type. """ - if is_product_base_type_supported() and (instance and hasattr(instance, "product_base_type")): # noqa: E501 - product_base_type = instance.product_base_type + product_base_type = None + if hasattr(self, "product_base_type"): # noqa: E501 + product_base_type = self.product_base_type if host_name is None: host_name = self.create_context.host_name @@ -589,7 +582,8 @@ class BaseCreator(ABC): dynamic_data=dynamic_data, project_settings=self.project_settings, project_entity=project_entity, - product_base_type=product_base_type + # until we make product_base_type mandatory + product_base_type=self.product_base_type ) def get_instance_attr_defs(self): 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 44/99] 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 2cf392633e24b4465e846dbd534bd7461730da44 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 14:08:50 +0100 Subject: [PATCH 45/99] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20remove=20unnecessary?= =?UTF-8?q?=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ayon_core/pipeline/create/product_name.py | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index ab7de0c9e8..f1076e51b3 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -10,7 +10,6 @@ from ayon_core.lib import ( filter_profiles, prepare_template_data, ) -from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.settings import get_project_settings from .constants import DEFAULT_PRODUCT_TEMPLATE @@ -36,10 +35,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 (Optional, str): 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. product_base_type (Optional[str]): Base type of product. @@ -58,14 +57,16 @@ def get_product_name_template( "task_types": task_type } - if is_product_base_type_supported(): - if product_base_type: - filtering_criteria["product_base_types"] = product_base_type - else: - warn( - "Product base type is not provided, please update your" - "creation code to include it. It will be required in " - "the future.", DeprecationWarning, stacklevel=2) + if not product_base_type: + warn( + "Product base type is not provided, please update your" + "creation code to include it. It will be required in " + "the future.", + DeprecationWarning, + stacklevel=2 + ) + filtering_criteria["product_base_types"] = product_base_type + matching_profile = filter_profiles(profiles, filtering_criteria) template = None @@ -192,16 +193,20 @@ def get_product_name( # look what we have to do to make mypy happy. We should stop using # those undefined dict based types. - product: dict[str, str] = {"type": product_type} - if is_product_base_type_supported(): - if product_base_type: - product["baseType"] = product_base_type - elif "{product[basetype]}" in template.lower(): - warn( - "You have Product base type in product name template," - "but it is not provided by the creator, please update your" - "creation code to include it. It will be required in " - "the future.", DeprecationWarning, stacklevel=2) + product: dict[str, str] = { + "type": product_type, + "baseType": product_base_type + } + if not product_base_type and "{product[basetype]}" in template.lower(): + product["baseType"] = product_type + warn( + "You have Product base type in product name template, " + "but it is not provided by the creator, please update your " + "creation code to include it. It will be required in " + "the future.", + DeprecationWarning, + stacklevel=2) + fill_pairs: dict[str, Union[str, dict[str, str]]] = { "variant": variant, "family": product_type, From 05547c752ee788ec42fcf6c0b3235fc6f981353a Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 14:27:22 +0100 Subject: [PATCH 46/99] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20remove=20the=20check?= =?UTF-8?q?=20for=20product=20base=20type=20support=20-=20publisher=20mode?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/ayon_core/tools/publisher/models/create.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index ed3fd04d5c..86f0cd2d07 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -35,7 +35,6 @@ from ayon_core.pipeline.create import ( ConvertorsOperationFailed, ConvertorItem, ) -from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.tools.publisher.abstract import ( AbstractPublisherBackend, @@ -666,18 +665,14 @@ class CreateModel: kwargs = { "instance": instance, "project_entity": project_entity, + "product_base_type": creator.product_base_type, } - - if is_product_base_type_supported() and hasattr(creator, "product_base_type"): # noqa: E501 - kwargs["product_base_type"] = creator.product_base_type - # Backwards compatibility for 'project_entity' argument # - 'get_product_name' signature changed 24/07/08 if not is_func_signature_supported( creator.get_product_name, *args, **kwargs ): kwargs.pop("project_entity") - kwargs.pop("product_base_type") return creator.get_product_name(*args, **kwargs) def create( From b967f8f818b74e15ce62e06374c6213d721ecf1f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 15:01:25 +0100 Subject: [PATCH 47/99] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20consolidate=20warnin?= =?UTF-8?q?ings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/ayon_core/pipeline/create/context.py | 27 +++++++++---------- .../pipeline/create/creator_plugins.py | 11 -------- .../ayon_core/pipeline/create/product_name.py | 13 ++------- .../ayon_core/pipeline/create/structures.py | 16 +++-------- 4 files changed, 17 insertions(+), 50 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 6df437202e..0350c00977 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -29,7 +29,6 @@ from ayon_core.host import IWorkfileHost, IPublishHost from ayon_core.pipeline import Anatomy from ayon_core.pipeline.template_data import get_template_data from ayon_core.pipeline.plugin_discover import DiscoverResult -from ayon_core.pipeline.compatibility import is_product_base_type_supported from .exceptions import ( CreatorError, @@ -1237,6 +1236,15 @@ class CreateContext: """ creator = self._get_creator_in_create(creator_identifier) + if not hasattr(creator, "product_base_type"): + warn( + f"Provided creator {creator!r} doesn't have " + "product base type attribute defined. This will be " + "required in future.", + DeprecationWarning, + stacklevel=2 + ) + project_name = self.project_name if folder_entity is None: folder_path = self.get_current_folder_path() @@ -1290,23 +1298,12 @@ class CreateContext: "folderPath": folder_entity["path"], "task": task_entity["name"] if task_entity else None, "productType": creator.product_type, + # Add product base type if supported. Fallback to product type + "productBaseType": ( + creator.product_base_type or creator.product_type), "variant": variant } - # Add product base type if supported. - # TODO (antirotor): Once all creators support product base type - # remove this check. - if is_product_base_type_supported(): - - instance_data["productBaseType"] = creator.product_base_type - if creator.product_base_type is None: - msg = ( - f"Creator {creator_identifier} does not set " - "product base type. This will be required in future." - ) - warn(msg, DeprecationWarning, stacklevel=2) - self.log.warning(msg) - if active is not None: if not isinstance(active, bool): self.log.warning( diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 93dd763ed9..21d8596dea 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -6,7 +6,6 @@ import copy import os from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, Dict, Optional -from warnings import warn from ayon_core.lib import Logger, get_version_from_path from ayon_core.pipeline.plugin_discover import ( @@ -399,12 +398,6 @@ class BaseCreator(ABC): product_type = self.product_type if not product_base_type and not self.product_base_type: - warn( - f"Creator {self.identifier} does not support " - "product base type. This will be required in future.", - DeprecationWarning, - stacklevel=2, - ) product_base_type = product_type instance = CreatedInstance( @@ -547,10 +540,6 @@ class BaseCreator(ABC): project_entity (Optional[dict[str, Any]]): Project entity. """ - product_base_type = None - if hasattr(self, "product_base_type"): # noqa: E501 - product_base_type = self.product_base_type - if host_name is None: host_name = self.create_context.host_name diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index f1076e51b3..c4ddb34652 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -57,16 +57,7 @@ def get_product_name_template( "task_types": task_type } - if not product_base_type: - warn( - "Product base type is not provided, please update your" - "creation code to include it. It will be required in " - "the future.", - DeprecationWarning, - stacklevel=2 - ) filtering_criteria["product_base_types"] = product_base_type - matching_profile = filter_profiles(profiles, filtering_criteria) template = None @@ -127,8 +118,8 @@ def get_product_name( Args: project_name (str): Project name. - task_name (str): Task name. - task_type (str): Task type. + task_name (Optional[str]): Task name. + task_type (Optional[str]): Task type. host_name (str): Host name. product_type (str): Product type. variant (str): In most of the cases it is user input during creation. diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index e93270b357..dfa9d69938 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -4,7 +4,6 @@ from uuid import uuid4 from enum import Enum import typing from typing import Optional, Dict, List, Any -from warnings import warn from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, @@ -521,17 +520,9 @@ class CreatedInstance: product_base_type: Optional[str] = None ): """Initialize CreatedInstance.""" - if is_product_base_type_supported(): - if not hasattr(creator, "product_base_type"): - warn( - f"Provided creator {creator!r} doesn't have " - "product base type attribute defined. This will be " - "required in future.", - DeprecationWarning, - stacklevel=2 - ) - elif not product_base_type: - product_base_type = creator.product_base_type + # fallback to product type for backward compatibility + if not product_base_type: + product_base_type = creator.product_base_type or product_type self._creator = creator creator_identifier = creator.identifier @@ -587,7 +578,6 @@ class CreatedInstance: self._data["productName"] = product_name if is_product_base_type_supported(): - data.pop("productBaseType", None) self._data["productBaseType"] = product_base_type self._data["active"] = data.get("active", True) From 1cddb86918fbd2806cba78c549c2a0ca971caa30 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 15:17:19 +0100 Subject: [PATCH 48/99] =?UTF-8?q?=E2=9A=97=EF=B8=8F=20fix=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipeline/create/test_product_name.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/tests/client/ayon_core/pipeline/create/test_product_name.py b/tests/client/ayon_core/pipeline/create/test_product_name.py index a8a8566aa8..03b13d2c25 100644 --- a/tests/client/ayon_core/pipeline/create/test_product_name.py +++ b/tests/client/ayon_core/pipeline/create/test_product_name.py @@ -20,7 +20,6 @@ class TestGetProductNameTemplate: "is_product_base_type_supported") def test_matching_profile_with_replacements( self, - mock_is_supported, mock_filter_profiles, mock_get_settings, ): @@ -33,7 +32,6 @@ class TestGetProductNameTemplate: "template": ("{task}-{Task}-{TASK}-{family}-{Family}" "-{FAMILY}-{asset}-{Asset}-{ASSET}") } - mock_is_supported.return_value = False result = get_product_name_template( project_name="proj", @@ -54,7 +52,6 @@ class TestGetProductNameTemplate: "is_product_base_type_supported") def test_no_matching_profile_uses_default( self, - mock_is_supported, mock_filter_profiles, mock_get_settings, ): @@ -62,7 +59,6 @@ class TestGetProductNameTemplate: "core": {"tools": {"creator": {"product_name_profiles": []}}} } mock_filter_profiles.return_value = None - mock_is_supported.return_value = False assert ( get_product_name_template( @@ -81,7 +77,6 @@ class TestGetProductNameTemplate: "is_product_base_type_supported") def test_custom_default_template_used( self, - mock_is_supported, mock_filter_profiles, mock_get_settings, ): @@ -89,7 +84,6 @@ class TestGetProductNameTemplate: "core": {"tools": {"creator": {"product_name_profiles": []}}} } mock_filter_profiles.return_value = None - mock_is_supported.return_value = False custom_default = "{variant}_{family}" assert ( @@ -111,7 +105,6 @@ class TestGetProductNameTemplate: "is_product_base_type_supported") def test_product_base_type_warns_when_supported_and_missing( self, - mock_is_supported, mock_filter_profiles, mock_get_settings, mock_warn, @@ -120,7 +113,6 @@ class TestGetProductNameTemplate: "core": {"tools": {"creator": {"product_name_profiles": []}}} } mock_filter_profiles.return_value = None - mock_is_supported.return_value = True get_product_name_template( project_name="proj", @@ -137,7 +129,6 @@ class TestGetProductNameTemplate: "is_product_base_type_supported") def test_product_base_type_added_to_filtering_when_provided( self, - mock_is_supported, mock_filter_profiles, mock_get_settings, ): @@ -145,7 +136,6 @@ class TestGetProductNameTemplate: "core": {"tools": {"creator": {"product_name_profiles": []}}} } mock_filter_profiles.return_value = None - mock_is_supported.return_value = True get_product_name_template( project_name="proj", @@ -308,8 +298,6 @@ class TestGetProductName: ) @patch("ayon_core.pipeline.create.product_name.warn") - @patch("ayon_core.pipeline.create.product_name." - "is_product_base_type_supported") @patch("ayon_core.pipeline.create.product_name.get_product_name_template") @patch("ayon_core.pipeline.create.product_name." "StringTemplate.format_strict_template") @@ -319,11 +307,10 @@ class TestGetProductName: mock_prepare, mock_format, mock_get_tmpl, - mock_is_supported, mock_warn, ): mock_get_tmpl.return_value = "{product[basetype]}_{variant}" - mock_is_supported.return_value = True + mock_prepare.return_value = { "product": {"type": "model"}, "variant": "Main", From 794bb716b268a385dd978dab1099bba13ffb01de Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 15:25:54 +0100 Subject: [PATCH 49/99] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20small=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/ayon_core/pipeline/create/product_name.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index c4ddb34652..bf3d3b0abc 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -54,11 +54,9 @@ def get_product_name_template( "product_types": product_type, "hosts": host_name, "tasks": task_name, - "task_types": task_type + "task_types": task_type, + "product_base_types": product_base_type, } - - filtering_criteria["product_base_types"] = product_base_type - matching_profile = filter_profiles(profiles, filtering_criteria) template = None if matching_profile: From 00e2e3c2ade7b46c81377f083f8a321aae90c3e0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 15:33:43 +0100 Subject: [PATCH 50/99] =?UTF-8?q?=F0=9F=8E=9B=EF=B8=8F=20fix=20type=20hint?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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 bf3d3b0abc..f5a7418b57 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -83,8 +83,8 @@ def get_product_name_template( def get_product_name( project_name: str, task_name: str, - task_type: str, - host_name: str, + task_type: Optional[str], + host_name: Optional[str], product_type: str, variant: str, default_template: Optional[str] = None, From e6007b2cee79e82521850ce5d3658eb5a8d7279d Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 17:25:10 +0100 Subject: [PATCH 51/99] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit type hints, checks --- client/ayon_core/pipeline/create/context.py | 18 +++++++++--------- .../pipeline/create/creator_plugins.py | 4 ++-- .../ayon_core/pipeline/create/product_name.py | 16 ++++++---------- client/ayon_core/pipeline/create/structures.py | 4 +--- 4 files changed, 18 insertions(+), 24 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 0350c00977..a09f1924da 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -766,6 +766,15 @@ class CreateContext: "and skipping: %s", creator_identifier, creator_class ) continue + if not creator_class.product_base_type: + warn( + f"Provided creator {creator_class!r} doesn't have " + "product base type attribute defined. This will be " + "required in future.", + DeprecationWarning, + stacklevel=2 + ) + continue # Filter by host name if ( @@ -1236,15 +1245,6 @@ class CreateContext: """ creator = self._get_creator_in_create(creator_identifier) - if not hasattr(creator, "product_base_type"): - warn( - f"Provided creator {creator!r} doesn't have " - "product base type attribute defined. This will be " - "required in future.", - DeprecationWarning, - stacklevel=2 - ) - project_name = self.project_name if folder_entity is None: folder_path = self.get_current_folder_path() diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 21d8596dea..92eb3b6946 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -405,7 +405,7 @@ class BaseCreator(ABC): product_name=product_name, data=data, creator=self, - product_base_type=product_base_type + product_base_type=product_base_type, ) self._add_instance_to_context(instance) return instance @@ -516,7 +516,7 @@ class BaseCreator(ABC): self, project_name: str, folder_entity: dict[str, Any], - task_entity: dict[str, Any], + task_entity: Optional[dict[str, Any]], variant: str, host_name: Optional[str] = None, instance: Optional[CreatedInstance] = None, diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index f5a7418b57..cc1014173c 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -82,9 +82,9 @@ def get_product_name_template( def get_product_name( project_name: str, - task_name: str, + task_name: Optional[str], task_type: Optional[str], - host_name: Optional[str], + host_name: str, product_type: str, variant: str, default_template: Optional[str] = None, @@ -180,14 +180,7 @@ def get_product_name( task_short = task_types_by_name.get(task_type, {}).get("shortName") task_value["short"] = task_short - # look what we have to do to make mypy happy. We should stop using - # those undefined dict based types. - product: dict[str, str] = { - "type": product_type, - "baseType": product_base_type - } if not product_base_type and "{product[basetype]}" in template.lower(): - product["baseType"] = product_type warn( "You have Product base type in product name template, " "but it is not provided by the creator, please update your " @@ -200,7 +193,10 @@ def get_product_name( "variant": variant, "family": product_type, "task": task_value, - "product": product, + "product": { + "type": product_type, + "baseType": product_base_type or product_type, + } } if dynamic_data: diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index dfa9d69938..6f53a61b25 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -12,7 +12,6 @@ from ayon_core.lib.attribute_definitions import ( deserialize_attr_defs, ) -from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.pipeline import ( AYON_INSTANCE_ID, @@ -577,8 +576,7 @@ class CreatedInstance: self._data["productType"] = product_type self._data["productName"] = product_name - if is_product_base_type_supported(): - self._data["productBaseType"] = product_base_type + self._data["productBaseType"] = product_base_type self._data["active"] = data.get("active", True) self._data["creator_identifier"] = creator_identifier From 700b025024faf91a54a904d10b0bd7b45fa81f6e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 17:34:01 +0100 Subject: [PATCH 52/99] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20move=20plugin=20chec?= =?UTF-8?q?k=20earlier,=20fix=20hints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/ayon_core/pipeline/create/context.py | 16 +++++++++------- client/ayon_core/pipeline/create/product_name.py | 8 ++++---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index a09f1924da..f1a5b0e9f8 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -759,13 +759,6 @@ class CreateContext: ) for creator_class in report.plugins: - creator_identifier = creator_class.identifier - if creator_identifier in creators: - self.log.warning( - "Duplicate Creator identifier: '%s'. Using first Creator " - "and skipping: %s", creator_identifier, creator_class - ) - continue if not creator_class.product_base_type: warn( f"Provided creator {creator_class!r} doesn't have " @@ -776,6 +769,15 @@ class CreateContext: ) continue + creator_identifier = creator_class.identifier + if creator_identifier in creators: + self.log.warning( + "Duplicate Creator identifier: '%s'. Using first Creator " + "and skipping: %s", creator_identifier, creator_class + ) + continue + + # Filter by host name if ( creator_class.host_name diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index cc1014173c..7f87145595 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -19,8 +19,8 @@ from .exceptions import TaskNotSetError, TemplateFillError def get_product_name_template( project_name: str, product_type: str, - task_name: str, - task_type: str, + task_name: Optional[str], + task_type: Optional[str], host_name: str, default_template: Optional[str] = None, project_settings: Optional[dict[str, Any]] = None, @@ -33,8 +33,8 @@ def get_product_name_template( product_type (str): Product type for which the product name is calculated. host_name (str): Name of host in which the product name is calculated. - task_name (str): Name of task in which context the product is created. - task_type (str): Type of task in which context the product is created. + task_name (Optional[str]): Name of task in which context the product is created. + task_type (Optional[str]): Type of task in which context the product is created. default_template (Optional[str]): Default template which is used if settings won't find any matching possibility. Constant 'DEFAULT_PRODUCT_TEMPLATE' is used if not defined. From bb430342d8b7b7d395d8af99916297f52353d047 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 17:35:49 +0100 Subject: [PATCH 53/99] :dog: fix linter --- client/ayon_core/pipeline/create/context.py | 1 - client/ayon_core/pipeline/create/product_name.py | 6 ++++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index f1a5b0e9f8..2b9556d005 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -777,7 +777,6 @@ class CreateContext: ) continue - # Filter by host name if ( creator_class.host_name diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 7f87145595..d59e8f9b67 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -33,8 +33,10 @@ def get_product_name_template( product_type (str): Product type for which the product name is calculated. host_name (str): Name of host in which the product name is calculated. - task_name (Optional[str]): Name of task in which context the product is created. - task_type (Optional[str]): Type of task in which context the product is created. + task_name (Optional[str]): Name of task in which context the + product is created. + task_type (Optional[str]): Type of task in which context the + product is created. default_template (Optional[str]): Default template which is used if settings won't find any matching possibility. Constant 'DEFAULT_PRODUCT_TEMPLATE' is used if not defined. From b0005180f269f6139ca355c897052e4b417116eb Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 17:40:27 +0100 Subject: [PATCH 54/99] :alembic: fix tests --- .../pipeline/create/test_product_name.py | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/tests/client/ayon_core/pipeline/create/test_product_name.py b/tests/client/ayon_core/pipeline/create/test_product_name.py index 03b13d2c25..7181e18b43 100644 --- a/tests/client/ayon_core/pipeline/create/test_product_name.py +++ b/tests/client/ayon_core/pipeline/create/test_product_name.py @@ -16,8 +16,6 @@ from ayon_core.pipeline.create.exceptions import ( class TestGetProductNameTemplate: @patch("ayon_core.pipeline.create.product_name.get_project_settings") @patch("ayon_core.pipeline.create.product_name.filter_profiles") - @patch("ayon_core.pipeline.create.product_name." - "is_product_base_type_supported") def test_matching_profile_with_replacements( self, mock_filter_profiles, @@ -48,8 +46,6 @@ class TestGetProductNameTemplate: @patch("ayon_core.pipeline.create.product_name.get_project_settings") @patch("ayon_core.pipeline.create.product_name.filter_profiles") - @patch("ayon_core.pipeline.create.product_name." - "is_product_base_type_supported") def test_no_matching_profile_uses_default( self, mock_filter_profiles, @@ -73,8 +69,6 @@ class TestGetProductNameTemplate: @patch("ayon_core.pipeline.create.product_name.get_project_settings") @patch("ayon_core.pipeline.create.product_name.filter_profiles") - @patch("ayon_core.pipeline.create.product_name." - "is_product_base_type_supported") def test_custom_default_template_used( self, mock_filter_profiles, @@ -98,35 +92,8 @@ class TestGetProductNameTemplate: == custom_default ) - @patch("ayon_core.pipeline.create.product_name.warn") @patch("ayon_core.pipeline.create.product_name.get_project_settings") @patch("ayon_core.pipeline.create.product_name.filter_profiles") - @patch("ayon_core.pipeline.create.product_name." - "is_product_base_type_supported") - def test_product_base_type_warns_when_supported_and_missing( - self, - mock_filter_profiles, - mock_get_settings, - mock_warn, - ): - mock_get_settings.return_value = { - "core": {"tools": {"creator": {"product_name_profiles": []}}} - } - mock_filter_profiles.return_value = None - - get_product_name_template( - project_name="proj", - product_type="model", - task_name="modeling", - task_type="Modeling", - host_name="maya", - ) - mock_warn.assert_called_once() - - @patch("ayon_core.pipeline.create.product_name.get_project_settings") - @patch("ayon_core.pipeline.create.product_name.filter_profiles") - @patch("ayon_core.pipeline.create.product_name." - "is_product_base_type_supported") def test_product_base_type_added_to_filtering_when_provided( self, mock_filter_profiles, From 3a24db94f51915440b0a963b1ef9402d1681f0c0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 17:45:17 +0100 Subject: [PATCH 55/99] :memo: log deprecation warning --- client/ayon_core/pipeline/create/context.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 2b9556d005..6495a9d6e9 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -760,13 +760,15 @@ class CreateContext: for creator_class in report.plugins: if not creator_class.product_base_type: - warn( - f"Provided creator {creator_class!r} doesn't have " + message = (f"Provided creator {creator_class!r} doesn't have " "product base type attribute defined. This will be " - "required in future.", + "required in future.") + warn( + message, DeprecationWarning, stacklevel=2 ) + self.log.warning(message) continue creator_identifier = creator_class.identifier From 64f549c4956376b2c8fc92fc80cecfeb136a6432 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 17:48:52 +0100 Subject: [PATCH 56/99] ugly thing in name of compatibility? --- 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 d59e8f9b67..f10e375fb1 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -170,7 +170,7 @@ def get_product_name( "type": task_type, } if "{task}" in template.lower(): - task_value["name"] = task_name + task_value = task_name elif "{task[short]}" in template.lower(): if project_entity is None: From 1f88b0031dcdd72507c966cfb1dec0352fdc5ed3 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 17:56:35 +0100 Subject: [PATCH 57/99] :recycle: fix discovery --- client/ayon_core/pipeline/create/context.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 6495a9d6e9..08b6574db8 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -769,7 +769,6 @@ class CreateContext: stacklevel=2 ) self.log.warning(message) - continue creator_identifier = creator_class.identifier if creator_identifier in creators: From 67364633f0d3f68278d1d292a83eebd5e6ffd925 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 27 Nov 2025 10:56:02 +0100 Subject: [PATCH 58/99] :robot: implement copilot suggestions --- client/ayon_core/pipeline/create/context.py | 41 +++++++++---------- .../pipeline/create/creator_plugins.py | 5 ++- .../ayon_core/pipeline/create/product_name.py | 2 +- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 08b6574db8..c379dd38f2 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -758,18 +758,6 @@ class CreateContext: "Skipping abstract Creator '%s'", str(creator_class) ) - for creator_class in report.plugins: - if not creator_class.product_base_type: - message = (f"Provided creator {creator_class!r} doesn't have " - "product base type attribute defined. This will be " - "required in future.") - warn( - message, - DeprecationWarning, - stacklevel=2 - ) - self.log.warning(message) - creator_identifier = creator_class.identifier if creator_identifier in creators: self.log.warning( @@ -783,19 +771,17 @@ class CreateContext: creator_class.host_name and creator_class.host_name != self.host_name ): - self.log.info(( - "Creator's host name \"{}\"" - " is not supported for current host \"{}\"" - ).format(creator_class.host_name, self.host_name)) + self.log.info( + ( + 'Creator\'s host name "{}"' + ' is not supported for current host "{}"' + ).format(creator_class.host_name, self.host_name) + ) continue # TODO report initialization error try: - creator = creator_class( - project_settings, - self, - self.headless - ) + creator = creator_class(project_settings, self, self.headless) except Exception: self.log.error( f"Failed to initialize plugin: {creator_class}", @@ -803,6 +789,19 @@ class CreateContext: ) continue + if not creator.product_base_type: + message = ( + f"Provided creator {creator!r} doesn't have " + "product base type attribute defined. This will be " + "required in future." + ) + warn( + message, + DeprecationWarning, + stacklevel=2 + ) + self.log.warning(message) + if not creator.enabled: disabled_creators[creator_identifier] = creator continue diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 92eb3b6946..8d1dbd0f2e 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -290,7 +290,8 @@ class BaseCreator(ABC): def identifier(self): """Identifier of creator (must be unique). - Default implementation returns plugin's product type. + Default implementation returns plugin's product base type, or falls back + to product type if product base type is not set. """ identifier = self.product_base_type @@ -388,7 +389,7 @@ class BaseCreator(ABC): product_type (Optional[str]): Product type, object attribute 'product_type' is used if not passed. product_base_type (Optional[str]): Product base type, object - attribute 'product_type' is used if not passed. + attribute 'product_base_type' is used if not passed. Returns: CreatedInstance: Created instance. diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index f10e375fb1..2bf84db0f4 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -197,7 +197,7 @@ def get_product_name( "task": task_value, "product": { "type": product_type, - "baseType": product_base_type or product_type, + "basetype": product_base_type or product_type, } } From f8e8ab2b27f0619b98bca16069da99d033ea0ee0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 27 Nov 2025 10:58:13 +0100 Subject: [PATCH 59/99] :dog: fix long line --- client/ayon_core/pipeline/create/creator_plugins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 8d1dbd0f2e..a034451d83 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -290,8 +290,8 @@ class BaseCreator(ABC): def identifier(self): """Identifier of creator (must be unique). - Default implementation returns plugin's product base type, or falls back - to product type if product base type is not set. + Default implementation returns plugin's product base type, + or falls back to product type if product base type is not set. """ identifier = self.product_base_type From bb8f214e475ec657730f8bbb861e99896795005a Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 27 Nov 2025 11:59:20 +0100 Subject: [PATCH 60/99] :bug: fix the abstract plugin debug print --- client/ayon_core/pipeline/create/context.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index c379dd38f2..d8cb9d1b9e 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -755,9 +755,11 @@ class CreateContext: self.creator_discover_result = report for creator_class in report.abstract_plugins: self.log.debug( - "Skipping abstract Creator '%s'", str(creator_class) + "Skipping abstract Creator '%s'", + str(creator_class) ) + for creator_class in report.plugins: creator_identifier = creator_class.identifier if creator_identifier in creators: self.log.warning( From feb16122009857c4192914b1b3b9d9ca4936d4f7 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 27 Nov 2025 17:18:35 +0100 Subject: [PATCH 61/99] =?UTF-8?q?=E2=9E=96=20remove=20argument?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/ayon_core/tools/publisher/models/create.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 86f0cd2d07..b8518a7de6 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -665,7 +665,6 @@ class CreateModel: kwargs = { "instance": instance, "project_entity": project_entity, - "product_base_type": creator.product_base_type, } # Backwards compatibility for 'project_entity' argument # - 'get_product_name' signature changed 24/07/08 From 43b557d95e10978090492434ad8282dcd79d0958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 1 Dec 2025 18:17:08 +0100 Subject: [PATCH 62/99] :recycle: check for compatibility --- client/ayon_core/pipeline/compatibility.py | 3 ++- client/ayon_core/pipeline/publish/lib.py | 6 ++--- client/ayon_core/plugins/publish/integrate.py | 25 ++++++++++++------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/pipeline/compatibility.py b/client/ayon_core/pipeline/compatibility.py index f7d48526b7..dce627e391 100644 --- a/client/ayon_core/pipeline/compatibility.py +++ b/client/ayon_core/pipeline/compatibility.py @@ -13,4 +13,5 @@ def is_product_base_type_supported() -> bool: bool: True if product base types are supported, False otherwise. """ - return False + import ayon_api + return ayon_api.product_base_type_supported() diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index c2dcc89cd5..729e694c4f 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -122,8 +122,8 @@ def get_publish_template_name( task_type, project_settings=None, hero=False, + product_base_type: Optional[str] = None, logger=None, - product_base_type: Optional[str] = None ): """Get template name which should be used for passed context. @@ -141,10 +141,10 @@ def get_publish_template_name( task_type (str): Task type on which is instance working. project_settings (Dict[str, Any]): Prepared project settings. hero (bool): Template is for hero version publishing. - logger (logging.Logger): Custom logger used for 'filter_profiles' - function. product_base_type (Optional[str]): Product type for which should be found template. + logger (logging.Logger): Custom logger used for 'filter_profiles' + function. Returns: str: Template name which should be used for integration. diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 4589f6f542..eaf82b37c4 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -28,6 +28,7 @@ from ayon_core.pipeline.publish import ( KnownPublishError, get_publish_template_name, ) +from pipeline import is_product_base_type_supported log = logging.getLogger(__name__) @@ -396,15 +397,21 @@ class IntegrateAsset(pyblish.api.InstancePlugin): product_id = None if existing_product_entity: product_id = existing_product_entity["id"] - product_entity = new_product_entity( - product_name, - product_type, - folder_entity["id"], - data=data, - attribs=attributes, - entity_id=product_id, - product_base_type=product_base_type - ) + + new_product_entity_kwargs = { + "product_name": product_name, + "product_type": product_type, + "folder_id": folder_entity["id"], + "data": data, + "attribs": attributes, + "entity_id": product_id, + "product_base_type": product_base_type, + } + + if not is_product_base_type_supported(): + new_product_entity_kwargs.pop("product_base_type") + + product_entity = new_product_entity(**new_product_entity_kwargs) if existing_product_entity is None: # Create a new product From 2efda3d3fec2745ef658b79a1f280d57177e827e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 2 Dec 2025 15:41:37 +0100 Subject: [PATCH 63/99] :bug: fix import and function call/check --- client/ayon_core/pipeline/compatibility.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/compatibility.py b/client/ayon_core/pipeline/compatibility.py index dce627e391..78ba5ad71e 100644 --- a/client/ayon_core/pipeline/compatibility.py +++ b/client/ayon_core/pipeline/compatibility.py @@ -1,4 +1,5 @@ """Package to handle compatibility checks for pipeline components.""" +import ayon_api def is_product_base_type_supported() -> bool: @@ -13,5 +14,7 @@ def is_product_base_type_supported() -> bool: bool: True if product base types are supported, False otherwise. """ - import ayon_api - return ayon_api.product_base_type_supported() + + if not hasattr(ayon_api, "is_product_base_type_supported"): + return False + return ayon_api.is_product_base_type_supported() From 1e6601786110e39f26ad26f7925471548cc45ab6 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 2 Dec 2025 15:47:39 +0100 Subject: [PATCH 64/99] :recycle: use product base type if defined when product base types are not supported by api, product base type should be the source of truth. --- client/ayon_core/plugins/publish/integrate.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index eaf82b37c4..e93cf62a3c 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -410,6 +410,18 @@ class IntegrateAsset(pyblish.api.InstancePlugin): if not is_product_base_type_supported(): new_product_entity_kwargs.pop("product_base_type") + if ( + product_base_type is not None + and product_base_type != product_type): + self.log.warning(( + "Product base type %s is not supported by the server, " + "but it's defined - and it differs from product type %s. " + "Using product base type as product type." + ), product_base_type, product_type) + + new_product_entity_kwargs["product_type"] = ( + product_base_type + ) product_entity = new_product_entity(**new_product_entity_kwargs) From 206bcfe7176f3960bc764b5eee323c224aeeca6e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 2 Dec 2025 15:47:58 +0100 Subject: [PATCH 65/99] :memo: add warning --- client/ayon_core/pipeline/publish/lib.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 729e694c4f..7365ffee09 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -149,6 +149,15 @@ def get_publish_template_name( Returns: str: Template name which should be used for integration. """ + if not product_base_type: + msg = ( + "Argument 'product_base_type' is not provided to" + " 'get_publish_template_name' function. This argument" + " will be required in future versions." + ) + warnings.warn(msg, DeprecationWarning) + if logger: + logger.warning(msg) template = None filter_criteria = { From a9c77857001bedb0b1f4dc5ec1b4e3b4599ac772 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 2 Dec 2025 15:59:14 +0100 Subject: [PATCH 66/99] :dog: fix linter --- client/ayon_core/plugins/publish/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index e93cf62a3c..45209764ee 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -418,7 +418,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "but it's defined - and it differs from product type %s. " "Using product base type as product type." ), product_base_type, product_type) - + new_product_entity_kwargs["product_type"] = ( product_base_type ) From 24ff7f02d695a48511f323e180268a20a0cf4176 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:05:42 +0100 Subject: [PATCH 67/99] Fix wrongly resolved line --- 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 0cd3a1c51f..c4c27edc3b 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -54,7 +54,7 @@ def get_product_name_template( profiles = tools_settings["creator"]["product_name_profiles"] filtering_criteria = { "product_types": product_type, - "host_names": host_name,: host_name, + "host_names": host_name, "task_names": task_name, "task_types": task_type, "product_base_types": product_base_type, From f7f0005511c7aaa34e967100afa6db5f5ad53a1d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 5 Dec 2025 12:23:30 +0100 Subject: [PATCH 68/99] Fix import --- client/ayon_core/plugins/publish/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 45209764ee..8fce5574e9 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -28,7 +28,7 @@ from ayon_core.pipeline.publish import ( KnownPublishError, get_publish_template_name, ) -from pipeline import is_product_base_type_supported +from ayon_core.pipeline import is_product_base_type_supported log = logging.getLogger(__name__) From f665528ee7a64e416bab859537b24a8eaabaec00 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:36:01 +0100 Subject: [PATCH 69/99] fix 'product_name' to 'name' --- client/ayon_core/plugins/publish/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 8fce5574e9..9f24b35754 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -399,7 +399,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): product_id = existing_product_entity["id"] new_product_entity_kwargs = { - "product_name": product_name, + "name": product_name, "product_type": product_type, "folder_id": folder_entity["id"], "data": data, 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 70/99] 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 71/99] 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 72/99] 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 73/99] 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 74/99] 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 75/99] 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 76/99] 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 77/99] 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 78/99] 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 79/99] 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 80/99] 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 81/99] 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 82/99] 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 83/99] 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 84/99] 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 85/99] 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 86/99] 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 87/99] 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 88/99] 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 89/99] 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 90/99] 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 91/99] 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): From 82427cb004003e415583266fe036bb56fc240481 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 12 Dec 2025 10:21:45 +0100 Subject: [PATCH 92/99] Fix merge conflicts --- client/ayon_core/lib/transcoding.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 9848063133..d462b94153 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1122,9 +1122,6 @@ def convert_colorspace( view=None, display=None, additional_command_args=None, - frames=None, - frame_padding=None, - parallel_frames=False, logger=None, ): """DEPRECATED function use `oiio_color_convert` instead @@ -1176,6 +1173,9 @@ def oiio_color_convert( target_display=None, target_view=None, additional_command_args=None, + frames=None, + frame_padding=None, + parallel_frames=False, logger=None, ): """Transcode source file to other with colormanagement. @@ -1212,10 +1212,10 @@ def oiio_color_convert( frames (Optional[str]): Complex frame range to process. This requires input path and output path to use frame token placeholder like `#` or `%d`, e.g. file.#.exr - parallel_frames (bool): If True, process frames in parallel inside - the `oiiotool` process. Only supported in OIIO 2.5.20.0+. frame_padding (Optional[int]): Frame padding to use for the input and output when using a sequence filepath. + parallel_frames (bool): If True, process frames in parallel inside + the `oiiotool` process. Only supported in OIIO 2.5.20.0+. logger (logging.Logger): Logger used for logging. Raises: From 11ecc69b3537b6f3af974c794a18ff37fa066a17 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 12 Dec 2025 10:31:25 +0100 Subject: [PATCH 93/99] Refactor `_input` -> `input_item` --- client/ayon_core/lib/transcoding.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index d462b94153..c37a0a88b7 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -733,7 +733,7 @@ def convert_input_paths_for_ffmpeg( ) input_items = list(input_collections) input_items.extend(input_remainder) - for _input in input_items: + for input_item in input_items: # Prepare subprocess arguments oiio_cmd = get_oiio_tool_args( "oiiotool", @@ -746,21 +746,21 @@ def convert_input_paths_for_ffmpeg( # Convert a sequence of files using a single oiiotool command # using its sequence syntax - if isinstance(_input, clique.Collection): - frames = _input.format("{head}#{tail}").replace(" ", "") + if isinstance(input_item, clique.Collection): + frames = input_item.format("{head}#{tail}").replace(" ", "") oiio_cmd.extend([ - "--framepadding", _input.padding, + "--framepadding", input_item.padding, "--frames", frames, "--parallel-frames" ]) - _input: str = _input.format("{head}#{tail}") - elif not isinstance(_input, str): + input_item: str = input_item.format("{head}#{tail}") + elif not isinstance(input_item, str): raise TypeError( - f"Input is not a string or Collection: {_input}" + f"Input is not a string or Collection: {input_item}" ) oiio_cmd.extend([ - input_arg, _input, + input_arg, input_item, # Tell oiiotool which channels should be put to top stack # (and output) "--ch", channels_arg, @@ -772,7 +772,7 @@ def convert_input_paths_for_ffmpeg( oiio_cmd.extend(["--eraseattrib", attr_name]) # Add last argument - path to output - base_filename = os.path.basename(_input) + base_filename = os.path.basename(input_item) output_path = os.path.join(output_dir, base_filename) oiio_cmd.extend([ "-o", output_path From af901213a2fd31571fe3d06252eb20989fe2b6dc Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 12 Dec 2025 10:31:54 +0100 Subject: [PATCH 94/99] Update client/ayon_core/lib/transcoding.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/lib/transcoding.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index c37a0a88b7..e82905fe98 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1261,9 +1261,7 @@ def oiio_color_convert( ]) if parallel_frames: - oiio_cmd.extend([ - "--parallel-frames" - ]) + oiio_cmd.append("--parallel-frames") oiio_cmd.extend([ input_arg, input_path, From f9ca97ec714d261b6b1d1b94fa90f3b057bd57a7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 12 Dec 2025 10:32:54 +0100 Subject: [PATCH 95/99] Perform actual type hint --- client/ayon_core/lib/transcoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index c37a0a88b7..965ab5e865 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1228,7 +1228,7 @@ def oiio_color_convert( # Get oiioinfo only from first image, otherwise file can't be found first_input_path = input_path if frames: - assert isinstance(frames, str) # for type hints + frames: str first_frame = int(re.split("[ x-]", frames, 1)[0]) first_frame = str(first_frame).zfill(frame_padding or 0) for token in ["#", "%d"]: From 2a7316b262cc54c0ee50e05819c64d8ab3b61cc1 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 12 Dec 2025 10:40:10 +0100 Subject: [PATCH 96/99] Type hints to the arguments --- client/ayon_core/lib/transcoding.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index cc74964a2f..061a7bd398 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1173,10 +1173,10 @@ def oiio_color_convert( target_display=None, target_view=None, additional_command_args=None, - frames=None, - frame_padding=None, - parallel_frames=False, - logger=None, + frames: Optional[str] = None, + frame_padding: Optional[int] = None, + parallel_frames: bool = False, + logger: Optional[logging.Logger] = None, ): """Transcode source file to other with colormanagement. From 55eb4cccbe267af4f8560604e73a7214b13a276b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 12 Dec 2025 10:44:46 +0100 Subject: [PATCH 97/99] Add soft compatibility requirement to `ayon_third_party` `>=1.3.0` --- package.py | 1 + 1 file changed, 1 insertion(+) diff --git a/package.py b/package.py index 003c41f0f5..857b3f6906 100644 --- a/package.py +++ b/package.py @@ -12,6 +12,7 @@ ayon_server_version = ">=1.8.4,<2.0.0" ayon_launcher_version = ">=1.0.2" ayon_required_addons = {} ayon_compatible_addons = { + "ayon_third_party": ">=1.3.0", "ayon_ocio": ">=1.2.1", "applications": ">=1.1.2", "harmony": ">0.4.0", From ea2642ab15692910c41bf1151e1f89cd56d0c722 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 12 Dec 2025 12:21:06 +0100 Subject: [PATCH 98/99] added resolution and pixel aspect to version --- client/ayon_core/plugins/publish/integrate.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 9f24b35754..b37091e8d4 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -924,8 +924,12 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # Include optional data if present in optionals = [ - "frameStart", "frameEnd", "step", - "handleEnd", "handleStart", "sourceHashes" + "frameStart", "frameEnd", + "handleEnd", "handleStart", + "resolutionWidth", "resolutionHeight", + "pixelAspect", + "step", + "sourceHashes" ] for key in optionals: if key in instance.data: From 15b0192d4effe4723a41ffeffe96440346aba5d2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 12 Dec 2025 12:54:17 +0100 Subject: [PATCH 99/99] move step next to frames Co-authored-by: Roy Nieterau --- client/ayon_core/plugins/publish/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index b37091e8d4..5afdcbdf07 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -926,9 +926,9 @@ class IntegrateAsset(pyblish.api.InstancePlugin): optionals = [ "frameStart", "frameEnd", "handleEnd", "handleStart", + "step", "resolutionWidth", "resolutionHeight", "pixelAspect", - "step", "sourceHashes" ] for key in optionals: