diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index 07457db1a4..5ca901caaa 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -146,6 +146,8 @@ class CreatorWidget(QtWidgets.QDialog): return " ".join([str(m.group(0)).capitalize() for m in matches]) def create_row(self, layout, type, text, **kwargs): + value_keys = ["setText", "setCheckState", "setValue", "setChecked"] + # get type attribute from qwidgets attr = getattr(QtWidgets, type) @@ -167,14 +169,27 @@ class CreatorWidget(QtWidgets.QDialog): # assign the created attribute to variable item = getattr(self, attr_name) + + # set attributes to item which are not values for func, val in kwargs.items(): + if func in value_keys: + continue + if getattr(item, func): + log.debug("Setting {} to {}".format(func, val)) func_attr = getattr(item, func) if isinstance(val, tuple): func_attr(*val) else: func_attr(val) + # set values to item + for value_item in value_keys: + if value_item not in kwargs: + continue + if getattr(item, value_item): + getattr(item, value_item)(kwargs[value_item]) + # add to layout layout.addRow(label, item) @@ -276,8 +291,11 @@ class CreatorWidget(QtWidgets.QDialog): elif v["type"] == "QSpinBox": data[k]["value"] = self.create_row( content_layout, "QSpinBox", v["label"], - setValue=v["value"], setMinimum=0, + setValue=v["value"], + setDisplayIntegerBase=10000, + setRange=(0, 99999), setMinimum=0, setMaximum=100000, setToolTip=tool_tip) + return data diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index bc506b7feb..447c9a615c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -30,36 +30,6 @@ def _has_arnold(): return False -def escape_space(path): - """Ensure path is enclosed by quotes to allow paths with spaces""" - return '"{}"'.format(path) if " " in path else path - - -def get_ocio_config_path(profile_folder): - """Path to OpenPype vendorized OCIO. - - Vendorized OCIO config file path is grabbed from the specific path - hierarchy specified below. - - "{OPENPYPE_ROOT}/vendor/OpenColorIO-Configs/{profile_folder}/config.ocio" - Args: - profile_folder (str): Name of folder to grab config file from. - - Returns: - str: Path to vendorized config file. - """ - - return os.path.join( - os.environ["OPENPYPE_ROOT"], - "vendor", - "bin", - "ocioconfig", - "OpenColorIOConfigs", - profile_folder, - "config.ocio" - ) - - def find_paths_by_hash(texture_hash): """Find the texture hash key in the dictionary. diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data.py b/openpype/hosts/nuke/plugins/publish/extract_review_data.py index dee8248295..c221af40fb 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data.py @@ -25,7 +25,7 @@ class ExtractReviewData(publish.Extractor): # review can be removed since `ProcessSubmittedJobOnFarm` will create # reviewable representation if needed if ( - "render.farm" in instance.data["families"] + instance.data.get("farm") and "review" in instance.data["families"] ): instance.data["families"].remove("review") diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py index 67779e9599..e4b7b155cd 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py @@ -49,7 +49,12 @@ class ExtractReviewDataLut(publish.Extractor): exporter.stagingDir, exporter.file).replace("\\", "/") instance.data["representations"] += data["representations"] - if "render.farm" in families: + # review can be removed since `ProcessSubmittedJobOnFarm` will create + # reviewable representation if needed + if ( + instance.data.get("farm") + and "review" in instance.data["families"] + ): instance.data["families"].remove("review") self.log.debug( diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index 3fcfc2a4b5..956d1a54a3 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -105,10 +105,7 @@ class ExtractReviewDataMov(publish.Extractor): self, instance, o_name, o_data["extension"], multiple_presets) - if ( - "render.farm" in families or - "prerender.farm" in families - ): + if instance.data.get("farm"): if "review" in instance.data["families"]: instance.data["families"].remove("review") diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py index a1a0e241c0..f391ca1e7c 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py @@ -31,7 +31,7 @@ class ExtractThumbnail(publish.Extractor): def process(self, instance): - if "render.farm" in instance.data["families"]: + if instance.data.get("farm"): return with napi.maintained_selection(): diff --git a/openpype/hosts/unreal/ue_workers.py b/openpype/hosts/unreal/ue_workers.py index 00f83a7d7a..d1740124a8 100644 --- a/openpype/hosts/unreal/ue_workers.py +++ b/openpype/hosts/unreal/ue_workers.py @@ -5,29 +5,38 @@ import re import subprocess from distutils import dir_util from pathlib import Path -from typing import List +from typing import List, Union import openpype.hosts.unreal.lib as ue_lib from qtpy import QtCore -def parse_comp_progress(line: str, progress_signal: QtCore.Signal(int)) -> int: - match = re.search('\[[1-9]+/[0-9]+\]', line) +def parse_comp_progress(line: str, progress_signal: QtCore.Signal(int)): + match = re.search(r"\[[1-9]+/[0-9]+]", line) if match is not None: - split: list[str] = match.group().split('/') + split: list[str] = match.group().split("/") curr: float = float(split[0][1:]) total: float = float(split[1][:-1]) progress_signal.emit(int((curr / total) * 100.0)) -def parse_prj_progress(line: str, progress_signal: QtCore.Signal(int)) -> int: - match = re.search('@progress', line) +def parse_prj_progress(line: str, progress_signal: QtCore.Signal(int)): + match = re.search("@progress", line) if match is not None: - percent_match = re.search('\d{1,3}', line) + percent_match = re.search(r"\d{1,3}", line) progress_signal.emit(int(percent_match.group())) +def retrieve_exit_code(line: str): + match = re.search(r"ExitCode=\d+", line) + if match is not None: + split: list[str] = match.group().split("=") + return int(split[1]) + + return None + + class UEProjectGenerationWorker(QtCore.QObject): finished = QtCore.Signal(str) failed = QtCore.Signal(str) @@ -77,16 +86,19 @@ class UEProjectGenerationWorker(QtCore.QObject): if self.dev_mode: stage_count = 4 - self.stage_begin.emit(f'Generating a new UE project ... 1 out of ' - f'{stage_count}') + self.stage_begin.emit( + ("Generating a new UE project ... 1 out of " + f"{stage_count}")) - commandlet_cmd = [f'{ue_editor_exe.as_posix()}', - f'{cmdlet_project.as_posix()}', - f'-run=OPGenerateProject', - f'{project_file.resolve().as_posix()}'] + commandlet_cmd = [ + f"{ue_editor_exe.as_posix()}", + f"{cmdlet_project.as_posix()}", + "-run=OPGenerateProject", + f"{project_file.resolve().as_posix()}", + ] if self.dev_mode: - commandlet_cmd.append('-GenerateCode') + commandlet_cmd.append("-GenerateCode") gen_process = subprocess.Popen(commandlet_cmd, stdout=subprocess.PIPE, @@ -94,24 +106,27 @@ class UEProjectGenerationWorker(QtCore.QObject): for line in gen_process.stdout: decoded_line = line.decode(errors="replace") - print(decoded_line, end='') + print(decoded_line, end="") self.log.emit(decoded_line) gen_process.stdout.close() return_code = gen_process.wait() if return_code and return_code != 0: - msg = 'Failed to generate ' + self.project_name \ - + f' project! Exited with return code {return_code}' + msg = ( + f"Failed to generate {self.project_name} " + f"project! Exited with return code {return_code}" + ) self.failed.emit(msg, return_code) raise RuntimeError(msg) print("--- Project has been generated successfully.") - self.stage_begin.emit(f'Writing the Engine ID of the build UE ... 1' - f' out of {stage_count}') + self.stage_begin.emit( + (f"Writing the Engine ID of the build UE ... 1" + f" out of {stage_count}")) if not project_file.is_file(): - msg = "Failed to write the Engine ID into .uproject file! Can " \ - "not read!" + msg = ("Failed to write the Engine ID into .uproject file! Can " + "not read!") self.failed.emit(msg) raise RuntimeError(msg) @@ -125,13 +140,14 @@ class UEProjectGenerationWorker(QtCore.QObject): pf.seek(0) json.dump(pf_json, pf, indent=4) pf.truncate() - print(f'--- Engine ID has been written into the project file') + print("--- Engine ID has been written into the project file") self.progress.emit(90) if self.dev_mode: # 2nd stage - self.stage_begin.emit(f'Generating project files ... 2 out of ' - f'{stage_count}') + self.stage_begin.emit( + (f"Generating project files ... 2 out of " + f"{stage_count}")) self.progress.emit(0) ubt_path = ue_lib.get_path_to_ubt(self.engine_path, @@ -154,8 +170,8 @@ class UEProjectGenerationWorker(QtCore.QObject): stdout=subprocess.PIPE, stderr=subprocess.PIPE) for line in gen_proc.stdout: - decoded_line: str = line.decode(errors='replace') - print(decoded_line, end='') + decoded_line: str = line.decode(errors="replace") + print(decoded_line, end="") self.log.emit(decoded_line) parse_prj_progress(decoded_line, self.progress) @@ -163,13 +179,13 @@ class UEProjectGenerationWorker(QtCore.QObject): return_code = gen_proc.wait() if return_code and return_code != 0: - msg = 'Failed to generate project files! ' \ - f'Exited with return code {return_code}' + msg = ("Failed to generate project files! " + f"Exited with return code {return_code}") self.failed.emit(msg, return_code) raise RuntimeError(msg) - self.stage_begin.emit(f'Building the project ... 3 out of ' - f'{stage_count}') + self.stage_begin.emit( + f"Building the project ... 3 out of {stage_count}") self.progress.emit(0) # 3rd stage build_prj_cmd = [ubt_path.as_posix(), @@ -177,16 +193,16 @@ class UEProjectGenerationWorker(QtCore.QObject): arch, "Development", "-TargetType=Editor", - f'-Project={project_file}', - f'{project_file}', + f"-Project={project_file}", + f"{project_file}", "-IgnoreJunk"] build_prj_proc = subprocess.Popen(build_prj_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) for line in build_prj_proc.stdout: - decoded_line: str = line.decode(errors='replace') - print(decoded_line, end='') + decoded_line: str = line.decode(errors="replace") + print(decoded_line, end="") self.log.emit(decoded_line) parse_comp_progress(decoded_line, self.progress) @@ -194,16 +210,17 @@ class UEProjectGenerationWorker(QtCore.QObject): return_code = build_prj_proc.wait() if return_code and return_code != 0: - msg = 'Failed to build project! ' \ - f'Exited with return code {return_code}' + msg = ("Failed to build project! " + f"Exited with return code {return_code}") self.failed.emit(msg, return_code) raise RuntimeError(msg) # ensure we have PySide2 installed in engine self.progress.emit(0) - self.stage_begin.emit(f'Checking PySide2 installation... {stage_count}' - f' out of {stage_count}') + self.stage_begin.emit( + (f"Checking PySide2 installation... {stage_count} " + f" out of {stage_count}")) python_path = None if platform.system().lower() == "windows": python_path = self.engine_path / ("Engine/Binaries/ThirdParty/" @@ -225,9 +242,30 @@ class UEProjectGenerationWorker(QtCore.QObject): msg = f"Unreal Python not found at {python_path}" self.failed.emit(msg, 1) raise RuntimeError(msg) - subprocess.check_call( - [python_path.as_posix(), "-m", "pip", "install", "pyside2"] - ) + pyside_cmd = [python_path.as_posix(), + "-m", + "pip", + "install", + "pyside2"] + + pyside_install = subprocess.Popen(pyside_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + + for line in pyside_install.stdout: + decoded_line: str = line.decode(errors="replace") + print(decoded_line, end="") + self.log.emit(decoded_line) + + pyside_install.stdout.close() + return_code = pyside_install.wait() + + if return_code and return_code != 0: + msg = ("Failed to create the project! " + "The installation of PySide2 has failed!") + self.failed.emit(msg, return_code) + raise RuntimeError(msg) + self.progress.emit(100) self.finished.emit("Project successfully built!") @@ -266,26 +304,30 @@ class UEPluginInstallWorker(QtCore.QObject): # in order to successfully build the plugin, # It must be built outside the Engine directory and then moved - build_plugin_cmd: List[str] = [f'{uat_path.as_posix()}', - 'BuildPlugin', - f'-Plugin={uplugin_path.as_posix()}', - f'-Package={temp_dir.as_posix()}'] + build_plugin_cmd: List[str] = [f"{uat_path.as_posix()}", + "BuildPlugin", + f"-Plugin={uplugin_path.as_posix()}", + f"-Package={temp_dir.as_posix()}"] build_proc = subprocess.Popen(build_plugin_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return_code: Union[None, int] = None for line in build_proc.stdout: - decoded_line: str = line.decode(errors='replace') - print(decoded_line, end='') + decoded_line: str = line.decode(errors="replace") + print(decoded_line, end="") self.log.emit(decoded_line) + if return_code is None: + return_code = retrieve_exit_code(decoded_line) parse_comp_progress(decoded_line, self.progress) build_proc.stdout.close() - return_code = build_proc.wait() + build_proc.wait() if return_code and return_code != 0: - msg = 'Failed to build plugin' \ - f' project! Exited with return code {return_code}' + msg = ("Failed to build plugin" + f" project! Exited with return code {return_code}") + dir_util.remove_tree(temp_dir.as_posix()) self.failed.emit(msg, return_code) raise RuntimeError(msg) diff --git a/openpype/lib/file_transaction.py b/openpype/lib/file_transaction.py index fe70b37cb1..81332a8891 100644 --- a/openpype/lib/file_transaction.py +++ b/openpype/lib/file_transaction.py @@ -13,6 +13,16 @@ else: from shutil import copyfile +class DuplicateDestinationError(ValueError): + """Error raised when transfer destination already exists in queue. + + The error is only raised if `allow_queue_replacements` is False on the + FileTransaction instance and the added file to transfer is of a different + src file than the one already detected in the queue. + + """ + + class FileTransaction(object): """File transaction with rollback options. @@ -44,7 +54,7 @@ class FileTransaction(object): MODE_COPY = 0 MODE_HARDLINK = 1 - def __init__(self, log=None): + def __init__(self, log=None, allow_queue_replacements=False): if log is None: log = logging.getLogger("FileTransaction") @@ -60,6 +70,8 @@ class FileTransaction(object): # Backup file location mapping to original locations self._backup_to_original = {} + self._allow_queue_replacements = allow_queue_replacements + def add(self, src, dst, mode=MODE_COPY): """Add a new file to transfer queue. @@ -82,6 +94,14 @@ class FileTransaction(object): src, dst)) return else: + if not self._allow_queue_replacements: + raise DuplicateDestinationError( + "Transfer to destination is already in queue: " + "{} -> {}. It's not allowed to be replaced by " + "a new transfer from {}".format( + queued_src, dst, src + )) + self.log.warning("File transfer in queue replaced..") self.log.debug( "Removed from queue: {} -> {} replaced by {} -> {}".format( diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index aff34c7e4a..cc069cf51a 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -32,7 +32,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, label = "Submit Nuke to Deadline" order = pyblish.api.IntegratorOrder + 0.1 hosts = ["nuke"] - families = ["render", "prerender.farm"] + families = ["render", "prerender"] optional = True targets = ["local"] @@ -80,6 +80,10 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, ] def process(self, instance): + if not instance.data.get("farm"): + self.log.info("Skipping local instance.") + return + instance.data["attributeValues"] = self.get_attr_values_from_data( instance.data) @@ -168,10 +172,10 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, resp.json()["_id"]) # redefinition of families - if "render.farm" in families: + if "render" in instance.data["family"]: instance.data['family'] = 'write' families.insert(0, "render2d") - elif "prerender.farm" in families: + elif "prerender" in instance.data["family"]: instance.data['family'] = 'write' families.insert(0, "prerender") instance.data["families"] = families diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 53c09ad22f..0d0698c21f 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -756,6 +756,10 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): instance (pyblish.api.Instance): Instance data. """ + if not instance.data.get("farm"): + self.log.info("Skipping local instance.") + return + data = instance.data.copy() context = instance.context self.context = context diff --git a/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py b/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py index 78eed17c98..05afa5080d 100644 --- a/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py +++ b/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py @@ -21,6 +21,10 @@ class ValidateDeadlinePools(OptionalPyblishPluginMixin, optional = True def process(self, instance): + if not instance.data.get("farm"): + self.log.info("Skipping local instance.") + return + # get default deadline webservice url from deadline module deadline_url = instance.context.data["defaultDeadline"] self.log.info("deadline_url::{}".format(deadline_url)) diff --git a/openpype/modules/ftrack/event_handlers_user/action_applications.py b/openpype/modules/ftrack/event_handlers_user/action_applications.py index 102f04c956..30399b463d 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_applications.py +++ b/openpype/modules/ftrack/event_handlers_user/action_applications.py @@ -124,6 +124,11 @@ class AppplicationsAction(BaseAction): if not avalon_project_apps: return False + settings = self.get_project_settings_from_event( + event, avalon_project_doc["name"]) + + only_available = settings["applications"]["only_available"] + items = [] for app_name in avalon_project_apps: app = self.application_manager.applications.get(app_name) @@ -133,6 +138,10 @@ class AppplicationsAction(BaseAction): if app.group.name in CUSTOM_LAUNCH_APP_GROUPS: continue + # Skip applications without valid executables + if only_available and not app.find_executable(): + continue + app_icon = app.icon if app_icon and self.icon_url: try: diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 6702cbe7aa..6fda32d85f 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import gazu import pyblish.api +import re class IntegrateKitsuNote(pyblish.api.ContextPlugin): @@ -9,27 +10,66 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): order = pyblish.api.IntegratorOrder label = "Kitsu Note and Status" families = ["render", "kitsu"] + + # status settings set_status_note = False note_status_shortname = "wfa" + status_conditions = list() + + # comment settings + custom_comment_template = { + "enabled": False, + "comment_template": "{comment}", + } + + def format_publish_comment(self, instance): + """Format the instance's publish comment + + Formats `instance.data` against the custom template. + """ + + def replace_missing_key(match): + """If key is not found in kwargs, set None instead""" + key = match.group(1) + if key not in instance.data: + self.log.warning( + "Key '{}' was not found in instance.data " + "and will be rendered as an empty string " + "in the comment".format(key) + ) + return "" + else: + return str(instance.data[key]) + + template = self.custom_comment_template["comment_template"] + pattern = r"\{([^}]*)\}" + return re.sub(pattern, replace_missing_key, template) def process(self, context): - # Get comment text body - publish_comment = context.data.get("comment") - if not publish_comment: - self.log.info("Comment is not set.") - - self.log.debug("Comment is `{}`".format(publish_comment)) - for instance in context: + # Check if instance is a review by checking its family + if "review" not in instance.data["families"]: + continue + kitsu_task = instance.data.get("kitsu_task") if kitsu_task is None: continue # Get note status, by default uses the task status for the note # if it is not specified in the configuration - note_status = kitsu_task["task_status"]["id"] + shortname = kitsu_task["task_status"]["short_name"].upper() + note_status = kitsu_task["task_status_id"] - if self.set_status_note: + # Check if any status condition is not met + allow_status_change = True + for status_cond in self.status_conditions: + condition = status_cond["condition"] == "equal" + match = status_cond["short_name"].upper() == shortname + if match and not condition or condition and not match: + allow_status_change = False + break + + if self.set_status_note and allow_status_change: kitsu_status = gazu.task.get_task_status_by_short_name( self.note_status_shortname ) @@ -42,11 +82,22 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): "changed!".format(self.note_status_shortname) ) + # Get comment text body + publish_comment = instance.data.get("comment") + if self.custom_comment_template["enabled"]: + publish_comment = self.format_publish_comment(instance) + + if not publish_comment: + self.log.info("Comment is not set.") + else: + self.log.debug("Comment is `{}`".format(publish_comment)) + # Add comment to kitsu task - task_id = kitsu_task["id"] - self.log.debug("Add new note in taks id {}".format(task_id)) + self.log.debug( + "Add new note in tasks id {}".format(kitsu_task["id"]) + ) kitsu_comment = gazu.task.add_comment( - task_id, note_status, comment=publish_comment + kitsu_task, note_status, comment=publish_comment ) instance.data["kitsu_comment"] = kitsu_comment diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index 4fa8cf9fdd..1f38648dfa 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -129,7 +129,7 @@ def update_op_assets( frame_out = frame_in + frames_duration - 1 else: frame_out = project_doc["data"].get("frameEnd", frame_in) - item_data["frameEnd"] = frame_out + item_data["frameEnd"] = int(frame_out) # Fps, fallback to project's value or default value (25.0) try: fps = float(item_data.get("fps")) @@ -147,33 +147,37 @@ def update_op_assets( item_data["resolutionWidth"] = int(match_res.group(1)) item_data["resolutionHeight"] = int(match_res.group(2)) else: - item_data["resolutionWidth"] = project_doc["data"].get( - "resolutionWidth" + item_data["resolutionWidth"] = int( + project_doc["data"].get("resolutionWidth") ) - item_data["resolutionHeight"] = project_doc["data"].get( - "resolutionHeight" + item_data["resolutionHeight"] = int( + project_doc["data"].get("resolutionHeight") ) # Properties that doesn't fully exist in Kitsu. # Guessing those property names below: # Pixel Aspect Ratio - item_data["pixelAspect"] = item_data.get( - "pixel_aspect", project_doc["data"].get("pixelAspect") + item_data["pixelAspect"] = float( + item_data.get( + "pixel_aspect", project_doc["data"].get("pixelAspect") + ) ) # Handle Start - item_data["handleStart"] = item_data.get( - "handle_start", project_doc["data"].get("handleStart") + item_data["handleStart"] = int( + item_data.get( + "handle_start", project_doc["data"].get("handleStart") + ) ) # Handle End - item_data["handleEnd"] = item_data.get( - "handle_end", project_doc["data"].get("handleEnd") + item_data["handleEnd"] = int( + item_data.get("handle_end", project_doc["data"].get("handleEnd")) ) # Clip In - item_data["clipIn"] = item_data.get( - "clip_in", project_doc["data"].get("clipIn") + item_data["clipIn"] = int( + item_data.get("clip_in", project_doc["data"].get("clipIn")) ) # Clip Out - item_data["clipOut"] = item_data.get( - "clip_out", project_doc["data"].get("clipOut") + item_data["clipOut"] = int( + item_data.get("clip_out", project_doc["data"].get("clipOut")) ) # Tasks diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index 6a0327ec84..760b1a6b37 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -24,7 +24,10 @@ from openpype.client import ( get_version_by_name, ) from openpype.lib import source_hash -from openpype.lib.file_transaction import FileTransaction +from openpype.lib.file_transaction import ( + FileTransaction, + DuplicateDestinationError +) from openpype.pipeline.publish import ( KnownPublishError, get_publish_template_name, @@ -170,9 +173,18 @@ class IntegrateAsset(pyblish.api.InstancePlugin): ).format(instance.data["family"])) return - file_transactions = FileTransaction(log=self.log) + file_transactions = FileTransaction(log=self.log, + # Enforce unique transfers + allow_queue_replacements=False) try: self.register(instance, file_transactions, filtered_repres) + except DuplicateDestinationError as exc: + # Raise DuplicateDestinationError as KnownPublishError + # and rollback the transactions + file_transactions.rollback() + six.reraise(KnownPublishError, + KnownPublishError(exc), + sys.exc_info()[2]) except Exception: # clean destination # todo: preferably we'd also rollback *any* changes to the database diff --git a/openpype/plugins/publish/preintegrate_thumbnail_representation.py b/openpype/plugins/publish/preintegrate_thumbnail_representation.py index b88ccee9dc..1c95b82c97 100644 --- a/openpype/plugins/publish/preintegrate_thumbnail_representation.py +++ b/openpype/plugins/publish/preintegrate_thumbnail_representation.py @@ -60,6 +60,8 @@ class PreIntegrateThumbnails(pyblish.api.InstancePlugin): if not found_profile: return + thumbnail_repre.setdefault("tags", []) + if not found_profile["integrate_thumbnail"]: if "delete" not in thumbnail_repre["tags"]: thumbnail_repre["tags"].append("delete") diff --git a/openpype/settings/defaults/project_settings/applications.json b/openpype/settings/defaults/project_settings/applications.json new file mode 100644 index 0000000000..62f3cdfe1b --- /dev/null +++ b/openpype/settings/defaults/project_settings/applications.json @@ -0,0 +1,3 @@ +{ + "only_available": false +} diff --git a/openpype/settings/defaults/project_settings/kitsu.json b/openpype/settings/defaults/project_settings/kitsu.json index 95b3da04ae..0638450595 100644 --- a/openpype/settings/defaults/project_settings/kitsu.json +++ b/openpype/settings/defaults/project_settings/kitsu.json @@ -7,7 +7,12 @@ "publish": { "IntegrateKitsuNote": { "set_status_note": false, - "note_status_shortname": "wfa" + "note_status_shortname": "wfa", + "status_conditions": [], + "custom_comment_template": { + "enabled": false, + "comment_template": "{comment}\n\n| | |\n|--|--|\n| version| `{version}` |\n| family | `{family}` |\n| name | `{name}` |" + } } } } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index ebe59c7942..8c1d8ccbdd 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -82,6 +82,10 @@ "type": "schema", "name": "schema_project_slack" }, + { + "type": "schema", + "name": "schema_project_applications" + }, { "type": "schema", "name": "schema_project_max" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_applications.json b/openpype/settings/entities/schemas/projects_schema/schema_project_applications.json new file mode 100644 index 0000000000..030ed3ee8a --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_applications.json @@ -0,0 +1,14 @@ +{ + "type": "dict", + "key": "applications", + "label": "Applications", + "collapsible": true, + "is_file": true, + "children": [ + { + "type": "boolean", + "key": "only_available", + "label": "Show only available applications" + } + ] +} diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json index fb47670e74..ee309f63a7 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json @@ -46,12 +46,62 @@ { "type": "boolean", "key": "set_status_note", - "label": "Set status on note" + "label": "Set status with note" }, { "type": "text", "key": "note_status_shortname", "label": "Note shortname" + }, + { + "type": "list", + "key": "status_conditions", + "label": "Status conditions", + "object_type": { + "type": "dict", + "key": "conditions_dict", + "children": [ + { + "type": "enum", + "key": "condition", + "label": "Condition", + "enum_items": [ + {"equal": "Equal"}, + {"not_equal": "Not equal"} + ] + }, + { + "type": "text", + "key": "short_name", + "label": "Short name" + } + ] + }, + "label": "Status shortname" + }, + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "custom_comment_template", + "label": "Custom Comment Template", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "label", + "label": "Kitsu supports markdown and here you can create a custom comment template.
You can use data from your publishing instance's data." + }, + { + "key": "comment_template", + "type": "text", + "multiline": true, + "label": "Custom comment" + } + ] } ] } diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index 6c763544a9..3aa6c5d8cb 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -19,6 +19,7 @@ from openpype.lib.applications import ( CUSTOM_LAUNCH_APP_GROUPS, ApplicationManager ) +from openpype.settings import get_project_settings from openpype.pipeline import discover_launcher_actions from openpype.tools.utils.lib import ( DynamicQThread, @@ -94,6 +95,8 @@ class ActionModel(QtGui.QStandardItemModel): if not project_doc: return actions + project_settings = get_project_settings(project_name) + only_available = project_settings["applications"]["only_available"] self.application_manager.refresh() for app_def in project_doc["config"]["apps"]: app_name = app_def["name"] @@ -104,6 +107,9 @@ class ActionModel(QtGui.QStandardItemModel): if app.group.name in CUSTOM_LAUNCH_APP_GROUPS: continue + if only_available and not app.find_executable(): + continue + # Get from app definition, if not there from app in project action = type( "app_{}".format(app_name), diff --git a/openpype/tools/sceneinventory/model.py b/openpype/tools/sceneinventory/model.py index 680dfd5a51..63d2945145 100644 --- a/openpype/tools/sceneinventory/model.py +++ b/openpype/tools/sceneinventory/model.py @@ -327,7 +327,7 @@ class InventoryModel(TreeModel): project_name, repre_id ) if not representation: - not_found["representation"].append(group_items) + not_found["representation"].extend(group_items) not_found_ids.append(repre_id) continue @@ -335,7 +335,7 @@ class InventoryModel(TreeModel): project_name, representation["parent"] ) if not version: - not_found["version"].append(group_items) + not_found["version"].extend(group_items) not_found_ids.append(repre_id) continue @@ -348,13 +348,13 @@ class InventoryModel(TreeModel): subset = get_subset_by_id(project_name, version["parent"]) if not subset: - not_found["subset"].append(group_items) + not_found["subset"].extend(group_items) not_found_ids.append(repre_id) continue asset = get_asset_by_id(project_name, subset["parent"]) if not asset: - not_found["asset"].append(group_items) + not_found["asset"].extend(group_items) not_found_ids.append(repre_id) continue @@ -380,11 +380,11 @@ class InventoryModel(TreeModel): self.add_child(group_node, parent=parent) - for _group_items in group_items: + for item in group_items: item_node = Item() - item_node["Name"] = ", ".join( - [item["objectName"] for item in _group_items] - ) + item_node.update(item) + item_node["Name"] = item.get("objectName", "NO NAME") + item_node["isNotFound"] = True self.add_child(item_node, parent=group_node) for repre_id, group_dict in sorted(grouped.items()): diff --git a/openpype/tools/sceneinventory/view.py b/openpype/tools/sceneinventory/view.py index a04171e429..3279be6094 100644 --- a/openpype/tools/sceneinventory/view.py +++ b/openpype/tools/sceneinventory/view.py @@ -80,9 +80,16 @@ class SceneInventoryView(QtWidgets.QTreeView): self.setStyleSheet("QTreeView {}") def _build_item_menu_for_selection(self, items, menu): + + # Exclude items that are "NOT FOUND" since setting versions, updating + # and removal won't work for those items. + items = [item for item in items if not item.get("isNotFound")] + if not items: return + # An item might not have a representation, for example when an item + # is listed as "NOT FOUND" repre_ids = { item["representation"] for item in items diff --git a/openpype/tools/utils/delegates.py b/openpype/tools/utils/delegates.py index d76284afb1..fa69113ef1 100644 --- a/openpype/tools/utils/delegates.py +++ b/openpype/tools/utils/delegates.py @@ -30,9 +30,11 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): def displayText(self, value, locale): if isinstance(value, HeroVersionType): return lib.format_version(value, True) - assert isinstance(value, numbers.Integral), ( - "Version is not integer. \"{}\" {}".format(value, str(type(value))) - ) + if not isinstance(value, numbers.Integral): + # For cases where no version is resolved like NOT FOUND cases + # where a representation might not exist in current database + return + return lib.format_version(value) def paint(self, painter, option, index): diff --git a/openpype/widgets/splash_screen.py b/openpype/widgets/splash_screen.py index fffe143ea5..7c1ff72ecd 100644 --- a/openpype/widgets/splash_screen.py +++ b/openpype/widgets/splash_screen.py @@ -49,9 +49,7 @@ class SplashScreen(QtWidgets.QDialog): self.init_ui() def was_proc_successful(self) -> bool: - if self.thread_return_code == 0: - return True - return False + return self.thread_return_code == 0 def start_thread(self, q_thread: QtCore.QThread): """Saves the reference to this thread and starts it. @@ -80,8 +78,14 @@ class SplashScreen(QtWidgets.QDialog): """ self.thread_return_code = 0 self.q_thread.quit() + + if not self.q_thread.wait(5000): + raise RuntimeError("Failed to quit the QThread! " + "The deadline has been reached! The thread " + "has not finished it's execution!.") self.close() + @QtCore.Slot() def toggle_log(self): if self.is_log_visible: @@ -256,3 +260,4 @@ class SplashScreen(QtWidgets.QDialog): self.close_btn.show() self.thread_return_code = return_code self.q_thread.exit(return_code) + self.q_thread.wait() diff --git a/tests/integration/hosts/nuke/test_deadline_publish_in_nuke.py b/tests/integration/hosts/nuke/test_deadline_publish_in_nuke.py index cd9cbb94f8..a4026f195b 100644 --- a/tests/integration/hosts/nuke/test_deadline_publish_in_nuke.py +++ b/tests/integration/hosts/nuke/test_deadline_publish_in_nuke.py @@ -71,12 +71,30 @@ class TestDeadlinePublishInNuke(NukeDeadlinePublishTestClass): failures.append( DBAssert.count_of_types(dbcon, "representation", 4)) + additional_args = {"context.subset": "workfileTest_task", + "context.ext": "nk"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + additional_args = {"context.subset": "renderTest_taskMain", "context.ext": "exr"} failures.append( DBAssert.count_of_types(dbcon, "representation", 1, additional_args=additional_args)) + additional_args = {"context.subset": "renderTest_taskMain", + "name": "thumbnail"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + additional_args = {"context.subset": "renderTest_taskMain", + "name": "h264_mov"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + assert not any(failures) diff --git a/tests/integration/hosts/nuke/test_publish_in_nuke.py b/tests/integration/hosts/nuke/test_publish_in_nuke.py index f84f13fa20..bfd84e4fd5 100644 --- a/tests/integration/hosts/nuke/test_publish_in_nuke.py +++ b/tests/integration/hosts/nuke/test_publish_in_nuke.py @@ -15,7 +15,7 @@ class TestPublishInNuke(NukeLocalPublishTestClass): !!! It expects modified path in WriteNode, use '[python {nuke.script_directory()}]' instead of regular root - dir (eg. instead of `c:/projects/test_project/test_asset/test_task`). + dir (eg. instead of `c:/projects`). Access file path by selecting WriteNode group, CTRL+Enter, update file input !!! @@ -70,12 +70,30 @@ class TestPublishInNuke(NukeLocalPublishTestClass): failures.append( DBAssert.count_of_types(dbcon, "representation", 4)) + additional_args = {"context.subset": "workfileTest_task", + "context.ext": "nk"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + additional_args = {"context.subset": "renderTest_taskMain", "context.ext": "exr"} failures.append( DBAssert.count_of_types(dbcon, "representation", 1, additional_args=additional_args)) + additional_args = {"context.subset": "renderTest_taskMain", + "name": "thumbnail"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + additional_args = {"context.subset": "renderTest_taskMain", + "name": "h264_mov"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + assert not any(failures) diff --git a/website/docs/assets/integrate_kitsu_note_settings.png b/website/docs/assets/integrate_kitsu_note_settings.png new file mode 100644 index 0000000000..127e79ab80 Binary files /dev/null and b/website/docs/assets/integrate_kitsu_note_settings.png differ diff --git a/website/docs/module_kitsu.md b/website/docs/module_kitsu.md index 7be2a42c45..23898fba2e 100644 --- a/website/docs/module_kitsu.md +++ b/website/docs/module_kitsu.md @@ -38,6 +38,18 @@ This functionality cannot deal with all cases and is not error proof, some inter openpype_console module kitsu push-to-zou -l me@domain.ext -p my_password ``` +## Integrate Kitsu Note +Task status can be automatically set during publish thanks to `Integrate Kitsu Note`. This feature can be configured in: + +`Admin -> Studio Settings -> Project Settings -> Kitsu -> Integrate Kitsu Note`. + +There are three settings available: +- `Set status on note` -> turns on and off this integrator. +- `Note shortname` -> Which status shortname should be set automatically (Case sensitive). +- `Status conditions` -> Conditions that need to be met for kitsu status to be changed. You can add as many conditions as you like. There are two fields to each conditions: `Condition` (Whether current status should be equal or not equal to the condition status) and `Short name` (Kitsu Shortname of the condition status). + +![Integrate Kitsu Note project settings](assets/integrate_kitsu_note_settings.png) + ## Q&A ### Is it safe to rename an entity from Kitsu? Absolutely! Entities are linked by their unique IDs between the two databases.