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/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/execute.py b/openpype/lib/execute.py index 759a4db0cb..7a929a0ade 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -102,6 +102,10 @@ def run_subprocess(*args, **kwargs): if ( platform.system().lower() == "windows" and "creationflags" not in kwargs + # shell=True already tries to hide the console window + # and passing these creationflags then shows the window again + # so we avoid it for shell=True cases + and kwargs.get("shell") is not True ): kwargs["creationflags"] = ( subprocess.CREATE_NEW_PROCESS_GROUP 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/lib/vendor_bin_utils.py b/openpype/lib/vendor_bin_utils.py index 00dd1955fe..e5deb7a6b2 100644 --- a/openpype/lib/vendor_bin_utils.py +++ b/openpype/lib/vendor_bin_utils.py @@ -224,18 +224,26 @@ def find_tool_in_custom_paths(paths, tool, validation_func=None): def _check_args_returncode(args): try: - # Python 2 compatibility where DEVNULL is not available + kwargs = {} + if platform.system().lower() == "windows": + kwargs["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP + | getattr(subprocess, "DETACHED_PROCESS", 0) + | getattr(subprocess, "CREATE_NO_WINDOW", 0) + ) + if hasattr(subprocess, "DEVNULL"): proc = subprocess.Popen( args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + **kwargs ) proc.wait() else: with open(os.devnull, "w") as devnull: proc = subprocess.Popen( - args, stdout=devnull, stderr=devnull, + args, stdout=devnull, stderr=devnull, **kwargs ) proc.wait() 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/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index f113e61bb0..38ec08e8d9 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -16,9 +16,7 @@ from openpype.lib import ( get_transcode_temp_directory, convert_input_paths_for_ffmpeg, - should_convert_for_ffmpeg, - - CREATE_NO_WINDOW + should_convert_for_ffmpeg ) from openpype.lib.profiles_filtering import filter_profiles @@ -338,8 +336,6 @@ class ExtractBurnin(publish.Extractor): "logger": self.log, "env": {} } - if platform.system().lower() == "windows": - process_kwargs["creationflags"] = CREATE_NO_WINDOW run_openpype_process(*args, **process_kwargs) # Remove the temporary json 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/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py index cb4646c099..ef449f4f74 100644 --- a/openpype/scripts/otio_burnin.py +++ b/openpype/scripts/otio_burnin.py @@ -345,12 +345,6 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): "stderr": subprocess.PIPE, "shell": True, } - if platform.system().lower() == "windows": - kwargs["creationflags"] = ( - subprocess.CREATE_NEW_PROCESS_GROUP - | getattr(subprocess, "DETACHED_PROCESS", 0) - | getattr(subprocess, "CREATE_NO_WINDOW", 0) - ) proc = subprocess.Popen(command, **kwargs) _stdout, _stderr = proc.communicate() 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/version.py b/openpype/version.py index 339c17dc70..5b6db12b5e 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.15.3-nightly.2" +__version__ = "3.15.3-nightly.3" 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/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.