From 6e34c4aed26e20bc8b1af0fc0e368060cf684d20 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Thu, 9 Mar 2023 16:13:32 +0100 Subject: [PATCH 01/45] Make sure value-data is int or float --- .../modules/kitsu/utils/update_op_with_zou.py | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index 4fa8cf9fdd..a2b560b75b 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -128,8 +128,8 @@ def update_op_assets( if frames_duration: frame_out = frame_in + frames_duration - 1 else: - frame_out = project_doc["data"].get("frameEnd", frame_in) - item_data["frameEnd"] = frame_out + frame_out = int(project_doc["data"].get("frameEnd", frame_in)) + 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 From 10da5885cd8611354f0c903622a33124dcda4ac4 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Thu, 9 Mar 2023 21:13:42 +0100 Subject: [PATCH 02/45] Get the settings up and running --- .../defaults/project_settings/kitsu.json | 6 ++++- .../projects_schema/schema_project_kitsu.json | 26 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/openpype/settings/defaults/project_settings/kitsu.json b/openpype/settings/defaults/project_settings/kitsu.json index 95b3da04ae..280895f1b9 100644 --- a/openpype/settings/defaults/project_settings/kitsu.json +++ b/openpype/settings/defaults/project_settings/kitsu.json @@ -8,6 +8,10 @@ "IntegrateKitsuNote": { "set_status_note": false, "note_status_shortname": "wfa" + }, + "CustomCommentTemplate": { + "enabled": false, + "comment_template": "{comment}\n\n| | |\n|--|--|\n| version| `{version}` |\n| family | `{family}` |\n| name | `{name}` |" } } -} +} \ No newline at end of file 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..255190c396 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json @@ -54,8 +54,32 @@ "label": "Note shortname" } ] + }, + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "CustomCommentTemplate", + "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 instance's anatomy." + }, + { + "key": "comment_template", + "type": "text", + "multiline": true, + "label": "Custom comment" + } + ] } ] } ] -} +} \ No newline at end of file From e2771abfb48d255288a708407b305241f4601737 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Fri, 10 Mar 2023 23:07:13 +0100 Subject: [PATCH 03/45] Move CustomCommentTemplate to within IntegrateKitsuNote --- .../defaults/project_settings/kitsu.json | 10 ++--- .../projects_schema/schema_project_kitsu.json | 44 +++++++++---------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/openpype/settings/defaults/project_settings/kitsu.json b/openpype/settings/defaults/project_settings/kitsu.json index 280895f1b9..f8a98d1a0b 100644 --- a/openpype/settings/defaults/project_settings/kitsu.json +++ b/openpype/settings/defaults/project_settings/kitsu.json @@ -7,11 +7,11 @@ "publish": { "IntegrateKitsuNote": { "set_status_note": false, - "note_status_shortname": "wfa" - }, - "CustomCommentTemplate": { - "enabled": false, - "comment_template": "{comment}\n\n| | |\n|--|--|\n| version| `{version}` |\n| family | `{family}` |\n| name | `{name}` |" + "note_status_shortname": "wfa", + "CustomCommentTemplate": { + "enabled": false, + "comment_template": "{comment}\n\n| | |\n|--|--|\n| version| `{version}` |\n| family | `{family}` |\n| name | `{name}` |" + } } } } \ No newline at end of file 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 255190c396..25f75c8280 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json @@ -52,30 +52,30 @@ "type": "text", "key": "note_status_shortname", "label": "Note shortname" - } - ] - }, - { - "type": "dict", - "collapsible": true, - "checkbox_key": "enabled", - "key": "CustomCommentTemplate", - "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 instance's anatomy." - }, - { - "key": "comment_template", - "type": "text", - "multiline": true, - "label": "Custom comment" + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "CustomCommentTemplate", + "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 instance's anatomy." + }, + { + "key": "comment_template", + "type": "text", + "multiline": true, + "label": "Custom comment" + } + ] } ] } From 7c1789faabea2380ab3001e32876d2747cf55f28 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Fri, 10 Mar 2023 23:07:41 +0100 Subject: [PATCH 04/45] Add custom message functionally --- .../plugins/publish/integrate_kitsu_note.py | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 6702cbe7aa..b1743ca828 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,17 +10,39 @@ 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" + # comment settings + CustomCommentTemplate = {} + CustomCommentTemplate["enabled"] = False + CustomCommentTemplate["comment_template"] = "{comment}" + + def safe_format(self, msg, **kwargs): + def replace_missing(match): + value = kwargs.get(match.group(1), None) + if value is None: + self.log.warning( + "Key `{}` was not found in instance.data " + "and will be rendered as `` in the comment".format( + match.group(1) + ) + ) + return "" + else: + return str(value) + + pattern = r"\{([^}]*)\}" + return re.sub(pattern, replace_missing, msg) + 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: kitsu_task = instance.data.get("kitsu_task") if kitsu_task is None: @@ -42,6 +65,15 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): "changed!".format(self.note_status_shortname) ) + # If custom comment, create it + if self.CustomCommentTemplate["enabled"]: + publish_comment = self.safe_format( + self.CustomCommentTemplate["comment_template"], + **instance.data, + ) + + 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)) From a797d78d451eb953bd5c4a2018712ade3e645c2a Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Fri, 10 Mar 2023 23:19:31 +0100 Subject: [PATCH 05/45] Added a check so only renders gets published to Kitsu --- .../modules/kitsu/plugins/publish/integrate_kitsu_note.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index b1743ca828..44134dec6d 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -44,6 +44,10 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): self.log.info("Comment is not set.") for instance in context: + # Check if instance is a render by checking its family + if "render" not in instance.data["family"]: + continue + kitsu_task = instance.data.get("kitsu_task") if kitsu_task is None: continue From 1093c519add9baae3276e01aa28e1a1d46cab0c8 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Fri, 10 Mar 2023 23:27:47 +0100 Subject: [PATCH 06/45] Change the label to explain more correct what it does --- .../schemas/projects_schema/schema_project_kitsu.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 25f75c8280..1a7747b3dc 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,12 @@ { "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" + "label": "Status shortname" }, { "type": "dict", From 24a22e964a4de4481c9896eca2ebc5ebf75adabd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 13 Mar 2023 16:27:33 +0100 Subject: [PATCH 07/45] Nuke: fix families mixing up with old way. --- .../deadline/plugins/publish/submit_nuke_deadline.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index aff34c7e4a..9abe8dec8b 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,9 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, ] def process(self, instance): + if not instance.data.get("farm"): + return + instance.data["attributeValues"] = self.get_attr_values_from_data( instance.data) @@ -168,10 +171,10 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, resp.json()["_id"]) # redefinition of families - if "render.farm" in families: + if "render" in family: instance.data['family'] = 'write' families.insert(0, "render2d") - elif "prerender.farm" in families: + elif "prerender" in family: instance.data['family'] = 'write' families.insert(0, "prerender") instance.data["families"] = families From be463b05dc2fae9eda44bd2ce3bd1fd47eca82d0 Mon Sep 17 00:00:00 2001 From: Oscar Domingo Date: Wed, 18 Jan 2023 15:55:40 +0000 Subject: [PATCH 08/45] documentation: Add `ARCHITECTURE.md` document This document attemps to give a quick overview of the project to help onboarding, it's not an extensive documentation but more of a elevator pitch one-line descriptions of files/directories and what the attempt to do. --- ARCHITECTURE.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000000..912780d803 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,77 @@ +# Architecture + +OpenPype is a monolithic Python project that bundles several parts, this document will try to give a birds eye overview of the project and, to a certain degree, each of the sub-projects. +The current file structure looks like this: + +``` +. +├── common - Code in this folder is backend portion of Addon distribution logic for v4 server. +├── docs - Documentation of the source code. +├── igniter - The OpenPype bootstrapper, deals with running version resolution and setting up the connection to the mongodb. +├── openpype - The actual OpenPype core package. +├── schema - Collection of JSON files describing schematics of objects. This follows Avalon's convention. +├── tests - Integration and unit tests. +├── tools - Conveninece scripts to perform common actions (in both bash and ps1). +├── vendor - When using the igniter, it deploys third party tools in here, such as ffmpeg. +└── website - Source files for https://openpype.io/ which is Docusaursus (https://docusaurus.io/). +``` + +The core functionality of the pipeline can be found in `igniter` and `openpype`, which in turn rely on the `schema` files, whenever you build (or download a pre-built) version of OpenPype, these two are bundled in there, and `Igniter` is the entry point. + + +## Igniter + +It's the setup and update tool for OpenPype, unless you want to package `openpype` separately and deal with all the config manually, this will most likely be your entry point. + +``` +igniter/ +├── bootstrap_repos.py - Module that will find or install OpenPype versions in the system. +├── __init__.py - Igniter entry point. +├── install_dialog.py- Show dialog for choosing central pype repository. +├── install_thread.py - Threading helpers for the install process. +├── __main__.py - Like `__init__.py` ? +├── message_dialog.py - Qt Dialog with a message and "Ok" button. +├── nice_progress_bar.py - Fancy Qt progress bar. +├── splash.txt - ASCII art for the terminal installer. +├── stylesheet.css - Installer Qt styles. +├── terminal_splash.py - Terminal installer animation, relies in `splash.txt`. +├── tools.py - Collection of methods that don't fit in other modules. +├── update_thread.py - Threading helper to update existing OpenPype installs. +├── update_window.py - Qt UI to update OpenPype installs. +├── user_settings.py - Interface for the OpenPype user settings. +└── version.py - Igniter's version number. +``` + +## OpenPype + +This is the main package of the OpenPype logic, it could be loosely described as a combination of [Avalon](https://getavalon.github.io), [Pyblish](https://pyblish.com/) and glue around those with custom OpenPype only elements, things are in progress of being moved around to better prepare for V4, which will be released under a new name AYON. + +``` +openpype/ +├── client - Interface for the MongoDB. +├── hooks - Hooks to be executed on certain OpenPype Applications defined in `openpype.lib.applications`. +├── host - Base class for the different hosts. +├── hosts - Integration with the different DCCs (hosts) using the `host` base class. +├── lib - Libraries that stitch together the package, some have been moved into other parts. +├── modules - OpenPype modules should contain separated logic of specific kind of implementation, such as Ftrack connection and its python API. +├── pipeline - Core of the OpenPype pipeline, handles creation of data, publishing, etc. +├── plugins - Global/core plugins for loader and publisher tool. +├── resources - Icons, fonts, etc. +├── scripts - Loose scipts that get run by tools/publishers. +├── settings - OpenPype settings interface. +├── style - Qt styling. +├── tests - Unit tests. +├── tools - Core tools, check out https://openpype.io/docs/artist_tools. +├── vendor - Vendoring of needed required Python packes. +├── widgets - Common re-usable Qt Widgets. +├── action.py - LEGACY: Lives now in `openpype.pipeline.publish.action` Pyblish actions. +├── cli.py - Command line interface, leverages `click`. +├── __init__.py - Sets two constants. +├── __main__.py - Entry point, calls the `cli.py` +├── plugin.py - Pyblish plugins. +├── pype_commands.py - Implementation of OpenPype commands. +└── version.py - Current version number. +``` + + + From 1e458d6d1d67b2eb88c0d6e649fd80f7192f08ce Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 14 Mar 2023 14:44:29 +0100 Subject: [PATCH 09/45] global and nuke: farm publishing workflow fixes --- openpype/hosts/nuke/plugins/publish/extract_review_data.py | 2 +- .../hosts/nuke/plugins/publish/extract_review_data_lut.py | 7 ++++++- .../hosts/nuke/plugins/publish/extract_review_data_mov.py | 5 +---- openpype/hosts/nuke/plugins/publish/extract_thumbnail.py | 2 +- .../deadline/plugins/publish/submit_nuke_deadline.py | 5 +++-- .../modules/deadline/plugins/publish/submit_publish_job.py | 4 ++++ .../deadline/plugins/publish/validate_deadline_pools.py | 4 ++++ 7 files changed, 20 insertions(+), 9 deletions(-) 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/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index 9abe8dec8b..cc069cf51a 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -81,6 +81,7 @@ 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( @@ -171,10 +172,10 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, resp.json()["_id"]) # redefinition of families - if "render" in family: + if "render" in instance.data["family"]: instance.data['family'] = 'write' families.insert(0, "render2d") - elif "prerender" in family: + 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)) From 78ce629ba63ee31f104255d628dbb6067578bea9 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Tue, 14 Mar 2023 18:14:19 +0100 Subject: [PATCH 10/45] Removed double casting --- openpype/modules/kitsu/utils/update_op_with_zou.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index a2b560b75b..1f38648dfa 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -128,7 +128,7 @@ def update_op_assets( if frames_duration: frame_out = frame_in + frames_duration - 1 else: - frame_out = int(project_doc["data"].get("frameEnd", frame_in)) + frame_out = project_doc["data"].get("frameEnd", frame_in) item_data["frameEnd"] = int(frame_out) # Fps, fallback to project's value or default value (25.0) try: From 383ce7ccb6f20453ef2aa40d81b2af4ae5856eba Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Tue, 14 Mar 2023 18:32:18 +0100 Subject: [PATCH 11/45] Change variable name to snake_case --- .../kitsu/plugins/publish/integrate_kitsu_note.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 44134dec6d..debbfdf98e 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -16,9 +16,11 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): note_status_shortname = "wfa" # comment settings - CustomCommentTemplate = {} - CustomCommentTemplate["enabled"] = False - CustomCommentTemplate["comment_template"] = "{comment}" + custom_comment_template = {} + custom_comment_template = { + "enabled": False, + "comment_template": "{comment}", + } def safe_format(self, msg, **kwargs): def replace_missing(match): @@ -70,9 +72,9 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): ) # If custom comment, create it - if self.CustomCommentTemplate["enabled"]: + if self.custom_comment_template["enabled"]: publish_comment = self.safe_format( - self.CustomCommentTemplate["comment_template"], + self.custom_comment_template["comment_template"], **instance.data, ) From c53e5b2302b9916360bf3e3a2f38e550596f64d8 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Tue, 14 Mar 2023 18:32:41 +0100 Subject: [PATCH 12/45] Added docstring --- openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index debbfdf98e..a4b229d236 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -23,6 +23,8 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): } def safe_format(self, msg, **kwargs): + """If key is not found in kwargs, set None instead""" + def replace_missing(match): value = kwargs.get(match.group(1), None) if value is None: From a789b135aed5d1dabdbee3b3ae14904a95b34428 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Tue, 14 Mar 2023 18:33:00 +0100 Subject: [PATCH 13/45] Changed the instance check from render to review --- .../modules/kitsu/plugins/publish/integrate_kitsu_note.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index a4b229d236..2e1e656cee 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -48,8 +48,8 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): self.log.info("Comment is not set.") for instance in context: - # Check if instance is a render by checking its family - if "render" not in instance.data["family"]: + # Check if instance is a review by checking its family + if "review" not in instance.data["family"]: continue kitsu_task = instance.data.get("kitsu_task") From b3816ae876ff35d6187e43357d2580e46bafe2ae Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Wed, 15 Mar 2023 17:05:00 +0100 Subject: [PATCH 14/45] Fixed key check towards kwargs --- .../kitsu/plugins/publish/integrate_kitsu_note.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 2e1e656cee..d4282ab048 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -16,7 +16,6 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): note_status_shortname = "wfa" # comment settings - custom_comment_template = {} custom_comment_template = { "enabled": False, "comment_template": "{comment}", @@ -26,17 +25,15 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): """If key is not found in kwargs, set None instead""" def replace_missing(match): - value = kwargs.get(match.group(1), None) - if value is None: + key = match.group(1) + if key not in kwargs: self.log.warning( "Key `{}` was not found in instance.data " - "and will be rendered as `` in the comment".format( - match.group(1) - ) + "and will be rendered as `` in the comment".format(key) ) return "" else: - return str(value) + return str(kwargs[key]) pattern = r"\{([^}]*)\}" return re.sub(pattern, replace_missing, msg) From a30887bb746ebba4756329cefb36eb4fa77d0db7 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Wed, 15 Mar 2023 17:05:34 +0100 Subject: [PATCH 15/45] Change name from CamelCase to snake_case --- openpype/settings/defaults/project_settings/kitsu.json | 2 +- .../schemas/projects_schema/schema_project_kitsu.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/settings/defaults/project_settings/kitsu.json b/openpype/settings/defaults/project_settings/kitsu.json index f8a98d1a0b..738bd95e38 100644 --- a/openpype/settings/defaults/project_settings/kitsu.json +++ b/openpype/settings/defaults/project_settings/kitsu.json @@ -8,7 +8,7 @@ "IntegrateKitsuNote": { "set_status_note": false, "note_status_shortname": "wfa", - "CustomCommentTemplate": { + "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_project_kitsu.json b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json index 1a7747b3dc..fc421c20f5 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json @@ -57,7 +57,7 @@ "type": "dict", "collapsible": true, "checkbox_key": "enabled", - "key": "CustomCommentTemplate", + "key": "custom_comment_template", "label": "Custom Comment Template", "children": [ { @@ -67,7 +67,7 @@ }, { "type": "label", - "label": "Kitsu supports markdown and here you can create a custom comment template.
You can use data from your instance's anatomy." + "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", From 8449c2131d49354b581e8db495b1b36b64f1a89f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 15 Mar 2023 18:28:55 +0100 Subject: [PATCH 16/45] Extended Nuke testing classes with representation details --- .../nuke/test_deadline_publish_in_nuke.py | 18 +++++++++++ .../hosts/nuke/test_publish_in_nuke.py | 31 +++++++++++++++---- 2 files changed, 43 insertions(+), 6 deletions(-) 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..a4026f195b 100644 --- a/tests/integration/hosts/nuke/test_publish_in_nuke.py +++ b/tests/integration/hosts/nuke/test_publish_in_nuke.py @@ -1,12 +1,12 @@ import logging from tests.lib.assert_classes import DBAssert -from tests.integration.hosts.nuke.lib import NukeLocalPublishTestClass +from tests.integration.hosts.nuke.lib import NukeDeadlinePublishTestClass log = logging.getLogger("test_publish_in_nuke") -class TestPublishInNuke(NukeLocalPublishTestClass): +class TestDeadlinePublishInNuke(NukeDeadlinePublishTestClass): """Basic test case for publishing in Nuke Uses generic TestCase to prepare fixtures for test data, testing DBs, @@ -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 !!! @@ -36,12 +36,13 @@ class TestPublishInNuke(NukeLocalPublishTestClass): """ # https://drive.google.com/file/d/1SUurHj2aiQ21ZIMJfGVBI2KjR8kIjBGI/view?usp=sharing # noqa: E501 TEST_FILES = [ - ("1SUurHj2aiQ21ZIMJfGVBI2KjR8kIjBGI", "test_Nuke_publish.zip", "") + ("1SeWprClKhWMv2xVC9AcnekIJFExxnp_b", + "test_nuke_deadline_publish.zip", "") ] APP_GROUP = "nuke" - TIMEOUT = 50 # publish timeout + TIMEOUT = 180 # publish timeout # could be overwritten by command line arguments # keep empty to locate latest installed variant or explicit @@ -70,14 +71,32 @@ 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) if __name__ == "__main__": - test_case = TestPublishInNuke() + test_case = TestDeadlinePublishInNuke() From ce22b665b4b1ce1216f84156c09074cb6436545a Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Thu, 16 Mar 2023 10:39:47 +0100 Subject: [PATCH 17/45] Fixed comments in code --- .../kitsu/plugins/publish/integrate_kitsu_note.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index d4282ab048..69b456426f 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -22,21 +22,24 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): } def safe_format(self, msg, **kwargs): - """If key is not found in kwargs, set None instead""" + """Pars the msg thourgh a custom format code. + It makes sure non existing keys gets None returned instead of error + """ - def replace_missing(match): + def replace_missing_key(match): + """If key is not found in kwargs, set None instead""" key = match.group(1) if key not in kwargs: self.log.warning( - "Key `{}` was not found in instance.data " - "and will be rendered as `` in the comment".format(key) + "Key '{}' was not found in instance.data " + "and will be rendered as '' in the comment".format(key) ) return "" else: return str(kwargs[key]) pattern = r"\{([^}]*)\}" - return re.sub(pattern, replace_missing, msg) + return re.sub(pattern, replace_missing_key, msg) def process(self, context): # Get comment text body From adc648616f2eebe7780a81de5882bf95e805ddf9 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Thu, 16 Mar 2023 10:40:03 +0100 Subject: [PATCH 18/45] Look for review in families instead of family --- openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 69b456426f..3ad53a1f12 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -49,7 +49,7 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): for instance in context: # Check if instance is a review by checking its family - if "review" not in instance.data["family"]: + if "review" not in instance.data["families"]: continue kitsu_task = instance.data.get("kitsu_task") From f70eac116d1978a337c8adc912413ab5a12bd842 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Thu, 16 Mar 2023 17:11:16 +0100 Subject: [PATCH 19/45] Made the format function more logical --- .../plugins/publish/integrate_kitsu_note.py | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 3ad53a1f12..b0063282d0 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -21,25 +21,27 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): "comment_template": "{comment}", } - def safe_format(self, msg, **kwargs): - """Pars the msg thourgh a custom format code. - It makes sure non existing keys gets None returned instead of error - """ + 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 kwargs: + if key not in instance.data: self.log.warning( - "Key '{}' was not found in instance.data " - "and will be rendered as '' in the comment".format(key) + "Key "{}" was not found in instance.data " + "and will be rendered as "" in the comment".format(key) ) return "" else: - return str(kwargs[key]) + return str(instance.data[key]) + template = self.custom_comment_template["comment_template"] pattern = r"\{([^}]*)\}" - return re.sub(pattern, replace_missing_key, msg) + return re.sub(pattern, replace_missing_key, template) def process(self, context): # Get comment text body @@ -75,10 +77,7 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): # If custom comment, create it if self.custom_comment_template["enabled"]: - publish_comment = self.safe_format( - self.custom_comment_template["comment_template"], - **instance.data, - ) + publish_comment = self.format_publish_comment(instance) self.log.debug("Comment is `{}`".format(publish_comment)) From 5a7bf785f72d4dacbf9283ee362f447991db2de8 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Thu, 16 Mar 2023 17:13:17 +0100 Subject: [PATCH 20/45] Fixed hound comments --- .../modules/kitsu/plugins/publish/integrate_kitsu_note.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index b0063282d0..a5a58c8462 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -26,14 +26,15 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): 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 "" in the comment".format(key) + "Key '{}' was not found in instance.data " + "and will be rendered as " + " in the comment".format(key) ) return "" else: From 316c3577c6143aab061677fbf643340ed2d6c1e6 Mon Sep 17 00:00:00 2001 From: Joseff Date: Thu, 16 Mar 2023 20:35:21 +0100 Subject: [PATCH 21/45] Fixed a bug where the QThread in the splash screen could be destroyed before finishing execution --- openpype/hosts/unreal/ue_workers.py | 42 ++++++++++++++++++++++++++--- openpype/widgets/splash_screen.py | 7 +++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/unreal/ue_workers.py b/openpype/hosts/unreal/ue_workers.py index 00f83a7d7a..f7bc0e90dc 100644 --- a/openpype/hosts/unreal/ue_workers.py +++ b/openpype/hosts/unreal/ue_workers.py @@ -28,6 +28,15 @@ def parse_prj_progress(line: str, progress_signal: QtCore.Signal(int)) -> int: progress_signal.emit(int(percent_match.group())) +def retrieve_exit_code(line: str): + match = re.search('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) @@ -225,9 +234,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! ' \ + f'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!") @@ -274,18 +304,22 @@ class UEPluginInstallWorker(QtCore.QObject): build_proc = subprocess.Popen(build_plugin_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return_code: int = None for line in build_proc.stdout: 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}' + dir_util.remove_tree(temp_dir.as_posix()) self.failed.emit(msg, return_code) raise RuntimeError(msg) diff --git a/openpype/widgets/splash_screen.py b/openpype/widgets/splash_screen.py index fffe143ea5..6bb0944c46 100644 --- a/openpype/widgets/splash_screen.py +++ b/openpype/widgets/splash_screen.py @@ -80,8 +80,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 +262,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() From c86e8e1d6ea0ae292fe414d5c499451884b230e1 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Fri, 17 Mar 2023 16:22:01 +0100 Subject: [PATCH 22/45] Added empty line in the end VSC removed it automatically before. --- openpype/settings/defaults/project_settings/kitsu.json | 2 +- .../entities/schemas/projects_schema/schema_project_kitsu.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/settings/defaults/project_settings/kitsu.json b/openpype/settings/defaults/project_settings/kitsu.json index 738bd95e38..11c138e8e5 100644 --- a/openpype/settings/defaults/project_settings/kitsu.json +++ b/openpype/settings/defaults/project_settings/kitsu.json @@ -14,4 +14,4 @@ } } } -} \ No newline at end of file +} 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 fc421c20f5..7ceb979d6f 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json @@ -82,4 +82,4 @@ ] } ] -} \ No newline at end of file +} From 8a91e4aaa07dc10ae0d0c373749af103e284e5a7 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Fri, 17 Mar 2023 16:22:28 +0100 Subject: [PATCH 23/45] Fixed comment --- .../modules/kitsu/plugins/publish/integrate_kitsu_note.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index a5a58c8462..67702578c5 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -33,8 +33,8 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): if key not in instance.data: self.log.warning( "Key '{}' was not found in instance.data " - "and will be rendered as " - " in the comment".format(key) + "and will be rendered as an empty string " + "in the comment".format(key) ) return "" else: From 94ba8151e72b6d069d8a72fb74477a6ad0f7739c Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Fri, 17 Mar 2023 16:23:13 +0100 Subject: [PATCH 24/45] Read the comment from the instance instead from the context --- .../kitsu/plugins/publish/integrate_kitsu_note.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 67702578c5..cf36bbc4fe 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -45,11 +45,6 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): 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.") - for instance in context: # Check if instance is a review by checking its family if "review" not in instance.data["families"]: @@ -76,11 +71,15 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): "changed!".format(self.note_status_shortname) ) - # If custom comment, create it + # Get comment text body + publish_comment = instance.data.get("comment") if self.custom_comment_template["enabled"]: publish_comment = self.format_publish_comment(instance) - self.log.debug("Comment is `{}`".format(publish_comment)) + 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"] From 4506a2f3ef3e7fe7b7002d89266fdb8dfb6ce8da Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 20 Mar 2023 13:41:17 +0100 Subject: [PATCH 25/45] Fix broken Nuke local test Accidentally used DL version --- tests/integration/hosts/nuke/test_publish_in_nuke.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/integration/hosts/nuke/test_publish_in_nuke.py b/tests/integration/hosts/nuke/test_publish_in_nuke.py index a4026f195b..bfd84e4fd5 100644 --- a/tests/integration/hosts/nuke/test_publish_in_nuke.py +++ b/tests/integration/hosts/nuke/test_publish_in_nuke.py @@ -1,12 +1,12 @@ import logging from tests.lib.assert_classes import DBAssert -from tests.integration.hosts.nuke.lib import NukeDeadlinePublishTestClass +from tests.integration.hosts.nuke.lib import NukeLocalPublishTestClass log = logging.getLogger("test_publish_in_nuke") -class TestDeadlinePublishInNuke(NukeDeadlinePublishTestClass): +class TestPublishInNuke(NukeLocalPublishTestClass): """Basic test case for publishing in Nuke Uses generic TestCase to prepare fixtures for test data, testing DBs, @@ -36,13 +36,12 @@ class TestDeadlinePublishInNuke(NukeDeadlinePublishTestClass): """ # https://drive.google.com/file/d/1SUurHj2aiQ21ZIMJfGVBI2KjR8kIjBGI/view?usp=sharing # noqa: E501 TEST_FILES = [ - ("1SeWprClKhWMv2xVC9AcnekIJFExxnp_b", - "test_nuke_deadline_publish.zip", "") + ("1SUurHj2aiQ21ZIMJfGVBI2KjR8kIjBGI", "test_Nuke_publish.zip", "") ] APP_GROUP = "nuke" - TIMEOUT = 180 # publish timeout + TIMEOUT = 50 # publish timeout # could be overwritten by command line arguments # keep empty to locate latest installed variant or explicit @@ -99,4 +98,4 @@ class TestDeadlinePublishInNuke(NukeDeadlinePublishTestClass): if __name__ == "__main__": - test_case = TestDeadlinePublishInNuke() + test_case = TestPublishInNuke() From 71a28cb76048d0fa057f2ec6ede62fe84fe54587 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 21 Mar 2023 10:09:36 +0100 Subject: [PATCH 26/45] Scene inventory: Fix code errors when "not found" entries are found (#4594) * Avoid VersionDelegate error if version value is not set, e.g. for NOT FOUND instances * Ignore items without `representation` data * Add not found items per container into the model like regular containers * Do not provide set version + remove options for NOT FOUND items --- openpype/tools/sceneinventory/model.py | 16 ++++++++-------- openpype/tools/sceneinventory/view.py | 7 +++++++ openpype/tools/utils/delegates.py | 8 +++++--- 3 files changed, 20 insertions(+), 11 deletions(-) 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): From a0c599203e992b8aea3f7898ce040362e3c11032 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 21 Mar 2023 10:42:06 +0100 Subject: [PATCH 27/45] Global: add tags field to thumbnail representation (#4660) * Fix add tags field to thumbnail representation Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../plugins/publish/preintegrate_thumbnail_representation.py | 2 ++ 1 file changed, 2 insertions(+) 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") From b8ce4b71e2fdd7786d0cb14b5b6c1bf91b5e6851 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 21 Mar 2023 11:20:32 +0100 Subject: [PATCH 28/45] hiero: fix padding in workfile input --- openpype/hosts/hiero/api/plugin.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) 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 From 6f16f3e4f402ff37fd6c8264615d856a6cea67f4 Mon Sep 17 00:00:00 2001 From: Sharkitty <81646000+Sharkitty@users.noreply.github.com> Date: Tue, 21 Mar 2023 14:22:23 +0000 Subject: [PATCH 29/45] Enhancement kitsu note with exceptions (#4537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enhancement: Allowing kitsu not status exceptions * Update openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py Co-authored-by: Félix David * adding equal/not equal option * Making equal/not equal option available at for every list item * Changed into , renamed into , added documentation * Using upper cases during check, so the new settings aren't case sensitive * Linting little detail * Renaming Equality into Condition, new screenshot with both equal and not equal shown on it * Update website/docs/module_kitsu.md README adjustments Co-authored-by: Félix David * Changes needed to resolve conflict * Changing context into instance where appropriate * Minor change to avoid changing a line that doesn't need to be changed * Turning exceptions into conditions. Making checks positive instead of negative. Changing implementation based on suggestions. --------- Co-authored-by: Félix David --- .../plugins/publish/integrate_kitsu_note.py | 22 +++++++++++---- .../defaults/project_settings/kitsu.json | 1 + .../projects_schema/schema_project_kitsu.json | 26 ++++++++++++++++++ .../assets/integrate_kitsu_note_settings.png | Bin 0 -> 30524 bytes website/docs/module_kitsu.md | 12 ++++++++ 5 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 website/docs/assets/integrate_kitsu_note_settings.png diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index cf36bbc4fe..6fda32d85f 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -14,6 +14,7 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): # status settings set_status_note = False note_status_shortname = "wfa" + status_conditions = list() # comment settings custom_comment_template = { @@ -56,9 +57,19 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): # 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 ) @@ -82,10 +93,11 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): 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/settings/defaults/project_settings/kitsu.json b/openpype/settings/defaults/project_settings/kitsu.json index 11c138e8e5..0638450595 100644 --- a/openpype/settings/defaults/project_settings/kitsu.json +++ b/openpype/settings/defaults/project_settings/kitsu.json @@ -8,6 +8,7 @@ "IntegrateKitsuNote": { "set_status_note": false, "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_project_kitsu.json b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json index 7ceb979d6f..ee309f63a7 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_kitsu.json @@ -51,6 +51,32 @@ { "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" }, { 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 0000000000000000000000000000000000000000..127e79ab8063dae0408f90a82d6bb4b5e233ed47 GIT binary patch literal 30524 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 literal 0 HcmV?d00001 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. From 96e184c8ca7db7d579065e3617149a38c176ebae Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 21 Mar 2023 15:29:19 +0100 Subject: [PATCH 30/45] Integrator: Enforce unique destination transfers, disallow overwrites in queued transfers (#4662) * Fix #4656: Enforce unique destination transfer in Integrator Note that this is per instance - it doesn't validate cross-instance destinations in the context * Use explicit DuplicateDestinationError and raise as KnownPublishError --- openpype/lib/file_transaction.py | 22 +++++++++++++++++++++- openpype/plugins/publish/integrate.py | 16 ++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) 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/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 From 36ee01997672e78591e80e1d93278dc9db1a2800 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 21 Mar 2023 16:48:50 +0100 Subject: [PATCH 31/45] :rotating_light: some style fixes --- openpype/hosts/unreal/ue_workers.py | 114 +++++++++++++++------------- openpype/widgets/splash_screen.py | 4 +- 2 files changed, 62 insertions(+), 56 deletions(-) diff --git a/openpype/hosts/unreal/ue_workers.py b/openpype/hosts/unreal/ue_workers.py index f7bc0e90dc..d1740124a8 100644 --- a/openpype/hosts/unreal/ue_workers.py +++ b/openpype/hosts/unreal/ue_workers.py @@ -5,33 +5,33 @@ 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('ExitCode=\d+', line) + match = re.search(r"ExitCode=\d+", line) if match is not None: - split: list[str] = match.group().split('=') + split: list[str] = match.group().split("=") return int(split[1]) return None @@ -86,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, @@ -103,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) @@ -134,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, @@ -163,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) @@ -172,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(), @@ -186,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) @@ -203,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/" @@ -245,16 +253,16 @@ class UEProjectGenerationWorker(QtCore.QObject): stderr=subprocess.PIPE) for line in pyside_install.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) pyside_install.stdout.close() return_code = pyside_install.wait() if return_code and return_code != 0: - msg = 'Failed to create the project! ' \ - f'The installation of PySide2 has failed!' + msg = ("Failed to create the project! " + "The installation of PySide2 has failed!") self.failed.emit(msg, return_code) raise RuntimeError(msg) @@ -296,18 +304,18 @@ 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: int = None + 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) @@ -317,8 +325,8 @@ class UEPluginInstallWorker(QtCore.QObject): 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/widgets/splash_screen.py b/openpype/widgets/splash_screen.py index 6bb0944c46..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. From 25f7478955e75d852095236ce6a9b21c6a5d9a9d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 21 Mar 2023 17:02:31 +0100 Subject: [PATCH 32/45] Remove unused functions (#4671) --- .../maya/plugins/publish/extract_look.py | 30 ------------------- 1 file changed, 30 deletions(-) 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. From c8c31018d656ea206c42e4a083365e21b4252861 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 21 Mar 2023 18:10:09 +0100 Subject: [PATCH 33/45] General: Filter available applications (#4667) * added project settings for applications * filter applications by new settings in launcher and ftrack * disable filtering by default --- .../event_handlers_user/action_applications.py | 9 +++++++++ .../defaults/project_settings/applications.json | 3 +++ .../schemas/projects_schema/schema_main.json | 4 ++++ .../schema_project_applications.json | 14 ++++++++++++++ openpype/tools/launcher/models.py | 6 ++++++ 5 files changed, 36 insertions(+) create mode 100644 openpype/settings/defaults/project_settings/applications.json create mode 100644 openpype/settings/entities/schemas/projects_schema/schema_project_applications.json 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/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/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/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), From 749bf7d2290ff256da95f634090d5a2c3bff18dd Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 22 Mar 2023 03:26:05 +0000 Subject: [PATCH 34/45] [Automated] Bump version --- openpype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 28b424bf2fe113857d3513948a6bdcd897fbb859 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 22 Mar 2023 12:13:21 +0100 Subject: [PATCH 35/45] Publisher: Windows reduce command window pop-ups during Publishing (#4672) * Avoid command pop-ups during publishing (tip by @iLLiCiTiT) * No need to pass creationflags because it's already done in `run_subprocess` * Hide command window for `shell=True` calls * Update openpype/lib/execute.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --------- Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/lib/execute.py | 4 ++++ openpype/lib/vendor_bin_utils.py | 12 ++++++++++-- openpype/plugins/publish/extract_burnin.py | 6 +----- openpype/scripts/otio_burnin.py | 6 ------ 4 files changed, 15 insertions(+), 13 deletions(-) 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/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/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/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() From 654bef0afcfb5159b541b38c7abd5e4ae50c307e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 22 Mar 2023 23:53:30 +0100 Subject: [PATCH 36/45] Tweak logging - preserve order and clarify versions with groups --- openpype/lib/applications.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 7cc296f47b..e9d49337f9 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1508,8 +1508,8 @@ def prepare_app_environments( if key in source_env: source_env[key] = value - # `added_env_keys` has debug purpose - added_env_keys = {app.group.name, app.name} + # `app_and_tool_labels` has debug purpose + app_and_tool_labels = ["/".join([app.group.name, app.name])] # Environments for application environments = [ app.group.environment, @@ -1532,15 +1532,14 @@ def prepare_app_environments( for group_name in sorted(groups_by_name.keys()): group = groups_by_name[group_name] environments.append(group.environment) - added_env_keys.add(group_name) for tool_name in sorted(tool_by_group_name[group_name].keys()): tool = tool_by_group_name[group_name][tool_name] environments.append(tool.environment) - added_env_keys.add(tool.name) + app_and_tool_labels.append("/".join([group_name, tool_name])) log.debug( "Will add environments for apps and tools: {}".format( - ", ".join(added_env_keys) + ", ".join(app_and_tool_labels) ) ) From 47052f7445316e49dff631c91c5967ea7a60b486 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 23 Mar 2023 11:15:51 +0800 Subject: [PATCH 37/45] incrment workfile version --- .../publish/increment_workfile_version.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 openpype/hosts/max/plugins/publish/increment_workfile_version.py diff --git a/openpype/hosts/max/plugins/publish/increment_workfile_version.py b/openpype/hosts/max/plugins/publish/increment_workfile_version.py new file mode 100644 index 0000000000..eda8cd7677 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/increment_workfile_version.py @@ -0,0 +1,20 @@ +import pyblish.api +from openpype.lib import version_up +from pymxs import runtime as rt + + +class IncrementWorkfileVersion(pyblish.api.ContextPlugin): + """Increment current workfile version.""" + + order = pyblish.api.IntegratorOrder + 0.9 + label = "Increment Workfile Version" + optional = True + hosts = ["max"] + families = ["workfile"] + + def process(self, context): + path = context.data["currentFile"] + filepath = version_up(path) + + rt.saveMaxFile(filepath) + self.log.info('Incrementing file version') From 2130f3d826dbb4e156f02acc680596091a1b4ce8 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 23 Mar 2023 11:19:46 +0800 Subject: [PATCH 38/45] remove optional --- openpype/hosts/max/plugins/publish/increment_workfile_version.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/increment_workfile_version.py b/openpype/hosts/max/plugins/publish/increment_workfile_version.py index eda8cd7677..7b4f4e238d 100644 --- a/openpype/hosts/max/plugins/publish/increment_workfile_version.py +++ b/openpype/hosts/max/plugins/publish/increment_workfile_version.py @@ -8,7 +8,6 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin): order = pyblish.api.IntegratorOrder + 0.9 label = "Increment Workfile Version" - optional = True hosts = ["max"] families = ["workfile"] From f681e9843d03a04ef2e5f413b3cc32953c45ced4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 23 Mar 2023 09:50:28 +0100 Subject: [PATCH 39/45] Extract Review code refactor (#3930) * Tweak variable names * Use `filter_profiles` from lib * Fix type fallback * Simplify additional family filters * Use legacy_io.Session instead of os.environ * Fix logging message * Indent todo comment for better todo highlighting in Pycharm * Simplify gap filling logic * Optimize getting nearest frames * Fix logic for nearest frame - This fixes cases where nearest frame isn't directly the next frame * Refactor `index` in variable `idx` to match `missing_idx` naming * Use `filter_profiles` from lib * Match family filter validation of extract review * Fix typo `overscal` -> `overscan` * Use `legacy_io.Session` instead of `os.environ` * Remove unused import * use 'KnownPublishError' instead of 'AssertionError' * modify nearest frame logic in holes fill * Fix unsupported indexing of clique Collection + slightly simplify --------- Co-authored-by: Jakub Trllo --- openpype/plugins/publish/extract_burnin.py | 33 +- openpype/plugins/publish/extract_review.py | 396 ++++----------------- 2 files changed, 82 insertions(+), 347 deletions(-) diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 38ec08e8d9..44ba4a5025 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -725,7 +725,6 @@ class ExtractBurnin(publish.Extractor): return filtered_burnin_defs families = self.families_from_instance(instance) - low_families = [family.lower() for family in families] for filename_suffix, orig_burnin_def in burnin_defs.items(): burnin_def = copy.deepcopy(orig_burnin_def) @@ -736,7 +735,7 @@ class ExtractBurnin(publish.Extractor): families_filters = def_filter["families"] if not self.families_filter_validation( - low_families, families_filters + families, families_filters ): self.log.debug(( "Skipped burnin definition \"{}\". Family" @@ -773,31 +772,19 @@ class ExtractBurnin(publish.Extractor): return filtered_burnin_defs def families_filter_validation(self, families, output_families_filter): - """Determine if entered families intersect with families filters. + """Determines if entered families intersect with families filters. All family values are lowered to avoid unexpected results. """ - if not output_families_filter: + + families_filter_lower = set(family.lower() for family in + output_families_filter + # Exclude empty filter values + if family) + if not families_filter_lower: return True - - for family_filter in output_families_filter: - if not family_filter: - continue - - if not isinstance(family_filter, (list, tuple)): - if family_filter.lower() not in families: - continue - return True - - valid = True - for family in family_filter: - if family.lower() not in families: - valid = False - break - - if valid: - return True - return False + return any(family.lower() in families_filter_lower + for family in families) def families_from_instance(self, instance): """Return all families of entered instance.""" diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index acb1fc10bf..7de92a8bbd 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -12,7 +12,7 @@ import pyblish.api from openpype.lib import ( get_ffmpeg_tool_path, - + filter_profiles, path_to_subprocess_arg, run_subprocess, ) @@ -23,6 +23,7 @@ from openpype.lib.transcoding import ( convert_input_paths_for_ffmpeg, get_transcode_temp_directory, ) +from openpype.pipeline.publish import KnownPublishError class ExtractReview(pyblish.api.InstancePlugin): @@ -88,21 +89,23 @@ class ExtractReview(pyblish.api.InstancePlugin): def _get_outputs_for_instance(self, instance): host_name = instance.context.data["hostName"] - task_name = os.environ["AVALON_TASK"] family = self.main_family_from_instance(instance) self.log.info("Host: \"{}\"".format(host_name)) - self.log.info("Task: \"{}\"".format(task_name)) self.log.info("Family: \"{}\"".format(family)) - profile = self.find_matching_profile( - host_name, task_name, family - ) + profile = filter_profiles( + self.profiles, + { + "hosts": host_name, + "families": family, + }, + logger=self.log) if not profile: self.log.info(( "Skipped instance. None of profiles in presets are for" - " Host: \"{}\" | Family: \"{}\" | Task \"{}\"" - ).format(host_name, family, task_name)) + " Host: \"{}\" | Family: \"{}\"" + ).format(host_name, family)) return self.log.debug("Matching profile: \"{}\"".format(json.dumps(profile))) @@ -112,17 +115,19 @@ class ExtractReview(pyblish.api.InstancePlugin): filtered_outputs = self.filter_output_defs( profile, subset_name, instance_families ) + if not filtered_outputs: + self.log.info(( + "Skipped instance. All output definitions from selected" + " profile do not match instance families \"{}\" or" + " subset name \"{}\"." + ).format(str(instance_families), subset_name)) + # Store `filename_suffix` to save arguments profile_outputs = [] for filename_suffix, definition in filtered_outputs.items(): definition["filename_suffix"] = filename_suffix profile_outputs.append(definition) - if not filtered_outputs: - self.log.info(( - "Skipped instance. All output definitions from selected" - " profile does not match to instance families. \"{}\"" - ).format(str(instance_families))) return profile_outputs def _get_outputs_per_representations(self, instance, profile_outputs): @@ -216,6 +221,7 @@ class ExtractReview(pyblish.api.InstancePlugin): outputs_per_repres = self._get_outputs_per_representations( instance, profile_outputs ) + for repre, output_defs in outputs_per_repres: # Check if input should be preconverted before processing # Store original staging dir (it's value may change) @@ -297,10 +303,10 @@ class ExtractReview(pyblish.api.InstancePlugin): shutil.rmtree(new_staging_dir) def _render_output_definitions( - self, instance, repre, src_repre_staging_dir, output_defs + self, instance, repre, src_repre_staging_dir, output_definitions ): fill_data = copy.deepcopy(instance.data["anatomyData"]) - for _output_def in output_defs: + for _output_def in output_definitions: output_def = copy.deepcopy(_output_def) # Make sure output definition has "tags" key if "tags" not in output_def: @@ -346,10 +352,11 @@ class ExtractReview(pyblish.api.InstancePlugin): if temp_data["input_is_sequence"]: self.log.info("Filling gaps in sequence.") files_to_clean = self.fill_sequence_gaps( - temp_data["origin_repre"]["files"], - new_repre["stagingDir"], - temp_data["frame_start"], - temp_data["frame_end"]) + files=temp_data["origin_repre"]["files"], + staging_dir=new_repre["stagingDir"], + start_frame=temp_data["frame_start"], + end_frame=temp_data["frame_end"] + ) # create or update outputName output_name = new_repre.get("outputName", "") @@ -421,10 +428,10 @@ class ExtractReview(pyblish.api.InstancePlugin): def input_is_sequence(self, repre): """Deduce from representation data if input is sequence.""" # TODO GLOBAL ISSUE - Find better way how to find out if input - # is sequence. Issues( in theory): - # - there may be multiple files ant not be sequence - # - remainders are not checked at all - # - there can be more than one collection + # is sequence. Issues (in theory): + # - there may be multiple files ant not be sequence + # - remainders are not checked at all + # - there can be more than one collection return isinstance(repre["files"], (list, tuple)) def prepare_temp_data(self, instance, repre, output_def): @@ -816,76 +823,41 @@ class ExtractReview(pyblish.api.InstancePlugin): is done. Raises: - AssertionError: if more then one collection is obtained. - + KnownPublishError: if more than one collection is obtained. """ - start_frame = int(start_frame) - end_frame = int(end_frame) + collections = clique.assemble(files)[0] - msg = "Multiple collections {} found.".format(collections) - assert len(collections) == 1, msg + if len(collections) != 1: + raise KnownPublishError( + "Multiple collections {} found.".format(collections)) + col = collections[0] - # do nothing if no gap is found in input range - not_gap = True - for fr in range(start_frame, end_frame + 1): - if fr not in col.indexes: - not_gap = False - - if not_gap: - return [] - - holes = col.holes() - - # generate ideal sequence - complete_col = clique.assemble( - [("{}{:0" + str(col.padding) + "d}{}").format( - col.head, f, col.tail - ) for f in range(start_frame, end_frame)] - )[0][0] # type: clique.Collection - - new_files = {} - last_existing_file = None - - for idx in holes.indexes: - # get previous existing file - test_file = os.path.normpath(os.path.join( - staging_dir, - ("{}{:0" + str(complete_col.padding) + "d}{}").format( - complete_col.head, idx - 1, complete_col.tail))) - if os.path.isfile(test_file): - new_files[idx] = test_file - last_existing_file = test_file + # Prepare which hole is filled with what frame + # - the frame is filled only with already existing frames + prev_frame = next(iter(col.indexes)) + hole_frame_to_nearest = {} + for frame in range(int(start_frame), int(end_frame) + 1): + if frame in col.indexes: + prev_frame = frame else: - if not last_existing_file: - # previous file is not found (sequence has a hole - # at the beginning. Use first available frame - # there is. - try: - last_existing_file = list(col)[0] - except IndexError: - # empty collection? - raise AssertionError( - "Invalid sequence collected") - new_files[idx] = os.path.normpath( - os.path.join(staging_dir, last_existing_file)) + # Use previous frame as source for hole + hole_frame_to_nearest[frame] = prev_frame - files_to_clean = [] - if new_files: - # so now new files are dict with missing frame as a key and - # existing file as a value. - for frame, file in new_files.items(): - self.log.info( - "Filling gap {} with {}".format(frame, file)) + # Calculate paths + added_files = [] + col_format = col.format("{head}{padding}{tail}") + for hole_frame, src_frame in hole_frame_to_nearest.items(): + hole_fpath = os.path.join(staging_dir, col_format % hole_frame) + src_fpath = os.path.join(staging_dir, col_format % src_frame) + if not os.path.isfile(src_fpath): + raise KnownPublishError( + "Missing previously detected file: {}".format(src_fpath)) - hole = os.path.join( - staging_dir, - ("{}{:0" + str(col.padding) + "d}{}").format( - col.head, frame, col.tail)) - speedcopy.copyfile(file, hole) - files_to_clean.append(hole) + speedcopy.copyfile(src_fpath, hole_fpath) + added_files.append(hole_fpath) - return files_to_clean + return added_files def input_output_paths(self, new_repre, output_def, temp_data): """Deduce input nad output file paths based on entered data. @@ -1281,7 +1253,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # 'use_input_res' is set to 'True'. use_input_res = False - # Overscal color + # Overscan color overscan_color_value = "black" overscan_color = output_def.get("overscan_color") if overscan_color: @@ -1468,240 +1440,20 @@ class ExtractReview(pyblish.api.InstancePlugin): families.append(family) return families - def compile_list_of_regexes(self, in_list): - """Convert strings in entered list to compiled regex objects.""" - regexes = [] - if not in_list: - return regexes - - for item in in_list: - if not item: - continue - - try: - regexes.append(re.compile(item)) - except TypeError: - self.log.warning(( - "Invalid type \"{}\" value \"{}\"." - " Expected string based object. Skipping." - ).format(str(type(item)), str(item))) - - return regexes - - def validate_value_by_regexes(self, value, in_list): - """Validates in any regex from list match entered value. - - Args: - in_list (list): List with regexes. - value (str): String where regexes is checked. - - Returns: - int: Returns `0` when list is not set or is empty. Returns `1` when - any regex match value and returns `-1` when none of regexes - match value entered. - """ - if not in_list: - return 0 - - output = -1 - regexes = self.compile_list_of_regexes(in_list) - for regex in regexes: - if not value: - continue - if re.match(regex, value): - output = 1 - break - return output - - def profile_exclusion(self, matching_profiles): - """Find out most matching profile byt host, task and family match. - - Profiles are selectively filtered. Each profile should have - "__value__" key with list of booleans. Each boolean represents - existence of filter for specific key (host, tasks, family). - Profiles are looped in sequence. In each sequence are split into - true_list and false_list. For next sequence loop are used profiles in - true_list if there are any profiles else false_list is used. - - Filtering ends when only one profile left in true_list. Or when all - existence booleans loops passed, in that case first profile from left - profiles is returned. - - Args: - matching_profiles (list): Profiles with same values. - - Returns: - dict: Most matching profile. - """ - self.log.info( - "Search for first most matching profile in match order:" - " Host name -> Task name -> Family." - ) - # Filter all profiles with highest points value. First filter profiles - # with matching host if there are any then filter profiles by task - # name if there are any and lastly filter by family. Else use first in - # list. - idx = 0 - final_profile = None - while True: - profiles_true = [] - profiles_false = [] - for profile in matching_profiles: - value = profile["__value__"] - # Just use first profile when idx is greater than values. - if not idx < len(value): - final_profile = profile - break - - if value[idx]: - profiles_true.append(profile) - else: - profiles_false.append(profile) - - if final_profile is not None: - break - - if profiles_true: - matching_profiles = profiles_true - else: - matching_profiles = profiles_false - - if len(matching_profiles) == 1: - final_profile = matching_profiles[0] - break - idx += 1 - - final_profile.pop("__value__") - return final_profile - - def find_matching_profile(self, host_name, task_name, family): - """ Filter profiles by Host name, Task name and main Family. - - Filtering keys are "hosts" (list), "tasks" (list), "families" (list). - If key is not find or is empty than it's expected to match. - - Args: - profiles (list): Profiles definition from presets. - host_name (str): Current running host name. - task_name (str): Current context task name. - family (str): Main family of current Instance. - - Returns: - dict/None: Return most matching profile or None if none of profiles - match at least one criteria. - """ - - matching_profiles = None - if not self.profiles: - return matching_profiles - - highest_profile_points = -1 - # Each profile get 1 point for each matching filter. Profile with most - # points is returned. For cases when more than one profile will match - # are also stored ordered lists of matching values. - for profile in self.profiles: - profile_points = 0 - profile_value = [] - - # Host filtering - host_names = profile.get("hosts") - match = self.validate_value_by_regexes(host_name, host_names) - if match == -1: - self.log.debug( - "\"{}\" not found in {}".format(host_name, host_names) - ) - continue - profile_points += match - profile_value.append(bool(match)) - - # Task filtering - task_names = profile.get("tasks") - match = self.validate_value_by_regexes(task_name, task_names) - if match == -1: - self.log.debug( - "\"{}\" not found in {}".format(task_name, task_names) - ) - continue - profile_points += match - profile_value.append(bool(match)) - - # Family filtering - families = profile.get("families") - match = self.validate_value_by_regexes(family, families) - if match == -1: - self.log.debug( - "\"{}\" not found in {}".format(family, families) - ) - continue - profile_points += match - profile_value.append(bool(match)) - - if profile_points < highest_profile_points: - continue - - if profile_points > highest_profile_points: - matching_profiles = [] - highest_profile_points = profile_points - - if profile_points == highest_profile_points: - profile["__value__"] = profile_value - matching_profiles.append(profile) - - if not matching_profiles: - self.log.warning(( - "None of profiles match your setup." - " Host \"{}\" | Task: \"{}\" | Family: \"{}\"" - ).format(host_name, task_name, family)) - return - - if len(matching_profiles) == 1: - # Pop temporary key `__value__` - matching_profiles[0].pop("__value__") - return matching_profiles[0] - - self.log.warning(( - "More than one profile match your setup." - " Host \"{}\" | Task: \"{}\" | Family: \"{}\"" - ).format(host_name, task_name, family)) - - return self.profile_exclusion(matching_profiles) - def families_filter_validation(self, families, output_families_filter): """Determines if entered families intersect with families filters. All family values are lowered to avoid unexpected results. """ - if not output_families_filter: + + families_filter_lower = set(family.lower() for family in + output_families_filter + # Exclude empty filter values + if family) + if not families_filter_lower: return True - - single_families = [] - combination_families = [] - for family_filter in output_families_filter: - if not family_filter: - continue - if isinstance(family_filter, (list, tuple)): - _family_filter = [] - for family in family_filter: - if family: - _family_filter.append(family.lower()) - combination_families.append(_family_filter) - else: - single_families.append(family_filter.lower()) - - for family in single_families: - if family in families: - return True - - for family_combination in combination_families: - valid = True - for family in family_combination: - if family not in families: - valid = False - break - - if valid: - return True - return False + return any(family.lower() in families_filter_lower + for family in families) def filter_output_defs(self, profile, subset_name, families): """Return outputs matching input instance families. @@ -1716,14 +1468,10 @@ class ExtractReview(pyblish.api.InstancePlugin): Returns: list: Containg all output definitions matching entered families. """ - outputs = profile.get("outputs") or [] + outputs = profile.get("outputs") or {} if not outputs: return outputs - # lower values - # QUESTION is this valid operation? - families = [family.lower() for family in families] - filtered_outputs = {} for filename_suffix, output_def in outputs.items(): output_filters = output_def.get("filter") @@ -1995,14 +1743,14 @@ class OverscanCrop: relative_source_regex = re.compile(r"%([\+\-])") def __init__( - self, input_width, input_height, string_value, overscal_color=None + self, input_width, input_height, string_value, overscan_color=None ): # Make sure that is not None string_value = string_value or "" self.input_width = input_width self.input_height = input_height - self.overscal_color = overscal_color + self.overscan_color = overscan_color width, height = self._convert_string_to_values(string_value) self._width_value = width @@ -2058,20 +1806,20 @@ class OverscanCrop: elif width >= self.input_width and height >= self.input_height: output.append( "pad={}:{}:(iw-ow)/2:(ih-oh)/2:{}".format( - width, height, self.overscal_color + width, height, self.overscan_color ) ) elif width > self.input_width and height < self.input_height: output.append("crop=iw:{}".format(height)) output.append("pad={}:ih:(iw-ow)/2:(ih-oh)/2:{}".format( - width, self.overscal_color + width, self.overscan_color )) elif width < self.input_width and height > self.input_height: output.append("crop={}:ih".format(width)) output.append("pad=iw:{}:(iw-ow)/2:(ih-oh)/2:{}".format( - height, self.overscal_color + height, self.overscan_color )) return output From 90f5cdf7f37942c5dd4c81fd4feee51fe568bd33 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 23 Mar 2023 09:59:56 +0100 Subject: [PATCH 40/45] Use `app.full_name` (#4686) --- openpype/lib/applications.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index e9d49337f9..9c791eff38 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1509,7 +1509,7 @@ def prepare_app_environments( source_env[key] = value # `app_and_tool_labels` has debug purpose - app_and_tool_labels = ["/".join([app.group.name, app.name])] + app_and_tool_labels = [app.full_name] # Environments for application environments = [ app.group.environment, @@ -1535,7 +1535,7 @@ def prepare_app_environments( for tool_name in sorted(tool_by_group_name[group_name].keys()): tool = tool_by_group_name[group_name][tool_name] environments.append(tool.environment) - app_and_tool_labels.append("/".join([group_name, tool_name])) + app_and_tool_labels.append(tool.full_name) log.debug( "Will add environments for apps and tools: {}".format( From a14b645d892e71ef60c6e15da0a9e7f350d6b66f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 23 Mar 2023 10:01:50 +0100 Subject: [PATCH 41/45] Fix class name and docstring (#4683) --- openpype/hooks/pre_create_extra_workdir_folders.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openpype/hooks/pre_create_extra_workdir_folders.py b/openpype/hooks/pre_create_extra_workdir_folders.py index c5af620c87..8856281120 100644 --- a/openpype/hooks/pre_create_extra_workdir_folders.py +++ b/openpype/hooks/pre_create_extra_workdir_folders.py @@ -3,10 +3,13 @@ from openpype.lib import PreLaunchHook from openpype.pipeline.workfile import create_workdir_extra_folders -class AddLastWorkfileToLaunchArgs(PreLaunchHook): - """Add last workfile path to launch arguments. +class CreateWorkdirExtraFolders(PreLaunchHook): + """Create extra folders for the work directory. + + Based on setting `project_settings/global/tools/Workfiles/extra_folders` + profile filtering will decide whether extra folders need to be created in + the work directory. - This is not possible to do for all applications the same way. """ # Execute after workfile template copy From 4d53e4f5fb81e633eb0a404d6cbca7da152572a8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 23 Mar 2023 10:27:02 +0100 Subject: [PATCH 42/45] Application launch context: Include app group name in logger (#4684) * Include app group name with app name for logger * Include app group name in launch finished log message * Include app group name with launching message * Use `application.full_name` instead --- openpype/lib/applications.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 9c791eff38..127d31d042 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -889,7 +889,8 @@ class ApplicationLaunchContext: self.modules_manager = ModulesManager() # Logger - logger_name = "{}-{}".format(self.__class__.__name__, self.app_name) + logger_name = "{}-{}".format(self.__class__.__name__, + self.application.full_name) self.log = Logger.get_logger(logger_name) self.executable = executable @@ -1246,7 +1247,7 @@ class ApplicationLaunchContext: args_len_str = " ({})".format(len(args)) self.log.info( "Launching \"{}\" with args{}: {}".format( - self.app_name, args_len_str, args + self.application.full_name, args_len_str, args ) ) self.launch_args = args @@ -1271,7 +1272,9 @@ class ApplicationLaunchContext: exc_info=True ) - self.log.debug("Launch of {} finished.".format(self.app_name)) + self.log.debug("Launch of {} finished.".format( + self.application.full_name + )) return self.process From 81b659e933591e3fbabc9ea1b57ce99a25ef5396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Thu, 23 Mar 2023 12:06:01 +0100 Subject: [PATCH 43/45] Update openpype/hosts/max/plugins/publish/increment_workfile_version.py --- .../hosts/max/plugins/publish/increment_workfile_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/increment_workfile_version.py b/openpype/hosts/max/plugins/publish/increment_workfile_version.py index 7b4f4e238d..3dec214f77 100644 --- a/openpype/hosts/max/plugins/publish/increment_workfile_version.py +++ b/openpype/hosts/max/plugins/publish/increment_workfile_version.py @@ -16,4 +16,4 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin): filepath = version_up(path) rt.saveMaxFile(filepath) - self.log.info('Incrementing file version') + self.log.info("Incrementing file version") From 0a085c7001db46671d26beb0c8a59de1a37a58a2 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 23 Mar 2023 12:06:05 +0100 Subject: [PATCH 44/45] Kitsu: Slightly less strict with instance data (#4678) * Match family and families * Allow kitsu note to not have set comment and capture it without erroring in IntegrateKitsuReview * Allow fallback to context for instance `task` * Shush hound * Refactor variable names --- .../kitsu/plugins/publish/collect_kitsu_entities.py | 2 +- .../modules/kitsu/plugins/publish/integrate_kitsu_note.py | 5 ++++- .../kitsu/plugins/publish/integrate_kitsu_review.py | 8 ++++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py index a0bd2b305b..4ea8135620 100644 --- a/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py +++ b/openpype/modules/kitsu/plugins/publish/collect_kitsu_entities.py @@ -29,7 +29,7 @@ class CollectKitsuEntities(pyblish.api.ContextPlugin): if not zou_asset_data: raise ValueError("Zou asset data not found in OpenPype!") - task_name = instance.data.get("task") + task_name = instance.data.get("task", context.data.get("task")) if not task_name: continue diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 6fda32d85f..6be14b3bdf 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -48,7 +48,10 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): def process(self, context): for instance in context: # Check if instance is a review by checking its family - if "review" not in instance.data["families"]: + # Allow a match to primary family or any of families + families = set([instance.data["family"]] + + instance.data.get("families", [])) + if "review" not in families: continue kitsu_task = instance.data.get("kitsu_task") diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py index 12482b5657..e05ff05f50 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_review.py @@ -12,17 +12,17 @@ class IntegrateKitsuReview(pyblish.api.InstancePlugin): optional = True def process(self, instance): - task = instance.data["kitsu_task"]["id"] - comment = instance.data["kitsu_comment"]["id"] # Check comment has been created - if not comment: + comment_id = instance.data.get("kitsu_comment", {}).get("id") + if not comment_id: self.log.debug( "Comment not created, review not pushed to preview." ) return # Add review representations as preview of comment + task_id = instance.data["kitsu_task"]["id"] for representation in instance.data.get("representations", []): # Skip if not tagged as review if "kitsureview" not in representation.get("tags", []): @@ -31,6 +31,6 @@ class IntegrateKitsuReview(pyblish.api.InstancePlugin): self.log.debug("Found review at: {}".format(review_path)) gazu.task.add_preview( - task, comment, review_path, normalize_movie=True + task_id, comment_id, review_path, normalize_movie=True ) self.log.info("Review upload on comment") From d0f083ec195a0e4ac639865c14d50635d318bb6e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 23 Mar 2023 12:52:25 +0100 Subject: [PATCH 45/45] Publisher: Explicit save (#4676) * added save button class * added save button to window * workfiles is also part of context in CreateContext to be able check if context changed * window cares about trigger of convertors * use abstractproperty with property and abstractmethod decorators * save changes happens using main window and can be blocked * fix pyside compatibility * use create context to get current context names * Fix docstring label * added shortcuts for save and reset * change control string matching for macos * added 'publish_has_started' property * allow save only if publishing did not start yet * rename 'get_selected_convertors' to 'get_selected_legacy_convertors' and added docstrings * Added Saved changes * disable instances toggle when publishing started * Fix reset button tooltip Co-authored-by: Roy Nieterau * Use QKeySequence to string for tooltips * added example output * use predefined method to emit card message --------- Co-authored-by: Roy Nieterau --- openpype/pipeline/create/context.py | 91 +++++++-- openpype/tools/publisher/constants.py | 5 +- openpype/tools/publisher/control.py | 185 +++++++++++++----- openpype/tools/publisher/widgets/__init__.py | 6 +- .../publisher/widgets/card_view_widgets.py | 19 ++ .../tools/publisher/widgets/images/save.png | Bin 0 -> 3961 bytes .../publisher/widgets/list_view_widgets.py | 27 +++ .../publisher/widgets/overview_widget.py | 38 +++- openpype/tools/publisher/widgets/widgets.py | 34 +++- openpype/tools/publisher/window.py | 127 ++++++++++-- openpype/tools/traypublisher/window.py | 2 +- 11 files changed, 450 insertions(+), 84 deletions(-) create mode 100644 openpype/tools/publisher/widgets/images/save.png diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index acc2bb054f..22cab28e4b 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -22,7 +22,7 @@ from openpype.lib.attribute_definitions import ( deserialize_attr_defs, get_default_values, ) -from openpype.host import IPublishHost +from openpype.host import IPublishHost, IWorkfileHost from openpype.pipeline import legacy_io from openpype.pipeline.plugin_discover import DiscoverResult @@ -1374,6 +1374,7 @@ class CreateContext: self._current_project_name = None self._current_asset_name = None self._current_task_name = None + self._current_workfile_path = None self._host_is_valid = host_is_valid # Currently unused variable @@ -1503,14 +1504,62 @@ class CreateContext: return os.environ["AVALON_APP"] def get_current_project_name(self): + """Project name which was used as current context on context reset. + + Returns: + Union[str, None]: Project name. + """ + return self._current_project_name def get_current_asset_name(self): + """Asset name which was used as current context on context reset. + + Returns: + Union[str, None]: Asset name. + """ + return self._current_asset_name def get_current_task_name(self): + """Task name which was used as current context on context reset. + + Returns: + Union[str, None]: Task name. + """ + return self._current_task_name + def get_current_workfile_path(self): + """Workfile path which was opened on context reset. + + Returns: + Union[str, None]: Workfile path. + """ + + return self._current_workfile_path + + @property + def context_has_changed(self): + """Host context has changed. + + As context is used project, asset, task name and workfile path if + host does support workfiles. + + Returns: + bool: Context changed. + """ + + project_name, asset_name, task_name, workfile_path = ( + self._get_current_host_context() + ) + return ( + self._current_project_name != project_name + or self._current_asset_name != asset_name + or self._current_task_name != task_name + or self._current_workfile_path != workfile_path + ) + project_name = property(get_current_project_name) @property @@ -1575,6 +1624,28 @@ class CreateContext: self._collection_shared_data = None self.refresh_thumbnails() + def _get_current_host_context(self): + project_name = asset_name = task_name = workfile_path = None + if hasattr(self.host, "get_current_context"): + host_context = self.host.get_current_context() + if host_context: + project_name = host_context.get("project_name") + asset_name = host_context.get("asset_name") + task_name = host_context.get("task_name") + + if isinstance(self.host, IWorkfileHost): + workfile_path = self.host.get_current_workfile() + + # --- TODO remove these conditions --- + if not project_name: + project_name = legacy_io.Session.get("AVALON_PROJECT") + if not asset_name: + asset_name = legacy_io.Session.get("AVALON_ASSET") + if not task_name: + task_name = legacy_io.Session.get("AVALON_TASK") + # --- + return project_name, asset_name, task_name, workfile_path + def reset_current_context(self): """Refresh current context. @@ -1593,24 +1664,14 @@ class CreateContext: are stored. We should store the workfile (if is available) too. """ - project_name = asset_name = task_name = None - if hasattr(self.host, "get_current_context"): - host_context = self.host.get_current_context() - if host_context: - project_name = host_context.get("project_name") - asset_name = host_context.get("asset_name") - task_name = host_context.get("task_name") - - if not project_name: - project_name = legacy_io.Session.get("AVALON_PROJECT") - if not asset_name: - asset_name = legacy_io.Session.get("AVALON_ASSET") - if not task_name: - task_name = legacy_io.Session.get("AVALON_TASK") + project_name, asset_name, task_name, workfile_path = ( + self._get_current_host_context() + ) self._current_project_name = project_name self._current_asset_name = asset_name self._current_task_name = task_name + self._current_workfile_path = workfile_path def reset_plugins(self, discover_publish_plugins=True): """Reload plugins. diff --git a/openpype/tools/publisher/constants.py b/openpype/tools/publisher/constants.py index b2bfd7dd5c..5d23886aa8 100644 --- a/openpype/tools/publisher/constants.py +++ b/openpype/tools/publisher/constants.py @@ -1,4 +1,4 @@ -from qtpy import QtCore +from qtpy import QtCore, QtGui # ID of context item in instance view CONTEXT_ID = "context" @@ -26,6 +26,9 @@ GROUP_ROLE = QtCore.Qt.UserRole + 7 CONVERTER_IDENTIFIER_ROLE = QtCore.Qt.UserRole + 8 CREATOR_SORT_ROLE = QtCore.Qt.UserRole + 9 +ResetKeySequence = QtGui.QKeySequence( + QtCore.Qt.ControlModifier | QtCore.Qt.Key_R +) __all__ = ( "CONTEXT_ID", diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index 49e7eeb4f7..b62ae7ecc1 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -6,7 +6,7 @@ import collections import uuid import tempfile import shutil -from abc import ABCMeta, abstractmethod, abstractproperty +from abc import ABCMeta, abstractmethod import six import pyblish.api @@ -964,7 +964,8 @@ class AbstractPublisherController(object): access objects directly but by using wrappers that can be serialized. """ - @abstractproperty + @property + @abstractmethod def log(self): """Controller's logger object. @@ -974,13 +975,15 @@ class AbstractPublisherController(object): pass - @abstractproperty + @property + @abstractmethod def event_system(self): """Inner event system for publisher controller.""" pass - @abstractproperty + @property + @abstractmethod def project_name(self): """Current context project name. @@ -990,7 +993,8 @@ class AbstractPublisherController(object): pass - @abstractproperty + @property + @abstractmethod def current_asset_name(self): """Current context asset name. @@ -1000,7 +1004,8 @@ class AbstractPublisherController(object): pass - @abstractproperty + @property + @abstractmethod def current_task_name(self): """Current context task name. @@ -1010,7 +1015,21 @@ class AbstractPublisherController(object): pass - @abstractproperty + @property + @abstractmethod + def host_context_has_changed(self): + """Host context changed after last reset. + + 'CreateContext' has this option available using 'context_has_changed'. + + Returns: + bool: Context has changed. + """ + + pass + + @property + @abstractmethod def host_is_valid(self): """Host is valid for creation part. @@ -1023,7 +1042,8 @@ class AbstractPublisherController(object): pass - @abstractproperty + @property + @abstractmethod def instances(self): """Collected/created instances. @@ -1134,7 +1154,13 @@ class AbstractPublisherController(object): @abstractmethod def save_changes(self): - """Save changes in create context.""" + """Save changes in create context. + + Save can crash because of unexpected errors. + + Returns: + bool: Save was successful. + """ pass @@ -1145,7 +1171,19 @@ class AbstractPublisherController(object): pass - @abstractproperty + @property + @abstractmethod + def publish_has_started(self): + """Has publishing finished. + + Returns: + bool: If publishing finished and all plugins were iterated. + """ + + pass + + @property + @abstractmethod def publish_has_finished(self): """Has publishing finished. @@ -1155,7 +1193,8 @@ class AbstractPublisherController(object): pass - @abstractproperty + @property + @abstractmethod def publish_is_running(self): """Publishing is running right now. @@ -1165,7 +1204,8 @@ class AbstractPublisherController(object): pass - @abstractproperty + @property + @abstractmethod def publish_has_validated(self): """Publish validation passed. @@ -1175,7 +1215,8 @@ class AbstractPublisherController(object): pass - @abstractproperty + @property + @abstractmethod def publish_has_crashed(self): """Publishing crashed for any reason. @@ -1185,7 +1226,8 @@ class AbstractPublisherController(object): pass - @abstractproperty + @property + @abstractmethod def publish_has_validation_errors(self): """During validation happened at least one validation error. @@ -1195,7 +1237,8 @@ class AbstractPublisherController(object): pass - @abstractproperty + @property + @abstractmethod def publish_max_progress(self): """Get maximum possible progress number. @@ -1205,7 +1248,8 @@ class AbstractPublisherController(object): pass - @abstractproperty + @property + @abstractmethod def publish_progress(self): """Current progress number. @@ -1215,7 +1259,8 @@ class AbstractPublisherController(object): pass - @abstractproperty + @property + @abstractmethod def publish_error_msg(self): """Current error message which cause fail of publishing. @@ -1267,7 +1312,8 @@ class AbstractPublisherController(object): pass - @abstractproperty + @property + @abstractmethod def convertor_items(self): pass @@ -1356,6 +1402,7 @@ class BasePublisherController(AbstractPublisherController): self._publish_has_validation_errors = False self._publish_has_crashed = False # All publish plugins are processed + self._publish_has_started = False self._publish_has_finished = False self._publish_max_progress = 0 self._publish_progress = 0 @@ -1386,7 +1433,8 @@ class BasePublisherController(AbstractPublisherController): "show.card.message" - Show card message request (UI related). "instances.refresh.finished" - Instances are refreshed. "plugins.refresh.finished" - Plugins refreshed. - "publish.reset.finished" - Publish context reset finished. + "publish.reset.finished" - Reset finished. + "controller.reset.started" - Controller reset started. "controller.reset.finished" - Controller reset finished. "publish.process.started" - Publishing started. Can be started from paused state. @@ -1425,7 +1473,16 @@ class BasePublisherController(AbstractPublisherController): def _set_host_is_valid(self, value): if self._host_is_valid != value: self._host_is_valid = value - self._emit_event("publish.host_is_valid.changed", {"value": value}) + self._emit_event( + "publish.host_is_valid.changed", {"value": value} + ) + + def _get_publish_has_started(self): + return self._publish_has_started + + def _set_publish_has_started(self, value): + if value != self._publish_has_started: + self._publish_has_started = value def _get_publish_has_finished(self): return self._publish_has_finished @@ -1449,7 +1506,9 @@ class BasePublisherController(AbstractPublisherController): def _set_publish_has_validated(self, value): if self._publish_has_validated != value: self._publish_has_validated = value - self._emit_event("publish.has_validated.changed", {"value": value}) + self._emit_event( + "publish.has_validated.changed", {"value": value} + ) def _get_publish_has_crashed(self): return self._publish_has_crashed @@ -1497,6 +1556,9 @@ class BasePublisherController(AbstractPublisherController): host_is_valid = property( _get_host_is_valid, _set_host_is_valid ) + publish_has_started = property( + _get_publish_has_started, _set_publish_has_started + ) publish_has_finished = property( _get_publish_has_finished, _set_publish_has_finished ) @@ -1526,6 +1588,7 @@ class BasePublisherController(AbstractPublisherController): """Reset most of attributes that can be reset.""" self.publish_is_running = False + self.publish_has_started = False self.publish_has_validated = False self.publish_has_crashed = False self.publish_has_validation_errors = False @@ -1645,10 +1708,7 @@ class PublisherController(BasePublisherController): str: Project name. """ - if not hasattr(self._host, "get_current_context"): - return legacy_io.active_project() - - return self._host.get_current_context()["project_name"] + return self._create_context.get_current_project_name() @property def current_asset_name(self): @@ -1658,10 +1718,7 @@ class PublisherController(BasePublisherController): Union[str, None]: Asset name or None if asset is not set. """ - if not hasattr(self._host, "get_current_context"): - return legacy_io.Session["AVALON_ASSET"] - - return self._host.get_current_context()["asset_name"] + return self._create_context.get_current_asset_name() @property def current_task_name(self): @@ -1671,10 +1728,11 @@ class PublisherController(BasePublisherController): Union[str, None]: Task name or None if task is not set. """ - if not hasattr(self._host, "get_current_context"): - return legacy_io.Session["AVALON_TASK"] + return self._create_context.get_current_task_name() - return self._host.get_current_context()["task_name"] + @property + def host_context_has_changed(self): + return self._create_context.context_has_changed @property def instances(self): @@ -1751,6 +1809,8 @@ class PublisherController(BasePublisherController): """Reset everything related to creation and publishing.""" self.stop_publish() + self._emit_event("controller.reset.started") + self.host_is_valid = self._create_context.host_is_valid self._create_context.reset_preparation() @@ -1992,7 +2052,15 @@ class PublisherController(BasePublisherController): ) def trigger_convertor_items(self, convertor_identifiers): - self.save_changes() + """Trigger legacy item convertors. + + This functionality requires to save and reset CreateContext. The reset + is needed so Creators can collect converted items. + + Args: + convertor_identifiers (list[str]): Identifiers of convertor + plugins. + """ success = True try: @@ -2039,13 +2107,33 @@ class PublisherController(BasePublisherController): self._on_create_instance_change() return success - def save_changes(self): - """Save changes happened during creation.""" + def save_changes(self, show_message=True): + """Save changes happened during creation. + + Trigger save of changes using host api. This functionality does not + validate anything. It is required to do checks before this method is + called to be able to give user actionable response e.g. check of + context using 'host_context_has_changed'. + + Args: + show_message (bool): Show message that changes were + saved successfully. + + Returns: + bool: Save of changes was successful. + """ + if not self._create_context.host_is_valid: - return + # TODO remove + # Fake success save when host is not valid for CreateContext + # this is for testing as experimental feature + return True try: self._create_context.save_changes() + if show_message: + self.emit_card_message("Saved changes..") + return True except CreatorsOperationFailed as exc: self._emit_event( @@ -2056,16 +2144,17 @@ class PublisherController(BasePublisherController): } ) + return False + def remove_instances(self, instance_ids): """Remove instances based on instance ids. Args: instance_ids (List[str]): List of instance ids to remove. """ - # QUESTION Expect that instances are really removed? In that case save - # reset is not required and save changes too. - self.save_changes() + # QUESTION Expect that instances are really removed? In that case reset + # is not required. self._remove_instances_from_context(instance_ids) self._on_create_instance_change() @@ -2136,12 +2225,22 @@ class PublisherController(BasePublisherController): self._publish_comment_is_set = True def publish(self): - """Run publishing.""" + """Run publishing. + + Make sure all changes are saved before method is called (Call + 'save_changes' and check output). + """ + self._publish_up_validation = False self._start_publish() def validate(self): - """Run publishing and stop after Validation.""" + """Run publishing and stop after Validation. + + Make sure all changes are saved before method is called (Call + 'save_changes' and check output). + """ + if self.publish_has_validated: return self._publish_up_validation = True @@ -2152,10 +2251,8 @@ class PublisherController(BasePublisherController): if self.publish_is_running: return - # Make sure changes are saved - self.save_changes() - self.publish_is_running = True + self.publish_has_started = True self._emit_event("publish.process.started") diff --git a/openpype/tools/publisher/widgets/__init__.py b/openpype/tools/publisher/widgets/__init__.py index 042985b007..f18e6cc61e 100644 --- a/openpype/tools/publisher/widgets/__init__.py +++ b/openpype/tools/publisher/widgets/__init__.py @@ -4,8 +4,9 @@ from .icons import ( get_icon ) from .widgets import ( - StopBtn, + SaveBtn, ResetBtn, + StopBtn, ValidateBtn, PublishBtn, CreateNextPageOverlay, @@ -25,8 +26,9 @@ __all__ = ( "get_pixmap", "get_icon", - "StopBtn", + "SaveBtn", "ResetBtn", + "StopBtn", "ValidateBtn", "PublishBtn", "CreateNextPageOverlay", diff --git a/openpype/tools/publisher/widgets/card_view_widgets.py b/openpype/tools/publisher/widgets/card_view_widgets.py index 3fd5243ce9..0734e1bc27 100644 --- a/openpype/tools/publisher/widgets/card_view_widgets.py +++ b/openpype/tools/publisher/widgets/card_view_widgets.py @@ -164,6 +164,11 @@ class BaseGroupWidget(QtWidgets.QWidget): def _on_widget_selection(self, instance_id, group_id, selection_type): self.selected.emit(instance_id, group_id, selection_type) + def set_active_toggle_enabled(self, enabled): + for widget in self._widgets_by_id.values(): + if isinstance(widget, InstanceCardWidget): + widget.set_active_toggle_enabled(enabled) + class ConvertorItemsGroupWidget(BaseGroupWidget): def update_items(self, items_by_id): @@ -437,6 +442,9 @@ class InstanceCardWidget(CardWidget): self.update_instance_values() + def set_active_toggle_enabled(self, enabled): + self._active_checkbox.setEnabled(enabled) + def set_active(self, new_value): """Set instance as active.""" checkbox_value = self._active_checkbox.isChecked() @@ -551,6 +559,7 @@ class InstanceCardView(AbstractInstanceView): self._context_widget = None self._convertor_items_group = None + self._active_toggle_enabled = True self._widgets_by_group = {} self._ordered_groups = [] @@ -667,6 +676,9 @@ class InstanceCardView(AbstractInstanceView): group_widget.update_instances( instances_by_group[group_name] ) + group_widget.set_active_toggle_enabled( + self._active_toggle_enabled + ) self._update_ordered_group_names() @@ -1091,3 +1103,10 @@ class InstanceCardView(AbstractInstanceView): self._explicitly_selected_groups = selected_groups self._explicitly_selected_instance_ids = selected_instances + + def set_active_toggle_enabled(self, enabled): + if self._active_toggle_enabled is enabled: + return + self._active_toggle_enabled = enabled + for group_widget in self._widgets_by_group.values(): + group_widget.set_active_toggle_enabled(enabled) diff --git a/openpype/tools/publisher/widgets/images/save.png b/openpype/tools/publisher/widgets/images/save.png new file mode 100644 index 0000000000000000000000000000000000000000..0db48d74ae2660e8766fc2bcd30ed794fd067068 GIT binary patch literal 3961 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 0: dialog_x -= diff @@ -549,6 +633,14 @@ class PublisherWindow(QtWidgets.QDialog): def _on_create_request(self): self._go_to_create_tab() + def _on_convert_requested(self): + if not self._save_changes(False): + return + convertor_identifiers = ( + self._overview_widget.get_selected_legacy_convertors() + ) + self._controller.trigger_convertor_items(convertor_identifiers) + def _set_current_tab(self, identifier): self._tabs_widget.set_current_tab(identifier) @@ -599,8 +691,10 @@ class PublisherWindow(QtWidgets.QDialog): self._publish_frame.setVisible(visible) self._update_publish_frame_rect() + def _on_save_clicked(self): + self._save_changes(True) + def _on_reset_clicked(self): - self.save_changes() self.reset() def _on_stop_clicked(self): @@ -610,14 +704,17 @@ class PublisherWindow(QtWidgets.QDialog): self._controller.set_comment(self._comment_input.text()) def _on_validate_clicked(self): - self._set_publish_comment() - self._controller.validate() + if self._save_changes(False): + self._set_publish_comment() + self._controller.validate() def _on_publish_clicked(self): - self._set_publish_comment() - self._controller.publish() + if self._save_changes(False): + self._set_publish_comment() + self._controller.publish() def _set_footer_enabled(self, enabled): + self._save_btn.setEnabled(True) self._reset_btn.setEnabled(True) if enabled: self._stop_btn.setEnabled(False) diff --git a/openpype/tools/traypublisher/window.py b/openpype/tools/traypublisher/window.py index 3007fa66a5..3ac1b4c4ad 100644 --- a/openpype/tools/traypublisher/window.py +++ b/openpype/tools/traypublisher/window.py @@ -247,7 +247,7 @@ class TrayPublishWindow(PublisherWindow): def _on_project_select(self, project_name): # TODO register project specific plugin paths - self._controller.save_changes() + self._controller.save_changes(False) self._controller.reset_project_data_cache() self.reset()