Merge branch 'develop' into bugfix/ocio-v2-aces1.3-display-resolving-error

This commit is contained in:
Jakub Ježek 2025-09-08 15:08:54 +02:00 committed by GitHub
commit bd48482746
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 949 additions and 161 deletions

View file

@ -35,6 +35,7 @@ body:
label: Version
description: What version are you running? Look to AYON Tray
options:
- 1.6.0
- 1.5.3
- 1.5.2
- 1.5.1

View file

@ -14,7 +14,7 @@ class OCIOEnvHook(PreLaunchHook):
"fusion",
"blender",
"aftereffects",
"3dsmax",
"max",
"houdini",
"maya",
"nuke",

View file

@ -0,0 +1,630 @@
"""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
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, 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
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":
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: list[str] = []
db_representation_context_keys = [
"project", "folder", "asset", "hierarchy", "task", "product",
"subset", "family", "representation", "username", "user", "output"
]
use_hardlinks = False
@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.WindowType.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"],
}
anatomy_data["version"] = version["version"]
published_representations = {}
for repre in repres:
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", [])],
"anatomy_data": repre_anatomy
}
# get the publish directory
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": published_dir,
"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: 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.")
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,
)
hero_template = anatomy.get_template_item(
"hero", template_key, "path", default=None
)
if hero_template is None:
raise RuntimeError("Project anatomy does not have hero "
f"template key: {template_key}")
self.log.info(f"Hero template: {hero_template.template}")
hero_publish_dir = self.get_publish_dir(
instance_data, anatomy, template_key
)
self.log.info(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", [])
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", [])
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)
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):
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 = {}
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)
)
old_repres_to_delete = old_repres_by_name or {}
backup_hero_publish_dir = None
if os.path.exists(hero_publish_dir):
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):
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
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 as e:
raise AssertionError(
"Could not create hero version because it is "
"not possible to replace current hero files."
) from e
try:
src_to_dst_file_paths = []
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:
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))
mapped_published_file = StringTemplate(
published_files[0]).format_strict(
anatomy_root
)
src_to_dst_file_paths.append(
(mapped_published_file, template_filled)
)
self.log.info(
f"Single published file: {mapped_published_file} -> "
f"{template_filled}"
)
else:
collections, remainders = clique.assemble(published_files)
if remainders or not collections or len(collections) > 1:
raise RuntimeError(
(
"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_file = StringTemplate(src_file).format_strict(
anatomy_root
)
src_to_dst_file_paths.append((src_file, dst_file))
dst_paths.append(dst_file)
self.log.info(
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)
]
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: 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: 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),
"path": self.get_rootless_path(anatomy, path),
"size": os.path.getsize(path),
"hash": source_hash(path),
"hash_type": "op3",
}
@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"] = (
instance_data["originalBasename"]
)
template_obj = anatomy.get_template_item(
"hero", template_key, "directory"
)
return os.path.normpath(template_obj.format_strict(template_data))
@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: 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)
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)
@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
@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, []
hero_repres = list(
ayon_api.get_representations(
project_name, version_ids={hero_version["id"]}
)
)
return hero_version, hero_repres

View file

@ -6,15 +6,15 @@ from ayon_core.pipeline import load
from ayon_core.pipeline.load import LoadError
class PushToLibraryProject(load.ProductLoaderPlugin):
"""Export selected versions to folder structure from Template"""
class PushToProject(load.ProductLoaderPlugin):
"""Export selected versions to different project"""
is_multiple_contexts_compatible = True
representations = {"*"}
product_types = {"*"}
label = "Push to Library project"
label = "Push to project"
order = 35
icon = "send"
color = "#d8d8d8"
@ -28,10 +28,12 @@ class PushToLibraryProject(load.ProductLoaderPlugin):
if not filtered_contexts:
raise LoadError("Nothing to push for your selection")
if len(filtered_contexts) > 1:
raise LoadError("Please select only one item")
context = tuple(filtered_contexts)[0]
folder_ids = set(
context["folder"]["id"]
for context in filtered_contexts
)
if len(folder_ids) > 1:
raise LoadError("Please select products from single folder")
push_tool_script_path = os.path.join(
AYON_CORE_ROOT,
@ -39,14 +41,16 @@ class PushToLibraryProject(load.ProductLoaderPlugin):
"push_to_project",
"main.py"
)
project_name = filtered_contexts[0]["project"]["name"]
project_name = context["project"]["name"]
version_id = context["version"]["id"]
version_ids = {
context["version"]["id"]
for context in filtered_contexts
}
args = get_ayon_launcher_args(
"run",
push_tool_script_path,
"--project", project_name,
"--version", version_id
"--versions", ",".join(version_ids)
)
run_detached_process(args)

View file

@ -1,4 +1,5 @@
import threading
from typing import Dict
import ayon_api
@ -13,10 +14,11 @@ from .models import (
UserPublishValuesModel,
IntegrateModel,
)
from .models.integrate import ProjectPushItemProcess
class PushToContextController:
def __init__(self, project_name=None, version_id=None):
def __init__(self, project_name=None, version_ids=None):
self._event_system = self._create_event_system()
self._projects_model = ProjectsModel(self)
@ -27,18 +29,20 @@ class PushToContextController:
self._user_values = UserPublishValuesModel(self)
self._src_project_name = None
self._src_version_id = None
self._src_version_ids = []
self._src_folder_entity = None
self._src_folder_task_entities = {}
self._src_product_entity = None
self._src_version_entity = None
self._src_version_entities = []
self._src_product_entities = {}
self._src_label = None
self._submission_enabled = False
self._process_thread = None
self._process_item_id = None
self.set_source(project_name, version_id)
self._use_original_name = False
self.set_source(project_name, version_ids)
# Events system
def emit_event(self, topic, data=None, source=None):
@ -51,38 +55,47 @@ class PushToContextController:
def register_event_callback(self, topic, callback):
self._event_system.add_callback(topic, callback)
def set_source(self, project_name, version_id):
def set_source(self, project_name, version_ids):
"""Set source project and version.
There is currently assumption that tool is working on products of same
folder.
Args:
project_name (Union[str, None]): Source project name.
version_id (Union[str, None]): Source version id.
version_ids (Optional[list[str]]): Version ids.
"""
if not project_name or not version_ids:
return
if (
project_name == self._src_project_name
and version_id == self._src_version_id
and version_ids == self._src_version_ids
):
return
self._src_project_name = project_name
self._src_version_id = version_id
self._src_version_ids = version_ids
self._src_label = None
folder_entity = None
task_entities = {}
product_entity = None
version_entity = None
if project_name and version_id:
version_entity = ayon_api.get_version_by_id(
project_name, version_id
product_entities = []
version_entities = []
if project_name and self._src_version_ids:
version_entities = list(ayon_api.get_versions(
project_name, version_ids=self._src_version_ids))
if version_entities:
product_ids = [
version_entity["productId"]
for version_entity in version_entities
]
product_entities = list(ayon_api.get_products(
project_name, product_ids=product_ids)
)
if version_entity:
product_entity = ayon_api.get_product_by_id(
project_name, version_entity["productId"]
)
if product_entity:
if product_entities:
# all products for same folder
product_entity = product_entities[0]
folder_entity = ayon_api.get_folder_by_id(
project_name, product_entity["folderId"]
)
@ -97,15 +110,18 @@ class PushToContextController:
self._src_folder_entity = folder_entity
self._src_folder_task_entities = task_entities
self._src_product_entity = product_entity
self._src_version_entity = version_entity
self._src_version_entities = version_entities
self._src_product_entities = {
product["id"]: product
for product in product_entities
}
if folder_entity:
self._user_values.set_new_folder_name(folder_entity["name"])
variant = self._get_src_variant()
if variant:
self._user_values.set_variant(variant)
comment = version_entity["attrib"].get("comment")
comment = version_entities[0]["attrib"].get("comment")
if comment:
self._user_values.set_comment(comment)
@ -113,7 +129,7 @@ class PushToContextController:
"source.changed",
{
"project_name": project_name,
"version_id": version_id
"version_ids": self._src_version_ids
}
)
@ -142,6 +158,14 @@ class PushToContextController:
def get_user_values(self):
return self._user_values.get_data()
def original_names_required(self):
"""Checks if original product names must be used.
Currently simple check if multiple versions, but if multiple products
with different product_type were used, it wouldn't be necessary.
"""
return len(self._src_version_entities) > 1
def set_user_value_folder_name(self, folder_name):
self._user_values.set_new_folder_name(folder_name)
self._invalidate()
@ -165,8 +189,9 @@ class PushToContextController:
def set_selected_task(self, task_id, task_name):
self._selection_model.set_selected_task(task_id, task_name)
def get_process_item_status(self, item_id):
return self._integrate_model.get_item_status(item_id)
def get_process_items(self) -> Dict[str, ProjectPushItemProcess]:
"""Returns dict of all ProjectPushItemProcess items """
return self._integrate_model.get_items()
# Processing methods
def submit(self, wait=True):
@ -176,29 +201,33 @@ class PushToContextController:
if self._process_thread is not None:
return
item_id = self._integrate_model.create_process_item(
self._src_project_name,
self._src_version_id,
self._selection_model.get_selected_project_name(),
self._selection_model.get_selected_folder_id(),
self._selection_model.get_selected_task_name(),
self._user_values.variant,
comment=self._user_values.comment,
new_folder_name=self._user_values.new_folder_name,
dst_version=1
)
item_ids = []
for src_version_entity in self._src_version_entities:
item_id = self._integrate_model.create_process_item(
self._src_project_name,
src_version_entity["id"],
self._selection_model.get_selected_project_name(),
self._selection_model.get_selected_folder_id(),
self._selection_model.get_selected_task_name(),
self._user_values.variant,
comment=self._user_values.comment,
new_folder_name=self._user_values.new_folder_name,
dst_version=1,
use_original_name=self._use_original_name,
)
item_ids.append(item_id)
self._process_item_id = item_id
self._process_item_ids = item_ids
self._emit_event("submit.started")
if wait:
self._submit_callback()
self._process_item_id = None
self._process_item_ids = []
return item_id
thread = threading.Thread(target=self._submit_callback)
self._process_thread = thread
thread.start()
return item_id
return item_ids
def wait_for_process_thread(self):
if self._process_thread is None:
@ -207,7 +236,7 @@ class PushToContextController:
self._process_thread = None
def _prepare_source_label(self):
if not self._src_project_name or not self._src_version_id:
if not self._src_project_name or not self._src_version_ids:
return "Source is not defined"
folder_entity = self._src_folder_entity
@ -215,14 +244,21 @@ class PushToContextController:
return "Source is invalid"
folder_path = folder_entity["path"]
product_entity = self._src_product_entity
version_entity = self._src_version_entity
return "Source: {}{}/{}/v{:0>3}".format(
self._src_project_name,
folder_path,
product_entity["name"],
version_entity["version"]
)
src_labels = []
for version_entity in self._src_version_entities:
product_entity = self._src_product_entities.get(
version_entity["productId"]
)
src_labels.append(
"Source: {}{}/{}/v{:0>3}".format(
self._src_project_name,
folder_path,
product_entity["name"],
version_entity["version"],
)
)
return "\n".join(src_labels)
def _get_task_info_from_repre_entities(
self, task_entities, repre_entities
@ -256,7 +292,8 @@ class PushToContextController:
def _get_src_variant(self):
project_name = self._src_project_name
version_entity = self._src_version_entity
# parse variant only from first version
version_entity = self._src_version_entities[0]
task_entities = self._src_folder_task_entities
repre_entities = ayon_api.get_representations(
project_name, version_ids={version_entity["id"]}
@ -264,9 +301,12 @@ class PushToContextController:
task_name, task_type = self._get_task_info_from_repre_entities(
task_entities, repre_entities
)
product_entity = self._src_product_entities.get(
version_entity["productId"]
)
project_settings = get_project_settings(project_name)
product_type = self._src_product_entity["productType"]
product_type = product_entity["productType"]
template = get_product_name_template(
self._src_project_name,
product_type,
@ -300,7 +340,7 @@ class PushToContextController:
print("Failed format", exc)
return ""
product_name = self._src_product_entity["name"]
product_name = product_entity["name"]
if (
(product_s and not product_name.startswith(product_s))
or (product_e and not product_name.endswith(product_e))
@ -314,9 +354,6 @@ class PushToContextController:
return product_name
def _check_submit_validations(self):
if not self._user_values.is_valid:
return False
if not self._selection_model.get_selected_project_name():
return False
@ -325,6 +362,13 @@ class PushToContextController:
and not self._selection_model.get_selected_folder_id()
):
return False
if self._use_original_name:
return True
if not self._user_values.is_valid:
return False
return True
def _invalidate(self):
@ -338,13 +382,14 @@ class PushToContextController:
)
def _submit_callback(self):
process_item_id = self._process_item_id
if process_item_id is None:
return
self._integrate_model.integrate_item(process_item_id)
process_item_ids = self._process_item_ids
for process_item_id in process_item_ids:
self._integrate_model.integrate_item(process_item_id)
self._emit_event("submit.finished", {})
if process_item_id == self._process_item_id:
self._process_item_id = None
if process_item_ids is self._process_item_ids:
self._process_item_ids = []
def _emit_event(self, topic, data=None):
if data is None:

View file

@ -4,28 +4,28 @@ from ayon_core.tools.utils import get_ayon_qt_app
from ayon_core.tools.push_to_project.ui import PushToContextSelectWindow
def main_show(project_name, version_id):
def main_show(project_name, version_ids):
app = get_ayon_qt_app()
window = PushToContextSelectWindow()
window.show()
window.set_source(project_name, version_id)
window.set_source(project_name, version_ids)
app.exec_()
@click.command()
@click.option("--project", help="Source project name")
@click.option("--version", help="Source version id")
def main(project, version):
@click.option("--versions", help="Source version ids")
def main(project, versions):
"""Run PushToProject tool to integrate version in different project.
Args:
project (str): Source project name.
version (str): Version id.
versions (str): comma separated versions for same context
"""
main_show(project, version)
main_show(project, versions.split(","))
if __name__ == "__main__":

View file

@ -5,6 +5,7 @@ import itertools
import sys
import traceback
import uuid
from typing import Optional, Dict
import ayon_api
from ayon_api.utils import create_entity_id
@ -21,6 +22,7 @@ from ayon_core.lib import (
source_hash,
)
from ayon_core.lib.file_transaction import FileTransaction
from ayon_core.pipeline.thumbnails import get_thumbnail_path
from ayon_core.settings import get_project_settings
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.version_start import get_versioning_start
@ -88,6 +90,7 @@ class ProjectPushItem:
new_folder_name,
dst_version,
item_id=None,
use_original_name=False
):
if not item_id:
item_id = uuid.uuid4().hex
@ -102,6 +105,7 @@ class ProjectPushItem:
self.comment = comment or ""
self.item_id = item_id
self._repr_value = None
self.use_original_name = use_original_name
@property
def _repr(self):
@ -113,7 +117,8 @@ class ProjectPushItem:
str(self.dst_folder_id),
str(self.new_folder_name),
str(self.dst_task_name),
str(self.dst_version)
str(self.dst_version),
self.use_original_name
])
return self._repr_value
@ -132,6 +137,7 @@ class ProjectPushItem:
"comment": self.comment,
"new_folder_name": self.new_folder_name,
"item_id": self.item_id,
"use_original_name": self.use_original_name
}
@classmethod
@ -311,7 +317,7 @@ class ProjectPushRepreItem:
if self._src_files is not None:
return self._src_files, self._resource_files
repre_context = self._repre_entity["context"]
repre_context = self.repre_entity["context"]
if "frame" in repre_context or "udim" in repre_context:
src_files, resource_files = self._get_source_files_with_frames()
else:
@ -328,7 +334,7 @@ class ProjectPushRepreItem:
udim_placeholder = "__udim__"
src_files = []
resource_files = []
template = self._repre_entity["attrib"]["template"]
template = self.repre_entity["attrib"]["template"]
# Remove padding from 'udim' and 'frame' formatting keys
# - "{frame:0>4}" -> "{frame}"
for key in ("udim", "frame"):
@ -336,7 +342,7 @@ class ProjectPushRepreItem:
replacement = "{{{}}}".format(key)
template = re.sub(sub_part, replacement, template)
repre_context = self._repre_entity["context"]
repre_context = self.repre_entity["context"]
fill_repre_context = copy.deepcopy(repre_context)
if "frame" in fill_repre_context:
fill_repre_context["frame"] = frame_placeholder
@ -357,7 +363,7 @@ class ProjectPushRepreItem:
.replace(udim_placeholder, "(?P<udim>[0-9]+)")
)
src_basename_regex = re.compile("^{}$".format(src_basename))
for file_info in self._repre_entity["files"]:
for file_info in self.repre_entity["files"]:
filepath_template = self._clean_path(file_info["path"])
filepath = self._clean_path(
filepath_template.format(root=self._roots)
@ -371,7 +377,6 @@ class ProjectPushRepreItem:
resource_files.append(ResourceFile(filepath, relative_path))
continue
filepath = os.path.join(src_dirpath, basename)
frame = None
udim = None
for item in src_basename_regex.finditer(basename):
@ -389,8 +394,8 @@ class ProjectPushRepreItem:
def _get_source_files(self):
src_files = []
resource_files = []
template = self._repre_entity["attrib"]["template"]
repre_context = self._repre_entity["context"]
template = self.repre_entity["attrib"]["template"]
repre_context = self.repre_entity["context"]
fill_repre_context = copy.deepcopy(repre_context)
fill_roots = fill_repre_context["root"]
for root_name in tuple(fill_roots.keys()):
@ -399,7 +404,7 @@ class ProjectPushRepreItem:
fill_repre_context)
repre_path = self._clean_path(repre_path)
src_dirpath = os.path.dirname(repre_path)
for file_info in self._repre_entity["files"]:
for file_info in self.repre_entity["files"]:
filepath_template = self._clean_path(file_info["path"])
filepath = self._clean_path(
filepath_template.format(root=self._roots))
@ -492,8 +497,11 @@ class ProjectPushItemProcess:
except Exception as exc:
_exc, _value, _tb = sys.exc_info()
product_name = self._src_product_entity["name"]
self._status.set_failed(
"Unhandled error happened: {}".format(str(exc)),
"Unhandled error happened for `{}`: {}".format(
product_name, str(exc)
),
(_exc, _value, _tb)
)
@ -816,31 +824,34 @@ class ProjectPushItemProcess:
self._template_name = template_name
def _determine_product_name(self):
product_type = self._product_type
task_info = self._task_info
task_name = task_type = None
if task_info:
task_name = task_info["name"]
task_type = task_info["taskType"]
if self._item.use_original_name:
product_name = self._src_product_entity["name"]
else:
product_type = self._product_type
task_info = self._task_info
task_name = task_type = None
if task_info:
task_name = task_info["name"]
task_type = task_info["taskType"]
try:
product_name = get_product_name(
self._item.dst_project_name,
task_name,
task_type,
self.host_name,
product_type,
self._item.variant,
project_settings=self._project_settings
)
except TaskNotSetError:
self._status.set_failed(
"Target product name template requires task name. To continue"
" you have to select target task or change settings"
" <b>ayon+settings://core/tools/creator/product_name_profiles"
f"?project={self._item.dst_project_name}</b>."
)
raise PushToProjectError(self._status.fail_reason)
try:
product_name = get_product_name(
self._item.dst_project_name,
task_name,
task_type,
self.host_name,
product_type,
self._item.variant,
project_settings=self._project_settings
)
except TaskNotSetError:
self._status.set_failed(
"Target product name template requires task name. To "
"continue you have to select target task or change settings " # noqa: E501
" <b>ayon+settings://core/tools/creator/product_name_profiles" # noqa: E501
f"?project={self._item.dst_project_name}</b>."
)
raise PushToProjectError(self._status.fail_reason)
self._log_info(
f"Push will be integrating to product with name '{product_name}'"
@ -917,14 +928,19 @@ class ProjectPushItemProcess:
task_name=self._task_info["name"],
task_type=self._task_info["taskType"],
product_type=product_type,
product_name=product_entity["name"]
product_name=product_entity["name"],
)
existing_version_entity = ayon_api.get_version_by_name(
project_name, version, product_id
)
thumbnail_id = self._copy_version_thumbnail()
# Update existing version
if existing_version_entity:
updata_data = {"attrib": dst_attrib}
if thumbnail_id:
updata_data["thumbnailId"] = thumbnail_id
self._operations.update_entity(
project_name,
"version",
@ -939,6 +955,7 @@ class ProjectPushItemProcess:
version,
product_id,
attribs=dst_attrib,
thumbnail_id=thumbnail_id,
)
self._operations.create_entity(
project_name, "version", version_entity
@ -1005,10 +1022,18 @@ class ProjectPushItemProcess:
self, anatomy, template_name, formatting_data, file_template
):
processed_repre_items = []
repre_context = None
for repre_item in self._src_repre_items:
repre_entity = repre_item.repre_entity
repre_name = repre_entity["name"]
repre_format_data = copy.deepcopy(formatting_data)
if not repre_context:
repre_context = self._update_repre_context(
copy.deepcopy(repre_entity),
formatting_data
)
repre_format_data["representation"] = repre_name
for src_file in repre_item.src_files:
ext = os.path.splitext(src_file.path)[-1]
@ -1024,7 +1049,6 @@ class ProjectPushItemProcess:
"publish", template_name, "directory"
)
folder_path = template_obj.format_strict(formatting_data)
repre_context = folder_path.used_values
folder_path_rootless = folder_path.rootless
repre_filepaths = []
published_path = None
@ -1047,7 +1071,6 @@ class ProjectPushItemProcess:
)
if published_path is None or frame == repre_item.frame:
published_path = dst_filepath
repre_context.update(filename.used_values)
repre_filepaths.append((dst_filepath, dst_rootless_path))
self._file_transaction.add(src_file.path, dst_filepath)
@ -1134,7 +1157,7 @@ class ProjectPushItemProcess:
self._item.dst_project_name,
"representation",
entity_id,
changes
changes,
)
existing_repre_names = set(existing_repres_by_low_name.keys())
@ -1147,6 +1170,45 @@ class ProjectPushItemProcess:
{"active": False}
)
def _copy_version_thumbnail(self) -> Optional[str]:
thumbnail_id = self._src_version_entity["thumbnailId"]
if not thumbnail_id:
return None
path = get_thumbnail_path(
self._item.src_project_name,
"version",
self._src_version_entity["id"],
thumbnail_id
)
if not path:
return None
return ayon_api.create_thumbnail(
self._item.dst_project_name,
path
)
def _update_repre_context(self, repre_entity, formatting_data):
"""Replace old context value with new ones.
Folder might change, project definitely changes etc.
"""
repre_context = repre_entity["context"]
for context_key, context_value in repre_context.items():
if context_value and isinstance(context_value, dict):
for context_sub_key in context_value.keys():
value_to_update = formatting_data.get(context_key, {}).get(
context_sub_key)
if value_to_update:
repre_context[context_key][
context_sub_key] = value_to_update
else:
value_to_update = formatting_data.get(context_key)
if value_to_update:
repre_context[context_key] = value_to_update
if "task" not in formatting_data:
repre_context.pop("task")
return repre_context
class IntegrateModel:
def __init__(self, controller):
@ -1170,6 +1232,7 @@ class IntegrateModel:
comment,
new_folder_name,
dst_version,
use_original_name
):
"""Create new item for integration.
@ -1183,6 +1246,7 @@ class IntegrateModel:
comment (Union[str, None]): Comment.
new_folder_name (Union[str, None]): New folder name.
dst_version (int): Destination version number.
use_original_name (bool): If original product names should be used
Returns:
str: Item id. The id can be used to trigger integration or get
@ -1198,7 +1262,8 @@ class IntegrateModel:
variant,
comment=comment,
new_folder_name=new_folder_name,
dst_version=dst_version
dst_version=dst_version,
use_original_name=use_original_name
)
process_item = ProjectPushItemProcess(self, item)
self._process_items[item.item_id] = process_item
@ -1216,17 +1281,6 @@ class IntegrateModel:
return
item.integrate()
def get_item_status(self, item_id):
"""Status of an item.
Args:
item_id (str): Item id for which status should be returned.
Returns:
dict[str, Any]: Status data.
"""
item = self._process_items.get(item_id)
if item is not None:
return item.get_status_data()
return None
def get_items(self) -> Dict[str, ProjectPushItemProcess]:
"""Returns dict of all ProjectPushItemProcess items """
return self._process_items

View file

@ -85,6 +85,13 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
header_widget = QtWidgets.QWidget(main_context_widget)
library_only_label = QtWidgets.QLabel(
"Show only libraries",
header_widget
)
library_only_checkbox = NiceCheckbox(
True, parent=header_widget)
header_label = QtWidgets.QLabel(
controller.get_source_label(),
header_widget
@ -92,7 +99,9 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
header_layout = QtWidgets.QHBoxLayout(header_widget)
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.addWidget(header_label)
header_layout.addWidget(header_label, 1)
header_layout.addWidget(library_only_label, 0)
header_layout.addWidget(library_only_checkbox, 0)
main_splitter = QtWidgets.QSplitter(
QtCore.Qt.Horizontal, main_context_widget
@ -124,6 +133,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
inputs_widget = QtWidgets.QWidget(main_splitter)
new_folder_checkbox = NiceCheckbox(True, parent=inputs_widget)
original_names_checkbox = NiceCheckbox(False, parent=inputs_widget)
folder_name_input = PlaceholderLineEdit(inputs_widget)
folder_name_input.setPlaceholderText("< Name of new folder >")
@ -142,6 +152,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
inputs_layout.addRow("Create new folder", new_folder_checkbox)
inputs_layout.addRow("New folder name", folder_name_input)
inputs_layout.addRow("Variant", variant_input)
inputs_layout.addRow(
"Use original product names", original_names_checkbox)
inputs_layout.addRow("Comment", comment_input)
main_splitter.addWidget(context_widget)
@ -196,6 +208,10 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
show_detail_btn.setToolTip(
"Show error detail dialog to copy full error."
)
original_names_checkbox.setToolTip(
"Required for multi copy, doesn't allow changes "
"variant values."
)
overlay_close_btn = QtWidgets.QPushButton(
"Close", overlay_btns_widget
@ -240,6 +256,9 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
folder_name_input.textChanged.connect(self._on_new_folder_change)
variant_input.textChanged.connect(self._on_variant_change)
comment_input.textChanged.connect(self._on_comment_change)
library_only_checkbox.stateChanged.connect(self._on_library_only_change)
original_names_checkbox.stateChanged.connect(
self._on_original_names_change)
publish_btn.clicked.connect(self._on_select_click)
cancel_btn.clicked.connect(self._on_close_click)
@ -288,6 +307,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._new_folder_checkbox = new_folder_checkbox
self._folder_name_input = folder_name_input
self._comment_input = comment_input
self._use_original_names_checkbox = original_names_checkbox
self._publish_btn = publish_btn
@ -316,7 +336,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._main_thread_timer = main_thread_timer
self._main_thread_timer_can_stop = True
self._last_submit_message = None
self._process_item_id = None
self._variant_is_valid = None
self._folder_is_valid = None
@ -327,17 +346,17 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
overlay_try_btn.setVisible(False)
# Support of public api function of controller
def set_source(self, project_name, version_id):
def set_source(self, project_name, version_ids):
"""Set source project and version.
Call the method on controller.
Args:
project_name (Union[str, None]): Name of project.
version_id (Union[str, None]): Version id.
version_ids (Union[str, None]): comma separated Version ids.
"""
self._controller.set_source(project_name, version_id)
self._controller.set_source(project_name, version_ids)
def showEvent(self, event):
super(PushToContextSelectWindow, self).showEvent(event)
@ -352,10 +371,12 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._folder_name_input.setText(new_folder_name or "")
self._variant_input.setText(variant or "")
self._invalidate_variant(user_values["is_variant_valid"])
self._invalidate_use_original_names(
self._use_original_names_checkbox.isChecked())
self._invalidate_new_folder_name(
new_folder_name, user_values["is_new_folder_name_valid"]
)
self._controller._invalidate()
self._projects_combobox.refresh()
def _on_first_show(self):
@ -394,6 +415,15 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._comment_input_text = text
self._user_input_changed_timer.start()
def _on_library_only_change(self, state: int) -> None:
"""Change toggle state, reset filter, recalculate dropdown"""
state = bool(state)
self._projects_combobox.set_standard_filter_enabled(state)
def _on_original_names_change(self, state: int) -> None:
use_original_name = bool(state)
self._invalidate_use_original_names(use_original_name)
def _on_user_input_timer(self):
folder_name_enabled = self._new_folder_name_enabled
folder_name = self._new_folder_name_input_text
@ -456,17 +486,27 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
state = ""
if folder_name is not None:
state = "valid" if is_valid else "invalid"
set_style_property(
self._folder_name_input, "state", state
)
set_style_property(self._folder_name_input, "state", state)
def _invalidate_variant(self, is_valid):
if self._variant_is_valid is is_valid:
return
self._variant_is_valid = is_valid
state = "valid" if is_valid else "invalid"
set_style_property(self._variant_input, "state", state)
def _invalidate_use_original_names(self, use_original_names):
"""Checks if original names must be used.
Invalidates Variant if necessary
"""
if self._controller.original_names_required():
use_original_names = True
self._variant_input.setEnabled(not use_original_names)
self._invalidate_variant(not use_original_names)
self._controller._use_original_name = use_original_names
self._use_original_names_checkbox.setChecked(use_original_names)
def _on_submission_change(self, event):
self._publish_btn.setEnabled(event["enabled"])
@ -495,31 +535,43 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._overlay_label.setText(self._last_submit_message)
self._last_submit_message = None
process_status = self._controller.get_process_item_status(
self._process_item_id
)
push_failed = process_status["failed"]
fail_traceback = process_status["full_traceback"]
failed_pushes = []
fail_tracebacks = []
for process_item in self._controller.get_process_items().values():
process_status = process_item.get_status_data()
if process_status["failed"]:
failed_pushes.append(process_status)
# push_failed = process_status["failed"]
# fail_traceback = process_status["full_traceback"]
if self._main_thread_timer_can_stop:
self._main_thread_timer.stop()
self._overlay_close_btn.setVisible(True)
if push_failed:
if failed_pushes:
self._overlay_try_btn.setVisible(True)
if fail_traceback:
fail_tracebacks = [
process_status["full_traceback"]
for process_status in failed_pushes
if process_status["full_traceback"]
]
if fail_tracebacks:
self._show_detail_btn.setVisible(True)
if push_failed:
reason = process_status["fail_reason"]
if fail_traceback:
if failed_pushes:
reasons = [
process_status["fail_reason"]
for process_status in failed_pushes
]
if fail_tracebacks:
reason = "\n".join(reasons)
message = (
"Unhandled error happened."
" Check error detail for more information."
)
self._error_detail_dialog.set_detail(
reason, fail_traceback
reason, "\n".join(fail_tracebacks)
)
else:
message = f"Push Failed:\n{reason}"
message = f"Push Failed:\n{reasons}"
self._overlay_label.setText(message)
set_style_property(self._overlay_close_btn, "state", "error")
@ -534,7 +586,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._main_thread_timer_can_stop = False
self._main_thread_timer.start()
self._main_layout.setCurrentWidget(self._overlay_widget)
self._overlay_label.setText("Submittion started")
self._overlay_label.setText("Submission started")
def _on_controller_submit_end(self):
self._main_thread_timer_can_stop = True

View file

@ -127,6 +127,7 @@ class SelectVersionComboBox(QtWidgets.QComboBox):
status_text_rect.setLeft(icon_rect.right() + 2)
if status_text_rect.width() <= 0:
painter.restore()
return
if status_text_rect.width() < metrics.width(status_name):
@ -144,6 +145,7 @@ class SelectVersionComboBox(QtWidgets.QComboBox):
QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
status_name
)
painter.restore()
def set_current_index(self, index):
model = self._combo_view.model()

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring AYON addon 'core' version."""
__version__ = "1.5.3+dev"
__version__ = "1.6.0+dev"

View file

@ -15,7 +15,7 @@ qtawesome = "0.7.3"
[ayon.runtimeDependencies]
aiohttp-middlewares = "^2.0.0"
Click = "^8"
OpenTimelineIO = "0.16.0"
OpenTimelineIO = "0.17.0"
opencolorio = "^2.3.2,<2.4.0"
Pillow = "9.5.0"
websocket-client = ">=0.40.0,<2"

View file

@ -1,6 +1,6 @@
name = "core"
title = "Core"
version = "1.5.3+dev"
version = "1.6.0+dev"
client_dir = "ayon_core"

View file

@ -5,7 +5,7 @@
[tool.poetry]
name = "ayon-core"
version = "1.5.3+dev"
version = "1.6.0+dev"
description = ""
authors = ["Ynput Team <team@ynput.io>"]
readme = "README.md"