From ddc9390c909da21a01fd8d623a4234a7dafbb977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Fri, 17 Jul 2026 11:10:51 +0200 Subject: [PATCH 1/4] Add support for delivery template overrides Allow delivery functions to accept an explicit anatomy template object instead of looking it up by name in the project anatomy. This enables dynamic template overrides based on representation rules. Also includes: - New delivery tool settings models for template override rules - Support for `{publishedFilename}` without extension in templates - Integration of override lookups in the delivery UI logic --- client/ayon_core/pipeline/delivery.py | 45 ++++++++++--- client/ayon_core/tools/delivery/delivery.py | 42 +++++++++--- server/settings/tools.py | 71 ++++++++++++++++++++- 3 files changed, 139 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/pipeline/delivery.py b/client/ayon_core/pipeline/delivery.py index e686b739ae9..fe39e36914c 100644 --- a/client/ayon_core/pipeline/delivery.py +++ b/client/ayon_core/pipeline/delivery.py @@ -66,7 +66,9 @@ def check_destination_path( anatomy, anatomy_data, datetime_data, - template_name + template_name, + *, + explicit_template_obj=None, ): """ Try to create destination path based on 'template_name'. @@ -80,14 +82,20 @@ def check_destination_path( datetime_data (dict): Values with actual date. template_name (str): Name of template which should be used from anatomy templates. + explicit_template_obj (AnatomyTemplateItem, optional): Anatomy template + item to use instead of the one defined in the delivery template. Returns: Dict[str, List[str]]: Report of happened errors. Key is message title value is detailed information. """ anatomy_data.update(datetime_data) - path_template = anatomy.get_template_item( - "delivery", template_name, "path" + + if explicit_template_obj is not None: + path_template = explicit_template_obj.get("path") + else: + path_template = anatomy.get_template_item( + "delivery", template_name, "path" ) dest_path = path_template.format(anatomy_data) report_items = collections.defaultdict(list) @@ -131,7 +139,9 @@ def deliver_single_file( anatomy_data, format_dict, report_items, - log + log, + *, + explicit_template_obj=None ): """Copy single file to calculated path based on template @@ -145,6 +155,8 @@ def deliver_single_file( format_dict (dict): root dictionary with names and values report_items (collections.defaultdict): to return error messages log (logging.Logger): for log printing + explicit_template_obj (AnatomyTemplateItem, optional): Anatomy template + item to use instead of the one defined in the delivery template. Returns: (collections.defaultdict, int) @@ -161,8 +173,12 @@ def deliver_single_file( if format_dict: anatomy_data = copy.deepcopy(anatomy_data) anatomy_data["root"] = format_dict["root"] - template_obj = anatomy.get_template_item( - "delivery", template_name, "path" + + if explicit_template_obj is not None: + template_obj = explicit_template_obj.get("path") + else: + template_obj = anatomy.get_template_item( + "delivery", template_name, "path" ) delivery_path = template_obj.format_strict(anatomy_data) @@ -192,8 +208,10 @@ def deliver_sequence( format_dict, report_items, log, + *, has_renumbered_frame=False, - new_frame_start=0 + new_frame_start=0, + explicit_template_obj=None, ): """ For Pype2(mainly - works in 3 too) where representation might not contain files. @@ -213,6 +231,12 @@ def deliver_sequence( format_dict (dict): root dictionary with names and values report_items (collections.defaultdict): to return error messages log (logging.Logger): for log printing + has_renumbered_frame (bool, optional): whether the frame has been + renumbered. + new_frame_start (int, optional): new frame start value. + explicit_template_obj (AnatomyTemplateItem, optional): Anatomy template + item to use instead of the one defined in the delivery template. + Returns: (collections.defaultdict, int) @@ -233,8 +257,11 @@ def hash_path_exist(myPath): report_items["Source file was not found"].append(msg) return report_items, 0 - delivery_template = anatomy.get_template_item( - "delivery", template_name, "path", default=None + if explicit_template_obj is not None: + delivery_template = explicit_template_obj.get("path") + else: + delivery_template = anatomy.get_template_item( + "delivery", template_name, "path", default=None ) if delivery_template is None: msg = ( diff --git a/client/ayon_core/tools/delivery/delivery.py b/client/ayon_core/tools/delivery/delivery.py index 2b60dd916fa..d7e4c1b8c57 100644 --- a/client/ayon_core/tools/delivery/delivery.py +++ b/client/ayon_core/tools/delivery/delivery.py @@ -1,26 +1,26 @@ +import logging import os import platform -import logging from collections import defaultdict import ayon_api -from qtpy import QtWidgets, QtCore, QtGui +from qtpy import QtCore, QtGui, QtWidgets from ayon_core import resources, style from ayon_core.lib import ( - format_file_size, collect_frames, + format_file_size, get_datetime_data, ) from ayon_core.pipeline import Anatomy - -from ayon_core.pipeline.load import get_representation_path_with_anatomy from ayon_core.pipeline.delivery import ( - get_format_dict, check_destination_path, deliver_single_file, + get_format_dict, get_representations_delivery_template_data, ) +from ayon_core.pipeline.load import get_representation_path_with_anatomy +from ayon_core.settings import get_project_settings class DeliveryOptionsDialog(QtWidgets.QDialog): @@ -229,7 +229,21 @@ def deliver(self): self.anatomy.project_name, repre_ids ) ) + # TODO: implement overrides + # get settings and check if template name in override presets + # if it is then get template data from self.templates with + # template name. Then get representation rules + core_settings = get_project_settings(self.anatomy.project_name) + overrides: list[dict] = core_settings["tools"]["delivery"]["overrides"] + # check if template name is in overrides keys + override_preset = None + for preset in overrides: + if preset["name"] == template_name: + override_preset = preset + break + for repre in filtered_repres: + explicit_template_obj = None repre_path = get_representation_path_with_anatomy( repre, self.anatomy ) @@ -238,6 +252,12 @@ def deliver(self): if list_label: template_data["list"] = {"label": list_label} + # TODO: check if representation is in override preset rules + # if it is then get override template data + # get format new explicit_template_obj and add it to args. + if override_preset: + + # Use temporary placeholder so we don't need to check destination # path per file of the representation template_data["publishedFilename"] = "" @@ -246,7 +266,8 @@ def deliver(self): self.anatomy, template_data, datetime_data, - template_name + template_name, + explicit_template_obj=explicit_template_obj, ) report_items.update(new_report_items) @@ -261,7 +282,8 @@ def deliver(self): template_data, format_dict, report_items, - self.log + self.log, + explicit_template_obj, ] # TODO: This will currently incorrectly detect 'resources' @@ -283,11 +305,13 @@ def deliver(self): for src_path, frame in sources_and_frames.items(): # Support {publishedFilename} token - template_data["publishedFilename"] = os.path.basename( + publish_basename = os.path.basename( # Replace backslash to forward slashes so basename # also resolves to filename only on POSIX src_path.replace("\\", "/") ) + filename, _ = os.path.splitext(publish_basename) + template_data["publishedFilename"] = filename args[0] = src_path # Renumber frames if renumber_frame and frame is not None: diff --git a/server/settings/tools.py b/server/settings/tools.py index 84847a88d4b..5fa929f119c 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -451,6 +451,67 @@ class PublishToolModel(BaseSettingsModel): ) +class DeliveryRepresentationRuleModel(BaseSettingsModel): + name: str = SettingsField( + "", + title="Representation Name", + description="Name of the representation to match" + ) + template_dir: str = SettingsField( + "", + title="Directory Template Override", + description=( + "Overriding 'Directory Template'. Additional tokens: \n" + "- `{directory}` - original 'Directory Template'\n" + "- `{file}` - original 'File Name Template'" + ) + ) + template_file: str = SettingsField( + "", + title="File Name Template Override", + description=( + "Overriding 'File Name Template'. Additional tokens: \n" + "- `{directory}` - original 'Directory Template'\n" + "- `{file}` - original 'File Name Template'" + ) + ) + @validator("name") + def normalize_value(cls, value): + return normalize_name(value) + + +class DeliveryTemplateOverrideModel(BaseSettingsModel): + name: str = SettingsField( + "", + title="Anatomy DeliveryTemplate Name", + description="Name of the delivery category template to match" + ) + rules: list[DeliveryRepresentationRuleModel] = SettingsField( + default_factory=list, + title="Representation Rules", + description=( + "Rules to override the chosen delivery template. " + "Rules are evaluated in order, and the first matching rule " + "determines the delivery template to use." + ) + ) + @validator("name") + def normalize_value(cls, value): + return normalize_name(value) + + +class DeliveryToolModel(BaseSettingsModel): + overrides: list[DeliveryTemplateOverrideModel] = SettingsField( + default_factory=list, + title="Delivery Anatomy Template Override Presets", + description=( + "Overrides presets to override the chosen delivery template. " + "Evaluated in order, and the first matching override " + "determines the delivery template to use." + ) + ) + + class GlobalToolsModel(BaseSettingsModel): ayon_menu: AYONMenuModel = SettingsField( default_factory=AYONMenuModel, @@ -476,6 +537,10 @@ class GlobalToolsModel(BaseSettingsModel): default_factory=PublishToolModel, title="Publish" ) + delivery: DeliveryToolModel = SettingsField( + default_factory=DeliveryToolModel, + title="Delivery" + ) DEFAULT_TOOLS_VALUES = { @@ -772,5 +837,9 @@ class GlobalToolsModel(BaseSettingsModel): "ignore_paths": [], }, "comment_minimum_required_chars": 0, - } + }, + "delivery": { + "enabled": False, + "overrides": [], + }, } From e381e9de0152d1ea5b3263ab3fe98e754c75b315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Fri, 17 Jul 2026 12:31:31 +0200 Subject: [PATCH 2/4] Fix delivery template overrides and path formatting Implement template overrides for representation delivery and fix an issue where frame numbers were incorrectly included in directory paths when using the publishedFilename token. Also corrects the settings path for delivery overrides. --- client/ayon_core/pipeline/delivery.py | 22 ++--- client/ayon_core/tools/delivery/delivery.py | 90 ++++++++++++++++++--- 2 files changed, 89 insertions(+), 23 deletions(-) diff --git a/client/ayon_core/pipeline/delivery.py b/client/ayon_core/pipeline/delivery.py index fe39e36914c..bb51828d9dd 100644 --- a/client/ayon_core/pipeline/delivery.py +++ b/client/ayon_core/pipeline/delivery.py @@ -190,6 +190,16 @@ def deliver_single_file( delivery_path = delivery_path.rstrip() delivery_folder = os.path.dirname(delivery_path) + # Remove frame number if is find in folder path + # usually due publishedFilename token used in directory + frame = anatomy_data.get("frame") + if frame is not None: + frame_pattern = f".{frame}" + if frame_pattern in delivery_folder: + delivery_folder = delivery_folder.replace(frame_pattern, "") + delivery_path = os.path.join( + delivery_folder, os.path.basename(delivery_path)) + if not os.path.exists(delivery_folder): os.makedirs(delivery_folder) @@ -208,10 +218,8 @@ def deliver_sequence( format_dict, report_items, log, - *, has_renumbered_frame=False, - new_frame_start=0, - explicit_template_obj=None, + new_frame_start=0 ): """ For Pype2(mainly - works in 3 too) where representation might not contain files. @@ -234,9 +242,6 @@ def deliver_sequence( has_renumbered_frame (bool, optional): whether the frame has been renumbered. new_frame_start (int, optional): new frame start value. - explicit_template_obj (AnatomyTemplateItem, optional): Anatomy template - item to use instead of the one defined in the delivery template. - Returns: (collections.defaultdict, int) @@ -257,10 +262,7 @@ def hash_path_exist(myPath): report_items["Source file was not found"].append(msg) return report_items, 0 - if explicit_template_obj is not None: - delivery_template = explicit_template_obj.get("path") - else: - delivery_template = anatomy.get_template_item( + delivery_template = anatomy.get_template_item( "delivery", template_name, "path", default=None ) if delivery_template is None: diff --git a/client/ayon_core/tools/delivery/delivery.py b/client/ayon_core/tools/delivery/delivery.py index d7e4c1b8c57..34ee3fc1baf 100644 --- a/client/ayon_core/tools/delivery/delivery.py +++ b/client/ayon_core/tools/delivery/delivery.py @@ -13,6 +13,7 @@ get_datetime_data, ) from ayon_core.pipeline import Anatomy +from ayon_core.pipeline.anatomy.templates import TemplateItem from ayon_core.pipeline.delivery import ( check_destination_path, deliver_single_file, @@ -229,13 +230,10 @@ def deliver(self): self.anatomy.project_name, repre_ids ) ) - # TODO: implement overrides - # get settings and check if template name in override presets - # if it is then get template data from self.templates with - # template name. Then get representation rules core_settings = get_project_settings(self.anatomy.project_name) - overrides: list[dict] = core_settings["tools"]["delivery"]["overrides"] - # check if template name is in overrides keys + overrides: list[dict] = ( + core_settings["core"]["tools"]["delivery"]["overrides"] + ) override_preset = None for preset in overrides: if preset["name"] == template_name: @@ -252,11 +250,10 @@ def deliver(self): if list_label: template_data["list"] = {"label": list_label} - # TODO: check if representation is in override preset rules - # if it is then get override template data - # get format new explicit_template_obj and add it to args. if override_preset: - + explicit_template_obj = self._get_explicit_template_obj( + repre, override_preset, template_name + ) # Use temporary placeholder so we don't need to check destination # path per file of the representation @@ -283,7 +280,6 @@ def deliver(self): format_dict, report_items, self.log, - explicit_template_obj, ] # TODO: This will currently incorrectly detect 'resources' @@ -322,7 +318,10 @@ def deliver(self): # Add offset to new frame start dst_frame = int(frame) + offset if dst_frame < 0: - msg = "Renumber frame has a smaller number than original frame" # noqa + msg = ( + "Renumber frame has a smaller number than " + "original frame" + ) report_items[msg].append(src_path) self.log.warning("{} <{}>".format( msg, dst_frame)) @@ -342,13 +341,78 @@ def deliver(self): " formatting data." ) template_data["frame"] = frame - new_report_items, uploaded = deliver_single_file(*args) + + new_report_items, uploaded = deliver_single_file( + *args, + explicit_template_obj=explicit_template_obj + ) report_items.update(new_report_items) self._update_progress(uploaded) self.text_area.setText(self._format_report(report_items)) self.text_area.setVisible(True) + def _get_explicit_template_obj( + self, repre, override_preset, template_name + ): + """Build an explicit template override for a representation.""" + if not override_preset: + return None + + matching_rule = None + for rule in override_preset["rules"]: + if rule["name"] == repre["name"]: + matching_rule = rule + break + + if matching_rule is None: + return None + + original_template = self.templates[template_name] + if ( + not matching_rule["template_dir"] + and not matching_rule["template_file"] + ): + return None + + original_directory = original_template["directory"] + original_file = original_template["file"] + # make sure if file or directory is in template it is + # wrapped into double braces so it is not formatted + # e.g. "{directory}" should be "{{directory}}" so it is not + # replaced by the format method + m_directory_tmpl = ( + matching_rule["template_dir"] or original_directory + ) + if m_directory_tmpl != original_directory: + if "{directory}" in m_directory_tmpl: + m_directory_tmpl = m_directory_tmpl.replace( + "{directory}", original_directory + ) + if "{file}" in m_directory_tmpl: + m_directory_tmpl = m_directory_tmpl.replace( + "{file}", original_file + ) + print(m_directory_tmpl) + + m_file_tmpl = ( + matching_rule["template_file"] or original_file + ) + if m_file_tmpl != original_file: + if "{directory}" in m_file_tmpl: + m_file_tmpl = m_file_tmpl.replace( + "{directory}", original_directory + ) + if "{file}" in m_file_tmpl: + m_file_tmpl = m_file_tmpl.replace( + "{file}", original_file + ) + + return TemplateItem( + self.anatomy.templates_obj, + {"directory": m_directory_tmpl, "file": m_file_tmpl}, + ) + def _get_representation_names(self): """Get set of representation names for checkbox filtering.""" return set([repre["name"] for repre in self._representations]) From 1424f9142f80ec09edba270a690a2e8a30333462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Fri, 17 Jul 2026 12:38:41 +0200 Subject: [PATCH 3/4] Add missing blank lines before validators --- server/settings/tools.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/settings/tools.py b/server/settings/tools.py index 5fa929f119c..aac106bc315 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -475,6 +475,7 @@ class DeliveryRepresentationRuleModel(BaseSettingsModel): "- `{file}` - original 'File Name Template'" ) ) + @validator("name") def normalize_value(cls, value): return normalize_name(value) @@ -495,6 +496,7 @@ class DeliveryTemplateOverrideModel(BaseSettingsModel): "determines the delivery template to use." ) ) + @validator("name") def normalize_value(cls, value): return normalize_name(value) From 4160f2197e22fd7eb0f1eb86dc424001bff2caf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Fri, 17 Jul 2026 12:41:35 +0200 Subject: [PATCH 4/4] Remove debug print from delivery tool --- client/ayon_core/tools/delivery/delivery.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/delivery/delivery.py b/client/ayon_core/tools/delivery/delivery.py index 34ee3fc1baf..6012ee4c9f5 100644 --- a/client/ayon_core/tools/delivery/delivery.py +++ b/client/ayon_core/tools/delivery/delivery.py @@ -393,7 +393,6 @@ def _get_explicit_template_obj( m_directory_tmpl = m_directory_tmpl.replace( "{file}", original_file ) - print(m_directory_tmpl) m_file_tmpl = ( matching_rule["template_file"] or original_file