From 2757c6efbb7e68c6c0ff1ac43e6d4b0bef2c2971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 12 Aug 2025 18:42:49 +0200 Subject: [PATCH 1/7] :sparkles: very raw WIP version --- .../plugins/load/create_hero_version.py | 415 ++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 client/ayon_core/plugins/load/create_hero_version.py diff --git a/client/ayon_core/plugins/load/create_hero_version.py b/client/ayon_core/plugins/load/create_hero_version.py new file mode 100644 index 0000000000..7e1a0d8a3d --- /dev/null +++ b/client/ayon_core/plugins/load/create_hero_version.py @@ -0,0 +1,415 @@ + +import os +import copy +import shutil +import errno +import itertools +from concurrent.futures import ThreadPoolExecutor + +from speedcopy import copyfile +import clique +import ayon_api +from ayon_api.operations import OperationsSession, new_version_entity +from ayon_api.utils import create_entity_id +from qtpy import QtWidgets, QtCore +from ayon_core import style +from ayon_core.pipeline import load, Anatomy +from ayon_core.lib import create_hard_link, source_hash +from ayon_core.lib.file_transaction import wait_for_future_errors +from ayon_core.pipeline.publish import get_publish_template_name +from ayon_core.pipeline.template_data import get_template_data + + +def prepare_changes(old_entity, new_entity): + changes = {} + for key in set(new_entity.keys()): + if key == "attrib": + continue + if key in new_entity and new_entity[key] != old_entity.get(key): + changes[key] = new_entity[key] + attrib_changes = {} + if "attrib" in new_entity: + for key, value in new_entity["attrib"].items(): + if value != old_entity["attrib"].get(key): + attrib_changes[key] = value + if attrib_changes: + changes["attrib"] = attrib_changes + return changes + + +class CreateHeroVersion(load.ProductLoaderPlugin): + """Create hero version from selected context.""" + + is_multiple_contexts_compatible = False + representations = {"*"} + product_types = {"*"} + label = "Create Hero Version" + order = 36 + icon = "star" + color = "#ffd700" + + ignored_representation_names = [] + db_representation_context_keys = [ + "project", "folder", "asset", "hierarchy", "task", "product", + "subset", "family", "representation", "username", "user", "output" + ] + use_hardlinks = False + + def message(self, text): + msgBox = QtWidgets.QMessageBox() + msgBox.setText(text) + msgBox.setStyleSheet(style.load_stylesheet()) + msgBox.setWindowFlags( + msgBox.windowFlags() | QtCore.Qt.FramelessWindowHint + ) + msgBox.exec_() + + + def load(self, context, name=None, namespace=None, options=None) -> None: + """Load hero version from context (dict as in context.py).""" + success = True + errors = [] + + # Extract project, product, version, folder from context + project = context.get("project") + product = context.get("product") + version = context.get("version") + folder = context.get("folder") + task_entity = ayon_api.get_task_by_id( + task_id=version.get("taskId"), project_name=project["name"]) + + anatomy = Anatomy(project["name"]) + + version_id = version["id"] + project_name = project["name"] + repres = list( + ayon_api.get_representations(project_name, version_ids={version_id})) + anatomy_data = get_template_data( + project_entity=project, + folder_entity=folder, + task_entity=task_entity, + ) + anatomy_data["product"] = { + "name": product["name"], + "type": product["productType"], + } + published_representations = {} + for repre in repres: + repre_anatomy = anatomy_data + repre_anatomy["ext"] = repre.get("ext", "") + published_representations[repre["id"]] = { + "representation": repre, + "published_files": [f["path"] for f in repre.get("files", [])], + "anatomy_data": repre_anatomy + } + + instance_data = { + "productName": product["name"], + "productType": product["productType"], + "anatomyData": anatomy_data, + "publishDir": "", # TODO: Set to actual publish directory + "published_representations": published_representations, + "versionEntity": version, + } + + try: + self.create_hero_version(instance_data, anatomy, context) + except Exception as exc: + success = False + errors.append(str(exc)) + if success: + self.message("Hero version created successfully.") + else: + self.message( + f"Failed to create hero version:\n{chr(10).join(errors)}") + + def create_hero_version(self, instance_data, anatomy, context): + """Create hero version from instance data.""" + published_repres = instance_data.get("published_representations") + if not published_repres: + raise RuntimeError("No published representations found.") + + project_name = anatomy.project_name + template_key = get_publish_template_name( + project_name, + context.get("hostName"), + instance_data.get("productType"), + instance_data.get("anatomyData", {}).get("task", {}).get("name"), + instance_data.get("anatomyData", {}).get("task", {}).get("type"), + project_settings=context.get("project_settings", {}), + hero=True, + logger=None + ) + hero_template = anatomy.get_template_item("hero", template_key, "path", default=None) + if hero_template is None: + raise RuntimeError(f"Project anatomy does not have hero template key: {template_key}") + + print(f"Hero template: {hero_template.template}") + + hero_publish_dir = self.get_publish_dir(instance_data, anatomy, template_key) + + print(f"Hero publish dir: {hero_publish_dir}") + + src_version_entity = instance_data.get("versionEntity") + filtered_repre_ids = [] + for repre_id, repre_info in published_repres.items(): + repre = repre_info["representation"] + if repre["name"].lower() in self.ignored_representation_names: + filtered_repre_ids.append(repre_id) + for repre_id in filtered_repre_ids: + published_repres.pop(repre_id, None) + if not published_repres: + raise RuntimeError("All published representations were filtered by name.") + + if src_version_entity is None: + src_version_entity = self.version_from_representations(project_name, published_repres) + if not src_version_entity: + raise RuntimeError("Can't find origin version in database.") + if src_version_entity["version"] == 0: + raise RuntimeError("Version 0 cannot have hero version.") + + all_copied_files = [] + transfers = instance_data.get("transfers", list()) + for _src, dst in transfers: + dst = os.path.normpath(dst) + if dst not in all_copied_files: + all_copied_files.append(dst) + hardlinks = instance_data.get("hardlinks", list()) + for _src, dst in hardlinks: + dst = os.path.normpath(dst) + if dst not in all_copied_files: + all_copied_files.append(dst) + + all_repre_file_paths = [] + for repre_info in published_repres.values(): + published_files = repre_info.get("published_files") or [] + for file_path in published_files: + file_path = os.path.normpath(file_path) + if file_path not in all_repre_file_paths: + all_repre_file_paths.append(file_path) + + instance_publish_dir = os.path.normpath(instance_data["publishDir"]) + other_file_paths_mapping = [] + for file_path in all_copied_files: + if not file_path.startswith(instance_publish_dir): + continue + if file_path in all_repre_file_paths: + continue + dst_filepath = file_path.replace(instance_publish_dir, hero_publish_dir) + other_file_paths_mapping.append((file_path, dst_filepath)) + + old_version, old_repres = self.current_hero_ents(project_name, src_version_entity) + inactive_old_repres_by_name = {} + old_repres_by_name = {} + for repre in old_repres: + low_name = repre["name"].lower() + if repre["active"]: + old_repres_by_name[low_name] = repre + else: + inactive_old_repres_by_name[low_name] = repre + + op_session = OperationsSession() + entity_id = old_version["id"] if old_version else None + new_hero_version = new_version_entity( + -src_version_entity["version"], + src_version_entity["productId"], + task_id=src_version_entity.get("taskId"), + data=copy.deepcopy(src_version_entity["data"]), + attribs=copy.deepcopy(src_version_entity["attrib"]), + entity_id=entity_id, + ) + if old_version: + update_data = prepare_changes(old_version, new_hero_version) + op_session.update_entity(project_name, "version", old_version["id"], update_data) + else: + op_session.create_entity(project_name, "version", new_hero_version) + + # Store hero entity to instance_data + instance_data["heroVersionEntity"] = new_hero_version + + old_repres_to_replace = {} + old_repres_to_delete = {} + for repre_info in published_repres.values(): + repre = repre_info["representation"] + repre_name_low = repre["name"].lower() + if repre_name_low in old_repres_by_name: + old_repres_to_replace[repre_name_low] = old_repres_by_name.pop(repre_name_low) + if old_repres_by_name: + old_repres_to_delete = old_repres_by_name + + backup_hero_publish_dir = None + if os.path.exists(hero_publish_dir): + backup_hero_publish_dir = hero_publish_dir + ".BACKUP" + max_idx = 10 + idx = 0 + _backup_hero_publish_dir = backup_hero_publish_dir + while os.path.exists(_backup_hero_publish_dir): + try: + shutil.rmtree(_backup_hero_publish_dir) + backup_hero_publish_dir = _backup_hero_publish_dir + break + except Exception: + _backup_hero_publish_dir = backup_hero_publish_dir + str(idx) + if not os.path.exists(_backup_hero_publish_dir): + backup_hero_publish_dir = _backup_hero_publish_dir + break + if idx > max_idx: + raise AssertionError(f"Backup folders are fully occupied to max index {max_idx}") + idx += 1 + try: + os.rename(hero_publish_dir, backup_hero_publish_dir) + except PermissionError: + raise AssertionError( + "Could not create hero version because it is " + "not possible to replace current hero files.") + + try: + src_to_dst_file_paths = [] + repre_integrate_data = [] + path_template_obj = anatomy.get_template_item( + "hero", template_key, "path") + for repre_info in published_repres.values(): + published_files = repre_info["published_files"] + if len(published_files) == 0: + continue + anatomy_data = copy.deepcopy(repre_info["anatomy_data"]) + anatomy_data.pop("version", None) + template_filled = path_template_obj.format_strict(anatomy_data) + repre_context = template_filled.used_values + for key in self.db_representation_context_keys: + value = anatomy_data.get(key) + if value is not None: + repre_context[key] = value + repre_entity = copy.deepcopy(repre_info["representation"]) + repre_entity.pop("id", None) + repre_entity["versionId"] = new_hero_version["id"] + repre_entity["context"] = repre_context + repre_entity["attrib"] = { + "path": str(template_filled), + "template": hero_template.template + } + dst_paths = [] + if len(published_files) == 1: + dst_paths.append(str(template_filled)) + src_to_dst_file_paths.append((published_files[0], template_filled)) + print(f"Single published file: {published_files[0]} -> {template_filled}") + else: + collections, remainders = clique.assemble(published_files) + if remainders or not collections or len(collections) > 1: + raise Exception( + "Integrity error. Files of published representation is " + "combination of frame collections and single files.") + src_col = collections[0] + frame_splitter = "_-_FRAME_SPLIT_-_" + anatomy_data["frame"] = frame_splitter + _template_filled = path_template_obj.format_strict(anatomy_data) + head, tail = _template_filled.split(frame_splitter) + padding = anatomy.templates_obj.frame_padding + dst_col = clique.Collection(head=head, padding=padding, tail=tail) + dst_col.indexes.clear() + dst_col.indexes.update(src_col.indexes) + for src_file, dst_file in zip(src_col, dst_col): + src_to_dst_file_paths.append((src_file, dst_file)) + dst_paths.append(dst_file) + print(f"Collection published file: {src_file} -> {dst_file}") + repre_integrate_data.append((repre_entity, dst_paths)) + + # Copy files + with ThreadPoolExecutor(max_workers=8) as executor: + futures = [ + executor.submit(self.copy_file, src_path, dst_path) + for src_path, dst_path in itertools.chain(src_to_dst_file_paths, other_file_paths_mapping) + ] + wait_for_future_errors(executor, futures) + + # Update/create representations + for repre_entity, dst_paths in repre_integrate_data: + repre_files = self.get_files_info(dst_paths, anatomy) + repre_entity["files"] = repre_files + repre_name_low = repre_entity["name"].lower() + if repre_name_low in old_repres_to_replace: + old_repre = old_repres_to_replace.pop(repre_name_low) + repre_entity["id"] = old_repre["id"] + update_data = prepare_changes(old_repre, repre_entity) + op_session.update_entity(project_name, "representation", old_repre["id"], update_data) + elif repre_name_low in inactive_old_repres_by_name: + inactive_repre = inactive_old_repres_by_name.pop(repre_name_low) + repre_entity["id"] = inactive_repre["id"] + update_data = prepare_changes(inactive_repre, repre_entity) + op_session.update_entity(project_name, "representation", inactive_repre["id"], update_data) + else: + op_session.create_entity(project_name, "representation", repre_entity) + + for repre in old_repres_to_delete.values(): + op_session.update_entity(project_name, "representation", repre["id"], {"active": False}) + + op_session.commit() + + if backup_hero_publish_dir is not None and os.path.exists(backup_hero_publish_dir): + shutil.rmtree(backup_hero_publish_dir) + + except Exception: + if backup_hero_publish_dir is not None and os.path.exists(backup_hero_publish_dir): + if os.path.exists(hero_publish_dir): + shutil.rmtree(hero_publish_dir) + os.rename(backup_hero_publish_dir, hero_publish_dir) + raise + + def get_files_info(self, filepaths, anatomy): + file_infos = [] + for filepath in filepaths: + file_info = self.prepare_file_info(filepath, anatomy) + file_infos.append(file_info) + return file_infos + + def prepare_file_info(self, path, anatomy): + return { + "id": create_entity_id(), + "name": os.path.basename(path), + "path": self.get_rootless_path(anatomy, path), + "size": os.path.getsize(path), + "hash": source_hash(path), + "hash_type": "op3", + } + + def get_publish_dir(self, instance_data, anatomy, template_key): + template_data = copy.deepcopy(instance_data.get("anatomyData", {})) + if "originalBasename" in instance_data: + template_data["originalBasename"] = instance_data["originalBasename"] + template_obj = anatomy.get_template_item("hero", template_key, "directory") + return os.path.normpath(template_obj.format_strict(template_data)) + + def get_rootless_path(self, anatomy, path): + success, rootless_path = anatomy.find_root_template_from_path(path) + if success: + path = rootless_path + return path + + def copy_file(self, src_path, dst_path): + dirname = os.path.dirname(dst_path) + try: + os.makedirs(dirname) + except OSError as exc: + if exc.errno != errno.EEXIST: + raise + if self.use_hardlinks: + try: + create_hard_link(src_path, dst_path) + return + except OSError as exc: + if exc.errno not in [errno.EXDEV, errno.EINVAL]: + raise + copyfile(src_path, dst_path) + + def version_from_representations(self, project_name, repres): + for repre_info in repres.values(): + version = ayon_api.get_version_by_id(project_name, repre_info["representation"]["versionId"]) + if version: + return version + + def current_hero_ents(self, project_name, version): + hero_version = ayon_api.get_hero_version_by_product_id(project_name, version["productId"]) + if not hero_version: + return (None, []) + hero_repres = list(ayon_api.get_representations(project_name, version_ids={hero_version["id"]})) + return (hero_version, hero_repres) From 9ffaa15dfb4ec0484a5303ed1a8a04bd2805c7e9 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 15 Aug 2025 22:09:15 +0800 Subject: [PATCH 2/7] make sure the hero version can be created successfully by ensuring to copy the path into the right path --- .../plugins/load/create_hero_version.py | 141 ++++++++++++++---- 1 file changed, 110 insertions(+), 31 deletions(-) diff --git a/client/ayon_core/plugins/load/create_hero_version.py b/client/ayon_core/plugins/load/create_hero_version.py index 7e1a0d8a3d..b18e874644 100644 --- a/client/ayon_core/plugins/load/create_hero_version.py +++ b/client/ayon_core/plugins/load/create_hero_version.py @@ -14,7 +14,7 @@ from ayon_api.utils import create_entity_id from qtpy import QtWidgets, QtCore from ayon_core import style from ayon_core.pipeline import load, Anatomy -from ayon_core.lib import create_hard_link, source_hash +from ayon_core.lib import create_hard_link, source_hash, StringTemplate from ayon_core.lib.file_transaction import wait_for_future_errors from ayon_core.pipeline.publish import get_publish_template_name from ayon_core.pipeline.template_data import get_template_data @@ -83,7 +83,10 @@ class CreateHeroVersion(load.ProductLoaderPlugin): version_id = version["id"] project_name = project["name"] repres = list( - ayon_api.get_representations(project_name, version_ids={version_id})) + ayon_api.get_representations( + project_name, version_ids={version_id} + ) + ) anatomy_data = get_template_data( project_entity=project, folder_entity=folder, @@ -95,8 +98,9 @@ class CreateHeroVersion(load.ProductLoaderPlugin): } published_representations = {} for repre in repres: - repre_anatomy = anatomy_data - repre_anatomy["ext"] = repre.get("ext", "") + repre_anatomy = copy.deepcopy(anatomy_data) + if "ext" not in repre_anatomy: + repre_anatomy["ext"] = repre.get("context", {}).get("ext", "") published_representations[repre["id"]] = { "representation": repre, "published_files": [f["path"] for f in repre.get("files", [])], @@ -140,13 +144,18 @@ class CreateHeroVersion(load.ProductLoaderPlugin): hero=True, logger=None ) - hero_template = anatomy.get_template_item("hero", template_key, "path", default=None) + hero_template = anatomy.get_template_item( + "hero", template_key, "path", default=None + ) if hero_template is None: - raise RuntimeError(f"Project anatomy does not have hero template key: {template_key}") + raise RuntimeError("Project anatomy does not have hero " + f"template key: {template_key}") print(f"Hero template: {hero_template.template}") - hero_publish_dir = self.get_publish_dir(instance_data, anatomy, template_key) + hero_publish_dir = self.get_publish_dir( + instance_data, anatomy, template_key + ) print(f"Hero publish dir: {hero_publish_dir}") @@ -162,7 +171,8 @@ class CreateHeroVersion(load.ProductLoaderPlugin): raise RuntimeError("All published representations were filtered by name.") if src_version_entity is None: - src_version_entity = self.version_from_representations(project_name, published_repres) + src_version_entity = self.version_from_representations( + project_name, published_repres) if not src_version_entity: raise RuntimeError("Can't find origin version in database.") if src_version_entity["version"] == 0: @@ -195,10 +205,14 @@ class CreateHeroVersion(load.ProductLoaderPlugin): continue if file_path in all_repre_file_paths: continue - dst_filepath = file_path.replace(instance_publish_dir, hero_publish_dir) + dst_filepath = file_path.replace( + instance_publish_dir, hero_publish_dir + ) other_file_paths_mapping.append((file_path, dst_filepath)) - old_version, old_repres = self.current_hero_ents(project_name, src_version_entity) + old_version, old_repres = self.current_hero_ents( + project_name, src_version_entity + ) inactive_old_repres_by_name = {} old_repres_by_name = {} for repre in old_repres: @@ -220,7 +234,9 @@ class CreateHeroVersion(load.ProductLoaderPlugin): ) if old_version: update_data = prepare_changes(old_version, new_hero_version) - op_session.update_entity(project_name, "version", old_version["id"], update_data) + op_session.update_entity( + project_name, "version", old_version["id"], update_data + ) else: op_session.create_entity(project_name, "version", new_hero_version) @@ -233,7 +249,9 @@ class CreateHeroVersion(load.ProductLoaderPlugin): repre = repre_info["representation"] repre_name_low = repre["name"].lower() if repre_name_low in old_repres_by_name: - old_repres_to_replace[repre_name_low] = old_repres_by_name.pop(repre_name_low) + old_repres_to_replace[repre_name_low] = ( + old_repres_by_name.pop(repre_name_low) + ) if old_repres_by_name: old_repres_to_delete = old_repres_by_name @@ -254,7 +272,9 @@ class CreateHeroVersion(load.ProductLoaderPlugin): backup_hero_publish_dir = _backup_hero_publish_dir break if idx > max_idx: - raise AssertionError(f"Backup folders are fully occupied to max index {max_idx}") + raise AssertionError( + f"Backup folders are fully occupied to max index {max_idx}" + ) idx += 1 try: os.rename(hero_publish_dir, backup_hero_publish_dir) @@ -268,6 +288,7 @@ class CreateHeroVersion(load.ProductLoaderPlugin): repre_integrate_data = [] path_template_obj = anatomy.get_template_item( "hero", template_key, "path") + anatomy_root = {"root": anatomy.roots} for repre_info in published_repres.values(): published_files = repre_info["published_files"] if len(published_files) == 0: @@ -289,10 +310,21 @@ class CreateHeroVersion(load.ProductLoaderPlugin): "template": hero_template.template } dst_paths = [] + if len(published_files) == 1: dst_paths.append(str(template_filled)) - src_to_dst_file_paths.append((published_files[0], template_filled)) - print(f"Single published file: {published_files[0]} -> {template_filled}") + mapped_published_file = StringTemplate( + published_files[0]).format_strict( + anatomy_root + ) + src_to_dst_file_paths.append( + (mapped_published_file, template_filled) + ) + print( + f"Single published file: {mapped_published_file} -> " + f"{template_filled}" + ) + # src_to_dst_file_paths being wrong else: collections, remainders = clique.assemble(published_files) if remainders or not collections or len(collections) > 1: @@ -302,23 +334,34 @@ class CreateHeroVersion(load.ProductLoaderPlugin): src_col = collections[0] frame_splitter = "_-_FRAME_SPLIT_-_" anatomy_data["frame"] = frame_splitter - _template_filled = path_template_obj.format_strict(anatomy_data) + _template_filled = path_template_obj.format_strict( + anatomy_data + ) head, tail = _template_filled.split(frame_splitter) padding = anatomy.templates_obj.frame_padding - dst_col = clique.Collection(head=head, padding=padding, tail=tail) + dst_col = clique.Collection( + head=head, padding=padding, tail=tail + ) dst_col.indexes.clear() dst_col.indexes.update(src_col.indexes) for src_file, dst_file in zip(src_col, dst_col): + src_file = StringTemplate(src_file).format_strict( + anatomy_root + ) src_to_dst_file_paths.append((src_file, dst_file)) dst_paths.append(dst_file) - print(f"Collection published file: {src_file} -> {dst_file}") + print( + f"Collection published file: {src_file} " + f"-> {dst_file}" + ) repre_integrate_data.append((repre_entity, dst_paths)) # Copy files with ThreadPoolExecutor(max_workers=8) as executor: futures = [ executor.submit(self.copy_file, src_path, dst_path) - for src_path, dst_path in itertools.chain(src_to_dst_file_paths, other_file_paths_mapping) + for src_path, dst_path in itertools.chain( + src_to_dst_file_paths, other_file_paths_mapping) ] wait_for_future_errors(executor, futures) @@ -331,25 +374,49 @@ class CreateHeroVersion(load.ProductLoaderPlugin): old_repre = old_repres_to_replace.pop(repre_name_low) repre_entity["id"] = old_repre["id"] update_data = prepare_changes(old_repre, repre_entity) - op_session.update_entity(project_name, "representation", old_repre["id"], update_data) + op_session.update_entity( + project_name, + "representation", + old_repre["id"], + update_data + ) elif repre_name_low in inactive_old_repres_by_name: - inactive_repre = inactive_old_repres_by_name.pop(repre_name_low) + inactive_repre = inactive_old_repres_by_name.pop( + repre_name_low + ) repre_entity["id"] = inactive_repre["id"] update_data = prepare_changes(inactive_repre, repre_entity) - op_session.update_entity(project_name, "representation", inactive_repre["id"], update_data) + op_session.update_entity( + project_name, + "representation", + inactive_repre["id"], + update_data + ) else: - op_session.create_entity(project_name, "representation", repre_entity) + op_session.create_entity( + project_name, + "representation", + repre_entity + ) for repre in old_repres_to_delete.values(): - op_session.update_entity(project_name, "representation", repre["id"], {"active": False}) + op_session.update_entity( + project_name, + "representation", + repre["id"], + {"active": False} + ) op_session.commit() - if backup_hero_publish_dir is not None and os.path.exists(backup_hero_publish_dir): + if backup_hero_publish_dir is not None and os.path.exists( + backup_hero_publish_dir + ): shutil.rmtree(backup_hero_publish_dir) except Exception: - if backup_hero_publish_dir is not None and os.path.exists(backup_hero_publish_dir): + if backup_hero_publish_dir is not None and os.path.exists( + backup_hero_publish_dir): if os.path.exists(hero_publish_dir): shutil.rmtree(hero_publish_dir) os.rename(backup_hero_publish_dir, hero_publish_dir) @@ -375,8 +442,12 @@ class CreateHeroVersion(load.ProductLoaderPlugin): def get_publish_dir(self, instance_data, anatomy, template_key): template_data = copy.deepcopy(instance_data.get("anatomyData", {})) if "originalBasename" in instance_data: - template_data["originalBasename"] = instance_data["originalBasename"] - template_obj = anatomy.get_template_item("hero", template_key, "directory") + template_data["originalBasename"] = ( + instance_data["originalBasename"] + ) + template_obj = anatomy.get_template_item( + "hero", template_key, "directory" + ) return os.path.normpath(template_obj.format_strict(template_data)) def get_rootless_path(self, anatomy, path): @@ -403,13 +474,21 @@ class CreateHeroVersion(load.ProductLoaderPlugin): def version_from_representations(self, project_name, repres): for repre_info in repres.values(): - version = ayon_api.get_version_by_id(project_name, repre_info["representation"]["versionId"]) + version = ayon_api.get_version_by_id( + project_name, repre_info["representation"]["versionId"] + ) if version: return version def current_hero_ents(self, project_name, version): - hero_version = ayon_api.get_hero_version_by_product_id(project_name, version["productId"]) + hero_version = ayon_api.get_hero_version_by_product_id( + project_name, version["productId"] + ) if not hero_version: return (None, []) - hero_repres = list(ayon_api.get_representations(project_name, version_ids={hero_version["id"]})) + hero_repres = list( + ayon_api.get_representations( + project_name, version_ids={hero_version["id"]} + ) + ) return (hero_version, hero_repres) From 7e9493736d1abbddda6b2efa37302ac03a864fe3 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 15 Aug 2025 22:27:22 +0800 Subject: [PATCH 3/7] ruff fix --- .../plugins/load/create_hero_version.py | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/plugins/load/create_hero_version.py b/client/ayon_core/plugins/load/create_hero_version.py index b18e874644..d741dafcce 100644 --- a/client/ayon_core/plugins/load/create_hero_version.py +++ b/client/ayon_core/plugins/load/create_hero_version.py @@ -64,7 +64,6 @@ class CreateHeroVersion(load.ProductLoaderPlugin): ) msgBox.exec_() - def load(self, context, name=None, namespace=None, options=None) -> None: """Load hero version from context (dict as in context.py).""" success = True @@ -168,7 +167,9 @@ class CreateHeroVersion(load.ProductLoaderPlugin): for repre_id in filtered_repre_ids: published_repres.pop(repre_id, None) if not published_repres: - raise RuntimeError("All published representations were filtered by name.") + raise RuntimeError( + "All published representations were filtered by name." + ) if src_version_entity is None: src_version_entity = self.version_from_representations( @@ -266,15 +267,18 @@ class CreateHeroVersion(load.ProductLoaderPlugin): shutil.rmtree(_backup_hero_publish_dir) backup_hero_publish_dir = _backup_hero_publish_dir break - except Exception: - _backup_hero_publish_dir = backup_hero_publish_dir + str(idx) + except Exception as exc: + _backup_hero_publish_dir = ( + backup_hero_publish_dir + str(idx) + ) if not os.path.exists(_backup_hero_publish_dir): backup_hero_publish_dir = _backup_hero_publish_dir break if idx > max_idx: raise AssertionError( - f"Backup folders are fully occupied to max index {max_idx}" - ) + "Backup folders are fully occupied to max index " + f"{max_idx}" + ) from exc idx += 1 try: os.rename(hero_publish_dir, backup_hero_publish_dir) @@ -324,13 +328,16 @@ class CreateHeroVersion(load.ProductLoaderPlugin): f"Single published file: {mapped_published_file} -> " f"{template_filled}" ) - # src_to_dst_file_paths being wrong else: collections, remainders = clique.assemble(published_files) if remainders or not collections or len(collections) > 1: raise Exception( - "Integrity error. Files of published representation is " - "combination of frame collections and single files.") + ( + "Integrity error. Files of published " + "representation is combination of frame " + "collections and single files." + ) + ) src_col = collections[0] frame_splitter = "_-_FRAME_SPLIT_-_" anatomy_data["frame"] = frame_splitter From 5740b9f495f3c81f7eb405e83d81e804493a19c5 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 22 Aug 2025 16:04:27 +0800 Subject: [PATCH 4/7] update publishDir as being part of the instance_data --- .../plugins/load/create_hero_version.py | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/plugins/load/create_hero_version.py b/client/ayon_core/plugins/load/create_hero_version.py index d741dafcce..2d55069abf 100644 --- a/client/ayon_core/plugins/load/create_hero_version.py +++ b/client/ayon_core/plugins/load/create_hero_version.py @@ -75,7 +75,8 @@ class CreateHeroVersion(load.ProductLoaderPlugin): version = context.get("version") folder = context.get("folder") task_entity = ayon_api.get_task_by_id( - task_id=version.get("taskId"), project_name=project["name"]) + task_id=version.get("taskId"), project_name=project["name"] + ) anatomy = Anatomy(project["name"]) @@ -95,6 +96,7 @@ class CreateHeroVersion(load.ProductLoaderPlugin): "name": product["name"], "type": product["productType"], } + anatomy_data["version"] = version["version"] published_representations = {} for repre in repres: repre_anatomy = copy.deepcopy(anatomy_data) @@ -105,12 +107,26 @@ class CreateHeroVersion(load.ProductLoaderPlugin): "published_files": [f["path"] for f in repre.get("files", [])], "anatomy_data": repre_anatomy } - + publish_template_key = get_publish_template_name( + project_name, + context.get("hostName"), + product["productType"], + task_name=anatomy_data.get("task", {}).get("name"), + task_type=anatomy_data.get("task", {}).get("type"), + project_settings=context.get("project_settings", {}), + logger=self.log + ) + published_template_obj = anatomy.get_template_item( + "publish", publish_template_key, "directory" + ) + published_dir = os.path.normpath( + published_template_obj.format_strict(anatomy_data) + ) instance_data = { "productName": product["name"], "productType": product["productType"], "anatomyData": anatomy_data, - "publishDir": "", # TODO: Set to actual publish directory + "publishDir": published_dir, "published_representations": published_representations, "versionEntity": version, } @@ -199,7 +215,12 @@ class CreateHeroVersion(load.ProductLoaderPlugin): if file_path not in all_repre_file_paths: all_repre_file_paths.append(file_path) - instance_publish_dir = os.path.normpath(instance_data["publishDir"]) + publish_dir = instance_data.get("publishDir", "") + if not publish_dir: + raise RuntimeError( + "publishDir is empty in instance_data, cannot continue." + ) + instance_publish_dir = os.path.normpath(publish_dir) other_file_paths_mapping = [] for file_path in all_copied_files: if not file_path.startswith(instance_publish_dir): @@ -331,7 +352,7 @@ class CreateHeroVersion(load.ProductLoaderPlugin): else: collections, remainders = clique.assemble(published_files) if remainders or not collections or len(collections) > 1: - raise Exception( + raise RuntimeError( ( "Integrity error. Files of published " "representation is combination of frame " From 22e18cdfa253c7c4c53751bff2928e11206101fa Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 22 Aug 2025 16:07:02 +0800 Subject: [PATCH 5/7] add comment --- client/ayon_core/plugins/load/create_hero_version.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/plugins/load/create_hero_version.py b/client/ayon_core/plugins/load/create_hero_version.py index 2d55069abf..e9dbbfa652 100644 --- a/client/ayon_core/plugins/load/create_hero_version.py +++ b/client/ayon_core/plugins/load/create_hero_version.py @@ -107,6 +107,7 @@ class CreateHeroVersion(load.ProductLoaderPlugin): "published_files": [f["path"] for f in repre.get("files", [])], "anatomy_data": repre_anatomy } + # get the publish directory publish_template_key = get_publish_template_name( project_name, context.get("hostName"), From 89d0777bafcda1da9a7355a00da619cf8c326870 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 22 Aug 2025 16:21:37 +0800 Subject: [PATCH 6/7] copilot's feedback on - backup directory loop --- .../plugins/load/create_hero_version.py | 42 ++++++++----------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/client/ayon_core/plugins/load/create_hero_version.py b/client/ayon_core/plugins/load/create_hero_version.py index e9dbbfa652..adf9d5f669 100644 --- a/client/ayon_core/plugins/load/create_hero_version.py +++ b/client/ayon_core/plugins/load/create_hero_version.py @@ -167,13 +167,13 @@ class CreateHeroVersion(load.ProductLoaderPlugin): raise RuntimeError("Project anatomy does not have hero " f"template key: {template_key}") - print(f"Hero template: {hero_template.template}") + self.log.info(f"Hero template: {hero_template.template}") hero_publish_dir = self.get_publish_dir( instance_data, anatomy, template_key ) - print(f"Hero publish dir: {hero_publish_dir}") + self.log.info(f"Hero publish dir: {hero_publish_dir}") src_version_entity = instance_data.get("versionEntity") filtered_repre_ids = [] @@ -280,28 +280,22 @@ class CreateHeroVersion(load.ProductLoaderPlugin): backup_hero_publish_dir = None if os.path.exists(hero_publish_dir): - backup_hero_publish_dir = hero_publish_dir + ".BACKUP" + base_backup_dir = hero_publish_dir + ".BACKUP" max_idx = 10 - idx = 0 - _backup_hero_publish_dir = backup_hero_publish_dir - while os.path.exists(_backup_hero_publish_dir): - try: - shutil.rmtree(_backup_hero_publish_dir) - backup_hero_publish_dir = _backup_hero_publish_dir + # Find the first available backup directory name + for idx in range(max_idx + 1): + if idx == 0: + candidate_backup_dir = base_backup_dir + else: + candidate_backup_dir = f"{base_backup_dir}{idx}" + if not os.path.exists(candidate_backup_dir): + backup_hero_publish_dir = candidate_backup_dir break - except Exception as exc: - _backup_hero_publish_dir = ( - backup_hero_publish_dir + str(idx) - ) - if not os.path.exists(_backup_hero_publish_dir): - backup_hero_publish_dir = _backup_hero_publish_dir - break - if idx > max_idx: - raise AssertionError( - "Backup folders are fully occupied to max index " - f"{max_idx}" - ) from exc - idx += 1 + else: + raise AssertionError( + f"Backup folders are fully occupied to max index {max_idx}" + ) + try: os.rename(hero_publish_dir, backup_hero_publish_dir) except PermissionError: @@ -346,7 +340,7 @@ class CreateHeroVersion(load.ProductLoaderPlugin): src_to_dst_file_paths.append( (mapped_published_file, template_filled) ) - print( + self.log.info( f"Single published file: {mapped_published_file} -> " f"{template_filled}" ) @@ -379,7 +373,7 @@ class CreateHeroVersion(load.ProductLoaderPlugin): ) src_to_dst_file_paths.append((src_file, dst_file)) dst_paths.append(dst_file) - print( + self.log.info( f"Collection published file: {src_file} " f"-> {dst_file}" ) From 941d4aee9ea66b758bd337560fb48b6e38a1b1c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 26 Aug 2025 15:13:52 +0200 Subject: [PATCH 7/7] :recycle: add docstrings and hints --- .../plugins/load/create_hero_version.py | 165 +++++++++++++++--- 1 file changed, 139 insertions(+), 26 deletions(-) diff --git a/client/ayon_core/plugins/load/create_hero_version.py b/client/ayon_core/plugins/load/create_hero_version.py index adf9d5f669..aef0cf8863 100644 --- a/client/ayon_core/plugins/load/create_hero_version.py +++ b/client/ayon_core/plugins/load/create_hero_version.py @@ -1,10 +1,12 @@ - +"""Plugin to create hero version from selected context.""" +from __future__ import annotations import os import copy import shutil import errno import itertools from concurrent.futures import ThreadPoolExecutor +from typing import Any, Optional from speedcopy import copyfile import clique @@ -20,7 +22,17 @@ from ayon_core.pipeline.publish import get_publish_template_name from ayon_core.pipeline.template_data import get_template_data -def prepare_changes(old_entity, new_entity): +def prepare_changes(old_entity: dict, new_entity: dict) -> dict: + """Prepare changes dict for update entity operation. + + Args: + old_entity (dict): Existing entity data from database. + new_entity (dict): New entity data to compare against old. + + Returns: + dict: Changes to apply to old entity to make it like new entity. + + """ changes = {} for key in set(new_entity.keys()): if key == "attrib": @@ -48,19 +60,21 @@ class CreateHeroVersion(load.ProductLoaderPlugin): icon = "star" color = "#ffd700" - ignored_representation_names = [] + ignored_representation_names: list[str] = [] db_representation_context_keys = [ "project", "folder", "asset", "hierarchy", "task", "product", "subset", "family", "representation", "username", "user", "output" ] use_hardlinks = False - def message(self, text): + @staticmethod + def message(text: str) -> None: + """Show message box with text.""" msgBox = QtWidgets.QMessageBox() msgBox.setText(text) msgBox.setStyleSheet(style.load_stylesheet()) msgBox.setWindowFlags( - msgBox.windowFlags() | QtCore.Qt.FramelessWindowHint + msgBox.windowFlags() | QtCore.Qt.WindowType.FramelessWindowHint ) msgBox.exec_() @@ -143,8 +157,31 @@ class CreateHeroVersion(load.ProductLoaderPlugin): self.message( f"Failed to create hero version:\n{chr(10).join(errors)}") - def create_hero_version(self, instance_data, anatomy, context): - """Create hero version from instance data.""" + def create_hero_version( + self, + instance_data: dict[str, Any], + anatomy: Anatomy, + context: dict[str, Any]) -> None: + """Create hero version from instance data. + + Args: + instance_data (dict): Instance data with keys: + - productName (str): Name of the product. + - productType (str): Type of the product. + - anatomyData (dict): Anatomy data for templates. + - publishDir (str): Directory where the product is published. + - published_representations (dict): Published representations. + - versionEntity (dict, optional): Source version entity. + anatomy (Anatomy): Anatomy object for the project. + context (dict): Context data with keys: + - hostName (str): Name of the host application. + - project_settings (dict): Project settings. + + Raises: + RuntimeError: If any required data is missing or an error occurs + during the hero version creation process. + + """ published_repres = instance_data.get("published_representations") if not published_repres: raise RuntimeError("No published representations found.") @@ -158,7 +195,6 @@ class CreateHeroVersion(load.ProductLoaderPlugin): instance_data.get("anatomyData", {}).get("task", {}).get("type"), project_settings=context.get("project_settings", {}), hero=True, - logger=None ) hero_template = anatomy.get_template_item( "hero", template_key, "path", default=None @@ -197,12 +233,12 @@ class CreateHeroVersion(load.ProductLoaderPlugin): raise RuntimeError("Version 0 cannot have hero version.") all_copied_files = [] - transfers = instance_data.get("transfers", list()) + transfers = instance_data.get("transfers", []) for _src, dst in transfers: dst = os.path.normpath(dst) if dst not in all_copied_files: all_copied_files.append(dst) - hardlinks = instance_data.get("hardlinks", list()) + hardlinks = instance_data.get("hardlinks", []) for _src, dst in hardlinks: dst = os.path.normpath(dst) if dst not in all_copied_files: @@ -267,7 +303,6 @@ class CreateHeroVersion(load.ProductLoaderPlugin): instance_data["heroVersionEntity"] = new_hero_version old_repres_to_replace = {} - old_repres_to_delete = {} for repre_info in published_repres.values(): repre = repre_info["representation"] repre_name_low = repre["name"].lower() @@ -275,12 +310,10 @@ class CreateHeroVersion(load.ProductLoaderPlugin): old_repres_to_replace[repre_name_low] = ( old_repres_by_name.pop(repre_name_low) ) - if old_repres_by_name: - old_repres_to_delete = old_repres_by_name - + old_repres_to_delete = old_repres_by_name or {} backup_hero_publish_dir = None if os.path.exists(hero_publish_dir): - base_backup_dir = hero_publish_dir + ".BACKUP" + base_backup_dir = f"{hero_publish_dir}.BACKUP" max_idx = 10 # Find the first available backup directory name for idx in range(max_idx + 1): @@ -298,10 +331,11 @@ class CreateHeroVersion(load.ProductLoaderPlugin): try: os.rename(hero_publish_dir, backup_hero_publish_dir) - except PermissionError: + except PermissionError as e: raise AssertionError( "Could not create hero version because it is " - "not possible to replace current hero files.") + "not possible to replace current hero files." + ) from e try: src_to_dst_file_paths = [] @@ -445,14 +479,41 @@ class CreateHeroVersion(load.ProductLoaderPlugin): os.rename(backup_hero_publish_dir, hero_publish_dir) raise - def get_files_info(self, filepaths, anatomy): + def get_files_info( + self, filepaths: list[str], anatomy: Anatomy) -> list[dict]: + """Get list of file info dictionaries for given file paths. + + Args: + filepaths (list[str]): List of absolute file paths. + anatomy (Anatomy): Anatomy object for the project. + + Returns: + list[dict]: List of file info dictionaries. + + """ file_infos = [] for filepath in filepaths: file_info = self.prepare_file_info(filepath, anatomy) file_infos.append(file_info) return file_infos - def prepare_file_info(self, path, anatomy): + def prepare_file_info(self, path: str, anatomy: Anatomy) -> dict: + """Prepare file info dictionary for given path. + + Args: + path (str): Absolute file path. + anatomy (Anatomy): Anatomy object for the project. + + Returns: + dict: File info dictionary with keys: + - id (str): Unique identifier for the file. + - name (str): Base name of the file. + - path (str): Rootless file path. + - size (int): Size of the file in bytes. + - hash (str): Hash of the file content. + - hash_type (str): Type of the hash used. + + """ return { "id": create_entity_id(), "name": os.path.basename(path), @@ -462,7 +523,22 @@ class CreateHeroVersion(load.ProductLoaderPlugin): "hash_type": "op3", } - def get_publish_dir(self, instance_data, anatomy, template_key): + @staticmethod + def get_publish_dir( + instance_data: dict, + anatomy: Anatomy, + template_key: str) -> str: + """Get publish directory from instance data and anatomy. + + Args: + instance_data (dict): Instance data with "anatomyData" key. + anatomy (Anatomy): Anatomy object for the project. + template_key (str): Template key for the hero template. + + Returns: + str: Normalized publish directory path. + + """ template_data = copy.deepcopy(instance_data.get("anatomyData", {})) if "originalBasename" in instance_data: template_data["originalBasename"] = ( @@ -473,13 +549,34 @@ class CreateHeroVersion(load.ProductLoaderPlugin): ) return os.path.normpath(template_obj.format_strict(template_data)) - def get_rootless_path(self, anatomy, path): + @staticmethod + def get_rootless_path(anatomy: Anatomy, path: str) -> str: + """Get rootless path from absolute path. + + Args: + anatomy (Anatomy): Anatomy object for the project. + path (str): Absolute file path. + + Returns: + str: Rootless file path if root found, else original path. + + """ success, rootless_path = anatomy.find_root_template_from_path(path) if success: path = rootless_path return path - def copy_file(self, src_path, dst_path): + def copy_file(self, src_path: str, dst_path: str) -> None: + """Copy file from src to dst with creating directories. + + Args: + src_path (str): Source file path. + dst_path (str): Destination file path. + + Raises: + OSError: If copying or linking fails. + + """ dirname = os.path.dirname(dst_path) try: os.makedirs(dirname) @@ -495,23 +592,39 @@ class CreateHeroVersion(load.ProductLoaderPlugin): raise copyfile(src_path, dst_path) - def version_from_representations(self, project_name, repres): + @staticmethod + def version_from_representations( + project_name: str, repres: dict) -> Optional[dict[str, Any]]: + """Find version from representations. + + Args: + project_name (str): Name of the project. + repres (dict): Dictionary of representations info. + + Returns: + Optional[dict]: Version entity if found, else None. + + """ for repre_info in repres.values(): version = ayon_api.get_version_by_id( project_name, repre_info["representation"]["versionId"] ) if version: return version + return None - def current_hero_ents(self, project_name, version): + @staticmethod + def current_hero_ents( + project_name: str, + version: dict[str, Any]) -> tuple[Any, list[dict[str, Any]]]: hero_version = ayon_api.get_hero_version_by_product_id( project_name, version["productId"] ) if not hero_version: - return (None, []) + return None, [] hero_repres = list( ayon_api.get_representations( project_name, version_ids={hero_version["id"]} ) ) - return (hero_version, hero_repres) + return hero_version, hero_repres