From 6644f4c5f8e11b3d217c2c3d019536f69b8a4a60 Mon Sep 17 00:00:00 2001 From: Jiri Sindelar Date: Thu, 13 Jul 2023 13:15:23 +0200 Subject: [PATCH 001/107] Autosave preferences can be read after Nuke opens the script --- openpype/hosts/nuke/api/workio.py | 34 ++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/nuke/api/workio.py b/openpype/hosts/nuke/api/workio.py index 8d29e0441f..50bd9d6dec 100644 --- a/openpype/hosts/nuke/api/workio.py +++ b/openpype/hosts/nuke/api/workio.py @@ -1,6 +1,7 @@ """Host API required Work Files tool""" import os import nuke +import shutil from .utils import is_headless @@ -21,21 +22,40 @@ def save_file(filepath): def open_file(filepath): + + def read_script(nuke_script): + nuke.scriptClear() + if int(nuke.NUKE_VERSION_MAJOR) > 12: + nuke.scriptReadFile(nuke_script) + else: + nuke.scriptOpen(nuke_script) + nuke.Root()["name"].setValue(nuke_script) + nuke.Root()["project_directory"].setValue(os.path.dirname(nuke_script)) + nuke.Root().setModified(False) + filepath = filepath.replace("\\", "/") # To remain in the same window, we have to clear the script and read # in the contents of the workfile. - nuke.scriptClear() + # Nuke Preferences can be read after the script is read. + read_script(filepath) + if not is_headless(): autosave = nuke.toNode("preferences")["AutoSaveName"].evaluate() - autosave_prmpt = "Autosave detected.\nWould you like to load the autosave file?" # noqa + autosave_prmpt = "Autosave detected.\n" \ + "Would you like to load the autosave file?" # noqa if os.path.isfile(autosave) and nuke.ask(autosave_prmpt): - filepath = autosave + try: + # Overwrite the filepath with autosave + shutil.copy(autosave, filepath) + # Now read the (auto-saved) script again + read_script(filepath) + except shutil.Error as err: + nuke.message( + "Detected autosave file could not be used.\n{}" + + .format(err)) - nuke.scriptReadFile(filepath) - nuke.Root()["name"].setValue(filepath) - nuke.Root()["project_directory"].setValue(os.path.dirname(filepath)) - nuke.Root().setModified(False) return True From c3e20024bf65a2bff967043635a96444a9726432 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Thu, 13 Jul 2023 15:55:39 +0200 Subject: [PATCH 002/107] Copy publish attributes from review instance to any attached instances --- openpype/hosts/maya/plugins/publish/collect_review.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/collect_review.py b/openpype/hosts/maya/plugins/publish/collect_review.py index 6cb10f9066..fa00fc661e 100644 --- a/openpype/hosts/maya/plugins/publish/collect_review.py +++ b/openpype/hosts/maya/plugins/publish/collect_review.py @@ -107,6 +107,11 @@ class CollectReview(pyblish.api.InstancePlugin): data["displayLights"] = display_lights data["burninDataMembers"] = burninDataMembers + publish_attributes = data.setdefault("publish_attributes", {}) + for key, value in instance.data["publish_attributes"].items(): + if key not in publish_attributes: + publish_attributes[key] = value + # The review instance must be active cmds.setAttr(str(instance) + '.active', 1) From 103c8cd56e580bafd4f59813d0086153a487fbef Mon Sep 17 00:00:00 2001 From: Jiri Sindelar Date: Fri, 14 Jul 2023 13:37:28 +0200 Subject: [PATCH 003/107] Read pixel aspect from input --- .../plugins/publish/extract_slate_frame.py | 2 +- .../plugins/publish/extract_review_slate.py | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py index 06c086b10d..54c88717c5 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py +++ b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py @@ -242,7 +242,7 @@ class ExtractSlateFrame(publish.Extractor): # render slate as sequence frame nuke.execute( - instance.data["name"], + str(instance.data["name"]), int(slate_first_frame), int(slate_first_frame) ) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index fca3d96ca6..75c501a85c 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -86,8 +86,11 @@ class ExtractReviewSlate(publish.Extractor): input_width, input_height, input_timecode, - input_frame_rate + input_frame_rate, + input_pixel_aspect ) = self._get_video_metadata(streams) + if input_pixel_aspect: + pixel_aspect = input_pixel_aspect # Raise exception of any stream didn't define input resolution if input_width is None: @@ -421,6 +424,7 @@ class ExtractReviewSlate(publish.Extractor): input_width = None input_height = None input_frame_rate = None + input_pixel_aspect = None for stream in streams: if stream.get("codec_type") != "video": continue @@ -438,6 +442,16 @@ class ExtractReviewSlate(publish.Extractor): input_width = width input_height = height + input_pixel_aspect = str(stream.get("sample_aspect_ratio")) + if input_pixel_aspect is not None: + try: + input_pixel_aspect = float( + eval(input_pixel_aspect.replace(':', '/'))) + except Exception: + self.log.debug( + "__Converting pixel aspect to float failed: {}".format( + input_pixel_aspect)) + tags = stream.get("tags") or {} input_timecode = tags.get("timecode") or "" @@ -448,7 +462,8 @@ class ExtractReviewSlate(publish.Extractor): input_width, input_height, input_timecode, - input_frame_rate + input_frame_rate, + input_pixel_aspect ) def _get_audio_metadata(self, streams): From 916e9cfa974c79f53e161f5d3c681855b42e210f Mon Sep 17 00:00:00 2001 From: Jiri Sindelar Date: Fri, 14 Jul 2023 15:41:39 +0200 Subject: [PATCH 004/107] Allow exporting with no timecode knob --- openpype/hosts/nuke/api/plugin.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 7035da2bb5..4755fa8c56 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -955,7 +955,11 @@ class ExporterReviewMov(ExporterReview): except Exception: self.log.info("`mov64_codec` knob was not found") - write_node["mov64_write_timecode"].setValue(1) + try: + write_node["mov64_write_timecode"].setValue(1) + except Exception: + self.log.info("`mov64_write_timecode` knob was not found") + write_node["raw"].setValue(1) # connect write_node.setInput(0, self.previous_node) From 3e5aa1033b654e87b47ed52f1663ef31bc7edc67 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 17 Jul 2023 17:19:51 +0200 Subject: [PATCH 005/107] OP-4845 - created new AYON_* env var to differentiate Deadline jobs OP and Ayon will live together for a while so jobs sent to DL need to be differentiated by new env vars. --- .../modules/deadline/abstract_submit_deadline.py | 9 +++++++++ .../plugins/publish/submit_aftereffects_deadline.py | 4 ++-- .../plugins/publish/submit_harmony_deadline.py | 4 ++-- .../publish/submit_houdini_render_deadline.py | 4 ++-- .../deadline/plugins/publish/submit_max_deadline.py | 4 ++-- .../deadline/plugins/publish/submit_maya_deadline.py | 4 ++-- .../publish/submit_maya_remote_publish_deadline.py | 7 ++++++- .../deadline/plugins/publish/submit_nuke_deadline.py | 7 +++++-- .../deadline/plugins/publish/submit_publish_job.py | 12 +++++++++--- .../repository/custom/plugins/GlobalJobPreLoad.py | 2 +- 10 files changed, 40 insertions(+), 17 deletions(-) diff --git a/openpype/modules/deadline/abstract_submit_deadline.py b/openpype/modules/deadline/abstract_submit_deadline.py index 551a2f7373..85b537360c 100644 --- a/openpype/modules/deadline/abstract_submit_deadline.py +++ b/openpype/modules/deadline/abstract_submit_deadline.py @@ -394,6 +394,15 @@ class DeadlineJobInfo(object): for key, value in data.items(): setattr(self, key, value) + def add_render_job_env_var(self): + """Check if in OP or AYON mode and use appropriate env var.""" + render_job = ( + "AYON_RENDER_JOB" if os.environ.get("USE_AYON_SERVER") == '1' + else "OPENPYPE_RENDER_JOB") + + self.EnvironmentKeyValue[render_job] = "1" + + @six.add_metaclass(AbstractMetaInstancePlugin) class AbstractSubmitDeadline(pyblish.api.InstancePlugin, diff --git a/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py b/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py index 83dd5b49e2..009375e87e 100644 --- a/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py @@ -106,8 +106,8 @@ class AfterEffectsSubmitDeadline( if value: dln_job_info.EnvironmentKeyValue[key] = value - # to recognize job from PYPE for turning Event On/Off - dln_job_info.EnvironmentKeyValue["OPENPYPE_RENDER_JOB"] = "1" + # to recognize render jobs + dln_job_info.add_render_job_env_var() return dln_job_info diff --git a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py index 84fca11d9d..2c37268f04 100644 --- a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py @@ -299,8 +299,8 @@ class HarmonySubmitDeadline( if value: job_info.EnvironmentKeyValue[key] = value - # to recognize job from PYPE for turning Event On/Off - job_info.EnvironmentKeyValue["OPENPYPE_RENDER_JOB"] = "1" + # to recognize render jobs + job_info.add_render_job_env_var() return job_info diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py index af341ca8e8..8c814bec95 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py @@ -105,8 +105,8 @@ class HoudiniSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): if value: job_info.EnvironmentKeyValue[key] = value - # to recognize job from PYPE for turning Event On/Off - job_info.EnvironmentKeyValue["OPENPYPE_RENDER_JOB"] = "1" + # to recognize render jobs + job_info.add_render_job_env_var(job_info) for i, filepath in enumerate(instance.data["files"]): dirname = os.path.dirname(filepath) diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index fff7a4ced5..2c1db1c880 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -131,8 +131,8 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, continue job_info.EnvironmentKeyValue[key] = value - # to recognize job from PYPE for turning Event On/Off - job_info.EnvironmentKeyValue["OPENPYPE_RENDER_JOB"] = "1" + # to recognize render jobs + job_info.add_render_job_env_var(job_info) job_info.EnvironmentKeyValue["OPENPYPE_LOG_NO_COLORS"] = "1" # Add list of expected files to job diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 159ac43289..d14daf0823 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -225,8 +225,8 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, continue job_info.EnvironmentKeyValue[key] = value - # to recognize job from PYPE for turning Event On/Off - job_info.EnvironmentKeyValue["OPENPYPE_RENDER_JOB"] = "1" + # to recognize render jobs + job_info.add_render_job_env_var() job_info.EnvironmentKeyValue["OPENPYPE_LOG_NO_COLORS"] = "1" # Adding file dependencies. diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py index 39120f7c8a..d7440fd0f4 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py @@ -114,11 +114,16 @@ class MayaSubmitRemotePublishDeadline( environment["AVALON_TASK"] = instance.context.data["task"] environment["AVALON_APP_NAME"] = os.environ.get("AVALON_APP_NAME") environment["OPENPYPE_LOG_NO_COLORS"] = "1" - environment["OPENPYPE_REMOTE_JOB"] = "1" environment["OPENPYPE_USERNAME"] = instance.context.data["user"] environment["OPENPYPE_PUBLISH_SUBSET"] = instance.data["subset"] environment["OPENPYPE_REMOTE_PUBLISH"] = "1" + if os.environ.get("USE_AYON_SERVER") == '1': + environment["AYON_REMOTE_PUBLISH"] = "1" + else: + environment["OPENPYPE_REMOTE_PUBLISH"] = "1" + + for key, value in environment.items(): job_info.EnvironmentKeyValue[key] = value diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index 4900231783..8f68a3a480 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -337,8 +337,11 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, if _path.lower().startswith('openpype_'): environment[_path] = os.environ[_path] - # to recognize job from PYPE for turning Event On/Off - environment["OPENPYPE_RENDER_JOB"] = "1" + # to recognize render jobs + render_job_label = ( + "AYON_RENDER_JOB" if os.environ.get("USE_AYON_SERVER") == '1' + else "OPENPYPE_RENDER_JOB") + environment[render_job_label] = "1" # finally search replace in values of any key if self.env_search_replace_values: diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 01a5c55286..161cf25cde 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -255,13 +255,19 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "AVALON_ASSET": instance.context.data["asset"], "AVALON_TASK": instance.context.data["task"], "OPENPYPE_USERNAME": instance.context.data["user"], - "OPENPYPE_PUBLISH_JOB": "1", - "OPENPYPE_RENDER_JOB": "0", - "OPENPYPE_REMOTE_JOB": "0", "OPENPYPE_LOG_NO_COLORS": "1", "IS_TEST": str(int(is_in_tests())) } + if os.environ.get("USE_AYON_SERVER") == '1': + environment["AYON_PUBLISH_JOB"] = "1" + environment["AYON_RENDER_JOB"] = "0" + environment["AYON_REMOTE_PUBLISH"] = "0" + else: + environment["OPENPYPE_PUBLISH_JOB"] = "1" + environment["OPENPYPE_RENDER_JOB"] = "0" + environment["OPENPYPE_REMOTE_PUBLISH"] = "0" + # add environments from self.environ_keys for env_key in self.environ_keys: if os.getenv(env_key): diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index 15226bb773..d69aa12b5a 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -422,7 +422,7 @@ def __main__(deadlinePlugin): openpype_publish_job = \ job.GetJobEnvironmentKeyValue('OPENPYPE_PUBLISH_JOB') or '0' openpype_remote_job = \ - job.GetJobEnvironmentKeyValue('OPENPYPE_REMOTE_JOB') or '0' + job.GetJobEnvironmentKeyValue('OPENPYPE_REMOTE_PUBLISH') or '0' print("--- Job type - render {}".format(openpype_render_job)) print("--- Job type - publish {}".format(openpype_publish_job)) From a40e64ee0bb406fc4d6e0184da7e830d98ea3dd7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 17 Jul 2023 17:20:38 +0200 Subject: [PATCH 006/107] OP-4845 - added Ayon DL plugin --- .../repository/custom/plugins/Ayon/Ayon.ico | Bin 0 -> 7679 bytes .../custom/plugins/Ayon/Ayon.options | 9 + .../repository/custom/plugins/Ayon/Ayon.param | 17 ++ .../repository/custom/plugins/Ayon/Ayon.py | 235 ++++++++++++++++++ 4 files changed, 261 insertions(+) create mode 100644 openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.ico create mode 100644 openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.options create mode 100644 openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.param create mode 100644 openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py diff --git a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.ico b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.ico new file mode 100644 index 0000000000000000000000000000000000000000..aea977a1251232d0f3d78ea6cb124994f4d31eb4 GIT binary patch literal 7679 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 dg=}F#T++JjmkP-0+ukjp@", str(self.GetStartFrame()), + arguments) + arguments = re.sub(r"<(?i)ENDFRAME>", str(self.GetEndFrame()), + arguments) + arguments = re.sub(r"<(?i)QUOTE>", "\"", arguments) + + arguments = self.ReplacePaddedFrame(arguments, + "<(?i)STARTFRAME%([0-9]+)>", + self.GetStartFrame()) + arguments = self.ReplacePaddedFrame(arguments, + "<(?i)ENDFRAME%([0-9]+)>", + self.GetEndFrame()) + + count = 0 + for filename in self.GetAuxiliaryFilenames(): + localAuxFile = Path.Combine(self.GetJobsDataDirectory(), filename) + arguments = re.sub(r"<(?i)AUXFILE" + str(count) + r">", + localAuxFile.replace("\\", "/"), arguments) + count += 1 + + return arguments + + def ReplacePaddedFrame(self, arguments, pattern, frame): + frameRegex = Regex(pattern) + while True: + frameMatch = frameRegex.Match(arguments) + if frameMatch.Success: + paddingSize = int(frameMatch.Groups[1].Value) + if paddingSize > 0: + padding = StringUtils.ToZeroPaddedString(frame, + paddingSize, + False) + else: + padding = str(frame) + arguments = arguments.replace(frameMatch.Groups[0].Value, + padding) + else: + break + + return arguments + + def HandleProgress(self): + progress = float(self.GetRegexMatch(1)) + self.SetProgress(progress) From 86c681df83e55127dd507a4f01964b0c1b7aa35c Mon Sep 17 00:00:00 2001 From: Jiri Sindelar Date: Tue, 18 Jul 2023 11:46:36 +0200 Subject: [PATCH 007/107] fix string conversion --- openpype/plugins/publish/extract_review_slate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 75c501a85c..7de3825108 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -442,11 +442,11 @@ class ExtractReviewSlate(publish.Extractor): input_width = width input_height = height - input_pixel_aspect = str(stream.get("sample_aspect_ratio")) + input_pixel_aspect = stream.get("sample_aspect_ratio") if input_pixel_aspect is not None: try: input_pixel_aspect = float( - eval(input_pixel_aspect.replace(':', '/'))) + eval(str(input_pixel_aspect).replace(':', '/'))) except Exception: self.log.debug( "__Converting pixel aspect to float failed: {}".format( From f2e4607434d283f3e4769495b369839813df28b4 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 18 Jul 2023 16:03:55 +0200 Subject: [PATCH 008/107] OP-4845 - add bundle name as a job env var Ayon must have AYON_BUNDLE_NAME to get proper env variables and addon used. --- .../deadline/abstract_submit_deadline.py | 102 ++---------------- 1 file changed, 11 insertions(+), 91 deletions(-) diff --git a/openpype/modules/deadline/abstract_submit_deadline.py b/openpype/modules/deadline/abstract_submit_deadline.py index 85b537360c..9fcff111e6 100644 --- a/openpype/modules/deadline/abstract_submit_deadline.py +++ b/openpype/modules/deadline/abstract_submit_deadline.py @@ -22,6 +22,9 @@ from openpype.pipeline.publish import ( KnownPublishError, OpenPypePyblishPluginMixin ) +from openpype.pipeline.publish.lib import ( + replace_with_published_scene_path +) JSONDecodeError = getattr(json.decoder, "JSONDecodeError", ValueError) @@ -396,12 +399,12 @@ class DeadlineJobInfo(object): def add_render_job_env_var(self): """Check if in OP or AYON mode and use appropriate env var.""" - render_job = ( - "AYON_RENDER_JOB" if os.environ.get("USE_AYON_SERVER") == '1' - else "OPENPYPE_RENDER_JOB") - - self.EnvironmentKeyValue[render_job] = "1" - + if os.environ.get("USE_AYON_SERVER") == '1': + self.EnvironmentKeyValue["AYON_RENDER_JOB"] = "1" + self.EnvironmentKeyValue["AYON_BUNDLE_NAME"] = ( + os.environ["AYON_BUNDLE_NAME"]) + else: + self.EnvironmentKeyValue["OPENPYPE_RENDER_JOB"] = "1" @six.add_metaclass(AbstractMetaInstancePlugin) @@ -534,72 +537,8 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin, published. """ - instance = self._instance - workfile_instance = self._get_workfile_instance(instance.context) - if workfile_instance is None: - return - - # determine published path from Anatomy. - template_data = workfile_instance.data.get("anatomyData") - rep = workfile_instance.data["representations"][0] - template_data["representation"] = rep.get("name") - template_data["ext"] = rep.get("ext") - template_data["comment"] = None - - anatomy = instance.context.data['anatomy'] - template_obj = anatomy.templates_obj["publish"]["path"] - template_filled = template_obj.format_strict(template_data) - file_path = os.path.normpath(template_filled) - - self.log.info("Using published scene for render {}".format(file_path)) - - if not os.path.exists(file_path): - self.log.error("published scene does not exist!") - raise - - if not replace_in_path: - return file_path - - # now we need to switch scene in expected files - # because token will now point to published - # scene file and that might differ from current one - def _clean_name(path): - return os.path.splitext(os.path.basename(path))[0] - - new_scene = _clean_name(file_path) - orig_scene = _clean_name(instance.context.data["currentFile"]) - expected_files = instance.data.get("expectedFiles") - - if isinstance(expected_files[0], dict): - # we have aovs and we need to iterate over them - new_exp = {} - for aov, files in expected_files[0].items(): - replaced_files = [] - for f in files: - replaced_files.append( - str(f).replace(orig_scene, new_scene) - ) - new_exp[aov] = replaced_files - # [] might be too much here, TODO - instance.data["expectedFiles"] = [new_exp] - else: - new_exp = [] - for f in expected_files: - new_exp.append( - str(f).replace(orig_scene, new_scene) - ) - instance.data["expectedFiles"] = new_exp - - metadata_folder = instance.data.get("publishRenderMetadataFolder") - if metadata_folder: - metadata_folder = metadata_folder.replace(orig_scene, - new_scene) - instance.data["publishRenderMetadataFolder"] = metadata_folder - self.log.info("Scene name was switched {} -> {}".format( - orig_scene, new_scene - )) - - return file_path + return replace_with_published_scene_path( + self._instance, replace_in_path=replace_in_path) def assemble_payload( self, job_info=None, plugin_info=None, aux_files=None): @@ -660,22 +599,3 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin, self._instance.data["deadlineSubmissionJob"] = result return result["_id"] - - @staticmethod - def _get_workfile_instance(context): - """Find workfile instance in context""" - for instance in context: - - is_workfile = ( - "workfile" in instance.data.get("families", []) or - instance.data["family"] == "workfile" - ) - if not is_workfile: - continue - - # test if there is instance of workfile waiting - # to be published. - assert instance.data.get("publish", True) is True, ( - "Workfile (scene) must be published along") - - return instance From a612956dd1e1d21dbfedc279ac80ce9dbe7c5b31 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Tue, 18 Jul 2023 15:35:01 +0100 Subject: [PATCH 009/107] Update with compatible resolve version and latest docs --- website/docs/admin_hosts_resolve.md | 116 +++++++--------------------- 1 file changed, 27 insertions(+), 89 deletions(-) diff --git a/website/docs/admin_hosts_resolve.md b/website/docs/admin_hosts_resolve.md index 09e7df1d9f..8bb8440f78 100644 --- a/website/docs/admin_hosts_resolve.md +++ b/website/docs/admin_hosts_resolve.md @@ -4,100 +4,38 @@ title: DaVinci Resolve Setup sidebar_label: DaVinci Resolve --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; +:::warning +Only Resolve Studio is supported due to Python API limitation in Resolve (free). +::: ## Resolve requirements Due to the way resolve handles python and python scripts there are a few steps required steps needed to be done on any machine that will be using OpenPype with resolve. -### Installing Resolve's own python 3.6 interpreter. -Resolve uses a hardcoded method to look for the python executable path. All of tho following paths are defined automatically by Python msi installer. We are using Python 3.6.2. +## Basic setup - +- Supported version is up to v18 +- Install Python 3.6.2 (latest tested v17) or up to 3.9.13 (latest tested on v18) +- pip install PySide2: + - Python 3.9.*: open terminal and go to python.exe directory, then `python -m pip install PySide2` +- pip install OpenTimelineIO: + - Python 3.9.*: open terminal and go to python.exe directory, then `python -m pip install OpenTimelineIO` + - Python 3.6: open terminal and go to python.exe directory, then `python -m pip install git+https://github.com/PixarAnimationStudios/OpenTimelineIO.git@5aa24fbe89d615448876948fe4b4900455c9a3e8` and move built files from `./Lib/site-packages/opentimelineio/cxx-libs/bin and lib` to `./Lib/site-packages/opentimelineio/`. I was building it on Win10 machine with Visual Studio Community 2019 and + ![image](https://user-images.githubusercontent.com/40640033/102792588-ffcb1c80-43a8-11eb-9c6b-bf2114ed578e.png) with installed CMake in PATH. +- make sure Resolve Fusion (Fusion Tab/menu/Fusion/Fusion Settings) is set to Python 3.6 + ![image](https://user-images.githubusercontent.com/40640033/102631545-280b0f00-414e-11eb-89fc-98ac268d209d.png) +- Open OpenPype **Tray/Admin/Studio settings** > `applications/resolve/environment` and add Python3 path to `RESOLVE_PYTHON3_HOME` platform related. - +## Editorial setup -`%LOCALAPPDATA%\Programs\Python\Python36` +This is how it looks on my testing project timeline +![image](https://user-images.githubusercontent.com/40640033/102637638-96ec6600-4156-11eb-9656-6e8e3ce4baf8.png) +Notice I had renamed tracks to `main` (holding metadata markers) and `review` used for generating review data with ffmpeg confersion to jpg sequence. - - - -`/opt/Python/3.6/bin` - - - - -`~/Library/Python/3.6/bin` - - - - - -### Installing PySide2 into python 3.6 for correct gui work - -OpenPype is using its own window widget inside Resolve, for that reason PySide2 has to be installed into the python 3.6 (as explained above). - - - - - -paste to any terminal of your choice - -```bash -%LOCALAPPDATA%\Programs\Python\Python36\python.exe -m pip install PySide2 -``` - - - - -paste to any terminal of your choice - -```bash -/opt/Python/3.6/bin/python -m pip install PySide2 -``` - - - - -paste to any terminal of your choice - -```bash -~/Library/Python/3.6/bin/python -m pip install PySide2 -``` - - - - -
- -### Set Resolve's Fusion settings for Python 3.6 interpereter - -
- - -As it is shown in below picture you have to go to Fusion Tab and then in Fusion menu find Fusion Settings. Go to Fusion/Script and find Default Python Version and switch to Python 3.6 - -
- -
- -![Create menu](assets/resolve_fusion_tab.png) -![Create menu](assets/resolve_fusion_menu.png) -![Create menu](assets/resolve_fusion_script_settings.png) - -
-
\ No newline at end of file +1. you need to start OpenPype menu from Resolve/EditTab/Menu/Workspace/Scripts/Comp/**__OpenPype_Menu__** +2. then select any clips in `main` track and change their color to `Chocolate` +3. in OpenPype Menu select `Create` +4. in Creator select `Create Publishable Clip [New]` (temporary name) +5. set `Rename clips` to True, Master Track to `main` and Use review track to `review` as in picture + ![image](https://user-images.githubusercontent.com/40640033/102643773-0d419600-4160-11eb-919e-9c2be0aecab8.png) +6. after you hit `ok` all clips are colored to `ping` and marked with openpype metadata tag +7. git `Publish` on openpype menu and see that all had been collected correctly. That is the last step for now as rest is Work in progress. Next steps will follow. From 7c02c3b9d604acaeb9e05b96df1b991bddaeeb15 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 18 Jul 2023 18:14:05 +0200 Subject: [PATCH 010/107] OP-4845 - add fields for server url and api key --- .../repository/custom/plugins/Ayon/Ayon.param | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.param b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.param index 81df2ecd95..8ba044ff81 100644 --- a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.param +++ b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.param @@ -15,3 +15,21 @@ CategoryOrder=1 Index=0 Default= Description=The path to the Ayon executable. Enter alternative paths on separate lines. + +[AyonServerUrl] +Type=string +Label=Ayon Server Url +Category=Ayon Credentials +CategoryOrder=2 +Index=0 +Default= +Description=Url to Ayon server + +[AyonApiKey] +Type=password +Label=Ayon API key +Category=Ayon Credentials +CategoryOrder=2 +Index=0 +Default= +Description=API key for service account on Ayon Server From 2bc019f6c0c60735840a88a1ef60e8e8697cebb5 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 18 Jul 2023 18:14:41 +0200 Subject: [PATCH 011/107] OP-4845 - try to push OPENPYPE_MONGO to extractenvironment process --- .../deadline/repository/custom/plugins/GlobalJobPreLoad.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index d69aa12b5a..ed06b2b16b 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -355,6 +355,12 @@ def inject_openpype_environment(deadlinePlugin): " AVALON_TASK, AVALON_APP_NAME" )) + openpype_mongo = job.GetJobEnvironmentKeyValue("OPENPYPE_MONGO") + if openpype_mongo: + # inject env var for OP extractenvironments + deadlinePlugin.SetProcessEnvironmentVariable("OPENPYPE_MONGO", + openpype_mongo) + if not os.environ.get("OPENPYPE_MONGO"): print(">>> Missing OPENPYPE_MONGO env var, process won't work") From 098e58ba7b1d818b1baca9244ce6fc096434be21 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 18 Jul 2023 18:15:50 +0200 Subject: [PATCH 012/107] OP-4845 - added Ayon logic For now both OP and Ayon will live together, later OP logic should be made obsolete. --- .../custom/plugins/GlobalJobPreLoad.py | 168 +++++++++++++++++- 1 file changed, 164 insertions(+), 4 deletions(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index ed06b2b16b..4697cce38e 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -404,6 +404,153 @@ def inject_openpype_environment(deadlinePlugin): raise +def inject_ayon_environment(deadlinePlugin): + """ Pull env vars from Ayon and push them to rendering process. + + Used for correct paths, configuration from OpenPype etc. + """ + job = deadlinePlugin.GetJob() + + print(">>> Injecting Ayon environments ...") + try: + exe_list = get_ayon_executable() + exe = FileUtils.SearchFileList(exe_list) + + if not exe: + raise RuntimeError(( + "Ayon executable was not found in the semicolon " + "separated list \"{}\"." + "The path to the render executable can be configured" + " from the Plugin Configuration in the Deadline Monitor." + ).format(";".join(exe_list))) + + print("--- Ayon executable: {}".format(exe)) + + ayon_bundle_name = job.GetJobEnvironmentKeyValue("AYON_BUNDLE_NAME") + if not ayon_bundle_name: + raise RuntimeError("Missing env var in job properties " + "AYON_BUNDLE_NAME") + + config = RepositoryUtils.GetPluginConfig("Ayon") + ayon_server_url = ( + job.GetJobEnvironmentKeyValue("AYON_SERVER_URL") or + config.GetConfigEntryWithDefault("AyonServerUrl", "") + ) + ayon_api_key = ( + job.GetJobEnvironmentKeyValue("AYON_API_KEY") or + config.GetConfigEntryWithDefault("AyonApiKey", "") + ) + + if not all([ayon_server_url, ayon_api_key]): + raise RuntimeError(( + "Missing required values for server url and api key. " + "Please fill in Ayon Deadline plugin or provide by " + "AYON_SERVER_URL and AYON_API_KEY" + )) + + # tempfile.TemporaryFile cannot be used because of locking + temp_file_name = "{}_{}.json".format( + datetime.utcnow().strftime('%Y%m%d%H%M%S%f'), + str(uuid.uuid1()) + ) + export_url = os.path.join(tempfile.gettempdir(), temp_file_name) + print(">>> Temporary path: {}".format(export_url)) + + args = [ + "--headless", + "extractenvironments", + export_url + ] + + add_kwargs = { + "project": job.GetJobEnvironmentKeyValue("AVALON_PROJECT"), + "asset": job.GetJobEnvironmentKeyValue("AVALON_ASSET"), + "task": job.GetJobEnvironmentKeyValue("AVALON_TASK"), + "app": job.GetJobEnvironmentKeyValue("AVALON_APP_NAME"), + "envgroup": "farm", + } + + if job.GetJobEnvironmentKeyValue('IS_TEST'): + args.append("--automatic-tests") + + if all(add_kwargs.values()): + for key, value in add_kwargs.items(): + args.extend(["--{}".format(key), value]) + else: + raise RuntimeError(( + "Missing required env vars: AVALON_PROJECT, AVALON_ASSET," + " AVALON_TASK, AVALON_APP_NAME" + )) + + os.environ["AVALON_TIMEOUT"] = "5000" + + environment = { + "AYON_SERVER_URL": ayon_server_url, + "AYON_API_KEY": ayon_api_key, + "AYON_BUNDLE_NAME": ayon_bundle_name, + } + for env, val in environment.items(): + deadlinePlugin.SetEnvironmentVariable(env, val) + + args_str = subprocess.list2cmdline(args) + print(">>> Executing: {} {}".format(exe, args_str)) + process_exitcode = deadlinePlugin.RunProcess( + exe, args_str, os.path.dirname(exe), -1 + ) + + if process_exitcode != 0: + raise RuntimeError( + "Failed to run Ayon process to extract environments." + ) + + print(">>> Loading file ...") + with open(export_url) as fp: + contents = json.load(fp) + + for key, value in contents.items(): + deadlinePlugin.SetProcessEnvironmentVariable(key, value) + + script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") + if script_url: + script_url = script_url.format(**contents).replace("\\", "/") + print(">>> Setting script path {}".format(script_url)) + job.SetJobPluginInfoKeyValue("ScriptFilename", script_url) + + print(">>> Removing temporary file") + os.remove(export_url) + + print(">> Injection end.") + except Exception as e: + if hasattr(e, "output"): + print(">>> Exception {}".format(e.output)) + import traceback + print(traceback.format_exc()) + print("!!! Injection failed.") + RepositoryUtils.FailJob(job) + raise + + +def get_ayon_executable(): + """Return OpenPype Executable from Event Plug-in Settings + + Returns: + (list) of paths + Raises: + (RuntimeError) if no path configured at all + """ + config = RepositoryUtils.GetPluginConfig("Ayon") + exe_list = config.GetConfigEntryWithDefault("AyonExecutable", "") + + if not exe_list: + raise RuntimeError("Path to Ayon executable not configured." + "Please set it in Ayon Deadline Plugin.") + + # clean '\ ' for MacOS pasting + if platform.system().lower() == "darwin": + exe_list = exe_list.replace("\\ ", " ") + return exe_list + + def inject_render_job_id(deadlinePlugin): """Inject dependency ids to publish process as env var for validation.""" print(">>> Injecting render job id ...") @@ -430,14 +577,27 @@ def __main__(deadlinePlugin): openpype_remote_job = \ job.GetJobEnvironmentKeyValue('OPENPYPE_REMOTE_PUBLISH') or '0' - print("--- Job type - render {}".format(openpype_render_job)) - print("--- Job type - publish {}".format(openpype_publish_job)) - print("--- Job type - remote {}".format(openpype_remote_job)) if openpype_publish_job == '1' and openpype_render_job == '1': raise RuntimeError("Misconfiguration. Job couldn't be both " + "render and publish.") if openpype_publish_job == '1': inject_render_job_id(deadlinePlugin) - elif openpype_render_job == '1' or openpype_remote_job == '1': + if openpype_render_job == '1' or openpype_remote_job == '1': inject_openpype_environment(deadlinePlugin) + + ayon_render_job = \ + job.GetJobEnvironmentKeyValue('AYON_RENDER_JOB') or '0' + ayon_publish_job = \ + job.GetJobEnvironmentKeyValue('AYON_PUBLISH_JOB') or '0' + ayon_remote_job = \ + job.GetJobEnvironmentKeyValue('AYON_REMOTE_PUBLISH') or '0' + + if ayon_publish_job == '1' and ayon_render_job == '1': + raise RuntimeError("Misconfiguration. Job couldn't be both " + + "render and publish.") + + if ayon_publish_job == '1': + inject_render_job_id(deadlinePlugin) + if ayon_render_job == '1' or ayon_remote_job == '1': + inject_ayon_environment(deadlinePlugin) From 31f2ff680aeb5775034ddee2d274541a55be2605 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 18 Jul 2023 19:37:03 +0200 Subject: [PATCH 013/107] OP-4845 - fix passing correct values to Ayon publish job --- .../deadline/plugins/publish/submit_publish_job.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 1b51c8efd1..54236d3cc2 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -94,7 +94,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, label = "Submit image sequence jobs to Deadline or Muster" order = pyblish.api.IntegratorOrder + 0.2 icon = "tractor" - deadline_plugin = "OpenPype" + targets = ["local"] hosts = ["fusion", "max", "maya", "nuke", "houdini", @@ -126,10 +126,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "OPENPYPE_SG_USER" ] - # Add OpenPype version if we are running from build. - if is_running_from_build(): - environ_keys.append("OPENPYPE_VERSION") - # custom deadline attributes deadline_department = "" deadline_pool = "" @@ -211,10 +207,16 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, environment["AYON_PUBLISH_JOB"] = "1" environment["AYON_RENDER_JOB"] = "0" environment["AYON_REMOTE_PUBLISH"] = "0" + environment["AYON_BUNDLE_NAME"] = os.environ["AYON_BUNDLE_NAME"] + deadline_plugin = "Ayon" else: environment["OPENPYPE_PUBLISH_JOB"] = "1" environment["OPENPYPE_RENDER_JOB"] = "0" environment["OPENPYPE_REMOTE_PUBLISH"] = "0" + deadline_plugin = "Openpype" + # Add OpenPype version if we are running from build. + if is_running_from_build(): + self.environ_keys.append("OPENPYPE_VERSION") # add environments from self.environ_keys for env_key in self.environ_keys: @@ -258,7 +260,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, ) payload = { "JobInfo": { - "Plugin": self.deadline_plugin, + "Plugin": deadline_plugin, "BatchName": job["Props"]["Batch"], "Name": job_name, "UserName": job["Props"]["User"], From bf831778d8cb2aa1f634911199c06a7a5c360c38 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 18 Jul 2023 19:38:08 +0200 Subject: [PATCH 014/107] OP-4845 - temporary fix for missing Ayon template Not sure if it was decided that Ayon won't have default 'render' template as OP does, but this should workaround it for testing. Needs to be fixed! --- .../deadline/plugins/publish/submit_publish_job.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 54236d3cc2..f912be1abe 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -575,7 +575,13 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, template_data["family"] = family template_data["version"] = version - render_templates = anatomy.templates_obj["render"] + # temporary fix, Ayon Settings don't have 'render' template, but they + # have "publish" TODO!!! + template_name = "render" + if os.environ.get("USE_AYON_SERVER") == '1': + template_name = "publish" + + render_templates = anatomy.templates_obj[template_name] if "folder" in render_templates: publish_folder = render_templates["folder"].format_strict( template_data From 87700f72dccc836376b036b1dc4aef9191d6983a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 18 Jul 2023 19:48:03 +0200 Subject: [PATCH 015/107] OP-4845 - injecting required env vars for ayon_console Renamed OP to Ayon --- .../repository/custom/plugins/Ayon/Ayon.py | 136 ++++-------------- 1 file changed, 26 insertions(+), 110 deletions(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py index c29f7ca4e2..ae7aa7df75 100644 --- a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py +++ b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py @@ -21,18 +21,18 @@ import platform # main DeadlinePlugin class. ###################################################################### def GetDeadlinePlugin(): - return OpenPypeDeadlinePlugin() + return AyonDeadlinePlugin() def CleanupDeadlinePlugin(deadlinePlugin): deadlinePlugin.Cleanup() -class OpenPypeDeadlinePlugin(DeadlinePlugin): +class AyonDeadlinePlugin(DeadlinePlugin): """ - Standalone plugin for publishing from OpenPype. + Standalone plugin for publishing from Ayon - Calls OpenPype executable 'openpype_console' from first correctly found + Calls Ayonexecutable 'ayon_console' from first correctly found file based on plugin configuration. Uses 'publish' command and passes path to metadata json file, which contains all needed information for publish process. @@ -61,124 +61,40 @@ class OpenPypeDeadlinePlugin(DeadlinePlugin): self.AddStdoutHandlerCallback( ".*Progress: (\d+)%.*").HandleCallback += self.HandleProgress - @staticmethod - def get_openpype_version_from_path(path, build=True): - """Get OpenPype version from provided path. - path (str): Path to scan. - build (bool, optional): Get only builds, not sources - - Returns: - str or None: version of OpenPype if found. - - """ - # fix path for application bundle on macos - if platform.system().lower() == "darwin": - path = os.path.join(path, "MacOS") - - version_file = os.path.join(path, "openpype", "version.py") - if not os.path.isfile(version_file): - return None - - # skip if the version is not build - exe = os.path.join(path, "openpype_console.exe") - if platform.system().lower() in ["linux", "darwin"]: - exe = os.path.join(path, "openpype_console") - - # if only builds are requested - if build and not os.path.isfile(exe): # noqa: E501 - print(f" ! path is not a build: {path}") - return None - - version = {} - with open(version_file, "r") as vf: - exec(vf.read(), version) - - version_match = re.search(r"(\d+\.\d+.\d+).*", version["__version__"]) - return version_match[1] - def RenderExecutable(self): job = self.GetJob() - openpype_versions = [] - # if the job requires specific OpenPype version, - # lets go over all available and find compatible build. - requested_version = job.GetJobEnvironmentKeyValue("OPENPYPE_VERSION") - if requested_version: - self.LogInfo(( - "Scanning for compatible requested " - f"version {requested_version}")) - dir_list = self.GetConfigEntry("OpenPypeInstallationDirs") - # clean '\ ' for MacOS pasting - if platform.system().lower() == "darwin": - dir_list = dir_list.replace("\\ ", " ") + # set required env vars for Ayon + # cannot be in InitializeProcess as it is too soon + config = RepositoryUtils.GetPluginConfig("Ayon") + ayon_server_url = ( + job.GetJobEnvironmentKeyValue("AYON_SERVER_URL") or + config.GetConfigEntryWithDefault("AyonServerUrl", "") + ) + ayon_api_key = ( + job.GetJobEnvironmentKeyValue("AYON_API_KEY") or + config.GetConfigEntryWithDefault("AyonApiKey", "") + ) + ayon_bundle_name = job.GetJobEnvironmentKeyValue("AYON_BUNDLE_NAME") - for dir_list in dir_list.split(","): - install_dir = DirectoryUtils.SearchDirectoryList(dir_list) - if install_dir: - sub_dirs = [ - f.path for f in os.scandir(install_dir) - if f.is_dir() - ] - for subdir in sub_dirs: - version = self.get_openpype_version_from_path(subdir) - if not version: - continue - openpype_versions.append((version, subdir)) + environment = { + "AYON_SERVER_URL": ayon_server_url, + "AYON_API_KEY": ayon_api_key, + "AYON_BUNDLE_NAME": ayon_bundle_name, + } - exe_list = self.GetConfigEntry("OpenPypeExecutable") + for env, val in environment.items(): + self.SetProcessEnvironmentVariable(env, val) + + exe_list = self.GetConfigEntry("AyonExecutable") # clean '\ ' for MacOS pasting if platform.system().lower() == "darwin": exe_list = exe_list.replace("\\ ", " ") exe = FileUtils.SearchFileList(exe_list) - if openpype_versions: - # if looking for requested compatible version, - # add the implicitly specified to the list too. - version = self.get_openpype_version_from_path( - os.path.dirname(exe)) - if version: - openpype_versions.append((version, os.path.dirname(exe))) - - if requested_version: - # sort detected versions - if openpype_versions: - openpype_versions.sort( - key=lambda ver: [ - int(t) if t.isdigit() else t.lower() - for t in re.split(r"(\d+)", ver[0]) - ]) - requested_major, requested_minor, _ = requested_version.split(".")[:3] # noqa: E501 - compatible_versions = [] - for version in openpype_versions: - v = version[0].split(".")[:3] - if v[0] == requested_major and v[1] == requested_minor: - compatible_versions.append(version) - if not compatible_versions: - self.FailRender(("Cannot find compatible version available " - "for version {} requested by the job. " - "Please add it through plugin configuration " - "in Deadline or install it to configured " - "directory.").format(requested_version)) - # sort compatible versions nad pick the last one - compatible_versions.sort( - key=lambda ver: [ - int(t) if t.isdigit() else t.lower() - for t in re.split(r"(\d+)", ver[0]) - ]) - # create list of executables for different platform and let - # Deadline decide. - exe_list = [ - os.path.join( - compatible_versions[-1][1], "openpype_console.exe"), - os.path.join( - compatible_versions[-1][1], "openpype_console"), - os.path.join( - compatible_versions[-1][1], "MacOS", "openpype_console") - ] - exe = FileUtils.SearchFileList(";".join(exe_list)) if exe == "": self.FailRender( - "OpenPype executable was not found " + + "Ayon executable was not found " + "in the semicolon separated list " + "\"" + ";".join(exe_list) + "\". " + "The path to the render executable can be configured " + From d1f6e664ab565598a8483fde544c2b20ad83ca9e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 18 Jul 2023 19:51:46 +0200 Subject: [PATCH 016/107] OP-4845 - updated injection of ayon env var It seems that SetEnvironmentVariable is required instead of SetProcessEnvironmentVariable. (In Ayon Deadline plugin it is opposite..probably because of Deadline... --- .../deadline/repository/custom/plugins/GlobalJobPreLoad.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index 4697cce38e..f3e49efefd 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -358,8 +358,9 @@ def inject_openpype_environment(deadlinePlugin): openpype_mongo = job.GetJobEnvironmentKeyValue("OPENPYPE_MONGO") if openpype_mongo: # inject env var for OP extractenvironments - deadlinePlugin.SetProcessEnvironmentVariable("OPENPYPE_MONGO", - openpype_mongo) + # SetEnvironmentVariable is important, not SetProcessEnv... + deadlinePlugin.SetEnvironmentVariable("OPENPYPE_MONGO", + openpype_mongo) if not os.environ.get("OPENPYPE_MONGO"): print(">>> Missing OPENPYPE_MONGO env var, process won't work") From 7eea0cab973ca7b0920c6e9ab537f439205bd556 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 20 Jul 2023 18:23:43 +0100 Subject: [PATCH 017/107] Order instances for processing --- openpype/hosts/nuke/api/pipeline.py | 29 ++++++++++++++++--- .../plugins/create/create_write_prerender.py | 5 ++++ .../plugins/create/create_write_render.py | 5 ++++ openpype/pipeline/create/context.py | 2 +- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index cdfc8aa512..fcc3becd2d 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -2,7 +2,7 @@ import nuke import os import importlib -from collections import OrderedDict +from collections import OrderedDict, defaultdict import pyblish.api @@ -537,7 +537,8 @@ def list_instances(creator_id=None): Returns: (list) of dictionaries matching instances format """ - listed_instances = [] + instances_by_order = defaultdict(list) + subset_instances = [] for node in nuke.allNodes(recurseGroups=True): if node.Class() in ["Viewer", "Dot"]: @@ -563,9 +564,29 @@ def list_instances(creator_id=None): if creator_id and instance_data["creator_identifier"] != creator_id: continue - listed_instances.append((node, instance_data)) + if "render_order" not in node.knobs(): + subset_instances.append((node, instance_data)) + continue - return listed_instances + order = int(node["render_order"].value()) + instances_by_order[order].append((node, instance_data)) + + # Sort instances based on order attribute or subset name. + ordered_instances = [] + for key in sorted(instances_by_order.keys()): + instances_by_subset = {} + for node, data in instances_by_order[key]: + instances_by_subset[data["subset"]] = (node, data) + for subkey in sorted(instances_by_subset.keys()): + ordered_instances.append(instances_by_subset[subkey]) + + instances_by_subset = {} + for node, data in subset_instances: + instances_by_subset[data["subset"]] = (node, data) + for key in sorted(instances_by_subset.keys()): + ordered_instances.append(instances_by_subset[key]) + + return ordered_instances def remove_instance(instance): diff --git a/openpype/hosts/nuke/plugins/create/create_write_prerender.py b/openpype/hosts/nuke/plugins/create/create_write_prerender.py index f46dd2d6d5..c3bba5f477 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_prerender.py +++ b/openpype/hosts/nuke/plugins/create/create_write_prerender.py @@ -30,6 +30,9 @@ class CreateWritePrerender(napi.NukeWriteCreator): temp_rendering_path_template = ( "{work}/renders/nuke/{subset}/{subset}.{frame}.{ext}") + # Before write node render. + order = 90 + def get_pre_create_attr_defs(self): attr_defs = [ BoolDef( @@ -46,6 +49,8 @@ class CreateWritePrerender(napi.NukeWriteCreator): if "use_range_limit" in self.instance_attributes: linked_knobs_ = ["channels", "___", "first", "last", "use_limit"] + linked_knobs_.append("render_order") + # add fpath_template write_data = { "creator": self.__class__.__name__, diff --git a/openpype/hosts/nuke/plugins/create/create_write_render.py b/openpype/hosts/nuke/plugins/create/create_write_render.py index c24405873a..aef4b06a2c 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_render.py +++ b/openpype/hosts/nuke/plugins/create/create_write_render.py @@ -39,6 +39,10 @@ class CreateWriteRender(napi.NukeWriteCreator): return attr_defs def create_instance_node(self, subset_name, instance_data): + linked_knobs_ = [ + "channels", "___", "first", "last", "use_limit", "render_order" + ] + # add fpath_template write_data = { "creator": self.__class__.__name__, @@ -61,6 +65,7 @@ class CreateWriteRender(napi.NukeWriteCreator): write_data, input=self.selected_node, prenodes=self.prenodes, + linked_knobs=linked_knobs_, **{ "width": width, "height": height diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 98fcee5fe5..6bdf7bb719 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -2121,7 +2121,7 @@ class CreateContext: def reset_instances(self): """Reload instances""" - self._instances_by_id = {} + self._instances_by_id = collections.OrderedDict() # Collect instances error_message = "Collection of instances for creator {} failed. {}" From 67cd2ff6506e2065c64c88f6848b84a465d9305b Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Fri, 21 Jul 2023 11:07:04 +0100 Subject: [PATCH 018/107] Fix fetching top level parents --- .../plugins/publish/validate_model_content.py | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_model_content.py b/openpype/hosts/maya/plugins/publish/validate_model_content.py index 9ba458a416..19373efad9 100644 --- a/openpype/hosts/maya/plugins/publish/validate_model_content.py +++ b/openpype/hosts/maya/plugins/publish/validate_model_content.py @@ -63,15 +63,10 @@ class ValidateModelContent(pyblish.api.InstancePlugin): return True # Top group - assemblies = cmds.ls(content_instance, assemblies=True, long=True) - if len(assemblies) != 1 and cls.validate_top_group: + top_parents = set([x.split("|")[1] for x in content_instance]) + if cls.validate_top_group and len(top_parents) != 1: cls.log.error("Must have exactly one top group") - return assemblies - if len(assemblies) == 0: - cls.log.warning("No top group found. " - "(Are there objects in the instance?" - " Or is it parented in another group?)") - return assemblies or True + return top_parents def _is_visible(node): """Return whether node is visible""" @@ -82,11 +77,11 @@ class ValidateModelContent(pyblish.api.InstancePlugin): visibility=True) # The roots must be visible (the assemblies) - for assembly in assemblies: - if not _is_visible(assembly): - cls.log.error("Invisible assembly (root node) is not " - "allowed: {0}".format(assembly)) - invalid.add(assembly) + for parent in top_parents: + if not _is_visible(parent): + cls.log.error("Invisible parent (root node) is not " + "allowed: {0}".format(parent)) + invalid.add(parent) # Ensure at least one shape is visible if not any(_is_visible(shape) for shape in shapes): From 441618974e49a8ef6774836dbfd5b84584d29b56 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Sat, 22 Jul 2023 10:19:47 +0100 Subject: [PATCH 019/107] All review publish attributes should be copied to model instance --- openpype/hosts/maya/plugins/publish/collect_review.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_review.py b/openpype/hosts/maya/plugins/publish/collect_review.py index fa00fc661e..586939a3b8 100644 --- a/openpype/hosts/maya/plugins/publish/collect_review.py +++ b/openpype/hosts/maya/plugins/publish/collect_review.py @@ -107,10 +107,8 @@ class CollectReview(pyblish.api.InstancePlugin): data["displayLights"] = display_lights data["burninDataMembers"] = burninDataMembers - publish_attributes = data.setdefault("publish_attributes", {}) for key, value in instance.data["publish_attributes"].items(): - if key not in publish_attributes: - publish_attributes[key] = value + data["publish_attributes"][key] = value # The review instance must be active cmds.setAttr(str(instance) + '.active', 1) From a3467b25b66a846acd29b49da7e76a94fbd355cc Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Sat, 22 Jul 2023 10:20:12 +0100 Subject: [PATCH 020/107] Better labelling for ValidateFrameRange setting. --- .../schemas/projects_schema/schemas/schema_maya_publish.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 07c8d8715b..b115ee3faa 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -103,7 +103,7 @@ }, { "key": "exclude_families", - "label": "Families", + "label": "Exclude Families", "type": "list", "object_type": "text" } From 412c83bda1f4e2a3a646c153122b7cead73310dc Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 24 Jul 2023 15:44:23 +0100 Subject: [PATCH 021/107] Set UE_PYTHONPATH when launching Unreal --- openpype/hosts/unreal/addon.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/addon.py b/openpype/hosts/unreal/addon.py index b5c978d98f..3225d742a3 100644 --- a/openpype/hosts/unreal/addon.py +++ b/openpype/hosts/unreal/addon.py @@ -54,7 +54,8 @@ class UnrealAddon(OpenPypeModule, IHostAddon): # Set default environments if are not set via settings defaults = { - "OPENPYPE_LOG_NO_COLORS": "True" + "OPENPYPE_LOG_NO_COLORS": "True", + "UE_PYTHONPATH": os.environ.get("PYTHONPATH", ""), } for key, value in defaults.items(): if not env.get(key): From 58a62a3ccbafce219400efa9cf1a66cd903a769f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 24 Jul 2023 17:45:40 +0200 Subject: [PATCH 022/107] OP-4845 - added settings to limit hardcoded template name 'render' template name was hardcoded which is causing issues in Ayon --- openpype/settings/defaults/project_settings/deadline.json | 1 + .../schemas/projects_schema/schema_project_deadline.json | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 1b8c8397d7..139a6f44b7 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -101,6 +101,7 @@ }, "ProcessSubmittedJobOnFarm": { "enabled": true, + "template_name": "render", "deadline_department": "", "deadline_pool": "", "deadline_group": "", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json index 6d59b5a92b..201fca3fa6 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -544,6 +544,11 @@ "key": "enabled", "label": "Enabled" }, + { + "type": "text", + "key": "template_name", + "label": "Publish template name" + }, { "type": "text", "key": "deadline_department", From 2470911c5a8628f33e79a36a2cace8c13b3e4801 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 24 Jul 2023 18:38:07 +0200 Subject: [PATCH 023/107] Revert "OP-4845 - added settings to limit hardcoded template name" This reverts commit 58a62a3ccbafce219400efa9cf1a66cd903a769f. --- openpype/settings/defaults/project_settings/deadline.json | 1 - .../schemas/projects_schema/schema_project_deadline.json | 5 ----- 2 files changed, 6 deletions(-) diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 139a6f44b7..1b8c8397d7 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -101,7 +101,6 @@ }, "ProcessSubmittedJobOnFarm": { "enabled": true, - "template_name": "render", "deadline_department": "", "deadline_pool": "", "deadline_group": "", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json index 201fca3fa6..6d59b5a92b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -544,11 +544,6 @@ "key": "enabled", "label": "Enabled" }, - { - "type": "text", - "key": "template_name", - "label": "Publish template name" - }, { "type": "text", "key": "deadline_department", From 4055536411794dd089c23db8e455e7e93f854434 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 25 Jul 2023 11:48:32 +0100 Subject: [PATCH 024/107] Added env variable to set existing built Ayon plugin --- .../unreal/hooks/pre_workfile_preparation.py | 40 ++++++++++++------- openpype/hosts/unreal/lib.py | 30 ++++++++++++++ 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index 760d55077a..e6662e7420 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -187,24 +187,36 @@ class UnrealPrelaunchHook(PreLaunchHook): project_path.mkdir(parents=True, exist_ok=True) - # Set "AYON_UNREAL_PLUGIN" to current process environment for - # execution of `create_unreal_project` - - if self.launch_context.env.get("AYON_UNREAL_PLUGIN"): - self.log.info(( - f"{self.signature} using Ayon plugin from " - f"{self.launch_context.env.get('AYON_UNREAL_PLUGIN')}" - )) - env_key = "AYON_UNREAL_PLUGIN" - if self.launch_context.env.get(env_key): - os.environ[env_key] = self.launch_context.env[env_key] - # engine_path points to the specific Unreal Engine root # so, we are going up from the executable itself 3 levels. engine_path: Path = Path(executable).parents[3] - if not unreal_lib.check_plugin_existence(engine_path): - self.exec_plugin_install(engine_path) + # Check if new env variable exists, and if it does, if the path + # actually contains the plugin. If not, install it. + + built_plugin_path = self.launch_context.env.get( + "AYON_BUILT_UNREAL_PLUGIN", None) + + if unreal_lib.check_built_plugin_existance(built_plugin_path): + self.log.info(( + f"{self.signature} using existing built Ayon plugin from " + f"{built_plugin_path}" + )) + unreal_lib.move_built_plugin(engine_path, Path(built_plugin_path)) + else: + # Set "AYON_UNREAL_PLUGIN" to current process environment for + # execution of `create_unreal_project` + env_key = "AYON_UNREAL_PLUGIN" + if self.launch_context.env.get(env_key): + self.log.info(( + f"{self.signature} using Ayon plugin from " + f"{self.launch_context.env.get(env_key)}" + )) + if self.launch_context.env.get(env_key): + os.environ[env_key] = self.launch_context.env[env_key] + + if not unreal_lib.check_plugin_existence(engine_path): + self.exec_plugin_install(engine_path) project_file = project_path / unreal_project_filename diff --git a/openpype/hosts/unreal/lib.py b/openpype/hosts/unreal/lib.py index 67e7891344..cffb5fd1c0 100644 --- a/openpype/hosts/unreal/lib.py +++ b/openpype/hosts/unreal/lib.py @@ -429,6 +429,36 @@ def get_build_id(engine_path: Path, ue_version: str) -> str: return "{" + loaded_modules.get("BuildId") + "}" +def check_built_plugin_existance(plugin_path) -> bool: + if not plugin_path: + return False + + integration_plugin_path = Path(plugin_path) + + if not os.path.isdir(integration_plugin_path): + raise RuntimeError("Path to the integration plugin is null!") + + if not (integration_plugin_path / "Binaries").is_dir() \ + or not (integration_plugin_path / "Intermediate").is_dir(): + return False + + return True + + +def move_built_plugin(engine_path: Path, plugin_path: Path) -> None: + ayon_plugin_path: Path = engine_path / "Engine/Plugins/Marketplace/Ayon" + + if not ayon_plugin_path.is_dir(): + ayon_plugin_path.mkdir(parents=True, exist_ok=True) + + engine_plugin_config_path: Path = ayon_plugin_path / "Config" + engine_plugin_config_path.mkdir(exist_ok=True) + + dir_util._path_created = {} + + dir_util.copy_tree(plugin_path.as_posix(), ayon_plugin_path.as_posix()) + + def check_plugin_existence(engine_path: Path, env: dict = None) -> bool: env = env or os.environ integration_plugin_path: Path = Path(env.get("AYON_UNREAL_PLUGIN", "")) From edbed9ed0e90c5745604a4568109eeab74198efb Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 25 Jul 2023 14:43:55 +0100 Subject: [PATCH 025/107] Improved code based on suggestions Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/unreal/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/lib.py b/openpype/hosts/unreal/lib.py index cffb5fd1c0..5b2e35958b 100644 --- a/openpype/hosts/unreal/lib.py +++ b/openpype/hosts/unreal/lib.py @@ -435,7 +435,7 @@ def check_built_plugin_existance(plugin_path) -> bool: integration_plugin_path = Path(plugin_path) - if not os.path.isdir(integration_plugin_path): + if not integration_plugin_path.is_dir(): raise RuntimeError("Path to the integration plugin is null!") if not (integration_plugin_path / "Binaries").is_dir() \ From 476f018485f87617f168dd1fb9d63803b29d4100 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 25 Jul 2023 16:35:47 +0200 Subject: [PATCH 026/107] OP-4845 - use ordinary publish template resolving instead hardcoding Now it should use configuration in `tools/publish/template_name_profiles` instead of hardcoded value. --- .../plugins/publish/submit_publish_job.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index f912be1abe..fc119a655a 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -16,9 +16,8 @@ from openpype.pipeline import ( legacy_io, ) from openpype.pipeline import publish -from openpype.lib import EnumDef +from openpype.lib import EnumDef, is_running_from_build from openpype.tests.lib import is_in_tests -from openpype.lib import is_running_from_build from openpype.pipeline.farm.pyblish_functions import ( create_skeleton_instance, @@ -185,7 +184,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, instance.data.get("asset"), instances[0]["subset"], instance.context, - 'render', + instances[0]["family"], override_version ) @@ -571,16 +570,21 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, else: version = 1 + host_name = context.data["hostName"] + task_info = template_data.get("task") or {} + + template_name = publish.get_publish_template_name( + project_name, + host_name, + family, + task_info.get("name"), + task_info.get("type"), + ) + template_data["subset"] = subset template_data["family"] = family template_data["version"] = version - # temporary fix, Ayon Settings don't have 'render' template, but they - # have "publish" TODO!!! - template_name = "render" - if os.environ.get("USE_AYON_SERVER") == '1': - template_name = "publish" - render_templates = anatomy.templates_obj[template_name] if "folder" in render_templates: publish_folder = render_templates["folder"].format_strict( From 8fe7ab25c5519d1d9c0f1acdc5e6696317c97898 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 25 Jul 2023 15:45:23 +0100 Subject: [PATCH 027/107] Added exception handling to UE Workers --- openpype/hosts/unreal/ue_workers.py | 39 ++++++++++++++++++----------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/unreal/ue_workers.py b/openpype/hosts/unreal/ue_workers.py index 3a0f976957..75487427d4 100644 --- a/openpype/hosts/unreal/ue_workers.py +++ b/openpype/hosts/unreal/ue_workers.py @@ -40,17 +40,34 @@ def retrieve_exit_code(line: str): return None -class UEProjectGenerationWorker(QtCore.QObject): +class UEWorker(QtCore.QObject): finished = QtCore.Signal(str) - failed = QtCore.Signal(str) + failed = QtCore.Signal(str, int) progress = QtCore.Signal(int) log = QtCore.Signal(str) + + engine_path: Path = None + env = None + + def execute(self): + raise NotImplementedError("Please implement this method!") + + def run(self): + try: + self.execute() + except Exception as e: + import traceback + self.log.emit(str(e)) + self.log.emit(traceback.format_exc()) + self.failed.emit(str(e), 1) + raise e + + +class UEProjectGenerationWorker(UEWorker): stage_begin = QtCore.Signal(str) ue_version: str = None project_name: str = None - env = None - engine_path: Path = None project_dir: Path = None dev_mode = False @@ -87,7 +104,7 @@ class UEProjectGenerationWorker(QtCore.QObject): self.project_name = unreal_project_name self.engine_path = engine_path - def run(self): + def execute(self): # engine_path should be the location of UE_X.X folder ue_editor_exe = ue_lib.get_editor_exe_path(self.engine_path, @@ -297,16 +314,8 @@ class UEProjectGenerationWorker(QtCore.QObject): self.progress.emit(100) self.finished.emit("Project successfully built!") - -class UEPluginInstallWorker(QtCore.QObject): - finished = QtCore.Signal(str) +class UEPluginInstallWorker(UEWorker): installing = QtCore.Signal(str) - failed = QtCore.Signal(str, int) - progress = QtCore.Signal(int) - log = QtCore.Signal(str) - - engine_path: Path = None - env = None def setup(self, engine_path: Path, env: dict = None, ): self.engine_path = engine_path @@ -374,7 +383,7 @@ class UEPluginInstallWorker(QtCore.QObject): dir_util.remove_tree(temp_dir.as_posix()) - def run(self): + def execute(self): src_plugin_dir = Path(self.env.get("AYON_UNREAL_PLUGIN", "")) if not os.path.isdir(src_plugin_dir): From debc9b6fd84df7c9ee092123616b48c90db0a125 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 26 Jul 2023 12:19:38 +0100 Subject: [PATCH 028/107] Hound fixes --- openpype/hosts/unreal/ue_workers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/unreal/ue_workers.py b/openpype/hosts/unreal/ue_workers.py index 75487427d4..386ad877d7 100644 --- a/openpype/hosts/unreal/ue_workers.py +++ b/openpype/hosts/unreal/ue_workers.py @@ -314,6 +314,7 @@ class UEProjectGenerationWorker(UEWorker): self.progress.emit(100) self.finished.emit("Project successfully built!") + class UEPluginInstallWorker(UEWorker): installing = QtCore.Signal(str) From 2bc8b49b9c5005950b481904a7ee3efdc0bd99bf Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 27 Jul 2023 09:52:17 +0100 Subject: [PATCH 029/107] Use more appropriate name for function Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/unreal/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/lib.py b/openpype/hosts/unreal/lib.py index 5b2e35958b..0c39773c19 100644 --- a/openpype/hosts/unreal/lib.py +++ b/openpype/hosts/unreal/lib.py @@ -445,7 +445,7 @@ def check_built_plugin_existance(plugin_path) -> bool: return True -def move_built_plugin(engine_path: Path, plugin_path: Path) -> None: +def copy_built_plugin(engine_path: Path, plugin_path: Path) -> None: ayon_plugin_path: Path = engine_path / "Engine/Plugins/Marketplace/Ayon" if not ayon_plugin_path.is_dir(): From 84d5c1681cc325f99cac3d5672ec41b9001e3b85 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 27 Jul 2023 14:41:57 +0200 Subject: [PATCH 030/107] Copy file_handler as it will be removed by purging ayon code (#5357) Ayon code will get purged in the future therefore all ayon_common will be gone. file_handler gets internalized to tests as it is not used anywhere else. --- tests/lib/file_handler.py | 289 +++++++++++++++++++++++++++++++++++ tests/lib/testing_classes.py | 2 +- 2 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 tests/lib/file_handler.py diff --git a/tests/lib/file_handler.py b/tests/lib/file_handler.py new file mode 100644 index 0000000000..07f6962c98 --- /dev/null +++ b/tests/lib/file_handler.py @@ -0,0 +1,289 @@ +import os +import re +import urllib +from urllib.parse import urlparse +import urllib.request +import urllib.error +import itertools +import hashlib +import tarfile +import zipfile + +import requests + +USER_AGENT = "AYON-launcher" + + +class RemoteFileHandler: + """Download file from url, might be GDrive shareable link""" + + IMPLEMENTED_ZIP_FORMATS = { + "zip", "tar", "tgz", "tar.gz", "tar.xz", "tar.bz2" + } + + @staticmethod + def calculate_md5(fpath, chunk_size=10000): + md5 = hashlib.md5() + with open(fpath, "rb") as f: + for chunk in iter(lambda: f.read(chunk_size), b""): + md5.update(chunk) + return md5.hexdigest() + + @staticmethod + def check_md5(fpath, md5, **kwargs): + return md5 == RemoteFileHandler.calculate_md5(fpath, **kwargs) + + @staticmethod + def calculate_sha256(fpath): + """Calculate sha256 for content of the file. + + Args: + fpath (str): Path to file. + + Returns: + str: hex encoded sha256 + + """ + h = hashlib.sha256() + b = bytearray(128 * 1024) + mv = memoryview(b) + with open(fpath, "rb", buffering=0) as f: + for n in iter(lambda: f.readinto(mv), 0): + h.update(mv[:n]) + return h.hexdigest() + + @staticmethod + def check_sha256(fpath, sha256, **kwargs): + return sha256 == RemoteFileHandler.calculate_sha256(fpath, **kwargs) + + @staticmethod + def check_integrity(fpath, hash_value=None, hash_type=None): + if not os.path.isfile(fpath): + return False + if hash_value is None: + return True + if not hash_type: + raise ValueError("Provide hash type, md5 or sha256") + if hash_type == "md5": + return RemoteFileHandler.check_md5(fpath, hash_value) + if hash_type == "sha256": + return RemoteFileHandler.check_sha256(fpath, hash_value) + + @staticmethod + def download_url( + url, + root, + filename=None, + max_redirect_hops=3, + headers=None + ): + """Download a file from url and place it in root. + + Args: + url (str): URL to download file from + root (str): Directory to place downloaded file in + filename (str, optional): Name to save the file under. + If None, use the basename of the URL + max_redirect_hops (Optional[int]): Maximum number of redirect + hops allowed + headers (Optional[dict[str, str]]): Additional required headers + - Authentication etc.. + """ + + root = os.path.expanduser(root) + if not filename: + filename = os.path.basename(url) + fpath = os.path.join(root, filename) + + os.makedirs(root, exist_ok=True) + + # expand redirect chain if needed + url = RemoteFileHandler._get_redirect_url( + url, max_hops=max_redirect_hops, headers=headers) + + # check if file is located on Google Drive + file_id = RemoteFileHandler._get_google_drive_file_id(url) + if file_id is not None: + return RemoteFileHandler.download_file_from_google_drive( + file_id, root, filename) + + # download the file + try: + print(f"Downloading {url} to {fpath}") + RemoteFileHandler._urlretrieve(url, fpath, headers=headers) + except (urllib.error.URLError, IOError) as exc: + if url[:5] != "https": + raise exc + + url = url.replace("https:", "http:") + print(( + "Failed download. Trying https -> http instead." + f" Downloading {url} to {fpath}" + )) + RemoteFileHandler._urlretrieve(url, fpath, headers=headers) + + @staticmethod + def download_file_from_google_drive( + file_id, root, filename=None + ): + """Download a Google Drive file from and place it in root. + Args: + file_id (str): id of file to be downloaded + root (str): Directory to place downloaded file in + filename (str, optional): Name to save the file under. + If None, use the id of the file. + """ + # Based on https://stackoverflow.com/questions/38511444/python-download-files-from-google-drive-using-url # noqa + + url = "https://docs.google.com/uc?export=download" + + root = os.path.expanduser(root) + if not filename: + filename = file_id + fpath = os.path.join(root, filename) + + os.makedirs(root, exist_ok=True) + + if os.path.isfile(fpath) and RemoteFileHandler.check_integrity(fpath): + print(f"Using downloaded and verified file: {fpath}") + else: + session = requests.Session() + + response = session.get(url, params={"id": file_id}, stream=True) + token = RemoteFileHandler._get_confirm_token(response) + + if token: + params = {"id": file_id, "confirm": token} + response = session.get(url, params=params, stream=True) + + response_content_generator = response.iter_content(32768) + first_chunk = None + while not first_chunk: # filter out keep-alive new chunks + first_chunk = next(response_content_generator) + + if RemoteFileHandler._quota_exceeded(first_chunk): + msg = ( + f"The daily quota of the file {filename} is exceeded and " + f"it can't be downloaded. This is a limitation of " + f"Google Drive and can only be overcome by trying " + f"again later." + ) + raise RuntimeError(msg) + + RemoteFileHandler._save_response_content( + itertools.chain((first_chunk, ), + response_content_generator), fpath) + response.close() + + @staticmethod + def unzip(path, destination_path=None): + if not destination_path: + destination_path = os.path.dirname(path) + + _, archive_type = os.path.splitext(path) + archive_type = archive_type.lstrip(".") + + if archive_type in ["zip"]: + print(f"Unzipping {path}->{destination_path}") + zip_file = zipfile.ZipFile(path) + zip_file.extractall(destination_path) + zip_file.close() + + elif archive_type in [ + "tar", "tgz", "tar.gz", "tar.xz", "tar.bz2" + ]: + print(f"Unzipping {path}->{destination_path}") + if archive_type == "tar": + tar_type = "r:" + elif archive_type.endswith("xz"): + tar_type = "r:xz" + elif archive_type.endswith("gz"): + tar_type = "r:gz" + elif archive_type.endswith("bz2"): + tar_type = "r:bz2" + else: + tar_type = "r:*" + try: + tar_file = tarfile.open(path, tar_type) + except tarfile.ReadError: + raise SystemExit("corrupted archive") + tar_file.extractall(destination_path) + tar_file.close() + + @staticmethod + def _urlretrieve(url, filename, chunk_size=None, headers=None): + final_headers = {"User-Agent": USER_AGENT} + if headers: + final_headers.update(headers) + + chunk_size = chunk_size or 8192 + with open(filename, "wb") as fh: + with urllib.request.urlopen( + urllib.request.Request(url, headers=final_headers) + ) as response: + for chunk in iter(lambda: response.read(chunk_size), ""): + if not chunk: + break + fh.write(chunk) + + @staticmethod + def _get_redirect_url(url, max_hops, headers=None): + initial_url = url + final_headers = {"Method": "HEAD", "User-Agent": USER_AGENT} + if headers: + final_headers.update(headers) + for _ in range(max_hops + 1): + with urllib.request.urlopen( + urllib.request.Request(url, headers=final_headers) + ) as response: + if response.url == url or response.url is None: + return url + + return response.url + else: + raise RecursionError( + f"Request to {initial_url} exceeded {max_hops} redirects. " + f"The last redirect points to {url}." + ) + + @staticmethod + def _get_confirm_token(response): + for key, value in response.cookies.items(): + if key.startswith("download_warning"): + return value + + # handle antivirus warning for big zips + found = re.search("(confirm=)([^&.+])", response.text) + if found: + return found.groups()[1] + + return None + + @staticmethod + def _save_response_content( + response_gen, destination, + ): + with open(destination, "wb") as f: + for chunk in response_gen: + if chunk: # filter out keep-alive new chunks + f.write(chunk) + + @staticmethod + def _quota_exceeded(first_chunk): + try: + return "Google Drive - Quota exceeded" in first_chunk.decode() + except UnicodeDecodeError: + return False + + @staticmethod + def _get_google_drive_file_id(url): + parts = urlparse(url) + + if re.match(r"(drive|docs)[.]google[.]com", parts.netloc) is None: + return None + + match = re.match(r"/file/d/(?P[^/]*)", parts.path) + if match is None: + return None + + return match.group("id") diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py index f04607dc27..2af4af02de 100644 --- a/tests/lib/testing_classes.py +++ b/tests/lib/testing_classes.py @@ -12,7 +12,7 @@ import requests import re from tests.lib.db_handler import DBHandler -from common.ayon_common.distribution.file_handler import RemoteFileHandler +from tests.lib.file_handler import RemoteFileHandler from openpype.modules import ModulesManager from openpype.settings import get_project_settings From a9eaa68ac60e783691806d172056251305f4a961 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 27 Jul 2023 14:42:21 +0200 Subject: [PATCH 031/107] AYON: Remove AYON launch logic from OpenPype (#5348) * removed AYON launch logic from OpenPype * updated ayon api to 0.3.3 * removed common from include files --------- Co-authored-by: 64qam --- ayon_start.py | 483 ------- common/ayon_common/__init__.py | 16 - common/ayon_common/connection/__init__.py | 0 common/ayon_common/connection/credentials.py | 511 -------- common/ayon_common/connection/ui/__init__.py | 12 - common/ayon_common/connection/ui/__main__.py | 23 - .../ayon_common/connection/ui/login_window.py | 710 ----------- common/ayon_common/connection/ui/widgets.py | 47 - common/ayon_common/distribution/README.md | 18 - common/ayon_common/distribution/__init__.py | 9 - common/ayon_common/distribution/control.py | 1116 ----------------- .../distribution/data_structures.py | 265 ---- .../ayon_common/distribution/downloaders.py | 250 ---- .../ayon_common/distribution/file_handler.py | 289 ----- .../tests/test_addon_distributtion.py | 248 ---- .../distribution/ui/missing_bundle_window.py | 146 --- common/ayon_common/distribution/utils.py | 90 -- common/ayon_common/resources/AYON.icns | Bin 40634 -> 0 bytes common/ayon_common/resources/AYON.ico | Bin 4286 -> 0 bytes common/ayon_common/resources/AYON.png | Bin 16907 -> 0 bytes common/ayon_common/resources/AYON_staging.png | Bin 15273 -> 0 bytes common/ayon_common/resources/__init__.py | 25 - common/ayon_common/resources/edit.png | Bin 9138 -> 0 bytes common/ayon_common/resources/eye.png | Bin 2152 -> 0 bytes common/ayon_common/resources/stylesheet.css | 84 -- common/ayon_common/ui_utils.py | 36 - common/ayon_common/utils.py | 90 -- .../vendor/python/common/ayon_api/__init__.py | 4 + .../vendor/python/common/ayon_api/_api.py | 64 +- .../python/common/ayon_api/server_api.py | 163 ++- .../python/common/ayon_api/thumbnails.py | 2 +- .../vendor/python/common/ayon_api/utils.py | 139 +- .../vendor/python/common/ayon_api/version.py | 2 +- setup.py | 17 - tools/run_tray_ayon.ps1 | 41 - tools/run_tray_ayon.sh | 78 -- 36 files changed, 321 insertions(+), 4657 deletions(-) delete mode 100644 ayon_start.py delete mode 100644 common/ayon_common/__init__.py delete mode 100644 common/ayon_common/connection/__init__.py delete mode 100644 common/ayon_common/connection/credentials.py delete mode 100644 common/ayon_common/connection/ui/__init__.py delete mode 100644 common/ayon_common/connection/ui/__main__.py delete mode 100644 common/ayon_common/connection/ui/login_window.py delete mode 100644 common/ayon_common/connection/ui/widgets.py delete mode 100644 common/ayon_common/distribution/README.md delete mode 100644 common/ayon_common/distribution/__init__.py delete mode 100644 common/ayon_common/distribution/control.py delete mode 100644 common/ayon_common/distribution/data_structures.py delete mode 100644 common/ayon_common/distribution/downloaders.py delete mode 100644 common/ayon_common/distribution/file_handler.py delete mode 100644 common/ayon_common/distribution/tests/test_addon_distributtion.py delete mode 100644 common/ayon_common/distribution/ui/missing_bundle_window.py delete mode 100644 common/ayon_common/distribution/utils.py delete mode 100644 common/ayon_common/resources/AYON.icns delete mode 100644 common/ayon_common/resources/AYON.ico delete mode 100644 common/ayon_common/resources/AYON.png delete mode 100644 common/ayon_common/resources/AYON_staging.png delete mode 100644 common/ayon_common/resources/__init__.py delete mode 100644 common/ayon_common/resources/edit.png delete mode 100644 common/ayon_common/resources/eye.png delete mode 100644 common/ayon_common/resources/stylesheet.css delete mode 100644 common/ayon_common/ui_utils.py delete mode 100644 common/ayon_common/utils.py delete mode 100644 tools/run_tray_ayon.ps1 delete mode 100755 tools/run_tray_ayon.sh diff --git a/ayon_start.py b/ayon_start.py deleted file mode 100644 index 458c46bba6..0000000000 --- a/ayon_start.py +++ /dev/null @@ -1,483 +0,0 @@ -# -*- coding: utf-8 -*- -"""Main entry point for AYON command. - -Bootstrapping process of AYON. -""" -import os -import sys -import site -import traceback -import contextlib - - -# Enabled logging debug mode when "--debug" is passed -if "--verbose" in sys.argv: - expected_values = ( - "Expected: notset, debug, info, warning, error, critical" - " or integer [0-50]." - ) - idx = sys.argv.index("--verbose") - sys.argv.pop(idx) - if idx < len(sys.argv): - value = sys.argv.pop(idx) - else: - raise RuntimeError(( - f"Expect value after \"--verbose\" argument. {expected_values}" - )) - - log_level = None - low_value = value.lower() - if low_value.isdigit(): - log_level = int(low_value) - elif low_value == "notset": - log_level = 0 - elif low_value == "debug": - log_level = 10 - elif low_value == "info": - log_level = 20 - elif low_value == "warning": - log_level = 30 - elif low_value == "error": - log_level = 40 - elif low_value == "critical": - log_level = 50 - - if log_level is None: - raise ValueError(( - "Unexpected value after \"--verbose\" " - f"argument \"{value}\". {expected_values}" - )) - - os.environ["OPENPYPE_LOG_LEVEL"] = str(log_level) - os.environ["AYON_LOG_LEVEL"] = str(log_level) - -# Enable debug mode, may affect log level if log level is not defined -if "--debug" in sys.argv: - sys.argv.remove("--debug") - os.environ["AYON_DEBUG"] = "1" - os.environ["OPENPYPE_DEBUG"] = "1" - -if "--automatic-tests" in sys.argv: - sys.argv.remove("--automatic-tests") - os.environ["IS_TEST"] = "1" - -SKIP_HEADERS = False -if "--skip-headers" in sys.argv: - sys.argv.remove("--skip-headers") - SKIP_HEADERS = True - -SKIP_BOOTSTRAP = False -if "--skip-bootstrap" in sys.argv: - sys.argv.remove("--skip-bootstrap") - SKIP_BOOTSTRAP = True - -if "--use-staging" in sys.argv: - sys.argv.remove("--use-staging") - os.environ["AYON_USE_STAGING"] = "1" - os.environ["OPENPYPE_USE_STAGING"] = "1" - -if "--headless" in sys.argv: - os.environ["AYON_HEADLESS_MODE"] = "1" - os.environ["OPENPYPE_HEADLESS_MODE"] = "1" - sys.argv.remove("--headless") - -elif ( - os.getenv("AYON_HEADLESS_MODE") != "1" - or os.getenv("OPENPYPE_HEADLESS_MODE") != "1" -): - os.environ.pop("AYON_HEADLESS_MODE", None) - os.environ.pop("OPENPYPE_HEADLESS_MODE", None) - -elif ( - os.getenv("AYON_HEADLESS_MODE") - != os.getenv("OPENPYPE_HEADLESS_MODE") -): - os.environ["OPENPYPE_HEADLESS_MODE"] = ( - os.environ["AYON_HEADLESS_MODE"] - ) - -IS_BUILT_APPLICATION = getattr(sys, "frozen", False) -HEADLESS_MODE_ENABLED = os.getenv("AYON_HEADLESS_MODE") == "1" - -_pythonpath = os.getenv("PYTHONPATH", "") -_python_paths = _pythonpath.split(os.pathsep) -if not IS_BUILT_APPLICATION: - # Code root defined by `start.py` directory - AYON_ROOT = os.path.dirname(os.path.abspath(__file__)) - _dependencies_path = site.getsitepackages()[-1] -else: - AYON_ROOT = os.path.dirname(sys.executable) - - # add dependencies folder to sys.pat for frozen code - _dependencies_path = os.path.normpath( - os.path.join(AYON_ROOT, "dependencies") - ) -# add stuff from `/dependencies` to PYTHONPATH. -sys.path.append(_dependencies_path) -_python_paths.append(_dependencies_path) - -# Vendored python modules that must not be in PYTHONPATH environment but -# are required for OpenPype processes -sys.path.insert(0, os.path.join(AYON_ROOT, "vendor", "python")) - -# Add common package to sys path -# - common contains common code for bootstraping and OpenPype processes -sys.path.insert(0, os.path.join(AYON_ROOT, "common")) - -# This is content of 'core' addon which is ATM part of build -common_python_vendor = os.path.join( - AYON_ROOT, - "openpype", - "vendor", - "python", - "common" -) -# Add tools dir to sys path for pyblish UI discovery -tools_dir = os.path.join(AYON_ROOT, "openpype", "tools") -for path in (AYON_ROOT, common_python_vendor, tools_dir): - while path in _python_paths: - _python_paths.remove(path) - - while path in sys.path: - sys.path.remove(path) - - _python_paths.insert(0, path) - sys.path.insert(0, path) - -os.environ["PYTHONPATH"] = os.pathsep.join(_python_paths) - -# enabled AYON state -os.environ["USE_AYON_SERVER"] = "1" -# Set this to point either to `python` from venv in case of live code -# or to `ayon` or `ayon_console` in case of frozen code -os.environ["AYON_EXECUTABLE"] = sys.executable -os.environ["OPENPYPE_EXECUTABLE"] = sys.executable -os.environ["AYON_ROOT"] = AYON_ROOT -os.environ["OPENPYPE_ROOT"] = AYON_ROOT -os.environ["OPENPYPE_REPOS_ROOT"] = AYON_ROOT -os.environ["AYON_MENU_LABEL"] = "AYON" -os.environ["AVALON_LABEL"] = "AYON" -# Set name of pyblish UI import -os.environ["PYBLISH_GUI"] = "pyblish_pype" -# Set builtin OCIO root -os.environ["BUILTIN_OCIO_ROOT"] = os.path.join( - AYON_ROOT, - "vendor", - "bin", - "ocioconfig", - "OpenColorIOConfigs" -) - -import blessed # noqa: E402 -import certifi # noqa: E402 - - -if sys.__stdout__: - term = blessed.Terminal() - - def _print(message: str): - if message.startswith("!!! "): - print(f'{term.orangered2("!!! ")}{message[4:]}') - elif message.startswith(">>> "): - print(f'{term.aquamarine3(">>> ")}{message[4:]}') - elif message.startswith("--- "): - print(f'{term.darkolivegreen3("--- ")}{message[4:]}') - elif message.startswith("*** "): - print(f'{term.gold("*** ")}{message[4:]}') - elif message.startswith(" - "): - print(f'{term.wheat(" - ")}{message[4:]}') - elif message.startswith(" . "): - print(f'{term.tan(" . ")}{message[4:]}') - elif message.startswith(" - "): - print(f'{term.seagreen3(" - ")}{message[7:]}') - elif message.startswith(" ! "): - print(f'{term.goldenrod(" ! ")}{message[7:]}') - elif message.startswith(" * "): - print(f'{term.aquamarine1(" * ")}{message[7:]}') - elif message.startswith(" "): - print(f'{term.darkseagreen3(" ")}{message[4:]}') - else: - print(message) -else: - def _print(message: str): - print(message) - - -# if SSL_CERT_FILE is not set prior to OpenPype launch, we set it to point -# to certifi bundle to make sure we have reasonably new CA certificates. -if not os.getenv("SSL_CERT_FILE"): - os.environ["SSL_CERT_FILE"] = certifi.where() -elif os.getenv("SSL_CERT_FILE") != certifi.where(): - _print("--- your system is set to use custom CA certificate bundle.") - -from ayon_api import get_base_url -from ayon_api.constants import SERVER_URL_ENV_KEY, SERVER_API_ENV_KEY -from ayon_common import is_staging_enabled -from ayon_common.connection.credentials import ( - ask_to_login_ui, - add_server, - need_server_or_login, - load_environments, - set_environments, - create_global_connection, - confirm_server_login, -) -from ayon_common.distribution import ( - AyonDistribution, - BundleNotFoundError, - show_missing_bundle_information, -) - - -def set_global_environments() -> None: - """Set global OpenPype's environments.""" - import acre - - from openpype.settings import get_general_environments - - general_env = get_general_environments() - - # first resolve general environment because merge doesn't expect - # values to be list. - # TODO: switch to OpenPype environment functions - merged_env = acre.merge( - acre.compute(acre.parse(general_env), cleanup=False), - dict(os.environ) - ) - env = acre.compute( - merged_env, - cleanup=False - ) - os.environ.clear() - os.environ.update(env) - - # Hardcoded default values - os.environ["PYBLISH_GUI"] = "pyblish_pype" - # Change scale factor only if is not set - if "QT_AUTO_SCREEN_SCALE_FACTOR" not in os.environ: - os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" - - -def set_addons_environments(): - """Set global environments for OpenPype modules. - - This requires to have OpenPype in `sys.path`. - """ - - import acre - from openpype.modules import ModulesManager - - modules_manager = ModulesManager() - - # Merge environments with current environments and update values - if module_envs := modules_manager.collect_global_environments(): - parsed_envs = acre.parse(module_envs) - env = acre.merge(parsed_envs, dict(os.environ)) - os.environ.clear() - os.environ.update(env) - - -def _connect_to_ayon_server(): - load_environments() - if not need_server_or_login(): - create_global_connection() - return - - if HEADLESS_MODE_ENABLED: - _print("!!! Cannot open v4 Login dialog in headless mode.") - _print(( - "!!! Please use `{}` to specify server address" - " and '{}' to specify user's token." - ).format(SERVER_URL_ENV_KEY, SERVER_API_ENV_KEY)) - sys.exit(1) - - current_url = os.environ.get(SERVER_URL_ENV_KEY) - url, token, username = ask_to_login_ui(current_url, always_on_top=True) - if url is not None and token is not None: - confirm_server_login(url, token, username) - return - - if url is not None: - add_server(url, username) - - _print("!!! Login was not successful.") - sys.exit(0) - - -def _check_and_update_from_ayon_server(): - """Gets addon info from v4, compares with local folder and updates it. - - Raises: - RuntimeError - """ - - distribution = AyonDistribution() - bundle = None - bundle_name = None - try: - bundle = distribution.bundle_to_use - if bundle is not None: - bundle_name = bundle.name - except BundleNotFoundError as exc: - bundle_name = exc.bundle_name - - if bundle is None: - url = get_base_url() - if not HEADLESS_MODE_ENABLED: - show_missing_bundle_information(url, bundle_name) - - elif bundle_name: - _print(( - f"!!! Requested release bundle '{bundle_name}'" - " is not available on server." - )) - _print( - "!!! Check if selected release bundle" - f" is available on the server '{url}'." - ) - - else: - mode = "staging" if is_staging_enabled() else "production" - _print( - f"!!! No release bundle is set as {mode} on the AYON server." - ) - _print( - "!!! Make sure there is a release bundle set" - f" as \"{mode}\" on the AYON server '{url}'." - ) - sys.exit(1) - - distribution.distribute() - distribution.validate_distribution() - os.environ["AYON_BUNDLE_NAME"] = bundle_name - - python_paths = [ - path - for path in os.getenv("PYTHONPATH", "").split(os.pathsep) - if path - ] - - for path in distribution.get_sys_paths(): - sys.path.insert(0, path) - if path not in python_paths: - python_paths.append(path) - os.environ["PYTHONPATH"] = os.pathsep.join(python_paths) - - -def boot(): - """Bootstrap OpenPype.""" - - from openpype.version import __version__ - - # TODO load version - os.environ["OPENPYPE_VERSION"] = __version__ - os.environ["AYON_VERSION"] = __version__ - - _connect_to_ayon_server() - _check_and_update_from_ayon_server() - - # delete OpenPype module and it's submodules from cache so it is used from - # specific version - modules_to_del = [ - sys.modules.pop(module_name) - for module_name in tuple(sys.modules) - if module_name == "openpype" or module_name.startswith("openpype.") - ] - - for module_name in modules_to_del: - with contextlib.suppress(AttributeError, KeyError): - del sys.modules[module_name] - - -def main_cli(): - from openpype import cli - from openpype.version import __version__ - from openpype.lib import terminal as t - - _print(">>> loading environments ...") - _print(" - global AYON ...") - set_global_environments() - _print(" - for addons ...") - set_addons_environments() - - # print info when not running scripts defined in 'silent commands' - if not SKIP_HEADERS: - info = get_info(is_staging_enabled()) - info.insert(0, f">>> Using AYON from [ {AYON_ROOT} ]") - - t_width = 20 - with contextlib.suppress(ValueError, OSError): - t_width = os.get_terminal_size().columns - 2 - - _header = f"*** AYON [{__version__}] " - info.insert(0, _header + "-" * (t_width - len(_header))) - - for i in info: - t.echo(i) - - try: - cli.main(obj={}, prog_name="ayon") - except Exception: # noqa - exc_info = sys.exc_info() - _print("!!! AYON crashed:") - traceback.print_exception(*exc_info) - sys.exit(1) - - -def script_cli(): - """Run and execute script.""" - - filepath = os.path.abspath(sys.argv[1]) - - # Find '__main__.py' in directory - if os.path.isdir(filepath): - new_filepath = os.path.join(filepath, "__main__.py") - if not os.path.exists(new_filepath): - raise RuntimeError( - f"can't find '__main__' module in '{filepath}'") - filepath = new_filepath - - # Add parent dir to sys path - sys.path.insert(0, os.path.dirname(filepath)) - - # Read content and execute - with open(filepath, "r") as stream: - content = stream.read() - - exec(compile(content, filepath, "exec"), globals()) - - -def get_info(use_staging=None) -> list: - """Print additional information to console.""" - - inf = [] - if use_staging: - inf.append(("AYON variant", "staging")) - else: - inf.append(("AYON variant", "production")) - inf.append(("AYON bundle", os.getenv("AYON_BUNDLE"))) - - # NOTE add addons information - - maximum = max(len(i[0]) for i in inf) - formatted = [] - for info in inf: - padding = (maximum - len(info[0])) + 1 - formatted.append(f'... {info[0]}:{" " * padding}[ {info[1]} ]') - return formatted - - -def main(): - if not SKIP_BOOTSTRAP: - boot() - - args = list(sys.argv) - args.pop(0) - if args and os.path.exists(args[0]): - script_cli() - else: - main_cli() - - -if __name__ == "__main__": - main() diff --git a/common/ayon_common/__init__.py b/common/ayon_common/__init__.py deleted file mode 100644 index ddabb7da2f..0000000000 --- a/common/ayon_common/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -from .utils import ( - IS_BUILT_APPLICATION, - is_staging_enabled, - get_local_site_id, - get_ayon_appdirs, - get_ayon_launch_args, -) - - -__all__ = ( - "IS_BUILT_APPLICATION", - "is_staging_enabled", - "get_local_site_id", - "get_ayon_appdirs", - "get_ayon_launch_args", -) diff --git a/common/ayon_common/connection/__init__.py b/common/ayon_common/connection/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/common/ayon_common/connection/credentials.py b/common/ayon_common/connection/credentials.py deleted file mode 100644 index 7f70cb7992..0000000000 --- a/common/ayon_common/connection/credentials.py +++ /dev/null @@ -1,511 +0,0 @@ -"""Handle credentials and connection to server for client application. - -Cache and store used server urls. Store/load API keys to/from keyring if -needed. Store metadata about used urls, usernames for the urls and when was -the connection with the username established. - -On bootstrap is created global connection with information about site and -client version. The connection object lives in 'ayon_api'. -""" - -import os -import json -import platform -import datetime -import contextlib -import subprocess -import tempfile -from typing import Optional, Union, Any - -import ayon_api - -from ayon_api.constants import SERVER_URL_ENV_KEY, SERVER_API_ENV_KEY -from ayon_api.exceptions import UrlError -from ayon_api.utils import ( - validate_url, - is_token_valid, - logout_from_server, -) - -from ayon_common.utils import ( - get_ayon_appdirs, - get_local_site_id, - get_ayon_launch_args, - is_staging_enabled, -) - - -class ChangeUserResult: - def __init__( - self, logged_out, old_url, old_token, old_username, - new_url, new_token, new_username - ): - shutdown = logged_out - restart = new_url is not None and new_url != old_url - token_changed = new_token is not None and new_token != old_token - - self.logged_out = logged_out - self.old_url = old_url - self.old_token = old_token - self.old_username = old_username - self.new_url = new_url - self.new_token = new_token - self.new_username = new_username - - self.shutdown = shutdown - self.restart = restart - self.token_changed = token_changed - - -def _get_servers_path(): - return get_ayon_appdirs("used_servers.json") - - -def get_servers_info_data(): - """Metadata about used server on this machine. - - Store data about all used server urls, last used url and user username for - the url. Using this metadata we can remember which username was used per - url if token stored in keyring loose lifetime. - - Returns: - dict[str, Any]: Information about servers. - """ - - data = {} - servers_info_path = _get_servers_path() - if not os.path.exists(servers_info_path): - dirpath = os.path.dirname(servers_info_path) - if not os.path.exists(dirpath): - os.makedirs(dirpath) - - return data - - with open(servers_info_path, "r") as stream: - with contextlib.suppress(BaseException): - data = json.load(stream) - return data - - -def add_server(url: str, username: str): - """Add server to server info metadata. - - This function will also mark the url as last used url on the machine so on - next launch will be used. - - Args: - url (str): Server url. - username (str): Name of user used to log in. - """ - - servers_info_path = _get_servers_path() - data = get_servers_info_data() - data["last_server"] = url - if "urls" not in data: - data["urls"] = {} - data["urls"][url] = { - "updated_dt": datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S"), - "username": username, - } - - with open(servers_info_path, "w") as stream: - json.dump(data, stream) - - -def remove_server(url: str): - """Remove server url from servers information. - - This should be used on logout to completelly loose information about server - on the machine. - - Args: - url (str): Server url. - """ - - if not url: - return - - servers_info_path = _get_servers_path() - data = get_servers_info_data() - if data.get("last_server") == url: - data["last_server"] = None - - if "urls" in data: - data["urls"].pop(url, None) - - with open(servers_info_path, "w") as stream: - json.dump(data, stream) - - -def get_last_server( - data: Optional[dict[str, Any]] = None -) -> Union[str, None]: - """Last server used to log in on this machine. - - Args: - data (Optional[dict[str, Any]]): Prepared server information data. - - Returns: - Union[str, None]: Last used server url. - """ - - if data is None: - data = get_servers_info_data() - return data.get("last_server") - - -def get_last_username_by_url( - url: str, - data: Optional[dict[str, Any]] = None -) -> Union[str, None]: - """Get last username which was used for passed url. - - Args: - url (str): Server url. - data (Optional[dict[str, Any]]): Servers info. - - Returns: - Union[str, None]: Username. - """ - - if not url: - return None - - if data is None: - data = get_servers_info_data() - - if urls := data.get("urls"): - if url_info := urls.get(url): - return url_info.get("username") - return None - - -def get_last_server_with_username(): - """Receive last server and username used in last connection. - - Returns: - tuple[Union[str, None], Union[str, None]]: Url and username. - """ - - data = get_servers_info_data() - url = get_last_server(data) - username = get_last_username_by_url(url) - return url, username - - -class TokenKeyring: - # Fake username with hardcoded username - username_key = "username" - - def __init__(self, url): - try: - import keyring - - except Exception as exc: - raise NotImplementedError( - "Python module `keyring` is not available." - ) from exc - - # hack for cx_freeze and Windows keyring backend - if platform.system().lower() == "windows": - from keyring.backends import Windows - - keyring.set_keyring(Windows.WinVaultKeyring()) - - self._url = url - self._keyring_key = f"AYON/{url}" - - def get_value(self): - import keyring - - return keyring.get_password(self._keyring_key, self.username_key) - - def set_value(self, value): - import keyring - - if value is not None: - keyring.set_password(self._keyring_key, self.username_key, value) - return - - with contextlib.suppress(keyring.errors.PasswordDeleteError): - keyring.delete_password(self._keyring_key, self.username_key) - - -def load_token(url: str) -> Union[str, None]: - """Get token for url from keyring. - - Args: - url (str): Server url. - - Returns: - Union[str, None]: Token for passed url available in keyring. - """ - - return TokenKeyring(url).get_value() - - -def store_token(url: str, token: str): - """Store token by url to keyring. - - Args: - url (str): Server url. - token (str): User token to server. - """ - - TokenKeyring(url).set_value(token) - - -def ask_to_login_ui( - url: Optional[str] = None, - always_on_top: Optional[bool] = False -) -> tuple[str, str, str]: - """Ask user to login using UI. - - This should be used only when user is not yet logged in at all or available - credentials are invalid. To change credentials use 'change_user_ui' - function. - - Use a subprocess to show UI. - - Args: - url (Optional[str]): Server url that could be prefilled in UI. - always_on_top (Optional[bool]): Window will be drawn on top of - other windows. - - Returns: - tuple[str, str, str]: Url, user's token and username. - """ - - current_dir = os.path.dirname(os.path.abspath(__file__)) - ui_dir = os.path.join(current_dir, "ui") - - if url is None: - url = get_last_server() - username = get_last_username_by_url(url) - data = { - "url": url, - "username": username, - "always_on_top": always_on_top, - } - - with tempfile.NamedTemporaryFile( - mode="w", prefix="ayon_login", suffix=".json", delete=False - ) as tmp: - output = tmp.name - json.dump(data, tmp) - - code = subprocess.call( - get_ayon_launch_args(ui_dir, "--skip-bootstrap", output)) - if code != 0: - raise RuntimeError("Failed to show login UI") - - with open(output, "r") as stream: - data = json.load(stream) - os.remove(output) - return data["output"] - - -def change_user_ui() -> ChangeUserResult: - """Change user using UI. - - Show UI to user where he can change credentials or url. Output will contain - all information about old/new values of url, username, api key. If user - confirmed or declined values. - - Returns: - ChangeUserResult: Information about user change. - """ - - from .ui import change_user - - url, username = get_last_server_with_username() - token = load_token(url) - result = change_user(url, username, token) - new_url, new_token, new_username, logged_out = result - - output = ChangeUserResult( - logged_out, url, token, username, - new_url, new_token, new_username - ) - if output.logged_out: - logout(url, token) - - elif output.token_changed: - change_token( - output.new_url, - output.new_token, - output.new_username, - output.old_url - ) - return output - - -def change_token( - url: str, - token: str, - username: Optional[str] = None, - old_url: Optional[str] = None -): - """Change url and token in currently running session. - - Function can also change server url, in that case are previous credentials - NOT removed from cache. - - Args: - url (str): Url to server. - token (str): New token to be used for url connection. - username (Optional[str]): Username of logged user. - old_url (Optional[str]): Previous url. Value from 'get_last_server' - is used if not entered. - """ - - if old_url is None: - old_url = get_last_server() - if old_url and old_url == url: - remove_url_cache(old_url) - - # TODO check if ayon_api is already connected - add_server(url, username) - store_token(url, token) - ayon_api.change_token(url, token) - - -def remove_url_cache(url: str): - """Clear cache for server url. - - Args: - url (str): Server url which is removed from cache. - """ - - store_token(url, None) - - -def remove_token_cache(url: str, token: str): - """Remove token from local cache of url. - - Is skipped if cached token under the passed url is not the same - as passed token. - - Args: - url (str): Url to server. - token (str): Token to be removed from url cache. - """ - - if load_token(url) == token: - remove_url_cache(url) - - -def logout(url: str, token: str): - """Logout from server and throw token away. - - Args: - url (str): Url from which should be logged out. - token (str): Token which should be used to log out. - """ - - remove_server(url) - ayon_api.close_connection() - ayon_api.set_environments(None, None) - remove_token_cache(url, token) - logout_from_server(url, token) - - -def load_environments(): - """Load environments on startup. - - Handle environments needed for connection with server. Environments are - 'AYON_SERVER_URL' and 'AYON_API_KEY'. - - Server is looked up from environment. Already set environent is not - changed. If environemnt is not filled then last server stored in appdirs - is used. - - Token is skipped if url is not available. Otherwise, is also checked from - env and if is not available then uses 'load_token' to try to get token - based on server url. - """ - - server_url = os.environ.get(SERVER_URL_ENV_KEY) - if not server_url: - server_url = get_last_server() - if not server_url: - return - os.environ[SERVER_URL_ENV_KEY] = server_url - - if not os.environ.get(SERVER_API_ENV_KEY): - if token := load_token(server_url): - os.environ[SERVER_API_ENV_KEY] = token - - -def set_environments(url: str, token: str): - """Change url and token environemnts in currently running process. - - Args: - url (str): New server url. - token (str): User's token. - """ - - ayon_api.set_environments(url, token) - - -def create_global_connection(): - """Create global connection with site id and client version. - - Make sure the global connection in 'ayon_api' have entered site id and - client version. - - Set default settings variant to use based on 'is_staging_enabled'. - """ - - ayon_api.create_connection( - get_local_site_id(), os.environ.get("AYON_VERSION") - ) - ayon_api.set_default_settings_variant( - "staging" if is_staging_enabled() else "production" - ) - - -def need_server_or_login() -> bool: - """Check if server url or login to the server are needed. - - It is recommended to call 'load_environments' on startup before this check. - But in some cases this function could be called after startup. - - Returns: - bool: 'True' if server and token are available. Otherwise 'False'. - """ - - server_url = os.environ.get(SERVER_URL_ENV_KEY) - if not server_url: - return True - - try: - server_url = validate_url(server_url) - except UrlError: - return True - - token = os.environ.get(SERVER_API_ENV_KEY) - if token: - return not is_token_valid(server_url, token) - - token = load_token(server_url) - if token: - return not is_token_valid(server_url, token) - return True - - -def confirm_server_login(url, token, username): - """Confirm login of user and do necessary stepts to apply changes. - - This should not be used on "change" of user but on first login. - - Args: - url (str): Server url where user authenticated. - token (str): API token used for authentication to server. - username (Union[str, None]): Username related to API token. - """ - - add_server(url, username) - store_token(url, token) - set_environments(url, token) - create_global_connection() diff --git a/common/ayon_common/connection/ui/__init__.py b/common/ayon_common/connection/ui/__init__.py deleted file mode 100644 index 96e573df0d..0000000000 --- a/common/ayon_common/connection/ui/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from .login_window import ( - ServerLoginWindow, - ask_to_login, - change_user, -) - - -__all__ = ( - "ServerLoginWindow", - "ask_to_login", - "change_user", -) diff --git a/common/ayon_common/connection/ui/__main__.py b/common/ayon_common/connection/ui/__main__.py deleted file mode 100644 index 719b2b8ef5..0000000000 --- a/common/ayon_common/connection/ui/__main__.py +++ /dev/null @@ -1,23 +0,0 @@ -import sys -import json - -from ayon_common.connection.ui.login_window import ask_to_login - - -def main(output_path): - with open(output_path, "r") as stream: - data = json.load(stream) - - url = data.get("url") - username = data.get("username") - always_on_top = data.get("always_on_top", False) - out_url, out_token, out_username = ask_to_login( - url, username, always_on_top=always_on_top) - - data["output"] = [out_url, out_token, out_username] - with open(output_path, "w") as stream: - json.dump(data, stream) - - -if __name__ == "__main__": - main(sys.argv[-1]) diff --git a/common/ayon_common/connection/ui/login_window.py b/common/ayon_common/connection/ui/login_window.py deleted file mode 100644 index 94c239852e..0000000000 --- a/common/ayon_common/connection/ui/login_window.py +++ /dev/null @@ -1,710 +0,0 @@ -import traceback - -from qtpy import QtWidgets, QtCore, QtGui - -from ayon_api.exceptions import UrlError -from ayon_api.utils import validate_url, login_to_server - -from ayon_common.resources import ( - get_resource_path, - get_icon_path, - load_stylesheet, -) -from ayon_common.ui_utils import set_style_property, get_qt_app - -from .widgets import ( - PressHoverButton, - PlaceholderLineEdit, -) - - -class LogoutConfirmDialog(QtWidgets.QDialog): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setWindowTitle("Logout confirmation") - - message_widget = QtWidgets.QWidget(self) - - message_label = QtWidgets.QLabel( - ( - "You are going to logout. This action will close this" - " application and will invalidate your login." - " All other applications launched with this login won't be" - " able to use it anymore.

" - "You can cancel logout and only change server and user login" - " in login dialog.

" - "Press OK to confirm logout." - ), - message_widget - ) - message_label.setWordWrap(True) - - message_layout = QtWidgets.QHBoxLayout(message_widget) - message_layout.setContentsMargins(0, 0, 0, 0) - message_layout.addWidget(message_label, 1) - - sep_frame = QtWidgets.QFrame(self) - sep_frame.setObjectName("Separator") - sep_frame.setMinimumHeight(2) - sep_frame.setMaximumHeight(2) - - footer_widget = QtWidgets.QWidget(self) - - cancel_btn = QtWidgets.QPushButton("Cancel", footer_widget) - confirm_btn = QtWidgets.QPushButton("OK", footer_widget) - - footer_layout = QtWidgets.QHBoxLayout(footer_widget) - footer_layout.setContentsMargins(0, 0, 0, 0) - footer_layout.addStretch(1) - footer_layout.addWidget(cancel_btn, 0) - footer_layout.addWidget(confirm_btn, 0) - - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.addWidget(message_widget, 0) - main_layout.addStretch(1) - main_layout.addWidget(sep_frame, 0) - main_layout.addWidget(footer_widget, 0) - - cancel_btn.clicked.connect(self._on_cancel_click) - confirm_btn.clicked.connect(self._on_confirm_click) - - self._cancel_btn = cancel_btn - self._confirm_btn = confirm_btn - self._result = False - - def showEvent(self, event): - super().showEvent(event) - self._match_btns_sizes() - - def resizeEvent(self, event): - super().resizeEvent(event) - self._match_btns_sizes() - - def _match_btns_sizes(self): - width = max( - self._cancel_btn.sizeHint().width(), - self._confirm_btn.sizeHint().width() - ) - self._cancel_btn.setMinimumWidth(width) - self._confirm_btn.setMinimumWidth(width) - - def _on_cancel_click(self): - self._result = False - self.reject() - - def _on_confirm_click(self): - self._result = True - self.accept() - - def get_result(self): - return self._result - - -class ServerLoginWindow(QtWidgets.QDialog): - default_width = 410 - default_height = 170 - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - icon_path = get_icon_path() - icon = QtGui.QIcon(icon_path) - self.setWindowIcon(icon) - self.setWindowTitle("Login to server") - - edit_icon_path = get_resource_path("edit.png") - edit_icon = QtGui.QIcon(edit_icon_path) - - # --- URL page --- - login_widget = QtWidgets.QWidget(self) - - user_cred_widget = QtWidgets.QWidget(login_widget) - - url_label = QtWidgets.QLabel("URL:", user_cred_widget) - - url_widget = QtWidgets.QWidget(user_cred_widget) - - url_input = PlaceholderLineEdit(url_widget) - url_input.setPlaceholderText("< https://ayon.server.com >") - - url_preview = QtWidgets.QLineEdit(url_widget) - url_preview.setReadOnly(True) - url_preview.setObjectName("LikeDisabledInput") - - url_edit_btn = PressHoverButton(user_cred_widget) - url_edit_btn.setIcon(edit_icon) - url_edit_btn.setObjectName("PasswordBtn") - - url_layout = QtWidgets.QHBoxLayout(url_widget) - url_layout.setContentsMargins(0, 0, 0, 0) - url_layout.addWidget(url_input, 1) - url_layout.addWidget(url_preview, 1) - - # --- URL separator --- - url_cred_sep = QtWidgets.QFrame(self) - url_cred_sep.setObjectName("Separator") - url_cred_sep.setMinimumHeight(2) - url_cred_sep.setMaximumHeight(2) - - # --- Login page --- - username_label = QtWidgets.QLabel("Username:", user_cred_widget) - - username_widget = QtWidgets.QWidget(user_cred_widget) - - username_input = PlaceholderLineEdit(username_widget) - username_input.setPlaceholderText("< Artist >") - - username_preview = QtWidgets.QLineEdit(username_widget) - username_preview.setReadOnly(True) - username_preview.setObjectName("LikeDisabledInput") - - username_edit_btn = PressHoverButton(user_cred_widget) - username_edit_btn.setIcon(edit_icon) - username_edit_btn.setObjectName("PasswordBtn") - - username_layout = QtWidgets.QHBoxLayout(username_widget) - username_layout.setContentsMargins(0, 0, 0, 0) - username_layout.addWidget(username_input, 1) - username_layout.addWidget(username_preview, 1) - - password_label = QtWidgets.QLabel("Password:", user_cred_widget) - password_input = PlaceholderLineEdit(user_cred_widget) - password_input.setPlaceholderText("< *********** >") - password_input.setEchoMode(PlaceholderLineEdit.Password) - - api_label = QtWidgets.QLabel("API key:", user_cred_widget) - api_preview = QtWidgets.QLineEdit(user_cred_widget) - api_preview.setReadOnly(True) - api_preview.setObjectName("LikeDisabledInput") - - show_password_icon_path = get_resource_path("eye.png") - show_password_icon = QtGui.QIcon(show_password_icon_path) - show_password_btn = PressHoverButton(user_cred_widget) - show_password_btn.setObjectName("PasswordBtn") - show_password_btn.setIcon(show_password_icon) - show_password_btn.setFocusPolicy(QtCore.Qt.ClickFocus) - - cred_msg_sep = QtWidgets.QFrame(self) - cred_msg_sep.setObjectName("Separator") - cred_msg_sep.setMinimumHeight(2) - cred_msg_sep.setMaximumHeight(2) - - # --- Credentials inputs --- - user_cred_layout = QtWidgets.QGridLayout(user_cred_widget) - user_cred_layout.setContentsMargins(0, 0, 0, 0) - row = 0 - - user_cred_layout.addWidget(url_label, row, 0, 1, 1) - user_cred_layout.addWidget(url_widget, row, 1, 1, 1) - user_cred_layout.addWidget(url_edit_btn, row, 2, 1, 1) - row += 1 - - user_cred_layout.addWidget(url_cred_sep, row, 0, 1, 3) - row += 1 - - user_cred_layout.addWidget(username_label, row, 0, 1, 1) - user_cred_layout.addWidget(username_widget, row, 1, 1, 1) - user_cred_layout.addWidget(username_edit_btn, row, 2, 2, 1) - row += 1 - - user_cred_layout.addWidget(api_label, row, 0, 1, 1) - user_cred_layout.addWidget(api_preview, row, 1, 1, 1) - row += 1 - - user_cred_layout.addWidget(password_label, row, 0, 1, 1) - user_cred_layout.addWidget(password_input, row, 1, 1, 1) - user_cred_layout.addWidget(show_password_btn, row, 2, 1, 1) - row += 1 - - user_cred_layout.addWidget(cred_msg_sep, row, 0, 1, 3) - row += 1 - - user_cred_layout.setColumnStretch(0, 0) - user_cred_layout.setColumnStretch(1, 1) - user_cred_layout.setColumnStretch(2, 0) - - login_layout = QtWidgets.QVBoxLayout(login_widget) - login_layout.setContentsMargins(0, 0, 0, 0) - login_layout.addWidget(user_cred_widget, 1) - - # --- Messages --- - # Messages for users (e.g. invalid url etc.) - message_label = QtWidgets.QLabel(self) - message_label.setWordWrap(True) - message_label.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) - - footer_widget = QtWidgets.QWidget(self) - logout_btn = QtWidgets.QPushButton("Logout", footer_widget) - user_message = QtWidgets.QLabel(footer_widget) - login_btn = QtWidgets.QPushButton("Login", footer_widget) - confirm_btn = QtWidgets.QPushButton("Confirm", footer_widget) - - footer_layout = QtWidgets.QHBoxLayout(footer_widget) - footer_layout.setContentsMargins(0, 0, 0, 0) - footer_layout.addWidget(logout_btn, 0) - footer_layout.addWidget(user_message, 1) - footer_layout.addWidget(login_btn, 0) - footer_layout.addWidget(confirm_btn, 0) - - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.addWidget(login_widget, 0) - main_layout.addWidget(message_label, 0) - main_layout.addStretch(1) - main_layout.addWidget(footer_widget, 0) - - url_input.textChanged.connect(self._on_url_change) - url_input.returnPressed.connect(self._on_url_enter_press) - username_input.textChanged.connect(self._on_user_change) - username_input.returnPressed.connect(self._on_username_enter_press) - password_input.returnPressed.connect(self._on_password_enter_press) - show_password_btn.change_state.connect(self._on_show_password) - url_edit_btn.clicked.connect(self._on_url_edit_click) - username_edit_btn.clicked.connect(self._on_username_edit_click) - logout_btn.clicked.connect(self._on_logout_click) - login_btn.clicked.connect(self._on_login_click) - confirm_btn.clicked.connect(self._on_login_click) - - self._message_label = message_label - - self._url_widget = url_widget - self._url_input = url_input - self._url_preview = url_preview - self._url_edit_btn = url_edit_btn - - self._login_widget = login_widget - - self._user_cred_widget = user_cred_widget - self._username_input = username_input - self._username_preview = username_preview - self._username_edit_btn = username_edit_btn - - self._password_label = password_label - self._password_input = password_input - self._show_password_btn = show_password_btn - self._api_label = api_label - self._api_preview = api_preview - - self._logout_btn = logout_btn - self._user_message = user_message - self._login_btn = login_btn - self._confirm_btn = confirm_btn - - self._url_is_valid = None - self._credentials_are_valid = None - self._result = (None, None, None, False) - self._first_show = True - - self._allow_logout = False - self._logged_in = False - self._url_edit_mode = False - self._username_edit_mode = False - - def set_allow_logout(self, allow_logout): - if allow_logout is self._allow_logout: - return - self._allow_logout = allow_logout - - self._update_states_by_edit_mode() - - def _set_logged_in(self, logged_in): - if logged_in is self._logged_in: - return - self._logged_in = logged_in - - self._update_states_by_edit_mode() - - def _set_url_edit_mode(self, edit_mode): - if self._url_edit_mode is not edit_mode: - self._url_edit_mode = edit_mode - self._update_states_by_edit_mode() - - def _set_username_edit_mode(self, edit_mode): - if self._username_edit_mode is not edit_mode: - self._username_edit_mode = edit_mode - self._update_states_by_edit_mode() - - def _get_url_user_edit(self): - url_edit = True - if self._logged_in and not self._url_edit_mode: - url_edit = False - user_edit = url_edit - if not user_edit and self._logged_in and self._username_edit_mode: - user_edit = True - return url_edit, user_edit - - def _update_states_by_edit_mode(self): - url_edit, user_edit = self._get_url_user_edit() - - self._url_preview.setVisible(not url_edit) - self._url_input.setVisible(url_edit) - self._url_edit_btn.setVisible(self._allow_logout and not url_edit) - - self._username_preview.setVisible(not user_edit) - self._username_input.setVisible(user_edit) - self._username_edit_btn.setVisible( - self._allow_logout and not user_edit - ) - - self._api_preview.setVisible(not user_edit) - self._api_label.setVisible(not user_edit) - - self._password_label.setVisible(user_edit) - self._show_password_btn.setVisible(user_edit) - self._password_input.setVisible(user_edit) - - self._logout_btn.setVisible(self._allow_logout and self._logged_in) - self._login_btn.setVisible(not self._allow_logout) - self._confirm_btn.setVisible(self._allow_logout) - self._update_login_btn_state(url_edit, user_edit) - - def _update_login_btn_state(self, url_edit=None, user_edit=None, url=None): - if url_edit is None: - url_edit, user_edit = self._get_url_user_edit() - - if url is None: - url = self._url_input.text() - - enabled = bool(url) and (url_edit or user_edit) - - self._login_btn.setEnabled(enabled) - self._confirm_btn.setEnabled(enabled) - - def showEvent(self, event): - super().showEvent(event) - if self._first_show: - self._first_show = False - self._on_first_show() - - def _on_first_show(self): - self.setStyleSheet(load_stylesheet()) - self.resize(self.default_width, self.default_height) - self._center_window() - if self._allow_logout is None: - self.set_allow_logout(False) - - self._update_states_by_edit_mode() - if not self._url_input.text(): - widget = self._url_input - elif not self._username_input.text(): - widget = self._username_input - else: - widget = self._password_input - - self._set_input_focus(widget) - - def result(self): - """Result url and token or login. - - Returns: - Union[Tuple[str, str], Tuple[None, None]]: Url and token used for - login if was successful otherwise are both set to None. - """ - return self._result - - def _center_window(self): - """Move window to center of screen.""" - - if hasattr(QtWidgets.QApplication, "desktop"): - desktop = QtWidgets.QApplication.desktop() - screen_idx = desktop.screenNumber(self) - screen_geo = desktop.screenGeometry(screen_idx) - else: - screen = self.screen() - screen_geo = screen.geometry() - - geo = self.frameGeometry() - geo.moveCenter(screen_geo.center()) - if geo.y() < screen_geo.y(): - geo.setY(screen_geo.y()) - self.move(geo.topLeft()) - - def _on_url_change(self, text): - self._update_login_btn_state(url=text) - self._set_url_valid(None) - self._set_credentials_valid(None) - self._url_preview.setText(text) - - def _set_url_valid(self, valid): - if valid is self._url_is_valid: - return - - self._url_is_valid = valid - self._set_input_valid_state(self._url_input, valid) - - def _set_credentials_valid(self, valid): - if self._credentials_are_valid is valid: - return - - self._credentials_are_valid = valid - self._set_input_valid_state(self._username_input, valid) - self._set_input_valid_state(self._password_input, valid) - - def _on_url_enter_press(self): - self._set_input_focus(self._username_input) - - def _on_user_change(self, username): - self._username_preview.setText(username) - - def _on_username_enter_press(self): - self._set_input_focus(self._password_input) - - def _on_password_enter_press(self): - self._login() - - def _on_show_password(self, show_password): - if show_password: - placeholder_text = "< MySecret124 >" - echo_mode = QtWidgets.QLineEdit.Normal - else: - placeholder_text = "< *********** >" - echo_mode = QtWidgets.QLineEdit.Password - - self._password_input.setEchoMode(echo_mode) - self._password_input.setPlaceholderText(placeholder_text) - - def _on_username_edit_click(self): - self._username_edit_mode = True - self._update_states_by_edit_mode() - - def _on_url_edit_click(self): - self._url_edit_mode = True - self._update_states_by_edit_mode() - - def _on_logout_click(self): - dialog = LogoutConfirmDialog(self) - dialog.exec_() - if dialog.get_result(): - self._result = (None, None, None, True) - self.accept() - - def _on_login_click(self): - self._login() - - def _validate_url(self): - """Use url from input to connect and change window state on success. - - Todos: - Threaded check. - """ - - url = self._url_input.text() - valid_url = None - try: - valid_url = validate_url(url) - - except UrlError as exc: - parts = [f"{exc.title}"] - parts.extend(f"- {hint}" for hint in exc.hints) - self._set_message("
".join(parts)) - - except KeyboardInterrupt: - # Reraise KeyboardInterrupt error - raise - - except BaseException: - self._set_unexpected_error() - return - - if valid_url is None: - return False - - self._url_input.setText(valid_url) - return True - - def _login(self): - if ( - not self._login_btn.isEnabled() - and not self._confirm_btn.isEnabled() - ): - return - - if not self._url_is_valid: - self._set_url_valid(self._validate_url()) - - if not self._url_is_valid: - self._set_input_focus(self._url_input) - self._set_credentials_valid(None) - return - - self._clear_message() - - url = self._url_input.text() - username = self._username_input.text() - password = self._password_input.text() - try: - token = login_to_server(url, username, password) - except BaseException: - self._set_unexpected_error() - return - - if token is not None: - self._result = (url, token, username, False) - self.accept() - return - - self._set_credentials_valid(False) - message_lines = ["Invalid credentials"] - if not username.strip(): - message_lines.append("- Username is not filled") - - if not password.strip(): - message_lines.append("- Password is not filled") - - if username and password: - message_lines.append("- Check your credentials") - - self._set_message("
".join(message_lines)) - self._set_input_focus(self._username_input) - - def _set_input_focus(self, widget): - widget.setFocus(QtCore.Qt.MouseFocusReason) - - def _set_input_valid_state(self, widget, valid): - state = "" - if valid is True: - state = "valid" - elif valid is False: - state = "invalid" - set_style_property(widget, "state", state) - - def _set_message(self, message): - self._message_label.setText(message) - - def _clear_message(self): - self._message_label.setText("") - - def _set_unexpected_error(self): - # TODO add traceback somewhere - # - maybe a button to show or copy? - traceback.print_exc() - lines = [ - "Unexpected error happened", - "- Can be caused by wrong url (leading elsewhere)" - ] - self._set_message("
".join(lines)) - - def set_url(self, url): - self._url_preview.setText(url) - self._url_input.setText(url) - self._validate_url() - - def set_username(self, username): - self._username_preview.setText(username) - self._username_input.setText(username) - - def _set_api_key(self, api_key): - if not api_key or len(api_key) < 3: - self._api_preview.setText(api_key or "") - return - - api_key_len = len(api_key) - offset = 6 - if api_key_len < offset: - offset = api_key_len // 2 - api_key = api_key[:offset] + "." * (api_key_len - offset) - - self._api_preview.setText(api_key) - - def set_logged_in( - self, - logged_in, - url=None, - username=None, - api_key=None, - allow_logout=None - ): - if url is not None: - self.set_url(url) - - if username is not None: - self.set_username(username) - - if api_key: - self._set_api_key(api_key) - - if logged_in and allow_logout is None: - allow_logout = True - - self._set_logged_in(logged_in) - - if allow_logout: - self.set_allow_logout(True) - elif allow_logout is False: - self.set_allow_logout(False) - - -def ask_to_login(url=None, username=None, always_on_top=False): - """Ask user to login using Qt dialog. - - Function creates new QApplication if is not created yet. - - Args: - url (Optional[str]): Server url that will be prefilled in dialog. - username (Optional[str]): Username that will be prefilled in dialog. - always_on_top (Optional[bool]): Window will be drawn on top of - other windows. - - Returns: - tuple[str, str, str]: Returns Url, user's token and username. Url can - be changed during dialog lifetime that's why the url is returned. - """ - - app_instance = get_qt_app() - - window = ServerLoginWindow() - if always_on_top: - window.setWindowFlags( - window.windowFlags() - | QtCore.Qt.WindowStaysOnTopHint - ) - - if url: - window.set_url(url) - - if username: - window.set_username(username) - - if not app_instance.startingUp(): - window.exec_() - else: - window.open() - app_instance.exec_() - result = window.result() - out_url, out_token, out_username, _ = result - return out_url, out_token, out_username - - -def change_user(url, username, api_key, always_on_top=False): - """Ask user to login using Qt dialog. - - Function creates new QApplication if is not created yet. - - Args: - url (str): Server url that will be prefilled in dialog. - username (str): Username that will be prefilled in dialog. - api_key (str): API key that will be prefilled in dialog. - always_on_top (Optional[bool]): Window will be drawn on top of - other windows. - - Returns: - Tuple[str, str]: Returns Url and user's token. Url can be changed - during dialog lifetime that's why the url is returned. - """ - - app_instance = get_qt_app() - window = ServerLoginWindow() - if always_on_top: - window.setWindowFlags( - window.windowFlags() - | QtCore.Qt.WindowStaysOnTopHint - ) - window.set_logged_in(True, url, username, api_key) - - if not app_instance.startingUp(): - window.exec_() - else: - window.open() - # This can become main Qt loop. Maybe should live elsewhere - app_instance.exec_() - return window.result() diff --git a/common/ayon_common/connection/ui/widgets.py b/common/ayon_common/connection/ui/widgets.py deleted file mode 100644 index 78b73e056d..0000000000 --- a/common/ayon_common/connection/ui/widgets.py +++ /dev/null @@ -1,47 +0,0 @@ -from qtpy import QtWidgets, QtCore, QtGui - - -class PressHoverButton(QtWidgets.QPushButton): - """Keep track about mouse press/release and enter/leave.""" - - _mouse_pressed = False - _mouse_hovered = False - change_state = QtCore.Signal(bool) - - def mousePressEvent(self, event): - self._mouse_pressed = True - self._mouse_hovered = True - self.change_state.emit(self._mouse_hovered) - super(PressHoverButton, self).mousePressEvent(event) - - def mouseReleaseEvent(self, event): - self._mouse_pressed = False - self._mouse_hovered = False - self.change_state.emit(self._mouse_hovered) - super(PressHoverButton, self).mouseReleaseEvent(event) - - def mouseMoveEvent(self, event): - mouse_pos = self.mapFromGlobal(QtGui.QCursor.pos()) - under_mouse = self.rect().contains(mouse_pos) - if under_mouse != self._mouse_hovered: - self._mouse_hovered = under_mouse - self.change_state.emit(self._mouse_hovered) - - super(PressHoverButton, self).mouseMoveEvent(event) - - -class PlaceholderLineEdit(QtWidgets.QLineEdit): - """Set placeholder color of QLineEdit in Qt 5.12 and higher.""" - - def __init__(self, *args, **kwargs): - super(PlaceholderLineEdit, self).__init__(*args, **kwargs) - # Change placeholder palette color - if hasattr(QtGui.QPalette, "PlaceholderText"): - filter_palette = self.palette() - color = QtGui.QColor("#D3D8DE") - color.setAlpha(67) - filter_palette.setColor( - QtGui.QPalette.PlaceholderText, - color - ) - self.setPalette(filter_palette) diff --git a/common/ayon_common/distribution/README.md b/common/ayon_common/distribution/README.md deleted file mode 100644 index f1c34ba722..0000000000 --- a/common/ayon_common/distribution/README.md +++ /dev/null @@ -1,18 +0,0 @@ -Addon distribution tool ------------------------- - -Code in this folder is backend portion of Addon distribution logic for v4 server. - -Each host, module will be separate Addon in the future. Each v4 server could run different set of Addons. - -Client (running on artist machine) will in the first step ask v4 for list of enabled addons. -(It expects list of json documents matching to `addon_distribution.py:AddonInfo` object.) -Next it will compare presence of enabled addon version in local folder. In the case of missing version of -an addon, client will use information in the addon to download (from http/shared local disk/git) zip file -and unzip it. - -Required part of addon distribution will be sharing of dependencies (python libraries, utilities) which is not part of this folder. - -Location of this folder might change in the future as it will be required for a clint to add this folder to sys.path reliably. - -This code needs to be independent on Openpype code as much as possible! diff --git a/common/ayon_common/distribution/__init__.py b/common/ayon_common/distribution/__init__.py deleted file mode 100644 index e3c0f0e161..0000000000 --- a/common/ayon_common/distribution/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .control import AyonDistribution, BundleNotFoundError -from .utils import show_missing_bundle_information - - -__all__ = ( - "AyonDistribution", - "BundleNotFoundError", - "show_missing_bundle_information", -) diff --git a/common/ayon_common/distribution/control.py b/common/ayon_common/distribution/control.py deleted file mode 100644 index 95c221d753..0000000000 --- a/common/ayon_common/distribution/control.py +++ /dev/null @@ -1,1116 +0,0 @@ -import os -import sys -import json -import traceback -import collections -import datetime -import logging -import shutil -import threading -import platform -import attr -from enum import Enum - -import ayon_api - -from ayon_common.utils import is_staging_enabled - -from .utils import ( - get_addons_dir, - get_dependencies_dir, -) -from .downloaders import get_default_download_factory -from .data_structures import ( - AddonInfo, - DependencyItem, - Bundle, -) - -NOT_SET = type("UNKNOWN", (), {"__bool__": lambda: False})() - - -class BundleNotFoundError(Exception): - """Bundle name is defined but is not available on server. - - Args: - bundle_name (str): Name of bundle that was not found. - """ - - def __init__(self, bundle_name): - self.bundle_name = bundle_name - super().__init__( - f"Bundle '{bundle_name}' is not available on server" - ) - - -class UpdateState(Enum): - UNKNOWN = "unknown" - UPDATED = "udated" - OUTDATED = "outdated" - UPDATE_FAILED = "failed" - MISS_SOURCE_FILES = "miss_source_files" - - -class DistributeTransferProgress: - """Progress of single source item in 'DistributionItem'. - - The item is to keep track of single source item. - """ - - def __init__(self): - self._transfer_progress = ayon_api.TransferProgress() - self._started = False - self._failed = False - self._fail_reason = None - self._unzip_started = False - self._unzip_finished = False - self._hash_check_started = False - self._hash_check_finished = False - - def set_started(self): - """Call when source distribution starts.""" - - self._started = True - - def set_failed(self, reason): - """Set source distribution as failed. - - Args: - reason (str): Error message why the transfer failed. - """ - - self._failed = True - self._fail_reason = reason - - def set_hash_check_started(self): - """Call just before hash check starts.""" - - self._hash_check_started = True - - def set_hash_check_finished(self): - """Call just after hash check finishes.""" - - self._hash_check_finished = True - - def set_unzip_started(self): - """Call just before unzip starts.""" - - self._unzip_started = True - - def set_unzip_finished(self): - """Call just after unzip finishes.""" - - self._unzip_finished = True - - @property - def is_running(self): - """Source distribution is in progress. - - Returns: - bool: Transfer is in progress. - """ - - return bool( - self._started - and not self._failed - and not self._hash_check_finished - ) - - @property - def transfer_progress(self): - """Source file 'download' progress tracker. - - Returns: - ayon_api.TransferProgress.: Content download progress. - """ - - return self._transfer_progress - - @property - def started(self): - return self._started - - @property - def hash_check_started(self): - return self._hash_check_started - - @property - def hash_check_finished(self): - return self._has_check_finished - - @property - def unzip_started(self): - return self._unzip_started - - @property - def unzip_finished(self): - return self._unzip_finished - - @property - def failed(self): - return self._failed or self._transfer_progress.failed - - @property - def fail_reason(self): - return self._fail_reason or self._transfer_progress.fail_reason - - -class DistributionItem: - """Distribution item with sources and target directories. - - Distribution item can be an addon or dependency package. Distribution item - can be already distributed and don't need any progression. The item keeps - track of the progress. The reason is to be able to use the distribution - items as source data for UI without implementing the same logic. - - Distribution is "state" based. Distribution can be 'UPDATED' or 'OUTDATED' - at the initialization. If item is 'UPDATED' the distribution is skipped - and 'OUTDATED' will trigger the distribution process. - - Because the distribution may have multiple sources each source has own - progress item. - - Args: - state (UpdateState): Initial state (UpdateState.UPDATED or - UpdateState.OUTDATED). - unzip_dirpath (str): Path to directory where zip is downloaded. - download_dirpath (str): Path to directory where file is unzipped. - file_hash (str): Hash of file for validation. - factory (DownloadFactory): Downloaders factory object. - sources (List[SourceInfo]): Possible sources to receive the - distribution item. - downloader_data (Dict[str, Any]): More information for downloaders. - item_label (str): Label used in log outputs (and in UI). - logger (logging.Logger): Logger object. - """ - - def __init__( - self, - state, - unzip_dirpath, - download_dirpath, - file_hash, - factory, - sources, - downloader_data, - item_label, - logger=None, - ): - if logger is None: - logger = logging.getLogger(self.__class__.__name__) - self.log = logger - self.state = state - self.unzip_dirpath = unzip_dirpath - self.download_dirpath = download_dirpath - self.file_hash = file_hash - self.factory = factory - self.sources = [ - (source, DistributeTransferProgress()) - for source in sources - ] - self.downloader_data = downloader_data - self.item_label = item_label - - self._need_distribution = state != UpdateState.UPDATED - self._current_source_progress = None - self._used_source_progress = None - self._used_source = None - self._dist_started = False - self._dist_finished = False - - self._error_msg = None - self._error_detail = None - - @property - def need_distribution(self): - """Need distribution based on initial state. - - Returns: - bool: Need distribution. - """ - - return self._need_distribution - - @property - def current_source_progress(self): - """Currently processed source progress object. - - Returns: - Union[DistributeTransferProgress, None]: Transfer progress or None. - """ - - return self._current_source_progress - - @property - def used_source_progress(self): - """Transfer progress that successfully distributed the item. - - Returns: - Union[DistributeTransferProgress, None]: Transfer progress or None. - """ - - return self._used_source_progress - - @property - def used_source(self): - """Data of source item. - - Returns: - Union[Dict[str, Any], None]: SourceInfo data or None. - """ - - return self._used_source - - @property - def error_message(self): - """Reason why distribution item failed. - - Returns: - Union[str, None]: Error message. - """ - - return self._error_msg - - @property - def error_detail(self): - """Detailed reason why distribution item failed. - - Returns: - Union[str, None]: Detailed information (maybe traceback). - """ - - return self._error_detail - - def _distribute(self): - if not self.sources: - message = ( - f"{self.item_label}: Don't have" - " any sources to download from." - ) - self.log.error(message) - self._error_msg = message - self.state = UpdateState.MISS_SOURCE_FILES - return - - download_dirpath = self.download_dirpath - unzip_dirpath = self.unzip_dirpath - for source, source_progress in self.sources: - self._current_source_progress = source_progress - source_progress.set_started() - - # Remove directory if exists - if os.path.isdir(unzip_dirpath): - self.log.debug(f"Cleaning {unzip_dirpath}") - shutil.rmtree(unzip_dirpath) - - # Create directory - os.makedirs(unzip_dirpath) - if not os.path.isdir(download_dirpath): - os.makedirs(download_dirpath) - - try: - downloader = self.factory.get_downloader(source.type) - except Exception: - message = f"Unknown downloader {source.type}" - source_progress.set_failed(message) - self.log.warning(message, exc_info=True) - continue - - source_data = attr.asdict(source) - cleanup_args = ( - source_data, - download_dirpath, - self.downloader_data - ) - - try: - zip_filepath = downloader.download( - source_data, - download_dirpath, - self.downloader_data, - source_progress.transfer_progress, - ) - except Exception: - message = "Failed to download source" - source_progress.set_failed(message) - self.log.warning( - f"{self.item_label}: {message}", - exc_info=True - ) - downloader.cleanup(*cleanup_args) - continue - - source_progress.set_hash_check_started() - try: - downloader.check_hash(zip_filepath, self.file_hash) - except Exception: - message = "File hash does not match" - source_progress.set_failed(message) - self.log.warning( - f"{self.item_label}: {message}", - exc_info=True - ) - downloader.cleanup(*cleanup_args) - continue - - source_progress.set_hash_check_finished() - source_progress.set_unzip_started() - try: - downloader.unzip(zip_filepath, unzip_dirpath) - except Exception: - message = "Couldn't unzip source file" - source_progress.set_failed(message) - self.log.warning( - f"{self.item_label}: {message}", - exc_info=True - ) - downloader.cleanup(*cleanup_args) - continue - - source_progress.set_unzip_finished() - downloader.cleanup(*cleanup_args) - self.state = UpdateState.UPDATED - self._used_source = source_data - break - - last_progress = self._current_source_progress - self._current_source_progress = None - if self.state == UpdateState.UPDATED: - self._used_source_progress = last_progress - self.log.info(f"{self.item_label}: Distributed") - return - - self.log.error(f"{self.item_label}: Failed to distribute") - self._error_msg = "Failed to receive or install source files" - - def distribute(self): - """Execute distribution logic.""" - - if not self.need_distribution or self._dist_started: - return - - self._dist_started = True - try: - if self.state == UpdateState.OUTDATED: - self._distribute() - - except Exception as exc: - self.state = UpdateState.UPDATE_FAILED - self._error_msg = str(exc) - self._error_detail = "".join( - traceback.format_exception(*sys.exc_info()) - ) - self.log.error( - f"{self.item_label}: Distibution filed", - exc_info=True - ) - - finally: - self._dist_finished = True - if self.state == UpdateState.OUTDATED: - self.state = UpdateState.UPDATE_FAILED - self._error_msg = "Distribution failed" - - if ( - self.state != UpdateState.UPDATED - and self.unzip_dirpath - and os.path.isdir(self.unzip_dirpath) - ): - self.log.debug(f"Cleaning {self.unzip_dirpath}") - shutil.rmtree(self.unzip_dirpath) - - -class AyonDistribution: - """Distribution control. - - Receive information from server what addons and dependency packages - should be available locally and prepare/validate their distribution. - - Arguments are available for testing of the class. - - Args: - addon_dirpath (Optional[str]): Where addons will be stored. - dependency_dirpath (Optional[str]): Where dependencies will be stored. - dist_factory (Optional[DownloadFactory]): Factory which cares about - downloading of items based on source type. - addons_info (Optional[list[dict[str, Any]]): List of prepared - addons' info. - dependency_packages_info (Optional[list[dict[str, Any]]): Info - about packages from server. - bundles_info (Optional[Dict[str, Any]]): Info about - bundles. - bundle_name (Optional[str]): Name of bundle to use. If not passed - an environment variable 'AYON_BUNDLE_NAME' is checked for value. - When both are not available the bundle is defined by 'use_staging' - value. - use_staging (Optional[bool]): Use staging versions of an addon. - If not passed, 'is_staging_enabled' is used as default value. - """ - - def __init__( - self, - addon_dirpath=None, - dependency_dirpath=None, - dist_factory=None, - addons_info=NOT_SET, - dependency_packages_info=NOT_SET, - bundles_info=NOT_SET, - bundle_name=NOT_SET, - use_staging=None - ): - self._log = None - - self._dist_started = False - self._dist_finished = False - - self._addons_dirpath = addon_dirpath or get_addons_dir() - self._dependency_dirpath = dependency_dirpath or get_dependencies_dir() - self._dist_factory = ( - dist_factory or get_default_download_factory() - ) - - if bundle_name is NOT_SET: - bundle_name = os.environ.get("AYON_BUNDLE_NAME", NOT_SET) - - # Raw addons data from server - self._addons_info = addons_info - # Prepared data as Addon objects - self._addon_items = NOT_SET - # Distrubtion items of addons - # - only those addons and versions that should be distributed - self._addon_dist_items = NOT_SET - - # Raw dependency packages data from server - self._dependency_packages_info = dependency_packages_info - # Prepared dependency packages as objects - self._dependency_packages_items = NOT_SET - # Dependency package item that should be used - self._dependency_package_item = NOT_SET - # Distribution item of dependency package - self._dependency_dist_item = NOT_SET - - # Raw bundles data from server - self._bundles_info = bundles_info - # Bundles as objects - self._bundle_items = NOT_SET - - # Bundle that should be used in production - self._production_bundle = NOT_SET - # Bundle that should be used in staging - self._staging_bundle = NOT_SET - # Boolean that defines if staging bundle should be used - self._use_staging = use_staging - - # Specific bundle name should be used - self._bundle_name = bundle_name - # Final bundle that will be used - self._bundle = NOT_SET - - @property - def use_staging(self): - """Staging version of a bundle should be used. - - This value is completely ignored if specific bundle name should - be used. - - Returns: - bool: True if staging version should be used. - """ - - if self._use_staging is None: - self._use_staging = is_staging_enabled() - return self._use_staging - - @property - def log(self): - """Helper to access logger. - - Returns: - logging.Logger: Logger instance. - """ - if self._log is None: - self._log = logging.getLogger(self.__class__.__name__) - return self._log - - @property - def bundles_info(self): - """ - - Returns: - dict[str, dict[str, Any]]: Bundles information from server. - """ - - if self._bundles_info is NOT_SET: - self._bundles_info = ayon_api.get_bundles() - return self._bundles_info - - @property - def bundle_items(self): - """ - - Returns: - list[Bundle]: List of bundles info. - """ - - if self._bundle_items is NOT_SET: - self._bundle_items = [ - Bundle.from_dict(info) - for info in self.bundles_info["bundles"] - ] - return self._bundle_items - - def _prepare_production_staging_bundles(self): - production_bundle = None - staging_bundle = None - for bundle in self.bundle_items: - if bundle.is_production: - production_bundle = bundle - if bundle.is_staging: - staging_bundle = bundle - self._production_bundle = production_bundle - self._staging_bundle = staging_bundle - - @property - def production_bundle(self): - """ - Returns: - Union[Bundle, None]: Bundle that should be used in production. - """ - - if self._production_bundle is NOT_SET: - self._prepare_production_staging_bundles() - return self._production_bundle - - @property - def staging_bundle(self): - """ - Returns: - Union[Bundle, None]: Bundle that should be used in staging. - """ - - if self._staging_bundle is NOT_SET: - self._prepare_production_staging_bundles() - return self._staging_bundle - - @property - def bundle_to_use(self): - """Bundle that will be used for distribution. - - Bundle that should be used can be affected by 'bundle_name' - or 'use_staging'. - - Returns: - Union[Bundle, None]: Bundle that will be used for distribution - or None. - - Raises: - BundleNotFoundError: When bundle name to use is defined - but is not available on server. - """ - - if self._bundle is NOT_SET: - if self._bundle_name is not NOT_SET: - bundle = next( - ( - bundle - for bundle in self.bundle_items - if bundle.name == self._bundle_name - ), - None - ) - if bundle is None: - raise BundleNotFoundError(self._bundle_name) - - self._bundle = bundle - elif self.use_staging: - self._bundle = self.staging_bundle - else: - self._bundle = self.production_bundle - return self._bundle - - @property - def bundle_name_to_use(self): - bundle = self.bundle_to_use - return None if bundle is None else bundle.name - - @property - def addons_info(self): - """Server information about available addons. - - Returns: - Dict[str, dict[str, Any]: Addon info by addon name. - """ - - if self._addons_info is NOT_SET: - server_info = ayon_api.get_addons_info(details=True) - self._addons_info = server_info["addons"] - return self._addons_info - - @property - def addon_items(self): - """Information about available addons on server. - - Addons may require distribution of files. For those addons will be - created 'DistributionItem' handling distribution itself. - - Returns: - Dict[str, AddonInfo]: Addon info object by addon name. - """ - - if self._addon_items is NOT_SET: - addons_info = {} - for addon in self.addons_info: - addon_info = AddonInfo.from_dict(addon) - addons_info[addon_info.name] = addon_info - self._addon_items = addons_info - return self._addon_items - - @property - def dependency_packages_info(self): - """Server information about available dependency packages. - - Notes: - For testing purposes it is possible to pass dependency packages - information to '__init__'. - - Returns: - list[dict[str, Any]]: Dependency packages information. - """ - - if self._dependency_packages_info is NOT_SET: - self._dependency_packages_info = ( - ayon_api.get_dependency_packages())["packages"] - return self._dependency_packages_info - - @property - def dependency_packages_items(self): - """Dependency packages as objects. - - Returns: - dict[str, DependencyItem]: Dependency packages as objects by name. - """ - - if self._dependency_packages_items is NOT_SET: - dependenc_package_items = {} - for item in self.dependency_packages_info: - item = DependencyItem.from_dict(item) - dependenc_package_items[item.name] = item - self._dependency_packages_items = dependenc_package_items - return self._dependency_packages_items - - @property - def dependency_package_item(self): - """Dependency package item that should be used by bundle. - - Returns: - Union[None, Dict[str, Any]]: None if bundle does not have - specified dependency package. - """ - - if self._dependency_package_item is NOT_SET: - dependency_package_item = None - bundle = self.bundle_to_use - if bundle is not None: - package_name = bundle.dependency_packages.get( - platform.system().lower() - ) - dependency_package_item = self.dependency_packages_items.get( - package_name) - self._dependency_package_item = dependency_package_item - return self._dependency_package_item - - def _prepare_current_addon_dist_items(self): - addons_metadata = self.get_addons_metadata() - output = [] - addon_versions = {} - bundle = self.bundle_to_use - if bundle is not None: - addon_versions = bundle.addon_versions - for addon_name, addon_item in self.addon_items.items(): - addon_version = addon_versions.get(addon_name) - # Addon is not in bundle -> Skip - if addon_version is None: - continue - - addon_version_item = addon_item.versions.get(addon_version) - # Addon version is not available in addons info - # - TODO handle this case (raise error, skip, store, report, ...) - if addon_version_item is None: - print( - f"Version '{addon_version}' of addon '{addon_name}'" - " is not available on server." - ) - continue - - if not addon_version_item.require_distribution: - continue - full_name = addon_version_item.full_name - addon_dest = os.path.join(self._addons_dirpath, full_name) - self.log.debug(f"Checking {full_name} in {addon_dest}") - addon_in_metadata = ( - addon_name in addons_metadata - and addon_version_item.version in addons_metadata[addon_name] - ) - if addon_in_metadata and os.path.isdir(addon_dest): - self.log.debug( - f"Addon version folder {addon_dest} already exists." - ) - state = UpdateState.UPDATED - - else: - state = UpdateState.OUTDATED - - downloader_data = { - "type": "addon", - "name": addon_name, - "version": addon_version - } - - dist_item = DistributionItem( - state, - addon_dest, - addon_dest, - addon_version_item.hash, - self._dist_factory, - list(addon_version_item.sources), - downloader_data, - full_name, - self.log - ) - output.append({ - "dist_item": dist_item, - "addon_name": addon_name, - "addon_version": addon_version, - "addon_item": addon_item, - "addon_version_item": addon_version_item, - }) - return output - - def _prepare_dependency_progress(self): - package = self.dependency_package_item - if package is None: - return None - - metadata = self.get_dependency_metadata() - downloader_data = { - "type": "dependency_package", - "name": package.name, - "platform": package.platform_name - } - zip_dir = package_dir = os.path.join( - self._dependency_dirpath, package.name - ) - self.log.debug(f"Checking {package.name} in {package_dir}") - - if not os.path.isdir(package_dir) or package.name not in metadata: - state = UpdateState.OUTDATED - else: - state = UpdateState.UPDATED - - return DistributionItem( - state, - zip_dir, - package_dir, - package.checksum, - self._dist_factory, - package.sources, - downloader_data, - package.name, - self.log, - ) - - def get_addon_dist_items(self): - """Addon distribution items. - - These items describe source files required by addon to be available on - machine. Each item may have 0-n source information from where can be - obtained. If file is already available it's state will be 'UPDATED'. - - Example output: - [ - { - "dist_item": DistributionItem, - "addon_name": str, - "addon_version": str, - "addon_item": AddonInfo, - "addon_version_item": AddonVersionInfo - }, { - ... - } - ] - - Returns: - list[dict[str, Any]]: Distribution items with addon version item. - """ - - if self._addon_dist_items is NOT_SET: - self._addon_dist_items = ( - self._prepare_current_addon_dist_items()) - return self._addon_dist_items - - def get_dependency_dist_item(self): - """Dependency package distribution item. - - Item describe source files required by server to be available on - machine. Item may have 0-n source information from where can be - obtained. If file is already available it's state will be 'UPDATED'. - - 'None' is returned if server does not have defined any dependency - package. - - Returns: - Union[None, DistributionItem]: Dependency item or None if server - does not have specified any dependency package. - """ - - if self._dependency_dist_item is NOT_SET: - self._dependency_dist_item = self._prepare_dependency_progress() - return self._dependency_dist_item - - def get_dependency_metadata_filepath(self): - """Path to distribution metadata file. - - Metadata contain information about distributed packages, used source, - expected file hash and time when file was distributed. - - Returns: - str: Path to a file where dependency package metadata are stored. - """ - - return os.path.join(self._dependency_dirpath, "dependency.json") - - def get_addons_metadata_filepath(self): - """Path to addons metadata file. - - Metadata contain information about distributed addons, used sources, - expected file hashes and time when files were distributed. - - Returns: - str: Path to a file where addons metadata are stored. - """ - - return os.path.join(self._addons_dirpath, "addons.json") - - def read_metadata_file(self, filepath, default_value=None): - """Read json file from path. - - Method creates the file when does not exist with default value. - - Args: - filepath (str): Path to json file. - default_value (Union[Dict[str, Any], List[Any], None]): Default - value if the file is not available (or valid). - - Returns: - Union[Dict[str, Any], List[Any]]: Value from file. - """ - - if default_value is None: - default_value = {} - - if not os.path.exists(filepath): - return default_value - - try: - with open(filepath, "r") as stream: - data = json.load(stream) - except ValueError: - data = default_value - return data - - def save_metadata_file(self, filepath, data): - """Store data to json file. - - Method creates the file when does not exist. - - Args: - filepath (str): Path to json file. - data (Union[Dict[str, Any], List[Any]]): Data to store into file. - """ - - if not os.path.exists(filepath): - dirpath = os.path.dirname(filepath) - if not os.path.exists(dirpath): - os.makedirs(dirpath) - with open(filepath, "w") as stream: - json.dump(data, stream, indent=4) - - def get_dependency_metadata(self): - filepath = self.get_dependency_metadata_filepath() - return self.read_metadata_file(filepath, {}) - - def update_dependency_metadata(self, package_name, data): - dependency_metadata = self.get_dependency_metadata() - dependency_metadata[package_name] = data - filepath = self.get_dependency_metadata_filepath() - self.save_metadata_file(filepath, dependency_metadata) - - def get_addons_metadata(self): - filepath = self.get_addons_metadata_filepath() - return self.read_metadata_file(filepath, {}) - - def update_addons_metadata(self, addons_information): - if not addons_information: - return - addons_metadata = self.get_addons_metadata() - for addon_name, version_value in addons_information.items(): - if addon_name not in addons_metadata: - addons_metadata[addon_name] = {} - for addon_version, version_data in version_value.items(): - addons_metadata[addon_name][addon_version] = version_data - - filepath = self.get_addons_metadata_filepath() - self.save_metadata_file(filepath, addons_metadata) - - def finish_distribution(self): - """Store metadata about distributed items.""" - - self._dist_finished = True - stored_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - dependency_dist_item = self.get_dependency_dist_item() - if ( - dependency_dist_item is not None - and dependency_dist_item.need_distribution - and dependency_dist_item.state == UpdateState.UPDATED - ): - package = self.dependency_package - source = dependency_dist_item.used_source - if source is not None: - data = { - "source": source, - "file_hash": dependency_dist_item.file_hash, - "distributed_dt": stored_time - } - self.update_dependency_metadata(package.name, data) - - addons_info = {} - for item in self.get_addon_dist_items(): - dist_item = item["dist_item"] - if ( - not dist_item.need_distribution - or dist_item.state != UpdateState.UPDATED - ): - continue - - source_data = dist_item.used_source - if not source_data: - continue - - addon_name = item["addon_name"] - addon_version = item["addon_version"] - addons_info.setdefault(addon_name, {}) - addons_info[addon_name][addon_version] = { - "source": source_data, - "file_hash": dist_item.file_hash, - "distributed_dt": stored_time - } - - self.update_addons_metadata(addons_info) - - def get_all_distribution_items(self): - """Distribution items required by server. - - Items contain dependency package item and all addons that are enabled - and have distribution requirements. - - Items can be already available on machine. - - Returns: - List[DistributionItem]: Distribution items required by server. - """ - - output = [ - item["dist_item"] - for item in self.get_addon_dist_items() - ] - dependency_dist_item = self.get_dependency_dist_item() - if dependency_dist_item is not None: - output.insert(0, dependency_dist_item) - - return output - - def distribute(self, threaded=False): - """Distribute all missing items. - - Method will try to distribute all items that are required by server. - - This method does not handle failed items. To validate the result call - 'validate_distribution' when this method finishes. - - Args: - threaded (bool): Distribute items in threads. - """ - - if self._dist_started: - raise RuntimeError("Distribution already started") - self._dist_started = True - threads = collections.deque() - for item in self.get_all_distribution_items(): - if threaded: - threads.append(threading.Thread(target=item.distribute)) - else: - item.distribute() - - while threads: - thread = threads.popleft() - if thread.is_alive(): - threads.append(thread) - else: - thread.join() - - self.finish_distribution() - - def validate_distribution(self): - """Check if all required distribution items are distributed. - - Raises: - RuntimeError: Any of items is not available. - """ - - invalid = [] - dependency_package = self.get_dependency_dist_item() - if ( - dependency_package is not None - and dependency_package.state != UpdateState.UPDATED - ): - invalid.append("Dependency package") - - for item in self.get_addon_dist_items(): - dist_item = item["dist_item"] - if dist_item.state != UpdateState.UPDATED: - invalid.append(item["addon_name"]) - - if not invalid: - return - - raise RuntimeError("Failed to distribute {}".format( - ", ".join([f'"{item}"' for item in invalid]) - )) - - def get_sys_paths(self): - """Get all paths to python packages that should be added to python. - - These paths lead to addon directories and python dependencies in - dependency package. - - Todos: - Add dependency package directory to output. ATM is not structure of - dependency package 100% defined. - - Returns: - List[str]: Paths that should be added to 'sys.path' and - 'PYTHONPATH'. - """ - - output = [] - for item in self.get_all_distribution_items(): - if item.state != UpdateState.UPDATED: - continue - unzip_dirpath = item.unzip_dirpath - if unzip_dirpath and os.path.exists(unzip_dirpath): - output.append(unzip_dirpath) - return output - - -def cli(*args): - raise NotImplementedError diff --git a/common/ayon_common/distribution/data_structures.py b/common/ayon_common/distribution/data_structures.py deleted file mode 100644 index aa93d4ed71..0000000000 --- a/common/ayon_common/distribution/data_structures.py +++ /dev/null @@ -1,265 +0,0 @@ -import attr -from enum import Enum - - -class UrlType(Enum): - HTTP = "http" - GIT = "git" - FILESYSTEM = "filesystem" - SERVER = "server" - - -@attr.s -class MultiPlatformValue(object): - windows = attr.ib(default=None) - linux = attr.ib(default=None) - darwin = attr.ib(default=None) - - -@attr.s -class SourceInfo(object): - type = attr.ib() - - -@attr.s -class LocalSourceInfo(SourceInfo): - path = attr.ib(default=attr.Factory(MultiPlatformValue)) - - -@attr.s -class WebSourceInfo(SourceInfo): - url = attr.ib(default=None) - headers = attr.ib(default=None) - filename = attr.ib(default=None) - - -@attr.s -class ServerSourceInfo(SourceInfo): - filename = attr.ib(default=None) - path = attr.ib(default=None) - - -def convert_source(source): - """Create source object from data information. - - Args: - source (Dict[str, any]): Information about source. - - Returns: - Union[None, SourceInfo]: Object with source information if type is - known. - """ - - source_type = source.get("type") - if not source_type: - return None - - if source_type == UrlType.FILESYSTEM.value: - return LocalSourceInfo( - type=source_type, - path=source["path"] - ) - - if source_type == UrlType.HTTP.value: - url = source["path"] - return WebSourceInfo( - type=source_type, - url=url, - headers=source.get("headers"), - filename=source.get("filename") - ) - - if source_type == UrlType.SERVER.value: - return ServerSourceInfo( - type=source_type, - filename=source.get("filename"), - path=source.get("path") - ) - - -def prepare_sources(src_sources): - sources = [] - unknown_sources = [] - for source in (src_sources or []): - dependency_source = convert_source(source) - if dependency_source is not None: - sources.append(dependency_source) - else: - print(f"Unknown source {source.get('type')}") - unknown_sources.append(source) - return sources, unknown_sources - - -@attr.s -class VersionData(object): - version_data = attr.ib(default=None) - - -@attr.s -class AddonVersionInfo(object): - version = attr.ib() - full_name = attr.ib() - title = attr.ib(default=None) - require_distribution = attr.ib(default=False) - sources = attr.ib(default=attr.Factory(list)) - unknown_sources = attr.ib(default=attr.Factory(list)) - hash = attr.ib(default=None) - - @classmethod - def from_dict( - cls, addon_name, addon_title, addon_version, version_data - ): - """Addon version info. - - Args: - addon_name (str): Name of addon. - addon_title (str): Title of addon. - addon_version (str): Version of addon. - version_data (dict[str, Any]): Addon version information from - server. - - Returns: - AddonVersionInfo: Addon version info. - """ - - full_name = f"{addon_name}_{addon_version}" - title = f"{addon_title} {addon_version}" - - source_info = version_data.get("clientSourceInfo") - require_distribution = source_info is not None - sources, unknown_sources = prepare_sources(source_info) - - return cls( - version=addon_version, - full_name=full_name, - require_distribution=require_distribution, - sources=sources, - unknown_sources=unknown_sources, - hash=version_data.get("hash"), - title=title - ) - - -@attr.s -class AddonInfo(object): - """Object matching json payload from Server""" - name = attr.ib() - versions = attr.ib(default=attr.Factory(dict)) - title = attr.ib(default=None) - description = attr.ib(default=None) - license = attr.ib(default=None) - authors = attr.ib(default=None) - - @classmethod - def from_dict(cls, data): - """Addon info by available versions. - - Args: - data (dict[str, Any]): Addon information from server. Should - contain information about every version under 'versions'. - - Returns: - AddonInfo: Addon info with available versions. - """ - - # server payload contains info about all versions - addon_name = data["name"] - title = data.get("title") or addon_name - - src_versions = data.get("versions") or {} - dst_versions = { - addon_version: AddonVersionInfo.from_dict( - addon_name, title, addon_version, version_data - ) - for addon_version, version_data in src_versions.items() - } - return cls( - name=addon_name, - versions=dst_versions, - description=data.get("description"), - title=data.get("title") or addon_name, - license=data.get("license"), - authors=data.get("authors") - ) - - -@attr.s -class DependencyItem(object): - """Object matching payload from Server about single dependency package""" - name = attr.ib() - platform_name = attr.ib() - checksum = attr.ib() - sources = attr.ib(default=attr.Factory(list)) - unknown_sources = attr.ib(default=attr.Factory(list)) - source_addons = attr.ib(default=attr.Factory(dict)) - python_modules = attr.ib(default=attr.Factory(dict)) - - @classmethod - def from_dict(cls, package): - src_sources = package.get("sources") or [] - for source in src_sources: - if source.get("type") == "server" and not source.get("filename"): - source["filename"] = package["filename"] - sources, unknown_sources = prepare_sources(src_sources) - return cls( - name=package["filename"], - platform_name=package["platform"], - sources=sources, - unknown_sources=unknown_sources, - checksum=package["checksum"], - source_addons=package["sourceAddons"], - python_modules=package["pythonModules"] - ) - - -@attr.s -class Installer: - version = attr.ib() - filename = attr.ib() - platform_name = attr.ib() - size = attr.ib() - checksum = attr.ib() - python_version = attr.ib() - python_modules = attr.ib() - sources = attr.ib(default=attr.Factory(list)) - unknown_sources = attr.ib(default=attr.Factory(list)) - - @classmethod - def from_dict(cls, installer_info): - sources, unknown_sources = prepare_sources( - installer_info.get("sources")) - - return cls( - version=installer_info["version"], - filename=installer_info["filename"], - platform_name=installer_info["platform"], - size=installer_info["size"], - sources=sources, - unknown_sources=unknown_sources, - checksum=installer_info["checksum"], - python_version=installer_info["pythonVersion"], - python_modules=installer_info["pythonModules"] - ) - - -@attr.s -class Bundle: - """Class representing bundle information.""" - - name = attr.ib() - installer_version = attr.ib() - addon_versions = attr.ib(default=attr.Factory(dict)) - dependency_packages = attr.ib(default=attr.Factory(dict)) - is_production = attr.ib(default=False) - is_staging = attr.ib(default=False) - - @classmethod - def from_dict(cls, data): - return cls( - name=data["name"], - installer_version=data.get("installerVersion"), - addon_versions=data.get("addons", {}), - dependency_packages=data.get("dependencyPackages", {}), - is_production=data["isProduction"], - is_staging=data["isStaging"], - ) diff --git a/common/ayon_common/distribution/downloaders.py b/common/ayon_common/distribution/downloaders.py deleted file mode 100644 index 23280176c3..0000000000 --- a/common/ayon_common/distribution/downloaders.py +++ /dev/null @@ -1,250 +0,0 @@ -import os -import logging -import platform -from abc import ABCMeta, abstractmethod - -import ayon_api - -from .file_handler import RemoteFileHandler -from .data_structures import UrlType - - -class SourceDownloader(metaclass=ABCMeta): - """Abstract class for source downloader.""" - - log = logging.getLogger(__name__) - - @classmethod - @abstractmethod - def download(cls, source, destination_dir, data, transfer_progress): - """Returns url of downloaded addon zip file. - - Tranfer progress can be ignored, in that case file transfer won't - be shown as 0-100% but as 'running'. First step should be to set - destination content size and then add transferred chunk sizes. - - Args: - source (dict): {type:"http", "url":"https://} ...} - destination_dir (str): local folder to unzip - data (dict): More information about download content. Always have - 'type' key in. - transfer_progress (ayon_api.TransferProgress): Progress of - transferred (copy/download) content. - - Returns: - (str) local path to addon zip file - """ - - pass - - @classmethod - @abstractmethod - def cleanup(cls, source, destination_dir, data): - """Cleanup files when distribution finishes or crashes. - - Cleanup e.g. temporary files (downloaded zip) or other related stuff - to downloader. - """ - - pass - - @classmethod - def check_hash(cls, addon_path, addon_hash, hash_type="sha256"): - """Compares 'hash' of downloaded 'addon_url' file. - - Args: - addon_path (str): Local path to addon file. - addon_hash (str): Hash of downloaded file. - hash_type (str): Type of hash. - - Raises: - ValueError if hashes doesn't match - """ - - if not os.path.exists(addon_path): - raise ValueError(f"{addon_path} doesn't exist.") - if not RemoteFileHandler.check_integrity( - addon_path, addon_hash, hash_type=hash_type - ): - raise ValueError(f"{addon_path} doesn't match expected hash.") - - @classmethod - def unzip(cls, addon_zip_path, destination_dir): - """Unzips local 'addon_zip_path' to 'destination'. - - Args: - addon_zip_path (str): local path to addon zip file - destination_dir (str): local folder to unzip - """ - - RemoteFileHandler.unzip(addon_zip_path, destination_dir) - os.remove(addon_zip_path) - - -class OSDownloader(SourceDownloader): - """Downloader using files from file drive.""" - - @classmethod - def download(cls, source, destination_dir, data, transfer_progress): - # OS doesn't need to download, unzip directly - addon_url = source["path"].get(platform.system().lower()) - if not os.path.exists(addon_url): - raise ValueError(f"{addon_url} is not accessible") - return addon_url - - @classmethod - def cleanup(cls, source, destination_dir, data): - # Nothing to do - download does not copy anything - pass - - -class HTTPDownloader(SourceDownloader): - """Downloader using http or https protocol.""" - - CHUNK_SIZE = 100000 - - @staticmethod - def get_filename(source): - source_url = source["url"] - filename = source.get("filename") - if not filename: - filename = os.path.basename(source_url) - basename, ext = os.path.splitext(filename) - allowed_exts = set(RemoteFileHandler.IMPLEMENTED_ZIP_FORMATS) - if ext.lower().lstrip(".") not in allowed_exts: - filename = f"{basename}.zip" - return filename - - @classmethod - def download(cls, source, destination_dir, data, transfer_progress): - source_url = source["url"] - cls.log.debug(f"Downloading {source_url} to {destination_dir}") - headers = source.get("headers") - filename = cls.get_filename(source) - - # TODO use transfer progress - RemoteFileHandler.download_url( - source_url, - destination_dir, - filename, - headers=headers - ) - - return os.path.join(destination_dir, filename) - - @classmethod - def cleanup(cls, source, destination_dir, data): - filename = cls.get_filename(source) - filepath = os.path.join(destination_dir, filename) - if os.path.exists(filepath) and os.path.isfile(filepath): - os.remove(filepath) - - -class AyonServerDownloader(SourceDownloader): - """Downloads static resource file from AYON Server. - - Expects filled env var AYON_SERVER_URL. - """ - - CHUNK_SIZE = 8192 - - @classmethod - def download(cls, source, destination_dir, data, transfer_progress): - path = source["path"] - filename = source["filename"] - if path and not filename: - filename = path.split("/")[-1] - - cls.log.debug(f"Downloading {filename} to {destination_dir}") - - _, ext = os.path.splitext(filename) - ext = ext.lower().lstrip(".") - valid_exts = set(RemoteFileHandler.IMPLEMENTED_ZIP_FORMATS) - if ext not in valid_exts: - raise ValueError(( - f"Invalid file extension \"{ext}\"." - f" Expected {', '.join(valid_exts)}" - )) - - if path: - filepath = os.path.join(destination_dir, filename) - return ayon_api.download_file( - path, - filepath, - chunk_size=cls.CHUNK_SIZE, - progress=transfer_progress - ) - - # dst_filepath = os.path.join(destination_dir, filename) - if data["type"] == "dependency_package": - return ayon_api.download_dependency_package( - data["name"], - destination_dir, - filename, - platform_name=data["platform"], - chunk_size=cls.CHUNK_SIZE, - progress=transfer_progress - ) - - if data["type"] == "addon": - return ayon_api.download_addon_private_file( - data["name"], - data["version"], - filename, - destination_dir, - chunk_size=cls.CHUNK_SIZE, - progress=transfer_progress - ) - - raise ValueError(f"Unknown type to download \"{data['type']}\"") - - @classmethod - def cleanup(cls, source, destination_dir, data): - filename = source["filename"] - filepath = os.path.join(destination_dir, filename) - if os.path.exists(filepath) and os.path.isfile(filepath): - os.remove(filepath) - - -class DownloadFactory: - """Factory for downloaders.""" - - def __init__(self): - self._downloaders = {} - - def register_format(self, downloader_type, downloader): - """Register downloader for download type. - - Args: - downloader_type (UrlType): Type of source. - downloader (SourceDownloader): Downloader which cares about - download, hash check and unzipping. - """ - - self._downloaders[downloader_type.value] = downloader - - def get_downloader(self, downloader_type): - """Registered downloader for type. - - Args: - downloader_type (UrlType): Type of source. - - Returns: - SourceDownloader: Downloader object which should care about file - distribution. - - Raises: - ValueError: If type does not have registered downloader. - """ - - if downloader := self._downloaders.get(downloader_type): - return downloader() - raise ValueError(f"{downloader_type} not implemented") - - -def get_default_download_factory(): - download_factory = DownloadFactory() - download_factory.register_format(UrlType.FILESYSTEM, OSDownloader) - download_factory.register_format(UrlType.HTTP, HTTPDownloader) - download_factory.register_format(UrlType.SERVER, AyonServerDownloader) - return download_factory diff --git a/common/ayon_common/distribution/file_handler.py b/common/ayon_common/distribution/file_handler.py deleted file mode 100644 index 07f6962c98..0000000000 --- a/common/ayon_common/distribution/file_handler.py +++ /dev/null @@ -1,289 +0,0 @@ -import os -import re -import urllib -from urllib.parse import urlparse -import urllib.request -import urllib.error -import itertools -import hashlib -import tarfile -import zipfile - -import requests - -USER_AGENT = "AYON-launcher" - - -class RemoteFileHandler: - """Download file from url, might be GDrive shareable link""" - - IMPLEMENTED_ZIP_FORMATS = { - "zip", "tar", "tgz", "tar.gz", "tar.xz", "tar.bz2" - } - - @staticmethod - def calculate_md5(fpath, chunk_size=10000): - md5 = hashlib.md5() - with open(fpath, "rb") as f: - for chunk in iter(lambda: f.read(chunk_size), b""): - md5.update(chunk) - return md5.hexdigest() - - @staticmethod - def check_md5(fpath, md5, **kwargs): - return md5 == RemoteFileHandler.calculate_md5(fpath, **kwargs) - - @staticmethod - def calculate_sha256(fpath): - """Calculate sha256 for content of the file. - - Args: - fpath (str): Path to file. - - Returns: - str: hex encoded sha256 - - """ - h = hashlib.sha256() - b = bytearray(128 * 1024) - mv = memoryview(b) - with open(fpath, "rb", buffering=0) as f: - for n in iter(lambda: f.readinto(mv), 0): - h.update(mv[:n]) - return h.hexdigest() - - @staticmethod - def check_sha256(fpath, sha256, **kwargs): - return sha256 == RemoteFileHandler.calculate_sha256(fpath, **kwargs) - - @staticmethod - def check_integrity(fpath, hash_value=None, hash_type=None): - if not os.path.isfile(fpath): - return False - if hash_value is None: - return True - if not hash_type: - raise ValueError("Provide hash type, md5 or sha256") - if hash_type == "md5": - return RemoteFileHandler.check_md5(fpath, hash_value) - if hash_type == "sha256": - return RemoteFileHandler.check_sha256(fpath, hash_value) - - @staticmethod - def download_url( - url, - root, - filename=None, - max_redirect_hops=3, - headers=None - ): - """Download a file from url and place it in root. - - Args: - url (str): URL to download file from - root (str): Directory to place downloaded file in - filename (str, optional): Name to save the file under. - If None, use the basename of the URL - max_redirect_hops (Optional[int]): Maximum number of redirect - hops allowed - headers (Optional[dict[str, str]]): Additional required headers - - Authentication etc.. - """ - - root = os.path.expanduser(root) - if not filename: - filename = os.path.basename(url) - fpath = os.path.join(root, filename) - - os.makedirs(root, exist_ok=True) - - # expand redirect chain if needed - url = RemoteFileHandler._get_redirect_url( - url, max_hops=max_redirect_hops, headers=headers) - - # check if file is located on Google Drive - file_id = RemoteFileHandler._get_google_drive_file_id(url) - if file_id is not None: - return RemoteFileHandler.download_file_from_google_drive( - file_id, root, filename) - - # download the file - try: - print(f"Downloading {url} to {fpath}") - RemoteFileHandler._urlretrieve(url, fpath, headers=headers) - except (urllib.error.URLError, IOError) as exc: - if url[:5] != "https": - raise exc - - url = url.replace("https:", "http:") - print(( - "Failed download. Trying https -> http instead." - f" Downloading {url} to {fpath}" - )) - RemoteFileHandler._urlretrieve(url, fpath, headers=headers) - - @staticmethod - def download_file_from_google_drive( - file_id, root, filename=None - ): - """Download a Google Drive file from and place it in root. - Args: - file_id (str): id of file to be downloaded - root (str): Directory to place downloaded file in - filename (str, optional): Name to save the file under. - If None, use the id of the file. - """ - # Based on https://stackoverflow.com/questions/38511444/python-download-files-from-google-drive-using-url # noqa - - url = "https://docs.google.com/uc?export=download" - - root = os.path.expanduser(root) - if not filename: - filename = file_id - fpath = os.path.join(root, filename) - - os.makedirs(root, exist_ok=True) - - if os.path.isfile(fpath) and RemoteFileHandler.check_integrity(fpath): - print(f"Using downloaded and verified file: {fpath}") - else: - session = requests.Session() - - response = session.get(url, params={"id": file_id}, stream=True) - token = RemoteFileHandler._get_confirm_token(response) - - if token: - params = {"id": file_id, "confirm": token} - response = session.get(url, params=params, stream=True) - - response_content_generator = response.iter_content(32768) - first_chunk = None - while not first_chunk: # filter out keep-alive new chunks - first_chunk = next(response_content_generator) - - if RemoteFileHandler._quota_exceeded(first_chunk): - msg = ( - f"The daily quota of the file {filename} is exceeded and " - f"it can't be downloaded. This is a limitation of " - f"Google Drive and can only be overcome by trying " - f"again later." - ) - raise RuntimeError(msg) - - RemoteFileHandler._save_response_content( - itertools.chain((first_chunk, ), - response_content_generator), fpath) - response.close() - - @staticmethod - def unzip(path, destination_path=None): - if not destination_path: - destination_path = os.path.dirname(path) - - _, archive_type = os.path.splitext(path) - archive_type = archive_type.lstrip(".") - - if archive_type in ["zip"]: - print(f"Unzipping {path}->{destination_path}") - zip_file = zipfile.ZipFile(path) - zip_file.extractall(destination_path) - zip_file.close() - - elif archive_type in [ - "tar", "tgz", "tar.gz", "tar.xz", "tar.bz2" - ]: - print(f"Unzipping {path}->{destination_path}") - if archive_type == "tar": - tar_type = "r:" - elif archive_type.endswith("xz"): - tar_type = "r:xz" - elif archive_type.endswith("gz"): - tar_type = "r:gz" - elif archive_type.endswith("bz2"): - tar_type = "r:bz2" - else: - tar_type = "r:*" - try: - tar_file = tarfile.open(path, tar_type) - except tarfile.ReadError: - raise SystemExit("corrupted archive") - tar_file.extractall(destination_path) - tar_file.close() - - @staticmethod - def _urlretrieve(url, filename, chunk_size=None, headers=None): - final_headers = {"User-Agent": USER_AGENT} - if headers: - final_headers.update(headers) - - chunk_size = chunk_size or 8192 - with open(filename, "wb") as fh: - with urllib.request.urlopen( - urllib.request.Request(url, headers=final_headers) - ) as response: - for chunk in iter(lambda: response.read(chunk_size), ""): - if not chunk: - break - fh.write(chunk) - - @staticmethod - def _get_redirect_url(url, max_hops, headers=None): - initial_url = url - final_headers = {"Method": "HEAD", "User-Agent": USER_AGENT} - if headers: - final_headers.update(headers) - for _ in range(max_hops + 1): - with urllib.request.urlopen( - urllib.request.Request(url, headers=final_headers) - ) as response: - if response.url == url or response.url is None: - return url - - return response.url - else: - raise RecursionError( - f"Request to {initial_url} exceeded {max_hops} redirects. " - f"The last redirect points to {url}." - ) - - @staticmethod - def _get_confirm_token(response): - for key, value in response.cookies.items(): - if key.startswith("download_warning"): - return value - - # handle antivirus warning for big zips - found = re.search("(confirm=)([^&.+])", response.text) - if found: - return found.groups()[1] - - return None - - @staticmethod - def _save_response_content( - response_gen, destination, - ): - with open(destination, "wb") as f: - for chunk in response_gen: - if chunk: # filter out keep-alive new chunks - f.write(chunk) - - @staticmethod - def _quota_exceeded(first_chunk): - try: - return "Google Drive - Quota exceeded" in first_chunk.decode() - except UnicodeDecodeError: - return False - - @staticmethod - def _get_google_drive_file_id(url): - parts = urlparse(url) - - if re.match(r"(drive|docs)[.]google[.]com", parts.netloc) is None: - return None - - match = re.match(r"/file/d/(?P[^/]*)", parts.path) - if match is None: - return None - - return match.group("id") diff --git a/common/ayon_common/distribution/tests/test_addon_distributtion.py b/common/ayon_common/distribution/tests/test_addon_distributtion.py deleted file mode 100644 index 3e7bd1bc6a..0000000000 --- a/common/ayon_common/distribution/tests/test_addon_distributtion.py +++ /dev/null @@ -1,248 +0,0 @@ -import os -import sys -import copy -import tempfile - - -import attr -import pytest - -current_dir = os.path.dirname(os.path.abspath(__file__)) -root_dir = os.path.abspath(os.path.join(current_dir, "..", "..", "..", "..")) -sys.path.append(root_dir) - -from common.ayon_common.distribution.downloaders import ( - DownloadFactory, - OSDownloader, - HTTPDownloader, -) -from common.ayon_common.distribution.control import ( - AyonDistribution, - UpdateState, -) -from common.ayon_common.distribution.data_structures import ( - AddonInfo, - UrlType, -) - - -@pytest.fixture -def download_factory(): - addon_downloader = DownloadFactory() - addon_downloader.register_format(UrlType.FILESYSTEM, OSDownloader) - addon_downloader.register_format(UrlType.HTTP, HTTPDownloader) - - yield addon_downloader - - -@pytest.fixture -def http_downloader(download_factory): - yield download_factory.get_downloader(UrlType.HTTP.value) - - -@pytest.fixture -def temp_folder(): - yield tempfile.mkdtemp(prefix="ayon_test_") - - -@pytest.fixture -def sample_bundles(): - yield { - "bundles": [ - { - "name": "TestBundle", - "createdAt": "2023-06-29T00:00:00.0+00:00", - "installerVersion": None, - "addons": { - "slack": "1.0.0" - }, - "dependencyPackages": {}, - "isProduction": True, - "isStaging": False - } - ], - "productionBundle": "TestBundle", - "stagingBundle": None - } - - -@pytest.fixture -def sample_addon_info(): - yield { - "name": "slack", - "title": "Slack addon", - "versions": { - "1.0.0": { - "hasSettings": True, - "hasSiteSettings": False, - "clientPyproject": { - "tool": { - "poetry": { - "dependencies": { - "nxtools": "^1.6", - "orjson": "^3.6.7", - "typer": "^0.4.1", - "email-validator": "^1.1.3", - "python": "^3.10", - "fastapi": "^0.73.0" - } - } - } - }, - "clientSourceInfo": [ - { - "type": "http", - "path": "https://drive.google.com/file/d/1TcuV8c2OV8CcbPeWi7lxOdqWsEqQNPYy/view?usp=sharing", # noqa - "filename": "dummy.zip" - }, - { - "type": "filesystem", - "path": { - "windows": "P:/sources/some_file.zip", - "linux": "/mnt/srv/sources/some_file.zip", - "darwin": "/Volumes/srv/sources/some_file.zip" - } - } - ], - "frontendScopes": { - "project": { - "sidebar": "hierarchy", - } - }, - "hash": "4be25eb6215e91e5894d3c5475aeb1e379d081d3f5b43b4ee15b0891cf5f5658" # noqa - } - }, - "description": "" - } - - -def test_register(printer): - download_factory = DownloadFactory() - - assert len(download_factory._downloaders) == 0, "Contains registered" - - download_factory.register_format(UrlType.FILESYSTEM, OSDownloader) - assert len(download_factory._downloaders) == 1, "Should contain one" - - -def test_get_downloader(printer, download_factory): - assert download_factory.get_downloader(UrlType.FILESYSTEM.value), "Should find" # noqa - - with pytest.raises(ValueError): - download_factory.get_downloader("unknown"), "Shouldn't find" - - -def test_addon_info(printer, sample_addon_info): - """Tests parsing of expected payload from v4 server into AadonInfo.""" - valid_minimum = { - "name": "slack", - "versions": { - "1.0.0": { - "clientSourceInfo": [ - { - "type": "filesystem", - "path": { - "windows": "P:/sources/some_file.zip", - "linux": "/mnt/srv/sources/some_file.zip", - "darwin": "/Volumes/srv/sources/some_file.zip" - } - } - ] - } - } - } - - assert AddonInfo.from_dict(valid_minimum), "Missing required fields" - - addon = AddonInfo.from_dict(sample_addon_info) - assert addon, "Should be created" - assert addon.name == "slack", "Incorrect name" - assert "1.0.0" in addon.versions, "Version is not in versions" - - with pytest.raises(TypeError): - assert addon["name"], "Dict approach not implemented" - - addon_as_dict = attr.asdict(addon) - assert addon_as_dict["name"], "Dict approach should work" - - -def _get_dist_item(dist_items, name, version): - final_dist_info = next( - ( - dist_info - for dist_info in dist_items - if ( - dist_info["addon_name"] == name - and dist_info["addon_version"] == version - ) - ), - {} - ) - return final_dist_info["dist_item"] - - -def test_update_addon_state( - printer, sample_addon_info, temp_folder, download_factory, sample_bundles -): - """Tests possible cases of addon update.""" - - addon_version = list(sample_addon_info["versions"])[0] - broken_addon_info = copy.deepcopy(sample_addon_info) - - # Cause crash because of invalid hash - broken_addon_info["versions"][addon_version]["hash"] = "brokenhash" - distribution = AyonDistribution( - addon_dirpath=temp_folder, - dependency_dirpath=temp_folder, - dist_factory=download_factory, - addons_info=[broken_addon_info], - dependency_packages_info=[], - bundles_info=sample_bundles - ) - distribution.distribute() - dist_items = distribution.get_addon_dist_items() - slack_dist_item = _get_dist_item( - dist_items, - sample_addon_info["name"], - addon_version - ) - slack_state = slack_dist_item.state - assert slack_state == UpdateState.UPDATE_FAILED, ( - "Update should have failed because of wrong hash") - - # Fix cache and validate if was updated - distribution = AyonDistribution( - addon_dirpath=temp_folder, - dependency_dirpath=temp_folder, - dist_factory=download_factory, - addons_info=[sample_addon_info], - dependency_packages_info=[], - bundles_info=sample_bundles - ) - distribution.distribute() - dist_items = distribution.get_addon_dist_items() - slack_dist_item = _get_dist_item( - dist_items, - sample_addon_info["name"], - addon_version - ) - assert slack_dist_item.state == UpdateState.UPDATED, ( - "Addon should have been updated") - - # Is UPDATED without calling distribute - distribution = AyonDistribution( - addon_dirpath=temp_folder, - dependency_dirpath=temp_folder, - dist_factory=download_factory, - addons_info=[sample_addon_info], - dependency_packages_info=[], - bundles_info=sample_bundles - ) - dist_items = distribution.get_addon_dist_items() - slack_dist_item = _get_dist_item( - dist_items, - sample_addon_info["name"], - addon_version - ) - assert slack_dist_item.state == UpdateState.UPDATED, ( - "Addon should already exist") diff --git a/common/ayon_common/distribution/ui/missing_bundle_window.py b/common/ayon_common/distribution/ui/missing_bundle_window.py deleted file mode 100644 index ae7a6a2976..0000000000 --- a/common/ayon_common/distribution/ui/missing_bundle_window.py +++ /dev/null @@ -1,146 +0,0 @@ -import sys - -from qtpy import QtWidgets, QtGui - -from ayon_common import is_staging_enabled -from ayon_common.resources import ( - get_icon_path, - load_stylesheet, -) -from ayon_common.ui_utils import get_qt_app - - -class MissingBundleWindow(QtWidgets.QDialog): - default_width = 410 - default_height = 170 - - def __init__( - self, url=None, bundle_name=None, use_staging=None, parent=None - ): - super().__init__(parent) - - icon_path = get_icon_path() - icon = QtGui.QIcon(icon_path) - self.setWindowIcon(icon) - self.setWindowTitle("Missing Bundle") - - self._url = url - self._bundle_name = bundle_name - self._use_staging = use_staging - self._first_show = True - - info_label = QtWidgets.QLabel("", self) - info_label.setWordWrap(True) - - btns_widget = QtWidgets.QWidget(self) - confirm_btn = QtWidgets.QPushButton("Exit", btns_widget) - - btns_layout = QtWidgets.QHBoxLayout(btns_widget) - btns_layout.setContentsMargins(0, 0, 0, 0) - btns_layout.addStretch(1) - btns_layout.addWidget(confirm_btn, 0) - - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.addWidget(info_label, 0) - main_layout.addStretch(1) - main_layout.addWidget(btns_widget, 0) - - confirm_btn.clicked.connect(self._on_confirm_click) - - self._info_label = info_label - self._confirm_btn = confirm_btn - - self._update_label() - - def set_url(self, url): - if url == self._url: - return - self._url = url - self._update_label() - - def set_bundle_name(self, bundle_name): - if bundle_name == self._bundle_name: - return - self._bundle_name = bundle_name - self._update_label() - - def set_use_staging(self, use_staging): - if self._use_staging == use_staging: - return - self._use_staging = use_staging - self._update_label() - - def showEvent(self, event): - super().showEvent(event) - if self._first_show: - self._first_show = False - self._on_first_show() - self._recalculate_sizes() - - def resizeEvent(self, event): - super().resizeEvent(event) - self._recalculate_sizes() - - def _recalculate_sizes(self): - hint = self._confirm_btn.sizeHint() - new_width = max((hint.width(), hint.height() * 3)) - self._confirm_btn.setMinimumWidth(new_width) - - def _on_first_show(self): - self.setStyleSheet(load_stylesheet()) - self.resize(self.default_width, self.default_height) - - def _on_confirm_click(self): - self.accept() - self.close() - - def _update_label(self): - self._info_label.setText(self._get_label()) - - def _get_label(self): - url_part = f" {self._url}" if self._url else "" - - if self._bundle_name: - return ( - f"Requested release bundle {self._bundle_name}" - f" is not available on server{url_part}." - "

Try to restart AYON desktop launcher. Please" - " contact your administrator if issue persist." - ) - mode = "staging" if self._use_staging else "production" - return ( - f"No release bundle is set as {mode} on the AYON" - f" server{url_part} so there is nothing to launch." - "

Please contact your administrator" - " to resolve the issue." - ) - - -def main(): - """Show message that server does not have set bundle to use. - - It is possible to pass url as argument to show it in the message. To use - this feature, pass `--url ` as argument to this script. - """ - - url = None - bundle_name = None - if "--url" in sys.argv: - url_index = sys.argv.index("--url") + 1 - if url_index < len(sys.argv): - url = sys.argv[url_index] - - if "--bundle" in sys.argv: - bundle_index = sys.argv.index("--bundle") + 1 - if bundle_index < len(sys.argv): - bundle_name = sys.argv[bundle_index] - - use_staging = is_staging_enabled() - app = get_qt_app() - window = MissingBundleWindow(url, bundle_name, use_staging) - window.show() - app.exec_() - - -if __name__ == "__main__": - main() diff --git a/common/ayon_common/distribution/utils.py b/common/ayon_common/distribution/utils.py deleted file mode 100644 index a8b755707a..0000000000 --- a/common/ayon_common/distribution/utils.py +++ /dev/null @@ -1,90 +0,0 @@ -import os -import subprocess - -from ayon_common.utils import get_ayon_appdirs, get_ayon_launch_args - - -def get_local_dir(*subdirs): - """Get product directory in user's home directory. - - Each user on machine have own local directory where are downloaded updates, - addons etc. - - Returns: - str: Path to product local directory. - """ - - if not subdirs: - raise ValueError("Must fill dir_name if nothing else provided!") - - local_dir = get_ayon_appdirs(*subdirs) - if not os.path.isdir(local_dir): - try: - os.makedirs(local_dir) - except Exception: # TODO fix exception - raise RuntimeError(f"Cannot create {local_dir}") - - return local_dir - - -def get_addons_dir(): - """Directory where addon packages are stored. - - Path to addons is defined using python module 'appdirs' which - - The path is stored into environment variable 'AYON_ADDONS_DIR'. - Value of environment variable can be overriden, but we highly recommended - to use that option only for development purposes. - - Returns: - str: Path to directory where addons should be downloaded. - """ - - addons_dir = os.environ.get("AYON_ADDONS_DIR") - if not addons_dir: - addons_dir = get_local_dir("addons") - os.environ["AYON_ADDONS_DIR"] = addons_dir - return addons_dir - - -def get_dependencies_dir(): - """Directory where dependency packages are stored. - - Path to addons is defined using python module 'appdirs' which - - The path is stored into environment variable 'AYON_DEPENDENCIES_DIR'. - Value of environment variable can be overriden, but we highly recommended - to use that option only for development purposes. - - Returns: - str: Path to directory where dependency packages should be downloaded. - """ - - dependencies_dir = os.environ.get("AYON_DEPENDENCIES_DIR") - if not dependencies_dir: - dependencies_dir = get_local_dir("dependency_packages") - os.environ["AYON_DEPENDENCIES_DIR"] = dependencies_dir - return dependencies_dir - - -def show_missing_bundle_information(url, bundle_name=None): - """Show missing bundle information window. - - This function should be called when server does not have set bundle for - production or staging, or when bundle that should be used is not available - on server. - - Using subprocess to show the dialog. Is blocking and is waiting until - dialog is closed. - - Args: - url (str): Server url where bundle is not set. - bundle_name (Optional[str]): Name of bundle that was not found. - """ - - ui_dir = os.path.join(os.path.dirname(__file__), "ui") - script_path = os.path.join(ui_dir, "missing_bundle_window.py") - args = get_ayon_launch_args(script_path, "--skip-bootstrap", "--url", url) - if bundle_name: - args.extend(["--bundle", bundle_name]) - subprocess.call(args) diff --git a/common/ayon_common/resources/AYON.icns b/common/ayon_common/resources/AYON.icns deleted file mode 100644 index 2ec66cf3e0bb522c40209b5f1767ffd01014b0a2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40634 zcmZsC1CS^%ljhjAZQFih+qP}nwr$(CZQIrx&%J+d>+0%mQ%U;kPSU9))k#GbMz&4> z0J)tOMhu+)Xd?gs0F0$P0Rb#j7}P(EV(RQ+Z)wj&@DBt0H-dXqLr$ zkaI|^!oX1Fox@^*3j&(y`ok)l;GZ8mzk@^8QgIf-{m@Er=Ty@2=3lG?QPn;k)}TgR z2rYMRU||I5aveT4}zC>9Z=d(NqFT@sbKV}j3+U!i7M2L)5{B?ne9Az);z1MyIZ z4RL48J(gidtVZWj78gmyA9X<8{eceFa<<6@I@0FT$GsO){l5p71YR`7gG@JN$cP4h!+Fvf)vhZ6 z7cu2dHw&1~1LqT7kO;5Ufl2%YNQ$^XQ2$HD8qu+SLIm&K?Glbt&U!$Yrz7Z)N!VFx z&=RNgD(=^39wzs$ zxUR0lZsD_$^?{Omh-+#yS|v64p_)7ahP)hvM@5y|tg1!?^yT5&ar{AMa|aZuiSy2K zQX$N6h^bn!;U{I_2#Q}G1LGbo!e~ZQX_XuA*Kde_Y?B$^eAyRRAm9&0K%MU&>i!N= zu9{-WbO!l5NaU9}d%M6wGZPijl65pQ$(vI%#~=6fK@tcGKYEkCKwHpRAasP&pwqZ} zRTi_}Uo;JEf2Zou8mqa%oV*Y<{16U$$n}ZT3hI0GUsfh;6`Cj~Wu`E`d4JCGZyCd} zXAhc4Fdmzb}(zOEL8O#Gw zs6BTM<7%aH{;A48PJ0yqyj4`+3q=`86(qZu(^St zr-x{tw6G+XLY25g;r@4utE%_} z=P${3v72`%CtG?)S0P~KvKtN>aQmQWW;>j+>=R{wrx> zQUNe}{uEyXX={yTOjYhaxQB1pmWZ&u=C~EIoFsqj5ZccG)KDEF4`b_X%;EREObBk`8{W#6J-g$ z&v_vHED4q%=B#W+2H|wrKai~y&sm7U!N71pch-Rgu{<<%&xK#Vulk-iPg+SYS$gI1 ze29e(Rz{Ps?%#-AFcO z(4qb*SWUB0(KnF>5oj0rfV&?K=Z4Wnev!($%@~3zdQ}XjjBtW?G z9f+603S4l516^raElWFOGzd5BfKAJPbMy>alIAJq7!L%{O!*sMgNl`BWiLzki-O_{ z$G=7?nBktA4y|&xG!H4%2N+UadR8I@Uz2@W2M(}`n(nk%ssSdGN&yCp@S|2D0srP8 ziC$O)ntBv1)&Z@lleFHp#eSYcszyX;P|EGJMRmmyt6y?qJL~< z7~K)k8>!2{lg)5Autz1$YLAwA;z75f8+U;S)V0ZpW` zARV@_`=j<882lU;e()eGcBzA9r`tu+%8lveYECho_g zlrpYC7@CDLJ??JWGe9Zgr^B*!rBI)2UlNOr=`W!-KiZhs;#k%RN^wzWb$Wc>tDM6* zSydPdukyk7yl&+^uVpqbuIkRbVeY?2Wn-+u)?xFcpX3YyqJrs)oOI`q-n+Q)@bv8L zNg~Mle+8QK(-3Ccu38=~%)$4Pb}-R)(Y`mmk%453LSOd{$_5*LC?6<@D_V3R@SQ`U z>J;FvqI8Z9k1_Ya3(rDwFNfHJ7SoN5JSBvPm61HCY4{bENZWaPUK-V)%Wg9L;v!aD z!q6-%s<7U~5iD_9Eq;t>+J+G<24e`zTO>!{VYZ(41u`*a^wZeBHfd(7M_B#c`et+w zb6|WWSHMs3RJ+BprVEZ3m+*R<@(dvO#x}DrhjZ1&r5c&29Z_ri3<#^bO)|>+&Ea&j z3>9Iw-^dbSZ7{2g9d2s7X^t!#*%4`4Um6Gv5yB0ohK)9C)wKc+q|Y4X;PpqU32o@X z+^#AEuPp>rt7qW_GYNaUO!d*zK<*m&ts1!!GeAf4taUSI{|3oKIiu}=U4{)(_+gEzzXumC#?`UbR+q95q_gYOZpQ>05vIhDz>@H!K?1=V|xtqLap}q6HZz;LWa1m zjA;$_N6X<_jhkxj(qM3ID34)_AO-lK*t2VY>Ed{7^N-9F7Oo? zRVeGkavxh4xQRN{T6PQz*)S7qm}1V8q>jdJYp^Xz8`}Ha*n4s>E=TreG?!L(1Aj9@ z<8UUXl7t;PFtc^g1B00w_A&1)s2ZL<=>dIPvNRb`ypU!4g}pQY>H$ASZ9YDNXNOM& zspT75L)@k-72;-b^D!ky>sPKf^C$X~*WKtDR#I0z`Ar%pq=uA4`dBdL|C)R=%HB-= zDN!wTc0lBEblG}$tjy&HcYo*0q1Zstf*X(vgxD&Mlea}pFRlqSnGWlQlInbIEjhfH2F%A=6z0TG zpU^PNvGW(q=_}|qo6*Ck*@!)o?rY2Yv)iGMq+@gKJ3AKxqESb4U{#8k>TzT`GN}?IgPR)?m|+8o=o|F19A)<`5WW zaTB#3NhRRV(z81hhyhB7PXy{;`rQnr_cE?3p(0gOdsK&#`0&&%3n%gc9f_V3C<8Zk zmCXI>21J7d+w^QfS&thn{iF=XBzc9_ccyeVpk)`>LOMG4nK6_Y-QQ`^>X2AZb+5*W)EW zAg2NC0oM2-5JOf$%p_WOnwr65drT4n|wo&;f7`}ot|G*#E{%!J;2XER6@KOyS z3rOh9Ob0eCeuI-}=|O_2J*5U&peSiurh(uB^FeYY4IJ6|q7%MY@F-!PQX>@tQdxM+ z7G?3=jhV{f)&8n@-e+MC=7Hb9c{7>L%K(%v0*WAXI1DkvI}}r?sttfkG~&2AqT?5s zeJ1KKF$?sC&G^+7lF+mdHX~Ve%Mf;xT<+hb5heasDBStXmKl(Act#xsQEIH5XC!Cq zNTV7_{?1VL0HWjq%{&Wz*D$I8YTt655XOUOckJu|#;bThLn_;^BtIv-1$nnD*C(z! zdVe8|g*|-s-bOg;z}11xBo{DPInh}4dQk{hAWzUew?EI=Vq1Ot$>++3xq%1M1OlFTRfdP8niNk2!>%? zQP+CAv3_NF`7AsmEKMfGQZ_+RCH#RblrjL3Zc?B|F%LnEu-S}ZsIBIHY!fi(E80l??Q=J}90iDl1 zj69Q-+7_Ip64#n>zGRHbVs>O8CAqP<8*lvCh;#Hz4{5|TBp@FTv+VgQ16PW~YI93|@}gF=}v&n-6FY z#gNV|Tc|OepgTi@<1sL^Fz}4f0J=|IFYOW~{I}UNg2~L)eb|Ohc97nQJihC%} zxG$bb)5x{RF5GvG67Cu%HBf_<5INIdRbFC=yLMK;hFkYwCV6fzr;Q&LATOu$z~LuQ zK*Bu$P|&5oQA-C2H-F}3CXKemdTz8NMn_>wXApSXQ4FBC!R%eHDQO-L_pbh-;X3_5 zt?^tH`xu!`tN89s(ToVQayTapms*)OqAT6nbpc?MH5Nc>}zy6n1tRpphH!#87vv^D%r@!nM617)pKjIPN^y1|}Kjv6-p7Mw@Nx3WWu65hibF;JWhk|g7_4**vQ0rUJ~ zE5BMecu|XsR>3PwSfr-v-j8Mm#x6IpYz?L|mYo@Q3Q;HGalSH1NTJE6Hoo4nEVwZc z<$Q`bK34T~A^=WXMGF@_R*>NYFhyn(Xu@KgHee2etr(-}w}wR-?sZvdI@!PBdCJ0U|Gxv1nfG`;;A>Sm5`P zoLJ9iqsOlN1#lMNZZ#R#@S4S_)H}-&-s#lo5)p%W2xT7n8wqt@=z=`{m@(a4ja!pF zM3gIp62o7XaW-zls9Dq>|6yE%@>F}Fu^4MC14wtCGnxxo9nJeCpRaOqJDl+sc&nI< zEbi_FqKVbA1)eM|G;C^*ZOd`~k5`gebASv<~J24hR`vO}`Y(d|(Dg{Httb z!x6e=JN6i>1lf;iY+!DvR^!cO0zXU@VDWLmf1TkGmmiOy-Po}zMmh@*(Ab=3k*E{5 z!sRv`bu=-Sl|sDp>HKosXY;3D2}~41xwojKB;aS8aw19Jex1P~E0>vgD|!9z4yhnN z_p1tw%tYzUR*0>phfUnC%Trfo#V3S;CJ0GqlQFeO3K*R2lHU5)bMYj<1ymV661HXYn(O>ct&<6la3P~GNQLv^E!7?_OJZWgWM_-iRK zZ#dI95S>+eybYBETRBQiC4rrT`H`%Sw%E%n$w@Fx=O>q%Q5&%}v?-$sO>}tr5AQCE zrF_n<-A11^QdChn6;bq>BGXAD{DU}Yd=o*Z&qu`#Xs)Ei$bk)0QkHEx!n>tgt@ldR zFwIA3$%GF*4rXft6%sLi4J^!&XmfjIwS>zx`AUDB7;s~!FX-7R9~kd)z+#r;$zy>| zp+yNWq?DaOS>s;G!mbt|hvKBEY~?{cMp+MjDBQQ4Gq4K~|kul%;Lfv zqmxXT6|>I<{8jH1E52CEn+h;A5fQ}DgC7NbvNX%Z5~mZ~-6}&xl)GqU8RiRJt=@LM zA9&4s0N-A+VyHe_TWvIYa0WWL1VrbmUqo7@k!&na#3(u#FqoqIS|R{M)C~{$H94NY z(y$G&icF`Q+wEygh(P8}E+O>52M%P$NHXlR5FL=us&brzmL3x>XB(>e)Yn?7A!w9i zlL5{|rg&*`V$${M3O*7Q|y{ z>5c?)>D?6k7;yGt{X!ep$uf|f(-S^hD|&Mf-6^gGQw0x@Ieo451vK(`t~J91_9$|n z%5^|!JG&nWs~0=kVfoe&2_u7o%EjhasK6q;$>BTTR4M-^qd=88MC*Y=-PIs3fF8Xx zPsugj?LelM?eH3jcja$b_&8Dq`$RYy2kQrTXT%xxNe$4mj4cCAWc3>bTY03^?zDw> zZk_%+FY!tAE4q3F;8|v$$EyuhmiyTj_HF=cS_~ax#V1n=FV47G!Y3A!P=9xa)HlCw zk>{9EuBSeVpiI@yv-yNtkN?D!(_hyStIDN)HQ+g=jQp0bnrj0?2);5Q0-2N_ifFr% zGZ@3zl;mV}hAA8%Z%PBvfDG_*T85!93bIBn_^E~u-VNcX>%y9D zpcm455X*2|hVZ>;oPpz6h;DT#b7ylo!W|fvw(^v;gI*Qgcz6WeL0L0s{xF zVWBtbF1ygngS*UNRIxzog$tGWhv3fr;V+nXfCLE-xb|(Z&!5W93*?z8MLg!*@h`)r z(F`M(9R?Zl-Jv@B8S{6EClbF#6pKHJFJM~8{T8&398ky8>KnFgI&|}NarTT-Wud?p zy=xv#2(y&^Q)PMW(^$FQ-T6#kiCpNx{SbNVx{iCB%MXvQL%Yk0b6VqWdRCa^Y^yQO zQO2jzH)R+lqDOX9} zv66-r9;%`V{YBrhr4srcfmoSs8}>Jau$g2_&5gP$x<snZS^Iu0*U39WeYw{)=re-T=yZ z1+Fd!fG5di4z!sxH&oUB>Dd}LRDtjY(!^i(UNr{c{}+#|{}kIwgX^6Xj*?@^x$@&y zO5;hf93a{mMc9vMz^jM=C<4~26C);}*CxQBoIA^<_X(${1Q>g^MX%>_2HDy8Rhr|kE7@gb*{}Fumm_O#Hp5TcP~+=3!7E1r*z$Y@cMXjZTjXH>!NOlu zicz-w8Pya+SKJZrZ=dHPw?=`}v@FU8Z!G2-N{qIlQ*XWGu@RfENWifg{_Oi*e=iru zPu~Omg-6i@-)xI~O${k5z!QvMYz-jWXM8Y6kG^&Pwj=i)gW;XEapDFVTg2u;bV|zgvbL zUm;;QcZ>4YoO0CaJ0M0>7}kIk1k7ced4Io|4;D&-yasSUH`TgeTtbPu#Ua7vk&DPJ zT@c3XOMlkcE08>f#Hw^O>lJxhprH-N%UvfkNxq0&0Dkj_KqiZHZXk(9y7 z9~XsSC)H94s_qKLS&8E1!nqjOp=xIEoxUo>(Gj8q0VI2E$uc35;?Y-7Pow>ea0y#g z+mwwG4dXeXyEXckm0 zrkUCGN=b8oe3Ec#&pL9@*y<3(JQbb%0e?LS{VY7qN#HNm5BNsYWiGX>bg4Xc3--Sb zBjT7e{IQ8|_?Z*+Qnbm8ZNL<)tb3Y6N~=6sd;FEbTQ1FPp&lF{PZPUtEcLO^T7`_gs!>#taVG!f{7IC#AMUU-{jTD`E z$cyG<^De8}OS=oAXkR2t=w?6jD0HbFq#tKc7t~71yDj!awkX`~onn}s9?8jm9@iI{ zz6=FEir@ZFo5Z)lAqmWg zvj>~NMXqX#bY2ru(@Q7N?Mta`W>5cgPkp!QM}wPgGbudCoMh4C$s~{hQ!o35y};?+ z0fax79J7iK*unXM6@8i?poJPYts+^h5w~V9MM!c@;K8wl0;Sf&_@3Wi>SG79z=1W> z^6goVGWTpr^;c$g?GmC6dBSJc3r^Kv*egWpU{QDsOCn3x3Y@C(u?0FjW3rJH&o{0~ z=67wX#S=@27V52pcxdTl`Sg1+T7{Vtp3mi<^#h=m9wh;xefJ`uOI zU)OQp$u#xfPo{4!1d*0lyHgAw|3qQk#^_m8TZA*lux?aZ)ij%l&*QQ zt5Nfo1weW2X5w8iZsiLb4jEo4vk{M#r*qT#(;H^ShK-#8$E!IfYTh_;F2i8fI6M<< zI{K8VuC)*7NE2I6AFR`|MP(L&Z!a)4#&%}%?|DF0<^H;+Dg(%N9tx`bzS4<6QAIvL z;x9=Km4iw!8p5=d9*v>2Mz$Ph??TUd&M?K+$DmJqsbv=>_I884=eyK7+Jl?Y z;fC1(i!e$xvfa9R*dIrO7WbApUPA%Bwx-#ZCuKULVTe=ia(5426I_5K?SqcBVij6Q z&p&S`{?ZKczcR%iW17{^;*DyGols}ZDaHC%a=PLqhG30npStfjwu}O-1X7rMD{Hmh<hm?F2#~Q zmI13zCbEiHA5BbdkfR>zzuKNoFom%@CTk;XD*DFz}V*ELWai4QZ8)iQb2y&Gsn#hS&<)f zKD^oJFK%#W0v}3Pbp}xV2?6sfqNO{KkaV#+tgq3^iN?>w?3K`rfPI(N|B$yE99Bpu z{3S=MWhr{(OfX3#ZV|HGbWN>6nekiYl9@jGO0lZMjeXII72Fg4%%Eeis`sHi1pm2} z>D<$6I4%Sx%?g@l|Ii7CM}NglF*f45 z>&0Crl!yA9ALpBdaU3-TqaR&693~^&4kXaa?+hyZNfoBh?hLvfCrB(tsU^QdQzcz( zy8vKL7woc5!vBTbaarU|79Q;6n|i>@y1Zmp>aZO_@5Uq|Ssx_@74mDsP45|%F@J#_ z(TjgCpBa?^m~dcoKEB-3PDQan_k=|O_>&U5WPN+l+I-*0NhX3;-nhUFRgU1ATq z-Xuk{H$5>l=(MXSojDnC9H&a;QVw@$(KWIY@`2?#_<=a08a=@CIO|TNr?ybX{8(>p zte3KfIJ8TpL3*4Q$s=FcI0TkhaO8bppiG%McK54~!Nmquc&8TKpY^=4SXQ<6ePrD% zr$)PSbEQpmc$)#BA$-KN;lgKL(QrIpoQn0}X1x8%(fEyz-po^-8y+Vv5ONf|`kY+{r1cKa8VI5YYby z*h*1nK}1iv_a1>cu%8dEH<%x_db>U+hc7VF2cM35qy)#9wMX~aV<4Sm21^*iCP_vJ zpVsJw&ac=17RTqm%HKu_r*}K3xH-#jbgK!MC0D-`&9R-1H9C%=?VGC~7co|50Omg~ zP71+|dO-Nz#7{vWE+;BT$U`>6Bna#1&l6Q0xT}0%x~`OfnJ0YMA}X{SPA$=a9c8&t zy|9onorKRxqz8l>GKhSMESh35wr&E*#O7~eH&_|kAQ_piQfU$3`BnOMN6KpK9j)KN zLd10pMI2W6%qAx{7e%3`DhVmY6(Km69ZTGLINrKhRST51(kei41EP|1N{wdZZ&Ty^y1_4 zQi^_ZvDGr#UhoR8QE!!XvfYAlRlWA@A*GEpe$+ZHLTU07PWza{QJG*d0ig9_1$p zEYF|!4B!8V#Slw+_^%7xB>I(yQvgD2q=Ka^e8?q|x{m};Ovl9}ka%wMT}$0%Ozf-A zS+PE<7)tCM+EULFL_`?%jh7UW6X9hxP=Sxp5v|~|gN}A!ZSls(W~gfzKOEC{FiK|A z0xebdIHZR)GJl>l%$HJBkt?fzJF47|UQ};(yrZValikXPP(Vi8*9SkFUf)9?7>H-Ut2w(lKZOa)gH0`GXQA;ea*aDD z&RV2zPK=Sl=% z5wdun6m&Yqxb!1%(`RBGj~PsxxRg$h)FJ-3`k<}26MsG+&@tM}b>d!DMQ5O1M`5is z4AkUGMM9zjn@uq?z$Dm`niCIk4>1A_`0p014ws#0t{F{xz%B|P(5>iW5rqMD3-5U6 zpO0<=#>P+C$8CqkU?HE3^ZK|S}vnY(3I*^jr%azjq;pF z3s9$HF8X4+GZZUxHuMftE+Qt%hfolmGXvh#H;Vavx(z6XGTX1<{VI;IAF~7?C}Mt{ z;oL?bG&pIVG)l;+4rGH22y9n_4{iTay}7=#`{lTUO8z0^-#>ayZ5s79F9a{w264Zh}?*G9_~uyA+szdj7~y`$Dy#KXv7;227KNyQSd!yPx9#u6i+ z%Hr#?Mfg<7X?qYp%J5410PsO2-NcxyIGy!vDGxR;#;GHL*5Du+n^E<{LB9)< zwVa=X-`pqUywGg}B{C$o`mS1YmD5QABr1l(uf!iQRAoxf_ ze!gd7(5J)~ecM^=AogERzE#pn1GWv=AseIgKzeQv5vXoqHUQ(%>cg-jeaW4!HtvR3;WsTMoggeI!v2+ z4o|nD>cG^%f^g%g8}S^$8Qv@yiq3_62Zq5EJ^d@xk{Q)Eq`Nl%)Z~^LCl1bzV(x^d zSAnVMO`edOFyo&|4ngvj)YFHf3C&p|$-x&m7tbwgW@H>OgA_e8$Oy_vuZLrFgCpd3 z6nG_i4_^h9f&D>HtK*4NydIggGtnw}n&C|8Oi5eb2T&ij{=b9o?=yPLD9Gy#{BH%I z)>S{6R@bN2-Waxu5XN2`5^M(FX9ua;{hLIbI=_|_;sI$z`Ki=L>tpsWH$JjX!I6Pv z{G@Ev*n}8)r20CdMpOcORRSi-73}MsRTfC|&jLB$JM*{pz8P@nJKVXh`J2wOEvgh3 z!PXbJz;$5X-`EfFCCY|7MCC)>Ncqil=sd8*dOSv1yq&j8ka{etYkwsYWCIxP6WQ) zXmBLFrA)LK9|y8+@=ZAxs)Dl|9XRXxIzKXm6QlP7^|1&eYyEyDIy^WwDzwuC@igLS z2pM8zDQ8rn(jYqFIOcU77>rXO=x1+dRuR#Z-fz5-auW zU;Q*tGlRvos2TI|22Tjx$AZ}O{b%lnjp6J&Wqy67KdhPu{ftX=F9vKC(=?=nsY0PG z-Ed4h9&|*v(eUS;pLYn(U{_-`00#B@>-CYf{lV1qqaG7Dx3E0Ia6mzIJyl|D>-#-0 z!BV>H)9lWb9svUL*_sXOxI*Q@R@?0F5*?G^t+z4i@82-Mf(w-bJEnCauvUW=%;59; zj+1|J*u(-rd)%=xIi4Zt*Zxib<5J~ZiR3*%R~^$|e|i|lkHWEFs4=YueXc2|Xr1a# zh6A8Z`P!P4N3tfC#o;FKkYntxo^C8_MXkxp)%{A}v!pvmQKF5t2}acyz~#0Wt_JR# z_ku7hs<#~rCjy*%%6Yv4n*zC?A=8#zYqpvC>qCoV7ChN$)t;=eBs6!J#ShkPF|tHg z7^K}1>mdO_+XlOwIZ;k@DnhOn4y5(x$R@0rQmZ5LW|D18oe~eci*GB)XIw(@V*b<; zoK)hT5>j)aV>XG9Ic&sFU#5_)p)oMWKBbn!rUtu= zI1CGM_9GM^woeHCN#_>ZeZL+hl}E)|lZep#MQG7A8M5LR>6SQT>(Wk;LgU3qz41d4 zpGbs6Jdmz@9O$K;&>IQ&-Haoj+D{EuBwaHMWpKcDfFqdtx5S+k3bK|BPn3g(m_gfB zv?wg}TO0VYya_$8VZwblmriBaxbyLI;MvFe?BjRkpBu9+b)fH>lBBBPG5$CaTRd>= zqI7N0B)jWj(m|kjnsav;Ok>SPTRATW1MM9Rkn9&7=Mq|kvv1~p_gTz11}M_%brhza z4Y@7wa#n{0{xapyD9g9)gXFAp7=`{?&e29!%J zv8;Kq(%1S;ffAwAj-Y#KazX)7pAf-%hsU{@rX9>(G*RL;KE!k#li7~&rtCatU$B&M z*(8mSt-~&QGukJm1QH5Xow<)+y+Tev#~zeO7K_DD+Kxx(uaFL}sQj%!)d|;8UaqOR zjpIdMP1pN+GpRpQ5R zuj^}VTz6yrH`r<@Wt%f_bvu?1d5uM#$;^D3G`M;F3c+aoOZAQ93(4We^lwagnbh$a zrY})Y;Q&jB(Q&PeoyoPrCRoAAT~I8d?8(cC{CWb!{mg;mkMC&-^0urxxN<;fSF4e+ z3`4dXi$NxnX|LcVE55Vzue9SIvxc*NoAlFm|VVOycW?zJaEh)7163+{@0&X>(B&;FmZohPUslCd-ve~S)0Q{Wr9hf4B!H1FfdpuQno?R~j_{LlEd z`!!+!PN2X%%6&hWx=13q%&f?Iy~~QrX6`?evkKAH<5mq1LF^))u=PcUL-iVz!d@U!iAmzAXx9r9Ga9x< zzj{=AV{}?~Vuj-&352`m+ooh?mwdw|4cfwJ;egXZ1|RJMU^hM|-|0*$W*%LdwMXI> z?o{x*5oo6KAnCVAR46qJJ1hd9LGXRYT?Nv|ZWBpkX7=y~g@++WetFyZRe$7B9WP5{ zKs(&zrrSR>mbPM_{QyHjadwfLm$-|pL=)f9J`caVlC7C(*8^QfcYyY;Z{DLzQigHr z(-hntKk6f_=+R8CR~cdKrOS?`AABvY$dI)PO`JY|MbW(b?;^_sC!yEa@SyJ!_Bjh4 zUlQz1Q|vY=%zyl~UZqwwFJTUaIQ|4X0l#`A32^fNyd?7F7M|(*Y+wHOcTuKxWzj+6Nm>KKn?2W((XF+ zlGNV-{NzqhXF~+$p_>I8%)?1=4@98QXdtR1EQx_H3S}p8wxxm;q^LFA94rZ_b`99o zV5v@)%6)Ck*Ass-ShYe@O3Bm{&=g*QDY}?Un%67&CcL5C=uo&p3gsfstPeqwy?E-H`qYYVYE!X*xUviqcfL)`d&mtS|WmwWpF(rCiOax`WK z-QaSLx}J*-|5(9zVud%{X+_8chq|-yF+@*A#c29E9?o6`&NTh?l_ih$XLrogDb5UF zTx!X<6X*+of6~%7&Lx{1c{&hnXb~?mM6d%nO>WtrZT&61le*^ z;EntUc~hiI4%TWjtJ#WTfHP+RiIy;`bs&XJxu)7Vdi2dM;3SUTPR?&+4h}(>W1# zx{cjr&&HXl3DFf7YRFyX(Ac-`LJdjz@R0DXELWm+?GEjL$mol+XTUd3BTH*ef+;gv zbX&KAR?pLVMDUJ&pKc);R95Lr07ZpOt&oKfNUJ%Nw5AaRFZ?4wSl>VaK^<&zHfmFb zE1idFuL5R9Cl{|jGV}hHdKIzN-nxL5Tj>3wfoeR(y15l6w=_vNpQgPGGLJuGGCLrU zUYe{7b}iG#5TbqT)vO|O>*9ToV`tPsmtHS8V}<47?WuzJ{Xsci)EhDgx|G;Ryz{Su zA250iYU^HeX$?MHHoA$JP;Im?-KoEo$qPhaN0TWA4V}w^H8dv|o?W0Bp=Y6cDBLrH*u5U#>jI;IB+Xs&JEqS>knm5$&cFM43XQowYY-k)X-7 zIapvtu3>B&2QxR!?J|}>FR!g~+Lqohj$SYMZcR`0fI(|@pX zzF6tsEZXju;twa?9?$b&F+!|&S=^m=#fhMJ=vqE8&3fLC5*)i+8aIej0x$j71GcPT zc}4BP#DCo`+3!s(M?gRH9p_AUP`-p1)XzT_a7I4+W)*lJTRFD62}G4;I#PU2eZ5F#!;wO#iZ{AwMc_m%I(jU~uTc0QvN1TZsNV!(m zVZmJ287ggODwHV-o|5RW zYNKW43*tBzKL&2Rr;d8nce&2uKVyVievh?CzQ-s91F|msyICrn0-n2CfV6&GEA&jC zbn6R(9q%Jo{iV-}qWb6(52b}V5~zD+A9%W#MN~y-6OXkZ^m(ZsP ziU9jwUT@^+gt3F73qaqFQcaT3C6Q59a$|@pXeV0va|iq;I~&F+tq4kt70Q0c8qWG6 zs)-X2>0_b3zO%gH9I%JXv-<2tQ=G@(LhRXpgz1(i1Md{pt}yKu0YRMz54{24fLV^bPoC{$?6hl z#|IZvHwL4ma>L)m$(xQnkhT7!H4Np)8S7Y&C@;cou_O)t7WxXqrPr^`XYoz3%zA~% zG5oK|Smh>QGArIBI)@aMHP($f!3i=n?kD!o;nOjK_rQdqyvqqnTg8GQj!f|+3I=uL zSRrcAEv&n;?o26vyiCUXs!;sSRHuV?F+mjY>|&oTQ^a2?_oI?_kNt;@)S5#SXHNjN zxS_6Z-Ax=}M2&!dMUsLD8@m|DQ-Wq&yZqN9Z2To6Tb64|Gie2+kGGW>f<7FFrfd}U zmX0y5P9YZ>SaSXWC;a9s1eGpbb&~W(!o;gR^KOrM~!KOHb#qy}&~T&_9&- z<81tly0`dP+K!5kB?|-sm^J_jKl+r)`~A|=4K=hrnxNOAb^a$Y%q2^%dA@mE3F<)o zUSD^AEw(?mDrW}J441$0$fbu?^MaW%IE;Sf^+&z1fq+Cttw>(;xTCR59ImPJi)U4> zs(^acJ>1A6`#h2&c+_*>D`5LbJ<1ZPd%4c%NlCdFLy1Va!mQXRgW&zk~5##iK zxX0b?sib%V8jR+!BG|CaTe6Ur?i;w#=aC(-ZE!5M$&??IeG?pWE4!TdZ)|R?CDpOl zbiKT>SfREf_D{;lD7mg)IKG)s$u#Q$%TS?JwuS~b2aam?PWIQa$Tot5Q^V;c*tKPj z_e7x%dfjZN`c_WJMW?zhh9CDN%ERlD?eUrLBYx+*FBE_i6nu~MPuQ2+Zm5?F+IRkY z!~cHbIvM_Kw139Ro!B8>an&c9zM3hZTs;89+Ln`Wm0U-Br}1Seo`w4w&^(ps{W}1g+~kw+IhElL@pr6qDYawOdVO#cQ##=@{w~90Wy!eQje_c?VeBR) z&bD@pC#0#QbJWc~4hccd9D)GKL+TS|g(jWx36th}jsU)7Ju{b)<)9gW&fv6|scbUM zGVjy?J-Q>k*i8R#Kx_h2ys)YQ6n2x@WFE@nZfHbUNuE3g1~db{{EiSRFE8c(unnix*V( zg08`?VN&`H4R53%j3Udt?M^Cg==K+S5MH%vheTd2pe^IeeGs~ zL`X%}CT790F;2V!ZUBK{3d)=B@7>g)OJ)oKT-ZI(!yLYZm6D4cDacCq=%hLMim6fM z0%tK~Il_Bkl$tlJvsdZvK3}Rf-KBy~2cpkITR4kCN<C)wnA zm@tV!Q1_LKK7KN8lb48C|8}GSjPiYOu`C9Y z(31p2k>T@u1>=H$Wwsywv7u(=OE`d5RK#nhB@LAhgsQQ83bGXuz7Sw9FR&bh%>=^) zbA0fuVHuPs-oj)s3ML%zNN=ED*NsVzBmW+pT>2c*lOR4J#2DdLaXir!)D)+Zya|<2 zN`C5uY{v&hgA*i;D;gGvvC2~E@`l>Mmm&PBmwrC1q;`_-B0Ya=rAQD|dmpp5{=xVVdO> zGCHDbIWZh?31lP-k#9*V?Tii8e1)A~tX??#z_XY<_Y1hrQ_6nskLU7b)$U}MAEyy- zpi;i*1OYA{L0J{&#c1cqqb*^}sgjHPnrDC`yTIXIOAlRuL~t)Pk}^P^-%&O?+F9}C z(R_KxNB$y8{4e4+kJz)~;0%%92fajWa!@lhpnq;ANB+d3xk;+ZLr{)JQ+c22!|V~^ zwPo$O^`hn^T+x}6@@%2_a>Q5HK4J*o+ftMNNM5yGtm20e6-~NCzOMUYdunj|*azMi z5owHL$mFzr(#7>N(X^{fvjV=*4w<4Po7AK@6IDfn3*Z^^(fjSn?$4?uKjP?Vzu^uO z5oaOj$yZc+Kc|mck6q*7Yim_2do_ODvqRmB`KZBk!f5(+Ntp43YvMvLLauOOr4lXJ zOyhk?PG$^mK7G#Vt#w0RcoS;wrqv?~V-GIYD|Bl`^SNzm9(|-c0-))*0~bNtzNDEB zSA6|45Qygc;P#T3B;WI&AMEBaiym3)jZ|&Fz0Z^?8`b|0ST_>-ADoQ~=fY+wb!W$4 zo>Sd6a)*Qn?Mz_^!av(Sr7EZu5=fNR*ioS!OV>7cVMk~(m^BW1?03Z&23P>QblTKJ zwBX<22+We_wK3fHkhJG3XZy3d)%mR!{dklQ9_f;T7lw!SftO{)9(Y)6dU)E{Hs=mF^-RCY_ZBv(v>xdkY zQID_DLJj7SYURT77F(a_*6<3=8_UNbbw9nWk(Grpo#zaCB??yzD-#34h@zFaCqJF{ zuM_(h1P&@ThweBI8~ke)@>k+T^~OfCC+zgci_W8`0O!$p>kE9rX>Fc*gGVtC!Q_}o z&k@dU??&z9;)WHJnI)uyeCuR~nG3J&zhy}ah~#D-Hd+X)3tuE;nzis?3fCq=S4(Fh zOJ%9=n?er*w!M9^*ZuH-6W~mP4#4+0k_yxUV1+q=?-Xa6BL`~Jc%G13!Z)1bYNM-@ zdAAJ7y!GuY8MCa^mPMggLiyxYkM$vx0}XktqqY$@ADOFxo&=b35S1aI~4dH*uP1OA_#g_leC#yKQsD&JEIZ zGaf|mR-fEPu4~rs<5=j$=4_NXj0(w!p$*%6!DTUv2cGdRP6+oDLi#a80-;FhFz58O zEB{7)D`fqTD#<{0F%Clo1mgtJ#8(k?df==zEdh$T&4MLY7au#}jxJyfADbvEW1_X| zT;Y^?A(%BrvMl06WdV_>YdGs^6*$q2n3xiugZ`RLoeJDLqglq9-t%?S&nl=EE_v4>6DSHYj5BYT)xo>yDLxk{?Mkm#4%1L#f~ zVJ&-&!o+tJ8DUkVXZ0=tmN89C*&j@8{F3R=R`W1d zb1^BkFHuR9&O!8c236osv2;C{6c0z}!5}MtVsPqWtxUQiW-Wjnp7>f6<=|cRA~u3( z#_}YdP!;FPZdqK?rvGxN|6U<&TM z(T^fcm4-wKZ|m&@K2(C#wqdpz$8^N4=stMfF=Tq9rRVUp|3cUP7O(KOGx%A*!qom6 z1N<}xAHvc87N793zlD(I&zQ!3d&{VElAd^GTl&)PVAe~xjr(6VdOxXF&XDn3^pO|( zX!Cu7{Jg1@Zt}PW9l3+xiqS^!4=u^ODGDa%u25)6Ux?u|$o=ujtui+H;d21lpHs`K z^_1!eGx-F`Q~|dN9~rfl2ehl+xi~4l++mf%EflXW3vfCh=KmAUwranp@kcjiclG@M zQ~wo|NL5O&R4Zt;73R1B0KA4spb^mPVqX<#lm<~^CVwn2B;}dkL~-MU{F7bC!6+Iv z3RRpaC9HVfOiu%kPKWbY1s&i&+8|VA&&Uz7@CG_b%YV^U-05%lh2({Cv4&G>(AWbv zN4fjk3zmqe)^eb`4!Arv=;pzgK z{(j!x`7#YUW9p`K#d52Ja%U+dWWxb36`FY^&WF%zEMtV4xQg4s*oiwKHPuTiq1;+I zbPCQ){PTV6XA2k7cp5rRbD>UG3G%Fxu08H3T4y7Aht;&RV5GU2%Wi#=M4e1U{#UMr zNqYr=xvJd(wYfQpmSDgXU1y|&shti^UxMh2sv?x$mK7sU>Q1S!g7p->&FJroqqcPi z48nbum(ZRQFMQjXh+cB|kSJ`o7>yB~YT8g`exbRg{krEJm>QpEGYHApQ?fh}b|DP# z&Gt9g-E%ZA^#ZU(~Wg zR@9?Y-wF1yeC22r5)+LT_wr}60DKBdU82XhfQtM;xGEyG3hz@ZmbHd4uJRBoz%~;)(_4d2L_QSEf<12nf^S_CJ4TLf7942K^ek zxG6kpG$&Gcf4JrAs`sWak{N$Vh)k=(BTR)6oeVkv+pUOQ{a

H};dB$t%vn!rC`t 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 Vr*$JL34HDeq4bR}Rp~lD`afSKqZR-F diff --git a/common/ayon_common/resources/__init__.py b/common/ayon_common/resources/__init__.py deleted file mode 100644 index 2b516feff3..0000000000 --- a/common/ayon_common/resources/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -import os - -from ayon_common.utils import is_staging_enabled - -RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__)) - - -def get_resource_path(*args): - path_items = list(args) - path_items.insert(0, RESOURCES_DIR) - return os.path.sep.join(path_items) - - -def get_icon_path(): - if is_staging_enabled(): - return get_resource_path("AYON_staging.png") - return get_resource_path("AYON.png") - - -def load_stylesheet(): - stylesheet_path = get_resource_path("stylesheet.css") - - with open(stylesheet_path, "r") as stream: - content = stream.read() - return content diff --git a/common/ayon_common/resources/edit.png b/common/ayon_common/resources/edit.png deleted file mode 100644 index a5a07998a65fa0c37e2089b4da30db757e42115e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9138 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 diff --git a/common/ayon_common/resources/eye.png b/common/ayon_common/resources/eye.png deleted file mode 100644 index 5a683e29748b35b3b64a200cb9723e82df45f4b9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2152 zcmai0dstIt93Gi@K}1AK5$ilhQ4HAG#Z7kHAlarv9U_+)52cRn3=U&EhwZ>N#7GK* zAtv}_f~X1RrMQNGK3E`_G;gFyW#kPtEFnU861>%SHUdqpKh8P(zW4pT=l6Sm-}jy6 zgoQ43ndmu@!C<(A$Ry$5Ii0@7zXa}AO`<^t_f`G3=Ox+lsoQqojl#IUJPsC?Dy)e-<~A9e{EIA z2qq1LIz@%4?PUQu2WliVlu2p87RQ4Ii{Ql?4G!$IKw#_O@p{Yvv6%v`A@WrELObEHO$y>1b71p>Qv?|~M!;a?Aj0(E^f7>A#^Nq7WiXsF zanP8j8p2@s=#T9iz0>8ZP<}T^%toqu=t8RV^cK;{W+B5aNrsb!i zj#T(0_uXz<(-bA}mF~T3cAV1g z`Q!@km7S*cAZ64)V9FAjd+^XsnOp`qd~uScqb=LTmL7JgneuAyqOcKHsI zId_6EJKHtG1L8K2p7W|o#06JKkN4dAK2^PY`sYoHems9H*`u;L2;Qe1qt=XRWoK4c z`=&9kMh#ub+j{HsTW6HhTip6)ZL8lR4v5`)as0-YCoL1&PUoDzxHdO^#@V&rp%2R- zuB+yu0Kf=!mq?PT=Hn*Dh_bC}42 zBhD+UzbsjhRc@WzTWj`@CfA(1(RHi*$1^4K;J~`G$2^Z7-)Oxf_?o*@>!JCD(-;^r z`2OYW?Rz-3!XuvbRSo+JR^__%tSZa@t-tu#u55D+bRgEl*?s+Lc0h);Az-T8?$|6{ zJ+Arl8p=;4AFQq(TOmE3{!#a#ika5AuQkmri;m>QzT?^#0DqM#>kQg9C5Ul7FTy@qaAA}HTsH7rzZRX#RRxAbp diff --git a/common/ayon_common/resources/stylesheet.css b/common/ayon_common/resources/stylesheet.css deleted file mode 100644 index 01e664e9e8..0000000000 --- a/common/ayon_common/resources/stylesheet.css +++ /dev/null @@ -1,84 +0,0 @@ -* { - font-size: 10pt; - font-family: "Noto Sans"; - font-weight: 450; - outline: none; -} - -QWidget { - color: #D3D8DE; - background: #2C313A; - border-radius: 0px; -} - -QWidget:disabled { - color: #5b6779; -} - -QLabel { - background: transparent; -} - -QPushButton { - text-align:center center; - border: 0px solid transparent; - border-radius: 0.2em; - padding: 3px 5px 3px 5px; - background: #434a56; -} - -QPushButton:hover { - background: rgba(168, 175, 189, 0.3); - color: #F0F2F5; -} - -QPushButton:pressed {} - -QPushButton:disabled { - background: #434a56; -} - -QLineEdit { - border: 1px solid #373D48; - border-radius: 0.3em; - background: #21252B; - padding: 0.1em; -} - -QLineEdit:disabled { - background: #2C313A; -} -QLineEdit:hover { - border-color: rgba(168, 175, 189, .3); -} -QLineEdit:focus { - border-color: rgb(92, 173, 214); -} - -QLineEdit[state="invalid"] { - border-color: #AA5050; -} - -#Separator { - background: rgba(75, 83, 98, 127); -} - -#PasswordBtn { - border: none; - padding: 0.1em; - background: transparent; -} - -#PasswordBtn:hover { - background: #434a56; -} - -#LikeDisabledInput { - background: #2C313A; -} -#LikeDisabledInput:hover { - border-color: #373D48; -} -#LikeDisabledInput:focus { - border-color: #373D48; -} diff --git a/common/ayon_common/ui_utils.py b/common/ayon_common/ui_utils.py deleted file mode 100644 index a3894d0d9c..0000000000 --- a/common/ayon_common/ui_utils.py +++ /dev/null @@ -1,36 +0,0 @@ -import sys -from qtpy import QtWidgets, QtCore - - -def set_style_property(widget, property_name, property_value): - """Set widget's property that may affect style. - - Style of widget is polished if current property value is different. - """ - - cur_value = widget.property(property_name) - if cur_value == property_value: - return - widget.setProperty(property_name, property_value) - widget.style().polish(widget) - - -def get_qt_app(): - app = QtWidgets.QApplication.instance() - if app is not None: - return app - - for attr_name in ( - "AA_EnableHighDpiScaling", - "AA_UseHighDpiPixmaps", - ): - attr = getattr(QtCore.Qt, attr_name, None) - if attr is not None: - QtWidgets.QApplication.setAttribute(attr) - - if hasattr(QtWidgets.QApplication, "setHighDpiScaleFactorRoundingPolicy"): - QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy( - QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough - ) - - return QtWidgets.QApplication(sys.argv) diff --git a/common/ayon_common/utils.py b/common/ayon_common/utils.py deleted file mode 100644 index c0d0c7c0b1..0000000000 --- a/common/ayon_common/utils.py +++ /dev/null @@ -1,90 +0,0 @@ -import os -import sys -import appdirs - -IS_BUILT_APPLICATION = getattr(sys, "frozen", False) - - -def get_ayon_appdirs(*args): - """Local app data directory of AYON client. - - Args: - *args (Iterable[str]): Subdirectories/files in local app data dir. - - Returns: - str: Path to directory/file in local app data dir. - """ - - return os.path.join( - appdirs.user_data_dir("AYON", "Ynput"), - *args - ) - - -def is_staging_enabled(): - """Check if staging is enabled. - - Returns: - bool: True if staging is enabled. - """ - - return os.getenv("AYON_USE_STAGING") == "1" - - -def _create_local_site_id(): - """Create a local site identifier. - - Returns: - str: Randomly generated site id. - """ - - from coolname import generate_slug - - new_id = generate_slug(3) - - print("Created local site id \"{}\"".format(new_id)) - - return new_id - - -def get_local_site_id(): - """Get local site identifier. - - Site id is created if does not exist yet. - - Returns: - str: Site id. - """ - - # used for background syncing - site_id = os.environ.get("AYON_SITE_ID") - if site_id: - return site_id - - site_id_path = get_ayon_appdirs("site_id") - if os.path.exists(site_id_path): - with open(site_id_path, "r") as stream: - site_id = stream.read() - - if not site_id: - site_id = _create_local_site_id() - with open(site_id_path, "w") as stream: - stream.write(site_id) - return site_id - - -def get_ayon_launch_args(*args): - """Launch arguments that can be used to launch ayon process. - - Args: - *args (str): Additional arguments. - - Returns: - list[str]: Launch arguments. - """ - - output = [sys.executable] - if not IS_BUILT_APPLICATION: - output.append(sys.argv[0]) - output.extend(args) - return output diff --git a/openpype/vendor/python/common/ayon_api/__init__.py b/openpype/vendor/python/common/ayon_api/__init__.py index 4b4e0f3359..0540d7692d 100644 --- a/openpype/vendor/python/common/ayon_api/__init__.py +++ b/openpype/vendor/python/common/ayon_api/__init__.py @@ -78,6 +78,8 @@ from ._api import ( download_dependency_package, upload_dependency_package, + upload_addon_zip, + get_bundles, create_bundle, update_bundle, @@ -262,6 +264,8 @@ __all__ = ( "download_dependency_package", "upload_dependency_package", + "upload_addon_zip", + "get_bundles", "create_bundle", "update_bundle", diff --git a/openpype/vendor/python/common/ayon_api/_api.py b/openpype/vendor/python/common/ayon_api/_api.py index 82ffdc7527..26a4b1530a 100644 --- a/openpype/vendor/python/common/ayon_api/_api.py +++ b/openpype/vendor/python/common/ayon_api/_api.py @@ -25,12 +25,29 @@ class GlobalServerAPI(ServerAPI): but that can be filled afterwards with calling 'login' method. """ - def __init__(self, site_id=None, client_version=None): + def __init__( + self, + site_id=None, + client_version=None, + default_settings_variant=None, + ssl_verify=None, + cert=None, + ): url = self.get_url() token = self.get_token() - super(GlobalServerAPI, self).__init__(url, token, site_id, client_version) - + super(GlobalServerAPI, self).__init__( + url, + token, + site_id, + client_version, + default_settings_variant, + ssl_verify, + cert, + # We want to make sure that server and api key validation + # happens all the time in 'GlobalServerAPI'. + create_session=False, + ) self.validate_server_availability() self.create_session() @@ -129,17 +146,6 @@ class ServiceContext: addon_version = None service_name = None - @staticmethod - def get_value_from_envs(env_keys, value=None): - if value: - return value - - for env_key in env_keys: - value = os.environ.get(env_key) - if value: - break - return value - @classmethod def init_service( cls, @@ -150,14 +156,8 @@ class ServiceContext: service_name=None, connect=True ): - token = cls.get_value_from_envs( - ("AY_API_KEY", "AYON_API_KEY"), - token - ) - server_url = cls.get_value_from_envs( - ("AY_SERVER_URL", "AYON_SERVER_URL"), - server_url - ) + token = token or os.environ.get("AYON_API_KEY") + server_url = server_url or os.environ.get("AYON_SERVER_URL") if not server_url: raise FailedServiceInit("URL to server is not set") @@ -166,18 +166,9 @@ class ServiceContext: "Token to server {} is not set".format(server_url) ) - addon_name = cls.get_value_from_envs( - ("AY_ADDON_NAME", "AYON_ADDON_NAME"), - addon_name - ) - addon_version = cls.get_value_from_envs( - ("AY_ADDON_VERSION", "AYON_ADDON_VERSION"), - addon_version - ) - service_name = cls.get_value_from_envs( - ("AY_SERVICE_NAME", "AYON_SERVICE_NAME"), - service_name - ) + addon_name = addon_name or os.environ.get("AYON_ADDON_NAME") + addon_version = addon_version or os.environ.get("AYON_ADDON_VERSION") + service_name = service_name or os.environ.get("AYON_SERVICE_NAME") cls.token = token cls.server_url = server_url @@ -618,6 +609,11 @@ def delete_dependency_package(*args, **kwargs): return con.delete_dependency_package(*args, **kwargs) +def upload_addon_zip(*args, **kwargs): + con = get_server_api_connection() + return con.upload_addon_zip(*args, **kwargs) + + def get_project_anatomy_presets(*args, **kwargs): con = get_server_api_connection() return con.get_project_anatomy_presets(*args, **kwargs) diff --git a/openpype/vendor/python/common/ayon_api/server_api.py b/openpype/vendor/python/common/ayon_api/server_api.py index c886fed976..c578124cfc 100644 --- a/openpype/vendor/python/common/ayon_api/server_api.py +++ b/openpype/vendor/python/common/ayon_api/server_api.py @@ -4,7 +4,6 @@ import io import json import logging import collections -import datetime import platform import copy import uuid @@ -325,6 +324,8 @@ class ServerAPI(object): available then 'True' is used. cert (Optional[str]): Path to certificate file. Looks for env variable value 'AYON_CERT_FILE' by default. + create_session (Optional[bool]): Create session for connection if + token is available. Default is True. """ def __init__( @@ -336,6 +337,7 @@ class ServerAPI(object): default_settings_variant=None, ssl_verify=None, cert=None, + create_session=True, ): if not base_url: raise ValueError("Invalid server URL {}".format(str(base_url))) @@ -367,6 +369,7 @@ class ServerAPI(object): self._access_token_is_service = None self._token_is_valid = None + self._token_validation_started = False self._server_available = None self._server_version = None self._server_version_tuple = None @@ -389,6 +392,11 @@ class ServerAPI(object): self._as_user_stack = _AsUserStack() self._thumbnail_cache = ThumbnailCache(True) + # Create session + if self._access_token and create_session: + self.validate_server_availability() + self.create_session() + @property def log(self): if self._log is None: @@ -652,6 +660,7 @@ class ServerAPI(object): def validate_token(self): try: + self._token_validation_started = True # TODO add other possible validations # - existence of 'user' key in info # - validate that 'site_id' is in 'sites' in info @@ -661,6 +670,9 @@ class ServerAPI(object): except UnauthorizedError: self._token_is_valid = False + + finally: + self._token_validation_started = False return self._token_is_valid def set_token(self, token): @@ -673,8 +685,25 @@ class ServerAPI(object): self._token_is_valid = None self.close_session() - def create_session(self): + def create_session(self, ignore_existing=True, force=False): + """Create a connection session. + + Session helps to keep connection with server without + need to reconnect on each call. + + Args: + ignore_existing (bool): If session already exists, + ignore creation. + force (bool): If session already exists, close it and + create new. + """ + + if force and self._session is not None: + self.close_session() + if self._session is not None: + if ignore_existing: + return raise ValueError("Session is already created.") self._as_user_stack.clear() @@ -841,7 +870,19 @@ class ServerAPI(object): self._access_token) return headers - def login(self, username, password): + def login(self, username, password, create_session=True): + """Login to server. + + Args: + username (str): Username. + password (str): Password. + create_session (Optional[bool]): Create session after login. + Default: True. + + Raises: + AuthenticationError: Login failed. + """ + if self.has_valid_token: try: user_info = self.get_user() @@ -851,7 +892,8 @@ class ServerAPI(object): current_username = user_info.get("name") if current_username == username: self.close_session() - self.create_session() + if create_session: + self.create_session() return self.reset_token() @@ -875,7 +917,9 @@ class ServerAPI(object): if not self.has_valid_token: raise AuthenticationError("Invalid credentials") - self.create_session() + + if create_session: + self.create_session() def logout(self, soft=False): if self._access_token: @@ -888,6 +932,15 @@ class ServerAPI(object): def _do_rest_request(self, function, url, **kwargs): if self._session is None: + # Validate token if was not yet validated + # - ignore validation if we're in middle of + # validation + if ( + self._token_is_valid is None + and not self._token_validation_started + ): + self.validate_token() + if "headers" not in kwargs: kwargs["headers"] = self.get_headers() @@ -1328,6 +1381,7 @@ class ServerAPI(object): response = post_func(url, data=stream, **kwargs) response.raise_for_status() progress.set_transferred_size(size) + return response def upload_file( self, endpoint, filepath, progress=None, request_type=None @@ -1344,6 +1398,9 @@ class ServerAPI(object): to track upload progress. request_type (Optional[RequestType]): Type of request that will be used to upload file. + + Returns: + requests.Response: Response object. """ if endpoint.startswith(self._base_url): @@ -1362,7 +1419,7 @@ class ServerAPI(object): progress.set_started() try: - self._upload_file(url, filepath, progress, request_type) + return self._upload_file(url, filepath, progress, request_type) except Exception as exc: progress.set_failed(str(exc)) @@ -1640,7 +1697,7 @@ class ServerAPI(object): Args: addon_name (str): Name of addon. addon_version (str): Version of addon. - subpaths (tuple[str]): Any amount of subpaths that are added to + *subpaths (str): Any amount of subpaths that are added to addon url. Returns: @@ -1848,9 +1905,12 @@ class ServerAPI(object): dst_filename (str): Destination filename. progress (Optional[TransferProgress]): Object that gives ability to track download progress. + + Returns: + requests.Response: Response object. """ - self.upload_file( + return self.upload_file( "desktop/installers/{}".format(dst_filename), src_filepath, progress=progress @@ -2162,6 +2222,33 @@ class ServerAPI(object): return create_dependency_package_basename(platform_name) + def upload_addon_zip(self, src_filepath, progress=None): + """Upload addon zip file to server. + + File is validated on server. If it is valid, it is installed. It will + create an event job which can be tracked (tracking part is not + implemented yet). + + Example output: + {'eventId': 'a1bfbdee27c611eea7580242ac120003'} + + Args: + src_filepath (str): Path to a zip file. + progress (Optional[TransferProgress]): Object to keep track about + upload state. + + Returns: + dict[str, Any]: Response data from server. + """ + + response = self.upload_file( + "addons/install", + src_filepath, + progress=progress, + request_type=RequestTypes.post, + ) + return response.json() + def _get_bundles_route(self): major, minor, patch, _, _ = self.server_version_tuple # Backwards compatibility for AYON server 0.3.0 @@ -3051,6 +3138,65 @@ class ServerAPI(object): fill_own_attribs(project) return project + def get_folders_hierarchy( + self, + project_name, + search_string=None, + folder_types=None + ): + """Get project hierarchy. + + All folders in project in hierarchy data structure. + + Example output: + { + "hierarchy": [ + { + "id": "...", + "name": "...", + "label": "...", + "status": "...", + "folderType": "...", + "hasTasks": False, + "taskNames": [], + "parents": [], + "parentId": None, + "children": [...children folders...] + }, + ... + ] + } + + Args: + project_name (str): Project where to look for folders. + search_string (Optional[str]): Search string to filter folders. + folder_types (Optional[Iterable[str]]): Folder types to filter. + + Returns: + dict[str, Any]: Response data from server. + """ + + if folder_types: + folder_types = ",".join(folder_types) + + query_fields = [ + "{}={}".format(key, value) + for key, value in ( + ("search", search_string), + ("types", folder_types), + ) + if value + ] + query = "" + if query_fields: + query = "?{}".format(",".join(query_fields)) + + response = self.get( + "projects/{}/hierarchy{}".format(project_name, query) + ) + response.raise_for_status() + return response.data + def get_folders( self, project_name, @@ -3622,7 +3768,6 @@ class ServerAPI(object): if filtered_product is not None: yield filtered_product - def get_product_by_id( self, project_name, diff --git a/openpype/vendor/python/common/ayon_api/thumbnails.py b/openpype/vendor/python/common/ayon_api/thumbnails.py index 11734ca762..50acd94dcb 100644 --- a/openpype/vendor/python/common/ayon_api/thumbnails.py +++ b/openpype/vendor/python/common/ayon_api/thumbnails.py @@ -50,7 +50,7 @@ class ThumbnailCache: """ if self._thumbnails_dir is None: - directory = appdirs.user_data_dir("AYON", "Ynput") + directory = appdirs.user_data_dir("ayon", "ynput") self._thumbnails_dir = os.path.join(directory, "thumbnails") return self._thumbnails_dir diff --git a/openpype/vendor/python/common/ayon_api/utils.py b/openpype/vendor/python/common/ayon_api/utils.py index 69fd8e9b41..93822a58ac 100644 --- a/openpype/vendor/python/common/ayon_api/utils.py +++ b/openpype/vendor/python/common/ayon_api/utils.py @@ -359,7 +359,7 @@ class TransferProgress: def __init__(self): self._started = False self._transfer_done = False - self._transfered = 0 + self._transferred = 0 self._content_size = None self._failed = False @@ -369,25 +369,66 @@ class TransferProgress: self._destination_url = "N/A" def get_content_size(self): + """Content size in bytes. + + Returns: + Union[int, None]: Content size in bytes or None + if is unknown. + """ + return self._content_size def set_content_size(self, content_size): + """Set content size in bytes. + + Args: + content_size (int): Content size in bytes. + + Raises: + ValueError: If content size was already set. + """ + if self._content_size is not None: raise ValueError("Content size was set more then once") self._content_size = content_size def get_started(self): + """Transfer was started. + + Returns: + bool: True if transfer started. + """ + return self._started def set_started(self): + """Mark that transfer started. + + Raises: + ValueError: If transfer was already started. + """ + if self._started: raise ValueError("Progress already started") self._started = True def get_transfer_done(self): + """Transfer finished. + + Returns: + bool: Transfer finished. + """ + return self._transfer_done def set_transfer_done(self): + """Mark progress as transfer finished. + + Raises: + ValueError: If progress was already marked as done + or wasn't started yet. + """ + if self._transfer_done: raise ValueError("Progress was already marked as done") if not self._started: @@ -395,41 +436,117 @@ class TransferProgress: self._transfer_done = True def get_failed(self): + """Transfer failed. + + Returns: + bool: True if transfer failed. + """ + return self._failed def get_fail_reason(self): + """Get reason why transfer failed. + + Returns: + Union[str, None]: Reason why transfer + failed or None. + """ + return self._fail_reason def set_failed(self, reason): + """Mark progress as failed. + + Args: + reason (str): Reason why transfer failed. + """ + self._fail_reason = reason self._failed = True def get_transferred_size(self): - return self._transfered + """Already transferred size in bytes. - def set_transferred_size(self, transfered): - self._transfered = transfered + Returns: + int: Already transferred size in bytes. + """ + + return self._transferred + + def set_transferred_size(self, transferred): + """Set already transferred size in bytes. + + Args: + transferred (int): Already transferred size in bytes. + """ + + self._transferred = transferred def add_transferred_chunk(self, chunk_size): - self._transfered += chunk_size + """Add transferred chunk size in bytes. + + Args: + chunk_size (int): Add transferred chunk size + in bytes. + """ + + self._transferred += chunk_size def get_source_url(self): + """Source url from where transfer happens. + + Note: + Consider this as title. Must be set using + 'set_source_url' or 'N/A' will be returned. + + Returns: + str: Source url from where transfer happens. + """ + return self._source_url def set_source_url(self, url): + """Set source url from where transfer happens. + + Args: + url (str): Source url from where transfer happens. + """ + self._source_url = url def get_destination_url(self): + """Destination url where transfer happens. + + Note: + Consider this as title. Must be set using + 'set_source_url' or 'N/A' will be returned. + + Returns: + str: Destination url where transfer happens. + """ + return self._destination_url def set_destination_url(self, url): + """Set destination url where transfer happens. + + Args: + url (str): Destination url where transfer happens. + """ + self._destination_url = url @property def is_running(self): + """Check if transfer is running. + + Returns: + bool: True if transfer is running. + """ + if ( not self.started - or self.done + or self.transfer_done or self.failed ): return False @@ -437,9 +554,16 @@ class TransferProgress: @property def transfer_progress(self): + """Get transfer progress in percents. + + Returns: + Union[float, None]: Transfer progress in percents or 'None' + if content size is unknown. + """ + if self._content_size is None: return None - return (self._transfered * 100.0) / float(self._content_size) + return (self._transferred * 100.0) / float(self._content_size) content_size = property(get_content_size, set_content_size) started = property(get_started) @@ -448,7 +572,6 @@ class TransferProgress: fail_reason = property(get_fail_reason) source_url = property(get_source_url, set_source_url) destination_url = property(get_destination_url, set_destination_url) - content_size = property(get_content_size, set_content_size) transferred_size = property(get_transferred_size, set_transferred_size) diff --git a/openpype/vendor/python/common/ayon_api/version.py b/openpype/vendor/python/common/ayon_api/version.py index 238f6e9426..93024ea5f2 100644 --- a/openpype/vendor/python/common/ayon_api/version.py +++ b/openpype/vendor/python/common/ayon_api/version.py @@ -1,2 +1,2 @@ """Package declaring Python API for Ayon server.""" -__version__ = "0.3.2" +__version__ = "0.3.3" diff --git a/setup.py b/setup.py index 260728dde6..4b6f286730 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- """Setup info for building OpenPype 3.0.""" import os -import sys import re import platform import distutils.spawn @@ -125,7 +124,6 @@ bin_includes = [ include_files = [ "igniter", "openpype", - "common", "schema", "LICENSE", "README.md" @@ -170,22 +168,7 @@ executables = [ target_name="openpype_console", icon=icon_path.as_posix() ), - Executable( - "ayon_start.py", - base=base, - target_name="ayon", - icon=icon_path.as_posix() - ), ] -if IS_WINDOWS: - executables.append( - Executable( - "ayon_start.py", - base=None, - target_name="ayon_console", - icon=icon_path.as_posix() - ) - ) if IS_LINUX: executables.append( diff --git a/tools/run_tray_ayon.ps1 b/tools/run_tray_ayon.ps1 deleted file mode 100644 index 54a80f93fd..0000000000 --- a/tools/run_tray_ayon.ps1 +++ /dev/null @@ -1,41 +0,0 @@ -<# -.SYNOPSIS - Helper script AYON Tray. - -.DESCRIPTION - - -.EXAMPLE - -PS> .\run_tray.ps1 - -#> -$current_dir = Get-Location -$script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent -$ayon_root = (Get-Item $script_dir).parent.FullName - -# Install PSWriteColor to support colorized output to terminal -$env:PSModulePath = $env:PSModulePath + ";$($ayon_root)\tools\modules\powershell" - -$env:_INSIDE_OPENPYPE_TOOL = "1" - -# make sure Poetry is in PATH -if (-not (Test-Path 'env:POETRY_HOME')) { - $env:POETRY_HOME = "$ayon_root\.poetry" -} -$env:PATH = "$($env:PATH);$($env:POETRY_HOME)\bin" - - -Set-Location -Path $ayon_root - -Write-Color -Text ">>> ", "Reading Poetry ... " -Color Green, Gray -NoNewline -if (-not (Test-Path -PathType Container -Path "$($env:POETRY_HOME)\bin")) { - Write-Color -Text "NOT FOUND" -Color Yellow - Write-Color -Text "*** ", "We need to install Poetry create virtual env first ..." -Color Yellow, Gray - & "$ayon_root\tools\create_env.ps1" -} else { - Write-Color -Text "OK" -Color Green -} - -& "$($env:POETRY_HOME)\bin\poetry" run python "$($ayon_root)\ayon_start.py" tray --debug -Set-Location -Path $current_dir diff --git a/tools/run_tray_ayon.sh b/tools/run_tray_ayon.sh deleted file mode 100755 index 3039750b87..0000000000 --- a/tools/run_tray_ayon.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env bash -# Run AYON Tray - -# Colors for terminal - -RST='\033[0m' # Text Reset - -# Regular Colors -Black='\033[0;30m' # Black -Red='\033[0;31m' # Red -Green='\033[0;32m' # Green -Yellow='\033[0;33m' # Yellow -Blue='\033[0;34m' # Blue -Purple='\033[0;35m' # Purple -Cyan='\033[0;36m' # Cyan -White='\033[0;37m' # White - -# Bold -BBlack='\033[1;30m' # Black -BRed='\033[1;31m' # Red -BGreen='\033[1;32m' # Green -BYellow='\033[1;33m' # Yellow -BBlue='\033[1;34m' # Blue -BPurple='\033[1;35m' # Purple -BCyan='\033[1;36m' # Cyan -BWhite='\033[1;37m' # White - -# Bold High Intensity -BIBlack='\033[1;90m' # Black -BIRed='\033[1;91m' # Red -BIGreen='\033[1;92m' # Green -BIYellow='\033[1;93m' # Yellow -BIBlue='\033[1;94m' # Blue -BIPurple='\033[1;95m' # Purple -BICyan='\033[1;96m' # Cyan -BIWhite='\033[1;97m' # White - - -############################################################################## -# Return absolute path -# Globals: -# None -# Arguments: -# Path to resolve -# Returns: -# None -############################################################################### -realpath () { - echo $(cd $(dirname "$1"); pwd)/$(basename "$1") -} - -# Main -main () { - # Directories - ayon_root=$(realpath $(dirname $(dirname "${BASH_SOURCE[0]}"))) - - _inside_openpype_tool="1" - - if [[ -z $POETRY_HOME ]]; then - export POETRY_HOME="$ayon_root/.poetry" - fi - - echo -e "${BIGreen}>>>${RST} Reading Poetry ... \c" - if [ -f "$POETRY_HOME/bin/poetry" ]; then - echo -e "${BIGreen}OK${RST}" - else - echo -e "${BIYellow}NOT FOUND${RST}" - echo -e "${BIYellow}***${RST} We need to install Poetry and virtual env ..." - . "$ayon_root/tools/create_env.sh" || { echo -e "${BIRed}!!!${RST} Poetry installation failed"; return; } - fi - - pushd "$ayon_root" > /dev/null || return > /dev/null - - echo -e "${BIGreen}>>>${RST} Running AYON Tray with debug option ..." - "$POETRY_HOME/bin/poetry" run python3 "$ayon_root/ayon_start.py" tray --debug -} - -main From 22288486b63de491fb2010d40658722552cb2107 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 27 Jul 2023 14:46:55 +0100 Subject: [PATCH 032/107] Fix call to renamed function --- openpype/hosts/unreal/hooks/pre_workfile_preparation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index e6662e7420..1c42d7d246 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -202,7 +202,7 @@ class UnrealPrelaunchHook(PreLaunchHook): f"{self.signature} using existing built Ayon plugin from " f"{built_plugin_path}" )) - unreal_lib.move_built_plugin(engine_path, Path(built_plugin_path)) + unreal_lib.copy_built_plugin(engine_path, Path(built_plugin_path)) else: # Set "AYON_UNREAL_PLUGIN" to current process environment for # execution of `create_unreal_project` From cd7042a106d5d37ca1418878c7fe04dbbd6b219f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Thu, 27 Jul 2023 19:37:17 +0200 Subject: [PATCH 033/107] removing also settings schema with defaults for qt gui (#5306) --- .../defaults/project_settings/nuke.json | 28 ------------------- .../schemas/schema_nuke_publish.json | 20 ------------- 2 files changed, 48 deletions(-) diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 85e3c0d3c3..b736c462ff 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -465,34 +465,6 @@ "viewer_process_override": "", "bake_viewer_process": true, "bake_viewer_input_process": true, - "reformat_node_add": false, - "reformat_node_config": [ - { - "type": "text", - "name": "type", - "value": "to format" - }, - { - "type": "text", - "name": "format", - "value": "HD_1080" - }, - { - "type": "text", - "name": "filter", - "value": "Lanczos6" - }, - { - "type": "bool", - "name": "black_outside", - "value": true - }, - { - "type": "bool", - "name": "pbb", - "value": false - } - ], "reformat_nodes_config": { "enabled": false, "reposition_nodes": [ diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json index 3019c9b1b5..f006392bef 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json @@ -308,26 +308,6 @@ { "type": "separator" }, - { - "type": "label", - "label": "Currently we are supporting also multiple reposition nodes.
Older single reformat node is still supported
and if it is activated then preference will
be on it. If you want to use multiple reformat
nodes then you need to disable single reformat
node and enable multiple Reformat nodes
here." - }, - { - "type": "boolean", - "key": "reformat_node_add", - "label": "Add Reformat Node", - "default": false - }, - { - "type": "schema_template", - "name": "template_nuke_knob_inputs", - "template_data": [ - { - "label": "Reformat Node Knobs", - "key": "reformat_node_config" - } - ] - }, { "key": "reformat_nodes_config", "type": "dict", From 527a4499a8c4acbb77e04140306e2c647717a59e Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 27 Jul 2023 22:35:04 +0300 Subject: [PATCH 034/107] set selction to empty --- openpype/hosts/houdini/api/plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 1e7eaa7e22..05e52e2478 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -170,6 +170,8 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): def create(self, subset_name, instance_data, pre_create_data): try: + self.selected_nodes = [] + if pre_create_data.get("use_selection"): self.selected_nodes = hou.selectedNodes() From 404079f07f02e05a0e8b4dd7fb0c21b94cd54277 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 28 Jul 2023 13:29:14 +0800 Subject: [PATCH 035/107] fixing the bug of not being able to select the camera when using selection --- openpype/hosts/houdini/plugins/create/create_karma_rop.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/houdini/plugins/create/create_karma_rop.py b/openpype/hosts/houdini/plugins/create/create_karma_rop.py index 71c2bf1b28..11957e8a61 100644 --- a/openpype/hosts/houdini/plugins/create/create_karma_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_karma_rop.py @@ -66,6 +66,7 @@ class CreateKarmaROP(plugin.HoudiniCreator): # we will use as render camera camera = None for node in self.selected_nodes: + camera = node.path() if node.type().name() == "cam": has_camera = pre_create_data.get("cam_res") if has_camera: From 7aba02ab7342231d07f0a21902dc174a02c96df4 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 28 Jul 2023 17:20:53 +0800 Subject: [PATCH 036/107] make sure the type of selected node is camera --- openpype/hosts/houdini/plugins/create/create_karma_rop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_karma_rop.py b/openpype/hosts/houdini/plugins/create/create_karma_rop.py index 11957e8a61..c7a9fe0968 100644 --- a/openpype/hosts/houdini/plugins/create/create_karma_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_karma_rop.py @@ -66,8 +66,8 @@ class CreateKarmaROP(plugin.HoudiniCreator): # we will use as render camera camera = None for node in self.selected_nodes: - camera = node.path() if node.type().name() == "cam": + camera = node.path() has_camera = pre_create_data.get("cam_res") if has_camera: res_x = node.evalParm("resx") From b43cac0b51f582579c0eae7508e918d2724fe5a6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 28 Jul 2023 11:51:23 +0200 Subject: [PATCH 037/107] AYON: Addons creation enhancements (#5356) * updated nuke settings * added addon version to zip filename * fix Pattern type hint * added ignored subdirs for openpype * added titles to addons * type hint fix - again * modified settings conversion * updated aftereffects settings * updated blender settings * updated clockify settings * updated core settings * updated deadline settings * updated harmo settings * updated kistsu settings * updated maya settings * updated muster settings * updated royal render settings * updated timers manager settings * updated traypublisher settings * implemented conversion of rr paths * formatting fix --- openpype/settings/ayon_settings.py | 204 +++++++++--------- server_addon/aftereffects/server/__init__.py | 1 + .../server/settings/creator_plugins.py | 6 +- .../aftereffects/server/settings/main.py | 34 ++- .../server/settings/publish_plugins.py | 56 +++-- .../settings/templated_workfile_build.py | 33 +++ .../server/settings/workfile_builder.py | 2 +- server_addon/aftereffects/server/version.py | 2 +- server_addon/applications/server/__init__.py | 1 + server_addon/blender/server/settings/main.py | 10 + .../server/settings/publish_plugins.py | 12 +- server_addon/blender/server/version.py | 2 +- server_addon/clockify/server/settings.py | 3 +- server_addon/clockify/server/version.py | 2 +- server_addon/core/server/__init__.py | 1 + server_addon/core/server/settings/main.py | 4 +- server_addon/core/server/version.py | 2 +- server_addon/create_ayon_addons.py | 51 ++++- server_addon/deadline/server/settings/main.py | 6 +- server_addon/deadline/server/version.py | 2 +- server_addon/harmony/server/__init__.py | 1 + server_addon/harmony/server/settings/load.py | 20 -- server_addon/harmony/server/settings/main.py | 5 - server_addon/harmony/server/version.py | 2 +- server_addon/kitsu/server/settings.py | 7 +- server_addon/kitsu/server/version.py | 2 +- server_addon/maya/server/settings/creators.py | 2 - server_addon/maya/server/settings/main.py | 4 +- server_addon/maya/server/version.py | 2 +- server_addon/muster/server/settings.py | 6 +- server_addon/muster/server/version.py | 2 +- .../nuke/server/settings/publish_plugins.py | 32 --- server_addon/nuke/server/version.py | 2 +- server_addon/photoshop/server/__init__.py | 1 + server_addon/royal_render/server/settings.py | 25 ++- server_addon/royal_render/server/version.py | 2 +- .../timers_manager/server/settings.py | 24 ++- server_addon/timers_manager/server/version.py | 2 +- server_addon/traypublisher/server/__init__.py | 1 + .../server/settings/publish_plugins.py | 9 + server_addon/traypublisher/server/version.py | 2 +- 41 files changed, 341 insertions(+), 246 deletions(-) create mode 100644 server_addon/aftereffects/server/settings/templated_workfile_build.py delete mode 100644 server_addon/harmony/server/settings/load.py diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index d2a2afbee0..90c7f33fd2 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -161,91 +161,95 @@ def _convert_general(ayon_settings, output, default_settings): output["general"] = general -def _convert_kitsu_system_settings(ayon_settings, output): - output["modules"]["kitsu"] = { - "server": ayon_settings["kitsu"]["server"] - } +def _convert_kitsu_system_settings( + ayon_settings, output, addon_versions, default_settings +): + enabled = addon_versions.get("kitsu") is not None + kitsu_settings = default_settings["modules"]["kitsu"] + kitsu_settings["enabled"] = enabled + if enabled: + kitsu_settings["server"] = ayon_settings["kitsu"]["server"] + output["modules"]["kitsu"] = kitsu_settings -def _convert_ftrack_system_settings(ayon_settings, output, defaults): - # Ftrack contains few keys that are needed for initialization in OpenPype - # mode and some are used on different places - ftrack_settings = defaults["modules"]["ftrack"] - ftrack_settings["ftrack_server"] = ( - ayon_settings["ftrack"]["ftrack_server"]) - output["modules"]["ftrack"] = ftrack_settings - - -def _convert_shotgrid_system_settings(ayon_settings, output): - ayon_shotgrid = ayon_settings["shotgrid"] - # Skip conversion if different ayon addon is used - if "leecher_manager_url" not in ayon_shotgrid: - output["shotgrid"] = ayon_shotgrid - return - - shotgrid_settings = {} - for key in ( - "leecher_manager_url", - "leecher_backend_url", - "filter_projects_by_login", - ): - shotgrid_settings[key] = ayon_shotgrid[key] - - new_items = {} - for item in ayon_shotgrid["shotgrid_settings"]: - name = item.pop("name") - new_items[name] = item - shotgrid_settings["shotgrid_settings"] = new_items - - output["modules"]["shotgrid"] = shotgrid_settings - - -def _convert_timers_manager_system_settings(ayon_settings, output): - ayon_manager = ayon_settings["timers_manager"] - manager_settings = { - key: ayon_manager[key] - for key in { - "auto_stop", "full_time", "message_time", "disregard_publishing" - } - } +def _convert_timers_manager_system_settings( + ayon_settings, output, addon_versions, default_settings +): + enabled = addon_versions.get("timers_manager") is not None + manager_settings = default_settings["modules"]["timers_manager"] + manager_settings["enabled"] = enabled + if enabled: + ayon_manager = ayon_settings["timers_manager"] + manager_settings.update({ + key: ayon_manager[key] + for key in { + "auto_stop", + "full_time", + "message_time", + "disregard_publishing" + } + }) output["modules"]["timers_manager"] = manager_settings -def _convert_clockify_system_settings(ayon_settings, output): - output["modules"]["clockify"] = ayon_settings["clockify"] +def _convert_clockify_system_settings( + ayon_settings, output, addon_versions, default_settings +): + enabled = addon_versions.get("clockify") is not None + clockify_settings = default_settings["modules"]["clockify"] + clockify_settings["enabled"] = enabled + if enabled: + clockify_settings["workspace_name"] = ( + ayon_settings["clockify"]["workspace_name"] + ) + output["modules"]["clockify"] = clockify_settings -def _convert_deadline_system_settings(ayon_settings, output): - ayon_deadline = ayon_settings["deadline"] - deadline_settings = { - "deadline_urls": { +def _convert_deadline_system_settings( + ayon_settings, output, addon_versions, default_settings +): + enabled = addon_versions.get("deadline") is not None + deadline_settings = default_settings["modules"]["deadline"] + deadline_settings["enabled"] = enabled + if enabled: + ayon_deadline = ayon_settings["deadline"] + deadline_settings["deadline_urls"] = { item["name"]: item["value"] for item in ayon_deadline["deadline_urls"] } - } + output["modules"]["deadline"] = deadline_settings -def _convert_muster_system_settings(ayon_settings, output): - ayon_muster = ayon_settings["muster"] - templates_mapping = { - item["name"]: item["value"] - for item in ayon_muster["templates_mapping"] - } - output["modules"]["muster"] = { - "templates_mapping": templates_mapping, - "MUSTER_REST_URL": ayon_muster["MUSTER_REST_URL"] - } +def _convert_muster_system_settings( + ayon_settings, output, addon_versions, default_settings +): + enabled = addon_versions.get("muster") is not None + muster_settings = default_settings["modules"]["muster"] + muster_settings["enabled"] = enabled + if enabled: + ayon_muster = ayon_settings["muster"] + muster_settings["MUSTER_REST_URL"] = ayon_muster["MUSTER_REST_URL"] + muster_settings["templates_mapping"] = { + item["name"]: item["value"] + for item in ayon_muster["templates_mapping"] + } + output["modules"]["muster"] = muster_settings -def _convert_royalrender_system_settings(ayon_settings, output): - ayon_royalrender = ayon_settings["royalrender"] - output["modules"]["royalrender"] = { - "rr_paths": { +def _convert_royalrender_system_settings( + ayon_settings, output, addon_versions, default_settings +): + enabled = addon_versions.get("royalrender") is not None + rr_settings = default_settings["modules"]["royalrender"] + rr_settings["enabled"] = enabled + if enabled: + ayon_royalrender = ayon_settings["royalrender"] + rr_settings["rr_paths"] = { item["name"]: item["value"] for item in ayon_royalrender["rr_paths"] } - } + output["modules"]["royalrender"] = rr_settings def _convert_modules_system( @@ -253,42 +257,29 @@ def _convert_modules_system( ): # TODO add all modules # TODO add 'enabled' values - for key, func in ( - ("kitsu", _convert_kitsu_system_settings), - ("shotgrid", _convert_shotgrid_system_settings), - ("timers_manager", _convert_timers_manager_system_settings), - ("clockify", _convert_clockify_system_settings), - ("deadline", _convert_deadline_system_settings), - ("muster", _convert_muster_system_settings), - ("royalrender", _convert_royalrender_system_settings), + for func in ( + _convert_kitsu_system_settings, + _convert_timers_manager_system_settings, + _convert_clockify_system_settings, + _convert_deadline_system_settings, + _convert_muster_system_settings, + _convert_royalrender_system_settings, ): - if key in ayon_settings: - func(ayon_settings, output) + func(ayon_settings, output, addon_versions, default_settings) - if "ftrack" in ayon_settings: - _convert_ftrack_system_settings( - ayon_settings, output, default_settings) - - output_modules = output["modules"] - # TODO remove when not needed - for module_name, value in default_settings["modules"].items(): - if module_name not in output_modules: - output_modules[module_name] = value - - for module_name, value in default_settings["modules"].items(): - if "enabled" not in value or module_name not in output_modules: - continue - - ayon_module_name = module_name - if module_name == "sync_server": - ayon_module_name = "sitesync" - output_modules[module_name]["enabled"] = ( - ayon_module_name in addon_versions) - - # Missing modules conversions - # - "sync_server" -> renamed to sitesync - # - "slack" -> only 'enabled' - # - "job_queue" -> completelly missing in ayon + for module_name in ( + "sync_server", + "log_viewer", + "standalonepublish_tool", + "project_manager", + "job_queue", + "avalon", + "addon_paths", + ): + settings = default_settings["modules"][module_name] + if "enabled" in settings: + settings["enabled"] = False + output["modules"][module_name] = settings def convert_system_settings(ayon_settings, default_settings, addon_versions): @@ -724,12 +715,6 @@ def _convert_nuke_project_settings(ayon_settings, output): item_filter["subsets"] = item_filter.pop("product_names") item_filter["families"] = item_filter.pop("product_types") - item["reformat_node_config"] = _convert_nuke_knobs( - item["reformat_node_config"]) - - for node in item["reformat_nodes_config"]["reposition_nodes"]: - node["knobs"] = _convert_nuke_knobs(node["knobs"]) - name = item.pop("name") new_review_data_outputs[name] = item ayon_publish["ExtractReviewDataMov"]["outputs"] = new_review_data_outputs @@ -990,8 +975,11 @@ def _convert_royalrender_project_settings(ayon_settings, output): if "royalrender" not in ayon_settings: return ayon_royalrender = ayon_settings["royalrender"] + rr_paths = ayon_royalrender.get("selected_rr_paths", []) + output["royalrender"] = { - "publish": ayon_royalrender["publish"] + "publish": ayon_royalrender["publish"], + "rr_paths": rr_paths, } diff --git a/server_addon/aftereffects/server/__init__.py b/server_addon/aftereffects/server/__init__.py index e895c07ce1..e14e76e9db 100644 --- a/server_addon/aftereffects/server/__init__.py +++ b/server_addon/aftereffects/server/__init__.py @@ -6,6 +6,7 @@ from .version import __version__ class AfterEffects(BaseServerAddon): name = "aftereffects" + title = "AfterEffects" version = __version__ settings_model = AfterEffectsSettings diff --git a/server_addon/aftereffects/server/settings/creator_plugins.py b/server_addon/aftereffects/server/settings/creator_plugins.py index fee01bad26..ee52fadd40 100644 --- a/server_addon/aftereffects/server/settings/creator_plugins.py +++ b/server_addon/aftereffects/server/settings/creator_plugins.py @@ -5,8 +5,10 @@ from ayon_server.settings import BaseSettingsModel class CreateRenderPlugin(BaseSettingsModel): mark_for_review: bool = Field(True, title="Review") - defaults: list[str] = Field(default_factory=list, - title="Default Variants") + defaults: list[str] = Field( + default_factory=list, + title="Default Variants" + ) class AfterEffectsCreatorPlugins(BaseSettingsModel): diff --git a/server_addon/aftereffects/server/settings/main.py b/server_addon/aftereffects/server/settings/main.py index 9da872bd92..04d2e51cc9 100644 --- a/server_addon/aftereffects/server/settings/main.py +++ b/server_addon/aftereffects/server/settings/main.py @@ -3,8 +3,12 @@ from ayon_server.settings import BaseSettingsModel from .imageio import AfterEffectsImageIOModel from .creator_plugins import AfterEffectsCreatorPlugins -from .publish_plugins import AfterEffectsPublishPlugins +from .publish_plugins import ( + AfterEffectsPublishPlugins, + AE_PUBLISH_PLUGINS_DEFAULTS, +) from .workfile_builder import WorkfileBuilderPlugin +from .templated_workfile_build import TemplatedWorkfileBuildModel class AfterEffectsSettings(BaseSettingsModel): @@ -18,16 +22,18 @@ class AfterEffectsSettings(BaseSettingsModel): default_factory=AfterEffectsCreatorPlugins, title="Creator plugins" ) - publish: AfterEffectsPublishPlugins = Field( default_factory=AfterEffectsPublishPlugins, title="Publish plugins" ) - workfile_builder: WorkfileBuilderPlugin = Field( default_factory=WorkfileBuilderPlugin, title="Workfile Builder" ) + templated_workfile_build: TemplatedWorkfileBuildModel = Field( + default_factory=TemplatedWorkfileBuildModel, + title="Templated Workfile Build Settings" + ) DEFAULT_AFTEREFFECTS_SETTING = { @@ -39,24 +45,12 @@ DEFAULT_AFTEREFFECTS_SETTING = { ] } }, - "publish": { - "CollectReview": { - "enabled": True - }, - "ValidateSceneSettings": { - "enabled": True, - "optional": True, - "active": True, - "skip_resolution_check": [ - ".*" - ], - "skip_timelines_check": [ - ".*" - ] - } - }, + "publish": AE_PUBLISH_PLUGINS_DEFAULTS, "workfile_builder": { "create_first_version": False, "custom_templates": [] - } + }, + "templated_workfile_build": { + "profiles": [] + }, } diff --git a/server_addon/aftereffects/server/settings/publish_plugins.py b/server_addon/aftereffects/server/settings/publish_plugins.py index 0d90b08b5a..78445d3223 100644 --- a/server_addon/aftereffects/server/settings/publish_plugins.py +++ b/server_addon/aftereffects/server/settings/publish_plugins.py @@ -7,30 +7,62 @@ class CollectReviewPluginModel(BaseSettingsModel): enabled: bool = Field(True, title="Enabled") -class ValidateSceneSettingsPlugin(BaseSettingsModel): - """Validate naming of products and layers""" # - _isGroup = True - enabled: bool = True +class ValidateSceneSettingsModel(BaseSettingsModel): + """Validate naming of products and layers""" + + # _isGroup = True + enabled: bool = Field(True, title="Enabled") optional: bool = Field(False, title="Optional") active: bool = Field(True, title="Active") - skip_resolution_check: list[str] = Field( default_factory=list, - title="Skip Resolution Check for Tasks" + title="Skip Resolution Check for Tasks", ) - skip_timelines_check: list[str] = Field( default_factory=list, - title="Skip Timeline Check for Tasks" + title="Skip Timeline Check for Tasks", ) +class ValidateContainersModel(BaseSettingsModel): + enabled: bool = Field(True, title="Enabled") + optional: bool = Field(True, title="Optional") + active: bool = Field(True, title="Active") + + class AfterEffectsPublishPlugins(BaseSettingsModel): CollectReview: CollectReviewPluginModel = Field( - default_facotory=CollectReviewPluginModel, - title="Collect Review" + default_factory=CollectReviewPluginModel, + title="Collect Review", ) - ValidateSceneSettings: ValidateSceneSettingsPlugin = Field( + ValidateSceneSettings: ValidateSceneSettingsModel = Field( + default_factory=ValidateSceneSettingsModel, title="Validate Scene Settings", - default_factory=ValidateSceneSettingsPlugin, ) + ValidateContainers: ValidateContainersModel = Field( + default_factory=ValidateContainersModel, + title="Validate Containers", + ) + + +AE_PUBLISH_PLUGINS_DEFAULTS = { + "CollectReview": { + "enabled": True + }, + "ValidateSceneSettings": { + "enabled": True, + "optional": True, + "active": True, + "skip_resolution_check": [ + ".*" + ], + "skip_timelines_check": [ + ".*" + ] + }, + "ValidateContainers": { + "enabled": True, + "optional": True, + "active": True, + } +} diff --git a/server_addon/aftereffects/server/settings/templated_workfile_build.py b/server_addon/aftereffects/server/settings/templated_workfile_build.py new file mode 100644 index 0000000000..e0245c8d06 --- /dev/null +++ b/server_addon/aftereffects/server/settings/templated_workfile_build.py @@ -0,0 +1,33 @@ +from pydantic import Field +from ayon_server.settings import ( + BaseSettingsModel, + task_types_enum, +) + + +class TemplatedWorkfileProfileModel(BaseSettingsModel): + task_types: list[str] = Field( + default_factory=list, + title="Task types", + enum_resolver=task_types_enum + ) + task_names: list[str] = Field( + default_factory=list, + title="Task names" + ) + path: str = Field( + title="Path to template" + ) + keep_placeholder: bool = Field( + False, + title="Keep placeholders") + create_first_version: bool = Field( + True, + title="Create first version" + ) + + +class TemplatedWorkfileBuildModel(BaseSettingsModel): + profiles: list[TemplatedWorkfileProfileModel] = Field( + default_factory=list + ) diff --git a/server_addon/aftereffects/server/settings/workfile_builder.py b/server_addon/aftereffects/server/settings/workfile_builder.py index d9d5fa41bf..d45d3f7f24 100644 --- a/server_addon/aftereffects/server/settings/workfile_builder.py +++ b/server_addon/aftereffects/server/settings/workfile_builder.py @@ -21,5 +21,5 @@ class WorkfileBuilderPlugin(BaseSettingsModel): ) custom_templates: list[CustomBuilderTemplate] = Field( - default_factory=CustomBuilderTemplate + default_factory=list ) diff --git a/server_addon/aftereffects/server/version.py b/server_addon/aftereffects/server/version.py index d4b9e2d7f3..a242f0e757 100644 --- a/server_addon/aftereffects/server/version.py +++ b/server_addon/aftereffects/server/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring addon version.""" -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/server_addon/applications/server/__init__.py b/server_addon/applications/server/__init__.py index a3fd92eb6e..fdec05006b 100644 --- a/server_addon/applications/server/__init__.py +++ b/server_addon/applications/server/__init__.py @@ -32,6 +32,7 @@ def get_enum_items_from_groups(groups): class ApplicationsAddon(BaseServerAddon): name = "applications" + title = "Applications" version = __version__ settings_model = ApplicationsAddonSettings diff --git a/server_addon/blender/server/settings/main.py b/server_addon/blender/server/settings/main.py index ec969afa93..f6118d39cd 100644 --- a/server_addon/blender/server/settings/main.py +++ b/server_addon/blender/server/settings/main.py @@ -25,6 +25,14 @@ class BlenderSettings(BaseSettingsModel): default_factory=UnitScaleSettingsModel, title="Set Unit Scale" ) + set_resolution_startup: bool = Field( + True, + title="Set Resolution on Startup" + ) + set_frames_startup: bool = Field( + True, + title="Set Start/End Frames and FPS on Startup" + ) imageio: BlenderImageIOModel = Field( default_factory=BlenderImageIOModel, title="Color Management (ImageIO)" @@ -45,6 +53,8 @@ DEFAULT_VALUES = { "apply_on_opening": False, "base_file_unit_scale": 0.01 }, + "set_frames_startup": True, + "set_resolution_startup": True, "publish": DEFAULT_BLENDER_PUBLISH_SETTINGS, "workfile_builder": { "create_first_version": False, diff --git a/server_addon/blender/server/settings/publish_plugins.py b/server_addon/blender/server/settings/publish_plugins.py index 43ed3e3d0d..65dda78411 100644 --- a/server_addon/blender/server/settings/publish_plugins.py +++ b/server_addon/blender/server/settings/publish_plugins.py @@ -94,6 +94,10 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=ValidatePluginModel, title="Extract Camera" ) + ExtractCameraABC: ValidatePluginModel = Field( + default_factory=ValidatePluginModel, + title="Extract Camera as ABC" + ) ExtractLayout: ValidatePluginModel = Field( default_factory=ValidatePluginModel, title="Extract Layout" @@ -143,7 +147,8 @@ DEFAULT_BLENDER_PUBLISH_SETTINGS = { "camera", "rig", "action", - "layout" + "layout", + "blendScene" ] }, "ExtractFBX": { @@ -171,6 +176,11 @@ DEFAULT_BLENDER_PUBLISH_SETTINGS = { "optional": True, "active": True }, + "ExtractCameraABC": { + "enabled": True, + "optional": True, + "active": True + }, "ExtractLayout": { "enabled": True, "optional": True, diff --git a/server_addon/blender/server/version.py b/server_addon/blender/server/version.py index 3dc1f76bc6..485f44ac21 100644 --- a/server_addon/blender/server/version.py +++ b/server_addon/blender/server/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/server_addon/clockify/server/settings.py b/server_addon/clockify/server/settings.py index f6891fc5b8..9067cd4243 100644 --- a/server_addon/clockify/server/settings.py +++ b/server_addon/clockify/server/settings.py @@ -5,5 +5,6 @@ from ayon_server.settings import BaseSettingsModel class ClockifySettings(BaseSettingsModel): workspace_name: str = Field( "", - title="Workspace name" + title="Workspace name", + scope=["studio"] ) diff --git a/server_addon/clockify/server/version.py b/server_addon/clockify/server/version.py index 3dc1f76bc6..485f44ac21 100644 --- a/server_addon/clockify/server/version.py +++ b/server_addon/clockify/server/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/server_addon/core/server/__init__.py b/server_addon/core/server/__init__.py index ff91f91c75..4de2b038a5 100644 --- a/server_addon/core/server/__init__.py +++ b/server_addon/core/server/__init__.py @@ -6,6 +6,7 @@ from .settings import CoreSettings, DEFAULT_VALUES class CoreAddon(BaseServerAddon): name = "core" + title = "Core" version = __version__ settings_model = CoreSettings diff --git a/server_addon/core/server/settings/main.py b/server_addon/core/server/settings/main.py index a1a86ae0a5..d19d732e71 100644 --- a/server_addon/core/server/settings/main.py +++ b/server_addon/core/server/settings/main.py @@ -49,8 +49,8 @@ class CoreImageIOBaseModel(BaseSettingsModel): class CoreSettings(BaseSettingsModel): - studio_name: str = Field("", title="Studio name") - studio_code: str = Field("", title="Studio code") + studio_name: str = Field("", title="Studio name", scope=["studio"]) + studio_code: str = Field("", title="Studio code", scope=["studio"]) environments: str = Field( "{}", title="Global environment variables", diff --git a/server_addon/core/server/version.py b/server_addon/core/server/version.py index 3dc1f76bc6..485f44ac21 100644 --- a/server_addon/core/server/version.py +++ b/server_addon/core/server/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/server_addon/create_ayon_addons.py b/server_addon/create_ayon_addons.py index 3b566cec63..61dbd5c8d9 100644 --- a/server_addon/create_ayon_addons.py +++ b/server_addon/create_ayon_addons.py @@ -7,10 +7,10 @@ import zipfile import platform import collections from pathlib import Path -from typing import Any, Optional, Iterable +from typing import Any, Optional, Iterable, Pattern, List, Tuple # Patterns of directories to be skipped for server part of addon -IGNORE_DIR_PATTERNS: list[re.Pattern] = [ +IGNORE_DIR_PATTERNS: List[Pattern] = [ re.compile(pattern) for pattern in { # Skip directories starting with '.' @@ -21,7 +21,7 @@ IGNORE_DIR_PATTERNS: list[re.Pattern] = [ ] # Patterns of files to be skipped for server part of addon -IGNORE_FILE_PATTERNS: list[re.Pattern] = [ +IGNORE_FILE_PATTERNS: List[Pattern] = [ re.compile(pattern) for pattern in { # Skip files starting with '.' @@ -56,7 +56,7 @@ class ZipFileLongPaths(zipfile.ZipFile): ) -def _value_match_regexes(value: str, regexes: Iterable[re.Pattern]) -> bool: +def _value_match_regexes(value: str, regexes: Iterable[Pattern]) -> bool: return any( regex.search(value) for regex in regexes @@ -65,8 +65,9 @@ def _value_match_regexes(value: str, regexes: Iterable[re.Pattern]) -> bool: def find_files_in_subdir( src_path: str, - ignore_file_patterns: Optional[list[re.Pattern]] = None, - ignore_dir_patterns: Optional[list[re.Pattern]] = None + ignore_file_patterns: Optional[List[Pattern]] = None, + ignore_dir_patterns: Optional[List[Pattern]] = None, + ignore_subdirs: Optional[Iterable[Tuple[str]]] = None ): """Find all files to copy in subdirectories of given path. @@ -76,13 +77,15 @@ def find_files_in_subdir( Args: src_path (str): Path to directory to search in. - ignore_file_patterns (Optional[list[re.Pattern]]): List of regexes + ignore_file_patterns (Optional[List[Pattern]]): List of regexes to match files to ignore. - ignore_dir_patterns (Optional[list[re.Pattern]]): List of regexes + ignore_dir_patterns (Optional[List[Pattern]]): List of regexes to match directories to ignore. + ignore_subdirs (Optional[Iterable[Tuple[str]]]): List of + subdirectories to ignore. Returns: - list[tuple[str, str]]: List of tuples with path to file and parent + List[Tuple[str, str]]: List of tuples with path to file and parent directories relative to 'src_path'. """ @@ -98,6 +101,8 @@ def find_files_in_subdir( while hierarchy_queue: item: tuple[str, str] = hierarchy_queue.popleft() dirpath, parents = item + if ignore_subdirs and parents in ignore_subdirs: + continue for name in os.listdir(dirpath): path = os.path.join(dirpath, name) if os.path.isfile(path): @@ -133,7 +138,7 @@ def create_addon_zip( addon_version: str, keep_source: bool ): - zip_filepath = output_dir / f"{addon_name}.zip" + zip_filepath = output_dir / f"{addon_name}-{addon_version}.zip" addon_output_dir = output_dir / addon_name / addon_version with ZipFileLongPaths(zip_filepath, "w", zipfile.ZIP_DEFLATED) as zipf: zipf.writestr( @@ -194,11 +199,35 @@ def create_openpype_package( (private_dir / pyproject_path.name) ) + ignored_hosts = [] + ignored_modules = [ + "ftrack", + "shotgrid", + "sync_server", + "example_addons", + "slack" + ] + # Subdirs that won't be added to output zip file + ignored_subpaths = [ + ["addons"], + ["vendor", "common", "ayon_api"], + ] + ignored_subpaths.extend( + ["hosts", host_name] + for host_name in ignored_hosts + ) + ignored_subpaths.extend( + ["modules", module_name] + for module_name in ignored_modules + ) + # Zip client zip_filepath = private_dir / "client.zip" with ZipFileLongPaths(zip_filepath, "w", zipfile.ZIP_DEFLATED) as zipf: # Add client code content to zip - for path, sub_path in find_files_in_subdir(str(openpype_dir)): + for path, sub_path in find_files_in_subdir( + str(openpype_dir), ignore_subdirs=ignored_subpaths + ): zipf.write(path, f"{openpype_dir.name}/{sub_path}") if create_zip: diff --git a/server_addon/deadline/server/settings/main.py b/server_addon/deadline/server/settings/main.py index e60df2eda3..f158b7464d 100644 --- a/server_addon/deadline/server/settings/main.py +++ b/server_addon/deadline/server/settings/main.py @@ -18,12 +18,12 @@ class DeadlineSettings(BaseSettingsModel): deadline_urls: list[ServerListSubmodel] = Field( default_factory=list, title="System Deadline Webservice URLs", + scope=["studio"], ) - deadline_servers: list[str] = Field( title="Project deadline servers", - section="---") - + section="---", + ) publish: PublishPluginsModel = Field( default_factory=PublishPluginsModel, title="Publish Plugins", diff --git a/server_addon/deadline/server/version.py b/server_addon/deadline/server/version.py index 3dc1f76bc6..485f44ac21 100644 --- a/server_addon/deadline/server/version.py +++ b/server_addon/deadline/server/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/server_addon/harmony/server/__init__.py b/server_addon/harmony/server/__init__.py index 64f41849ad..4ecda1989e 100644 --- a/server_addon/harmony/server/__init__.py +++ b/server_addon/harmony/server/__init__.py @@ -6,6 +6,7 @@ from .version import __version__ class Harmony(BaseServerAddon): name = "harmony" + title = "Harmony" version = __version__ settings_model = HarmonySettings diff --git a/server_addon/harmony/server/settings/load.py b/server_addon/harmony/server/settings/load.py deleted file mode 100644 index 1222485ff9..0000000000 --- a/server_addon/harmony/server/settings/load.py +++ /dev/null @@ -1,20 +0,0 @@ -from pydantic import Field -from ayon_server.settings import BaseSettingsModel - - -class ImageSequenceLoaderModel(BaseSettingsModel): - family: list[str] = Field( - default_factory=list, - title="Families" - ) - representations: list[str] = Field( - default_factory=list, - title="Representations" - ) - - -class HarmonyLoadModel(BaseSettingsModel): - ImageSequenceLoader: ImageSequenceLoaderModel = Field( - default_factory=ImageSequenceLoaderModel, - title="Load Image Sequence" - ) diff --git a/server_addon/harmony/server/settings/main.py b/server_addon/harmony/server/settings/main.py index ae08da0198..0936bc1fc7 100644 --- a/server_addon/harmony/server/settings/main.py +++ b/server_addon/harmony/server/settings/main.py @@ -2,7 +2,6 @@ from pydantic import Field from ayon_server.settings import BaseSettingsModel from .imageio import HarmonyImageIOModel -from .load import HarmonyLoadModel from .publish_plugins import HarmonyPublishPlugins @@ -13,10 +12,6 @@ class HarmonySettings(BaseSettingsModel): default_factory=HarmonyImageIOModel, title="OCIO config" ) - load: HarmonyLoadModel = Field( - default_factory=HarmonyLoadModel, - title="Loader plugins" - ) publish: HarmonyPublishPlugins = Field( default_factory=HarmonyPublishPlugins, title="Publish plugins" diff --git a/server_addon/harmony/server/version.py b/server_addon/harmony/server/version.py index a242f0e757..df0c92f1e2 100644 --- a/server_addon/harmony/server/version.py +++ b/server_addon/harmony/server/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring addon version.""" -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/server_addon/kitsu/server/settings.py b/server_addon/kitsu/server/settings.py index 7afa73ec72..a4d10d889d 100644 --- a/server_addon/kitsu/server/settings.py +++ b/server_addon/kitsu/server/settings.py @@ -76,15 +76,16 @@ class PublishPlugins(BaseSettingsModel): class KitsuSettings(BaseSettingsModel): server: str = Field( "", - title="Kitsu Server" + title="Kitsu Server", + scope=["studio"], ) entities_naming_pattern: EntityPattern = Field( default_factory=EntityPattern, - title="Entities naming pattern" + title="Entities naming pattern", ) publish: PublishPlugins = Field( default_factory=PublishPlugins, - title="Publish plugins" + title="Publish plugins", ) diff --git a/server_addon/kitsu/server/version.py b/server_addon/kitsu/server/version.py index 3dc1f76bc6..485f44ac21 100644 --- a/server_addon/kitsu/server/version.py +++ b/server_addon/kitsu/server/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/server_addon/maya/server/settings/creators.py b/server_addon/maya/server/settings/creators.py index 3756d45e6c..291b3ec660 100644 --- a/server_addon/maya/server/settings/creators.py +++ b/server_addon/maya/server/settings/creators.py @@ -55,7 +55,6 @@ class BasicExportMeshModel(BaseSettingsModel): class CreateAnimationModel(BaseSettingsModel): - enabled: bool = Field(title="Enabled") write_color_sets: bool = Field(title="Write Color Sets") write_face_sets: bool = Field(title="Write Face Sets") include_parent_hierarchy: bool = Field( @@ -259,7 +258,6 @@ DEFAULT_CREATORS_SETTINGS = { "publish_mip_map": True }, "CreateAnimation": { - "enabled": False, "write_color_sets": False, "write_face_sets": False, "include_parent_hierarchy": False, diff --git a/server_addon/maya/server/settings/main.py b/server_addon/maya/server/settings/main.py index 47f4121584..c8021614be 100644 --- a/server_addon/maya/server/settings/main.py +++ b/server_addon/maya/server/settings/main.py @@ -60,7 +60,9 @@ class MayaSettings(BaseSettingsModel): title="Include/Exclude Handles in default playback & render range" ) scriptsmenu: ScriptsmenuModel = Field( - default_factory=ScriptsmenuModel, title="Scriptsmenu Settings") + default_factory=ScriptsmenuModel, + title="Scriptsmenu Settings" + ) render_settings: RenderSettingsModel = Field( default_factory=RenderSettingsModel, title="Render Settings") create: CreatorsModel = Field( diff --git a/server_addon/maya/server/version.py b/server_addon/maya/server/version.py index d4b9e2d7f3..a242f0e757 100644 --- a/server_addon/maya/server/version.py +++ b/server_addon/maya/server/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring addon version.""" -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/server_addon/muster/server/settings.py b/server_addon/muster/server/settings.py index f3f6660abc..e37c762870 100644 --- a/server_addon/muster/server/settings.py +++ b/server_addon/muster/server/settings.py @@ -10,7 +10,11 @@ class TemplatesMapping(BaseSettingsModel): class MusterSettings(BaseSettingsModel): enabled: bool = True - MUSTER_REST_URL: str = Field("", title="Muster Rest URL") + MUSTER_REST_URL: str = Field( + "", + title="Muster Rest URL", + scope=["studio"], + ) templates_mapping: list[TemplatesMapping] = Field( default_factory=list, diff --git a/server_addon/muster/server/version.py b/server_addon/muster/server/version.py index 3dc1f76bc6..485f44ac21 100644 --- a/server_addon/muster/server/version.py +++ b/server_addon/muster/server/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/server_addon/nuke/server/settings/publish_plugins.py b/server_addon/nuke/server/settings/publish_plugins.py index f057fd629d..7e898f8c9a 100644 --- a/server_addon/nuke/server/settings/publish_plugins.py +++ b/server_addon/nuke/server/settings/publish_plugins.py @@ -165,10 +165,6 @@ class BakingStreamModel(BaseSettingsModel): viewer_process_override: str = Field(title="Viewer process override") bake_viewer_process: bool = Field(title="Bake view process") bake_viewer_input_process: bool = Field(title="Bake viewer input process") - reformat_node_add: bool = Field(title="Add reformat node") - reformat_node_config: list[KnobModel] = Field( - default_factory=list, - title="Reformat node properties") reformat_nodes_config: ReformatNodesConfigModel = Field( default_factory=ReformatNodesConfigModel, title="Reformat Nodes") @@ -443,34 +439,6 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = { "viewer_process_override": "", "bake_viewer_process": True, "bake_viewer_input_process": True, - "reformat_node_add": False, - "reformat_node_config": [ - { - "type": "text", - "name": "type", - "text": "to format" - }, - { - "type": "text", - "name": "format", - "text": "HD_1080" - }, - { - "type": "text", - "name": "filter", - "text": "Lanczos6" - }, - { - "type": "boolean", - "name": "black_outside", - "boolean": True - }, - { - "type": "boolean", - "name": "pbb", - "boolean": False - } - ], "reformat_nodes_config": { "enabled": False, "reposition_nodes": [ diff --git a/server_addon/nuke/server/version.py b/server_addon/nuke/server/version.py index 3dc1f76bc6..485f44ac21 100644 --- a/server_addon/nuke/server/version.py +++ b/server_addon/nuke/server/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/server_addon/photoshop/server/__init__.py b/server_addon/photoshop/server/__init__.py index e7ac218b5a..3a45f7a809 100644 --- a/server_addon/photoshop/server/__init__.py +++ b/server_addon/photoshop/server/__init__.py @@ -6,6 +6,7 @@ from .version import __version__ class Photoshop(BaseServerAddon): name = "photoshop" + title = "Photoshop" version = __version__ settings_model = PhotoshopSettings diff --git a/server_addon/royal_render/server/settings.py b/server_addon/royal_render/server/settings.py index 8b1fde6493..677d7e2671 100644 --- a/server_addon/royal_render/server/settings.py +++ b/server_addon/royal_render/server/settings.py @@ -2,11 +2,15 @@ from pydantic import Field from ayon_server.settings import BaseSettingsModel, MultiplatformPathModel +class CustomPath(MultiplatformPathModel): + _layout = "expanded" + + class ServerListSubmodel(BaseSettingsModel): - _layout = "compact" + _layout = "expanded" name: str = Field("", title="Name") - value: MultiplatformPathModel = Field( - default_factory=MultiplatformPathModel + value: CustomPath = Field( + default_factory=CustomPath ) @@ -23,13 +27,25 @@ class PublishPluginsModel(BaseSettingsModel): class RoyalRenderSettings(BaseSettingsModel): enabled: bool = True + # WARNING/TODO this needs change + # - both system and project settings contained 'rr_path' + # where project settings did choose one of rr_path from system settings + # that is not possible in AYON rr_paths: list[ServerListSubmodel] = Field( default_factory=list, title="Royal Render Root Paths", + scope=["studio"], + ) + # This was 'rr_paths' in project settings and should be enum of + # 'rr_paths' from system settings, but that's not possible in AYON + selected_rr_paths: list[str] = Field( + default_factory=list, + title="Selected Royal Render Paths", + section="---", ) publish: PublishPluginsModel = Field( default_factory=PublishPluginsModel, - title="Publish plugins" + title="Publish plugins", ) @@ -45,6 +61,7 @@ DEFAULT_VALUES = { } } ], + "selected_rr_paths": ["default"], "publish": { "CollectSequencesFromJob": { "review": True diff --git a/server_addon/royal_render/server/version.py b/server_addon/royal_render/server/version.py index 3dc1f76bc6..485f44ac21 100644 --- a/server_addon/royal_render/server/version.py +++ b/server_addon/royal_render/server/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/server_addon/timers_manager/server/settings.py b/server_addon/timers_manager/server/settings.py index 27dbc6ef8e..a5c5721a57 100644 --- a/server_addon/timers_manager/server/settings.py +++ b/server_addon/timers_manager/server/settings.py @@ -3,7 +3,23 @@ from ayon_server.settings import BaseSettingsModel class TimersManagerSettings(BaseSettingsModel): - auto_stop: bool = Field(True, title="Auto stop timer") - full_time: int = Field(15, title="Max idle time") - message_time: float = Field(0.5, title="When dialog will show") - disregard_publishing: bool = Field(False, title="Disregard publishing") + auto_stop: bool = Field( + True, + title="Auto stop timer", + scope=["studio"], + ) + full_time: int = Field( + 15, + title="Max idle time", + scope=["studio"], + ) + message_time: float = Field( + 0.5, + title="When dialog will show", + scope=["studio"], + ) + disregard_publishing: bool = Field( + False, + title="Disregard publishing", + scope=["studio"], + ) diff --git a/server_addon/timers_manager/server/version.py b/server_addon/timers_manager/server/version.py index 3dc1f76bc6..485f44ac21 100644 --- a/server_addon/timers_manager/server/version.py +++ b/server_addon/timers_manager/server/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/server_addon/traypublisher/server/__init__.py b/server_addon/traypublisher/server/__init__.py index 308f32069f..e6f079609f 100644 --- a/server_addon/traypublisher/server/__init__.py +++ b/server_addon/traypublisher/server/__init__.py @@ -6,6 +6,7 @@ from .settings import TraypublisherSettings, DEFAULT_TRAYPUBLISHER_SETTING class Traypublisher(BaseServerAddon): name = "traypublisher" + title = "TrayPublisher" version = __version__ settings_model = TraypublisherSettings diff --git a/server_addon/traypublisher/server/settings/publish_plugins.py b/server_addon/traypublisher/server/settings/publish_plugins.py index 3f00f3d52e..8c844f29f2 100644 --- a/server_addon/traypublisher/server/settings/publish_plugins.py +++ b/server_addon/traypublisher/server/settings/publish_plugins.py @@ -17,6 +17,10 @@ class ValidateFrameRangeModel(ValidatePluginModel): class TrayPublisherPublishPlugins(BaseSettingsModel): + CollectFrameDataFromAssetEntity: ValidatePluginModel = Field( + default_factory=ValidatePluginModel, + title="Collect Frame Data From Folder Entity", + ) ValidateFrameRange: ValidateFrameRangeModel = Field( title="Validate Frame Range", default_factory=ValidateFrameRangeModel, @@ -28,6 +32,11 @@ class TrayPublisherPublishPlugins(BaseSettingsModel): DEFAULT_PUBLISH_PLUGINS = { + "CollectFrameDataFromAssetEntity": { + "enabled": True, + "optional": True, + "active": True + }, "ValidateFrameRange": { "enabled": True, "optional": True, diff --git a/server_addon/traypublisher/server/version.py b/server_addon/traypublisher/server/version.py index a242f0e757..df0c92f1e2 100644 --- a/server_addon/traypublisher/server/version.py +++ b/server_addon/traypublisher/server/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring addon version.""" -__version__ = "0.1.1" +__version__ = "0.1.2" From 0d9ea4aa266a25d35f555a0b7eb1e1d13aa1f41e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 28 Jul 2023 15:46:32 +0200 Subject: [PATCH 038/107] Applications: Environment variables order (#5245) * apply project environemnts after context environments are set * make asset and task environments optional * added more conditions for host environemnts * validate context for host * fix double negative --- openpype/lib/applications.py | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index f47e11926c..fbde59ced5 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1640,11 +1640,7 @@ def prepare_context_environments(data, env_group=None, modules_manager=None): project_doc = data["project_doc"] asset_doc = data["asset_doc"] task_name = data["task_name"] - if ( - not project_doc - or not asset_doc - or not task_name - ): + if not project_doc: log.info( "Skipping context environments preparation." " Launch context does not contain required data." @@ -1657,18 +1653,16 @@ def prepare_context_environments(data, env_group=None, modules_manager=None): system_settings = get_system_settings() data["project_settings"] = project_settings data["system_settings"] = system_settings - # Apply project specific environments on current env value - apply_project_environments_value( - project_name, data["env"], project_settings, env_group - ) app = data["app"] context_env = { "AVALON_PROJECT": project_doc["name"], - "AVALON_ASSET": asset_doc["name"], - "AVALON_TASK": task_name, "AVALON_APP_NAME": app.full_name } + if asset_doc: + context_env["AVALON_ASSET"] = asset_doc["name"] + if task_name: + context_env["AVALON_TASK"] = task_name log.debug( "Context environments set:\n{}".format( @@ -1676,9 +1670,25 @@ def prepare_context_environments(data, env_group=None, modules_manager=None): ) ) data["env"].update(context_env) + + # Apply project specific environments on current env value + # - apply them once the context environments are set + apply_project_environments_value( + project_name, data["env"], project_settings, env_group + ) + if not app.is_host: return + data["env"]["AVALON_APP"] = app.host_name + + if not asset_doc or not task_name: + # QUESTION replace with log.info and skip workfile discovery? + # - technically it should be possible to launch host without context + raise ApplicationLaunchFailed( + "Host launch require asset and task context." + ) + workdir_data = get_template_data( project_doc, asset_doc, task_name, app.host_name, system_settings ) @@ -1716,7 +1726,6 @@ def prepare_context_environments(data, env_group=None, modules_manager=None): "Couldn't create workdir because: {}".format(str(exc)) ) - data["env"]["AVALON_APP"] = app.host_name data["env"]["AVALON_WORKDIR"] = workdir _prepare_last_workfile(data, workdir, modules_manager) From 4247fce5a97b43298946ef2550aa0b7613a822c9 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 29 Jul 2023 03:24:13 +0000 Subject: [PATCH 039/107] [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 0a0b192892..61bb0f8288 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.3-nightly.1" +__version__ = "3.16.3-nightly.2" From 9a06b4f5912ce30165c6fbe8aaeb7e5b048d2597 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 29 Jul 2023 03:25:00 +0000 Subject: [PATCH 040/107] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c71822db2d..387b5574ab 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.16.3-nightly.2 - 3.16.3-nightly.1 - 3.16.2 - 3.16.2-nightly.2 @@ -134,7 +135,6 @@ body: - 3.14.7-nightly.3 - 3.14.7-nightly.2 - 3.14.7-nightly.1 - - 3.14.6 validations: required: true - type: dropdown From 20987f82de9aa4a0eb41fafb09cbb75eee7f50f5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 31 Jul 2023 10:53:50 +0200 Subject: [PATCH 041/107] move unreal splash screen to unreal --- .../hosts/unreal/hooks/pre_workfile_preparation.py | 12 ++++++------ openpype/hosts/unreal/ui/__init__.py | 5 +++++ .../{widgets => hosts/unreal/ui}/splash_screen.py | 3 +-- 3 files changed, 12 insertions(+), 8 deletions(-) create mode 100644 openpype/hosts/unreal/ui/__init__.py rename openpype/{widgets => hosts/unreal/ui}/splash_screen.py (98%) diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index 1c42d7d246..e5010366b8 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -3,21 +3,21 @@ import os import copy from pathlib import Path -from openpype.widgets.splash_screen import SplashScreen + from qtpy import QtCore -from openpype.hosts.unreal.ue_workers import ( - UEProjectGenerationWorker, - UEPluginInstallWorker -) from openpype import resources from openpype.lib import ( PreLaunchHook, ApplicationLaunchFailed, - ApplicationNotFound, ) from openpype.pipeline.workfile import get_workfile_template_key import openpype.hosts.unreal.lib as unreal_lib +from openpype.hosts.unreal.ue_workers import ( + UEProjectGenerationWorker, + UEPluginInstallWorker +) +from openpype.hosts.unreal.ui import SplashScreen class UnrealPrelaunchHook(PreLaunchHook): diff --git a/openpype/hosts/unreal/ui/__init__.py b/openpype/hosts/unreal/ui/__init__.py new file mode 100644 index 0000000000..606b21ef19 --- /dev/null +++ b/openpype/hosts/unreal/ui/__init__.py @@ -0,0 +1,5 @@ +from .splash_screen import SplashScreen + +__all__ = ( + "SplashScreen", +) diff --git a/openpype/widgets/splash_screen.py b/openpype/hosts/unreal/ui/splash_screen.py similarity index 98% rename from openpype/widgets/splash_screen.py rename to openpype/hosts/unreal/ui/splash_screen.py index 7c1ff72ecd..7ac77821d9 100644 --- a/openpype/widgets/splash_screen.py +++ b/openpype/hosts/unreal/ui/splash_screen.py @@ -1,6 +1,5 @@ from qtpy import QtWidgets, QtCore, QtGui from openpype import style, resources -from igniter.nice_progress_bar import NiceProgressBar class SplashScreen(QtWidgets.QDialog): @@ -143,7 +142,7 @@ class SplashScreen(QtWidgets.QDialog): button_layout.addWidget(self.close_btn) # Progress Bar - self.progress_bar = NiceProgressBar() + self.progress_bar = QtWidgets.QProgressBar() self.progress_bar.setValue(0) self.progress_bar.setAlignment(QtCore.Qt.AlignTop) From 40037b050ce287f503bf4214f63e9e9f1b8028f5 Mon Sep 17 00:00:00 2001 From: Mustafa Zarkash Date: Mon, 31 Jul 2023 16:01:18 +0300 Subject: [PATCH 042/107] update defaults variables (#5368) --- openpype/hosts/maya/plugins/create/create_model.py | 2 +- openpype/hosts/maya/plugins/create/create_setdress.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_model.py b/openpype/hosts/maya/plugins/create/create_model.py index 30f1a82281..5c3dd04af0 100644 --- a/openpype/hosts/maya/plugins/create/create_model.py +++ b/openpype/hosts/maya/plugins/create/create_model.py @@ -12,7 +12,7 @@ class CreateModel(plugin.MayaCreator): label = "Model" family = "model" icon = "cube" - defaults = ["Main", "Proxy", "_MD", "_HD", "_LD"] + default_variants = ["Main", "Proxy", "_MD", "_HD", "_LD"] write_color_sets = False write_face_sets = False diff --git a/openpype/hosts/maya/plugins/create/create_setdress.py b/openpype/hosts/maya/plugins/create/create_setdress.py index 594a3dc46d..23a706380a 100644 --- a/openpype/hosts/maya/plugins/create/create_setdress.py +++ b/openpype/hosts/maya/plugins/create/create_setdress.py @@ -9,7 +9,7 @@ class CreateSetDress(plugin.MayaCreator): label = "Set Dress" family = "setdress" icon = "cubes" - defaults = ["Main", "Anim"] + default_variants = ["Main", "Anim"] def get_instance_attr_defs(self): return [ From 8d1b28f8d71af82af8d68c643cfd9d49511ffed1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 31 Jul 2023 15:14:50 +0200 Subject: [PATCH 043/107] updated ayon staging icons --- .../resources/icons/AYON_icon_staging.png | Bin 15273 -> 11268 bytes .../resources/icons/AYON_splash_staging.png | Bin 20527 -> 22076 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/openpype/resources/icons/AYON_icon_staging.png b/openpype/resources/icons/AYON_icon_staging.png index 75dadfd56c812d3bee941e90ecdd7f9f18831760..9da5b0488e25f3f15a85b625a0adeac726f7fe05 100644 GIT binary patch literal 11268 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 Hb_)N0?04LC literal 15273 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 Vr*$JL34HDeq4bR}Rp~lD`afSKqZR-F diff --git a/openpype/resources/icons/AYON_splash_staging.png b/openpype/resources/icons/AYON_splash_staging.png index 2923413664e8d14b37ee4344b928656b2bb6c0b8..ab2537e8a8b1d9751868a57768176dd3287389a1 100644 GIT binary patch literal 22076 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 literal 20527 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 From 15fd357267198edd23036785eb38a43793bc5e54 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 31 Jul 2023 18:08:54 +0300 Subject: [PATCH 044/107] update labels --- openpype/hosts/houdini/plugins/create/create_bgeo.py | 2 +- openpype/hosts/houdini/plugins/create/create_pointcache.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_bgeo.py b/openpype/hosts/houdini/plugins/create/create_bgeo.py index a1101fd045..a3f31e7e94 100644 --- a/openpype/hosts/houdini/plugins/create/create_bgeo.py +++ b/openpype/hosts/houdini/plugins/create/create_bgeo.py @@ -8,7 +8,7 @@ from openpype.lib import EnumDef class CreateBGEO(plugin.HoudiniCreator): """BGEO pointcache creator.""" identifier = "io.openpype.creators.houdini.bgeo" - label = "BGEO PointCache" + label = "PointCache (Bgeo)" family = "pointcache" icon = "gears" diff --git a/openpype/hosts/houdini/plugins/create/create_pointcache.py b/openpype/hosts/houdini/plugins/create/create_pointcache.py index 554d5f2016..7eaf2aff2b 100644 --- a/openpype/hosts/houdini/plugins/create/create_pointcache.py +++ b/openpype/hosts/houdini/plugins/create/create_pointcache.py @@ -8,7 +8,7 @@ import hou class CreatePointCache(plugin.HoudiniCreator): """Alembic ROP to pointcache""" identifier = "io.openpype.creators.houdini.pointcache" - label = "Point Cache" + label = "PointCache (Abc)" family = "pointcache" icon = "gears" From 65c9582d5513afd4e16e6baa60550a00f543b5c8 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 31 Jul 2023 18:28:39 +0200 Subject: [PATCH 045/107] Nuke: farm rendering of prerender ignore roots in nuke (#5366) * OP-6407 - fix wrong value used in comparison `prerender.farm` is correct value for prerender family sent to farm * OP-6407 - added test class for prerender family --- openpype/pipeline/farm/pyblish_functions.py | 2 +- ...test_deadline_publish_in_nuke_prerender.py | 106 ++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 tests/integration/hosts/nuke/test_deadline_publish_in_nuke_prerender.py diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 2df8269d79..e979c2d6ae 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -212,7 +212,7 @@ def create_skeleton_instance( "This may cause issues.").format(source)) family = ("render" - if "prerender" not in instance.data["families"] + if "prerender.farm" not in instance.data["families"] else "prerender") families = [family] diff --git a/tests/integration/hosts/nuke/test_deadline_publish_in_nuke_prerender.py b/tests/integration/hosts/nuke/test_deadline_publish_in_nuke_prerender.py new file mode 100644 index 0000000000..57e2f78973 --- /dev/null +++ b/tests/integration/hosts/nuke/test_deadline_publish_in_nuke_prerender.py @@ -0,0 +1,106 @@ +import logging + +from tests.lib.assert_classes import DBAssert +from tests.integration.hosts.nuke.lib import NukeDeadlinePublishTestClass + +log = logging.getLogger("test_publish_in_nuke") + + +class TestDeadlinePublishInNukePrerender(NukeDeadlinePublishTestClass): + """Basic test case for publishing in Nuke and Deadline for prerender + + It is different from `test_deadline_publish_in_nuke` as that one is for + `render` family >> this test expects different subset names. + + Uses generic TestCase to prepare fixtures for test data, testing DBs, + env vars. + + !!! + It expects path in WriteNode starting with 'c:/projects', it replaces + it with correct value in temp folder. + Access file path by selecting WriteNode group, CTRL+Enter, update file + input + !!! + + Opens Nuke, run publish on prepared workile. + + Then checks content of DB (if subset, version, representations were + created. + Checks tmp folder if all expected files were published. + + How to run: + (in cmd with activated {OPENPYPE_ROOT}/.venv) + {OPENPYPE_ROOT}/.venv/Scripts/python.exe {OPENPYPE_ROOT}/start.py + runtests ../tests/integration/hosts/nuke # noqa: E501 + + To check log/errors from launched app's publish process keep PERSIST + to True and check `test_openpype.logs` collection. + """ + TEST_FILES = [ + ("1aQaKo3cF-fvbTfvODIRFMxgherjbJ4Ql", + "test_nuke_deadline_publish_in_nuke_prerender.zip", "") + ] + + APP_GROUP = "nuke" + + TIMEOUT = 180 # publish timeout + + # could be overwritten by command line arguments + # keep empty to locate latest installed variant or explicit + APP_VARIANT = "" + PERSIST = False # True - keep test_db, test_openpype, outputted test files + TEST_DATA_FOLDER = None + + def test_db_asserts(self, dbcon, publish_finished): + """Host and input data dependent expected results in DB.""" + print("test_db_asserts") + failures = [] + + failures.append(DBAssert.count_of_types(dbcon, "version", 2)) + + failures.append( + DBAssert.count_of_types(dbcon, "version", 0, name={"$ne": 1})) + + # prerender has only default subset format `{family}{variant}`, + # Key01 is used variant + failures.append( + DBAssert.count_of_types(dbcon, "subset", 1, + name="prerenderKey01")) + + failures.append( + DBAssert.count_of_types(dbcon, "subset", 1, + name="workfileTest_task")) + + failures.append( + DBAssert.count_of_types(dbcon, "representation", 2)) + + 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": "prerenderKey01", + "context.ext": "exr"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + # prerender doesn't have set creation of review by default + additional_args = {"context.subset": "prerenderKey01", + "name": "thumbnail"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 0, + additional_args=additional_args)) + + additional_args = {"context.subset": "prerenderKey01", + "name": "h264_mov"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 0, + additional_args=additional_args)) + + assert not any(failures) + + +if __name__ == "__main__": + test_case = TestDeadlinePublishInNukePrerender() From 28768dc01216a56bc48a9b2659082e4893081bae Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 31 Jul 2023 18:37:46 +0200 Subject: [PATCH 046/107] AYON: Environment variables and functions (#5361) * use proper start script * implemented ayon variants of execute functions * more suitable names of functions * use 'PACKAGE_DIR' instead of 'OPENPYPE_REPOS_ROOT' environment variable * use suitable enviornment variables in ayon mode * keep sync server in openpype * Better comment --- openpype/cli.py | 49 ++++++++- openpype/lib/__init__.py | 8 +- openpype/lib/applications.py | 20 +++- openpype/lib/execute.py | 158 ++++++++++++++++++++++++----- openpype/lib/openpype_version.py | 25 ++++- server_addon/create_ayon_addons.py | 3 +- 6 files changed, 225 insertions(+), 38 deletions(-) diff --git a/openpype/cli.py b/openpype/cli.py index bc837cdeba..6d6a34b0fb 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -5,6 +5,7 @@ import sys import code import click +from openpype import AYON_SERVER_ENABLED from .pype_commands import PypeCommands @@ -46,7 +47,11 @@ def main(ctx): if ctx.invoked_subcommand is None: # Print help if headless mode is used - if os.environ.get("OPENPYPE_HEADLESS_MODE") == "1": + if AYON_SERVER_ENABLED: + is_headless = os.getenv("AYON_HEADLESS_MODE") == "1" + else: + is_headless = os.getenv("OPENPYPE_HEADLESS_MODE") == "1" + if is_headless: print(ctx.get_help()) sys.exit(0) else: @@ -57,6 +62,9 @@ def main(ctx): @click.option("-d", "--dev", is_flag=True, help="Settings in Dev mode") def settings(dev): """Show Pype Settings UI.""" + + if AYON_SERVER_ENABLED: + raise RuntimeError("AYON does not support 'settings' command.") PypeCommands().launch_settings_gui(dev) @@ -110,6 +118,8 @@ def eventserver(ftrack_url, on linux and window service). """ + if AYON_SERVER_ENABLED: + raise RuntimeError("AYON does not support 'eventserver' command.") PypeCommands().launch_eventservercli( ftrack_url, ftrack_user, @@ -134,6 +144,10 @@ def webpublisherwebserver(executable, upload_dir, host=None, port=None): Expect "pype.club" user created on Ftrack. """ + if AYON_SERVER_ENABLED: + raise RuntimeError( + "AYON does not support 'webpublisherwebserver' command." + ) PypeCommands().launch_webpublisher_webservercli( upload_dir=upload_dir, executable=executable, @@ -196,6 +210,10 @@ def remotepublishfromapp(project, path, host, user=None, targets=None): More than one path is allowed. """ + if AYON_SERVER_ENABLED: + raise RuntimeError( + "AYON does not support 'remotepublishfromapp' command." + ) PypeCommands.remotepublishfromapp( project, path, host, user, targets=targets ) @@ -214,11 +232,15 @@ def remotepublish(project, path, user=None, targets=None): More than one path is allowed. """ + if AYON_SERVER_ENABLED: + raise RuntimeError("AYON does not support 'remotepublish' command.") PypeCommands.remotepublish(project, path, user, targets=targets) @main.command(context_settings={"ignore_unknown_options": True}) def projectmanager(): + if AYON_SERVER_ENABLED: + raise RuntimeError("AYON does not support 'projectmanager' command.") PypeCommands().launch_project_manager() @@ -335,6 +357,8 @@ def syncserver(active_site): var OPENPYPE_LOCAL_ID set to 'active_site'. """ + if AYON_SERVER_ENABLED: + raise RuntimeError("AYON does not support 'syncserver' command.") PypeCommands().syncserver(active_site) @@ -347,6 +371,8 @@ def repack_version(directory): recalculating file checksums. It will try to use version detected in directory name. """ + if AYON_SERVER_ENABLED: + raise RuntimeError("AYON does not support 'repack-version' command.") PypeCommands().repack_version(directory) @@ -358,6 +384,9 @@ def repack_version(directory): "--dbonly", help="Store only Database data", default=False, is_flag=True) def pack_project(project, dirpath, dbonly): """Create a package of project with all files and database dump.""" + + if AYON_SERVER_ENABLED: + raise RuntimeError("AYON does not support 'pack-project' command.") PypeCommands().pack_project(project, dirpath, dbonly) @@ -370,6 +399,8 @@ def pack_project(project, dirpath, dbonly): "--dbonly", help="Store only Database data", default=False, is_flag=True) def unpack_project(zipfile, root, dbonly): """Create a package of project with all files and database dump.""" + if AYON_SERVER_ENABLED: + raise RuntimeError("AYON does not support 'unpack-project' command.") PypeCommands().unpack_project(zipfile, root, dbonly) @@ -384,9 +415,17 @@ def interactive(): Executable 'openpype_gui' on Windows won't work. """ - from openpype.version import __version__ + if AYON_SERVER_ENABLED: + version = os.environ["AYON_VERSION"] + banner = ( + f"AYON launcher {version}\nPython {sys.version} on {sys.platform}" + ) + else: + from openpype.version import __version__ - banner = f"OpenPype {__version__}\nPython {sys.version} on {sys.platform}" + banner = ( + f"OpenPype {__version__}\nPython {sys.version} on {sys.platform}" + ) code.interact(banner) @@ -395,11 +434,13 @@ def interactive(): is_flag=True, default=False) def version(build): """Print OpenPype version.""" + if AYON_SERVER_ENABLED: + print(os.environ["AYON_VERSION"]) + return from openpype.version import __version__ from igniter.bootstrap_repos import BootstrapRepos, OpenPypeVersion from pathlib import Path - import os if getattr(sys, 'frozen', False): local_version = BootstrapRepos.get_version( diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 40df264452..f1eb564e5e 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -5,11 +5,11 @@ import sys import os import site +from openpype import PACKAGE_DIR # Add Python version specific vendor folder python_version_dir = os.path.join( - os.getenv("OPENPYPE_REPOS_ROOT", ""), - "openpype", "vendor", "python", "python_{}".format(sys.version[0]) + PACKAGE_DIR, "vendor", "python", "python_{}".format(sys.version[0]) ) # Prepend path in sys paths sys.path.insert(0, python_version_dir) @@ -55,11 +55,13 @@ from .env_tools import ( from .terminal import Terminal from .execute import ( + get_ayon_launcher_args, get_openpype_execute_args, get_linux_launcher_args, execute, run_subprocess, run_detached_process, + run_ayon_launcher_process, run_openpype_process, clean_envs_for_openpype_process, path_to_subprocess_arg, @@ -175,11 +177,13 @@ __all__ = [ "emit_event", "register_event_callback", + "get_ayon_launcher_args", "get_openpype_execute_args", "get_linux_launcher_args", "execute", "run_subprocess", "run_detached_process", + "run_ayon_launcher_process", "run_openpype_process", "clean_envs_for_openpype_process", "path_to_subprocess_arg", diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index fbde59ced5..fac3e33f71 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -11,6 +11,7 @@ from abc import ABCMeta, abstractmethod import six +from openpype import AYON_SERVER_ENABLED, PACKAGE_DIR from openpype.client import ( get_project, get_asset_by_name, @@ -1435,10 +1436,8 @@ def _add_python_version_paths(app, env, logger, modules_manager): return # Add Python 2/3 modules - openpype_root = os.getenv("OPENPYPE_REPOS_ROOT") python_vendor_dir = os.path.join( - openpype_root, - "openpype", + PACKAGE_DIR, "vendor", "python" ) @@ -1959,17 +1958,28 @@ def get_non_python_host_kwargs(kwargs, allow_console=True): allow_console (bool): use False for inner Popen opening app itself or it will open additional console (at least for Harmony) """ + if kwargs is None: kwargs = {} if platform.system().lower() != "windows": return kwargs - executable_path = os.environ.get("OPENPYPE_EXECUTABLE") + if AYON_SERVER_ENABLED: + executable_path = os.environ.get("AYON_EXECUTABLE") + else: + executable_path = os.environ.get("OPENPYPE_EXECUTABLE") + executable_filename = "" if executable_path: executable_filename = os.path.basename(executable_path) - if "openpype_gui" in executable_filename: + + if AYON_SERVER_ENABLED: + is_gui_executable = "ayon_console" not in executable_filename + else: + is_gui_executable = "openpype_gui" in executable_filename + + if is_gui_executable: kwargs.update({ "creationflags": subprocess.CREATE_NO_WINDOW, "stdout": subprocess.DEVNULL, diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index b3c8185d3e..c54541a116 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -164,12 +164,19 @@ def run_subprocess(*args, **kwargs): return full_output -def clean_envs_for_openpype_process(env=None): - """Modify environments that may affect OpenPype process. +def clean_envs_for_ayon_process(env=None): + """Modify environments that may affect ayon-launcher process. Main reason to implement this function is to pop PYTHONPATH which may be affected by in-host environments. + + Args: + env (Optional[dict[str, str]]): Environment variables to modify. + + Returns: + dict[str, str]: Environment variables for ayon process. """ + if env is None: env = os.environ @@ -181,6 +188,64 @@ def clean_envs_for_openpype_process(env=None): return env +def clean_envs_for_openpype_process(env=None): + """Modify environments that may affect OpenPype process. + + Main reason to implement this function is to pop PYTHONPATH which may be + affected by in-host environments. + """ + + if AYON_SERVER_ENABLED: + return clean_envs_for_ayon_process(env=env) + + if env is None: + env = os.environ + + # Exclude some environment variables from a copy of the environment + env = env.copy() + for key in ["PYTHONPATH", "PYTHONHOME"]: + env.pop(key, None) + + return env + + +def run_ayon_launcher_process(*args, **kwargs): + """Execute OpenPype process with passed arguments and wait. + + Wrapper for 'run_process' which prepends OpenPype executable arguments + before passed arguments and define environments if are not passed. + + Values from 'os.environ' are used for environments if are not passed. + They are cleaned using 'clean_envs_for_openpype_process' function. + + Example: + ``` + run_ayon_process("run", "") + ``` + + Args: + *args (str): ayon-launcher cli arguments. + **kwargs (Any): Keyword arguments for subprocess.Popen. + + Returns: + str: Full output of subprocess concatenated stdout and stderr. + """ + + args = get_ayon_launcher_args(*args) + env = kwargs.pop("env", None) + # Keep env untouched if are passed and not empty + if not env: + # Skip envs that can affect OpenPype process + # - fill more if you find more + env = clean_envs_for_openpype_process(os.environ) + + # Only keep OpenPype version if we are running from build. + if not is_running_from_build(): + env.pop("OPENPYPE_VERSION", None) + + return run_subprocess(args, env=env, **kwargs) + + def run_openpype_process(*args, **kwargs): """Execute OpenPype process with passed arguments and wait. @@ -191,14 +256,16 @@ def run_openpype_process(*args, **kwargs): They are cleaned using 'clean_envs_for_openpype_process' function. Example: - ``` - run_detached_process("run", "") - ``` + >>> run_openpype_process("version") Args: *args (tuple): OpenPype cli arguments. - **kwargs (dict): Keyword arguments for for subprocess.Popen. + **kwargs (dict): Keyword arguments for subprocess.Popen. """ + + if AYON_SERVER_ENABLED: + return run_ayon_launcher_process(*args, **kwargs) + args = get_openpype_execute_args(*args) env = kwargs.pop("env", None) # Keep env untouched if are passed and not empty @@ -221,18 +288,18 @@ def run_detached_process(args, **kwargs): They are cleaned using 'clean_envs_for_openpype_process' function. Example: - ``` - run_detached_openpype_process("run", "") - ``` + >>> run_detached_process("run", "./path_to.py") + Args: *args (tuple): OpenPype cli arguments. - **kwargs (dict): Keyword arguments for for subprocess.Popen. + **kwargs (dict): Keyword arguments for subprocess.Popen. Returns: subprocess.Popen: Pointer to launched process but it is possible that launched process is already killed (on linux). """ + env = kwargs.pop("env", None) # Keep env untouched if are passed and not empty if not env: @@ -296,6 +363,39 @@ def path_to_subprocess_arg(path): return subprocess.list2cmdline([path]) +def get_ayon_launcher_args(*args): + """Arguments to run ayon-launcher process. + + Arguments for subprocess when need to spawn new pype process. Which may be + needed when new python process for pype scripts must be executed in build + pype. + + Reasons: + Ayon-launcher started from code has different executable set to + virtual env python and must have path to script as first argument + which is not needed for built application. + + Args: + *args (str): Any arguments that will be added after executables. + + Returns: + list[str]: List of arguments to run ayon-launcher process. + """ + + executable = os.environ["AYON_EXECUTABLE"] + launch_args = [executable] + + executable_filename = os.path.basename(executable) + if "python" in executable_filename.lower(): + filepath = os.path.join(os.environ["AYON_ROOT"], "start.py") + launch_args.append(filepath) + + if args: + launch_args.extend(args) + + return launch_args + + def get_openpype_execute_args(*args): """Arguments to run pype command. @@ -311,17 +411,17 @@ def get_openpype_execute_args(*args): It is possible to pass any arguments that will be added after pype executables. """ + + if AYON_SERVER_ENABLED: + return get_ayon_launcher_args(*args) + executable = os.environ["OPENPYPE_EXECUTABLE"] launch_args = [executable] executable_filename = os.path.basename(executable) if "python" in executable_filename.lower(): - filename = "start.py" - if AYON_SERVER_ENABLED: - filename = "ayon_start.py" - launch_args.append( - os.path.join(os.environ["OPENPYPE_ROOT"], filename) - ) + filepath = os.path.join(os.environ["OPENPYPE_ROOT"], "start.py") + launch_args.append(filepath) if args: launch_args.extend(args) @@ -338,6 +438,9 @@ def get_linux_launcher_args(*args): It is possible that this function is used in OpenPype build which does not have yet the new executable. In that case 'None' is returned. + Todos: + Replace by script in scripts for ayon-launcher. + Args: args (iterable): List of additional arguments added after executable argument. @@ -346,19 +449,24 @@ def get_linux_launcher_args(*args): list: Executables with possible positional argument to script when called from code. """ - filename = "app_launcher" - openpype_executable = os.environ["OPENPYPE_EXECUTABLE"] - executable_filename = os.path.basename(openpype_executable) + filename = "app_launcher" + if AYON_SERVER_ENABLED: + executable = os.environ["AYON_EXECUTABLE"] + else: + executable = os.environ["OPENPYPE_EXECUTABLE"] + + executable_filename = os.path.basename(executable) if "python" in executable_filename.lower(): - script_path = os.path.join( - os.environ["OPENPYPE_ROOT"], - "{}.py".format(filename) - ) - launch_args = [openpype_executable, script_path] + if AYON_SERVER_ENABLED: + root = os.environ["AYON_ROOT"] + else: + root = os.environ["OPENPYPE_ROOT"] + script_path = os.path.join(root, "{}.py".format(filename)) + launch_args = [executable, script_path] else: new_executable = os.path.join( - os.path.dirname(openpype_executable), + os.path.dirname(executable), filename ) executable_path = find_executable(new_executable) diff --git a/openpype/lib/openpype_version.py b/openpype/lib/openpype_version.py index bdf7099f61..1c8356d5fe 100644 --- a/openpype/lib/openpype_version.py +++ b/openpype/lib/openpype_version.py @@ -26,8 +26,25 @@ def get_openpype_version(): return openpype.version.__version__ +def get_ayon_launcher_version(): + version_filepath = os.path.join( + os.environ["AYON_ROOT"], + "version.py" + ) + if not os.path.exists(version_filepath): + return None + content = {} + with open(version_filepath, "r") as stream: + exec(stream.read(), content) + return content["__version__"] + + def get_build_version(): """OpenPype version of build.""" + + if AYON_SERVER_ENABLED: + return get_ayon_launcher_version() + # Return OpenPype version if is running from code if not is_running_from_build(): return get_openpype_version() @@ -51,7 +68,11 @@ def is_running_from_build(): Returns: bool: True if running from build. """ - executable_path = os.environ["OPENPYPE_EXECUTABLE"] + + if AYON_SERVER_ENABLED: + executable_path = os.environ["AYON_EXECUTABLE"] + else: + executable_path = os.environ["OPENPYPE_EXECUTABLE"] executable_filename = os.path.basename(executable_path) if "python" in executable_filename.lower(): return False @@ -59,6 +80,8 @@ def is_running_from_build(): def is_staging_enabled(): + if AYON_SERVER_ENABLED: + return os.getenv("AYON_USE_STAGING") == "1" return os.environ.get("OPENPYPE_USE_STAGING") == "1" diff --git a/server_addon/create_ayon_addons.py b/server_addon/create_ayon_addons.py index 61dbd5c8d9..8be9baa983 100644 --- a/server_addon/create_ayon_addons.py +++ b/server_addon/create_ayon_addons.py @@ -203,7 +203,8 @@ def create_openpype_package( ignored_modules = [ "ftrack", "shotgrid", - "sync_server", + # Sync server is still expected at multiple places + # "sync_server", "example_addons", "slack" ] From 6196ded1a9cfb17154a3a35b735f25ac41690721 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Aug 2023 10:46:59 +0200 Subject: [PATCH 047/107] Chore: Python 2 support fix (#5375) * remove f-string formatting * added python 2 compatible click into python 2 vendor --- openpype/hosts/maya/api/plugin.py | 2 +- .../vendor/python/python_2/click/__init__.py | 79 + .../python/python_2/click/_bashcomplete.py | 375 +++ .../vendor/python/python_2/click/_compat.py | 786 +++++++ .../python/python_2/click/_termui_impl.py | 657 ++++++ .../vendor/python/python_2/click/_textwrap.py | 37 + .../python/python_2/click/_unicodefun.py | 131 ++ .../python/python_2/click/_winconsole.py | 370 +++ openpype/vendor/python/python_2/click/core.py | 2030 +++++++++++++++++ .../python/python_2/click/decorators.py | 333 +++ .../python/python_2/click/exceptions.py | 253 ++ .../python/python_2/click/formatting.py | 283 +++ .../vendor/python/python_2/click/globals.py | 47 + .../vendor/python/python_2/click/parser.py | 428 ++++ .../vendor/python/python_2/click/termui.py | 681 ++++++ .../vendor/python/python_2/click/testing.py | 382 ++++ .../vendor/python/python_2/click/types.py | 762 +++++++ .../vendor/python/python_2/click/utils.py | 455 ++++ 18 files changed, 8090 insertions(+), 1 deletion(-) create mode 100644 openpype/vendor/python/python_2/click/__init__.py create mode 100644 openpype/vendor/python/python_2/click/_bashcomplete.py create mode 100644 openpype/vendor/python/python_2/click/_compat.py create mode 100644 openpype/vendor/python/python_2/click/_termui_impl.py create mode 100644 openpype/vendor/python/python_2/click/_textwrap.py create mode 100644 openpype/vendor/python/python_2/click/_unicodefun.py create mode 100644 openpype/vendor/python/python_2/click/_winconsole.py create mode 100644 openpype/vendor/python/python_2/click/core.py create mode 100644 openpype/vendor/python/python_2/click/decorators.py create mode 100644 openpype/vendor/python/python_2/click/exceptions.py create mode 100644 openpype/vendor/python/python_2/click/formatting.py create mode 100644 openpype/vendor/python/python_2/click/globals.py create mode 100644 openpype/vendor/python/python_2/click/parser.py create mode 100644 openpype/vendor/python/python_2/click/termui.py create mode 100644 openpype/vendor/python/python_2/click/testing.py create mode 100644 openpype/vendor/python/python_2/click/types.py create mode 100644 openpype/vendor/python/python_2/click/utils.py diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 34b61698a3..0ee02d8485 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -439,7 +439,7 @@ class RenderlayerCreator(NewCreator, MayaCreatorBase): creator_identifier = cmds.getAttr(node + ".creator_identifier") if creator_identifier == self.identifier: - self.log.info(f"Found node: {node}") + self.log.info("Found node: {}".format(node)) return node def _create_layer_instance_node(self, layer): diff --git a/openpype/vendor/python/python_2/click/__init__.py b/openpype/vendor/python/python_2/click/__init__.py new file mode 100644 index 0000000000..2b6008f2dd --- /dev/null +++ b/openpype/vendor/python/python_2/click/__init__.py @@ -0,0 +1,79 @@ +""" +Click is a simple Python module inspired by the stdlib optparse to make +writing command line scripts fun. Unlike other modules, it's based +around a simple API that does not come with too much magic and is +composable. +""" +from .core import Argument +from .core import BaseCommand +from .core import Command +from .core import CommandCollection +from .core import Context +from .core import Group +from .core import MultiCommand +from .core import Option +from .core import Parameter +from .decorators import argument +from .decorators import command +from .decorators import confirmation_option +from .decorators import group +from .decorators import help_option +from .decorators import make_pass_decorator +from .decorators import option +from .decorators import pass_context +from .decorators import pass_obj +from .decorators import password_option +from .decorators import version_option +from .exceptions import Abort +from .exceptions import BadArgumentUsage +from .exceptions import BadOptionUsage +from .exceptions import BadParameter +from .exceptions import ClickException +from .exceptions import FileError +from .exceptions import MissingParameter +from .exceptions import NoSuchOption +from .exceptions import UsageError +from .formatting import HelpFormatter +from .formatting import wrap_text +from .globals import get_current_context +from .parser import OptionParser +from .termui import clear +from .termui import confirm +from .termui import echo_via_pager +from .termui import edit +from .termui import get_terminal_size +from .termui import getchar +from .termui import launch +from .termui import pause +from .termui import progressbar +from .termui import prompt +from .termui import secho +from .termui import style +from .termui import unstyle +from .types import BOOL +from .types import Choice +from .types import DateTime +from .types import File +from .types import FLOAT +from .types import FloatRange +from .types import INT +from .types import IntRange +from .types import ParamType +from .types import Path +from .types import STRING +from .types import Tuple +from .types import UNPROCESSED +from .types import UUID +from .utils import echo +from .utils import format_filename +from .utils import get_app_dir +from .utils import get_binary_stream +from .utils import get_os_args +from .utils import get_text_stream +from .utils import open_file + +# Controls if click should emit the warning about the use of unicode +# literals. +disable_unicode_literals_warning = False + +__version__ = "7.1.2" diff --git a/openpype/vendor/python/python_2/click/_bashcomplete.py b/openpype/vendor/python/python_2/click/_bashcomplete.py new file mode 100644 index 0000000000..8bca24480f --- /dev/null +++ b/openpype/vendor/python/python_2/click/_bashcomplete.py @@ -0,0 +1,375 @@ +import copy +import os +import re + +from .core import Argument +from .core import MultiCommand +from .core import Option +from .parser import split_arg_string +from .types import Choice +from .utils import echo + +try: + from collections import abc +except ImportError: + import collections as abc + +WORDBREAK = "=" + +# Note, only BASH version 4.4 and later have the nosort option. +COMPLETION_SCRIPT_BASH = """ +%(complete_func)s() { + local IFS=$'\n' + COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \\ + COMP_CWORD=$COMP_CWORD \\ + %(autocomplete_var)s=complete $1 ) ) + return 0 +} + +%(complete_func)setup() { + local COMPLETION_OPTIONS="" + local BASH_VERSION_ARR=(${BASH_VERSION//./ }) + # Only BASH version 4.4 and later have the nosort option. + if [ ${BASH_VERSION_ARR[0]} -gt 4 ] || ([ ${BASH_VERSION_ARR[0]} -eq 4 ] \ +&& [ ${BASH_VERSION_ARR[1]} -ge 4 ]); then + COMPLETION_OPTIONS="-o nosort" + fi + + complete $COMPLETION_OPTIONS -F %(complete_func)s %(script_names)s +} + +%(complete_func)setup +""" + +COMPLETION_SCRIPT_ZSH = """ +#compdef %(script_names)s + +%(complete_func)s() { + local -a completions + local -a completions_with_descriptions + local -a response + (( ! $+commands[%(script_names)s] )) && return 1 + + response=("${(@f)$( env COMP_WORDS=\"${words[*]}\" \\ + COMP_CWORD=$((CURRENT-1)) \\ + %(autocomplete_var)s=\"complete_zsh\" \\ + %(script_names)s )}") + + for key descr in ${(kv)response}; do + if [[ "$descr" == "_" ]]; then + completions+=("$key") + else + completions_with_descriptions+=("$key":"$descr") + fi + done + + if [ -n "$completions_with_descriptions" ]; then + _describe -V unsorted completions_with_descriptions -U + fi + + if [ -n "$completions" ]; then + compadd -U -V unsorted -a completions + fi + compstate[insert]="automenu" +} + +compdef %(complete_func)s %(script_names)s +""" + +COMPLETION_SCRIPT_FISH = ( + "complete --no-files --command %(script_names)s --arguments" + ' "(env %(autocomplete_var)s=complete_fish' + " COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t)" + ' %(script_names)s)"' +) + +_completion_scripts = { + "bash": COMPLETION_SCRIPT_BASH, + "zsh": COMPLETION_SCRIPT_ZSH, + "fish": COMPLETION_SCRIPT_FISH, +} + +_invalid_ident_char_re = re.compile(r"[^a-zA-Z0-9_]") + + +def get_completion_script(prog_name, complete_var, shell): + cf_name = _invalid_ident_char_re.sub("", prog_name.replace("-", "_")) + script = _completion_scripts.get(shell, COMPLETION_SCRIPT_BASH) + return ( + script + % { + "complete_func": "_{}_completion".format(cf_name), + "script_names": prog_name, + "autocomplete_var": complete_var, + } + ).strip() + ";" + + +def resolve_ctx(cli, prog_name, args): + """Parse into a hierarchy of contexts. Contexts are connected + through the parent variable. + + :param cli: command definition + :param prog_name: the program that is running + :param args: full list of args + :return: the final context/command parsed + """ + ctx = cli.make_context(prog_name, args, resilient_parsing=True) + args = ctx.protected_args + ctx.args + while args: + if isinstance(ctx.command, MultiCommand): + if not ctx.command.chain: + cmd_name, cmd, args = ctx.command.resolve_command(ctx, args) + if cmd is None: + return ctx + ctx = cmd.make_context( + cmd_name, args, parent=ctx, resilient_parsing=True + ) + args = ctx.protected_args + ctx.args + else: + # Walk chained subcommand contexts saving the last one. + while args: + cmd_name, cmd, args = ctx.command.resolve_command(ctx, args) + if cmd is None: + return ctx + sub_ctx = cmd.make_context( + cmd_name, + args, + parent=ctx, + allow_extra_args=True, + allow_interspersed_args=False, + resilient_parsing=True, + ) + args = sub_ctx.args + ctx = sub_ctx + args = sub_ctx.protected_args + sub_ctx.args + else: + break + return ctx + + +def start_of_option(param_str): + """ + :param param_str: param_str to check + :return: whether or not this is the start of an option declaration + (i.e. starts "-" or "--") + """ + return param_str and param_str[:1] == "-" + + +def is_incomplete_option(all_args, cmd_param): + """ + :param all_args: the full original list of args supplied + :param cmd_param: the current command paramter + :return: whether or not the last option declaration (i.e. starts + "-" or "--") is incomplete and corresponds to this cmd_param. In + other words whether this cmd_param option can still accept + values + """ + if not isinstance(cmd_param, Option): + return False + if cmd_param.is_flag: + return False + last_option = None + for index, arg_str in enumerate( + reversed([arg for arg in all_args if arg != WORDBREAK]) + ): + if index + 1 > cmd_param.nargs: + break + if start_of_option(arg_str): + last_option = arg_str + + return True if last_option and last_option in cmd_param.opts else False + + +def is_incomplete_argument(current_params, cmd_param): + """ + :param current_params: the current params and values for this + argument as already entered + :param cmd_param: the current command parameter + :return: whether or not the last argument is incomplete and + corresponds to this cmd_param. In other words whether or not the + this cmd_param argument can still accept values + """ + if not isinstance(cmd_param, Argument): + return False + current_param_values = current_params[cmd_param.name] + if current_param_values is None: + return True + if cmd_param.nargs == -1: + return True + if ( + isinstance(current_param_values, abc.Iterable) + and cmd_param.nargs > 1 + and len(current_param_values) < cmd_param.nargs + ): + return True + return False + + +def get_user_autocompletions(ctx, args, incomplete, cmd_param): + """ + :param ctx: context associated with the parsed command + :param args: full list of args + :param incomplete: the incomplete text to autocomplete + :param cmd_param: command definition + :return: all the possible user-specified completions for the param + """ + results = [] + if isinstance(cmd_param.type, Choice): + # Choices don't support descriptions. + results = [ + (c, None) for c in cmd_param.type.choices if str(c).startswith(incomplete) + ] + elif cmd_param.autocompletion is not None: + dynamic_completions = cmd_param.autocompletion( + ctx=ctx, args=args, incomplete=incomplete + ) + results = [ + c if isinstance(c, tuple) else (c, None) for c in dynamic_completions + ] + return results + + +def get_visible_commands_starting_with(ctx, starts_with): + """ + :param ctx: context associated with the parsed command + :starts_with: string that visible commands must start with. + :return: all visible (not hidden) commands that start with starts_with. + """ + for c in ctx.command.list_commands(ctx): + if c.startswith(starts_with): + command = ctx.command.get_command(ctx, c) + if not command.hidden: + yield command + + +def add_subcommand_completions(ctx, incomplete, completions_out): + # Add subcommand completions. + if isinstance(ctx.command, MultiCommand): + completions_out.extend( + [ + (c.name, c.get_short_help_str()) + for c in get_visible_commands_starting_with(ctx, incomplete) + ] + ) + + # Walk up the context list and add any other completion + # possibilities from chained commands + while ctx.parent is not None: + ctx = ctx.parent + if isinstance(ctx.command, MultiCommand) and ctx.command.chain: + remaining_commands = [ + c + for c in get_visible_commands_starting_with(ctx, incomplete) + if c.name not in ctx.protected_args + ] + completions_out.extend( + [(c.name, c.get_short_help_str()) for c in remaining_commands] + ) + + +def get_choices(cli, prog_name, args, incomplete): + """ + :param cli: command definition + :param prog_name: the program that is running + :param args: full list of args + :param incomplete: the incomplete text to autocomplete + :return: all the possible completions for the incomplete + """ + all_args = copy.deepcopy(args) + + ctx = resolve_ctx(cli, prog_name, args) + if ctx is None: + return [] + + has_double_dash = "--" in all_args + + # In newer versions of bash long opts with '='s are partitioned, but + # it's easier to parse without the '=' + if start_of_option(incomplete) and WORDBREAK in incomplete: + partition_incomplete = incomplete.partition(WORDBREAK) + all_args.append(partition_incomplete[0]) + incomplete = partition_incomplete[2] + elif incomplete == WORDBREAK: + incomplete = "" + + completions = [] + if not has_double_dash and start_of_option(incomplete): + # completions for partial options + for param in ctx.command.params: + if isinstance(param, Option) and not param.hidden: + param_opts = [ + param_opt + for param_opt in param.opts + param.secondary_opts + if param_opt not in all_args or param.multiple + ] + completions.extend( + [(o, param.help) for o in param_opts if o.startswith(incomplete)] + ) + return completions + # completion for option values from user supplied values + for param in ctx.command.params: + if is_incomplete_option(all_args, param): + return get_user_autocompletions(ctx, all_args, incomplete, param) + # completion for argument values from user supplied values + for param in ctx.command.params: + if is_incomplete_argument(ctx.params, param): + return get_user_autocompletions(ctx, all_args, incomplete, param) + + add_subcommand_completions(ctx, incomplete, completions) + # Sort before returning so that proper ordering can be enforced in custom types. + return sorted(completions) + + +def do_complete(cli, prog_name, include_descriptions): + cwords = split_arg_string(os.environ["COMP_WORDS"]) + cword = int(os.environ["COMP_CWORD"]) + args = cwords[1:cword] + try: + incomplete = cwords[cword] + except IndexError: + incomplete = "" + + for item in get_choices(cli, prog_name, args, incomplete): + echo(item[0]) + if include_descriptions: + # ZSH has trouble dealing with empty array parameters when + # returned from commands, use '_' to indicate no description + # is present. + echo(item[1] if item[1] else "_") + + return True + + +def do_complete_fish(cli, prog_name): + cwords = split_arg_string(os.environ["COMP_WORDS"]) + incomplete = os.environ["COMP_CWORD"] + args = cwords[1:] + + for item in get_choices(cli, prog_name, args, incomplete): + if item[1]: + echo("{arg}\t{desc}".format(arg=item[0], desc=item[1])) + else: + echo(item[0]) + + return True + + +def bashcomplete(cli, prog_name, complete_var, complete_instr): + if "_" in complete_instr: + command, shell = complete_instr.split("_", 1) + else: + command = complete_instr + shell = "bash" + + if command == "source": + echo(get_completion_script(prog_name, complete_var, shell)) + return True + elif command == "complete": + if shell == "fish": + return do_complete_fish(cli, prog_name) + elif shell in {"bash", "zsh"}: + return do_complete(cli, prog_name, shell == "zsh") + + return False diff --git a/openpype/vendor/python/python_2/click/_compat.py b/openpype/vendor/python/python_2/click/_compat.py new file mode 100644 index 0000000000..60cb115bc5 --- /dev/null +++ b/openpype/vendor/python/python_2/click/_compat.py @@ -0,0 +1,786 @@ +# flake8: noqa +import codecs +import io +import os +import re +import sys +from weakref import WeakKeyDictionary + +PY2 = sys.version_info[0] == 2 +CYGWIN = sys.platform.startswith("cygwin") +MSYS2 = sys.platform.startswith("win") and ("GCC" in sys.version) +# Determine local App Engine environment, per Google's own suggestion +APP_ENGINE = "APPENGINE_RUNTIME" in os.environ and "Development/" in os.environ.get( + "SERVER_SOFTWARE", "" +) +WIN = sys.platform.startswith("win") and not APP_ENGINE and not MSYS2 +DEFAULT_COLUMNS = 80 + + +_ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") + + +def get_filesystem_encoding(): + return sys.getfilesystemencoding() or sys.getdefaultencoding() + + +def _make_text_stream( + stream, encoding, errors, force_readable=False, force_writable=False +): + if encoding is None: + encoding = get_best_encoding(stream) + if errors is None: + errors = "replace" + return _NonClosingTextIOWrapper( + stream, + encoding, + errors, + line_buffering=True, + force_readable=force_readable, + force_writable=force_writable, + ) + + +def is_ascii_encoding(encoding): + """Checks if a given encoding is ascii.""" + try: + return codecs.lookup(encoding).name == "ascii" + except LookupError: + return False + + +def get_best_encoding(stream): + """Returns the default stream encoding if not found.""" + rv = getattr(stream, "encoding", None) or sys.getdefaultencoding() + if is_ascii_encoding(rv): + return "utf-8" + return rv + + +class _NonClosingTextIOWrapper(io.TextIOWrapper): + def __init__( + self, + stream, + encoding, + errors, + force_readable=False, + force_writable=False, + **extra + ): + self._stream = stream = _FixupStream(stream, force_readable, force_writable) + io.TextIOWrapper.__init__(self, stream, encoding, errors, **extra) + + # The io module is a place where the Python 3 text behavior + # was forced upon Python 2, so we need to unbreak + # it to look like Python 2. + if PY2: + + def write(self, x): + if isinstance(x, str) or is_bytes(x): + try: + self.flush() + except Exception: + pass + return self.buffer.write(str(x)) + return io.TextIOWrapper.write(self, x) + + def writelines(self, lines): + for line in lines: + self.write(line) + + def __del__(self): + try: + self.detach() + except Exception: + pass + + def isatty(self): + # https://bitbucket.org/pypy/pypy/issue/1803 + return self._stream.isatty() + + +class _FixupStream(object): + """The new io interface needs more from streams than streams + traditionally implement. As such, this fix-up code is necessary in + some circumstances. + + The forcing of readable and writable flags are there because some tools + put badly patched objects on sys (one such offender are certain version + of jupyter notebook). + """ + + def __init__(self, stream, force_readable=False, force_writable=False): + self._stream = stream + self._force_readable = force_readable + self._force_writable = force_writable + + def __getattr__(self, name): + return getattr(self._stream, name) + + def read1(self, size): + f = getattr(self._stream, "read1", None) + if f is not None: + return f(size) + # We only dispatch to readline instead of read in Python 2 as we + # do not want cause problems with the different implementation + # of line buffering. + if PY2: + return self._stream.readline(size) + return self._stream.read(size) + + def readable(self): + if self._force_readable: + return True + x = getattr(self._stream, "readable", None) + if x is not None: + return x() + try: + self._stream.read(0) + except Exception: + return False + return True + + def writable(self): + if self._force_writable: + return True + x = getattr(self._stream, "writable", None) + if x is not None: + return x() + try: + self._stream.write("") + except Exception: + try: + self._stream.write(b"") + except Exception: + return False + return True + + def seekable(self): + x = getattr(self._stream, "seekable", None) + if x is not None: + return x() + try: + self._stream.seek(self._stream.tell()) + except Exception: + return False + return True + + +if PY2: + text_type = unicode + raw_input = raw_input + string_types = (str, unicode) + int_types = (int, long) + iteritems = lambda x: x.iteritems() + range_type = xrange + + def is_bytes(x): + return isinstance(x, (buffer, bytearray)) + + _identifier_re = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") + + # For Windows, we need to force stdout/stdin/stderr to binary if it's + # fetched for that. This obviously is not the most correct way to do + # it as it changes global state. Unfortunately, there does not seem to + # be a clear better way to do it as just reopening the file in binary + # mode does not change anything. + # + # An option would be to do what Python 3 does and to open the file as + # binary only, patch it back to the system, and then use a wrapper + # stream that converts newlines. It's not quite clear what's the + # correct option here. + # + # This code also lives in _winconsole for the fallback to the console + # emulation stream. + # + # There are also Windows environments where the `msvcrt` module is not + # available (which is why we use try-catch instead of the WIN variable + # here), such as the Google App Engine development server on Windows. In + # those cases there is just nothing we can do. + def set_binary_mode(f): + return f + + try: + import msvcrt + except ImportError: + pass + else: + + def set_binary_mode(f): + try: + fileno = f.fileno() + except Exception: + pass + else: + msvcrt.setmode(fileno, os.O_BINARY) + return f + + try: + import fcntl + except ImportError: + pass + else: + + def set_binary_mode(f): + try: + fileno = f.fileno() + except Exception: + pass + else: + flags = fcntl.fcntl(fileno, fcntl.F_GETFL) + fcntl.fcntl(fileno, fcntl.F_SETFL, flags & ~os.O_NONBLOCK) + return f + + def isidentifier(x): + return _identifier_re.search(x) is not None + + def get_binary_stdin(): + return set_binary_mode(sys.stdin) + + def get_binary_stdout(): + _wrap_std_stream("stdout") + return set_binary_mode(sys.stdout) + + def get_binary_stderr(): + _wrap_std_stream("stderr") + return set_binary_mode(sys.stderr) + + def get_text_stdin(encoding=None, errors=None): + rv = _get_windows_console_stream(sys.stdin, encoding, errors) + if rv is not None: + return rv + return _make_text_stream(sys.stdin, encoding, errors, force_readable=True) + + def get_text_stdout(encoding=None, errors=None): + _wrap_std_stream("stdout") + rv = _get_windows_console_stream(sys.stdout, encoding, errors) + if rv is not None: + return rv + return _make_text_stream(sys.stdout, encoding, errors, force_writable=True) + + def get_text_stderr(encoding=None, errors=None): + _wrap_std_stream("stderr") + rv = _get_windows_console_stream(sys.stderr, encoding, errors) + if rv is not None: + return rv + return _make_text_stream(sys.stderr, encoding, errors, force_writable=True) + + def filename_to_ui(value): + if isinstance(value, bytes): + value = value.decode(get_filesystem_encoding(), "replace") + return value + + +else: + import io + + text_type = str + raw_input = input + string_types = (str,) + int_types = (int,) + range_type = range + isidentifier = lambda x: x.isidentifier() + iteritems = lambda x: iter(x.items()) + + def is_bytes(x): + return isinstance(x, (bytes, memoryview, bytearray)) + + def _is_binary_reader(stream, default=False): + try: + return isinstance(stream.read(0), bytes) + except Exception: + return default + # This happens in some cases where the stream was already + # closed. In this case, we assume the default. + + def _is_binary_writer(stream, default=False): + try: + stream.write(b"") + except Exception: + try: + stream.write("") + return False + except Exception: + pass + return default + return True + + def _find_binary_reader(stream): + # We need to figure out if the given stream is already binary. + # This can happen because the official docs recommend detaching + # the streams to get binary streams. Some code might do this, so + # we need to deal with this case explicitly. + if _is_binary_reader(stream, False): + return stream + + buf = getattr(stream, "buffer", None) + + # Same situation here; this time we assume that the buffer is + # actually binary in case it's closed. + if buf is not None and _is_binary_reader(buf, True): + return buf + + def _find_binary_writer(stream): + # We need to figure out if the given stream is already binary. + # This can happen because the official docs recommend detatching + # the streams to get binary streams. Some code might do this, so + # we need to deal with this case explicitly. + if _is_binary_writer(stream, False): + return stream + + buf = getattr(stream, "buffer", None) + + # Same situation here; this time we assume that the buffer is + # actually binary in case it's closed. + if buf is not None and _is_binary_writer(buf, True): + return buf + + def _stream_is_misconfigured(stream): + """A stream is misconfigured if its encoding is ASCII.""" + # If the stream does not have an encoding set, we assume it's set + # to ASCII. This appears to happen in certain unittest + # environments. It's not quite clear what the correct behavior is + # but this at least will force Click to recover somehow. + return is_ascii_encoding(getattr(stream, "encoding", None) or "ascii") + + def _is_compat_stream_attr(stream, attr, value): + """A stream attribute is compatible if it is equal to the + desired value or the desired value is unset and the attribute + has a value. + """ + stream_value = getattr(stream, attr, None) + return stream_value == value or (value is None and stream_value is not None) + + def _is_compatible_text_stream(stream, encoding, errors): + """Check if a stream's encoding and errors attributes are + compatible with the desired values. + """ + return _is_compat_stream_attr( + stream, "encoding", encoding + ) and _is_compat_stream_attr(stream, "errors", errors) + + def _force_correct_text_stream( + text_stream, + encoding, + errors, + is_binary, + find_binary, + force_readable=False, + force_writable=False, + ): + if is_binary(text_stream, False): + binary_reader = text_stream + else: + # If the stream looks compatible, and won't default to a + # misconfigured ascii encoding, return it as-is. + if _is_compatible_text_stream(text_stream, encoding, errors) and not ( + encoding is None and _stream_is_misconfigured(text_stream) + ): + return text_stream + + # Otherwise, get the underlying binary reader. + binary_reader = find_binary(text_stream) + + # If that's not possible, silently use the original reader + # and get mojibake instead of exceptions. + if binary_reader is None: + return text_stream + + # Default errors to replace instead of strict in order to get + # something that works. + if errors is None: + errors = "replace" + + # Wrap the binary stream in a text stream with the correct + # encoding parameters. + return _make_text_stream( + binary_reader, + encoding, + errors, + force_readable=force_readable, + force_writable=force_writable, + ) + + def _force_correct_text_reader(text_reader, encoding, errors, force_readable=False): + return _force_correct_text_stream( + text_reader, + encoding, + errors, + _is_binary_reader, + _find_binary_reader, + force_readable=force_readable, + ) + + def _force_correct_text_writer(text_writer, encoding, errors, force_writable=False): + return _force_correct_text_stream( + text_writer, + encoding, + errors, + _is_binary_writer, + _find_binary_writer, + force_writable=force_writable, + ) + + def get_binary_stdin(): + reader = _find_binary_reader(sys.stdin) + if reader is None: + raise RuntimeError("Was not able to determine binary stream for sys.stdin.") + return reader + + def get_binary_stdout(): + writer = _find_binary_writer(sys.stdout) + if writer is None: + raise RuntimeError( + "Was not able to determine binary stream for sys.stdout." + ) + return writer + + def get_binary_stderr(): + writer = _find_binary_writer(sys.stderr) + if writer is None: + raise RuntimeError( + "Was not able to determine binary stream for sys.stderr." + ) + return writer + + def get_text_stdin(encoding=None, errors=None): + rv = _get_windows_console_stream(sys.stdin, encoding, errors) + if rv is not None: + return rv + return _force_correct_text_reader( + sys.stdin, encoding, errors, force_readable=True + ) + + def get_text_stdout(encoding=None, errors=None): + rv = _get_windows_console_stream(sys.stdout, encoding, errors) + if rv is not None: + return rv + return _force_correct_text_writer( + sys.stdout, encoding, errors, force_writable=True + ) + + def get_text_stderr(encoding=None, errors=None): + rv = _get_windows_console_stream(sys.stderr, encoding, errors) + if rv is not None: + return rv + return _force_correct_text_writer( + sys.stderr, encoding, errors, force_writable=True + ) + + def filename_to_ui(value): + if isinstance(value, bytes): + value = value.decode(get_filesystem_encoding(), "replace") + else: + value = value.encode("utf-8", "surrogateescape").decode("utf-8", "replace") + return value + + +def get_streerror(e, default=None): + if hasattr(e, "strerror"): + msg = e.strerror + else: + if default is not None: + msg = default + else: + msg = str(e) + if isinstance(msg, bytes): + msg = msg.decode("utf-8", "replace") + return msg + + +def _wrap_io_open(file, mode, encoding, errors): + """On Python 2, :func:`io.open` returns a text file wrapper that + requires passing ``unicode`` to ``write``. Need to open the file in + binary mode then wrap it in a subclass that can write ``str`` and + ``unicode``. + + Also handles not passing ``encoding`` and ``errors`` in binary mode. + """ + binary = "b" in mode + + if binary: + kwargs = {} + else: + kwargs = {"encoding": encoding, "errors": errors} + + if not PY2 or binary: + return io.open(file, mode, **kwargs) + + f = io.open(file, "{}b".format(mode.replace("t", ""))) + return _make_text_stream(f, **kwargs) + + +def open_stream(filename, mode="r", encoding=None, errors="strict", atomic=False): + binary = "b" in mode + + # Standard streams first. These are simple because they don't need + # special handling for the atomic flag. It's entirely ignored. + if filename == "-": + if any(m in mode for m in ["w", "a", "x"]): + if binary: + return get_binary_stdout(), False + return get_text_stdout(encoding=encoding, errors=errors), False + if binary: + return get_binary_stdin(), False + return get_text_stdin(encoding=encoding, errors=errors), False + + # Non-atomic writes directly go out through the regular open functions. + if not atomic: + return _wrap_io_open(filename, mode, encoding, errors), True + + # Some usability stuff for atomic writes + if "a" in mode: + raise ValueError( + "Appending to an existing file is not supported, because that" + " would involve an expensive `copy`-operation to a temporary" + " file. Open the file in normal `w`-mode and copy explicitly" + " if that's what you're after." + ) + if "x" in mode: + raise ValueError("Use the `overwrite`-parameter instead.") + if "w" not in mode: + raise ValueError("Atomic writes only make sense with `w`-mode.") + + # Atomic writes are more complicated. They work by opening a file + # as a proxy in the same folder and then using the fdopen + # functionality to wrap it in a Python file. Then we wrap it in an + # atomic file that moves the file over on close. + import errno + import random + + try: + perm = os.stat(filename).st_mode + except OSError: + perm = None + + flags = os.O_RDWR | os.O_CREAT | os.O_EXCL + + if binary: + flags |= getattr(os, "O_BINARY", 0) + + while True: + tmp_filename = os.path.join( + os.path.dirname(filename), + ".__atomic-write{:08x}".format(random.randrange(1 << 32)), + ) + try: + fd = os.open(tmp_filename, flags, 0o666 if perm is None else perm) + break + except OSError as e: + if e.errno == errno.EEXIST or ( + os.name == "nt" + and e.errno == errno.EACCES + and os.path.isdir(e.filename) + and os.access(e.filename, os.W_OK) + ): + continue + raise + + if perm is not None: + os.chmod(tmp_filename, perm) # in case perm includes bits in umask + + f = _wrap_io_open(fd, mode, encoding, errors) + return _AtomicFile(f, tmp_filename, os.path.realpath(filename)), True + + +# Used in a destructor call, needs extra protection from interpreter cleanup. +if hasattr(os, "replace"): + _replace = os.replace + _can_replace = True +else: + _replace = os.rename + _can_replace = not WIN + + +class _AtomicFile(object): + def __init__(self, f, tmp_filename, real_filename): + self._f = f + self._tmp_filename = tmp_filename + self._real_filename = real_filename + self.closed = False + + @property + def name(self): + return self._real_filename + + def close(self, delete=False): + if self.closed: + return + self._f.close() + if not _can_replace: + try: + os.remove(self._real_filename) + except OSError: + pass + _replace(self._tmp_filename, self._real_filename) + self.closed = True + + def __getattr__(self, name): + return getattr(self._f, name) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, tb): + self.close(delete=exc_type is not None) + + def __repr__(self): + return repr(self._f) + + +auto_wrap_for_ansi = None +colorama = None +get_winterm_size = None + + +def strip_ansi(value): + return _ansi_re.sub("", value) + + +def _is_jupyter_kernel_output(stream): + if WIN: + # TODO: Couldn't test on Windows, should't try to support until + # someone tests the details wrt colorama. + return + + while isinstance(stream, (_FixupStream, _NonClosingTextIOWrapper)): + stream = stream._stream + + return stream.__class__.__module__.startswith("ipykernel.") + + +def should_strip_ansi(stream=None, color=None): + if color is None: + if stream is None: + stream = sys.stdin + return not isatty(stream) and not _is_jupyter_kernel_output(stream) + return not color + + +# If we're on Windows, we provide transparent integration through +# colorama. This will make ANSI colors through the echo function +# work automatically. +if WIN: + # Windows has a smaller terminal + DEFAULT_COLUMNS = 79 + + from ._winconsole import _get_windows_console_stream, _wrap_std_stream + + def _get_argv_encoding(): + import locale + + return locale.getpreferredencoding() + + if PY2: + + def raw_input(prompt=""): + sys.stderr.flush() + if prompt: + stdout = _default_text_stdout() + stdout.write(prompt) + stdin = _default_text_stdin() + return stdin.readline().rstrip("\r\n") + + try: + import colorama + except ImportError: + pass + else: + _ansi_stream_wrappers = WeakKeyDictionary() + + def auto_wrap_for_ansi(stream, color=None): + """This function wraps a stream so that calls through colorama + are issued to the win32 console API to recolor on demand. It + also ensures to reset the colors if a write call is interrupted + to not destroy the console afterwards. + """ + try: + cached = _ansi_stream_wrappers.get(stream) + except Exception: + cached = None + if cached is not None: + return cached + strip = should_strip_ansi(stream, color) + ansi_wrapper = colorama.AnsiToWin32(stream, strip=strip) + rv = ansi_wrapper.stream + _write = rv.write + + def _safe_write(s): + try: + return _write(s) + except: + ansi_wrapper.reset_all() + raise + + rv.write = _safe_write + try: + _ansi_stream_wrappers[stream] = rv + except Exception: + pass + return rv + + def get_winterm_size(): + win = colorama.win32.GetConsoleScreenBufferInfo( + colorama.win32.STDOUT + ).srWindow + return win.Right - win.Left, win.Bottom - win.Top + + +else: + + def _get_argv_encoding(): + return getattr(sys.stdin, "encoding", None) or get_filesystem_encoding() + + _get_windows_console_stream = lambda *x: None + _wrap_std_stream = lambda *x: None + + +def term_len(x): + return len(strip_ansi(x)) + + +def isatty(stream): + try: + return stream.isatty() + except Exception: + return False + + +def _make_cached_stream_func(src_func, wrapper_func): + cache = WeakKeyDictionary() + + def func(): + stream = src_func() + try: + rv = cache.get(stream) + except Exception: + rv = None + if rv is not None: + return rv + rv = wrapper_func() + try: + stream = src_func() # In case wrapper_func() modified the stream + cache[stream] = rv + except Exception: + pass + return rv + + return func + + +_default_text_stdin = _make_cached_stream_func(lambda: sys.stdin, get_text_stdin) +_default_text_stdout = _make_cached_stream_func(lambda: sys.stdout, get_text_stdout) +_default_text_stderr = _make_cached_stream_func(lambda: sys.stderr, get_text_stderr) + + +binary_streams = { + "stdin": get_binary_stdin, + "stdout": get_binary_stdout, + "stderr": get_binary_stderr, +} + +text_streams = { + "stdin": get_text_stdin, + "stdout": get_text_stdout, + "stderr": get_text_stderr, +} diff --git a/openpype/vendor/python/python_2/click/_termui_impl.py b/openpype/vendor/python/python_2/click/_termui_impl.py new file mode 100644 index 0000000000..88bec37701 --- /dev/null +++ b/openpype/vendor/python/python_2/click/_termui_impl.py @@ -0,0 +1,657 @@ +# -*- coding: utf-8 -*- +""" +This module contains implementations for the termui module. To keep the +import time of Click down, some infrequently used functionality is +placed in this module and only imported as needed. +""" +import contextlib +import math +import os +import sys +import time + +from ._compat import _default_text_stdout +from ._compat import CYGWIN +from ._compat import get_best_encoding +from ._compat import int_types +from ._compat import isatty +from ._compat import open_stream +from ._compat import range_type +from ._compat import strip_ansi +from ._compat import term_len +from ._compat import WIN +from .exceptions import ClickException +from .utils import echo + +if os.name == "nt": + BEFORE_BAR = "\r" + AFTER_BAR = "\n" +else: + BEFORE_BAR = "\r\033[?25l" + AFTER_BAR = "\033[?25h\n" + + +def _length_hint(obj): + """Returns the length hint of an object.""" + try: + return len(obj) + except (AttributeError, TypeError): + try: + get_hint = type(obj).__length_hint__ + except AttributeError: + return None + try: + hint = get_hint(obj) + except TypeError: + return None + if hint is NotImplemented or not isinstance(hint, int_types) or hint < 0: + return None + return hint + + +class ProgressBar(object): + def __init__( + self, + iterable, + length=None, + fill_char="#", + empty_char=" ", + bar_template="%(bar)s", + info_sep=" ", + show_eta=True, + show_percent=None, + show_pos=False, + item_show_func=None, + label=None, + file=None, + color=None, + width=30, + ): + self.fill_char = fill_char + self.empty_char = empty_char + self.bar_template = bar_template + self.info_sep = info_sep + self.show_eta = show_eta + self.show_percent = show_percent + self.show_pos = show_pos + self.item_show_func = item_show_func + self.label = label or "" + if file is None: + file = _default_text_stdout() + self.file = file + self.color = color + self.width = width + self.autowidth = width == 0 + + if length is None: + length = _length_hint(iterable) + if iterable is None: + if length is None: + raise TypeError("iterable or length is required") + iterable = range_type(length) + self.iter = iter(iterable) + self.length = length + self.length_known = length is not None + self.pos = 0 + self.avg = [] + self.start = self.last_eta = time.time() + self.eta_known = False + self.finished = False + self.max_width = None + self.entered = False + self.current_item = None + self.is_hidden = not isatty(self.file) + self._last_line = None + self.short_limit = 0.5 + + def __enter__(self): + self.entered = True + self.render_progress() + return self + + def __exit__(self, exc_type, exc_value, tb): + self.render_finish() + + def __iter__(self): + if not self.entered: + raise RuntimeError("You need to use progress bars in a with block.") + self.render_progress() + return self.generator() + + def __next__(self): + # Iteration is defined in terms of a generator function, + # returned by iter(self); use that to define next(). This works + # because `self.iter` is an iterable consumed by that generator, + # so it is re-entry safe. Calling `next(self.generator())` + # twice works and does "what you want". + return next(iter(self)) + + # Python 2 compat + next = __next__ + + def is_fast(self): + return time.time() - self.start <= self.short_limit + + def render_finish(self): + if self.is_hidden or self.is_fast(): + return + self.file.write(AFTER_BAR) + self.file.flush() + + @property + def pct(self): + if self.finished: + return 1.0 + return min(self.pos / (float(self.length) or 1), 1.0) + + @property + def time_per_iteration(self): + if not self.avg: + return 0.0 + return sum(self.avg) / float(len(self.avg)) + + @property + def eta(self): + if self.length_known and not self.finished: + return self.time_per_iteration * (self.length - self.pos) + return 0.0 + + def format_eta(self): + if self.eta_known: + t = int(self.eta) + seconds = t % 60 + t //= 60 + minutes = t % 60 + t //= 60 + hours = t % 24 + t //= 24 + if t > 0: + return "{}d {:02}:{:02}:{:02}".format(t, hours, minutes, seconds) + else: + return "{:02}:{:02}:{:02}".format(hours, minutes, seconds) + return "" + + def format_pos(self): + pos = str(self.pos) + if self.length_known: + pos += "/{}".format(self.length) + return pos + + def format_pct(self): + return "{: 4}%".format(int(self.pct * 100))[1:] + + def format_bar(self): + if self.length_known: + bar_length = int(self.pct * self.width) + bar = self.fill_char * bar_length + bar += self.empty_char * (self.width - bar_length) + elif self.finished: + bar = self.fill_char * self.width + else: + bar = list(self.empty_char * (self.width or 1)) + if self.time_per_iteration != 0: + bar[ + int( + (math.cos(self.pos * self.time_per_iteration) / 2.0 + 0.5) + * self.width + ) + ] = self.fill_char + bar = "".join(bar) + return bar + + def format_progress_line(self): + show_percent = self.show_percent + + info_bits = [] + if self.length_known and show_percent is None: + show_percent = not self.show_pos + + if self.show_pos: + info_bits.append(self.format_pos()) + if show_percent: + info_bits.append(self.format_pct()) + if self.show_eta and self.eta_known and not self.finished: + info_bits.append(self.format_eta()) + if self.item_show_func is not None: + item_info = self.item_show_func(self.current_item) + if item_info is not None: + info_bits.append(item_info) + + return ( + self.bar_template + % { + "label": self.label, + "bar": self.format_bar(), + "info": self.info_sep.join(info_bits), + } + ).rstrip() + + def render_progress(self): + from .termui import get_terminal_size + + if self.is_hidden: + return + + buf = [] + # Update width in case the terminal has been resized + if self.autowidth: + old_width = self.width + self.width = 0 + clutter_length = term_len(self.format_progress_line()) + new_width = max(0, get_terminal_size()[0] - clutter_length) + if new_width < old_width: + buf.append(BEFORE_BAR) + buf.append(" " * self.max_width) + self.max_width = new_width + self.width = new_width + + clear_width = self.width + if self.max_width is not None: + clear_width = self.max_width + + buf.append(BEFORE_BAR) + line = self.format_progress_line() + line_len = term_len(line) + if self.max_width is None or self.max_width < line_len: + self.max_width = line_len + + buf.append(line) + buf.append(" " * (clear_width - line_len)) + line = "".join(buf) + # Render the line only if it changed. + + if line != self._last_line and not self.is_fast(): + self._last_line = line + echo(line, file=self.file, color=self.color, nl=False) + self.file.flush() + + def make_step(self, n_steps): + self.pos += n_steps + if self.length_known and self.pos >= self.length: + self.finished = True + + if (time.time() - self.last_eta) < 1.0: + return + + self.last_eta = time.time() + + # self.avg is a rolling list of length <= 7 of steps where steps are + # defined as time elapsed divided by the total progress through + # self.length. + if self.pos: + step = (time.time() - self.start) / self.pos + else: + step = time.time() - self.start + + self.avg = self.avg[-6:] + [step] + + self.eta_known = self.length_known + + def update(self, n_steps): + self.make_step(n_steps) + self.render_progress() + + def finish(self): + self.eta_known = 0 + self.current_item = None + self.finished = True + + def generator(self): + """Return a generator which yields the items added to the bar + during construction, and updates the progress bar *after* the + yielded block returns. + """ + # WARNING: the iterator interface for `ProgressBar` relies on + # this and only works because this is a simple generator which + # doesn't create or manage additional state. If this function + # changes, the impact should be evaluated both against + # `iter(bar)` and `next(bar)`. `next()` in particular may call + # `self.generator()` repeatedly, and this must remain safe in + # order for that interface to work. + if not self.entered: + raise RuntimeError("You need to use progress bars in a with block.") + + if self.is_hidden: + for rv in self.iter: + yield rv + else: + for rv in self.iter: + self.current_item = rv + yield rv + self.update(1) + self.finish() + self.render_progress() + + +def pager(generator, color=None): + """Decide what method to use for paging through text.""" + stdout = _default_text_stdout() + if not isatty(sys.stdin) or not isatty(stdout): + return _nullpager(stdout, generator, color) + pager_cmd = (os.environ.get("PAGER", None) or "").strip() + if pager_cmd: + if WIN: + return _tempfilepager(generator, pager_cmd, color) + return _pipepager(generator, pager_cmd, color) + if os.environ.get("TERM") in ("dumb", "emacs"): + return _nullpager(stdout, generator, color) + if WIN or sys.platform.startswith("os2"): + return _tempfilepager(generator, "more <", color) + if hasattr(os, "system") and os.system("(less) 2>/dev/null") == 0: + return _pipepager(generator, "less", color) + + import tempfile + + fd, filename = tempfile.mkstemp() + os.close(fd) + try: + if hasattr(os, "system") and os.system('more "{}"'.format(filename)) == 0: + return _pipepager(generator, "more", color) + return _nullpager(stdout, generator, color) + finally: + os.unlink(filename) + + +def _pipepager(generator, cmd, color): + """Page through text by feeding it to another program. Invoking a + pager through this might support colors. + """ + import subprocess + + env = dict(os.environ) + + # If we're piping to less we might support colors under the + # condition that + cmd_detail = cmd.rsplit("/", 1)[-1].split() + if color is None and cmd_detail[0] == "less": + less_flags = "{}{}".format(os.environ.get("LESS", ""), " ".join(cmd_detail[1:])) + if not less_flags: + env["LESS"] = "-R" + color = True + elif "r" in less_flags or "R" in less_flags: + color = True + + c = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, env=env) + encoding = get_best_encoding(c.stdin) + try: + for text in generator: + if not color: + text = strip_ansi(text) + + c.stdin.write(text.encode(encoding, "replace")) + except (IOError, KeyboardInterrupt): + pass + else: + c.stdin.close() + + # Less doesn't respect ^C, but catches it for its own UI purposes (aborting + # search or other commands inside less). + # + # That means when the user hits ^C, the parent process (click) terminates, + # but less is still alive, paging the output and messing up the terminal. + # + # If the user wants to make the pager exit on ^C, they should set + # `LESS='-K'`. It's not our decision to make. + while True: + try: + c.wait() + except KeyboardInterrupt: + pass + else: + break + + +def _tempfilepager(generator, cmd, color): + """Page through text by invoking a program on a temporary file.""" + import tempfile + + filename = tempfile.mktemp() + # TODO: This never terminates if the passed generator never terminates. + text = "".join(generator) + if not color: + text = strip_ansi(text) + encoding = get_best_encoding(sys.stdout) + with open_stream(filename, "wb")[0] as f: + f.write(text.encode(encoding)) + try: + os.system('{} "{}"'.format(cmd, filename)) + finally: + os.unlink(filename) + + +def _nullpager(stream, generator, color): + """Simply print unformatted text. This is the ultimate fallback.""" + for text in generator: + if not color: + text = strip_ansi(text) + stream.write(text) + + +class Editor(object): + def __init__(self, editor=None, env=None, require_save=True, extension=".txt"): + self.editor = editor + self.env = env + self.require_save = require_save + self.extension = extension + + def get_editor(self): + if self.editor is not None: + return self.editor + for key in "VISUAL", "EDITOR": + rv = os.environ.get(key) + if rv: + return rv + if WIN: + return "notepad" + for editor in "sensible-editor", "vim", "nano": + if os.system("which {} >/dev/null 2>&1".format(editor)) == 0: + return editor + return "vi" + + def edit_file(self, filename): + import subprocess + + editor = self.get_editor() + if self.env: + environ = os.environ.copy() + environ.update(self.env) + else: + environ = None + try: + c = subprocess.Popen( + '{} "{}"'.format(editor, filename), env=environ, shell=True, + ) + exit_code = c.wait() + if exit_code != 0: + raise ClickException("{}: Editing failed!".format(editor)) + except OSError as e: + raise ClickException("{}: Editing failed: {}".format(editor, e)) + + def edit(self, text): + import tempfile + + text = text or "" + if text and not text.endswith("\n"): + text += "\n" + + fd, name = tempfile.mkstemp(prefix="editor-", suffix=self.extension) + try: + if WIN: + encoding = "utf-8-sig" + text = text.replace("\n", "\r\n") + else: + encoding = "utf-8" + text = text.encode(encoding) + + f = os.fdopen(fd, "wb") + f.write(text) + f.close() + timestamp = os.path.getmtime(name) + + self.edit_file(name) + + if self.require_save and os.path.getmtime(name) == timestamp: + return None + + f = open(name, "rb") + try: + rv = f.read() + finally: + f.close() + return rv.decode("utf-8-sig").replace("\r\n", "\n") + finally: + os.unlink(name) + + +def open_url(url, wait=False, locate=False): + import subprocess + + def _unquote_file(url): + try: + import urllib + except ImportError: + import urllib + if url.startswith("file://"): + url = urllib.unquote(url[7:]) + return url + + if sys.platform == "darwin": + args = ["open"] + if wait: + args.append("-W") + if locate: + args.append("-R") + args.append(_unquote_file(url)) + null = open("/dev/null", "w") + try: + return subprocess.Popen(args, stderr=null).wait() + finally: + null.close() + elif WIN: + if locate: + url = _unquote_file(url) + args = 'explorer /select,"{}"'.format(_unquote_file(url.replace('"', ""))) + else: + args = 'start {} "" "{}"'.format( + "/WAIT" if wait else "", url.replace('"', "") + ) + return os.system(args) + elif CYGWIN: + if locate: + url = _unquote_file(url) + args = 'cygstart "{}"'.format(os.path.dirname(url).replace('"', "")) + else: + args = 'cygstart {} "{}"'.format("-w" if wait else "", url.replace('"', "")) + return os.system(args) + + try: + if locate: + url = os.path.dirname(_unquote_file(url)) or "." + else: + url = _unquote_file(url) + c = subprocess.Popen(["xdg-open", url]) + if wait: + return c.wait() + return 0 + except OSError: + if url.startswith(("http://", "https://")) and not locate and not wait: + import webbrowser + + webbrowser.open(url) + return 0 + return 1 + + +def _translate_ch_to_exc(ch): + if ch == u"\x03": + raise KeyboardInterrupt() + if ch == u"\x04" and not WIN: # Unix-like, Ctrl+D + raise EOFError() + if ch == u"\x1a" and WIN: # Windows, Ctrl+Z + raise EOFError() + + +if WIN: + import msvcrt + + @contextlib.contextmanager + def raw_terminal(): + yield + + def getchar(echo): + # The function `getch` will return a bytes object corresponding to + # the pressed character. Since Windows 10 build 1803, it will also + # return \x00 when called a second time after pressing a regular key. + # + # `getwch` does not share this probably-bugged behavior. Moreover, it + # returns a Unicode object by default, which is what we want. + # + # Either of these functions will return \x00 or \xe0 to indicate + # a special key, and you need to call the same function again to get + # the "rest" of the code. The fun part is that \u00e0 is + # "latin small letter a with grave", so if you type that on a French + # keyboard, you _also_ get a \xe0. + # E.g., consider the Up arrow. This returns \xe0 and then \x48. The + # resulting Unicode string reads as "a with grave" + "capital H". + # This is indistinguishable from when the user actually types + # "a with grave" and then "capital H". + # + # When \xe0 is returned, we assume it's part of a special-key sequence + # and call `getwch` again, but that means that when the user types + # the \u00e0 character, `getchar` doesn't return until a second + # character is typed. + # The alternative is returning immediately, but that would mess up + # cross-platform handling of arrow keys and others that start with + # \xe0. Another option is using `getch`, but then we can't reliably + # read non-ASCII characters, because return values of `getch` are + # limited to the current 8-bit codepage. + # + # Anyway, Click doesn't claim to do this Right(tm), and using `getwch` + # is doing the right thing in more situations than with `getch`. + if echo: + func = msvcrt.getwche + else: + func = msvcrt.getwch + + rv = func() + if rv in (u"\x00", u"\xe0"): + # \x00 and \xe0 are control characters that indicate special key, + # see above. + rv += func() + _translate_ch_to_exc(rv) + return rv + + +else: + import tty + import termios + + @contextlib.contextmanager + def raw_terminal(): + if not isatty(sys.stdin): + f = open("/dev/tty") + fd = f.fileno() + else: + fd = sys.stdin.fileno() + f = None + try: + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(fd) + yield fd + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + sys.stdout.flush() + if f is not None: + f.close() + except termios.error: + pass + + def getchar(echo): + with raw_terminal() as fd: + ch = os.read(fd, 32) + ch = ch.decode(get_best_encoding(sys.stdin), "replace") + if echo and isatty(sys.stdout): + sys.stdout.write(ch) + _translate_ch_to_exc(ch) + return ch diff --git a/openpype/vendor/python/python_2/click/_textwrap.py b/openpype/vendor/python/python_2/click/_textwrap.py new file mode 100644 index 0000000000..6959087b7f --- /dev/null +++ b/openpype/vendor/python/python_2/click/_textwrap.py @@ -0,0 +1,37 @@ +import textwrap +from contextlib import contextmanager + + +class TextWrapper(textwrap.TextWrapper): + def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): + space_left = max(width - cur_len, 1) + + if self.break_long_words: + last = reversed_chunks[-1] + cut = last[:space_left] + res = last[space_left:] + cur_line.append(cut) + reversed_chunks[-1] = res + elif not cur_line: + cur_line.append(reversed_chunks.pop()) + + @contextmanager + def extra_indent(self, indent): + old_initial_indent = self.initial_indent + old_subsequent_indent = self.subsequent_indent + self.initial_indent += indent + self.subsequent_indent += indent + try: + yield + finally: + self.initial_indent = old_initial_indent + self.subsequent_indent = old_subsequent_indent + + def indent_only(self, text): + rv = [] + for idx, line in enumerate(text.splitlines()): + indent = self.initial_indent + if idx > 0: + indent = self.subsequent_indent + rv.append(indent + line) + return "\n".join(rv) diff --git a/openpype/vendor/python/python_2/click/_unicodefun.py b/openpype/vendor/python/python_2/click/_unicodefun.py new file mode 100644 index 0000000000..781c365227 --- /dev/null +++ b/openpype/vendor/python/python_2/click/_unicodefun.py @@ -0,0 +1,131 @@ +import codecs +import os +import sys + +from ._compat import PY2 + + +def _find_unicode_literals_frame(): + import __future__ + + if not hasattr(sys, "_getframe"): # not all Python implementations have it + return 0 + frm = sys._getframe(1) + idx = 1 + while frm is not None: + if frm.f_globals.get("__name__", "").startswith("click."): + frm = frm.f_back + idx += 1 + elif frm.f_code.co_flags & __future__.unicode_literals.compiler_flag: + return idx + else: + break + return 0 + + +def _check_for_unicode_literals(): + if not __debug__: + return + + from . import disable_unicode_literals_warning + + if not PY2 or disable_unicode_literals_warning: + return + bad_frame = _find_unicode_literals_frame() + if bad_frame <= 0: + return + from warnings import warn + + warn( + Warning( + "Click detected the use of the unicode_literals __future__" + " import. This is heavily discouraged because it can" + " introduce subtle bugs in your code. You should instead" + ' use explicit u"" literals for your unicode strings. For' + " more information see" + " https://click.palletsprojects.com/python3/" + ), + stacklevel=bad_frame, + ) + + +def _verify_python3_env(): + """Ensures that the environment is good for unicode on Python 3.""" + if PY2: + return + try: + import locale + + fs_enc = codecs.lookup(locale.getpreferredencoding()).name + except Exception: + fs_enc = "ascii" + if fs_enc != "ascii": + return + + extra = "" + if os.name == "posix": + import subprocess + + try: + rv = subprocess.Popen( + ["locale", "-a"], stdout=subprocess.PIPE, stderr=subprocess.PIPE + ).communicate()[0] + except OSError: + rv = b"" + good_locales = set() + has_c_utf8 = False + + # Make sure we're operating on text here. + if isinstance(rv, bytes): + rv = rv.decode("ascii", "replace") + + for line in rv.splitlines(): + locale = line.strip() + if locale.lower().endswith((".utf-8", ".utf8")): + good_locales.add(locale) + if locale.lower() in ("c.utf8", "c.utf-8"): + has_c_utf8 = True + + extra += "\n\n" + if not good_locales: + extra += ( + "Additional information: on this system no suitable" + " UTF-8 locales were discovered. This most likely" + " requires resolving by reconfiguring the locale" + " system." + ) + elif has_c_utf8: + extra += ( + "This system supports the C.UTF-8 locale which is" + " recommended. You might be able to resolve your issue" + " by exporting the following environment variables:\n\n" + " export LC_ALL=C.UTF-8\n" + " export LANG=C.UTF-8" + ) + else: + extra += ( + "This system lists a couple of UTF-8 supporting locales" + " that you can pick from. The following suitable" + " locales were discovered: {}".format(", ".join(sorted(good_locales))) + ) + + bad_locale = None + for locale in os.environ.get("LC_ALL"), os.environ.get("LANG"): + if locale and locale.lower().endswith((".utf-8", ".utf8")): + bad_locale = locale + if locale is not None: + break + if bad_locale is not None: + extra += ( + "\n\nClick discovered that you exported a UTF-8 locale" + " but the locale system could not pick up from it" + " because it does not exist. The exported locale is" + " '{}' but it is not supported".format(bad_locale) + ) + + raise RuntimeError( + "Click will abort further execution because Python 3 was" + " configured to use ASCII as encoding for the environment." + " Consult https://click.palletsprojects.com/python3/ for" + " mitigation steps.{}".format(extra) + ) diff --git a/openpype/vendor/python/python_2/click/_winconsole.py b/openpype/vendor/python/python_2/click/_winconsole.py new file mode 100644 index 0000000000..b6c4274af0 --- /dev/null +++ b/openpype/vendor/python/python_2/click/_winconsole.py @@ -0,0 +1,370 @@ +# -*- coding: utf-8 -*- +# This module is based on the excellent work by Adam Bartoš who +# provided a lot of what went into the implementation here in +# the discussion to issue1602 in the Python bug tracker. +# +# There are some general differences in regards to how this works +# compared to the original patches as we do not need to patch +# the entire interpreter but just work in our little world of +# echo and prmopt. +import ctypes +import io +import os +import sys +import time +import zlib +from ctypes import byref +from ctypes import c_char +from ctypes import c_char_p +from ctypes import c_int +from ctypes import c_ssize_t +from ctypes import c_ulong +from ctypes import c_void_p +from ctypes import POINTER +from ctypes import py_object +from ctypes import windll +from ctypes import WinError +from ctypes import WINFUNCTYPE +from ctypes.wintypes import DWORD +from ctypes.wintypes import HANDLE +from ctypes.wintypes import LPCWSTR +from ctypes.wintypes import LPWSTR + +import msvcrt + +from ._compat import _NonClosingTextIOWrapper +from ._compat import PY2 +from ._compat import text_type + +try: + from ctypes import pythonapi + + PyObject_GetBuffer = pythonapi.PyObject_GetBuffer + PyBuffer_Release = pythonapi.PyBuffer_Release +except ImportError: + pythonapi = None + + +c_ssize_p = POINTER(c_ssize_t) + +kernel32 = windll.kernel32 +GetStdHandle = kernel32.GetStdHandle +ReadConsoleW = kernel32.ReadConsoleW +WriteConsoleW = kernel32.WriteConsoleW +GetConsoleMode = kernel32.GetConsoleMode +GetLastError = kernel32.GetLastError +GetCommandLineW = WINFUNCTYPE(LPWSTR)(("GetCommandLineW", windll.kernel32)) +CommandLineToArgvW = WINFUNCTYPE(POINTER(LPWSTR), LPCWSTR, POINTER(c_int))( + ("CommandLineToArgvW", windll.shell32) +) +LocalFree = WINFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p)( + ("LocalFree", windll.kernel32) +) + + +STDIN_HANDLE = GetStdHandle(-10) +STDOUT_HANDLE = GetStdHandle(-11) +STDERR_HANDLE = GetStdHandle(-12) + + +PyBUF_SIMPLE = 0 +PyBUF_WRITABLE = 1 + +ERROR_SUCCESS = 0 +ERROR_NOT_ENOUGH_MEMORY = 8 +ERROR_OPERATION_ABORTED = 995 + +STDIN_FILENO = 0 +STDOUT_FILENO = 1 +STDERR_FILENO = 2 + +EOF = b"\x1a" +MAX_BYTES_WRITTEN = 32767 + + +class Py_buffer(ctypes.Structure): + _fields_ = [ + ("buf", c_void_p), + ("obj", py_object), + ("len", c_ssize_t), + ("itemsize", c_ssize_t), + ("readonly", c_int), + ("ndim", c_int), + ("format", c_char_p), + ("shape", c_ssize_p), + ("strides", c_ssize_p), + ("suboffsets", c_ssize_p), + ("internal", c_void_p), + ] + + if PY2: + _fields_.insert(-1, ("smalltable", c_ssize_t * 2)) + + +# On PyPy we cannot get buffers so our ability to operate here is +# serverly limited. +if pythonapi is None: + get_buffer = None +else: + + def get_buffer(obj, writable=False): + buf = Py_buffer() + flags = PyBUF_WRITABLE if writable else PyBUF_SIMPLE + PyObject_GetBuffer(py_object(obj), byref(buf), flags) + try: + buffer_type = c_char * buf.len + return buffer_type.from_address(buf.buf) + finally: + PyBuffer_Release(byref(buf)) + + +class _WindowsConsoleRawIOBase(io.RawIOBase): + def __init__(self, handle): + self.handle = handle + + def isatty(self): + io.RawIOBase.isatty(self) + return True + + +class _WindowsConsoleReader(_WindowsConsoleRawIOBase): + def readable(self): + return True + + def readinto(self, b): + bytes_to_be_read = len(b) + if not bytes_to_be_read: + return 0 + elif bytes_to_be_read % 2: + raise ValueError( + "cannot read odd number of bytes from UTF-16-LE encoded console" + ) + + buffer = get_buffer(b, writable=True) + code_units_to_be_read = bytes_to_be_read // 2 + code_units_read = c_ulong() + + rv = ReadConsoleW( + HANDLE(self.handle), + buffer, + code_units_to_be_read, + byref(code_units_read), + None, + ) + if GetLastError() == ERROR_OPERATION_ABORTED: + # wait for KeyboardInterrupt + time.sleep(0.1) + if not rv: + raise OSError("Windows error: {}".format(GetLastError())) + + if buffer[0] == EOF: + return 0 + return 2 * code_units_read.value + + +class _WindowsConsoleWriter(_WindowsConsoleRawIOBase): + def writable(self): + return True + + @staticmethod + def _get_error_message(errno): + if errno == ERROR_SUCCESS: + return "ERROR_SUCCESS" + elif errno == ERROR_NOT_ENOUGH_MEMORY: + return "ERROR_NOT_ENOUGH_MEMORY" + return "Windows error {}".format(errno) + + def write(self, b): + bytes_to_be_written = len(b) + buf = get_buffer(b) + code_units_to_be_written = min(bytes_to_be_written, MAX_BYTES_WRITTEN) // 2 + code_units_written = c_ulong() + + WriteConsoleW( + HANDLE(self.handle), + buf, + code_units_to_be_written, + byref(code_units_written), + None, + ) + bytes_written = 2 * code_units_written.value + + if bytes_written == 0 and bytes_to_be_written > 0: + raise OSError(self._get_error_message(GetLastError())) + return bytes_written + + +class ConsoleStream(object): + def __init__(self, text_stream, byte_stream): + self._text_stream = text_stream + self.buffer = byte_stream + + @property + def name(self): + return self.buffer.name + + def write(self, x): + if isinstance(x, text_type): + return self._text_stream.write(x) + try: + self.flush() + except Exception: + pass + return self.buffer.write(x) + + def writelines(self, lines): + for line in lines: + self.write(line) + + def __getattr__(self, name): + return getattr(self._text_stream, name) + + def isatty(self): + return self.buffer.isatty() + + def __repr__(self): + return "".format( + self.name, self.encoding + ) + + +class WindowsChunkedWriter(object): + """ + Wraps a stream (such as stdout), acting as a transparent proxy for all + attribute access apart from method 'write()' which we wrap to write in + limited chunks due to a Windows limitation on binary console streams. + """ + + def __init__(self, wrapped): + # double-underscore everything to prevent clashes with names of + # attributes on the wrapped stream object. + self.__wrapped = wrapped + + def __getattr__(self, name): + return getattr(self.__wrapped, name) + + def write(self, text): + total_to_write = len(text) + written = 0 + + while written < total_to_write: + to_write = min(total_to_write - written, MAX_BYTES_WRITTEN) + self.__wrapped.write(text[written : written + to_write]) + written += to_write + + +_wrapped_std_streams = set() + + +def _wrap_std_stream(name): + # Python 2 & Windows 7 and below + if ( + PY2 + and sys.getwindowsversion()[:2] <= (6, 1) + and name not in _wrapped_std_streams + ): + setattr(sys, name, WindowsChunkedWriter(getattr(sys, name))) + _wrapped_std_streams.add(name) + + +def _get_text_stdin(buffer_stream): + text_stream = _NonClosingTextIOWrapper( + io.BufferedReader(_WindowsConsoleReader(STDIN_HANDLE)), + "utf-16-le", + "strict", + line_buffering=True, + ) + return ConsoleStream(text_stream, buffer_stream) + + +def _get_text_stdout(buffer_stream): + text_stream = _NonClosingTextIOWrapper( + io.BufferedWriter(_WindowsConsoleWriter(STDOUT_HANDLE)), + "utf-16-le", + "strict", + line_buffering=True, + ) + return ConsoleStream(text_stream, buffer_stream) + + +def _get_text_stderr(buffer_stream): + text_stream = _NonClosingTextIOWrapper( + io.BufferedWriter(_WindowsConsoleWriter(STDERR_HANDLE)), + "utf-16-le", + "strict", + line_buffering=True, + ) + return ConsoleStream(text_stream, buffer_stream) + + +if PY2: + + def _hash_py_argv(): + return zlib.crc32("\x00".join(sys.argv[1:])) + + _initial_argv_hash = _hash_py_argv() + + def _get_windows_argv(): + argc = c_int(0) + argv_unicode = CommandLineToArgvW(GetCommandLineW(), byref(argc)) + if not argv_unicode: + raise WinError() + try: + argv = [argv_unicode[i] for i in range(0, argc.value)] + finally: + LocalFree(argv_unicode) + del argv_unicode + + if not hasattr(sys, "frozen"): + argv = argv[1:] + while len(argv) > 0: + arg = argv[0] + if not arg.startswith("-") or arg == "-": + break + argv = argv[1:] + if arg.startswith(("-c", "-m")): + break + + return argv[1:] + + +_stream_factories = { + 0: _get_text_stdin, + 1: _get_text_stdout, + 2: _get_text_stderr, +} + + +def _is_console(f): + if not hasattr(f, "fileno"): + return False + + try: + fileno = f.fileno() + except OSError: + return False + + handle = msvcrt.get_osfhandle(fileno) + return bool(GetConsoleMode(handle, byref(DWORD()))) + + +def _get_windows_console_stream(f, encoding, errors): + if ( + get_buffer is not None + and encoding in ("utf-16-le", None) + and errors in ("strict", None) + and _is_console(f) + ): + func = _stream_factories.get(f.fileno()) + if func is not None: + if not PY2: + f = getattr(f, "buffer", None) + if f is None: + return None + else: + # If we are on Python 2 we need to set the stream that we + # deal with to binary mode as otherwise the exercise if a + # bit moot. The same problems apply as for + # get_binary_stdin and friends from _compat. + msvcrt.setmode(f.fileno(), os.O_BINARY) + return func(f) diff --git a/openpype/vendor/python/python_2/click/core.py b/openpype/vendor/python/python_2/click/core.py new file mode 100644 index 0000000000..f58bf26d2f --- /dev/null +++ b/openpype/vendor/python/python_2/click/core.py @@ -0,0 +1,2030 @@ +import errno +import inspect +import os +import sys +from contextlib import contextmanager +from functools import update_wrapper +from itertools import repeat + +from ._compat import isidentifier +from ._compat import iteritems +from ._compat import PY2 +from ._compat import string_types +from ._unicodefun import _check_for_unicode_literals +from ._unicodefun import _verify_python3_env +from .exceptions import Abort +from .exceptions import BadParameter +from .exceptions import ClickException +from .exceptions import Exit +from .exceptions import MissingParameter +from .exceptions import UsageError +from .formatting import HelpFormatter +from .formatting import join_options +from .globals import pop_context +from .globals import push_context +from .parser import OptionParser +from .parser import split_opt +from .termui import confirm +from .termui import prompt +from .termui import style +from .types import BOOL +from .types import convert_type +from .types import IntRange +from .utils import echo +from .utils import get_os_args +from .utils import make_default_short_help +from .utils import make_str +from .utils import PacifyFlushWrapper + +_missing = object() + +SUBCOMMAND_METAVAR = "COMMAND [ARGS]..." +SUBCOMMANDS_METAVAR = "COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]..." + +DEPRECATED_HELP_NOTICE = " (DEPRECATED)" +DEPRECATED_INVOKE_NOTICE = "DeprecationWarning: The command %(name)s is deprecated." + + +def _maybe_show_deprecated_notice(cmd): + if cmd.deprecated: + echo(style(DEPRECATED_INVOKE_NOTICE % {"name": cmd.name}, fg="red"), err=True) + + +def fast_exit(code): + """Exit without garbage collection, this speeds up exit by about 10ms for + things like bash completion. + """ + sys.stdout.flush() + sys.stderr.flush() + os._exit(code) + + +def _bashcomplete(cmd, prog_name, complete_var=None): + """Internal handler for the bash completion support.""" + if complete_var is None: + complete_var = "_{}_COMPLETE".format(prog_name.replace("-", "_").upper()) + complete_instr = os.environ.get(complete_var) + if not complete_instr: + return + + from ._bashcomplete import bashcomplete + + if bashcomplete(cmd, prog_name, complete_var, complete_instr): + fast_exit(1) + + +def _check_multicommand(base_command, cmd_name, cmd, register=False): + if not base_command.chain or not isinstance(cmd, MultiCommand): + return + if register: + hint = ( + "It is not possible to add multi commands as children to" + " another multi command that is in chain mode." + ) + else: + hint = ( + "Found a multi command as subcommand to a multi command" + " that is in chain mode. This is not supported." + ) + raise RuntimeError( + "{}. Command '{}' is set to chain and '{}' was added as" + " subcommand but it in itself is a multi command. ('{}' is a {}" + " within a chained {} named '{}').".format( + hint, + base_command.name, + cmd_name, + cmd_name, + cmd.__class__.__name__, + base_command.__class__.__name__, + base_command.name, + ) + ) + + +def batch(iterable, batch_size): + return list(zip(*repeat(iter(iterable), batch_size))) + + +def invoke_param_callback(callback, ctx, param, value): + code = getattr(callback, "__code__", None) + args = getattr(code, "co_argcount", 3) + + if args < 3: + from warnings import warn + + warn( + "Parameter callbacks take 3 args, (ctx, param, value). The" + " 2-arg style is deprecated and will be removed in 8.0.".format(callback), + DeprecationWarning, + stacklevel=3, + ) + return callback(ctx, value) + + return callback(ctx, param, value) + + +@contextmanager +def augment_usage_errors(ctx, param=None): + """Context manager that attaches extra information to exceptions.""" + try: + yield + except BadParameter as e: + if e.ctx is None: + e.ctx = ctx + if param is not None and e.param is None: + e.param = param + raise + except UsageError as e: + if e.ctx is None: + e.ctx = ctx + raise + + +def iter_params_for_processing(invocation_order, declaration_order): + """Given a sequence of parameters in the order as should be considered + for processing and an iterable of parameters that exist, this returns + a list in the correct order as they should be processed. + """ + + def sort_key(item): + try: + idx = invocation_order.index(item) + except ValueError: + idx = float("inf") + return (not item.is_eager, idx) + + return sorted(declaration_order, key=sort_key) + + +class Context(object): + """The context is a special internal object that holds state relevant + for the script execution at every single level. It's normally invisible + to commands unless they opt-in to getting access to it. + + The context is useful as it can pass internal objects around and can + control special execution features such as reading data from + environment variables. + + A context can be used as context manager in which case it will call + :meth:`close` on teardown. + + .. versionadded:: 2.0 + Added the `resilient_parsing`, `help_option_names`, + `token_normalize_func` parameters. + + .. versionadded:: 3.0 + Added the `allow_extra_args` and `allow_interspersed_args` + parameters. + + .. versionadded:: 4.0 + Added the `color`, `ignore_unknown_options`, and + `max_content_width` parameters. + + .. versionadded:: 7.1 + Added the `show_default` parameter. + + :param command: the command class for this context. + :param parent: the parent context. + :param info_name: the info name for this invocation. Generally this + is the most descriptive name for the script or + command. For the toplevel script it is usually + the name of the script, for commands below it it's + the name of the script. + :param obj: an arbitrary object of user data. + :param auto_envvar_prefix: the prefix to use for automatic environment + variables. If this is `None` then reading + from environment variables is disabled. This + does not affect manually set environment + variables which are always read. + :param default_map: a dictionary (like object) with default values + for parameters. + :param terminal_width: the width of the terminal. The default is + inherit from parent context. If no context + defines the terminal width then auto + detection will be applied. + :param max_content_width: the maximum width for content rendered by + Click (this currently only affects help + pages). This defaults to 80 characters if + not overridden. In other words: even if the + terminal is larger than that, Click will not + format things wider than 80 characters by + default. In addition to that, formatters might + add some safety mapping on the right. + :param resilient_parsing: if this flag is enabled then Click will + parse without any interactivity or callback + invocation. Default values will also be + ignored. This is useful for implementing + things such as completion support. + :param allow_extra_args: if this is set to `True` then extra arguments + at the end will not raise an error and will be + kept on the context. The default is to inherit + from the command. + :param allow_interspersed_args: if this is set to `False` then options + and arguments cannot be mixed. The + default is to inherit from the command. + :param ignore_unknown_options: instructs click to ignore options it does + not know and keeps them for later + processing. + :param help_option_names: optionally a list of strings that define how + the default help parameter is named. The + default is ``['--help']``. + :param token_normalize_func: an optional function that is used to + normalize tokens (options, choices, + etc.). This for instance can be used to + implement case insensitive behavior. + :param color: controls if the terminal supports ANSI colors or not. The + default is autodetection. This is only needed if ANSI + codes are used in texts that Click prints which is by + default not the case. This for instance would affect + help output. + :param show_default: if True, shows defaults for all options. + Even if an option is later created with show_default=False, + this command-level setting overrides it. + """ + + def __init__( + self, + command, + parent=None, + info_name=None, + obj=None, + auto_envvar_prefix=None, + default_map=None, + terminal_width=None, + max_content_width=None, + resilient_parsing=False, + allow_extra_args=None, + allow_interspersed_args=None, + ignore_unknown_options=None, + help_option_names=None, + token_normalize_func=None, + color=None, + show_default=None, + ): + #: the parent context or `None` if none exists. + self.parent = parent + #: the :class:`Command` for this context. + self.command = command + #: the descriptive information name + self.info_name = info_name + #: the parsed parameters except if the value is hidden in which + #: case it's not remembered. + self.params = {} + #: the leftover arguments. + self.args = [] + #: protected arguments. These are arguments that are prepended + #: to `args` when certain parsing scenarios are encountered but + #: must be never propagated to another arguments. This is used + #: to implement nested parsing. + self.protected_args = [] + if obj is None and parent is not None: + obj = parent.obj + #: the user object stored. + self.obj = obj + self._meta = getattr(parent, "meta", {}) + + #: A dictionary (-like object) with defaults for parameters. + if ( + default_map is None + and parent is not None + and parent.default_map is not None + ): + default_map = parent.default_map.get(info_name) + self.default_map = default_map + + #: This flag indicates if a subcommand is going to be executed. A + #: group callback can use this information to figure out if it's + #: being executed directly or because the execution flow passes + #: onwards to a subcommand. By default it's None, but it can be + #: the name of the subcommand to execute. + #: + #: If chaining is enabled this will be set to ``'*'`` in case + #: any commands are executed. It is however not possible to + #: figure out which ones. If you require this knowledge you + #: should use a :func:`resultcallback`. + self.invoked_subcommand = None + + if terminal_width is None and parent is not None: + terminal_width = parent.terminal_width + #: The width of the terminal (None is autodetection). + self.terminal_width = terminal_width + + if max_content_width is None and parent is not None: + max_content_width = parent.max_content_width + #: The maximum width of formatted content (None implies a sensible + #: default which is 80 for most things). + self.max_content_width = max_content_width + + if allow_extra_args is None: + allow_extra_args = command.allow_extra_args + #: Indicates if the context allows extra args or if it should + #: fail on parsing. + #: + #: .. versionadded:: 3.0 + self.allow_extra_args = allow_extra_args + + if allow_interspersed_args is None: + allow_interspersed_args = command.allow_interspersed_args + #: Indicates if the context allows mixing of arguments and + #: options or not. + #: + #: .. versionadded:: 3.0 + self.allow_interspersed_args = allow_interspersed_args + + if ignore_unknown_options is None: + ignore_unknown_options = command.ignore_unknown_options + #: Instructs click to ignore options that a command does not + #: understand and will store it on the context for later + #: processing. This is primarily useful for situations where you + #: want to call into external programs. Generally this pattern is + #: strongly discouraged because it's not possibly to losslessly + #: forward all arguments. + #: + #: .. versionadded:: 4.0 + self.ignore_unknown_options = ignore_unknown_options + + if help_option_names is None: + if parent is not None: + help_option_names = parent.help_option_names + else: + help_option_names = ["--help"] + + #: The names for the help options. + self.help_option_names = help_option_names + + if token_normalize_func is None and parent is not None: + token_normalize_func = parent.token_normalize_func + + #: An optional normalization function for tokens. This is + #: options, choices, commands etc. + self.token_normalize_func = token_normalize_func + + #: Indicates if resilient parsing is enabled. In that case Click + #: will do its best to not cause any failures and default values + #: will be ignored. Useful for completion. + self.resilient_parsing = resilient_parsing + + # If there is no envvar prefix yet, but the parent has one and + # the command on this level has a name, we can expand the envvar + # prefix automatically. + if auto_envvar_prefix is None: + if ( + parent is not None + and parent.auto_envvar_prefix is not None + and self.info_name is not None + ): + auto_envvar_prefix = "{}_{}".format( + parent.auto_envvar_prefix, self.info_name.upper() + ) + else: + auto_envvar_prefix = auto_envvar_prefix.upper() + if auto_envvar_prefix is not None: + auto_envvar_prefix = auto_envvar_prefix.replace("-", "_") + self.auto_envvar_prefix = auto_envvar_prefix + + if color is None and parent is not None: + color = parent.color + + #: Controls if styling output is wanted or not. + self.color = color + + self.show_default = show_default + + self._close_callbacks = [] + self._depth = 0 + + def __enter__(self): + self._depth += 1 + push_context(self) + return self + + def __exit__(self, exc_type, exc_value, tb): + self._depth -= 1 + if self._depth == 0: + self.close() + pop_context() + + @contextmanager + def scope(self, cleanup=True): + """This helper method can be used with the context object to promote + it to the current thread local (see :func:`get_current_context`). + The default behavior of this is to invoke the cleanup functions which + can be disabled by setting `cleanup` to `False`. The cleanup + functions are typically used for things such as closing file handles. + + If the cleanup is intended the context object can also be directly + used as a context manager. + + Example usage:: + + with ctx.scope(): + assert get_current_context() is ctx + + This is equivalent:: + + with ctx: + assert get_current_context() is ctx + + .. versionadded:: 5.0 + + :param cleanup: controls if the cleanup functions should be run or + not. The default is to run these functions. In + some situations the context only wants to be + temporarily pushed in which case this can be disabled. + Nested pushes automatically defer the cleanup. + """ + if not cleanup: + self._depth += 1 + try: + with self as rv: + yield rv + finally: + if not cleanup: + self._depth -= 1 + + @property + def meta(self): + """This is a dictionary which is shared with all the contexts + that are nested. It exists so that click utilities can store some + state here if they need to. It is however the responsibility of + that code to manage this dictionary well. + + The keys are supposed to be unique dotted strings. For instance + module paths are a good choice for it. What is stored in there is + irrelevant for the operation of click. However what is important is + that code that places data here adheres to the general semantics of + the system. + + Example usage:: + + LANG_KEY = f'{__name__}.lang' + + def set_language(value): + ctx = get_current_context() + ctx.meta[LANG_KEY] = value + + def get_language(): + return get_current_context().meta.get(LANG_KEY, 'en_US') + + .. versionadded:: 5.0 + """ + return self._meta + + def make_formatter(self): + """Creates the formatter for the help and usage output.""" + return HelpFormatter( + width=self.terminal_width, max_width=self.max_content_width + ) + + def call_on_close(self, f): + """This decorator remembers a function as callback that should be + executed when the context tears down. This is most useful to bind + resource handling to the script execution. For instance, file objects + opened by the :class:`File` type will register their close callbacks + here. + + :param f: the function to execute on teardown. + """ + self._close_callbacks.append(f) + return f + + def close(self): + """Invokes all close callbacks.""" + for cb in self._close_callbacks: + cb() + self._close_callbacks = [] + + @property + def command_path(self): + """The computed command path. This is used for the ``usage`` + information on the help page. It's automatically created by + combining the info names of the chain of contexts to the root. + """ + rv = "" + if self.info_name is not None: + rv = self.info_name + if self.parent is not None: + rv = "{} {}".format(self.parent.command_path, rv) + return rv.lstrip() + + def find_root(self): + """Finds the outermost context.""" + node = self + while node.parent is not None: + node = node.parent + return node + + def find_object(self, object_type): + """Finds the closest object of a given type.""" + node = self + while node is not None: + if isinstance(node.obj, object_type): + return node.obj + node = node.parent + + def ensure_object(self, object_type): + """Like :meth:`find_object` but sets the innermost object to a + new instance of `object_type` if it does not exist. + """ + rv = self.find_object(object_type) + if rv is None: + self.obj = rv = object_type() + return rv + + def lookup_default(self, name): + """Looks up the default for a parameter name. This by default + looks into the :attr:`default_map` if available. + """ + if self.default_map is not None: + rv = self.default_map.get(name) + if callable(rv): + rv = rv() + return rv + + def fail(self, message): + """Aborts the execution of the program with a specific error + message. + + :param message: the error message to fail with. + """ + raise UsageError(message, self) + + def abort(self): + """Aborts the script.""" + raise Abort() + + def exit(self, code=0): + """Exits the application with a given exit code.""" + raise Exit(code) + + def get_usage(self): + """Helper method to get formatted usage string for the current + context and command. + """ + return self.command.get_usage(self) + + def get_help(self): + """Helper method to get formatted help page for the current + context and command. + """ + return self.command.get_help(self) + + def invoke(*args, **kwargs): # noqa: B902 + """Invokes a command callback in exactly the way it expects. There + are two ways to invoke this method: + + 1. the first argument can be a callback and all other arguments and + keyword arguments are forwarded directly to the function. + 2. the first argument is a click command object. In that case all + arguments are forwarded as well but proper click parameters + (options and click arguments) must be keyword arguments and Click + will fill in defaults. + + Note that before Click 3.2 keyword arguments were not properly filled + in against the intention of this code and no context was created. For + more information about this change and why it was done in a bugfix + release see :ref:`upgrade-to-3.2`. + """ + self, callback = args[:2] + ctx = self + + # It's also possible to invoke another command which might or + # might not have a callback. In that case we also fill + # in defaults and make a new context for this command. + if isinstance(callback, Command): + other_cmd = callback + callback = other_cmd.callback + ctx = Context(other_cmd, info_name=other_cmd.name, parent=self) + if callback is None: + raise TypeError( + "The given command does not have a callback that can be invoked." + ) + + for param in other_cmd.params: + if param.name not in kwargs and param.expose_value: + kwargs[param.name] = param.get_default(ctx) + + args = args[2:] + with augment_usage_errors(self): + with ctx: + return callback(*args, **kwargs) + + def forward(*args, **kwargs): # noqa: B902 + """Similar to :meth:`invoke` but fills in default keyword + arguments from the current context if the other command expects + it. This cannot invoke callbacks directly, only other commands. + """ + self, cmd = args[:2] + + # It's also possible to invoke another command which might or + # might not have a callback. + if not isinstance(cmd, Command): + raise TypeError("Callback is not a command.") + + for param in self.params: + if param not in kwargs: + kwargs[param] = self.params[param] + + return self.invoke(cmd, **kwargs) + + +class BaseCommand(object): + """The base command implements the minimal API contract of commands. + Most code will never use this as it does not implement a lot of useful + functionality but it can act as the direct subclass of alternative + parsing methods that do not depend on the Click parser. + + For instance, this can be used to bridge Click and other systems like + argparse or docopt. + + Because base commands do not implement a lot of the API that other + parts of Click take for granted, they are not supported for all + operations. For instance, they cannot be used with the decorators + usually and they have no built-in callback system. + + .. versionchanged:: 2.0 + Added the `context_settings` parameter. + + :param name: the name of the command to use unless a group overrides it. + :param context_settings: an optional dictionary with defaults that are + passed to the context object. + """ + + #: the default for the :attr:`Context.allow_extra_args` flag. + allow_extra_args = False + #: the default for the :attr:`Context.allow_interspersed_args` flag. + allow_interspersed_args = True + #: the default for the :attr:`Context.ignore_unknown_options` flag. + ignore_unknown_options = False + + def __init__(self, name, context_settings=None): + #: the name the command thinks it has. Upon registering a command + #: on a :class:`Group` the group will default the command name + #: with this information. You should instead use the + #: :class:`Context`\'s :attr:`~Context.info_name` attribute. + self.name = name + if context_settings is None: + context_settings = {} + #: an optional dictionary with defaults passed to the context. + self.context_settings = context_settings + + def __repr__(self): + return "<{} {}>".format(self.__class__.__name__, self.name) + + def get_usage(self, ctx): + raise NotImplementedError("Base commands cannot get usage") + + def get_help(self, ctx): + raise NotImplementedError("Base commands cannot get help") + + def make_context(self, info_name, args, parent=None, **extra): + """This function when given an info name and arguments will kick + off the parsing and create a new :class:`Context`. It does not + invoke the actual command callback though. + + :param info_name: the info name for this invokation. Generally this + is the most descriptive name for the script or + command. For the toplevel script it's usually + the name of the script, for commands below it it's + the name of the script. + :param args: the arguments to parse as list of strings. + :param parent: the parent context if available. + :param extra: extra keyword arguments forwarded to the context + constructor. + """ + for key, value in iteritems(self.context_settings): + if key not in extra: + extra[key] = value + ctx = Context(self, info_name=info_name, parent=parent, **extra) + with ctx.scope(cleanup=False): + self.parse_args(ctx, args) + return ctx + + def parse_args(self, ctx, args): + """Given a context and a list of arguments this creates the parser + and parses the arguments, then modifies the context as necessary. + This is automatically invoked by :meth:`make_context`. + """ + raise NotImplementedError("Base commands do not know how to parse arguments.") + + def invoke(self, ctx): + """Given a context, this invokes the command. The default + implementation is raising a not implemented error. + """ + raise NotImplementedError("Base commands are not invokable by default") + + def main( + self, + args=None, + prog_name=None, + complete_var=None, + standalone_mode=True, + **extra + ): + """This is the way to invoke a script with all the bells and + whistles as a command line application. This will always terminate + the application after a call. If this is not wanted, ``SystemExit`` + needs to be caught. + + This method is also available by directly calling the instance of + a :class:`Command`. + + .. versionadded:: 3.0 + Added the `standalone_mode` flag to control the standalone mode. + + :param args: the arguments that should be used for parsing. If not + provided, ``sys.argv[1:]`` is used. + :param prog_name: the program name that should be used. By default + the program name is constructed by taking the file + name from ``sys.argv[0]``. + :param complete_var: the environment variable that controls the + bash completion support. The default is + ``"__COMPLETE"`` with prog_name in + uppercase. + :param standalone_mode: the default behavior is to invoke the script + in standalone mode. Click will then + handle exceptions and convert them into + error messages and the function will never + return but shut down the interpreter. If + this is set to `False` they will be + propagated to the caller and the return + value of this function is the return value + of :meth:`invoke`. + :param extra: extra keyword arguments are forwarded to the context + constructor. See :class:`Context` for more information. + """ + # If we are in Python 3, we will verify that the environment is + # sane at this point or reject further execution to avoid a + # broken script. + if not PY2: + _verify_python3_env() + else: + _check_for_unicode_literals() + + if args is None: + args = get_os_args() + else: + args = list(args) + + if prog_name is None: + prog_name = make_str( + os.path.basename(sys.argv[0] if sys.argv else __file__) + ) + + # Hook for the Bash completion. This only activates if the Bash + # completion is actually enabled, otherwise this is quite a fast + # noop. + _bashcomplete(self, prog_name, complete_var) + + try: + try: + with self.make_context(prog_name, args, **extra) as ctx: + rv = self.invoke(ctx) + if not standalone_mode: + return rv + # it's not safe to `ctx.exit(rv)` here! + # note that `rv` may actually contain data like "1" which + # has obvious effects + # more subtle case: `rv=[None, None]` can come out of + # chained commands which all returned `None` -- so it's not + # even always obvious that `rv` indicates success/failure + # by its truthiness/falsiness + ctx.exit() + except (EOFError, KeyboardInterrupt): + echo(file=sys.stderr) + raise Abort() + except ClickException as e: + if not standalone_mode: + raise + e.show() + sys.exit(e.exit_code) + except IOError as e: + if e.errno == errno.EPIPE: + sys.stdout = PacifyFlushWrapper(sys.stdout) + sys.stderr = PacifyFlushWrapper(sys.stderr) + sys.exit(1) + else: + raise + except Exit as e: + if standalone_mode: + sys.exit(e.exit_code) + else: + # in non-standalone mode, return the exit code + # note that this is only reached if `self.invoke` above raises + # an Exit explicitly -- thus bypassing the check there which + # would return its result + # the results of non-standalone execution may therefore be + # somewhat ambiguous: if there are codepaths which lead to + # `ctx.exit(1)` and to `return 1`, the caller won't be able to + # tell the difference between the two + return e.exit_code + except Abort: + if not standalone_mode: + raise + echo("Aborted!", file=sys.stderr) + sys.exit(1) + + def __call__(self, *args, **kwargs): + """Alias for :meth:`main`.""" + return self.main(*args, **kwargs) + + +class Command(BaseCommand): + """Commands are the basic building block of command line interfaces in + Click. A basic command handles command line parsing and might dispatch + more parsing to commands nested below it. + + .. versionchanged:: 2.0 + Added the `context_settings` parameter. + .. versionchanged:: 7.1 + Added the `no_args_is_help` parameter. + + :param name: the name of the command to use unless a group overrides it. + :param context_settings: an optional dictionary with defaults that are + passed to the context object. + :param callback: the callback to invoke. This is optional. + :param params: the parameters to register with this command. This can + be either :class:`Option` or :class:`Argument` objects. + :param help: the help string to use for this command. + :param epilog: like the help string but it's printed at the end of the + help page after everything else. + :param short_help: the short help to use for this command. This is + shown on the command listing of the parent command. + :param add_help_option: by default each command registers a ``--help`` + option. This can be disabled by this parameter. + :param no_args_is_help: this controls what happens if no arguments are + provided. This option is disabled by default. + If enabled this will add ``--help`` as argument + if no arguments are passed + :param hidden: hide this command from help outputs. + + :param deprecated: issues a message indicating that + the command is deprecated. + """ + + def __init__( + self, + name, + context_settings=None, + callback=None, + params=None, + help=None, + epilog=None, + short_help=None, + options_metavar="[OPTIONS]", + add_help_option=True, + no_args_is_help=False, + hidden=False, + deprecated=False, + ): + BaseCommand.__init__(self, name, context_settings) + #: the callback to execute when the command fires. This might be + #: `None` in which case nothing happens. + self.callback = callback + #: the list of parameters for this command in the order they + #: should show up in the help page and execute. Eager parameters + #: will automatically be handled before non eager ones. + self.params = params or [] + # if a form feed (page break) is found in the help text, truncate help + # text to the content preceding the first form feed + if help and "\f" in help: + help = help.split("\f", 1)[0] + self.help = help + self.epilog = epilog + self.options_metavar = options_metavar + self.short_help = short_help + self.add_help_option = add_help_option + self.no_args_is_help = no_args_is_help + self.hidden = hidden + self.deprecated = deprecated + + def get_usage(self, ctx): + """Formats the usage line into a string and returns it. + + Calls :meth:`format_usage` internally. + """ + formatter = ctx.make_formatter() + self.format_usage(ctx, formatter) + return formatter.getvalue().rstrip("\n") + + def get_params(self, ctx): + rv = self.params + help_option = self.get_help_option(ctx) + if help_option is not None: + rv = rv + [help_option] + return rv + + def format_usage(self, ctx, formatter): + """Writes the usage line into the formatter. + + This is a low-level method called by :meth:`get_usage`. + """ + pieces = self.collect_usage_pieces(ctx) + formatter.write_usage(ctx.command_path, " ".join(pieces)) + + def collect_usage_pieces(self, ctx): + """Returns all the pieces that go into the usage line and returns + it as a list of strings. + """ + rv = [self.options_metavar] + for param in self.get_params(ctx): + rv.extend(param.get_usage_pieces(ctx)) + return rv + + def get_help_option_names(self, ctx): + """Returns the names for the help option.""" + all_names = set(ctx.help_option_names) + for param in self.params: + all_names.difference_update(param.opts) + all_names.difference_update(param.secondary_opts) + return all_names + + def get_help_option(self, ctx): + """Returns the help option object.""" + help_options = self.get_help_option_names(ctx) + if not help_options or not self.add_help_option: + return + + def show_help(ctx, param, value): + if value and not ctx.resilient_parsing: + echo(ctx.get_help(), color=ctx.color) + ctx.exit() + + return Option( + help_options, + is_flag=True, + is_eager=True, + expose_value=False, + callback=show_help, + help="Show this message and exit.", + ) + + def make_parser(self, ctx): + """Creates the underlying option parser for this command.""" + parser = OptionParser(ctx) + for param in self.get_params(ctx): + param.add_to_parser(parser, ctx) + return parser + + def get_help(self, ctx): + """Formats the help into a string and returns it. + + Calls :meth:`format_help` internally. + """ + formatter = ctx.make_formatter() + self.format_help(ctx, formatter) + return formatter.getvalue().rstrip("\n") + + def get_short_help_str(self, limit=45): + """Gets short help for the command or makes it by shortening the + long help string. + """ + return ( + self.short_help + or self.help + and make_default_short_help(self.help, limit) + or "" + ) + + def format_help(self, ctx, formatter): + """Writes the help into the formatter if it exists. + + This is a low-level method called by :meth:`get_help`. + + This calls the following methods: + + - :meth:`format_usage` + - :meth:`format_help_text` + - :meth:`format_options` + - :meth:`format_epilog` + """ + self.format_usage(ctx, formatter) + self.format_help_text(ctx, formatter) + self.format_options(ctx, formatter) + self.format_epilog(ctx, formatter) + + def format_help_text(self, ctx, formatter): + """Writes the help text to the formatter if it exists.""" + if self.help: + formatter.write_paragraph() + with formatter.indentation(): + help_text = self.help + if self.deprecated: + help_text += DEPRECATED_HELP_NOTICE + formatter.write_text(help_text) + elif self.deprecated: + formatter.write_paragraph() + with formatter.indentation(): + formatter.write_text(DEPRECATED_HELP_NOTICE) + + def format_options(self, ctx, formatter): + """Writes all the options into the formatter if they exist.""" + opts = [] + for param in self.get_params(ctx): + rv = param.get_help_record(ctx) + if rv is not None: + opts.append(rv) + + if opts: + with formatter.section("Options"): + formatter.write_dl(opts) + + def format_epilog(self, ctx, formatter): + """Writes the epilog into the formatter if it exists.""" + if self.epilog: + formatter.write_paragraph() + with formatter.indentation(): + formatter.write_text(self.epilog) + + def parse_args(self, ctx, args): + if not args and self.no_args_is_help and not ctx.resilient_parsing: + echo(ctx.get_help(), color=ctx.color) + ctx.exit() + + parser = self.make_parser(ctx) + opts, args, param_order = parser.parse_args(args=args) + + for param in iter_params_for_processing(param_order, self.get_params(ctx)): + value, args = param.handle_parse_result(ctx, opts, args) + + if args and not ctx.allow_extra_args and not ctx.resilient_parsing: + ctx.fail( + "Got unexpected extra argument{} ({})".format( + "s" if len(args) != 1 else "", " ".join(map(make_str, args)) + ) + ) + + ctx.args = args + return args + + def invoke(self, ctx): + """Given a context, this invokes the attached callback (if it exists) + in the right way. + """ + _maybe_show_deprecated_notice(self) + if self.callback is not None: + return ctx.invoke(self.callback, **ctx.params) + + +class MultiCommand(Command): + """A multi command is the basic implementation of a command that + dispatches to subcommands. The most common version is the + :class:`Group`. + + :param invoke_without_command: this controls how the multi command itself + is invoked. By default it's only invoked + if a subcommand is provided. + :param no_args_is_help: this controls what happens if no arguments are + provided. This option is enabled by default if + `invoke_without_command` is disabled or disabled + if it's enabled. If enabled this will add + ``--help`` as argument if no arguments are + passed. + :param subcommand_metavar: the string that is used in the documentation + to indicate the subcommand place. + :param chain: if this is set to `True` chaining of multiple subcommands + is enabled. This restricts the form of commands in that + they cannot have optional arguments but it allows + multiple commands to be chained together. + :param result_callback: the result callback to attach to this multi + command. + """ + + allow_extra_args = True + allow_interspersed_args = False + + def __init__( + self, + name=None, + invoke_without_command=False, + no_args_is_help=None, + subcommand_metavar=None, + chain=False, + result_callback=None, + **attrs + ): + Command.__init__(self, name, **attrs) + if no_args_is_help is None: + no_args_is_help = not invoke_without_command + self.no_args_is_help = no_args_is_help + self.invoke_without_command = invoke_without_command + if subcommand_metavar is None: + if chain: + subcommand_metavar = SUBCOMMANDS_METAVAR + else: + subcommand_metavar = SUBCOMMAND_METAVAR + self.subcommand_metavar = subcommand_metavar + self.chain = chain + #: The result callback that is stored. This can be set or + #: overridden with the :func:`resultcallback` decorator. + self.result_callback = result_callback + + if self.chain: + for param in self.params: + if isinstance(param, Argument) and not param.required: + raise RuntimeError( + "Multi commands in chain mode cannot have" + " optional arguments." + ) + + def collect_usage_pieces(self, ctx): + rv = Command.collect_usage_pieces(self, ctx) + rv.append(self.subcommand_metavar) + return rv + + def format_options(self, ctx, formatter): + Command.format_options(self, ctx, formatter) + self.format_commands(ctx, formatter) + + def resultcallback(self, replace=False): + """Adds a result callback to the chain command. By default if a + result callback is already registered this will chain them but + this can be disabled with the `replace` parameter. The result + callback is invoked with the return value of the subcommand + (or the list of return values from all subcommands if chaining + is enabled) as well as the parameters as they would be passed + to the main callback. + + Example:: + + @click.group() + @click.option('-i', '--input', default=23) + def cli(input): + return 42 + + @cli.resultcallback() + def process_result(result, input): + return result + input + + .. versionadded:: 3.0 + + :param replace: if set to `True` an already existing result + callback will be removed. + """ + + def decorator(f): + old_callback = self.result_callback + if old_callback is None or replace: + self.result_callback = f + return f + + def function(__value, *args, **kwargs): + return f(old_callback(__value, *args, **kwargs), *args, **kwargs) + + self.result_callback = rv = update_wrapper(function, f) + return rv + + return decorator + + def format_commands(self, ctx, formatter): + """Extra format methods for multi methods that adds all the commands + after the options. + """ + commands = [] + for subcommand in self.list_commands(ctx): + cmd = self.get_command(ctx, subcommand) + # What is this, the tool lied about a command. Ignore it + if cmd is None: + continue + if cmd.hidden: + continue + + commands.append((subcommand, cmd)) + + # allow for 3 times the default spacing + if len(commands): + limit = formatter.width - 6 - max(len(cmd[0]) for cmd in commands) + + rows = [] + for subcommand, cmd in commands: + help = cmd.get_short_help_str(limit) + rows.append((subcommand, help)) + + if rows: + with formatter.section("Commands"): + formatter.write_dl(rows) + + def parse_args(self, ctx, args): + if not args and self.no_args_is_help and not ctx.resilient_parsing: + echo(ctx.get_help(), color=ctx.color) + ctx.exit() + + rest = Command.parse_args(self, ctx, args) + if self.chain: + ctx.protected_args = rest + ctx.args = [] + elif rest: + ctx.protected_args, ctx.args = rest[:1], rest[1:] + + return ctx.args + + def invoke(self, ctx): + def _process_result(value): + if self.result_callback is not None: + value = ctx.invoke(self.result_callback, value, **ctx.params) + return value + + if not ctx.protected_args: + # If we are invoked without command the chain flag controls + # how this happens. If we are not in chain mode, the return + # value here is the return value of the command. + # If however we are in chain mode, the return value is the + # return value of the result processor invoked with an empty + # list (which means that no subcommand actually was executed). + if self.invoke_without_command: + if not self.chain: + return Command.invoke(self, ctx) + with ctx: + Command.invoke(self, ctx) + return _process_result([]) + ctx.fail("Missing command.") + + # Fetch args back out + args = ctx.protected_args + ctx.args + ctx.args = [] + ctx.protected_args = [] + + # If we're not in chain mode, we only allow the invocation of a + # single command but we also inform the current context about the + # name of the command to invoke. + if not self.chain: + # Make sure the context is entered so we do not clean up + # resources until the result processor has worked. + with ctx: + cmd_name, cmd, args = self.resolve_command(ctx, args) + ctx.invoked_subcommand = cmd_name + Command.invoke(self, ctx) + sub_ctx = cmd.make_context(cmd_name, args, parent=ctx) + with sub_ctx: + return _process_result(sub_ctx.command.invoke(sub_ctx)) + + # In chain mode we create the contexts step by step, but after the + # base command has been invoked. Because at that point we do not + # know the subcommands yet, the invoked subcommand attribute is + # set to ``*`` to inform the command that subcommands are executed + # but nothing else. + with ctx: + ctx.invoked_subcommand = "*" if args else None + Command.invoke(self, ctx) + + # Otherwise we make every single context and invoke them in a + # chain. In that case the return value to the result processor + # is the list of all invoked subcommand's results. + contexts = [] + while args: + cmd_name, cmd, args = self.resolve_command(ctx, args) + sub_ctx = cmd.make_context( + cmd_name, + args, + parent=ctx, + allow_extra_args=True, + allow_interspersed_args=False, + ) + contexts.append(sub_ctx) + args, sub_ctx.args = sub_ctx.args, [] + + rv = [] + for sub_ctx in contexts: + with sub_ctx: + rv.append(sub_ctx.command.invoke(sub_ctx)) + return _process_result(rv) + + def resolve_command(self, ctx, args): + cmd_name = make_str(args[0]) + original_cmd_name = cmd_name + + # Get the command + cmd = self.get_command(ctx, cmd_name) + + # If we can't find the command but there is a normalization + # function available, we try with that one. + if cmd is None and ctx.token_normalize_func is not None: + cmd_name = ctx.token_normalize_func(cmd_name) + cmd = self.get_command(ctx, cmd_name) + + # If we don't find the command we want to show an error message + # to the user that it was not provided. However, there is + # something else we should do: if the first argument looks like + # an option we want to kick off parsing again for arguments to + # resolve things like --help which now should go to the main + # place. + if cmd is None and not ctx.resilient_parsing: + if split_opt(cmd_name)[0]: + self.parse_args(ctx, ctx.args) + ctx.fail("No such command '{}'.".format(original_cmd_name)) + + return cmd_name, cmd, args[1:] + + def get_command(self, ctx, cmd_name): + """Given a context and a command name, this returns a + :class:`Command` object if it exists or returns `None`. + """ + raise NotImplementedError() + + def list_commands(self, ctx): + """Returns a list of subcommand names in the order they should + appear. + """ + return [] + + +class Group(MultiCommand): + """A group allows a command to have subcommands attached. This is the + most common way to implement nesting in Click. + + :param commands: a dictionary of commands. + """ + + def __init__(self, name=None, commands=None, **attrs): + MultiCommand.__init__(self, name, **attrs) + #: the registered subcommands by their exported names. + self.commands = commands or {} + + def add_command(self, cmd, name=None): + """Registers another :class:`Command` with this group. If the name + is not provided, the name of the command is used. + """ + name = name or cmd.name + if name is None: + raise TypeError("Command has no name.") + _check_multicommand(self, name, cmd, register=True) + self.commands[name] = cmd + + def command(self, *args, **kwargs): + """A shortcut decorator for declaring and attaching a command to + the group. This takes the same arguments as :func:`command` but + immediately registers the created command with this instance by + calling into :meth:`add_command`. + """ + from .decorators import command + + def decorator(f): + cmd = command(*args, **kwargs)(f) + self.add_command(cmd) + return cmd + + return decorator + + def group(self, *args, **kwargs): + """A shortcut decorator for declaring and attaching a group to + the group. This takes the same arguments as :func:`group` but + immediately registers the created command with this instance by + calling into :meth:`add_command`. + """ + from .decorators import group + + def decorator(f): + cmd = group(*args, **kwargs)(f) + self.add_command(cmd) + return cmd + + return decorator + + def get_command(self, ctx, cmd_name): + return self.commands.get(cmd_name) + + def list_commands(self, ctx): + return sorted(self.commands) + + +class CommandCollection(MultiCommand): + """A command collection is a multi command that merges multiple multi + commands together into one. This is a straightforward implementation + that accepts a list of different multi commands as sources and + provides all the commands for each of them. + """ + + def __init__(self, name=None, sources=None, **attrs): + MultiCommand.__init__(self, name, **attrs) + #: The list of registered multi commands. + self.sources = sources or [] + + def add_source(self, multi_cmd): + """Adds a new multi command to the chain dispatcher.""" + self.sources.append(multi_cmd) + + def get_command(self, ctx, cmd_name): + for source in self.sources: + rv = source.get_command(ctx, cmd_name) + if rv is not None: + if self.chain: + _check_multicommand(self, cmd_name, rv) + return rv + + def list_commands(self, ctx): + rv = set() + for source in self.sources: + rv.update(source.list_commands(ctx)) + return sorted(rv) + + +class Parameter(object): + r"""A parameter to a command comes in two versions: they are either + :class:`Option`\s or :class:`Argument`\s. Other subclasses are currently + not supported by design as some of the internals for parsing are + intentionally not finalized. + + Some settings are supported by both options and arguments. + + :param param_decls: the parameter declarations for this option or + argument. This is a list of flags or argument + names. + :param type: the type that should be used. Either a :class:`ParamType` + or a Python type. The later is converted into the former + automatically if supported. + :param required: controls if this is optional or not. + :param default: the default value if omitted. This can also be a callable, + in which case it's invoked when the default is needed + without any arguments. + :param callback: a callback that should be executed after the parameter + was matched. This is called as ``fn(ctx, param, + value)`` and needs to return the value. + :param nargs: the number of arguments to match. If not ``1`` the return + value is a tuple instead of single value. The default for + nargs is ``1`` (except if the type is a tuple, then it's + the arity of the tuple). + :param metavar: how the value is represented in the help page. + :param expose_value: if this is `True` then the value is passed onwards + to the command callback and stored on the context, + otherwise it's skipped. + :param is_eager: eager values are processed before non eager ones. This + should not be set for arguments or it will inverse the + order of processing. + :param envvar: a string or list of strings that are environment variables + that should be checked. + + .. versionchanged:: 7.1 + Empty environment variables are ignored rather than taking the + empty string value. This makes it possible for scripts to clear + variables if they can't unset them. + + .. versionchanged:: 2.0 + Changed signature for parameter callback to also be passed the + parameter. The old callback format will still work, but it will + raise a warning to give you a chance to migrate the code easier. + """ + param_type_name = "parameter" + + def __init__( + self, + param_decls=None, + type=None, + required=False, + default=None, + callback=None, + nargs=None, + metavar=None, + expose_value=True, + is_eager=False, + envvar=None, + autocompletion=None, + ): + self.name, self.opts, self.secondary_opts = self._parse_decls( + param_decls or (), expose_value + ) + + self.type = convert_type(type, default) + + # Default nargs to what the type tells us if we have that + # information available. + if nargs is None: + if self.type.is_composite: + nargs = self.type.arity + else: + nargs = 1 + + self.required = required + self.callback = callback + self.nargs = nargs + self.multiple = False + self.expose_value = expose_value + self.default = default + self.is_eager = is_eager + self.metavar = metavar + self.envvar = envvar + self.autocompletion = autocompletion + + def __repr__(self): + return "<{} {}>".format(self.__class__.__name__, self.name) + + @property + def human_readable_name(self): + """Returns the human readable name of this parameter. This is the + same as the name for options, but the metavar for arguments. + """ + return self.name + + def make_metavar(self): + if self.metavar is not None: + return self.metavar + metavar = self.type.get_metavar(self) + if metavar is None: + metavar = self.type.name.upper() + if self.nargs != 1: + metavar += "..." + return metavar + + def get_default(self, ctx): + """Given a context variable this calculates the default value.""" + # Otherwise go with the regular default. + if callable(self.default): + rv = self.default() + else: + rv = self.default + return self.type_cast_value(ctx, rv) + + def add_to_parser(self, parser, ctx): + pass + + def consume_value(self, ctx, opts): + value = opts.get(self.name) + if value is None: + value = self.value_from_envvar(ctx) + if value is None: + value = ctx.lookup_default(self.name) + return value + + def type_cast_value(self, ctx, value): + """Given a value this runs it properly through the type system. + This automatically handles things like `nargs` and `multiple` as + well as composite types. + """ + if self.type.is_composite: + if self.nargs <= 1: + raise TypeError( + "Attempted to invoke composite type but nargs has" + " been set to {}. This is not supported; nargs" + " needs to be set to a fixed value > 1.".format(self.nargs) + ) + if self.multiple: + return tuple(self.type(x or (), self, ctx) for x in value or ()) + return self.type(value or (), self, ctx) + + def _convert(value, level): + if level == 0: + return self.type(value, self, ctx) + return tuple(_convert(x, level - 1) for x in value or ()) + + return _convert(value, (self.nargs != 1) + bool(self.multiple)) + + def process_value(self, ctx, value): + """Given a value and context this runs the logic to convert the + value as necessary. + """ + # If the value we were given is None we do nothing. This way + # code that calls this can easily figure out if something was + # not provided. Otherwise it would be converted into an empty + # tuple for multiple invocations which is inconvenient. + if value is not None: + return self.type_cast_value(ctx, value) + + def value_is_missing(self, value): + if value is None: + return True + if (self.nargs != 1 or self.multiple) and value == (): + return True + return False + + def full_process_value(self, ctx, value): + value = self.process_value(ctx, value) + + if value is None and not ctx.resilient_parsing: + value = self.get_default(ctx) + + if self.required and self.value_is_missing(value): + raise MissingParameter(ctx=ctx, param=self) + + return value + + def resolve_envvar_value(self, ctx): + if self.envvar is None: + return + if isinstance(self.envvar, (tuple, list)): + for envvar in self.envvar: + rv = os.environ.get(envvar) + if rv is not None: + return rv + else: + rv = os.environ.get(self.envvar) + + if rv != "": + return rv + + def value_from_envvar(self, ctx): + rv = self.resolve_envvar_value(ctx) + if rv is not None and self.nargs != 1: + rv = self.type.split_envvar_value(rv) + return rv + + def handle_parse_result(self, ctx, opts, args): + with augment_usage_errors(ctx, param=self): + value = self.consume_value(ctx, opts) + try: + value = self.full_process_value(ctx, value) + except Exception: + if not ctx.resilient_parsing: + raise + value = None + if self.callback is not None: + try: + value = invoke_param_callback(self.callback, ctx, self, value) + except Exception: + if not ctx.resilient_parsing: + raise + + if self.expose_value: + ctx.params[self.name] = value + return value, args + + def get_help_record(self, ctx): + pass + + def get_usage_pieces(self, ctx): + return [] + + def get_error_hint(self, ctx): + """Get a stringified version of the param for use in error messages to + indicate which param caused the error. + """ + hint_list = self.opts or [self.human_readable_name] + return " / ".join(repr(x) for x in hint_list) + + +class Option(Parameter): + """Options are usually optional values on the command line and + have some extra features that arguments don't have. + + All other parameters are passed onwards to the parameter constructor. + + :param show_default: controls if the default value should be shown on the + help page. Normally, defaults are not shown. If this + value is a string, it shows the string instead of the + value. This is particularly useful for dynamic options. + :param show_envvar: controls if an environment variable should be shown on + the help page. Normally, environment variables + are not shown. + :param prompt: if set to `True` or a non empty string then the user will be + prompted for input. If set to `True` the prompt will be the + option name capitalized. + :param confirmation_prompt: if set then the value will need to be confirmed + if it was prompted for. + :param hide_input: if this is `True` then the input on the prompt will be + hidden from the user. This is useful for password + input. + :param is_flag: forces this option to act as a flag. The default is + auto detection. + :param flag_value: which value should be used for this flag if it's + enabled. This is set to a boolean automatically if + the option string contains a slash to mark two options. + :param multiple: if this is set to `True` then the argument is accepted + multiple times and recorded. This is similar to ``nargs`` + in how it works but supports arbitrary number of + arguments. + :param count: this flag makes an option increment an integer. + :param allow_from_autoenv: if this is enabled then the value of this + parameter will be pulled from an environment + variable in case a prefix is defined on the + context. + :param help: the help string. + :param hidden: hide this option from help outputs. + """ + + param_type_name = "option" + + def __init__( + self, + param_decls=None, + show_default=False, + prompt=False, + confirmation_prompt=False, + hide_input=False, + is_flag=None, + flag_value=None, + multiple=False, + count=False, + allow_from_autoenv=True, + type=None, + help=None, + hidden=False, + show_choices=True, + show_envvar=False, + **attrs + ): + default_is_missing = attrs.get("default", _missing) is _missing + Parameter.__init__(self, param_decls, type=type, **attrs) + + if prompt is True: + prompt_text = self.name.replace("_", " ").capitalize() + elif prompt is False: + prompt_text = None + else: + prompt_text = prompt + self.prompt = prompt_text + self.confirmation_prompt = confirmation_prompt + self.hide_input = hide_input + self.hidden = hidden + + # Flags + if is_flag is None: + if flag_value is not None: + is_flag = True + else: + is_flag = bool(self.secondary_opts) + if is_flag and default_is_missing: + self.default = False + if flag_value is None: + flag_value = not self.default + self.is_flag = is_flag + self.flag_value = flag_value + if self.is_flag and isinstance(self.flag_value, bool) and type in [None, bool]: + self.type = BOOL + self.is_bool_flag = True + else: + self.is_bool_flag = False + + # Counting + self.count = count + if count: + if type is None: + self.type = IntRange(min=0) + if default_is_missing: + self.default = 0 + + self.multiple = multiple + self.allow_from_autoenv = allow_from_autoenv + self.help = help + self.show_default = show_default + self.show_choices = show_choices + self.show_envvar = show_envvar + + # Sanity check for stuff we don't support + if __debug__: + if self.nargs < 0: + raise TypeError("Options cannot have nargs < 0") + if self.prompt and self.is_flag and not self.is_bool_flag: + raise TypeError("Cannot prompt for flags that are not bools.") + if not self.is_bool_flag and self.secondary_opts: + raise TypeError("Got secondary option for non boolean flag.") + if self.is_bool_flag and self.hide_input and self.prompt is not None: + raise TypeError("Hidden input does not work with boolean flag prompts.") + if self.count: + if self.multiple: + raise TypeError( + "Options cannot be multiple and count at the same time." + ) + elif self.is_flag: + raise TypeError( + "Options cannot be count and flags at the same time." + ) + + def _parse_decls(self, decls, expose_value): + opts = [] + secondary_opts = [] + name = None + possible_names = [] + + for decl in decls: + if isidentifier(decl): + if name is not None: + raise TypeError("Name defined twice") + name = decl + else: + split_char = ";" if decl[:1] == "/" else "/" + if split_char in decl: + first, second = decl.split(split_char, 1) + first = first.rstrip() + if first: + possible_names.append(split_opt(first)) + opts.append(first) + second = second.lstrip() + if second: + secondary_opts.append(second.lstrip()) + else: + possible_names.append(split_opt(decl)) + opts.append(decl) + + if name is None and possible_names: + possible_names.sort(key=lambda x: -len(x[0])) # group long options first + name = possible_names[0][1].replace("-", "_").lower() + if not isidentifier(name): + name = None + + if name is None: + if not expose_value: + return None, opts, secondary_opts + raise TypeError("Could not determine name for option") + + if not opts and not secondary_opts: + raise TypeError( + "No options defined but a name was passed ({}). Did you" + " mean to declare an argument instead of an option?".format(name) + ) + + return name, opts, secondary_opts + + def add_to_parser(self, parser, ctx): + kwargs = { + "dest": self.name, + "nargs": self.nargs, + "obj": self, + } + + if self.multiple: + action = "append" + elif self.count: + action = "count" + else: + action = "store" + + if self.is_flag: + kwargs.pop("nargs", None) + action_const = "{}_const".format(action) + if self.is_bool_flag and self.secondary_opts: + parser.add_option(self.opts, action=action_const, const=True, **kwargs) + parser.add_option( + self.secondary_opts, action=action_const, const=False, **kwargs + ) + else: + parser.add_option( + self.opts, action=action_const, const=self.flag_value, **kwargs + ) + else: + kwargs["action"] = action + parser.add_option(self.opts, **kwargs) + + def get_help_record(self, ctx): + if self.hidden: + return + any_prefix_is_slash = [] + + def _write_opts(opts): + rv, any_slashes = join_options(opts) + if any_slashes: + any_prefix_is_slash[:] = [True] + if not self.is_flag and not self.count: + rv += " {}".format(self.make_metavar()) + return rv + + rv = [_write_opts(self.opts)] + if self.secondary_opts: + rv.append(_write_opts(self.secondary_opts)) + + help = self.help or "" + extra = [] + if self.show_envvar: + envvar = self.envvar + if envvar is None: + if self.allow_from_autoenv and ctx.auto_envvar_prefix is not None: + envvar = "{}_{}".format(ctx.auto_envvar_prefix, self.name.upper()) + if envvar is not None: + extra.append( + "env var: {}".format( + ", ".join(str(d) for d in envvar) + if isinstance(envvar, (list, tuple)) + else envvar + ) + ) + if self.default is not None and (self.show_default or ctx.show_default): + if isinstance(self.show_default, string_types): + default_string = "({})".format(self.show_default) + elif isinstance(self.default, (list, tuple)): + default_string = ", ".join(str(d) for d in self.default) + elif inspect.isfunction(self.default): + default_string = "(dynamic)" + else: + default_string = self.default + extra.append("default: {}".format(default_string)) + + if self.required: + extra.append("required") + if extra: + help = "{}[{}]".format( + "{} ".format(help) if help else "", "; ".join(extra) + ) + + return ("; " if any_prefix_is_slash else " / ").join(rv), help + + def get_default(self, ctx): + # If we're a non boolean flag our default is more complex because + # we need to look at all flags in the same group to figure out + # if we're the the default one in which case we return the flag + # value as default. + if self.is_flag and not self.is_bool_flag: + for param in ctx.command.params: + if param.name == self.name and param.default: + return param.flag_value + return None + return Parameter.get_default(self, ctx) + + def prompt_for_value(self, ctx): + """This is an alternative flow that can be activated in the full + value processing if a value does not exist. It will prompt the + user until a valid value exists and then returns the processed + value as result. + """ + # Calculate the default before prompting anything to be stable. + default = self.get_default(ctx) + + # If this is a prompt for a flag we need to handle this + # differently. + if self.is_bool_flag: + return confirm(self.prompt, default) + + return prompt( + self.prompt, + default=default, + type=self.type, + hide_input=self.hide_input, + show_choices=self.show_choices, + confirmation_prompt=self.confirmation_prompt, + value_proc=lambda x: self.process_value(ctx, x), + ) + + def resolve_envvar_value(self, ctx): + rv = Parameter.resolve_envvar_value(self, ctx) + if rv is not None: + return rv + if self.allow_from_autoenv and ctx.auto_envvar_prefix is not None: + envvar = "{}_{}".format(ctx.auto_envvar_prefix, self.name.upper()) + return os.environ.get(envvar) + + def value_from_envvar(self, ctx): + rv = self.resolve_envvar_value(ctx) + if rv is None: + return None + value_depth = (self.nargs != 1) + bool(self.multiple) + if value_depth > 0 and rv is not None: + rv = self.type.split_envvar_value(rv) + if self.multiple and self.nargs != 1: + rv = batch(rv, self.nargs) + return rv + + def full_process_value(self, ctx, value): + if value is None and self.prompt is not None and not ctx.resilient_parsing: + return self.prompt_for_value(ctx) + return Parameter.full_process_value(self, ctx, value) + + +class Argument(Parameter): + """Arguments are positional parameters to a command. They generally + provide fewer features than options but can have infinite ``nargs`` + and are required by default. + + All parameters are passed onwards to the parameter constructor. + """ + + param_type_name = "argument" + + def __init__(self, param_decls, required=None, **attrs): + if required is None: + if attrs.get("default") is not None: + required = False + else: + required = attrs.get("nargs", 1) > 0 + Parameter.__init__(self, param_decls, required=required, **attrs) + if self.default is not None and self.nargs < 0: + raise TypeError( + "nargs=-1 in combination with a default value is not supported." + ) + + @property + def human_readable_name(self): + if self.metavar is not None: + return self.metavar + return self.name.upper() + + def make_metavar(self): + if self.metavar is not None: + return self.metavar + var = self.type.get_metavar(self) + if not var: + var = self.name.upper() + if not self.required: + var = "[{}]".format(var) + if self.nargs != 1: + var += "..." + return var + + def _parse_decls(self, decls, expose_value): + if not decls: + if not expose_value: + return None, [], [] + raise TypeError("Could not determine name for argument") + if len(decls) == 1: + name = arg = decls[0] + name = name.replace("-", "_").lower() + else: + raise TypeError( + "Arguments take exactly one parameter declaration, got" + " {}".format(len(decls)) + ) + return name, [arg], [] + + def get_usage_pieces(self, ctx): + return [self.make_metavar()] + + def get_error_hint(self, ctx): + return repr(self.make_metavar()) + + def add_to_parser(self, parser, ctx): + parser.add_argument(dest=self.name, nargs=self.nargs, obj=self) diff --git a/openpype/vendor/python/python_2/click/decorators.py b/openpype/vendor/python/python_2/click/decorators.py new file mode 100644 index 0000000000..c7b5af6cc5 --- /dev/null +++ b/openpype/vendor/python/python_2/click/decorators.py @@ -0,0 +1,333 @@ +import inspect +import sys +from functools import update_wrapper + +from ._compat import iteritems +from ._unicodefun import _check_for_unicode_literals +from .core import Argument +from .core import Command +from .core import Group +from .core import Option +from .globals import get_current_context +from .utils import echo + + +def pass_context(f): + """Marks a callback as wanting to receive the current context + object as first argument. + """ + + def new_func(*args, **kwargs): + return f(get_current_context(), *args, **kwargs) + + return update_wrapper(new_func, f) + + +def pass_obj(f): + """Similar to :func:`pass_context`, but only pass the object on the + context onwards (:attr:`Context.obj`). This is useful if that object + represents the state of a nested system. + """ + + def new_func(*args, **kwargs): + return f(get_current_context().obj, *args, **kwargs) + + return update_wrapper(new_func, f) + + +def make_pass_decorator(object_type, ensure=False): + """Given an object type this creates a decorator that will work + similar to :func:`pass_obj` but instead of passing the object of the + current context, it will find the innermost context of type + :func:`object_type`. + + This generates a decorator that works roughly like this:: + + from functools import update_wrapper + + def decorator(f): + @pass_context + def new_func(ctx, *args, **kwargs): + obj = ctx.find_object(object_type) + return ctx.invoke(f, obj, *args, **kwargs) + return update_wrapper(new_func, f) + return decorator + + :param object_type: the type of the object to pass. + :param ensure: if set to `True`, a new object will be created and + remembered on the context if it's not there yet. + """ + + def decorator(f): + def new_func(*args, **kwargs): + ctx = get_current_context() + if ensure: + obj = ctx.ensure_object(object_type) + else: + obj = ctx.find_object(object_type) + if obj is None: + raise RuntimeError( + "Managed to invoke callback without a context" + " object of type '{}' existing".format(object_type.__name__) + ) + return ctx.invoke(f, obj, *args, **kwargs) + + return update_wrapper(new_func, f) + + return decorator + + +def _make_command(f, name, attrs, cls): + if isinstance(f, Command): + raise TypeError("Attempted to convert a callback into a command twice.") + try: + params = f.__click_params__ + params.reverse() + del f.__click_params__ + except AttributeError: + params = [] + help = attrs.get("help") + if help is None: + help = inspect.getdoc(f) + if isinstance(help, bytes): + help = help.decode("utf-8") + else: + help = inspect.cleandoc(help) + attrs["help"] = help + _check_for_unicode_literals() + return cls( + name=name or f.__name__.lower().replace("_", "-"), + callback=f, + params=params, + **attrs + ) + + +def command(name=None, cls=None, **attrs): + r"""Creates a new :class:`Command` and uses the decorated function as + callback. This will also automatically attach all decorated + :func:`option`\s and :func:`argument`\s as parameters to the command. + + The name of the command defaults to the name of the function with + underscores replaced by dashes. If you want to change that, you can + pass the intended name as the first argument. + + All keyword arguments are forwarded to the underlying command class. + + Once decorated the function turns into a :class:`Command` instance + that can be invoked as a command line utility or be attached to a + command :class:`Group`. + + :param name: the name of the command. This defaults to the function + name with underscores replaced by dashes. + :param cls: the command class to instantiate. This defaults to + :class:`Command`. + """ + if cls is None: + cls = Command + + def decorator(f): + cmd = _make_command(f, name, attrs, cls) + cmd.__doc__ = f.__doc__ + return cmd + + return decorator + + +def group(name=None, **attrs): + """Creates a new :class:`Group` with a function as callback. This + works otherwise the same as :func:`command` just that the `cls` + parameter is set to :class:`Group`. + """ + attrs.setdefault("cls", Group) + return command(name, **attrs) + + +def _param_memo(f, param): + if isinstance(f, Command): + f.params.append(param) + else: + if not hasattr(f, "__click_params__"): + f.__click_params__ = [] + f.__click_params__.append(param) + + +def argument(*param_decls, **attrs): + """Attaches an argument to the command. All positional arguments are + passed as parameter declarations to :class:`Argument`; all keyword + arguments are forwarded unchanged (except ``cls``). + This is equivalent to creating an :class:`Argument` instance manually + and attaching it to the :attr:`Command.params` list. + + :param cls: the argument class to instantiate. This defaults to + :class:`Argument`. + """ + + def decorator(f): + ArgumentClass = attrs.pop("cls", Argument) + _param_memo(f, ArgumentClass(param_decls, **attrs)) + return f + + return decorator + + +def option(*param_decls, **attrs): + """Attaches an option to the command. All positional arguments are + passed as parameter declarations to :class:`Option`; all keyword + arguments are forwarded unchanged (except ``cls``). + This is equivalent to creating an :class:`Option` instance manually + and attaching it to the :attr:`Command.params` list. + + :param cls: the option class to instantiate. This defaults to + :class:`Option`. + """ + + def decorator(f): + # Issue 926, copy attrs, so pre-defined options can re-use the same cls= + option_attrs = attrs.copy() + + if "help" in option_attrs: + option_attrs["help"] = inspect.cleandoc(option_attrs["help"]) + OptionClass = option_attrs.pop("cls", Option) + _param_memo(f, OptionClass(param_decls, **option_attrs)) + return f + + return decorator + + +def confirmation_option(*param_decls, **attrs): + """Shortcut for confirmation prompts that can be ignored by passing + ``--yes`` as parameter. + + This is equivalent to decorating a function with :func:`option` with + the following parameters:: + + def callback(ctx, param, value): + if not value: + ctx.abort() + + @click.command() + @click.option('--yes', is_flag=True, callback=callback, + expose_value=False, prompt='Do you want to continue?') + def dropdb(): + pass + """ + + def decorator(f): + def callback(ctx, param, value): + if not value: + ctx.abort() + + attrs.setdefault("is_flag", True) + attrs.setdefault("callback", callback) + attrs.setdefault("expose_value", False) + attrs.setdefault("prompt", "Do you want to continue?") + attrs.setdefault("help", "Confirm the action without prompting.") + return option(*(param_decls or ("--yes",)), **attrs)(f) + + return decorator + + +def password_option(*param_decls, **attrs): + """Shortcut for password prompts. + + This is equivalent to decorating a function with :func:`option` with + the following parameters:: + + @click.command() + @click.option('--password', prompt=True, confirmation_prompt=True, + hide_input=True) + def changeadmin(password): + pass + """ + + def decorator(f): + attrs.setdefault("prompt", True) + attrs.setdefault("confirmation_prompt", True) + attrs.setdefault("hide_input", True) + return option(*(param_decls or ("--password",)), **attrs)(f) + + return decorator + + +def version_option(version=None, *param_decls, **attrs): + """Adds a ``--version`` option which immediately ends the program + printing out the version number. This is implemented as an eager + option that prints the version and exits the program in the callback. + + :param version: the version number to show. If not provided Click + attempts an auto discovery via setuptools. + :param prog_name: the name of the program (defaults to autodetection) + :param message: custom message to show instead of the default + (``'%(prog)s, version %(version)s'``) + :param others: everything else is forwarded to :func:`option`. + """ + if version is None: + if hasattr(sys, "_getframe"): + module = sys._getframe(1).f_globals.get("__name__") + else: + module = "" + + def decorator(f): + prog_name = attrs.pop("prog_name", None) + message = attrs.pop("message", "%(prog)s, version %(version)s") + + def callback(ctx, param, value): + if not value or ctx.resilient_parsing: + return + prog = prog_name + if prog is None: + prog = ctx.find_root().info_name + ver = version + if ver is None: + try: + import pkg_resources + except ImportError: + pass + else: + for dist in pkg_resources.working_set: + scripts = dist.get_entry_map().get("console_scripts") or {} + for _, entry_point in iteritems(scripts): + if entry_point.module_name == module: + ver = dist.version + break + if ver is None: + raise RuntimeError("Could not determine version") + echo(message % {"prog": prog, "version": ver}, color=ctx.color) + ctx.exit() + + attrs.setdefault("is_flag", True) + attrs.setdefault("expose_value", False) + attrs.setdefault("is_eager", True) + attrs.setdefault("help", "Show the version and exit.") + attrs["callback"] = callback + return option(*(param_decls or ("--version",)), **attrs)(f) + + return decorator + + +def help_option(*param_decls, **attrs): + """Adds a ``--help`` option which immediately ends the program + printing out the help page. This is usually unnecessary to add as + this is added by default to all commands unless suppressed. + + Like :func:`version_option`, this is implemented as eager option that + prints in the callback and exits. + + All arguments are forwarded to :func:`option`. + """ + + def decorator(f): + def callback(ctx, param, value): + if value and not ctx.resilient_parsing: + echo(ctx.get_help(), color=ctx.color) + ctx.exit() + + attrs.setdefault("is_flag", True) + attrs.setdefault("expose_value", False) + attrs.setdefault("help", "Show this message and exit.") + attrs.setdefault("is_eager", True) + attrs["callback"] = callback + return option(*(param_decls or ("--help",)), **attrs)(f) + + return decorator diff --git a/openpype/vendor/python/python_2/click/exceptions.py b/openpype/vendor/python/python_2/click/exceptions.py new file mode 100644 index 0000000000..592ee38f0d --- /dev/null +++ b/openpype/vendor/python/python_2/click/exceptions.py @@ -0,0 +1,253 @@ +from ._compat import filename_to_ui +from ._compat import get_text_stderr +from ._compat import PY2 +from .utils import echo + + +def _join_param_hints(param_hint): + if isinstance(param_hint, (tuple, list)): + return " / ".join(repr(x) for x in param_hint) + return param_hint + + +class ClickException(Exception): + """An exception that Click can handle and show to the user.""" + + #: The exit code for this exception + exit_code = 1 + + def __init__(self, message): + ctor_msg = message + if PY2: + if ctor_msg is not None: + ctor_msg = ctor_msg.encode("utf-8") + Exception.__init__(self, ctor_msg) + self.message = message + + def format_message(self): + return self.message + + def __str__(self): + return self.message + + if PY2: + __unicode__ = __str__ + + def __str__(self): + return self.message.encode("utf-8") + + def show(self, file=None): + if file is None: + file = get_text_stderr() + echo("Error: {}".format(self.format_message()), file=file) + + +class UsageError(ClickException): + """An internal exception that signals a usage error. This typically + aborts any further handling. + + :param message: the error message to display. + :param ctx: optionally the context that caused this error. Click will + fill in the context automatically in some situations. + """ + + exit_code = 2 + + def __init__(self, message, ctx=None): + ClickException.__init__(self, message) + self.ctx = ctx + self.cmd = self.ctx.command if self.ctx else None + + def show(self, file=None): + if file is None: + file = get_text_stderr() + color = None + hint = "" + if self.cmd is not None and self.cmd.get_help_option(self.ctx) is not None: + hint = "Try '{} {}' for help.\n".format( + self.ctx.command_path, self.ctx.help_option_names[0] + ) + if self.ctx is not None: + color = self.ctx.color + echo("{}\n{}".format(self.ctx.get_usage(), hint), file=file, color=color) + echo("Error: {}".format(self.format_message()), file=file, color=color) + + +class BadParameter(UsageError): + """An exception that formats out a standardized error message for a + bad parameter. This is useful when thrown from a callback or type as + Click will attach contextual information to it (for instance, which + parameter it is). + + .. versionadded:: 2.0 + + :param param: the parameter object that caused this error. This can + be left out, and Click will attach this info itself + if possible. + :param param_hint: a string that shows up as parameter name. This + can be used as alternative to `param` in cases + where custom validation should happen. If it is + a string it's used as such, if it's a list then + each item is quoted and separated. + """ + + def __init__(self, message, ctx=None, param=None, param_hint=None): + UsageError.__init__(self, message, ctx) + self.param = param + self.param_hint = param_hint + + def format_message(self): + if self.param_hint is not None: + param_hint = self.param_hint + elif self.param is not None: + param_hint = self.param.get_error_hint(self.ctx) + else: + return "Invalid value: {}".format(self.message) + param_hint = _join_param_hints(param_hint) + + return "Invalid value for {}: {}".format(param_hint, self.message) + + +class MissingParameter(BadParameter): + """Raised if click required an option or argument but it was not + provided when invoking the script. + + .. versionadded:: 4.0 + + :param param_type: a string that indicates the type of the parameter. + The default is to inherit the parameter type from + the given `param`. Valid values are ``'parameter'``, + ``'option'`` or ``'argument'``. + """ + + def __init__( + self, message=None, ctx=None, param=None, param_hint=None, param_type=None + ): + BadParameter.__init__(self, message, ctx, param, param_hint) + self.param_type = param_type + + def format_message(self): + if self.param_hint is not None: + param_hint = self.param_hint + elif self.param is not None: + param_hint = self.param.get_error_hint(self.ctx) + else: + param_hint = None + param_hint = _join_param_hints(param_hint) + + param_type = self.param_type + if param_type is None and self.param is not None: + param_type = self.param.param_type_name + + msg = self.message + if self.param is not None: + msg_extra = self.param.type.get_missing_message(self.param) + if msg_extra: + if msg: + msg += ". {}".format(msg_extra) + else: + msg = msg_extra + + return "Missing {}{}{}{}".format( + param_type, + " {}".format(param_hint) if param_hint else "", + ". " if msg else ".", + msg or "", + ) + + def __str__(self): + if self.message is None: + param_name = self.param.name if self.param else None + return "missing parameter: {}".format(param_name) + else: + return self.message + + if PY2: + __unicode__ = __str__ + + def __str__(self): + return self.__unicode__().encode("utf-8") + + +class NoSuchOption(UsageError): + """Raised if click attempted to handle an option that does not + exist. + + .. versionadded:: 4.0 + """ + + def __init__(self, option_name, message=None, possibilities=None, ctx=None): + if message is None: + message = "no such option: {}".format(option_name) + UsageError.__init__(self, message, ctx) + self.option_name = option_name + self.possibilities = possibilities + + def format_message(self): + bits = [self.message] + if self.possibilities: + if len(self.possibilities) == 1: + bits.append("Did you mean {}?".format(self.possibilities[0])) + else: + possibilities = sorted(self.possibilities) + bits.append("(Possible options: {})".format(", ".join(possibilities))) + return " ".join(bits) + + +class BadOptionUsage(UsageError): + """Raised if an option is generally supplied but the use of the option + was incorrect. This is for instance raised if the number of arguments + for an option is not correct. + + .. versionadded:: 4.0 + + :param option_name: the name of the option being used incorrectly. + """ + + def __init__(self, option_name, message, ctx=None): + UsageError.__init__(self, message, ctx) + self.option_name = option_name + + +class BadArgumentUsage(UsageError): + """Raised if an argument is generally supplied but the use of the argument + was incorrect. This is for instance raised if the number of values + for an argument is not correct. + + .. versionadded:: 6.0 + """ + + def __init__(self, message, ctx=None): + UsageError.__init__(self, message, ctx) + + +class FileError(ClickException): + """Raised if a file cannot be opened.""" + + def __init__(self, filename, hint=None): + ui_filename = filename_to_ui(filename) + if hint is None: + hint = "unknown error" + ClickException.__init__(self, hint) + self.ui_filename = ui_filename + self.filename = filename + + def format_message(self): + return "Could not open file {}: {}".format(self.ui_filename, self.message) + + +class Abort(RuntimeError): + """An internal signalling exception that signals Click to abort.""" + + +class Exit(RuntimeError): + """An exception that indicates that the application should exit with some + status code. + + :param code: the status code to exit with. + """ + + __slots__ = ("exit_code",) + + def __init__(self, code=0): + self.exit_code = code diff --git a/openpype/vendor/python/python_2/click/formatting.py b/openpype/vendor/python/python_2/click/formatting.py new file mode 100644 index 0000000000..319c7f6163 --- /dev/null +++ b/openpype/vendor/python/python_2/click/formatting.py @@ -0,0 +1,283 @@ +from contextlib import contextmanager + +from ._compat import term_len +from .parser import split_opt +from .termui import get_terminal_size + +# Can force a width. This is used by the test system +FORCED_WIDTH = None + + +def measure_table(rows): + widths = {} + for row in rows: + for idx, col in enumerate(row): + widths[idx] = max(widths.get(idx, 0), term_len(col)) + return tuple(y for x, y in sorted(widths.items())) + + +def iter_rows(rows, col_count): + for row in rows: + row = tuple(row) + yield row + ("",) * (col_count - len(row)) + + +def wrap_text( + text, width=78, initial_indent="", subsequent_indent="", preserve_paragraphs=False +): + """A helper function that intelligently wraps text. By default, it + assumes that it operates on a single paragraph of text but if the + `preserve_paragraphs` parameter is provided it will intelligently + handle paragraphs (defined by two empty lines). + + If paragraphs are handled, a paragraph can be prefixed with an empty + line containing the ``\\b`` character (``\\x08``) to indicate that + no rewrapping should happen in that block. + + :param text: the text that should be rewrapped. + :param width: the maximum width for the text. + :param initial_indent: the initial indent that should be placed on the + first line as a string. + :param subsequent_indent: the indent string that should be placed on + each consecutive line. + :param preserve_paragraphs: if this flag is set then the wrapping will + intelligently handle paragraphs. + """ + from ._textwrap import TextWrapper + + text = text.expandtabs() + wrapper = TextWrapper( + width, + initial_indent=initial_indent, + subsequent_indent=subsequent_indent, + replace_whitespace=False, + ) + if not preserve_paragraphs: + return wrapper.fill(text) + + p = [] + buf = [] + indent = None + + def _flush_par(): + if not buf: + return + if buf[0].strip() == "\b": + p.append((indent or 0, True, "\n".join(buf[1:]))) + else: + p.append((indent or 0, False, " ".join(buf))) + del buf[:] + + for line in text.splitlines(): + if not line: + _flush_par() + indent = None + else: + if indent is None: + orig_len = term_len(line) + line = line.lstrip() + indent = orig_len - term_len(line) + buf.append(line) + _flush_par() + + rv = [] + for indent, raw, text in p: + with wrapper.extra_indent(" " * indent): + if raw: + rv.append(wrapper.indent_only(text)) + else: + rv.append(wrapper.fill(text)) + + return "\n\n".join(rv) + + +class HelpFormatter(object): + """This class helps with formatting text-based help pages. It's + usually just needed for very special internal cases, but it's also + exposed so that developers can write their own fancy outputs. + + At present, it always writes into memory. + + :param indent_increment: the additional increment for each level. + :param width: the width for the text. This defaults to the terminal + width clamped to a maximum of 78. + """ + + def __init__(self, indent_increment=2, width=None, max_width=None): + self.indent_increment = indent_increment + if max_width is None: + max_width = 80 + if width is None: + width = FORCED_WIDTH + if width is None: + width = max(min(get_terminal_size()[0], max_width) - 2, 50) + self.width = width + self.current_indent = 0 + self.buffer = [] + + def write(self, string): + """Writes a unicode string into the internal buffer.""" + self.buffer.append(string) + + def indent(self): + """Increases the indentation.""" + self.current_indent += self.indent_increment + + def dedent(self): + """Decreases the indentation.""" + self.current_indent -= self.indent_increment + + def write_usage(self, prog, args="", prefix="Usage: "): + """Writes a usage line into the buffer. + + :param prog: the program name. + :param args: whitespace separated list of arguments. + :param prefix: the prefix for the first line. + """ + usage_prefix = "{:>{w}}{} ".format(prefix, prog, w=self.current_indent) + text_width = self.width - self.current_indent + + if text_width >= (term_len(usage_prefix) + 20): + # The arguments will fit to the right of the prefix. + indent = " " * term_len(usage_prefix) + self.write( + wrap_text( + args, + text_width, + initial_indent=usage_prefix, + subsequent_indent=indent, + ) + ) + else: + # The prefix is too long, put the arguments on the next line. + self.write(usage_prefix) + self.write("\n") + indent = " " * (max(self.current_indent, term_len(prefix)) + 4) + self.write( + wrap_text( + args, text_width, initial_indent=indent, subsequent_indent=indent + ) + ) + + self.write("\n") + + def write_heading(self, heading): + """Writes a heading into the buffer.""" + self.write("{:>{w}}{}:\n".format("", heading, w=self.current_indent)) + + def write_paragraph(self): + """Writes a paragraph into the buffer.""" + if self.buffer: + self.write("\n") + + def write_text(self, text): + """Writes re-indented text into the buffer. This rewraps and + preserves paragraphs. + """ + text_width = max(self.width - self.current_indent, 11) + indent = " " * self.current_indent + self.write( + wrap_text( + text, + text_width, + initial_indent=indent, + subsequent_indent=indent, + preserve_paragraphs=True, + ) + ) + self.write("\n") + + def write_dl(self, rows, col_max=30, col_spacing=2): + """Writes a definition list into the buffer. This is how options + and commands are usually formatted. + + :param rows: a list of two item tuples for the terms and values. + :param col_max: the maximum width of the first column. + :param col_spacing: the number of spaces between the first and + second column. + """ + rows = list(rows) + widths = measure_table(rows) + if len(widths) != 2: + raise TypeError("Expected two columns for definition list") + + first_col = min(widths[0], col_max) + col_spacing + + for first, second in iter_rows(rows, len(widths)): + self.write("{:>{w}}{}".format("", first, w=self.current_indent)) + if not second: + self.write("\n") + continue + if term_len(first) <= first_col - col_spacing: + self.write(" " * (first_col - term_len(first))) + else: + self.write("\n") + self.write(" " * (first_col + self.current_indent)) + + text_width = max(self.width - first_col - 2, 10) + wrapped_text = wrap_text(second, text_width, preserve_paragraphs=True) + lines = wrapped_text.splitlines() + + if lines: + self.write("{}\n".format(lines[0])) + + for line in lines[1:]: + self.write( + "{:>{w}}{}\n".format( + "", line, w=first_col + self.current_indent + ) + ) + + if len(lines) > 1: + # separate long help from next option + self.write("\n") + else: + self.write("\n") + + @contextmanager + def section(self, name): + """Helpful context manager that writes a paragraph, a heading, + and the indents. + + :param name: the section name that is written as heading. + """ + self.write_paragraph() + self.write_heading(name) + self.indent() + try: + yield + finally: + self.dedent() + + @contextmanager + def indentation(self): + """A context manager that increases the indentation.""" + self.indent() + try: + yield + finally: + self.dedent() + + def getvalue(self): + """Returns the buffer contents.""" + return "".join(self.buffer) + + +def join_options(options): + """Given a list of option strings this joins them in the most appropriate + way and returns them in the form ``(formatted_string, + any_prefix_is_slash)`` where the second item in the tuple is a flag that + indicates if any of the option prefixes was a slash. + """ + rv = [] + any_prefix_is_slash = False + for opt in options: + prefix = split_opt(opt)[0] + if prefix == "/": + any_prefix_is_slash = True + rv.append((len(prefix), opt)) + + rv.sort(key=lambda x: x[0]) + + rv = ", ".join(x[1] for x in rv) + return rv, any_prefix_is_slash diff --git a/openpype/vendor/python/python_2/click/globals.py b/openpype/vendor/python/python_2/click/globals.py new file mode 100644 index 0000000000..1649f9a0bf --- /dev/null +++ b/openpype/vendor/python/python_2/click/globals.py @@ -0,0 +1,47 @@ +from threading import local + +_local = local() + + +def get_current_context(silent=False): + """Returns the current click context. This can be used as a way to + access the current context object from anywhere. This is a more implicit + alternative to the :func:`pass_context` decorator. This function is + primarily useful for helpers such as :func:`echo` which might be + interested in changing its behavior based on the current context. + + To push the current context, :meth:`Context.scope` can be used. + + .. versionadded:: 5.0 + + :param silent: if set to `True` the return value is `None` if no context + is available. The default behavior is to raise a + :exc:`RuntimeError`. + """ + try: + return _local.stack[-1] + except (AttributeError, IndexError): + if not silent: + raise RuntimeError("There is no active click context.") + + +def push_context(ctx): + """Pushes a new context to the current stack.""" + _local.__dict__.setdefault("stack", []).append(ctx) + + +def pop_context(): + """Removes the top level from the stack.""" + _local.stack.pop() + + +def resolve_color_default(color=None): + """"Internal helper to get the default value of the color flag. If a + value is passed it's returned unchanged, otherwise it's looked up from + the current context. + """ + if color is not None: + return color + ctx = get_current_context(silent=True) + if ctx is not None: + return ctx.color diff --git a/openpype/vendor/python/python_2/click/parser.py b/openpype/vendor/python/python_2/click/parser.py new file mode 100644 index 0000000000..f43ebfe9fc --- /dev/null +++ b/openpype/vendor/python/python_2/click/parser.py @@ -0,0 +1,428 @@ +# -*- coding: utf-8 -*- +""" +This module started out as largely a copy paste from the stdlib's +optparse module with the features removed that we do not need from +optparse because we implement them in Click on a higher level (for +instance type handling, help formatting and a lot more). + +The plan is to remove more and more from here over time. + +The reason this is a different module and not optparse from the stdlib +is that there are differences in 2.x and 3.x about the error messages +generated and optparse in the stdlib uses gettext for no good reason +and might cause us issues. + +Click uses parts of optparse written by Gregory P. Ward and maintained +by the Python Software Foundation. This is limited to code in parser.py. + +Copyright 2001-2006 Gregory P. Ward. All rights reserved. +Copyright 2002-2006 Python Software Foundation. All rights reserved. +""" +import re +from collections import deque + +from .exceptions import BadArgumentUsage +from .exceptions import BadOptionUsage +from .exceptions import NoSuchOption +from .exceptions import UsageError + + +def _unpack_args(args, nargs_spec): + """Given an iterable of arguments and an iterable of nargs specifications, + it returns a tuple with all the unpacked arguments at the first index + and all remaining arguments as the second. + + The nargs specification is the number of arguments that should be consumed + or `-1` to indicate that this position should eat up all the remainders. + + Missing items are filled with `None`. + """ + args = deque(args) + nargs_spec = deque(nargs_spec) + rv = [] + spos = None + + def _fetch(c): + try: + if spos is None: + return c.popleft() + else: + return c.pop() + except IndexError: + return None + + while nargs_spec: + nargs = _fetch(nargs_spec) + if nargs == 1: + rv.append(_fetch(args)) + elif nargs > 1: + x = [_fetch(args) for _ in range(nargs)] + # If we're reversed, we're pulling in the arguments in reverse, + # so we need to turn them around. + if spos is not None: + x.reverse() + rv.append(tuple(x)) + elif nargs < 0: + if spos is not None: + raise TypeError("Cannot have two nargs < 0") + spos = len(rv) + rv.append(None) + + # spos is the position of the wildcard (star). If it's not `None`, + # we fill it with the remainder. + if spos is not None: + rv[spos] = tuple(args) + args = [] + rv[spos + 1 :] = reversed(rv[spos + 1 :]) + + return tuple(rv), list(args) + + +def _error_opt_args(nargs, opt): + if nargs == 1: + raise BadOptionUsage(opt, "{} option requires an argument".format(opt)) + raise BadOptionUsage(opt, "{} option requires {} arguments".format(opt, nargs)) + + +def split_opt(opt): + first = opt[:1] + if first.isalnum(): + return "", opt + if opt[1:2] == first: + return opt[:2], opt[2:] + return first, opt[1:] + + +def normalize_opt(opt, ctx): + if ctx is None or ctx.token_normalize_func is None: + return opt + prefix, opt = split_opt(opt) + return prefix + ctx.token_normalize_func(opt) + + +def split_arg_string(string): + """Given an argument string this attempts to split it into small parts.""" + rv = [] + for match in re.finditer( + r"('([^'\\]*(?:\\.[^'\\]*)*)'|\"([^\"\\]*(?:\\.[^\"\\]*)*)\"|\S+)\s*", + string, + re.S, + ): + arg = match.group().strip() + if arg[:1] == arg[-1:] and arg[:1] in "\"'": + arg = arg[1:-1].encode("ascii", "backslashreplace").decode("unicode-escape") + try: + arg = type(string)(arg) + except UnicodeError: + pass + rv.append(arg) + return rv + + +class Option(object): + def __init__(self, opts, dest, action=None, nargs=1, const=None, obj=None): + self._short_opts = [] + self._long_opts = [] + self.prefixes = set() + + for opt in opts: + prefix, value = split_opt(opt) + if not prefix: + raise ValueError("Invalid start character for option ({})".format(opt)) + self.prefixes.add(prefix[0]) + if len(prefix) == 1 and len(value) == 1: + self._short_opts.append(opt) + else: + self._long_opts.append(opt) + self.prefixes.add(prefix) + + if action is None: + action = "store" + + self.dest = dest + self.action = action + self.nargs = nargs + self.const = const + self.obj = obj + + @property + def takes_value(self): + return self.action in ("store", "append") + + def process(self, value, state): + if self.action == "store": + state.opts[self.dest] = value + elif self.action == "store_const": + state.opts[self.dest] = self.const + elif self.action == "append": + state.opts.setdefault(self.dest, []).append(value) + elif self.action == "append_const": + state.opts.setdefault(self.dest, []).append(self.const) + elif self.action == "count": + state.opts[self.dest] = state.opts.get(self.dest, 0) + 1 + else: + raise ValueError("unknown action '{}'".format(self.action)) + state.order.append(self.obj) + + +class Argument(object): + def __init__(self, dest, nargs=1, obj=None): + self.dest = dest + self.nargs = nargs + self.obj = obj + + def process(self, value, state): + if self.nargs > 1: + holes = sum(1 for x in value if x is None) + if holes == len(value): + value = None + elif holes != 0: + raise BadArgumentUsage( + "argument {} takes {} values".format(self.dest, self.nargs) + ) + state.opts[self.dest] = value + state.order.append(self.obj) + + +class ParsingState(object): + def __init__(self, rargs): + self.opts = {} + self.largs = [] + self.rargs = rargs + self.order = [] + + +class OptionParser(object): + """The option parser is an internal class that is ultimately used to + parse options and arguments. It's modelled after optparse and brings + a similar but vastly simplified API. It should generally not be used + directly as the high level Click classes wrap it for you. + + It's not nearly as extensible as optparse or argparse as it does not + implement features that are implemented on a higher level (such as + types or defaults). + + :param ctx: optionally the :class:`~click.Context` where this parser + should go with. + """ + + def __init__(self, ctx=None): + #: The :class:`~click.Context` for this parser. This might be + #: `None` for some advanced use cases. + self.ctx = ctx + #: This controls how the parser deals with interspersed arguments. + #: If this is set to `False`, the parser will stop on the first + #: non-option. Click uses this to implement nested subcommands + #: safely. + self.allow_interspersed_args = True + #: This tells the parser how to deal with unknown options. By + #: default it will error out (which is sensible), but there is a + #: second mode where it will ignore it and continue processing + #: after shifting all the unknown options into the resulting args. + self.ignore_unknown_options = False + if ctx is not None: + self.allow_interspersed_args = ctx.allow_interspersed_args + self.ignore_unknown_options = ctx.ignore_unknown_options + self._short_opt = {} + self._long_opt = {} + self._opt_prefixes = {"-", "--"} + self._args = [] + + def add_option(self, opts, dest, action=None, nargs=1, const=None, obj=None): + """Adds a new option named `dest` to the parser. The destination + is not inferred (unlike with optparse) and needs to be explicitly + provided. Action can be any of ``store``, ``store_const``, + ``append``, ``appnd_const`` or ``count``. + + The `obj` can be used to identify the option in the order list + that is returned from the parser. + """ + if obj is None: + obj = dest + opts = [normalize_opt(opt, self.ctx) for opt in opts] + option = Option(opts, dest, action=action, nargs=nargs, const=const, obj=obj) + self._opt_prefixes.update(option.prefixes) + for opt in option._short_opts: + self._short_opt[opt] = option + for opt in option._long_opts: + self._long_opt[opt] = option + + def add_argument(self, dest, nargs=1, obj=None): + """Adds a positional argument named `dest` to the parser. + + The `obj` can be used to identify the option in the order list + that is returned from the parser. + """ + if obj is None: + obj = dest + self._args.append(Argument(dest=dest, nargs=nargs, obj=obj)) + + def parse_args(self, args): + """Parses positional arguments and returns ``(values, args, order)`` + for the parsed options and arguments as well as the leftover + arguments if there are any. The order is a list of objects as they + appear on the command line. If arguments appear multiple times they + will be memorized multiple times as well. + """ + state = ParsingState(args) + try: + self._process_args_for_options(state) + self._process_args_for_args(state) + except UsageError: + if self.ctx is None or not self.ctx.resilient_parsing: + raise + return state.opts, state.largs, state.order + + def _process_args_for_args(self, state): + pargs, args = _unpack_args( + state.largs + state.rargs, [x.nargs for x in self._args] + ) + + for idx, arg in enumerate(self._args): + arg.process(pargs[idx], state) + + state.largs = args + state.rargs = [] + + def _process_args_for_options(self, state): + while state.rargs: + arg = state.rargs.pop(0) + arglen = len(arg) + # Double dashes always handled explicitly regardless of what + # prefixes are valid. + if arg == "--": + return + elif arg[:1] in self._opt_prefixes and arglen > 1: + self._process_opts(arg, state) + elif self.allow_interspersed_args: + state.largs.append(arg) + else: + state.rargs.insert(0, arg) + return + + # Say this is the original argument list: + # [arg0, arg1, ..., arg(i-1), arg(i), arg(i+1), ..., arg(N-1)] + # ^ + # (we are about to process arg(i)). + # + # Then rargs is [arg(i), ..., arg(N-1)] and largs is a *subset* of + # [arg0, ..., arg(i-1)] (any options and their arguments will have + # been removed from largs). + # + # The while loop will usually consume 1 or more arguments per pass. + # If it consumes 1 (eg. arg is an option that takes no arguments), + # then after _process_arg() is done the situation is: + # + # largs = subset of [arg0, ..., arg(i)] + # rargs = [arg(i+1), ..., arg(N-1)] + # + # If allow_interspersed_args is false, largs will always be + # *empty* -- still a subset of [arg0, ..., arg(i-1)], but + # not a very interesting subset! + + def _match_long_opt(self, opt, explicit_value, state): + if opt not in self._long_opt: + possibilities = [word for word in self._long_opt if word.startswith(opt)] + raise NoSuchOption(opt, possibilities=possibilities, ctx=self.ctx) + + option = self._long_opt[opt] + if option.takes_value: + # At this point it's safe to modify rargs by injecting the + # explicit value, because no exception is raised in this + # branch. This means that the inserted value will be fully + # consumed. + if explicit_value is not None: + state.rargs.insert(0, explicit_value) + + nargs = option.nargs + if len(state.rargs) < nargs: + _error_opt_args(nargs, opt) + elif nargs == 1: + value = state.rargs.pop(0) + else: + value = tuple(state.rargs[:nargs]) + del state.rargs[:nargs] + + elif explicit_value is not None: + raise BadOptionUsage(opt, "{} option does not take a value".format(opt)) + + else: + value = None + + option.process(value, state) + + def _match_short_opt(self, arg, state): + stop = False + i = 1 + prefix = arg[0] + unknown_options = [] + + for ch in arg[1:]: + opt = normalize_opt(prefix + ch, self.ctx) + option = self._short_opt.get(opt) + i += 1 + + if not option: + if self.ignore_unknown_options: + unknown_options.append(ch) + continue + raise NoSuchOption(opt, ctx=self.ctx) + if option.takes_value: + # Any characters left in arg? Pretend they're the + # next arg, and stop consuming characters of arg. + if i < len(arg): + state.rargs.insert(0, arg[i:]) + stop = True + + nargs = option.nargs + if len(state.rargs) < nargs: + _error_opt_args(nargs, opt) + elif nargs == 1: + value = state.rargs.pop(0) + else: + value = tuple(state.rargs[:nargs]) + del state.rargs[:nargs] + + else: + value = None + + option.process(value, state) + + if stop: + break + + # If we got any unknown options we re-combinate the string of the + # remaining options and re-attach the prefix, then report that + # to the state as new larg. This way there is basic combinatorics + # that can be achieved while still ignoring unknown arguments. + if self.ignore_unknown_options and unknown_options: + state.largs.append("{}{}".format(prefix, "".join(unknown_options))) + + def _process_opts(self, arg, state): + explicit_value = None + # Long option handling happens in two parts. The first part is + # supporting explicitly attached values. In any case, we will try + # to long match the option first. + if "=" in arg: + long_opt, explicit_value = arg.split("=", 1) + else: + long_opt = arg + norm_long_opt = normalize_opt(long_opt, self.ctx) + + # At this point we will match the (assumed) long option through + # the long option matching code. Note that this allows options + # like "-foo" to be matched as long options. + try: + self._match_long_opt(norm_long_opt, explicit_value, state) + except NoSuchOption: + # At this point the long option matching failed, and we need + # to try with short options. However there is a special rule + # which says, that if we have a two character options prefix + # (applies to "--foo" for instance), we do not dispatch to the + # short option code and will instead raise the no option + # error. + if arg[:2] not in self._opt_prefixes: + return self._match_short_opt(arg, state) + if not self.ignore_unknown_options: + raise + state.largs.append(arg) diff --git a/openpype/vendor/python/python_2/click/termui.py b/openpype/vendor/python/python_2/click/termui.py new file mode 100644 index 0000000000..02ef9e9f04 --- /dev/null +++ b/openpype/vendor/python/python_2/click/termui.py @@ -0,0 +1,681 @@ +import inspect +import io +import itertools +import os +import struct +import sys + +from ._compat import DEFAULT_COLUMNS +from ._compat import get_winterm_size +from ._compat import isatty +from ._compat import raw_input +from ._compat import string_types +from ._compat import strip_ansi +from ._compat import text_type +from ._compat import WIN +from .exceptions import Abort +from .exceptions import UsageError +from .globals import resolve_color_default +from .types import Choice +from .types import convert_type +from .types import Path +from .utils import echo +from .utils import LazyFile + +# The prompt functions to use. The doc tools currently override these +# functions to customize how they work. +visible_prompt_func = raw_input + +_ansi_colors = { + "black": 30, + "red": 31, + "green": 32, + "yellow": 33, + "blue": 34, + "magenta": 35, + "cyan": 36, + "white": 37, + "reset": 39, + "bright_black": 90, + "bright_red": 91, + "bright_green": 92, + "bright_yellow": 93, + "bright_blue": 94, + "bright_magenta": 95, + "bright_cyan": 96, + "bright_white": 97, +} +_ansi_reset_all = "\033[0m" + + +def hidden_prompt_func(prompt): + import getpass + + return getpass.getpass(prompt) + + +def _build_prompt( + text, suffix, show_default=False, default=None, show_choices=True, type=None +): + prompt = text + if type is not None and show_choices and isinstance(type, Choice): + prompt += " ({})".format(", ".join(map(str, type.choices))) + if default is not None and show_default: + prompt = "{} [{}]".format(prompt, _format_default(default)) + return prompt + suffix + + +def _format_default(default): + if isinstance(default, (io.IOBase, LazyFile)) and hasattr(default, "name"): + return default.name + + return default + + +def prompt( + text, + default=None, + hide_input=False, + confirmation_prompt=False, + type=None, + value_proc=None, + prompt_suffix=": ", + show_default=True, + err=False, + show_choices=True, +): + """Prompts a user for input. This is a convenience function that can + be used to prompt a user for input later. + + If the user aborts the input by sending a interrupt signal, this + function will catch it and raise a :exc:`Abort` exception. + + .. versionadded:: 7.0 + Added the show_choices parameter. + + .. versionadded:: 6.0 + Added unicode support for cmd.exe on Windows. + + .. versionadded:: 4.0 + Added the `err` parameter. + + :param text: the text to show for the prompt. + :param default: the default value to use if no input happens. If this + is not given it will prompt until it's aborted. + :param hide_input: if this is set to true then the input value will + be hidden. + :param confirmation_prompt: asks for confirmation for the value. + :param type: the type to use to check the value against. + :param value_proc: if this parameter is provided it's a function that + is invoked instead of the type conversion to + convert a value. + :param prompt_suffix: a suffix that should be added to the prompt. + :param show_default: shows or hides the default value in the prompt. + :param err: if set to true the file defaults to ``stderr`` instead of + ``stdout``, the same as with echo. + :param show_choices: Show or hide choices if the passed type is a Choice. + For example if type is a Choice of either day or week, + show_choices is true and text is "Group by" then the + prompt will be "Group by (day, week): ". + """ + result = None + + def prompt_func(text): + f = hidden_prompt_func if hide_input else visible_prompt_func + try: + # Write the prompt separately so that we get nice + # coloring through colorama on Windows + echo(text, nl=False, err=err) + return f("") + except (KeyboardInterrupt, EOFError): + # getpass doesn't print a newline if the user aborts input with ^C. + # Allegedly this behavior is inherited from getpass(3). + # A doc bug has been filed at https://bugs.python.org/issue24711 + if hide_input: + echo(None, err=err) + raise Abort() + + if value_proc is None: + value_proc = convert_type(type, default) + + prompt = _build_prompt( + text, prompt_suffix, show_default, default, show_choices, type + ) + + while 1: + while 1: + value = prompt_func(prompt) + if value: + break + elif default is not None: + if isinstance(value_proc, Path): + # validate Path default value(exists, dir_okay etc.) + value = default + break + return default + try: + result = value_proc(value) + except UsageError as e: + echo("Error: {}".format(e.message), err=err) # noqa: B306 + continue + if not confirmation_prompt: + return result + while 1: + value2 = prompt_func("Repeat for confirmation: ") + if value2: + break + if value == value2: + return result + echo("Error: the two entered values do not match", err=err) + + +def confirm( + text, default=False, abort=False, prompt_suffix=": ", show_default=True, err=False +): + """Prompts for confirmation (yes/no question). + + If the user aborts the input by sending a interrupt signal this + function will catch it and raise a :exc:`Abort` exception. + + .. versionadded:: 4.0 + Added the `err` parameter. + + :param text: the question to ask. + :param default: the default for the prompt. + :param abort: if this is set to `True` a negative answer aborts the + exception by raising :exc:`Abort`. + :param prompt_suffix: a suffix that should be added to the prompt. + :param show_default: shows or hides the default value in the prompt. + :param err: if set to true the file defaults to ``stderr`` instead of + ``stdout``, the same as with echo. + """ + prompt = _build_prompt( + text, prompt_suffix, show_default, "Y/n" if default else "y/N" + ) + while 1: + try: + # Write the prompt separately so that we get nice + # coloring through colorama on Windows + echo(prompt, nl=False, err=err) + value = visible_prompt_func("").lower().strip() + except (KeyboardInterrupt, EOFError): + raise Abort() + if value in ("y", "yes"): + rv = True + elif value in ("n", "no"): + rv = False + elif value == "": + rv = default + else: + echo("Error: invalid input", err=err) + continue + break + if abort and not rv: + raise Abort() + return rv + + +def get_terminal_size(): + """Returns the current size of the terminal as tuple in the form + ``(width, height)`` in columns and rows. + """ + # If shutil has get_terminal_size() (Python 3.3 and later) use that + if sys.version_info >= (3, 3): + import shutil + + shutil_get_terminal_size = getattr(shutil, "get_terminal_size", None) + if shutil_get_terminal_size: + sz = shutil_get_terminal_size() + return sz.columns, sz.lines + + # We provide a sensible default for get_winterm_size() when being invoked + # inside a subprocess. Without this, it would not provide a useful input. + if get_winterm_size is not None: + size = get_winterm_size() + if size == (0, 0): + return (79, 24) + else: + return size + + def ioctl_gwinsz(fd): + try: + import fcntl + import termios + + cr = struct.unpack("hh", fcntl.ioctl(fd, termios.TIOCGWINSZ, "1234")) + except Exception: + return + return cr + + cr = ioctl_gwinsz(0) or ioctl_gwinsz(1) or ioctl_gwinsz(2) + if not cr: + try: + fd = os.open(os.ctermid(), os.O_RDONLY) + try: + cr = ioctl_gwinsz(fd) + finally: + os.close(fd) + except Exception: + pass + if not cr or not cr[0] or not cr[1]: + cr = (os.environ.get("LINES", 25), os.environ.get("COLUMNS", DEFAULT_COLUMNS)) + return int(cr[1]), int(cr[0]) + + +def echo_via_pager(text_or_generator, color=None): + """This function takes a text and shows it via an environment specific + pager on stdout. + + .. versionchanged:: 3.0 + Added the `color` flag. + + :param text_or_generator: the text to page, or alternatively, a + generator emitting the text to page. + :param color: controls if the pager supports ANSI colors or not. The + default is autodetection. + """ + color = resolve_color_default(color) + + if inspect.isgeneratorfunction(text_or_generator): + i = text_or_generator() + elif isinstance(text_or_generator, string_types): + i = [text_or_generator] + else: + i = iter(text_or_generator) + + # convert every element of i to a text type if necessary + text_generator = (el if isinstance(el, string_types) else text_type(el) for el in i) + + from ._termui_impl import pager + + return pager(itertools.chain(text_generator, "\n"), color) + + +def progressbar( + iterable=None, + length=None, + label=None, + show_eta=True, + show_percent=None, + show_pos=False, + item_show_func=None, + fill_char="#", + empty_char="-", + bar_template="%(label)s [%(bar)s] %(info)s", + info_sep=" ", + width=36, + file=None, + color=None, +): + """This function creates an iterable context manager that can be used + to iterate over something while showing a progress bar. It will + either iterate over the `iterable` or `length` items (that are counted + up). While iteration happens, this function will print a rendered + progress bar to the given `file` (defaults to stdout) and will attempt + to calculate remaining time and more. By default, this progress bar + will not be rendered if the file is not a terminal. + + The context manager creates the progress bar. When the context + manager is entered the progress bar is already created. With every + iteration over the progress bar, the iterable passed to the bar is + advanced and the bar is updated. When the context manager exits, + a newline is printed and the progress bar is finalized on screen. + + Note: The progress bar is currently designed for use cases where the + total progress can be expected to take at least several seconds. + Because of this, the ProgressBar class object won't display + progress that is considered too fast, and progress where the time + between steps is less than a second. + + No printing must happen or the progress bar will be unintentionally + destroyed. + + Example usage:: + + with progressbar(items) as bar: + for item in bar: + do_something_with(item) + + Alternatively, if no iterable is specified, one can manually update the + progress bar through the `update()` method instead of directly + iterating over the progress bar. The update method accepts the number + of steps to increment the bar with:: + + with progressbar(length=chunks.total_bytes) as bar: + for chunk in chunks: + process_chunk(chunk) + bar.update(chunks.bytes) + + .. versionadded:: 2.0 + + .. versionadded:: 4.0 + Added the `color` parameter. Added a `update` method to the + progressbar object. + + :param iterable: an iterable to iterate over. If not provided the length + is required. + :param length: the number of items to iterate over. By default the + progressbar will attempt to ask the iterator about its + length, which might or might not work. If an iterable is + also provided this parameter can be used to override the + length. If an iterable is not provided the progress bar + will iterate over a range of that length. + :param label: the label to show next to the progress bar. + :param show_eta: enables or disables the estimated time display. This is + automatically disabled if the length cannot be + determined. + :param show_percent: enables or disables the percentage display. The + default is `True` if the iterable has a length or + `False` if not. + :param show_pos: enables or disables the absolute position display. The + default is `False`. + :param item_show_func: a function called with the current item which + can return a string to show the current item + next to the progress bar. Note that the current + item can be `None`! + :param fill_char: the character to use to show the filled part of the + progress bar. + :param empty_char: the character to use to show the non-filled part of + the progress bar. + :param bar_template: the format string to use as template for the bar. + The parameters in it are ``label`` for the label, + ``bar`` for the progress bar and ``info`` for the + info section. + :param info_sep: the separator between multiple info items (eta etc.) + :param width: the width of the progress bar in characters, 0 means full + terminal width + :param file: the file to write to. If this is not a terminal then + only the label is printed. + :param color: controls if the terminal supports ANSI colors or not. The + default is autodetection. This is only needed if ANSI + codes are included anywhere in the progress bar output + which is not the case by default. + """ + from ._termui_impl import ProgressBar + + color = resolve_color_default(color) + return ProgressBar( + iterable=iterable, + length=length, + show_eta=show_eta, + show_percent=show_percent, + show_pos=show_pos, + item_show_func=item_show_func, + fill_char=fill_char, + empty_char=empty_char, + bar_template=bar_template, + info_sep=info_sep, + file=file, + label=label, + width=width, + color=color, + ) + + +def clear(): + """Clears the terminal screen. This will have the effect of clearing + the whole visible space of the terminal and moving the cursor to the + top left. This does not do anything if not connected to a terminal. + + .. versionadded:: 2.0 + """ + if not isatty(sys.stdout): + return + # If we're on Windows and we don't have colorama available, then we + # clear the screen by shelling out. Otherwise we can use an escape + # sequence. + if WIN: + os.system("cls") + else: + sys.stdout.write("\033[2J\033[1;1H") + + +def style( + text, + fg=None, + bg=None, + bold=None, + dim=None, + underline=None, + blink=None, + reverse=None, + reset=True, +): + """Styles a text with ANSI styles and returns the new string. By + default the styling is self contained which means that at the end + of the string a reset code is issued. This can be prevented by + passing ``reset=False``. + + Examples:: + + click.echo(click.style('Hello World!', fg='green')) + click.echo(click.style('ATTENTION!', blink=True)) + click.echo(click.style('Some things', reverse=True, fg='cyan')) + + Supported color names: + + * ``black`` (might be a gray) + * ``red`` + * ``green`` + * ``yellow`` (might be an orange) + * ``blue`` + * ``magenta`` + * ``cyan`` + * ``white`` (might be light gray) + * ``bright_black`` + * ``bright_red`` + * ``bright_green`` + * ``bright_yellow`` + * ``bright_blue`` + * ``bright_magenta`` + * ``bright_cyan`` + * ``bright_white`` + * ``reset`` (reset the color code only) + + .. versionadded:: 2.0 + + .. versionadded:: 7.0 + Added support for bright colors. + + :param text: the string to style with ansi codes. + :param fg: if provided this will become the foreground color. + :param bg: if provided this will become the background color. + :param bold: if provided this will enable or disable bold mode. + :param dim: if provided this will enable or disable dim mode. This is + badly supported. + :param underline: if provided this will enable or disable underline. + :param blink: if provided this will enable or disable blinking. + :param reverse: if provided this will enable or disable inverse + rendering (foreground becomes background and the + other way round). + :param reset: by default a reset-all code is added at the end of the + string which means that styles do not carry over. This + can be disabled to compose styles. + """ + bits = [] + if fg: + try: + bits.append("\033[{}m".format(_ansi_colors[fg])) + except KeyError: + raise TypeError("Unknown color '{}'".format(fg)) + if bg: + try: + bits.append("\033[{}m".format(_ansi_colors[bg] + 10)) + except KeyError: + raise TypeError("Unknown color '{}'".format(bg)) + if bold is not None: + bits.append("\033[{}m".format(1 if bold else 22)) + if dim is not None: + bits.append("\033[{}m".format(2 if dim else 22)) + if underline is not None: + bits.append("\033[{}m".format(4 if underline else 24)) + if blink is not None: + bits.append("\033[{}m".format(5 if blink else 25)) + if reverse is not None: + bits.append("\033[{}m".format(7 if reverse else 27)) + bits.append(text) + if reset: + bits.append(_ansi_reset_all) + return "".join(bits) + + +def unstyle(text): + """Removes ANSI styling information from a string. Usually it's not + necessary to use this function as Click's echo function will + automatically remove styling if necessary. + + .. versionadded:: 2.0 + + :param text: the text to remove style information from. + """ + return strip_ansi(text) + + +def secho(message=None, file=None, nl=True, err=False, color=None, **styles): + """This function combines :func:`echo` and :func:`style` into one + call. As such the following two calls are the same:: + + click.secho('Hello World!', fg='green') + click.echo(click.style('Hello World!', fg='green')) + + All keyword arguments are forwarded to the underlying functions + depending on which one they go with. + + .. versionadded:: 2.0 + """ + if message is not None: + message = style(message, **styles) + return echo(message, file=file, nl=nl, err=err, color=color) + + +def edit( + text=None, editor=None, env=None, require_save=True, extension=".txt", filename=None +): + r"""Edits the given text in the defined editor. If an editor is given + (should be the full path to the executable but the regular operating + system search path is used for finding the executable) it overrides + the detected editor. Optionally, some environment variables can be + used. If the editor is closed without changes, `None` is returned. In + case a file is edited directly the return value is always `None` and + `require_save` and `extension` are ignored. + + If the editor cannot be opened a :exc:`UsageError` is raised. + + Note for Windows: to simplify cross-platform usage, the newlines are + automatically converted from POSIX to Windows and vice versa. As such, + the message here will have ``\n`` as newline markers. + + :param text: the text to edit. + :param editor: optionally the editor to use. Defaults to automatic + detection. + :param env: environment variables to forward to the editor. + :param require_save: if this is true, then not saving in the editor + will make the return value become `None`. + :param extension: the extension to tell the editor about. This defaults + to `.txt` but changing this might change syntax + highlighting. + :param filename: if provided it will edit this file instead of the + provided text contents. It will not use a temporary + file as an indirection in that case. + """ + from ._termui_impl import Editor + + editor = Editor( + editor=editor, env=env, require_save=require_save, extension=extension + ) + if filename is None: + return editor.edit(text) + editor.edit_file(filename) + + +def launch(url, wait=False, locate=False): + """This function launches the given URL (or filename) in the default + viewer application for this file type. If this is an executable, it + might launch the executable in a new session. The return value is + the exit code of the launched application. Usually, ``0`` indicates + success. + + Examples:: + + click.launch('https://click.palletsprojects.com/') + click.launch('/my/downloaded/file', locate=True) + + .. versionadded:: 2.0 + + :param url: URL or filename of the thing to launch. + :param wait: waits for the program to stop. + :param locate: if this is set to `True` then instead of launching the + application associated with the URL it will attempt to + launch a file manager with the file located. This + might have weird effects if the URL does not point to + the filesystem. + """ + from ._termui_impl import open_url + + return open_url(url, wait=wait, locate=locate) + + +# If this is provided, getchar() calls into this instead. This is used +# for unittesting purposes. +_getchar = None + + +def getchar(echo=False): + """Fetches a single character from the terminal and returns it. This + will always return a unicode character and under certain rare + circumstances this might return more than one character. The + situations which more than one character is returned is when for + whatever reason multiple characters end up in the terminal buffer or + standard input was not actually a terminal. + + Note that this will always read from the terminal, even if something + is piped into the standard input. + + Note for Windows: in rare cases when typing non-ASCII characters, this + function might wait for a second character and then return both at once. + This is because certain Unicode characters look like special-key markers. + + .. versionadded:: 2.0 + + :param echo: if set to `True`, the character read will also show up on + the terminal. The default is to not show it. + """ + f = _getchar + if f is None: + from ._termui_impl import getchar as f + return f(echo) + + +def raw_terminal(): + from ._termui_impl import raw_terminal as f + + return f() + + +def pause(info="Press any key to continue ...", err=False): + """This command stops execution and waits for the user to press any + key to continue. This is similar to the Windows batch "pause" + command. If the program is not run through a terminal, this command + will instead do nothing. + + .. versionadded:: 2.0 + + .. versionadded:: 4.0 + Added the `err` parameter. + + :param info: the info string to print before pausing. + :param err: if set to message goes to ``stderr`` instead of + ``stdout``, the same as with echo. + """ + if not isatty(sys.stdin) or not isatty(sys.stdout): + return + try: + if info: + echo(info, nl=False, err=err) + try: + getchar() + except (KeyboardInterrupt, EOFError): + pass + finally: + if info: + echo(err=err) diff --git a/openpype/vendor/python/python_2/click/testing.py b/openpype/vendor/python/python_2/click/testing.py new file mode 100644 index 0000000000..a3dba3b301 --- /dev/null +++ b/openpype/vendor/python/python_2/click/testing.py @@ -0,0 +1,382 @@ +import contextlib +import os +import shlex +import shutil +import sys +import tempfile + +from . import formatting +from . import termui +from . import utils +from ._compat import iteritems +from ._compat import PY2 +from ._compat import string_types + + +if PY2: + from cStringIO import StringIO +else: + import io + from ._compat import _find_binary_reader + + +class EchoingStdin(object): + def __init__(self, input, output): + self._input = input + self._output = output + + def __getattr__(self, x): + return getattr(self._input, x) + + def _echo(self, rv): + self._output.write(rv) + return rv + + def read(self, n=-1): + return self._echo(self._input.read(n)) + + def readline(self, n=-1): + return self._echo(self._input.readline(n)) + + def readlines(self): + return [self._echo(x) for x in self._input.readlines()] + + def __iter__(self): + return iter(self._echo(x) for x in self._input) + + def __repr__(self): + return repr(self._input) + + +def make_input_stream(input, charset): + # Is already an input stream. + if hasattr(input, "read"): + if PY2: + return input + rv = _find_binary_reader(input) + if rv is not None: + return rv + raise TypeError("Could not find binary reader for input stream.") + + if input is None: + input = b"" + elif not isinstance(input, bytes): + input = input.encode(charset) + if PY2: + return StringIO(input) + return io.BytesIO(input) + + +class Result(object): + """Holds the captured result of an invoked CLI script.""" + + def __init__( + self, runner, stdout_bytes, stderr_bytes, exit_code, exception, exc_info=None + ): + #: The runner that created the result + self.runner = runner + #: The standard output as bytes. + self.stdout_bytes = stdout_bytes + #: The standard error as bytes, or None if not available + self.stderr_bytes = stderr_bytes + #: The exit code as integer. + self.exit_code = exit_code + #: The exception that happened if one did. + self.exception = exception + #: The traceback + self.exc_info = exc_info + + @property + def output(self): + """The (standard) output as unicode string.""" + return self.stdout + + @property + def stdout(self): + """The standard output as unicode string.""" + return self.stdout_bytes.decode(self.runner.charset, "replace").replace( + "\r\n", "\n" + ) + + @property + def stderr(self): + """The standard error as unicode string.""" + if self.stderr_bytes is None: + raise ValueError("stderr not separately captured") + return self.stderr_bytes.decode(self.runner.charset, "replace").replace( + "\r\n", "\n" + ) + + def __repr__(self): + return "<{} {}>".format( + type(self).__name__, repr(self.exception) if self.exception else "okay" + ) + + +class CliRunner(object): + """The CLI runner provides functionality to invoke a Click command line + script for unittesting purposes in a isolated environment. This only + works in single-threaded systems without any concurrency as it changes the + global interpreter state. + + :param charset: the character set for the input and output data. This is + UTF-8 by default and should not be changed currently as + the reporting to Click only works in Python 2 properly. + :param env: a dictionary with environment variables for overriding. + :param echo_stdin: if this is set to `True`, then reading from stdin writes + to stdout. This is useful for showing examples in + some circumstances. Note that regular prompts + will automatically echo the input. + :param mix_stderr: if this is set to `False`, then stdout and stderr are + preserved as independent streams. This is useful for + Unix-philosophy apps that have predictable stdout and + noisy stderr, such that each may be measured + independently + """ + + def __init__(self, charset=None, env=None, echo_stdin=False, mix_stderr=True): + if charset is None: + charset = "utf-8" + self.charset = charset + self.env = env or {} + self.echo_stdin = echo_stdin + self.mix_stderr = mix_stderr + + def get_default_prog_name(self, cli): + """Given a command object it will return the default program name + for it. The default is the `name` attribute or ``"root"`` if not + set. + """ + return cli.name or "root" + + def make_env(self, overrides=None): + """Returns the environment overrides for invoking a script.""" + rv = dict(self.env) + if overrides: + rv.update(overrides) + return rv + + @contextlib.contextmanager + def isolation(self, input=None, env=None, color=False): + """A context manager that sets up the isolation for invoking of a + command line tool. This sets up stdin with the given input data + and `os.environ` with the overrides from the given dictionary. + This also rebinds some internals in Click to be mocked (like the + prompt functionality). + + This is automatically done in the :meth:`invoke` method. + + .. versionadded:: 4.0 + The ``color`` parameter was added. + + :param input: the input stream to put into sys.stdin. + :param env: the environment overrides as dictionary. + :param color: whether the output should contain color codes. The + application can still override this explicitly. + """ + input = make_input_stream(input, self.charset) + + old_stdin = sys.stdin + old_stdout = sys.stdout + old_stderr = sys.stderr + old_forced_width = formatting.FORCED_WIDTH + formatting.FORCED_WIDTH = 80 + + env = self.make_env(env) + + if PY2: + bytes_output = StringIO() + if self.echo_stdin: + input = EchoingStdin(input, bytes_output) + sys.stdout = bytes_output + if not self.mix_stderr: + bytes_error = StringIO() + sys.stderr = bytes_error + else: + bytes_output = io.BytesIO() + if self.echo_stdin: + input = EchoingStdin(input, bytes_output) + input = io.TextIOWrapper(input, encoding=self.charset) + sys.stdout = io.TextIOWrapper(bytes_output, encoding=self.charset) + if not self.mix_stderr: + bytes_error = io.BytesIO() + sys.stderr = io.TextIOWrapper(bytes_error, encoding=self.charset) + + if self.mix_stderr: + sys.stderr = sys.stdout + + sys.stdin = input + + def visible_input(prompt=None): + sys.stdout.write(prompt or "") + val = input.readline().rstrip("\r\n") + sys.stdout.write("{}\n".format(val)) + sys.stdout.flush() + return val + + def hidden_input(prompt=None): + sys.stdout.write("{}\n".format(prompt or "")) + sys.stdout.flush() + return input.readline().rstrip("\r\n") + + def _getchar(echo): + char = sys.stdin.read(1) + if echo: + sys.stdout.write(char) + sys.stdout.flush() + return char + + default_color = color + + def should_strip_ansi(stream=None, color=None): + if color is None: + return not default_color + return not color + + old_visible_prompt_func = termui.visible_prompt_func + old_hidden_prompt_func = termui.hidden_prompt_func + old__getchar_func = termui._getchar + old_should_strip_ansi = utils.should_strip_ansi + termui.visible_prompt_func = visible_input + termui.hidden_prompt_func = hidden_input + termui._getchar = _getchar + utils.should_strip_ansi = should_strip_ansi + + old_env = {} + try: + for key, value in iteritems(env): + old_env[key] = os.environ.get(key) + if value is None: + try: + del os.environ[key] + except Exception: + pass + else: + os.environ[key] = value + yield (bytes_output, not self.mix_stderr and bytes_error) + finally: + for key, value in iteritems(old_env): + if value is None: + try: + del os.environ[key] + except Exception: + pass + else: + os.environ[key] = value + sys.stdout = old_stdout + sys.stderr = old_stderr + sys.stdin = old_stdin + termui.visible_prompt_func = old_visible_prompt_func + termui.hidden_prompt_func = old_hidden_prompt_func + termui._getchar = old__getchar_func + utils.should_strip_ansi = old_should_strip_ansi + formatting.FORCED_WIDTH = old_forced_width + + def invoke( + self, + cli, + args=None, + input=None, + env=None, + catch_exceptions=True, + color=False, + **extra + ): + """Invokes a command in an isolated environment. The arguments are + forwarded directly to the command line script, the `extra` keyword + arguments are passed to the :meth:`~clickpkg.Command.main` function of + the command. + + This returns a :class:`Result` object. + + .. versionadded:: 3.0 + The ``catch_exceptions`` parameter was added. + + .. versionchanged:: 3.0 + The result object now has an `exc_info` attribute with the + traceback if available. + + .. versionadded:: 4.0 + The ``color`` parameter was added. + + :param cli: the command to invoke + :param args: the arguments to invoke. It may be given as an iterable + or a string. When given as string it will be interpreted + as a Unix shell command. More details at + :func:`shlex.split`. + :param input: the input data for `sys.stdin`. + :param env: the environment overrides. + :param catch_exceptions: Whether to catch any other exceptions than + ``SystemExit``. + :param extra: the keyword arguments to pass to :meth:`main`. + :param color: whether the output should contain color codes. The + application can still override this explicitly. + """ + exc_info = None + with self.isolation(input=input, env=env, color=color) as outstreams: + exception = None + exit_code = 0 + + if isinstance(args, string_types): + args = shlex.split(args) + + try: + prog_name = extra.pop("prog_name") + except KeyError: + prog_name = self.get_default_prog_name(cli) + + try: + cli.main(args=args or (), prog_name=prog_name, **extra) + except SystemExit as e: + exc_info = sys.exc_info() + exit_code = e.code + if exit_code is None: + exit_code = 0 + + if exit_code != 0: + exception = e + + if not isinstance(exit_code, int): + sys.stdout.write(str(exit_code)) + sys.stdout.write("\n") + exit_code = 1 + + except Exception as e: + if not catch_exceptions: + raise + exception = e + exit_code = 1 + exc_info = sys.exc_info() + finally: + sys.stdout.flush() + stdout = outstreams[0].getvalue() + if self.mix_stderr: + stderr = None + else: + stderr = outstreams[1].getvalue() + + return Result( + runner=self, + stdout_bytes=stdout, + stderr_bytes=stderr, + exit_code=exit_code, + exception=exception, + exc_info=exc_info, + ) + + @contextlib.contextmanager + def isolated_filesystem(self): + """A context manager that creates a temporary folder and changes + the current working directory to it for isolated filesystem tests. + """ + cwd = os.getcwd() + t = tempfile.mkdtemp() + os.chdir(t) + try: + yield t + finally: + os.chdir(cwd) + try: + shutil.rmtree(t) + except (OSError, IOError): # noqa: B014 + pass diff --git a/openpype/vendor/python/python_2/click/types.py b/openpype/vendor/python/python_2/click/types.py new file mode 100644 index 0000000000..505c39f850 --- /dev/null +++ b/openpype/vendor/python/python_2/click/types.py @@ -0,0 +1,762 @@ +import os +import stat +from datetime import datetime + +from ._compat import _get_argv_encoding +from ._compat import filename_to_ui +from ._compat import get_filesystem_encoding +from ._compat import get_streerror +from ._compat import open_stream +from ._compat import PY2 +from ._compat import text_type +from .exceptions import BadParameter +from .utils import LazyFile +from .utils import safecall + + +class ParamType(object): + """Helper for converting values through types. The following is + necessary for a valid type: + + * it needs a name + * it needs to pass through None unchanged + * it needs to convert from a string + * it needs to convert its result type through unchanged + (eg: needs to be idempotent) + * it needs to be able to deal with param and context being `None`. + This can be the case when the object is used with prompt + inputs. + """ + + is_composite = False + + #: the descriptive name of this type + name = None + + #: if a list of this type is expected and the value is pulled from a + #: string environment variable, this is what splits it up. `None` + #: means any whitespace. For all parameters the general rule is that + #: whitespace splits them up. The exception are paths and files which + #: are split by ``os.path.pathsep`` by default (":" on Unix and ";" on + #: Windows). + envvar_list_splitter = None + + def __call__(self, value, param=None, ctx=None): + if value is not None: + return self.convert(value, param, ctx) + + def get_metavar(self, param): + """Returns the metavar default for this param if it provides one.""" + + def get_missing_message(self, param): + """Optionally might return extra information about a missing + parameter. + + .. versionadded:: 2.0 + """ + + def convert(self, value, param, ctx): + """Converts the value. This is not invoked for values that are + `None` (the missing value). + """ + return value + + def split_envvar_value(self, rv): + """Given a value from an environment variable this splits it up + into small chunks depending on the defined envvar list splitter. + + If the splitter is set to `None`, which means that whitespace splits, + then leading and trailing whitespace is ignored. Otherwise, leading + and trailing splitters usually lead to empty items being included. + """ + return (rv or "").split(self.envvar_list_splitter) + + def fail(self, message, param=None, ctx=None): + """Helper method to fail with an invalid value message.""" + raise BadParameter(message, ctx=ctx, param=param) + + +class CompositeParamType(ParamType): + is_composite = True + + @property + def arity(self): + raise NotImplementedError() + + +class FuncParamType(ParamType): + def __init__(self, func): + self.name = func.__name__ + self.func = func + + def convert(self, value, param, ctx): + try: + return self.func(value) + except ValueError: + try: + value = text_type(value) + except UnicodeError: + value = str(value).decode("utf-8", "replace") + self.fail(value, param, ctx) + + +class UnprocessedParamType(ParamType): + name = "text" + + def convert(self, value, param, ctx): + return value + + def __repr__(self): + return "UNPROCESSED" + + +class StringParamType(ParamType): + name = "text" + + def convert(self, value, param, ctx): + if isinstance(value, bytes): + enc = _get_argv_encoding() + try: + value = value.decode(enc) + except UnicodeError: + fs_enc = get_filesystem_encoding() + if fs_enc != enc: + try: + value = value.decode(fs_enc) + except UnicodeError: + value = value.decode("utf-8", "replace") + else: + value = value.decode("utf-8", "replace") + return value + return value + + def __repr__(self): + return "STRING" + + +class Choice(ParamType): + """The choice type allows a value to be checked against a fixed set + of supported values. All of these values have to be strings. + + You should only pass a list or tuple of choices. Other iterables + (like generators) may lead to surprising results. + + The resulting value will always be one of the originally passed choices + regardless of ``case_sensitive`` or any ``ctx.token_normalize_func`` + being specified. + + See :ref:`choice-opts` for an example. + + :param case_sensitive: Set to false to make choices case + insensitive. Defaults to true. + """ + + name = "choice" + + def __init__(self, choices, case_sensitive=True): + self.choices = choices + self.case_sensitive = case_sensitive + + def get_metavar(self, param): + return "[{}]".format("|".join(self.choices)) + + def get_missing_message(self, param): + return "Choose from:\n\t{}.".format(",\n\t".join(self.choices)) + + def convert(self, value, param, ctx): + # Match through normalization and case sensitivity + # first do token_normalize_func, then lowercase + # preserve original `value` to produce an accurate message in + # `self.fail` + normed_value = value + normed_choices = {choice: choice for choice in self.choices} + + if ctx is not None and ctx.token_normalize_func is not None: + normed_value = ctx.token_normalize_func(value) + normed_choices = { + ctx.token_normalize_func(normed_choice): original + for normed_choice, original in normed_choices.items() + } + + if not self.case_sensitive: + if PY2: + lower = str.lower + else: + lower = str.casefold + + normed_value = lower(normed_value) + normed_choices = { + lower(normed_choice): original + for normed_choice, original in normed_choices.items() + } + + if normed_value in normed_choices: + return normed_choices[normed_value] + + self.fail( + "invalid choice: {}. (choose from {})".format( + value, ", ".join(self.choices) + ), + param, + ctx, + ) + + def __repr__(self): + return "Choice('{}')".format(list(self.choices)) + + +class DateTime(ParamType): + """The DateTime type converts date strings into `datetime` objects. + + The format strings which are checked are configurable, but default to some + common (non-timezone aware) ISO 8601 formats. + + When specifying *DateTime* formats, you should only pass a list or a tuple. + Other iterables, like generators, may lead to surprising results. + + The format strings are processed using ``datetime.strptime``, and this + consequently defines the format strings which are allowed. + + Parsing is tried using each format, in order, and the first format which + parses successfully is used. + + :param formats: A list or tuple of date format strings, in the order in + which they should be tried. Defaults to + ``'%Y-%m-%d'``, ``'%Y-%m-%dT%H:%M:%S'``, + ``'%Y-%m-%d %H:%M:%S'``. + """ + + name = "datetime" + + def __init__(self, formats=None): + self.formats = formats or ["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"] + + def get_metavar(self, param): + return "[{}]".format("|".join(self.formats)) + + def _try_to_convert_date(self, value, format): + try: + return datetime.strptime(value, format) + except ValueError: + return None + + def convert(self, value, param, ctx): + # Exact match + for format in self.formats: + dtime = self._try_to_convert_date(value, format) + if dtime: + return dtime + + self.fail( + "invalid datetime format: {}. (choose from {})".format( + value, ", ".join(self.formats) + ) + ) + + def __repr__(self): + return "DateTime" + + +class IntParamType(ParamType): + name = "integer" + + def convert(self, value, param, ctx): + try: + return int(value) + except ValueError: + self.fail("{} is not a valid integer".format(value), param, ctx) + + def __repr__(self): + return "INT" + + +class IntRange(IntParamType): + """A parameter that works similar to :data:`click.INT` but restricts + the value to fit into a range. The default behavior is to fail if the + value falls outside the range, but it can also be silently clamped + between the two edges. + + See :ref:`ranges` for an example. + """ + + name = "integer range" + + def __init__(self, min=None, max=None, clamp=False): + self.min = min + self.max = max + self.clamp = clamp + + def convert(self, value, param, ctx): + rv = IntParamType.convert(self, value, param, ctx) + if self.clamp: + if self.min is not None and rv < self.min: + return self.min + if self.max is not None and rv > self.max: + return self.max + if ( + self.min is not None + and rv < self.min + or self.max is not None + and rv > self.max + ): + if self.min is None: + self.fail( + "{} is bigger than the maximum valid value {}.".format( + rv, self.max + ), + param, + ctx, + ) + elif self.max is None: + self.fail( + "{} is smaller than the minimum valid value {}.".format( + rv, self.min + ), + param, + ctx, + ) + else: + self.fail( + "{} is not in the valid range of {} to {}.".format( + rv, self.min, self.max + ), + param, + ctx, + ) + return rv + + def __repr__(self): + return "IntRange({}, {})".format(self.min, self.max) + + +class FloatParamType(ParamType): + name = "float" + + def convert(self, value, param, ctx): + try: + return float(value) + except ValueError: + self.fail( + "{} is not a valid floating point value".format(value), param, ctx + ) + + def __repr__(self): + return "FLOAT" + + +class FloatRange(FloatParamType): + """A parameter that works similar to :data:`click.FLOAT` but restricts + the value to fit into a range. The default behavior is to fail if the + value falls outside the range, but it can also be silently clamped + between the two edges. + + See :ref:`ranges` for an example. + """ + + name = "float range" + + def __init__(self, min=None, max=None, clamp=False): + self.min = min + self.max = max + self.clamp = clamp + + def convert(self, value, param, ctx): + rv = FloatParamType.convert(self, value, param, ctx) + if self.clamp: + if self.min is not None and rv < self.min: + return self.min + if self.max is not None and rv > self.max: + return self.max + if ( + self.min is not None + and rv < self.min + or self.max is not None + and rv > self.max + ): + if self.min is None: + self.fail( + "{} is bigger than the maximum valid value {}.".format( + rv, self.max + ), + param, + ctx, + ) + elif self.max is None: + self.fail( + "{} is smaller than the minimum valid value {}.".format( + rv, self.min + ), + param, + ctx, + ) + else: + self.fail( + "{} is not in the valid range of {} to {}.".format( + rv, self.min, self.max + ), + param, + ctx, + ) + return rv + + def __repr__(self): + return "FloatRange({}, {})".format(self.min, self.max) + + +class BoolParamType(ParamType): + name = "boolean" + + def convert(self, value, param, ctx): + if isinstance(value, bool): + return bool(value) + value = value.lower() + if value in ("true", "t", "1", "yes", "y"): + return True + elif value in ("false", "f", "0", "no", "n"): + return False + self.fail("{} is not a valid boolean".format(value), param, ctx) + + def __repr__(self): + return "BOOL" + + +class UUIDParameterType(ParamType): + name = "uuid" + + def convert(self, value, param, ctx): + import uuid + + try: + if PY2 and isinstance(value, text_type): + value = value.encode("ascii") + return uuid.UUID(value) + except ValueError: + self.fail("{} is not a valid UUID value".format(value), param, ctx) + + def __repr__(self): + return "UUID" + + +class File(ParamType): + """Declares a parameter to be a file for reading or writing. The file + is automatically closed once the context tears down (after the command + finished working). + + Files can be opened for reading or writing. The special value ``-`` + indicates stdin or stdout depending on the mode. + + By default, the file is opened for reading text data, but it can also be + opened in binary mode or for writing. The encoding parameter can be used + to force a specific encoding. + + The `lazy` flag controls if the file should be opened immediately or upon + first IO. The default is to be non-lazy for standard input and output + streams as well as files opened for reading, `lazy` otherwise. When opening a + file lazily for reading, it is still opened temporarily for validation, but + will not be held open until first IO. lazy is mainly useful when opening + for writing to avoid creating the file until it is needed. + + Starting with Click 2.0, files can also be opened atomically in which + case all writes go into a separate file in the same folder and upon + completion the file will be moved over to the original location. This + is useful if a file regularly read by other users is modified. + + See :ref:`file-args` for more information. + """ + + name = "filename" + envvar_list_splitter = os.path.pathsep + + def __init__( + self, mode="r", encoding=None, errors="strict", lazy=None, atomic=False + ): + self.mode = mode + self.encoding = encoding + self.errors = errors + self.lazy = lazy + self.atomic = atomic + + def resolve_lazy_flag(self, value): + if self.lazy is not None: + return self.lazy + if value == "-": + return False + elif "w" in self.mode: + return True + return False + + def convert(self, value, param, ctx): + try: + if hasattr(value, "read") or hasattr(value, "write"): + return value + + lazy = self.resolve_lazy_flag(value) + + if lazy: + f = LazyFile( + value, self.mode, self.encoding, self.errors, atomic=self.atomic + ) + if ctx is not None: + ctx.call_on_close(f.close_intelligently) + return f + + f, should_close = open_stream( + value, self.mode, self.encoding, self.errors, atomic=self.atomic + ) + # If a context is provided, we automatically close the file + # at the end of the context execution (or flush out). If a + # context does not exist, it's the caller's responsibility to + # properly close the file. This for instance happens when the + # type is used with prompts. + if ctx is not None: + if should_close: + ctx.call_on_close(safecall(f.close)) + else: + ctx.call_on_close(safecall(f.flush)) + return f + except (IOError, OSError) as e: # noqa: B014 + self.fail( + "Could not open file: {}: {}".format( + filename_to_ui(value), get_streerror(e) + ), + param, + ctx, + ) + + +class Path(ParamType): + """The path type is similar to the :class:`File` type but it performs + different checks. First of all, instead of returning an open file + handle it returns just the filename. Secondly, it can perform various + basic checks about what the file or directory should be. + + .. versionchanged:: 6.0 + `allow_dash` was added. + + :param exists: if set to true, the file or directory needs to exist for + this value to be valid. If this is not required and a + file does indeed not exist, then all further checks are + silently skipped. + :param file_okay: controls if a file is a possible value. + :param dir_okay: controls if a directory is a possible value. + :param writable: if true, a writable check is performed. + :param readable: if true, a readable check is performed. + :param resolve_path: if this is true, then the path is fully resolved + before the value is passed onwards. This means + that it's absolute and symlinks are resolved. It + will not expand a tilde-prefix, as this is + supposed to be done by the shell only. + :param allow_dash: If this is set to `True`, a single dash to indicate + standard streams is permitted. + :param path_type: optionally a string type that should be used to + represent the path. The default is `None` which + means the return value will be either bytes or + unicode depending on what makes most sense given the + input data Click deals with. + """ + + envvar_list_splitter = os.path.pathsep + + def __init__( + self, + exists=False, + file_okay=True, + dir_okay=True, + writable=False, + readable=True, + resolve_path=False, + allow_dash=False, + path_type=None, + ): + self.exists = exists + self.file_okay = file_okay + self.dir_okay = dir_okay + self.writable = writable + self.readable = readable + self.resolve_path = resolve_path + self.allow_dash = allow_dash + self.type = path_type + + if self.file_okay and not self.dir_okay: + self.name = "file" + self.path_type = "File" + elif self.dir_okay and not self.file_okay: + self.name = "directory" + self.path_type = "Directory" + else: + self.name = "path" + self.path_type = "Path" + + def coerce_path_result(self, rv): + if self.type is not None and not isinstance(rv, self.type): + if self.type is text_type: + rv = rv.decode(get_filesystem_encoding()) + else: + rv = rv.encode(get_filesystem_encoding()) + return rv + + def convert(self, value, param, ctx): + rv = value + + is_dash = self.file_okay and self.allow_dash and rv in (b"-", "-") + + if not is_dash: + if self.resolve_path: + rv = os.path.realpath(rv) + + try: + st = os.stat(rv) + except OSError: + if not self.exists: + return self.coerce_path_result(rv) + self.fail( + "{} '{}' does not exist.".format( + self.path_type, filename_to_ui(value) + ), + param, + ctx, + ) + + if not self.file_okay and stat.S_ISREG(st.st_mode): + self.fail( + "{} '{}' is a file.".format(self.path_type, filename_to_ui(value)), + param, + ctx, + ) + if not self.dir_okay and stat.S_ISDIR(st.st_mode): + self.fail( + "{} '{}' is a directory.".format( + self.path_type, filename_to_ui(value) + ), + param, + ctx, + ) + if self.writable and not os.access(value, os.W_OK): + self.fail( + "{} '{}' is not writable.".format( + self.path_type, filename_to_ui(value) + ), + param, + ctx, + ) + if self.readable and not os.access(value, os.R_OK): + self.fail( + "{} '{}' is not readable.".format( + self.path_type, filename_to_ui(value) + ), + param, + ctx, + ) + + return self.coerce_path_result(rv) + + +class Tuple(CompositeParamType): + """The default behavior of Click is to apply a type on a value directly. + This works well in most cases, except for when `nargs` is set to a fixed + count and different types should be used for different items. In this + case the :class:`Tuple` type can be used. This type can only be used + if `nargs` is set to a fixed number. + + For more information see :ref:`tuple-type`. + + This can be selected by using a Python tuple literal as a type. + + :param types: a list of types that should be used for the tuple items. + """ + + def __init__(self, types): + self.types = [convert_type(ty) for ty in types] + + @property + def name(self): + return "<{}>".format(" ".join(ty.name for ty in self.types)) + + @property + def arity(self): + return len(self.types) + + def convert(self, value, param, ctx): + if len(value) != len(self.types): + raise TypeError( + "It would appear that nargs is set to conflict with the" + " composite type arity." + ) + return tuple(ty(x, param, ctx) for ty, x in zip(self.types, value)) + + +def convert_type(ty, default=None): + """Converts a callable or python type into the most appropriate + param type. + """ + guessed_type = False + if ty is None and default is not None: + if isinstance(default, tuple): + ty = tuple(map(type, default)) + else: + ty = type(default) + guessed_type = True + + if isinstance(ty, tuple): + return Tuple(ty) + if isinstance(ty, ParamType): + return ty + if ty is text_type or ty is str or ty is None: + return STRING + if ty is int: + return INT + # Booleans are only okay if not guessed. This is done because for + # flags the default value is actually a bit of a lie in that it + # indicates which of the flags is the one we want. See get_default() + # for more information. + if ty is bool and not guessed_type: + return BOOL + if ty is float: + return FLOAT + if guessed_type: + return STRING + + # Catch a common mistake + if __debug__: + try: + if issubclass(ty, ParamType): + raise AssertionError( + "Attempted to use an uninstantiated parameter type ({}).".format(ty) + ) + except TypeError: + pass + return FuncParamType(ty) + + +#: A dummy parameter type that just does nothing. From a user's +#: perspective this appears to just be the same as `STRING` but internally +#: no string conversion takes place. This is necessary to achieve the +#: same bytes/unicode behavior on Python 2/3 in situations where you want +#: to not convert argument types. This is usually useful when working +#: with file paths as they can appear in bytes and unicode. +#: +#: For path related uses the :class:`Path` type is a better choice but +#: there are situations where an unprocessed type is useful which is why +#: it is is provided. +#: +#: .. versionadded:: 4.0 +UNPROCESSED = UnprocessedParamType() + +#: A unicode string parameter type which is the implicit default. This +#: can also be selected by using ``str`` as type. +STRING = StringParamType() + +#: An integer parameter. This can also be selected by using ``int`` as +#: type. +INT = IntParamType() + +#: A floating point value parameter. This can also be selected by using +#: ``float`` as type. +FLOAT = FloatParamType() + +#: A boolean parameter. This is the default for boolean flags. This can +#: also be selected by using ``bool`` as a type. +BOOL = BoolParamType() + +#: A UUID parameter. +UUID = UUIDParameterType() diff --git a/openpype/vendor/python/python_2/click/utils.py b/openpype/vendor/python/python_2/click/utils.py new file mode 100644 index 0000000000..79265e732d --- /dev/null +++ b/openpype/vendor/python/python_2/click/utils.py @@ -0,0 +1,455 @@ +import os +import sys + +from ._compat import _default_text_stderr +from ._compat import _default_text_stdout +from ._compat import auto_wrap_for_ansi +from ._compat import binary_streams +from ._compat import filename_to_ui +from ._compat import get_filesystem_encoding +from ._compat import get_streerror +from ._compat import is_bytes +from ._compat import open_stream +from ._compat import PY2 +from ._compat import should_strip_ansi +from ._compat import string_types +from ._compat import strip_ansi +from ._compat import text_streams +from ._compat import text_type +from ._compat import WIN +from .globals import resolve_color_default + +if not PY2: + from ._compat import _find_binary_writer +elif WIN: + from ._winconsole import _get_windows_argv + from ._winconsole import _hash_py_argv + from ._winconsole import _initial_argv_hash + +echo_native_types = string_types + (bytes, bytearray) + + +def _posixify(name): + return "-".join(name.split()).lower() + + +def safecall(func): + """Wraps a function so that it swallows exceptions.""" + + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception: + pass + + return wrapper + + +def make_str(value): + """Converts a value into a valid string.""" + if isinstance(value, bytes): + try: + return value.decode(get_filesystem_encoding()) + except UnicodeError: + return value.decode("utf-8", "replace") + return text_type(value) + + +def make_default_short_help(help, max_length=45): + """Return a condensed version of help string.""" + words = help.split() + total_length = 0 + result = [] + done = False + + for word in words: + if word[-1:] == ".": + done = True + new_length = 1 + len(word) if result else len(word) + if total_length + new_length > max_length: + result.append("...") + done = True + else: + if result: + result.append(" ") + result.append(word) + if done: + break + total_length += new_length + + return "".join(result) + + +class LazyFile(object): + """A lazy file works like a regular file but it does not fully open + the file but it does perform some basic checks early to see if the + filename parameter does make sense. This is useful for safely opening + files for writing. + """ + + def __init__( + self, filename, mode="r", encoding=None, errors="strict", atomic=False + ): + self.name = filename + self.mode = mode + self.encoding = encoding + self.errors = errors + self.atomic = atomic + + if filename == "-": + self._f, self.should_close = open_stream(filename, mode, encoding, errors) + else: + if "r" in mode: + # Open and close the file in case we're opening it for + # reading so that we can catch at least some errors in + # some cases early. + open(filename, mode).close() + self._f = None + self.should_close = True + + def __getattr__(self, name): + return getattr(self.open(), name) + + def __repr__(self): + if self._f is not None: + return repr(self._f) + return "".format(self.name, self.mode) + + def open(self): + """Opens the file if it's not yet open. This call might fail with + a :exc:`FileError`. Not handling this error will produce an error + that Click shows. + """ + if self._f is not None: + return self._f + try: + rv, self.should_close = open_stream( + self.name, self.mode, self.encoding, self.errors, atomic=self.atomic + ) + except (IOError, OSError) as e: # noqa: E402 + from .exceptions import FileError + + raise FileError(self.name, hint=get_streerror(e)) + self._f = rv + return rv + + def close(self): + """Closes the underlying file, no matter what.""" + if self._f is not None: + self._f.close() + + def close_intelligently(self): + """This function only closes the file if it was opened by the lazy + file wrapper. For instance this will never close stdin. + """ + if self.should_close: + self.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, tb): + self.close_intelligently() + + def __iter__(self): + self.open() + return iter(self._f) + + +class KeepOpenFile(object): + def __init__(self, file): + self._file = file + + def __getattr__(self, name): + return getattr(self._file, name) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, tb): + pass + + def __repr__(self): + return repr(self._file) + + def __iter__(self): + return iter(self._file) + + +def echo(message=None, file=None, nl=True, err=False, color=None): + """Prints a message plus a newline to the given file or stdout. On + first sight, this looks like the print function, but it has improved + support for handling Unicode and binary data that does not fail no + matter how badly configured the system is. + + Primarily it means that you can print binary data as well as Unicode + data on both 2.x and 3.x to the given file in the most appropriate way + possible. This is a very carefree function in that it will try its + best to not fail. As of Click 6.0 this includes support for unicode + output on the Windows console. + + In addition to that, if `colorama`_ is installed, the echo function will + also support clever handling of ANSI codes. Essentially it will then + do the following: + + - add transparent handling of ANSI color codes on Windows. + - hide ANSI codes automatically if the destination file is not a + terminal. + + .. _colorama: https://pypi.org/project/colorama/ + + .. versionchanged:: 6.0 + As of Click 6.0 the echo function will properly support unicode + output on the windows console. Not that click does not modify + the interpreter in any way which means that `sys.stdout` or the + print statement or function will still not provide unicode support. + + .. versionchanged:: 2.0 + Starting with version 2.0 of Click, the echo function will work + with colorama if it's installed. + + .. versionadded:: 3.0 + The `err` parameter was added. + + .. versionchanged:: 4.0 + Added the `color` flag. + + :param message: the message to print + :param file: the file to write to (defaults to ``stdout``) + :param err: if set to true the file defaults to ``stderr`` instead of + ``stdout``. This is faster and easier than calling + :func:`get_text_stderr` yourself. + :param nl: if set to `True` (the default) a newline is printed afterwards. + :param color: controls if the terminal supports ANSI colors or not. The + default is autodetection. + """ + if file is None: + if err: + file = _default_text_stderr() + else: + file = _default_text_stdout() + + # Convert non bytes/text into the native string type. + if message is not None and not isinstance(message, echo_native_types): + message = text_type(message) + + if nl: + message = message or u"" + if isinstance(message, text_type): + message += u"\n" + else: + message += b"\n" + + # If there is a message, and we're in Python 3, and the value looks + # like bytes, we manually need to find the binary stream and write the + # message in there. This is done separately so that most stream + # types will work as you would expect. Eg: you can write to StringIO + # for other cases. + if message and not PY2 and is_bytes(message): + binary_file = _find_binary_writer(file) + if binary_file is not None: + file.flush() + binary_file.write(message) + binary_file.flush() + return + + # ANSI-style support. If there is no message or we are dealing with + # bytes nothing is happening. If we are connected to a file we want + # to strip colors. If we are on windows we either wrap the stream + # to strip the color or we use the colorama support to translate the + # ansi codes to API calls. + if message and not is_bytes(message): + color = resolve_color_default(color) + if should_strip_ansi(file, color): + message = strip_ansi(message) + elif WIN: + if auto_wrap_for_ansi is not None: + file = auto_wrap_for_ansi(file) + elif not color: + message = strip_ansi(message) + + if message: + file.write(message) + file.flush() + + +def get_binary_stream(name): + """Returns a system stream for byte processing. This essentially + returns the stream from the sys module with the given name but it + solves some compatibility issues between different Python versions. + Primarily this function is necessary for getting binary streams on + Python 3. + + :param name: the name of the stream to open. Valid names are ``'stdin'``, + ``'stdout'`` and ``'stderr'`` + """ + opener = binary_streams.get(name) + if opener is None: + raise TypeError("Unknown standard stream '{}'".format(name)) + return opener() + + +def get_text_stream(name, encoding=None, errors="strict"): + """Returns a system stream for text processing. This usually returns + a wrapped stream around a binary stream returned from + :func:`get_binary_stream` but it also can take shortcuts on Python 3 + for already correctly configured streams. + + :param name: the name of the stream to open. Valid names are ``'stdin'``, + ``'stdout'`` and ``'stderr'`` + :param encoding: overrides the detected default encoding. + :param errors: overrides the default error mode. + """ + opener = text_streams.get(name) + if opener is None: + raise TypeError("Unknown standard stream '{}'".format(name)) + return opener(encoding, errors) + + +def open_file( + filename, mode="r", encoding=None, errors="strict", lazy=False, atomic=False +): + """This is similar to how the :class:`File` works but for manual + usage. Files are opened non lazy by default. This can open regular + files as well as stdin/stdout if ``'-'`` is passed. + + If stdin/stdout is returned the stream is wrapped so that the context + manager will not close the stream accidentally. This makes it possible + to always use the function like this without having to worry to + accidentally close a standard stream:: + + with open_file(filename) as f: + ... + + .. versionadded:: 3.0 + + :param filename: the name of the file to open (or ``'-'`` for stdin/stdout). + :param mode: the mode in which to open the file. + :param encoding: the encoding to use. + :param errors: the error handling for this file. + :param lazy: can be flipped to true to open the file lazily. + :param atomic: in atomic mode writes go into a temporary file and it's + moved on close. + """ + if lazy: + return LazyFile(filename, mode, encoding, errors, atomic=atomic) + f, should_close = open_stream(filename, mode, encoding, errors, atomic=atomic) + if not should_close: + f = KeepOpenFile(f) + return f + + +def get_os_args(): + """This returns the argument part of sys.argv in the most appropriate + form for processing. What this means is that this return value is in + a format that works for Click to process but does not necessarily + correspond well to what's actually standard for the interpreter. + + On most environments the return value is ``sys.argv[:1]`` unchanged. + However if you are on Windows and running Python 2 the return value + will actually be a list of unicode strings instead because the + default behavior on that platform otherwise will not be able to + carry all possible values that sys.argv can have. + + .. versionadded:: 6.0 + """ + # We can only extract the unicode argv if sys.argv has not been + # changed since the startup of the application. + if PY2 and WIN and _initial_argv_hash == _hash_py_argv(): + return _get_windows_argv() + return sys.argv[1:] + + +def format_filename(filename, shorten=False): + """Formats a filename for user display. The main purpose of this + function is to ensure that the filename can be displayed at all. This + will decode the filename to unicode if necessary in a way that it will + not fail. Optionally, it can shorten the filename to not include the + full path to the filename. + + :param filename: formats a filename for UI display. This will also convert + the filename into unicode without failing. + :param shorten: this optionally shortens the filename to strip of the + path that leads up to it. + """ + if shorten: + filename = os.path.basename(filename) + return filename_to_ui(filename) + + +def get_app_dir(app_name, roaming=True, force_posix=False): + r"""Returns the config folder for the application. The default behavior + is to return whatever is most appropriate for the operating system. + + To give you an idea, for an app called ``"Foo Bar"``, something like + the following folders could be returned: + + Mac OS X: + ``~/Library/Application Support/Foo Bar`` + Mac OS X (POSIX): + ``~/.foo-bar`` + Unix: + ``~/.config/foo-bar`` + Unix (POSIX): + ``~/.foo-bar`` + Win XP (roaming): + ``C:\Documents and Settings\\Local Settings\Application Data\Foo Bar`` + Win XP (not roaming): + ``C:\Documents and Settings\\Application Data\Foo Bar`` + Win 7 (roaming): + ``C:\Users\\AppData\Roaming\Foo Bar`` + Win 7 (not roaming): + ``C:\Users\\AppData\Local\Foo Bar`` + + .. versionadded:: 2.0 + + :param app_name: the application name. This should be properly capitalized + and can contain whitespace. + :param roaming: controls if the folder should be roaming or not on Windows. + Has no affect otherwise. + :param force_posix: if this is set to `True` then on any POSIX system the + folder will be stored in the home folder with a leading + dot instead of the XDG config home or darwin's + application support folder. + """ + if WIN: + key = "APPDATA" if roaming else "LOCALAPPDATA" + folder = os.environ.get(key) + if folder is None: + folder = os.path.expanduser("~") + return os.path.join(folder, app_name) + if force_posix: + return os.path.join(os.path.expanduser("~/.{}".format(_posixify(app_name)))) + if sys.platform == "darwin": + return os.path.join( + os.path.expanduser("~/Library/Application Support"), app_name + ) + return os.path.join( + os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), + _posixify(app_name), + ) + + +class PacifyFlushWrapper(object): + """This wrapper is used to catch and suppress BrokenPipeErrors resulting + from ``.flush()`` being called on broken pipe during the shutdown/final-GC + of the Python interpreter. Notably ``.flush()`` is always called on + ``sys.stdout`` and ``sys.stderr``. So as to have minimal impact on any + other cleanup code, and the case where the underlying file is not a broken + pipe, all calls and attributes are proxied. + """ + + def __init__(self, wrapped): + self.wrapped = wrapped + + def flush(self): + try: + self.wrapped.flush() + except IOError as e: + import errno + + if e.errno != errno.EPIPE: + raise + + def __getattr__(self, attr): + return getattr(self.wrapped, attr) From a8ab471f84dfc8f87dc0b7a368132ca81cda156e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 1 Aug 2023 12:05:23 +0200 Subject: [PATCH 048/107] OP-4845 - removed unnecessary env var Used only when connecting to OP MongoDB. --- .../deadline/repository/custom/plugins/GlobalJobPreLoad.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index f3e49efefd..5f7e1f1032 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -483,8 +483,6 @@ def inject_ayon_environment(deadlinePlugin): " AVALON_TASK, AVALON_APP_NAME" )) - os.environ["AVALON_TIMEOUT"] = "5000" - environment = { "AYON_SERVER_URL": ayon_server_url, "AYON_API_KEY": ayon_api_key, From 6c8d2f23072569ae9bec7a5ce648ccc977d6f9e0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 1 Aug 2023 14:40:17 +0200 Subject: [PATCH 049/107] fixing sequence loading --- openpype/hosts/nuke/plugins/load/load_clip.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_clip.py b/openpype/hosts/nuke/plugins/load/load_clip.py index 5539324fb7..19038b168d 100644 --- a/openpype/hosts/nuke/plugins/load/load_clip.py +++ b/openpype/hosts/nuke/plugins/load/load_clip.py @@ -91,14 +91,14 @@ class LoadClip(plugin.NukeLoader): # reset container id so it is always unique for each instance self.reset_container_id() - self.log.warning(self.extensions) - is_sequence = len(representation["files"]) > 1 if is_sequence: - representation = self._representation_with_hash_in_frame( - representation + context["representation"] = \ + self._representation_with_hash_in_frame( + representation ) + filepath = self.filepath_from_context(context) filepath = filepath.replace("\\", "/") self.log.debug("_ filepath: {}".format(filepath)) @@ -260,6 +260,7 @@ class LoadClip(plugin.NukeLoader): representation = self._representation_with_hash_in_frame( representation ) + filepath = get_representation_path(representation).replace("\\", "/") self.log.debug("_ filepath: {}".format(filepath)) From d33d20b5485c70e037abab021f9da687234f8cbe Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 1 Aug 2023 15:37:57 +0200 Subject: [PATCH 050/107] Nuke: returned not cleaning of renders folder on the farm (#5374) * OP-6439 - mark farm rendered images for deletion only if not persistent Farm produces images into `renders` folder, which might be set as persistent for some hosts (Nuke). Mark rendered images for explicit deletion if they are not stored in persistent staging folder. * OP-6439 - allow storing of stagingDir_persistent into metadata.json Instance could carry `stagingDir_persistent` flag denoting that staging dir shouldnt be deleted. This allow to propagate this into farm publishing. TODO - shouldnt this be on representation as stagingDir is there and each repre could have different stagingDir? * OP-6439 - mark all Nuke staging dir as persistent Backward compatibility as previously Nuke kept images rendered in `renders` eg. stagingDir. There are workflows which rely on presence of files in `renders` folder. * Update openpype/pipeline/farm/pyblish_functions.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --------- Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/nuke/plugins/publish/collect_writes.py | 6 ++++++ openpype/pipeline/farm/pyblish_functions.py | 3 +++ openpype/pipeline/publish/lib.py | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/collect_writes.py b/openpype/hosts/nuke/plugins/publish/collect_writes.py index 2d1caacdc3..1eb1e1350f 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_writes.py +++ b/openpype/hosts/nuke/plugins/publish/collect_writes.py @@ -193,4 +193,10 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, if not instance.data.get("review"): instance.data["useSequenceForReview"] = False + # TODO temporarily set stagingDir as persistent for backward + # compatibility. This is mainly focused on `renders`folders which + # were previously not cleaned up (and could be used in read notes) + # this logic should be removed and replaced with custom staging dir + instance.data["stagingDir_persistent"] = True + self.log.debug("instance.data: {}".format(pformat(instance.data))) diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index e979c2d6ae..9278b0efc5 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -268,6 +268,9 @@ def create_skeleton_instance( instance_skeleton_data["representations"] = [] instance_skeleton_data["representations"] += representations + persistent = instance.data.get("stagingDir_persistent") is True + instance_skeleton_data["stagingDir_persistent"] = persistent + return instance_skeleton_data diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index 2768fe3fa1..c14b6d2445 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -981,7 +981,7 @@ def add_repre_files_for_cleanup(instance, repre): """ files = repre["files"] staging_dir = repre.get("stagingDir") - if not staging_dir: + if not staging_dir or instance.data.get("stagingDir_persistent"): return if isinstance(files, str): From caa6a7d5f70c651d690041833ca82abb0b48ab61 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 1 Aug 2023 16:39:21 +0200 Subject: [PATCH 051/107] cosmetics --- openpype/hosts/nuke/api/pipeline.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index fcc3becd2d..a48ae0032a 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -155,6 +155,7 @@ def add_nuke_callbacks(): """ nuke_settings = get_current_project_settings()["nuke"] workfile_settings = WorkfileSettings() + # Set context settings. nuke.addOnCreate( workfile_settings.set_context_settings, nodeClass="Root") @@ -173,7 +174,7 @@ def add_nuke_callbacks(): nuke.addOnScriptLoad(WorkfileSettings().set_context_settings) if nuke_settings["nuke-dirmap"]["enabled"]: - log.info("Added Nuke's dirmaping callback ...") + log.info("Added Nuke's dir-mapping callback ...") # Add dirmap for file paths. nuke.addFilenameFilter(dirmap_file_name_filter) From 93e02e88553f9d693f2a391eef388af3482f712c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Aug 2023 16:55:15 +0200 Subject: [PATCH 052/107] AYON: Fix settings conversion for ayon addons (#5377) * fix settings conversion for ayon addons * Removed empty line --- openpype/settings/ayon_settings.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 90c7f33fd2..cd12a8f757 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -267,6 +267,7 @@ def _convert_modules_system( ): func(ayon_settings, output, addon_versions, default_settings) + modules_settings = output["modules"] for module_name in ( "sync_server", "log_viewer", @@ -279,7 +280,16 @@ def _convert_modules_system( settings = default_settings["modules"][module_name] if "enabled" in settings: settings["enabled"] = False - output["modules"][module_name] = settings + modules_settings[module_name] = settings + + for key, value in ayon_settings.items(): + if key not in output: + output[key] = value + + # Make sure addons have access to settings in initialization + # - ModulesManager passes only modules settings into initialization + if key not in modules_settings: + modules_settings[key] = value def convert_system_settings(ayon_settings, default_settings, addon_versions): @@ -293,15 +303,16 @@ def convert_system_settings(ayon_settings, default_settings, addon_versions): if "core" in ayon_settings: _convert_general(ayon_settings, output, default_settings) + for key, value in default_settings.items(): + if key not in output: + output[key] = value + _convert_modules_system( ayon_settings, output, addon_versions, default_settings ) - for key, value in default_settings.items(): - if key not in output: - output[key] = value return output From 6cb9779e6a745857c1d9cbc6bca1afea3c0124d6 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 2 Aug 2023 03:24:56 +0000 Subject: [PATCH 053/107] [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 61bb0f8288..bbe452aeba 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.3-nightly.2" +__version__ = "3.16.3-nightly.3" From 31fc87a66784b6f608260be2666f79250a79f2c7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 2 Aug 2023 03:25:40 +0000 Subject: [PATCH 054/107] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 387b5574ab..b6a243bcfe 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.16.3-nightly.3 - 3.16.3-nightly.2 - 3.16.3-nightly.1 - 3.16.2 @@ -134,7 +135,6 @@ body: - 3.14.7-nightly.4 - 3.14.7-nightly.3 - 3.14.7-nightly.2 - - 3.14.7-nightly.1 validations: required: true - type: dropdown From 48ac6c8ed1049b21920aaeee87cc143eb8f8bcb7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Aug 2023 10:09:11 +0200 Subject: [PATCH 055/107] define 'AYON_UNREAL_ROOT' environment variable in unreal addon --- openpype/hosts/unreal/addon.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/hosts/unreal/addon.py b/openpype/hosts/unreal/addon.py index 3225d742a3..fcc5d98ab6 100644 --- a/openpype/hosts/unreal/addon.py +++ b/openpype/hosts/unreal/addon.py @@ -12,6 +12,11 @@ class UnrealAddon(OpenPypeModule, IHostAddon): def initialize(self, module_settings): self.enabled = True + def get_global_environments(self): + return { + "AYON_UNREAL_ROOT": UNREAL_ROOT_DIR, + } + def add_implementation_envs(self, env, app): """Modify environments to contain all required for implementation.""" # Set AYON_UNREAL_PLUGIN required for Unreal implementation From ad82adbeca40a9cb8672916d20add28032b6405e Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 2 Aug 2023 10:21:27 +0100 Subject: [PATCH 056/107] Use new env variable to get integration path --- openpype/hosts/unreal/lib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/unreal/lib.py b/openpype/hosts/unreal/lib.py index 0c39773c19..53a70c9b87 100644 --- a/openpype/hosts/unreal/lib.py +++ b/openpype/hosts/unreal/lib.py @@ -369,11 +369,11 @@ def get_compatible_integration( def get_path_to_cmdlet_project(ue_version: str) -> Path: cmd_project = Path( - os.path.abspath(os.getenv("OPENPYPE_ROOT"))) + os.path.abspath(os.getenv("AYON_UNREAL_ROOT"))) # For now, only tested on Windows (For Linux and Mac # it has to be implemented) - cmd_project /= f"openpype/hosts/unreal/integration/UE_{ue_version}" + cmd_project /= f"integration/UE_{ue_version}" # if the integration doesn't exist for current engine version # try to find the closest to it. From 7a5ecce6cc98bb4eefb25ccbd89e36aa193b0d61 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 2 Aug 2023 10:46:53 +0100 Subject: [PATCH 057/107] Better way to get integration path Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/unreal/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/lib.py b/openpype/hosts/unreal/lib.py index 53a70c9b87..6d544f65b2 100644 --- a/openpype/hosts/unreal/lib.py +++ b/openpype/hosts/unreal/lib.py @@ -369,7 +369,7 @@ def get_compatible_integration( def get_path_to_cmdlet_project(ue_version: str) -> Path: cmd_project = Path( - os.path.abspath(os.getenv("AYON_UNREAL_ROOT"))) + os.path.dirname(os.path.abspath(__file__))) # For now, only tested on Windows (For Linux and Mac # it has to be implemented) From ebd0d016b2ff03416441db7b28aef520b075796c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Aug 2023 13:02:34 +0200 Subject: [PATCH 058/107] updated unreal integration submodule --- openpype/hosts/unreal/integration | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/integration b/openpype/hosts/unreal/integration index ff15c70077..63266607ce 160000 --- a/openpype/hosts/unreal/integration +++ b/openpype/hosts/unreal/integration @@ -1 +1 @@ -Subproject commit ff15c700771e719cc5f3d561ac5d6f7590623986 +Subproject commit 63266607ceb972a61484f046634ddfc9eb0b5757 From 31eabd4e6336606da8d3176421de447808adc553 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 2 Aug 2023 13:48:15 +0200 Subject: [PATCH 059/107] OP-4845 - sanitizing deadline url Deadline behaves weirdly if trailing slash is left in webservice url. This should remove it. --- .../plugins/publish/collect_deadline_server_from_instance.py | 2 ++ .../plugins/publish/collect_default_deadline_server.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py b/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py index 2de6073e29..eadfc3c83e 100644 --- a/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py +++ b/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py @@ -21,6 +21,8 @@ class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): def process(self, instance): instance.data["deadlineUrl"] = self._collect_deadline_url(instance) + instance.data["deadlineUrl"] = \ + instance.data["deadlineUrl"].strip().rstrip("/") self.log.info( "Using {} for submission.".format(instance.data["deadlineUrl"])) diff --git a/openpype/modules/deadline/plugins/publish/collect_default_deadline_server.py b/openpype/modules/deadline/plugins/publish/collect_default_deadline_server.py index 1a0d615dc3..58721efad3 100644 --- a/openpype/modules/deadline/plugins/publish/collect_default_deadline_server.py +++ b/openpype/modules/deadline/plugins/publish/collect_default_deadline_server.py @@ -48,3 +48,6 @@ class CollectDefaultDeadlineServer(pyblish.api.ContextPlugin): context.data["defaultDeadline"] = deadline_webservice self.log.debug("Overriding from project settings with {}".format( # noqa: E501 deadline_webservice)) + + context.data["defaultDeadline"] = \ + context.data["defaultDeadline"].strip().rstrip("/") From b8e7ec291253c25f995539a171acb398c15b270b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 2 Aug 2023 14:08:22 +0200 Subject: [PATCH 060/107] Update openpype/modules/deadline/abstract_submit_deadline.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/modules/deadline/abstract_submit_deadline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/deadline/abstract_submit_deadline.py b/openpype/modules/deadline/abstract_submit_deadline.py index 9fcff111e6..3300bad6a9 100644 --- a/openpype/modules/deadline/abstract_submit_deadline.py +++ b/openpype/modules/deadline/abstract_submit_deadline.py @@ -399,7 +399,7 @@ class DeadlineJobInfo(object): def add_render_job_env_var(self): """Check if in OP or AYON mode and use appropriate env var.""" - if os.environ.get("USE_AYON_SERVER") == '1': + if AYON_SERVER_ENABLED: self.EnvironmentKeyValue["AYON_RENDER_JOB"] = "1" self.EnvironmentKeyValue["AYON_BUNDLE_NAME"] = ( os.environ["AYON_BUNDLE_NAME"]) From 67149111928fee1eae26bf93069a50c128e1cc29 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 2 Aug 2023 14:08:38 +0200 Subject: [PATCH 061/107] Update openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../modules/deadline/plugins/publish/submit_nuke_deadline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index 8f68a3a480..d427931c16 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -339,7 +339,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, # to recognize render jobs render_job_label = ( - "AYON_RENDER_JOB" if os.environ.get("USE_AYON_SERVER") == '1' + "AYON_RENDER_JOB" if AYON_SERVER_ENABLED else "OPENPYPE_RENDER_JOB") environment[render_job_label] = "1" From ee0a39d945a1b173432926231858ff1cfcecf6ef Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 2 Aug 2023 14:08:53 +0200 Subject: [PATCH 062/107] Update openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../plugins/publish/submit_maya_remote_publish_deadline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py index d7440fd0f4..a9d4f7fbe8 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py @@ -118,7 +118,7 @@ class MayaSubmitRemotePublishDeadline( environment["OPENPYPE_PUBLISH_SUBSET"] = instance.data["subset"] environment["OPENPYPE_REMOTE_PUBLISH"] = "1" - if os.environ.get("USE_AYON_SERVER") == '1': + if AYON_SERVER_ENABLED: environment["AYON_REMOTE_PUBLISH"] = "1" else: environment["OPENPYPE_REMOTE_PUBLISH"] = "1" From 11c766eca742ac6c760a3bfcda1ffed397ee09d0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 2 Aug 2023 14:09:03 +0200 Subject: [PATCH 063/107] Update openpype/modules/deadline/plugins/publish/submit_publish_job.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/modules/deadline/plugins/publish/submit_publish_job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index fc119a655a..0c25bda049 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -202,7 +202,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "IS_TEST": str(int(is_in_tests())) } - if os.environ.get("USE_AYON_SERVER") == '1': + if AYON_SERVER_ENABLED: environment["AYON_PUBLISH_JOB"] = "1" environment["AYON_RENDER_JOB"] = "0" environment["AYON_REMOTE_PUBLISH"] = "0" From 4b6bee1c76fde36befa659100df4cf1003576edf Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 2 Aug 2023 14:13:56 +0200 Subject: [PATCH 064/107] OP-4845 - fix missing import AYON_SERVER_ENABLED --- openpype/modules/deadline/abstract_submit_deadline.py | 1 + .../plugins/publish/submit_maya_remote_publish_deadline.py | 1 + .../modules/deadline/plugins/publish/submit_nuke_deadline.py | 2 ++ openpype/modules/deadline/plugins/publish/submit_publish_job.py | 1 + 4 files changed, 5 insertions(+) diff --git a/openpype/modules/deadline/abstract_submit_deadline.py b/openpype/modules/deadline/abstract_submit_deadline.py index 3300bad6a9..23e959d84c 100644 --- a/openpype/modules/deadline/abstract_submit_deadline.py +++ b/openpype/modules/deadline/abstract_submit_deadline.py @@ -25,6 +25,7 @@ from openpype.pipeline.publish import ( from openpype.pipeline.publish.lib import ( replace_with_published_scene_path ) +from openpype import AYON_SERVER_ENABLED JSONDecodeError = getattr(json.decoder, "JSONDecodeError", ValueError) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py index a9d4f7fbe8..988f8d106a 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py @@ -4,6 +4,7 @@ from datetime import datetime from maya import cmds +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import legacy_io, PublishXmlValidationError from openpype.tests.lib import is_in_tests from openpype.lib import is_running_from_build diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index d427931c16..2bb7ca9662 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -8,6 +8,8 @@ import requests import pyblish.api import nuke + +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import legacy_io from openpype.pipeline.publish import ( OpenPypePyblishPluginMixin diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 0c25bda049..8d46f8241e 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -15,6 +15,7 @@ from openpype.client import ( from openpype.pipeline import ( legacy_io, ) +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import publish from openpype.lib import EnumDef, is_running_from_build from openpype.tests.lib import is_in_tests From ed3e5a8c6b019cedbfb75bb875e545bc5643526f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 2 Aug 2023 14:20:07 +0200 Subject: [PATCH 065/107] OP-4845 - fix missing AYON_BUNDLE_NAME --- .../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 2bb7ca9662..93c6ad8139 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -340,9 +340,12 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, environment[_path] = os.environ[_path] # to recognize render jobs - render_job_label = ( - "AYON_RENDER_JOB" if AYON_SERVER_ENABLED - else "OPENPYPE_RENDER_JOB") + if AYON_SERVER_ENABLED: + environment["AYON_BUNDLE_NAME"] = os.environ["AYON_BUNDLE_NAME"] + render_job_label = "AYON_RENDER_JOB" + else: + render_job_label = "OPENPYPE_RENDER_JOB" + environment[render_job_label] = "1" # finally search replace in values of any key From c71aae5fd8088604028b3038bc64ac5772417e7a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Aug 2023 14:21:24 +0200 Subject: [PATCH 066/107] added missing imports --- openpype/modules/deadline/abstract_submit_deadline.py | 1 + .../plugins/publish/submit_maya_remote_publish_deadline.py | 1 + .../deadline/plugins/publish/submit_nuke_deadline.py | 1 + .../modules/deadline/plugins/publish/submit_publish_job.py | 6 ++---- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openpype/modules/deadline/abstract_submit_deadline.py b/openpype/modules/deadline/abstract_submit_deadline.py index 3300bad6a9..c1a6eade46 100644 --- a/openpype/modules/deadline/abstract_submit_deadline.py +++ b/openpype/modules/deadline/abstract_submit_deadline.py @@ -11,6 +11,7 @@ import platform import getpass from functools import partial from collections import OrderedDict +from openpype import AYON_SERVER_ENABLED import six import attr diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py index a9d4f7fbe8..988f8d106a 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py @@ -4,6 +4,7 @@ from datetime import datetime from maya import cmds +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import legacy_io, PublishXmlValidationError from openpype.tests.lib import is_in_tests from openpype.lib import is_running_from_build diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index d427931c16..cafa71d3cb 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -8,6 +8,7 @@ import requests import pyblish.api import nuke +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import legacy_io from openpype.pipeline.publish import ( OpenPypePyblishPluginMixin diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 0c25bda049..ec182fcd66 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -9,13 +9,11 @@ import clique import pyblish.api +from openpype import AYON_SERVER_ENABLED from openpype.client import ( get_last_version_by_subset_name, ) -from openpype.pipeline import ( - legacy_io, -) -from openpype.pipeline import publish +from openpype.pipeline import publish, legacy_io from openpype.lib import EnumDef, is_running_from_build from openpype.tests.lib import is_in_tests From 997d8a7a30c7860fb90276ca636048950735bf6c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 2 Aug 2023 14:54:25 +0200 Subject: [PATCH 067/107] Update openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../plugins/publish/submit_maya_remote_publish_deadline.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py index 988f8d106a..0d23f44333 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py @@ -123,8 +123,6 @@ class MayaSubmitRemotePublishDeadline( environment["AYON_REMOTE_PUBLISH"] = "1" else: environment["OPENPYPE_REMOTE_PUBLISH"] = "1" - - for key, value in environment.items(): job_info.EnvironmentKeyValue[key] = value From ab1f0599d7bc59dec08c8f5092f281ee6a2d8a1a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 2 Aug 2023 14:54:34 +0200 Subject: [PATCH 068/107] Update openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../repository/custom/plugins/Ayon/Ayon.py | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py index ae7aa7df75..16149d7e20 100644 --- a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py +++ b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py @@ -131,18 +131,16 @@ class AyonDeadlinePlugin(DeadlinePlugin): frameRegex = Regex(pattern) while True: frameMatch = frameRegex.Match(arguments) - if frameMatch.Success: - paddingSize = int(frameMatch.Groups[1].Value) - if paddingSize > 0: - padding = StringUtils.ToZeroPaddedString(frame, - paddingSize, - False) - else: - padding = str(frame) - arguments = arguments.replace(frameMatch.Groups[0].Value, - padding) - else: + if not frameMatch.Success: break + paddingSize = int(frameMatch.Groups[1].Value) + if paddingSize > 0: + padding = StringUtils.ToZeroPaddedString( + frame, paddingSize, False) + else: + padding = str(frame) + arguments = arguments.replace( + frameMatch.Groups[0].Value, padding) return arguments From 1ddc9f2fd6aadbab6960c374427bb5fa570334fd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 2 Aug 2023 15:46:26 +0200 Subject: [PATCH 069/107] nuke: split write node features --- openpype/hosts/nuke/api/plugin.py | 11 +++++++++++ .../hosts/nuke/plugins/create/create_write_image.py | 5 +---- .../nuke/plugins/create/create_write_prerender.py | 8 +------- .../hosts/nuke/plugins/create/create_write_render.py | 9 ++++----- .../schemas/template_nuke_write_attrs.json | 6 ++++++ 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index cfdb407d26..03dd8915d6 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -256,6 +256,17 @@ class NukeWriteCreator(NukeCreator): family = "write" icon = "sign-out" + def get_linked_knobs(self): + linked_knobs = [] + if "channels" in self.instance_attributes: + linked_knobs.append("channels") + if "ordered" in self.instance_attributes: + linked_knobs.append("render_order") + if "use_range_limit" in self.instance_attributes: + linked_knobs.extend(["___", "first", "last", "use_limit"]) + + return linked_knobs + def integrate_links(self, node, outputs=True): # skip if no selection if not self.selected_node: diff --git a/openpype/hosts/nuke/plugins/create/create_write_image.py b/openpype/hosts/nuke/plugins/create/create_write_image.py index 0c8adfb75c..8c18739587 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_image.py +++ b/openpype/hosts/nuke/plugins/create/create_write_image.py @@ -64,9 +64,6 @@ class CreateWriteImage(napi.NukeWriteCreator): ) def create_instance_node(self, subset_name, instance_data): - linked_knobs_ = [] - if "use_range_limit" in self.instance_attributes: - linked_knobs_ = ["channels", "___", "first", "last", "use_limit"] # add fpath_template write_data = { @@ -81,7 +78,7 @@ class CreateWriteImage(napi.NukeWriteCreator): write_data, input=self.selected_node, prenodes=self.prenodes, - linked_knobs=linked_knobs_, + linked_knobs=self.get_linked_knobs(), **{ "frame": nuke.frame() } diff --git a/openpype/hosts/nuke/plugins/create/create_write_prerender.py b/openpype/hosts/nuke/plugins/create/create_write_prerender.py index c3bba5f477..395c3b002f 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_prerender.py +++ b/openpype/hosts/nuke/plugins/create/create_write_prerender.py @@ -45,12 +45,6 @@ class CreateWritePrerender(napi.NukeWriteCreator): return attr_defs def create_instance_node(self, subset_name, instance_data): - linked_knobs_ = [] - if "use_range_limit" in self.instance_attributes: - linked_knobs_ = ["channels", "___", "first", "last", "use_limit"] - - linked_knobs_.append("render_order") - # add fpath_template write_data = { "creator": self.__class__.__name__, @@ -73,7 +67,7 @@ class CreateWritePrerender(napi.NukeWriteCreator): write_data, input=self.selected_node, prenodes=self.prenodes, - linked_knobs=linked_knobs_, + linked_knobs=self.get_linked_knobs(), **{ "width": width, "height": height diff --git a/openpype/hosts/nuke/plugins/create/create_write_render.py b/openpype/hosts/nuke/plugins/create/create_write_render.py index aef4b06a2c..91acf4eabc 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_render.py +++ b/openpype/hosts/nuke/plugins/create/create_write_render.py @@ -39,10 +39,6 @@ class CreateWriteRender(napi.NukeWriteCreator): return attr_defs def create_instance_node(self, subset_name, instance_data): - linked_knobs_ = [ - "channels", "___", "first", "last", "use_limit", "render_order" - ] - # add fpath_template write_data = { "creator": self.__class__.__name__, @@ -60,12 +56,15 @@ class CreateWriteRender(napi.NukeWriteCreator): actual_format = nuke.root().knob('format').value() width, height = (actual_format.width(), actual_format.height()) + self.log.debug(">>>>>>> : {}".format(self.instance_attributes)) + self.log.debug(">>>>>>> : {}".format(self.get_linked_knobs())) + created_node = napi.create_write_node( subset_name, write_data, input=self.selected_node, prenodes=self.prenodes, - linked_knobs=linked_knobs_, + linked_knobs=self.get_linked_knobs(), **{ "width": width, "height": height diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_write_attrs.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_write_attrs.json index 8be48e669d..3a34858f4e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_write_attrs.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_write_attrs.json @@ -13,6 +13,12 @@ }, { "use_range_limit": "Use range limit" + }, + { + "ordered": "Defined order" + }, + { + "channels": "Channels override" } ] } From dc8cd15f18ec39ae97e83e9381744a44970a5b34 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 2 Aug 2023 17:00:47 +0200 Subject: [PATCH 070/107] nuke: subset name driven form node name also publisher variant change is reflected in node name --- openpype/hosts/nuke/api/pipeline.py | 22 ++++++++++++++++++++++ openpype/hosts/nuke/api/plugin.py | 13 ++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index fcc3becd2d..2871f8afbc 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -564,6 +564,9 @@ def list_instances(creator_id=None): if creator_id and instance_data["creator_identifier"] != creator_id: continue + # node name could change, so update subset name data + _update_subset_name_data(instance_data, node) + if "render_order" not in node.knobs(): subset_instances.append((node, instance_data)) continue @@ -589,6 +592,25 @@ def list_instances(creator_id=None): return ordered_instances +def _update_subset_name_data(instance_data, node): + """Update subset name data in instance data. + + Args: + instance_data (dict): instance creator data + node (nuke.Node): nuke node + """ + # make sure node name is subset name + old_subset_name = instance_data["subset"] + old_variant = instance_data["variant"] + subset_name_root = old_subset_name.replace(old_variant, "") + + new_subset_name = node.name() + new_variant = new_subset_name.replace(subset_name_root, "") + + instance_data["subset"] = new_subset_name + instance_data["variant"] = new_variant + + def remove_instance(instance): """Remove instance from current workfile metadata. diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index cfdb407d26..4a7bb03216 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -212,9 +212,20 @@ class NukeCreator(NewCreator): created_instance["creator_attributes"].pop(key) def update_instances(self, update_list): - for created_inst, _changes in update_list: + for created_inst, changes in update_list: instance_node = created_inst.transient_data["node"] + changed_keys = { + key: changes[key].new_value + for key in changes.changed_keys + } + + # update instance node name if subset name changed + if "subset" in changed_keys: + instance_node["name"].setValue( + changed_keys["subset"] + ) + # in case node is not existing anymore (user erased it manually) try: instance_node.fullName() From df78b060149dff6db27440aca8cee402be94cb0b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 2 Aug 2023 17:32:50 +0200 Subject: [PATCH 071/107] Nuke: improve ordering publishing instances --- openpype/hosts/nuke/api/pipeline.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index 2871f8afbc..045f7ec85d 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -539,6 +539,8 @@ def list_instances(creator_id=None): """ instances_by_order = defaultdict(list) subset_instances = [] + instance_ids = set() + for node in nuke.allNodes(recurseGroups=True): if node.Class() in ["Viewer", "Dot"]: @@ -564,6 +566,11 @@ def list_instances(creator_id=None): if creator_id and instance_data["creator_identifier"] != creator_id: continue + if instance_data["instance_id"] in instance_ids: + instance_data.pop("instance_id") + else: + instance_ids.add(instance_data["instance_id"]) + # node name could change, so update subset name data _update_subset_name_data(instance_data, node) @@ -575,19 +582,20 @@ def list_instances(creator_id=None): instances_by_order[order].append((node, instance_data)) # Sort instances based on order attribute or subset name. + # TODO: remove in future Publisher enhanced with sorting ordered_instances = [] for key in sorted(instances_by_order.keys()): - instances_by_subset = {} - for node, data in instances_by_order[key]: - instances_by_subset[data["subset"]] = (node, data) + instances_by_subset = defaultdict(list) + for node, data_ in instances_by_order[key]: + instances_by_subset[data_["subset"]].append((node, data_)) for subkey in sorted(instances_by_subset.keys()): - ordered_instances.append(instances_by_subset[subkey]) + ordered_instances.extend(instances_by_subset[subkey]) - instances_by_subset = {} - for node, data in subset_instances: - instances_by_subset[data["subset"]] = (node, data) + instances_by_subset = defaultdict(list) + for node, data_ in subset_instances: + instances_by_subset[data_["subset"]].append((node, data_)) for key in sorted(instances_by_subset.keys()): - ordered_instances.append(instances_by_subset[key]) + ordered_instances.extend(instances_by_subset[key]) return ordered_instances From ba7dca9a255e1b23b28d1c1e207b74ee19a1c789 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 2 Aug 2023 18:24:59 +0200 Subject: [PATCH 072/107] Publisher: Fix create/publish animation (#5369) * use geometry movement instead of min/max width * take height in calculation too * right parenting of widgets --- .../publisher/widgets/overview_widget.py | 127 ++++++++++-------- openpype/tools/publisher/window.py | 26 ++-- 2 files changed, 88 insertions(+), 65 deletions(-) diff --git a/openpype/tools/publisher/widgets/overview_widget.py b/openpype/tools/publisher/widgets/overview_widget.py index 25fff73134..470645b9ee 100644 --- a/openpype/tools/publisher/widgets/overview_widget.py +++ b/openpype/tools/publisher/widgets/overview_widget.py @@ -28,12 +28,14 @@ class OverviewWidget(QtWidgets.QFrame): self._refreshing_instances = False self._controller = controller - create_widget = CreateWidget(controller, self) + subset_content_widget = QtWidgets.QWidget(self) + + create_widget = CreateWidget(controller, subset_content_widget) # --- Created Subsets/Instances --- # Common widget for creation and overview subset_views_widget = BorderedLabelWidget( - "Subsets to publish", self + "Subsets to publish", subset_content_widget ) subset_view_cards = InstanceCardView(controller, subset_views_widget) @@ -45,14 +47,14 @@ class OverviewWidget(QtWidgets.QFrame): subset_views_layout.setCurrentWidget(subset_view_cards) # Buttons at the bottom of subset view - create_btn = CreateInstanceBtn(self) - delete_btn = RemoveInstanceBtn(self) - change_view_btn = ChangeViewBtn(self) + create_btn = CreateInstanceBtn(subset_views_widget) + delete_btn = RemoveInstanceBtn(subset_views_widget) + change_view_btn = ChangeViewBtn(subset_views_widget) # --- Overview --- # Subset details widget subset_attributes_wrap = BorderedLabelWidget( - "Publish options", self + "Publish options", subset_content_widget ) subset_attributes_widget = SubsetAttributesWidget( controller, subset_attributes_wrap @@ -81,7 +83,6 @@ class OverviewWidget(QtWidgets.QFrame): subset_views_widget.set_center_widget(subset_view_widget) # Whole subset layout with attributes and details - subset_content_widget = QtWidgets.QWidget(self) subset_content_layout = QtWidgets.QHBoxLayout(subset_content_widget) subset_content_layout.setContentsMargins(0, 0, 0, 0) subset_content_layout.addWidget(create_widget, 7) @@ -161,44 +162,62 @@ class OverviewWidget(QtWidgets.QFrame): self._change_anim = change_anim # Start in create mode - self._create_widget_policy = create_widget.sizePolicy() - self._subset_views_widget_policy = subset_views_widget.sizePolicy() - self._subset_attributes_wrap_policy = ( - subset_attributes_wrap.sizePolicy() - ) - self._max_widget_width = None self._current_state = "create" subset_attributes_wrap.setVisible(False) + def make_sure_animation_is_finished(self): + if self._change_anim.state() == QtCore.QAbstractAnimation.Running: + self._change_anim.stop() + self._on_change_anim_finished() + def set_state(self, new_state, animate): if new_state == self._current_state: return self._current_state = new_state - anim_is_running = ( - self._change_anim.state() == QtCore.QAbstractAnimation.Running - ) if not animate: - self._change_visibility_for_state() - if anim_is_running: - self._change_anim.stop() + self.make_sure_animation_is_finished() return - if self._max_widget_width is None: - self._max_widget_width = self._subset_views_widget.maximumWidth() - if new_state == "create": direction = QtCore.QAbstractAnimation.Backward else: direction = QtCore.QAbstractAnimation.Forward self._change_anim.setDirection(direction) - if not anim_is_running: - view_width = self._subset_views_widget.width() - self._subset_views_widget.setMinimumWidth(view_width) - self._subset_views_widget.setMaximumWidth(view_width) + if ( + self._change_anim.state() != QtCore.QAbstractAnimation.Running + ): + self._start_animation() + + def _start_animation(self): + views_geo = self._subset_views_widget.geometry() + layout_spacing = self._subset_content_layout.spacing() + if self._create_widget.isVisible(): + create_geo = self._create_widget.geometry() + subset_geo = QtCore.QRect(create_geo) + subset_geo.moveTop(views_geo.top()) + subset_geo.moveLeft(views_geo.right() + layout_spacing) + self._subset_attributes_wrap.setVisible(True) + + elif self._subset_attributes_wrap.isVisible(): + subset_geo = self._subset_attributes_wrap.geometry() + create_geo = QtCore.QRect(subset_geo) + create_geo.moveTop(views_geo.top()) + create_geo.moveRight(views_geo.left() - (layout_spacing + 1)) + self._create_widget.setVisible(True) + else: self._change_anim.start() + return + + while self._subset_content_layout.count(): + self._subset_content_layout.takeAt(0) + self._subset_views_widget.setGeometry(views_geo) + self._create_widget.setGeometry(create_geo) + self._subset_attributes_wrap.setGeometry(subset_geo) + + self._change_anim.start() def get_subset_views_geo(self): parent = self._subset_views_widget.parent() @@ -281,41 +300,39 @@ class OverviewWidget(QtWidgets.QFrame): def _on_change_anim(self, value): self._create_widget.setVisible(True) self._subset_attributes_wrap.setVisible(True) - width = ( - self._subset_content_widget.width() - - ( - self._subset_views_widget.width() - + (self._subset_content_layout.spacing() * 2) - ) - ) - subset_attrs_width = int((float(width) / self.anim_end_value) * value) - if subset_attrs_width > width: - subset_attrs_width = width + layout_spacing = self._subset_content_layout.spacing() + content_width = ( + self._subset_content_widget.width() - (layout_spacing * 2) + ) + content_height = self._subset_content_widget.height() + views_width = max( + int(content_width * 0.3), + self._subset_views_widget.minimumWidth() + ) + width = content_width - views_width + # Visible widths of other widgets + subset_attrs_width = int((float(width) / self.anim_end_value) * value) create_width = width - subset_attrs_width - self._create_widget.setMinimumWidth(create_width) - self._create_widget.setMaximumWidth(create_width) - self._subset_attributes_wrap.setMinimumWidth(subset_attrs_width) - self._subset_attributes_wrap.setMaximumWidth(subset_attrs_width) + views_geo = QtCore.QRect( + create_width + layout_spacing, 0, + views_width, content_height + ) + create_geo = QtCore.QRect(0, 0, width, content_height) + subset_attrs_geo = QtCore.QRect(create_geo) + create_geo.moveRight(views_geo.left() - (layout_spacing + 1)) + subset_attrs_geo.moveLeft(views_geo.right() + layout_spacing) + + self._subset_views_widget.setGeometry(views_geo) + self._create_widget.setGeometry(create_geo) + self._subset_attributes_wrap.setGeometry(subset_attrs_geo) def _on_change_anim_finished(self): self._change_visibility_for_state() - self._create_widget.setMinimumWidth(0) - self._create_widget.setMaximumWidth(self._max_widget_width) - self._subset_attributes_wrap.setMinimumWidth(0) - self._subset_attributes_wrap.setMaximumWidth(self._max_widget_width) - self._subset_views_widget.setMinimumWidth(0) - self._subset_views_widget.setMaximumWidth(self._max_widget_width) - self._create_widget.setSizePolicy( - self._create_widget_policy - ) - self._subset_attributes_wrap.setSizePolicy( - self._subset_attributes_wrap_policy - ) - self._subset_views_widget.setSizePolicy( - self._subset_views_widget_policy - ) + self._subset_content_layout.addWidget(self._create_widget, 7) + self._subset_content_layout.addWidget(self._subset_views_widget, 3) + self._subset_content_layout.addWidget(self._subset_attributes_wrap, 7) def _change_visibility_for_state(self): self._create_widget.setVisible( diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index 2bda0c1cfe..39e78c01bb 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -634,16 +634,7 @@ class PublisherWindow(QtWidgets.QDialog): if old_tab == "details": self._publish_details_widget.close_details_popup() - if new_tab in ("create", "publish"): - animate = True - if old_tab not in ("create", "publish"): - animate = False - self._content_stacked_layout.setCurrentWidget( - self._overview_widget - ) - self._overview_widget.set_state(new_tab, animate) - - elif new_tab == "details": + if new_tab == "details": self._content_stacked_layout.setCurrentWidget( self._publish_details_widget ) @@ -654,6 +645,21 @@ class PublisherWindow(QtWidgets.QDialog): self._report_widget ) + old_on_overview = old_tab in ("create", "publish") + if new_tab in ("create", "publish"): + self._content_stacked_layout.setCurrentWidget( + self._overview_widget + ) + # Overview state is animated only when switching between + # 'create' and 'publish' tab + self._overview_widget.set_state(new_tab, old_on_overview) + + elif old_on_overview: + # Make sure animation finished if previous tab was 'create' + # or 'publish'. That is just for safety to avoid stuck animation + # when user clicks too fast. + self._overview_widget.make_sure_animation_is_finished() + is_create = new_tab == "create" if is_create: self._install_app_event_listener() From 0c423a9a32e831d7ea196bfa199d99e55b6c2bfe Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 2 Aug 2023 20:55:54 +0300 Subject: [PATCH 073/107] delete redundant bgeo sop validator --- .../publish/validate_bgeo_file_sop_path.py | 26 ------------------- 1 file changed, 26 deletions(-) delete mode 100644 openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py diff --git a/openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py b/openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py deleted file mode 100644 index 22746aabb0..0000000000 --- a/openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validator plugin for SOP Path in bgeo isntance.""" -import pyblish.api -from openpype.pipeline import PublishValidationError - - -class ValidateNoSOPPath(pyblish.api.InstancePlugin): - """Validate if SOP Path in BGEO instance exists.""" - - order = pyblish.api.ValidatorOrder - families = ["bgeo"] - label = "Validate BGEO SOP Path" - - def process(self, instance): - - import hou - - node = hou.node(instance.data.get("instance_node")) - sop_path = node.evalParm("soppath") - if not sop_path: - raise PublishValidationError( - ("Empty SOP Path ('soppath' parameter) found in " - f"the BGEO instance Geometry - {node.path()}")) - if not isinstance(hou.node(sop_path), hou.SopNode): - raise PublishValidationError( - "SOP path is not pointing to valid SOP node.") From 7e9f42b4479dca34fa21bcdb73950da85f757542 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 3 Aug 2023 10:04:15 +0200 Subject: [PATCH 074/107] Applications: Use prelaunch hooks to extract environments (#5387) * ApplicationManager can have more granular way how applications are launched * executable is optional to be able create ApplicationLaunchContext * launch context can run prelaunch hooks without launching application * 'get_app_environments_for_context' is using launch context to prepare environments * added 'launch_type' as one of filtering options for LaunchHook * added 'local' launch type filter to existing launch hooks * define 'automated' launch type in remote publish function * modified publish and extract environments cli commands * launch types are only for local by default * fix import * fix launch types of global host data * change order or kwargs * change unreal filter attribute --- openpype/hooks/pre_add_last_workfile_arg.py | 3 +- openpype/hooks/pre_copy_template_workfile.py | 3 +- .../hooks/pre_create_extra_workdir_folders.py | 3 +- openpype/hooks/pre_foundry_apps.py | 3 +- openpype/hooks/pre_global_host_data.py | 3 +- openpype/hooks/pre_mac_launch.py | 3 +- openpype/hooks/pre_non_python_host_launch.py | 9 +- openpype/hooks/pre_ocio_hook.py | 1 + .../hooks/pre_add_run_python_script_arg.py | 7 +- .../hosts/blender/hooks/pre_pyside_install.py | 5 +- .../blender/hooks/pre_windows_console.py | 3 +- .../celaction/hooks/pre_celaction_setup.py | 4 +- openpype/hosts/flame/hooks/pre_flame_setup.py | 6 +- .../fusion/hooks/pre_fusion_profile_hook.py | 7 +- .../hosts/fusion/hooks/pre_fusion_setup.py | 7 +- openpype/hosts/houdini/hooks/set_paths.py | 3 +- .../hosts/max/hooks/force_startup_script.py | 3 +- openpype/hosts/max/hooks/inject_python.py | 3 +- openpype/hosts/max/hooks/set_paths.py | 3 +- .../hosts/maya/hooks/pre_auto_load_plugins.py | 3 +- openpype/hosts/maya/hooks/pre_copy_mel.py | 3 +- .../pre_open_workfile_post_initialization.py | 3 +- .../hosts/nuke/hooks/pre_nukeassist_setup.py | 3 +- .../hooks/pre_resolve_last_workfile.py | 3 +- .../hosts/resolve/hooks/pre_resolve_setup.py | 3 +- .../resolve/hooks/pre_resolve_startup.py | 3 +- .../hosts/tvpaint/hooks/pre_launch_args.py | 7 +- .../unreal/hooks/pre_workfile_preparation.py | 5 +- .../hosts/webpublisher/publish_functions.py | 45 ++-- openpype/lib/applications.py | 223 ++++++++++++------ .../launch_hooks/post_ftrack_changes.py | 3 +- .../slack/launch_hooks/pre_python2_vendor.py | 3 +- .../pre_copy_last_published_workfile.py | 9 +- .../launch_hooks/post_start_timer.py | 3 +- openpype/pype_commands.py | 20 +- 35 files changed, 266 insertions(+), 152 deletions(-) diff --git a/openpype/hooks/pre_add_last_workfile_arg.py b/openpype/hooks/pre_add_last_workfile_arg.py index c54acbc203..0e43f1bfe6 100644 --- a/openpype/hooks/pre_add_last_workfile_arg.py +++ b/openpype/hooks/pre_add_last_workfile_arg.py @@ -1,6 +1,6 @@ import os -from openpype.lib import PreLaunchHook +from openpype.lib.applications import PreLaunchHook, LaunchTypes class AddLastWorkfileToLaunchArgs(PreLaunchHook): @@ -28,6 +28,7 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): "substancepainter", "aftereffects" ] + launch_types = {LaunchTypes.local} def execute(self): if not self.data.get("start_last_workfile"): diff --git a/openpype/hooks/pre_copy_template_workfile.py b/openpype/hooks/pre_copy_template_workfile.py index 70c549919f..9962dabdd8 100644 --- a/openpype/hooks/pre_copy_template_workfile.py +++ b/openpype/hooks/pre_copy_template_workfile.py @@ -1,7 +1,7 @@ import os import shutil -from openpype.lib import PreLaunchHook from openpype.settings import get_project_settings +from openpype.lib.applications import PreLaunchHook, LaunchTypes from openpype.pipeline.workfile import ( get_custom_workfile_template, get_custom_workfile_template_by_string_context @@ -20,6 +20,7 @@ class CopyTemplateWorkfile(PreLaunchHook): # Before `AddLastWorkfileToLaunchArgs` order = 0 app_groups = ["blender", "photoshop", "tvpaint", "aftereffects"] + launch_types = {LaunchTypes.local} def execute(self): """Check if can copy template for context and do it if possible. diff --git a/openpype/hooks/pre_create_extra_workdir_folders.py b/openpype/hooks/pre_create_extra_workdir_folders.py index 8856281120..4c9d08b375 100644 --- a/openpype/hooks/pre_create_extra_workdir_folders.py +++ b/openpype/hooks/pre_create_extra_workdir_folders.py @@ -1,5 +1,5 @@ import os -from openpype.lib import PreLaunchHook +from openpype.lib.applications import PreLaunchHook, LaunchTypes from openpype.pipeline.workfile import create_workdir_extra_folders @@ -14,6 +14,7 @@ class CreateWorkdirExtraFolders(PreLaunchHook): # Execute after workfile template copy order = 15 + launch_types = {LaunchTypes.local} def execute(self): if not self.application.is_host: diff --git a/openpype/hooks/pre_foundry_apps.py b/openpype/hooks/pre_foundry_apps.py index 21ec8e7881..50e50e74a2 100644 --- a/openpype/hooks/pre_foundry_apps.py +++ b/openpype/hooks/pre_foundry_apps.py @@ -1,5 +1,5 @@ import subprocess -from openpype.lib import PreLaunchHook +from openpype.lib.applications import PreLaunchHook, LaunchTypes class LaunchFoundryAppsWindows(PreLaunchHook): @@ -15,6 +15,7 @@ class LaunchFoundryAppsWindows(PreLaunchHook): order = 1000 app_groups = ["nuke", "nukeassist", "nukex", "hiero", "nukestudio"] platforms = ["windows"] + launch_types = {LaunchTypes.local} def execute(self): # Change `creationflags` to CREATE_NEW_CONSOLE diff --git a/openpype/hooks/pre_global_host_data.py b/openpype/hooks/pre_global_host_data.py index 260e28a18b..813df24af0 100644 --- a/openpype/hooks/pre_global_host_data.py +++ b/openpype/hooks/pre_global_host_data.py @@ -1,5 +1,5 @@ from openpype.client import get_project, get_asset_by_name -from openpype.lib import ( +from openpype.lib.applications import ( PreLaunchHook, EnvironmentPrepData, prepare_app_environments, @@ -10,6 +10,7 @@ from openpype.pipeline import Anatomy class GlobalHostDataHook(PreLaunchHook): order = -100 + launch_types = set() def execute(self): """Prepare global objects to `data` that will be used for sure.""" diff --git a/openpype/hooks/pre_mac_launch.py b/openpype/hooks/pre_mac_launch.py index f85557a4f0..298346c9b1 100644 --- a/openpype/hooks/pre_mac_launch.py +++ b/openpype/hooks/pre_mac_launch.py @@ -1,5 +1,5 @@ import os -from openpype.lib import PreLaunchHook +from openpype.lib.applications import PreLaunchHook, LaunchTypes class LaunchWithTerminal(PreLaunchHook): @@ -13,6 +13,7 @@ class LaunchWithTerminal(PreLaunchHook): order = 1000 platforms = ["darwin"] + launch_types = {LaunchTypes.local} def execute(self): executable = str(self.launch_context.executable) diff --git a/openpype/hooks/pre_non_python_host_launch.py b/openpype/hooks/pre_non_python_host_launch.py index 043cb3c7f6..e58c354360 100644 --- a/openpype/hooks/pre_non_python_host_launch.py +++ b/openpype/hooks/pre_non_python_host_launch.py @@ -1,10 +1,11 @@ import os -from openpype.lib import ( +from openpype.lib import get_openpype_execute_args +from openpype.lib.applications import ( + get_non_python_host_kwargs, PreLaunchHook, - get_openpype_execute_args + LaunchTypes, ) -from openpype.lib.applications import get_non_python_host_kwargs from openpype import PACKAGE_DIR as OPENPYPE_DIR @@ -19,6 +20,7 @@ class NonPythonHostHook(PreLaunchHook): app_groups = ["harmony", "photoshop", "aftereffects"] order = 20 + launch_types = {LaunchTypes.local} def execute(self): # Pop executable @@ -54,4 +56,3 @@ class NonPythonHostHook(PreLaunchHook): self.launch_context.kwargs = \ get_non_python_host_kwargs(self.launch_context.kwargs) - diff --git a/openpype/hooks/pre_ocio_hook.py b/openpype/hooks/pre_ocio_hook.py index 8f462665bc..7c53d3db66 100644 --- a/openpype/hooks/pre_ocio_hook.py +++ b/openpype/hooks/pre_ocio_hook.py @@ -22,6 +22,7 @@ class OCIOEnvHook(PreLaunchHook): "hiero", "resolve" ] + launch_types = set() def execute(self): """Hook entry method.""" diff --git a/openpype/hosts/blender/hooks/pre_add_run_python_script_arg.py b/openpype/hosts/blender/hooks/pre_add_run_python_script_arg.py index 559e9ae0ce..68c9bfdd57 100644 --- a/openpype/hosts/blender/hooks/pre_add_run_python_script_arg.py +++ b/openpype/hosts/blender/hooks/pre_add_run_python_script_arg.py @@ -1,6 +1,6 @@ from pathlib import Path -from openpype.lib import PreLaunchHook +from openpype.lib.applications import PreLaunchHook, LaunchTypes class AddPythonScriptToLaunchArgs(PreLaunchHook): @@ -8,9 +8,8 @@ class AddPythonScriptToLaunchArgs(PreLaunchHook): # Append after file argument order = 15 - app_groups = [ - "blender", - ] + app_groups = {"blender"} + launch_types = {LaunchTypes.local} def execute(self): if not self.launch_context.data.get("python_scripts"): diff --git a/openpype/hosts/blender/hooks/pre_pyside_install.py b/openpype/hosts/blender/hooks/pre_pyside_install.py index e5f66d2a26..777e383215 100644 --- a/openpype/hosts/blender/hooks/pre_pyside_install.py +++ b/openpype/hosts/blender/hooks/pre_pyside_install.py @@ -2,7 +2,7 @@ import os import re import subprocess from platform import system -from openpype.lib import PreLaunchHook +from openpype.lib.applications import PreLaunchHook, LaunchTypes class InstallPySideToBlender(PreLaunchHook): @@ -16,7 +16,8 @@ class InstallPySideToBlender(PreLaunchHook): blender's python packages. """ - app_groups = ["blender"] + app_groups = {"blender"} + launch_types = {LaunchTypes.local} def execute(self): # Prelaunch hook is not crucial diff --git a/openpype/hosts/blender/hooks/pre_windows_console.py b/openpype/hosts/blender/hooks/pre_windows_console.py index d6be45b225..c6ecf284ef 100644 --- a/openpype/hosts/blender/hooks/pre_windows_console.py +++ b/openpype/hosts/blender/hooks/pre_windows_console.py @@ -1,5 +1,5 @@ import subprocess -from openpype.lib import PreLaunchHook +from openpype.lib.applications import PreLaunchHook, LaunchTypes class BlenderConsoleWindows(PreLaunchHook): @@ -15,6 +15,7 @@ class BlenderConsoleWindows(PreLaunchHook): order = 1000 app_groups = ["blender"] platforms = ["windows"] + launch_types = {LaunchTypes.local} def execute(self): # Change `creationflags` to CREATE_NEW_CONSOLE diff --git a/openpype/hosts/celaction/hooks/pre_celaction_setup.py b/openpype/hosts/celaction/hooks/pre_celaction_setup.py index 96e784875c..df27195e60 100644 --- a/openpype/hosts/celaction/hooks/pre_celaction_setup.py +++ b/openpype/hosts/celaction/hooks/pre_celaction_setup.py @@ -2,7 +2,8 @@ import os import shutil import winreg import subprocess -from openpype.lib import PreLaunchHook, get_openpype_execute_args +from openpype.lib import get_openpype_execute_args +from openpype.lib.applications import PreLaunchHook, LaunchTypes from openpype.hosts.celaction import scripts CELACTION_SCRIPTS_DIR = os.path.dirname( @@ -16,6 +17,7 @@ class CelactionPrelaunchHook(PreLaunchHook): """ app_groups = ["celaction"] platforms = ["windows"] + launch_types = {LaunchTypes.local} def execute(self): asset_doc = self.data["asset_doc"] diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index 83110bb6b5..61e3200d89 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -6,13 +6,10 @@ import socket from pprint import pformat from openpype.lib import ( - PreLaunchHook, get_openpype_username, run_subprocess, ) -from openpype.lib.applications import ( - ApplicationLaunchFailed -) +from openpype.lib.applications import PreLaunchHook, LaunchTypes from openpype.hosts import flame as opflame @@ -27,6 +24,7 @@ class FlamePrelaunch(PreLaunchHook): wtc_script_path = os.path.join( opflame.HOST_DIR, "api", "scripts", "wiretap_com.py") + launch_types = {LaunchTypes.local} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py b/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py index fd726ccda1..da74f8e1fe 100644 --- a/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py +++ b/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py @@ -2,12 +2,16 @@ import os import shutil import platform from pathlib import Path -from openpype.lib import PreLaunchHook, ApplicationLaunchFailed from openpype.hosts.fusion import ( FUSION_HOST_DIR, FUSION_VERSIONS_DICT, get_fusion_version, ) +from openpype.lib.applications import ( + PreLaunchHook, + LaunchTypes, + ApplicationLaunchFailed, +) class FusionCopyPrefsPrelaunch(PreLaunchHook): @@ -23,6 +27,7 @@ class FusionCopyPrefsPrelaunch(PreLaunchHook): app_groups = ["fusion"] order = 2 + launch_types = {LaunchTypes.local} def get_fusion_profile_name(self, profile_version) -> str: # Returns 'Default', unless FUSION16_PROFILE is set diff --git a/openpype/hosts/fusion/hooks/pre_fusion_setup.py b/openpype/hosts/fusion/hooks/pre_fusion_setup.py index f27cd1674b..68ef23d520 100644 --- a/openpype/hosts/fusion/hooks/pre_fusion_setup.py +++ b/openpype/hosts/fusion/hooks/pre_fusion_setup.py @@ -1,5 +1,9 @@ import os -from openpype.lib import PreLaunchHook, ApplicationLaunchFailed +from openpype.lib.applications import ( + PreLaunchHook, + LaunchTypes, + ApplicationLaunchFailed, +) from openpype.hosts.fusion import ( FUSION_HOST_DIR, FUSION_VERSIONS_DICT, @@ -19,6 +23,7 @@ class FusionPrelaunch(PreLaunchHook): app_groups = ["fusion"] order = 1 + launch_types = {LaunchTypes.local} def execute(self): # making sure python 3 is installed at provided path diff --git a/openpype/hosts/houdini/hooks/set_paths.py b/openpype/hosts/houdini/hooks/set_paths.py index 04a33b1643..2e7bf51757 100644 --- a/openpype/hosts/houdini/hooks/set_paths.py +++ b/openpype/hosts/houdini/hooks/set_paths.py @@ -1,4 +1,4 @@ -from openpype.lib import PreLaunchHook +from openpype.lib.applications import PreLaunchHook, LaunchTypes class SetPath(PreLaunchHook): @@ -7,6 +7,7 @@ class SetPath(PreLaunchHook): Hook `GlobalHostDataHook` must be executed before this hook. """ app_groups = ["houdini"] + launch_types = {LaunchTypes.local} def execute(self): workdir = self.launch_context.env.get("AVALON_WORKDIR", "") diff --git a/openpype/hosts/max/hooks/force_startup_script.py b/openpype/hosts/max/hooks/force_startup_script.py index 4fcf4fef21..701e348293 100644 --- a/openpype/hosts/max/hooks/force_startup_script.py +++ b/openpype/hosts/max/hooks/force_startup_script.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """Pre-launch to force 3ds max startup script.""" -from openpype.lib import PreLaunchHook import os +from openpype.lib.applications import PreLaunchHook, LaunchTypes class ForceStartupScript(PreLaunchHook): @@ -15,6 +15,7 @@ class ForceStartupScript(PreLaunchHook): """ app_groups = ["3dsmax"] order = 11 + launch_types = {LaunchTypes.local} def execute(self): startup_args = [ diff --git a/openpype/hosts/max/hooks/inject_python.py b/openpype/hosts/max/hooks/inject_python.py index d9753ccbd8..bbfc95c078 100644 --- a/openpype/hosts/max/hooks/inject_python.py +++ b/openpype/hosts/max/hooks/inject_python.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """Pre-launch hook to inject python environment.""" -from openpype.lib import PreLaunchHook import os +from openpype.lib.applications import PreLaunchHook, LaunchTypes class InjectPythonPath(PreLaunchHook): @@ -14,6 +14,7 @@ class InjectPythonPath(PreLaunchHook): Hook `GlobalHostDataHook` must be executed before this hook. """ app_groups = ["3dsmax"] + launch_types = {LaunchTypes.local} def execute(self): self.launch_context.env["MAX_PYTHONPATH"] = os.environ["PYTHONPATH"] diff --git a/openpype/hosts/max/hooks/set_paths.py b/openpype/hosts/max/hooks/set_paths.py index 3db5306344..f06efff7c8 100644 --- a/openpype/hosts/max/hooks/set_paths.py +++ b/openpype/hosts/max/hooks/set_paths.py @@ -1,4 +1,4 @@ -from openpype.lib import PreLaunchHook +from openpype.lib.applications import PreLaunchHook, LaunchTypes class SetPath(PreLaunchHook): @@ -7,6 +7,7 @@ class SetPath(PreLaunchHook): Hook `GlobalHostDataHook` must be executed before this hook. """ app_groups = ["max"] + launch_types = {LaunchTypes.local} def execute(self): workdir = self.launch_context.env.get("AVALON_WORKDIR", "") diff --git a/openpype/hosts/maya/hooks/pre_auto_load_plugins.py b/openpype/hosts/maya/hooks/pre_auto_load_plugins.py index 689d7adb4f..0437b6fd9d 100644 --- a/openpype/hosts/maya/hooks/pre_auto_load_plugins.py +++ b/openpype/hosts/maya/hooks/pre_auto_load_plugins.py @@ -1,4 +1,4 @@ -from openpype.lib import PreLaunchHook +from openpype.lib.applications import PreLaunchHook, LaunchTypes class MayaPreAutoLoadPlugins(PreLaunchHook): @@ -7,6 +7,7 @@ class MayaPreAutoLoadPlugins(PreLaunchHook): # Before AddLastWorkfileToLaunchArgs order = 9 app_groups = ["maya"] + launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hosts/maya/hooks/pre_copy_mel.py b/openpype/hosts/maya/hooks/pre_copy_mel.py index 9cea829ad7..ebb0c521c9 100644 --- a/openpype/hosts/maya/hooks/pre_copy_mel.py +++ b/openpype/hosts/maya/hooks/pre_copy_mel.py @@ -1,4 +1,4 @@ -from openpype.lib import PreLaunchHook +from openpype.lib.applications import PreLaunchHook, LaunchTypes from openpype.hosts.maya.lib import create_workspace_mel @@ -8,6 +8,7 @@ class PreCopyMel(PreLaunchHook): Hook `GlobalHostDataHook` must be executed before this hook. """ app_groups = ["maya"] + launch_types = {LaunchTypes.local} def execute(self): project_doc = self.data["project_doc"] diff --git a/openpype/hosts/maya/hooks/pre_open_workfile_post_initialization.py b/openpype/hosts/maya/hooks/pre_open_workfile_post_initialization.py index 7582ce0591..0c1fd0efe3 100644 --- a/openpype/hosts/maya/hooks/pre_open_workfile_post_initialization.py +++ b/openpype/hosts/maya/hooks/pre_open_workfile_post_initialization.py @@ -1,4 +1,4 @@ -from openpype.lib import PreLaunchHook +from openpype.lib.applications import PreLaunchHook, LaunchTypes class MayaPreOpenWorkfilePostInitialization(PreLaunchHook): @@ -7,6 +7,7 @@ class MayaPreOpenWorkfilePostInitialization(PreLaunchHook): # Before AddLastWorkfileToLaunchArgs. order = 9 app_groups = ["maya"] + launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hosts/nuke/hooks/pre_nukeassist_setup.py b/openpype/hosts/nuke/hooks/pre_nukeassist_setup.py index 3948a665c6..bdb271e3f1 100644 --- a/openpype/hosts/nuke/hooks/pre_nukeassist_setup.py +++ b/openpype/hosts/nuke/hooks/pre_nukeassist_setup.py @@ -1,4 +1,4 @@ -from openpype.lib import PreLaunchHook +from openpype.lib.applications import PreLaunchHook class PrelaunchNukeAssistHook(PreLaunchHook): @@ -6,6 +6,7 @@ class PrelaunchNukeAssistHook(PreLaunchHook): Adding flag when nukeassist """ app_groups = ["nukeassist"] + launch_types = set() def execute(self): self.launch_context.env["NUKEASSIST"] = "1" diff --git a/openpype/hosts/resolve/hooks/pre_resolve_last_workfile.py b/openpype/hosts/resolve/hooks/pre_resolve_last_workfile.py index bc03baad8d..dc986ec1d2 100644 --- a/openpype/hosts/resolve/hooks/pre_resolve_last_workfile.py +++ b/openpype/hosts/resolve/hooks/pre_resolve_last_workfile.py @@ -1,5 +1,5 @@ import os -from openpype.lib import PreLaunchHook +from openpype.lib.applications import PreLaunchHook, LaunchTypes class PreLaunchResolveLastWorkfile(PreLaunchHook): @@ -10,6 +10,7 @@ class PreLaunchResolveLastWorkfile(PreLaunchHook): """ order = 10 app_groups = ["resolve"] + launch_types = {LaunchTypes.local} def execute(self): if not self.data.get("start_last_workfile"): diff --git a/openpype/hosts/resolve/hooks/pre_resolve_setup.py b/openpype/hosts/resolve/hooks/pre_resolve_setup.py index 3fd39d665c..389256f4da 100644 --- a/openpype/hosts/resolve/hooks/pre_resolve_setup.py +++ b/openpype/hosts/resolve/hooks/pre_resolve_setup.py @@ -1,7 +1,7 @@ import os from pathlib import Path import platform -from openpype.lib import PreLaunchHook +from openpype.lib.applications import PreLaunchHook, LaunchTypes from openpype.hosts.resolve.utils import setup @@ -31,6 +31,7 @@ class PreLaunchResolveSetup(PreLaunchHook): """ app_groups = ["resolve"] + launch_types = {LaunchTypes.local} def execute(self): current_platform = platform.system().lower() diff --git a/openpype/hosts/resolve/hooks/pre_resolve_startup.py b/openpype/hosts/resolve/hooks/pre_resolve_startup.py index 599e0c0008..649af817ae 100644 --- a/openpype/hosts/resolve/hooks/pre_resolve_startup.py +++ b/openpype/hosts/resolve/hooks/pre_resolve_startup.py @@ -1,6 +1,6 @@ import os -from openpype.lib import PreLaunchHook +from openpype.lib.applications import PreLaunchHook, LaunchTypes import openpype.hosts.resolve @@ -10,6 +10,7 @@ class PreLaunchResolveStartup(PreLaunchHook): """ order = 11 app_groups = ["resolve"] + launch_types = {LaunchTypes.local} def execute(self): # Set the openpype prelaunch startup script path for easy access diff --git a/openpype/hosts/tvpaint/hooks/pre_launch_args.py b/openpype/hosts/tvpaint/hooks/pre_launch_args.py index c31403437a..065da316ab 100644 --- a/openpype/hosts/tvpaint/hooks/pre_launch_args.py +++ b/openpype/hosts/tvpaint/hooks/pre_launch_args.py @@ -1,7 +1,5 @@ -from openpype.lib import ( - PreLaunchHook, - get_openpype_execute_args -) +from openpype.lib import get_openpype_execute_args +from openpype.lib.applications import PreLaunchHook, LaunchTypes class TvpaintPrelaunchHook(PreLaunchHook): @@ -14,6 +12,7 @@ class TvpaintPrelaunchHook(PreLaunchHook): to copy templated workfile from predefined path. """ app_groups = ["tvpaint"] + launch_types = {LaunchTypes.local} def execute(self): # Pop tvpaint executable diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index e5010366b8..202d7854f6 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -7,9 +7,10 @@ from pathlib import Path from qtpy import QtCore from openpype import resources -from openpype.lib import ( +from openpype.lib.applications import ( PreLaunchHook, ApplicationLaunchFailed, + LaunchTypes, ) from openpype.pipeline.workfile import get_workfile_template_key import openpype.hosts.unreal.lib as unreal_lib @@ -29,6 +30,8 @@ class UnrealPrelaunchHook(PreLaunchHook): shell script. """ + app_groups = {"unreal"} + launch_types = {LaunchTypes.local} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/openpype/hosts/webpublisher/publish_functions.py b/openpype/hosts/webpublisher/publish_functions.py index 83f53ced68..41aab68cce 100644 --- a/openpype/hosts/webpublisher/publish_functions.py +++ b/openpype/hosts/webpublisher/publish_functions.py @@ -6,7 +6,7 @@ import pyblish.util from openpype.lib import Logger from openpype.lib.applications import ( ApplicationManager, - get_app_environments_for_context, + LaunchTypes, ) from openpype.pipeline import install_host from openpype.hosts.webpublisher.api import WebpublisherHost @@ -156,22 +156,31 @@ def cli_publish_from_app( found_variant_key = find_variant_key(application_manager, host_name) app_name = "{}/{}".format(host_name, found_variant_key) + data = { + "last_workfile_path": workfile_path, + "start_last_workfile": True, + "project_name": project_name, + "asset_name": asset_name, + "task_name": task_name, + "launch_type": LaunchTypes.automated, + } + launch_context = application_manager.create_launch_context( + app_name, **data) + launch_context.run_prelaunch_hooks() + # must have for proper launch of app - env = get_app_environments_for_context( - project_name, - asset_name, - task_name, - app_name - ) + env = launch_context.env print("env:: {}".format(env)) + env["OPENPYPE_PUBLISH_DATA"] = batch_path + # must pass identifier to update log lines for a batch + env["BATCH_LOG_ID"] = str(_id) + env["HEADLESS_PUBLISH"] = 'true' # to use in app lib + env["USER_EMAIL"] = user_email + os.environ.update(env) - os.environ["OPENPYPE_PUBLISH_DATA"] = batch_path - # must pass identifier to update log lines for a batch - os.environ["BATCH_LOG_ID"] = str(_id) - os.environ["HEADLESS_PUBLISH"] = 'true' # to use in app lib - os.environ["USER_EMAIL"] = user_email - + # Why is this here? Registered host in this process does not affect + # regitered host in launched process. pyblish.api.register_host(host_name) if targets: if isinstance(targets, str): @@ -184,15 +193,7 @@ def cli_publish_from_app( os.environ["PYBLISH_TARGETS"] = os.pathsep.join( set(current_targets)) - data = { - "last_workfile_path": workfile_path, - "start_last_workfile": True, - "project_name": project_name, - "asset_name": asset_name, - "task_name": task_name - } - - launched_app = application_manager.launch(app_name, **data) + launched_app = application_manager.launch_with_context(launch_context) timeout = get_timeout(project_name, host_name, task_type) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index fac3e33f71..ff5e27c122 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -12,10 +12,6 @@ from abc import ABCMeta, abstractmethod import six from openpype import AYON_SERVER_ENABLED, PACKAGE_DIR -from openpype.client import ( - get_project, - get_asset_by_name, -) from openpype.settings import ( get_system_settings, get_project_settings, @@ -47,6 +43,25 @@ CUSTOM_LAUNCH_APP_GROUPS = { } +class LaunchTypes: + """Launch types are filters for pre/post-launch hooks. + + Please use these variables in case they'll change values. + """ + + # Local launch - application is launched on local machine + local = "local" + # Farm render job - application is on farm + farm_render = "farm-render" + # Farm publish job - integration post-render job + farm_publish = "farm-publish" + # Remote launch - application is launched on remote machine from which + # can be started publishing + remote = "remote" + # Automated launch - application is launched with automated publishing + automated = "automated" + + def parse_environments(env_data, env_group=None, platform_name=None): """Parse environment values from settings byt group and platform. @@ -483,6 +498,42 @@ class ApplicationManager: break return output + def create_launch_context(self, app_name, **data): + """Prepare launch context for application. + + Args: + app_name (str): Name of application that should be launched. + **data (Any): Any additional data. Data may be used during + + Returns: + ApplicationLaunchContext: Launch context for application. + + Raises: + ApplicationNotFound: Application was not found by entered name. + """ + + app = self.applications.get(app_name) + if not app: + raise ApplicationNotFound(app_name) + + executable = app.find_executable() + + return ApplicationLaunchContext( + app, executable, **data + ) + + def launch_with_context(self, launch_context): + """Launch application using existing launch context. + + Args: + launch_context (ApplicationLaunchContext): Prepared launch + context. + """ + + if not launch_context.executable: + raise ApplictionExecutableNotFound(launch_context.application) + return launch_context.launch() + def launch(self, app_name, **data): """Launch procedure. @@ -503,18 +554,10 @@ class ApplicationManager: failed. Exception should contain explanation message, traceback should not be needed. """ - app = self.applications.get(app_name) - if not app: - raise ApplicationNotFound(app_name) - executable = app.find_executable() - if not executable: - raise ApplictionExecutableNotFound(app) + context = self.create_launch_context(app_name, **data) + return self.launch_with_context(context) - context = ApplicationLaunchContext( - app, executable, **data - ) - return context.launch() class EnvironmentToolGroup: @@ -736,13 +779,17 @@ class LaunchHook: # Order of prelaunch hook, will be executed as last if set to None. order = None # List of host implementations, skipped if empty. - hosts = [] - # List of application groups - app_groups = [] - # List of specific application names - app_names = [] - # List of platform availability, skipped if empty. - platforms = [] + hosts = set() + # Set of application groups + app_groups = set() + # Set of specific application names + app_names = set() + # Set of platform availability + platforms = set() + # Set of launch types for which is available + # - if empty then is available for all launch types + # - by default has 'local' which is most common reason for launc hooks + launch_types = {LaunchTypes.local} def __init__(self, launch_context): """Constructor of launch hook. @@ -790,6 +837,10 @@ class LaunchHook: if launch_context.app_name not in cls.app_names: return False + if cls.launch_types: + if launch_context.launch_type not in cls.launch_types: + return False + return True @property @@ -859,9 +910,9 @@ class PostLaunchHook(LaunchHook): class ApplicationLaunchContext: """Context of launching application. - Main purpose of context is to prepare launch arguments and keyword arguments - for new process. Most important part of keyword arguments preparations - are environment variables. + Main purpose of context is to prepare launch arguments and keyword + arguments for new process. Most important part of keyword arguments + preparations are environment variables. During the whole process is possible to use `data` attribute to store object usable in multiple places. @@ -874,14 +925,30 @@ class ApplicationLaunchContext: insert argument between `nuke.exe` and `--NukeX`. To keep them together it is better to wrap them in another list: `[["nuke.exe", "--NukeX"]]`. + Notes: + It is possible to use launch context only to prepare environment + variables. In that case `executable` may be None and can be used + 'run_prelaunch_hooks' method to run prelaunch hooks which prepare + them. + Args: application (Application): Application definition. executable (ApplicationExecutable): Object with path to executable. + env_group (Optional[str]): Environment variable group. If not set + 'DEFAULT_ENV_SUBGROUP' is used. + launch_type (Optional[str]): Launch type. If not set 'local' is used. **data (dict): Any additional data. Data may be used during preparation to store objects usable in multiple places. """ - def __init__(self, application, executable, env_group=None, **data): + def __init__( + self, + application, + executable, + env_group=None, + launch_type=None, + **data + ): from openpype.modules import ModulesManager # Application object @@ -896,6 +963,10 @@ class ApplicationLaunchContext: self.executable = executable + if launch_type is None: + launch_type = LaunchTypes.local + self.launch_type = launch_type + if env_group is None: env_group = DEFAULT_ENV_SUBGROUP @@ -903,8 +974,11 @@ class ApplicationLaunchContext: self.data = dict(data) + launch_args = [] + if executable is not None: + launch_args = executable.as_args() # subprocess.Popen launch arguments (first argument in constructor) - self.launch_args = executable.as_args() + self.launch_args = launch_args self.launch_args.extend(application.arguments) if self.data.get("app_args"): self.launch_args.extend(self.data.pop("app_args")) @@ -946,6 +1020,7 @@ class ApplicationLaunchContext: self.postlaunch_hooks = None self.process = None + self._prelaunch_hooks_executed = False @property def env(self): @@ -1215,6 +1290,27 @@ class ApplicationLaunchContext: # Return process which is already terminated return process + def run_prelaunch_hooks(self): + """Run prelaunch hooks. + + This method will be executed only once, any future calls will skip + the processing. + """ + + if self._prelaunch_hooks_executed: + self.log.warning("Prelaunch hooks were already executed.") + return + # Discover launch hooks + self.discover_launch_hooks() + + # Execute prelaunch hooks + for prelaunch_hook in self.prelaunch_hooks: + self.log.debug("Executing prelaunch hook: {}".format( + str(prelaunch_hook.__class__.__name__) + )) + prelaunch_hook.execute() + self._prelaunch_hooks_executed = True + def launch(self): """Collect data for new process and then create it. @@ -1227,15 +1323,8 @@ class ApplicationLaunchContext: self.log.warning("Application was already launched.") return - # Discover launch hooks - self.discover_launch_hooks() - - # Execute prelaunch hooks - for prelaunch_hook in self.prelaunch_hooks: - self.log.debug("Executing prelaunch hook: {}".format( - str(prelaunch_hook.__class__.__name__) - )) - prelaunch_hook.execute() + if not self._prelaunch_hooks_executed: + self.run_prelaunch_hooks() self.log.debug("All prelaunch hook executed. Starting new process.") @@ -1353,6 +1442,7 @@ def get_app_environments_for_context( task_name, app_name, env_group=None, + launch_type=None, env=None, modules_manager=None ): @@ -1363,54 +1453,33 @@ def get_app_environments_for_context( task_name (str): Name of task. app_name (str): Name of application that is launched and can be found by ApplicationManager. - env (dict): Initial environment variables. `os.environ` is used when - not passed. - modules_manager (ModulesManager): Initialized modules manager. + env_group (Optional[str]): Name of environment group. If not passed + default group is used. + launch_type (Optional[str]): Type for which prelaunch hooks are + executed. + env (Optional[dict[str, str]]): Initial environment variables. + `os.environ` is used when not passed. + modules_manager (Optional[ModulesManager]): Initialized modules + manager. Returns: dict: Environments for passed context and application. """ - from openpype.modules import ModulesManager - from openpype.pipeline import Anatomy - from openpype.lib.openpype_version import is_running_staging - - # Project document - project_doc = get_project(project_name) - asset_doc = get_asset_by_name(project_name, asset_name) - - if modules_manager is None: - modules_manager = ModulesManager() - - # Prepare app object which can be obtained only from ApplciationManager + # Prepare app object which can be obtained only from ApplicationManager app_manager = ApplicationManager() - app = app_manager.applications[app_name] - - # Project's anatomy - anatomy = Anatomy(project_name) - - data = EnvironmentPrepData({ - "project_name": project_name, - "asset_name": asset_name, - "task_name": task_name, - - "app": app, - - "project_doc": project_doc, - "asset_doc": asset_doc, - - "anatomy": anatomy, - - "env": env - }) - data["env"].update(anatomy.root_environments()) - if is_running_staging(): - data["env"]["OPENPYPE_IS_STAGING"] = "1" - - prepare_app_environments(data, env_group, modules_manager) - prepare_context_environments(data, env_group, modules_manager) - - return data["env"] + context = app_manager.create_launch_context( + app_name, + project_name=project_name, + asset_name=asset_name, + task_name=task_name, + env_group=env_group, + launch_type=launch_type, + env=env, + modules_manager=modules_manager, + ) + context.run_prelaunch_hooks() + return context.env def _merge_env(env, current_env): diff --git a/openpype/modules/ftrack/launch_hooks/post_ftrack_changes.py b/openpype/modules/ftrack/launch_hooks/post_ftrack_changes.py index 86ecffd5b8..ac4e499e41 100644 --- a/openpype/modules/ftrack/launch_hooks/post_ftrack_changes.py +++ b/openpype/modules/ftrack/launch_hooks/post_ftrack_changes.py @@ -2,11 +2,12 @@ import os import ftrack_api from openpype.settings import get_project_settings -from openpype.lib import PostLaunchHook +from openpype.lib.applications import PostLaunchHook, LaunchTypes class PostFtrackHook(PostLaunchHook): order = None + launch_types = {LaunchTypes.local} def execute(self): project_name = self.data.get("project_name") diff --git a/openpype/modules/slack/launch_hooks/pre_python2_vendor.py b/openpype/modules/slack/launch_hooks/pre_python2_vendor.py index 0f4bc22a34..891c92bb7a 100644 --- a/openpype/modules/slack/launch_hooks/pre_python2_vendor.py +++ b/openpype/modules/slack/launch_hooks/pre_python2_vendor.py @@ -1,5 +1,5 @@ import os -from openpype.lib import PreLaunchHook +from openpype.lib.applications import PreLaunchHook from openpype_modules.slack import SLACK_MODULE_DIR @@ -8,6 +8,7 @@ class PrePython2Support(PreLaunchHook): Path to vendor modules is added to the beginning of PYTHONPATH. """ + launch_types = set() def execute(self): if not self.application.use_python_2: diff --git a/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py b/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py index bbc220945c..77f6933756 100644 --- a/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py +++ b/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py @@ -1,12 +1,8 @@ import os import shutil -from openpype.client.entities import ( - get_representations, - get_project -) - -from openpype.lib import PreLaunchHook +from openpype.client.entities import get_representations +from openpype.lib.applications import PreLaunchHook, LaunchTypes from openpype.lib.profiles_filtering import filter_profiles from openpype.modules.sync_server.sync_server import ( download_last_published_workfile, @@ -32,6 +28,7 @@ class CopyLastPublishedWorkfile(PreLaunchHook): "nuke", "nukeassist", "nukex", "hiero", "nukestudio", "maya", "harmony", "celaction", "flame", "fusion", "houdini", "tvpaint"] + launch_types = {LaunchTypes.local} def execute(self): """Check if local workfile doesn't exist, else copy it. diff --git a/openpype/modules/timers_manager/launch_hooks/post_start_timer.py b/openpype/modules/timers_manager/launch_hooks/post_start_timer.py index d6ae013403..76c3cca33e 100644 --- a/openpype/modules/timers_manager/launch_hooks/post_start_timer.py +++ b/openpype/modules/timers_manager/launch_hooks/post_start_timer.py @@ -1,4 +1,4 @@ -from openpype.lib import PostLaunchHook +from openpype.lib.applications import PostLaunchHook, LaunchTypes class PostStartTimerHook(PostLaunchHook): @@ -7,6 +7,7 @@ class PostStartTimerHook(PostLaunchHook): This module requires enabled TimerManager module. """ order = None + launch_types = {LaunchTypes.local} def execute(self): project_name = self.data.get("project_name") diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 8a3f25a026..4cb4b97707 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -88,7 +88,10 @@ class PypeCommands: """ from openpype.lib import Logger - from openpype.lib.applications import get_app_environments_for_context + from openpype.lib.applications import ( + get_app_environments_for_context, + LaunchTypes, + ) from openpype.modules import ModulesManager from openpype.pipeline import ( install_openpype_plugins, @@ -122,7 +125,8 @@ class PypeCommands: context["project_name"], context["asset_name"], context["task_name"], - app_full_name + app_full_name, + launch_type=LaunchTypes.farm_publish, ) os.environ.update(env) @@ -237,11 +241,19 @@ class PypeCommands: Called by Deadline plugin to propagate environment into render jobs. """ - from openpype.lib.applications import get_app_environments_for_context + from openpype.lib.applications import ( + get_app_environments_for_context, + LaunchTypes, + ) if all((project, asset, task, app)): env = get_app_environments_for_context( - project, asset, task, app, env_group + project, + asset, + task, + app, + env_group=env_group, + launch_type=LaunchTypes.farm_render, ) else: env = os.environ.copy() From a4660f4d6ccbb0f2c3a9197b52da9a797fc758d9 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 3 Aug 2023 16:06:36 +0800 Subject: [PATCH 075/107] use the empty modifiers in container to store OP/AYON Parameter --- openpype/hosts/max/api/plugin.py | 8 +++++--- openpype/hosts/max/plugins/publish/collect_members.py | 2 +- openpype/hosts/unreal/integration | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/max/api/plugin.py b/openpype/hosts/max/api/plugin.py index d8db716e6d..7b93a1a7cf 100644 --- a/openpype/hosts/max/api/plugin.py +++ b/openpype/hosts/max/api/plugin.py @@ -183,9 +183,11 @@ class MaxCreatorBase(object): """ if isinstance(node, str): node = rt.Container(name=node) - attrs = rt.Execute(MS_CUSTOM_ATTRIB) - rt.custAttributes.add(node.baseObject, attrs) + modifier = rt.EmptyModifier() + rt.addModifier(node, modifier) + node.modifiers[0].name = "OP Data" + rt.custAttributes.add(node.modifiers[0], attrs) return node @@ -215,7 +217,7 @@ class MaxCreator(Creator, MaxCreatorBase): # Setting the property rt.setProperty( - instance_node.openPypeData, "all_handles", node_list) + instance_node.modifiers[0].openPypeData, "all_handles", node_list) self._add_instance_to_context(instance) imprint(instance_node.name, instance.data_to_store()) diff --git a/openpype/hosts/max/plugins/publish/collect_members.py b/openpype/hosts/max/plugins/publish/collect_members.py index 812d82ff26..2970cf0e24 100644 --- a/openpype/hosts/max/plugins/publish/collect_members.py +++ b/openpype/hosts/max/plugins/publish/collect_members.py @@ -17,6 +17,6 @@ class CollectMembers(pyblish.api.InstancePlugin): container = rt.GetNodeByName(instance.data["instance_node"]) instance.data["members"] = [ member.node for member - in container.openPypeData.all_handles + in container.modifiers[0].openPypeData.all_handles ] self.log.debug("{}".format(instance.data["members"])) diff --git a/openpype/hosts/unreal/integration b/openpype/hosts/unreal/integration index 63266607ce..ff15c70077 160000 --- a/openpype/hosts/unreal/integration +++ b/openpype/hosts/unreal/integration @@ -1 +1 @@ -Subproject commit 63266607ceb972a61484f046634ddfc9eb0b5757 +Subproject commit ff15c700771e719cc5f3d561ac5d6f7590623986 From 20f1f99f9ebe1b9ace17e4e1484d41484b1dfe90 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 3 Aug 2023 16:17:56 +0800 Subject: [PATCH 076/107] hound shut --- openpype/hosts/max/api/plugin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/api/plugin.py b/openpype/hosts/max/api/plugin.py index 7b93a1a7cf..9d36e36ccb 100644 --- a/openpype/hosts/max/api/plugin.py +++ b/openpype/hosts/max/api/plugin.py @@ -217,7 +217,8 @@ class MaxCreator(Creator, MaxCreatorBase): # Setting the property rt.setProperty( - instance_node.modifiers[0].openPypeData, "all_handles", node_list) + instance_node.modifiers[0].openPypeData, + "all_handles", node_list) self._add_instance_to_context(instance) imprint(instance_node.name, instance.data_to_store()) From 20376655faba25002d4e0bc0f31e2c4cd0bd2bb3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 3 Aug 2023 10:24:00 +0200 Subject: [PATCH 077/107] use relative path to MAX_HOST_DIR constant (#5382) --- openpype/hosts/max/hooks/force_startup_script.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/hooks/force_startup_script.py b/openpype/hosts/max/hooks/force_startup_script.py index 701e348293..64ce46336f 100644 --- a/openpype/hosts/max/hooks/force_startup_script.py +++ b/openpype/hosts/max/hooks/force_startup_script.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Pre-launch to force 3ds max startup script.""" import os +from openpype.hosts.max import MAX_HOST_DIR from openpype.lib.applications import PreLaunchHook, LaunchTypes @@ -21,5 +22,6 @@ class ForceStartupScript(PreLaunchHook): startup_args = [ "-U", "MAXScript", - f"{os.getenv('OPENPYPE_ROOT')}\\openpype\\hosts\\max\\startup\\startup.ms"] # noqa + os.path.join(MAX_HOST_DIR, "startup", "startup.ms"), + ] self.launch_context.launch_args.append(startup_args) From 9e008a80e0cb45f25d06f29d626e5657e199cf69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Thu, 3 Aug 2023 10:33:09 +0200 Subject: [PATCH 078/107] Update openpype/hosts/nuke/api/plugin.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/nuke/api/plugin.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 4a7bb03216..18e48ec79d 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -215,15 +215,10 @@ class NukeCreator(NewCreator): for created_inst, changes in update_list: instance_node = created_inst.transient_data["node"] - changed_keys = { - key: changes[key].new_value - for key in changes.changed_keys - } - # update instance node name if subset name changed - if "subset" in changed_keys: + if "subset" in changes: instance_node["name"].setValue( - changed_keys["subset"] + changes["subset"].new_value ) # in case node is not existing anymore (user erased it manually) From 9a8a16eed89d321c631aff3bd1379700afbd901c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 3 Aug 2023 10:35:36 +0200 Subject: [PATCH 079/107] use better list to check from --- openpype/hosts/nuke/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 18e48ec79d..85a4046823 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -216,7 +216,7 @@ class NukeCreator(NewCreator): instance_node = created_inst.transient_data["node"] # update instance node name if subset name changed - if "subset" in changes: + if "subset" in changes.changed_keys: instance_node["name"].setValue( changes["subset"].new_value ) From bee48e9fbfc669163fc521e2a4b180a1d28cb420 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 3 Aug 2023 16:41:13 +0800 Subject: [PATCH 080/107] resolve unrelated codes --- openpype/hosts/unreal/integration | 2 +- tools/modules/powershell/PSWriteColor | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/unreal/integration b/openpype/hosts/unreal/integration index ff15c70077..63266607ce 160000 --- a/openpype/hosts/unreal/integration +++ b/openpype/hosts/unreal/integration @@ -1 +1 @@ -Subproject commit ff15c700771e719cc5f3d561ac5d6f7590623986 +Subproject commit 63266607ceb972a61484f046634ddfc9eb0b5757 diff --git a/tools/modules/powershell/PSWriteColor b/tools/modules/powershell/PSWriteColor index 12eda384eb..5941ee3803 160000 --- a/tools/modules/powershell/PSWriteColor +++ b/tools/modules/powershell/PSWriteColor @@ -1 +1 @@ -Subproject commit 12eda384ebd7a7954e15855e312215c009c97114 +Subproject commit 5941ee380367693bcd52dfe269f63ed4120df900 From ed3e008781be9fb6d48b571b19ad976365671cf0 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 3 Aug 2023 16:49:03 +0800 Subject: [PATCH 081/107] resovled the code --- openpype/hosts/unreal/integration | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/integration b/openpype/hosts/unreal/integration index 63266607ce..ff15c70077 160000 --- a/openpype/hosts/unreal/integration +++ b/openpype/hosts/unreal/integration @@ -1 +1 @@ -Subproject commit 63266607ceb972a61484f046634ddfc9eb0b5757 +Subproject commit ff15c700771e719cc5f3d561ac5d6f7590623986 From 5d8ac1d63757d806e992c9faefb2f4b25345ee35 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 3 Aug 2023 16:55:26 +0800 Subject: [PATCH 082/107] resolve unrelated codes --- openpype/hosts/max/api/plugin.py | 5 +++-- openpype/hosts/max/plugins/publish/collect_members.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/api/plugin.py b/openpype/hosts/max/api/plugin.py index 9d36e36ccb..670c3ba860 100644 --- a/openpype/hosts/max/api/plugin.py +++ b/openpype/hosts/max/api/plugin.py @@ -183,6 +183,7 @@ class MaxCreatorBase(object): """ if isinstance(node, str): node = rt.Container(name=node) + attrs = rt.Execute(MS_CUSTOM_ATTRIB) modifier = rt.EmptyModifier() rt.addModifier(node, modifier) @@ -257,8 +258,8 @@ class MaxCreator(Creator, MaxCreatorBase): instance_node = rt.GetNodeByName( instance.data.get("instance_node")) if instance_node: - count = rt.custAttributes.count(instance_node) - rt.custAttributes.delete(instance_node, count) + count = rt.custAttributes.count(instance_node.modifiers[0]) + rt.custAttributes.delete(instance_node.modifiers[0], count) rt.Delete(instance_node) self._remove_instance_from_context(instance) diff --git a/openpype/hosts/max/plugins/publish/collect_members.py b/openpype/hosts/max/plugins/publish/collect_members.py index 2970cf0e24..4efd92dd70 100644 --- a/openpype/hosts/max/plugins/publish/collect_members.py +++ b/openpype/hosts/max/plugins/publish/collect_members.py @@ -17,6 +17,6 @@ class CollectMembers(pyblish.api.InstancePlugin): container = rt.GetNodeByName(instance.data["instance_node"]) instance.data["members"] = [ member.node for member - in container.modifiers[0].openPypeData.all_handles + in container.modifiers[0].openPypeData.all_handles ] self.log.debug("{}".format(instance.data["members"])) From e230b3a66dd080c836924ad88fd2f830a4497bcb Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 3 Aug 2023 16:56:19 +0800 Subject: [PATCH 083/107] resolve unrelated codes --- openpype/hosts/unreal/integration | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/integration b/openpype/hosts/unreal/integration index ff15c70077..63266607ce 160000 --- a/openpype/hosts/unreal/integration +++ b/openpype/hosts/unreal/integration @@ -1 +1 @@ -Subproject commit ff15c700771e719cc5f3d561ac5d6f7590623986 +Subproject commit 63266607ceb972a61484f046634ddfc9eb0b5757 From 188c6f64b08b9953a0d8b5f61b3b29ec84b08dd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Thu, 3 Aug 2023 10:58:52 +0200 Subject: [PATCH 084/107] Bugfix: Dependency without 'inputLinks' not downloaded (#5337) * Bugfix: Dependency without 'inputLinks' not downloaded * cleaning --- openpype/client/mongo/entity_links.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openpype/client/mongo/entity_links.py b/openpype/client/mongo/entity_links.py index c97a828118..fd13a2d83b 100644 --- a/openpype/client/mongo/entity_links.py +++ b/openpype/client/mongo/entity_links.py @@ -212,16 +212,12 @@ def _process_referenced_pipeline_result(result, link_type): continue for output in sorted(outputs_recursive, key=lambda o: o["depth"]): - output_links = output.get("data", {}).get("inputLinks") - if not output_links and output["type"] != "hero_version": - continue - # Leaf if output["_id"] not in correctly_linked_ids: continue _filter_input_links( - output_links, + output.get("data", {}).get("inputLinks"), link_type, correctly_linked_ids ) From 5da9e65975171b31d599dd15c065ac4f518e067d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 3 Aug 2023 10:59:39 +0200 Subject: [PATCH 085/107] removed unused imports from AE extractor (#5397) --- .../aftereffects/plugins/publish/extract_local_render.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py b/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py index c70aa41dbe..bdb48e11f8 100644 --- a/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py @@ -1,11 +1,5 @@ import os -import sys -import six -from openpype.lib import ( - get_ffmpeg_tool_path, - run_subprocess, -) from openpype.pipeline import publish from openpype.hosts.aftereffects.api import get_stub From 8130699bd81ee49c5800b501c18f48faa19343bf Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 3 Aug 2023 17:02:10 +0800 Subject: [PATCH 086/107] resolve unrelated codes --- openpype/hosts/max/plugins/publish/collect_members.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/collect_members.py b/openpype/hosts/max/plugins/publish/collect_members.py index 4efd92dd70..2970cf0e24 100644 --- a/openpype/hosts/max/plugins/publish/collect_members.py +++ b/openpype/hosts/max/plugins/publish/collect_members.py @@ -17,6 +17,6 @@ class CollectMembers(pyblish.api.InstancePlugin): container = rt.GetNodeByName(instance.data["instance_node"]) instance.data["members"] = [ member.node for member - in container.modifiers[0].openPypeData.all_handles + in container.modifiers[0].openPypeData.all_handles ] self.log.debug("{}".format(instance.data["members"])) From 6f376d39163fc981526a163d8fd9f0001865812d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 3 Aug 2023 11:29:36 +0200 Subject: [PATCH 087/107] nuke: put Workfile builder on deprication also fix the workfile calback loop --- openpype/hosts/nuke/api/lib.py | 37 ++++++++++--------- openpype/hosts/nuke/api/pipeline.py | 10 ++++- .../projects_schema/schema_project_nuke.json | 4 ++ 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 364c8eeff4..6b88cbcf34 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -424,10 +424,13 @@ def add_publish_knob(node): return node -@deprecated +@deprecated("openpype.hosts.nuke.api.lib.set_node_data") def set_avalon_knob_data(node, data=None, prefix="avalon:"): """[DEPRECATED] Sets data into nodes's avalon knob + This function is still used but soon will be deprecated. + Use `set_node_data` instead. + Arguments: node (nuke.Node): Nuke node to imprint with data, data (dict, optional): Data to be imprinted into AvalonTab @@ -487,10 +490,13 @@ def set_avalon_knob_data(node, data=None, prefix="avalon:"): return node -@deprecated +@deprecated("openpype.hosts.nuke.api.lib.get_node_data") def get_avalon_knob_data(node, prefix="avalon:", create=True): """[DEPRECATED] Gets a data from nodes's avalon knob + This function is still used but soon will be deprecated. + Use `get_node_data` instead. + Arguments: node (obj): Nuke node to search for data, prefix (str, optional): filtering prefix @@ -2204,7 +2210,6 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. continue preset_clrsp = input["colorspace"] - log.debug(preset_clrsp) if preset_clrsp is not None: current = n["colorspace"].value() future = str(preset_clrsp) @@ -2686,7 +2691,15 @@ def _launch_workfile_app(): host_tools.show_workfiles(parent=None, on_top=True) +@deprecated("openpype.hosts.nuke.api.lib.start_workfile_template_builder") def process_workfile_builder(): + """ [DEPRECATED] Process workfile builder on nuke start + + This function is deprecated and will be removed in future versions. + Use settings for `project_settings/nuke/templated_workfile_build` which are + supported by api `start_workfile_template_builder()`. + """ + # to avoid looping of the callback, remove it! nuke.removeOnCreate(process_workfile_builder, nodeClass="Root") @@ -2695,11 +2708,6 @@ def process_workfile_builder(): workfile_builder = project_settings["nuke"].get( "workfile_builder", {}) - # get all imortant settings - openlv_on = env_value_to_bool( - env_key="AVALON_OPEN_LAST_WORKFILE", - default=None) - # get settings createfv_on = workfile_builder.get("create_first_version") or None builder_on = workfile_builder.get("builder_on_start") or None @@ -2740,20 +2748,15 @@ def process_workfile_builder(): save_file(last_workfile_path) return - # skip opening of last version if it is not enabled - if not openlv_on or not os.path.exists(last_workfile_path): - return - - log.info("Opening last workfile...") - # open workfile - open_file(last_workfile_path) - def start_workfile_template_builder(): from .workfile_template_builder import ( build_workfile_template ) + # remove callback since it would be duplicating the workfile + nuke.removeOnCreate(start_workfile_template_builder, nodeClass="Root") + # to avoid looping of the callback, remove it! log.info("Starting workfile template builder...") try: @@ -2761,8 +2764,6 @@ def start_workfile_template_builder(): except TemplateProfileNotFound: log.warning("Template profile not found. Skipping...") - # remove callback since it would be duplicating the workfile - nuke.removeOnCreate(start_workfile_template_builder, nodeClass="Root") @deprecated def recreate_instance(origin_node, avalon_data=None): diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index a48ae0032a..c6bdd5feaf 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -34,6 +34,7 @@ from .lib import ( get_main_window, add_publish_knob, WorkfileSettings, + # TODO: remove this once workfile builder will be removed process_workfile_builder, start_workfile_template_builder, launch_workfiles_app, @@ -159,8 +160,14 @@ def add_nuke_callbacks(): # Set context settings. nuke.addOnCreate( workfile_settings.set_context_settings, nodeClass="Root") + + # adding favorites to file browser nuke.addOnCreate(workfile_settings.set_favorites, nodeClass="Root") + + # template builder callbacks nuke.addOnCreate(start_workfile_template_builder, nodeClass="Root") + + # TODO: remove this callback once workfile builder will be removed nuke.addOnCreate(process_workfile_builder, nodeClass="Root") # fix ffmpeg settings on script @@ -170,9 +177,10 @@ def add_nuke_callbacks(): nuke.addOnScriptLoad(check_inventory_versions) nuke.addOnScriptSave(check_inventory_versions) - # # set apply all workfile settings on script load and save + # set apply all workfile settings on script load and save nuke.addOnScriptLoad(WorkfileSettings().set_context_settings) + if nuke_settings["nuke-dirmap"]["enabled"]: log.info("Added Nuke's dir-mapping callback ...") # Add dirmap for file paths. diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json index 26c64e6219..6b516ddf4a 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json @@ -284,6 +284,10 @@ "type": "schema_template", "name": "template_workfile_options" }, + { + "type": "label", + "label": "^ Settings and for Workfile Builder is deprecated and will be soon removed.
Please use Template Workfile Build Settings instead." + }, { "type": "schema", "name": "schema_templated_workfile_build" From 18f891a3f9436dc6f056bed86fd309bb56f56ac0 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 3 Aug 2023 17:48:32 +0800 Subject: [PATCH 088/107] resolve submodule conflict --- tools/modules/powershell/PSWriteColor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/modules/powershell/PSWriteColor b/tools/modules/powershell/PSWriteColor index 5941ee3803..12eda384eb 160000 --- a/tools/modules/powershell/PSWriteColor +++ b/tools/modules/powershell/PSWriteColor @@ -1 +1 @@ -Subproject commit 5941ee380367693bcd52dfe269f63ed4120df900 +Subproject commit 12eda384ebd7a7954e15855e312215c009c97114 From 3ae020f064feb9eb9ee61576ab9a1a9264f1433b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 3 Aug 2023 12:24:23 +0200 Subject: [PATCH 089/107] Applications: Launch hooks cleanup (#5395) * ApplicationManager can have more granular way how applications are launched * executable is optional to be able create ApplicationLaunchContext * launch context can run prelaunch hooks without launching application * 'get_app_environments_for_context' is using launch context to prepare environments * added 'launch_type' as one of filtering options for LaunchHook * added 'local' launch type filter to existing launch hooks * define 'automated' launch type in remote publish function * modified publish and extract environments cli commands * launch types are only for local by default * fix import * fix launch types of global host data * change order or kwargs * change unreal filter attribute * use set instead of list * removed '__init__' from celaction hooks * use 'CELACTION_ROOT_DIR' in pre setup * use full import from applications --- openpype/hooks/pre_add_last_workfile_arg.py | 6 +++--- openpype/hooks/pre_copy_template_workfile.py | 2 +- openpype/hooks/pre_foundry_apps.py | 4 ++-- openpype/hooks/pre_mac_launch.py | 2 +- openpype/hooks/pre_non_python_host_launch.py | 2 +- openpype/hooks/pre_ocio_hook.py | 12 +++++------- .../hosts/blender/hooks/pre_windows_console.py | 4 ++-- openpype/hosts/celaction/hooks/__init__.py | 0 .../celaction/hooks/pre_celaction_setup.py | 17 +++++++---------- openpype/hosts/flame/hooks/pre_flame_setup.py | 2 +- .../fusion/hooks/pre_fusion_profile_hook.py | 2 +- openpype/hosts/fusion/hooks/pre_fusion_setup.py | 2 +- openpype/hosts/houdini/hooks/set_paths.py | 2 +- .../hosts/max/hooks/force_startup_script.py | 2 +- openpype/hosts/max/hooks/inject_python.py | 2 +- openpype/hosts/max/hooks/set_paths.py | 2 +- .../hosts/maya/hooks/pre_auto_load_plugins.py | 2 +- openpype/hosts/maya/hooks/pre_copy_mel.py | 2 +- .../pre_open_workfile_post_initialization.py | 2 +- .../hosts/nuke/hooks/pre_nukeassist_setup.py | 2 +- .../resolve/hooks/pre_resolve_last_workfile.py | 2 +- .../hosts/resolve/hooks/pre_resolve_setup.py | 2 +- .../hosts/resolve/hooks/pre_resolve_startup.py | 2 +- openpype/hosts/tvpaint/hooks/pre_launch_args.py | 2 +- 24 files changed, 37 insertions(+), 42 deletions(-) delete mode 100644 openpype/hosts/celaction/hooks/__init__.py diff --git a/openpype/hooks/pre_add_last_workfile_arg.py b/openpype/hooks/pre_add_last_workfile_arg.py index 0e43f1bfe6..c160d8e062 100644 --- a/openpype/hooks/pre_add_last_workfile_arg.py +++ b/openpype/hooks/pre_add_last_workfile_arg.py @@ -13,7 +13,7 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): # Execute after workfile template copy order = 10 - app_groups = [ + app_groups = { "3dsmax", "maya", "nuke", @@ -26,8 +26,8 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): "photoshop", "tvpaint", "substancepainter", - "aftereffects" - ] + "aftereffects", + } launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hooks/pre_copy_template_workfile.py b/openpype/hooks/pre_copy_template_workfile.py index 9962dabdd8..2203ff4396 100644 --- a/openpype/hooks/pre_copy_template_workfile.py +++ b/openpype/hooks/pre_copy_template_workfile.py @@ -19,7 +19,7 @@ class CopyTemplateWorkfile(PreLaunchHook): # Before `AddLastWorkfileToLaunchArgs` order = 0 - app_groups = ["blender", "photoshop", "tvpaint", "aftereffects"] + app_groups = {"blender", "photoshop", "tvpaint", "aftereffects"} launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hooks/pre_foundry_apps.py b/openpype/hooks/pre_foundry_apps.py index 50e50e74a2..7536df4c16 100644 --- a/openpype/hooks/pre_foundry_apps.py +++ b/openpype/hooks/pre_foundry_apps.py @@ -13,8 +13,8 @@ class LaunchFoundryAppsWindows(PreLaunchHook): # Should be as last hook because must change launch arguments to string order = 1000 - app_groups = ["nuke", "nukeassist", "nukex", "hiero", "nukestudio"] - platforms = ["windows"] + app_groups = {"nuke", "nukeassist", "nukex", "hiero", "nukestudio"} + platforms = {"windows"} launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hooks/pre_mac_launch.py b/openpype/hooks/pre_mac_launch.py index 298346c9b1..402e9a5517 100644 --- a/openpype/hooks/pre_mac_launch.py +++ b/openpype/hooks/pre_mac_launch.py @@ -12,7 +12,7 @@ class LaunchWithTerminal(PreLaunchHook): """ order = 1000 - platforms = ["darwin"] + platforms = {"darwin"} launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hooks/pre_non_python_host_launch.py b/openpype/hooks/pre_non_python_host_launch.py index e58c354360..d9e912c826 100644 --- a/openpype/hooks/pre_non_python_host_launch.py +++ b/openpype/hooks/pre_non_python_host_launch.py @@ -17,7 +17,7 @@ class NonPythonHostHook(PreLaunchHook): python script which launch the host. For these cases it is necessary to prepend python (or openpype) executable and script path before application's. """ - app_groups = ["harmony", "photoshop", "aftereffects"] + app_groups = {"harmony", "photoshop", "aftereffects"} order = 20 launch_types = {LaunchTypes.local} diff --git a/openpype/hooks/pre_ocio_hook.py b/openpype/hooks/pre_ocio_hook.py index 7c53d3db66..1ac305b635 100644 --- a/openpype/hooks/pre_ocio_hook.py +++ b/openpype/hooks/pre_ocio_hook.py @@ -1,8 +1,6 @@ -from openpype.lib import PreLaunchHook +from openpype.lib.applications import PreLaunchHook -from openpype.pipeline.colorspace import ( - get_imageio_config -) +from openpype.pipeline.colorspace import get_imageio_config from openpype.pipeline.template_data import get_template_data_with_names @@ -10,7 +8,7 @@ class OCIOEnvHook(PreLaunchHook): """Set OCIO environment variable for hosts that use OpenColorIO.""" order = 0 - hosts = [ + hosts = { "substancepainter", "fusion", "blender", @@ -20,8 +18,8 @@ class OCIOEnvHook(PreLaunchHook): "maya", "nuke", "hiero", - "resolve" - ] + "resolve", + } launch_types = set() def execute(self): diff --git a/openpype/hosts/blender/hooks/pre_windows_console.py b/openpype/hosts/blender/hooks/pre_windows_console.py index c6ecf284ef..2161b7a2f5 100644 --- a/openpype/hosts/blender/hooks/pre_windows_console.py +++ b/openpype/hosts/blender/hooks/pre_windows_console.py @@ -13,8 +13,8 @@ class BlenderConsoleWindows(PreLaunchHook): # Should be as last hook because must change launch arguments to string order = 1000 - app_groups = ["blender"] - platforms = ["windows"] + app_groups = {"blender"} + platforms = {"windows"} launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hosts/celaction/hooks/__init__.py b/openpype/hosts/celaction/hooks/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openpype/hosts/celaction/hooks/pre_celaction_setup.py b/openpype/hosts/celaction/hooks/pre_celaction_setup.py index df27195e60..83aeab7c58 100644 --- a/openpype/hosts/celaction/hooks/pre_celaction_setup.py +++ b/openpype/hosts/celaction/hooks/pre_celaction_setup.py @@ -4,19 +4,15 @@ import winreg import subprocess from openpype.lib import get_openpype_execute_args from openpype.lib.applications import PreLaunchHook, LaunchTypes -from openpype.hosts.celaction import scripts - -CELACTION_SCRIPTS_DIR = os.path.dirname( - os.path.abspath(scripts.__file__) -) +from openpype.hosts.celaction import CELACTION_ROOT_DIR class CelactionPrelaunchHook(PreLaunchHook): """ Bootstrap celacion with pype """ - app_groups = ["celaction"] - platforms = ["windows"] + app_groups = {"celaction"} + platforms = {"windows"} launch_types = {LaunchTypes.local} def execute(self): @@ -39,7 +35,9 @@ class CelactionPrelaunchHook(PreLaunchHook): winreg.KEY_ALL_ACCESS ) - path_to_cli = os.path.join(CELACTION_SCRIPTS_DIR, "publish_cli.py") + path_to_cli = os.path.join( + CELACTION_ROOT_DIR, "scripts", "publish_cli.py" + ) subprocess_args = get_openpype_execute_args("run", path_to_cli) openpype_executable = subprocess_args.pop(0) workfile_settings = self.get_workfile_settings() @@ -124,9 +122,8 @@ class CelactionPrelaunchHook(PreLaunchHook): if not os.path.exists(workfile_path): # TODO add ability to set different template workfile path via # settings - openpype_celaction_dir = os.path.dirname(CELACTION_SCRIPTS_DIR) template_path = os.path.join( - openpype_celaction_dir, + CELACTION_ROOT_DIR, "resources", "celaction_template_scene.scn" ) diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index 61e3200d89..850569cfdd 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -19,7 +19,7 @@ class FlamePrelaunch(PreLaunchHook): Will make sure flame_script_dirs are copied to user's folder defined in environment var FLAME_SCRIPT_DIR. """ - app_groups = ["flame"] + app_groups = {"flame"} permissions = 0o777 wtc_script_path = os.path.join( diff --git a/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py b/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py index da74f8e1fe..66b0f803aa 100644 --- a/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py +++ b/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py @@ -25,7 +25,7 @@ class FusionCopyPrefsPrelaunch(PreLaunchHook): Master.prefs is defined in openpype/hosts/fusion/deploy/fusion_shared.prefs """ - app_groups = ["fusion"] + app_groups = {"fusion"} order = 2 launch_types = {LaunchTypes.local} diff --git a/openpype/hosts/fusion/hooks/pre_fusion_setup.py b/openpype/hosts/fusion/hooks/pre_fusion_setup.py index 68ef23d520..576628e876 100644 --- a/openpype/hosts/fusion/hooks/pre_fusion_setup.py +++ b/openpype/hosts/fusion/hooks/pre_fusion_setup.py @@ -21,7 +21,7 @@ class FusionPrelaunch(PreLaunchHook): Fusion 18 : Python 3.6 - 3.10 """ - app_groups = ["fusion"] + app_groups = {"fusion"} order = 1 launch_types = {LaunchTypes.local} diff --git a/openpype/hosts/houdini/hooks/set_paths.py b/openpype/hosts/houdini/hooks/set_paths.py index 2e7bf51757..b23659e23b 100644 --- a/openpype/hosts/houdini/hooks/set_paths.py +++ b/openpype/hosts/houdini/hooks/set_paths.py @@ -6,7 +6,7 @@ class SetPath(PreLaunchHook): Hook `GlobalHostDataHook` must be executed before this hook. """ - app_groups = ["houdini"] + app_groups = {"houdini"} launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hosts/max/hooks/force_startup_script.py b/openpype/hosts/max/hooks/force_startup_script.py index 64ce46336f..d87697b819 100644 --- a/openpype/hosts/max/hooks/force_startup_script.py +++ b/openpype/hosts/max/hooks/force_startup_script.py @@ -14,7 +14,7 @@ class ForceStartupScript(PreLaunchHook): Hook `GlobalHostDataHook` must be executed before this hook. """ - app_groups = ["3dsmax"] + app_groups = {"3dsmax"} order = 11 launch_types = {LaunchTypes.local} diff --git a/openpype/hosts/max/hooks/inject_python.py b/openpype/hosts/max/hooks/inject_python.py index bbfc95c078..874884585e 100644 --- a/openpype/hosts/max/hooks/inject_python.py +++ b/openpype/hosts/max/hooks/inject_python.py @@ -13,7 +13,7 @@ class InjectPythonPath(PreLaunchHook): Hook `GlobalHostDataHook` must be executed before this hook. """ - app_groups = ["3dsmax"] + app_groups = {"3dsmax"} launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hosts/max/hooks/set_paths.py b/openpype/hosts/max/hooks/set_paths.py index f06efff7c8..4b961fa91e 100644 --- a/openpype/hosts/max/hooks/set_paths.py +++ b/openpype/hosts/max/hooks/set_paths.py @@ -6,7 +6,7 @@ class SetPath(PreLaunchHook): Hook `GlobalHostDataHook` must be executed before this hook. """ - app_groups = ["max"] + app_groups = {"max"} launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hosts/maya/hooks/pre_auto_load_plugins.py b/openpype/hosts/maya/hooks/pre_auto_load_plugins.py index 0437b6fd9d..4b1ea698a6 100644 --- a/openpype/hosts/maya/hooks/pre_auto_load_plugins.py +++ b/openpype/hosts/maya/hooks/pre_auto_load_plugins.py @@ -6,7 +6,7 @@ class MayaPreAutoLoadPlugins(PreLaunchHook): # Before AddLastWorkfileToLaunchArgs order = 9 - app_groups = ["maya"] + app_groups = {"maya"} launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hosts/maya/hooks/pre_copy_mel.py b/openpype/hosts/maya/hooks/pre_copy_mel.py index ebb0c521c9..0fb5af149a 100644 --- a/openpype/hosts/maya/hooks/pre_copy_mel.py +++ b/openpype/hosts/maya/hooks/pre_copy_mel.py @@ -7,7 +7,7 @@ class PreCopyMel(PreLaunchHook): Hook `GlobalHostDataHook` must be executed before this hook. """ - app_groups = ["maya"] + app_groups = {"maya"} launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hosts/maya/hooks/pre_open_workfile_post_initialization.py b/openpype/hosts/maya/hooks/pre_open_workfile_post_initialization.py index 0c1fd0efe3..1fe3c3ca2c 100644 --- a/openpype/hosts/maya/hooks/pre_open_workfile_post_initialization.py +++ b/openpype/hosts/maya/hooks/pre_open_workfile_post_initialization.py @@ -6,7 +6,7 @@ class MayaPreOpenWorkfilePostInitialization(PreLaunchHook): # Before AddLastWorkfileToLaunchArgs. order = 9 - app_groups = ["maya"] + app_groups = {"maya"} launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hosts/nuke/hooks/pre_nukeassist_setup.py b/openpype/hosts/nuke/hooks/pre_nukeassist_setup.py index bdb271e3f1..657291ec51 100644 --- a/openpype/hosts/nuke/hooks/pre_nukeassist_setup.py +++ b/openpype/hosts/nuke/hooks/pre_nukeassist_setup.py @@ -5,7 +5,7 @@ class PrelaunchNukeAssistHook(PreLaunchHook): """ Adding flag when nukeassist """ - app_groups = ["nukeassist"] + app_groups = {"nukeassist"} launch_types = set() def execute(self): diff --git a/openpype/hosts/resolve/hooks/pre_resolve_last_workfile.py b/openpype/hosts/resolve/hooks/pre_resolve_last_workfile.py index dc986ec1d2..73f5ac75b1 100644 --- a/openpype/hosts/resolve/hooks/pre_resolve_last_workfile.py +++ b/openpype/hosts/resolve/hooks/pre_resolve_last_workfile.py @@ -9,7 +9,7 @@ class PreLaunchResolveLastWorkfile(PreLaunchHook): workfile. This property is set explicitly in Launcher. """ order = 10 - app_groups = ["resolve"] + app_groups = {"resolve"} launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hosts/resolve/hooks/pre_resolve_setup.py b/openpype/hosts/resolve/hooks/pre_resolve_setup.py index 389256f4da..326f37dffc 100644 --- a/openpype/hosts/resolve/hooks/pre_resolve_setup.py +++ b/openpype/hosts/resolve/hooks/pre_resolve_setup.py @@ -30,7 +30,7 @@ class PreLaunchResolveSetup(PreLaunchHook): """ - app_groups = ["resolve"] + app_groups = {"resolve"} launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hosts/resolve/hooks/pre_resolve_startup.py b/openpype/hosts/resolve/hooks/pre_resolve_startup.py index 649af817ae..6dbfd09a37 100644 --- a/openpype/hosts/resolve/hooks/pre_resolve_startup.py +++ b/openpype/hosts/resolve/hooks/pre_resolve_startup.py @@ -9,7 +9,7 @@ class PreLaunchResolveStartup(PreLaunchHook): """ order = 11 - app_groups = ["resolve"] + app_groups = {"resolve"} launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hosts/tvpaint/hooks/pre_launch_args.py b/openpype/hosts/tvpaint/hooks/pre_launch_args.py index 065da316ab..a1c946b60b 100644 --- a/openpype/hosts/tvpaint/hooks/pre_launch_args.py +++ b/openpype/hosts/tvpaint/hooks/pre_launch_args.py @@ -11,7 +11,7 @@ class TvpaintPrelaunchHook(PreLaunchHook): Existence of last workfile is checked. If workfile does not exists tries to copy templated workfile from predefined path. """ - app_groups = ["tvpaint"] + app_groups = {"tvpaint"} launch_types = {LaunchTypes.local} def execute(self): From 3ba5f1ce6236f5e3fd4da1cf66cfc138e2c70b95 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 3 Aug 2023 14:23:58 +0200 Subject: [PATCH 090/107] adding BBox knob type to settings also fixing some typos --- openpype/hosts/nuke/api/lib.py | 14 ++--- .../schemas/template_nuke_knob_inputs.json | 58 +++++++++++++++++-- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 364c8eeff4..a42983b32e 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -1699,7 +1699,7 @@ def create_write_node_legacy( knob_value = float(knob_value) if knob_type == "bool": knob_value = bool(knob_value) - if knob_type in ["2d_vector", "3d_vector"]: + if knob_type in ["2d_vector", "3d_vector", "color", "box"]: knob_value = list(knob_value) GN[knob_name].setValue(knob_value) @@ -1715,7 +1715,7 @@ def set_node_knobs_from_settings(node, knob_settings, **kwargs): Args: node (nuke.Node): nuke node knob_settings (list): list of dict. Keys are `type`, `name`, `value` - kwargs (dict)[optional]: keys for formatable knob settings + kwargs (dict)[optional]: keys for formattable knob settings """ for knob in knob_settings: log.debug("__ knob: {}".format(pformat(knob))) @@ -1732,7 +1732,7 @@ def set_node_knobs_from_settings(node, knob_settings, **kwargs): ) continue - # first deal with formatable knob settings + # first deal with formattable knob settings if knob_type == "formatable": template = knob["template"] to_type = knob["to_type"] @@ -1741,8 +1741,8 @@ def set_node_knobs_from_settings(node, knob_settings, **kwargs): **kwargs ) except KeyError as msg: - log.warning("__ msg: {}".format(msg)) - raise KeyError(msg) + raise KeyError( + "Not able to format expression: {}".format(msg)) # convert value to correct type if to_type == "2d_vector": @@ -1781,8 +1781,8 @@ def convert_knob_value_to_correct_type(knob_type, knob_value): knob_value = knob_value elif knob_type == "color_gui": knob_value = color_gui_to_int(knob_value) - elif knob_type in ["2d_vector", "3d_vector", "color"]: - knob_value = [float(v) for v in knob_value] + elif knob_type in ["2d_vector", "3d_vector", "color", "box"]: + knob_value = [float(val_) for val_ in knob_value] return knob_value diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json index c9dee8681a..51c78ce8f0 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_nuke_knob_inputs.json @@ -213,7 +213,7 @@ }, { "type": "number", - "key": "y", + "key": "z", "default": 1, "decimal": 4, "maximum": 99999999 @@ -238,29 +238,75 @@ "object_types": [ { "type": "number", - "key": "x", + "key": "r", "default": 1, "decimal": 4, "maximum": 99999999 }, { "type": "number", - "key": "x", + "key": "g", "default": 1, "decimal": 4, "maximum": 99999999 }, + { + "type": "number", + "key": "b", + "default": 1, + "decimal": 4, + "maximum": 99999999 + }, + { + "type": "number", + "key": "a", + "default": 1, + "decimal": 4, + "maximum": 99999999 + } + ] + } + ] + }, + { + "key": "box", + "label": "Box", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "list-strict", + "key": "value", + "label": "Value", + "object_types": [ + { + "type": "number", + "key": "x", + "default": 0, + "decimal": 4, + "maximum": 99999999 + }, { "type": "number", "key": "y", - "default": 1, + "default": 0, "decimal": 4, "maximum": 99999999 }, { "type": "number", - "key": "y", - "default": 1, + "key": "r", + "default": 1920, + "decimal": 4, + "maximum": 99999999 + }, + { + "type": "number", + "key": "t", + "default": 1080, "decimal": 4, "maximum": 99999999 } From c9cf6646f78223f492f21b8097f2b73ae6df6b64 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 3 Aug 2023 15:08:19 +0200 Subject: [PATCH 091/107] AYON: 3dsMax settings (#5401) * create copy of 3dsmax settings instead of removing it * keep '3dsmax' as 'adsk_3dsmax' --- openpype/hooks/pre_add_last_workfile_arg.py | 2 +- openpype/hosts/max/hooks/force_startup_script.py | 2 +- openpype/hosts/max/hooks/inject_python.py | 2 +- openpype/settings/ayon_settings.py | 2 -- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/openpype/hooks/pre_add_last_workfile_arg.py b/openpype/hooks/pre_add_last_workfile_arg.py index c160d8e062..1418bc210b 100644 --- a/openpype/hooks/pre_add_last_workfile_arg.py +++ b/openpype/hooks/pre_add_last_workfile_arg.py @@ -14,7 +14,7 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): # Execute after workfile template copy order = 10 app_groups = { - "3dsmax", + "3dsmax", "adsk_3dsmax", "maya", "nuke", "nukex", diff --git a/openpype/hosts/max/hooks/force_startup_script.py b/openpype/hosts/max/hooks/force_startup_script.py index d87697b819..5fb8334d4b 100644 --- a/openpype/hosts/max/hooks/force_startup_script.py +++ b/openpype/hosts/max/hooks/force_startup_script.py @@ -14,7 +14,7 @@ class ForceStartupScript(PreLaunchHook): Hook `GlobalHostDataHook` must be executed before this hook. """ - app_groups = {"3dsmax"} + app_groups = {"3dsmax", "adsk_3dsmax"} order = 11 launch_types = {LaunchTypes.local} diff --git a/openpype/hosts/max/hooks/inject_python.py b/openpype/hosts/max/hooks/inject_python.py index 874884585e..e9dddbf710 100644 --- a/openpype/hosts/max/hooks/inject_python.py +++ b/openpype/hosts/max/hooks/inject_python.py @@ -13,7 +13,7 @@ class InjectPythonPath(PreLaunchHook): Hook `GlobalHostDataHook` must be executed before this hook. """ - app_groups = {"3dsmax"} + app_groups = {"3dsmax", "adsk_3dsmax"} launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index cd12a8f757..904751e653 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -124,8 +124,6 @@ def _convert_applications_system_settings( # Applications settings ayon_apps = addon_settings["applications"] - if "adsk_3dsmax" in ayon_apps: - ayon_apps["3dsmax"] = ayon_apps.pop("adsk_3dsmax") additional_apps = ayon_apps.pop("additional_apps") applications = _convert_applications_groups( From f4f1484c6abc69dfa66f0abf4169da7e2b41f638 Mon Sep 17 00:00:00 2001 From: Mustafa Zarkash Date: Thu, 3 Aug 2023 22:57:26 +0300 Subject: [PATCH 092/107] Bugfix: update defaults to default_variants in maya and houdini OP DCC settings (#5407) * update defaults to default_variants * update defaults to defaults in Ayon dcc settings * increment maya and houdini ayon addons patch version --- .../defaults/project_settings/houdini.json | 2 +- .../defaults/project_settings/maya.json | 20 +++++++++---------- .../schemas/schema_houdini_create.json | 4 ++-- .../schemas/schema_maya_create.json | 20 +++++++++---------- .../server/settings/publish_plugins.py | 2 +- server_addon/houdini/server/version.py | 2 +- server_addon/maya/server/settings/creators.py | 20 +++++++++---------- server_addon/maya/server/version.py | 2 +- 8 files changed, 36 insertions(+), 36 deletions(-) diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index a53f1ff202..a5256aad8b 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -14,7 +14,7 @@ "create": { "CreateArnoldAss": { "enabled": true, - "defaults": [], + "default_variants": [], "ext": ".ass" }, "CreateAlembicCamera": { diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 8e1022f877..342d2bfb2a 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -521,7 +521,7 @@ "enabled": true, "make_tx": true, "rs_tex": false, - "defaults": [ + "default_variants": [ "Main" ] }, @@ -533,7 +533,7 @@ }, "CreateUnrealStaticMesh": { "enabled": true, - "defaults": [ + "default_variants": [ "", "_Main" ], @@ -547,7 +547,7 @@ }, "CreateUnrealSkeletalMesh": { "enabled": true, - "defaults": [], + "default_variants": [], "joint_hints": "jnt_org" }, "CreateMultiverseLook": { @@ -559,7 +559,7 @@ "write_face_sets": false, "include_parent_hierarchy": false, "include_user_defined_attributes": false, - "defaults": [ + "default_variants": [ "Main" ] }, @@ -567,7 +567,7 @@ "enabled": true, "write_color_sets": false, "write_face_sets": false, - "defaults": [ + "default_variants": [ "Main", "Proxy", "Sculpt" @@ -578,7 +578,7 @@ "write_color_sets": false, "write_face_sets": false, "include_user_defined_attributes": false, - "defaults": [ + "default_variants": [ "Main" ] }, @@ -586,20 +586,20 @@ "enabled": true, "write_color_sets": false, "write_face_sets": false, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateReview": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ], "useMayaTimeline": true }, "CreateAss": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ], "expandProcedurals": false, @@ -621,7 +621,7 @@ "enabled": true, "vrmesh": true, "alembic": true, - "defaults": [ + "default_variants": [ "Main" ] }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json index 83e0cf789a..64d157d281 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json @@ -18,7 +18,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" }, @@ -86,4 +86,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json index d28d42c10c..8dec0a8817 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json @@ -28,7 +28,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } @@ -52,7 +52,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" }, @@ -84,7 +84,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" }, @@ -147,7 +147,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } @@ -177,7 +177,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } @@ -212,7 +212,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } @@ -242,7 +242,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } @@ -262,7 +262,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" }, @@ -287,7 +287,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" }, @@ -389,7 +389,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index ca5d0a4ea5..4155c75eb7 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -54,7 +54,7 @@ class CreatePluginsModel(BaseSettingsModel): DEFAULT_HOUDINI_CREATE_SETTINGS = { "CreateArnoldAss": { "enabled": True, - "defaults": [], + "default_variants": [], "ext": ".ass" }, "CreateAlembicCamera": { diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index 3dc1f76bc6..485f44ac21 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/server_addon/maya/server/settings/creators.py b/server_addon/maya/server/settings/creators.py index 291b3ec660..039b027898 100644 --- a/server_addon/maya/server/settings/creators.py +++ b/server_addon/maya/server/settings/creators.py @@ -224,7 +224,7 @@ DEFAULT_CREATORS_SETTINGS = { "enabled": True, "make_tx": True, "rs_tex": False, - "defaults": [ + "default_variants": [ "Main" ] }, @@ -236,7 +236,7 @@ DEFAULT_CREATORS_SETTINGS = { }, "CreateUnrealStaticMesh": { "enabled": True, - "defaults": [ + "default_variants": [ "", "_Main" ], @@ -250,7 +250,7 @@ DEFAULT_CREATORS_SETTINGS = { }, "CreateUnrealSkeletalMesh": { "enabled": True, - "defaults": [], + "default_variants": [], "joint_hints": "jnt_org" }, "CreateMultiverseLook": { @@ -262,7 +262,7 @@ DEFAULT_CREATORS_SETTINGS = { "write_face_sets": False, "include_parent_hierarchy": False, "include_user_defined_attributes": False, - "defaults": [ + "default_variants": [ "Main" ] }, @@ -270,7 +270,7 @@ DEFAULT_CREATORS_SETTINGS = { "enabled": True, "write_color_sets": False, "write_face_sets": False, - "defaults": [ + "default_variants": [ "Main", "Proxy", "Sculpt" @@ -281,7 +281,7 @@ DEFAULT_CREATORS_SETTINGS = { "write_color_sets": False, "write_face_sets": False, "include_user_defined_attributes": False, - "defaults": [ + "default_variants": [ "Main" ] }, @@ -289,7 +289,7 @@ DEFAULT_CREATORS_SETTINGS = { "enabled": True, "write_color_sets": False, "write_face_sets": False, - "defaults": [ + "default_variants": [ "Main" ] }, @@ -313,7 +313,7 @@ DEFAULT_CREATORS_SETTINGS = { }, "CreateAss": { "enabled": True, - "defaults": [ + "default_variants": [ "Main" ], "expandProcedurals": False, @@ -363,7 +363,7 @@ DEFAULT_CREATORS_SETTINGS = { }, "CreateReview": { "enabled": True, - "defaults": [ + "default_variants": [ "Main" ], "useMayaTimeline": True @@ -387,7 +387,7 @@ DEFAULT_CREATORS_SETTINGS = { "enabled": True, "vrmesh": True, "alembic": True, - "defaults": [ + "default_variants": [ "Main" ] }, diff --git a/server_addon/maya/server/version.py b/server_addon/maya/server/version.py index a242f0e757..df0c92f1e2 100644 --- a/server_addon/maya/server/version.py +++ b/server_addon/maya/server/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring addon version.""" -__version__ = "0.1.1" +__version__ = "0.1.2" From bdaf86700bc33f85f13ee0f9fb5897770cef1aba Mon Sep 17 00:00:00 2001 From: Mustafa Zarkash Date: Fri, 4 Aug 2023 15:28:33 +0300 Subject: [PATCH 093/107] Bugfix: houdini hard coded project settings (#5400) * get poject settings in creator * add comment about reading ext from project settings * update validator to get project settings * update comment about reading ext from project settings * revert explicit edits it's automated * remove redundant line --- openpype/hosts/houdini/api/plugin.py | 19 +++++++++++++++++++ .../plugins/create/create_arnold_ass.py | 2 ++ .../publish/validate_workfile_paths.py | 2 -- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 05e52e2478..70c837205e 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -167,6 +167,7 @@ class HoudiniCreatorBase(object): class HoudiniCreator(NewCreator, HoudiniCreatorBase): """Base class for most of the Houdini creator plugins.""" selected_nodes = [] + settings_name = None def create(self, subset_name, instance_data, pre_create_data): try: @@ -294,3 +295,21 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): """ return [hou.ropNodeTypeCategory()] + + def apply_settings(self, project_settings, system_settings): + """Method called on initialization of plugin to apply settings.""" + + settings_name = self.settings_name + if settings_name is None: + settings_name = self.__class__.__name__ + + settings = project_settings["houdini"]["create"] + settings = settings.get(settings_name) + if settings is None: + self.log.debug( + "No settings found for {}".format(self.__class__.__name__) + ) + return + + for key, value in settings.items(): + setattr(self, key, value) diff --git a/openpype/hosts/houdini/plugins/create/create_arnold_ass.py b/openpype/hosts/houdini/plugins/create/create_arnold_ass.py index 8b310753d0..45ef9ea82f 100644 --- a/openpype/hosts/houdini/plugins/create/create_arnold_ass.py +++ b/openpype/hosts/houdini/plugins/create/create_arnold_ass.py @@ -13,6 +13,8 @@ class CreateArnoldAss(plugin.HoudiniCreator): defaults = ["Main"] # Default extension: `.ass` or `.ass.gz` + # however calling HoudiniCreator.create() + # will override it by the value in the project settings ext = ".ass" def create(self, subset_name, instance_data, pre_create_data): diff --git a/openpype/hosts/houdini/plugins/publish/validate_workfile_paths.py b/openpype/hosts/houdini/plugins/publish/validate_workfile_paths.py index 543c8e1407..afe05e3173 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_workfile_paths.py +++ b/openpype/hosts/houdini/plugins/publish/validate_workfile_paths.py @@ -7,8 +7,6 @@ from openpype.pipeline import ( ) from openpype.pipeline.publish import RepairAction -from openpype.pipeline.publish import RepairAction - class ValidateWorkfilePaths( pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): From 4ff85c7e1873e8ae9e71d6ec6245bf6e9aaca73c Mon Sep 17 00:00:00 2001 From: Jiri Sindelar Date: Fri, 4 Aug 2023 14:58:30 +0200 Subject: [PATCH 094/107] remove string conversion for instance name Should not be in this PR --- openpype/hosts/nuke/plugins/publish/extract_slate_frame.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py index 54c88717c5..06c086b10d 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py +++ b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py @@ -242,7 +242,7 @@ class ExtractSlateFrame(publish.Extractor): # render slate as sequence frame nuke.execute( - str(instance.data["name"]), + instance.data["name"], int(slate_first_frame), int(slate_first_frame) ) From 86f39e8e8f3cb7f6152982bae6486ba8125a03e1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Aug 2023 14:58:50 +0200 Subject: [PATCH 095/107] Applications: Attributes creation (#5408) * merge applications and tools from all addon versions into one big set * bump applications version to '0.1.1' * impemented 'pre_setup' to fix old versions of applications addon * Fix version access --- server_addon/applications/server/__init__.py | 111 ++++++++++++++++--- server_addon/applications/server/version.py | 2 +- 2 files changed, 96 insertions(+), 17 deletions(-) diff --git a/server_addon/applications/server/__init__.py b/server_addon/applications/server/__init__.py index fdec05006b..e782e8a591 100644 --- a/server_addon/applications/server/__init__.py +++ b/server_addon/applications/server/__init__.py @@ -2,12 +2,68 @@ import os import json import copy -from ayon_server.addons import BaseServerAddon +from ayon_server.addons import BaseServerAddon, AddonLibrary from ayon_server.lib.postgres import Postgres from .version import __version__ from .settings import ApplicationsAddonSettings, DEFAULT_VALUES +try: + import semver +except ImportError: + semver = None + + +def sort_versions(addon_versions, reverse=False): + if semver is None: + for addon_version in sorted(addon_versions, reverse=reverse): + yield addon_version + return + + version_objs = [] + invalid_versions = [] + for addon_version in addon_versions: + try: + version_objs.append( + (addon_version, semver.VersionInfo.parse(addon_version)) + ) + except ValueError: + invalid_versions.append(addon_version) + + valid_versions = [ + addon_version + for addon_version, _ in sorted(version_objs, key=lambda x: x[1]) + ] + sorted_versions = list(sorted(invalid_versions)) + valid_versions + if reverse: + sorted_versions = reversed(sorted_versions) + for addon_version in sorted_versions: + yield addon_version + + +def merge_groups(output, new_groups): + groups_by_name = { + o_group["name"]: o_group + for o_group in output + } + extend_groups = [] + for new_group in new_groups: + group_name = new_group["name"] + if group_name not in groups_by_name: + extend_groups.append(new_group) + continue + existing_group = groups_by_name[group_name] + existing_variants = existing_group["variants"] + existing_variants_by_name = { + variant["name"]: variant + for variant in existing_variants + } + for new_variant in new_group["variants"]: + if new_variant["name"] not in existing_variants_by_name: + existing_variants.append(new_variant) + + output.extend(extend_groups) + def get_enum_items_from_groups(groups): label_by_name = {} @@ -22,12 +78,11 @@ def get_enum_items_from_groups(groups): full_name = f"{group_name}/{variant_name}" full_label = f"{group_label} {variant_label}" label_by_name[full_name] = full_label - enum_items = [] - for full_name in sorted(label_by_name): - enum_items.append( - {"value": full_name, "label": label_by_name[full_name]} - ) - return enum_items + + return [ + {"value": full_name, "label": label_by_name[full_name]} + for full_name in sorted(label_by_name) + ] class ApplicationsAddon(BaseServerAddon): @@ -48,6 +103,19 @@ class ApplicationsAddon(BaseServerAddon): return self.get_settings_model()(**default_values) + async def pre_setup(self): + """Make sure older version of addon use the new way of attributes.""" + + instance = AddonLibrary.getinstance() + app_defs = instance.data.get(self.name) + old_addon = app_defs.versions.get("0.1.0") + if old_addon is not None: + # Override 'create_applications_attribute' for older versions + # - avoid infinite server restart loop + old_addon.create_applications_attribute = ( + self.create_applications_attribute + ) + async def setup(self): need_restart = await self.create_applications_attribute() if need_restart: @@ -60,21 +128,32 @@ class ApplicationsAddon(BaseServerAddon): bool: 'True' if an attribute was created or updated. """ - settings_model = await self.get_studio_settings() - studio_settings = settings_model.dict() - applications = studio_settings["applications"] - _applications = applications.pop("additional_apps") - for name, value in applications.items(): - value["name"] = name - _applications.append(value) + instance = AddonLibrary.getinstance() + app_defs = instance.data.get(self.name) + all_applications = [] + all_tools = [] + for addon_version in sort_versions( + app_defs.versions.keys(), reverse=True + ): + addon = app_defs.versions[addon_version] + for variant in ("production", "staging"): + settings_model = await addon.get_studio_settings(variant) + studio_settings = settings_model.dict() + application_settings = studio_settings["applications"] + app_groups = application_settings.pop("additional_apps") + for group_name, value in application_settings.items(): + value["name"] = group_name + app_groups.append(value) + merge_groups(all_applications, app_groups) + merge_groups(all_tools, studio_settings["tool_groups"]) query = "SELECT name, position, scope, data from public.attributes" apps_attrib_name = "applications" tools_attrib_name = "tools" - apps_enum = get_enum_items_from_groups(_applications) - tools_enum = get_enum_items_from_groups(studio_settings["tool_groups"]) + apps_enum = get_enum_items_from_groups(all_applications) + tools_enum = get_enum_items_from_groups(all_tools) apps_attribute_data = { "type": "list_of_strings", "title": "Applications", diff --git a/server_addon/applications/server/version.py b/server_addon/applications/server/version.py index 3dc1f76bc6..485f44ac21 100644 --- a/server_addon/applications/server/version.py +++ b/server_addon/applications/server/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" From f0537f0d57fd0bf4d1d0ae5e6ef3a67a166a3922 Mon Sep 17 00:00:00 2001 From: Jiri Sindelar Date: Fri, 4 Aug 2023 15:14:59 +0200 Subject: [PATCH 096/107] Use scriptReadFile for all Nuke versions --- openpype/hosts/nuke/api/workio.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/api/workio.py b/openpype/hosts/nuke/api/workio.py index 50bd9d6dec..98e59eff71 100644 --- a/openpype/hosts/nuke/api/workio.py +++ b/openpype/hosts/nuke/api/workio.py @@ -25,10 +25,7 @@ def open_file(filepath): def read_script(nuke_script): nuke.scriptClear() - if int(nuke.NUKE_VERSION_MAJOR) > 12: - nuke.scriptReadFile(nuke_script) - else: - nuke.scriptOpen(nuke_script) + nuke.scriptReadFile(nuke_script) nuke.Root()["name"].setValue(nuke_script) nuke.Root()["project_directory"].setValue(os.path.dirname(nuke_script)) nuke.Root().setModified(False) From 0a765c45282a48a6843afd490635aceb6c8f0056 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 4 Aug 2023 16:25:50 +0200 Subject: [PATCH 097/107] adding input arguments for env --- openpype/hooks/pre_ocio_hook.py | 3 ++- openpype/pipeline/colorspace.py | 15 ++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/openpype/hooks/pre_ocio_hook.py b/openpype/hooks/pre_ocio_hook.py index 1ac305b635..9a7c036266 100644 --- a/openpype/hooks/pre_ocio_hook.py +++ b/openpype/hooks/pre_ocio_hook.py @@ -38,7 +38,8 @@ class OCIOEnvHook(PreLaunchHook): host_name=self.host_name, project_settings=self.data["project_settings"], anatomy_data=template_data, - anatomy=self.data["anatomy"] + anatomy=self.data["anatomy"], + environment=self.launch_context.env, ) if config_data: diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index caa0f6dcd7..731132911a 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -329,7 +329,8 @@ def get_imageio_config( host_name, project_settings=None, anatomy_data=None, - anatomy=None + anatomy=None, + env=None ): """Returns config data from settings @@ -342,6 +343,7 @@ def get_imageio_config( project_settings (Optional[dict]): Project settings. anatomy_data (Optional[dict]): anatomy formatting data. anatomy (Optional[Anatomy]): Anatomy object. + env (Optional[dict]): Environment variables. Returns: dict: config path data or empty dict @@ -414,13 +416,13 @@ def get_imageio_config( if override_global_config: config_data = _get_config_data( - host_ocio_config["filepath"], formatting_data + host_ocio_config["filepath"], formatting_data, env ) else: # get config path from global config_global = imageio_global["ocio_config"] config_data = _get_config_data( - config_global["filepath"], formatting_data + config_global["filepath"], formatting_data, env ) if not config_data: @@ -432,7 +434,7 @@ def get_imageio_config( return config_data -def _get_config_data(path_list, anatomy_data): +def _get_config_data(path_list, anatomy_data, env=None): """Return first existing path in path list. If template is used in path inputs, @@ -442,14 +444,17 @@ def _get_config_data(path_list, anatomy_data): Args: path_list (list[str]): list of abs paths anatomy_data (dict): formatting data + env (Optional[dict]): Environment variables. Returns: dict: config data """ formatting_data = deepcopy(anatomy_data) + environment_vars = env or dict(**os.environ) + # format the path for potential env vars - formatting_data.update(dict(**os.environ)) + formatting_data.update(environment_vars) # first try host config paths for path_ in path_list: From 255b65780fb4d00203d43b88d2ca8f0fe3d8eb74 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 4 Aug 2023 16:56:34 +0200 Subject: [PATCH 098/107] fixing typo --- openpype/hooks/pre_ocio_hook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hooks/pre_ocio_hook.py b/openpype/hooks/pre_ocio_hook.py index 9a7c036266..1307ed9f76 100644 --- a/openpype/hooks/pre_ocio_hook.py +++ b/openpype/hooks/pre_ocio_hook.py @@ -39,7 +39,7 @@ class OCIOEnvHook(PreLaunchHook): project_settings=self.data["project_settings"], anatomy_data=template_data, anatomy=self.data["anatomy"], - environment=self.launch_context.env, + env=self.launch_context.env, ) if config_data: From 511b899397cf3d571e3a408c59c3a4464856112b Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 5 Aug 2023 03:24:43 +0000 Subject: [PATCH 099/107] [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 bbe452aeba..12bff54676 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.3-nightly.3" +__version__ = "3.16.3-nightly.4" From 653a1bc0a83af2d82df9115a85a5d6f4155b937e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 5 Aug 2023 03:25:28 +0000 Subject: [PATCH 100/107] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b6a243bcfe..dea7e3c57f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.16.3-nightly.4 - 3.16.3-nightly.3 - 3.16.3-nightly.2 - 3.16.3-nightly.1 @@ -134,7 +135,6 @@ body: - 3.14.7-nightly.5 - 3.14.7-nightly.4 - 3.14.7-nightly.3 - - 3.14.7-nightly.2 validations: required: true - type: dropdown From 4fdcab4000bb31418a5e18047b3bb9467338f337 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 7 Aug 2023 16:28:54 +0200 Subject: [PATCH 101/107] nuke: update server addon settings --- server_addon/nuke/server/settings/common.py | 14 ++++++++++++++ server_addon/nuke/server/version.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/server_addon/nuke/server/settings/common.py b/server_addon/nuke/server/settings/common.py index f1bb46ff90..700f01f3dc 100644 --- a/server_addon/nuke/server/settings/common.py +++ b/server_addon/nuke/server/settings/common.py @@ -39,6 +39,15 @@ class Vector3d(BaseSettingsModel): z: float = Field(1.0, title="Z") +class Box(BaseSettingsModel): + _layout = "compact" + + x: float = Field(1.0, title="X") + y: float = Field(1.0, title="Y") + r: float = Field(1.0, title="R") + t: float = Field(1.0, title="T") + + def formatable_knob_type_enum(): return [ {"value": "text", "label": "Text"}, @@ -74,6 +83,7 @@ knob_types_enum = [ {"value": "vector_2d", "label": "2D vector"}, {"value": "vector_3d", "label": "3D vector"}, {"value": "color", "label": "Color"}, + {"value": "box", "label": "Box"}, {"value": "expression", "label": "Expression"} ] @@ -118,6 +128,10 @@ class KnobModel(BaseSettingsModel): (0.0, 0.0, 1.0, 1.0), title="RGBA Float" ) + box: Box = Field( + default_factory=Box, + title="Value" + ) formatable: Formatable = Field( default_factory=Formatable, title="Formatable" diff --git a/server_addon/nuke/server/version.py b/server_addon/nuke/server/version.py index 485f44ac21..b3f4756216 100644 --- a/server_addon/nuke/server/version.py +++ b/server_addon/nuke/server/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.2" From 94568326e86d09204ffc650ba543cd747b502e23 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 7 Aug 2023 16:51:23 +0200 Subject: [PATCH 102/107] added box conversion to nuke knobs conversions --- openpype/settings/ayon_settings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 904751e653..10f43dc377 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -645,6 +645,9 @@ def _convert_nuke_knobs(knobs): elif knob_type == "vector_3d": value = [value["x"], value["y"], value["z"]] + elif knob_type == "box": + value = [value["x"], value["y"], value["r"], value["t"]] + new_knob[value_key] = value return new_knobs From 90a7e33a263902b60185c44e91a956cd7358b6b5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 7 Aug 2023 16:53:09 +0200 Subject: [PATCH 103/107] Webpublisher: Self contain test publish logic (#5414) * implement test logic of webpublisher in webpublisher * simplified 'remote_publish' * removed unused 'raise_error' argument --- openpype/hosts/webpublisher/addon.py | 5 ++-- openpype/hosts/webpublisher/lib.py | 41 +++++++++++++++++++++++++++- openpype/pipeline/publish/lib.py | 38 ++++++-------------------- openpype/scripts/remote_publish.py | 2 +- 4 files changed, 52 insertions(+), 34 deletions(-) diff --git a/openpype/hosts/webpublisher/addon.py b/openpype/hosts/webpublisher/addon.py index eb7fced2e6..4438775b03 100644 --- a/openpype/hosts/webpublisher/addon.py +++ b/openpype/hosts/webpublisher/addon.py @@ -20,11 +20,10 @@ class WebpublisherAddon(OpenPypeModule, IHostAddon): Close Python process at the end. """ - from openpype.pipeline.publish.lib import remote_publish - from .lib import get_webpublish_conn, publish_and_log + from .lib import get_webpublish_conn, publish_and_log, publish_in_test if is_test: - remote_publish(log, close_plugin_name) + publish_in_test(log, close_plugin_name) return dbcon = get_webpublish_conn() diff --git a/openpype/hosts/webpublisher/lib.py b/openpype/hosts/webpublisher/lib.py index b207f85b46..11c287761b 100644 --- a/openpype/hosts/webpublisher/lib.py +++ b/openpype/hosts/webpublisher/lib.py @@ -12,7 +12,6 @@ from openpype.client.mongo import OpenPypeMongoConnection from openpype.settings import get_project_settings from openpype.lib import Logger from openpype.lib.profiles_filtering import filter_profiles -from openpype.pipeline.publish.lib import find_close_plugin ERROR_STATUS = "error" IN_PROGRESS_STATUS = "in_progress" @@ -68,6 +67,46 @@ def get_batch_asset_task_info(ctx): return asset, task_name, task_type +def find_close_plugin(close_plugin_name, log): + if close_plugin_name: + plugins = pyblish.api.discover() + for plugin in plugins: + if plugin.__name__ == close_plugin_name: + return plugin + + log.debug("Close plugin not found, app might not close.") + + +def publish_in_test(log, close_plugin_name=None): + """Loops through all plugins, logs to console. Used for tests. + + Args: + log (Logger) + close_plugin_name (Optional[str]): Name of plugin with responsibility + to close application. + """ + + # Error exit as soon as any error occurs. + error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}" + + close_plugin = find_close_plugin(close_plugin_name, log) + + for result in pyblish.util.publish_iter(): + for record in result["records"]: + # Why do we log again? pyblish logger is logging to stdout... + log.info("{}: {}".format(result["plugin"].label, record.msg)) + + if not result["error"]: + continue + + # QUESTION We don't break on error? + error_message = error_format.format(**result) + log.error(error_message) + if close_plugin: # close host app explicitly after error + context = pyblish.api.Context() + close_plugin().process(context) + + def get_webpublish_conn(): """Get connection to OP 'webpublishes' collection.""" mongo_client = OpenPypeMongoConnection.get_mongo_client() diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index c14b6d2445..ada12800a9 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -537,44 +537,24 @@ def filter_pyblish_plugins(plugins): plugins.remove(plugin) -def find_close_plugin(close_plugin_name, log): - if close_plugin_name: - plugins = pyblish.api.discover() - for plugin in plugins: - if plugin.__name__ == close_plugin_name: - return plugin - - log.debug("Close plugin not found, app might not close.") - - -def remote_publish(log, close_plugin_name=None, raise_error=False): +def remote_publish(log): """Loops through all plugins, logs to console. Used for tests. Args: log (Logger) - close_plugin_name (str): name of plugin with responsibility to - close host app """ - # Error exit as soon as any error occurs. - error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}" - close_plugin = find_close_plugin(close_plugin_name, log) + # Error exit as soon as any error occurs. + error_format = "Failed {plugin.__name__}: {error}\n{error.traceback}" for result in pyblish.util.publish_iter(): - for record in result["records"]: - log.info("{}: {}".format( - result["plugin"].label, record.msg)) + if not result["error"]: + continue - if result["error"]: - error_message = error_format.format(**result) - log.error(error_message) - if close_plugin: # close host app explicitly after error - context = pyblish.api.Context() - close_plugin().process(context) - if raise_error: - # Fatal Error is because of Deadline - error_message = "Fatal Error: " + error_format.format(**result) - raise RuntimeError(error_message) + error_message = error_format.format(**result) + log.error(error_message) + # 'Fatal Error: ' is because of Deadline + raise RuntimeError("Fatal Error: {}".format(error_message)) def get_errored_instances_from_context(context, plugin=None): diff --git a/openpype/scripts/remote_publish.py b/openpype/scripts/remote_publish.py index 37df35e36c..d362f7abdc 100644 --- a/openpype/scripts/remote_publish.py +++ b/openpype/scripts/remote_publish.py @@ -9,4 +9,4 @@ except ImportError as exc: if __name__ == "__main__": # Perform remote publish with thorough error checking log = Logger.get_logger(__name__) - remote_publish(log, raise_error=True) + remote_publish(log) From 03032f990c2e570d69cce8d326c03d48715c4d18 Mon Sep 17 00:00:00 2001 From: Mustafa Zarkash Date: Mon, 7 Aug 2023 18:05:17 +0300 Subject: [PATCH 104/107] update error message (#5386) --- .../plugins/publish/validate_primitive_hierarchy_paths.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py index ca06617ab0..471fa5b6d1 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py +++ b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py @@ -32,8 +32,9 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): def process(self, instance): invalid = self.get_invalid(instance) if invalid: + nodes = [n.path() for n in invalid] raise PublishValidationError( - "See log for details. " "Invalid nodes: {0}".format(invalid), + "See log for details. " "Invalid nodes: {0}".format(nodes), title=self.label ) From 15f8440ee123db73546e19444c7f6106a8595688 Mon Sep 17 00:00:00 2001 From: Mustafa Zarkash Date: Mon, 7 Aug 2023 18:06:47 +0300 Subject: [PATCH 105/107] Bugfix: Houdini update defaults variable (#5367) * update defaults variable * capitalize first letter of variant * update default_variants to main --- openpype/hosts/houdini/plugins/create/create_arnold_ass.py | 1 - openpype/hosts/houdini/plugins/create/create_arnold_rop.py | 1 - openpype/hosts/houdini/plugins/create/create_karma_rop.py | 1 - openpype/hosts/houdini/plugins/create/create_mantra_rop.py | 1 - openpype/hosts/houdini/plugins/create/create_redshift_rop.py | 1 - openpype/hosts/houdini/plugins/create/create_vray_rop.py | 2 -- 6 files changed, 7 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_arnold_ass.py b/openpype/hosts/houdini/plugins/create/create_arnold_ass.py index 45ef9ea82f..12d08f7d83 100644 --- a/openpype/hosts/houdini/plugins/create/create_arnold_ass.py +++ b/openpype/hosts/houdini/plugins/create/create_arnold_ass.py @@ -10,7 +10,6 @@ class CreateArnoldAss(plugin.HoudiniCreator): label = "Arnold ASS" family = "ass" icon = "magic" - defaults = ["Main"] # Default extension: `.ass` or `.ass.gz` # however calling HoudiniCreator.create() diff --git a/openpype/hosts/houdini/plugins/create/create_arnold_rop.py b/openpype/hosts/houdini/plugins/create/create_arnold_rop.py index ca516619f6..b58c377a20 100644 --- a/openpype/hosts/houdini/plugins/create/create_arnold_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_arnold_rop.py @@ -9,7 +9,6 @@ class CreateArnoldRop(plugin.HoudiniCreator): label = "Arnold ROP" family = "arnold_rop" icon = "magic" - defaults = ["master"] # Default extension ext = "exr" diff --git a/openpype/hosts/houdini/plugins/create/create_karma_rop.py b/openpype/hosts/houdini/plugins/create/create_karma_rop.py index c7a9fe0968..4e1360ca45 100644 --- a/openpype/hosts/houdini/plugins/create/create_karma_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_karma_rop.py @@ -11,7 +11,6 @@ class CreateKarmaROP(plugin.HoudiniCreator): label = "Karma ROP" family = "karma_rop" icon = "magic" - defaults = ["master"] def create(self, subset_name, instance_data, pre_create_data): import hou # noqa diff --git a/openpype/hosts/houdini/plugins/create/create_mantra_rop.py b/openpype/hosts/houdini/plugins/create/create_mantra_rop.py index 5c29adb33f..d2f0e735a8 100644 --- a/openpype/hosts/houdini/plugins/create/create_mantra_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_mantra_rop.py @@ -11,7 +11,6 @@ class CreateMantraROP(plugin.HoudiniCreator): label = "Mantra ROP" family = "mantra_rop" icon = "magic" - defaults = ["master"] def create(self, subset_name, instance_data, pre_create_data): import hou # noqa diff --git a/openpype/hosts/houdini/plugins/create/create_redshift_rop.py b/openpype/hosts/houdini/plugins/create/create_redshift_rop.py index 8f4aa1327d..1b8826a932 100644 --- a/openpype/hosts/houdini/plugins/create/create_redshift_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_redshift_rop.py @@ -13,7 +13,6 @@ class CreateRedshiftROP(plugin.HoudiniCreator): label = "Redshift ROP" family = "redshift_rop" icon = "magic" - defaults = ["master"] ext = "exr" def create(self, subset_name, instance_data, pre_create_data): diff --git a/openpype/hosts/houdini/plugins/create/create_vray_rop.py b/openpype/hosts/houdini/plugins/create/create_vray_rop.py index 58748d4c34..793a544fdf 100644 --- a/openpype/hosts/houdini/plugins/create/create_vray_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_vray_rop.py @@ -14,8 +14,6 @@ class CreateVrayROP(plugin.HoudiniCreator): label = "VRay ROP" family = "vray_rop" icon = "magic" - defaults = ["master"] - ext = "exr" def create(self, subset_name, instance_data, pre_create_data): From ae3eb37776bb0a1a81686467ae981f78eddb1952 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 7 Aug 2023 17:22:28 +0200 Subject: [PATCH 106/107] AYON: Add folder to template data (#5417) * added 'folder[name]' to template data * removed asset <-> folder conversion from conversion utils * use folder[name] in maya namespace --- openpype/client/server/conversion_utils.py | 4 ---- openpype/hosts/maya/api/plugin.py | 3 +++ openpype/lib/usdlib.py | 3 +++ openpype/pipeline/template_data.py | 3 +++ openpype/settings/ayon_settings.py | 1 - 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/openpype/client/server/conversion_utils.py b/openpype/client/server/conversion_utils.py index 24d4678095..42df337b6d 100644 --- a/openpype/client/server/conversion_utils.py +++ b/openpype/client/server/conversion_utils.py @@ -133,7 +133,6 @@ def _get_default_template_name(templates): def _template_replacements_to_v3(template): return ( template - .replace("{folder[name]}", "{asset}") .replace("{product[name]}", "{subset}") .replace("{product[type]}", "{family}") ) @@ -715,7 +714,6 @@ def convert_v4_representation_to_v3(representation): if "template" in output_data: output_data["template"] = ( output_data["template"] - .replace("{folder[name]}", "{asset}") .replace("{product[name]}", "{subset}") .replace("{product[type]}", "{family}") ) @@ -977,7 +975,6 @@ def convert_create_representation_to_v4(representation, con): representation_data = representation["data"] representation_data["template"] = ( representation_data["template"] - .replace("{asset}", "{folder[name]}") .replace("{subset}", "{product[name]}") .replace("{family}", "{product[type]}") ) @@ -1266,7 +1263,6 @@ def convert_update_representation_to_v4( if "template" in attribs: attribs["template"] = ( attribs["template"] - .replace("{asset}", "{folder[name]}") .replace("{family}", "{product[type]}") .replace("{subset}", "{product[name]}") ) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 0ee02d8485..bfe01c8981 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -581,6 +581,9 @@ class ReferenceLoader(Loader): formatting_data = { "asset_name": asset['name'], "asset_type": asset['type'], + "folder": { + "name": asset["name"], + }, "subset": subset['name'], "family": ( subset['data'].get('family') or diff --git a/openpype/lib/usdlib.py b/openpype/lib/usdlib.py index cb96a0c1d0..c166feb3a6 100644 --- a/openpype/lib/usdlib.py +++ b/openpype/lib/usdlib.py @@ -334,6 +334,9 @@ def get_usd_master_path(asset, subset, representation): "name": project_name, "code": project_doc.get("data", {}).get("code") }, + "folder": { + "name": asset_doc["name"], + }, "asset": asset_doc["name"], "subset": subset, "representation": representation, diff --git a/openpype/pipeline/template_data.py b/openpype/pipeline/template_data.py index fd21930ecc..a48f0721b6 100644 --- a/openpype/pipeline/template_data.py +++ b/openpype/pipeline/template_data.py @@ -94,6 +94,9 @@ def get_asset_template_data(asset_doc, project_name): return { "asset": asset_doc["name"], + "folder": { + "name": asset_doc["name"] + }, "hierarchy": hierarchy, "parent": parent_name } diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 10f43dc377..78eed359a3 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -599,7 +599,6 @@ def _convert_maya_project_settings(ayon_settings, output): reference_loader = ayon_maya_load["reference_loader"] reference_loader["namespace"] = ( reference_loader["namespace"] - .replace("{folder[name]}", "{asset_name}") .replace("{product[name]}", "{subset}") ) From a31b2d9d77f834441d65733c8ee91c6c3ca5b623 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 7 Aug 2023 18:48:27 +0200 Subject: [PATCH 107/107] SyncServer: Existence of module is optional (#5413) * 'get_repre_icons' have optional sync server * local settings have optional sync server * sync server is optional in sceneinventory * sync server is optional in loader tool * sync server is optional in library loader * sync server is optional in host dirmap * sync server is optional in nuke cache * sync server is optional in integrate plugin * added "sync_server" back to ignored modules for openpype package * fix missing variable * mark syncserver command as deprecated * define 'SYNC_SERVER_ROOT' * added method to receive icon paths * use sync server module to receive icons * fix scene inventory --- openpype/cli.py | 17 ++++++-- openpype/host/dirmap.py | 35 ++++++++++------ openpype/hosts/nuke/api/lib.py | 7 +++- .../modules/sync_server/sync_server_module.py | 30 ++++++++++---- openpype/modules/sync_server/utils.py | 3 ++ openpype/plugins/publish/integrate.py | 17 +++++--- openpype/pype_commands.py | 28 ------------- openpype/tools/libraryloader/app.py | 7 ++-- openpype/tools/loader/model.py | 40 ++++++++++++++----- openpype/tools/sceneinventory/lib.py | 22 ---------- openpype/tools/sceneinventory/model.py | 16 ++++---- openpype/tools/sceneinventory/view.py | 7 +++- .../local_settings/projects_widget.py | 18 ++++----- openpype/tools/utils/lib.py | 9 +++-- server_addon/create_ayon_addons.py | 3 +- 15 files changed, 142 insertions(+), 117 deletions(-) diff --git a/openpype/cli.py b/openpype/cli.py index 6d6a34b0fb..22ad16e937 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -338,12 +338,18 @@ def runtests(folder, mark, pyargs, test_data_folder, persist, app_variant, persist, app_variant, timeout, setup_only) -@main.command() +@main.command(help="DEPRECATED - run sync server") +@click.pass_context @click.option("-a", "--active_site", required=True, - help="Name of active stie") -def syncserver(active_site): + help="Name of active site") +def syncserver(ctx, active_site): """Run sync site server in background. + Deprecated: + This command is deprecated and will be removed in future versions. + Use '~/openpype_console module sync_server syncservice' instead. + + Details: Some Site Sync use cases need to expose site to another one. For example if majority of artists work in studio, they are not using SS at all, but if you want to expose published assets to 'studio' site @@ -359,7 +365,10 @@ def syncserver(active_site): if AYON_SERVER_ENABLED: raise RuntimeError("AYON does not support 'syncserver' command.") - PypeCommands().syncserver(active_site) + + from openpype.modules.sync_server.sync_server_module import ( + syncservice) + ctx.invoke(syncservice, active_site=active_site) @main.command() diff --git a/openpype/host/dirmap.py b/openpype/host/dirmap.py index e77f06e9d6..96a98e808e 100644 --- a/openpype/host/dirmap.py +++ b/openpype/host/dirmap.py @@ -32,19 +32,26 @@ class HostDirmap(object): """ def __init__( - self, host_name, project_name, project_settings=None, sync_module=None + self, + host_name, + project_name, + project_settings=None, + sync_module=None ): self.host_name = host_name self.project_name = project_name self._project_settings = project_settings - self._sync_module = sync_module # to limit reinit of Modules + self._sync_module = sync_module + # to limit reinit of Modules + self._sync_module_discovered = sync_module is not None self._log = None @property def sync_module(self): - if self._sync_module is None: + if not self._sync_module_discovered: + self._sync_module_discovered = True manager = ModulesManager() - self._sync_module = manager["sync_server"] + self._sync_module = manager.get("sync_server") return self._sync_module @property @@ -151,21 +158,25 @@ class HostDirmap(object): """ project_name = self.project_name + sync_module = self.sync_module mapping = {} - if (not self.sync_module.enabled or - project_name not in self.sync_module.get_enabled_projects()): + if ( + sync_module is None + or not sync_module.enabled + or project_name not in sync_module.get_enabled_projects() + ): return mapping - active_site = self.sync_module.get_local_normalized_site( - self.sync_module.get_active_site(project_name)) - remote_site = self.sync_module.get_local_normalized_site( - self.sync_module.get_remote_site(project_name)) + active_site = sync_module.get_local_normalized_site( + sync_module.get_active_site(project_name)) + remote_site = sync_module.get_local_normalized_site( + sync_module.get_remote_site(project_name)) self.log.debug( "active {} - remote {}".format(active_site, remote_site) ) if active_site == "local" and active_site != remote_site: - sync_settings = self.sync_module.get_sync_project_setting( + sync_settings = sync_module.get_sync_project_setting( project_name, exclude_locals=False, cached=False) @@ -179,7 +190,7 @@ class HostDirmap(object): self.log.debug("remote overrides {}".format(remote_overrides)) current_platform = platform.system().lower() - remote_provider = self.sync_module.get_provider_for_site( + remote_provider = sync_module.get_provider_for_site( project_name, remote_site ) # dirmap has sense only with regular disk provider, in the workfile diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 9942229155..fcf162c84f 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2955,6 +2955,7 @@ class DirmapCache: """Caching class to get settings and sync_module easily and only once.""" _project_name = None _project_settings = None + _sync_module_discovered = False _sync_module = None _mapping = None @@ -2972,8 +2973,10 @@ class DirmapCache: @classmethod def sync_module(cls): - if cls._sync_module is None: - cls._sync_module = ModulesManager().modules_by_name["sync_server"] + if not cls._sync_module_discovered: + cls._sync_module_discovered = True + cls._sync_module = ModulesManager().modules_by_name.get( + "sync_server") return cls._sync_module @classmethod diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index 67856f0d8e..8a92697920 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -34,7 +34,12 @@ from openpype.settings.constants import ( from .providers.local_drive import LocalDriveHandler from .providers import lib -from .utils import time_function, SyncStatus, SiteAlreadyPresentError +from .utils import ( + time_function, + SyncStatus, + SiteAlreadyPresentError, + SYNC_SERVER_ROOT, +) log = Logger.get_logger("SyncServer") @@ -138,9 +143,23 @@ class SyncServerModule(OpenPypeModule, ITrayModule, IPluginPaths): def get_plugin_paths(self): """Deadline plugin paths.""" - current_dir = os.path.dirname(os.path.abspath(__file__)) return { - "load": [os.path.join(current_dir, "plugins", "load")] + "load": [os.path.join(SYNC_SERVER_ROOT, "plugins", "load")] + } + + def get_site_icons(self): + """Icons for sites. + + Returns: + dict[str, str]: Path to icon by site. + """ + + resource_path = os.path.join( + SYNC_SERVER_ROOT, "providers", "resources" + ) + return { + provider: "{}/{}.png".format(resource_path, provider) + for provider in ["studio", "local_drive", "gdrive"] } """ Start of Public API """ @@ -904,10 +923,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule, IPluginPaths): (str): full absolut path to directory with hooks for the module """ - return os.path.join( - os.path.dirname(os.path.abspath(__file__)), - "launch_hooks" - ) + return os.path.join(SYNC_SERVER_ROOT, "launch_hooks") # Needs to be refactored after Settings are updated # # Methods for Settings to get appriate values to fill forms diff --git a/openpype/modules/sync_server/utils.py b/openpype/modules/sync_server/utils.py index 4caa01e9d7..b2f855539f 100644 --- a/openpype/modules/sync_server/utils.py +++ b/openpype/modules/sync_server/utils.py @@ -1,9 +1,12 @@ +import os import time from openpype.lib import Logger log = Logger.get_logger("SyncServer") +SYNC_SERVER_ROOT = os.path.dirname(os.path.abspath(__file__)) + class ResumableError(Exception): """Error which could be temporary, skip current loop, try next time""" diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index ffb9acf4a7..be07cffe72 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -2,9 +2,10 @@ import os import logging import sys import copy +import datetime + import clique import six - from bson.objectid import ObjectId import pyblish.api @@ -320,10 +321,16 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # Get the accessible sites for Site Sync modules_by_name = instance.context.data["openPypeModules"] - sync_server_module = modules_by_name["sync_server"] - sites = sync_server_module.compute_resource_sync_sites( - project_name=instance.data["projectEntity"]["name"] - ) + sync_server_module = modules_by_name.get("sync_server") + if sync_server_module is None: + sites = [{ + "name": "studio", + "created_dt": datetime.datetime.now() + }] + else: + sites = sync_server_module.compute_resource_sync_sites( + project_name=instance.data["projectEntity"]["name"] + ) self.log.debug("Sync Server Sites: {}".format(sites)) # Compute the resource file infos once (files belonging to the diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 4cb4b97707..57bbb0bbe3 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -336,34 +336,6 @@ class PypeCommands: import pytest pytest.main(args) - def syncserver(self, active_site): - """Start running sync_server in background. - - This functionality is available in directly in module cli commands. - `~/openpype_console module sync_server syncservice` - """ - - os.environ["OPENPYPE_LOCAL_ID"] = active_site - - def signal_handler(sig, frame): - print("You pressed Ctrl+C. Process ended.") - sync_server_module.server_exit() - sys.exit(0) - - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - - from openpype.modules import ModulesManager - - manager = ModulesManager() - sync_server_module = manager.modules_by_name["sync_server"] - - sync_server_module.server_init() - sync_server_module.server_start() - - while True: - time.sleep(1.0) - def repack_version(self, directory): """Repacking OpenPype version.""" from openpype.tools.repack_version import VersionRepacker diff --git a/openpype/tools/libraryloader/app.py b/openpype/tools/libraryloader/app.py index bd10595333..e68e9a5931 100644 --- a/openpype/tools/libraryloader/app.py +++ b/openpype/tools/libraryloader/app.py @@ -114,9 +114,10 @@ class LibraryLoaderWindow(QtWidgets.QDialog): manager = ModulesManager() sync_server = manager.modules_by_name.get("sync_server") - sync_server_enabled = False - if sync_server is not None: - sync_server_enabled = sync_server.enabled + sync_server_enabled = ( + sync_server is not None + and sync_server.enabled + ) repres_widget = None if sync_server_enabled: diff --git a/openpype/tools/loader/model.py b/openpype/tools/loader/model.py index 5115f39a69..69b7e593b1 100644 --- a/openpype/tools/loader/model.py +++ b/openpype/tools/loader/model.py @@ -64,6 +64,7 @@ class BaseRepresentationModel(object): """Sets/Resets sync server vars after every change (refresh.)""" repre_icons = {} sync_server = None + sync_server_enabled = False active_site = active_provider = None remote_site = remote_provider = None @@ -75,6 +76,7 @@ class BaseRepresentationModel(object): if not project_name: self.repre_icons = repre_icons self.sync_server = sync_server + self.sync_server_enabled = sync_server_enabled self.active_site = active_site self.active_provider = active_provider self.remote_site = remote_site @@ -100,8 +102,13 @@ class BaseRepresentationModel(object): self._modules_manager = ModulesManager() self._last_manager_cache = now_time - sync_server = self._modules_manager.modules_by_name["sync_server"] - if sync_server.is_project_enabled(project_name, single=True): + sync_server = self._modules_manager.modules_by_name.get("sync_server") + if ( + sync_server is not None + and sync_server.enabled + and sync_server.is_project_enabled(project_name, single=True) + ): + sync_server_enabled = True active_site = sync_server.get_active_site(project_name) active_provider = sync_server.get_provider_for_site( project_name, active_site) @@ -118,6 +125,7 @@ class BaseRepresentationModel(object): self.repre_icons = repre_icons self.sync_server = sync_server + self.sync_server_enabled = sync_server_enabled self.active_site = active_site self.active_provider = active_provider self.remote_site = remote_site @@ -213,6 +221,7 @@ class SubsetsModel(BaseRepresentationModel, TreeModel): self.repre_icons = {} self.sync_server = None + self.sync_server_enabled = False self.active_site = self.active_provider = None self.columns_index = dict( @@ -282,7 +291,7 @@ class SubsetsModel(BaseRepresentationModel, TreeModel): ) # update availability on active site when version changes - if self.sync_server.enabled and version_doc: + if self.sync_server_enabled and version_doc: repres_info = list( self.sync_server.get_repre_info_for_versions( project_name, @@ -507,7 +516,7 @@ class SubsetsModel(BaseRepresentationModel, TreeModel): return repre_info_by_version_id = {} - if self.sync_server.enabled: + if self.sync_server_enabled: versions_by_id = {} for _subset_id, doc in last_versions_by_subset_id.items(): versions_by_id[doc["_id"]] = doc @@ -1033,12 +1042,16 @@ class RepresentationModel(TreeModel, BaseRepresentationModel): self._version_ids = [] manager = ModulesManager() - sync_server = active_site = remote_site = None + active_site = remote_site = None active_provider = remote_provider = None + sync_server = manager.modules_by_name.get("sync_server") + sync_server_enabled = ( + sync_server is not None + and sync_server.enabled + ) project_name = dbcon.current_project() - if project_name: - sync_server = manager.modules_by_name["sync_server"] + if sync_server_enabled and project_name: active_site = sync_server.get_active_site(project_name) remote_site = sync_server.get_remote_site(project_name) @@ -1057,6 +1070,7 @@ class RepresentationModel(TreeModel, BaseRepresentationModel): remote_provider = 'studio' self.sync_server = sync_server + self.sync_server_enabled = sync_server_enabled self.active_site = active_site self.active_provider = active_provider self.remote_site = remote_site @@ -1174,9 +1188,15 @@ class RepresentationModel(TreeModel, BaseRepresentationModel): repre_groups_items[doc["name"]] = 0 group = group_item - progress = self.sync_server.get_progress_for_repre( - doc, - self.active_site, self.remote_site) + progress = { + self.active_site: 0, + self.remote_site: 0, + } + if self.sync_server_enabled: + progress = self.sync_server.get_progress_for_repre( + doc, + self.active_site, + self.remote_site) active_site_icon = self._icons.get(self.active_provider) remote_site_icon = self._icons.get(self.remote_provider) diff --git a/openpype/tools/sceneinventory/lib.py b/openpype/tools/sceneinventory/lib.py index 4b1860342a..0ac7622d65 100644 --- a/openpype/tools/sceneinventory/lib.py +++ b/openpype/tools/sceneinventory/lib.py @@ -1,9 +1,3 @@ -import os -from openpype_modules import sync_server - -from qtpy import QtGui - - def walk_hierarchy(node): """Recursively yield group node.""" for child in node.children(): @@ -12,19 +6,3 @@ def walk_hierarchy(node): for _child in walk_hierarchy(child): yield _child - - -def get_site_icons(): - resource_path = os.path.join( - os.path.dirname(sync_server.sync_server_module.__file__), - "providers", - "resources" - ) - icons = {} - # TODO get from sync module - for provider in ["studio", "local_drive", "gdrive"]: - pix_url = "{}/{}.png".format(resource_path, provider) - icons[provider] = QtGui.QIcon(pix_url) - - return icons - diff --git a/openpype/tools/sceneinventory/model.py b/openpype/tools/sceneinventory/model.py index 1cfcd0d8c0..64c439712c 100644 --- a/openpype/tools/sceneinventory/model.py +++ b/openpype/tools/sceneinventory/model.py @@ -24,10 +24,7 @@ from openpype.style import get_default_entity_icon_color from openpype.tools.utils.models import TreeModel, Item from openpype.modules import ModulesManager -from .lib import ( - get_site_icons, - walk_hierarchy, -) +from .lib import walk_hierarchy class InventoryModel(TreeModel): @@ -53,8 +50,10 @@ class InventoryModel(TreeModel): self._default_icon_color = get_default_entity_icon_color() manager = ModulesManager() - sync_server = manager.modules_by_name["sync_server"] - self.sync_enabled = sync_server.enabled + sync_server = manager.modules_by_name.get("sync_server") + self.sync_enabled = ( + sync_server is not None and sync_server.enabled + ) self._site_icons = {} self.active_site = self.remote_site = None self.active_provider = self.remote_provider = None @@ -84,7 +83,10 @@ class InventoryModel(TreeModel): self.active_provider = active_provider self.remote_site = remote_site self.remote_provider = remote_provider - self._site_icons = get_site_icons() + self._site_icons = { + provider: QtGui.QIcon(icon_path) + for provider, icon_path in self.get_site_icons().items() + } if "active_site" not in self.Columns: self.Columns.append("active_site") if "remote_site" not in self.Columns: diff --git a/openpype/tools/sceneinventory/view.py b/openpype/tools/sceneinventory/view.py index d22b2bdd0f..816e396c08 100644 --- a/openpype/tools/sceneinventory/view.py +++ b/openpype/tools/sceneinventory/view.py @@ -54,8 +54,11 @@ class SceneInventoryView(QtWidgets.QTreeView): self._selected = None manager = ModulesManager() - self.sync_server = manager.modules_by_name["sync_server"] - self.sync_enabled = self.sync_server.enabled + sync_server = manager.modules_by_name.get("sync_server") + sync_enabled = sync_server is not None and self.sync_server.enabled + + self.sync_server = sync_server + self.sync_enabled = sync_enabled def _set_hierarchy_view(self, enabled): if enabled == self._hierarchy_view: diff --git a/openpype/tools/settings/local_settings/projects_widget.py b/openpype/tools/settings/local_settings/projects_widget.py index 4a4148d7cd..68e144f87b 100644 --- a/openpype/tools/settings/local_settings/projects_widget.py +++ b/openpype/tools/settings/local_settings/projects_widget.py @@ -267,19 +267,20 @@ class SitesWidget(QtWidgets.QWidget): self.input_objects = {} def _get_sites_inputs(self): - sync_server_module = ( - self.modules_manager.modules_by_name["sync_server"] - ) + output = [] + if self._project_name is None: + return output + + sync_server_module = self.modules_manager.modules_by_name.get( + "sync_server") + if sync_server_module is None or not sync_server_module.enabled: + return output site_configs = sync_server_module.get_all_site_configs( self._project_name, local_editable_only=True) - roots_entity = ( - self.project_settings[PROJECT_ANATOMY_KEY][LOCAL_ROOTS_KEY] - ) site_names = [self.active_site_widget.current_text(), self.remote_site_widget.current_text()] - output = [] for site_name in site_names: if not site_name: continue @@ -350,9 +351,6 @@ class SitesWidget(QtWidgets.QWidget): def refresh(self): self._clear_widgets() - if self._project_name is None: - return - # Site label for site_name, site_inputs in self._get_sites_inputs(): site_widget = QtWidgets.QWidget(self.content_widget) diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 82ca23c848..2df46c1eae 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -760,20 +760,23 @@ def create_qthread(func, *args, **kwargs): def get_repre_icons(): """Returns a dict {'provider_name': QIcon}""" + icons = {} try: from openpype_modules import sync_server except Exception: # Backwards compatibility - from openpype.modules import sync_server + try: + from openpype.modules import sync_server + except Exception: + return icons resource_path = os.path.join( os.path.dirname(sync_server.sync_server_module.__file__), "providers", "resources" ) - icons = {} if not os.path.exists(resource_path): print("No icons for Site Sync found") - return {} + return icons for file_name in os.listdir(resource_path): if file_name and not file_name.endswith("png"): diff --git a/server_addon/create_ayon_addons.py b/server_addon/create_ayon_addons.py index 8be9baa983..61dbd5c8d9 100644 --- a/server_addon/create_ayon_addons.py +++ b/server_addon/create_ayon_addons.py @@ -203,8 +203,7 @@ def create_openpype_package( ignored_modules = [ "ftrack", "shotgrid", - # Sync server is still expected at multiple places - # "sync_server", + "sync_server", "example_addons", "slack" ]